前言

我已经学习完了Redis的基础篇,笔记见https://blog.lecspace.com/index.php/archives/80/。但是,这只是学会使用Redis的入场券,想要在被面试官拷打redis相关知识点时游刃有余,必须要更加深入研究它。

我会跟着《Redis45讲》进行学习,这篇文章是我学习这东西的笔记。

1.怎样学Redis更好?

此节不重要,浓缩总结一下:如何学Redis?

🧭 两大维度:

  • 系统维度:理解 Redis 的底层原理与架构;
  • 应用维度:从实际业务场景和案例出发,掌握使用技巧。

🧩 三大主线(简称“三高”):

主线涉及模块目标
高性能线程模型、数据结构、持久化、网络框架提升吞吐与响应速度
高可靠主从复制、哨兵机制保证数据一致与高可用
高可扩展数据分片、负载均衡支撑大规模集群部署

作者建议:

  • 先按“三高主线”构建知识框架;
  • 再结合“应用场景驱动 + 典型案例驱动”深入学习。

01 | 基本架构:一个键值数据库包含什么?

1)主旨

  • 先用最小可用的键值库 SimpleKV 搭“骨架”,形成系统观,再带着框架去理解 Redis 的复杂特性与优化路径。

2)SimpleKV 的架构与搭建顺序

  1. 数据模型:Key 为 String;Value 支持基础类型(String、整数等)。
    └→ 现实里的 KV(如 Redis)会把 Value 做成多类型(String/Hash/List/Set…),以适配更多场景。【重点】
  2. 操作接口:最小集合为 PUT/GET/DELETE/SCAN,可扩展 EXISTS
    └→ 操作能力决定适用场景:KV 易做点查/简单集合操作,但不擅长聚合计算(如平均值)。【重点】
  3. 数据放哪里

    • 内存:快,但断电丢;
    • 外存:稳,但慢。
      SimpleKV 选内存以贴近 Redis 的“高性能缓存”定位。【重点】
  4. 访问模式(I/O 模型)

    • 库调用(如 RocksDB) vs 网络服务(如 Redis/Memcached);
    • 线程/进程模型取舍:单线程易被阻塞,多线程有锁竞争。I/O 设计直接影响吞吐与延迟。【重点】
  5. 索引模块:按 key 找 value 的位置;内存 KV 常用 哈希表(O(1) 近似)。
    └→ Redis 的多 Value 类型内部还有自己的数据结构(如列表/集合),二级结构的选择影响性能。【重点】
  6. 操作模块

    • GET/SCAN:读出 value;
    • PUT:分配空间并写入;
    • DELETE:删除并释放空间。
  7. 存储/持久化模块

    • 内存分配器选择会影响碎片与效率(SimpleKV 用 malloc/free,Redis 可选多种分配器);
    • 持久化策略

      • 逐条落盘:稳但慢;
      • 周期落盘:快但偶有丢失。
        Redis 对持久化做了更多机制与优化(见下)。

3)从 SimpleKV → Redis 的关键“进化”

  • 访问方式:以 网络服务为主(RESP 协议),通用性更强。
  • 数据模型丰富的 Value 类型 → 对应更丰富的命令(LPUSH/LPOP、SADD/SREM…)。
  • 持久化AOF(日志) + RDB(快照) 两条线,权衡性能 vs 数据安全。【重点】
  • 集群/高可用:支持复制、哨兵、分片等,满足 高可靠/高扩展。【重点】

圈重点

  1. 系统观优先:先搭框架(数据模型→接口→I/O→索引→操作→持久化),再深入点技。
    【重点:学习顺序与定位问题的方法论】
  2. 操作能力决定边界:KV 擅长点查与简单集合操作,不擅长复杂聚合(平均值、join)。
    【重点:选型与场景匹配】
  3. 多类型 Value = 性能与空间的权衡:每种结构背后都有不同的时间/空间复杂度内存布局
    【重点:命令性能差异的根源在“底层数据结构”】
  4. I/O 与线程模型是吞吐/延迟的第一性问题:单线程避免锁但怕阻塞;多线程并发高但有竞争。
    【重点:理解 Redis 为何“单线程也高性能”及其边界】
  5. 内存分配与碎片会放大延迟波动与内存占用。
    【重点:生产环境经常被忽视的性能坑】
  6. 持久化两条线(AOF/RDB)

    • AOF:更安全,可控刷盘,可能带来写放大/延迟抖动;
    • RDB:整库快照,恢复快但快照时有资源压力。
      【重点:按业务容忍度选择或组合】
  7. 从 SimpleKV 到 Redis 的“加法”:网络协议、丰富数据结构、两种持久化、复制/哨兵/分片。
    【重点:Redis 的价值不只在快,更在“完备的工程化能力”】

速用清单

  • 说清 KV 的能与不能:点查强,聚合弱。
  • 题到 性能:先问 I/O/线程模型、数据结构、内存分配、持久化策略。
  • 题到 持久化:AOF vs RDB 的权衡与组合策略(如 AOF everysec + 定期 RDB)。
  • 题到 集群/高可用:复制、哨兵、分片的一句话职责与常见坑(复制延迟、重分片、热点键)。
  • 题到 命令效率:回到底层结构(Hash/Skiplist/Ziplist/Quicklist/Dict…)解释“为什么”。

SimpleKV 相比 Redis 还缺什么?

  • 协议与客户端生态(RESP、Pipeline、批量、连接管理)。
  • 过期与淘汰策略(TTL、LRU/LFU、定期/惰性删除)。【重点】
  • 复制/哨兵/分片(高可用与水平扩展)。【重点】
  • 更丰富的数据结构(Hash/List/Set/ZSet、Stream、Bitmap/HyperLogLog、Geo)。
  • 持久化完善(AOF 重写、RDB 触发策略、混合持久化、恢复流程)。【重点】
  • 内存治理(分配器选择、碎片率监控、maxmemory 策略)。
  • 可观测性(慢日志、命令统计、热点 Key、延迟监控)。
  • 安全与多租户(权限/ACL、隔离/限流/防穿透)。
  • 脚本与事务(Lua、事务/原子性原语、分布式锁语义)。

02 | 数据结构:快速的Redis有哪些慢操作?

1)Redis 为啥快?

  • 内存数据库:操作发生在内存,先天快。
  • 数据结构选得好:键值组织 + 值的底层结构共同决定了增删改查的复杂度。【重点】

2)键和值如何组织?

  • 全局哈希表(dict)保存所有键值对:

    • 哈希桶数组 → 每个桶存 entry(含 key*value* 指针)。
    • 查找复杂度 ≈ O(1)(与数据量弱相关)。【重点】

  • 冲突处理:链式哈希(同桶元素用链表串起来)。

  • 扩容机制渐进式 rehash(请求期间分批搬迁哈希桶,避免一次性阻塞)。【重点】

3)值(集合类型)的底层数据结构(6 选)

  • String:SDS(简单动态字符串)
  • List / Hash / Set / ZSet:会在两种结构间切换(按大小/编码等阈值):

    • 整数数组(intset)双向链表(linkedlist)压缩列表(ziplist)哈希表(hashtable)跳表(skiplist)。【重点】

  • 直觉复杂度梯度(查找):

    • hashtable:O(1);skiplist:O(logN);
    • linkedlist / ziplist / intset:O(N)(顺序访问为主)。

跳表能快速查找的底层逻辑

4)集合操作的复杂度——四句口诀

  1. 单元素操作是基础:底层若是哈希表,多为 O(1)(如 HGET/HSET、SADD/SREM)。【重点】
  2. 范围操作非常耗时:遍历/范围读取多为 O(N)(如 HGETALL、SMEMBERS、LRANGE、ZRANGE)。【重点】
  3. 统计操作通常高效:记录了长度/基数的结构,LLEN/SCARD 常为 O(1)
  4. 例外情况有几个:List 的 头尾操作(LPUSH/RPUSH/LPOP/RPOP)可 O(1)(有偏移量/指针可直达)。

