Skip to content

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二进制安全的字节数组,最大 512MBSET/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
Hashfield-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 scoreZADD/ZRANGE/ZRANGEBYSCORE/ZINCRBY/ZREVRANK/ZREMRANGEBYSCORE排行榜、延时队列、滑动窗口限流、带权重的分页score 是 double,超过 2^53 精度丢失(订单号/雪花 ID 不能直接当 score)

两条选型原则,面试里很值钱:

  1. 能用 Hash 别用多个 String:user:1:name、user:1:age 拆成 N 个 key,会丢掉 listpack 的内存压缩优势,内存可能差 5~10 倍;但如果单个字段需要独立过期,就只能拆。
  2. 能用 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基数估算,固定 12KBPFADD/PFCOUNT/PFMERGEUV 统计、去重计数标准误差 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 FilterRedisBloom 模块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
Stringint(long 范围整数)非整数—
Stringembstr(≤44 字节,对象头和 SDS 一次分配)> 44 字节raw(对象头与 SDS 分开分配)
Listlistpack元素 > 128 或单元素 > 64 字节quicklist(双向链表每个节点挂一个 listpack)
Hashlistpack字段 > 128 或字段/值 > 64 字节hashtable
Setintset(全是整数且 ≤ 512 个)出现非整数 / 超过 512listpack(7.2+ 小的非整数集合)→ hashtable
ZSetlistpack元素 > 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~5ms1 次 RTT,服务端仍是串行执行
MSET 一条命令≈ 1~3ms更省解析开销,但只能同类型操作
Lua 脚本≈ 1~3ms1 次 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 fsyncbio 线程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 永远不被访问 → 内存泄漏式占用
定期删除 activeserverCron 每秒 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

三个直接推论:

  1. 过期 key 的内存不是立刻释放的,used_memory 下降滞后很正常;INFO stats 里的 expired_keys 才是真实删除数。
  2. 大量 key 同时过期会导致 CPU 抖动:过期比例持续 > 25% 会让 activeExpireCycle 一直循环,表现为延迟毛刺——这是缓存雪崩之外,"过期时间要打散" 的第二个理由。
  3. 过期不会立刻从从库删除:从库不主动删过期 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 最短优先希望"快过期的先走"

三个必须知道的坑:

  1. volatile-* 的致命陷阱:如果没有任何 key 带 TTL,volatile-* 会退化成 noeviction,于是写入全部报错。混放场景必须保证缓存 key 一定有 TTL。
  2. maxmemory 要留出余量:这个值只统计数据内存,不含 replication backlog、客户端输出缓冲区、AOF 重写缓冲区、内存碎片。生产一般设为物理内存的 60%~70%(开 AOF/有大量从库时更低),否则 fork 时会 OOM 或触发 swap。
  3. 淘汰是在命令执行前同步做的: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.rdb

fork 的真实代价(面试深挖点):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/enabled

10. 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 左右)丢几十秒最好

写后日志的两个设计后果(追问高频):

  1. 好处:命令先执行再记录,所以不会把语法错误的命令写进 AOF,恢复时不用做语法校验;同时避免了 MySQL 那种"写前日志要先阻塞"的额外延迟。
  2. 坏处:命令执行完还没落盘就宕机 → 这条写丢了,而且客户端已经收到成功。这是 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 挂了锁服务还能用)。这五个要求正好对应五次进化。

版本写法新解决的问题仍然存在的问题
V1SETNX key 1互斥宕机死锁(没有过期时间)
V2SETNX + EXPIRE 两条命令想加过期两条命令非原子,中间宕机仍死锁
V3SET key uuid NX EX 30原子性 + 自动过期 + value 带身份释放时"判断 + 删除"非原子,可能误删别人的锁
V4V3 + Lua 脚本释放(比对 value 再 DEL)不误删业务超过 30 秒锁就提前失效,多个线程同时持锁
V5Redisson: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 的从库
1replica-priority 小者优先默认 100,设为 0 表示永不晋升(常用于跨机房的备份从库)
2复制偏移量 offset 大者优先数据最新,丢失最少
3runid 字典序小者优先纯粹为了有个确定性结果

