概要

MySQL在高并发场景下可能会面临性能瓶颈、数据库连接压力、锁竞争、缓存不足和扩展性受限等问题。为了提高系统的性能和可伸缩性,常常需要结合其他技术和工具,比如缓存系统(如 Redis)。

**Redis(Remote Dictionary Server)**是一个开源的内存数据存储系统,它可以用作数据库、缓存和消息中间件。Redis 支持多种数据结构,如字符串(String)、哈希(Hash)、列表(List)、集合(Set)、有序集合(Sorted Set)等,并提供了丰富的操作命令来对这些数据结构进行读写操作。

Redis解决了以下几个问题:

  1. **高性能缓存:**Redis 将数据存储在内存中,因此具有快速的读写速度。它适用于需要频繁读写的场景,可以显著提升应用程序的响应速度和吞吐量。
  2. **数据持久化:**Redis 支持数据持久化,可以将数据保存到磁盘上,以防止数据丢失。通过持久化功能,Redis 可以在重启后将数据重新加载到内存中,实现数据的持久化存储。
  3. **分布式缓存:**Redis 可以作为分布式缓存使用,多个应用程序实例可以共享同一个 Redis 集群,提高缓存的命中率和整体性能。它还提供了一些分布式缓存的特性,如数据分片、数据复制和故障转移等。
  4. **发布订阅消息系统:**Redis 支持发布订阅模式,可以实现消息的发布和订阅机制。应用程序可以通过Redis 的发布订阅功能进行消息的异步传递和广播,实现解耦和消息通信。

总之,Redis 是一个功能强大的内存数据存储系统,它提供了高性能的数据操作和丰富的功能,可以应用于多种场景,如缓存、数据库、消息中间件等,解决了数据存储和处理的效率、可靠性和可扩展性等问题。

画板


Redis 核心技术学习方法


一句话摘要

单点技术积累无法解决复杂线上问题,必须用**“两大维度 × 三大主线”**的系统观构建 Redis 全景知识框架,才能做到有依据、有章法地定位和解决问题。


核心知识点

1. Redis 生产环境的四类”坑”

线上 Redis 问题高度集中在以下四个维度,几乎所有故障都可归类于此:

  • CPU:数据结构时间复杂度过高、跨 CPU core 的内存访问
  • 内存:主从同步与 AOF 重写之间的内存竞争
  • 存储持久化:在 SSD 上做 RDB 快照引发的性能抖动
  • 网络通信:多实例部署时的异常网络丢包

2. 系统观 vs. 零散技术点

只掌握孤立的技术点,遇到复杂问题时无从下手。系统观的价值在于:拥有一张”问题 → 主线 → 技术点”的映射图,能快速定位根因

案例:长尾延迟排查链路

目标:将 Redis 长尾延迟控制在阈值以下。

排查链路依次展开:

  1. 单线程模型 → 任何阻塞操作都会产生长尾延迟
  2. 网络层 → IO 多路复用(epoll),不阻塞单个客户端,排除
  3. 数据结构 → 复杂度高的操作(如 KEYS *、大 Hash)可能阻塞
  4. 持久化 → fork() 系统调用本身耗时,会短暂阻塞主线程
  5. 主从同步 → AOF 重写期间内存与 CPU 竞争
  6. 缓冲区 → 输出缓冲区溢出导致连接断开或阻塞

结论:没有系统观就只能逐一”瞎摸”;有了完整链路,同类问题可复用此路径快速解决。

3. Redis 全景知识图:两大维度 × 三大主线

┌──────────────────────────────────────────────────┐
│              Redis 全景知识图                    	 │
├─────────────────┬────────────────────────────────┤
│   系统维度  	    │   设计原理:为什么这样实现       	 │
│   应用维度  	    │   使用场景:在哪里用、怎么用好  	   │
├─────────────────┴────────────────────────────────┤
│  三大主线                                     	   │
│  高性能:线程模型 / 数据结构 / 持久化 / 网络框架   	   │
│  高可靠:主从复制 / 哨兵机制                     	   │
│  高可扩展:数据分片 / 负载均衡                   	   │
└──────────────────────────────────────────────────┘
  • 高性能主线:单线程 + IO 多路复用、跳表 / 压缩列表等数据结构、RDB/AOF 持久化、epoll 网络模型
  • 高可靠主线:主从全量/增量同步、哨兵选举与故障切换
  • 高可扩展主线:Codis / Redis Cluster 数据分片、一致性哈希与负载均衡

4. 应用维度的两种学习方式

场景驱动(面):适合有完整技术链的场景。

场景核心技术链
缓存缓存机制 → 替换策略(LRU/LFU)→ 雪崩 / 击穿 / 穿透 / 污染
集群集群方案选型 → 数据一致性 → 高并发访问优化

案例驱动(点):适合零碎场景或隐性问题(如亿级访问下才暴露的长尾延迟)。整理成可复用的 Checklist / “锦囊”,问题出现时直接查表。

5. 问题画像图的使用方法

核心思路:问题 → 主线 → 技术点 三级映射。

示例:Redis 响应变慢

响应变慢
  └→ 性能主线
       ├→ 数据结构(大 Key、慢命令)
       ├→ 异步机制(阻塞操作未异步化)
       ├→ RDB 快照(fork 阻塞)
       └→ AOF 重写(内存竞争、刷盘)

6. NVM 在 Redis 中的应用方向

传统 Redis 持久化痛点:写 AOF/RDB 引发性能抖动,读 RDB 恢复慢。NVM(非易失内存)同时具备接近 DRAM 的读写速度和持久化能力,是解决上述矛盾的前沿方向(对应课程”未来篇”内容)。


优缺点与局限性

系统观学习方法

  • 优点:建立结构化框架后,新问题可快速归类到已知主线,排查效率显著提升
  • 局限:前期建立框架需要时间投入,不适合临时抱佛脚式学习
  • 踩坑点:容易只”画图”不”深挖”,框架有了但每个技术点理解停留在表面

场景驱动 vs. 案例驱动

  • 场景驱动局限:只适合有显式技术链的主线场景,零碎场景无法覆盖
  • 案例驱动局限:需要大量一线实战积累,缺乏实际项目经验时学习效果有限
  • 踩坑点:两种方式要组合使用,偏废任何一种都会造成知识盲区

平均延迟 vs. 长尾延迟

  • 局限:用平均延迟评估性能会掩盖 1% 的极端慢请求。100 万请求中 1% 长尾 = 1 万次糟糕体验
  • 正确做法:监控 P99 / P999 延迟,而非仅看平均值

行动清单

  1. 画出自己的 Redis 问题画像图:以”高性能 / 高可靠 / 高可扩展”为三个根节点,把自己曾遇到过的 Redis 问题挂到对应节点下,先建立初始版本,后续持续补充。
  2. 验证监控指标:检查现有 Redis 监控是否包含 P99 延迟,如果只有平均延迟,补充 INFO latency 或接入 redis-cli --latency-history
  3. 按主线顺序学习后续章节:建议优先跟完高性能主线(第 02~05 讲:数据结构 → IO 模型 → AOF → RDB),再学高可靠主线(第 06~08 讲),最后高可扩展主线(第 09 讲)。
  4. 建立个人 Checklist:每学完一个技术点,提炼出”该技术点在什么条件下会成为瓶颈”,整理为可操作的排查步骤,积累成自己的”锦囊”文档。
  5. 实践长尾延迟排查:在开发或测试环境中故意执行 KEYS * 或对大 Hash 执行 HGETALL,用 redis-cli --latency 观察延迟抖动,直观感受单线程阻塞对长尾延迟的影响。

Redis 数据类型与应用场景


一句话摘要

Redis 提供 9 种数据类型(5 种基础 + 4 种扩展),每种类型有不同的底层编码和适用场景;正确选型的关键在于理解数据结构特性与业务需求的映射关系。


核心知识点


1. String

概念: 最基础的 key-value 结构。value 可以是字符串、整数或浮点数,最大容量 512MB。

底层实现:

底层由 int**SDS****(Simple Dynamic String)**两种结构支撑,编码方式有三种:

  • int:value 是可用 long 表示的整数时使用
  • embstr字符串 ≤ 44 字节,redisObject 和 SDS 存储在一块连续内存中,一次分配、一次释放、CPU 缓存友好;但**只读,执行修改命令(如 **APPEND**)会先转为 ****raw**
  • raw字符串 > 44 字节,redisObject 和 SDS 分两次分配,各自独立内存

SDS 相比 C 原生字符串的三个优势:

  • len 属性直接记录长度 → 获取长度 O(1)
  • 二进制安全 → 可存图片、音频等非文本数据
  • 拼接前自动检查空间并扩容 → 杜绝缓冲区溢出

embstr/raw 的边界值随版本变化:Redis 2.x 为 32 字节,3.0~4.0 为 39 字节,5.0 为 44 字节。

常用命令:

SET name lin              # 设置值
GET name                  # 获取值
MSET k1 v1 k2 v2          # 批量设置
MGET k1 k2                # 批量获取
INCR number               # 值 +1(原子操作)
INCRBY number 10          # 值 +10
DECR number               # 值 -1
DECRBY number 10          # 值 -10
STRLEN name               # 获取值长度
EXISTS name               # 判断 key 是否存在
DEL name                  # 删除 key
EXPIRE name 60            # 设置 60 秒过期
TTL name                  # 查看剩余过期时间
SET key value EX 60       # 设置值并指定过期时间
SETNX key value           # key 不存在时才设置

案例①:缓存对象

# 方式一:整体 JSON
SET user:1 '{"name":"xiaolin", "age":18}'

# 方式二:分字段存储
MSET user:1:name xiaolin user:1:age 18 user:2:name xiaomei user:2:age 20

案例②:计数器(文章阅读量)

Redis 单线程执行命令,INCR 天然原子。

SET aritcle:readcount:1001 0
INCR aritcle:readcount:1001    # → 1
INCR aritcle:readcount:1001    # → 2
INCR aritcle:readcount:1001    # → 3
GET aritcle:readcount:1001     # → "3"

案例③:分布式锁

加锁:

SET lock_key unique_value NX PX 10000
# NX → key 不存在才设置(保证互斥)
# PX 10000 → 10 秒自动过期(兜底防死锁)
# unique_value → 客户端唯一标识(防止误删他人的锁)

解锁必须用 Lua 脚本保证「判断 + 删除」的原子性:

if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

案例④:共享 Session

分布式系统中,请求可能被分配到不同服务器,各服务器本地 Session 不互通。将 Session 统一存入 Redis,所有服务器从同一个 Redis 读取,解决重复登录问题。


2. List

概念: 有序字符串列表,按插入顺序排列,可从头部或尾部操作。最大长度 2³²-1(约 40 亿)。

底层实现:

  • Redis 3.2 之后:统一使用 quicklist

常用命令:

LPUSH key v1 v2 v3        # 从左侧插入(v3 在最左)
RPUSH key v1 v2 v3        # 从右侧插入
LPOP key                  # 弹出最左元素
RPOP key                  # 弹出最右元素
LRANGE key 0 -1           # 获取全部元素
BLPOP key timeout         # 阻塞式左弹出(timeout=0 永久阻塞)
BRPOP key timeout         # 阻塞式右弹出

案例:用 List 实现消息队列

消息队列有三大需求,List 逐一解决:

需求一:消息保序

LPUSH 入队 + RPOP 出队,天然 FIFO。

LPUSH mq "111000102:stock:99"
RPOP mq

需求二:避免 CPU 空转

直接用 RPOP 轮询会空耗 CPU。改用 BRPOP 阻塞读,没消息就挂起等待:

BRPOP mq 0     # 阻塞等待直到有消息

需求三:处理重复消息

List 不自动生成消息 ID,需要生产者自行在消息体中嵌入全局唯一 ID:

LPUSH mq "111000102:stock:99"     # ID 111000102 + 数据 stock:99

消费者通过比对已处理 ID 记录来判断消息是否已消费。

需求四:消息可靠性

消费者读取后消息从 List 删除,若处理中宕机则消息丢失。用 BRPOPLPUSH 将消息同时备份到另一个 List:

BRPOPLPUSH mq mq_backup 0
# 消费者宕机后可从 mq_backup 重新读取

3. Hash

概念: 键值对集合,结构为 key → {field1:value1, ..., fieldN:valueN}

底层实现:

  • 元素数 < 512 且所有值 < 64 字节 → listpack
  • 否则 → 哈希表

常用命令:

HSET key field value          # 设置单个字段
HGET key field                # 获取单个字段
HMSET key f1 v1 f2 v2         # 批量设置
HMGET key f1 f2               # 批量获取
HDEL key field                # 删除字段
HLEN key                      # 字段数量
HGETALL key                   # 获取全部字段和值
HINCRBY key field n           # 字段值加 n

案例①:缓存对象

Hash 的 (key, field, value) 天然对应对象的 (对象ID, 属性名, 属性值)

HMSET uid:1 name Tom age 15
HMSET uid:2 name Jerry age 13
HGETALL uid:1
# 1) "name"  2) "Tom"  3) "age"  4) "15"

选型原则:整体读写、不常变化 → String + JSON;频繁更新部分字段 → Hash。

案例②:购物车

用户 ID 为 key、商品 ID 为 field、数量为 value:

HSET cart:{用户id} {商品id} 1        # 添加商品
HINCRBY cart:{用户id} {商品id} 1     # 数量 +1
HLEN cart:{用户id}                   # 商品总数
HDEL cart:{用户id} {商品id}          # 删除商品
HGETALL cart:{用户id}                # 获取购物车全部商品

注意:这里只存了商品 ID 和数量,展示完整商品信息还需拿 ID 回查数据库。


4. Set

概念: 无序、元素唯一的集合。最大 2³²-1 个元素。核心能力:去重 + 交/并/差集运算。

与 List 的区别:List 有序可重复;Set 无序不重复。

底层实现:

  • 元素全为整数且数量 < 512set-maxintset-entries 配置)→ 整数集合
  • 否则 → 哈希表

常用命令:

SADD key m1 m2 m3         # 添加元素
SREM key m1               # 删除元素
SMEMBERS key              # 获取所有元素
SCARD key                 # 元素数量
SISMEMBER key m1          # 判断元素是否存在(1=存在,0=不存在)
SRANDMEMBER key count     # 随机取 count 个元素(不删除)
SPOP key count            # 随机取 count 个元素(删除)

SINTER key1 key2          # 交集
SUNION key1 key2          # 并集
SDIFF key1 key2           # 差集(key1 有但 key2 没有)
SINTERSTORE dest k1 k2    # 交集结果存入 dest

案例①:点赞

文章 ID 为 key,用户 ID 为 value,Set 自动去重保证每人只能赞一次:

SADD article:1 uid:1        # 点赞
SADD article:1 uid:2
SADD article:1 uid:3
SREM article:1 uid:1        # 取消点赞
SMEMBERS article:1          # 所有点赞用户 → uid:2, uid:3
SCARD article:1             # 点赞总数 → 2
SISMEMBER article:1 uid:1   # uid:1 是否点赞 → 0(否)

案例②:共同关注

SADD uid:1 5 6 7 8 9        # uid:1 关注的公众号
SADD uid:2 7 8 9 10 11      # uid:2 关注的公众号

SINTER uid:1 uid:2          # 共同关注 → 7, 8, 9
SDIFF uid:1 uid:2           # 推荐给 uid:2(uid:1 有但 uid:2 没有)→ 5, 6

案例③:抽奖

SADD lucky Tom Jerry John Sean Marry Lindy Sary Mark

# 允许重复中奖(元素不删除)
SRANDMEMBER lucky 1    # 一等奖
SRANDMEMBER lucky 2    # 二等奖

# 不允许重复中奖(元素删除)
SPOP lucky 1           # 一等奖
SPOP lucky 2           # 二等奖
SPOP lucky 3           # 三等奖

5. Zset(有序集合)

概念: Set 的基础上给每个元素附加一个 score(分值)用于排序。元素唯一,score 可重复。排序规则:先按 score 升序,score 相同时按字典序。

底层实现:

  • 元素数 < 128 且每个值 < 64 字节 → listpack
  • 否则 → 跳表(skiplist)

常用命令:

ZADD key score member          # 添加元素
ZREM key member                # 删除元素
ZSCORE key member              # 查询分值
ZCARD key                      # 元素总数
ZINCRBY key increment member   # 分值 +increment

ZRANGE key start stop [WITHSCORES]       # 正序取(按下标)
ZREVRANGE key start stop [WITHSCORES]    # 倒序取
ZRANGEBYSCORE key min max [WITHSCORES]   # 按分值范围取(低→高)
ZRANGEBYLEX key min max                  # 按字典序取(要求所有元素 score 相同)
ZREVRANGEBYLEX key max min               # 按字典倒序取

ZUNIONSTORE dest numkeys key [key...]    # 并集(score 相加)
ZINTERSTORE dest numkeys key [key...]    # 交集(score 相加)
# 注意:Zset 不支持差集运算

案例①:排行榜(博文点赞排名)

ZADD user:xiaolin:ranking 200 arcticle:1
ZADD user:xiaolin:ranking 40  arcticle:2
ZADD user:xiaolin:ranking 100 arcticle:3
ZADD user:xiaolin:ranking 50  arcticle:4
ZADD user:xiaolin:ranking 150 arcticle:5

ZINCRBY user:xiaolin:ranking 1 arcticle:4   # 文章4点赞 +1 → "51"
ZSCORE user:xiaolin:ranking arcticle:4      # 查看文章4赞数 → "50"

# Top 3(倒序取前3)
ZREVRANGE user:xiaolin:ranking 0 2 WITHSCORES
# → arcticle:1(200), arcticle:5(150), arcticle:3(100)

# 100~200 赞的文章
ZRANGEBYSCORE user:xiaolin:ranking 100 200 WITHSCORES
# → arcticle:3(100), arcticle:5(150), arcticle:1(200)

案例②:电话号码排序

所有 score 设为 0,利用 ZRANGEBYLEX 按字典序筛选号段:

ZADD phone 0 13100111100 0 13110114300 0 13132110901
ZADD phone 0 13200111100 0 13210414300 0 13252110901
ZADD phone 0 13300111100 0 13310414300 0 13352110901