5)慢从哪来?

  • 哈希冲突链过长;
  • 一次性 rehash 会阻塞(Redis 用渐进式缓解);【重点】
  • 对集合做全量遍历/范围操作导致 O(N)
  • 选择了不合适的底层结构(如把 List 当随机读写容器)。

实战“避坑清单”(按影响度排序)

  1. 避免 O(N) 的范围命令:HGETALL / SMEMBERS / LRANGE / ZRANGE 等,
    → 尽量用 SCAN/HSCAN/SSCAN/ZSCAN 渐进遍历;限制返回条数。【重点】
  2. 单元素优先:HGET/HSET、SADD/SREM、ZADD/ZREM 等是基础操作路径,延迟稳定。
  3. 别把 List 当数组:List 适合 队列(头尾),不适合随机访问/大范围读取。
    → 队列/日志:LPUSH/RPOP 等;随机访问考虑 Hash/Sorted Set。
  4. 关注哈希表负载与 rehash

    • 大批量写入可能触发扩容;
    • 服务繁忙期避免集中触发;
    • 合理批量/预热/均衡写入节奏。【重点】
  5. 按“规模/类型”选编码

    • 小而简单的集合:ziplist/intset(省内存、CPU 缓存友好);
    • 变大后自动“升级”为 hashtable/skiplist(更快)。【重点】

你最该记的“圈重点”

  • 全局哈希表 + 渐进式 rehash 是 Redis 查找快且不抖的根基。【重点】
  • 集合底层结构两套切换(空间 vs 时间):小集合偏紧凑结构(ziplist/intset),大集合自动升级(hashtable/skiplist)。【重点】
  • 范围 = O(N),生产环境要警惕;用 SCAN 系列替代全量遍历。【重点】
  • List 的价值在“头尾 O(1)”,主要用于队列,不做随机读。【重点】
  • 复杂度=数据结构:先判断底层结构,再推操作成本,少靠死记硬背。【重点】

面试/答辩速背(30 秒)

  • Redis 快:内存 + 数据结构。键值由全局哈希表组织,冲突用链式,渐进式 rehash摊薄扩容成本。
  • 集合类型底层:hashtable O(1)skiplist O(logN)、其余多为 O(N)
  • 命令策略:单元素优先范围慎用 → 用 SCAN;List 只做队列头尾。
  • 小集合走 ziplist/intset 省内存,变大自动升级到更高效结构。

“每课一问”参考思路

问:整数数组(intset)和压缩列表(ziplist)查找不是 O(N),为啥还用作底层?
答:因为它们在小集合下有显著工程优势:

  • 内存开销小(紧凑、额外指针少);
  • CPU 缓存友好(连续内存,遍历快);
  • 编码简单、分配/拷贝便宜(小集在 O(N) 的常数项很小);
  • 按阈值“自动升级”到 hashtable/skiplist,兼顾小集合省内存与大集合高性能。【重点:小规模走空间效率,大规模走时间效率】

03 | 高性能IO模型:为什么单线程Redis能那么快?

一句话总览(背诵版)

  • Redis“单线程”只指:网络 I/O + KV 读写在一个线程;持久化/异步删除/集群同步等有额外线程。
  • 用单线程的核心原因:避免多线程对共享数据的并发控制与锁开销。
  • 快的本质:内存操作 + 高效数据结构(哈希表、跳表)+ I/O 多路复用(select/epoll 等)+ 非阻塞套接字 + 事件回调
  • 关键阻塞点被规避accept()recv() 改为非阻塞,由内核监听 FD,事件就绪再回调处理。

面试常问点 & 标准作答要点

1) Redis 真的是单线程吗?

  • :对外部服务路径(网络 I/O 与键值读写)是单线程;后台任务(持久化、异步删除、集群数据同步)有额外线程。
  • 加分:单线程减少锁竞争、上下文切换、并发 Bug;更易维护和调试。

2) 为什么不做多线程?

  • 多线程需要保护共享资源(如 List 的长度计数),必须串行化关键区或加锁 → 锁竞争、上下文切换、复杂性↑,吞吐不一定随线程数线性增长,甚至下降。

3) 单线程为何还能很快?

  • 内存级操作 + 高效数据结构(哈希表/跳表)
  • I/O 多路复用:内核同时监听多个监听/已连接套接字(FD),请求就绪触发事件 → 事件队列 → 单线程回调处理。
  • 非阻塞 Socket:避免阻塞在 accept()/recv();Redis 线程可继续处理其他事件。
  • 事件驱动:避免忙轮询,减少 CPU 浪费,提升吞吐和响应。

4) I/O 多路复用怎么落地?

  • 机制:select / epoll(Linux)、kqueue(FreeBSD)、evport(Solaris)。
  • 流程:设置 FD 非阻塞 → 注册到多路复用器 → 内核监听 → 事件触发 → 放入事件队列回调处理函数(如 accept / read handler)。

5) Redis 的潜在阻塞点/慢点(面试高频追问)

  • 网络层:accept()recv()(已用非阻塞+多路复用规避)。
  • 慢命令/高复杂度命令SORTSUNIONZUNIONSTOREKEYS、超大 LRANGE / HGETALL 等。
  • 大 Key/Big Value:单线程处理一个大对象会拉长事件处理时间。
  • 持久化:RDB/AOF 重写期间的 I/O 压力、写放大。
  • 内存复制/序列化:协议编解码、大量返回值拷贝。
  • 网络带宽/内核参数:大批量返回、Nagle、缓冲区设置不当等。
  • 阻塞式操作:同步删除、脚本执行时间过长、事务中重命令。
  • 集群/复制:全量同步、慢网络导致的复制积压。

和 Go 后端的关联点(容易被问)

  • Go 的多路复用模型:Go 的 netpoller 在 Linux 下基于 epoll;与 Redis 的事件驱动理念一致(就绪再处理)。
  • 避免在 Redis 上做重活:在 Go 层限制慢命令、避免大 Key、细化数据结构(分片/拆桶)、限流超时
  • 连接管理:用连接池(如 go-redis),Pipeline/批量以减少 RTT;合理 ReadTimeout/WriteTimeout
  • 观测与保护:慢查询日志、latency doctormaxmemory 策略、AOF 后台重写时的降压策略。
  • 脚本与事务:Lua 脚本控制原子性但要短小;事务避免长队列。

图景化理解(面试口述示意)

  • 单线程事件环

    1. 内核监听一组 FD(监听 + 已连接)。
    2. 事件就绪 → 投递到事件队列。
    3. Redis 单线程按事件处理accept / read / 解析 / 执行命令 / send)。
  • 关键点:单线程不在某个 FD 上阻塞,把等待交给内核,自己只做“就绪即处理”。

你可以这样回答“为什么 Redis 单线程还这么快?”

Redis 的对外服务路径是单线程,避免了多线程对共享数据结构的加锁与上下文切换开销;它把等待交给内核,通过 非阻塞 socket + epoll/kqueue 的多路复用 实现事件驱动,单线程就能高并发处理大量连接。同时,Redis 基于内存与高效数据结构(哈希表/跳表)把单次操作成本压到极低,所以整体吞吐非常高。

面试加分扩展:Redis 6.0 的多线程

  • 要点:Redis 6.0 在 I/O 阶段(读写/解析/回复)引入多线程以进一步利用多核,核心命令执行仍保持单线程逻辑,避免数据结构并发控制复杂化。
  • 如何衔接本章:多路复用 + 事件环依旧存在,多线程更多用于I/O offload,不破坏核心串行语义。

复习清单(打星必背)

  • “单线程”的准确含义与后台线程存在
  • 单线程优点:无锁/少锁、上下文切换少、可维护性好
  • 非阻塞 socket阻塞点accept / recv
  • select/epoll/kqueue就绪事件驱动模型
  • Redis 高性能三件套:内存 + 高效数据结构 + I/O 多路复用
  • 面试陷阱:慢命令大 Key持久化冲击复制放大
  • Go 侧优化:Pipeline/批量连接池限时限流慢日志与告警
  • Redis 6.0 多线程的I/O 加速执行仍单线程的设计取舍