哨兵自己怎么发现彼此和从库

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/EXECLua 脚本
主要目的减少 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 侧落地:三个客户端、序列化与连接池 ​

三个客户端怎么选

客户端底层线程安全特点适用
JedisBIO,每连接一线程❌ 实例不安全,必须配 JedisPoolAPI 贴近原生命令,直观老项目、同步简单场景
LettuceNetty NIO,单连接可多线程复用✅ 安全Spring Boot 2.x 默认,支持响应式、自动重连、集群拓扑刷新新项目首选
RedissonNetty✅ 安全提供分布式锁、信号量、限流器、延时队列等分布式对象,不以命令为中心需要分布式协调原语时

⚠️ 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-idlemin-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 后)
listpackHash、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 次数)

核心结论三条:

  1. B+ 树是为磁盘设计的——它的"多路"是为了压低树高、减少磁盘 IO;Redis 全在内存里,没有 IO 放大问题,多路带来的只有额外复杂度。
  2. 红黑树的范围查询不好写——ZSet 的 ZRANGEBYSCORE、ZRANGEBYRANK 是核心命令,跳表定位到起点后直接沿底层链表走即可;红黑树要维护中序遍历状态。
  3. 跳表实现简单得多——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几乎等于真实 LRUCPU 开销明显上升

面试加分表述:「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-factor10越大,计数器增长越慢,能区分的频率范围越大
lfu-decay-time1(分钟)越小,遗忘越快,越偏向"最近的热度"
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 报错
关闭 THPecho 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-percentage100比上次重写后的大小增长 100% 就触发;设 0 关闭自动重写
auto-aof-rewrite-min-size64mb小于这个大小不触发,避免小文件频繁重写
aof-rewrite-incremental-fsyncyes重写时每累积 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

类型警戒线危险线
Stringvalue > 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 代替 JSONString 型大 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 同时认为自己持有锁,都在写

关键结论:任何基于"时间租约"的锁都无法保证互斥,因为:

  1. 进程可能被任意长时间暂停——GC STW、虚拟机挂起、容器被 CFS 限流、磁盘 IO 卡住。
  2. 网络延迟没有上界——消息可能延迟任意长时间后才到达。
  3. 时钟不可信——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-lag
