Appearance
Day17 Redis:数据结构、持久化、缓存三大问题、分布式锁、主从 + 哨兵 + Cluster
📖 基础知识讲解【基础原理 + 高级深挖知识点】
基础原理
1. 全景:一条 Redis 命令从网卡到内存经过了什么
Day16 关心的是"数据怎么安全地落到磁盘",Day17 要换一个方向:数据怎么高效地待在内存里,并且在宕机、过期、并发、集群分片之下仍然可用。先把整条链路画出来,后面所有知识点都挂在这张图上。
text
客户端
│ RESP 协议:*3\r\n$3\r\nSET\r\n$1\r\nk\r\n$1\r\nv\r\n
▼
┌──────────────── 单进程单线程事件循环 aeMain() ────────────────┐
│ ① IO 多路复用 epoll_wait 一次拿回所有就绪 fd(Linux) │
│ ② 读取与解析 socket → querybuf → RESP 解析成 argv[] │ ← 6.0 起可多线程
│ ③ 命令查找 lookupCommand,校验参数个数 / 权限 / 内存 │
│ ④ 命令执行 call() ★ 严格串行,天然原子,无需加锁 │
│ ⑤ 传播 propagate 写 AOF buffer + 写所有从库的 replication buf │
│ ⑥ 回复 写 client->buf,beforeSleep 时统一回写 socket│ ← 6.0 起可多线程
└───────────────────────────────┬──────────────────────────────┘
│
后台线程 bio(3 个):close(fd) / fsync(AOF) / lazyfree 释放大对象
│
fork 子进程:bgsave 生成 RDB、bgrewriteaof 重写 AOF四个必须记住的结论:
| 结论 | 为什么重要 |
|---|---|
| 命令执行是串行的,所以单命令天然原子 | 这是 INCR、SETNX、LPUSH 不需要加锁的根本原因;也是 Lua 脚本能做"原子组合"的根本原因 |
| 单线程指的是"命令执行"这一步 | 持久化、释放大对象、fsync、网络读写(6.0+)都不在主线程里,说 "Redis 是单线程的" 要补这一句才准确 |
| 一个慢命令会阻塞所有客户端 | KEYS *、HGETALL 大 hash、DEL 百万元素集合,没有并行度可言,这是 Redis 最主要的故障来源 |
| 瓶颈通常是内存和网络,不是 CPU | 所以优化方向是"少传数据、少存冗余、别把 Redis 当数据库用",而不是加核 |
面试加分表述:「我理解 Redis 快的本质不是 "单线程快",而是 它把所有可能慢的事情都挪出了主线程:IO 等待交给 epoll,落盘交给 fork 出来的子进程,大对象释放交给 bio 线程。主线程只剩下纯内存操作,所以才能做到微秒级。反过来说,一旦有什么东西没挪出去——比如一个
DEL掉几百万成员的 set,或者appendfsync always的那次 fsync——整个实例就会卡住。」
2. 五种基础数据结构:选型是第一道考题
| 类型 | 一句话定位 | 常用命令 | 典型业务场景 | 坑 |
|---|---|---|---|---|
| String | 二进制安全的字节数组,最大 512MB | SET/GET/INCR/INCRBY/SETNX/MSET/GETSET | 计数器、分布式 ID、JSON 缓存、限流、分布式锁 | 存大 JSON 导致网络放大;INCR 只能用于整数字符串 |
| List | 双向链表(7.0 起为 quicklist = 链表 + listpack) | LPUSH/RPOP/LRANGE/BLPOP/LLEN | 简单消息队列、最新 N 条、栈/队列 | LRANGE 0 -1 是 O(N) 的大 key 杀手;用 List 做队列无法多消费者组、无 ACK |
| Hash | field-value 映射 | HSET/HGET/HMGET/HINCRBY/HSCAN | 对象存储 user:1 {name,age}、购物车、计数聚合 | 整个 Hash 只能设一个过期时间(7.4 才支持 field 级 TTL);HGETALL 大 hash 会阻塞 |
| Set | 无序去重集合 | SADD/SISMEMBER/SINTER/SUNION/SPOP/SRANDMEMBER | 去重、标签、共同好友、抽奖 | SINTERSTORE 等聚合是 O(N×M),大集合要放从库或离线算 |
| ZSet | 有序集合,member + double score | ZADD/ZRANGE/ZRANGEBYSCORE/ZINCRBY/ZREVRANK/ZREMRANGEBYSCORE | 排行榜、延时队列、滑动窗口限流、带权重的分页 | score 是 double,超过 2^53 精度丢失(订单号/雪花 ID 不能直接当 score) |
两条选型原则,面试里很值钱:
- 能用 Hash 别用多个 String:
user:1:name、user:1:age拆成 N 个 key,会丢掉 listpack 的内存压缩优势,内存可能差 5~10 倍;但如果单个字段需要独立过期,就只能拆。 - 能用 ZSet 的 score 表达业务时间就别自己维护索引:延时队列、滑动窗口、排行榜本质都是"按一个数值排序 + 范围取出",
ZRANGEBYSCORE+ZREMRANGEBYSCORE一套搞定。
3. 高级结构:BitMap、HyperLogLog、GEO、Stream
| 结构 | 本质 | 命令 | 场景 | 精度 / 代价 |
|---|---|---|---|---|
| BitMap | 就是 String,按位操作 | SETBIT/GETBIT/BITCOUNT/BITOP/BITPOS | 签到、日活去重、用户在线状态、布隆过滤器底座 | 1 亿用户 1 天 = 12.5MB;偏移量稀疏时极度浪费(SETBIT key 100000000 1 直接分配 12.5MB) |
| HyperLogLog | 基数估算,固定 12KB | PFADD/PFCOUNT/PFMERGE | UV 统计、去重计数 | 标准误差 0.81%,不能判断"某个元素是否存在",也拿不到精确值 |
| GEO | 底层就是 ZSet,score 存 GeoHash 编码 | GEOADD/GEOSEARCH/GEODIST | 附近的人、门店、骑手调度 | 本质是 ZSet,所以可以用 ZREM 删成员;范围查询在高纬度有误差 |
| Stream | 可持久化的 append-only 消息日志 | XADD/XREAD/XGROUP/XREADGROUP/XACK/XPENDING/XAUTOCLAIM | 真正能用的消息队列:消费组、ACK、PEL、消息回溯 | 需要主动 XTRIM/MAXLEN 否则无限增长;至少一次语义,业务仍要幂等 |
| Bloom Filter | RedisBloom 模块 | BF.ADD/BF.EXISTS/BF.MADD | 缓存穿透第一道闸门 | 有假阳性无假阴性;标准布隆不支持删除(要删用 Cuckoo Filter) |
bash
# 签到:一个用户一年 365 bit = 46 字节
SETBIT sign:u1001:2026 72 1 # 第 73 天签到
BITCOUNT sign:u1001:2026 # 全年签到天数
BITPOS sign:u1001:2026 0 # 第一次漏签是哪天
# 日活:BITOP 做多天留存
BITOP AND dau:and:0401_0402 dau:20260401 dau:20260402
BITCOUNT dau:and:0401_0402 # 两天都活跃的用户数(次日留存)
# UV:误差 0.81%,但只要 12KB
PFADD uv:20260401 u1 u2 u3
PFCOUNT uv:20260401
PFMERGE uv:202604 uv:20260401 uv:20260402 # 月 UV 不是简单相加,必须 merge面试加分表述:「统计类需求我会按 "要不要精确 / 要不要判存在" 两个问题分流:要精确又要判存在,用 Set(贵);只要精确计数不判存在,用 BitMap(用户 ID 连续时最省);既不要精确也不判存在,用 HyperLogLog(12KB 封顶)。我们的日活从 Set 换成 BitMap,单日内存从 800MB 降到 12MB;月 UV 换成 HyperLogLog 之后只剩 12KB,业务方接受 1% 的误差。」
4. 底层编码:OBJECT ENCODING 看到的那些名字
Redis 的每个 key 都指向一个 redisObject,里面有 type(五种对外类型)和 encoding(内部实现)。同一个 type 会随数据量自动切换 encoding,而且只升不降,这是内存优化和面试深挖的核心。
| type | 小数据时的 encoding | 升级条件(默认值) | 大数据时的 encoding |
|---|---|---|---|
| String | int(long 范围整数) | 非整数 | — |
| String | embstr(≤44 字节,对象头和 SDS 一次分配) | > 44 字节 | raw(对象头与 SDS 分开分配) |
| List | listpack | 元素 > 128 或单元素 > 64 字节 | quicklist(双向链表每个节点挂一个 listpack) |
| Hash | listpack | 字段 > 128 或字段/值 > 64 字节 | hashtable |
| Set | intset(全是整数且 ≤ 512 个) | 出现非整数 / 超过 512 | listpack(7.2+ 小的非整数集合)→ hashtable |
| ZSet | listpack | 元素 > 128 或成员 > 64 字节 | skiplist + dict(跳表排序 + 字典 O(1) 查 score) |
bash
127.0.0.1:6379> SET k 12345
127.0.0.1:6379> OBJECT ENCODING k # "int"
127.0.0.1:6379> SET k "hello"
127.0.0.1:6379> OBJECT ENCODING k # "embstr"
127.0.0.1:6379> APPEND k "!" # 任何修改都会变 raw
127.0.0.1:6379> OBJECT ENCODING k # "raw"
127.0.0.1:6379> RPUSH l a b c
127.0.0.1:6379> OBJECT ENCODING l # "listpack"
127.0.0.1:6379> RPUSH l "一个长度超过 64 字节的字符串......"
127.0.0.1:6379> OBJECT ENCODING l # "quicklist",且再删回去也不会退回 listpack对应的配置项(上线前必须按业务调):
ini
hash-max-listpack-entries 128
hash-max-listpack-value 64
list-max-listpack-size 128
set-max-intset-entries 512
set-max-listpack-entries 128
zset-max-listpack-entries 128
zset-max-listpack-value 64⚠️ "只升不降"是高频追问:一个 Hash 临时写进去 200 个字段,之后删到只剩 2 个,编码依然是 hashtable,内存不会退回 listpack 的水平。要降回去只能
DEL重建(或RESTORE)。所以"先灌全量再裁剪"的脚本很容易留下一堆内存高位的 key。
面试加分表述:「编码这件事我在项目里真实踩过:购物车用 Hash 存 SKU,默认
hash-max-listpack-entries=128,大部分用户十几个 SKU 走 listpack 很省;但有批量导购场景一次写进去 300 多个 SKU,这些 key 全部升级成 hashtable,而且清空到几个 SKU 之后内存也不降。后来我们把这类批量场景单独拆 key,并把阈值调到 512(代价是单次HGETALL的 CPU 变高,因为 listpack 是 O(N) 查找),内存下降了约 35%。所以调这两个阈值本质是 用 CPU 换内存,不能只看一边。」
5. Redis 为什么快:四个层次,缺一个都答不完整
text
① 纯内存操作 ─ 内存随机访问 ~100ns,SSD ~100μs,差 1000 倍
② 单线程串行执行 ─ 无锁、无上下文切换、无竞态,CPU cache 命中率高
③ IO 多路复用 ─ epoll + 自研 ae 事件库,一个线程管十万连接
④ 为场景定制的结构 ─ SDS / listpack / intset / skiplist / 渐进式 rehash
⑤ 协议简单 ─ RESP 可读、解析成本极低,支持 pipeline 批量常被忽略的第 ⑤ 条和 pipeline 的关系:Redis 单命令耗时只有几十微秒,但一次网络往返(RTT)在同机房就要 0.2~0.5ms。所以绝大多数"Redis 慢"其实是 RTT 慢,解法是 pipeline / MGET / Lua 合并,而不是加机器。
| 做法 | 1000 次 SET 的耗时(RTT 0.3ms) | 说明 |
|---|---|---|
| 1000 次单独调用 | ≈ 300ms | 全是网络往返 |
| pipeline 一次发 1000 条 | ≈ 2~5ms | 1 次 RTT,服务端仍是串行执行 |
MSET 一条命令 | ≈ 1~3ms | 更省解析开销,但只能同类型操作 |
| Lua 脚本 | ≈ 1~3ms | 1 次 RTT 且整体原子 |
⚠️ pipeline 不是事务:中间某条命令失败,前后命令照常执行,没有回滚。要原子就用 Lua。
6. 单线程模型与 Redis 6.0 的多线程 IO
"Redis 是单线程" 这句话在 6.0 之后必须拆开说。完整版本是:
| 组件 | 线程情况 | 从哪个版本开始 |
|---|---|---|
| 命令执行 | 永远单线程(串行) | 至今未变 |
| 网络读(读 socket + 解析协议) | 可多线程 | 6.0,io-threads |
| 网络写(回写 socket) | 可多线程 | 6.0,io-threads |
UNLINK / FLUSHALL ASYNC 异步释放 | bio 线程 | 4.0 lazyfree |
AOF fsync | bio 线程 | 2.4 |
| 关闭文件描述符 | bio 线程 | 2.4 |
| RDB / AOF 重写 | fork 子进程 | 很早 |
ini
# 6.0+:建议设为 CPU 核数的 1/2~3/4,4 核机器设 2~3,超过 8 基本没收益
io-threads 4
# 6.0 默认只多线程"写",要连"读+解析"一起多线程必须显式开
io-threads-do-reads yes多线程 IO 的真实收益:它优化的是"内核态拷贝 + 协议解析"这段 CPU 开销,所以只有在单实例已经被网卡/协议解析打满(通常 10 万 QPS 以上,或者 value 比较大)时才有意义。小 value、低 QPS 的实例开了反而因为线程同步开销略微变慢。
面试加分表述:「6.0 的多线程我理解它刻意没有动命令执行环节,因为一旦并行执行命令,就要为每个数据结构加锁,Redis 代码里大量的 O(1) 无锁假设全部失效,性能收益很可能被锁开销吃掉,而且丢掉 "单命令原子" 这个让业务代码极度简化的特性。所以 Redis 的选择是:在不破坏单线程语义的前提下,把能并行的边角料并行化。这和 MySQL 为了保持 WAL 语义把刷脏放到后台线程是同一种思路。」
7. 过期策略:为什么 key 到期了内存还没降
Redis 的 key 过期不是到点就删,而是两种机制的组合:
| 策略 | 触发时机 | 优点 | 缺点 |
|---|---|---|---|
| 惰性删除 lazy | 访问 key 时发现已过期才删 | CPU 友好,零额外开销 | 冷门 key 永远不被访问 → 内存泄漏式占用 |
| 定期删除 active | serverCron 每秒 hz 次(默认 10 次)执行 activeExpireCycle | 兜住冷门 key | 有 CPU 开销,且是抽样不是全扫 |
定期删除的细节(能说出来就是深度):
text
每次 activeExpireCycle:
遍历每个 db 的 expires 字典
循环:
随机取 20 个带 TTL 的 key(ACTIVE_EXPIRE_CYCLE_LOOKUPS_PER_LOOP)
删掉其中已过期的
如果过期比例 > 25% → 立刻再来一轮
否则跳出
整轮时间上限:慢模式 25% 的 CPU 时间(hz=10 时约 25ms/s)
快模式 1ms,且两次之间至少间隔 2ms三个直接推论:
- 过期 key 的内存不是立刻释放的,
used_memory下降滞后很正常;INFO stats里的expired_keys才是真实删除数。 - 大量 key 同时过期会导致 CPU 抖动:过期比例持续 > 25% 会让
activeExpireCycle一直循环,表现为延迟毛刺——这是缓存雪崩之外,"过期时间要打散" 的第二个理由。 - 过期不会立刻从从库删除:从库不主动删过期 key,必须等主库
DEL/UNLINK传过来(详见高级深挖 17)。
8. 内存淘汰:8 种策略与 maxmemory 的正确姿势
maxmemory 用满之后,由 maxmemory-policy 决定行为。默认是 noeviction,也就是写命令直接报错 OOM command not allowed,很多人以为默认会 LRU,这是线上事故高发点。
| 策略 | 候选范围 | 淘汰依据 | 适用场景 |
|---|---|---|---|
noeviction | — | 不淘汰,写报错、读正常 | 把 Redis 当数据库用(不能丢数据)时才用 |
allkeys-lru | 所有 key | 近似 LRU | 纯缓存场景的默认选择 |
allkeys-lfu | 所有 key | 近似 LFU(4.0+) | 存在明显长尾热点、访问频次差异大 |
allkeys-random | 所有 key | 随机 | 访问分布均匀、没有热点 |
volatile-lru | 仅带 TTL 的 key | 近似 LRU | 缓存和持久数据混放在同一实例 |
volatile-lfu | 仅带 TTL 的 key | 近似 LFU | 同上 + 有热点 |
volatile-random | 仅带 TTL 的 key | 随机 | 同上 + 无热点 |
volatile-ttl | 仅带 TTL 的 key | 剩余 TTL 最短优先 | 希望"快过期的先走" |
三个必须知道的坑:
volatile-*的致命陷阱:如果没有任何 key 带 TTL,volatile-*会退化成noeviction,于是写入全部报错。混放场景必须保证缓存 key 一定有 TTL。maxmemory要留出余量:这个值只统计数据内存,不含replication backlog、客户端输出缓冲区、AOF 重写缓冲区、内存碎片。生产一般设为物理内存的 60%~70%(开 AOF/有大量从库时更低),否则 fork 时会 OOM 或触发 swap。- 淘汰是在命令执行前同步做的:
performEvictions()在主线程里循环释放直到内存低于阈值,所以内存打满时的延迟会显著上升;配lazyfree-lazy-eviction yes可以把大对象的释放丢给 bio 线程。
bash
# 必须盯的几个指标
redis-cli info memory | grep -E "used_memory_human|used_memory_rss|maxmemory_human|mem_fragmentation_ratio"
redis-cli info stats | grep -E "evicted_keys|expired_keys|keyspace_hits|keyspace_misses"面试加分表述:「
maxmemory-policy我的判断标准只有一句话:这个实例里的数据,丢了会不会出业务错误。纯缓存丢了只是回源,选allkeys-lru;里面混了分布式锁、幂等标记、会话,这些丢了会出错,就必须把它们拆到独立实例并设noeviction,而不是靠volatile-lru赌它们不被淘汰——因为volatile-lru只保护"没有 TTL"的 key,而锁恰恰是有 TTL 的,照样会被淘汰掉。」
9. RDB:二进制快照
| 维度 | 说明 |
|---|---|
| 内容 | 某一时刻全量数据的二进制压缩快照(dump.rdb) |
| 触发 | SAVE(阻塞主线程,线上禁用)、BGSAVE(fork 子进程)、save m n 自动规则、主从全量同步、SHUTDOWN(不带 nosave)、FLUSHALL |
| 优点 | 文件小、恢复极快(直接按二进制结构载入内存)、适合全量备份与异地灾备 |
| 缺点 | 会丢最后一次快照之后的全部写入;fork 和快照期间内存可能翻倍 |
ini
save 900 1 # 900 秒内至少 1 次修改 → bgsave
save 300 10
save 60 10000 # 三条是"或"关系,满足任一条就触发
rdbcompression yes # LZF 压缩字符串,省磁盘费 CPU
rdbchecksum yes # CRC64 校验,载入时能发现损坏
stop-writes-on-bgsave-error yes # bgsave 失败就拒绝写入(提醒你磁盘满了)
dbfilename dump.rdbfork 的真实代价(面试深挖点):fork() 本身不复制数据,但要复制页表。经验值是每 1GB 内存约 20ms 的 fork 耗时,10GB 实例 fork 就要 200ms,这 200ms 内主线程完全阻塞。随后子进程与父进程共享内存页,父进程一旦写入就触发 COW(Copy-On-Write)复制该页,写入越频繁,额外内存占用越大,最坏情况内存翻倍。
bash
# 看最近一次 fork 耗时(微秒)
redis-cli info stats | grep latest_fork_usec
# 必须关掉透明大页,否则 COW 粒度从 4KB 变成 2MB,内存与延迟双爆
echo never > /sys/kernel/mm/transparent_hugepage/enabled10. AOF:追加写命令日志
AOF 记录的是写命令本身(RESP 格式),重启时 "重放一遍" 恢复数据。注意它是 写后日志:Redis 先执行命令改内存,再写 AOF buffer——和 MySQL 的 WAL(写前日志)正好相反。
text
命令执行成功
↓
写入 aof_buf(内存)
↓ appendfsync 决定何时真正落盘
write() 到 page cache ──► fsync() 到磁盘appendfsync | 落盘时机 | 丢数据窗口 | 性能 |
|---|---|---|---|
always | 每个写命令都 fsync | 最多丢 1 条 | 最差,主线程等 fsync,QPS 可能掉到几千 |
everysec(默认) | 每秒 fsync 一次,由 bio 线程做 | 最多丢 1 秒 | 好,推荐 |
no | 交给操作系统(30s 左右) | 丢几十秒 | 最好 |
写后日志的两个设计后果(追问高频):
- 好处:命令先执行再记录,所以不会把语法错误的命令写进 AOF,恢复时不用做语法校验;同时避免了 MySQL 那种"写前日志要先阻塞"的额外延迟。
- 坏处:命令执行完还没落盘就宕机 → 这条写丢了,而且客户端已经收到成功。这是 Redis 和 MySQL
innodb_flush_log_at_trx_commit=1的本质区别,Redis 永远不要当"不能丢一条"的账本。
AOF 重写(rewrite):AOF 会越写越大(同一个 key INCR 一万次就有一万条),重写的本质是 读当前内存状态,生成"能重建这份数据的最少命令集"。
text
bgrewriteaof
① fork 子进程,基于 fork 那一刻的内存快照写新 AOF
② 期间主进程的新写入 → 同时写 aof_buf(老文件)和 aof_rewrite_buf(增量)
③ 子进程写完 → 父进程把 aof_rewrite_buf 追加到新文件 → 原子 rename 替换ini
appendonly yes
appendfsync everysec
auto-aof-rewrite-percentage 100 # 比上次重写后的大小涨了 100% 就重写
auto-aof-rewrite-min-size 64mb # 且至少 64MB 才触发(避免小文件反复重写)
no-appendfsync-on-rewrite no # ⚠️ 设 yes 可避免重写期间 fsync 争抢,但会丢更多数据
aof-use-rdb-preamble yes # 混合持久化,4.0+ 默认开⚠️ 7.0 之前重写有个严重的写放大问题:步骤 ② 里增量要同时写两份,如果重写耗时很久(大实例几十秒)且写入很高,
aof_rewrite_buf会膨胀甚至打爆内存。7.0 引入 Multi Part AOF 彻底解决:改成base.rdb+ 若干incr.aof+manifest清单文件,重写期间增量只写新的 incr 文件,不再需要重写缓冲区(详见高级深挖 8)。
11. 混合持久化与恢复优先级
4.0 起 aof-use-rdb-preamble yes(默认开):AOF 文件的前半段是 RDB 格式的全量快照,后半段是增量命令。这样同时拿到了 RDB 的"恢复快"和 AOF 的"丢得少"。
text
AOF 文件(混合格式)
┌──────────────── RDB 二进制 ────────────────┬──── 增量写命令 ────┐
│ 上次重写那一刻的全量数据(载入极快) │ SET k v / INCR ... │
└────────────────────────────────────────────┴────────────────────┘启动时的恢复优先级(必背):
text
appendonly yes → 只加载 AOF(哪怕 RDB 更新也不看),AOF 不存在则空库启动
appendonly no → 加载 RDB这条规则导致一个经典事故:实例原本只开 RDB,运维临时 CONFIG SET appendonly yes 又重启,Redis 发现没有 AOF 文件就以空库启动,RDB 里的数据全部"消失"(文件其实还在,只是没加载)。正确做法是 CONFIG SET appendonly yes 后等 aof_rewrite_in_progress 变 0、AOF 文件生成完再改配置文件。
| 场景 | 推荐配置 |
|---|---|
| 纯缓存,丢了可回源 | 可以全关(save "" + appendonly no),省掉 fork 抖动 |
| 缓存 + 不想冷启动打穿 DB | 只开 RDB,save 900 1 一类的宽松规则 |
| 存在不可重建数据(会话、幂等标记) | RDB + AOF 混合,appendfsync everysec |
| 要求几乎不丢 | 混合持久化 + appendfsync always + 主从 + WAIT 命令,但仍无法做到 0 丢失 |
12. 缓存三大经典问题:穿透、击穿、雪崩
这三个名字最容易背混,一句话区分:穿透是"查不存在的",击穿是"一个热点失效",雪崩是"一大片同时失效"。
text
请求
│
┌───────▼────────┐
│ Redis 缓存 │
└───────┬────────┘
命中 ◄─────────┤
│ 未命中
┌───────▼────────┐
│ 数据库 │
└────────────────┘
穿透 Penetration:key 在 DB 里也不存在 → 每次都回源,缓存形同虚设
击穿 Breakdown :单个热点 key 刚好过期 → 瞬间成千上万并发同时回源同一条数据
雪崩 Avalanche :大批 key 同时过期 / 实例宕机 → 全站流量直接压到 DB| 维度 | 缓存穿透 | 缓存击穿 | 缓存雪崩 |
|---|---|---|---|
| 本质 | 数据根本不存在,缓存永远不会命中 | 一个热点 key 失效 | 大量 key 同时失效 / Redis 挂了 |
| 典型诱因 | 恶意刷不存在的 ID、爬虫遍历 | 秒杀商品、首页 Banner 到期 | 统一设置 30 分钟 TTL、整点刷缓存、实例重启 |
| 流量特征 | 持续、分散在很多不同 key | 瞬时、集中在同一个 key | 瞬时、覆盖面极广 |
| 核心解法 | 缓存空值 + 布隆过滤器 + 参数校验 | 互斥锁重建 + 逻辑过期 + 热点永不过期 | TTL 随机打散 + 多级缓存 + 高可用 + 熔断降级 |
穿透的两种解法对比
| 方案 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 缓存空值 | 查不到也写入 ""/特殊标记,TTL 设短(30s~5min) | 实现简单,立即生效 | 大量不同的非法 key 会占满内存;数据真的被创建后有短暂不一致(需插入时主动删 key) |
| 布隆过滤器 | 把所有存在的 ID 预热进 BF,不存在直接拒绝 | 内存极省(1 亿 ID 约 100~200MB,误判率 1%) | 有假阳性(判断存在其实不存在,所以要配合缓存空值兜底);标准布隆不能删除,新增要同步写入,删除只能定期重建 |
击穿的三种解法对比
| 方案 | 做法 | 一致性 | 可用性 | 适用 |
|---|---|---|---|---|
| 互斥锁重建 | 未命中时抢分布式锁,抢到的查 DB 回写,没抢到的 sleep 重试或返回旧值 | 强(只有一个线程回源) | 差(其他线程被阻塞,锁超时还会级联) | 一致性要求高、QPS 不极端 |
| 逻辑过期 | value 里存一个 expireAt 字段,key 本身不设 TTL;发现逻辑过期就异步起线程重建,当前请求直接返回旧值 | 弱(会读到短时旧数据) | 好(永不阻塞、永不回源打穿) | 秒杀、首页等高并发读 |
| 热点永不过期 + 主动更新 | 不设 TTL,靠 binlog/MQ/定时任务主动刷新 | 取决于刷新链路 | 最好 | 数据源变更可感知的场景 |
雪崩的分层解法(从前到后)
text
① TTL 随机化 expire = base + random(0, base * 0.2) ← 成本最低,必做
② 多级缓存 本地 Caffeine(1~5s)+ Redis,本地挡住峰值 ← 挡住实例抖动
③ 永不过期 + 异步 热点数据不设 TTL,后台定时/消息驱动刷新
④ 高可用 主从 + 哨兵 / Cluster,避免"Redis 挂"这一类雪崩
⑤ 限流与熔断 Sentinel/Hystrix 对 DB 侧做并发隔离,超限直接降级
⑥ 降级兜底 返回兜底数据/静态页/友好提示,保住核心链路
⑦ 缓存预热 上线、重启、大促前主动灌入热点数据⚠️ 别把 "Redis 宕机型雪崩" 和 "集体过期型雪崩" 混成一个。前者只能靠高可用 + 限流熔断 + 多级缓存解决,TTL 打散对它完全无效;后者才是 TTL 打散要治的病。面试时把这两类分开讲,立刻比背书的人高一档。
面试加分表述:「这三个问题我在项目里是分层治的:入口做参数合法性校验(ID 必须是 19 位数字,直接拦掉绝大部分扫描流量),第二层 布隆过滤器拦不存在的 ID,第三层缓存空值 60 秒兜住布隆的假阳性,第四层热点 key 用逻辑过期保证不被击穿,第五层所有 TTL 都带 ±20% 随机,最后在 DB 前面加信号量隔离,限制回源并发不超过 50。这六层里最便宜也最有效的其实是第一层和 TTL 随机,很多人却只记得布隆过滤器。」
13. 缓存与数据库的一致性:四种模式与"先删还是后删"
四种缓存读写模式
| 模式 | 读 | 写 | 谁操作 DB | 典型场景 |
|---|---|---|---|---|
| Cache Aside(旁路缓存) | 先查缓存,未命中查 DB 并回写 | 更新 DB,然后删除缓存 | 业务代码 | 90% 的互联网业务,最常用 |
| Read/Write Through | 只读缓存,缓存层自己回源 | 只写缓存,缓存层同步写 DB | 缓存组件 | 本地缓存框架、Guava/Caffeine LoadingCache |
| Write Behind(写回) | 只读缓存 | 只写缓存,异步批量刷 DB | 缓存组件 | 计数器、点赞数这类高频写、可容忍丢失 |
| Write Around | 同 Cache Aside | 只写 DB,不碰缓存,等自然过期 | 业务代码 | 写多读少、写完短期不会被读 |
Cache Aside 的四种写法,为什么只推荐"先更库,再删缓存"
| 写法 | 并发下会出什么问题 | 结论 |
|---|---|---|
| 先更缓存,再更库 | 两个写线程更新缓存和 DB 的顺序可能交叉,缓存留下旧值;且缓存值可能是复杂计算结果,白算 | ❌ 不用 |
| 先更库,再更缓存 | 同上的交叉覆盖问题;还有"写多读少时白更新"的浪费 | ❌ 不用 |
| 先删缓存,再更库 | 删完到更新完之间有读请求进来,会把旧值重新加载进缓存,并长期驻留 | ❌ 概率高,不用 |
| 先更库,再删缓存 | 只有极小概率出问题:读线程恰好在"缓存刚失效"时读到旧 DB 值,并且回写晚于写线程的删除动作 | ✅ 推荐 |
text
先更库再删缓存 唯一的不一致窗口(概率极低):
读线程 R: 缓存未命中 ──► 查 DB(得到旧值 V1) ─────────────────► 回写缓存 V1
写线程 W: 更新 DB 为 V2 ──► 删除缓存 ──►
时间轴 ───────────────────────────────────────────────────────►
最终缓存 = V1(脏)
成立条件:① 读恰好未命中 ② 读查库比写更新库更早 ③ 读回写比写删除更晚
而 "读 DB + 回写缓存" 通常只有几毫秒,写操作却要走事务提交,条件 ③ 极难满足。三种加固手段
| 手段 | 做法 | 解决什么 | 代价 |
|---|---|---|---|
| 延迟双删 | 更新 DB 后删一次,休眠/延迟 N 毫秒再删一次(第二次一般丢到延迟队列异步做) | 兜住"旧值回写"窗口 + 主从读写分离下从库未同步的窗口 | 延迟时间难定;只是降低概率,不能根治 |
| 删除失败重试 | 删缓存失败时投递到 MQ 重试,重试 N 次后告警 | 网络抖动导致缓存删不掉 | 引入 MQ 复杂度 |
| binlog 订阅 | Canal/Debezium 订阅 MySQL binlog,由消费者统一删缓存 | 业务代码彻底不管缓存,天然有序、可重放 | 架构成本高,有秒级延迟 |
⚠️ "读写分离 + 主从延迟"才是一致性问题的大头。即使你严格"先更库再删缓存",如果回源读的是从库,而从库还没同步到最新数据,照样会把旧值写进缓存,而且这个窗口是几十毫秒到几秒,比上面那个理论窗口大几个数量级。解法:关键链路回源强制走主库,或者用延迟双删把第二次删除时间设得大于主从延迟 P99。
面试加分表述:「缓存和数据库之间不存在强一致,只要它们是两个系统、不在一个事务里,就只能做最终一致。所以我的做法是先承认这一点,再问业务能接受多大的不一致窗口:能接受秒级就用 Cache Aside + 短 TTL 兜底;要求更严就加 binlog 订阅删缓存;真要强一致那就别用缓存,或者把读也加分布式锁串行化——但那样缓存就没意义了。面试里如果有人说'延迟双删能保证强一致',那是错的,它只是把不一致的概率压低。」
14. 分布式锁:从 SETNX 到 Redisson 的五次进化
一个"能用的"分布式锁,必须同时满足:互斥、不死锁(持有者宕机能自动释放)、不误删(只能释放自己的锁)、可重入(同线程重复加锁)、高可用(Redis 挂了锁服务还能用)。这五个要求正好对应五次进化。
| 版本 | 写法 | 新解决的问题 | 仍然存在的问题 |
|---|---|---|---|
| V1 | SETNX key 1 | 互斥 | 宕机死锁(没有过期时间) |
| V2 | SETNX + EXPIRE 两条命令 | 想加过期 | 两条命令非原子,中间宕机仍死锁 |
| V3 | SET key uuid NX EX 30 | 原子性 + 自动过期 + value 带身份 | 释放时"判断 + 删除"非原子,可能误删别人的锁 |
| V4 | V3 + Lua 脚本释放(比对 value 再 DEL) | 不误删 | 业务超过 30 秒锁就提前失效,多个线程同时持锁 |
| V5 | Redisson:Hash 结构记重入次数 + 看门狗自动续期 + Pub/Sub 唤醒等待者 | 可重入、自动续期、等待队列 | 主从切换丢锁(需 Redlock / RedissonRedLock,但工程上争议大) |
V4 的标准写法(加锁 + Lua 释放)
bash
# 加锁:NX 保证互斥,EX 保证不死锁,value 用 UUID + 线程 ID 标识持有者
SET lock:order:1001 "uuid-7f3a:thread-12" NX EX 30
# 返回 OK 表示加锁成功,返回 nil 表示已被别人持有lua
-- 释放锁:先比对 value 再删除,整体原子
-- KEYS[1] = 锁 key, ARGV[1] = 加锁时写入的唯一标识
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end⚠️ 为什么释放必须用 Lua:如果写成"
GET判断是自己的 →DEL"两步,那么在GET之后、DEL之前,锁可能刚好过期并被线程 B 抢到,此时线程 A 的DEL就把线程 B 的锁删了。这是分布式锁最经典的事故,且在压测时很难复现。
V5 Redisson 做了什么(看门狗机制)
text
lock() 不传 leaseTime 时:
① Lua 加锁,默认 TTL = lockWatchdogTimeout = 30s
② 加锁成功后,注册一个定时任务,周期 = 30s / 3 = 10s
③ 每 10s 检查"锁还在自己手上吗",是则 PEXPIRE 续到 30s
④ unlock() 时取消定时任务;进程直接 kill 时定时任务消失,锁 30s 后自然释放
重入结构(Hash):
HSET lock:order:1001 "uuid:threadId" 2 # field 是持有者,value 是重入次数
unlock 时 HINCRBY -1,减到 0 才真正 DEL 并 PUBLISH 通知等待者⚠️ 传了
leaseTime就没有看门狗了。lock(10, TimeUnit.SECONDS)表示"锁 10 秒后无条件过期",Redisson 不会续期。想要自动续期必须用无参lock()或tryLock(waitTime, TimeUnit.SECONDS)(只传等待时间,不传租期)。这个坑在线上非常常见:开发者为了"保险"传了租期,结果长事务跑到 15 秒时锁没了。
Redlock 与它的争议(细节见高级深挖 11)
Redlock 的思路是:向 N 个完全独立的 Redis 节点依次加锁,拿到超过半数(N/2+1)且总耗时小于锁有效期,才算加锁成功。它想解决的是"单点主从异步复制导致主从切换后锁失效"。但 Martin Kleppmann 指出:任何基于时间租约的锁都无法在 GC 停顿、网络延迟、时钟漂移面前保证安全,真正安全需要在下游资源侧做 fencing token 校验。
面试加分表述:「我会先问一句:这把锁是用来提效的还是用来保正确的?如果只是避免重复计算、重复发券这类'多做一次有点浪费但不致命'的场景,
SET NX EX+ Lua 释放就够了,Redis 分布式锁完全胜任。如果是资金、库存这类错一次就要赔钱的场景,我不会只依赖 Redis 锁,而是在数据库侧再加一道防线——唯一索引、WHERE stock > 0的乐观更新、或者状态机流转的 CAS。锁只是降低冲突概率的第一道闸门,最终正确性要由存储层的约束来兜底。」
15. 主从复制:全量同步、增量同步与 psync2
角色与定位:主从复制解决的是读扩展 + 数据冗余,它不提供自动故障转移(那是哨兵/Cluster 的事)。Redis 主从默认是异步复制:主库执行完命令立即返回客户端,再异步把命令传给从库。
三个阶段
text
① 建立连接与协商
从库执行 REPLICAOF masterhost masterport(5.0 前叫 SLAVEOF)
从库 ──PING──► 主库 # 探活
从库 ──REPLCONF listening-port──► # 上报自己的端口
从库 ──PSYNC replid offset──► # 第一次是 PSYNC ? -1
② 全量同步(Full Resynchronization)
主库 ──+FULLRESYNC replid offset──► 从库
主库 fork 子进程 bgsave 生成 RDB ──► 发给从库
期间新写入的命令暂存到 client-output-buffer(replica 类型的缓冲区)
从库: 清空自己的数据 ──► 加载 RDB ──► 回放缓冲区命令
③ 增量传播(Command Propagation)
主库每执行一个写命令,就追加到 repl_backlog 并发给所有从库
从库持续回放,并每秒上报 REPLCONF ACK offset断线重连:靠 replid + offset 决定全量还是增量
| 概念 | 含义 |
|---|---|
master_replid | 主库启动时生成的 40 位随机 ID,标识"这一份数据血统" |
master_repl_offset | 主库已经写入复制流的字节偏移量,单调递增 |
slave_repl_offset | 从库已经回放到的偏移量,和主库的差值就是复制延迟 |
repl_backlog | 主库上的环形缓冲区,默认 1MB,保存最近的复制流 |
text
从库重连后发 PSYNC 保存的replid 自己的offset+1
主库判断:
replid 对不上? ──► +FULLRESYNC 全量
offset 已经被环形缓冲区覆盖掉了? ──► +FULLRESYNC 全量
replid 对上 且 offset 还在 backlog ──► +CONTINUE 只补差量⚠️
repl-backlog-size默认 1MB 太小了。估算公式:backlog ≥ 平均写入速率(MB/s) × 最长可容忍断线时间(s) × 2。如果主库写入 10MB/s、网络抖动可能持续 30 秒,就需要 600MB 才能避免全量同步。线上一次全量同步意味着主库 fork + 传 RDB + 从库清库重载,几 GB 数据能卡住几分钟,且可能连锁触发更多从库重连。这是最值得调的一个参数。
psync2(4.0 引入)解决了两个痛点
| 痛点 | psync2 的解法 |
|---|---|
| 从库重启后丢失复制信息,必须全量 | 把 replid/offset 写进 RDB,重启后能继续增量 |
| 从库晋升为主库后,其他从库必须全量 | 新主库保留 replid2(原主库的 replid),接受按旧 replid 发来的 PSYNC,实现增量 |
其他关键机制
| 机制 | 说明 |
|---|---|
| 无盘复制 | repl-diskless-sync yes,RDB 直接通过 socket 发给从库,不落盘,适合磁盘慢网络快的环境 |
| 级联复制 | A ◄─ B ◄─ C 的链式结构,减轻主库的分发压力,代价是 C 的延迟 = A→B + B→C |
| 从库只读 | replica-read-only yes(默认),从库写入会报 READONLY 错误 |
| 复制缓冲区 | 每个从库一个 client-output-buffer-limit replica 256mb 64mb 60,超限主库会主动断开该从库,然后从库重连又触发全量——死循环 |
| 写入保护 | min-replicas-to-write 1 + min-replicas-max-lag 10:少于 1 个从库 ACK 正常时主库拒绝写入,用来对抗脑裂 |
⚠️ 主从复制不保证不丢数据。因为是异步复制,主库写完就返回,如果此刻主库宕机且这条命令还没传到从库,这条数据永久丢失。
WAIT numreplicas timeout命令可以阻塞等待 N 个从库确认,但它只是"尽力而为",不是同步复制,也不能回滚已写入的数据。
面试加分表述:「主从复制的三个核心概念是 replid + offset + backlog,断线后能不能走增量就看这三个。面试里很多人只会答'全量和增量',但答不出'什么情况下会退化成全量'——答案是 replid 变了(主库重启或切主)或者 offset 被环形缓冲区覆盖了。我们线上把
repl-backlog-size从默认 1MB 调到了 256MB,就是为了让网络抖动几十秒也能走增量,避免凌晨批量写入时触发雪崩式全量同步。」
16. 哨兵 Sentinel:主观下线、客观下线与故障转移
哨兵是一个独立进程集群(通常 3 个或 5 个,奇数个),负责监控、通知、自动故障转移、配置中心四件事。它不存数据,只盯着主从集群。
四个核心职责
| 职责 | 说明 |
|---|---|
| 监控 | 每秒向主库、从库、其他哨兵发 PING |
| 通知 | 通过 Pub/Sub 把故障事件推给运维和客户端 |
| 自动故障转移 | 主库挂了,选一个从库晋升为新主库,让其他从库改指向 |
| 配置中心 | 客户端连的是哨兵,通过 SENTINEL get-master-addr-by-name 问当前主库地址 |
下线判定:主观 vs 客观
| 概念 | 判定者 | 条件 | 含义 |
|---|---|---|---|
| 主观下线 SDOWN | 单个哨兵 | 连续 down-after-milliseconds(默认 30s)内 PING 无有效回复 | "我觉得它挂了",可能是我自己的网络有问题 |
| 客观下线 ODOWN | 多个哨兵 | 有 ≥ quorum 个哨兵都报了 SDOWN | "大家都觉得它挂了",才真正启动故障转移 |
⚠️ 只有主库有 ODOWN 的概念。从库和其他哨兵判定 SDOWN 后就直接标记下线,不需要走客观下线流程——因为从库挂了不需要做故障转移。
故障转移完整流程
text
① 主观下线:Sentinel-1 连续 30s PING 不通 master,标记 SDOWN
② 询问同伴:Sentinel-1 向其他哨兵发 SENTINEL is-master-down-by-addr
③ 客观下线:收到 ≥ quorum 个确认 → 标记 ODOWN
④ 选举 Leader:基于 Raft,每个哨兵一票,先到先得;
拿到 "> 哨兵总数/2" 且 "≥ quorum" 票的哨兵当 Leader,负责这次转移
⑤ 选新主库:Leader 按规则挑一个从库(见下表)
⑥ 晋升:对该从库执行 REPLICAOF NO ONE,并反复 INFO 确认 role 变成 master
⑦ 改指向:对其余从库执行 REPLICAOF 新主库地址
⑧ 旧主恢复:被标记为新主库的从库,降级后自动指向新主
⑨ 通知客户端:通过 +switch-master 频道广播,客户端重新获取地址选新主库的四级规则(依次比较,前面分不出胜负才看后面)
| 优先级 | 规则 | 说明 |
|---|---|---|
| 0 | 过滤 | 剔除已下线、最近 5 秒没回复 INFO、与旧主断连超过 down-after × 10 的从库 |
| 1 | replica-priority 小者优先 | 默认 100,设为 0 表示永不晋升(常用于跨机房的备份从库) |
| 2 | 复制偏移量 offset 大者优先 | 数据最新,丢失最少 |
| 3 | runid 字典序小者优先 | 纯粹为了有个确定性结果 |
哨兵自己怎么发现彼此和从库
text
发现从库: 向主库发 INFO,主库的回复里带着所有从库的 ip:port
发现哨兵: 所有哨兵都订阅主库的 __sentinel__:hello 频道,
每 2 秒往这个频道发一条自己的 ip/port/runid/epoch,
互相订阅 = 自动组网,所以配置文件里只需要写 master 地址⚠️ 哨兵数量必须 ≥ 3 且为奇数,
quorum建议设为 N/2+1。只部署 2 个哨兵时,quorum只能设 1 或 2:设 1 则单个哨兵网络抖动就会误切主;设 2 则挂掉任意一个哨兵后永远凑不齐票,故障转移彻底失效。另外三个哨兵要部署在不同物理机/不同机架,否则一起挂等于没部署。
⚠️ 哨兵会改写配置文件。故障转移后,哨兵会把新的
monitor地址和known-replica写回sentinel.conf,所以这个文件必须可写,而且运维不能用配置管理工具把它强行覆盖回旧内容,否则哨兵重启后会去监控一个已经降级的节点。
面试加分表述:「哨兵最容易被追问的是'选 Leader 和选新主库是两件事':选 Leader 用的是 Raft 投票,决定由谁来执行这次故障转移;选新主库用的是优先级/offset/runid 三级规则,决定把谁提上来。还有一个常被忽略的点:哨兵切换期间是有写入不可用窗口的,大概是
down-after-milliseconds+ 选举 + 晋升,通常 30 秒起步,所以down-after设太大会拖长故障时间,设太小又会被网络抖动误触发,我们线上是 10 秒配合 3 个跨机架哨兵。」
17. Cluster 集群:16384 个槽、MOVED/ASK 与 Gossip
主从 + 哨兵解决了高可用,但没解决写扩展和单机内存上限——所有写都压在一个主库上。Cluster 用数据分片解决这个问题。
槽(slot)机制
text
槽位总数固定 16384(0 ~ 16383)
key 定位:slot = CRC16(key) mod 16384
槽位分配(3 主为例):
节点 A:slot 0 ~ 5460
节点 B:slot 5461 ~ 10922
节点 C:slot 10923 ~ 16383
每个主节点挂 1 个从节点 → 3 主 3 从,最小生产部署⚠️ 为什么是 16384 而不是 65536?作者 antirez 的回答:① 节点间 Gossip 心跳包里要带槽位 bitmap,16384 bit = 2KB,65536 bit = 8KB,心跳每秒都在发,带宽差 4 倍;② Redis 集群设计上不建议超过 1000 个节点,16384 个槽足够均匀分配;③ bitmap 用于压缩传输,槽位数越少压缩率越高。
客户端重定向:MOVED 与 ASK
| 响应 | 含义 | 客户端该做什么 | 是否更新本地槽映射 |
|---|---|---|---|
MOVED 3999 127.0.0.1:6381 | 这个槽已经永久属于那个节点 | 重定向到新节点,并刷新本地槽表 | ✅ 是 |
ASK 3999 127.0.0.1:6381 | 这个槽正在迁移中,这个 key 已经搬过去了 | 先发 ASKING,再发原命令,只这一次 | ❌ 否 |
text
slot 迁移中的三态判断(源节点视角):
收到命令 ──► key 在本节点?
├─ 在 ──► 直接执行
└─ 不在 ──► 该 slot 正在 MIGRATING?
├─ 是 ──► 回 ASK(可能已搬走,让客户端去问目标节点)
└─ 不是 ──► 回 MOVED(槽根本不属于我)
目标节点视角:slot 处于 IMPORTING 状态时,
只接受带 ASKING 前缀的命令,否则一律回 MOVED(防止客户端提前打过来)Gossip 协议:节点间如何通信
| 消息类型 | 作用 |
|---|---|
| MEET | 新节点加入集群(CLUSTER MEET ip port) |
| PING / PONG | 每秒随机选几个节点互发,携带自己的槽位信息和已知节点的状态 |
| FAIL | 某节点被判定客观下线后,广播给全集群 |
| PUBLISH | 集群内传播发布订阅消息 |
text
端口规则:业务端口 6379,集群总线端口 = 业务端口 + 10000 = 16379
← 防火墙必须同时放开这两个端口,这是部署时最常见的失败原因故障判定与自动 failover(不需要哨兵)
text
① PFAIL(疑似下线):某节点超过 cluster-node-timeout 没回 PONG
② 通过 Gossip 传播,当**超过半数主节点**都认为它 PFAIL → 标记 FAIL
③ 该主节点的从节点发起选举(基于 configEpoch,类似 Raft),
由其他**主节点**投票,拿到半数以上票的从节点晋升
④ 新主节点广播 PONG 宣告自己接管了这些槽位Cluster 的使用限制(最容易在面试中被追问)
| 限制 | 说明 | 绕过方式 |
|---|---|---|
| 多 key 操作 | MSET/MGET/SUNION 等要求所有 key 在同一个槽 | hash tag:{user:1001}:name 和 {user:1001}:age 只用 {} 内的内容算 CRC16,必然同槽 |
| 事务 | MULTI/EXEC 内的 key 必须同槽 | hash tag |
| Lua 脚本 | KEYS[] 里的 key 必须同槽 | hash tag |
| pipeline | 客户端需按节点拆分后分别发送 | 用 Jedis/Lettuce 的 Cluster pipeline(内部已拆分) |
| 数据库 | 只有 db0,不支持 SELECT | 用 key 前缀代替 |
| 订阅 | 7.0 前 PUBLISH 会在全集群广播,浪费带宽 | 7.0 的 Sharded Pub/Sub(SPUBLISH/SSUBSCRIBE) |
⚠️ hash tag 是双刃剑。用
{}把一批 key 强行绑到同一个槽,确实能做多 key 操作,但如果这批 key 很大很热(比如把所有用户都打上{user}标签),就会造成严重的数据倾斜和热点节点,等于白分片。原则:hash tag 的粒度必须是单个业务实体(单用户、单订单),不能是实体类型。
面试加分表述:「选型顺序我是这样把握的:单机够用就别上集群——80% 的业务用主从 + 哨兵就够了,Cluster 带来的多 key 限制、运维复杂度、客户端兼容问题是实打实的成本。真正需要 Cluster 的信号有两个:单实例内存超过 20~30GB(fork 和全量同步都开始变慢)、或者写 QPS 打满了单核。另外要注意 Cluster 的高可用依赖'超过半数主节点存活',所以主节点必须 ≥ 3 个,2 主 2 从的集群挂一个主就直接整体不可用。」
18. 原子性三件套:事务、Lua、pipeline 的区别
这三个经常被混为一谈,但它们解决的是完全不同的问题。
| 维度 | pipeline | 事务 MULTI/EXEC | Lua 脚本 |
|---|---|---|---|
| 主要目的 | 减少 RTT(网络往返) | 命令打包顺序执行,中间不插入别人的命令 | 原子 + 可编程(能读到中间结果再决策) |
| 是否原子 | ❌ 不是,命令间可能插入其他客户端的命令 | ✅ 是(EXEC 期间不被打断) | ✅ 是 |
| 能否根据中间结果分支 | ❌ 不能 | ❌ 不能(命令提前入队,拿不到返回值) | ✅ 能 |
| 失败是否回滚 | — | ❌ 不回滚,执行期出错的命令跳过,其他照常生效 | ❌ 不回滚,但可以在脚本里自己判断后提前 return |
| 语法错误 | 各自报错 | 入队时就报错,整个事务 EXEC 被拒绝 | 加载时报错,整个脚本不执行 |
| 运行时错误(如对 String 用 LPUSH) | 单条失败 | 该条失败,其余成功——这是最反直觉的点 | 中断脚本,但已执行的命令不回滚 |
| 集群限制 | 需按节点拆分 | key 必须同槽 | key 必须同槽且应声明在 KEYS[] |
Redis 事务为什么"没有回滚"
bash
MULTI
SET user:1 "alice"
LPUSH user:1 "bob" # 运行时错误:对 String 执行 List 命令
SET user:2 "carol"
EXEC
# 结果:user:1 = "alice" 成功,LPUSH 报错,user:2 = "carol" 也成功
# Redis 不提供 ROLLBACK,作者认为运行时错误只可能来自编程失误,
# 生产代码不该出现,所以不值得为此付出回滚机制的复杂度和性能代价WATCH 实现乐观锁(CAS)
bash
WATCH stock:1001 # 监视这个 key
GET stock:1001 # 假设返回 10,业务侧算出要扣 3
MULTI
DECRBY stock:1001 3
EXEC # 若 WATCH 之后 stock:1001 被别人改过,EXEC 返回 nil(整个事务不执行)⚠️ WATCH 的三个坑:①
EXEC、DISCARD、连接断开都会清空所有 WATCH,所以失败后必须重新 WATCH 再重试;② 判断"被改过"是靠 key 的 dirty 标记,就算改回原值也算改过,存在 ABA 假失败;③ 高并发下重试率极高,性能远不如直接用 Lua 或DECRBY后判断结果为负再补偿。
Lua 脚本的工程规范
| 规范 | 原因 |
|---|---|
所有 key 必须通过 KEYS[] 传入,不能在脚本里拼字符串 | Cluster 要靠 KEYS[] 算槽位;硬编码 key 在集群下会报 CROSSSLOT 或打到错误节点 |
| 脚本必须短小无循环或循环次数可控 | Lua 执行期间阻塞整个 Redis,lua-time-limit 到了也只是开始接受 SCRIPT KILL,不会真的中断 |
禁止在脚本里用 TIME、RANDOMKEY、SRANDMEMBER 等非确定性命令后再写入 | 老版本会导致主从数据不一致(5.0 后改为传播脚本执行结果而非脚本本身,已缓解) |
用 EVALSHA + 预加载,而非每次发全文 | 省带宽;但要处理 NOSCRIPT 错误时回退到 EVAL |
7.0 之后优先考虑 Redis Function(FUNCTION LOAD) | 持久化到 RDB/AOF、可命名、可版本管理,不会因为重启丢失脚本缓存 |
面试加分表述:「一句话区分:pipeline 省网络,事务保顺序,Lua 保原子又能做判断。实际项目里我几乎不用
MULTI/EXEC,因为它既不能回滚也不能分支,能力被 Lua 完全覆盖;真正高频的是 pipeline(批量预热缓存)+ Lua(扣库存、分布式锁释放、限流) 这两个。唯一还会用到WATCH的场景是客户端库不方便下发脚本的时候。」
19. Java 侧落地:三个客户端、序列化与连接池
三个客户端怎么选
| 客户端 | 底层 | 线程安全 | 特点 | 适用 |
|---|---|---|---|---|
| Jedis | BIO,每连接一线程 | ❌ 实例不安全,必须配 JedisPool | API 贴近原生命令,直观 | 老项目、同步简单场景 |
| Lettuce | Netty NIO,单连接可多线程复用 | ✅ 安全 | Spring Boot 2.x 默认,支持响应式、自动重连、集群拓扑刷新 | 新项目首选 |
| Redisson | Netty | ✅ 安全 | 提供分布式锁、信号量、限流器、延时队列等分布式对象,不以命令为中心 | 需要分布式协调原语时 |
⚠️ Spring Boot 2.x 默认用 Lettuce,但默认不带连接池。Lettuce 共享单连接本身够用,可一旦你用了阻塞命令(
BLPOP)或事务,就需要独占连接。要启用池化必须额外引入commons-pool2依赖,否则spring.redis.lettuce.pool.*配置完全不生效,而且不报任何错——这是极其隐蔽的一类"配置写了没用"。
RedisTemplate 的序列化坑
java
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
// key 一律用 String,否则 redis-cli 里看到的是 \xac\xed\x00\x05 这种 JDK 序列化乱码
StringRedisSerializer keySer = new StringRedisSerializer();
template.setKeySerializer(keySer);
template.setHashKeySerializer(keySer);
// value 用 JSON,跨语言可读;注意要开启类型信息才能正确反序列化泛型
GenericJackson2JsonRedisSerializer valueSer = new GenericJackson2JsonRedisSerializer();
template.setValueSerializer(valueSer);
template.setHashValueSerializer(valueSer);
template.afterPropertiesSet();
return template;
}
}| 坑 | 现象 | 解法 |
|---|---|---|
| 不配序列化器 | 默认 JdkSerializationRedisSerializer,redis-cli 看到乱码,key 前面多一堆二进制前缀 | 显式配 StringRedisSerializer + JSON |
Jackson2JsonRedisSerializer 不带类型信息 | 反序列化泛型集合时得到 LinkedHashMap,强转报 ClassCastException | 用 GenericJackson2JsonRedisSerializer,或手动 activateDefaultTyping |
| JSON 里写入了类全名 | 重构包名/类名后老数据反序列化失败 | 要么用 String + 手动 JSON,要么保证类路径稳定 |
StringRedisTemplate 与 RedisTemplate 混用同一个 key | 一个写 JSON 一个写 JDK 序列化,互相读不出来 | 统一只用一种模板 |
连接池参数与超时
| 参数 | 建议 | 说明 |
|---|---|---|
max-active | 预估并发 QPS / 单连接 QPS,一般 8~50 | 设太大会耗尽 Redis 的 maxclients |
max-idle / min-idle | min-idle 设成常态并发数 | 避免冷启动时频繁建连 |
max-wait | 必须设(如 1000ms),默认 -1 表示无限等待 | 否则 Redis 变慢时业务线程全部挂死在获取连接上 |
timeout(命令超时) | 200~500ms | 缓存本来就是"快才有意义",宁可降级也不要长时间等 |
⚠️ 缓存调用必须有超时和降级。很多"Redis 慢导致全站雪崩"的事故,根因不是 Redis 慢,而是业务侧没设超时:Redis 卡 5 秒,Tomcat 的 200 个线程全部堵在等 Redis,于是整个应用无响应,连不依赖缓存的接口也一起挂。正确做法是命令超时设几百毫秒 + 捕获异常后回源或返回兜底值,让缓存故障只降低性能,不影响可用性。
面试加分表述:「Java 侧我强调两条:一是缓存不能成为强依赖,所有 Redis 调用都包在 try-catch 里,异常时走降级逻辑(回源 DB 并限流,或返回兜底数据),
@Cacheable也要配CacheErrorHandler吞掉异常;二是序列化方式要一次定好并写进规范,因为它一旦上线就很难改——老数据用 JDK 序列化写的,新代码用 JSON 读不出来,只能双写 + 灰度 + 等老 key 自然过期,成本极高。」
✨ 高级深挖知识点
1. SDS:Redis 为什么不用 C 原生字符串
c
struct sdshdr {
int len; // 已使用长度
int alloc; // 分配的总容量(不含头部和结尾的 \0)
char flags; // 头部类型:sdshdr5/8/16/32/64,按长度选最省内存的
char buf[]; // 实际数据,末尾仍放一个 \0 以便复用部分 C 函数
};| 对比项 | C 字符串 char* | Redis SDS |
|---|---|---|
| 取长度 | strlen 遍历,O(N) | 读 len 字段,O(1) |
| 缓冲区溢出 | strcat 不检查容量,会越界 | 拼接前检查 alloc,不足则自动扩容 |
| 内存重分配次数 | 每次增删都可能 realloc | 空间预分配 + 惰性释放,大幅减少 realloc |
| 二进制安全 | 以 \0 判断结束,不能存图片等二进制 | 以 len 判断结束,可存任意二进制 |
空间预分配规则:修改后若 len < 1MB,则额外多分配 len 字节(即容量翻倍);若 len ≥ 1MB,则固定多分配 1MB。惰性释放:字符串缩短时只改 len,不立即回收 alloc,留给下次增长复用(可用 SDS 的 trim 接口主动释放)。
⚠️ 惰性释放是
APPEND导致内存浪费的根源。对一个 key 反复APPEND,它的alloc可能远大于len,STRLEN看起来只有 100 字节,MEMORY USAGE却是几 KB。排查内存异常时要同时看这两个值。
2. listpack 取代 ziplist:连锁更新问题
ziplist 是一块连续内存,每个 entry 都保存着前一个 entry 的长度 prevlen,用于从后往前遍历。
text
ziplist 布局:
[zlbytes][zltail][zllen] [entry1][entry2]...[entryN] [zlend]
entry 布局:
[prevlen][encoding][data]
↑ prevlen 用 1 字节表示(前节点 < 254 字节)或 5 字节(≥ 254 字节)
连锁更新(cascade update):
原本 entry2..entryN 的前节点都是 253 字节 → prevlen 都只占 1 字节
现在在头部插入一个 300 字节的节点
→ entry2 的 prevlen 要从 1 字节扩成 5 字节
→ entry2 自身长度变成257 字节(跨过 254 阈值)
→ entry3 的 prevlen 也要扩成 5 字节
→ ... 一路连锁到尾部,最坏 O(N²) 的内存拷贝listpack(7.0 全面替换 ziplist)的解法:每个 entry 不再记录前节点长度,而是在自己的末尾记录自身的总长度 entry-len。要从后往前遍历时,先读末尾的长度字段跳到本节点开头。这样任何节点的修改都不会影响其他节点,连锁更新从根上消失。
| 结构 | 使用者(7.0 后) |
|---|---|
| listpack | Hash、ZSet、List(quicklist 的节点)、Set(小整数外的小集合) |
| ziplist | 已退役,仅为兼容旧 RDB 保留解析能力 |
面试加分表述:「ziplist 的设计是典型的'为了省内存牺牲修改复杂度'——用
prevlen变长编码省了几个字节,代价是极端情况下的连锁更新。listpack 把'记前驱长度'改成'记自身长度',一个很小的改动就把 O(N²) 的最坏情况消掉了,同时还保留了反向遍历能力。这是我很喜欢举的一个'数据结构微调带来质变'的例子。」
3. ZSet 为什么用跳表,而不是红黑树或 B+ 树
ZSet 的底层是 skiplist + dict 双结构:dict 保证 ZSCORE 按 member 查 score 是 O(1),skiplist 保证按 score 范围查询高效。
| 对比 | 跳表 | 红黑树 | B+ 树 |
|---|---|---|---|
| 范围查询 | ✅ 定位后沿最底层链表顺序走,极简单 | 需要中序遍历,实现复杂 | ✅ 也很好(叶子有链表) |
| 实现复杂度 | 极低(插入只需调整几个指针,无旋转) | 高(插入删除要旋转、变色) | 高 |
| 并发友好 | 局部修改,容易做无锁/细粒度锁 | 旋转影响范围大 | 分裂影响范围大 |
| 内存开销 | 每节点平均 1/(1-p) ≈ 1.33 个指针(p=0.25) | 固定 2 指针 + 颜色位 | 页内紧凑,适合磁盘 |
| 设计目标 | 内存结构 | 内存结构 | 磁盘结构(减少 IO 次数) |
核心结论三条:
- B+ 树是为磁盘设计的——它的"多路"是为了压低树高、减少磁盘 IO;Redis 全在内存里,没有 IO 放大问题,多路带来的只有额外复杂度。
- 红黑树的范围查询不好写——ZSet 的
ZRANGEBYSCORE、ZRANGEBYRANK是核心命令,跳表定位到起点后直接沿底层链表走即可;红黑树要维护中序遍历状态。 - 跳表实现简单得多——antirez 明确说过选跳表是因为"实现和调试都更简单",这对一个追求代码可维护性的项目非常重要。
⚠️ Redis 的跳表相比原版论文做了三处改动:① 最大层数 32(原版 16);② 概率 p = 0.25(原版 0.5),层数更矮更省内存;③ 节点里加了
backward指针和每层的span字段,前者支持反向遍历(ZREVRANGE),后者支持按排名查询(ZRANGE的 rank 是靠累加 span 算出来的)。这个span字段是很多人不知道的细节。
4. 渐进式 rehash:为什么扩容不会卡住 Redis
Redis 的 dict 里有两张哈希表 ht[0] 和 ht[1],以及一个 rehashidx 游标。
text
触发扩容: 负载因子 = used / size
≥ 1 且 没有子进程在做 bgsave/bgrewriteaof ──► 扩容到 >= used*2 的最小 2 次幂
≥ 5 无论有没有子进程 ──► 强制扩容
触发缩容: 负载因子 < 0.1 ──► 缩容到 >= used 的最小 2 次幂
渐进式搬迁:
rehashidx = 0 表示开始
① 每次对 dict 的增删改查,顺带把 ht[0] 的第 rehashidx 个桶整条链搬到 ht[1],rehashidx++
② 后台 serverCron 每 100ms 执行一次 databasesCron,花不超过 1ms 批量搬迁
③ 搬完所有桶后:释放 ht[0],令 ht[0] = ht[1],rehashidx = -1
rehash 期间的读写规则:
查找/删除:先查 ht[0],没找到再查 ht[1]
新增: **只写 ht[1]**,保证 ht[0] 的元素只减不增,搬迁必然收敛⚠️ "有子进程时不扩容"是为了配合 COW。bgsave/AOF 重写期间 fork 出了子进程,父子共享物理页;如果此时 rehash,会大面积修改哈希表结构,触发大量写时复制,导致内存占用暴涨。所以这期间把扩容阈值从 1 抬高到 5,宁可让哈希冲突变多(查找退化成短链表遍历),也要避免内存翻倍。这是一个典型的"内存 vs CPU"的权衡。
⚠️ 大 Hash 的 rehash 仍然可能造成毛刺。渐进式 rehash 每次只搬一个桶,但如果某个桶的冲突链特别长(比如 key 设计不当导致哈希倾斜),搬这一个桶就是一次长耗时操作。另外
ht[1]的分配本身是一次大块内存申请,百万级 Hash 扩容时malloc可能耗时几毫秒。
5. 近似 LRU:Redis 为什么不用标准 LRU
标准 LRU 需要一个双向链表把所有 key 按访问时间串起来,每次访问都要移动节点。对 Redis 来说这意味着:每个 key 多两个指针(16 字节),而且链表操作会破坏缓存局部性。Redis 选择了采样近似。
text
每个 redisObject 里有 24 bit 的 lru 字段,存的是"秒级时钟的低 24 位"
← 24 bit 约能表示 194 天,会回绕,所以比较时要处理回绕情况
淘汰流程(maxmemory-policy allkeys-lru):
① 从候选 key 里随机采样 maxmemory-samples 个(默认 5)
② 把它们按 idle time(空闲时间)放进一个大小为 16 的**淘汰池 evictionPool**
← 3.0 引入的关键改进:淘汰池是跨多次淘汰**持久保留**的,
每次新采样的 key 只有比池里现有的更"冷"才能进去
③ 淘汰池里 idle 最大的那个 key 被删除
④ 内存还不够?回到 ①maxmemory-samples | 效果 | 代价 |
|---|---|---|
| 3 | 明显偏离真实 LRU,可能淘汰刚访问过的 key | 最快 |
| 5(默认) | 和真实 LRU 已非常接近 | 均衡 |
| 10 | 几乎等于真实 LRU | CPU 开销明显上升 |
面试加分表述:「Redis 作者在博客里贴过一张三色散点图对比真实 LRU、samples=5、samples=10 的淘汰效果,结论是 samples=5 加上淘汰池之后,和真实 LRU 的差距已经小到不值得为精确性付出内存和 CPU 成本。这体现了 Redis 一贯的设计哲学:缓存淘汰本身就是启发式的,追求理论最优没有收益。我们线上就是用默认的 5。」
6. LFU:8 bit 对数计数器 + 衰减
LRU 有个硬伤:偶尔被扫一遍的冷数据会把真正的热数据挤掉(比如一次全表遍历的爬虫请求)。LFU 按访问频率淘汰,更适合这种场景。
text
同样复用 24 bit 的 lru 字段,但拆成两段:
┌──────── 16 bit ────────┬──────── 8 bit ────────┐
│ ldt 上次递减时间 │ logc 计数器 │
│ (分钟级时钟低 16 位)│ (对数计数,0~255) │
└────────────────────────┴───────────────────────┘
计数器递增(概率式对数计数):
r = random(0,1)
baseval = logc - LFU_INIT_VAL(5)
p = 1 / (baseval * lfu-log-factor + 1)
if r < p then logc++ # 计数越大,增长越难,8 bit 就能表示极高频率
计数器衰减:
每次访问该 key 时,先看 now - ldt 过了多少分钟
每过 lfu-decay-time(默认 1)分钟,logc 减 1
← 没有衰减的话,历史热 key 会永远占着高分,新热点永远上不来| 配置 | 默认 | 作用 |
|---|---|---|
lfu-log-factor | 10 | 越大,计数器增长越慢,能区分的频率范围越大 |
lfu-decay-time | 1(分钟) | 越小,遗忘越快,越偏向"最近的热度" |
bash
# 查看某个 key 的 LFU 计数(必须先设 maxmemory-policy 为 allkeys-lfu)
OBJECT FREQ user:1001
# 查看空闲时间(LRU 模式下用)
OBJECT IDLETIME user:1001⚠️
OBJECT FREQ和OBJECT IDLETIME互斥。前者要求当前策略是 LFU,后者要求是 LRU,否则直接报错。排查"为什么这个 key 被淘汰了"的时候先确认策略。
面试加分表述:「LRU 和 LFU 的选择看业务的访问模式:如果热点会随时间迁移(比如新闻资讯,昨天的头条今天就凉了),LRU 更合适;如果存在长期稳定的热数据,同时又有扫描型流量(批量导出、爬虫、定时任务全量刷),那必须用 LFU,否则一次扫描就能把缓存命中率打到地板上。我们商品详情缓存从 LRU 切到 LFU 后,命中率从 87% 提到 95%,根因就是每天凌晨的全量同步任务在污染缓存。」
7. fork、写时复制与透明大页
BGSAVE 和 BGREWRITEAOF 都要 fork() 出子进程。理解 fork 的代价是排查 Redis 毛刺的核心能力。
text
fork 做了什么:
① 复制父进程的**页表**(不是数据!),父子指向同一批物理页
② 所有页标记为**只读**
③ 父进程后续写某一页 → 触发缺页中断 → 内核复制该页 → 父进程写副本
这就是 Copy On Write
两处代价:
代价一:fork 本身阻塞主线程
耗时 ≈ 内存大小 / 一个经验系数,粗略按 **每 GB 约 20ms**(普通物理机)估算
10GB 实例 fork 一次就是约 200ms 的全局卡顿
代价二:COW 导致的内存膨胀
子进程存活期间,父进程写得越多,被复制的页就越多
极端情况(全量刷新)内存占用接近 **2 倍**| 监控/配置项 | 作用 |
|---|---|
INFO stats → latest_fork_usec | 上次 fork 耗时(微秒),持续关注这个值 |
INFO memory → used_memory_rss | 实际占用的物理内存,COW 期间会明显高于 used_memory |
vm.overcommit_memory = 1 | 必须设置,否则内存不足一半时 fork 直接失败,bgsave 报错 |
| 关闭 THP | echo never > /sys/kernel/mm/transparent_hugepage/enabled |
⚠️ THP(透明大页)为什么必须关。Linux 的 THP 会把内存页从 4KB 提升到 2MB。开启后,COW 时复制一页的代价从 4KB 变成 2MB,放大 512 倍:写入延迟明显上升,内存消耗暴涨。Redis 启动时如果检测到 THP 开着会打警告日志,很多人忽略了它,结果表现为"持久化期间 P99 延迟飙升"。关闭后还要写进
/etc/rc.local,否则重启失效。
⚠️ 云主机/容器上 fork 可能更慢。虚拟化环境下页表复制的开销高于物理机,同样 10GB 实例在某些云上 fork 要 500ms 以上。所以云上部署更推荐小实例多分片(单实例控制在 4~8GB),而不是堆一个大实例。
8. AOF 重写的写放大与 7.0 的 Multi Part AOF
7.0 之前的问题
text
BGREWRITEAOF 期间:
主进程 ──写命令──► ① 追加到 aof_buf(写旧 AOF 文件)
└► ② 同时追加到 aof_rewrite_buf(重写缓冲区)
子进程 ──► 遍历内存生成新 AOF 文件
子进程结束 ──► 主进程把 aof_rewrite_buf 的内容**追加**到新文件 ──► 原子 rename 替换
三个问题:
① 同一条命令写了**两份缓冲区**,内存和 CPU 双倍开销
② 重写期间写入量大时 aof_rewrite_buf 可能涨到几 GB
③ 收尾时把几 GB 的缓冲区一次性写进新文件,这个过程**阻塞主线程**7.0 的 Multi Part AOF(multi-part AOF)
text
目录结构(appenddirname 默认 "appendonlydir"):
appendonlydir/
├── appendonly.aof.1.base.rdb ← 基础文件:重写时刻的全量快照(RDB 格式)
├── appendonly.aof.1.incr.aof ← 增量文件:基础之后的所有写命令
└── appendonly.aof.manifest ← 清单文件:记录当前有哪些 base/incr 文件
重写流程变成:
① 打开一个**新的** incr 文件,主进程后续写入直接进新 incr
② 子进程生成新的 base 文件
③ 子进程完成 → 更新 manifest,指向 新base + 新incr,删除旧文件
④ 全程**不需要 aof_rewrite_buf**,主进程没有额外内存开销,收尾也不阻塞| 维度 | 7.0 之前 | 7.0 Multi Part AOF |
|---|---|---|
| 重写期间额外内存 | 等于期间的写入量,可能几 GB | 接近 0 |
| 收尾阻塞 | 有(写入大缓冲区) | 无(只改 manifest) |
| 文件组织 | 单文件 | base + incr + manifest 多文件 |
| 重写失败的影响 | 可能留下半截临时文件 | manifest 没更新就等于没发生,天然原子 |
| 重写触发配置 | 默认 | 说明 |
|---|---|---|
auto-aof-rewrite-percentage | 100 | 比上次重写后的大小增长 100% 就触发;设 0 关闭自动重写 |
auto-aof-rewrite-min-size | 64mb | 小于这个大小不触发,避免小文件频繁重写 |
aof-rewrite-incremental-fsync | yes | 重写时每累积 32MB 主动 fsync 一次,避免一次性刷盘造成长阻塞 |
⚠️
auto-aof-rewrite-percentage 100在大实例上会退化。假设实例 AOF 已经 20GB,那么要再涨 20GB 才触发重写,期间 AOF 文件一直是几十 GB,重启恢复要很久。大实例建议改成固定时间窗口由运维脚本在业务低峰期手动BGREWRITEAOF,并把自动触发的 percentage 调大作为兜底。
面试加分表述:「AOF 重写这个点能拉开差距,因为大多数人只会说'重写是把多条命令合并成一条'。真正的深度在于:重写期间新写入的数据去哪了。7.0 之前是双缓冲区 + 收尾追加,有内存放大和阻塞;7.0 用 base + incr + manifest 的多文件方案,让新写入直接落到新的 incr 文件,彻底消除了重写缓冲区。这是近几个版本里我认为最实用的一个改进。」
9. 大 key 与热 key:识别、危害与治理
什么算大 key
| 类型 | 警戒线 | 危险线 |
|---|---|---|
| String | value > 10KB | >100KB |
| Hash / Set / ZSet / List | 元素数 > 5000 | > 10000,或单元素 > 10KB |
大 key 的五个危害
text
① 网络阻塞 一个 1MB 的 value,千兆网卡每秒只能传约 100 个,带宽被打满
② 命令阻塞 DEL 一个百万元素的 Hash 是 O(N),主线程卡死几百毫秒
③ 阻塞点扩散 主从复制、RDB 加载、AOF 重写都要完整处理这个 key
④ 内存不均 Cluster 下某个分片内存远高于其他分片,无法通过加节点解决
⑤ 淘汰风险 淘汰一个大 key 要释放大量内存,同步执行时造成毛刺识别手段
bash
# ① 离线扫描:对每种类型找出最大的 key(遍历全库,-i 控制采样间隔避免影响线上)
redis-cli --bigkeys -i 0.1
# ② 内存维度扫描(4.0+,比 --bigkeys 更准,因为算的是真实内存占用)
redis-cli --memkeys -i 0.1
MEMORY USAGE user:1001 SAMPLES 0
# ③ 热 key 扫描(需要 maxmemory-policy 为 lfu)
redis-cli --hotkeys -i 0.1
# ④ 实时抓取(危险:会把所有命令推给你,只能短时间用)
redis-cli monitor
# ⑤ 离线分析 RDB(推荐,零线上影响)
# 用 rdb_bigkeys / redis-rdb-tools 解析 dump.rdb,生成 CSV 报表⚠️
--bigkeys看的是"元素个数",不是内存。一个有 10 万个 1 字节元素的 Set 和一个有 100 个 10KB 元素的 Set,前者会被--bigkeys报出来,后者可能被漏掉,但后者的内存占用更大。所以 4.0 以上优先用--memkeys。另外这两个命令底层都是SCAN,会增加 Redis 负载,务必加-i限速并在低峰执行。
大 key 治理方案
| 方案 | 做法 | 适用 |
|---|---|---|
| 拆分 | 把一个大 Hash 按 hash(field) % N 拆成 N 个小 Hash | 元素多但访问分散 |
| 压缩 | value 用 protobuf/gzip 代替 JSON | String 型大 key,CPU 换内存与带宽 |
| 换结构 | 用 BitMap/HLL 替代大 Set | 只需要存在性/基数 |
| 分页读取 | 用 HSCAN/SSCAN/ZSCAN 替代 HGETALL/SMEMBERS | 必须整体遍历时 |
| 异步删除 | UNLINK 替代 DEL,开启 lazyfree-lazy-* 四个开关 | 删除大 key |
| 移出 Redis | 本来就不适合放缓存的大对象(图片、长文档)放对象存储 | 根治 |
热 key 治理方案
| 方案 | 做法 | 代价 |
|---|---|---|
| 本地缓存 | JVM 侧 Caffeine 缓存 1~5 秒,挡住绝大部分请求 | 多实例间短时不一致 |
| key 打散 | 写入 hot:key:0 ~ hot:key:9 十个副本,读时随机选一个 | 更新要写 N 份 |
| 读写分离 | 热点只读数据扩从库,读请求分散 | 主从延迟 |
| 独立实例 | 把热点业务单独部署一套 Redis,物理隔离 | 成本 |
面试加分表述:「大 key 最隐蔽的危害不是'慢',而是它会让所有看似无关的问题一起爆发:主从同步因为它变慢、fork 因为它变慢、淘汰因为它产生毛刺、Cluster 因为它数据倾斜。我们排查一次'Redis 偶发 P99 毛刺',最后定位到的是运营同学往一个 List 里塞了 80 万条审计日志。治理手段上我强调两点:一定用
UNLINK不用DEL,以及上线前的 key 设计评审——事后治理的成本比事前约定高一个数量级。」
10. 分布式锁的安全性边界:Martin 与 antirez 之争
这是分布式系统里最著名的一场公开辩论,面试中答出来就是明显的区分度。
Martin Kleppmann 的核心质疑
text
场景:客户端 A 拿到锁(TTL 30s),准备写共享资源
A: 加锁成功 ──► 开始处理 ──► [ 遭遇 15s 的 GC STW / 网络分区 ] ──► 恢复,继续写资源
锁: t=0 持有 t=30 过期
B: t=31 加锁成功 ──► 写资源
↑
此刻 A 和 B 同时认为自己持有锁,都在写关键结论:任何基于"时间租约"的锁都无法保证互斥,因为:
- 进程可能被任意长时间暂停——GC STW、虚拟机挂起、容器被 CFS 限流、磁盘 IO 卡住。
- 网络延迟没有上界——消息可能延迟任意长时间后才到达。
- 时钟不可信——NTP 跳变、闰秒、虚拟机时钟漂移都会让"我还持有 10 秒"的判断失效。
Martin 的解法:fencing token(栅栏令牌)
text
锁服务每次发锁时附带一个**单调递增**的 token(如 33, 34, 35...)
客户端把 token 一起发给**存储服务**
存储服务记录"我见过的最大 token",拒绝任何更小的 token 的写入
A 持锁 token=33 ──► 卡住 15s ──► 写(token=33) ──► 存储:当前已见 34,拒绝 ✅
B 持锁 token=34 ──► 写(token=34) ──► 存储:接受,记录 34⚠️ fencing token 的前提是"下游资源支持校验"。MySQL 可以用版本号做乐观锁
UPDATE ... WHERE version = 33,对象存储有些支持条件写入,但很多下游(发短信、调第三方 API)根本没法校验,这时只能靠业务幂等(唯一请求 ID + 去重表)来兜底。所以实践中**"分布式锁 + 幂等"是标配,而不是二选一**。
antirez 的回应要点
| antirez 的论点 | 评价 |
|---|---|
| 如果下游能校验 token,那它本身就有一致性能力,为什么还需要分布式锁 | 有道理,但现实中下游的校验能力往往很弱 |
| Redlock 对"效率型"锁足够,对"正确型"锁本来就该用 ZooKeeper/etcd | 这是双方的共识,也是面试该给出的答案 |
| 时钟漂移可以通过运维手段(禁用大跳变 NTP)控制 | 理论上不严谨,工程上可接受 |
面试加分表述:「这场争论的落点其实双方是一致的:锁要分清用途。'效率锁'(避免重复做一件不致命的事)用 Redis 完全够用,简单、快、便宜;'正确锁'(钱、库存、唯一性)不要只信任何一个基于租约的锁,必须在数据层加约束——唯一索引、乐观锁版本号、状态机 CAS,或者用 ZooKeeper/etcd 这类基于共识算法 + 会话机制的协调服务。我在项目里的做法是:Redis 锁 + 数据库唯一索引 + 业务幂等键,三层中任意一层失效都不会造成资金错误。」
11. Redlock:原理、代价与为什么工程上少用
Redlock 算法(5 个独立 Redis 主节点,无主从)
text
① 记录当前时间 T1
② 依次向 5 个节点发 SET key uuid NX PX 10000
每个请求都带一个很短的超时(如 50ms),节点不可用就立刻跳过,不等
③ 记录当前时间 T2,计算 elapsed = T2 - T1
④ 加锁成功的条件(两个必须同时满足):
a. 成功节点数 >= N/2 + 1 = 3
b. elapsed < 锁有效期(10000ms)
⑤ 锁的**实际有效期** = 10000 - elapsed - 时钟漂移补偿
← 这一步最容易被忽略:加锁花了 3 秒,你真正能用的只剩 7 秒
⑥ 任一条件不满足 → 向**所有 5 个节点**发送释放(包括那些以为没加上的,因为可能是回复丢了)为什么线上很少用
| 原因 | 说明 |
|---|---|
| 成本高 | 需要 5 个完全独立的 Redis 实例(不是 5 个节点的集群,也不能是主从),运维成本是单实例的 5 倍 |
| 性能差 | 每次加解锁都是 5 次网络往返(即使并行,也要等最慢的),延迟明显上升 |
| 安全性仍不完备 | Martin 的 GC/网络延迟/时钟质疑依然成立,Redlock 只解决了"单点主从切换丢锁",没解决根本问题 |
| 时钟依赖 | 依赖各节点时钟不发生大幅跳变,而这在虚拟化环境里无法保证 |
| 续期困难 | 看门狗要同时给 5 个节点续期,任一失败的处理逻辑非常复杂 |
| 替代品更好 | 真要强一致,ZooKeeper(ZAB)或 etcd(Raft)更合适,它们本来就是为协调而生 |
三种分布式锁实现的横向对比
| 维度 | Redis(SET NX / Redisson) | ZooKeeper(临时顺序节点) | etcd(Lease + Txn) |
|---|---|---|---|
| 一致性模型 | AP(可用性优先,异步复制可能丢锁) | CP(一致性优先) | CP |
| 性能 | 最高(万级 TPS) | 中(写要过半确认) | 中 |
| 锁失效机制 | TTL 到期 | 会话(Session)断开自动删临时节点 | Lease 到期不续约 |
| 惊群效应 | Redisson 用 Pub/Sub 通知,较好 | 临时顺序节点 + 只监听前驱,天然避免 | Watch 机制 |
| 宕机检测 | 靠 TTL,有延迟 | 靠心跳,更及时 | 靠 Lease 续约 |
| 适合 | 效率型锁、高并发 | 正确型锁、低频关键操作 | 正确型锁、云原生体系 |
⚠️ ZooKeeper 的锁也不是绝对安全。如果客户端 GC 停顿超过 session timeout,ZK 会删掉临时节点让别人拿到锁,而 GC 结束后的客户端可能还没感知到 session 已失效。区别在于 ZK 的"失效"是服务端判定 + 客户端会收到 SessionExpired 事件,比纯 TTL 更可控,但窗口依然存在。
面试加分表述:「Redlock 我会这么评价:它的思路(多数派 + 考虑加锁耗时)是对的,但它解决的是一个次要问题(单点切主丢锁),没解决主要问题(进程暂停和时钟不可信),同时把成本提高了 5 倍。所以绝大多数团队的选择是:要么降级用单实例 Redis 锁 + 业务幂等兜底,要么升级用 ZooKeeper/etcd,中间那档 Redlock 性价比最低。能说出'Redlock 不是不安全,而是性价比不划算'这个判断,比单纯背算法步骤要值钱。」
12. 主从异步复制的丢数据窗口与脑裂
两类数据丢失
text
情况一:异步复制丢失
客户端 ──SET a 1──► 主库执行成功,立即返回 OK
主库还没把命令传给从库就宕机
哨兵切主 ──► 新主库没有 a=1 ──► **这条数据永久丢失**
情况二:脑裂(split brain)
网络分区:主库 M 与哨兵/从库失联,但**客户端仍能连上 M**
① 哨兵判定 M 下线,把从库 S 提升为新主 M'
② 客户端 C 不知情,继续往 M 写数据(M 以为自己还是主)
③ 网络恢复,M 被降级为 M' 的从库
④ M 执行全量同步,**清空自己的数据**加载 M' 的 RDB
⑤ 分区期间写进 M 的所有数据**全部丢失**对抗脑裂的两个参数
bash
# 主库至少要有 1 个从库的 ACK 延迟不超过 10 秒,否则主库拒绝写入
min-replicas-to-write 1
min-replicas-max-lag 10
# 5.0 之前叫 min-slaves-to-write / min-slaves-max-lagtext
效果:
网络分区后,M 收不到任何从库的 ACK
→ 10 秒后 M 自动**拒绝所有写请求**(返回 NOREPLICAS 错误)
→ 把"丢失窗口"从"整个分区时长"压缩到"最多 10 秒"
→ 代价:牺牲可用性换一致性,客户端必须能处理写失败| 参数组合 | 效果 | 适用 |
|---|---|---|
| 不设置(默认) | 分区期间一直可写,数据可能大量丢失 | 纯缓存、丢了无所谓 |
to-write 1 + max-lag 10 | 最多丢 10 秒 | 一般业务的平衡点 |
to-write 2 + max-lag 5 | 更严格,但挂两个从库就不可写 | 对数据较敏感 |
⚠️
min-replicas-to-write不能设成等于从库总数。假设 2 个从库都设要求 2 个 ACK,那么任意一个从库重启、网络抖动、做 bgsave 卡顿,主库就立刻整体拒绝写入,可用性反而更差。正确做法是to-write ≤ 从库数 - 1。
从一致性角度看 Redis 的定位
| 配置 | 丢数据风险 | 性能 |
|---|---|---|
| 纯内存,无持久化 | 重启全丢 | 最快 |
| RDB only | 丢最后一次快照之后的数据(可能几分钟) | 快 |
AOF everysec | 丢最多 1 秒 | 较快 |
AOF always | 几乎不丢(仍受磁盘缓存/硬件影响) | 慢 2~3 倍 |
AOF + WAIT 1 100 | 等 1 个从库确认再返回 | 延迟明显上升 |
面试加分表述:「要明确一个前提:Redis 是 AP 系统,不是 CP 系统,它在设计上就选择了可用性和性能,一致性靠配置'尽力而为'。所以面试里如果被问'怎么保证 Redis 不丢数据',正确答法不是罗列配置,而是先说'不能完全保证',然后说明三个层次:
appendfsync always把单机丢失压到最小、min-replicas-to-write把脑裂丢失压到最小、但真正不能丢的数据必须以 MySQL 等 CP 存储为准,Redis 只做加速层。」
13. slot 迁移的细节与 cluster-require-full-coverage
迁移一个 slot 的完整步骤
bash
# ① 目标节点准备接收
CLUSTER SETSLOT 3999 IMPORTING 源节点ID
# ② 源节点准备迁出
CLUSTER SETSLOT 3999 MIGRATING 目标节点ID
# ③ 分批取出该 slot 的 key 并逐个迁移
CLUSTER GETKEYSINSLOT 3999 100
MIGRATE 目标IP 目标端口 "" 0 5000 KEYS key1 key2 key3
# ④ 所有 key 迁完后,通知集群槽位归属变更(向所有主节点广播)
CLUSTER SETSLOT 3999 NODE 目标节点ID| 阶段 | 源节点行为 | 目标节点行为 |
|---|---|---|
| 迁移中,key 还在源 | 正常执行 | 回 MOVED(除非带 ASKING) |
| 迁移中,key 已到目标 | 回 ASK | 带 ASKING 才执行 |
| 迁移完成 | 回 MOVED | 正常执行 |
⚠️
MIGRATE是同步阻塞命令。它会在源节点序列化 key、发送、等待目标节点回复 OK、然后删除本地 key,整个过程阻塞源节点主线程。如果 slot 里有大 key,一次MIGRATE就能卡住几百毫秒甚至超时失败(留下"源删了目标没收到"的风险,虽然 Redis 做了原子保证)。所以迁移前必须先治理大 key,并把MIGRATE的批量大小调小(一次 10~100 个 key)。
cluster-require-full-coverage 这个开关
| 取值 | 行为 | 取舍 |
|---|---|---|
yes(默认) | 只要有任何一个 slot 没有节点负责,整个集群停止服务(返回 CLUSTERDOWN) | 一致性优先:宁可全停,也不让部分数据不可见造成逻辑错误 |
no | 只有落在缺失 slot 上的 key 报错,其他 slot 照常服务 | 可用性优先:缓存场景几乎都该设 no |
⚠️ 很多人被"一个主节点挂了且它的从节点也挂了,结果整个集群不可用"坑过,根因就是这个默认值
yes。纯缓存场景建议改成no,把影响范围限制在 1/N 的数据上;但如果 Redis 承担了数据库职责(比如存了唯一的会话或计数),no会让"查不到"和"不存在"无法区分,反而更危险。
其他 Cluster 运维要点
| 要点 | 说明 |
|---|---|
cluster-node-timeout | 默认 15 秒,既是故障判定阈值,也影响 MIGRATE 等命令的超时 |
cluster-replica-validity-factor | 从节点与主节点断连时间超过 node-timeout × factor(默认 10)则失去晋升资格 |
cluster-migration-barrier | 默认 1,一个主节点至少保留 1 个从节点后,多余的从节点才能被自动迁移去支援没有从节点的主节点 |
CLUSTER FORGET | 移除节点要在所有节点上执行,并且有 60 秒黑名单期,否则 Gossip 会把它加回来 |
| 扩容流程 | CLUSTER MEET 加入 → 分配 slot(redis-cli --cluster reshard)→ 加从节点 |
面试加分表述:「Cluster 的运维细节里,我认为最值得记住的两个开关是
cluster-require-full-coverage(默认 yes 会导致局部故障放大成全局不可用,缓存场景要改 no)和cluster-migration-barrier(它决定从节点能不能自动支援其他主节点,是提升集群自愈能力的关键)。另外扩缩容时要先治理大 key,因为MIGRATE是同步阻塞的,一个 500MB 的 key 能把整个迁移流程卡死。」
14. 内存碎片与 activedefrag
bash
INFO memory
# used_memory : Redis 自己统计的数据占用(逻辑内存)
# used_memory_rss : 操作系统看到的进程物理内存
# mem_fragmentation_ratio = used_memory_rss / used_memory| 比值 | 含义 | 处理 |
|---|---|---|
| 1.0 ~ 1.5 | 正常 | 不用管 |
| > 1.5 | 内存碎片严重,有大量分配器持有但未使用的空洞 | 开启 activedefrag 或重启 |
| < 1 | 物理内存不够,部分数据被 swap 到磁盘 | 🚨 最危险的情况,必须立刻扩容或淘汰数据 |
碎片的两个来源
text
来源一:内存分配器的规格化
jemalloc 按固定规格分配:8, 16, 32, 48, 64, 80, 96, 112, 128... 字节
申请 50 字节 → 实际给 64 字节 → 浪费 14 字节
value 大小分布越不规则,浪费越多
来源二:频繁修改与删除留下的空洞
key 删除后内存还给分配器,但分配器不一定还给操作系统
(因为一个 page 上还有其他存活对象,整页无法释放)
→ used_memory 降了,used_memory_rss 不降自动碎片整理(4.0+,要求分配器是 jemalloc)
bash
activedefrag yes
active-defrag-ignore-bytes 100mb # 碎片字节数超过 100MB 才启动
active-defrag-threshold-lower 10 # 碎片率超过 10% 开始整理
active-defrag-threshold-upper 100 # 碎片率达到 100% 时用最大力度
active-defrag-cycle-min 1 # 整理占用 CPU 的最小百分比
active-defrag-cycle-max 25 # 最大百分比(力度随碎片率在 min~max 间线性变化)⚠️ 碎片整理是在主线程里做的,会抢占命令执行时间。它的原理是把对象重新分配到新地址并修改所有指向它的指针,这个过程必须串行。线上开启后要观察 P99 延迟,如果毛刺明显就把
active-defrag-cycle-max调小(比如 10)。另外如果用的不是 jemalloc(比如编译时指定了 libc),这组配置完全无效,CONFIG SET activedefrag yes会直接报错。
⚠️ 必须禁用 swap 或至少把
vm.swappiness设成 1。Redis 的延迟优势建立在"全内存"之上,一旦 key 被换出到磁盘,访问延迟会从微秒级跳到毫秒甚至几十毫秒级,而且这种慢是随机出现、无法预测的,比直接 OOM 更难排查。判断方法:cat /proc/进程号/smaps | grep Swap看是否有非零值。
15. Redis 的阻塞点全集
Redis 主线程被阻塞,意味着所有客户端一起变慢。把阻塞点按来源分成五类,排查时逐类过一遍。
| 类别 | 具体阻塞点 | 应对 |
|---|---|---|
| 慢命令 | KEYS *、FLUSHALL(同步)、HGETALL/SMEMBERS 大集合、ZRANGE 0 -1、SORT、SUNIONSTORE、LRANGE 0 -1 | 禁用 KEYS,用 SCAN 系列;FLUSHALL ASYNC;分页取数 |
| 大 key 操作 | DEL 大 Hash/Set/ZSet、大 key 过期、大 key 淘汰、MIGRATE 大 key | UNLINK;开启 4 个 lazyfree-lazy-*;治理大 key |
| 持久化相关 | fork 本身、AOF always 的 fsync、AOF 重写收尾(7.0 前)、AOF 缓冲区满时主线程直接 fsync | 控制实例大小;用 everysec;升级 7.0;换 SSD |
| 系统与内核 | THP 导致的 COW 放大、swap、磁盘 IO 饱和、CPU 被其他进程抢占、NUMA 跨节点访问 | 关 THP、关 swap、独占部署、绑核 |
| 网络与协议 | 大 value 传输占满带宽、短连接频繁握手、客户端输出缓冲区超限、MONITOR 开着 | 连接池;压缩 value;慎用 MONITOR |
| 扩展机制 | Lua 脚本执行期间、长时间的 activeExpireCycle、activedefrag 力度过大、模块(Module)的同步 API | 脚本短小;调低整理力度 |
四个 lazyfree 开关(4.0+)
bash
lazyfree-lazy-eviction yes # 内存淘汰时异步释放
lazyfree-lazy-expire yes # 过期删除时异步释放
lazyfree-lazy-server-del yes # 隐式删除(如 SET 覆盖旧的大 value、RENAME 覆盖)时异步释放
replica-lazy-flush yes # 从库全量同步前清空旧数据时异步释放
lazyfree-lazy-user-del yes # 6.0+:让 DEL 的行为等同于 UNLINK⚠️
lazyfree不是万能的。它只把"释放内存"这一步丢给 bio 线程,计算要释放哪些内存、从字典里摘除 key 这些步骤仍在主线程。所以一个百万元素的 Hash,用UNLINK依然比删一个小 key 慢,只是从几百毫秒降到几毫秒。另外lazyfree会让内存释放延迟生效,在内存紧张时可能导致淘汰判断滞后。
面试加分表述:「排查 Redis 慢我有一套固定顺序:第一步看
SLOWLOG GET有没有慢命令,第二步看INFO stats的latest_fork_usec排除持久化,第三步用LATENCY DOCTOR/LATENCY HISTORY看延迟事件的归类,第四步看INFO memory的碎片率和是否 swap,第五步才去看机器层面的 CPU、磁盘、网卡。顺序很重要,因为前三步能覆盖八成以上的问题,而且都不需要登机器。」
16. 多级缓存架构与预热、降级、熔断
text
用户 ──► ① 浏览器缓存(Cache-Control / ETag)
──► ② CDN(静态资源、可缓存的接口响应)
──► ③ Nginx 本地缓存(proxy_cache / OpenResty 的 shared dict + Lua)
──► ④ 应用本地缓存(Caffeine / Guava,进程内,纳秒级)
──► ⑤ 分布式缓存(Redis,毫秒级,跨实例共享)
──► ⑥ 数据库(MySQL + 本身的 Buffer Pool)
每一层的职责:挡掉一部分流量,让下一层的 QPS 降一个数量级| 层级 | 延迟量级 | 容量 | 一致性 | 主要用途 |
|---|---|---|---|---|
| 本地缓存 | 纳秒 | 小(受 JVM 堆限制) | 差(多实例间不同步) | 挡住热 key 的瞬时洪峰 |
| Redis | 亚毫秒~毫秒 | 大 | 较好(全局共享) | 主力缓存层 |
| DB | 毫秒~十毫秒 | 最大 | 权威 | 数据源 |
本地缓存的两个核心取舍
| 问题 | 方案 |
|---|---|
| 多实例间数据不一致 | ① TTL 设极短(1~5 秒),用"短时不一致"换"抗洪峰";② 通过 Redis Pub/Sub 或 MQ 广播失效消息,各实例收到后清本地缓存 |
| 占用 JVM 堆、影响 GC | 严格限制 maximumSize,只放最热的几百到几千个 key,并监控命中率 |
⚠️ 不要给本地缓存配长 TTL。本地缓存的价值是"挡住 1 秒内的 10 万次重复请求",1 秒的 TTL 就能把 QPS 从 10 万降到 1。配成 10 分钟则收益几乎不增加(请求早已被挡住),不一致的窗口却放大了 600 倍。
缓存预热
text
触发时机:应用启动、Redis 实例重建、大促开始前、每日凌晨低峰
预热内容:Top N 热点商品、首页配置、字典表、分类树
预热方式:
① 启动时同步加载核心少量数据(阻塞启动,保证可用)
② 异步线程池分批 pipeline 灌入大批量数据(不阻塞启动)
③ 独立的预热任务 + 开关,由运维在大促前手动触发
注意:预热要限速,否则一次性几十万次 DB 查询会把数据库打挂降级与熔断
| 手段 | 触发条件 | 行为 |
|---|---|---|
| 超时控制 | 单次 Redis 调用超过 200ms | 放弃等待,走回源或兜底 |
| 异常兜底 | Redis 连接失败、NOREPLICAS 等错误 | 捕获后回源,不要抛给用户 |
| 回源限流 | 缓存不可用时 DB 并发超阈值 | 信号量隔离,超出的请求直接返回兜底数据 |
| 熔断 | 错误率超过阈值持续 N 秒 | 一段时间内直接走兜底,不再尝试,给下游恢复时间 |
| 功能降级 | 系统整体压力过大 | 关闭非核心功能(推荐、排行榜),保住下单链路 |
java
public Product getProduct(Long id) {
try {
String json = redisTemplate.opsForValue().get("product:" + id);
if (json != null) {
return JSON.parseObject(json, Product.class);
}
} catch (Exception e) {
// 缓存异常绝不能让请求失败,记录指标后继续往下走
cacheErrorCounter.increment();
log.warn("redis unavailable, fallback to db, id={}", id, e);
}
// 回源受信号量保护,避免缓存挂掉时 DB 被打穿
if (!dbSemaphore.tryAcquire()) {
return Product.fallback(id); // 兜底对象,前端展示"稍后重试"
}
try {
Product p = productMapper.selectById(id);
safePutCache("product:" + id, p); // 写缓存同样包在 try 里
return p;
} finally {
dbSemaphore.release();
}
}面试加分表述:「多级缓存最关键的认知是:每一层解决的问题不同,不是简单的'多加一层更快'。本地缓存解决的是热 key 的瞬时并发,Redis 解决的是跨实例共享和容量,CDN 解决的是地理距离和带宽。另外我特别强调一条铁律:缓存必须是弱依赖。判断标准很简单——把 Redis 整个停掉,系统应该是变慢、部分功能降级,而不是直接 500。做不到这一点,缓存就从'性能优化'变成了'新增的单点故障'。」
17. 过期 key 在主从与集群下的行为
核心规则:从库不主动删除过期 key
text
主库:惰性删除 + 定期删除,删除后向从库传播一条 DEL(7.0 起是 UNLINK)命令
从库:① 不主动删除任何过期 key,**只等主库的 DEL 命令**
② 读到一个已过期但还没收到 DEL 的 key 时,**逻辑上返回 nil**(Redis 3.2+),
但数据仍在内存里,dbsize 仍然算它
为什么要这样设计:
如果从库自己删,主从之间就会出现"同一时刻数据不一致",
而且违反了"从库只能被动接受主库数据流"这个一致性前提| 场景 | 行为 | 风险 |
|---|---|---|
| 主库正常 | 主库删 → 传播 DEL → 从库删 | 正常 |
| 主库定期删除没扫到该 key | 从库读返回 nil,但内存没释放 | 从库内存虚高,INFO keyspace 的数量不准 |
| Redis 3.2 之前 | 从库会返回过期的旧值 | 严重 bug,已修复 |
| 从库晋升为主库 | 新主库开始承担删除职责,对自己内存里的过期 key 做定期扫描 | 晋升瞬间可能有一批 key 集中被删 |
EXPIRE 相关的几个坑
| 坑 | 说明 |
|---|---|
SET key value 会清除 TTL | SET 不带 KEEPTTL 时相当于新建 key,原有过期时间丢失;6.0+ 可用 SET key value KEEPTTL |
GETSET、SETRANGE、APPEND、INCR 不清除 TTL | 只有"整体覆盖型"命令才重置 TTL,容易记混 |
RENAME 会把源 key 的 TTL 一起带过去 | 目标 key 原有的 TTL 被覆盖 |
PERSIST 移除 TTL | 返回 1 表示成功移除,0 表示本来就没 TTL |
| Hash 的 field 在 7.4 之前不能单独设 TTL | 只能整个 Hash 一个 TTL;7.4 引入了 HEXPIRE 系列命令 |
| 过期时间是绝对时间戳 | 主从服务器时钟不同步会导致删除时机偏差;实例间迁移数据要注意 |
⚠️ "从库内存比主库高"是一个常见告警。原因往往不是数据不一致,而是从库里堆积了"已过期但主库还没扫到、所以还没发 DEL"的 key。如果主库写入压力大、过期 key 多,这个差值可以很明显。排查时对比
INFO keyspace的keys和expires字段,并确认主库的expired_keys指标在正常增长。
面试加分表述:「这个点很能看出有没有真的读过文档:从库的过期 key 是'逻辑过期'而不是'物理删除',它靠主库下发 DEL 来真正释放内存,自己只在读的时候判断一下时间戳返回 nil。由此引出两个实践结论:一是从库内存可能长期高于主库,这是正常的;二是不要依赖
DBSIZE或KEYS的结果做业务判断,它们把还没物理删除的过期 key 也算进去了。」
18. Redis 做消息队列:List、Pub/Sub、Stream 三选一
| 维度 | List(LPUSH + BRPOP) | Pub/Sub | Stream(5.0+) |
|---|---|---|---|
| 消息持久化 | ✅ 在 Redis 里 | ❌ 纯内存转发,不存储 | ✅ |
| 离线消费者 | 消息留在 List 里等它 | 消息直接丢弃 | 消息留在 Stream 里 |
| 消费确认 ACK | ❌ 无(取出即丢) | ❌ 无 | ✅ PEL(Pending Entries List) |
| 消费者组 | ❌(多消费者是竞争关系) | ❌(所有订阅者都收到全量) | ✅ XGROUP,组内负载均衡 |
| 重复消费 / 回溯 | ❌ | ❌ | ✅ 按 ID 任意回溯 |
| 广播(一条消息多方消费) | ❌ | ✅ 天然支持 | ✅ 多个消费者组 |
| 消息积压上限 | 受内存限制,无上限保护 | 不积压 | XADD MAXLEN 可裁剪 |
| 典型用途 | 简单任务队列 | 配置变更通知、缓存失效广播 | 轻量级 MQ |
List 方案的要点与坑
bash
# 生产
LPUSH task:queue '{"orderId":1001}'
# 消费(阻塞 5 秒,0 表示永久阻塞)
BRPOP task:queue 5⚠️
BRPOP取出消息后如果消费者崩溃,消息就彻底丢了。要做可靠消费必须用BLMOVE(6.2+,替代已废弃的BRPOPLPUSH)把消息原子地移到一个"处理中"队列,处理成功后再从处理中队列删除,并用定时任务扫描处理中队列里超时的消息重新投递。这其实就是在手工实现 ACK。
Pub/Sub 的三个硬伤
text
① 不持久化:消费者离线期间的消息全部丢失,重连也拿不回来
② 不确认: 发送方无法知道消息是否被处理
③ 慢消费者会被踢:客户端输出缓冲区超过
client-output-buffer-limit pubsub 32mb 8mb 60 时,Redis 直接断开该订阅者⚠️ 所以 Pub/Sub 只能用于"丢了也没关系"的通知类场景,比如广播"清一下本地缓存"——丢了无非是多等一个 TTL。绝对不能用它传业务消息。另外 Cluster 下 7.0 之前的
PUBLISH会向所有节点广播,大量使用会吃掉集群总线带宽,7.0 的SPUBLISH/SSUBSCRIBE(Sharded Pub/Sub)按 channel 算 slot,只在负责该 slot 的分片内传播。
Stream 的核心命令
bash
XADD mystream * field1 v1 field2 v2 # * 表示由 Redis 生成 ID(毫秒时间戳-序号)
XADD mystream MAXLEN ~ 10000 * f v # ~ 是近似裁剪,性能远好于精确裁剪
XGROUP CREATE mystream group1 0 # 创建消费者组,从头开始消费
XREADGROUP GROUP group1 consumer1 COUNT 10 BLOCK 2000 STREAMS mystream >
XACK mystream group1 1700000000000-0 # 确认,从 PEL 移除
XPENDING mystream group1 # 查看未确认的消息
XAUTOCLAIM mystream group1 consumer2 60000 0 # 6.2+:接管超过 60 秒未确认的消息| Stream 的注意点 | 说明 |
|---|---|
> 与具体 ID | > 表示"从未投递给本组的新消息";传具体 ID(如 0)表示"读本消费者 PEL 里的历史消息",用于崩溃恢复 |
| 必须裁剪 | 不设 MAXLEN 会无限增长吃满内存,建议配合 XTRIM 定时任务 |
| 消费者要注销 | 消费者下线后不会自动清理,XINFO CONSUMERS 会一直列着,需 XGROUP DELCONSUMER |
| 不适合替代 Kafka | 没有分区、副本因子、顺序保证的严格语义,没有成熟的监控生态 |
面试加分表述:「我的选型原则是:只有在"已经有 Redis、不想再引入 Kafka/RocketMQ"的轻量场景才用 Redis 做 MQ。判断标准看三个问题——消息能丢吗?需要重复消费/回溯吗?峰值积压量有多大?如果都不敏感,List +
BLMOVE就够;如果需要消费者组和 ACK,用 Stream;如果需要削峰到百万级积压、严格顺序、事务消息、死信队列,那就必须上专业 MQ,Redis 的内存成本和可靠性都撑不住。」
19. 延时队列的三种实现与键空间通知为什么不可靠
方案一:ZSet + score 存执行时间(最常用)
bash
# 投递:score 是期望执行的毫秒时间戳
ZADD delay:queue 1700000000000 "order:1001:cancel"
# 消费:轮询取出所有到期的任务
ZRANGEBYSCORE delay:queue 0 1700000000000 LIMIT 0 10lua
-- 必须用 Lua 把"取出 + 删除"做成原子,否则多个消费者会重复处理
local tasks = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, ARGV[2])
if #tasks > 0 then
redis.call('ZREM', KEYS[1], unpack(tasks))
end
return tasks| 优点 | 缺点 |
|---|---|
实现简单、支持任意延时、可取消(ZREM)、可改时间(重新 ZADD) | 需要轮询(通常 100ms~1s 一次),精度受轮询间隔限制;任务一旦取出就脱离 Redis,消费者崩溃会丢任务(需配合"处理中"ZSet 做 ACK) |
方案二:Keyspace Notification(键空间通知)——不推荐
bash
# 配置:K=keyspace, E=keyevent, g=通用命令, x=过期事件, A=除m外全部
CONFIG SET notify-keyspace-events Ex
# 订阅 0 号库的过期事件
PSUBSCRIBE __keyevent@0__:expired⚠️ 三个致命问题,所以生产基本不用: ① 通知基于 Pub/Sub,不可靠——消费者离线期间的事件直接丢失,没有任何补偿机制。 ② 时间不准——过期事件是在 key 被真正删除时才发出的。由于定期删除是随机采样,一个 key 可能过期后几秒甚至几分钟才被扫到并发出通知。 ③ Cluster 下不跨节点——事件只在 key 所在的那个节点广播,消费者必须订阅所有节点。
另外过期事件只携带 key 名,不带 value(因为 value 已经被删了),所以必须另存一份数据,又增加一次读取和不一致风险。
方案三:Redisson 的 RDelayedQueue
text
内部结构:
一个 ZSet 存延时任务(score = 执行时间)
一个 List 存已到期任务
客户端启动一个定时任务,到期后由 Lua 从 ZSet 原子搬到 List
业务侧用阻塞的 take() 从 List 取
优点:封装完整、有超时接管;缺点:依赖客户端进程存活,多语言体系不通用三方案对比
| 维度 | ZSet 轮询 | Keyspace Notification | Redisson RDelayedQueue |
|---|---|---|---|
| 可靠性 | 中(需自己做 ACK) | 低 | 中高 |
| 时间精度 | 取决于轮询间隔 | 不可控 | 较好 |
| 实现成本 | 低 | 低 | 最低(开箱即用) |
| 跨语言 | ✅ | ✅ | ❌ 仅 Java |
| 生产推荐度 | ✅ 轻量场景首选 | ❌ 不推荐 | ✅ 纯 Java 项目 |
面试加分表述:「延时任务我的选型是:量小用 ZSet + Lua 原子弹出,量大直接上 RocketMQ 的定时消息或 RabbitMQ 的死信队列。特别要说的是 Keyspace Notification——它看起来最优雅(key 过期自动触发),但三个硬伤让它在生产上不可用:基于 Pub/Sub 会丢消息、触发时间取决于定期删除什么时候扫到它所以完全不准、集群下还不跨节点。面试里主动指出这一点,比答出三种方案本身更能体现实战经验。」
20. 分布式限流:四种算法与 Redis 实现要点
| 算法 | 原理 | Redis 结构 | 优点 | 缺点 |
|---|---|---|---|---|
| 固定窗口计数 | 每个时间窗口一个计数器,超限拒绝 | INCR + EXPIRE | 最简单 | 临界问题:窗口交界处可承受 2 倍流量 |
| 滑动窗口(日志) | 记录每次请求的时间戳,统计窗口内数量 | ZSet,score = 时间戳 | 精确 | 内存随 QPS 线性增长,需清理 |
| 滑动窗口(分段) | 把 1 分钟切成 60 个 1 秒的小格,累加 | Hash 或多个 String | 内存可控、较精确 | 实现稍复杂 |
| 令牌桶 | 按固定速率生成令牌,请求取令牌 | String 存"令牌数 + 上次填充时间" | 允许突发,平滑 | 需要处理时钟 |
| 漏桶 | 请求排队按固定速率流出 | List / ZSet | 输出绝对平滑 | 不允许突发,会增加延迟 |
固定窗口的临界问题
text
限流规则:1 分钟最多 100 次
时间轴: [--- 第1分钟 ---][--- 第2分钟 ---]
请求: 100次 | 100次
59.9s 60.1s
└──── 0.2 秒内通过了 200 次请求 ────┘
实际瞬时速率是限流值的 2 倍令牌桶的 Lua 实现要点
lua
-- KEYS[1] = 限流 key
-- ARGV[1] = 桶容量 ARGV[2] = 每秒生成令牌数 ARGV[3] = 当前时间(毫秒) ARGV[4] = 本次请求令牌数
local capacity = tonumber(ARGV[1])
local rate = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local need = tonumber(ARGV[4])
local data = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(data[1])
local lastTs = tonumber(data[2])
if tokens == nil then
tokens = capacity
lastTs = now
end
-- 按经过的时间补充令牌,上限是桶容量
local delta = math.max(0, now - lastTs) * rate / 1000
tokens = math.min(capacity, tokens + delta)
local allowed = 0
if tokens >= need then
tokens = tokens - need
allowed = 1
end
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', now)
-- TTL 设为"装满整桶所需时间 × 2",长期不访问则自动回收,避免 key 堆积
redis.call('PEXPIRE', KEYS[1], math.ceil(capacity / rate * 2000))
return allowed| 实现要点 | 原因 |
|---|---|
时间戳由客户端传入 ARGV 而不是脚本里调 TIME | 老版本 Redis 中 TIME 是非确定性命令,脚本里调用后再写入会被拒绝或导致主从不一致 |
| 但客户端时钟不一致会出问题 | 折中办法:用 redis.call('TIME') 并确保 Redis ≥ 5.0(改为传播执行结果,已安全),或由统一的网关层取时间 |
| 必须设 TTL | 否则每个用户/IP 都留一个永久 key,内存无限增长 |
| 限流 key 要分层 | 全局限流 + 单用户限流 + 单接口限流,分别用不同 key,在网关一次 Lua 里全部校验 |
⚠️ Cluster 下限流 key 必须保证同槽或单 key。如果一个 Lua 脚本里同时操作"全局限流 key"和"用户限流 key",它们大概率不在同一个 slot,会直接报
CROSSSLOT。解法:拆成两次调用(牺牲原子性),或者把全局限流做在网关本地(单机限流 × 网关实例数近似),或者用 hash tag 把它们绑在一起(但会造成热点)。
面试加分表述:「限流的算法本身不难,难点在分布式一致性和性能的取舍。我在网关层的做法是:本地令牌桶做粗粒度兜底 + Redis Lua 做精确的用户级限流。这样 Redis 挂了限流不会失效(退化成单机限流),Redis 正常时又能保证全局精度。另外一个实战细节是限流 key 一定要带 TTL 并且 TTL 要大于一个窗口,我见过线上因为限流 key 没设过期时间,三个月堆了两千万个 key,占了 4GB 内存。」
21. 性能排查工具箱
① 慢查询日志 SLOWLOG
bash
CONFIG SET slowlog-log-slower-than 10000 # 微秒,默认 10000 = 10ms;设 0 记录全部,-1 关闭
CONFIG SET slowlog-max-len 1024 # 最多保留多少条
SLOWLOG GET 10 # 查看最近 10 条
SLOWLOG LEN # 当前条数
SLOWLOG RESET # 清空⚠️ SLOWLOG 记录的只是"命令执行耗时",不含网络传输和排队等待时间。所以客户端测到 500ms,SLOWLOG 里可能一条慢日志都没有——这时问题在网络、在连接池排队、或者在"前面有一条慢命令把你堵住了"。排查这类问题要用 LATENCY。
② 延迟监控 LATENCY(需先开启阈值)
bash
CONFIG SET latency-monitor-threshold 100 # 毫秒,记录超过 100ms 的延迟事件
LATENCY LATEST # 各类事件的最新/最大延迟
LATENCY HISTORY command # 某类事件的历史
LATENCY DOCTOR # **人类可读的诊断报告 + 建议**,强烈推荐
LATENCY RESET| 事件名 | 含义 |
|---|---|
command | 命令执行慢 |
fork | fork 耗时长 |
expire-cycle | 过期扫描耗时长 |
eviction-del / eviction-cycle | 淘汰耗时长 |
aof-fsync-always / aof-write | AOF 刷盘慢 |
active-defrag-cycle | 碎片整理耗时长 |
③ 客观延迟基线
bash
redis-cli --latency # 持续采样 PING 的往返延迟
redis-cli --latency-history # 按 15 秒分段输出,看趋势
redis-cli --intrinsic-latency 100 # 在**服务器本机**运行,测量机器本身的固有延迟⚠️
--intrinsic-latency必须在 Redis 所在机器上跑,它测的是"操作系统 + 硬件"能提供的最小延迟。如果这个值本身就有 10ms,说明机器被严重超卖或有其他进程抢 CPU,再怎么优化 Redis 配置都没用。
④ 实时命令统计与连接排查
bash
INFO commandstats # 每个命令的调用次数、总耗时、平均耗时(排查哪个命令最耗 CPU)
INFO latencystats # 7.0+:每个命令的延迟百分位分布
CONFIG RESETSTAT # 重置统计,便于做"这一分钟"的对比
CLIENT LIST # 所有连接:地址、空闲时间、缓冲区大小、最后执行的命令
CLIENT INFO # 当前连接信息
CLIENT KILL ID 123 # 杀掉问题连接
CLIENT NO-EVICT on # 7.0+:保护某个连接不被 maxmemory-clients 驱逐
INFO clients # connected_clients、blocked_clients、client_recent_max_output_buffer⑤ 容量与内存
bash
INFO memory # used_memory / rss / peak / 碎片率 / 各部分占用
MEMORY STATS # 更详细的分项统计
MEMORY DOCTOR # 内存使用诊断建议
MEMORY USAGE key # 单个 key 占用(含管理开销)
DBSIZE # key 总数(O(1),安全)
redis-cli --bigkeys / --memkeys / --hotkeys / --scan --pattern 'xxx:*'一套标准排查顺序
text
① 先确认"是 Redis 慢,还是客户端慢"
redis-cli --latency 在客户端机器上跑 → 延迟正常则问题在应用侧(GC、连接池、序列化)
② SLOWLOG GET 50 → 有慢命令?治理命令和大 key
③ LATENCY DOCTOR → 有 fork / aof / expire 事件?调持久化策略和实例大小
④ INFO commandstats → 哪个命令占了最多 CPU 时间
⑤ INFO memory → 碎片率是否 > 1.5、是否 < 1(swap)
⑥ INFO clients → 连接数是否打满、是否有大输出缓冲区
⑦ 登机器:top / iostat / sar → CPU、磁盘、网卡饱和度,确认是否被邻居进程影响面试加分表述:「我最推荐的两个命令是
LATENCY DOCTOR和MEMORY DOCTOR,它们会直接输出中文可读的诊断建议(比如'检测到 fork 耗时过长,建议减小实例内存'),很多人根本不知道 Redis 自带这两个'医生'。另外 7.0 新增的INFO latencystats给出的是每个命令的延迟百分位,比commandstats的平均值有用得多——平均 1ms 可能是 P99 到了 50ms,而平均值会把问题藏起来。」
22. 版本演进:每个大版本带来了什么
| 版本 | 关键特性 | 为什么重要 |
|---|---|---|
| 2.6 | Lua 脚本 | 有了"原子 + 可编程"的能力,分布式锁、限流才成为可能 |
| 3.0 | Cluster 正式版、近似 LRU 淘汰池 | 原生分片,告别 Twemproxy/Codis 时代 |
| 3.2 | GEO、从库正确处理过期 key | 修复了"从库返回过期旧值"的严重问题 |
| 4.0 | 混合持久化、UNLINK/lazyfree、psync2、Module、activedefrag、LFU | 这个版本解决了大量生产痛点,是"能用"到"好用"的分水岭 |
| 5.0 | Stream、REPLICAOF 改名、ZSet 新命令(ZPOPMIN/BZPOPMIN) | 有了原生的消息流结构 |
| 6.0 | 多线程 IO、ACL、RESP3 协议、客户端缓存(Client Side Caching)、SSL/TLS | 网络层和安全性的全面升级 |
| 6.2 | GETDEL、GETEX、COPY、SET KEEPTTL、BLMOVE、ZRANGESTORE | 大量命令语义补齐,少写很多 Lua |
| 7.0 | Multi Part AOF、Function、Sharded Pub/Sub、maxmemory-clients、命令内省、ACL v2、listpack 全面替换 ziplist | 持久化和可编程性的大升级 |
| 7.2 / 7.4 | Hash field 级 TTL(HEXPIRE)、向量集合、性能优化 | 解决了"Hash 只能整体过期"这个长期痛点 |
| 许可证变更 | 7.4 起 Redis 改为 RSALv2/SSPLv1 双许可,社区 fork 出 Valkey(由 Linux 基金会托管) | 选型时要注意许可证和生态走向 |
几个值得单独了解的特性
| 特性 | 说明 |
|---|---|
| ACL(6.0) | 支持多用户、按命令/按 key 模式/按 channel 授权。ACL SETUSER app on >pwd ~cache:* +get +set。替代了只有一个 requirepass 的时代 |
| Client Side Caching(6.0) | 服务端用 Tracking 表记录"哪个客户端缓存了哪个 key",key 变更时主动推送失效通知。等于官方支持的本地缓存一致性方案;有 default 模式(精确跟踪)和 broadcast 模式(按前缀广播,省内存但有误报) |
| RESP3(6.0) | 新协议,支持 Map/Set/Double 等类型,客户端不必再靠约定解析数组;也是 Client Side Caching 推送通知的基础 |
maxmemory-clients(7.0) | 限制所有客户端连接缓冲区的总内存,超过时驱逐占用最大的连接。此前客户端缓冲区暴涨会直接把 Redis OOM 掉,没有任何保护 |
| Function(7.0) | 比 EVAL 更正式的服务端脚本:有名字、可声明多个函数、会随 RDB/AOF 持久化和主从复制,不会因为重启丢失 |
面试加分表述:「如果只让我挑一个最值得升级的版本,我会说 4.0——
UNLINK、lazyfree、混合持久化、psync2、activedefrag 这五个特性每一个都直接对应一类线上事故。如果挑第二个就是 7.0,Multi Part AOF 消除了重写期间的内存放大,maxmemory-clients补上了客户端缓冲区这个长期的 OOM 口子。另外要留意 Valkey:7.4 之后 Redis 改了许可证,很多云厂商转向了 Valkey 分支,技术选型时要关注这个变化。」
23. 生产配置与安全清单
bash
# ========== 安全 ==========
bind 127.0.0.1 10.0.0.5 # 绝不要 bind 0.0.0.0 暴露公网
protected-mode yes # 没设密码且 bind 全网时拒绝外部连接
requirepass 一个足够强的密码 # 或用 6.0 的 ACL 做细粒度授权
rename-command FLUSHALL "" # 禁用高危命令:FLUSHALL/FLUSHDB/KEYS/CONFIG/DEBUG/SHUTDOWN
rename-command CONFIG "cfg_a8f3e1"
# ========== 内存 ==========
maxmemory 8gb # 留足余量:不超过物理内存的 60~70%
maxmemory-policy allkeys-lru # 纯缓存场景;有重要数据则用 volatile-lru 并确保都设了 TTL
maxmemory-clients 1gb # 7.0+:客户端缓冲区总上限
# ========== 持久化 ==========
appendonly yes
appendfsync everysec
no-appendfsync-on-rewrite no # 设 yes 可减少重写时的 IO 争抢,但会扩大丢数据窗口
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 512mb
# ========== 复制 ==========
repl-backlog-size 256mb # 默认 1mb 太小,是全量同步的主要诱因
repl-diskless-sync yes
min-replicas-to-write 1
min-replicas-max-lag 10
# ========== 延迟与可观测 ==========
slowlog-log-slower-than 10000
latency-monitor-threshold 100
lazyfree-lazy-eviction yes
lazyfree-lazy-expire yes
lazyfree-lazy-server-del yes
replica-lazy-flush yes操作系统层面
| 项 | 设置 | 原因 |
|---|---|---|
vm.overcommit_memory | 1 | 否则 fork 在内存占用过半时直接失败 |
vm.swappiness | 1(或彻底关闭 swap) | 换出到磁盘会让延迟不可预测 |
| 透明大页 THP | never | COW 放大 512 倍 |
somaxconn / tcp-backlog | 调大到 1024+ | 高并发建连时避免队列溢出 |
ulimit -n | ≥ maxclients + 32 | 文件描述符不足会拒绝新连接 |
| 文件系统 | 独立磁盘/SSD 存 AOF 和 RDB | 避免与应用日志争抢 IO |
⚠️ 最常见的安全事故是"Redis 裸奔":
bind 0.0.0.0+ 无密码 + 开放 6379 公网端口。攻击者可以直接CONFIG SET dir /root/.ssh+CONFIG SET dbfilename authorized_keys+ 写入公钥,然后 SSH 登录服务器。所以bind内网地址、设强密码、rename-command禁用CONFIG、安全组只放行应用网段,这四条缺一不可。
面试加分表述:「生产配置里我最强调的三条是:
maxmemory必须设且要留 30% 余量(给 fork 的 COW 和客户端缓冲区留空间,否则持久化时就 OOM)、repl-backlog-size必须从 1MB 往上调(否则网络抖动就触发全量同步,大实例能卡几分钟)、高危命令必须 rename(KEYS *和FLUSHALL被误执行一次就是 P0 事故)。这三条都是'配置一行、避免一次故障'的高性价比动作。」
❓ 高频面试题【基础简答 + 连环追问链 + 精简口述答案 + 项目关联话术】
题 1:Redis 为什么这么快?它是单线程的吗?
精简口述答案(90 秒版)
「Redis 快有五个层次的原因:第一是全内存操作,内存随机访问是纳秒级,比磁盘快五到六个数量级,这是最根本的原因;第二是高效的数据结构,SDS 取长度 O(1)、跳表做范围查询、listpack 在小数据量时用连续内存提升缓存命中率,而且同一种类型会根据大小自动切换编码;第三是 IO 多路复用,用 epoll 让一个线程管理成千上万个连接,没有线程切换和锁竞争的开销;第四是单线程执行命令,避免了加锁、上下文切换、以及多线程下的伪共享问题;第五是协议和网络层的优化,RESP 协议解析极简,6.0 之后还把读写 socket 和协议解析交给了多个 IO 线程。
关于单线程这个说法要补充一句:Redis 从来不是真正的单线程进程。4.0 就有了 bio 线程做 UNLINK 和 fsync,6.0 又加了 IO 线程做网络读写,还有 fork 出来的子进程做持久化。准确的表述是:命令的执行始终在一个主线程里串行完成,这是它原子性的来源,也是作者刻意不动的部分。」
连环追问链
| 追问 | 答题要点 |
|---|---|
| 既然内存快是主因,那瓶颈在哪? | 瓶颈是内存容量和网络带宽,不是 CPU。所以官方建议用集群横向扩展,而不是加 CPU 核数 |
| 6.0 的多线程 IO 具体快多少? | 吞吐大约提升 1 倍左右,但只在"大 value + 高并发"场景有效。小 value 场景瓶颈不在网络解析,开了反而因为线程同步变慢 |
| 为什么命令执行坚持不做多线程? | 一旦多线程执行命令,就需要对每个 key 加锁,Redis 的数据结构会复杂化、原子性语义会被打破、性能收益也会被锁竞争吃掉 |
| 单线程有什么代价? | 一条慢命令会阻塞所有客户端。KEYS *、HGETALL 大 Hash、Lua 死循环都能让整个实例不可用 |
| 怎么用满多核 CPU? | 单机部署多实例(每个绑不同核)、或者直接上 Cluster。另外开 io-threads 能用上几个核做网络 IO |
| 为什么说 Redis 是 AP 系统? | 主从是异步复制,为了性能牺牲了一致性;Cluster 也没有共识算法保证强一致,脑裂和异步复制都会丢数据 |
✅ 项目关联话术
「我们线上单实例压测能跑到 10 万 QPS 左右,但真正限制我们的从来不是 Redis 的 CPU,而是单次请求的 value 大小。有一个接口的 value 是 200KB 的商品详情 JSON,1000 QPS 就把千兆网卡打满了。后来我们做了两件事:一是把 JSON 换成 protobuf 压缩,体积降到 40KB;二是在应用侧加了 2 秒的本地缓存。Redis 的 CPU 使用率反而从 60% 降到了 15%,这件事让我明白优化 Redis 要先看带宽而不是先看 QPS。」
题 2:Redis 有哪些数据结构?底层是怎么实现的?
精简口述答案(90 秒版)
「五种基础类型:String、List、Hash、Set、ZSet,加上扩展的 BitMap、HyperLogLog、GEO、Stream。
每种类型底层都有多种编码,Redis 会根据数据量自动选择:String 是 int(整数)、embstr(≤ 44 字节,和对象头一次性分配)、raw(> 44 字节,SDS 独立分配);List 7.0 后统一是 quicklist,就是双向链表串起多个 listpack;Hash 是小数据量用 listpack、超阈值转 hashtable;Set 是全整数且少量用 intset、小量非整数用 listpack、否则 hashtable;ZSet 是小量用 listpack、否则 skiplist + dict 双结构。
选编码的核心思路是:数据量小的时候用连续内存(listpack)省内存且 CPU 缓存友好,数据量大的时候切换成哈希表或跳表保证时间复杂度。要注意编码转换是只升不降的,一个 Hash 一旦因为塞了太多元素变成 hashtable,即使后面删到只剩一个元素,也不会回退成 listpack。」
连环追问链
| 追问 | 答题要点 |
|---|---|
| ZSet 为什么用跳表不用红黑树? | ① 范围查询简单(底层链表顺序走);② 实现和调试简单得多,无旋转;③ 局部修改并发友好。B+ 树是为磁盘设计的,内存场景没有 IO 放大问题 |
| 为什么 7.0 用 listpack 替换 ziplist? | ziplist 每个 entry 存前驱长度,插入大元素会触发连锁更新,最坏 O(N²)。listpack 改成在 entry 末尾存自身长度,修改互不影响 |
| embstr 为什么是 44 字节这个阈值? | 64 字节(jemalloc 的一个分配规格)减去 redisObject 的 16 字节、sdshdr8 的 3 字节、结尾 \0 的 1 字节,剩下 44 |
| ZSet 的 score 有什么坑? | score 是 double,超过 2^53 的整数会丢精度。用时间戳毫秒做 score 没问题,用雪花 ID 做 score 会出错 |
| 怎么查一个 key 的实际编码? | OBJECT ENCODING key,配合 MEMORY USAGE key 看真实内存 |
| 跳表怎么支持按排名查询? | 每个节点的每一层都存了 span(跨越的节点数),ZRANGE 按 rank 查询时累加 span 就能算出排名,不需要遍历 |
✅ 项目关联话术
「我们购物车用的是 Hash,cart:{userId} 作为 key,商品 ID 作为 field。上线一段时间后发现内存涨得很快,OBJECT ENCODING 一看全是 hashtable——因为默认 hash-max-listpack-entries 是 128,而我们允许用户加 200 个商品。于是我把这个阈值调到 256,内存直接降了 40%。这个改动的本质是用 CPU 换内存:listpack 的查找是 O(N) 线性扫描,但 200 个元素的线性扫描在内存里只是几微秒,完全可以接受。做这个决策之前我压测确认了 P99 没有变化。」
题 3:RDB 和 AOF 怎么选?线上你是怎么配的?
精简口述答案(90 秒版)
「RDB 是某一时刻的内存快照,二进制紧凑,文件小、恢复快,但它是周期性的,宕机会丢失最后一次快照之后的所有数据,而且每次 bgsave 都要 fork,大实例上 fork 本身就有几百毫秒的卡顿。
AOF 是写后日志,记录每条修改命令。先执行命令再写日志,所以不会因为日志格式错误而阻塞正确的命令,但代价是命令执行后、日志落盘前宕机还是会丢。appendfsync 有三档:always 每条都刷盘,几乎不丢但性能降一半以上;everysec 每秒刷一次,最多丢 1 秒,是默认也是绝大多数场景的选择;no 完全交给操作系统,可能丢几十秒。
实际线上我是两个都开,用 4.0 的混合持久化:aof-use-rdb-preamble yes,让 AOF 重写时把全量数据以 RDB 格式写在文件头部,后面追加增量命令。这样既有 RDB 的恢复速度,又有 AOF 的低丢失率。另外要注意只要 appendonly yes,重启时就只认 AOF,完全忽略 RDB 文件。」
连环追问链
| 追问 | 答题要点 |
|---|---|
| AOF 为什么是"写后日志"而不是 WAL? | ① 不需要在写日志前做语法校验,命令能执行就说明是合法的;② 不阻塞当前命令。代价是不能用日志做崩溃恢复前的回滚,且有丢数据窗口 |
| AOF 重写期间新写入的数据怎么办? | 7.0 之前写进 aof_rewrite_buf,子进程结束后追加到新文件,有内存放大和收尾阻塞;7.0 用 Multi Part AOF,新写入直接落到新的 incr 文件,无额外缓冲区 |
| fork 的代价有多大? | 复制页表,粗略每 GB 约 20ms;COW 导致父进程写入时复制物理页,极端情况内存翻倍。看 latest_fork_usec 监控 |
| 为什么必须关闭透明大页? | THP 把页从 4KB 变成 2MB,COW 复制一页的代价放大 512 倍,表现为持久化期间 P99 延迟飙升 |
| 混合持久化的文件怎么读? | 文件头是 RDB 二进制(所以 cat 看是乱码),后半段是 AOF 文本命令。Redis 靠文件头的 REDIS 魔数判断 |
| 纯缓存场景要不要开持久化? | 可以都关,但要考虑重启后缓存全空会不会把数据库打挂。至少保留 RDB 用于快速预热,或者准备好预热脚本 |
✅ 项目关联话术
「我们踩过一个很典型的坑:一个实例原本 appendonly no,只靠 RDB。有次为了排查问题,运维临时 CONFIG SET appendonly yes 开了 AOF,之后机器重启——结果整个库是空的。原因是开启 AOF 的瞬间 Redis 会创建一个 AOF 文件并开始重写,但当时这个操作被中断了,留下一个几乎为空的 AOF 文件;重启时因为 appendonly yes,Redis 完全忽略了几 GB 的 RDB 文件,只加载了那个空 AOF。从那以后我们的规范是:持久化配置只能在变更窗口改,改完必须 BGREWRITEAOF 并确认文件大小正常,同时 CONFIG REWRITE 持久化到配置文件。」
题 4:缓存穿透、击穿、雪崩分别是什么?怎么解决?
精简口述答案(90 秒版)
「一句话区分:穿透是查不存在的数据,击穿是一个热点 key 过期,雪崩是一大批 key 同时失效或 Redis 挂了。
穿透的特点是缓存永远不会命中,因为数据库里也没有这条数据,常见于恶意刷不存在的 ID。解法是参数合法性校验挡掉明显非法的请求,缓存空值并设短 TTL,以及布隆过滤器前置拦截。布隆过滤器有假阳性,所以还要靠缓存空值兜底。
击穿是单个热点 key 失效,瞬间大量并发打到数据库。解法有三种:互斥锁重建,只让一个线程回源,一致性好但会阻塞其他请求;逻辑过期,value 里存 expireAt 字段而 key 本身不设 TTL,发现逻辑过期就异步重建、当前请求直接返回旧值,可用性最好;热点永不过期 + 主动刷新。
雪崩要分成两种:一种是大批 key 同时过期,用 TTL 加随机抖动解决;另一种是 Redis 实例宕机,TTL 打散对它完全无效,只能靠主从哨兵做高可用、本地缓存兜住瞬时流量、以及对数据库侧做限流熔断。」
连环追问链
| 追问 | 答题要点 |
|---|---|
| 布隆过滤器的原理和参数怎么定? | 一个 bit 数组 + k 个哈希函数。误判率 p 和元素数 n 决定位数 m = -n·lnp/(ln2)²,哈希函数个数 k = (m/n)·ln2。1 亿元素、1% 误判约需 114MB |
| 布隆过滤器为什么不能删除? | 多个元素可能共用同一个 bit 位,置 0 会误删其他元素。要支持删除得用 Counting Bloom Filter(每位改成计数器)或 Cuckoo Filter |
| 缓存空值会不会被打爆内存? | 会。攻击者用大量不同的随机 ID 就能塞满内存。所以空值 TTL 要短(30s~5min),并且前面必须有布隆过滤器或参数校验把量压下来 |
| 互斥锁重建有什么风险? | 锁超时导致多个线程同时重建;没抢到锁的线程 sleep 重试会放大延迟;极端情况下形成线程池耗尽的连锁故障 |
| 逻辑过期怎么避免重复重建? | 异步重建前先抢一把短期分布式锁,抢不到就直接返回旧值,不参与重建 |
| 缓存预热要注意什么? | 必须限速。一次性几十万次 DB 查询会把数据库打挂,要用 pipeline 批量写 Redis + 限流控制 DB 查询并发 |
✅ 项目关联话术
「我们商品详情页的缓存是分五层治的:入口校验商品 ID 必须是 19 位数字,布隆过滤器拦不存在的 ID,缓存空值 60 秒兜住布隆假阳性,热点商品用逻辑过期永不被击穿,所有 TTL 带 ±20% 随机。有次大促前压测,我们故意把 Redis 整个停掉,发现接口直接 500 了——根因是缓存调用没包 try-catch,异常直接抛到了 Controller。修完之后同样的测试结果是接口变慢到 80ms 但功能正常,这个'拔网线演练'后来成了我们每次大促前的必做项。」
题 5:缓存和数据库的一致性怎么保证?
精简口述答案(90 秒版)
「首先要明确一个前提:缓存和数据库是两个独立系统,不在一个事务里,所以不存在强一致,只能做最终一致。承认这一点之后,问题就变成"能接受多大的不一致窗口"。
主流做法是 Cache Aside:读的时候先查缓存,未命中查库并回写;写的时候先更新数据库,再删除缓存。
为什么是"先更库再删缓存"而不是其他三种组合?更新缓存不如删除缓存,因为并发写时两次更新的顺序可能交叉,留下脏数据,而且缓存值可能是复杂计算结果,更新了也可能没人读。先删缓存再更库风险更大,因为删完到更新完之间只要有一个读请求,就会把旧值重新加载进缓存并长期驻留。先更库再删缓存只有在"读未命中 + 读查库早于写更新 + 读回写晚于写删除"三个条件同时成立时才出问题,而读操作只有几毫秒,这个窗口极小。
加固手段有三个:延迟双删(删完过一会儿再删一次)、删除失败重试(丢 MQ 重试)、binlog 订阅(Canal 监听 MySQL binlog 统一删缓存,业务代码完全不管缓存)。最后兜底的永远是短 TTL——不管前面漏了什么,TTL 一到数据就自愈了。」
连环追问链
| 追问 | 答题要点 |
|---|---|
| 延迟双删能保证强一致吗? | 不能,它只是把不一致的概率压低。而且延迟时间无法准确估计(要大于"读库 + 回写"的耗时,又要大于主从延迟),设长了影响缓存命中率 |
| 延迟双删的第二次删除怎么实现? | 不能用 Thread.sleep 阻塞业务线程,要投递到延迟队列/MQ 定时消息异步执行 |
| 真正的大头问题是什么? | 读写分离 + 主从延迟。回源读从库时从库还没同步,照样把旧值写进缓存,窗口是几十毫秒到几秒,比理论窗口大几个数量级。解法是关键链路强制读主库 |
| binlog 订阅方案的优缺点? | 优点:业务解耦、天然有序、可重放、不会漏。缺点:架构成本高、有秒级延迟、Canal 本身要保证高可用 |
| 什么时候必须用双写/事务消息? | 缓存里存了不能丢的派生数据(比如计数器累加结果)而非 DB 的镜像时。这时要用本地消息表 + 定时对账 |
| 真的要强一致怎么办? | ① 不用缓存;② 读写都加同一把分布式锁串行化(缓存就失去意义了);③ 把数据放进支持事务的存储。所以答案往往是降低一致性要求而不是提高一致性保证 |
✅ 项目关联话术
「我们订单状态的缓存一开始是"先删缓存再更库",线上偶发用户看到旧状态。排查后改成"先更库再删缓存",问题频率降了但没消失。继续查发现真正的根因是回源读的是从库:订单状态更新后缓存被删,紧接着的查询请求回源到从库,而从库有 200 毫秒的延迟,于是又把旧状态写进了缓存,而且因为 TTL 是 30 分钟,这个脏数据会停留很久。最终方案是订单详情的回源查询强制走主库,加上 TTL 缩短到 5 分钟兜底。这件事让我记住:一致性问题要先确认读的是哪个库,光调缓存的删除顺序没用。」
题 6:Redis 分布式锁怎么实现?它安全吗?
精简口述答案(90 秒版)
「基础实现是 SET key uuid NX EX 30:NX 保证互斥,EX 保证持有者宕机后不死锁,value 存一个唯一标识(UUID + 线程 ID)用来标明持有者。释放必须用 Lua 脚本,先比对 value 是不是自己的再删除——如果写成"GET 判断 + DEL"两步,那么在两步之间锁可能刚好过期被别人抢到,自己的 DEL 就把别人的锁删掉了。
但这个版本还有两个问题:不可重入,以及业务执行时间超过 TTL 时锁提前失效。Redisson 解决了这两点:用 Hash 结构的 field 记录持有者、value 记录重入次数;加锁成功后启动看门狗,每 TTL/3(默认 10 秒)续期一次,直到显式 unlock 或进程消失。要注意传了 leaseTime 参数就没有看门狗了,这是线上很常见的误用。
至于安全性,答案是:它不是绝对安全的。Martin Kleppmann 指出,任何基于时间租约的锁都挡不住 GC 长停顿、网络延迟、时钟漂移——持有者卡住 15 秒,锁过期了,别人拿到锁,两个人同时认为自己持有。真正安全需要fencing token,由下游存储拒绝旧 token 的写入。所以我的实践是:效率型场景用 Redis 锁足够,正确型场景必须在数据库侧加唯一索引、乐观锁或状态机 CAS 兜底。」
连环追问链
| 追问 | 答题要点 |
|---|---|
| 为什么释放锁必须用 Lua? | "判断 + 删除"两步之间锁可能过期并被他人获取,自己的 DEL 会误删别人的锁。Lua 保证两步原子 |
| 主从切换会不会丢锁? | 会。主库写入锁后异步复制还没到从库就宕机,哨兵切主后新主库上没有这把锁,别人可以再次获取 |
| 那 Redlock 能解决吗? | 能解决"主从切换丢锁",但解决不了 GC/网络/时钟问题,而且需要 5 个独立实例、5 倍运维成本、5 次网络往返。工程上性价比低,很少用 |
| ZooKeeper 的锁更安全在哪? | 基于**会话(临时节点)**而非 TTL,服务端通过心跳判定客户端存活并主动删节点;且客户端会收到 SessionExpired 事件,比纯 TTL 更可控。但 GC 超过 session timeout 时窗口依然存在 |
| 看门狗的实现细节? | lockWatchdogTimeout 默认 30 秒,定时任务周期是它的 1/3;每次续期前先用 Lua 校验"锁还在自己手上",不在就停止续期 |
| 锁的粒度怎么设计? | 锁 key 要精确到业务实体(lock:stock:{skuId}),不能用全局锁。粒度越细并发越高,但要小心多把锁的加锁顺序造成死锁 |
✅ 项目关联话术
「我们库存扣减最早只用了 Redisson 的锁,有一次大促还是出现了超卖。复盘发现是主从切换:那一刻正好做了故障转移,两个请求在新旧主库上各拿到了一把锁。修复方案不是换 Redlock,而是在 MySQL 层加了一道防线——扣减语句写成 UPDATE stock SET num = num - 1 WHERE sku_id = ? AND num >= 1,靠行锁和条件判断保证不会扣成负数。锁依然保留,但它的定位从'保证正确'降级成'减少数据库冲突'。这个改动之后再没出过超卖,也让我理解了 Martin 那篇文章的真正含义:锁只是优化,正确性必须由存储层的约束保证。」
题 7:Redis 的过期策略和内存淘汰策略是什么?
精简口述答案(90 秒版)
「这是两件不同的事,先要区分清楚:过期策略处理的是"设了 TTL 的 key 到期后怎么删",淘汰策略处理的是"内存满了(到达 maxmemory)该删谁"。
过期删除有两种方式配合:惰性删除是访问这个 key 的时候才检查过期并删除,对 CPU 友好但对内存不友好——不访问就一直占着内存;定期删除是 serverCron 每 100 毫秒执行一次,每次从设了 TTL 的 key 里随机抽 20 个检查,如果过期比例超过 25% 就再来一轮,慢模式最多占 25% 的 CPU 时间。注意它是随机采样而不是全量扫描,所以一定存在"已过期但还没被删"的 key,这意味着 DBSIZE 的结果是不准的。
淘汰策略有 8 种,按作用范围分两组:volatile-* 只淘汰设了 TTL 的 key,allkeys-* 淘汰所有 key;按算法分 lru(最久未使用)、lfu(最少频率使用)、random、ttl(只在 volatile 组,优先删最快过期的),加上 noeviction(不淘汰,直接对写命令返回 OOM 错误)。默认是 noeviction。
有个很容易踩的坑:如果设成 volatile-lru 但所有 key 都没设 TTL,那就没有候选可淘汰,行为会退化成 noeviction,写入直接报错。」
连环追问链
| 追问 | 答题要点 |
|---|---|
| Redis 的 LRU 是标准 LRU 吗? | 不是,是采样近似 LRU。每个对象有 24 bit 记录访问时钟,淘汰时随机采 maxmemory-samples(默认 5)个,配合一个大小 16 的跨次保留的淘汰池,挑最久未访问的删 |
| 为什么不用标准 LRU? | 需要双向链表串所有 key,每个 key 多 16 字节指针,还要每次访问都移动节点破坏缓存局部性。作者认为缓存淘汰本身是启发式的,没必要追求精确 |
| LFU 怎么实现的? | 24 bit 拆成 16 bit 的上次递减时间 + 8 bit 的对数计数器。计数器用概率方式递增(值越大越难加),并按 lfu-decay-time 定期衰减,防止历史热 key 永远占位 |
| 什么时候该用 LFU? | 存在长期稳定热数据且有扫描型流量(批量导出、爬虫、凌晨全量同步)时。LRU 会被一次全表扫描把热数据全挤掉 |
| 淘汰是在哪个线程做的? | 主线程同步做,所以淘汰大 key 会造成延迟毛刺。开 lazyfree-lazy-eviction yes 可以把内存释放丢给 bio 线程 |
| 从库上的过期 key 行为如何? | 从库不主动删除,只等主库下发 DEL。读到已过期未删的 key 时逻辑上返回 nil,但内存还占着,所以从库内存可能长期高于主库 |
✅ 项目关联话术
「我们商品缓存最早用的是 allkeys-lru,命中率长期在 87% 上不去。排查发现每天凌晨有一个全量数据同步任务,会把几百万个冷门 SKU 依次读一遍,这些冷数据被加载进缓存后,按 LRU 规则就成了'最近访问过的',把真正的热门商品全挤出去了。改成 allkeys-lfu 之后命中率提到 95%,因为 LFU 看的是访问频率——冷门 SKU 被访问一次,计数器只有 5 左右,热门商品计数器几十上百,不会被挤掉。这件事是我讲 LRU 和 LFU 区别时最喜欢举的例子。」
题 8:主从复制的原理?什么情况会触发全量同步?会丢数据吗?
精简口述答案(90 秒版)
「主从复制分三个阶段:建立连接与协商,从库发 PSYNC 带上自己记录的 replid 和 offset;全量同步,主库回 +FULLRESYNC 并 fork 子进程生成 RDB 发过去,期间的新写入暂存在该从库的输出缓冲区;增量传播,主库每执行一条写命令就追加到 repl_backlog 并发给所有从库。
判断走全量还是增量,靠三个概念:replid(主库的数据血统 ID)、offset(复制流的字节偏移量)、repl_backlog(主库上的环形缓冲区,默认只有 1MB)。从库重连后发 PSYNC replid offset,主库检查两件事——replid 对不对得上、offset 是否还在 backlog 里没被覆盖。两个都满足就回 +CONTINUE 只补差量,否则就 +FULLRESYNC 全量。
所以触发全量同步的情况有:从库第一次连接、主库重启导致 replid 变化、发生过主从切换(psync2 已部分缓解)、以及最常见的——网络断开时间过长,offset 被 1MB 的环形缓冲区覆盖了。这就是为什么 repl-backlog-size 必须从默认值往上调。
会丢数据。因为复制是异步的,主库写完立即返回客户端,这条命令还没传到从库主库就宕机,这条数据就永久丢失了。另外还有脑裂导致的丢失:网络分区期间客户端还在往旧主库写,分区恢复后旧主库降级并清空数据,期间的写入全丢。」
连环追问链
| 追问 | 答题要点 |
|---|---|
repl-backlog-size 怎么估算? | 平均写入速率(MB/s) × 最长可容忍断线秒数 × 2。10MB/s 写入、容忍 30 秒断线就需要 600MB,而默认只有 1MB |
| 全量同步为什么危险? | 主库要 fork(卡顿)+ 传几 GB RDB(占满带宽)+ 从库清库重载(期间不可用)。而且可能连锁触发:带宽被占满导致其他从库也超时断开,又触发更多全量 |
| psync2 解决了什么? | ① 从库重启后能继续增量(replid/offset 存进 RDB);② 从库晋升为主后其他从库能继续增量(新主库保留 replid2) |
| 怎么减少丢数据? | min-replicas-to-write 1 + min-replicas-max-lag 10:没有从库 ACK 时主库拒绝写入,把丢失窗口压到 10 秒内。但这是牺牲可用性换一致性 |
WAIT 命令能保证不丢吗? | 不能。WAIT numreplicas timeout 只是阻塞等待 N 个从库确认,超时了也会返回,而且已写入的数据不会回滚,它只是"尽力而为" |
| 复制缓冲区溢出会怎样? | client-output-buffer-limit replica 超限时主库主动断开该从库,从库重连又触发全量同步,形成死循环。大实例要把这个值调大 |
✅ 项目关联话术
「我们线上出过一次很典型的全量同步雪崩:凌晨有一个批量写入任务,瞬时写入速率到了 50MB/s,而 repl-backlog-size 是默认的 1MB——意味着网络只要抖动 20 毫秒,offset 就被覆盖了。那天网络抖了两秒,三个从库同时断开重连、同时要求全量同步,主库连续 fork 三次,RDB 传输把千兆网卡打满,然后从库又因为超时再次断开,陷入了死循环,持续了二十多分钟。修复动作很简单:repl-backlog-size 调到 256MB,client-output-buffer-limit replica 调到 512mb/128mb/60。之后再没发生过。这个参数我认为是 Redis 里默认值最不合理的一个。」
题 9:哨兵的作用和故障转移流程?
精简口述答案(90 秒版)
「哨兵是独立进程,干四件事:监控、通知、自动故障转移、配置中心。它不存数据,客户端连的是哨兵而不是 Redis,通过 SENTINEL get-master-addr-by-name 问当前主库地址。
故障转移流程是:① 某个哨兵连续 down-after-milliseconds(默认 30 秒)PING 不通主库,标记主观下线 SDOWN;② 它向其他哨兵发 is-master-down-by-addr 询问;③ 收到至少 quorum 个确认后标记客观下线 ODOWN;④ 哨兵之间基于 Raft 投票选出一个 Leader,由它执行这次转移;⑤ Leader 按规则选新主库;⑥ 对选中的从库执行 REPLICAOF NO ONE 晋升;⑦ 让其余从库指向新主库;⑧ 通过 +switch-master 频道通知客户端。
选新主库的规则是四级:先过滤掉下线的、最近没响应的、与旧主断连太久的;然后比 replica-priority(小的优先,设 0 表示永不晋升);再比 复制 offset(大的优先,数据最新);最后比 runid 字典序。
有两个关键点容易被忽略:选 Leader 和选新主库是两件独立的事,前者用 Raft 决定"谁来执行",后者用优先级规则决定"提拔谁";以及转移期间有写入不可用窗口,大约是 down-after 加上选举和晋升的时间,通常 30 秒起步。」
连环追问链
| 追问 | 答题要点 |
|---|---|
| 为什么哨兵要部署奇数个且至少 3 个? | 2 个哨兵时 quorum 只能设 1(单点抖动就误切主)或 2(挂一个就永远凑不齐票)。3 个才能既容忍单点故障又避免误判 |
| 哨兵之间是怎么互相发现的? | 都订阅主库的 __sentinel__:hello 频道,每 2 秒往里发一条自己的 ip/port/runid/epoch,互相订阅实现自动组网。所以配置文件里只写 master 地址 |
| 从库会被判定客观下线吗? | 不会。只有主库有 ODOWN 概念,从库和其他哨兵判定 SDOWN 后直接标记下线,因为从库挂了不需要故障转移 |
| 为什么说哨兵会修改配置文件? | 转移后哨兵要把新的 monitor 地址和 known-replica 写回 sentinel.conf。所以这个文件必须可写,且不能被配置管理工具强制覆盖回旧内容 |
down-after-milliseconds 怎么设? | 太大拖长故障时间,太小被网络抖动误触发。我们线上设 10 秒,配合 3 个跨机架哨兵 |
| 客户端怎么感知切主? | Jedis/Lettuce 的 Sentinel 模式会订阅 +switch-master 频道,收到后重建连接池。所以客户端必须配哨兵地址列表而不是 Redis 地址 |
✅ 项目关联话术
「我们有一次故障转移失败,排查了很久。原因是三个哨兵部署在同一台物理机的三个容器里——这台机器宕机,三个哨兵同时消失,根本没人执行转移。看着像"高可用",实际上是三份一模一样的单点。修复后我们的规范是:哨兵必须跨三个不同的可用区部署,而且和 Redis 节点不在同一台机器上。另外我们还把 down-after-milliseconds 从默认 30 秒降到 10 秒,因为我们的业务对 30 秒的写不可用窗口接受不了,10 秒配合跨机架部署没有出现过误切。」
题 10:Cluster 为什么是 16384 个槽?它有哪些使用限制?
精简口述答案(90 秒版)
「Cluster 用**槽(slot)**做数据分片,总数固定 16384 个。key 的归属用 CRC16(key) mod 16384 算出 slot,再根据 slot 找到负责它的节点。
为什么是 16384 而不是 65536?作者的解释有三点:① 节点间的 Gossip 心跳包里要携带槽位 bitmap,16384 bit 是 2KB,65536 bit 是 8KB,而心跳每秒都在发,带宽差 4 倍;② Redis 集群设计上不建议超过 1000 个节点,16384 个槽已经足够均匀分配;③ 槽位数越少,bitmap 的压缩率越高。
客户端访问时有两种重定向:MOVED 表示"这个槽已经永久属于另一个节点",客户端要跳转并刷新本地槽表;ASK 表示"这个槽正在迁移中、这个 key 已经搬过去了",客户端要先发 ASKING 再发命令,而且只这一次,不刷新槽表。
使用限制主要来自分片:多 key 操作(MSET/MGET/SUNION)、事务、Lua 脚本都要求所有 key 落在同一个槽,解法是用 hash tag,把 key 写成 {user:1001}:name 这样,只用花括号里的内容算 CRC16。另外只有 db0、不支持 SELECT,7.0 之前的 PUBLISH 会全集群广播。
还有一个很容易踩的坑:cluster-require-full-coverage 默认是 yes,意思是只要有任何一个槽没有节点负责,整个集群就停止服务。缓存场景应该改成 no。」
连环追问链
| 追问 | 答题要点 |
|---|---|
| hash tag 有什么风险? | 粒度错了就是数据倾斜。比如给所有用户打上 {user} 标签,所有数据都挤到一个槽,等于白分片。粒度必须是单个业务实体,不是实体类型 |
| Cluster 需要哨兵吗? | 不需要。集群节点通过 Gossip 互相探活,超过半数主节点认为某节点 FAIL 后,它的从节点基于 configEpoch 发起选举,由其他主节点投票晋升 |
| 为什么主节点至少要 3 个? | 故障判定和选举都需要"超过半数主节点"同意。2 主集群挂一个主就凑不到半数,整个集群不可用 |
slot 迁移时的 MIGRATE 有什么坑? | 它是同步阻塞命令,源节点要序列化 key、发送、等回复、删本地。有大 key 时一次调用就能卡几百毫秒甚至超时。所以迁移前必须治理大 key |
| 集群总线端口是什么? | 业务端口 + 10000(6379 → 16379)。防火墙必须同时放开两个端口,这是部署失败最常见的原因 |
| 什么时候才该上 Cluster? | 两个信号:单实例内存超过 20~30GB(fork 和全量同步开始变慢),或写 QPS 打满单核。否则主从 + 哨兵更简单 |
✅ 项目关联话术
「我们一个项目早期直接上了 6 节点 Cluster,后来发现是过度设计——数据量只有 3GB,QPS 两万,主从 + 哨兵完全够用,而 Cluster 带来的多 key 限制让我们在写批量查询时非常痛苦,原本一个 MGET 要拆成按节点分组的多次调用。更麻烦的是有同事为了能用 MGET,给所有商品 key 加了 {product} 的 hash tag,结果所有数据都压在一个节点上,内存和 QPS 完全倾斜。最后我们降级回主从 + 哨兵,代码反而简单了。这个经历让我在做选型时会先问'现在的瓶颈到底是什么',而不是直接选看起来最强的方案。」
题 11:线上 Redis 突然变慢了,你怎么排查?
精简口述答案(90 秒版)
「我有一套固定顺序,从最容易查、覆盖面最广的开始。
第一步先确认是谁慢:在应用机器上跑 redis-cli --latency,如果延迟正常,说明问题在应用侧——可能是 JVM 的 GC 停顿、连接池耗尽在排队、或者序列化反序列化太慢,根本不是 Redis 的问题。
第二步看慢命令:SLOWLOG GET 50。有慢命令就看是什么——KEYS * 这类要禁用,HGETALL 大 Hash 要改成 HSCAN 分页,操作大 key 的要治理 key 本身。
第三步看延迟事件:LATENCY DOCTOR,它会直接给出诊断报告。常见归类是 fork(持久化导致,看 latest_fork_usec,要么减小实例内存要么调整持久化策略)、expire-cycle(大批 key 同时过期)、aof-fsync(磁盘慢或用了 always)。
第四步看内存:INFO memory。碎片率超过 1.5 要考虑开 activedefrag;碎片率小于 1 是最危险的信号,说明数据被 swap 到磁盘了,延迟会变得完全不可预测。
第五步看连接:INFO clients 看连接数是否打满、blocked_clients 是否异常、输出缓冲区是否过大。
最后才登机器看 CPU、磁盘 IO、网卡饱和度,以及是否有邻居进程抢资源,用 redis-cli --intrinsic-latency 测机器本身的固有延迟。
这个顺序的好处是前三步能覆盖八成以上的问题,而且都不需要登机器。」
连环追问链
| 追问 | 答题要点 |
|---|---|
| SLOWLOG 里什么都没有,但客户端确实慢? | SLOWLOG 只记录命令执行耗时,不含网络传输、客户端排队、以及"前面有慢命令把你堵住"的等待时间。这时要用 LATENCY 系列和客户端侧的 --latency |
| 怎么判断是不是 fork 导致的? | INFO stats 看 latest_fork_usec,再对照慢的时间点和 bgsave/bgrewriteaof 的日志时间。LATENCY HISTORY fork 也能看历史 |
| 内存碎片率小于 1 意味着什么? | 被 swap 了,物理内存不足。必须立刻降低内存使用或扩容,同时把 vm.swappiness 设为 1。cat /proc/进程号/smaps 可确认 |
| 哪些命令是必须禁用的? | KEYS、FLUSHALL、FLUSHDB、CONFIG、DEBUG、SHUTDOWN,用 rename-command 改名或置空 |
| 7.0 有什么新的排查手段? | INFO latencystats 给出每个命令的延迟百分位,比 commandstats 的平均值有用得多——平均 1ms 可能 P99 已经 50ms |
| 怎么找出哪个命令最耗 CPU? | INFO commandstats 看 usec 总耗时(不是平均值),先 CONFIG RESETSTAT 再观察一段时间,对比更准 |
✅ 项目关联话术
「我排查过一次'Redis 每天凌晨两点 P99 飙到 2 秒'的问题。SLOWLOG 干净,--latency 从应用侧看是慢的,最后是 LATENCY DOCTOR 给了答案:fork 事件 1800 毫秒。原因是那个实例内存 40GB,save 900 1 的规则在凌晨批量写入时被触发,fork 一次就要将近 2 秒。更糟的是服务器的透明大页没关,COW 复制一页从 4KB 变成 2MB,持久化期间的写延迟也跟着涨。处理动作有三个:关 THP、把实例拆成 4 个 10GB 的分片、把 RDB 的自动触发改成凌晨四点由脚本执行。这件事让我把 LATENCY DOCTOR 放进了排查清单的前三步,它比我自己猜要快得多。」
题 12:大 key 和热 key 怎么发现、有什么危害、怎么治理?
精简口述答案(90 秒版)
「大 key 一般指 String 的 value 超过 10KB,或者集合类的元素数超过 5000。它的危害容易被低估,实际上是五个方向同时恶化:网络上一个 1MB 的 value 千兆网卡每秒只能传 100 个;命令上 DEL 一个百万元素的 Hash 是 O(N),主线程直接卡死;持久化上主从复制、RDB 加载、AOF 重写都要完整处理它;Cluster 下造成数据倾斜,加节点也解决不了;淘汰时释放大量内存造成毛刺。
发现手段有五个:redis-cli --bigkeys(按元素个数)、--memkeys(按真实内存,4.0+,更准)、--hotkeys(需要 LFU 策略)、MEMORY USAGE 查单个 key,以及最推荐的——离线解析 RDB 文件,用 redis-rdb-tools 生成报表,零线上影响。
治理手段是:拆分(大 Hash 按 field 哈希拆成 N 个小 Hash)、压缩(JSON 换 protobuf)、换结构(大 Set 换 BitMap 或 HyperLogLog)、分页读取(HSCAN 替代 HGETALL)、异步删除(UNLINK 替代 DEL,并开四个 lazyfree-lazy-* 开关)、以及移出 Redis(本来不该放缓存的图片、长文档放对象存储)。
热 key 是单个 key 的 QPS 过高,把一个分片打满。治理靠本地缓存(JVM 侧缓存 1~5 秒,挡住绝大部分请求)、key 打散(写入 10 个副本,读时随机选)、读写分离扩从库、以及独立实例物理隔离。」
连环追问链
| 追问 | 答题要点 |
|---|---|
--bigkeys 和 --memkeys 的区别? | 前者看元素个数,后者看真实内存占用。10 万个 1 字节元素的 Set 会被 --bigkeys 报出来,100 个 10KB 元素的 Set 可能漏掉但内存更大。4.0+ 优先用 --memkeys |
| 这两个命令会影响线上吗? | 会,底层是 SCAN 遍历全库。必须加 -i 0.1 限速,并在低峰期执行;更安全的做法是解析 RDB 离线分析 |
UNLINK 真的完全不阻塞吗? | 不是。它只把释放内存丢给 bio 线程,"计算要释放什么、从字典摘除 key"仍在主线程。百万元素的 Hash 用 UNLINK 依然比小 key 慢,只是从几百毫秒降到几毫秒 |
| 怎么实时发现热 key? | ① --hotkeys(需 LFU);② MONITOR 短时抓取后统计(有风险);③ 在客户端 SDK 里埋点统计 key 的调用次数并上报(推荐,零 Redis 开销) |
| 本地缓存解决热 key 的代价? | 多实例间短时不一致,所以 TTL 要极短(1~5 秒)。1 秒 TTL 就能把 10 万 QPS 降到 1,配长 TTL 收益不增加但不一致窗口放大几百倍 |
| 怎么从根上避免大 key? | 上线前的 key 设计评审。约定 value 大小上限、集合元素数上限、禁止把列表无限追加。事后治理的成本比事前约定高一个数量级 |
✅ 项目关联话术
「我们排查过一次'Redis 偶发 P99 毛刺',SLOWLOG 里是零散的 LRANGE 和 EXPIRE,看不出规律。最后用 --memkeys 扫出来一个 1.2GB 的 List——运营同学写了个脚本往里面塞审计日志,三个月攒了 80 万条,而且从来没人读。更麻烦的是它还设了 TTL,每次定期删除扫到它附近就会抖一下。处理的时候我们没有直接 DEL,而是用 LTRIM 分批裁剪(每次删 1000 条,中间 sleep),最后 UNLINK 收尾。之后我们做了两件事:一是把 lazyfree 四个开关全开,二是在 CI 里加了一条规则——代码里出现 HGETALL、SMEMBERS、LRANGE 0 -1、KEYS 必须人工评审。这个规则之后再没出现过新的大 key。」
✍️ 手撕代码【核心实现 + 边界 Case 自测清单 + 变种拓展题】
题 1:手写一个可重入、自动续期的 Redis 分布式锁
核心实现
java
public class RedisReentrantLock {
private final StringRedisTemplate redis;
private final ScheduledExecutorService scheduler;
/** 锁 key -> 续期任务,用于 unlock 时取消 */
private final Map<String, ScheduledFuture<?>> renewTasks = new ConcurrentHashMap<>();
/** 同一个 JVM 内的持有者标识:UUID 区分进程,线程 ID 区分线程 */
private final String clientId = UUID.randomUUID().toString();
private static final long LEASE_MILLIS = 30_000L;
// 加锁:Hash 的 field 是持有者,value 是重入次数
private static final String LOCK_SCRIPT =
"if redis.call('EXISTS', KEYS[1]) == 0 "
+ " or redis.call('HEXISTS', KEYS[1], ARGV[1]) == 1 then "
+ " redis.call('HINCRBY', KEYS[1], ARGV[1], 1); "
+ " redis.call('PEXPIRE', KEYS[1], ARGV[2]); "
+ " return 1; "
+ "end; "
+ "return 0;";
// 解锁:重入次数减 1,减到 0 才真正删除
private static final String UNLOCK_SCRIPT =
"if redis.call('HEXISTS', KEYS[1], ARGV[1]) == 0 then "
+ " return -1; " // 不是自己的锁
+ "end; "
+ "local cnt = redis.call('HINCRBY', KEYS[1], ARGV[1], -1); "
+ "if cnt > 0 then "
+ " redis.call('PEXPIRE', KEYS[1], ARGV[2]); "
+ " return 0; " // 仍持有(重入未归零)
+ "end; "
+ "redis.call('DEL', KEYS[1]); "
+ "return 1;"; // 真正释放
// 续期:只在锁仍属于自己时才续
private static final String RENEW_SCRIPT =
"if redis.call('HEXISTS', KEYS[1], ARGV[1]) == 1 then "
+ " return redis.call('PEXPIRE', KEYS[1], ARGV[2]); "
+ "end; "
+ "return 0;";
public RedisReentrantLock(StringRedisTemplate redis) {
this.redis = redis;
this.scheduler = Executors.newScheduledThreadPool(
2, r -> { Thread t = new Thread(r, "lock-watchdog"); t.setDaemon(true); return t; });
}
private String holder() {
return clientId + ":" + Thread.currentThread().getId();
}
/** 尝试加锁,waitMillis 内自旋等待 */
public boolean tryLock(String key, long waitMillis) throws InterruptedException {
long deadline = System.currentTimeMillis() + waitMillis;
String owner = holder();
do {
Long ok = redis.execute(RedisScript.of(LOCK_SCRIPT, Long.class),
Collections.singletonList(key), owner, String.valueOf(LEASE_MILLIS));
if (ok != null && ok == 1L) {
startWatchdog(key, owner);
return true;
}
// 真实实现应改为 Pub/Sub 等待锁释放通知,避免无效自旋
Thread.sleep(50);
} while (System.currentTimeMillis() < deadline);
return false;
}
public void unlock(String key) {
String owner = holder();
Long r = redis.execute(RedisScript.of(UNLOCK_SCRIPT, Long.class),
Collections.singletonList(key), owner, String.valueOf(LEASE_MILLIS));
if (r == null || r == -1L) {
throw new IllegalMonitorStateException("current thread does not hold lock: " + key);
}
if (r == 1L) { // 完全释放后才停止续期
stopWatchdog(key, owner);
}
}
private void startWatchdog(String key, String owner) {
String taskKey = key + "#" + owner;
if (renewTasks.containsKey(taskKey)) {
return; // 重入时不重复注册
}
ScheduledFuture<?> f = scheduler.scheduleAtFixedRate(() -> {
try {
Long ok = redis.execute(RedisScript.of(RENEW_SCRIPT, Long.class),
Collections.singletonList(key), owner, String.valueOf(LEASE_MILLIS));
if (ok == null || ok == 0L) {
stopWatchdog(key, owner); // 锁已不属于自己,停止续期
}
} catch (Exception e) {
// 续期失败不能抛出,否则定时任务会被取消;记录指标后等下次重试
}
}, LEASE_MILLIS / 3, LEASE_MILLIS / 3, TimeUnit.MILLISECONDS);
renewTasks.put(taskKey, f);
}
private void stopWatchdog(String key, String owner) {
ScheduledFuture<?> f = renewTasks.remove(key + "#" + owner);
if (f != null) {
f.cancel(true);
}
}
}边界 Case 自测清单
| # | Case | 期望行为 |
|---|---|---|
| 1 | 同一线程连续 tryLock 三次 | 三次都返回 true,Hash 里重入次数为 3,看门狗只注册一次 |
| 2 | 同一线程 unlock 两次但加锁三次 | 锁仍然存在,次数变为 1,续期任务未取消 |
| 3 | 线程 A 持锁,线程 B tryLock | B 在 waitMillis 内自旋,超时返回 false,绝不能误删 A 的锁 |
| 4 | 线程 B 对 A 持有的锁调 unlock | 抛 IllegalMonitorStateException,脚本返回 -1,A 的锁不受影响 |
| 5 | 业务执行 90 秒(超过 30 秒租期) | 看门狗每 10 秒续期一次,锁始终存在;业务结束后正常释放 |
| 6 | 持锁进程被 kill -9 | 续期停止,锁在最多 30 秒后自然过期,不会永久死锁 |
| 7 | 续期时 Redis 短暂不可用 | 捕获异常、不取消定时任务,下一个周期重试;若锁已过期则下次续期返回 0 并停止 |
| 8 | 同一 JVM 两个线程 | holder() 带线程 ID,互不干扰;不同进程的 clientId 不同 |
| 9 | 锁 key 对应的 value 被人手动改成 String 类型 | HEXISTS 报 WRONGTYPE,异常向上抛出,不能静默当成"加锁失败" |
| 10 | 在 Cluster 下使用 | 单个 key,KEYS[1] 正确声明,不会 CROSSSLOT;但主从切换仍可能丢锁,需业务侧兜底 |
🚀 变种拓展题
- 把自旋等待改成 Pub/Sub 通知:释放锁时
PUBLISH一条消息,等待者订阅后被唤醒,消除无效轮询(这正是 Redisson 的做法)。 - 实现公平锁:等待者先
ZADD到一个等待队列(score = 入队时间),只有队首的线程才允许尝试加锁。 - 实现读写锁:Hash 里同时记录读锁计数和写锁持有者,写锁独占、读锁共享,并处理读锁升级为写锁的死锁问题。
- 实现 RedLock:向 5 个独立实例并发加锁,统计成功数与耗时,计算真实有效期,并在失败时向全部实例发送释放。
- 给锁加上 fencing token:锁 key 旁边维护一个
INCR的单调计数器,加锁成功时返回 token,业务侧把 token 传给下游做校验。
题 2:用 Lua 实现滑动窗口限流器
核心实现
lua
-- sliding_window_limit.lua
-- KEYS[1] = 限流 key,如 rate:api:/order:uid:1001
-- ARGV[1] = 窗口长度(毫秒)
-- ARGV[2] = 窗口内允许的最大请求数
-- ARGV[3] = 当前时间戳(毫秒,由调用方传入,保证集群内时间一致)
-- ARGV[4] = 本次请求的唯一 ID(避免同一毫秒内多个请求的 member 冲突)
-- 返回:允许则返回剩余可用配额,拒绝则返回 -1
local key = KEYS[1]
local window = tonumber(ARGV[1])
local limit = tonumber(ARGV[2])
local now = tonumber(ARGV[3])
local reqId = ARGV[4]
-- ① 移除窗口之外的旧记录(这一步是"滑动"的关键)
redis.call('ZREMRANGEBYSCORE', key, 0, now - window)
-- ② 统计当前窗口内的请求数
local count = redis.call('ZCARD', key)
if count >= limit then
-- 被拒绝的请求**不写入** ZSet,否则会把窗口里的坑位占满导致永远恢复不了
-- 仍然刷新一次 TTL,保证 key 能被回收
redis.call('PEXPIRE', key, window * 2)
return -1
end
-- ③ 放行:记录本次请求
redis.call('ZADD', key, now, reqId)
-- ④ TTL 设为窗口的 2 倍,长期不访问则自动回收,避免无限堆积 key
redis.call('PEXPIRE', key, window * 2)
return limit - count - 1java
@Component
public class SlidingWindowLimiter {
private final StringRedisTemplate redis;
private final RedisScript<Long> script;
public SlidingWindowLimiter(StringRedisTemplate redis) {
this.redis = redis;
// 用 EVALSHA 复用脚本缓存;Spring 的 DefaultRedisScript 会自动处理 NOSCRIPT 回退
DefaultRedisScript<Long> s = new DefaultRedisScript<>();
s.setScriptSource(new ResourceScriptSource(
new ClassPathResource("lua/sliding_window_limit.lua")));
s.setResultType(Long.class);
this.script = s;
}
/**
* @return true 放行,false 限流
*/
public boolean tryAcquire(String bizKey, long windowMillis, int limit) {
// 时间由应用侧统一提供:集群部署时必须保证各节点 NTP 同步
long now = System.currentTimeMillis();
String reqId = now + "-" + ThreadLocalRandom.current().nextInt(1 << 20);
try {
Long remain = redis.execute(script,
Collections.singletonList("rate:" + bizKey),
String.valueOf(windowMillis),
String.valueOf(limit),
String.valueOf(now),
reqId);
return remain != null && remain >= 0;
} catch (Exception e) {
// 限流器自身故障时的策略:这里选择"失败放行",保可用性
// 若是防刷/防攻击场景,应改为"失败拒绝"
return true;
}
}
}边界 Case 自测清单
| # | Case | 期望行为 |
|---|---|---|
| 1 | 1 秒限 5 次,连续发 5 次 | 5 次全部放行,返回的剩余配额依次为 4、3、2、1、0 |
| 2 | 紧接着发第 6 次 | 返回 -1 被拒绝,且不写入 ZSet |
| 3 | 等满 1 秒后再发 | 旧记录被 ZREMRANGEBYSCORE 清掉,重新放行 |
| 4 | 固定窗口的临界问题 | 在第 0.9 秒和第 1.1 秒各发 5 次,滑动窗口会拒绝第二批(因为 1 秒内已有 5 次),固定窗口会全放行 |
| 5 | 同一毫秒内并发 100 次请求 | reqId 带随机数,100 个 member 互不覆盖,计数准确 |
| 6 | 被拒绝的请求如果也写入 ZSet | 会把窗口填满导致永久限流,这是最典型的实现 bug,必须验证不会发生 |
| 7 | key 长时间不访问 | TTL 到期自动删除,不会堆积垃圾 key |
| 8 | 高 QPS(10 万)下的内存 | ZSet 会存满一个窗口的请求记录,1 秒 10 万就是 10 万个 member,内存和 ZCARD 开销都要评估 |
| 9 | Redis 不可用 | 按配置策略"失败放行"或"失败拒绝",绝不能抛异常中断业务 |
| 10 | Cluster 下多个限流 key 同时校验 | 不同 key 大概率不同槽,一个脚本里操作会报 CROSSSLOT,必须拆成多次调用或改用 hash tag |
🚀 变种拓展题
- 改成分段滑动窗口:把窗口切成 N 个小格用 Hash 存计数,内存从 O(QPS×窗口) 降到 O(N),代价是精度损失。
- 实现令牌桶:用 Hash 存"当前令牌数 + 上次填充时间",按经过时间补充令牌,支持突发流量。
- 实现多级限流:一个脚本里同时校验"全局 QPS、单用户 QPS、单接口 QPS",全部通过才放行,任一失败要回滚已扣减的配额。
- 实现漏桶:请求入队后按固定速率流出,用 ZSet 存排队时间,返回给调用方"需要等待多少毫秒"。
- 实现预热限流(Warm Up):系统刚启动时阈值从 20% 开始,按斜率在 N 秒内线性爬升到 100%,避免冷启动被打挂。
题 3:用 Lua 实现秒杀库存扣减(原子扣减 + 一人一单)
核心实现
lua
-- seckill.lua
-- KEYS[1] = 库存 key seckill:stock:{1001}
-- KEYS[2] = 已购用户集合 seckill:users:{1001}
-- ARGV[1] = 用户 ID
-- ARGV[2] = 本次购买数量
-- 返回:0 成功 / 1 库存不足 / 2 重复下单 / 3 活动不存在
local stockKey = KEYS[1]
local userKey = KEYS[2]
local userId = ARGV[1]
local num = tonumber(ARGV[2])
-- ① 活动是否存在(库存 key 不存在说明未预热或活动已结束)
local stock = redis.call('GET', stockKey)
if stock == false then
return 3
end
-- ② 一人一单校验:放在扣库存之前,避免"扣了又回滚"
if redis.call('SISMEMBER', userKey, userId) == 1 then
return 2
end
-- ③ 库存校验
if tonumber(stock) < num then
return 1
end
-- ④ 扣减 + 记录用户,两步在同一脚本内,天然原子
redis.call('DECRBY', stockKey, num)
redis.call('SADD', userKey, userId)
return 0java
@Service
public class SeckillService {
private static final Map<Long, String> MSG = Map.of(
1L, "库存不足", 2L, "您已参与过本次活动", 3L, "活动未开始或已结束");
private final StringRedisTemplate redis;
private final RedisScript<Long> seckillScript;
private final RocketMQTemplate mq;
/** 活动开始前预热:库存写入 Redis */
public void warmUp(Long activityId, int stock) {
// hash tag 保证两个 key 在同一个槽,Cluster 下脚本才能执行
redis.opsForValue().set(stockKey(activityId), String.valueOf(stock));
redis.delete(userKey(activityId));
}
public Result seckill(Long activityId, Long userId) {
Long code = redis.execute(seckillScript,
List.of(stockKey(activityId), userKey(activityId)),
String.valueOf(userId), "1");
if (code == null) {
return Result.fail("系统繁忙,请稍后重试");
}
if (code != 0L) {
return Result.fail(MSG.get(code));
}
// Redis 扣减成功 → 异步落库创建订单(削峰)
// 消息必须可靠投递:发送失败要重试,重试仍失败要回滚 Redis 库存
try {
mq.syncSend("seckill-order", new OrderMsg(activityId, userId));
} catch (Exception e) {
rollback(activityId, userId);
return Result.fail("系统繁忙,请稍后重试");
}
return Result.ok("抢购成功,订单处理中");
}
/** 回滚:同样用 Lua 保证"加回库存 + 移除用户"原子 */
private void rollback(Long activityId, Long userId) {
redis.execute(RedisScript.of(
"redis.call('INCRBY', KEYS[1], ARGV[2]); "
+ "redis.call('SREM', KEYS[2], ARGV[1]); "
+ "return 1;", Long.class),
List.of(stockKey(activityId), userKey(activityId)),
String.valueOf(userId), "1");
}
private String stockKey(Long id) { return "seckill:stock:{" + id + "}"; }
private String userKey(Long id) { return "seckill:users:{" + id + "}"; }
}⚠️ 数据库侧必须有第二道防线。Redis 扣减成功不代表最终正确——主从切换、Lua 执行后主库宕机、消息重复消费都可能导致超卖。所以落库时必须:① 订单表对
(activity_id, user_id)建唯一索引防重复;② 扣减库存用UPDATE stock SET num = num - 1 WHERE id = ? AND num >= 1,靠行锁和条件保证不扣成负数。
边界 Case 自测清单
| # | Case | 期望行为 |
|---|---|---|
| 1 | 库存 100,并发 1000 个不同用户 | 恰好 100 人成功,库存归 0,用户集合 100 个元素 |
| 2 | 同一用户并发请求 50 次 | 只有 1 次成功,其余返回"已参与",不会扣减 50 个库存 |
| 3 | 库存为 0 时继续请求 | 返回"库存不足",库存不会变成负数 |
| 4 | 库存 key 不存在(未预热) | 返回"活动未开始",而不是把库存 DECRBY 成 -1 |
| 5 | 一人一单校验放在扣库存之后 | 会出现"扣了库存又要加回去"的窗口,并发下库存数会短暂错误,必须验证顺序正确 |
| 6 | MQ 发送失败 | Redis 库存和用户集合都被回滚,用户可以重新抢购 |
| 7 | MQ 消息重复消费 | 订单表唯一索引拦住,消费端捕获 DuplicateKeyException 后直接 ACK |
| 8 | Lua 执行成功但主库立刻宕机 | 扣减可能丢失(异步复制),导致少卖;或切主后库存回到旧值导致超卖。DB 唯一索引 + 条件更新兜底 |
| 9 | Cluster 环境 | 两个 key 用了相同的 hash tag {activityId},同槽,不报 CROSSSLOT |
| 10 | 活动结束后的清理 | 用户集合可能有百万元素,必须用 UNLINK 而不是 DEL 删除 |
🚀 变种拓展题
- 支持限购 N 件:把 Set 换成 Hash,field 是用户 ID、value 是已购数量,脚本里校验
已购 + 本次 <= N。 - 库存分段扣减:把 1000 库存拆成 10 个 key 各 100,请求随机落到一个分段,分段卖完后尝试其他分段,解决单 key 热点。
- 加入令牌预发:活动开始前给用户发令牌,扣减时校验令牌,把无效流量挡在 Redis 之前。
- 实现库存回补:订单超时未支付时把库存加回 Redis,并处理"回补后库存超过初始值"的异常情况。
- 改造成支持预占 + 确认两阶段:先
DECRBY预占并写入一条带 TTL 的预占记录,支付成功后确认,超时自动回补。
题 4:基于 Redis BitMap 手写布隆过滤器
核心实现
java
public class RedisBloomFilter {
private final StringRedisTemplate redis;
private final String key;
private final long bitSize; // 位数组长度 m
private final int hashCount; // 哈希函数个数 k
/**
* @param expected 预估元素数量 n
* @param falsePositive 期望误判率 p
*/
public RedisBloomFilter(StringRedisTemplate redis, String key,
long expected, double falsePositive) {
this.redis = redis;
this.key = key;
// m = -n * ln(p) / (ln2)^2
this.bitSize = (long) Math.ceil(
-expected * Math.log(falsePositive) / (Math.log(2) * Math.log(2)));
// k = (m / n) * ln2
this.hashCount = Math.max(1, (int) Math.round((double) bitSize / expected * Math.log(2)));
}
/**
* 用一次 128 位 MurmurHash 的两个 64 位分量,模拟 k 个独立哈希
* (Guava 的做法:h_i = h1 + i * h2,避免算 k 次哈希)
*/
private long[] positions(String value) {
byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
long h1 = Hashing.murmur3_128().hashBytes(bytes).asLong();
long h2 = Hashing.murmur3_128(0x9747b28c).hashBytes(bytes).asLong();
long[] pos = new long[hashCount];
for (int i = 0; i < hashCount; i++) {
long combined = h1 + (long) i * h2;
// 取绝对值:注意 Long.MIN_VALUE 取反仍是自己,必须用 & 0x7fff... 去掉符号位
pos[i] = (combined & Long.MAX_VALUE) % bitSize;
}
return pos;
}
/** 添加元素:k 次 SETBIT 必须用 pipeline,否则 k 次网络往返 */
public void add(String value) {
long[] pos = positions(value);
redis.executePipelined((RedisCallback<Object>) conn -> {
for (long p : pos) {
conn.setBit(key.getBytes(StandardCharsets.UTF_8), p, true);
}
return null;
});
}
/** 批量添加:预热百万数据时必须批量 */
public void addAll(Collection<String> values) {
redis.executePipelined((RedisCallback<Object>) conn -> {
byte[] rawKey = key.getBytes(StandardCharsets.UTF_8);
for (String v : values) {
for (long p : positions(v)) {
conn.setBit(rawKey, p, true);
}
}
return null;
});
}
/**
* 判断是否可能存在
* @return false = **一定不存在**;true = 可能存在(有误判率)
*/
public boolean mightContain(String value) {
long[] pos = positions(value);
// 用 Lua 把 k 次 GETBIT 做成一次调用,并支持"遇到 0 就提前返回"
String lua = "for i = 1, #ARGV do "
+ " if redis.call('GETBIT', KEYS[1], ARGV[i]) == 0 then return 0; end; "
+ "end; return 1;";
Object[] args = Arrays.stream(pos).mapToObj(String::valueOf).toArray();
Long r = redis.execute(RedisScript.of(lua, Long.class),
Collections.singletonList(key), args);
return r != null && r == 1L;
}
public long bitSize() { return bitSize; }
public int hashCount() { return hashCount; }
}text
参数速查(p = 误判率)
n = 100 万, p = 1% → m ≈ 958 万 bit ≈ 1.14 MB, k = 7
n = 1 亿, p = 1% → m ≈ 9.58 亿 bit ≈ 114 MB, k = 7
n = 1 亿, p = 0.1% → m ≈ 14.4 亿 bit ≈ 171 MB, k = 10
规律:误判率每降低 10 倍,内存只增加约 50%,k 增加约 3边界 Case 自测清单
| # | Case | 期望行为 |
|---|---|---|
| 1 | add("a") 后 mightContain("a") | 必须返回 true(布隆过滤器不允许假阴性) |
| 2 | 未添加过的 "zzz" | 绝大概率返回 false;返回 true 属于正常的假阳性,靠缓存空值兜底 |
| 3 | 实际插入量超过预估 n 的 10 倍 | 误判率急剧恶化(可能接近 100%),必须监控实际插入量并预留余量 |
| 4 | 哈希值为负数 | & Long.MAX_VALUE 去掉符号位。不能用 Math.abs(),因为 Math.abs(Long.MIN_VALUE) 仍是负数 |
| 5 | bitSize 超过 Redis 单 key 上限 | String 最大 512MB = 2^32 bit。超过要分片成多个 key,按元素哈希选分片 |
| 6 | 调用 mightContain 时 Redis 不可用 | 应当降级为"可能存在"(返回 true)放过请求,不能因为过滤器挂了就全部拒绝 |
| 7 | 需要删除某个元素 | 不支持。多个元素共用 bit 位,置 0 会误删他人。需删除就用 Counting Bloom Filter 或 Cuckoo Filter |
| 8 | 新增数据后如何同步到过滤器 | 必须在写数据库成功后同步 add,否则新数据会被过滤器拦住导致假阴性(这是最严重的 bug) |
| 9 | 过滤器需要重建 | 用新 key 重建后原子切换(RENAME),不能边删边建,否则重建期间假阴性 |
| 10 | Cluster 下使用 | 单 key 不跨槽;但 mightContain 的 Lua 脚本和 pipeline 都只操作一个 key,安全 |
🚀 变种拓展题
- 实现本地 + Redis 两级布隆:本地 Guava BloomFilter 挡住绝大部分,未命中再查 Redis,减少网络往返。
- 实现计数布隆(Counting Bloom Filter):每位改成 4 bit 计数器,支持删除,用
BITFIELD操作。 - 实现可扩展布隆(Scalable Bloom Filter):当插入量接近上限时自动新建一层更大的过滤器,查询时逐层检查。
- 用 RedisBloom 模块(
BF.ADD/BF.EXISTS/BF.MADD)替换手写实现,对比内存和性能差异。 - 实现布隆过滤器的热更新:用双 key(
bf:v1/bf:v2)加一个指向当前版本的指针 key,重建完成后切换指针。
题 5:封装一个同时解决穿透与击穿的缓存工具类
核心实现
java
@Component
public class CacheClient {
private static final String EMPTY = ""; // 空值标记
private static final long EMPTY_TTL = 60L; // 空值 TTL(秒),短
private static final long LOCK_TTL = 10L; // 重建锁 TTL(秒)
private final StringRedisTemplate redis;
/** 专用线程池做异步重建,必须有界队列 + 拒绝策略,不能用无界池 */
private final ExecutorService rebuildPool = new ThreadPoolExecutor(
4, 8, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
new ThreadPoolExecutor.DiscardPolicy()); // 重建任务可丢弃,丢了下次请求还会触发
/** 逻辑过期的包装对象 */
@Data
static class LogicalValue {
private String data; // 真实数据的 JSON
private long expireAt; // 逻辑过期时间戳(毫秒)
}
// ============ 方案 A:缓存空值,解决穿透 ============
public <R, ID> R queryWithPassThrough(String prefix, ID id, Class<R> type,
Function<ID, R> dbFallback, long ttlSeconds) {
String key = prefix + id;
String json = safeGet(key);
if (json != null) {
// **注意顺序**:必须先判断是否命中空值,再判断是否有内容
if (EMPTY.equals(json)) {
return null; // 命中空值,直接返回,不回源
}
return JSON.parseObject(json, type);
}
R data = dbFallback.apply(id);
if (data == null) {
// 写入空值并设短 TTL,挡住后续穿透
safeSet(key, EMPTY, EMPTY_TTL);
return null;
}
// TTL 加 ±20% 随机抖动,避免同批 key 同时失效
safeSet(key, JSON.toJSONString(data), withJitter(ttlSeconds));
return data;
}
// ============ 方案 B:逻辑过期,解决击穿 ============
public <R, ID> R queryWithLogicalExpire(String prefix, ID id, Class<R> type,
Function<ID, R> dbFallback, long ttlSeconds) {
String key = prefix + id;
String json = safeGet(key);
if (json == null) {
// 逻辑过期方案要求数据**必须预热**;未命中说明是冷数据,同步建一次
return loadAndCache(key, id, type, dbFallback, ttlSeconds);
}
LogicalValue wrapper = JSON.parseObject(json, LogicalValue.class);
R data = JSON.parseObject(wrapper.getData(), type);
if (wrapper.getExpireAt() > System.currentTimeMillis()) {
return data; // 未逻辑过期,直接返回
}
// 已逻辑过期:抢锁,抢到的异步重建,没抢到的直接返回旧值
String lockKey = "lock:rebuild:" + key;
if (tryLock(lockKey)) {
rebuildPool.submit(() -> {
try {
// 双重检查:可能在排队期间已被别人重建
String latest = safeGet(key);
if (latest != null) {
LogicalValue lv = JSON.parseObject(latest, LogicalValue.class);
if (lv.getExpireAt() > System.currentTimeMillis()) {
return;
}
}
loadAndCache(key, id, type, dbFallback, ttlSeconds);
} finally {
unlock(lockKey); // **必须放在 finally**,否则异常后锁要等 TTL
}
});
}
// 无论有没有抢到锁,**当前请求都立即返回旧值**,这是可用性优先的核心
return data;
}
private <R, ID> R loadAndCache(String key, ID id, Class<R> type,
Function<ID, R> dbFallback, long ttlSeconds) {
R data = dbFallback.apply(id);
LogicalValue wrapper = new LogicalValue();
wrapper.setData(data == null ? null : JSON.toJSONString(data));
wrapper.setExpireAt(System.currentTimeMillis() + ttlSeconds * 1000);
// key 本身设一个远大于逻辑 TTL 的物理 TTL 兜底,避免永久垃圾
safeSet(key, JSON.toJSONString(wrapper), ttlSeconds * 10);
return data;
}
private long withJitter(long ttlSeconds) {
double factor = 0.8 + ThreadLocalRandom.current().nextDouble() * 0.4; // 0.8 ~ 1.2
return (long) (ttlSeconds * factor);
}
private boolean tryLock(String lockKey) {
Boolean ok = redis.opsForValue()
.setIfAbsent(lockKey, "1", LOCK_TTL, TimeUnit.SECONDS);
return Boolean.TRUE.equals(ok);
}
private void unlock(String lockKey) {
safeDelete(lockKey);
}
// ============ 所有 Redis 调用都要包异常:缓存必须是弱依赖 ============
private String safeGet(String key) {
try {
return redis.opsForValue().get(key);
} catch (Exception e) {
return null; // 当作未命中,走回源
}
}
private void safeSet(String key, String value, long ttlSeconds) {
try {
redis.opsForValue().set(key, value, ttlSeconds, TimeUnit.SECONDS);
} catch (Exception e) {
// 写缓存失败不影响业务,记录指标即可
}
}
private void safeDelete(String key) {
try {
redis.delete(key);
} catch (Exception e) {
// 同上
}
}
}边界 Case 自测清单
| # | Case | 期望行为 |
|---|---|---|
| 1 | 查询不存在的 ID 两次 | 第一次回源数据库,第二次命中空值标记,不再回源 |
| 2 | 空值判断写成 StrUtil.isNotBlank(json) | 空字符串会被判为"未命中"从而每次都回源,穿透防护失效,必须单独判断 |
| 3 | 空值 TTL 设得很长(如 30 分钟) | 数据库后来插入了这条数据,用户 30 分钟内都看不到。空值 TTL 必须远短于正常 TTL |
| 4 | 热点 key 逻辑过期,1000 并发同时到达 | 只有 1 个请求抢到锁去异步重建,其余 999 个立即返回旧值,数据库 QPS = 1 |
| 5 | 异步重建时数据库抛异常 | finally 释放锁,缓存保留旧值;下一个请求会再次尝试重建 |
| 6 | 重建线程池队列满 | DiscardPolicy 丢弃任务,但锁已被占用 → 必须保证丢弃时也能释放锁(可改用提交失败时立即 unlock) |
| 7 | 逻辑过期的 key 没有预热 | 首次访问会同步回源(有击穿风险)。所以逻辑过期方案必须配合预热使用 |
| 8 | Redis 整个宕机 | safeGet 返回 null → 走回源 → safeSet 静默失败。接口变慢但功能正常,这是验收标准 |
| 9 | 同一批 1 万个 key 用相同 TTL 写入 | withJitter 让实际 TTL 散布在 0.8~1.2 倍区间,避免雪崩 |
| 10 | 逻辑过期对象的物理 TTL | 必须设置(这里是逻辑 TTL 的 10 倍),否则长期不访问的 key 永久驻留内存 |
🚀 变种拓展题
- 把"抢锁 + 异步重建"换成
CompletableFuture+ 本地ConcurrentHashMap去重,同一 JVM 内的并发请求共享一次回源(单机防击穿,减少分布式锁开销)。 - 增加本地缓存层:Caffeine 缓存 2 秒 + Redis + DB 三级,并通过 Redis Pub/Sub 广播失效消息清理各实例的本地缓存。
- 实现基于注解的声明式缓存:
@SmartCache(prefix="product:", ttl=30, strategy=LOGICAL_EXPIRE),用 AOP 拦截。 - 加入回源信号量隔离:缓存不可用时限制回源到数据库的并发数,超出的请求直接返回兜底对象。
- 把删除缓存改造成延迟双删 + 失败重试:删除失败时投递到 MQ,消费端重试,重试 N 次后告警。
⚠️ 当日踩坑实录
| ❌ 坑 | ✅ 解决方案 |
|---|---|
线上用 KEYS * 找 key | KEYS 会遍历全库并阻塞主线程,百万级 key 就是秒级卡顿;必须用 SCAN 游标分批取 |
以为 SCAN 的结果可以直接用 | SCAN 只保证「全程存在的 key 一定被返回」,rehash 期间可能重复返回同一个 key,业务侧必须自己去重 |
| 以为编码转换是双向的 | listpack/intset 一旦因为超阈值转成 hashtable/skiplist,即使元素再删回去也不会转回来,内存不会自动降下来 |
| 以为 Hash 一定比多个 String 省内存 | 只有 field 数和 value 长度都在 hash-max-listpack-entries/value 阈值内才是 listpack;超阈值转 hashtable 后反而更费 |
用 SETBIT 写一个很大的 offset | BitMap 按 offset 直接分配空间,SETBIT key 1000000000 1 会瞬间申请约 128MB,是典型的大 key 制造机 |
| 以为 HyperLogLog 能算交集 | 只有 PFMERGE(并集),交集只能靠容斥公式推,误差会被成倍放大;要精确去重交集请用 Set 或 BitMap |
| 以为 key 到点就被删除 | 惰性删除 + 定期抽样删除的组合,used_memory 下降滞后是正常现象;真实删除数看 INFO stats 的 expired_keys |
| 大批 key 设同一个 TTL | 除了缓存雪崩,过期比例持续 > 25% 还会让 activeExpireCycle 不断循环,造成 CPU 抖动和延迟毛刺 |
用 SET 覆盖已有 key 时以为 TTL 还在 | SET 不带 KEEPTTL 会清掉原有过期时间,key 从此永不过期,是静默的内存泄漏 |
以为 maxmemory-policy 默认是 LRU | 默认是 noeviction,内存打满后写命令直接报 OOM command not allowed,这是线上事故高发点 |
锁、幂等标记、会话和缓存混放一个实例,靠 volatile-lru 保护 | volatile-* 保护的是没有 TTL 的 key,而锁恰恰有 TTL,照样被淘汰;必须按「丢了会不会出错」拆实例 |
设了 volatile-* 但没有任何 key 带 TTL | 会退化成 noeviction,所有写入报错 |
maxmemory 设成物理内存的 90% | 它不统计 replication backlog、客户端输出缓冲、AOF 重写缓冲和内存碎片;生产设 60%~70%,否则 fork 时 OOM 或触发 swap |
以为 BGSAVE 完全不影响线上 | fork 本身阻塞主线程,内存越大越久;必须监控 latest_fork_usec,并关闭透明大页(THP 会让 COW 粒度从 4KB 变 2MB) |
以为 appendfsync always 才叫安全 | 每条命令都 fsync 会让吞吐掉一个数量级;生产标配 everysec,最多丢 1 秒数据 |
| AOF 重写时机器内存没留余量 | 子进程 COW + AOF 重写缓冲区会让内存接近翻倍;必须预留,并优先用 7.0 的 Multi Part AOF 降低写放大 |
| 以为恢复时优先加载 RDB | 开了 AOF 就优先加载 AOF(数据更全);想用 RDB 恢复必须先关 appendonly 或替换为混合持久化文件 |
| 把「Redis 宕机型雪崩」和「集体过期型雪崩」混为一谈 | TTL 打散对前者完全无效,宕机型只能靠高可用 + 多级缓存 + 限流熔断;面试时把两类分开讲立刻高一档 |
| 防穿透只记得布隆过滤器 | 最便宜有效的其实是入口参数合法性校验;布隆有假阳性且标准布隆不能删除,必须配缓存空值兜底 |
| 空值的 TTL 和正常 TTL 一样长 | 数据库后来真的插入了这条数据,用户长时间看不到;空值 TTL 必须远短于正常 TTL(30s~5min),并在插入时主动删 key |
用 StrUtil.isNotBlank 判断缓存命中 | 空字符串会被判成「未命中」从而每次都回源,防穿透直接失效;必须把「空值标记」和「未命中」分开判断 |
| 逻辑过期方案不做预热 | 首次访问仍然是同步回源,照样被击穿;逻辑过期必须配合缓存预热使用 |
| 逻辑过期的 key 不设物理 TTL | 冷掉之后永久驻留内存;应同时设一个远大于逻辑 TTL 的物理 TTL 兜底 |
| 写流程做成「先删缓存,再更库」 | 删完到更新完之间的读请求会把旧值重新加载进缓存并长期驻留;只推荐先更库,再删缓存 |
| 用「更新缓存」代替「删除缓存」 | 并发写会交叉覆盖留下旧值,而且复杂计算结果可能白算;统一改成删除,让下次读按需重建 |
| 认为延迟双删能保证强一致 | 它只是把不一致概率压低。缓存和 DB 是两个系统、不在一个事务里,只能做最终一致 |
| 回源读走从库 | 主从延迟几十毫秒到几秒,比理论不一致窗口大几个数量级,旧值照样被写进缓存;关键链路回源强制走主库 |
释放锁写成 GET 判断 + DEL 两步 | 判断之后锁可能刚好过期并被 B 抢到,A 的 DEL 删掉的是 B 的锁;必须用 Lua 把「比对 + 删除」合成一步 |
为了「保险」给 Redisson 传了 leaseTime | 传了租期就没有看门狗,lock(10, SECONDS) 表示 10 秒后无条件过期;要自动续期必须用无参 lock() 或只传 waitTime 的 tryLock() |
没抢到锁也在 finally 里 unlock() | 会误删别人持有的锁;必须加锁成功之后才进 try 块,或在 unlock 前校验持有者 |
在 @Transactional 方法内部加锁 | 锁在方法内释放,而事务在方法返回后才提交,并发线程仍会读到提交前的旧数据;锁范围必须包住整个事务 |
| 以为 Redlock 能解决一切 | 在 GC 停顿、网络延迟、时钟漂移面前,任何基于时间租约的锁都不绝对安全;资金/库存必须在 DB 侧用唯一索引、WHERE stock > 0 或状态机 CAS 兜底 |
| 以为 pipeline 是原子的 | 它只负责减少 RTT,命令之间完全可以插入其他客户端的命令;要原子请用 Lua |
| 以为 Redis 事务出错会回滚 | 运行时错误只是该条失败,其余命令照常生效,Redis 不提供 ROLLBACK;语法错误才会让整个 EXEC 被拒绝 |
WATCH 失败后直接重试 | EXEC、DISCARD、连接断开都会清空所有 WATCH,必须重新 WATCH 再重试;而且改回原值也算被改过,存在 ABA 假失败 |
Lua 脚本里拼接 key 而不走 KEYS[] | 集群下算不出槽位,报 CROSSSLOT 或打到错误节点;所有 key 必须通过 KEYS[] 传入 |
以为 lua-time-limit 到了脚本会被中断 | 它只是开始接受 SCRIPT KILL;一旦脚本已经产生写操作,SCRIPT KILL 无效,只能 SHUTDOWN NOSAVE,所以脚本必须短小无不可控循环 |
| 以为主从复制自带故障转移 | 主从只解决读扩展 + 数据冗余,自动切换要靠哨兵或 Cluster |
| 以为主从同步是同步的 | 默认异步复制,主库宕机存在丢数据窗口;用 min-replicas-to-write / min-replicas-max-lag 缩小窗口并防脑裂 |
repl-backlog-size 保持默认 1MB | 网络抖动稍久 offset 就滑出缓冲区,直接退化成全量同步,大实例上会连环 fork;按「写入速率 × 容忍断开秒数」调大 |
| 哨兵只部署 2 个 | 客观下线和 leader 选举都要超过半数,哨兵必须 ≥ 3 且为奇数 |
| 客户端硬编码主库 IP | 故障转移后连不上;必须通过哨兵获取主库地址,或用支持 Sentinel 的客户端自动感知 |
把 replica-read-only 关掉往从库写数据 | 这些写入在下一次全量同步时被直接覆盖丢失,而且没有任何报错 |
| Cluster 只部署 2 个主节点 | 故障判定依赖「超过半数主节点存活」,挂一个就整体不可用;主节点必须 ≥ 3 |
| 防火墙只放开 6379 | 集群总线端口 = 业务端口 + 10000(即 16379),不放开必然建不起集群,这是部署失败最常见原因 |
hash tag 用成实体类型,如 {user}:1001 | 所有 key 被绑到同一个槽,造成严重数据倾斜和热点节点,等于白分片;粒度必须是单个业务实体 {user:1001} |
在 Cluster 里直接用 MSET / 事务 / Lua 跨 key | 报 CROSSSLOT;必须用 hash tag 把相关 key 收拢到同槽,或改成单 key 操作 |
| 以为 Cluster 支持多个 db | 只有 db0,不支持 SELECT;需要隔离请用 key 前缀或独立集群 |
收到 ASK 后刷新了本地槽表 | ASK 表示槽正在迁移,只是一次性重定向,必须先发 ASKING 再发原命令,且不能更新槽映射;只有 MOVED 才刷新 |
配了 spring.redis.lettuce.pool.* 却没引 commons-pool2 | 池化配置完全不生效且不报任何错;需要池化(阻塞命令、事务)时必须显式加依赖 |
连接池不配 max-wait | 默认 -1 表示无限等待,Redis 一变慢业务线程全部挂死在获取连接上 |
| 不显式配置序列化器 | 默认 JdkSerializationRedisSerializer,redis-cli 里全是二进制乱码且跨语言不可读;key 用 StringRedisSerializer,value 用 JSON |
StringRedisTemplate 与 RedisTemplate 混用同一批 key | 一个写 JSON 一个写 JDK 序列化,互相读不出来;统一只用一种模板并写进规范 |
| Redis 调用不包 try-catch | 缓存从弱依赖变成强依赖,Redis 卡 5 秒就能把 Tomcat 线程池堵满,连不依赖缓存的接口一起挂;必须设命令超时 + 异常降级 |
线上长时间开 MONITOR | 所有命令都会推给客户端,输出缓冲区打满足以拖垮实例;只能极短时间使用,排查大 key 优先用 --bigkeys 或离线解析 RDB |
用 DEL 删大 key | 释放内存在主线程同步进行,会造成明显阻塞;改用 UNLINK 并开启 lazyfree-lazy-* 系列配置 |
| 认为 slowlog 里没有慢命令就说明 Redis 没问题 | slowlog 只记命令执行耗时,不含网络排队、fork 阻塞、淘汰和 AOF fsync;必须配合 latency monitor、INFO commandstats 与 RTT 基线一起看 |
| 实例不设密码、不改默认端口、绑定 0.0.0.0 | 公网暴露会被秒级植入挖矿脚本;必须 requirepass + bind 内网 + rename-command 禁用 FLUSHALL/CONFIG/KEYS |
📌 当日复盘任务
数据结构与底层编码
- [ ] 用一句话给出五种基础结构的选型标准,并各举一个真实业务场景
- [ ] 背出五种结构的两套底层编码及各自的转换阈值配置项,并说明转换为什么不可逆
- [ ] 解释 SDS 相比 C 原生字符串的四个改进,以及「二进制安全」具体指什么
- [ ] 说清 listpack 为什么能取代 ziplist(连锁更新问题的成因与代价)
- [ ] 说出 ZSet 不用红黑树、不用 B+ 树的各两条理由
- [ ] 在本地用
OBJECT ENCODING验证一次 listpack → hashtable 的转换,并确认删除元素后编码不回退 - [ ] 说明 BitMap / HyperLogLog / GEO / Stream 各自适合什么场景,以及 HLL 的误差率和内存占用
过期、淘汰与内存
- [ ] 复述
activeExpireCycle的抽样算法(20 个、25% 阈值、慢快两种模式与时间上限) - [ ] 解释为什么 key 过期后
used_memory不会立刻下降,以及应该看哪个指标 - [ ] 背出 8 种淘汰策略,并说清
volatile-*的两个陷阱(无 TTL 退化、保护不了锁) - [ ] 说明
maxmemory为什么只能设到物理内存的 60%~70%,列出它不统计的四类内存 - [ ] 讲清近似 LRU 的采样池机制与 LFU 的 8 bit 对数计数器 + 衰减
- [ ] 说出
mem_fragmentation_ratio的三个区间含义与activedefrag的开启代价
持久化
- [ ] 用一张表对比 RDB 与 AOF 的触发方式、文件大小、恢复速度、丢数据窗口
- [ ] 解释 fork 与写时复制的关系,以及为什么必须关闭透明大页
- [ ] 说出
appendfsync三个取值的丢数据边界,并给出生产选择及理由 - [ ] 复述 AOF 重写的写放大问题与 7.0 Multi Part AOF 的改进点
- [ ] 说明恢复优先级(AOF 优先)与混合持久化的文件结构
- [ ] 在本地验证一次「kill -9 后重启」的数据恢复结果,记录实际丢了多少
缓存设计:三大问题与一致性
- [ ] 用一句话区分穿透、击穿、雪崩,并说出各自的流量特征
- [ ] 把雪崩拆成「宕机型」和「集体过期型」,说明 TTL 打散只对后者有效
- [ ] 对比缓存空值与布隆过滤器的优缺点,解释为什么两者要叠加使用
- [ ] 对比互斥锁重建、逻辑过期、永不过期三种防击穿方案的一致性与可用性取舍
- [ ] 说清 Cache Aside 四种写法为什么只推荐「先更库,再删缓存」,并画出唯一的不一致窗口
- [ ] 说明「读写分离 + 主从延迟」为什么比理论窗口危害大得多,以及两种解法
- [ ] 完成手撕题 5,并在本地验证「Redis 整个宕机时接口仍然可用」
分布式锁与原子性
- [ ] 复述分布式锁的五次进化,每一版新解决了什么、还剩什么问题
- [ ] 手写 Lua 释放锁脚本,并解释为什么「GET 判断 + DEL」两步一定会出事故
- [ ] 讲清 Redisson 看门狗的四个步骤,以及传了
leaseTime后行为的变化 - [ ] 复述 Martin 与 antirez 之争的核心分歧,并说明 fencing token 的作用
- [ ] 说出 Redlock 的加锁条件与工程上少用的三个原因
- [ ] 用一张表区分 pipeline / 事务 / Lua(目的、原子性、能否分支、是否回滚、集群限制)
- [ ] 说出
WATCH的三个坑,并解释为什么项目里几乎不用MULTI/EXEC - [ ] 完成手撕题 1~3,并在本地压测验证锁的可重入、续期与库存不超卖
高可用:主从、哨兵、Cluster
- [ ] 复述主从复制的三个阶段,以及触发全量同步的四种情况
- [ ] 解释
repl-backlog与 psync2 的作用,说明 4.0 后主从切换为何能免全量 - [ ] 说出主从异步复制的丢数据窗口与脑裂成因,以及两个配置项如何缓解
- [ ] 复述哨兵的主观下线、客观下线、leader 选举与 failover 四步,并说明哨兵为何要 ≥3 个且奇数
- [ ] 回答「为什么是 16384 个槽」的三条理由
- [ ] 区分
MOVED与ASK,说明客户端处理方式与是否刷新槽表的差异 - [ ] 列出 Cluster 的六条使用限制及绕过方式,并说明 hash tag 的粒度原则
- [ ] 说明过期 key 在主从与集群下的行为差异(从库不主动删、读到已过期 key 返回什么)
工程落地与排障
- [ ] 对比 Jedis / Lettuce / Redisson 的定位,说出各自适用场景
- [ ] 说明 Lettuce 池化配置失效的根因,以及
max-wait为什么必须设 - [ ] 列出
RedisTemplate的四个序列化坑及解法 - [ ] 背出大 key / 热 key 的五种发现手段与各自对线上的影响
- [ ] 复述 Redis 变慢的排查顺序(基线 RTT → slowlog / commandstats → 阻塞点 → 内存与持久化 → 系统与网络)
- [ ] 说出 Redis 的阻塞点全集,并指出哪些可以用 lazyfree 缓解
- [ ] 对比 Redis 做 MQ 的三种方案(List / Pub/Sub / Stream),说明为什么键空间通知做延时队列不可靠
- [ ] 实现四种限流算法中的任意两种,并说明滑动窗口与令牌桶的差异
- [ ] 按生产配置清单逐项检查一个本地实例,重点确认安全三件套与持久化配置