“每课一问”参考答案(潜在瓶颈)

  • 协议解析/序列化成本
  • 单条命令执行过久(慢命令/大返回集)致事件环停顿
  • 持久化/复制期间的磁盘与网络压力
  • 大 Key/热 Key导致单事件占用时间过长
  • 网络栈/带宽与内核参数(缓冲区、队列)限制

04 | AOF日志:宕机了,Redis如何避免数据丢失?

一、AOF 的作用与场景

🔹 Redis 为什么需要持久化?

  • Redis 通常运行在内存中,宕机会导致数据丢失
  • 从数据库恢复数据:

    • ❌ 频繁访问数据库 → 数据库压力大
    • ❌ 恢复慢 → 响应变慢
  • ✅ 持久化的目标:在不依赖外部数据库的情况下,保证数据可靠性

🔹 Redis 持久化方式

  1. AOF 日志(Append Only File)
  2. RDB 快照(下一节)

二、AOF 的核心机制

1️⃣ AOF 是“写后日志”

  • 数据库的 WAL(Write Ahead Log)是写前日志:先写日志再更新。
  • Redis 的 AOF 是写后日志

    先执行命令(更新内存) → 再记录日志(命令文本)

📘 为什么写后?

  • 如果先记日志再执行,可能会把错误命令写入日志,导致恢复出错。
  • 写后日志只记录执行成功的命令,保证日志的正确性
  • 优点:

    • ✅ 只记录合法命令
    • ✅ 不阻塞当前命令执行
  • 风险:

    • ⚠️ 刚执行完命令但未写入日志 → 宕机会丢失该命令
    • ⚠️ 写日志慢(磁盘 I/O 压力大)→ 阻塞后续命令


三、AOF 三种写回策略(appendfsync

策略写回时机优点缺点使用场景
always每条命令执行完后立刻写盘几乎不丢数据性能最差,主线程阻塞明显数据绝对不能丢(金融类)
everysec每秒异步写盘性能好,影响可控宕机可能丢最近 1 秒数据性能与可靠性折中(默认)
no操作系统决定性能最高宕机丢数据风险最大数据可从 DB 重建的缓存场景

📌 核心考点
Redis 的三种持久化策略体现了系统设计的经典哲学——trade-off(性能 vs. 可靠性)


四、AOF 文件过大的问题与解决方案

问题:

  1. 文件系统对文件大小有限制。
  2. 文件越大,追加命令越慢。
  3. 宕机恢复时需重放所有命令,恢复速度变慢。

✅ 解决方案:AOF 重写机制(Rewrite)

⚙️ 原理:

  • 根据当前内存中的最新数据生成新的 AOF 文件
  • 不再保存所有历史命令,而是只记录恢复当前状态所需的最简命令集合。

📘 举例:

  • 原日志中 6 条命令修改一个 list,最终状态是 [D, C, N]
  • 重写后只保留:

    LPUSH u:list "N" "C" "D"
  • 多条操作 → 一条命令(多变一

📈 优点:

  • 文件显著变小
  • 重放更快


五、AOF 重写的实现细节(重点)

🔹 非阻塞的重写过程

  • 主线程执行 bgrewriteaof 命令后:

    • fork 一个子进程(bgrewriteaof)
    • 子进程拷贝主线程的内存快照,用于生成新的 AOF 文件
    • 主线程继续处理客户端请求,不被阻塞

🔹 “一个拷贝,两处日志”

  1. 一个拷贝
    fork 时复制主线程的内存,用于子进程重写。
  2. 两处日志

    • 主线程继续记录当前命令到旧 AOF 文件缓冲区
    • 同时把新命令也写入重写缓冲区
    • 重写完成后,把重写缓冲区的命令追加到新文件,替换旧文件。

📌 结论
AOF 重写通过子进程完成,不阻塞主线程,但 fork 阶段会有短暂的内存复制耗时(可能造成瞬时阻塞)。


六、潜在风险与面试延伸题

⚠️ 1) AOF 重写会完全无阻塞吗?

不会。虽然重写由子进程执行,但 fork 期间会执行写时复制(Copy-On-Write),如果 Redis 数据量大,fork 时间会显著变长,可能短暂阻塞主线程

⚠️ 2) 为什么重写日志不直接共用原 AOF 文件?

防止数据不一致:

  • 重写过程和主线程的写操作是并行的。
  • 共用同一文件可能导致新旧命令交织,日志错乱。
  • 所以采用独立重写文件,最后再整体替换。

七、面试口述范式(推荐复述模板)

Redis 的 AOF 是一种写后日志机制,会记录执行成功的命令,用于宕机恢复。
它提供三种写回策略:alwayseverysecno,在性能与可靠性之间做权衡。
为了防止 AOF 文件过大,Redis 支持后台非阻塞重写,由子进程 bgrewriteaof 完成,通过“一个拷贝,两处日志”保证数据不丢。
这体现了 Redis 在持久化机制中对性能、可靠性和主线程无阻塞的综合平衡。

八、Go 后端开发面试关联点

  • Go 服务常用 go-redis,要了解:

    • AOF 配置 (appendfsync everysec)
    • RDB/AOF 混合持久化策略
  • 设计缓存时:

    • 明确 缓存数据是否要求持久化
    • 合理选择策略:缓存型用 no,持久化用 everysec
  • 监控与优化:

    • 关注 aof_current_sizeaof_base_size
    • fork 时间过长 → 调整 vm.overcommit_memory
  • 若问“Redis 为什么快还能持久化?”:

    • 持久化异步化 + fork + COW + 写后日志 → 主线程性能不受影响

九、复习要点清单 ✅

  • Redis 持久化的两种方式:AOF / RDB
  • AOF 是写后日志,记录执行成功的命令文本
  • 三种写回策略:Always / Everysec / No
  • AOF 文件过大 → AOF 重写机制
  • 重写通过 bgrewriteaof 子进程执行 → 非阻塞主线程
  • “一个拷贝,两处日志”机制
  • 潜在阻塞点:fork 过程(COW)
  • 不共用日志的原因:避免数据交错/不一致
  • trade-off 思想:性能与可靠性的取舍

05 | 内存快照:宕机后,Redis如何实现快速恢复?

一、RDB 是什么?解决了 AOF 的什么痛点

  • 定义:把某一时刻内存中的所有数据写到磁盘(RDB 文件 = Redis DataBase snapshot)。
  • 对比 AOF:AOF 恢复要重放命令,日志多时恢复慢;RDB 直接读入内存,恢复快

二、核心设计与关键问题

1) “给哪些数据拍照?”

  • 全量快照:把所有键值写入 RDB(“大合影”)。文件越大,写盘时间越久。

2) “拍照时能不能动?”

  • bgsave(推荐,默认):主线程 fork 子进程写 RDB,主线程不被长期阻塞
  • save:主线程直接写 RDB,会阻塞
  • COW(写时复制):bgsave 期间有写请求,修改的页被复制一份;子进程写旧快照页,主线程继续改新页快照完整 + 业务可写
面试金句:RDB = bgsave + COW,既保证快照一致性,也不拖慢写路径。

三、快照频率的取舍(不能“每秒连拍”)

  • 频繁全量快照的两大代价:
    磁盘带宽被吃满(前一次未完,下一次又来)
    fork 本身阻塞主线程(内存越大越久)
  • 过稀又有数据丢失风险(两次快照间的修改不在 RDB 内)。

快照过稀的后果

四、增量思路 & 混合持久化(RDB + AOF)

  • 增量快照理念:只记录自上次快照以来被修改的数据,但需要额外“修改元数据”,空间与复杂度↑