text
效果:
  网络分区后,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 大 keyUNLINK;开启 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 会清除 TTLSET 不带 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/SubStream(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 10
lua
-- 必须用 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 NotificationRedisson 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命令执行慢
forkfork 耗时长
expire-cycle过期扫描耗时长
eviction-del / eviction-cycle淘汰耗时长
aof-fsync-always / aof-writeAOF 刷盘慢
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.6Lua 脚本有了"原子 + 可编程"的能力,分布式锁、限流才成为可能
3.0Cluster 正式版、近似 LRU 淘汰池原生分片,告别 Twemproxy/Codis 时代
3.2GEO、从库正确处理过期 key修复了"从库返回过期旧值"的严重问题
4.0混合持久化、UNLINK/lazyfree、psync2、Module、activedefrag、LFU这个版本解决了大量生产痛点,是"能用"到"好用"的分水岭
5.0Stream、REPLICAOF 改名、ZSet 新命令(ZPOPMIN/BZPOPMIN)有了原生的消息流结构
6.0多线程 IO、ACL、RESP3 协议、客户端缓存(Client Side Caching)、SSL/TLS网络层和安全性的全面升级
6.2GETDEL、GETEX、COPY、SET KEEPTTL、BLMOVE、ZRANGESTORE大量命令语义补齐,少写很多 Lua
7.0Multi Part AOF、Function、Sharded Pub/Sub、maxmemory-clients、命令内省、ACL v2、listpack 全面替换 ziplist持久化和可编程性的大升级
7.2 / 7.4Hash 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_memory1否则 fork 在内存占用过半时直接失败
vm.swappiness1(或彻底关闭 swap)换出到磁盘会让延迟不可预测
透明大页 THPneverCOW 放大 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 tryLockB 在 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;但主从切换仍可能丢锁,需业务侧兜底

🚀 变种拓展题

  1. 把自旋等待改成 Pub/Sub 通知:释放锁时 PUBLISH 一条消息,等待者订阅后被唤醒,消除无效轮询(这正是 Redisson 的做法)。
  2. 实现公平锁:等待者先 ZADD 到一个等待队列(score = 入队时间),只有队首的线程才允许尝试加锁。
  3. 实现读写锁:Hash 里同时记录读锁计数和写锁持有者,写锁独占、读锁共享,并处理读锁升级为写锁的死锁问题。
  4. 实现 RedLock:向 5 个独立实例并发加锁,统计成功数与耗时,计算真实有效期,并在失败时向全部实例发送释放。
  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 - 1
java
@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期望行为
11 秒限 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,必须验证不会发生
7key 长时间不访问TTL 到期自动删除,不会堆积垃圾 key
8高 QPS(10 万)下的内存ZSet 会存满一个窗口的请求记录,1 秒 10 万就是 10 万个 member,内存和 ZCARD 开销都要评估
9Redis 不可用按配置策略"失败放行"或"失败拒绝",绝不能抛异常中断业务
10Cluster 下多个限流 key 同时校验不同 key 大概率不同槽,一个脚本里操作会报 CROSSSLOT,必须拆成多次调用或改用 hash tag

🚀 变种拓展题

  1. 改成分段滑动窗口:把窗口切成 N 个小格用 Hash 存计数,内存从 O(QPS×窗口) 降到 O(N),代价是精度损失。
  2. 实现令牌桶:用 Hash 存"当前令牌数 + 上次填充时间",按经过时间补充令牌,支持突发流量。
  3. 实现多级限流:一个脚本里同时校验"全局 QPS、单用户 QPS、单接口 QPS",全部通过才放行,任一失败要回滚已扣减的配额。
  4. 实现漏桶:请求入队后按固定速率流出,用 ZSet 存排队时间,返回给调用方"需要等待多少毫秒"。
  5. 实现预热限流(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 0
java
@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一人一单校验放在扣库存之后会出现"扣了库存又要加回去"的窗口,并发下库存数会短暂错误,必须验证顺序正确
6MQ 发送失败Redis 库存和用户集合都被回滚,用户可以重新抢购
7MQ 消息重复消费订单表唯一索引拦住,消费端捕获 DuplicateKeyException 后直接 ACK
8Lua 执行成功但主库立刻宕机扣减可能丢失(异步复制),导致少卖;或切主后库存回到旧值导致超卖。DB 唯一索引 + 条件更新兜底
9Cluster 环境两个 key 用了相同的 hash tag {activityId},同槽,不报 CROSSSLOT
10活动结束后的清理用户集合可能有百万元素,必须用 UNLINK 而不是 DEL 删除

🚀 变种拓展题

  1. 支持限购 N 件:把 Set 换成 Hash,field 是用户 ID、value 是已购数量,脚本里校验 已购 + 本次 <= N。
  2. 库存分段扣减:把 1000 库存拆成 10 个 key 各 100,请求随机落到一个分段,分段卖完后尝试其他分段,解决单 key 热点。
  3. 加入令牌预发:活动开始前给用户发令牌,扣减时校验令牌,把无效流量挡在 Redis 之前。
  4. 实现库存回补:订单超时未支付时把库存加回 Redis,并处理"回补后库存超过初始值"的异常情况。
  5. 改造成支持预占 + 确认两阶段:先 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期望行为
1add("a") 后 mightContain("a")必须返回 true(布隆过滤器不允许假阴性)
2未添加过的 "zzz"绝大概率返回 false;返回 true 属于正常的假阳性,靠缓存空值兜底
3实际插入量超过预估 n 的 10 倍误判率急剧恶化(可能接近 100%),必须监控实际插入量并预留余量
4哈希值为负数& Long.MAX_VALUE 去掉符号位。不能用 Math.abs(),因为 Math.abs(Long.MIN_VALUE) 仍是负数
5bitSize 超过 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),不能边删边建,否则重建期间假阴性
10Cluster 下使用单 key 不跨槽;但 mightContain 的 Lua 脚本和 pipeline 都只操作一个 key,安全

🚀 变种拓展题

  1. 实现本地 + Redis 两级布隆:本地 Guava BloomFilter 挡住绝大部分,未命中再查 Redis,减少网络往返。
  2. 实现计数布隆(Counting Bloom Filter):每位改成 4 bit 计数器,支持删除,用 BITFIELD 操作。
  3. 实现可扩展布隆(Scalable Bloom Filter):当插入量接近上限时自动新建一层更大的过滤器,查询时逐层检查。
  4. 用 RedisBloom 模块(BF.ADD / BF.EXISTS / BF.MADD)替换手写实现,对比内存和性能差异。
  5. 实现布隆过滤器的热更新:用双 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 没有预热首次访问会同步回源(有击穿风险)。所以逻辑过期方案必须配合预热使用
8Redis 整个宕机safeGet 返回 null → 走回源 → safeSet 静默失败。接口变慢但功能正常,这是验收标准
9同一批 1 万个 key 用相同 TTL 写入withJitter 让实际 TTL 散布在 0.8~1.2 倍区间,避免雪崩
10逻辑过期对象的物理 TTL必须设置(这里是逻辑 TTL 的 10 倍),否则长期不访问的 key 永久驻留内存

🚀 变种拓展题

  1. 把"抢锁 + 异步重建"换成 CompletableFuture + 本地 ConcurrentHashMap 去重,同一 JVM 内的并发请求共享一次回源(单机防击穿,减少分布式锁开销)。
  2. 增加本地缓存层:Caffeine 缓存 2 秒 + Redis + DB 三级,并通过 Redis Pub/Sub 广播失效消息清理各实例的本地缓存。
  3. 实现基于注解的声明式缓存:@SmartCache(prefix="product:", ttl=30, strategy=LOGICAL_EXPIRE),用 AOP 拦截。
  4. 加入回源信号量隔离:缓存不可用时限制回源到数据库的并发数,超出的请求直接返回兜底对象。
  5. 把删除缓存改造成延迟双删 + 失败重试:删除失败时投递到 MQ,消费端重试,重试 N 次后告警。

⚠️ 当日踩坑实录 ​

❌ 坑✅ 解决方案
线上用 KEYS * 找 keyKEYS 会遍历全库并阻塞主线程,百万级 key 就是秒级卡顿;必须用 SCAN 游标分批取
以为 SCAN 的结果可以直接用SCAN 只保证「全程存在的 key 一定被返回」,rehash 期间可能重复返回同一个 key,业务侧必须自己去重
以为编码转换是双向的listpack/intset 一旦因为超阈值转成 hashtable/skiplist,即使元素再删回去也不会转回来,内存不会自动降下来
以为 Hash 一定比多个 String 省内存只有 field 数和 value 长度都在 hash-max-listpack-entries/value 阈值内才是 listpack;超阈值转 hashtable 后反而更费
用 SETBIT 写一个很大的 offsetBitMap 按 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),说明为什么键空间通知做延时队列不可靠
  • [ ] 实现四种限流算法中的任意两种,并说明滑动窗口与令牌桶的差异
  • [ ] 按生产配置清单逐项检查一个本地实例,重点确认安全三件套与持久化配置