ZRANGEBYLEX phone - +           # 所有号码
ZRANGEBYLEX phone [132 (133     # 132 号段([ 包含,( 不包含)
ZRANGEBYLEX phone [132 (134     # 132 + 133 号段

案例③:姓名排序

ZADD names 0 Toumas 0 Jake 0 Bluetuo 0 Gaodeng 0 Aimini 0 Aidehua

ZRANGEBYLEX names - +       # 全部人名(字母升序)
ZRANGEBYLEX names [A (B     # A 开头 → Aidehua, Aimini
ZRANGEBYLEX names [C [Z     # C~Z 开头 → Gaodeng, Jake, Toumas

踩坑点: **ZRANGEBYLEX / ZREVRANGEBYLEX 只有在所有元素 score 相同时结果才准确。**score 不一致时,排序是 score 优先而非字典序优先,字典序范围扫描会跨 score 段截断,导致漏取元素。


6. BitMap

概念: 连续的二进制数组,用每个 bit 位(0 或 1)表示一个元素的二值状态。操作时间复杂度 O(1),极致节省内存。

底层实现: 基于 String 类型的字节数组,将每个 bit 位独立利用。

常用命令:

SETBIT key offset value        # 设置 offset 位的值(0 或 1)
GETBIT key offset              # 获取 offset 位的值
BITCOUNT key [start end]       # 统计值为 1 的位数(start/end 单位是字节)
BITPOS key value [start end]   # 第一个值为 value 的位置

BITOP AND dest k1 k2 k3        # 多个 BitMap 做与运算
BITOP OR dest k1 k2            # 或运算
BITOP XOR dest k1 k2           # 异或运算
BITOP NOT dest k1              # 取反(仅支持单 key)

案例①:签到统计

用户 ID + 年月作为 key,日期作为 offset(offset 从 0 开始,所以 3 号对应 offset=2):

SETBIT uid:sign:100:202206 2 1    # 用户100在6月3日签到
GETBIT uid:sign:100:202206 2      # 6月3日是否签到
BITCOUNT uid:sign:100:202206      # 6月总签到天数
BITPOS uid:sign:100:202206 1      # 6月首次签到日期(返回值 +1 = 实际日期)

内存开销:一个月 31 bit,一年 365 bit,极小。

案例②:用户登录状态

一个 key 存全体用户状态,用户 ID 作 offset:

SETBIT login_status 10086 1    # 用户10086上线
GETBIT login_status 10086      # 是否在线(1=是,0=否)
SETBIT login_status 10086 0    # 用户10086下线

5000 万用户仅需约 6 MB。

案例③:连续签到用户总数

以日期为 key,用户 ID 为 offset。对 7 天的 BitMap 做 AND 运算,bit 仍为 1 的位即为连续 7 天签到的用户:

BITOP AND destmap bitmap:01 bitmap:02 bitmap:03
BITCOUNT destmap

1 亿用户一天约 12 MB,7 天约 84 MB。建议给 BitMap 设过期时间,自动清理旧数据。


7. HyperLogLog

概念: 概率型基数统计结构,用于统计集合中不重复元素的个数。标准误差率 0.81%,每个 key 固定占 12 KB 内存,可统计接近 2⁶⁴ 个不同元素。

底层实现: 基于概率数学算法(涉及伯努利试验等),不保存原始元素数据。

常用命令(仅三个):

PFADD key element [element...]     # 添加元素
PFCOUNT key [key...]               # 返回基数估算值
PFMERGE destkey sourcekey [...]    # 合并多个 HyperLogLog

案例:百万级网页 UV 统计

PFADD page1:uv user1 user2 user3 user4 user5
PFCOUNT page1:uv      # 返回去重后的访客数估算值

8. GEO

概念: 存储地理位置(经纬度)并支持距离计算、范围查询。

底层实现: 直接使用 Zset。通过 GeoHash 算法将经纬度编码为一个数值存入 Zset 的 score,借助 Zset 的有序范围查找能力实现地理位置搜索。

常用命令:

GEOADD key lng lat member [...]                  # 存入位置
GEOPOS key member [...]                          # 获取经纬度
GEODIST key member1 member2 [m|km|ft|mi]         # 两点距离
GEORADIUS key lng lat radius m|km [ASC|DESC] [COUNT n]  # 范围搜索

案例:滴滴叫车

# 司机上线,上报位置
GEOADD cars:locations 116.034579 39.030452 33

# 乘客搜索附近 5 公里内最近的 10 辆车
GEORADIUS cars:locations 116.054579 39.030452 5 km ASC COUNT 10

9. Stream

概念: Redis 5.0 推出的原生消息队列类型。支持消息持久化、自动生成全局唯一 ID、ACK 确认机制、消费组。

消息 ID 格式: 毫秒时间戳-当前毫秒内序号,如 1654254953808-0

常用命令:

# 基础操作
XADD key * field value             # 插入消息(* = 自动生成ID)
XLEN key                           # 消息数量
XREAD STREAMS key ID               # 从指定 ID 的下一条开始读
XREAD BLOCK 10000 STREAMS key $    # 阻塞读最新消息,10 秒超时
XDEL key ID                        # 删除消息
XRANGE key start end               # 读取区间消息

# 消费组
XGROUP CREATE key groupName 0-0                 # 创建消费组(0-0 从头读)
XREADGROUP GROUP group consumer STREAMS key >   # 消费组读(> 表示未消费的消息)
XPENDING key group                              # 查看已读取但未确认的消息
XPENDING key group - + 10 consumer              # 查某个消费者的未确认消息
XACK key group ID                               # 确认消息处理完成

案例:完整消息队列

生产者:

XADD mymq * name xiaolin
# → "1654254953808-0"(自动生成的全局唯一 ID)

消费者(简单模式):

XREAD STREAMS mymq 1654254953807-0     # 从指定 ID 之后读取
XREAD BLOCK 10000 STREAMS mymq $       # 阻塞等待最新消息

消费组模式(核心能力):

# 创建两个消费组
XGROUP CREATE mymq group1 0-0
XGROUP CREATE mymq group2 0-0

# group1 的 consumer1 读取 → 消费一条消息
XREADGROUP GROUP group1 consumer1 STREAMS mymq >
# 再执行一次 → 返回空(同一消费组内消息只能被消费一次)

# group2 的 consumer1 读取 → 仍能消费同一条消息(不同消费组互不影响)
XREADGROUP GROUP group2 consumer1 STREAMS mymq >

# 消费组内负载均衡:group2 的三个消费者各读一条
XREADGROUP GROUP group2 consumer1 COUNT 1 STREAMS mymq >
XREADGROUP GROUP group2 consumer2 COUNT 1 STREAMS mymq >
XREADGROUP GROUP group2 consumer3 COUNT 1 STREAMS mymq >

ACK 确认机制(保证可靠性):

消息被读取后自动进入 PENDING List,直到 XACK 确认才移除:

XPENDING mymq group2                        # 查看整体未确认状态
XPENDING mymq group2 - + 10 consumer2       # consumer2 的未确认消息
XACK mymq group2 1654256265584-0            # 确认处理完成
XPENDING mymq group2 - + 10 consumer2       # → empty array

消费者宕机重启后,用 XPENDING 找回未确认消息重新处理。


优缺点与局限性

String

  • 适用:简单 KV 缓存、原子计数、分布式锁、Session 共享
  • 局限:存储对象时,修改单个字段需要反序列化整个 JSON → 频繁更新部分字段时应换用 Hash

List

  • 适用:简单消息队列、按时间排序的列表
  • 局限:①不自动生成消息 ID,需自行实现;②消息被消费即删除,不支持多消费者重复消费;③不支持消费组

Hash

  • 适用:对象属性频繁局部更新、购物车
  • 局限:无法对整个 Hash 设置过期(只能对顶层 key 设置),不支持 field 级别过期

Set

  • 适用:去重、集合运算(交/并/差)、随机抽取
  • 踩坑:交/并/差运算复杂度高,数据量大时在主库执行会导致阻塞 → 应在从库或客户端完成聚合计算

Zset

  • 适用:排行榜、按权重分页、字典序范围查询
  • 踩坑:ZRANGEBYLEX / ZREVRANGEBYLEX 仅在所有元素 score 相同时结果准确,score 不一致会导致漏取元素
  • 局限:不支持差集运算(Set 支持)

BitMap

  • 适用:海量用户的二值状态统计(签到、在线状态、连续签到)
  • 局限:BITCOUNT 的 start/end 参数单位是字节而非 bit,精确到 bit 级别的范围统计需要额外计算

HyperLogLog

  • 适用:海量数据基数统计(UV),内存固定 12KB
  • 局限:有 0.81% 误差率,不保存原始数据 → 需要精确统计或需要知道具体元素时,必须用 Set/Hash

GEO

  • 适用:LBS 场景(附近的人/车/店)
  • 本质是 Zset → 受 Zset 的内存和性能特性约束

Stream

  • 适用:功能完善的消息队列,支持消费组、ACK、自动 ID
  • 核心局限(与 Kafka/RabbitMQ 的差距):
    • 可能丢消息: AOF 异步写盘、主从复制异步 → Redis 宕机或主从切换时,中间件层面会丢数据
    • 堆积风险大: 数据在内存中,积压会导致 OOM;即使设置最大队列长度也意味着旧消息被删
  • 选型判断:简单业务 + 容忍少量丢失 → Stream;海量消息 + 强可靠 → Kafka/RabbitMQ

发布/订阅模式(补充说明,非独立数据类型)

不推荐做消息队列,三大致命缺陷:

  • 无持久化(不写 RDB/AOF),重启即丢
  • 离线重连后无法消费历史消息
  • 消费端积压超过阈值(32MB 或持续 8MB 超 60 秒)会被强制断开

仅适合即时通信场景(如 Redis 哨兵集群间的通信)。


底层数据结构演进速查

类型Redis 3.0 以前Redis 3.2+ / 7.0+
List压缩列表 / 双向链表quicklist
Hash压缩列表 / 哈希表listpack / 哈希表(7.0)
Zset压缩列表 / 跳表listpack / 跳表(7.0)

核心趋势:压缩列表(ziplist)在 Redis 7.0 中被废弃,全面由 listpack 替代。


行动清单

  1. 动手实验:在本地 Redis 或 Redis 在线环境 逐一敲完上述每个案例的命令,亲眼看到返回值,建立肌肉记忆。
  2. 深入底层结构:重点学习 SDS、quicklist、skiplist、listpack 的源码实现。推荐阅读《Redis 设计与实现》中对应章节,以及 Redis GitHub 最新源码(注意书中是 Redis 3.0,部分结构已演进)。
  3. 分布式锁实战:用 SET NX PX + Lua 脚本实现一个完整的分布式锁 demo,然后研究 Redisson 框架的看门狗(watchdog)机制是如何解决锁续期问题的。
  4. 消息队列对比测试:分别用 List、Stream、RabbitMQ 实现同一个消息队列场景,对比三者在吞吐量、消息丢失率、消费组能力上的差异,形成自己的选型判断。
  5. HyperLogLog 误差验证:写一个脚本插入 100 万个唯一元素到 HyperLogLog,用 PFCOUNT 读取结果与实际值对比,实际感受 0.81% 误差率。
  6. BitMap 业务落地:在下一个有签到 / 打卡 / 在线状态需求的项目中尝试用 BitMap 替代传统数据库方案,对比内存开销和查询性能。

Redis 底层数据结构


一句话摘要

Redis 高性能的核心之一在于其底层数据结构的精心设计。本文系统梳理 SDS、链表、压缩列表、哈希表、跳表、整数集合、quicklist、listpack 共 9 种底层结构,解释它们如何支撑 String、List、Hash、Set、Zset 五种对象类型,以及各版本迭代中的演进原因。


核心知识点

1. Redis 键值对的存储全景

Redis 用一张全局哈希表保存所有 key-value,查找时间复杂度 O(1)。

分层结构如下:

  • redisDb → 持有 dict 指针
  • dict → 含两张哈希表 ht[0] / ht[1](用于 rehash)
  • dictht → 数组,每个元素是 dictEntry*
  • dictEntryvoid* key + void* value + next 指针
  • **redisObject****type**(对象类型)+ **encoding**(底层编码)+ **ptr**(指向实际数据结构)

示例命令及其对应对象类型:

SET name "xiaolincoding"                  # key 是 String 对象
HSET person name "xiaolincoding" age 18   # key 是 Hash 对象
RPUSH stu "xiaolin" "xiaomei"             # key 是 List 对象

2. SDS(简单动态字符串)

为何不用 C 原生 char*

  • strlen 时间复杂度 O(N)(需遍历到 \0
  • 不支持二进制数据(\0 会截断)
  • strcat 等函数不检查缓冲区,存在溢出风险

SDS 结构(Redis 5.0):

struct sdshdr {
    uint16_t len;        // 已使用长度,O(1) 获取长度
    uint16_t alloc;      // 分配的总空间
    unsigned char flags; // SDS 类型(sdshdr8/16/32/64)
    char buf[];          // 存储实际数据
};

关键机制:

  • 获取长度:直接读 **len**,O(1)
  • **二进制安全:**用 len 而非 \0 判断结尾,buf[] 可存任意字节
  • 自动扩容规则(核心代码):
// 空间足够时,直接使用未使用空间,不进行扩容操作

if (newlen < HI_SDS_MAX_PREALLOC)
    newlen *= 2;                    // 新长度 < 1MB,翻倍扩容
else
    newlen += HI_SDS_MAX_PREALLOC;  // 超过 1MB,每次 +1MB
  • 节省内存:sdshdr5/8/16/32/64 适配不同长度字符串;使用 __attribute__ ((packed)) 禁止编译器字节对齐,按实际大小分配内存(如 char + int 结构体是 5 字节而非默认的 8 字节)


3. 链表(双向链表)

节点结构:

typedef struct listNode {
    struct listNode *prev;
    struct listNode *next;
    void *value;
} listNode;

封装的 list 结构:

typedef struct list {
    listNode *head;                      // 链表尾节点
    listNode *tail;                      // 链表头节点
    unsigned long len;                   // 链表节点数量,O(1) 获取长度
    void *(*dup)(void *ptr);             // 节点值复制函数
    void (*free)(void *ptr);             // 节点值释放函数
    int (*match)(void *ptr, void *key);  // 节点值比较函数
} list;

特性: 无环双向链表,O(1) 访问头尾节点、获取长度void* 支持任意类型值。

缺陷: 内存不连续,无法利用 CPU 缓存;每个节点都需分配额外的节点头,内存开销大。


4. 压缩列表(ziplist)

设计目标: 内存紧凑,连续内存块,类似数组,节省内存、利用 CPU 缓存。

整体结构:

字段说明
zlbytes整个压缩列表占用字节数
zltail尾节点距起始地址的偏移量
zllen节点数量
entry...各个节点
zlend结束标识,固定 0xFF

节点结构(entry):

  • prevlen:前一个节点的长度(前驱 < 254 字节用 1 字节存,否则用 5 字节存)
  • encoding:当前节点的类型和数据长度(整数用 1 字节,字符串用 1/2/5 字节)
  • data:实际数据

连锁更新问题: 若在头部插入一个 ≥254 字节的节点,后续所有 prevlen 在 250~253 字节范围内的节点都需要从 1 字节扩展为 5 字节,引发多米诺效应,导致多次内存重分配,性能下降。


5. 哈希表

哈希表是一个数组(dictEntry **table),数组的每个元素是一个指向「哈希表节点(dictEntry)」的指针。

typedef struct dictEntry {
    void *key;
    union { void *val; uint64_t u64; int64_t s64; double d; } v; // 联合体节省内存
    struct dictEntry *next;
} dictEntry;

哈希冲突解决方式:链式哈希**dictEntry**** 通过 **next** 指针串联同一桶内的多个 key。**

rehash 机制(扩容):

  1. ht[1] 分配空间(通常是 ht[0] 的 2 倍)
  2. ht[0] 数据迁移到 ht[1]
  3. 释放 ht[0],将 ht[1] 设为 ht[0],新建空的 ht[1] 备用

渐进式 rehash(避免阻塞): 不一次性迁移,而是每次执行增删查改操作时,顺带将 ht[0] 中该索引位的 key-value 迁移到 ht[1],迁移期间:

  • 查找:先查 ht[0],找不到再查 ht[1]
  • 新增:只写入 ht[1],保证 ht[0] 只减不增

rehash 触发条件(负载因子 = used / size):

  • 负载因子 ≥ 1 且未执行 bgsave / bgrewriteaof 时触发
  • 负载因子 ≥ 5 时,强制触发(不管是否有持久化操作)

6. 整数集合(intset)

适用场景: Set 对象元素全为整数且数量不多时使用。

typedef struct intset {
    uint32_t encoding;  // INTSET_ENC_INT16 / INT32 / INT64
    uint32_t length;    // 元素数量
    int8_t contents[];  // 实际类型由 encoding 决定
} intset;

升级操作: 插入一个比当前所有元素类型更大的整数时(如现有 int16_t,插入需要 int32_t 表示的 65535),在原数组上扩容,将所有元素转换为新类型并保持有序。

  • 好处: 按需使用最小类型,节省内存;无需一开始就全用 int64_t
  • 不支持降级: 一旦升级为 int32_t,删除大元素后仍维持 int32_t,不会降回 int16_t


7. 跳表(skiplist)

适用场景: Zset 的底层实现之一,支持 O(log N) 范围查询。

Zset 同时维护跳表 + 哈希表

typedef struct zset {
    dict *dict;       // 哈希表:O(1) 获取元素权重(ZSCORE)
    zskiplist *zsl;   // 跳表:O(logN) 范围查询(ZRANGEBYSCORE)
} zset;

跳表节点结构:

typedef struct zskiplistNode {
    sds ele;                           // 元素值
    double score;                      // 权重
    struct zskiplistNode *backward;    // 后向指针(支持倒序遍历)
    struct zskiplistLevel {
        struct zskiplistNode *forward; // 前向指针
        unsigned long span;            // 跨度(用于计算排名)
    } level[];
} zskiplistNode;

层数设置(随机算法): 跳表的相邻两层的节点数量最理想的比例是 2:1,查找复杂度可以降低到 O(logN)。**创建节点时生成 [0,1] 随机数,< 0.25 则层数 +1,重复直到 ≥ 0.25 为止。**每层概率不超过 25%,层高上限:Redis 3.0/7.0 为 32,Redis 5.0 为 64。

为什么用跳表而非平衡树(AVL/红黑树):

  • 内存:跳表平均每节点 1.33 个指针(p=1/4 时),少于平衡树的 2 个
  • 范围查询:跳表找到最小值后直接在 L0 层顺序遍历,平衡树需中序遍历实现复杂
  • 实现复杂度:跳表插入删除只需修改相邻指针,平衡树需旋转调整

8. quicklist

引入版本: Redis 3.2,替代 List 对象底层的双向链表 + 压缩列表。

本质: 双向链表 + 压缩列表的组合,链表的每个节点是一个压缩列表。

typedef struct quicklistNode {
    struct quicklistNode *prev;
    struct quicklistNode *next;
    unsigned char *zl;       // 指向压缩列表
    unsigned int sz;         // 压缩列表字节大小
    unsigned int count : 16; // 压缩列表元素个数
} quicklistNode;

核心策略: 控制每个 quicklistNode 中压缩列表的大小/元素数,将连锁更新的范围限制在单个节点内,减轻影响。但压缩列表本身的问题(连锁更新)并未根本解决。


9. listpack

引入版本: Redis 5.0,最终在最新版本中全面替代压缩列表。

根本改进: 节点不再存储前一个节点的长度(prevlen),只存当前节点的长度(len),彻底消除连锁更新的根源。

节点结构:

  • encoding:元素编码类型
  • data:实际数据
  • len:encoding + data 的总长度

从后向前遍历的实现: 通过 lpDecodeBacklen 函数,从当前节点起始位置向左逐字节解析,获取前一节点的 entry-len,无需存储 prevlen 字段也能反向遍历。


版本演进对照

数据类型Redis 3.0Redis 3.2+Redis 5.0+最新版本
List双向链表 / 压缩列表quicklistquicklistquicklist
Hash压缩列表 / 哈希表压缩列表 / 哈希表压缩列表 / 哈希表listpack / 哈希表
Zset压缩列表 / 跳表压缩列表 / 跳表压缩列表 / 跳表listpack / 跳表
Set整数集合 / 哈希表整数集合 / 哈希表整数集合 / 哈希表整数集合 / 哈希表

优缺点与局限性

SDS

  • 适用于所有字符串场景,全面优于 C 字符串
  • 踩坑点:SDS 5 种类型的自动选择是内部行为,业务层无感知;但存储超大字符串时注意内存翻倍扩容可能造成瞬间内存峰值

压缩列表

  • 仅适用于少量、小尺寸元素(Redis 默认 Hash/Zset 元素 ≤ 128 个、单个元素 ≤ 64 字节时使用)
  • 连锁更新是核心缺陷,元素增多后性能急剧下降,已在新版中被 listpack 替代

哈希表(渐进式 rehash)

  • 渐进式 rehash 期间内存同时存在两张表,内存使用量翻倍
  • rehash 期间写操作只进 ht[1],查询需扫描两张表,有轻微性能损耗
  • 高并发写时,bgsave / bgrewriteaof 会推迟 rehash(负载因子阈值从 1 提高到 5),可能导致哈希桶链表变长,查询退化为 O(N)

跳表

  • 层数随机,极端情况下性能退化(但概率极低)
  • 相比平衡树,内存使用更灵活,范围查询更简单,实现更易维护
  • 头节点始终创建最大层数(64 层),固定有一定内存开销

整数集合

  • 只适用于纯整数 Set,混入字符串会立即转为哈希表
  • 升级后不支持降级,若存入了一个大整数后删除,已扩展的内存不会回收

quicklist

  • 通过限制每个压缩列表节点的大小来控制连锁更新的范围,但未根本解决,生产中建议使用更新版本(listpack)

行动清单

  1. 动手实验编码转换边界:OBJECT ENCODING 命令验证不同数据量下 Hash、Zset、Set 的编码切换时机(如 HSET 128 个字段前后的 encoding 变化)
  2. 阅读关键源码: 优先看 sds.c(SDS 扩容逻辑)、dict.c(渐进式 rehash 实现)、t_zset.c(跳表查询逻辑),配合本文的数据结构定义阅读效果最佳
  3. 排查 rehash 风险: 生产环境中执行 INFO stats 查看 loading_eta_secondsrdb_changes_since_last_save,评估 bgsave 期间的哈希冲突风险
  4. 验证 SDS 扩容: 构造一个接近 1MB 的字符串,通过 APPEND 命令触发扩容,用 MEMORY USAGE key 观察内存变化,验证 1MB 阈值前后的扩容策略差异
  5. 理解 listpack 意义: 升级 Redis 到 7.0+,确认 Hash 和 Zset 的小数据编码已从 ziplist 变为 listpackOBJECT ENCODING 返回值会不同),这对连锁更新问题有根本性改善
  6. 深入跳表面试题: 准备”为什么 Zset 用跳表不用红黑树”的完整回答,需涵盖内存、范围查询、实现复杂度三个维度,对照 antirez 原文理解设计哲学

Redis 跳跃表(Skip List)


一句话摘要

有序链表查询是 O(n) 的瓶颈,跳跃表通过多级索引将查询降至 O(log n),Redis 用它作为有序集合(ZSet)的底层实现,以替代平衡树。


核心知识点

1. 问题背景:普通链表的局限

链表各操作时间复杂度:

操作链表数组
查找(lookup)O(n)O(1)
插入/删除O(1)O(n)
头部插入(prepend)O(1)O(n)(特殊优化可达 O(1))

核心矛盾:当链表元素有序时,无法像数组那样做二分查找,查询只能逐个遍历,复杂度 O(n)。


2. 跳跃表的核心思路:升维加速

原理:一维结构加速 → 升为二维,增加索引层。

构建过程(以有序链表 1→3→4→5→7→8→9→10 为例):

  • 原始链表:查询复杂度 O(n)
  • 加第一级索引:每隔一个节点抽一个,索引指向 next.next,步长变为 2
  • 加第二级索引:在一级索引基础上再抽,步长变为 4
  • 加多级索引:每级步长翻倍,最终形成对数级查找

查找示例(查 8):

  1. 二级索引:1 → 7(7 < 8,继续)→ 下一个 > 8,下沉
  2. 一级索引:7 → 9(9 > 8,下沉)
  3. 原始链表:7 → 8,找到


3. 时间复杂度推导

  • 第 k 级索引节点数 = n / 2^k
  • 设最高级索引节点数为 2,则:n / 2^h = 2,解得 h = log₂n - 1
  • 每层最多遍历 3 个节点
  • 总时间复杂度:O(log n)

直观对比:n = 1024 时,普通链表需查 1024 次,跳跃表只需 10 次(log₂1024 = 10)。


4. 空间复杂度分析

每 2 个节点抽 1 个做索引,各层节点数为:

n/2, n/4, n/8, ..., 8, 4, 2

等比级数求和约为 n,空间复杂度 O(n)

每 3 个节点抽 1 个时各层为:n/3, n/9, n/27, ..., 9, 3, 1,空间更省,仍为 O(n)。


5. Redis 跳跃表的数据结构实现

Redis 用两个结构体定义跳跃表:

zskiplistNode(节点)

typedef struct zskiplistNode {
    struct zskiplistNode *backward;  // 后退指针(BW)
    double score;                    // 分值(排序依据)
    robj *obj;                       // 成员对象(SDS 字符串)
    struct zskiplistLevel {
        struct zskiplistNode *forward;  // 前进指针
        unsigned int span;              // 跨度(用于计算排位 rank)
    } level[];                          // 层数组,高度 1~32 随机生成
} zskiplistNode;

zskiplist(表结构)

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;  // 表头、表尾指针
    unsigned long length;                  // 节点总数
    int level;                             // 当前最大层数
} zskiplist;

各字段作用速查

字段位置作用访问复杂度
header / tailzskiplist定位首尾节点O(1)
lengthzskiplist返回链表长度O(1)
levelzskiplist获取最大层数O(1)
forwardlevel向表尾方向遍历
spanlevel累加得到节点排位
backwardnode向表头方向逐步回退
scorenode节点排序键(double)
objnode存储成员对象(唯一)

注意点

  • 层高由幂次定律随机生成(1~32 之间),大数出现概率更小
  • 同一跳跃表中,score 可以相同,但 obj 必须唯一
  • score 相同时,按 obj 的字典序排序(小的靠近表头)
  • 后退指针每次只能退一个节点,不能跨越

跨度(span)的实际用途:不用于遍历,用于计算 rank(排位)。查找过程中将沿途所有层的 span 累加,即得目标节点的排位。


6. 核心 API 一览

函数作用时间复杂度
zslCreate创建跳跃表O(1)
zslFree释放跳跃表及所有节点O(N)
zslInsert插入节点平均 O(log N),最坏 O(N)
zslDelete删除节点平均 O(log N),最坏 O(N)
zslGetRank查询节点排位平均 O(log N),最坏 O(N)
zslGetElementByRank按排位取节点平均 O(log N),最坏 O(N)
zslIsInRange判断分值范围是否有交集O(1)
zslFirstInRange / zslLastInRange取范围内首/末节点平均 O(log N)
zslDeleteRangeByScore按分值范围批量删除O(N),N 为删除数量
zslDeleteRangeByRank按排位范围批量删除O(N),N 为删除数量

7. 跳跃表 vs 红黑树(高频面试题)

Redis 选择跳跃表而非红黑树,原因有三:

实现复杂度:跳跃表原理简单,实现和调试更容易。例如,通过少量改动即可支持 ZRANK 的 O(log N) 实现,作者已收到社区 patch。

并发性能:红黑树插入/删除时可能触发 rebalance,操作会涉及整棵树;跳跃表的修改更局部,锁粒度更小,并发场景性能更好(参考 Herb Sutter 的 Choose Concurrency-Friendly Data Structures)。

内存效率:通过调整节点晋升概率参数,跳跃表的内存占用可以做得比 B 树更少。

遍历友好:ZSet 中 ZRANGE / ZREVRANGE 操作频繁,跳跃表本质是链表,顺序遍历的缓存局部性不弱于平衡树。


优缺点与局限性

适用场景

  • 元素有序的集合(必要前提)
  • 需要频繁范围查询(ZRANGEBYSCORE
  • 需要快速获取排名(ZRANK
  • 并发写入较多的场景

局限与踩坑点

  • 只能用于有序元素,无序场景无意义
  • 增删维护成本高:每次插入/删除都需要更新索引层,实际形态不如理想整齐,层数分布不均匀
  • 空间换时间:索引层额外占用 O(n) 空间
  • 层高随机性:最坏情况(极端随机)复杂度退化到 O(N),不像红黑树有严格的最坏保证
  • 后退指针限制backward 每次只能退一步,逆向遍历效率低于前向

行动清单

  1. 手写跳跃表:用 C 或 Java 实现 insertsearchdelete 三个核心操作,重点理解层高随机生成逻辑(幂次定律)
  2. 阅读 Redis 源码:在 Redis 源码中找到 t_zset.cserver.h(旧版为 redis.h),对照 zskiplistNode / zskiplist 结构体与本文描述
  3. 验证 span 计算:在 Redis 实例中执行 ZADDZRANK 命令,通过 DEBUG OBJECT 观察节点信息,理解 span 累加算排位的机制
  4. 对比实验:阅读 Herb Sutter 的 Choose Concurrency-Friendly Data Structures,理解跳跃表在并发场景相对红黑树的优势
  5. LevelDB 扩展:LevelDB 的 MemTable 也使用跳跃表,阅读其实现与 Redis 版本做横向对比,理解不同工程场景下的取舍
  6. 面试准备:能口述”为什么 Redis 用跳跃表而不是红黑树”的三点核心理由(简单、并发局部性好、遍历友好)

Redis 高性能 IO 模型


一句话摘要

Redis 用单线程处理网络 IO 和数据读写,规避了多线程并发控制的复杂性;配合内存操作、高效数据结构(哈希表/跳表)和 **epoll**** 多路复用机制**,实现了单线程下每秒数十万级的处理能力。


核心知识点

1. “单线程”的准确含义

Redis 的网络 IO + 键值对读写由单一线程处理,这是对外提供服务的主流程。
持久化(RDB/AOF)、异步删除(**UNLINK**)、集群数据同步等功能由额外线程承担。
因此 Redis 并非严格意义上的单线程进程,“单线程”特指核心请求处理路径。


2. 为什么选择单线程:多线程的代价

共享资源的并发控制是核心瓶颈。

典型案例:假设两个线程 A、B 同时对同一个 List 操作:

  • 线程 A 执行 LPUSH,队列长度 +1
  • 线程 B 执行 LPOP,队列长度 -1

若不加锁,队列长度计数将出错。加互斥锁后,并行退化为串行,吞吐率不升反降。

多线程的实际代价:

  • 粗粒度互斥锁 → 大量线程排队等锁,吞吐率随线程数增加出现”先升后降”曲线
  • 引入同步原语(mutex/semaphore)→ 代码可维护性和可调试性下降
  • 线程切换本身有 CPU 上下文开销

结论: Redis 直接用单线程,彻底消除并发控制问题。


3. 单线程高性能的两大支柱

支柱一:内存操作 + 高效数据结构

  • 所有读写发生在内存,无磁盘 IO 瓶颈
  • 哈希表(O(1) 查找)、跳表(O(log N) 有序操作)等结构保证单次操作极快

支柱二:IO 多路复用(epoll 机制)

  • 允许单线程同时监听并处理多个** FD(文件描述符/套接字)**上的 IO 事件
  • 核心是事件驱动 + 回调,而非轮询

4. 基本 IO 模型与阻塞点

一次 GET 请求的完整链路:

bind/listen → accept() → recv() → parse() → get() → send()

两个潜在阻塞点:

  • accept():等待客户端建立连接,若连接迟迟未建立 → 线程阻塞
  • recv():等待客户端发送数据,若数据未到达 → 线程阻塞

单线程一旦阻塞在这两处,其他所有客户端请求全部挂起。


5. 非阻塞 Socket 模式

Socket 有三种套接字类型,对应三个关键调用:

调用返回套接字类型作用
socket()主动套接字创建套接字
listen()监听套接字监听客户端连接请求
accept()已连接套接字接受具体客户端连接

监听套接字已连接套接字均可设置非阻塞模式(与 Go Hand Off 移交机制的异同?):

  • accept() 无连接到达时 → 线程立即返回,继续处理其他任务(不再死等)
  • recv() 无数据到达时 → 线程立即返回,继续处理其他任务

非阻塞只解决了”不死等”的问题,但仍需要一个机制来通知线程”什么时候有事件来了”——这就是多路复用的职责。


6. IO 多路复用:epoll 机制

核心原理: 一个线程同时监听多个 IO 流,基于事件回调驱动处理。

工作流程:

Linux内核
  └── 同时监听多个 FD(监听套接字 + 已连接套接字)
        ↓ 有事件到达
  触发对应事件 → 放入事件队列

Redis单线程 循环消费事件队列
  └── Accept事件 → 调用 accept 回调函数(建立连接)
  └── Read事件   → 调用 get 回调函数(读取数据)

关键优势:

  • Redis 线程不阻塞在任意单个 FD 上
  • 无需轮询所有 FD,内核主动通知 → 避免 CPU 空转浪费
  • 事件队列串行消费,天然无并发冲突

跨平台实现:

  • Linux → select / epoll
  • FreeBSD → kqueue
  • Solaris → evport

Redis 根据实际运行的 OS 选择对应实现。


优缺点与局限性

单线程模型

适用场景: 操作均为内存级、单次耗时极短的键值读写。

限制条件:

  • 执行复杂度高的命令(如 KEYS *、对大列表执行 LRANGE 0 -1)会阻塞整个服务,其他请求全部排队
  • CPU 密集型场景下,单核利用率上限制约整体吞吐量
  • 单线程无法利用多核并行,垂直扩展能力受限

踩坑点:

  • 误以为 Redis 是”完全单线程”进程,忽略持久化线程对 CPU/内存的消耗
  • 在高并发场景下执行慢查询(O(N) 命令),导致 Redis 响应延迟突增

epoll 多路复用

适用场景: 大量并发连接、每个连接数据量小、IO 等待时间长。

限制条件:

  • 事件队列串行处理,某个回调函数执行时间过长仍会影响后续事件响应
  • 网络 IO 本身不是瓶颈时(如纯内存批量计算),多路复用收益有限

踩坑点:

  • parse()send() 步骤同样在单线程内执行,复杂协议解析或大数据包发送也是潜在性能瓶颈(这是原文”每课一问”的答案方向)

行动清单

  1. 验证阻塞命令的影响:在测试环境执行 KEYS *(大数据量时),用 redis-cli --latency 观察其他请求的延迟变化,直观感受单线程阻塞效果。
  2. 学习 epoll 编程模型:用 C 或 Python(selectors 模块)写一个基于 epoll 的 echo server,加深对事件循环和回调机制的理解。
  3. 阅读 Redis 源码入口:重点看 ae.c(事件循环)和 ae_epoll.c(epoll 封装),对照本文的 epoll 流程图理解代码结构。
  4. 了解 Redis 6.0 多线程网络 IO:Redis 6.0 将网络 IO 的读写拆分给多线程处理,但命令执行本身仍是单线程——搞清楚这个边界,对比本文的单线程模型理解其演进逻辑(对应专栏第 39 讲)。
  5. 生产配置实践:排查线上 Redis 是否有慢查询,执行 SLOWLOG GET 10 查看最近 10 条慢命令,结合单线程模型评估影响。

Redis 线程模型


一句话摘要

Redis 进程不是单线程,“单线程”特指主线程处理网络 I/O + 命令执行这条核心链路;Redis 6.0 后网络 I/O 也已多线程化,但命令执行仍保持单线程。


核心知识点

1. Redis 的线程架构全貌

“Redis 是单线程”这个说法不准确,准确说法是:核心请求处理链路是单线程

核心链路(主线程承担):

接收客户端请求 → 解析请求 → 数据读写操作 → 返回数据给客户端

后台线程(BIO,Background I/O)随版本演进:

版本新增后台线程负责任务
2.62 个 BIO 线程关闭文件、AOF 刷盘
4.0+新增 lazyfree 线程异步释放内存

后台线程是生产者-消费者模型:主线程把耗时任务投入任务队列,BIO 线程轮询队列消费。

关闭文件、AOF 刷盘、释放内存三个独立任务队列:

  • BIO_CLOSE_FILE:后台线程调用 close(fd) 关闭文件
  • BIO_AOF_FSYNC:AOF 配置为 everysec 时,主线程封装写日志任务入队,后台线程调用 fsync(fd) 刷盘
  • BIO_LAZY_FREE:后台线程调用 free(obj) / free(dict) / free(skiplist) 释放内存

null


2. 单线程事件循环模型(Redis 6.0 之前)

Redis 初始化阶段:

  1. 调用 epoll_create() 创建 epoll 对象
  2. 调用 socket() 创建服务端 socket
  3. 调用 bind() 绑定端口,listen() 监听
  4. 调用 epoll_ctl() 将 listen socket 加入 epoll,注册连接事件处理函数

主线程事件循环处理三类事件:

连接事件: accept() 获取已连接 socket → epoll_ctl() 将其加入 epoll → 注册读事件处理函数

读事件: read() 获取客户端数据 → 解析命令 → 执行命令 → 将客户端加入发送队列 → 将结果写入发送缓冲区

写事件: write() 将发送缓冲区数据发出 → 若未发送完则继续注册写事件,等待下轮 epoll_wait 触发


3. 单线程为何还快

Redis 官方基准测试:单线程吞吐量可达 10W QPS

三个核心原因:

内存操作 + 高效数据结构: 绝大多数操作在内存中完成,瓶颈是内存容量和网络带宽,不是 CPU,单线程足够。

避免多线程开销: 无线程竞争、无上下文切换开销、无死锁问题。

I/O 多路复用: 基于 select/epoll 机制,单线程同时监听多个 socket(监听 socket + 已连接 socket),内核有事件时统一通知 Redis 线程处理,实现”一个线程处理多路 I/O 流”。


4. Redis 6.0 之前坚持单线程的理由

官方 FAQ 核心观点:CPU 不是性能瓶颈,内存大小和网络 I/O 才是限制因素。若需充分利用多核 CPU,推荐在同一台机器上启动多个 Redis 节点使用分片集群

工程侧理由:单线程可维护性高,多线程引入执行顺序不确定性、并发读写问题、加锁/解锁开销和死锁风险,系统复杂度显著上升。


5. Redis 6.0 引入多线程 I/O

背景: 随着网络硬件性能提升,网络 I/O 处理成为新瓶颈。

策略: 仅网络 I/O 多线程化,命令执行仍为单线程。官方数据:多线程 I/O 性能提升至少一倍

默认行为: 多线程只处理写操作(write client socket),读操作默认仍为单线程。

开启读请求多线程处理(redis.conf):

io-threads-do-reads yes

配置 I/O 线程数(N-1 个 I/O 多线程,主线程算一个):

io-threads 4

官方线程数建议:

  • 4 核 CPU → 设为 23
  • 8 核 CPU → 设为 6
  • 线程数必须小于机器核数,不是越大越好

Redis 6.0 启动后默认 6 个线程:

线程名职责
redis-server主线程,执行命令
bio_close_file异步关闭文件
bio_aof_fsync异步 AOF 刷盘
bio_lazy_free异步释放内存
io_thd_1/2/33 个 I/O 线程(io-threads 4 → 4-1=3)

优缺点与局限性

单线程模型的优势: 实现简单、无并发问题、调试容易、延迟稳定可预期。

单线程模型的限制: 无法利用多核 CPU(需靠多节点/分片弥补);单个慢命令(如大 key 的 del)会阻塞整个服务。

踩坑点1 — 删除大 key 用错命令:

  • 错误做法:del bigkey — 在主线程同步执行,会造成主线程阻塞
  • 正确做法:unlink bigkey — 将删除操作交给 lazyfree 后台线程异步执行

踩坑点2 — 多线程 I/O 配置误解: Redis 6.0 的多线程仅针对网络 I/O,命令依然串行执行,不存在命令级别的并发竞争问题,不需要客户端做额外的并发保护。

踩坑点3 — io-threads 设置过大: 线程数超过核数会因线程切换开销导致性能下降,务必遵循官方建议(线程数 < 核数)。


行动清单

  1. 立即实践: 在测试环境用 unlink 替代 del 删除大 key,用 debug sleep 模拟主线程阻塞,对比两种命令的影响
  2. 配置实验: 在 Redis 6.0+ 环境下修改 redis.conf,开启 io-threads-do-reads yes 并配置 io-threads,用 redis-benchmark 对比开启前后吞吐量
  3. 深入 epoll: 补充 Linux I/O 多路复用知识,理解 select vs poll vs epoll 的差异,重点掌握 epoll 的边缘触发(ET)和水平触发(LT)
  4. 源码验证: 查看 Redis 源码中 bio.c(后台线程实现)和 ae.c(事件循环实现),对照本文流程加深理解
  5. 扩展场景: 研究 Redis 分片集群(Redis Cluster)如何在单机多核场景下替代多线程,理解 Hash Slot 分配机制
  6. 面试准备: 能完整回答”Redis 单线程为何快”和”6.0 多线程改了什么、没改什么”这两道高频题,注意区分”网络 I/O 多线程”和”命令执行仍单线程”

Redis 单线程 VS 多线程


一句话摘要

Redis 并非一直是单线程,从 3.x 到 6.x/7.x 经历了明显的架构演进:命令执行始终单线程,网络 I/O 从 6.0 起改为多线程,核心目的是解决网络带宽成为瓶颈后的吞吐量瓶颈问题。


核心知识点

1. Redis 各版本线程模型演进

版本线程模型
3.x严格单线程(网络 IO + 命令执行均单线程)
4.x命令执行单线程,后台新增异步删除线程
6.0 / 7.0网络 IO 多线程化,命令执行仍单线程

结论: Redis 的”单线程”特指负责客户端请求的主线程(socket 读 → 解析 → 执行 → socket 写)持久化 RDB/AOF、异步删除、集群同步等始终由额外线程承担。


2. 单线程为什么快(3.x 时代)

四大原因:

  • 基于内存操作:所有数据在内存中,运算为内存级,延迟极低
  • 数据结构简单:专门设计的数据结构,大多数操作时间复杂度为 O(1)
  • IO 多路复用:用 epoll 监听多个 socket,单线程处理多连接,避免 IO 阻塞
  • 无上下文切换:单线程模型无锁竞争、无线程切换开销、不会死锁

官方定性(redis.io/docs/get-started/faq): Redis 的瓶颈是内存或网络带宽而非 CPU,因此单线程足够。


3. 单线程的问题 —— 大 Key 删除卡顿

场景复现:

del bigKey   # 同步删除,key 包含数万元素的 hash 时,主线程阻塞直到删除完成

主线程相当于加了 synchronized 锁,高并发下导致系统无响应。

Redis 4.x 的解决方案 —— 惰性删除(Lazy Free):

unlink key          # 异步删除单个 key,将实际删除交给后台 bio 子线程
flushdb async       # 异步清空当前库
flushall async      # 异步清空所有库

核心思想(antirez:“Lazy Redis is better Redis”): 把耗时高(主要是时间复杂度高)的删除操作从主线程剥离,交给 bio 子线程,极大减少主线程阻塞时间。


4. Redis 6/7 的多线程 IO 架构

背景: 随着网卡硬件性能提升,单线程处理网络 IO 的速度已跟不上底层网络硬件,网络 IO 成为新瓶颈。

设计原则: 多 IO 线程只负责网络读写,命令执行继续由单主线程串行处理(保持 Lua 脚本、事务原子性,无需引入锁机制)。

4 个协作阶段:

阶段执行者工作内容
阶段一主线程接收连接,创建 socket,通过轮询分配给 IO 线程
阶段二IO 线程(并行)读取并解析客户端请求,主线程进入阻塞等待
阶段三主线程(单线程)串行执行命令,与内存数据交互
阶段四IO 线程(并发) + 主线程IO 线程并发回写 socket,主线程清空全局队列

5. Unix 网络中的五种 IO 模型

  • Blocking IO(BIO):阻塞等待数据就绪
  • NonBlocking IO(NIO):轮询内核,数据未就绪立即返回
  • IO Multiplexing(IO 多路复用):单线程监听多个 FD,有事件才通知
  • Signal Driven IO:信号驱动,数据就绪时发信号
  • Asynchronous IO(AIO):全异步,内核完成拷贝后再通知

Redis 使用的是 IO 多路复用模型。


6. IO 多路复用详解

核心概念:

  • FD(File Descriptor):文件描述符,内核维护的进程打开文件记录表的索引,非负整数
  • 多路:多个客户端连接(即多个 socket/channel)
  • 复用:复用一个或少数几个线程

三个演化阶段:

机制特点限制
select()最早的多路复用有最大 FD 数量限制,大量 FD 时性能差
poll()改进 select,同时监视多个文件描述符,使用 pollfd 结构数组无 FD 数量限制,但大量连接时仍有性能问题
epoll()Linux 特有,epoll_ctl() 管理 FD,epoll_wait() 等待就绪事件发送性能最优,Redis 实际使用此机制
epoll 的工作方式:

第一步:把所有 fd 注册到 epoll,告诉内核"帮我盯着这些"
    epoll_ctl(epfd, EPOLL_CTL_ADD, fd, &event);

第二步:调用 epoll_wait(),在这里等
    n = epoll_wait(epfd, events, MAX_EVENTS, timeout);
    //  ↑ 这里会阻塞,但阻塞的是"等事件",不是"等某个fd"

第三步:epoll_wait 返回时,给你的是"已经就绪"的 fd 列表
    for (i = 0; i < n; i++) {
        read(events[i].fd);  // 此时读,一定有数据,不会阻塞
    }
内核在背后做的事:

  fd1 ──→ 没数据,继续监视
  fd2 ──→ 没数据,继续监视        内核红黑树
  fd3 ──→ 有数据了! ──────────→ 放入就绪链表
  fd4 ──→ 没数据,继续监视
  fd5 ──→ 有数据了! ──────────→ 放入就绪链表

                              epoll_wait() 返回 [fd3, fd5]

epoll 工作流程(以 Nginx/Redis 为例):
将 socket 对应的 FD 注册进 epoll → epoll 监听哪些 socket 有事件 → 有数据则通知对应应用处理 → socket 采用非阻塞模式(没有数据时立即返回,线程继续处理别的事务)


7. Reactor 模式

Redis 文件事件处理器的组成(4 部分):

  1. 多个套接字(socket)
  2. IO 多路复用程序(epoll 监听)
  3. 文件事件分派器(单线程消费队列——这是 Redis 被称为”单线程模型”的根本原因)
  4. 事件处理器(读事件/写事件处理器)

Reactor 两个核心组件:

  • Reactor:独立线程运行,负责监听和分发事件(类比”电话接线员”)
  • Handler:执行实际 IO 事件处理,执行非阻塞操作(类比”实际业务办理人”)

Reactor 模式内部:

  1. **Observer 模式:**fd 就绪 → 通知对应 Handler
  2. **Template Method 模式:**定义事件处理骨架,具体 Handler 实现细节
  3. **Factory 模式:**根据事件类型创建对应 Handler


优缺点与局限性

单线程模型(3.x)

项目内容
优点实现简单、无锁、无死锁、无上下文切换、易调试
适用场景小/中型并发量,value 体积不大
踩坑点大 Key(如含数万元素的 hash)执行 del阻塞主线程,影响所有请求
限制高并发下网络 IO 会成为瓶颈,单核 CPU 无法充分利用多核

惰性删除(4.x)

项目内容
优点解决大 Key 删除卡顿,主线程不阻塞
限制仅解决删除类操作,读写命令仍为单线程
注意unlink 不能完全替代 del,极个别场景行为有细微差异

多线程 IO(6.x/7.x)

项目内容
优点网络 IO 并行化,大幅提升高并发下的吞吐量
适用场景网络带宽成为瓶颈、大量并发连接的生产环境
限制命令执行仍单线程,CPU 多核利用率有上限
设计权衡保留单线程命令执行,以换取 Lua/事务原子性,无需引入互斥锁
注意多线程 IO 默认关闭,需手动开启配置(io-threads / io-threads-do-reads

行动清单

  1. 动手验证大 Key 问题:用 debug sleep 或构造大 hash,对比 delunlink 对主线程的阻塞影响,加深感性认识
  2. 掌握异步删除命令:在实际项目中,删除大 Key 时统一改用 unlink,并了解 lazyfree-lazy-evictionlazyfree-lazy-expire 等配置项
  3. 实验 epoll 机制:用 C 或 Python 写一个基于 epoll 的简单 echo server,理解 FD 注册、事件等待、回调处理的完整流程
  4. 开启 Redis 6 多线程并压测:修改 redis.conf
io-threads 4
io-threads-do-reads yes

redis-benchmark 对比开启前后的 QPS 差异

  1. 深入 Reactor 模式:阅读 Redis 源码中 ae.c(事件循环)和 networking.c(网络处理),对照 Reactor 四部分结构理解具体实现
  2. 拓展学习:将 Redis 的 IO 多路复用方案与 Netty(Java)、libuv(Node.js)的事件循环实现做横向对比,建立统一的高性能网络编程认知框架

Redis 过期删除策略 & 内存淘汰策略


一句话摘要

过期删除策略**(惰性删除 + 定期删除)负责清理已设置过期时间且到期的 key**;内存淘汰策略在运行内存超限时触发,按不同算法(LRU/LFU 等)主动驱逐 key——两者触发条件和作用对象均不同。


核心知识点

1. 过期时间的设置与查询

独立命令(4个):

expire <key> <n>             # n 秒后过期
pexpire <key> <n>            # n 毫秒后过期
expireat <key> <timestamp>   # Unix 时间戳(秒)后过期
pexpireat <key> <timestamp>  # Unix 时间戳(毫秒)后过期

写入时同步设置键值对过期时间(3个):

set <key> <value> ex <n>   # 写入并设秒级过期
set <key> <value> px <n>   # 写入并设毫秒级过期
setex <key> <n> <value>    # 同上(秒级)

查询 / 取消过期:

ttl <key>       # 查剩余存活秒数,返回 -1 表示永不过期
persist <key>   # 取消过期时间,key 变为永久存在

2. 过期判断机制:过期字典(expires dict)

数据结构:

typedef struct redisDb {
    dict *dict;    // 所有键值对
    dict *expires; // key → 过期时间戳(long long)
} redisDb;
  • 过期字典的 key 是指向键对象的指针;value 是 long long 类型的过期时间戳。
  • 查询复杂度 O(1)(哈希表实现)。
  • 判断逻辑:key 存在于 expires 字典中 当前时间 ≥ 过期时间戳 → 判定过期。


3. 三种过期删除策略对比

策略做法优点缺点
定时删除设置 key 时同步创建定时事件,时间到自动删除内存最友好,过期 key 立即释放大量过期 key 时占用大量 CPU
惰性删除访问 key 时才检查是否过期,过期则删除CPU 最友好,几乎不主动消耗资源未被访问的过期 key 长期占用内存
定期删除每隔一段时间随机抽取一批 key 检查并删除过期的折中方案,兼顾 CPU 和内存时间和频率难以调优

Redis 的选择:惰性删除 + 定期删除。


4. 惰性删除的实现

源码位置:db.cexpireIfNeeded() 函数。

int expireIfNeeded(redisDb *db, robj *key) {
    // 判断 key 是否过期
    if (!keyIsExpired(db, key)) return 0;
    ...
    /* 删除过期键 */
    ...
    // lazyfree_lazy_expire 为 1 表示异步删除,为 0 表示同步删除
    return server.lazyfree_lazy_expire ? dbAsyncDelete(db, key) : 
                                         dbSyncDelete(db, key);
}
  • 每次访问或修改 key 之前都会调用此函数。
  • lazyfree_lazy_expire 参数控制同步/异步删除,Redis 4.0 起支持。

5. 定期删除的实现

源码位置:expire.cactiveExpireCycle() 函数。

关键参数:

  • hz 10redis.conf 可配):默认每秒执行 10 次检查。
  • ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP = 20(硬编码):每轮随机抽取 20 个 key。

执行流程(伪代码):

do {
    // 已过期的数量
    expired = 0;
    // 随机抽取的数量
    num = 20;
    while (num--) {
        // 1. 从过期字典随机抽 1 个 key
        // 2. 若已过期则删除,expired++
    }
    if (timelimit_exit) return;  // 超时保护:单次不超过 25ms
} while (expired > 20/4);  // 过期占比 > 25% 则继续随机抽查
  • 超时保护:每轮定期删除最多执行 25ms,防止线程卡死。
  • 继续条件:本轮发现过期 key 数量 > 5(即 > 25%),则继续下一轮抽查。

6. 内存淘汰策略

触发条件: Redis 运行内存超过 maxmemory 设定值。

配置方式:

# 在 redis.conf 文件中设定最大运行内存
maxmemory <bytes>

默认值:

  • 64 位系统:0(无限制,内存耗尽会崩溃)
  • 32 位系统:3GB

8 种淘汰策略:

策略范围规则
noeviction全部不淘汰,写入直接报错(Redis 3.0+ 默认)
volatile-random有过期时间的 key随机淘汰
volatile-ttl有过期时间的 key优先淘汰过期时间最近的
volatile-lru有过期时间的 key淘汰最久未使用的(Redis 3.0 前默认)
volatile-lfu有过期时间的 key淘汰使用频率最低的(Redis 4.0+)
allkeys-random所有 key随机淘汰
allkeys-lru所有 key淘汰最久未使用的
allkeys-lfu所有 key淘汰使用频率最低的(Redis 4.0+)

查询和修改策略:

# 查询当前策略
config get maxmemory-policy

# 运行时修改(立即生效,重启失效)
config set maxmemory-policy allkeys-lru

# 持久化修改(需重启生效)
# 在 redis.conf 中设置:maxmemory-policy allkeys-lru

7. LRU vs LFU 算法

LRU(Least Recently Used,最近最少使用)

传统实现:双向链表,最新访问的移到链表头,淘汰时删链表尾。

Redis 近似 LRU 实现

  • 不维护全局链表,改为在 redisObject 中增加 24 bit 的 lru 字段记录最后访问时间戳
  • 淘汰时随机采样 5 个 key(可配置),淘汰其中 lru 最旧的。
  • 优点:节省内存、无链表移动开销。

LRU 的致命缺陷:缓存污染。 一次性批量读取的数据会长期占用缓存,而这些数据后续不再被访问。

LFU(Least Frequently Used,最近最不常用)

Redis 4.0 引入,解决 LRU 的缓存污染问题。

核心思想:过去访问次数多的数据,未来被访问的概率更高。

Redis LFU 实现细节:

同样复用 lru:24 字段,但分成两段:

高 16 bit → ldt(Last Decrement Time):上次访问时间戳
低  8 bit → logc(Logistic Counter):访问频次计数器

logc 的变化规则(每次访问时):

  1. 衰减距上次访问时间越长,衰减越大(解决历史高频但近期不用的问题)。
  2. 概率性增加:logc 越大,增加越难(对数增长,防止极高值)。

新 key 的 logc 初始值为 5(防止刚写入就被淘汰)。

调优参数(redis.conf):

lfu-decay-time 1    # 衰减速度(分钟单位),越大衰减越慢
lfu-log-factor 10   # 增长速度,越大增长越慢

优缺点与局限性

技术点适用场景踩坑点
惰性删除CPU 资源紧张时不访问的 key 永远不会被清理,可能大量内存泄漏
定期删除通用场景hz 设太高消耗 CPU,设太低清理不及时;每轮有 25ms 时间上限
noeviction数据不允许丢失的场景(如持久化队列)内存满时写操作直接报错,需业务层处理
allkeys-lru缓存场景(无法预知哪些 key 有过期时间)近似算法,采样量小(默认5)时精度有限
volatile-lru/lfu混合存储场景(有些 key 永久存储)没有设置过期时间的 key 不会被淘汰,可能导致关键数据挤不进来
LFU访问模式不均匀、有热点数据的场景logc 初始值为 5,新 key 短期内不易被淘汰,冷启动时可能淘汰逻辑不准确
64 位系统 maxmemory=0默认配置下无内存保护生产必须手动设置 maxmemory,否则 OOM 崩溃无任何保护机制

行动清单

  1. 立即检查生产配置: 确认 redis.conf 中已设置 maxmemory,并根据业务特性选择合适的 maxmemory-policy(缓存场景推荐 allkeys-lruallkeys-lfu)。
  2. 理解两者触发时机的区别: 过期删除策略(key 到期) vs 内存淘汰策略(内存超限),对照自己的业务判断哪个场景更容易触发。
  3. 动手实验 TTL 和 persist: 在本地 Redis 中执行 setexttlpersist 命令,直观感受 key 生命周期管理。
  4. 阅读定期删除源码: 找到 expire.c 中的 activeExpireCycle() 函数,对照伪代码理解 25% 阈值和 25ms 时间保护的实现细节。
  5. 对比 LRU 和 LFU 的实际效果: 模拟”一次性批量读取大量数据”的场景,对比两种策略下缓存命中率的差异,加深对缓存污染问题的理解。
  6. 调优 LFU 参数: 在测试环境修改 lfu-decay-timelfu-log-factor,观察 redis-cliobject freq <key> 返回的 logc 值变化。
  7. 延伸学习: 研究 lazyfree_lazy_expire 参数,理解 Redis 4.0 异步删除(Lazy Free)如何避免大 key 删除时的阻塞问题,与本篇惰性删除形成知识闭环。

Redis 数据淘汰算法


一句话摘要

Redis 内存耗尽时通过 8 种淘汰策略释放空间;核心实现是近似 LRU(随机采样而非全量排序),用 robj.lru 字段 + EvictionPoolLRU 样本池替代标准 LRU 链表,以极低的维护成本逼近最优淘汰效果。


核心知识点

1. 内存上限配置

Redis 所有数据存于内存,通过 maxmemory 限制最大用量。配置示例:

maxmemory 1G

内存使用超过 maxmemory 时触发淘汰策略。


2. 八种淘汰策略

通过 maxmemory_policy 配置,示例:

maxmemory_policy volatile-lru
策略范围规则
noeviction全部不淘汰,写入直接报错(Redis 3.0+ 默认)
volatile-random有过期时间的 key随机淘汰
volatile-ttl有过期时间的 key优先淘汰过期时间最近的
volatile-lru有过期时间的 key淘汰最久未使用的(Redis 3.0 前默认)
volatile-lfu有过期时间的 key淘汰使用频率最低的(Redis 4.0+)
allkeys-random所有 key随机淘汰
allkeys-lru所有 key淘汰最久未使用的
allkeys-lfu所有 key淘汰使用频率最低的(Redis 4.0+)

volatile-* 系列只对设置了过期时间的 key 生效;allkeys-* 系列对全量 key 生效。


3. LRU 算法原理

LRU(Least Recently Used):链表维护所有缓存,新建或被访问的对象移到链表头部;链表尾部即最久未访问,优先淘汰。

标准 LRU 需要维护全量 LRU 链表,成本高,Redis 改用近似实现。


4. Redis 的 LRU 实现:robj.lru 字段

每个缓存对象用 robjredisObject)结构体存储,其中 lru 字段记录最近访问时间:

typedef struct redisObject {
    ...
    unsigned lru;  // 记录最近访问时间戳
    ...
} robj;

缓存对象被访问时,由 lookupKey() 函数负责更新 lru 字段:

robj *lookupKey(redisDb *db, robj *key, int flags) {
    dictEntry *de = dictFind(db->dict, key->ptr);
    if (de) {
        robj *val = dictGetVal(de);
        // 更新访问时间
        if (server.rdb_child_pid == -1 && 
            server.aof_child_pid == -1 && 
            !(flags & LOOKUP_NOTOUCH)) {
            if (server.maxmemory_policy & MAXMEMORY_FLAG_LFU) {
                updateLFU(val);
            } else {
                val->lru = LRU_CLOCK();  // 更新 lru 字段的值
            }
        }
        return val;
    } else {
        return NULL;
    }
}

5. 淘汰触发机制:processCommandfreeMemoryIfNeeded

Redis 每处理一条命令前都会检查内存用量,调用链:

processCommand() → freeMemoryIfNeededAndSafe() → freeMemoryIfNeeded()

freeMemoryIfNeeded() 核心逻辑:

  1. 调用 getMaxmemoryState() 获取当前内存状态,返回 C_OK 表示未超限,直接返回;
  2. 若已超限且策略为 noeviction,返回 C_ERR,写操作报错;
  3. 否则进入 while (mem_freed < mem_tofree) 循环,持续淘汰直至释放足够内存;
  4. 找到目标 key 后调用 dbAsyncDeletedbSyncDelete 删除,同时同步到从节点并追加到 AOF 文件。

6. 近似 LRU 核心:样本池 EvictionPoolLRU

非随机策略(lru/lfu/ttl)使用样本采样代替全量遍历,通过 evictionPoolPopulate() 实现:

Step 1:随机采样

调用 dictGetSomeKeys() 从缓存中随机抽取 maxmemory_samples(默认 5)个样本。

**Step 2:计算排序权值 **idle

  • LRU 策略:调用 estimateObjectIdleTime() 得到对象的空闲时长,idle 越大说明越久未访问;
  • LFU 策略:调用 LFUDecrAndReturn() 得到使用频次,255 - 频次 作为 idle
  • TTL 策略:idle = ULLONG_MAX - 过期时间戳(过期时间越近,idle 越大)。

Step 3:插入样本池并保持有序

样本池 **EvictionPoolLRU** 是一个按 **idle** 升序排列的数组,尾部是 idle 最大的元素(最优淘汰候选)每次从尾部取第一个非空元素执行淘汰。

随机策略(volatile-random / allkeys-random)直接调用 dictGetRandomKey() 跳过以上流程。


优缺点与局限性

近似 LRU 的优势:不维护全量 LRU 链表,节省内存和 CPU;maxmemory_samples (样本量)越大越接近精确 LRU,但 CPU 消耗也越高,默认值 5 是工程折中。

近似 LRU 的局限:只从随机样本中选最优,存在误杀热点 key 的概率;样本量少时精度较差。

noeviction** 的踩坑点**:内存超限后所有写操作(SET、LPUSH 等)均返回错误,但读操作(GET)正常,业务需处理写失败异常。

volatile-*** 策略的踩坑点**:若**所有 key 都没有设置过期时间,策略退化为 ****noeviction** 行为,无法淘汰任何 key。

AOF 与从节点同步成本被淘汰的 key 会写入 AOF 并同步到从节点,高淘汰频率会增加 I/O 和网络压力。


行动清单

  1. 动手验证配置:在本地 Redis 实例中设置 maxmemory 100mbmaxmemory_policy allkeys-lru,写入超量数据观察淘汰行为;用 INFO stats 中的 evicted_keys 字段监控淘汰次数。
  2. **调优 **maxmemory_samples:测试 maxmemory_samples 从默认 5 调高到 10、20 时,命中率与 CPU 的变化(可用 redis-benchmark 配合 INFO 观察)。
  3. 阅读源码:重点阅读 eviction.c 中的 freeMemoryIfNeeded()evictionPoolPopulate() 函数,结合本文笔记对照代码逻辑。
  4. 策略选型实践:根据业务场景做决策——纯缓存场景优先选 allkeys-lru;带 TTL 的会话/Token 场景选 volatile-ttl;需要保护热点数据时选 allkeys-lfu(Redis 4.0+)。
  5. 对比标准 LRU 实现:用 Java/Go 手写一个基于 LinkedHashMap 的标准 LRU,与 Redis 近似 LRU 对比,深化对”全量链表 vs. 采样”设计取舍的理解。
  6. 延伸学习:研究 LFU 中的时间衰减机制(LFUDecrAndReturn),理解 Redis 如何用”时间窗口”避免历史高频 key 永远不被淘汰的问题。

Redis AOF 持久化


一句话摘要

AOF 通过追加写命令到日志文件实现 Redis 持久化,核心挑战是在数据安全性主进程性能之间取得平衡;写回策略、重写机制、后台子进程三套设计共同解决这一矛盾。


核心知识点

1. AOF 基本原理

每执行一条写操作命令,就将该命令以追加**(Append Only File)**方式写入 AOF 文件;读操作不记录。重启时顺序执行文件中的命令,完成数据恢复。

开启配置redis.conf):

appendonly yes

日志格式(RESP 协议)示例
执行 set name xiaolin 后,AOF 文件写入:

*3 # 命令包含 3 部分
$3 # 后面的字符长度为 3 字节
set
$4 # 后面的字符长度为 3 字节
name
$7 # 后面的字符长度为 3 字节
xiaolin
  • *3:命令共 3 个部分
  • $3:后面的字符串长度为 3 字节(即 set


2. 先执行命令,后写日志

Redis 先执行写命令,成功后才追加到 AOF 文件(与 WAL 日志相反)。

两个好处:

  • 只记录执行成功的合法命令,无需额外语法检查
  • 追加 AOF 文件的操作不会阻塞当前写命令的执行

两个风险:

  • 命令执行成功但还未写入日志时宕机 → 数据丢失
  • 日志写入硬盘时若磁盘 I/O 压力大 → 阻塞下一条命令(写日志在主进程

3. 三种写回策略(appendfsync

写入流程:执行写操作命令 → aof_buf 缓冲区 → write() → 内核 page cache → fsync() → 硬盘

策略行为数据安全性性能适用场景
Always每次写命令后立即 fsync()最高,最多丢 1 条最低金融/严格数据安全
Everysec每秒异步执行一次 fsync()中,最多丢 1 秒数据通用推荐
No永不主动 fsync(),由 OS 决定最低,丢失量不可控最高高性能且可接受丢数据

底层实现本质:三种策略只是对 fsync() 函数调用时机的控制,在数据安全性和服务器性能之间选择。

  • Always:每次 write 后调用 fsync()
  • Everysec:创建异步任务每秒调用 fsync()
  • No:从不调用 fsync()


4. AOF 重写机制

问题:AOF 文件随写命令增多不断膨胀,恢复慢。

解决思路:不复用旧文件,而是扫描当前数据库所有键值对,每个键值对用一条命令写入新 AOF 文件,再用新文件替换旧文件。

例子

# 旧 AOF 记录了两条命令:
set name xiaolin
set name xiaolincoding

# 重写后新 AOF 只有一条:
set name xiaolincoding

为什么不直接修改旧文件:重写失败会污染现有 AOF 文件,导致恢复时出错。新文件写失败直接删除,不影响旧文件。


5. AOF 后台重写(bgrewriteaof)

重写过程耗时(需扫描全量数据),不能在主进程执行,因此由后台子进程 **bgrewriteaof**** **完成。

使用子进程而非线程的原因线程共享内存需加锁,降低性能;子进程与父进程共享内存数据,并通过 写时复制(Copy-On-Write, CoW) 机制天然隔离数据修改,无需加锁。

写时复制(CoW)原理

  1. 主进程 fork() 子进程时,OS 只复制页表(虚拟地址→物理地址映射),不复制物理内存
  2. 父子进程共享同一块物理内存,权限标记为只读
  3. 任何一方发起写操作时,CPU 触发写保护中断,OS 复制该物理页,各自维护独立副本

两个会阻塞主进程的时机

  • fork() 执行期间:复制页表,页表越大阻塞越久
  • 写时复制触发期间:修改了 bigkey 时,复制的物理内存较大,阻塞时间较长

AOF 重写缓冲区(解决数据不一致)

子进程重写期间,主进程同时新收到的写命令会同时写入

  1. AOF 缓冲区(保证旧 AOF 文件持续可用)
  2. AOF 重写缓冲区(保存重写期间的增量命令)

整个流程:

主进程 fork() 子进程 bgrewriteaof

子进程扫描全量数据 → 写入新 AOF 文件
主进程同时将写命令 → 追加到 AOF 缓冲区 + AOF 重写缓冲区

子进程完成 → 发送信号给主进程

主进程信号处理函数:
  1. 将 AOF 重写缓冲区内容追加到新 AOF 文件
  2. 新 AOF 文件改名,覆盖旧 AOF 文件

主进程恢复正常处理命令

阻塞点汇总

  • fork() 时复制页表 → 短暂阻塞
  • 发生写时复制(尤其 bigkey)→ 短暂阻塞
  • 信号处理函数执行期间 → 短暂阻塞
  • 其余时间 AOF 后台重写不阻塞主进程

优缺点与局限性

AOF vs RDB

维度AOFRDB
数据安全性高(最多丢 1 秒)低(丢两次快照之间的数据)
恢复速度(逐条重放命令)(直接加载内存快照)
文件体积较大较小

各写回策略踩坑点

  • Always:磁盘 IOPS 成为瓶颈,写入 QPS 高的场景会严重影响吞吐量
  • Everysec:宕机时最多丢失 1 秒数据,不是”完全不丢”
  • No:Linux 默认刷盘间隔约 30 秒,宕机可能丢失大量数据,生产慎用

重写机制踩坑点

  • bigkey 触发写时复制时,主进程存在阻塞风险,应避免大 value 存储
  • 内存占用:重写期间父子进程可能各自持有部分物理内存副本,峰值内存可能翻倍
  • AOF 重写触发阈值默认:AOF 文件超过 64MB 且比上次重写后文件大小增长超过 100%

AOF 恢复速度是根本局限

AOF 恢复本质是单线程顺序重放命令,AOF 文件越大恢复越慢。生产环境大实例建议 AOF + RDB 混合持久化。


行动清单

  1. 动手验证 AOF 格式:本地启动 Redis,开启 appendonly yes,执行几条 set/hset/lpush 命令,用 cat 查看 AOF 文件,熟悉 RESP 协议格式
  2. 对比三种写回策略性能:用 redis-benchmark 分别在 Always / Everysec / No 下压测写入 QPS,观察吞吐量差异
  3. 手动触发 AOF 重写:执行 BGREWRITEAOF 命令,用 info persistence 观察 aof_rewrite_in_progressaof_current_sizeaof_base_size 字段变化
  4. 理解 fork + CoW:阅读 Linux fork() 和写时复制相关文档,深入理解页表复制机制,这是 Redis 很多特性(RDB 快照、AOF 重写)的共同底层基础
  5. 排查 bigkey:用 redis-cli --bigkeys 扫描生产实例,识别大 value,评估 AOF 重写时的 CoW 阻塞风险
  6. 学习混合持久化:了解 aof-use-rdb-preamble yes 配置(AOF + RDB 混合模式),这是解决 AOF 恢复慢问题的主要手段,是下一步的延伸知识点

Redis RDB 快照


一句话摘要

**RDB(Redis DataBase)**快照通过 bgsave + 写时复制(COW) 实现非阻塞全量持久化;混合持久化(Redis 4.0+)结合 RDB 的恢复速度优势与 AOF 的低丢失优势,是生产环境的推荐方案。


核心知识点

1. RDB vs AOF 的本质区别

RDBAOF
文件内容二进制内存快照(实际数据)操作命令日志
恢复方式直接加载进内存重放所有命令
恢复速度
数据丢失多(以分钟计)少(可达秒级)

2. 触发 RDB 快照的两个命令

save

  • 在主线程执行,会阻塞主线程
  • 写入 RDB 期间无法处理其他命令
  • 不推荐生产使用

bgsave

  • **fork()**** 出子进程,由子进程负责写 RDB 文件**
  • 主线程继续处理命令,不阻塞
  • 推荐方式

RDB 文件的加载在 Redis 服务器启动时自动执行,没有专用的加载命令。


3. 自动触发配置(redis.conf 默认值)

save 900 1       # 900 秒内,至少 1 次写操作
save 300 10      # 300 秒内,至少 10 次写操作
save 60 10000    # 60 秒内,至少 10000 次写操作

注意:配置项名为 **save**,实际执行的是 **bgsave**(子进程方式)。满足任意一条即触发。


4. 写时复制(Copy-On-Write, COW)机制

问题bgsave 子进程写快照期间,主线程能否修改数据?

答案:可以,靠 COW 实现。

原理

  1. fork() 子进程时,子进程与父进程共享同一块物理内存(复制父进程页表,但页表指向相同物理页)
  2. 只要没有写操作,双方都只读,互不干扰
  3. 一旦主线程要修改某块数据(如键值对 A),OS 将该物理页复制一份(A’),主线程在副本 A’ 上修改
  4. 子进程继续读取原始数据 A,并写入 RDB 文件

快照记录的内容bgsave 触发时刻的内存数据(快照期间主线程的新修改不会写入本次 RDB,留给下次快照)。

极端情况:若快照期间主线程对所有共享内存都发生了写操作,内存占用最多膨胀至原来的 2 倍


5. 混合持久化(Redis 4.0+)

开启配置

aof-use-rdb-preamble yes

工作机制(在 AOF 重写过程中触发):

AOF 文件结构:
[ RDB 格式全量数据 | AOF 格式增量命令 ]
   ↑ 重写子进程写入    ↑ 重写期间主线程的新操作
  1. AOF 重写时,fork 出重写子进程
  2. 子进程将当前内存数据RDB 格式写入新 AOF 文件前半段
  3. 主线程的新操作记录在重写缓冲区,以 AOF 格式追加到新 AOF 文件后半段
  4. 写完后,新 AOF 文件替换旧 AOF 文件

恢复流程先加载 RDB 部分(速度快),再回放 AOF 增量部分(丢失少)。


优缺点与局限性

RDB 快照

维度说明
优点恢复速度快,文件紧凑(二进制)
缺点全量快照,频率不能太高,最多丢失数分钟数据(通常配置 ≥ 5 分钟一次)
踩坑点写操作密集时,COW 导致内存暴涨至 2 倍,需预留足够内存
适用场景数据量大、恢复速度要求高、对少量数据丢失可接受的场景

混合持久化

维度说明
优点兼顾 RDB 快速恢复 + AOF 低丢失
缺点AOF 文件可读性下降(前半段为二进制 RDB)
版本要求Redis 4.0+
适用场景生产环境推荐方案

行动清单

  1. 动手验证:在本地 Redis 中执行 bgsave,观察 /var/lib/redis/dump.rdb 文件生成过程
  2. 调整配置:修改 redis.conf 中的 save 策略,理解不同频率对性能和数据安全的权衡
  3. 开启混合持久化:设置 aof-use-rdb-preamble yes 并用 BGREWRITEAOF 触发一次重写,用 hexdump 查看生成的 AOF 文件前半段确实是 RDB 格式
  4. 内存压测:在写操作密集场景下触发 bgsave,用 INFO memory 观察 used_memory 变化,验证 COW 内存膨胀现象
  5. 对比学习:阅读配套的 AOF 持久化篇,横向对比两种机制的实现差异
  6. 进阶阅读:研究 Redis 大 Key 对 RDB 持久化的影响(下一篇:《Redis 大 Key 对持久化有什么影响?》)

Redis 持久化:RDB 与 AOF


一句话摘要

Redis 提供** RDB(快照)和 AOF(追加日志)两种持久化机制,二者核心差异在于数据安全性与性能之间的取舍**:RDB 恢复快但可能丢几分钟数据,AOF 最多丢 1 秒数据但文件更大;生产环境推荐两者同时开启,重启时优先使用 AOF 恢复。


核心知识点

1. RDB 持久化(快照方式)

概念:将内存中某一时刻的全量数据以二进制压缩格式写入磁盘,文件名默认为 dump.rdb

工作原理

  • Redis fork 出一个子进程
  • 子进程将数据写入临时 RDB 文件
  • 写入完成后替换旧的 RDB 文件
  • 利用 copy-on-write 机制,主进程不阻塞

触发方式

  • 手动执行 save(同步,阻塞)或 bgsave(异步,后台执行)
  • 按配置的策略自动触发

默认配置(redis.conf)

save 900 1       # 900秒内,至少1个key被修改,触发快照
save 300 10      # 300秒内,至少10个key被修改,触发快照
save 60 10000    # 60秒内,至少10000个key被修改,触发快照

2. AOF 持久化(追加日志方式)

概念:将每一条写/删除命令以文本格式追加到 appendonly.aof 文件,查询命令不记录。重启时通过”重放”该文件恢复数据。

开启方式:在 redis.conf 中设置:

appendonly yes

三种 fsync 策略

appendfsync always   # 每次写命令后立即同步到磁盘,最安全,性能最差
appendfsync everysec # 每秒同步一次(默认策略),最多丢失1秒数据
appendfsync no       # 由操作系统决定同步时机,性能最好,安全性最低

工作流程:每执行一个修改命令 → 追加到 AOF 文件 → 重启时逐条重放恢复状态


3. RDB vs AOF 对比

维度RDBAOF
文件格式二进制压缩文本(可直接阅读)
文件大小较小通常大于 RDB
恢复速度慢(需逐条重放)
数据安全性低(可能丢失数分钟)高(最多丢失1秒)
写入性能影响巨大写入时有性能下降
适用场景备份、灾难恢复数据不可丢失的业务

优缺点与局限性

RDB

优点

  • 文件紧凑,天然适合定期备份(如每小时一份、每天一份)
  • 恢复速度比 AOF 快
  • fork 子进程写入,对主进程性能影响小

缺点 / 踩坑点

  • 最小保存间隔通常为 5 分钟,故障时最多丢失数分钟的数据
  • 数据集越大,fork 子进程耗时越长,可能短暂影响响应
  • 不适合对数据丢失零容忍的场景

AOF

优点

  • 默认 everysec 策略下,最多丢失 1 秒数据
  • fsync 在后台线程执行,主线程不阻塞
  • 文件内容可读,便于排查操作历史

缺点 / 踩坑点

  • 同等数据量下,AOF 文件体积通常大于 RDB
  • 处理巨大写入负载时,性能低于 RDB
  • always 策略下性能开销显著,慎用于高写入场景
  • 重放恢复速度慢,大数据集重启耗时长

同时开启两者

  • Redis 重启后优先使用 AOF 恢复(保留更完整的数据)
  • RDB 依然在后台定期生成,作为冗余备份
  • 是生产环境推荐的最佳实践

行动清单

  1. 动手验证默认配置:在本地 Redis 中执行 CONFIG GET save,确认当前 RDB 触发策略是否符合预期
  2. 练习手动快照:分别执行 SAVEBGSAVE,观察 dump.rdb 文件的生成时间差异,体验二者的阻塞与非阻塞区别
  3. 开启 AOF 并测试恢复:修改 redis.conf 设置 appendonly yes,写入若干数据后模拟重启,观察 AOF 重放的过程及耗时
  4. 对比三种 fsync 策略的性能:用 redis-benchmark 压测 always / everysec / no 三种模式下的写入吞吐量,记录差异
  5. 生产配置落地:对重要业务 Redis 实例,同时开启 RDB + AOF,并配置定时脚本将 dump.rdb 备份到对象存储(如 OSS / S3),形成完整灾备方案
  6. 深入学习方向:了解 AOF Rewrite(重写压缩机制),解决 AOF 文件随时间无限膨胀的问题(命令:BGREWRITEAOF

Redis 三种高可用模式


一句话摘要

Redis 通过主从复制、哨兵模式、Cluster 集群三种模式逐层解决**“数据冗余 → 自动故障转移 → 水平扩展写能力与存储容量”**的问题,三者是递进关系,后者都依赖前者作为基础。


二、核心知识点

2.1 主从复制(Master-Slave Replication)

概念: **将一台 Redis 的数据单向复制到其他 Redis 服务器。数据流向只能从 Master → Slave,不可反向。**默认每台 Redis 都是主节点;一主可多从,但一从只能有一主。

四大作用:

  • 数据冗余热备份,是持久化之外的数据冗余手段
  • 故障恢复主节点故障时,从节点可快速接管提供服务
  • 负载均衡:**主节点负责写,从节点负责读;**写少读多场景下显著提升并发量
  • 高可用基石哨兵和集群都建立在主从复制之上

同步流程:

  1. Slave 启动后向 Master 发送 **sync command**** 请求同步**
  2. Master 启动后台进程,执行 RDB 快照,同时缓存期间所有写命令
  3. Master 将 RDB 文件发给 Slave,Slave 先将其保存到硬盘,然后加载到内存
  4. Master 将缓存的写命令也发给 Slave,完成全量同步
  5. 后续增量命令实时同步;Slave 宕机重连后自动重新全量同步
  6. 多个 Slave 同时请求时,Master 只生成一份 RDB 文件,多播发送

搭建环境:

角色IP系统
Master192.168.154.10CentOS 7
Slave1192.168.154.11CentOS 7
Slave2192.168.154.12CentOS 7

关闭防火墙和 SELINUX(三台都执行):

# 三台主机都关闭防火墙和SELINUX
systemctl stop firewalld
systemctl disable firewalld
setenforce 0

安装命令(三台都执行):

yum install -y gcc gcc-c++ make
tar zxvf /opt/redis-7.0.9.tar.gz -C /opt/
cd /opt/redis-7.0.9
make && make PREFIX=/usr/local/redis install
mkdir /usr/local/redis/{conf,log,data}

内核参数优化(三台都执行):

# vim /etc/sysctl.conf
vm.overcommit_memory = 1
net.core.somaxconn = 2048
sysctl -p

Master 关键配置(redis.conf):

bind 0.0.0.0           # 87行
protected-mode no      # 111行
daemonize yes          # 309行
appendonly yes         # 1380行,开启 AOF

Slave 额外配置(在 Master 配置基础上追加):

replicaof 192.168.154.10 6379   # 528行,指定 Master
# masterauth abc123             # 535行,若 Master 有密码则填写

验证命令:

redis-cli info replication   # 在 Master 上查看从节点状态
tail -f /usr/local/redis/log/redis_6379.log # 在 Master 节点上看日志

2.2 哨兵模式(Sentinel)

概念: 在主从复制基础上,增加**哨兵节点实现对主节点的自动故障转移。哨兵系统由一个或多个哨兵节点组成,**哨兵节点是特殊的 Redis 节点,不存储数据,仅负责监控与通知。

三大核心功能:

  • 监控定期检查主从节点是否正常运行
  • 自动故障转移主节点失效时,自动选举从节点升为新主,并通知其他从节点跟随新主
  • 通知客户端故障转移结果发送给应用客户端

故障判定流程:

  1. 每个哨兵每隔 1 秒向主节点、从节点及其他哨兵发送 **ping** 心跳
  2. 若主节点无响应或返回错误 → 该哨兵判定其主观下线(单方面认定)
  3. 超过半数哨兵认为主观下线 → 升级为客观下线(注意:客观下线只针对主节点)
  4. 哨兵通过 Raft 算法选出一个 leader 哨兵执行故障转移
  5. leader 哨兵将某从节点升为新主,其余从节点跟随新主,原主节点恢复后降为从节点

新主节点选举规则(优先级从高到低):

  1. 过滤不健康节点(未响应 ping 的从节点)
  2. **replica-priority** 配置优先级最高的(配置文件中设置,默认 100)
  3. 复制偏移量最大的(数据最完整的

搭建关键配置(三台节点都执行):

cp /opt/redis-7.0.9/sentinel.conf /usr/local/redis/conf/
# sentinel.conf 关键配置
protected-mode no              # 6行
port 26379                     # 10行
daemonize yes                  # 15行
sentinel monitor mymaster 192.168.154.10 6379 2  # 73行,2代表至少2个哨兵同意才判定故障
sentinel down-after-milliseconds mymaster 3000   # 114行,3秒无响应判定下线
sentinel failover-timeout mymaster 1154000       # 214行,两次故障转移最小间隔

启动顺序:先启 Master,再启 Slave,最后启哨兵:

redis-sentinel /usr/local/redis/conf/sentinel.conf &

验证命令:

redis-cli -p 26379 info Sentinel     # 查看哨兵状态
ps -ef | grep redis
kill -9 <master-pid>                 # 模拟故障
redis-cli -p 26379 INFO Sentinel     # 故障后查看新主节点

2.3 Cluster 集群模式

概念: Redis 3.0 引入的分布式存储方案,通过数据分片将数据分散到多个主节点,突破单机内存限制,同时支持写操作负载均衡。集群中主节点负责读写,从节点仅做数据复制。

数据分片机制(哈希槽):

  • 共有 16384 个哈希槽,编号 0–16383
  • 键值对存储中 Key 的槽位 = CRC16(key) % 16384
  • 客户端请求自动跳转到对应节点-c 参数开启)

3主3从分槽示例:

节点A(6001):槽 0–5460
节点B(6002):槽 5461–10922
节点C(6003):槽 10923–16383
节点D/E/F(6004/6005/6006):分别是 A/B/C 的从节点

高可用逻辑: 若 B 节点失败,E(从节点)自动升为主节点。B 和 E 同时失败,集群对外不可用(该槽范围服务中断)。

搭建流程(单机模拟,6 个端口):

创建 6 个节点目录:

mkdir -p /usr/local/redis/redis-cluster/redis600{1..6}
for i in {1..6}; do
  cp /opt/redis-7.0.9/redis.conf /usr/local/redis/redis-cluster/redis600$i
  cp /opt/redis-7.0.9/src/redis-cli /opt/redis-7.0.9/src/redis-server \
     /usr/local/redis/redis-cluster/redis600$i
done

每个节点 redis.conf 关键配置(以 6001 为例):

#bind 127.0.0.1                        # 87行,注释掉
protected-mode no                      # 111行
port 6001                              # 138行
daemonize yes                          # 309行
appendonly yes                         # 1379行
cluster-enabled yes                    # 1576行,开启集群
cluster-config-file nodes-6001.conf   # 1584行
cluster-node-timeout 15000            # 1590行,节点超时15秒

批量替换端口配置(6001→6002 类推):

sed -i 's/6001/6002/' /usr/local/redis/redis-cluster/redis6002/redis.conf
# 6003–6006 以此类推

启动所有节点:

for i in {1..6}; do
  cd /usr/local/redis/redis-cluster/redis600$i
  ./redis-server redis.conf
done

初始化集群(--cluster-replicas 1** 表示每主一从):**

redis-cli --cluster create \
  192.168.154.10:6001 192.168.154.10:6002 192.168.154.10:6003 \
  192.168.154.10:6004 192.168.154.10:6005 192.168.154.10:6006 \
  --cluster-replicas 1
# 交互时输入 yes 确认

集群连接与验证:

redis-cli -h 192.168.154.10 -p 6001 -c    # -c 开启自动跳转
127.0.0.1:6001> cluster slots             # 查看哈希槽分布

动态扩容(新增 6007/6008 一组主从):

# 1. 创建并启动新节点
cp -a redis6001 redis6007 && cp -a redis6001 redis6008
sed -i 's/6001/6007/' redis6007/redis.conf
sed -i 's/6001/6008/' redis6008/redis.conf

# 2. 将新节点加入集群
redis-cli -h 192.168.154.10 -p 6001 --cluster add-node 192.168.154.10:6007 192.168.154.10:6001
redis-cli -h 192.168.154.10 -p 6001 --cluster add-node 192.168.154.10:6008 192.168.154.10:6001

# 3. 查看节点 ID
redis-cli -h 192.168.154.10 -p 6001 CLUSTER nodes

# 4. 设置 6008 为 6007 的从节点
redis-cli -h 192.168.154.10 -p 6008
> cluster replicate <6007的node-id>

# 5. 为新主节点分配哈希槽(需手动)
redis-cli -h 192.168.154.10 -p 6007 --cluster reshard 192.168.154.10:6001

三、优缺点与局限性

模式适用场景限制与踩坑
主从复制读多写少 需要数据备份① 故障恢复需人工干预; ② 写操作无法横向扩展; ③ 存储受单机限制;
哨兵模式需要自动故障转移 但数据量不大① 写操作仍无法负载均衡; ② 存储仍受单机限制; ③ 从节点故障哨兵不会自动转移,读服务需额外维护; ④ 哨兵节点数不得少于 3 个(Raft 算法要求半数以上);
Cluster 集群大数据量 高写入并发 需要完整高可用① 单个槽范围内的主从节点全部故障时集群服务中断; ② 新增节点后哈希槽不会自动均衡,需手动执行 reshard; ③ 跨槽的多键操作(如 mget)不被直接支持;

四、行动清单

  1. 动手搭建:按步骤在本机用 Docker 或三台虚拟机搭建主从复制环境,用 redis-cli info replication 验证主从状态。
  2. 故障模拟:用 kill -9 杀死 Master 进程,观察哨兵日志(sentinel.log)中主观下线 → 客观下线 → 选举 → 故障转移的完整过程。
  3. 理解哈希槽:在集群模式下写入多个 Key,用 cluster slots 观察槽分配,理解 -c 参数如何触发自动跳转(MOVED 重定向)。
  4. 实践扩容:在已有 3主3从集群中执行 6007/6008 扩容,重点练习 reshard 命令的槽迁移参数配置。
  5. 深入研究:阅读 Raft 选举算法原理,理解哨兵 leader 选举为何要求奇数个节点(≥3)。
  6. 对比选型:整理一张表格,按”团队规模/数据量/QPS/运维能力”四个维度,给出三种模式的推荐使用场景,作为日后架构选型参考。

Redis 主从复制(上)


一句话摘要

Redis 主从复制通过全量复制 → 命令传播 → 增量复制三种模式保证多节点数据一致性,并通过 **repl_backlog_buffer** 环形缓冲区 + 复制偏移量机制实现断线后的高效恢复,核心挑战在于如何应对主从数据不一致、数据丢失(异步复制 & 脑裂)两类问题。


核心知识点

1. 主从关系建立

使用 replicaof 命令(Redis 5.0 之前为 slaveof)建立主从关系:

# 在从服务器 B 上执行,使其成为服务器 A 的从节点
replicaof <服务器 A IP> <服务器 A Redis 端口>
  • 主节点:可读可写,自动将写操作同步给从节点
  • 从节点:默认只读,执行从主节点同步来的写命令


2. 第一次同步:全量复制(3 阶段)

阶段一:建立连接、协商同步

从节点发送 **psync** 命令给主节点,携带两个参数:

  • runID:第一次同步时设为 ?(因为不知道主节点 ID)
  • offset:第一次同步时设为 -1

**主节点回复 ****FULLRESYNC <runID> <offset>**,通知从节点进行全量复制,从节点记录这两个值。

阶段二:主节点同步 RDB 给从节点

  1. 主节点执行 **bgsave**,fork 子进程异步生成 RDB 文件(不阻塞主线程)
  2. RDB 文件发送给从节点
  3. 从节点清空旧数据,载入 RDB 文件

关键细节:bgsave 生成 RDB 期间 + 发送 RDB 期间 + 从节点加载 RDB 期间,主节点收到的新写命令会写入 replication buffer 缓冲区暂存。

阶段三:发送缓冲区积压命令

从节点加载 RDB 完成后回复确认消息,主节点将bgsave 生成 RDB + 发送 RDB + 从节点加载 RDB 期间**replication buffer** 中积压的写命令发给从节点执行,至此数据完全一致。


3. 命令传播:基于长连接的持续同步

  • 第一次同步完成后,主从节点维持一条 TCP 长连接
  • 后续主节点将写命令实时通过该长连接传播给从节点
  • 长连接目的:避免频繁建立/断开 TCP 连接的性能开销
  • Redis 复制是异步的:主节点写本地成功后立即返回客户端,不等从节点确认

心跳检测机制:

方向命令频率用途
主 → 从ping默认每 10 秒检测从节点存活与连接状态,参数 repl-ping-slave-period 可调
从 → 主replconf ack {offset}每 1 秒上报自身复制偏移量、检测数据是否丢失、监控网络状态


4. 分摊主节点压力:级联从节点(“经理”架构)

问题从节点数量多时,主节点需频繁 fork + 传输 RDB,压力过大。

解决方案:从节点可以拥有自己的从节点,形成树状结构。

# 在"从节点 B"上执行,使 B 的从节点 C 接入 B
replicaof <目标服务器 B IP> 6379

此时 B 承担”经理”角色:同时作为上游的从节点,又作为下游的主节点,分摊 RDB 生成和传输的压力。


5. 增量复制(Redis 2.8+)

背景:2.8 之前断线重连会触发全量复制,代价极大。2.8 之后引入增量复制。

核心数据结构:

  • **repl_backlog_buffer**:主节点维护的环形缓冲区(默认 1MB),命令传播时写入,用于断线后找到差异数据
  • 复制偏移量
    • **master_repl_offset**:主节点已写入位置
    • **slave_repl_offset**:从节点已读取位置

增量复制流程:

  1. 从节点网络恢复后,发送 psync <runID> <slave_repl_offset>
  2. 主节点比对 **master_repl_offset****slave_repl_offset** 的差距
  3. 若差异数据还在 repl_backlog_buffer 中 → 回复 CONTINUE,执行增量同步
  4. 若差异数据已被覆盖 → 回退到全量同步

缓冲区大小计算公式:

repl_backlog_buffer 最小值 = second × write_size_per_second

示例:主节点每秒产生 1MB 写命令,从节点平均断线 5 秒才重连 → 最小需要 5MB,建议配置为 2 倍即 10MB

# redis.conf 配置项
repl-backlog-size 10mb


6. 两个 Buffer 的对比

对比项replication bufferrepl_backlog_buffer
发送数据主节点生成 RDB + 主节点发送 RDB + 从节点接收 RDB 期间的所有写操作数据网络连接断开期间的所有写操作数据
出现阶段全量复制 + 增量复制均有仅增量复制阶段
分配方式每个从节点各自一个所有从节点共享一个
满了之后连接断开 → 从节点重连 → 重新全量复制环形覆盖旧数据

7. 数据丢失:异步复制导致

原因写命令先本地执行、返回客户端,再异步发给从节点。主节点宕机时,未同步的数据丢失。

缓解方案:配置 min-slaves-max-lag

# 若所有从节点的同步延迟都超过该秒数,主节点拒绝写请求
min-slaves-max-lag 10

**客户端降级策略:**主节点拒写后,将数据暂存本地或写入 Kafka 消息队列,等主节点恢复后重放写入。


8. 数据丢失:集群脑裂导致

脑裂过程:

  1. 主节点网络故障,与从节点/哨兵失联,但客户端仍向旧主节点写数据(写入其缓冲区)
  2. 哨兵将某从节点选举为新主节点 → 集群出现两个主节点
  3. 网络恢复后,旧主节点被降级为从节点,执行全量同步前清空自身数据 → 客户端此前写入的数据全部丢失

缓解方案:组合使用两个配置项:

min-slaves-to-write 1      # 主节点至少连接 N 个从节点,否则拒写
min-slaves-max-lag 12      # 主从同步延迟不能超过 T 秒,否则拒写

两者同时满足时主节点才接受写请求,可将脑裂期间的数据丢失控制在 T 秒内。


9. 过期 Key 的主从处理

主节点处理过期 key(主动删除或淘汰算法)后,模拟一条 **del <key>** 命令发给从节点,从节点收到后执行删除。


10. 故障自动切换

主节点故障后,从节点不能自动升级为主节点,需要引入 Redis 哨兵(Sentinel)机制实现自动故障发现与转移(下一章节内容)。


优缺点与局限性

全量复制

  • 适用场景:初次建立主从关系,或增量数据已丢失无法恢复时
  • 代价高:fork 子进程可能短暂阻塞(内存越大越明显)、占用大量网络带宽
  • 踩坑:多个从节点同时全量复制会严重拖慢主节点

增量复制

  • 依赖 repl_backlog_buffer 未被覆盖,若缓冲区太小会退化为全量复制
  • repl-backlog-size 默认 1MB,生产环境几乎必须调大
  • 环形覆盖是静默的,不会有任何报错,调试困难

命令传播(异步复制)

  • 无法保证强一致性,主节点崩溃时有数据丢失风险
  • 从节点读到旧数据是正常现象(最终一致性)
  • 可通过监控 master_repl_offset - slave_repl_offset 的差值来识别落后过多的从节点

脑裂防护

  • min-slaves-to-write + min-slaves-max-lag 只能减少丢失,不能完全避免
  • 配置过严会导致主节点频繁拒写,需根据业务容忍度权衡

行动清单

  1. 动手验证全量复制流程:本地启动两个 Redis 实例,执行 replicaof,用 INFO replication 观察 master_repl_offsetslave_repl_offset 的变化。
  2. **调整并测试 **repl-backlog-size:根据公式 second × write_size_per_second × 2 为自己的业务场景估算合理值,在 redis.conf 中修改后重启验证。
  3. 监控复制延迟:编写脚本定期执行 INFO replication,计算 master_repl_offset - slave_repl_offset,对差值超阈值的从节点发送告警。
  4. 配置脑裂防护参数:在主从 + 哨兵架构中设置 min-slaves-to-writemin-slaves-max-lag,并结合 down-after-milliseconds 测试效果(min-slaves-max-lag 应小于 down-after-milliseconds)。
  5. 进入下一章节:学习 Redis 哨兵(Sentinel)机制,理解故障自动切换的完整流程,作为本章”故障手动切换”的延伸。
  6. 深读对比:结合 replication bufferrepl_backlog_buffer 的区别,重点理解”满了之后行为不同”这一细节,这是面试高频考点。

Redis 主从复制(下)


一句话摘要

Redis 主从复制经历了从全量同步(sync)→ psync(2.8)→ psync2(4.0)的演进,核心目标是在保证数据多副本的前提下,尽可能将全量同步降级为增量同步,以降低 master 负荷和网络开销。


核心知识点

1. 主从拓扑结构

一个 master 可挂多个 slave,slave 下还可以继续挂 slave,形成多层嵌套树形结构。

  • 所有写操作只在 master 上执行,执行完毕后将写指令分发给下层 slave。
  • slave 收到写指令后,再继续向自己的下层 slave 分发。
  • 效果:多副本数据保障(任一节点故障不丢数据) + N 倍读性能提升(多 slave 分摊读请求)。

配置命令:

# 将当前节点设置为主库
slaveof no one

# 将当前节点挂到指定 master 下
slaveof <master_ip> <master_port>


2. 全量同步 vs 增量同步

维度全量同步增量同步
触发条件首次连接、runid/replid 不匹配、偏移量不在缓冲区内slave 短时断开重连、slave 重启(psync2)、切主(psync2)
master 动作bgsave 生成 rdb + 缓存期间写指令 → 全部发给 slave只发送 slave 上次复制偏移之后的写指令
对 master 影响大(CPU + 内存 + 带宽)极小
对带宽影响大(传输完整 rdb)可忽略
master 响应FULLRESYNC <replid> <offset>CONTINUE <replid>

3. 复制积压缓冲(Replication Backlog)

master 在将写指令同步给 slave 的同时,会将写指令同步写一份到复制积压缓冲,保存在 repl_backlog_buffer 中

作用:slave 短时断开后重连,只要其复制偏移量仍在缓冲区内,即可走增量同步而非全量同步。

关键限制:缓冲区大小有限,slave 断开时间过长会导致偏移量被覆盖,强制触发全量同步。


4. psync(Redis 2.8 引入)

解决的问题:2.8 之前任何断线重连都触发全量同步,开销极大。

核心机制

  • slave 重连后上报 **master runid**** + ****复制偏移量**
  • master 校验 runid 一致 且偏移量在积压缓冲区内 → 增量同步。

残留问题

  • slave 重启后 runid 丢失 → 仍需全量同步。
  • 切换 master 后 runid 变化 → 仍需全量同步。

5. psync2(Redis 4.0 引入)

解决的问题:slave 重启、切主场景下 psync 仍需全量同步。

核心改动

① 用 replid(复制 id)替换 runid 作为同步判断依据。

② 每个实例维护两个复制 id:

  • replid:当前 master 的复制 id(初始为 40 位随机字符串,建立主从连接后替换为 master 的 replid)。
  • replid2上一任 master 的 replid(切主时保存旧 master 的 replid)

③ 构建 rdb 时,将 replid 作为 aux 辅助信息写入 rdb 文件 → slave 重启加载 rdb 即可恢复 replid,无需全量同步

切主场景的增量同步条件slave 上报的复制 id 与新 master 的 **replid2** 相同,且偏移量在积压缓冲区内 → 增量同步成立。


6. 复制握手完整流程

slave 主动发起连接,流程如下:

1. slave → master: PING
   master → slave: PONG(确认 master 可用)

2. slave → master: AUTH $masterauth(若设置了密码)

3. slave → master: REPLCONF listening-port <port>
   slave → master: REPLCONF ip-address <ip>(上报自身端口和 IP)

4. slave → master: REPLCONF capa eof capa psync2(版本能力协商)

5. slave → master: PSYNC <replid> <offset>(正式请求同步)

6. master 判断:
   - replid 匹配(replid 或 replid2)且 offset 在积压缓冲区 → CONTINUE(增量)
   - 否则 → FULLRESYNC <replid> <offset>(全量)

7. 全量同步 slave 端执行细节

  1. 关闭所有嵌套子 slave 连接,清理自身复制积压缓冲。
  2. 创建临时 rdb 文件,从 master 连接持续接收 rdb 数据写入。
  3. 每写 8MB 执行一次 fsync,刷新文件缓冲。
  4. 接收完成后将临时 rdb 改名为正式文件名。
  5. 清空本地所有 DB 数据,暂停接收 master 新数据。
  6. 将 rdb 加载到内存。
  7. 重新注册 master 连接的读事件,开始接收写指令。
  8. replid2 清空(因为自身子 slave 也需全量复制)。
  9. 打开 aof 文件,后续写指令执行完毕后写入 aof。

优缺点与局限性

psync2 的优势

  • 短时断连、slave 重启、切主场景均可增量同步,大幅降低 master 负荷和带宽占用。
  • slave 可快速恢复服务,减少系统抖动。

复制积压缓冲的核心矛盾

  • 缓冲区设置过大:占用 master 过多内存。
  • 缓冲区设置过小:slave 稍长时间断连即超出缓冲区,强制全量同步。
  • 内存限制决定了即使配置较大缓冲区,断连时间较长时仍难逃全量同步。

全量同步的性能代价

  • master 执行 bgsave 对 CPU 和内存有压力。
  • 传输完整 rdb 占用大量带宽,影响业务流量。
  • slave 清空数据 + 加载 rdb 期间服务不可用,时间较长。

适用场景限制

  • 树形嵌套 slave 拓扑:一旦上层节点触发全量同步,其所有子 slave 也会被强制全量同步(slave 会清空 replid2)。
  • master 单线程写:slave 数量增多时,写指令分发本身也会消耗 master 资源。

行动清单

  1. 验证 psync2 效果:搭建本地 Redis 4.0+ 主从环境,手动 kill -9 slave 进程后重启,观察日志中是出现 PSYNC 增量同步还是 FULLRESYNC 全量同步,对比重启前后 INFO replication 中的 replid 和 offset。
  2. 调优复制积压缓冲:执行 CONFIG GET repl-backlog-size 查看当前配置(默认 1MB),根据业务写入 QPS 和可接受的最大断线时长计算合理大小:缓冲区大小 ≥ 写入速率(byte/s) × 最大容忍断线时长(s),用 CONFIG SET repl-backlog-size <bytes> 调整。
  3. 监控复制延迟:通过 INFO replication 关注 master_repl_offset 和各 slave 的 slave_repl_offset,差值即为复制延迟,建立告警阈值。
  4. 理解 psync 演进链路:阅读 Redis 2.8 和 4.0 的 release notes 或源码中 replication.c 文件,对照本笔记理解 runid → replid → replid2 的演进逻辑。
  5. 深入下一篇:结合「第 28 课:如何构建高性能易扩展的 Redis 集群」,理解主从复制如何在 Redis Sentinel 和 Redis Cluster 中被进一步应用和增强。

Redis 哨兵机制(Sentinel)


一句话摘要

Redis 主从架构在主节点宕机时需要人工介入,哨兵机制(Redis 2.8+)通过自动化的监控、Leader选举和故障转移,实现主从切换的完全自动化,核心结论是:至少 3 个哨兵节点 + quorum = N/2+1 的配置,是生产可用的最低门槛


核心知识点

1. 哨兵是什么

哨兵(Sentinel)是运行在特殊模式下的 Redis 进程,本身也是一个节点。职责三件事:监控、选主、通知

  • 监控:检测主从节点是否存活
  • 选主:主节点宕机后,从从节点中选出新主节点
  • 通知:将新主节点信息通知给其他从节点和客户端

2. 如何判断主节点故障

主观下线(Subjective Down)

哨兵每隔 1秒 向所有主从节点发送 PING 命令,若在 **down-after-milliseconds****(毫秒)**内未收到响应,则将该节点标记为主观下线。

客观下线(Objective Down)

仅针对主节点。单个哨兵可能因自身网络问题误判,因此需多个哨兵共同投票确认:

  • 发现主观下线的哨兵向其他哨兵发送 **is-master-down-by-addr** 命令
  • 其他哨兵根据自身与主节点的网络状况,返回赞成或拒绝票
  • 赞成票数 ≥ **quorum**(法定人数)配置值时,该哨兵将主节点标记为客观下线

配置示例:3 个哨兵,**quorum = 2**,需拿到 2 票(含自身)才能判定客观下线


3. 哨兵 Leader 选举(第二轮投票)

判定主节点客观下线的哨兵成为候选者,向其他哨兵发起 Leader 投票。

当选条件(需同时满足):

  1. 拿到半数以上赞成票(> 哨兵总数 / 2)
  2. 拿到的票数 ≥ **quorum**

投票规则:

  • 每个哨兵只有一次投票机会
  • **先到先得:**先收到哪个候选者的请求,就先投给谁
  • 只有候选者才能投票给自己

典型场景推演:

哨兵总数quorum最少需要票数允许最多故障哨兵数
3221
5332

4. 为什么哨兵节点至少要 3 个

只有 2 个哨兵时,Leader 当选需要 2 票。若 1 个哨兵宕机,剩余 1 个哨兵无法凑齐 2 票,故障转移完全失效。部署 3 个哨兵,允许 1 个宕机后仍可正常完成切换。

推论:哨兵节点数量应为奇数,**quorum**** 建议设为 ****N/2 + 1**


5. 主从故障转移四步流程

第一步:选出新主节点

依次按以下****规则过滤和排序从节点

  1. 过滤已下线的从节点
  2. 过滤历史网络状况不好的从节点:断连次数超过 down-after-milliseconds * 10 毫秒对应的阈值(具体:若发生断连次数超过 10 次,则过滤掉)
  3. 第一轮考察 — 优先级:比较 **slave-priority** 配置,值越小优先级越高
  4. 第二轮考察 — 复制进度**slave_repl_offset**** 最接近 ****master_repl_offset** 的从节点胜出
  5. 第三轮考察 — ID 号ID 号最小的从节点胜出

选定后,哨兵 Leader 向目标从节点发送:

SLAVEOF no one

随后以 每秒 1 次(正常为每 10 秒 1 次)的频率发送 INFO 命令,确认其角色从 slave 变为 master

第二步:其他从节点指向新主节点

哨兵 Leader 向所有其他从节点发送:

SLAVEOF <新主节点IP> <新主节点Port>

第三步:通知客户端

通过发布/订阅机制,哨兵向 **+switch-master** 频道发布新主节点的 IP 和端口,客户端订阅该频道后即可感知变化并更新连接。

常见订阅频道事件:

  • **+switch-master**主节点切换完成,含新主节点连接信息
  • 其他频道记录切换过程的各个关键事件,便于监控切换进度

第四步:旧主节点降级为从节点

哨兵持续监控旧主节点,待其重新上线后发送:

SLAVEOF <新主节点IP> <新主节点Port>

将其纳入新主节点的从节点体系。


6. 哨兵集群如何自动组成

哨兵配置只需填写主节点信息,无需手动配置其他哨兵地址:

sentinel monitor <master-name> <ip> <redis-port> <quorum>

哨兵互相发现机制:

主节点上存在一个特殊频道 **__sentinel__:hello**。哨兵 A 将自己的 IP 和端口发布到该频道,哨兵 B/C 订阅该频道后自动获取哨兵 A 的地址,进而建立连接,形成哨兵集群。

哨兵如何发现从节点:

**哨兵每 10 秒向主节点发送 ****INFO** 命令,主节点返回所有从节点列表,哨兵据此与每个从节点建立连接并持续监控。


优缺点与局限性

适用场景

适用于对高可用有要求但数据量未达到分片需求的场景,是主从架构的高可用增强方案。

局限性与踩坑点

  • 脑裂风险:网络分区场景下,旧主节点和新主节点可能同时接受写操作,造成数据不一致(需结合
    min-slaves-to-write 等参数缓解)
  • 切换期间写操作会短暂失败故障转移过程中客户端写请求会报错,需要客户端有重试和连接重建逻辑
  • 不解决容量问题:哨兵模式不做数据分片,单节点容量上限即为整体上限,需要分片场景要上 Cluster
  • quorum 配置不当会导致无效判定:例如 5 哨兵 quorum=2,3 个哨兵故障时,仍可判定客观下线但无法完成 Leader 选举,做了无用功;quorum 建议设为 N/2+1 避免此问题
  • 哨兵节点本身是单点生产环境哨兵和 Redis 节点建议分开部署在不同机器上,避免共存导致机器宕机时同时失去哨兵和数据节点

行动清单

  1. 动手部署:本地搭建 1主2从+3哨兵的 Redis 环境,手动 kill 主节点,观察故障转移完整过程和日志输出
  2. 参数验证:调整 quorumdown-after-milliseconds 参数,测试不同故障场景下哨兵的行为差异
  3. 订阅频道监控:用 redis-cli 订阅 __sentinel__:hello+switch-master 频道,实时观察哨兵通信内容
  4. 深入复制进度原理:重点理解 master_repl_offsetslave_repl_offsetrepl_backlog_buffer 的关系,这是选主第二轮考察的基础
  5. 研究脑裂问题:查阅 min-slaves-to-writemin-slaves-max-lag 配置项,了解如何通过配置降低脑裂导致的数据丢失风险
  6. 进阶路径:哨兵解决高可用,Cluster 解决水平扩容,下一步学习 Redis Cluster 的槽位分配和故障转移机制

Redis 集群模式(上)


一句话摘要

Redis Cluster 是官方提供的去中心化分布式方案,通过 16384 个哈希槽将数据分片到多个主节点,本篇覆盖集群的两种搭建方式与动态增删节点的完整操作流程。


核心知识点

1. Redis Cluster 基本架构

Redis Cluster 从 3.0 版本引入,核心机制:

  • 数据被划分为 16384 个 slots(槽),每个主节点负责一段连续的槽范围
  • 客户端连接时会获取一份槽位配置表,之后直接将请求发往对应节点,无需代理转发
  • 架构为无代理、去中心化模式,大多数请求无需转发或最多转发一次
  • 水平扩展一倍主节点,理论上吞吐量也提升一倍,性能接近单机 Redis

典型槽位分配(3 主节点示例):

节点端口槽位范围
Master[0]300010 – 5460
Master[1]300025461 – 10922
Master[2]3000310923 – 16383

2. 快速搭建(create-cluster 工具)

工具位置:utils/create-cluster/ 目录下。

# 启动 6 个节点(30001~30006)
./create-cluster start

# 自动组建集群(3 主 3 从)
./create-cluster create
# 执行过程中输入 yes 确认槽位分配方案

# 连接验证
redis-cli -c -p 30001
127.0.0.1:30001> cluster nodes

# 关闭与清理
./create-cluster stop
./create-cluster clean

3. 手动搭建(生产推荐方式)

**步骤一:修改每个节点的 **redis.conf

cluster-enabled yes
port 3000X      # 各节点分别改为 30001~30006

步骤二:逐一启动节点

cd /usr/local/soft/mycluster/node1
./src/redis-server redis.conf

步骤三:创建集群并分配槽位

redis-cli --cluster create \
  127.0.0.1:30001 127.0.0.1:30002 127.0.0.1:30003 \
  127.0.0.1:30004 127.0.0.1:30005 127.0.0.1:30006 \
  --cluster-replicas 1

--cluster-replicas 1 表示每个主节点配 1 个从节点,系统自动分配主从关系,输入 yes 确认后集群启动。

验证集群状态

redis-cli -c -p 30001
127.0.0.1:30001> cluster info

关键字段:

cluster_state:ok          # 状态正常
cluster_slots_assigned:16384
cluster_slots_ok:16384
cluster_known_nodes:6     # 总节点数
cluster_size:3            # 主节点数

4. 动态增删节点

4.1 添加主节点

方式一:cluster meet(在已连接节点内执行)

127.0.0.1:30001> cluster meet 127.0.0.1 30007

方式二:redis-cli —cluster add-node(命令行执行)

redis-cli --cluster add-node 127.0.0.1:30008 127.0.0.1:30001
# 参数格式:新节点ip:port  集群中任意节点ip:port

两种方式新加入的节点默认均为主节点,但没有分配任何槽位slots: (0 slots))。

4.2 添加从节点

在目标节点上连接后执行 cluster replicate

# 先连接到要设为从节点的实例
redis-cli -c -p 30008

# 指定要复制的主节点 ID
127.0.0.1:30008> cluster replicate df0190853a53d8e078205d0e2fa56046f20362a7

节点 ID 通过 cluster nodes 查看,每行最前面的 40 位十六进制字符串即为 ID。

4.3 删除节点

127.0.0.1:30001> cluster forget df0190853a53d8e078205d0e2fa56046f20362a7

cluster forget 使用的是节点 ID,而非 IP:Port(与 cluster meet 的参数形式不同)。


优缺点与局限性

方式适用场景限制 / 踩坑点
create-cluster 工具本地测试、快速验证主从数量固定、所有节点在同一台机器、不可用于生产
手动配置 redis-cli --cluster create生产环境步骤较多,需逐个修改配置文件并手动启动节点
cluster meet 添加节点临时扩容新节点加入后不带任何槽位,需手动迁移槽位(本篇未覆盖,见下篇)
cluster forget 删除节点节点下线若被删节点仍持有槽位,需先迁移槽位再删除,否则会丢数据
  • add-node 时若目标节点非空(已有数据或已知其他节点),会报 [ERR] Node is not empty,需清空节点后重试
  • 生产中建议主从节点分布在不同物理机,create-cluster 会警告 Some slaves are in the same host as their master

行动清单

  1. 动手实验:在本地用 create-cluster 快速搭建 6 节点集群,执行 cluster nodescluster info 观察输出,建立直觉
  2. 手动搭建练习:按手动步骤创建 6 个独立配置目录,完整走一遍 redis-cli --cluster create 流程
  3. 验证动态扩容:在运行中的集群里 add-node 一个新主节点,用 cluster nodes 确认它没有槽位,理解”加入集群 ≠ 承载数据”
  4. 预习下一篇:本篇未覆盖**槽位迁移(reshard)、故障转移(failover)**细节,阅读下篇《Redis 集群模式(下)》补全知识闭环
  5. 对比三种高可用方案:梳理主从同步、哨兵模式、Cluster 模式在架构和适用场景上的差异,形成选型判断依据

Redis 集群模式(下)


一句话摘要

新增主节点后必须通过 reshard 手动分配槽位才能真正承担数据;本篇完整覆盖槽位重分配、负载均衡、Java 客户端接入、集群故障发现与自动故障转移的全流程。


核心知识点

1. 集群现状查看

使用 --cluster info 查看各主节点的槽位分配和从节点数量:

$ redis-cli --cluster info 127.0.0.1:30001

示例输出:

127.0.0.1:30001 (887397e6...) -> 0 keys | 5461 slots | 1 slaves.
127.0.0.1:30007 (df019085...) -> 0 keys | 0 slots | 1 slaves.   # 新增节点,无槽位
127.0.0.1:30003 (f5958382...) -> 0 keys | 5461 slots | 1 slaves.
127.0.0.1:30002 (3da35c40...) -> 0 keys | 5462 slots | 1 slaves.

动态加入的主节点(30007)默认槽位为 0,不处理任何数据,必须手动执行重分片。


2. 重新分片(reshard)

命令:

$ redis-cli --cluster reshard 127.0.0.1:30007

交互流程(三步确认):

步骤提示输入示例
① 移动槽位数How many slots do you want to move?4000
② 目标节点 IDWhat is the receiving node ID?df0190853a53d8e078205d0e2fa56046f20362a7(30007 的 ID)
③ 来源节点Source node #1:all(从所有主节点均匀抽取)
④ 确认执行Do you want to proceed? (yes/no)yes

执行后用 cluster slots 验证分配结果:

$ redis-cli -c -p 30001
127.0.0.1:30001> cluster slots

30007 最终获得三段不连续槽位:[0-1332][5461-6794][10923-12255],共 4000 个槽。

踩坑:执行 reshard 时报 /usr/bin/env: ruby: No such file or directory,原因是工具依赖 Ruby 环境,执行 yum install ruby 安装即可。


3. 槽位定位算法

Redis 集群共 16384 个槽,key 的归属槽位计算公式:

slot = CRC16(key) % 16383
  • 每个主节点维护一部分槽及其映射的键值数据。
  • 客户端读写时,节点根据此公式计算目标槽,若不在本节点则返回 MOVED 重定向。

4. 负载均衡(rebalance)

自动将各主节点的槽数量重新均摊:

$ redis-cli --cluster rebalance 127.0.0.1:30007

当集群已处于均衡状态时(各节点偏差在 2% 以内),命令直接退出:

*** No rebalancing needed! All nodes are within the 2.00% threshold.

5. Java 代码接入集群(Jedis)

使用 JedisCluster 替代普通 Jedis 对象,API 方法名完全一致:

import redis.clients.jedis.HostAndPort;
import redis.clients.jedis.JedisCluster;
import java.util.HashSet;
import java.util.Set;

public class ClusterExample {
    public static void main(String[] args) {
        Set<HostAndPort> nodes = new HashSet<>();
        nodes.add(new HostAndPort("127.0.0.1", 30001));
        nodes.add(new HostAndPort("127.0.0.1", 30002));
        nodes.add(new HostAndPort("127.0.0.1", 30003));
        nodes.add(new HostAndPort("127.0.0.1", 30004));
        nodes.add(new HostAndPort("127.0.0.1", 30005));
        nodes.add(new HostAndPort("127.0.0.1", 30006));

        JedisCluster jedisCluster = new JedisCluster(
            nodes,
            10000,  // 超时时间(ms)
            10      // 最大尝试重连次数
        );

        String setResult = jedisCluster.set("lang", "redis");
        System.out.println("添加:" + setResult); // 添加:OK

        String getResult = jedisCluster.get("lang");
        System.out.println("查询:" + getResult); // 查询:redis
    }
}

关键点: 节点列表不必包含全部节点,JedisCluster 会自动发现集群拓扑;但建议配置多个节点地址,防止单点启动失败。


6. 故障发现:疑似下线(PFAIL)与确定下线(FAIL)

状态触发条件对应哨兵概念
疑似下线(PFAIL)节点 A 向节点 B 发送 PING,规定时间内未收到 PONG主观下线
确定下线(FAIL)收到超过集群半数节点对同一节点 PFAIL 的广播客观下线

确定下线后立即触发主从切换(故障转移)。


7. 故障转移流程

  1. 从下线主节点的所有从节点中选出一个新主节点。
  2. 该从节点执行 SLAVEOF NO ONE,关闭复制,升级为主节点,已同步数据不丢失
  3. 新主节点撤销原主节点的所有槽指派,全部指向自己。
  4. 新主节点广播 **PONG** 消息,通知集群其角色和槽位变更。
  5. 新主节点开始处理请求,故障转移完成。

8. 新主节点选举原则(Raft 思想)

  • **epoch****(纪元):**全局自增计数器,初始为 0,每轮选举加 1。
  • 每个主节点在一个 epoch 内只有一票,投给第一个请求投票的从节点。
  • 从节点检测到其主节点确认下线后,向集群广播投票请求。
  • 从节点获票数 > 集群主节点总数的一半,当选新主节点。

优缺点与局限性

reshard 槽位迁移

  • 适用场景:新增主节点后、节点负载严重不均时。
  • 限制:迁移过程中槽位数据在线搬移,可能短暂影响性能;槽位迁移是增量的,不会一次性原子完成。
  • 踩坑:迁移后需用 **cluster slots** 手动验证分配是否符合预期,不能仅凭命令成功退出来判断。

rebalance 负载均衡

  • 有 2% 的默认容差阈值,轻微不均衡时不触发,避免频繁迁移。
  • 无法控制哪些节点作为来源或目标,需更精细控制时应改用 reshard。

故障转移

  • 选举需要超过半数主节点投票,因此集群至少需要 3 个主节点才能保证在一个主节点宕机时仍能完成选举。
  • 故障转移期间该槽位数据不可用,存在短暂服务中断窗口。

行动清单

  1. 动手验证 reshard:本地搭建 3 主 3 从集群,添加一个空主节点后执行 reshard,观察槽位从各节点均匀抽取的过程。
  2. 验证槽位算法:用 redis-cli --cluster keyslot <key> 命令测试不同 key 的槽位计算结果,与公式 CRC16(key) % 16383 对比。
  3. Java 接入实践:用上述 JedisCluster 代码连接本地集群,尝试故意写入同名 key,观察 MOVED 重定向日志。
  4. 模拟故障转移:手动 kill 一个主节点进程,观察集群通过 PFAIL→FAIL→选举→PONG 广播的完整流程(可配合 cluster nodes 命令实时监控状态变化)。
  5. 学习 Hash Tag:研究如何**{tag}** 语法将相关 key 强制映射到同一槽,以支持集群模式下的 pipeline 和多 key 操作。
  6. 横向对比:将集群模式的故障发现/转移机制与哨兵模式(主观下线/客观下线/Sentinel 选举)进行对比梳理,加深理解。

Redis 切片集群(Sharding Cluster)


一句话摘要

单实例 Redis 在数据量大时因 fork 阻塞导致性能下降,切片集群通过横向扩展 + 哈希槽机制将数据分散到多个实例,兼顾容量与性能;核心机制是 CRC16 → 哈希槽 → 实例 的两级映射,客户端通过 MOVED/ASK 重定向保持路由一致性。


核心知识点

1. 问题根源:大内存单实例的性能陷阱

现象:用 INFO 命令观测 **latest_fork_usec** 指标,当值接近秒级,说明 fork 阻塞严重。

原因链

  • RDB 持久化 → Redis fork 子进程 → fork 耗时与数据量正相关 → fork 期间阻塞主线程
  • 25GB 数据的单实例场景下,fork 阻塞可达秒级

验证命令

INFO persistence   # 查看 latest_fork_usec 字段

2. 两种扩展方案对比

维度纵向扩展(Scale Up)横向扩展(Scale Out)
方式升级单实例内存/CPU/磁盘增加 Redis 实例数量
优点实施简单、无分布式复杂度扩展性强、无硬件上限
缺点受硬件和成本上限约束;内存越大 fork 阻塞越严重需解决数据分布和路由问题
适用不需要持久化、数据量中等百万/千万用户规模,数据量持续增长

3. Redis Cluster 的哈希槽机制(核心)

两级映射关系:Key → 哈希槽 → 实例

第一级:Key → 哈希槽

哈希槽编号 = CRC16(key) % 16384
  • 总槽数固定为 16384
  • CRC16 输出 16bit 值,对 16384 取模,结果范围 0~16383

第二级:哈希槽 → 实例(两种分配方式)

方式一:cluster create 自动均分,每实例分得 16384 / N 个槽

方式二:手动分配(适合实例配置不均等的场景):

redis-cli -h 172.16.19.3 -p 6379 cluster addslots 0,1
redis-cli -h 172.16.19.4 -p 6379 cluster addslots 2,3
redis-cli -h 172.16.19.5 -p 6379 cluster addslots 4

⚠️ 手动分配时,必须把 全部 16384 个槽都分配完,否则集群无法正常工作。

为什么不用”键值对 → 实例”直接映射表?
键值对数量可达亿级,映射表本身会极大,维护成本极高;而哈希槽只有 16384 个,映射表小且稳定,实例变动时只需重新分配槽,无需重写亿级条目。


4. 客户端路由:如何找到数据在哪个实例

初始化阶段:客户端与任意实例建立连接后,实例通过实例间的 Gossip 协议将哈希槽分配信息扩散给所有实例,客户端获取全量槽位信息并缓存在本地。

正常请求流程

客户端本地计算 CRC16(key) % 16384 → 查本地缓存 → 直发对应实例

触发路由变更的场景

  • 集群新增/删除实例,需重新分配哈希槽
  • 负载均衡触发槽在实例间迁移

5. 两种重定向机制

MOVED 重定向(迁移已完成)

触发条件:槽的数据已完全迁移到新实例,客户端缓存过期,此时请求的实例上没有这个键值对映射的哈希槽,该实例就会返回给客户端下面的 MOVED 响应,包含了新实例的访问地址。

响应示例:

GET hello:key
(error) MOVED 13320 172.16.19.5:6379

含义:槽 13320 的数据现在在 **172.16.19.5:6379**

客户端行为:更新本地缓存,后续所有对该槽的请求直接发往新实例。


ASK 重定向(迁移进行中)

触发条件:槽的数据正在迁移,**部分 key 已迁到新实例,部分还在旧实例,**此时请求的实例上因为在迁移的过程中,这个键值对映射的哈希槽已经被迁移到新的实例,该实例就会返回给客户端下面的 ASK 响应,包含了新实例的访问地址。

响应示例:

GET hello:key
(error) ASK 13320 172.16.19.5:6379

客户端行为:

  1. 先向 172.16.19.5:6379 发送 ASKING 命令(告知实例允许本次请求)
  2. 再发送原始操作命令

⚠️ ASK 不更新客户端本地缓存,下次请求该槽仍先发旧实例,只是本次临时重定向。

对比项MOVEDASK
触发时机迁移完成后迁移进行中
是否更新缓存✅ 更新❌ 不更新
作用范围持久生效仅本次有效

优缺点与局限性

切片集群优势

  • 单实例 fork 的数据量降低(25GB → 每实例 5GB),fork 阻塞时间大幅缩短
  • 横向无限扩展,不受单机硬件上限约束

切片集群局限与踩坑点

  • 槽未分配完:手动分配时漏掉任何槽,整个集群拒绝服务
  • 客户端缓存滞后:实例增减或迁移后,客户端不能主动感知,必须依赖重定向才能修正缓存
  • ASK 状态下的复杂性:迁移中访问需要两次额外交互(ASKING + 原命令),会短暂增加延迟
  • 多 key 操作受限:跨槽的批量操作(如 MGET 多个不同哈希槽的 key)在 Redis Cluster 中不被支持
  • Redis 3.0 之前无官方切片方案,需要依赖第三方(Codis、Twemproxy、ShardedJedis)

纵向扩展适用场景

  • 不需要 RDB/AOF 持久化
  • 数据量在单机硬件可承载范围内(建议不超过 32GB 以规避 fork 阻塞风险)

行动清单

  1. 动手部署 Redis Cluster:用 Docker 启动 3~6 个 Redis 实例,用 cluster create 命令建集群,观察哈希槽自动分配结果(CLUSTER INFO / CLUSTER SLOTS 命令验证)
  2. 监控 fork 阻塞:在现有 Redis 实例上执行 INFO persistence,关注 latest_fork_usec,建立基线值,超过 1 秒需要考虑切分数据
  3. 模拟重定向:手动迁移一个哈希槽(CLUSTER SETSLOT 系列命令),用 redis-cli 访问该槽的 key,观察 MOVED 和 ASK 报错的完整过程
  4. 理解哈希槽设计权衡:思考为何选 16384 而非更大的数字(Gossip 消息体大小与节点数的平衡),阅读 Redis 作者的 GitHub 说明
  5. 进阶对比:后续学习 Codis vs Redis Cluster 的差异(对应专栏第 35 讲),重点关注:代理层方案与 P2P 方案在运维复杂度、性能开销上的区别
  6. 多 key 跨槽问题实践:用 {} 哈希标签(Hash Tag)强制同类 key 落入同一槽(如 user:{1001}:nameuser:{1001}:age),解决业务中批量操作的跨槽限制

缓存高可用:Redis 集群方案


一句话摘要

Redis 单点故障风险通过主从复制 → 哨兵机制 → 集群方案三级演进解决,核心目标是自动故障转移(Failover)横向扩展能力。


核心知识点

1. 主从复制(Master-Slave Replication)

概念:将一台 Redis 节点的数据实时复制到其他节点,形成”一写多读”结构。任何节点均可充当主节点。

启用命令

SLAVEOF <master-ip> <master-port>

两大作用

  • 数据备份:主从最终一致性,降低数据丢失风险。
  • 读写分离:主节点处理写请求,从节点分担读请求,扩展读吞吐量。

缺陷:主节点宕机后,需要运维工程师手动从从节点中选一个晋升为主节点,并更新上游客户端配置——无自动故障转移能力。


2. Redis Sentinel(哨兵机制)

概念:官方推荐的 Redis 高可用方案,是一个独立运行的进程,对主从集群进行监控管理,实现自动 Failover。

三大核心功能

  • 不定期监控 Redis 节点运行状态。
  • 发现节点宕机时,通知上游客户端调整连接。
  • Master 不可用时,自动从 Slave 中选出新 Master,并更新数据同步关系。

关键设计——Sentinel 集群:Sentinel 本身存在单点问题,因此必须多节点部署。多个 Sentinel 节点之间互相监控,通过投票机制决定是否对某个 Redis 节点执行下线操作(主观下线 → 客观下线)。整个故障转移流程无需人工干预。

架构组成

多个 Sentinel 节点(互相监控)
    ↓ 监控
1 个 Master 节点 + N 个 Slave 节点

3. Redis Cluster(官方集群方案)

概念:官方原生的无中心化集群方案,所有节点均可对外提供服务,节点间通过 Gossip 协议通信。

核心机制——槽位分片

  • 将数据空间划分为 16384 个槽(slot)
  • 每个节点负责其中的一部分槽位,并持有完整的槽位映射表。
  • 客户端连接后获取槽位信息,访问某个 Key 时,根据本地槽位表直接路由到对应节点。

优点

  • 无 Proxy 层,客户端直连节点,读写性能最优。
  • 架构简洁,依赖组件少,支持横向扩展,官方称可扩展至 1000+ 节点
  • 路由分片、负载信息、节点状态维护全部内置。

4. Codis(国内开源方案)

概念:豌豆荚开源(作者后创立 PingCAP,即 TiDB)的 Redis 集群方案,与 Redis Cluster 思路相反,采用中心化架构

架构组成

上游客户端

Codis Proxy(路由 + 数据分片逻辑)

Redis 节点集群(底层存储引擎)

ZooKeeper(维护节点状态)

与 Redis Cluster 的核心区别

对比项Redis ClusterCodis
架构模式无中心化中心化(含 Proxy)
路由层节点内置,客户端直连Codis Proxy 统一处理
元数据存储节点间 Gossip 同步ZooKeeper 集中存储
监控与迁移相对复杂更简便直观
性能更高(无 Proxy 损耗)略低(多一层 Proxy)

优缺点与局限性

主从复制

  • ✅ 适用场景:读多写少,需要数据备份的低并发场景。
  • ❌ 限制:无自动故障转移,主节点宕机必须人工介入;写请求仍是单点瓶颈。

Redis Sentinel

  • ✅ 适用场景:中小规模集群,需要自动 HA 但不需要数据分片的场景。
  • ❌ 限制:仍是主从结构,写容量无法水平扩展;Sentinel 本身需多节点部署,增加运维成本。
  • ⚠️ 踩坑点:Sentinel 若只部署单节点,本身成为新的单点故障,HA 形同虚设。

Redis Cluster

  • ✅ 适用场景:大规模数据、高写入量、需要水平扩展的场景。
  • ❌ 限制:集群细节复杂,客户端需支持集群协议(MOVED/ASK 重定向);跨节点的多 Key 操作受限(Key 必须在同一槽位)。
  • ⚠️ 踩坑点:MGETPipeline 等批量操作需确保 Key 落在同一节点,可用 Hash Tag {tag} 强制同槽。

Codis

  • ✅ 适用场景:需要对 Redis 集群做精细化监控和平滑数据迁移的场景;对客户端透明(无需改造)。
  • ❌ 限制:Proxy 层引入额外网络开销;ZooKeeper 成为新的依赖和潜在故障点;社区活跃度低于官方 Redis Cluster。

行动清单

  1. 动手实践主从复制:本地搭建一主两从的 Redis 环境,用 SLAVEOF 命令配置,观察数据同步延迟。
  2. 配置 Sentinel 集群:在主从基础上部署 3 节点 Sentinel(奇数节点满足投票多数派),手动 kill 掉 Master,观察自动选主全流程。
  3. 深入 Redis Cluster 槽位机制:阅读官方文档,重点理解 CLUSTER INFOCLUSTER NODES 命令输出,以及 MOVED/ASK 重定向的区别。
  4. 了解 Codis 架构:访问 github.com/CodisLabs/codis 阅读官方架构图与 README,对比 Redis Cluster 加深理解。
  5. 研究 16384 槽设计原因:搜索 Redis 作者关于”why 16384 slots”的回答,理解该设计在心跳包大小与集群规模之间的权衡。
  6. 生产选型决策树:整理一份”Redis 高可用方案选型”的判断逻辑:单机 → 主从 → Sentinel(HA)→ Redis Cluster(水平扩展)。

Redis 集群构建:高性能与易扩展


一句话摘要

Redis 集群有三种分区方案(Client 端、Proxy 端、Redis Cluster),各有性能与运维成本的取舍,核心目标是通过分片实现写性能的水平扩展与更大的容量上限。


核心知识点

1. Client 端分区

概念:由客户端通过哈希算法决定 key 路由到哪个 Redis 分片,无中间层。

哈希算法选型:

  • 取模哈希hash(key) % N,直接映射到节点
  • 一致性哈希:节点增删时影响范围最小
  • 区间分布哈希:哈希后划分区间再映射节点。例如:哈希出 1024 个点,0~511 → 分片 1,512~1023 → 分片 2

多 key 请求处理:Client 将 key 按哈希分片分类,将单个请求拆分为多个子请求,分别发往对应分片节点。

DNS 管理主从

  • 每个分片的 master / slave 使用不同 DNS 域名
  • Client 解析域名获取 IP 列表,按权重建立连接,轮询访问 slave
  • 主从切换时,运维只需修改 DNS 下的 IP 列表,业务 Client 无需变更配置
  • Client 需异步定时探测主从域名,发现 IP 变更后及时重建连接

2. Proxy 端分区

概念:Client 只访问 Proxy 代理服务器,由 Proxy 负责解析请求、哈希计算、路由分发,屏蔽后端集群细节。

多 key 处理流程:Proxy 将多 key 请求拆分 → 分别访问各 Redis 分片 → 等待所有子响应到达 → 聚合返回 Client。

方案 A:Twemproxy

  • Twitter 开源,单进程单线程模型
  • 支持 Redis 和 Memcached 协议
  • 无管理后端,扩缩容需修改配置并重启,无法平滑扩缩
  • multi key 请求性能低(需拆分 + 聚合)

方案 B:Codis(推荐成熟方案)

组件构成:

组件职责
Codis-server基于 Redis 扩展,支持 1024 个 slot,支持同步/异步数据迁移
Codis-proxy解析请求,路由到对应 server group
Zookeeper / etcd存储元数据(Proxy 节点、路由表)
Codis-dashboard管理后台,支持节点增删、数据迁移、集群监控
  • 每个 server group = 1 master + N slave,相当于一个 Redis 分片
  • 支持在线数据迁移,业务 Client 无感知

3. Redis Cluster 分区

核心设计:

  • 集群包含 16384 个 slot,每个节点负责其中一部分
  • 去中心化架构,节点间通过 gossip 协议互联,每个节点保存全部 slot 拓扑
  • key 的 slot 计算公式:crc16(key) & 16383

请求路由(需 Smart Client):

  1. Client 发请求到任意节点
  2. 若 key 的 slot 在本节点 → 直接处理并返回
  3. slot 不在本节点 → 返回 **MOVED** 错误,携带正确节点的 host:port → Client 重定向
  4. Client 需本地缓存 slot → 节点映射表以加速访问

节点管理指令:

# slot 分配
CLUSTER ADDSLOTS <slot>
CLUSTER DELSLOTS <slot>
CLUSTER FLUSHSLOTS

# 节点加入集群
CLUSTER MEET <ip> <port>

# 添加从节点(集群模式专用,不能用 SLAVEOF)
CLUSTER REPLICATION

扩容完整流程(slot 迁移):

# 1. 部署新节点,配置 cluster-enabled yes 并启动
# 2. 将新节点加入集群
CLUSTER MEET <new-node-ip> <new-node-port>

# 3. 在新节点(目标节点)设置 slot 为 importing 状态
CLUSTER SETSLOT <slot> IMPORTING <source-node-id>

# 4. 在源节点设置 slot 为 migrating 状态
CLUSTER SETSLOT <slot> MIGRATING <target-node-id>

# 5. 循环获取并迁移 key
CLUSTER GETKEYSINSLOT <slot> <count>      # 获取 N 个 key
MIGRATE <host> <port> <key> <dbid> <timeout>            # 迁移单个 key
MIGRATE <host> <port> "" <dbid> <timeout> KEYS k1 k2    # 批量迁移多个 key

# 6. 迁移完成后,通知集群更新 slot 归属
CLUSTER SETSLOT <slot> NODE <target-node-id>

缩容流程:将目标节点的所有 slot 迁移走 → 用 **CLUSTER FORGET**** 通知集群移除节点**(同时将该节点加入禁止列表,1 分钟内禁止重新加入,防止误操作)。

slot 迁移中的 key 访问处理:

key 状态处理方式
在本地 DB 中存在(未迁移)直接本地读写,不受迁移影响
本地 DB 找不到,且所属 slot 正在迁移返回 ASK 错误 + 目标节点 host:port,Client 重定向
本地 DB 找不到,且 slot 不属于本节点返回 MOVED 错误 + 正确节点信息,Client 重定向

管理工具: redis-trib.rb(Ruby 开发,需安装依赖),封装了集群创建、节点增删、在线 slot 迁移等操作。


优缺点与局限性

Client 端分区

维度说明
优点无额外中间层,性能最高;逻辑简单;节点间无需协调
缺点扩容不灵活,只能成倍扩展或预分配足够分片;扩容后业务端需修改分发逻辑并重启
适用场景分片数量稳定、变动少的业务

Proxy 端分区(Twemproxy vs Codis)

维度TwemproxyCodis
优点实现简单、稳定性高在线迁移、管理后台完善
缺点单进程单线程,multi key 性能低;扩缩容需重启架构更复杂
通用缺点请求多一跳,性能损耗约 5~15%
适用场景访问量不大、扩缩容极少的业务需要平滑扩缩容的中大型业务

Redis Cluster

维度说明
优点官方原生支持;去中心化;支持在线扩缩容;工具链完整
缺点 1数据存储与集群逻辑耦合,代码复杂、容易出错
缺点 2每个节点需存储 slot-key 映射关系,额外内存占用明显(尤其 value 小、key 大的业务)
缺点 3key 迁移是**阻塞模式,迁移大 value 导致服务短暂卡顿;**迁移逐 key 获取再发送,效率低
缺点 4slave 只能挂载 master,不支持 slave 级联,无法支持读 TPS 极高(需大量 slave)业务
gossip 协议局限元数据更新存在延迟,集群操作不能立即同步到所有节点
踩坑点**集群模式下添加 slave 必须用 **CLUSTER REPLICATION**,不能用 ****SLAVEOF**
踩坑点单次 migrate 迁移的 key 不能过多,单个 value 不能过大,否则会造成 Redis 阻塞卡顿

行动清单

  1. 动手搭建 Redis Cluster:用 redis-trib.rbredis-cli --cluster create 搭建一个 3 主 3 从的本地测试集群,走一遍扩容(加新分片 + slot 迁移)和缩容的完整流程。
  2. 验证 MOVED / ASK 重定向:用普通 Redis 客户端访问集群,故意将请求发到错误节点,观察 MOVEDASK 响应的实际报文格式,加深理解 Smart Client 的路由机制。
  3. 测试 slot 迁移阻塞影响:在迁移一个含大 value 的 slot 时,用 redis-cli --latency 监控延迟,量化卡顿时长,建立对迁移粒度的实感。
  4. 对比三种方案的延迟:在相同硬件条件下,分别测试 Client 直连、Twemproxy 代理、Redis Cluster 的 p99 延迟,验证 Proxy 5~15% 损耗是否符合预期。
  5. 评估 Codis 适用性:若当前业务需要平滑扩缩容且 Client 侧难以改造,搭建 Codis 环境,重点测试其在线数据迁移过程中的业务可用性。
  6. 规划分片数量:对于 Client 端分区方案,提前规划好 slot / 分片数量(建议预留 2~4 倍余量),避免后期扩容时必须修改业务逻辑重启服务。

缓存雪崩、击穿、穿透


一句话摘要

Redis 缓存层的三类核心故障模式**(雪崩、击穿、穿透)**在触发原因上各不相同,解决思路也因此分叉:雪崩和击穿的本质是数据暂时不在缓存,恢复后系统自愈;穿透的本质是数据根本不存在,必须在请求链路前端拦截。


核心知识点

一、为什么需要缓存

  • 数据库数据落在磁盘,读写速度是计算机硬件中最慢的。
  • Redis 是内存数据库,读写速度比磁盘快若干数量级,用作缓存层可大幅降低数据库压力。
  • 引入缓存层随之带来三类异常问题:雪崩、击穿、穿透。


二、缓存雪崩

定义: 大量缓存数据同时过期,或 Redis 整体宕机,导致海量请求全部打到数据库,引发数据库崩溃及系统连锁反应。

原因 A:大量数据同时过期

应对方案实现方式关键细节
均匀设置过期时间在原有 **TTL(Time To Live 生存时间)**基础上加随机数expire = base_ttl + random(0, 300s)防止集中失效
互斥锁缓存失效时只允许一个请求访问数据库并更新 Redis 缓存,避免大量请求同时打到数据库锁必须设置超时时间,防止持锁线程阻塞导致全系统无响应
后台更新缓存缓存不设 TTL,由后台定时线程负责刷新内存紧张时缓存可能被淘汰,需配合以下两种补偿策略之一:① 后台线程以毫秒级间隔主动检测缓存是否存在;② 业务线程发现缓存缺失时通过消息队列通知后台线程更新(更及时,体验更好)。此机制同时适用于缓存预热(上线前提前填充缓存,而非等用户触发)。

原因 B:Redis 故障宕机

方案实现方式说明
服务熔断Redis 宕机后直接返回错误,停止访问数据库保护数据库但全部业务中断,是被动应急手段
请求限流只放行少量请求到数据库,其余在入口拒绝比熔断对业务影响更小,Redis 恢复后解除限流
Redis 高可用集群主从架构,主节点宕机后从节点自动切换为主节点最优先的预防手段,主动防范宕机引发雪崩

三、缓存击穿

定义: 单个热点数据(如秒杀商品)的缓存过期,此时大量并发请求全部穿透到数据库,将数据库打垮。

与雪崩的区别: 击穿是雪崩的子集,只涉及一个 key,雪崩是大批量 key 同时失效。

应对方案:

  • 互斥锁: 只允许一个线程回源数据库重建缓存,其余线程等待或返回空值(与雪崩方案相同)。
  • 不设过期时间: 热点数据的 TTL 设为永久,由后台异步更新缓存,或在到期前提前后台异步线程刷新并重设过期时间。

四、缓存穿透

定义: 请求的数据在缓存和数据库中都不存在,每次请求都穿透两层,导致数据库压力持续攀升且无法通过重建缓存来缓解。

常见触发场景:

  • 业务误操作:缓存与数据库数据被同时误删。
  • **黑客恶意攻击:大量请求伪造不存在的 ID(如 **id=-1**)**查询。

应对方案:

方案一:非法请求拦截
在 API 入口校验请求参数的合法性(如 ID 格式、范围),恶意请求直接返回错误,不进入缓存和数据库查询链路。

方案二:缓存空值
查询结果为空时,在 Redis 中为该 key 缓存一个空值或默认值,后续相同请求命中缓存直接返回,不再穿透到数据库。

方案三:布隆过滤器(推荐)

  • 用途: 业务线程确认缓存失效后,可以通过查询布隆过滤器快速判断数据是否存在,如果不存在,就不用通过查询数据库来判断数据是否存在。
  • 组成: 初始值全为 0 的位图数组(Bitmap)+ N 个哈希函数。
  • 写入流程(以 3 个哈希函数、长度为 8 的位图为例):
    1. 写数据 x 到数据库时,用 3 个哈希函数分别对 x 计算哈希值;
    2. 对每个哈希值取模 8,假设结果为 1, 4, 6
    3. 将位图数组第 1, 4, 6 位置置为 1
  • 查询流程: 对请求数据同样执行上述哈希计算,若对应位图位置不全为 1(存在 0),则该数据一定不在数据库中,直接拦截请求。
  • 核心特性:
    • 判断”不存在”:100% 准确(无假阴性)。
    • 判断”存在”:可能误判(有假阳性),因为不同数据可能映射到相同位图位置(哈希冲突)。
  • Redis 原生支持布隆过滤器。
  • 布隆过滤器拦截后,大量请求只查询 Redis 和布隆过滤器,不再触达数据库。
布隆过滤器误判率 ≈ (1 - e^(-kn/m))^k

m = bitmap 长度        → m 越大,误判率越低 ✅
n = 已插入元素数量     → n 越大,误判率越高
k = 哈希函数个数       → k 存在最优值,不是越多越好

三者关系:
  m ↑ → 位数组更稀疏 → 误判率 ↓
  n ↑ → 位数组更拥挤 → 误判率 ↑
  k 太少 → 映射位置少,碰撞概率高 → 误判率 ↑
  k 太多 → 填充位置太多,位数组快速变满 → 误判率 ↑


五、三者对比总结

缓存雪崩缓存击穿缓存穿透
触发条件大量 key 同时过期 / Redis 宕机热点 key 过期数据在缓存和 DB 中都不存在
影响范围全局大量请求单个热点 key 的大量请求针对不存在数据的大量请求
数据库能否恢复缓存重建后自愈缓存重建后自愈无法通过重建缓存自愈
核心方案随机 TTL / 互斥锁 / 后台更新 / 高可用集群互斥锁 / 热点 key 不设过期时间布隆过滤器 / 缓存空值 / 入口拦截


优缺点与局限性

均匀过期时间(随机 TTL)

  • 适用:通用缓存场景,实现成本最低。
  • 限制:治标不治本,极端情况下仍可能发生碰撞。

互斥锁

  • 适用:读多写少的热点数据。
  • 踩坑点:必须为锁设置超时时间,否则持锁线程异常阻塞时会导致全系统无响应;等锁期间的请求需处理好降级逻辑(返回空值还是等待)。

后台异步更新缓存

  • 适用:对一致性要求不极端严格、可接受短暂旧数据的场景;适合缓存预热
  • 踩坑点:内存不足时缓存被动淘汰,检测间隔过长会导致用户短暂读到空值;消息队列方案更及时但引入了额外基础设施依赖。

缓存空值

  • 适用:穿透频率不高的场景,实现简单。
  • 限制:如果不存在的 key 种类极多(如攻击者不断变换 ID),会大量占用 Redis 内存;需为空值 key 设置较短的 TTL,避免后续真实数据写入后仍返回空值。

布隆过滤器

  • 适用:需要抵御大规模穿透攻击的场景,性能极高。
  • 限制:存在误判(假阳性),不能 100% 确定数据存在;不支持删除操作(标记位不能还原,删数据后布隆过滤器状态与实际不符);需在数据写入时同步标记,改造成本略高。

Redis 高可用集群

  • 适用:生产环境必选,防止 Redis 单点故障引发雪崩。
  • 限制:主从切换存在短暂延迟,需配合熔断/限流兜底。

行动清单

  1. 动手实现互斥锁方案: 使用 Redis 的 **SET key value NX PX timeout****(原子性加锁 + 超时)**实现缓存重建的互斥锁,练习正确的超时时间设置和锁释放逻辑。
  2. 实践布隆过滤器: 在 Redis 中启用 RedisBloom 模块,用 BF.ADD 写入数据、BF.EXISTS 查询,理解误判率与 Bitmap 大小、哈希函数数量的关系。
  3. 模拟缓存穿透攻击场景: 写一个压测脚本,用随机不存在的 ID 请求接口,观察数据库 QPS 变化,然后分别接入”缓存空值”和”布隆过滤器”方案,对比效果。
  4. 搭建 Redis 主从集群: 在本地用 Docker Compose 跑一个 Redis 一主两从结构,练习主节点故障后的故障转移流程(Sentinel 或 Cluster 模式)。
  5. 梳理现有项目中的 TTL 设置: 检查当前项目中是否存在大量 key 使用相同过期时间,如有则添加随机偏移量(random.randint(0, 300) 秒)。
  6. 进阶阅读: 《Redis 核心技术与实战》(极客时间)对应缓存章节,以及参考 doocs/advanced-java 缓存穿透/雪崩章节

缓存策略:穿透、并发、雪崩与热点设计


一句话摘要

以电商商品详情页为场景,系统性拆解缓存穿透、缓存并发、缓存雪崩三大高频故障的成因与解法,并给出热点数据动态缓存和缓存解耦的工程级设计方案。


核心知识点

1. 缓存穿透

定义: 查询一个缓存和数据库中都不存在的 key,每次请求都穿过缓存直接打到数据库。恶意攻击者可以批量构造此类请求打垮数据库。

解决方案:预设空值

  • 对所有查询不到的 key,向缓存中写入一个默认值(如字符串 "Null"),并设置较短的 TTL。
  • 业务代码读到 "Null" 时,直接跳过数据库查询,或等待一段时间后重试。
  • 下次该 key 有真实数据时,缓存被正常更新,"Null" 值失效。

补充方案:布隆过滤器(了解即可,不推荐生产使用)

  • 存在误判概率,实现复杂度较高。

2. 缓存击穿

定义: 某热点 key 过期失效的瞬间,大量并发请求同时缓存未命中,全部涌向数据库查询同一条数据,并重复回写缓存。

解决方案:Redis setNX 互斥锁

完整流程如下:

1. 客户端请求 → 查 Redis,命中 → 直接返回
2. 未命中 → 执行 Redis SETNX lock_key 1(尝试加锁)
3. 加锁成功 → 查数据库 → 更新缓存 → 释放锁 → 返回数据
4. 加锁失败(锁已被占用) → sleep 一段时间 → 重新查 Redis
5. 重查命中(其他请求已回填) → 直接返回

核心命令:

SETNX lock_key 1   # 原子性设置锁,仅当 key 不存在时成功

效果: 同一时刻只有一个请求查库并回写缓存,其余请求排队等待重试。


3. 缓存雪崩

定义: 大量 key 被设置了相同的过期时间,集中到期后缓存批量失效,高并发请求同时打到数据库,导致数据库压力骤增甚至宕机。

解决方案一:随机打散过期时间

缓存过期时间 = 基础 TTL + random(1~10 分钟)

每个 key 的到期时间不再集中,大幅降低批量失效概率。

解决方案二:设置缓存永不过期

  • 由后台定时任务或异步服务主动更新缓存数据。
  • 缓存始终有值,彻底消除过期导致的雪崩风险。
  • 同时也能缓解缓存并发问题。

4. 动态热点数据缓存策略

场景: 只缓存访问量 Top 1000 的商品,而非全量数据。

设计思路:基于访问时间的排序队列

数据结构:
  - 排序队列(Redis Sorted Set):存储 1000 个商品 ID,score = 最近访问时间
  - 商品详情缓存:存储实际商品数据,以商品 ID 为 key

更新逻辑:
  - 每次访问 → ZADD 更新该商品 score(访问时间戳)
  - 定期任务 → 过滤掉 ZRANGE 末尾排名最后 200 个商品
              → 从数据库随机读取 200 个商品补入队列

查询逻辑:
  - 请求到达 → ZRANGE 获取热点 ID 列表
  - 命中 → 从商品详情缓存读实际数据并返回

核心命令:

ZADD  hot_products <timestamp> <product_id>   # 更新访问时间
ZRANGE hot_products 800 999                   # 获取末尾 200 个(待淘汰)

5. 缓存与业务代码解耦

问题: 缓存操作直接写在业务代码里,耦合度高,可维护性差,违反”高内聚低耦合”原则。

解决方案:MySQL Binlog + Canal + MQ 异步链路

业务系统写入 MySQL

MySQL 产生 Binlog 日志

Canal 订阅并解析 Binlog(增量数据变更)

Canal 按约定数据格式发送消息到 MQ

应用系统消费 MQ 消息 → 更新 Redis 缓存

效果:

  • 业务代码只管写数据库,缓存更新完全异步化。
  • 缓存层变更不影响业务逻辑,架构职责清晰。

优缺点与局限性

方案适用场景限制/踩坑点
预设空值(穿透)数据库中确实不存在某 key 的场景空值 TTL 设太长会导致真实数据写入后有延迟;需额外处理”Null”标记的业务逻辑
setNX 互斥锁(击穿)热点单 key 瞬时并发必须设置锁的过期时间(SET key val EX time NX),防止持锁进程崩溃导致死锁;等待时间设置需调优
随机 TTL(雪崩)大批量 key 同时写入的场景只能降低概率,不能完全消除;极端情况下随机区间内仍可能集中失效
永不过期(雪崩)数据更新不频繁、一致性要求不极端的场景强依赖后台更新任务的稳定性;存在短暂的数据不一致窗口期
Sorted Set 热点队列商品、文章等有明显热点分布的业务队列维护有额外写开销;随机补入数据不一定是真实热点,冷启动阶段效果有限
Binlog+Canal+MQ 解耦缓存更新逻辑复杂、多服务共享缓存的场景引入 Canal 和 MQ 增加了运维复杂度;消息消费延迟导致缓存有短暂不一致;需保证 MQ 消息不丢失

行动清单

  1. 动手验证 setNX 防并发: 在本地 Redis 环境中,用 SET key val EX 30 NX 模拟分布式锁,测试多线程并发场景下的互斥效果(注意 EX 防死锁)。
  2. 实现空值缓存: 在现有项目的查询方法中,对数据库返回空的情况写入 "NULL" 标记并设置 60 秒 TTL,观察数据库查询量变化。
  3. 验证随机 TTL 效果: 在批量 key 写入逻辑中,将 expire = base_ttl + random.randint(0, 600) 替换固定值,用压测工具(如 wrk/JMeter)对比雪崩前后 DB QPS 曲线。
  4. 搭建 Canal 本地演示环境: 参考 Canal 官方文档,配置 MySQL Binlog → Canal → Console 输出,理解 Binlog 解析的数据格式,为后续接入 MQ 打基础。
  5. 深入布隆过滤器原理: 了解其误判率与位数组大小、哈希函数个数的关系,评估在自己业务中使用的可行性(Guava BloomFilter 或 Redis 的 RedisBloom 模块)。
  6. 阅读上一讲(第13讲): 补充 Redis 线程模型、持久化(RDB/AOF)、主从复制原理,与本讲缓存策略形成完整知识闭环。

Redis 与 MySQL 数据一致性保证

一句话摘要

缓存一致性追求的是最终一致性而非强一致性,根据业务对延迟的容忍度,从简到复依次有四种方案可选,大多数场景方案一已足够。


核心知识点

为什么需要缓存

  • MySQL 支持完整 ACID,架构复杂,高并发下性能瓶颈明显
  • 局部性原理:80% 请求集中在 20% 热点数据
  • Redis 作为 MySQL 前置盾牌,缓存命中直接返回,未命中再穿透到 MySQL
  • 适用场景:读多写少

方案一:过期失效(顺其自然)

机制: 只依赖 Redis TTL,MySQL 更新时不操作 Redis,等缓存自然过期后下次请求重建。

适用场景: 业务对延迟有一定包容性,读多写少的标准场景。实际调研 4~5 个团队,均采用此方案。

优点缺点
原生接口,开发成本极低不一致窗口 = TTL 时长
管理成本低,出问题概率小TTL 太短 → 频繁缓存失效;TTL 太长 → 长时间脏数据

方案二:更新时主动删除(从头再来)

机制: 在方案一基础上,MySQL 更新成功后尝试删除 Redis 对应 key,下次读取时触发缓存重建。TTL 作为兜底保障,主动删除只是缩短不一致窗口。

⚠️ 删除操作不能作为关键路径,失败了退化为方案一即可,不应阻塞主流程。

优点缺点
最终一致延迟比方案一更小删除失败直接退化方案一
实现成本低,仅增加删除逻辑业务服务需同时连接 MySQL + Redis,连接数双倍消耗,高并发下易打满连接池

方案三:消息队列异步更新(信箱投递)

机制: MySQL 更新后,将 Redis 更新操作投递到消息队列(MQ),独立消费服务异步消费并更新 Redis。投递成功即返回,不关心消费结果。

可靠性保障: 消费端采用手动提交 offset,保证更新操作至少执行一次(at-least-once)。

优点缺点
业务与缓存更新解耦时序问题:两台服务器同时写 a=1a=5,MySQL 保证顺序但 MQ 无法保证,Redis 最终可能是旧值
MQ 自带可靠性,投递成功有保障引入 MQ + 消费服务,架构成本高
仍有双倍连接数问题

方案四:订阅 Binlog(完全解耦)

机制: 同步服务作为 MySQL slave,订阅 binlog,解析日志内容,异步更新 Redis。业务层对缓存更新完全无感知。

MySQL binlog → 同步服务(slave 角色)→ 解析日志 → 更新 Redis

时序问题解决原因: binlog 是 MySQL 主库顺序写入的,天然保证操作顺序。

优点缺点
与业务完全解耦,无需改动业务代码需单独搭建同步服务 + binlog 解析基础设施,成本最高
解决时序性问题,可靠性最强同步服务崩溃或压力大时,Redis 将长时间持有过期数据
压力不大时延迟较低

方案选型决策树

业务对不一致极度敏感且数据频繁变更?

        └─ 是 → 别用缓存

        └─ 否

            └─ 一般容忍延迟?→ 方案一(够用)

            └─ 希望更新更及时?→ 方案二(注意勿作关键路径)

            └─ 延迟要求高 + 愿意投入成本?→ 方案四(直接跳过方案三)

**方案三(推模式)vs 方案四(拉模式):**既然已愿意付出消费逻辑的成本,方案四可靠性更强且无时序问题,直接用方案四


优缺点与局限性汇总

方案延迟实现成本时序安全连接压力推荐场景
方案一高(= TTL)大多数业务
方案二⭐⭐需要即时性但预算有限
方案三⭐⭐⭐不推荐(被方案四替代)
方案四⭐⭐⭐⭐高要求 + 充足资源

通用踩坑点:

  • 强一致性 ≠ 目标,强行追求会使缓存失去意义
  • 方案二的删除操作一旦成为关键路径,失败会直接影响核心业务
  • 方案三的时序问题是根本性缺陷,用 MQ 无法绕开

行动清单

  1. 动手验证方案一:在本地用 Redis TTL + MySQL 搭建最小 demo,观察不一致窗口的实际表现
  2. 复现方案三时序问题:模拟两个并发写请求,验证 MQ 乱序导致 Redis 脏数据的场景
  3. 学习 Canal:阿里开源的 MySQL binlog 订阅工具,是方案四的标准实现,读官方文档了解部署方式
  4. 压测连接数瓶颈:在方案二/三场景下,用 wrk 或 JMeter 模拟高并发,观察 MySQL + Redis 连接数变化
  5. 深入 binlog 格式:了解 ROW / STATEMENT / MIXED 三种 binlog 格式对同步服务解析的影响

数据库与缓存一致性保证


一句话摘要

引入 Redis 缓存后,更新操作需同时维护数据库和缓存,最优策略是「先更新数据库,再删除缓存」,并配合消息队列重试Canal binlog 订阅来兜底第二步操作失败的情况。


核心知识点

1. 为什么「更新缓存」比「删除缓存」差

缓存数据通常聚合自多张底层表(如商品表 + 价格表 + 库存表),每次数据库写入都重新计算并更新缓存代价高昂。且更新后的缓存可能长时间不被访问,浪费计算资源。

Lazy Loading 思想:删除缓存,等下次查询未命中时再填充缓存,按需加载,避免无效计算。

结论:写操作一律删除缓存,不更新缓存,只在后续读操作未命中缓存时才加载到缓存


2. Cache Aside(旁路缓存)策略

这是引入缓存后的标准读写模型:

读策略:

  1. 查缓存,命中则直接返回
  2. 未命中则查数据库,将结果写入缓存,返回给用户

写策略:

  1. 更新数据库
  2. 删除缓存


3. 四种方案的并发问题对比

方案一:先更新数据库,再更新缓存 ❌

并发场景:

请求A: 更新DB → 1
请求B: 更新DB → 2, 更新缓存 → 2
请求A: 更新缓存 → 1   ← 覆盖了B的缓存
结果: DB = 2, 缓存 = 1  ← 不一致

方案二:先更新缓存,再更新数据库 ❌

并发场景:

请求A: 更新缓存 → 1
请求B: 更新缓存 → 2, 更新DB → 2
请求A: 更新DB → 1   ← 覆盖了B的DB数据
结果: DB = 1, 缓存 = 2  ← 不一致

方案三:先删除缓存,再更新数据库 ❌

并发场景(读 + 写):

请求A(写): 删除缓存
请求B(读): 缓存未命中,从DB读到旧值20,写入缓存
请求A(写): 更新DB → 21
结果: DB = 21, 缓存 = 20  ← 不一致

补救方案——延迟双删

redis.delKey(X)       # 第一次删除缓存
db.update(X)          # 更新数据库
Thread.sleep(N)       # 睡眠,等待读请求完成缓存回填
redis.delKey(X)       # 第二次删除缓存

睡眠时间 N > 请求B「查数据库 + 写缓存」的耗时。缺点:N 难以精确评估,极端情况仍可能不一致。

方案四:先更新数据库,再删除缓存 ✅(推荐)

并发场景(读 + 写):

请求A(读): 缓存未命中,从DB读到旧值20
请求B(写): 更新DB → 21, 删除缓存
请求A(读): 将旧值20写入缓存
结果: DB = 21, 缓存 = 20  ← 理论上不一致

为什么实际中此概率极低:缓存写入速度远快于数据库写入。请求A在写缓存之前,请求B已经完成「更新DB + 删除缓存」的概率极低。一旦请求A早于请求B删缓存就完成了写缓存,后续请求会因缓存未命中而重新从DB读取最新数据,自动修正。

兜底手段:给缓存设置过期时间,即使出现短暂不一致,过期后自动从DB重新加载。


4. 第二步操作失败的问题及解决方案

即使采用了「先更新数据库,再删除缓存」,若第二步**(删除缓存)失败**,则:

DB: X = 2(新值)
缓存: X = 1(旧值)  ← 删除失败,一直残留旧值

后续请求命中缓存,持续读到旧值。

解决方案 A:消息队列重试机制

流程:

  1. 更新数据库成功
  2. 将「删除缓存」操作投递到消息队列
  3. 消费者读取消息,执行删除缓存
  4. 删除成功 → 消息队列 ACK 确认
  5. 删除失败 → 重新入队,重试;重试超限则报警

关键点:必须在删除缓存成功后才回 ACK,否则提前 ACK 后失败则无法重试。

缺点:对业务代码侵入性强,需改造原有业务逻辑。

解决方案 B:订阅 MySQL Binlog + 消息队列(推荐)

工具:阿里巴巴开源的 Canal

Canal 工作原理:

  1. Canal** 伪装成 MySQL 从节点**
  2. 向 MySQL 主节点发送 dump 请求
  3. MySQL 推送 Binlog 给 Canal
  4. Canal 解析 Binlog 字节流,转换为结构化数据
  5. 下游系统订阅消费,执行删除缓存操作

完整链路:

MySQL(写) → Binlog → Canal → MQ队列 → 消费者 → 删除缓存
                                              ↓ 成功后
                                           ACK 确认

优点:与业务代码完全解耦,无代码侵入。
缺点:引入组件多(Canal + MQ),对团队运维能力要求高。


5. 「更新DB + 更新缓存」方案的并发控制手段

如果业务对缓存命中率要求极高,不愿意因删除缓存导致 miss,可用**「更新缓存」**方案,但必须加并发控制:

  • 分布式锁:**更新缓存前加锁,保证同一时刻只有一个请求写缓存。**代价:写入性能下降。
  • 短过期时间:更新缓存后**设置较短 TTL,不一致窗口可控。**代价:命中率仍会下降。

优缺点与局限性

方案适用场景限制 / 踩坑
先更新DB,再删缓存通用场景,首选方案第二步失败需重试兜底;影响命中率
延迟双删不得不用「先删缓存」时的补救睡眠时间 N 是玄学,极端情况仍不一致
消息队列重试团队有 MQ 基础设施侵入业务代码;ACK 必须在删缓存成功后
Canal + MQ大型项目、高一致性要求组件复杂,运维成本高
分布式锁 + 更新缓存命中率敏感的业务写性能损耗;锁粒度设计复杂

通用踩坑点:

  • 缓存必须设置过期时间作为兜底,不可裸奔
  • Canal 方案中 ACK 必须在删除缓存成功后发送,否则丢消息无法重试
  • 延迟双删的 sleep 时间要大于「DB读取 + 缓存写入」的最大耗时,实践中很难准确评估

行动清单

  1. 动手实现 Cache Aside 模式:用 Spring Boot + Redis 写一个标准的读写策略 Demo,包含缓存未命中时的 DB 回填逻辑。
  2. 复现并发问题:用 JMeter 或 Go 并发脚本模拟「读+写」并发请求,观察「先删缓存再更新DB」方案下的不一致现象。
  3. 搭建 Canal 环境:在本地用 Docker 启动 MySQL + Canal + RocketMQ/Kafka,跑通「Binlog → 消息队列 → 删除缓存」全链路。
  4. 实现消息队列重试:手写一个生产者-消费者模型,在消费者中模拟删除缓存偶发失败,验证重试机制和 ACK 时机的正确性。
  5. 深入学习:阅读 Canal 官方文档,理解 MySQL 主从复制协议(binlog dump 请求),以及 RocketMQ/Kafka 的 ACK 与重试机制。
  6. 系统设计延伸:研究 Lazy Loading 与 Write-Through、Write-Behind 三种缓存更新模式的适用场景对比。

Redis 分布式锁实现与可靠性保证

一句话摘要

用 Redis 实现分布式锁需经历四次迭代演进,最终结论是:没有完全可靠的分布式锁,锁必须配合业务幂等性设计才真正可用。


核心知识点

分布式锁的四大特性

特性含义
互斥性同一时刻只有一个竞争者持有锁
安全性持有者崩溃后锁能自动释放,避免死锁
对称性谁加锁谁解锁,不能释放他人的锁(即可重入性)
可靠性具备异常处理与容灾能力

版本一:最简实现(保证互斥性)

命令:

SETNX key value
  • key 不存在 → 设置成功,返回 1
  • key 存在 → 不操作,返回 0

加锁后用 DEL key 解锁。

缺陷: 持有锁的服务崩溃 → 锁永不释放 → 死锁。


版本二:支持过期时间(保证安全性)

错误做法: SETNX + EXPIRE 分两步执行,不具备原子性,宕机在两步之间仍会死锁。

正确命令:

SET key value NX EX seconds
  • NX:等价 setnx 语义
  • EX seconds:原子性设置过期时间

缺陷: 服务 A 锁过期后,服务 B 获取锁,服务 A 业务执行完毕后会误删服务 B 的锁。


版本三:加入 Owner 标识

加锁时写入唯一标识(如 UUID)作为 value,解锁前先校验 value 是否属于自己:

加锁:SET lock_key <uuid> NX EX 30
解锁:检查 value == uuid → 确认是自己的锁 → DEL

缺陷: “检查 value” 和 “DEL” 是两步操作,非原子性。检查通过后若锁恰好过期,此时该锁被他人获取,仍会误删。


版本四:引入 Lua 脚本(保证对称性)

将”校验 + 删除”合并为原子操作:

if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

Redis 执行 Lua 脚本具有原子性,彻底解决校验与删除的竞态问题。

至此满足互斥性、安全性、对称性三大特性,可用于大多数业务场景。


可靠性方案一:主从容灾 + 哨兵模式

主从容灾: 为 Redis 配置 Slave 节点,主节点宕机后手动切换从节点顶替。

哨兵模式: 在主从基础上实现自动切换,无需人工介入。

固有缺陷: 主从同步有时延,切换瞬间 Slave 可能丢失锁数据 → 多个竞争者同时获得执行权限。


可靠性方案二:RedLock(多机部署)

部署要求: 奇数个(如 5 个)独立 Redis 主节点。

加锁流程:

  1. 向全部 5 个节点发送加锁请求
  2. 超过半数(≥3)返回成功 → 加锁成功;否则向全部节点发送解锁
  3. 锁实际持有时间 = 设定时间 - 请求耗时;剩余时间 ≤ 0 则视为加锁失败
  4. 使用完毕后向全部 5 个节点发送解锁请求

优势: 允许 2 台宕机,集群仍可用;单节点主从切换不会导致锁失效。


RedLock 在 NPC 下的表现

即便是 RedLock,也无法应对分布式三大困境:

困境场景描述RedLock 表现
Network Delay
网络延迟
加锁成功但响应延迟,锁已临近过期通过减去请求耗时部分缓解
Process Pause
进程暂停
GC 期间锁超时,GC 结束后另一竞争者已持锁无解,恢复的进程误删当前持有者的锁
Clock Drift
时钟漂移
5 台节点时钟漂移,A 锁瞬间过期,B 可持有锁,但 A 以为自己还持有锁仍在继续操作无解,两个竞争者同时获得锁

根本结论:没有完全可靠的分布式锁。


优缺点与局限性汇总

方案适用场景核心限制踩坑点
版本一(SETNX)仅学习用途宕机死锁生产环境禁用
版本二(SET NX EX)简单低频场景可能误删他人锁注意过期时间设置
版本三(+Owner)大多数业务场景非原子校验删除高并发下仍有竞态
版本四(+Lua)✅ 推荐日常使用单点故障风险需评估 Redis 可用性要求
主从+哨兵可用性要求中等主从切换丢锁切换期间锁失效窗口
RedLock可用性要求较高NPC 三大问题时钟漂移、GC 暂停无法规避

通用踩坑点:

  • 对分布式锁强依赖是危险的,业务本身必须设计为幂等可重入(幂等 = 同一个操作执行多次,结果和执行一次完全相同;可重入 = 同一个操作被重复触发时,不会产生错误或脏数据)
  • 锁超时时间设置需结合实际业务执行时长,过短导致锁提前释放,过长影响系统吞吐

行动清单

  1. 动手实现版本四:用 Redis + Lua 实现完整的加锁/解锁,通过 redis-cli 验证原子性
  2. 模拟误删场景:用两个线程复现版本三中”校验通过后锁过期被抢占再被误删”的竞态
  3. 搭建哨兵模式:本地用 Docker 启动 1 主 2 从 + 3 哨兵,手动 kill 主节点,观察自动切换过程
  4. 阅读 RedLock 原始提案:Antirez 的博客 How to do distributed locking,以及 Martin Kleppmann 的反驳文章,形成独立判断
  5. 业务幂等性设计:梳理当前项目中依赖分布式锁的场景,评估是否能通过唯一约束、乐观锁等手段替代

分布式锁实现原理


一句话摘要

分布式锁解决多进程并发访问共享资源的协调问题,核心挑战是同时保证可用性、防死锁、防脑裂,三种主流实现**(MySQL / Redis / Redlock)**各有性能与可靠性的取舍。


核心知识点

1. 分布式锁必须解决的三个根本问题

可用性:锁服务任何时候都必须可用,是系统正常运行的前提。

死锁:持有锁的客户端崩溃或网络中断时,其他客户端仍能最终获得锁。

脑裂:集群数据同步不一致时,可能出现两个进程同时持有同一把锁。


2. 基于 MySQL 实现分布式锁

悲观锁(SELECT FOR UPDATE

用数据库行锁串行化操作,查询与插入必须在同一事务中提交,防止幻读。

SELECT id FROM order WHERE order_id = xxx FOR UPDATE;

乐观锁(版本号 ver

在表中增加 int 型字段 ver,先读取再更新时校验版本号,避免阻塞等待。

-- 读取时同时获取 ver
SELECT amount, old_ver FROM order WHERE order_id = xxx;

-- 更新时校验 ver 是否一致
UPDATE order SET ver = old_ver + 1, amount = yyy
WHERE order_id = xxx AND ver = old_ver;

UPDATE 影响行数为 1 → 成功;为 0 → 已被其他事务修改,需做异常处理。

数据库四种事务隔离级别(从低到高)

名称并发性能
READ UNCOMMITTED(读未提交)最高
READ COMMITTED(读已提交)较高
REPEATABLE READ(可重复读)
SERIALIZABLE(可串行化)最低

隔离级别越高,并发性能越差。


3. 基于 Redis 实现分布式锁

加锁命令:一条原子命令同时完成 SET + NX + 过期时间设置。

SET lock_key unique_value NX PX 10000
  • **NX**:仅当 **lock_key** 不存在时才执行 SET
  • PX 10000:过期时间 10 秒,防止客户端崩溃导致死锁
  • unique_value:客户端唯一标识,防止误释放他人持有的锁

解锁(Lua 脚本保证原子性):先比较 unique_value,相同才删除。

if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

超时续约(守护线程方案):主线程持锁期间,守护线程定期检测并在锁即将到期时重新设置超时时间,主线程执行完毕后销毁续约锁。


4. Redlock 算法(Redis 集群分布式锁)

问题背景:Redis 主从同步是异步的,主节点宕机且数据尚未同步到从节点时,新主节点仍可被加锁,导致两个客户端同时持有锁(脑裂)。

Redlock 解法部署 N 个独立 Redis 实例(无主从关系),客户端依次向所有实例加锁,满足以下条件才视为加锁成功:

  1. 超过半数(N/2 + 1)实例加锁成功
  2. 所有实例总耗时未超过锁的有效时间

某个实例宕机不影响整体锁的有效性,锁数据在其余实例上仍然保存。


5. 分布式锁的四项设计原则

互斥性:同一时刻只有一个进程/线程能持有锁。

锁释放:必须有失效机制(超时或主动释放),防止死锁。

可重入:已持有锁的节点可以再次获取同一锁资源。

高可用:锁服务集群化,单节点故障不影响整体服务。

特性含义
互斥性同一时刻只有一个竞争者持有锁
安全性持有者崩溃后锁能自动释放,避免死锁
对称性(可重入性)谁加锁谁解锁,不能释放他人的锁
可靠性具备异常处理与容灾能力

优缺点与局限性

MySQL 方案

说明
适用场景并发量低、对性能要求不高的业务场景
核心缺陷高并发下大量请求排队,性能瓶颈明显
踩坑点悲观锁会产生交叉死锁(事务1持有记录1等待记录2,事务2持有记录2等待记录1),需配合超时控制解决

Redis 方案

说明
适用场景大促(618、双11)等高并发场景,需要高性能锁
核心缺陷超时时间难以合理设置;单节点存在脑裂风险
踩坑点1Redis < 2.6.12 版本中 setnx 与设置过期时间是两个独立命令(非原子),线程在两者之间崩溃会导致锁永远不过期死锁;需使用 2.6.12+ 版本的原子 SET 命令或 Lua 脚本
踩坑点2超时时间过短:业务未执行完锁已失效,线程 A 的 del 会误删线程 B 的锁
踩坑点3超时时间过长:性能下降,锁资源长时间占用

Redlock 方案

说明
适用场景对可靠性要求极高、需应对 Redis 节点故障的场景
局限性需要部署多个独立 Redis 实例,运维成本高;加锁需串行请求 N 个实例,延迟高于单节点方案

行动清单

  1. 动手实现 Redis 分布式锁:用 SET key value NX PX 命令实现加锁,用 Lua 脚本实现原子解锁,跑通完整加锁/解锁流程。
  2. 复现超时误删 Bug:模拟线程 A 持锁超时后线程 B 加锁、线程 A 误删线程 B 锁的场景,验证 unique_value 校验的必要性。
  3. 实现续约守护线程:写一个守护线程,在锁过期前自动续期(参考 Redisson 的 WatchDog 机制)。
  4. 深入学习 Redlock:阅读 Redis 官方 Redlock 文档及 Martin Kleppmann 对 Redlock 的批评文章(“How to do distributed locking”),了解争议点。
  5. 补充 MySQL 锁机制:重点掌握 SELECT FOR UPDATE(悲观锁)、乐观锁版本号模式、四种事务隔离级别与幻读的关系(对应专栏第 10 讲内容)。
  6. 面试准备:以”可用性 → 死锁 → 脑裂”三个问题为纲展开回答,而不是直接背实现命令;能主动提及各方案的局限性和 Redlock 原理,体现架构思维。

分布式锁:原理演化与 Redisson 实战


一句话摘要

分布式锁的实现远不止一个 SETNX,需要依次解决原子性、误删他人锁、超时设置、可重入、主从数据丢失等问题,Redisson 是生产级的完整解决方案。


核心知识点

1. 分布式锁的三个核心特性

  • 互斥:任意时刻只有一个客户端持有锁
  • 无死锁:即使持锁客户端崩溃,锁最终也必须能被释放
  • 容错:大多数 Redis 节点存活即可正常工作
特性含义
互斥性同一时刻只有一个竞争者持有锁
安全性持有者崩溃后锁能自动释放,避免死锁
对称性(可重入性)谁加锁谁解锁,不能释放他人的锁
可靠性具备异常处理与容灾能力

2. 演化第一步:SETNX 基础实现

SETNX lock:168 1   # 返回 1 表示加锁成功,0 表示失败
DEL lock:168       # 释放锁

缺陷:客户端崩溃或业务异常时 DEL 无法执行,锁永远无法释放。


3. 演化第二步:加超时,但不能拆成两条命令

错误写法(非原子,EXPIRE 可能执行失败):

SETNX lock:168 1
EXPIRE lock:168 60

正确写法(Redis 2.6.X+ 原子指令):

SET resource_name random_value NX PX 30000
  • **NX**:key 不存在才 SET,保证互斥
  • **PX 30000**:30 秒自动过期,保证无死锁

4. 演化第三步:防止误删他人的锁

场景:客户端 1 超时锁自动释放 → 客户端 2 成功加锁 → 客户端 1 执行完毕执行 DEL,把客户端 2 的锁删了。

解决value 存唯一标识(UUID),解锁时先比对再删除,且必须用 Lua 保证原子性:

-- 解锁 Lua 脚本(GET + DEL 原子化)
if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

5. 超时时间设置原则

  • 在测试/压测环境测量平均执行时间,设置为 平均时间的 3~5 倍
  • 目的:为网络抖动、JVM FullGC 留缓冲
  • 不要设置过长:一旦节点宕机重启,整个分布式锁服务在该超时时间内全部不可用

6. 演化第四步:看门狗自动续期(Redisson Watch Dog)

问题:业务执行时间不可控,固定超时时间难以准确设置。

方案:持锁线程启动一个守护线程(Watch Dog),定时检测锁是否快过期,自动续期。

Redisson 实现细节

  • 默认锁超时 30s,Watch Dog 每 10s(即 30/3)续期一次
  • 续期通过 Lua 脚本执行:
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return 1;
end;
return 0;
  • Watch Dog 生效条件**lock()**** 时不显式指定 ****leaseTime**,否则不会自动续期
  • lockWatchdogTimeout 不能设置过小,否则续期时 key 可能已被删除

7. 加解锁代码位置规范

错误写法(异常时 unlock 不会执行):

redisLock.lock();
try {
    // 业务逻辑
    redisLock.unlock(); // ❌ 异常后执行不到
} catch (Exception e) { ... }

正确写法(lock 在 try 内,unlock 在 finally):

try {
    redisLock.lock();  // ✅ lock 放 try 内,防止加锁成功但读响应超时时丢失解锁机会
    // 业务逻辑
} catch (Exception e) {
    e.printStackTrace();
} finally {
    redisLock.unlock(); // ✅ 一定在 finally 中
}

8. 可重入锁:基于 Redis Hash 实现

可重入锁:可重入锁解决的是同一持有者在持锁期间再次请求同一把锁导致死锁的问题,最常见于递归调用、方法调用链、事务嵌套等场景,本质是通过记录持有者身份 + 引用计数来区分”自己重入”和”别人竞争”,只有计数归零才真正释放锁。

概念同一线程在已持锁的情况下再次请求同一把锁,不会阻塞,只累加计数。

数据结构Hash,key = 锁名,field = 线程唯一标识(UUID),value = 重入次数。

加锁 Lua 脚本

if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return 1;
end;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);
    redis.call('pexpire', KEYS[1], ARGV[1]);
    return 1;
end;
return 0;

解锁 Lua 脚本

if (redis.call('hexists', KEYS[1], ARGV[1]) == 0) then
    return nil;
end;
local counter = redis.call('hincrby', KEYS[1], ARGV[1], -1);
if (counter > 0) then
    return 0;
else
    redis.call('del', KEYS[1]);
    return 1;
end;
return nil;

解锁返回值:1 = 锁已释放,0 = 重入次数减 1(锁仍持有),**nil**** = 不是自己的锁,解锁失败。**


9. 主从架构的安全隐患

场景

  1. 客户端 A 在 master 加锁成功
  2. 锁数据尚未同步到 slave,master 宕机
  3. slave 晋升为 master,A 的锁数据丢失
  4. 客户端 B 成功加锁 → 互斥性被破坏

10. Redlock 红锁

目的解决主从切换导致多个客户端同时持锁的问题。

部署要求:5 个完全独立的 Redis 主节点(不使用主从复制,数量取奇数)。

加锁五步骤

  1. 记录当前时间 T1
  2. 顺序向 5 个节点请求加锁,每个请求设置远小于锁有效期的超时时间(如锁 10s,请求超时 5~50ms)
  3. 记录时间 T2,计算耗时 T3 = T2 - T1;当 N/2+1(即 3)个节点加锁成功,且 T3 < 锁有效期,才认为加锁成功
  4. 锁的真实有效期 = 设定有效期 - T3
  5. 整体加锁失败时,向所有节点发送解锁请求(无论各节点是否成功加锁)

争议(Martin vs Antirez)

  • Martin:Redlock 太重且依赖时钟假设,对于追求效率无必要,对于追求正确性又不够安全(无法提供 fencing token)
  • Antirez:时钟只需大体一致即可,允许合理误差;NPC 问题是 Zookeeper 同样解决不了的

11. Redisson 实战用法

Maven 依赖(SpringBoot 2.5.x):

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.16.4</version>
</dependency>
<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-data-25</artifactId>
    <version>3.16.4</version>
</dependency>

四种加锁模式

// 1. 失败无限重试 + Watch Dog 自动续期(最常用)
lock.lock();

// 2. 失败超时重试(10s内)+ Watch Dog 自动续期
boolean flag = lock.tryLock(10, TimeUnit.SECONDS);

// 3. 指定锁时间,无 Watch Dog,10s 后自动释放
lock.lock(10, TimeUnit.SECONDS);

// 4. 等待最多100s获锁,持锁10s自动释放,无 Watch Dog
boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);

优缺点与局限性

方案适用场景限制/踩坑
SET NX PX + Lua 解锁单节点/主从架构,对极低概率锁丢失可接受主从切换极小概率丢锁
Watch Dog(Redisson)执行时间不确定的业务不能显式设置 leaseTime,否则 Watch Dog 不生效;超时时间不能设太小
可重入锁(Redis Hash)同线程需要嵌套加锁的业务解锁逻辑稍复杂,需正确处理三种返回值
Redlock对锁安全性要求极高,可接受性能损耗需 5 个独立节点,性能低,存在时钟依赖争议,实现复杂

通用踩坑点

  • unlock() 必须在 finally
  • lock() 必须在 try 块内,防止加锁成功但客户端读超时时丢失解锁机会
  • 超时时间 = 平均执行时间 × 3~5,不可随意放大
  • Watch Dog 的 lockWatchdogTimeout 不能设置过小

行动清单

  1. 动手复现演化过程:用 Redis CLI 依次验证 SETNXSET NX PX、Lua 解锁脚本三个阶段,亲自感受每步解决的问题
  2. 阅读 Redisson 源码:从 lock()tryAcquire()tryAcquireAsync()scheduleExpirationRenewal()renewExpirationAsync() 这条调用链读一遍,重点看 Watch Dog 定时续期的实现
  3. 本地搭建 Redisson 环境:基于 SpringBoot + Redisson 3.16.4 跑通四种加锁模式,用日志验证 Watch Dog 的 10s 续期行为
  4. 实现一个简单可重入锁:脱离 Redisson,手写基于 Redis Hash + Lua 的可重入锁加解锁逻辑,加深理解
  5. 深入 Redlock 争议:阅读 Martin Kleppmann 的质疑博客(martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html)和 Antirez 的回复(antirez.com/news/101),形成自己的判断
  6. 横向对比:调研 ZooKeeper 分布式锁(curator)的实现方式,与 Redis 方案在性能、正确性、复杂度上做对比,确定项目选型依据

分布式锁 —— 可重入锁实现


一句话摘要

普通 Redis SET 分布式锁不支持可重入,通过** Redisson 的 Hash + Lua 脚本方案**,可原子性地记录**「线程标识 + 重入次数」**,从而实现与 ReentrantLock 等价的 Redis 可重入分布式锁。


核心知识点

1. 可重入锁的概念

不可重入锁:同一线程在已持有锁的情况下,再次尝试加锁会阻塞,导致死锁。

可重入锁(递归锁):同一线程在已持有锁后,仍可再次进入同样的代码块并再次加锁,不会阻塞。核心作用是防止同一线程多次获取锁时发生死锁。

Java 中 synchronizedReentrantLock 均为可重入锁。


2. ReentrantLock 可重入原理(参考基准)

加锁核心逻辑(nonfairTryAcquire

// 1. state == 0:无线程持有锁,CAS 将 state 从 0 改为 1,记录持有线程
if (c == 0) {
    if (compareAndSetState(0, acquires)) {
        setExclusiveOwnerThread(current);
        return true;
    }
}
// 2. 持有锁的线程就是当前线程:state 累加重入次数
else if (current == getExclusiveOwnerThread()) {
    int nextc = c + acquires;
    setState(nextc);
    return true;
}

解锁核心逻辑(tryRelease:每次 unlock()state - 1,直到 state == 0 才真正释放锁(将持有线程置 null)。

验证(3 次递归示例输出)

lock()  → getHoldCount()=1
lock()  → getHoldCount()=2
lock()  → getHoldCount()=3
unlock()→ getHoldCount()=2
unlock()→ getHoldCount()=1
unlock()→ getHoldCount()=0, isLocked()=false

3. 普通 Redis SET 为何不支持可重入

SET key value EX ttl NX 方式,value 只能存一个字符串。若要在 value 中嵌入「进程ID + 线程ID + 重入次数」,更新重入次数需要两步:先 GET 取出次数再 +1,然后重新 SET。两步之间 key 一旦过期,就存在并发写入问题,无法保证原子性。


4. Redisson 概述

  • Redis 官方推荐的 Java 客户端,网络层基于 Netty(NIO)。
  • 提供多种分布式锁:可重入锁、公平锁、联锁(MultiLock)、红锁(RedLock)、读写锁(ReadWriteLock)。
  • 可重入锁的关键设计:Redis Hash 数据结构 + Lua 脚本保证原子性

Maven 依赖

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson</artifactId>
    <version>3.15.5</version>
</dependency>


5. Redisson 客户端初始化与加锁

Config config = new Config();
String node = "redis://127.0.0.1:6379";
config.useSingleServer()
    .setAddress(node)
    .setTimeout(3000)
    .setConnectionPoolSize(10)
    .setConnectionMinimumIdleSize(10);

RedissonClient redissonClient = Redisson.create(config);
RLock lock = redissonClient.getLock("666");  // "666" 为锁的 key

调用方式与 ReentrantLock 完全一致:lock.lock() / lock.unlock()


6. Redisson 加锁核心 Lua 脚本(tryLockInnerAsync

-- KEYS[1] = 锁的 key
-- ARGV[1] = 持有锁的超时时间(毫秒)
-- ARGV[2] = getLockName(threadId) = uuid:threadId(进程ID:线程ID)

if (redis.call('exists', KEYS[1]) == 0) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);   -- 首次加锁,重入次数=1
    redis.call('pexpire', KEYS[1], ARGV[1]);        -- 设置过期时间
    return nil;
end;
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
    redis.call('hincrby', KEYS[1], ARGV[2], 1);   -- 同线程重入,次数+1
    redis.call('pexpire', KEYS[1], ARGV[1]);        -- 刷新过期时间
    return nil;
end;
return redis.call('pttl', KEYS[1]);                 -- 其他线程持有锁,返回剩余ttl

线程唯一标识的生成方式

// id 是启动时全局生成的 UUID(进程级别),threadId 是 JVM 线程 ID
protected String getLockName(long threadId) {
    return id + ":" + threadId;
}

集群环境下,单纯使用 threadId 可能重复,UUID + threadId 才能全局唯一。

Redis 中的存储结构(Hash)

Key(Hash名)Field(线程标识)Value(重入次数)
666uuid:threadId1 / 2 / 3

7. Redisson 解锁核心 Lua 脚本(unlockInnerAsync

-- ARGV[3] = getLockName(threadId)
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
    return nil;          -- 当前线程未持有该锁,直接返回
end;
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);  -- 重入次数-1
if (counter > 0) then
    redis.call('pexpire', KEYS[1], ARGV[2]);  -- 还有重入,刷新过期时间
    return 0;
else
    redis.call('del', KEYS[1]);               -- 重入次数归0,删除锁
    redis.call('publish', KEYS[2], ARGV[1]);  -- 发布通知,唤醒等待的线程
    return 1;
end;

解锁流程与 ReentrantLock.tryRelease() 一一对应:次数自减 → 归零才真正释放 → 通知等待者。


8. 涉及的 Redis 命令速查

命令用途
EXISTS keykey 存在返回 1,不存在返回 0
HINCRBY key field incrementHash 中指定 field 的值原子加 N(可为负数)
HEXISTS key field判断 Hash 中 field 是否存在
PEXPIRE key ms设置 key 的过期时间(毫秒)
PTTL key返回 key 的剩余存活毫秒数
DEL key删除 key
PUBLISH channel message向频道发布消息

优缺点与局限性

Redisson 可重入锁的优势

  • Lua 脚本保证多命令原子执行,无需额外的分布式事务。
  • Hash 结构天然支持**「标识 + 计数」二元信息**,设计简洁。
  • API 与 JDK ReentrantLock 对齐,业务代码改造成本低。

局限性与踩坑点

  • 每次 unlock() 都会刷新过期时间,若业务执行时间过长仍可能超时;Redisson 提供 WatchDog(看门狗)机制自动续期,需确认是否启用(使用 lock.lock() 不传 leaseTime 时自动启用,传固定时间则不启用)。
  • Redis 主从架构下,主节点写锁后尚未同步到从节点就宕机,从节点晋升会导致锁丢失。RedLock 算法可缓解但有争议。
  • 单节点 Redis 仍是单点故障风险,生产建议 Redis Sentinel 或 Cluster。
  • **getLockName**** 依赖启动时生成的线程 UUID,务必保证其全局唯一性;重启应用时 UUID 会变,已持有的锁 field 将无法匹配。**

行动清单

  1. 跑通 ReentrantLock 递归示例,观察 getHoldCount()isLocked() 的变化规律,建立可重入锁的直觉认知。
  2. 本地搭建 Redis + 运行 Redisson Demo,在 Redis 可视化工具(如 RedisInsight)中直接观察 Hash 结构,确认 uuid:threadId 作为 field、重入次数作为 value 的存储形态。
  3. 阅读 Redisson 源码:重点看 RedissonLock#tryLockInnerAsyncunlockInnerAsync,并对照 Lua 脚本逐行注释理解。
  4. 研究 WatchDog 续期机制:搜索 RedissonLock#scheduleExpirationRenewal,理解看门狗如何在后台定期延长锁的过期时间,以及何时会停止续期。
  5. 延伸阅读:了解 RedLock 算法及其争议(Antirez vs. Martin Kleppmann 的论战),判断自己的业务场景是否需要多节点锁。
  6. 实践系列下一篇:用 Spring Boot AOP + 自定义注解封装 Redisson 分布式锁,实现声明式加锁(原文有对应传送门)。

Redis 事务机制与 ACID 属性


一句话摘要

Redis 通过 MULTI/EXEC/DISCARD/WATCH 四个命令实现事务,能保证一致性隔离性,原子性有条件保证,持久性不能保证。

特性含义MySQL InnoDB 实现机制
原子性 Atomicity全成功或全回滚,无中间态undo log(回滚日志)
一致性 Consistency操作前后数据符合约束,总量不变由 A + I + D 共同保证
隔离性 Isolation并发事务互不干扰MVCC + Undo 版本链
持久性 Durability提交后永久生效,宕机不丢失redo log(重做日志)

核心知识点

1. Redis 事务执行三步骤

步骤命令行为
开启事务MULTI标记事务开始
入队命令GET/SET/DECR命令不立即执行,返回 QUEUED,暂存入命令队列
提交执行EXEC一次性顺序执行队列中所有命令

示例:

127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> DECR a:stock   # 初始值5
QUEUED
127.0.0.1:6379> DECR b:stock   # 初始值10
QUEUED
127.0.0.1:6379> EXEC
1) (integer) 4
2) (integer) 9

2. 原子性(Atomicity)—— 有条件保证

分三种情况:

情况一:命令入队时就报错(语法错误、不存在命令)
→ EXEC 时整个事务被拒绝执行,保证原子性

127.0.0.1:6379> MULTI
127.0.0.1:6379> PUT a:stock 5     # 不存在的命令
(error) ERR unknown command `PUT`...
127.0.0.1:6379> DECR b:stock
QUEUED
127.0.0.1:6379> EXEC
(error) EXECABORT Transaction discarded because of previous errors.

情况二:命令入队时没报错,执行时报错(类型不匹配)
→ 错误命令跳过,正确命令继续执行,不保证原子性

127.0.0.1:6379> MULTI
127.0.0.1:6379> LPOP a:stock      # a:stock 是 String 类型,类型不匹配
QUEUED
127.0.0.1:6379> DECR b:stock
QUEUED
127.0.0.1:6379> EXEC
1) (error) WRONGTYPE Operation against a key holding the wrong kind of value
2) (integer) 8                    # DECR 成功执行,原子性被破坏

情况三:EXEC 执行时实例故障
若开启了 AOF,使用 redis-check-aof 工具剔除未完成事务,可保证原子性;若未开启 AOF,数据丢失,原子性无从谈起。

关键点:Redis 没有回滚机制。 **DISCARD** 只是主动放弃尚未 EXEC 的事务(清空命令队列),不等于回滚:

127.0.0.1:6379> GET a:stock       # 值为 "4"
127.0.0.1:6379> MULTI
127.0.0.1:6379> DECR a:stock
QUEUED
127.0.0.1:6379> DISCARD           # 主动放弃
OK
127.0.0.1:6379> GET a:stock       # 仍为 "4",命令未执行

3. 一致性(Consistency)—— 能保证

场景一致性结论
入队报错 → 事务被放弃✅ 数据未被修改,一致
执行时报错 → 错误命令跳过,正确命令执行✅ 不违反数据约束,一致
EXEC 时故障 + 无 RDB/AOF✅ 数据全部丢失,空库也是一致状态
EXEC 时故障 + RDB✅ RDB 不在事务执行中触发,恢复数据是事务前的一致状态
EXEC 时故障 + AOF(部分写入)✅ 用 **redis-check-aof** 清除未完成事务,恢复后一致

4. 隔离性(Isolation)—— 有条件保证

Redis 单线程执行命令,但隔离性取决于并发操作发生在 EXEC 前还是后:

场景一:并发操作在 EXEC 之后 → Redis 单线程保证 EXEC 后的命令队列全部执行完再处理其他请求,隔离性天然保证

场景二:并发操作在 EXEC 之前 → 需借助 WATCH 机制,否则隔离性不保证

WATCH 机制原理:

  1. MULTI 前执行 WATCH key,让 Redis 监控该 key
  2. 若在 EXEC 前该 key 被其他客户端修改,则 EXEC 时自动放弃整个事务(返回 nil)
  3. 客户端可重试事务
t1: 客户端X  →  WATCH a:stock
t2: 客户端X  →  MULTI; DECR a:stock (入队)
t3: 客户端Y  →  DECR a:stock (直接执行,值被改变)
t4: 客户端X  →  EXEC → 检测到 a:stock 被修改,放弃执行,保证隔离性

5. 持久性(Durability)—— 不能保证

Redis 是内存数据库,持久性完全依赖持久化配置,但任何模式下均存在数据丢失窗口:

持久化模式持久性问题
无 RDB/AOF❌ 宕机数据全丢
RDB❌ 事务执行后、下次快照前宕机,修改丢失
AOF(no/everysec/always)❌ 三种策略均存在不同程度的数据丢失可能

结论:不论采用何种持久化模式,Redis 事务持久性均无法保证。


6. 四个核心命令汇总

命令作用
MULTI开启事务
EXEC提交并执行命令队列
DISCARD主动放弃事务,清空命令队列**(非回滚)**
WATCH key监控 key,EXEC 前若 key 被修改则放弃事务

优缺点与局限性

优点

  • 实现简单,MULTI/EXEC 即可保证多命令顺序批量执行
  • 单线程模型天然避免 EXEC 后的并发干扰

局限性与踩坑点

  • 无回滚执行时的命令错误不会撤销已执行的命令,必须业务层自己处理补偿
  • 类型错误运行时才报命令入队时不校验数据类型,错误只在 EXEC 时暴露,且不中断其他命令
  • WATCH 是乐观锁:高并发场景下冲突频繁会导致事务反复重试,影响性能
  • 持久性先天不足:不能用 Redis 事务替代关系型数据库事务做金融级一致性保证
  • DISCARD ≠ 回滚:常见误解,**DISCARD**** 只能在 EXEC 前使用,且只是取消尚未执行的命令**

行动清单

  1. 代码规范:所有使用 Redis 事务的代码必须通过 Code Review,确认命令名称和数据类型匹配,从源头消除”入队没报错但执行时出错”的隐患。
  2. 实践 WATCH 乐观锁:动手写一个模拟库存扣减的场景,验证 WATCH + MULTI + EXEC 的乐观锁机制,理解冲突后重试的完整流程。
  3. 理解 DISCARD 边界:在 Redis CLI 中分别演练 DISCARD 放弃事务 和 EXEC 执行时报错两种场景,亲自确认”无回滚”的真实表现。
  4. 结合 AOF 验证原子性:开启 AOF 并模拟 EXEC 执行中途中断,使用 redis-check-aof 工具修复 AOF 文件,观察恢复结果。
  5. 架构决策:若业务场景要求严格的原子性 + 持久性(如支付),评估使用 Lua 脚本(Redis 原子性更强)或将 Redis 退回到缓存角色,持久化逻辑交给关系型数据库。
  6. 延伸阅读:对比 Redis Lua 脚本与 MULTI/EXEC 事务在原子性上的差异——Lua 脚本在执行时不会被其他命令打断,且执行时错误也不回滚,但不依赖 WATCH 机制。

Hot Key 与 Big Key 问题应对


一句话摘要

Hot Key 导致单节点过载崩溃,Big Key 导致读写超时和网卡打满;核心解法是分散(key 拆分/多副本)+ 隔离(本地缓存/特殊淘汰策略)


核心知识点

1. Hot Key

定义
单个 key 在短时间内被超大量并发请求访问,导致该 key 所在缓存节点的网卡、带宽、CPU 达到物理极限,进而出现卡顿甚至宕机。

触发场景

  • 突发事件:明星出轨、离婚等社会热点
  • 重大活动:奥运、春节
  • 电商促销:秒杀、双12、618

如何发现 Hot Key

场景方法
可预知的热点(促销、节假日)提前人工评估
突发热点(实时新增)Spark 流任务实时分析
慢发酵热点(历史数据)Hadoop 批处理离线计算

解决方案(四种,可叠加使用)

Key 分散(最常用)
hotkey 拆分为 hotkey#1hotkey#2、…、hotkey#n分散存储在多个缓存节点。Client 请求时随机访问其中一个后缀的 key,将流量打散。

多副本 + 多级缓存架构
Key 名不变,提前在多个节点预置副本,配合 L1/L2 多级缓存架构共同承压。

实时监控 + 快速扩容
通过监控体系对缓存 SLA 实时观测,触发阈值时自动扩容缓存节点,稀释热 key 冲击。

本地缓存兜底
业务端将热 key 写入进程内本地缓存(如 Guava Cache、Caffeine),减少对远程缓存集群的请求压力。


2. Big Key

定义
部分 key 的 Value 体积过大,导致读写加载耗时超时的现象。

慢查询的四条成因链路

  • 大 key 占比小 → Memcached 中对应 slab 分配不足 → 被频繁剔除 → 反复回源 DB
  • 大 key 被高频访问 → 缓存节点网卡/带宽被打满
  • 大 key 字段多 → 频繁变更 + 频繁读取 → 读写互相干扰
  • 大 key 被淘汰 → DB 加载耗时极长 → 请求长时间挂起

触发场景

  • 用户最新 1 万个粉丝列表
  • 用户个人信息聚合缓存(基本资料 + 关系图谱计数 + feed 统计)
  • 超长微博内容(1000 字以上)

解决方案(三种)

Memcached 场景:压缩 + 预分配 slab

  • 设定 value 长度阈值,超过则启用压缩,缩减 KV 体积
  • 在 Mc 启动之初立即预写足量大 key,让 Mc 预先分配足够多的大 trunk size 的 slab,避免后续运行时空间不足

Redis 场景:序列化构建 + RESTORE 一次性写入

  • 针对 set 结构含数千乃至数万个元素的大 key,直接写入会导致 Redis 长时间卡顿
  • 解法:Client 端提前对数据序列化构建,再通过 **RESTORE** 命令一次性原子写入,绕过逐条写入的阻塞
# 示例逻辑(伪代码)
data = serialize(big_set_data)      # client 端序列化
redis.restore("big_key", 0, data)   # 一次性写入,避免逐条 SADD 阻塞

通用方案:大 key 拆分 + 特殊淘汰保护

  • 将大 key 横向拆分为多个小 key,分散存储
  • 对大 key 设置更长的过期时间
  • 缓存淘汰策略中,同等条件下优先保留大 key,避免其穿透 DB

优缺点与局限性

Hot Key 各方案

方案适用场景限制/踩坑点
Key 分散(hotkey#n读多写少的热点写操作需同步更新所有副本,增加写复杂度;n 的值需要根据节点数合理规划
多副本 + 多级缓存可提前规划的重大活动架构复杂度高,一致性维护难度大
实时扩容突发流量扩容有时延,极端突发(秒级)可能来不及响应
本地缓存读极热、容忍短暂不一致的场景本地缓存与远程缓存存在数据一致性窗口期,不适合强一致性业务

Big Key 各方案

方案适用场景限制/踩坑点
压缩 + 预分配 slabMemcached 存储大 value压缩/解压增加 CPU 消耗;预分配需在启动时完成,运行时调整效果有限
RESTORE 一次性写入Redis 中 set 等集合类大 keyclient 端需实现序列化逻辑,增加开发复杂度;RESTORE 涉及底层格式,需注意版本兼容
大 key 拆分通用场景拆分后的聚合查询需 client 端合并,应用层改造成本较高
延长过期时间 + 淘汰保护大 key 穿透 DB 代价极高的场景内存占用增加;若业务数据频繁变更,长 TTL 可能导致脏数据

行动清单

  1. 排查现有系统:使用 redis-cli --bigkeys 或 RDB 分析工具(如 rdb-tools)扫描当前 Redis 中存在的 Big Key,建立大 key 台账。
  2. 建立热 key 监控:在业务层接入 Spark 流任务,对缓存 key 访问频次实时统计,设定阈值告警(例如:单 key QPS 超过节点总 QPS 的 20% 即触发告警)。
  3. 落地 Key 分散方案:对已知或潜在热 key,封装统一的 Client SDK,在 get/set 时自动添加随机后缀 #1~#n,业务方无感知。
  4. 实践 RESTORE 写入:在本地搭建 Redis 测试环境,模拟大 set(1 万个元素)分别用逐条 SADD 和序列化 + RESTORE 写入,对比耗时,理解性能差异。
  5. 制定 Big Key 治理规范:确定 Value 大小阈值(建议 10KB 以上视为大 key),超过阈值强制走压缩或拆分逻辑,作为 Code Review 检查项落入开发流程。
  6. 结合容量规划:在 Mc 或 Redis 上线前,根据业务数据分布提前模拟大 key 写入,验证 slab/内存分配是否合理,避免上线后被频繁淘汰。

Redis 高频面试知识点

一句话摘要

覆盖 Redis 从基础到场景应用的六大模块高频考点,核心结论是:理解底层原理 + 结合场景权衡取舍,是回答 Redis 面试题的正确姿势。


核心知识点

一、基础概念

Redis 定义: NoSQL 非关系型数据库,Key-Value 存储结构,数据存于内存,读写极快,广泛用于缓存。

BASE 理论: CAP 中一致性的妥协方案,不追求强一致性,允许数据在一段时间内不一致,最终达到一致状态,换取更高可用性与性能。与 ACID 截然相反。

常用命令:

GET a                  # 获取 key=a 的数据
SETEX a t b            # 将 a 设置为 b,t 秒后过期

二、过期键清除策略

三种策略,Redis 实际采用定期删除 + 惰性删除组合:

策略机制特点
定时删除设置 key 时同步创建定时器,到期立即删除内存友好,CPU 消耗大
定期删除每隔一段时间扫描数据库,删除过期 key折中方案
惰性删除访问时发现过期再删除CPU 友好,内存不友好

内存淘汰: 若定期删除跟不上新 key 产生速度,触发内存淘汰。配置参数 maxmemory_policy,共 8 种枚举值。

近似 LRU: Redis 不维护完整双向链表,而是对少量 key 随机采样,与淘汰池比较,淘汰最久未访问的 key。精度通过 maxmemory-samples 调整。


三、数据结构

对外暴露 5 种对象

String / List / Hash / Set / Zset

底层依托:SDS / ziplist / skiplist / dict

String

  • 整数类型 → 用 int 存储
  • 非整数 → 用 SDS(Simple Dynamic String)存储
  • SDS 通过记录长度 + 预分配空间,高效计算长度,O(1) append

Hash 渐进式 Rehash

同时维护 ht[0]ht[1] 两张哈希表,三步完成迁移:

步骤一:装载因子超阈值 → 分配 ht[1] 空间,偏移索引从 -1 置为 0
步骤二:每次增删查改顺带迁移一个 ht[0] 的 bucket + 周期函数定时批量迁移
步骤三:ht[0] 全部迁移完 → 交换 ht[0]/ht[1] 指针 → 偏移索引重置为 -1

Rehash 期间请求处理:

  • 新增 key → 写入 ht[1]
  • 读/删/改 → 先查 ht[0],未命中再查 ht[1]

跳表(Skiplist)

本质是对链表的多层索引优化,查找时从高层索引跳跃,找过头则降层。

层数决定: 概率均衡,随机函数决定,默认 1 层,50% 概率升一层,最大 32 层。

第 1 层:50% 概率
第 2 层:25% 概率
第 3 层:12.5% 概率
...

越上层节点越少,跨越越快。

Zset 为何同时使用字典 + 跳表

结构支持的查询场景
字典(dict)按成员查询,O(1)
跳表(skiplist)范围查询,O(log N)

两种场景性能均达到极致。


四、系统容灾

持久化:RDB vs AOF

维度RDBAOF
内容二进制快照操作命令记录
文件大小小,紧凑
恢复速度慢(需重放)
数据丢失多(快照间隔内)
性能影响小(子进程 Fork)立即刷盘时影响大

AOF 刷盘三种模式:

  1. 关闭时刷入
  2. 每秒定期刷入(推荐,性能影响小)
  3. 每条命令后立即刷入(最安全,最慢)

AOF 重写: 文件过大时 Fork 子进程进行重写,合并相同 key 的操作(后覆盖前)。重写期间新操作同时写入 AOF 缓冲区和 AOF 重写缓冲区,重写完成后追加重写缓冲区内容,替换旧文件。

主从 + 哨兵模式

主从: Slave 同步 Master 数据,Master 宕机后手动切换。

哨兵模式: 自动监测并切换,哨兵本身也需多机部署避免单点。

哨兵 Leader 选举:

  • 任意哨兵发现主节点主观下线 → 请求其他哨兵投票
  • 获票超过节点总数一半且大于 quorum 配置值 → 成为 Leader

新 Master 选择优先级(Leader 执行):

  1. 过滤故障节点
  2. slave-priority 最大的
  3. 选复制偏移量最大的(数据最全)
  4. runid 最小的

五、性能优化

Redis 性能量级: 十万级 QPS,受带宽、负载、数据大小、是否多线程等因素影响,脱离场景谈性能无意义,使用前需跑 Benchmark。

线程模型:

  • 6.0 之前:单线程 Reactor 模型
  • 6.0 之后:网络 IO 解包用多线程优化,数据处理逻辑始终是单线程
  • RDB Fork、定时任务等属于多进程范畴,但数据处理从未多线程

集群模式(数据分片):

  • key 通过 Hash 路由到不同节点
  • 节点发现与状态同步基于 Gossip 协议

一致性 Hash:

传统 Hash 分片扩/缩容时路由全乱 → 触发缓存雪崩。

一致性 Hash 将数据和服务器映射到同一个 Hash 环,顺时针找最近节点:

  • 增加节点 → 只分流后一个节点的数据
  • 减少节点 → 请求由后一个节点继承
  • 最多影响 1 个节点的数据

六、场景应用

缓存一致性

两种模式:

  • Cache Aside:缓存责任交给应用层,性能极致但有 RPC 耗损
  • Read/Write Through:缓存责任放置到服务提供方,透明性更好

缓存三大问题

问题定义解决方案
雪崩大量缓存同时过期,请求全部打到 DB过期时间随机化;热点数据双缓存
穿透请求 DB 中根本不存在的 key,可能是攻击布隆过滤器拦截
击穿单个超热 key 失效瞬间,海量请求直达 DB热点 key 不设过期 + 单独数据同步逻辑;后端加分布式锁或令牌桶限频

布隆过滤器原理:

  • 底层 64 位整型,多个 Hash 函数将字符串映射到不同二进制位置并置 1
  • 查询时所有映射位均为 1 → 数据可能存在(存在误判,不可能存在则一定不存在)
  • 优点:时间、空间消耗极小;缺点:结果不完全准确

分布式锁

依赖存储组件对比:

组件可靠性性能
Etcd
MySQL
Redis

限流(分布式令牌桶)

  • Redis 负责管理令牌
  • 微服务向 Redis 申请令牌,获取令牌才能执行操作
  • 懒生成令牌:使用令牌时顺带生成,令牌获取与生成在同一 Lua 脚本中保证原子性

Redis 做消息队列

Redis 未支持 AMQP 规范,消息可靠性弱,不推荐用作消息队列。生产环境优先用 Kafka 等标准 MQ 组件。秒杀场景中 Redis 负责流量选拔(记录商品总数与已下单数),流量削峰后的队列交给 Kafka 处理。


优缺点与局限性汇总

技术点适用场景核心限制踩坑点
RDB对恢复速度要求高,可接受少量丢失快照间隔内数据会丢不适合对数据完整性要求极高的场景
AOF 每秒刷盘大多数生产场景最多丢 1 秒数据立即刷盘模式严重拖慢写性能
哨兵模式可用性要求中等主从切换有短暂不可用窗口哨兵本身也需多机部署
集群模式数据量超单机上限一致性 Hash 仍有数据迁移成本扩容时需重新分配 slot
布隆过滤器缓存穿透防护存在误判,不支持删除误判率需根据业务容忍度调整
Redis 分布式锁高性能锁场景NPC 问题无法完全规避强依赖分布式锁危险,业务需设计为幂等

行动清单

  1. 命令实操:用 redis-cli 练习 SET key value NX EX secondsOBJECT ENCODING key 查看底层编码类型
  2. 渐进式 Rehash 验证:阅读 Redis 源码 dict.cdictRehash() 函数,对照三步流程理解实现
  3. 跳表手写:用任意语言实现一个简化版跳表(支持插入、查找、范围查询),加深层数概率分配的理解
  4. 哨兵搭建:Docker 本地搭建 1 主 2 从 + 3 哨兵,kill 主节点后观察自动选举全过程和日志输出
  5. 布隆过滤器实践:用 Redis 的 RedisBloom 模块或手写位图实现一个布隆过滤器,测量误判率
  6. Benchmark 实测:用 redis-benchmark 跑读写压测,调整 --threads、数据大小等参数,观察 QPS 变化
  7. 串联三大缓存问题:设计一个包含雪崩、穿透、击穿防护的完整缓存层架构,画出数据流图

Redis 面试核心知识


一句话摘要

本文系统梳理了 Redis 的核心面试考点,涵盖数据类型与底层实现、线程模型演进、持久化机制、集群高可用、过期与淘汰策略、缓存设计模式及实战场景,核心结论:Redis 高性能来自内存操作 + 高效数据结构 + I/O 多路复用的组合,而非单纯的单线程。


二、核心知识点

1. Redis 是什么

基于内存的 Key-Value 数据库,读写全在内存完成。支持 9 种数据类型,所有数据类型操作原子性由单线程主线程保证。

**额外能力:**事务、持久化(AOF/RDB/混合)、Lua 脚本、发布订阅、主从/哨兵/切片集群、内存淘汰、过期删除。

Redis vs Memcached 核心差异:

  • Redis 支持丰富数据类型**(String/Hash/List/Set/ZSet)**,Memcached 只有 key-value
  • Redis 支持持久化,Memcached 重启数据全失
  • Redis 原生支持集群模式,Memcached 需客户端分片
  • Redis 支持发布订阅、Lua、事务

为什么用 Redis 做 MySQL 缓存:

  • Redis QPS 可达 10w+,MySQL 单机难破 1w,差 10 倍
  • 热点数据走缓存,直接命中内存,降低 DB 压力

2. 数据类型与底层实现

五种基础类型及典型场景:

类型典型场景
String缓存对象、计数器、分布式锁、Session 共享
List消息队列(注意:需自实现全局唯一 ID,不支持消费组)
Hash缓存对象、购物车
Set点赞、共同关注、抽奖(并集/交集/差集)
ZSet排行榜、按分值排序

四种新增类型(版本标注):

  • BitMap(2.2):签到、登录状态
  • HyperLogLog(2.8):百万级 UV 统计
  • GEO(3.2):地理位置(如滴滴叫车)
  • Stream(5.0):消息队列,支持自动全局唯一 ID + 消费组

底层数据结构(Redis 7.0):

  • String → SDS(简单动态字符串)。与 C 字符串的区别:用 len 判断结尾(支持二进制数据);获取长度 O(1) vs C 的 O(n);拼接前自动扩容,不会缓冲区溢出。
  • List → Redis 3.2 前:元素数 < 512 且单元素 < 64B 用压缩列表,否则双向链表。3.2 后统一用 quicklist
  • Hash → 元素数 < 512 且单值 < 64B 用压缩列表,否则哈希表。Redis 7.0 起压缩列表由 listpack 替代。
  • Set → 全整数且元素数 < 512(set-maxintset-entries)用整数集合,否则哈希表。
  • ZSet → 元素数 < 128 且单元素 < 64B 用压缩列表(7.0 后为 listpack),否则跳表


3. 线程模型

“单线程”的准确含义: 仅指**「接收请求 → 解析 → 数据读写 → 返回结果」**这条主链路是单线程。Redis 进程本身并非单线程。

后台线程(BIO)演进:

  • 2.6:2个后台线程(关闭文件、AOF 刷盘)
  • 4.0:新增 lazyfree 线程,异步释放内存。unlink / flushdb async / flushall async 走该线程。删大 key 必须用 unlink,不能用 del(del 在主线程,会卡顿)
  • 6.0:新增 I/O 多线程处理网络请求(命令执行仍是单线程)

单线程为什么快:

  1. 操作全在内存 + 高效数据结构,CPU 不是瓶颈
  2. 无多线程上下文切换和锁竞争开销
  3. I/O 多路复用(**epoll**** 机制):单线程同时监听多个 socket**

Redis 6.0 多线程 I/O 配置:

# 开启读请求多线程处理(默认仅写响应开启多线程)
io-threads-do-reads yes
# 设置 I/O 线程数(实际启用 N-1 个,主线程算 1 个)
io-threads 4

官方建议:4 核 CPU 设 2-3,8 核设 6,线程数必须小于核数。

Redis 6.0 默认启动线程清单(共 7 个):

  • 1 主线程(Redis-server)
  • 3 后台线程(bio_close_file / bio_aof_fsync / bio_lazy_free)
  • 3 I/O 线程(io_thd_1/2/3)


4. 持久化机制

AOF 日志

写操作执行完后,将命令追加写入文件。先执行后记录(优点:避免语法检查开销、不阻塞当前命令;缺点:宕机可能丢数据)。

AOF 写回策略(appendfsync** 配置项):**

策略时机可靠性性能
Always每次写操作同步刷盘最高,最多丢1条最差
Everysec每秒刷盘一次最多丢1秒数据中等(推荐)
No由 OS 决定最差最好

AOF 重写机制: AOF 文件过大时触发,读取当前所有键值对,每个 key 只保留最新状态用一条命令写入新文件,替换旧文件(相当于去掉历史冗余命令)。

重写过程由子进程 bgrewriteaof 完成(避免阻塞主线程;用子进程而非线程是为了利用写时复制 COW 避免加锁)。重写期间新写命令同时写入「AOF 缓冲区」和「AOF 重写缓冲区」,子进程完成后将重写缓冲区内容追加至新 AOF 文件,再原子替换旧文件。

RDB 快照

记录某一时刻的全量内存数据(二进制),恢复速度远快于 AOF。

触发命令:

  • save:主线程执行,会阻塞
  • **bgsave**:创建子进程执行,不阻塞主线程(推荐)

自动 bgsave 默认配置:

# 以下几条命令实际执行的是 bgsave
save 900 1       # 900秒内至少1次修改
save 300 10      # 300秒内至少10次修改
save 60 10000    # 60秒内至少10000次修改

快照期间数据可被修改,依赖写时复制(COW):子进程和父进程共享内存页表,父进程写操作时复制一份副本,子进程只读原始数据写入 RDB。

混合持久化(Redis 4.0+)

AOF 重写时,先将**共享内存以 RDB 格式写入新 AOF 文件前半段,再将增量操作命令以 AOF 格式写入后半段。**重启时先快速加载 RDB 部分,再重放 AOF 增量部分。

优点:启动速度快(RDB)+ 数据丢失少(AOF)。缺点:AOF 文件可读性差;不兼容 Redis 4.0 以下版本。


5. 集群高可用

三种方案:

主从复制: 一主多从,读写分离,主库写操作异步同步给从库。无法保证强一致性(复制是异步的,存在数据延迟窗口)。

哨兵模式(Sentinel): 监控主从节点状态,主节点故障时自动完成主从切换(故障转移),无需手动干预。

切片集群(Redis Cluster): 数据按哈希槽(Hash Slot)分布在多节点。总共 16384 个槽,Key 映射规则:CRC16(key) % 16384

槽分配两种方式:

  • 平均分配:cluster create 自动均分
  • 手动分配:cluster meet + cluster addslots
# 手动分配示例:节点1负责槽0,1,节点2负责槽2,3
redis-cli -h 192.168.1.10 -p 6379 cluster addslots 0,1
redis-cli -h 192.168.1.11 -p 6379 cluster addslots 2,3
# 注意:必须将全部16384个槽分配完,否则集群无法正常工作

集群脑裂问题: 主节点网络分区后与从节点失联,哨兵误判主节点宕机并选出新主节点,旧主节点继续接收客户端写入。网络恢复后旧主降级为从节点并全量同步,导致期间写入数据丢失。

解决方案(配置限制旧主写入):

min-slaves-to-write 1      # 至少有N个从节点连接才允许写
min-slaves-max-lag 12      # 主从同步延迟不超过T秒才允许写

配合哨兵 down-after-milliseconds,在主节点网络隔离期间自动拒绝写入,避免脑裂数据丢失。


6. 过期删除策略

过期键存在独立的「过期字典(expires dict)」中。查询 key 时先在过期字典中检查是否过期。

Redis 采用惰性删除 + 定期删除组合策略:

  • 惰性删除:访问时才检查是否过期,CPU 友好,但过期 key 不访问就一直占内存
  • 定期删除:每次随机抽取 20 个 key 检查并删除过期的;若过期 key 比例 > 25% 则继续循环,否则等待下轮;单次执行时间上限 25ms(防止卡死主线程)

持久化时的过期键处理:

  • RDB 生成:过期键不写入新 RDB 文件
  • RDB 加载(主库):过期键不载入;加载(从库):全部载入(但主从同步时从库数据会被清空,所以影响不大)
  • AOF 写入:过期键仍保留在 AOF 中,等实际删除时追加 **DEL** 命令
  • AOF 重写:过期键不写入重写后的 AOF 文件

主从模式: 从库不主动扫描过期键,依赖主库在 key 到期时发送 **del** 指令给从库执行。


7. 内存淘汰策略

触发条件:运行内存达到 maxmemory 配置值。

8 种策略:

策略范围算法
noeviction(3.0+ 默认)不淘汰,直接报错
volatile-random有过期时间的 key随机
volatile-ttl有过期时间的 key优先淘汰即将过期的
volatile-lru(3.0 前默认)有过期时间的 key最久未使用
volatile-lfu(4.0+)有过期时间的 key最少使用频次
allkeys-random全部 key随机
allkeys-lru全部 key最久未使用
allkeys-lfu(4.0+)全部 key最少使用频次

LRU vs LFU:

  • **LRU(Least Recently Used):按最后访问时间淘汰。**Redis 用近似 LRU:随机采样 5 个 key,淘汰最久未访问的那个(节省链表空间和移动开销)。缺点:无法处理缓存污染(批量一次性读取的数据长期占用缓存)。
  • **LFU(Least Frequently Used,4.0+):按访问频次淘汰,**解决缓存污染问题。

LFU 实现细节(redisObject** 的 lru 字段 24bit):**

  • LRU 模式:24bit 全部存储最后访问时间戳
  • LFU 模式:高 16bit 存 ldt(最后访问时间戳),低 8bit 存 logc(访问频次计数)

8. 缓存设计模式

三大缓存问题

缓存雪崩: 大量 key 同时过期 → 请求全打 DB → DB 宕机。

  • 方案1:给过期时间加随机偏移(如 1-10 分钟),避免集中失效
  • 方案2:关键数据设置永不过期,后台异步更新

缓存击穿: 单个热点 key 过期 → 大量并发请求直接打 DB。

  • 方案1:互斥锁(SET lock NX),同一时间只允许一个请求重建缓存
  • 方案2:热点 key 不设过期时间,后台异步更新

缓存穿透: 请求的数据在 DB 中根本不存在(恶意攻击或业务误删)→ 每次都穿透到 DB。

  • 方案1:API 入口校验参数合法性,拦截非法请求
  • 方案2:查询到空结果时,在缓存中写入空值或默认值
  • 方案3:布隆过滤器(写入 DB 时同步标记),请求先查布隆过滤器,不存在直接拦截

缓存更新策略

Cache Aside(旁路缓存,最常用):

  • 读:命中缓存直接返回;未命中 → 查 DB → 写缓存 → 返回
  • 写:先更新 DB,再删除缓存(顺序不能反,否则读写并发时会产生脏缓存)
  • 适合读多写少场景;写多场景缓存命中率下降

**Read/Write Through(读穿/写穿): 应用只与缓存交互,由缓存负责读写 DB。**Redis/Memcached 不原生支持,适用于本地缓存。

**Write Back(写回): 只更新缓存(标记为脏),批量异步写 DB。**写性能最高但数据不强一致,有丢失风险。Redis 不支持此模式,常见于 CPU Cache、OS 文件系统 Page Cache。


9. 实战场景

延迟队列

用 ZSet 实现,Score 存储执行时间戳:

# 生产消息
zadd delay_queue {执行时间戳} {任务内容}
# 消费:查询Score <= 当前时间的任务
zrangebyscore delay_queue 0 {当前时间戳}

大 Key 处理

定义:String 值 > 10KB;集合类型元素数 > 5000。

危害:阻塞主线程(命令执行慢)、网络拥塞(1MB key × 1000 QPS = 1GB/s 流量)、内存分布不均。

查找方法:

# 方法1:redis-cli内置命令(建议在从节点执行)
redis-cli -h 127.0.0.1 -p 6379 -a "password" --bigkeys
# 方法2:SCAN + TYPE + STRLEN/MEMORY USAGE
# 方法3:RdbTools解析RDB文件(输出大于10KB的key到CSV)
rdb dump.rdb -c memory --bytes 10240 -f redis.csv

删除方法(核心:不能一次性 del,会阻塞主线程):

分批删除示例:

# 大 Hash 分批删除
cursor, data = r.hscan(large_hash_key, cursor=cursor, count=100)
for item in data.items():
    r.hdel(large_hash_key, item[0])

# 大 List 分批删除(每次保留头部,删尾部100个)
r.ltrim(large_list_key, 0, -101)

# 大 ZSet 分批删除
r.zremrangebyrank(large_sortedset_key, 0, 99)

异步删除(4.0+,推荐):

unlink large_key   # 替代 del,异步释放内存

相关配置(建议开启):

lazyfree-lazy-eviction yes
lazyfree-lazy-expire yes
lazyfree-lazy-server-del yes

分布式锁

加锁(原子操作):

SET lock_key unique_value NX PX 10000
# NX:不存在才设置;PX 10000:过期时间10s;unique_value:区分不同客户端

解锁(必须用 Lua 脚本保证原子性):

if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

超时时间问题:业务执行时间 > 锁过期时间会导致锁提前释放。解决:守护线程续约(看门狗模式)。

Redlock(红锁): 多节点(官方推荐 5 个独立主节点)分布式锁,客户端依次向所有节点加锁,超过半数(≥ N/2+1)加锁成功且总耗时 < 锁过期时间,则认为加锁成功。解决单节点主从复制异步导致的锁丢失问题。

Redis 事务

不支持事务回滚。**DISCARD**** 只能放弃未提交的事务队列,不是回滚。命令入队时的语法错误不会被检测,提交后执行错误的命令会报错,但其他正确命令照常执行(非原子性)**。

MULTI          # 开启事务
DECR count     # 入队
EXEC           # 提交
DISCARD        # 放弃(清空队列,非回滚)

三、优缺点与局限性

AOF vs RDB:

  • AOF 数据安全(最多丢 1 秒),但恢复慢、文件大
  • RDB 恢复快,但快照频率难把握(太低丢数据多,太高影响性能)
  • 混合持久化综合两者,但 4.0 以下不兼容,文件可读性差

主从复制局限: 异步复制,无法强一致,主节点故障需手动切换(哨兵解决后者)。

Cache Aside 策略局限: 写多场景缓存频繁失效,命中率低;存在极小概率的读写并发脏缓存窗口期(缓存写入远快于 DB 写入,概率极低)。

Redis 分布式锁局限: 主从异步复制导致主节点宕机时新主节点可被重新加锁(用 Redlock 解决,但 Redlock 本身也存在争议);超时时间设置困难(用续约守护线程缓解)。

单线程命令执行局限: 慢查询(如对大 key 的 delkeys *)会阻塞所有后续请求。

--**bigkeys**** 命令局限:** 只返回每种类型中最大的那一个;集合类型只统计元素个数而非实际内存占用。


四、行动清单

  1. 动手配置实验: 搭建本地 Redis 实例,分别测试 save(阻塞)和 bgsave(非阻塞)生成 RDB 文件的行为差异,观察主进程是否被阻塞。
  2. 持久化策略选型练习: 针对不同业务场景(高频写入 / 数据不能丢 / 快速重启)写出推荐的持久化配置组合,并说明理由。
  3. 大 Key 模拟与清理: 用脚本往 Redis 写入一个包含 10000 个字段的 Hash,分别用 delhscan + hdel 方式删除,用 MONITORSLOWLOG 观察耗时差异。
  4. 分布式锁编码: 用 Redis 的 SET NX PX + Lua 脚本自己实现一个简版分布式锁,测试并发场景下锁的正确性,再尝试模拟锁超时续约逻辑。
  5. 缓存三大问题演练: 构造缓存穿透场景(查询不存在的 key),分别实现「缓存空值」和「布隆过滤器」两种防护方案,对比代码复杂度和效果。
  6. 线程模型深化: 阅读 Redis 源码中的 ae.c(事件循环)和 bio.c(后台线程),理解 epoll_create / epoll_ctl / epoll_wait 的调用时机。
  7. 脑裂防护配置: 在测试环境模拟网络分区,配置 min-slaves-to-writemin-slaves-max-lag,验证旧主节点是否正确拒绝写入。
  8. 内存淘汰策略对比:maxmemory-policy 分别设为 allkeys-lruallkeys-lfu,写入混合访问频率的数据集,观察两种策略淘汰的数据有何不同。