增量快照

  • Redis 4.0 混合方案(强烈推荐)

    • 周期性 RDB(提供快速恢复
    • 两次 RDB 之间用 AOF 记录变更(减少 RDB 频率 & AOF 体量、避免频繁重写)
    • 做到恢复快丢失少开销可控

RDB+AOF

五、面试高频问答范式

Q:RDB 会阻塞吗?
A:save 会;bgsave 主逻辑不阻塞,但fork 短时阻塞不可避免。写入阶段依赖 COW,主线程可继续写。

Q:为什么不用“每秒 bgsave”?
A:磁盘压力+频繁 fork 造成性能抖动主线程短阻塞,得不偿失。

Q:如何既恢复快又尽量少丢?
A:RDB + AOF(everysec)混合持久化:RDB 负责基线,AOF 覆盖快照间变更。

六、Go 后端落地建议(易被追问)

  • 持久化策略:生产建议 RDB + AOF(everysec)
  • 运维调度:在低峰期做 RDB;写高峰期避免触发快照。
  • 监控项:fork 时长、磁盘带宽、used_memory 与 COW 额外内存、RDB 用时。
  • 内核/配置

    • vm.overcommit_memory=1(降低大内存 fork 失败几率)
    • rdb-save-incremental-fsync yes(增量 fsync 降低大抖动)
    • SSD、充足可用内存,避免 COW 导致 OOM。

七、选择建议(背诵版)

  • 不能丢RDB + AOF 混合
  • 可分钟级丢失:仅 RDB。
  • 只用 AOF:优先 everysec(性能/可靠性折中)。

每课一问 · 场景风险分析(面试思路)

条件:2C/4GB/500GB 云主机;Redis 占 2GB;写 80%;持久化仅 RDB

主要风险

  1. fork 短时阻塞:2GB 内存 + 高写入 → fork 更慢,主线程短暂停顿(尾延迟抖动)。
  2. COW 额外内存:写 80% 导致大量页被改写,COW 复制页激增,瞬时内存需求可能逼近/超过 4GBOOM 风险或内核杀进程。
  3. 磁盘 I/O 压力:全量 RDB 写盘时间长;与常态写放在同一盘上会争用带宽,放大延迟。
  4. 数据丢失窗口:仅 RDB → 两次快照间宕机会丢最近窗口内的数据,写多更敏感。
  5. CPU 资源紧张:2C 下,fork+压缩+IO 校验等占用 CPU,影响请求处理

改进建议

  • 启用 RDB + AOF(everysec) 混合;降低 RDB 频次、在低峰执行。
  • 使用 SSD,并确保与业务日志/其他重 IO 任务分盘。
  • 预留足够内存(≥ 数据 + 峰值 COW 余量),或限制写峰值
  • 调整内核与 Redis:vm.overcommit_memory=1rdb-save-incremental-fsync yes
  • 观察 fork 时间、RSS、页脏化速率、磁盘吞吐;必要时分片/集群降低单实例内存规模。

复习清单(打星记忆)

  • RDB = 快照文件,恢复快;AOF = 命令日志,恢复慢但粒度细
  • bgsave + COW 核心机制;save 会阻塞
  • 频繁全量快照不可取:磁盘 & fork 抖动
  • 混合持久化(RDB + AOF everysec) = 恢复快 + 丢失少 + 开销可控
  • 高写入场景仅 RDB 的五大风险缓解措施

06 | 数据同步:主从库如何实现数据一致?

一、为什么需要主从复制?

目标:

  1. 提高可靠性

    • 数据尽量少丢失(RDB / AOF 负责)
    • 服务尽量少中断(主从复制负责)
  2. 提升读性能

    • 读写分离:主库负责写,从库负责读

核心机制:
👉 Redis 通过 主从模式(Master-Slave) 实现数据冗余与服务高可用。

  • :只能在主库执行
  • :主、从均可
  • 同步:主库负责把数据变化同步给从库

Redis主从库和读写分离

二、主从复制的核心流程(第一次全量同步)

主从复制的首次同步分为 三阶段

🔹 1. 建立连接与协商(准备阶段)

  • 从库执行:replicaof <master_ip> <port>
  • 从库发送 psync ? -1
  • 主库响应 FULLRESYNC <runID> <offset>

    • runID:主库唯一标识
    • offset:复制偏移量

👉 此阶段确定:是否为首次全量复制 + 主从同步起点。

🔹 2. 全量复制(数据传输阶段)

  • 主库执行 bgsave 生成 RDB 文件
  • 从库清空自身数据,加载主库发来的 RDB 文件。
  • 同时主库将新收到的写操作缓存进 replication buffer

🔹 3. 增量同步(追数据阶段)

  • 主库发送 replication buffer 中的新增写命令给从库。
  • 从库执行这些命令,实现主从一致。

主从库第一次同步的流程

📌 面试常问:

为什么主从复制用 RDB 不用 AOF?
答:
RDB 文件更适合一次性全量传输(体积小、加载快),而 AOF 文件冗长且重放慢。

三、级联复制(主-从-从)

问题:
主库同时对多个从库做全量复制 →
会导致主库 CPU 负载高、fork 阻塞、网络带宽压力大

解决:主-从-从 模式(级联复制)

  • 让部分从库从另一个从库复制数据。
  • 主库只需同步给第一级从库。
    👉 减轻主库压力、提高复制性能。

命令示例:

replicaof <上级从库IP> 6379

级联复制

四、长连接命令传播阶段

  • 主从完成全量同步后,保持长连接
  • 主库实时将写命令传播到从库。
  • 避免频繁建立连接的性能损耗。

风险: 网络断连。

  • 若断连,主从库无法同步,数据会产生延迟或不一致。

五、网络断连后的同步机制(增量复制)

Redis 2.8 之后引入 增量复制(Partial Resync),避免频繁全量复制。

📍 核心机制:repl_backlog_buffer

  • 主库维护一个 环形缓冲区,记录命令历史。
  • 主从分别维护偏移量:

    • master_repl_offset
    • slave_repl_offset

  • 断连后,从库上报自己的 offset,

    • 若数据仍在缓冲区中 → 增量复制
    • 若数据被覆盖 → 重新全量复制

缓冲区大小配置:

repl_backlog_size = (主库写入速率 - 传输速率) × 操作大小 × 2

👉 一般设为估算值的 2~4 倍,防止覆盖丢数据。

六、面试重点总结(必背)

考点要点
主从复制作用高可用 + 读写分离
同步模式全量复制、命令传播、增量复制
全量复制阶段建立连接 → 传 RDB → 传 buffer
增量复制机制利用 repl_backlog_buffer 追补缺失命令
RDB vs AOFRDB体积小传输快;AOF太大不适合复制
主-从-从分担主库压力,优化复制性能
网络断连处理断后通过 offset 差值进行增量同步
参数调优repl_backlog_size、避免过小导致回退全量复制
面试陷阱题为什么不用 AOF?增量复制怎么避免数据不一致?

✅ 面试回答模板示例

Redis 通过主从复制实现高可用与读写分离。
第一次同步采用全量复制(RDB 文件 + 缓冲命令),之后通过长连接持续命令传播。
若网络中断,2.8 之后支持增量复制,通过 repl_backlog_buffer 保存主库命令历史,仅同步差异部分。
为避免缓冲覆盖,应调大 repl_backlog_size。
若主库压力过大,可使用主-从-从级联复制结构

07 | 哨兵机制:主库挂了,如何不间断服务?

1️⃣ 背景与目标

  • 主从模式痛点:主库一旦故障

    • 从库无法继续复制 → 数据演进停滞
    • 写请求无人可接 → 服务中断

  • 哨兵的作用:在主库故障时自动完成主从切换,尽量做到服务无感恢复

    • 三大任务:监控选主通知

2️⃣ 三大任务(流程总览)

  1. 监控(PING 心跳)

    • 定期对主/从库发送 PING,判定存活状态
  2. 选主(Failover 决策)

    • 主库确定下线后,在从库中筛选并确定新主库
  3. 通知(拓扑切换)

    • 通知其他从库:replicaof <new-master> <port>
    • 通知客户端:将请求重定向到新主库

面试关键词:故障转移(Failover) = 主观下线 → 客观下线 → 选主 → 通知

3️⃣ 下线判定:主观下线 vs 客观下线(必考)

  • 主观下线:单个哨兵基于 PING 超时,认为某实例下线
  • 客观下线:多哨兵“少数服从多数”投票通过(一般需要 ≥ N/2+1)后,确认主库下线并触发切换
  • 意义:降低误判(网络拥塞/瞬时抖动引起的假故障)
记忆点:主观是个人看法,客观是群体共识;主库切换只在“客观下线”后触发。

4️⃣ 选主逻辑:筛选 + 打分(高频细节)

A. 先筛选

  • 要求从库在线,且历史网络稳定
  • 参考配置:

    • down-after-milliseconds 定义连接最大超时
    • 若一定时间窗口内断连次数过多(文中示例:>10 次),判为不稳定 → 淘汰

B. 再打分(逐轮决胜)

  1. 从库优先级slave-priority/replica-priority)高者胜
  2. 复制进度接近主库者胜

    • 主库:master_repl_offset
    • 从库:slave_repl_offset
    • 越接近,数据越新 → 分数越高
  3. 实例 ID 较小者胜(作为最终平手裁决)
面试常见追问:为什么看 offset?*因为最接近主库的从库,切上去*数据最新、回放成本最小

5️⃣ 通知与收敛

  • 从库统一改向新主库复制(replicaof
  • 客户端获知新主库地址,将写请求切至新主库(读也可跟随策略切换)

6️⃣ 典型面试问答(直接背)

Q1:为什么要“主观下线/客观下线”两级判定?
A:单哨兵易受自身网络/负载影响产生误判。多哨兵投票形成“客观下线”可显著降低误切换

Q2:选新主库的具体规则?
A:“筛选 + 三轮打分”:
在线且网络稳定 → 优先级高 → 复制进度更接近主库 → ID 小者胜。

Q3:主库宕机到新主库就位期间,客户端能否正常请求?需要做啥?
A:切换存在短暂不可用窗口。为尽量无感

  • 使用哨兵感知的客户端(能从哨兵拉取新主库信息)
  • 客户端增加重试/重连策略与超时合理配置
  • 写请求在切换完成后自动落到新主库

Q4:如何降低误判与抖动?
A:部署多个哨兵(常见 3 或 5 个)、合理设置 down-after-milliseconds,并确保哨兵与数据节点的网络质量。

7️⃣ 易错与优化点(面试加分)

  • 误判代价大:错误切主会引入多轮数据同步与拓扑切换 → 需依赖多数派判定
  • 筛选维度别只看“当前在线”:要结合历史断连频次
  • 客户端不能“被动等待”:应具备哨兵发现自动重连能力
  • 读写分离场景:切主期间可暂将只读流量打到从库,写流量等待新主就位
  • 配套监控:关注心跳延迟、投票决策时间、切换总时长、复制延迟等指标

8️⃣ 复盘清单(考前 30 秒过目表)

  • 主从与高可用的区别(数据可靠 vs 服务不中断
  • 哨兵三任务:监控 / 选主 / 通知
  • 主观下线客观下线区别与投票阈值
  • 选主流程:筛选(稳定性)→ 优先级 → 复制进度 → ID
  • 切换窗口内客户端应对:哨兵感知 + 重试/重连
  • 关键参数:down-after-milliseconds、(优先级)slave-priority
  • 为什么要多哨兵、常见部署数(3/5)

08 | 哨兵集群:哨兵挂了,主从库还能切换吗?

1️⃣ 目标与价值

  • 主从自动切换在哨兵单点失效下仍可继续:降低误判、保障故障转移(Failover)可执行性。
  • 哨兵集群承担三件事:监控 → 选主 → 通知(从库与客户端)。

2️⃣ 哨兵如何“组成集群”与“发现节点”

A. 哨兵之间的发现:pub/sub(频道:sentinel:hello

  • 任一哨兵连接主库后:

    • 发布自身地址到 sentinel:hello
    • 订阅该频道,获得其他哨兵地址
  • 据此互连,形成哨兵集群,后续用于状态协商/投票

B. 发现从库:INFO

  • 哨兵向主库发 INFO,拿到从库清单(IP/Port),逐个建立连接以做心跳监控与后续重定向

C. 通知客户端:哨兵自身的 pub/sub 事件

  • 客户端可订阅哨兵事件频道(如:+odownswitch-master 等):

    • +odown: 实例进入客观下线
    • switch-master: 产生新主库(含 IP/Port),客户端据此重连

面试关键句:哨兵-哨兵sentinel:hello 互发现;哨兵-从库INFO哨兵-客户端靠哨兵自身 pub/sub 事件。

3️⃣ 下线判定:主观下线 & 客观下线(必考)

  • 主观下线(SDown):单个哨兵基于 PING 超时的本地判断。
  • 客观下线(ODown):多哨兵“少数服从多数”(投票达到 quorum)后形成共识 → 才触发切主流程。
记忆:个人感觉(SDown)≠ 集体共识(ODown);切换只在 ODown 后进行。

4️⃣ 谁来执行切换:Leader 选举(仲裁)

  1. 任一哨兵判断主库 SDown → 发 is-master-down-by-addr 收集票数;
  2. 拿到 quorum 票后,可标记 ODown
  3. 随后发起 Leader 选举(谁来真正做切换):

    • 需满足:

      • 半数以上赞成(> N/2)
      • ≥ quorum
    • 失败则等待一段时间(故障转移超时的 2 倍)再选,防止拥塞期抖动。
实战要点:至少 3 个哨兵,2 个很脆弱(任一宕机即无法达成多数)。

5️⃣ 选主后的收敛(通知)

  • Leader 哨兵:

    • 通知其他从库replicaof <new-master> <port>
    • 客户端发布 switch-master 事件(携带新主库地址)
  • 客户端需实现哨兵发现自动重连/重试,缩短不可用窗口。

6️⃣ 关键配置 & 实战坑位

  • sentinel monitor <master-name> <ip> <port> <quorum>:最小化配置即可形成集群(其余通过互发现)。
  • 配置一致性极重要:尤其 down-after-milliseconds(主观下线阈值)需在所有哨兵上保持一致,否则难以形成 ODown 共识
  • quorum ≠ 半数

    • ODownquorum 判断;
    • Leader 选举 需同时满足半数多数派≥ quorum
  • 网络抖动/拥塞期:可能多轮选举;用超时退避降低抖动。
  • 事件订阅用于观测切换进度客户端收敛(如 +odownswitch-master)。

7️⃣ 面试高频问答(可直接背)

Q1:哨兵集群如何互相发现?如何知道从库?如何通知客户端?
A:哨兵-哨兵:主库 pub/sub 频道 sentinel:hello哨兵-从库:向主库发 INFO 获取从库清单;哨兵-客户端:客户端订阅哨兵事件(如 switch-master)获知新主库。

Q2:ODown 判定与 Leader 选举的关系和条件?
A:先基于 quorum 达到 ODown;再进行 Leader 选举,需 半数以上≥ quorum 才能成为执行切换的 Leader。

Q3:为什么至少 3 个哨兵?
A:2 个哨兵一挂就无多数派,无法形成 ODown/Leader 共识;≥3 才具备容错与投票能力。

Q4:哨兵越多越好吗?down-after-milliseconds 调大就一定更好吗?
A:不是。

  • 哨兵过多会增加网络与一致性协调开销,一般3/5 个够用。
  • down-after-milliseconds 过大虽降误判,但会延长故障发现与切换时间;需在误判率恢复时延间权衡,并保持各哨兵一致

Q5:切换过程中客户端能否无感?需要做什么?
A:短暂不可用窗口难免,但可尽量无感:

  • 客户端实现哨兵发现自动重连/重试、合理超时
  • 订阅 switch-master 等事件快速收敛到新主库。

8️⃣ 考前 30 秒复盘清单

  • sentinel:hello 互发现;INFO 拿从库;哨兵事件通知客户端
  • SDown vs ODownquorum半数多数派的区别
  • Leader 选举双条件:>N/2 且 ≥ quorum
  • 至少 3 个哨兵,配置(尤其 down-after-milliseconds一致
  • 客户端需具备:哨兵发现 + 重试/重连 + 超时
  • 哨兵数量与 down-after-milliseconds权衡取舍

09 | 切片集群:数据增多了,是该加内存还是加实例?

🧩 一、核心知识框架梳理

1. Redis 扩容问题背景

  • 需求:存储 5000 万键值对,每个约 512B,总约 25GB 数据
  • 误区:以为 32GB 内存足够,结果出现 Redis 响应变慢
  • 原因:Redis RDB 持久化时 fork 子进程阻塞主线程,数据量大导致 fork 耗时增加。

2. Redis 扩容的两种方式

扩展方式含义优点缺点适用场景
纵向扩展 (Scale Up)提升单实例资源(内存/CPU/磁盘)简单、部署方便受硬件限制,fork 阻塞问题严重数据量不大、无持久化需求
横向扩展 (Scale Out)启动多个实例组成集群扩展性好,fork 压力分散需要分布式管理、客户端路由复杂大规模数据、高并发场景

对比

3. Redis 切片集群(Sharding Cluster)

  • 概念:将数据切成多片,每片由不同 Redis 实例存储。

  • 关键机制:哈希槽(Hash Slot)

    • Redis Cluster 将所有 key 映射到 16384 个 Slot
    • 计算公式:

      slot = CRC16(key) % 16384
    • 每个实例持有若干 Slot,通过命令:

      cluster addslots {slot编号列表}
    • 可均分,也可根据实例资源手动分配。

4. 客户端如何定位数据

(1)哈希槽信息传播

  • 每个实例知道自己的槽;
  • 实例间传播后,全网共享槽映射;
  • 客户端连接任一实例 → 获取槽分布信息 → 缓存本地。

(2)重定向机制

  • 当槽迁移或集群变更后,客户端缓存可能过期。
命令类型含义客户端动作是否更新本地缓存
MOVED槽已迁移至其他实例重新连接目标实例✅ 更新缓存
ASK槽迁移中,部分数据已转移ASKING,再执行请求❌ 不更新缓存

5. Redis Cluster 的设计优势

  • 哈希槽机制比“直接建表记录 key→实例映射”更好:

    • 节省内存:不需为每个 key 维护映射表;
    • 计算高效:CRC16 + 取模开销小;
    • 迁移简单:以槽为单位迁移,无需逐 key 记录;
    • 负载均衡更自然

💡 二、Go 后端开发面试重点与考点

✅ 高频问答题方向

面试问题考察要点
Redis fork 为什么导致卡顿?Redis 单线程,fork 会复制页表(COW 机制),数据大时阻塞主线程。
RDB 和 AOF 有什么区别?RDB 快照持久化速度快但耗内存,AOF 逐步写入日志更安全。
如何解决 Redis 持久化阻塞问题?使用切片集群、关闭持久化、或采用混合持久化优化。
Redis Cluster 的哈希槽机制有什么作用?分配均衡、迁移方便、快速定位数据实例。
MOVED 与 ASK 的区别?MOVED = 槽已迁移并更新缓存;ASK = 槽迁移中,不更新缓存。
纵向扩展 vs 横向扩展 区别?纵向是加配置;横向是加实例;横向可无限扩展。

🧠 延伸思考题(面试深问)

Redis Cluster 为什么不用 “key → 实例表” 的方式映射?

答:

  • 维护成本高(每个 key 需存表);
  • 内存占用大;
  • 迁移复杂;
  • 哈希槽通过计算即可快速确定,O(1) 定位,无需额外存储;
  • 可自然支持负载均衡与动态扩容。

10 | 常见问题答疑

🧩 一、核心知识框架梳理

🔹问题 1:rehash 的触发时机与渐进式机制

✅ 1. rehash 触发条件

Redis 的哈希表会根据装载因子 (load factor) 决定是否 rehash。

  • 装载因子 = 元素个数 / 哈希桶个数
    触发条件如下:
  1. 装载因子 ≥ 1 且允许 rehash 时(系统未进行 RDB/AOF);
  2. 装载因子 ≥ 5 时,无论是否允许,都会强制 rehash(性能已明显下降)。

禁止 rehash 的情况:

  • 正在生成 RDB 快照;
  • 正在执行 AOF 重写。

✅ 2. 渐进式 rehash 的执行方式

  • Redis 为避免一次性 rehash 带来的性能抖动,采用渐进式 rehash

    • 每次执行部分 key 的迁移。
    • 触发机制:

      • 有新请求访问哈希表时执行;
      • 即使无请求,也有定时任务(每 100ms 左右)自动触发;
      • 每次执行不超过 1ms,防止阻塞主线程。

💬 面试延伸

Q: 为什么 Redis 要使用渐进式 rehash?

A: 避免一次性全表迁移造成主线程长时间阻塞;通过小步执行平滑迁移,保障低延迟性能。

🔹问题 2:主线程、子进程、后台线程的区别

类型创建方式作用特点
主线程(主进程)Redis 启动时创建处理客户端请求、执行命令单线程、事件驱动
子进程fork() 创建处理持久化任务:bgsavebgrewriteaof、无盘复制独立内存空间,复制主线程页表
后台线程pthread_create() 创建异步任务:异步删除、lazyfree、I/O flush 等共享内存,非核心逻辑

📘 从 Redis 4.0 开始引入后台线程机制,主要用于执行耗时操作,减少主线程阻塞。

💬 面试延伸

Q: Redis 是单线程的吗?

A: 主线程是单线程的,但从 Redis 4.0 开始引入后台线程执行异步任务。Redis 整体是主线程 + 多后台线程模型。

🔹问题 3:写时复制(Copy-On-Write, COW)底层机制

✅ 1. 背景

当执行 bgsave 时,Redis 主线程会通过 fork() 创建子进程。此时:

  • 子进程复制主线程的页表(不是数据本身)。
  • 页表指向相同的物理内存页。

✅ 2. 写时复制的工作机制

  • 子进程在读数据生成 RDB;
  • 主线程继续处理写操作;
  • 当主线程修改数据时:

    • 检测到页为共享页;
    • 分配一个新物理页;
    • 将修改后的数据写入新页;
    • 更新自己的页表;
    • 子进程仍指向旧页。

👉 优点:避免数据整体复制,节省内存;
👉 缺点:fork 时复制页表仍会消耗内存,写入频繁时会导致内存膨胀。

💬 面试延伸

Q: Redis 的持久化过程中为什么可能引发内存峰值?

A: 因为 COW 机制下,大量写入会复制页,导致额外内存占用。

🔹问题 4:replication buffer vs repl_backlog_buffer 区别

对比项replication bufferrepl_backlog_buffer
作用全量复制时的临时缓冲区增量复制(部分同步)专用缓冲区
创建时机主库与从库建立连接后Redis 启动时创建
作用范围每个从库独立持有所有从库共享
控制方式client_buffer 参数控制repl-backlog-size 参数控制
主要功能缓存全量传输数据(RDB + 命令)保存主库写命令,支持断线后部分同步
释放机制从库同步完成后释放一直存在,循环写入(环形缓冲区)

💬 面试延伸

Q: 为什么 Redis 需要两个 buffer?

A: 因为全量同步与增量同步需求不同。前者是临时数据传输缓存;后者是持续记录主库写命令用于断点续传。

💡 二、Go 后端开发面试高频考点汇总

知识点面试角度典型提问
rehash 机制Redis 字典结构优化Redis 为什么要分阶段 rehash?触发条件是什么?
单线程模型Redis 架构理解Redis 真的是单线程吗?
COW 写时复制持久化性能优化Redis 的 COW 是如何工作的?为什么会占用更多内存?
复制机制 buffer 区别主从同步机制replication buffer 和 repl_backlog_buffer 有何区别?
后台线程性能与异步机制Redis 哪些操作是后台线程完成的?
fork 性能瓶颈实际性能调优Redis 为什么 fork 会导致延迟?如何优化?

🚀 三、实战与备考建议

✅ 1. 实践建议

  • 动手实验

    • 使用 redis-cli info memory 观察 RDB 期间内存变化;
    • 手动触发 bgsave,同时进行写入,观察写时复制效果。
  • 代码层面

    • 在 Go 中使用 go-redis 模拟主从复制与 failover。
    • 利用 CLIENT LIST 查看每个从库对应的 replication buffer。

✅ 2. 面试技巧

  • 回答机制类问题时,多用 “因为 + 机制 + 影响 + 解决方式” 的结构。
    示例

    Redis 使用写时复制是为了让持久化子进程独立写 RDB,但这会导致写操作时内存激增,可通过控制写入速率或使用更高版本 Redis 的 lazy-free 来缓解。

✅ 3. 推荐复习路径

  1. Redis 内存模型与数据结构(hash、list、zset 等底层实现)
  2. Redis 持久化(RDB / AOF / 混合持久化)
  3. Redis 主从复制机制与高可用架构(哨兵、Cluster)
  4. 性能调优:fork、rehash、COW、pipeline、IO 多线程

11 | “万金油”的String,为什么不好用了?

一、文章在讲什么?

文章讨论一个真实问题:

保存大量“图片ID → 图片存储对象ID”的简单键值对,为什么用 Redis String 会占用超大内存?有没有更节省内存的方案?

他们存了 1 亿条记录,每条记录只有 16 字节有效数据,却占了 64 字节内存
于是想办法:把 String 换成 Hash,并利用 Hash 的底层结构“压缩列表(ziplist)”来节省内存。

最终效果:

  • String 每条 64 字节 → 总共约 6.4GB
  • Hash(ziplist)每条仅 16 字节 → 降到原来的 1/4

二、为什么 String 内存占这么多?

原始数据:

  • 图片ID:10位数字 → 用 long(8 字节)
  • 图片对象ID:10位数字 → long(8 字节)

理论只需要 16 字节

但 Redis String 不是只存数据,它要存一堆“元数据”。包括:

1. RedisObject(16 字节)

所有 Redis 数据类型都有的结构,包含:

  • 8 字节元信息
  • 8 字节指针(int 编码时直接存 long)

→ 共 16 字节

2. 字符串使用 SDS(Simple Dynamic String)

SDS 有额外字段:

  • len(4 字节)
  • alloc(4 字节)
  • buf[](最后还要加一个 \0

即便内容只有几字节,也会额外占用这些。

3. dictEntry(实际 32 字节)

Redis 的全局哈希表存储所有 key-value
dictEntry 内含三个指针:

  • key 指针
  • value 指针
  • 下一个 entry 指针

理论上是 24 字节,但 jemalloc 会按 2 的幂次分配 → 实际为 32 字节。


最终加总:

构成大小
RedisObject16
dictEntry32
SDS 结构及数据16 左右(即使 int 编码有优化,但整体下来合计 64)

所以:
一条看似只需要 16 字节的数据,String 却要用到 64 字节。

三、如何节省内存?用 Hash(内部采用 ziplist)

Redis Hash 有两种底层实现:

  1. ziplist(压缩列表) → 非常省内存
  2. hashtable → 内存较大,不想用它

只要满足条件,就用 ziplist:

  • 元素个数小于 hash-max-ziplist-entries
  • 单个元素长度小于 hash-max-ziplist-value

ziplist 每个 entry 只需要(大概):

  • prev_len(通常 1 字节)
  • encoding(1 字节)
  • len(4 字节)
  • content(实际的数据)

文中例子:保存一个 8 字节整数
→ entry 大约占 14 字节(算上内存分配约 16 字节)

这就是为什么每条记录只需要 16 字节

四、如何用 Hash 保存“单值键值对”?需要二级编码

问题:Hash 是“一个 key 对应多个 field-value”,
但我们需要的是“单值”。

怎么办?

作者用了 二级编码技巧

假设原始数据:

photo_id = 1101000060
obj_id   = 3302000080

把 photo_id 拆成:

  • 前7位:作为 Redis 的 key
    → 1101000
  • 后3位:作为 Hash 的 field
    → 060
  • value = obj_id
    → 3302000080

Redis 命令类似:

HSET 1101000 060 3302000080

这样 Hash 内一个 key(如 1101000)下有 1000 个 field(000~999),
满足 ziplist 的阈值,保持 Hash 的底层结构为 ziplist,非常节省内存。

五、为什么必须用“前7位 + 后3位”?

核心原因:

  1. 保证每个 Hash 元素个数 不超过 1000

    • 尽量让 Hash 用 ziplist
    • ziplist 对内存友好,不用 dictEntry,不用指针
  2. 每个 field 和 value 都很短

    • 不会超过 hash-max-ziplist-value

否则 Hash 会退化成 hashtable → 内存又变大。

六、最终效果

用 String:一条 64 字节
用 Hash(ziplist):一条 16 字节

节省了 75% 内存。

七、最后的问题:除了 String、Hash,还有别的类型可以用吗?

文章最后问你:

除了 String 和 Hash,你觉得还有适用的类型吗?

你可以这样理解:

  • List、Set、Zset底层也能用 ziplist
  • 但它们的数据模型不是“key → 单值”
  • 不太适用于“一对一映射”

真正能直接用于这个场景的除了 Hash(ziplist)和 String,
还可以考虑的是 Redis Module:比如 Redis 5 之后的 stream 或者自定义 module 类型,但不是天然适合一对一映射。

所以正常回答是:

👉 Hash 最合适,其次可以考虑自定义 Module 类型;其他内置类型不适合单值映射。

12 | 有一亿个keys要统计,应该用哪种集合?

这篇文章就是教你:遇到“一个 key 对一堆值”并且要做统计时,应该选哪种 Redis 集合类型,以及它们各自适合哪种统计模式。

核心只有 4 种统计模式:
👉 聚合统计、排序统计、二值状态统计、基数统计。

下面我给你「拆小块 + 场景联想 + 记忆小口诀」,帮你看懂 + 学会 + 记住。

一、大局观:4 种统计模式 + 6 个数据结构

先别急着看细节,先把“地图”记住:

  • 4 种统计模式

    1. 聚合统计:交、并、差 —— 集合之间的“比较”
    2. 排序统计:要按顺序的列表、排行榜、最新评论
    3. 二值状态统计:只有 0/1 的状态(签过到/没签)
    4. 基数统计:只关心“有多少不同的人”,去重计数(UV)
  • 6 个主要数据结构

    • Set
    • Sorted Set
    • List
    • Hash
    • Bitmap
    • HyperLogLog

先记一条总口诀:

**交并差找 Set,
排序首选 ZSet,
0/1 用 Bitmap,
去重估数 HLL。**

有了这张“地图”,后面就是往每一块里填细节。

二、模式 1:聚合统计(交集 / 并集 / 差集)

关键词:新增用户、留存用户、交并差、Set

1. 场景

  • 每天登录的用户集合:

    • user:id:20200803:8月3日登录过的用户ID(Set)
    • user:id:20200804:8月4日登录过的用户ID(Set)
  • 累计登录用户集合:

    • user:id:历史上所有登录过的用户(Set)

要统计:

  • 每天新增用户:今天登录,但历史上没出现过
  • 第二天留存用户:昨天登录,今天也登录

2. 用到的 Redis 命令和思路

  1. 累计用户 Set:保存所有登录过的用户

    SUNIONSTORE user:id user:id user:id:20200803

    把“原来的累计用户集合”和“今天的集合”做并集,结果再存回 user:id

  2. 今天的新增用户(差集)
    例:统计 2020-08-04 的新增

    SDIFFSTORE user:new user:id:20200804 user:id

    user:id:20200804 中有,但 user:id 中没有的用户取出来 → 新增。

  3. 第二天留存用户(交集)

    SINTERSTORE user:id:rem user:id:20200803 user:id:20200804

    同时出现在 8月3日 和 8月4日 的用户 → 留存。

3. 为什么用 Set?

  • Set 天然是“无序不重复集合”,支持:

    • 交集 SINTER
    • 并集 SUNION
    • 差集 SDIFF
  • 正好符合“多个集合做交并差”的聚合统计需求。

4. 风险和优化

  • 大集合做交并差运算,会很耗时、阻塞 Redis
  • 优化建议:

    • 从库 上做这些运算;
    • 或者把各集合拉回客户端,在业务层做统计。

三、模式 2:排序统计(最新评论/排行榜)

关键词:列表分页、最新评论、位置变化、Sorted Set

场景:电商商品的最新评论列表

需求:

  • 看到最新的评论
  • 通常要分页,比如:

    • 第 1 页:最新 10 条
    • 第 2 页:再往前 10 条

我们有两个候选:

  • List
  • Sorted Set(有序集合)

1. 为啥 List 不太行

假设评论 List 为:
{A, B, C, D, E, F} (A 最新、F 最旧)

  • 第 1 页:LRANGE product1 0 2 → A、B、C
  • 第 2 页:LRANGE product1 3 5 → D、E、F

此时又来了一条新评论 G,用 LPUSH 插入:

  • List 变成 {G, A, B, C, D, E, F}

再去拿第 2 页:LRANGE product1 3 5 → C、D、E

问题:C 重复出现了!
因为 List 是按位置排序的,新元素插入头部之后,所有元素整体往后移动,分页就乱了。

2. Sorted Set 怎么解决?

Sorted Set 里每个元素有一个score(权重),比如用“时间戳”做 score:

  • 时间越新,score 越大
  • 插入评论时,附上时间作为 score

然后用:

ZRANGEBYSCORE comments N-9 N

按 score 范围获取最新 10 条。
即使不断有新评论插入,只要 score 正确,取数据就是稳定的。

记忆点:

  • 排序 + 分页 + 不想被插入新数据影响 → Sorted Set。
  • List 只适合简单队列/栈,复杂分页排序不靠谱。

四、模式 3:二值状态统计(0/1:签到)

关键词:签到、0/1 状态、超省内存、Bitmap

场景:签到打卡

  • 一天的状态:只需“签了(1)”或“没签(0)”
  • 一个月:31天 → 31 个 bit
  • 一年:365 个 bit

完全没必要用 Set、ZSet 这种大结构,直接用 Bitmap

1. Bitmap 是什么?

  • 本质:底层是 String 的 bit 数组
  • SETBIT / GETBIT 操作某个 bit
  • BITCOUNT 统计有多少个 1

2. 日常操作示例

记录用户 3000 在 2020年8月 的签到

约定:offset 从 0 开始,0 表示 8月1日
所以 8月3日 → offset=2
  1. 标记 8月3日已签到:

    SETBIT uid:sign:3000:202008 2 1
  2. 查询 8月3日是否签到:

    GETBIT uid:sign:3000:202008 2
  3. 统计 8 月份总签到天数:

    BITCOUNT uid:sign:3000:202008

3. 连续 10 天签到人数怎么统计?

思路:

  • 每天一个 Bitmap:sign:20200801 ... sign:20200810
  • 每个 Bitmap 有 N 个 bit,对应 N 个用户
  • 把这 10 天的 Bitmap 做按位 AND(“与”):
BITOP AND sign:202008_01_10 sign:20200801 sign:20200802 ... sign:20200810
BITCOUNT sign:202008_01_10
  • 按位“与”的结果:只有 10 天都为 1 的用户对应的 bit 还是 1
  • BITCOUNT 就是“连续签到 10 天”的人数

记忆点:

  • 只有 0/1 状态 + 超大量用户 → Bitmap
  • “按位”是否存在、是否签到等 → Bitmap + BITOP

五、模式 4:基数统计(去重计数:UV)

关键词:去重人数、Set 很准但耗内存、HyperLogLog 很省内存但有误差

场景:统计网页的 UV

  • UV:Unique Visitor → 同一个用户访问多次,只算一次
  • 用户可能几千万,页面可能几万个,用 Set / Hash 都很浪费内存

1. 直接用 Set 的做法

SADD page1:uv user1
SCARD page1:uv   # 得到 UV 数

问题:

  • 每个访问用户 ID 都要存下
  • 页面多、用户多 → 内存爆炸

2. 用 Hash 的做法(思路类似)

HSET page1:uv user1 1
HLEN page1:uv

还是一样:精确,但很耗内存。

3. 用 HyperLogLog:超省内存的“近似去重计数”

特点:

  • 一个 HyperLogLog 固定只占 ~12KB 内存
  • 即使统计接近 2^64 个不同元素,内存还是大概 12KB
  • 但统计结果是近似值,误差约 0.81%

使用:

PFADD page1:uv user1 user2 user3
PFCOUNT page1:uv   # 近似 UV 数量
  • 如果你能接受 100万 实际是 101万、99万这种级别的误差 → 用它
  • 如果你要绝对精准 → 还是 Set / Hash

记忆点:

  • 需要“去重计数”

    • 精确 + 数量不夸张 → Set / Hash
    • 海量数据 + 内存敏感 + 可接受少量误差 → HyperLogLog

六、一起捋一遍:场景 → 选择什么?

我帮你把文章的 4 个场景和数据结构一一对上,你只要记这个表就够用了:

场景需求类型推荐结构原因
每日新增用户 & 留存用户交、并、差Set有交集/并集/差集操作
商品最新评论列表排序 + 分页Sorted Set通过 score 排序,不怕新数据插入打乱位置
连续签到、签到统计0/1 状态Bitmap1 个 bit 就能表示一个状态,极省内存
网页 UV 统计去重计数HyperLogLog固定内存、适合超大规模近似统计

再把那句口诀看一眼:

**交并差找 Set,
排序首选 ZSet,
0/1 用 Bitmap,
去重估数 HLL。**

七、怎么更好地“记住”这篇文章?

给你一个学习/记忆小策略,你可以照着做一遍(不用特别正式,自己在纸上写写就行):

  1. 画一个 2×3 小表格
    写上:Set / ZSet / List / Hash / Bitmap / HLL 各适合做什么。
  2. 每种统计模式,自己找一个额外例子:

    • 聚合统计:比如“多渠道用户去重”(App 用户集合 ∩ Web 用户集合)
    • 排序统计:比如“积分排行榜”
    • 二值状态统计:比如“今天是否登录”
    • 基数统计:比如“某个活动独立参与用户数”
  3. 用 Redis 命令简单写两三行伪代码
    不一定真的去运行,但把命令写出来能加深记忆。

八、文章最后的“每课一问”怎么答?

题目:你还遇到过其他的统计场景吗?用的是怎样的集合类型呢?

你可以这样回答(示范版,可以改成你的场景):

  1. 统计接口的 QPS(每秒请求数)

    • 需求:每秒请求量统计
    • 方案:用 String + INCR 记录每秒的计数,而不是用集合类型
  2. 活动积分排行榜

    • 需求:按积分排序、随时看前 N 名
    • 方案:用 Sorted Set,用户 ID 是成员,积分是 score
  3. 统计每天购买过商品 A 的用户数(精确)

    • 需求:去重计数且要精确
    • 方案:Set 或 Hash,用户 ID 作为 key

你也可以把自己真实见过的一个场景写出来,比如:

比如我在 xxx 项目里做过“xxx 的统计”,当时用的是 xxx(Set/Sorted Set/Bitmap/...),原因是 xxx。
最后修改:2025 年 11 月 18 日
如果觉得我的文章对你有用,请随意赞赏