Java 后端八股精讲一问一答,背得下也答得出 速查 追问链路 ⌂ 全部教程

主题 13Redis 原理与缓存

Redis 是八股里最容易背成口号的一章:「单线程所以快」「三种缓存问题」 「八种淘汰策略」——每句都能背,每句都说不出证据。这一章用一台真实的 Redis 8.10.3 跑 13 组实验,把每句口号换成一个数字。

特别是缓存那部分:穿透、击穿、雪崩三个词在简历上长得一模一样, 但它们的解法完全不同。这一章用 200 个并发线程把「回源了几次」数出来—— 200 次和 1 次之间隔着的就是你和别人的差距。

学习目标
能把「为什么快」「为什么脏」两个问题各讲 3 分钟不卡
前置要求
会装 Redis、会敲 redis-cli;读过第 12 章(事务与 MVCC)更好
本章产出
13 组实测 + 6 张对照表 + 5 张速记卡
建议学时
4 小时(缓存三问务必自己数一遍回源次数)

13.1这道题为什么必考

面试官问 Redis,不是想听「内存数据库、支持持久化、五种数据类型」。他想知道的是: 你有没有在真机上被它坑过。坑过的人,说话会带数字。

你听到的问题它其实在问说错的代价
「Redis 为什么快」单线程 + 内存 + IO 多路复用,以及单线程的代价只会说「因为它是内存的」→ 追问「那 Memcached 也是内存」
「五种数据类型底层」SDS / listpack / 跳表 / intset 的切换阈值背得出「ziplist」,说不出 Redis 7 之后已经换成 listpack
「缓存穿透怎么解决」空值缓存 vs 布隆过滤器,各自挡得住什么两个词都会说,分不清哪个挡「串行刷」哪个挡「并发打」
「缓存击穿呢」互斥锁 vs 逻辑过期,延迟差多少答「加锁」,说不出一加锁 p99 反而变差
「怎么保证缓存一致」先删还是后删,脏数据窗口有多大答「先更新库再删缓存就没问题」→ 实测也会脏
「分布式锁怎么实现」SET NX PX + Lua 校验 + 续期只会 setnx + expire 两条命令
实验环境:Redis 8.10.3(Windows 免安装版,跑在 6380 端口, 不动本机原有的 6379)+ 客户端 redis-py 8.1.0 + JDK 17 无关。源码单文件 redis_lab.py,python redis_lab.py enc 只跑一组。
之所以强调端口:本机 6379 上还躺着一个 2.8.19(2015 年的微软移植版), 用它做实验会得到一堆过时结论。版本一定要先 INFO server 看清。
先说三个和教科书不一样的地方(本章会逐个给证据):
① Redis 8 里 hash-max-listpack-entries 默认是 512,不是常说的 128;
② 小 list 的编码是 listpack,不是 quicklist;
③ embstr 的上限和 key 的长度有关,key 越长能内嵌的 value 越短。

13.2单线程为什么还能跑 6 万 QPS

「单线程」这三个字最容易被误解成「只能一件一件干,所以慢」。它的真实含义是: 命令的执行是串行的,但连接的收发不是。验证方法很土但很硬—— 让一条命令睡 2 秒,看另一个连接还能不能动:

=== 单线程的证据 ===
连接 A 执行 DEBUG SLEEP 2(2028 ms 返回)
连接 B 在此期间 PING 花了 1978 ms  <-- 被堵住了

两个连接、两条完全无关的命令,B 却整整等了 1978 毫秒。这就是单线程: 执行命令的只有一个线程,A 在睡,谁都别想动。

那为什么它快?因为「快」不是靠并行算出来的,是靠把等待全省掉:

它省掉的东西怎么省的代价是什么
线程切换只有一个执行线程,没有上下文切换一条慢命令拖死所有人
锁竞争不用给数据结构加锁无法利用多核(多核靠开多个实例)
网络等待epoll 多路复用,一个线程管上万个连接大 value 传输会占满这条线
磁盘等待持久化走 fork 子进程,主线程不碰盘fork 本身会卡一下(见 13.10)

「一条慢命令拖死所有人」不是理论。下面这条 KEYS 在 20 万个 key 上跑了一次:

已写入 200000 个 key
KEYS u:*  返回 200000 个,耗时 441 ms
SCAN  count=1000  扫回 200000 个,200 批,耗时 654 ms(每批最多堵 3.3 ms)

总耗时 SCAN 反而更长(654ms > 441ms),但它把 441 毫秒的一次长阻塞 切成了 200 次 3.3 毫秒的短阻塞。对线上服务来说,前者是不可接受的尖刺, 后者用户根本感觉不到。这就是为什么生产环境禁 KEYS 而不禁 SCAN。

再来看真实吞吐(redis-benchmark -n 20000 -c 50,本机 Windows 单实例):

场景QPSp50 延迟说明
SET(纯内存)609750.543 ms单条命令一次往返
GET(纯内存)645160.527 ms读比写略快
SET + pipeline 162739722.471 msQPS 涨 4.5 倍,但单条延迟变大
pipeline 的 4.5 倍是怎么来的:不是 Redis 变快了,是网络往返变少了。 16 条命令攒一批发过去,只花 1 次 RTT 而不是 16 次。这也解释了为什么 p50 会从 0.54ms 涨到 2.47ms—— 你测的已经不是单条命令的耗时,而是「一批命令排到队尾」的耗时。 批量导入、缓存预热必须用 pipeline,这是唯一能上 10 万 QPS 的客户端手段。

13.3五种类型,其实只有几种编码

「五种数据类型」是对外的说法。对内,Redis 只认几种数据结构, 类型到结构之间是可以切换的——这才是面试真正想听的。

先看最简单的 string。同一个 SET,存进去的东西不一样,底层编码完全不同:

=== string 的三种编码 ===
set s:int 12345        int        encoding:int serializedlength:3
set s:emb hello        embstr     encoding:embstr serializedlength:6
set s:float 3.14       embstr
append 前 int -> append 后 raw,值=123456

三种编码的分工:

编码什么时候用内存布局
int值能解析成 64 位整数直接把数字存进 robj.ptr 指针位,不额外分配内存
embstr短字符串(具体多短见 13.4)对象头和 SDS 在一次 malloc 里,内存连续
raw长字符串,或 APPEND 过的整数对象头和 SDS 分两次 malloc

APPEND 那行值得多看一眼:12345 本来是 int, 追加一个字符之后就变成 raw 了。因为它不再是整数了,只能退回字符串。 反过来也成立,实测 INC 之后 raw 会变回 int:

set n2 100; append n2 0   -> raw  值 1000
incr n2                   -> int  值 1001

其余四种类型的编码,一次跑完:

=== list ===              (配置 list-max-listpack-size = -2,即每节点 8KB)
rpush 128 个       listpack     mem=927
rpush 500 个       listpack     mem=3531
rpush 5000 个      quicklist    ql_nodes:5  mem=35308

=== hash ===              (hash-max-listpack-entries = 512)
hset 512 个字段     listpack
hset 513 个字段     hashtable
value 64 字节 -> listpack ;再来 65 字节 -> hashtable

=== set ===
100 个整数          intset
513 个整数          hashtable
3 个字符串          listpack
1 个字符串+128 个整数  listpack
1 个字符串+129 个整数  hashtable

=== zset ===             (zset-max-listpack-entries = 128)
zadd 128 个        listpack    mem=1183
zadd 129 个        skiplist    mem=10607

整理成一张对照表:

类型小数据编码大数据编码切换条件关键配置
stringint / embstrraw看长度无
listlistpackquicklist单节点超 8KBlist-max-listpack-size
hashlistpackhashtable字段数 > 512 或字段值 > 64 字节hash-max-listpack-entries
setintset / listpackhashtable全整数超 512,或非整数超 128set-max-intset-entries
zsetlistpackskiplist成员数 > 128 或成员 > 64 字节zset-max-listpack-entries
zset 那行的 mem=1183 → mem=10607 值得停下来看: 只多了一个元素,内存却涨了 9 倍。因为 skiplist 得先建 32 层的头结点、还要配一个 dict 做「成员→分值」的映射。 小 zset 一旦踩过阈值,代价是断崖式的——排行榜类需求务必估算成员数, 或者主动拆 key(按天、按小时分桶)。

13.4编码转换的几个临界点(含三个反教科书结论)

上一节的阈值不能只记结论,得知道卡在哪一字节。逐个逼近边界:

① hash:512 与 513 之间

511 个字段 -> listpack
512 个字段 -> listpack
513 个字段 -> hashtable

教科书常说 128,Redis 8 的默认值是 512。执行 CONFIG GET hash-max-listpack-entries 自己看一眼,别背。

② list:不是按元素个数,是按字节

默认 list-max-listpack-size = -2,负数的含义是「按大小算」,-2 对应 8KB。 用每个 5 字节的元素去逼近:

1637 个 = 8185 字节 -> listpack
1638 个 = 8190 字节 -> quicklist
1639 个 = 8195 字节 -> quicklist

边界卡在 8185 和 8190 之间——正好是 8192 减掉 listpack 头部那几字节。 所以「list 超过 128 个元素就转 quicklist」这个说法在默认配置下是错的: 你塞 1500 个短元素,它依然是一个 listpack。

③ string:embstr 的上限和 key 的长度有关

教科书的标准答案是「44 字节以内用 embstr」。实测 Redis 8.10.3 上,41 字节就变 raw 了。 再换不同的 key 长度扫一遍,规律出来了:

key 长度 -> value 能内嵌的最大长度
  key=  1 字节 -> value 最多 40 字节
  key=  5 字节 -> value 最多 36 字节
  key= 10 字节 -> value 最多 31 字节
  key= 20 字节 -> value 最多 21 字节
  key= 30 字节 -> value 最多 11 字节
  key= 40 字节 -> value 最多  1 字节
  key= 60 字节 -> value 最多  0 字节

key 长度 + value 长度 ≈ 41,这是一条很整齐的直线。再看内存占用就更明白了:

value=40 embstr mem=64
value=41 raw    mem=65
value=44 raw    mem=68

64 这个数字是关键:jemalloc 的 64 字节档。Redis 8 把 key 也内嵌进了同一个对象 (源码里叫 kvobj,比 robj 多存了 kvBits 和 key 的 SDS), 所以「对象头 + key + value」三者加起来必须塞进 64 字节的 arena—— key 多占一个字节,value 就少一个字节。

面试怎么答:先答标准答案「44 以内 embstr,超过就 raw,因为要保证对象能装进 64 字节的 jemalloc 块」,然后补一句「这是 Redis 7 及以前的数字;Redis 8 把 key 内嵌进对象之后, embstr 的上限变成 key+value 合计约 41 字节,key 越长 value 越短」。 能说出「为什么是 64」比记住 44 重要得多。

④ 转换是单向的

hash 从 513 个字段转成 hashtable 之后,把字段删回 1 个:

字段 513 个时 hashtable;删到 1 个字段后 hashtable

不会退回 listpack。因为转换是有成本的,Redis 不想在边界上反复横跳。 这也意味着一个「曾经很大」的 key,即使后来被清空,内存也不会自己还给你—— 大 key 清理后要 DEL 重建,不能指望它瘦回去。


13.5过期了,为什么不删

「给 key 设了 TTL,到点就没了」——这句话对,但只对了一半。到点只是「逻辑上过期」, 物理内存还在。用一个 20 万 key 的实验看清楚:先关掉定期删除,只留惰性删除。

=== 过期删除 ===
已关闭定期删除(DEBUG SET-ACTIVE-EXPIRE 0),只剩惰性删除
写入 200000 个带 10 秒 TTL 的 key:
  dbsize=200000  used_memory=17.6 MB  expired_keys=0
等待 12 秒...
12 秒后(全部已过期,但没人访问):
  dbsize=200000  used_memory=16.5 MB  expired_keys=0
  随机抽 3 个 key 看 TTL: [-2, -2, -2]
  EXISTS e:5 连问 3 次: [0, 0, 0]
  之后 expired_keys=4  dbsize=199996

两个关键事实:

而且注意 expired_keys 从 0 跳到 4,dbsize 掉了 4 个—— 可我只显式访问了 e:5 一个 key。答案是:TTL 命令本身也会触发惰性删除, 我抽了 3 个 key 看 TTL,加上 EXISTS,一共 4 次。

把 1000 个过期 key 逐个 GET:

逐个 GET 1000 个 key(惰性删除,每个 key 被访问时才真正回收):
  耗时 476.8 ms,expired_keys=1002  dbsize=198998

平均每个 0.48 ms——比正常 GET(0.5 ms 含网络)差不多,因为删除本身很快。 但如果这 1000 个 key 是 1000 个用户同时访问,那就变成了 1000 次「访问时才发现要删」的抖动。

打开定期删除,看它多久收拾残局:

重新打开定期删除,观察它多久清完:
  4.1 秒后 dbsize=0  expired_keys=200000  used_memory=0.8 MB
active-expire-effort = 1

4.1 秒清完 19.9 万个。定期删除不是「每秒扫全库」,而是随机抽样: 每次抽 20 个,如果过期比例超过 25% 就再来一轮。active-expire-effort(1–10) 控制每秒的工作量,调大清理更积极、但更吃 CPU。

顺带解释一个经典现象:为什么「明明设置了 TTL,内存却没降」? 因为过期删除和内存淘汰是两套机制。过期靠「惰性 + 定期」, 内存不够靠 maxmemory-policy。前者是「到点清」,后者是「装不下就踢人」。

装不下的时候:八种淘汰策略

=== 淘汰策略 ===
空库 used_memory = 1.12 MB
设置 maxmemory=20mb, policy=noeviction
写了 72313 个 200 字节的 key 后:OutOfMemoryError
  报错原文: command not allowed when used memory > 'maxmemory'.
  used_memory=20.0 MB  evicted_keys=0
切成 allkeys-lru,继续写 5 万个...
  dbsize=73545  evicted_keys=48867  used_memory=20.0 MB

同一个 20MB 上限,两种策略的差别是报错还是活下来:

策略从哪一批里挑挑谁适用
noeviction不淘汰写命令直接报错当数据库用(不能丢数据)
allkeys-lru所有 key最久没被访问纯缓存首选
allkeys-lfu所有 key访问频率最低热点集中、有长尾
allkeys-random所有 key随机几乎不用
volatile-lru只从设了 TTL 的里挑最久没被访问缓存和持久数据混部
volatile-lfu只从设了 TTL 的里挑频率最低同上
volatile-random只从设了 TTL 的里挑随机同上
volatile-ttl只从设了 TTL 的里挑剩余寿命最短同上
关于 LRU 的一个真相:Redis 的 LRU 不是真的链表, 是每个对象里 24 bit 的时间戳 + 随机采样 5 个挑最旧的。 所以它叫「近似 LRU」——省了内存,代价是有时会踢掉刚访问过的 key。 采样数由 maxmemory-samples 控制(默认 5,调到 10 更准但更慢)。
这也是为什么 volatile-* 系列有个坑:如果一个 key 都没设 TTL,它们会像 noeviction 一样直接报错。

13.6穿透:数据库被打穿的那一层

先把三个词分清楚,这是最容易被问倒的地方:

现象本质数据在不在库里关键词
穿透查一个根本不存在的数据不在缓存永远不命中
击穿一个热点 key 突然失效在,只是暂时没缓存单个 key,高并发
雪崩大批 key 同时失效或 Redis 挂了在,只是全都没缓存大面积,同一时刻

穿透最典型的场景:接口按 id 查用户,攻击者拿 id=999999 一直刷。 每次缓存都不命中,每次都打到库。实测(数据库每次 20ms):

=== 缓存穿透:查一个不存在的 id ===
--- A. 串行 200 次(模拟爬虫一个个刷)---
  不缓存空值:回源 200 次,耗时  4171 ms
  缓存空值  :回源   1 次,耗时    46 ms  占位值='__NULL__' TTL=60s
--- B. 并发 200 个同时打(空值缓存挡不住)---
  缓存空值 + 200 并发:回源 200 次  <-- 同一瞬间都 miss,占位值还没写进去

这两行是全章最重要的对照组。「缓存空值」把 200 次打到 1 次, 但它挡不住「同一瞬间的 200 个请求」——因为在第一个请求把 __NULL__ 写进去之前, 其余 199 个都已经判定「缓存没有」了。

要挡并发,得在缓存前面再加一层,让「不存在」这件事不用查库就能判断。 布隆过滤器就是干这个的(这里用 bitmap 手写,生产用 RedisBloom 模块或 Guava):

=== 布隆过滤器(bitmap 手写:20000 位,3 个哈希)===
放入 1000 个真实 id:置位 2768 / 20000 个 bit(13.8%)
拿 10000 个不存在的 id 去查:误判 29 个,误判率 0.29%
  也就是说 10000 次攻击只有 29 次会打到数据库,其余 99%+ 在 Redis 这层被挡掉

这个 0.29% 不是撞运气,可以用公式算出来:

p ≈ (1 - e^(-k·n/m))^k
  = (1 - e^(-3×1000/20000))^3
  = (1 - 0.8607)^3
  = 0.0027  = 0.27%

实测 0.29%,理论 0.27%,差的那点来自哈希分布不均。面试时把 m、n、k 三个字母和这个公式写出来, 比说「布隆过滤器能防止穿透」强十倍。

方案挡得住挡不住代价
缓存空值串行重复刷(200→1)并发同时打(实测仍 200)占内存;TTL 要短(建议 ≤60s)
布隆过滤器并发也能挡(0.29% 误判)删除后仍判「存在」(不能删元素)有误判率;需要预热
参数校验非法 id(负数、超长)合法格式但不存在的 id无,第一道就该有
限流高频攻击低频慢刷可能误伤正常用户
布隆过滤器的致命细节:它只增不减。用户注销、商品下架之后, 布隆里对应的位还在,那个 id 依然被判「可能存在」——也就是退化成穿透。 生产做法是定期用全量数据重建一个新的 bitmap,然后原子 RENAME 替换。

13.7击穿:一个热点 key 过期,200 个请求一起回源

穿透是「数据不存在」,击穿是「数据存在,但那一瞬间缓存没了」。差别在于回源的那一下非常贵—— 比如一个 100ms 的复杂查询。200 个并发同时来,三种写法各跑一遍,看回源次数和延迟分布:

=== 缓存击穿:热点 key 刚过期,200 个并发同时来(DB 每次 100ms)===
A 无保护        回源 200 次 | p50 101.2 ms | p99  109.0 ms | 最慢  122.4 ms
B 互斥锁        回源   1 次 | p50  18.3 ms | p99  145.6 ms | 最慢  182.8 ms
C 逻辑过期      回源   1 次 | p50   7.1 ms | p99   16.5 ms | 最慢   19.0 ms

这三行值得逐列读:

写法上的差别只有一句:B 把「等」放在请求线程里,C 把「等」放到了后台线程里。

C 逻辑过期的关键三步:
1) 不设 TTL,把过期时间写进 value:{"data": ..., "exp": 1762...}
2) 读到的 exp 已过期 -> 尝试拿 rebuild 锁
3) 拿到锁的线程【另起一个线程】重建,自己立刻返回旧值
方案回源次数p99数据新鲜度适合
无保护200109 ms实时只有 QPS 极低的场景才敢用
互斥锁1145.6 ms实时(拿到的是新值)对一致性要求高、能接受排队
逻辑过期116.5 ms短暂旧值(重建那 100ms 内)商品详情、首页推荐这类「旧一点没关系」
互斥锁那段的两个细节,写错就白写了:
① 双检:抢到锁之后必须再查一次缓存,因为可能在你拿锁的这一瞬间别人已经写好了;
② 锁要带 value 并用 Lua 释放:直接 DEL 会删掉别人的锁(13.9 有实测)。

13.8雪崩与一致性:脏数据是怎么产生的

雪崩(大批 key 同时失效 / Redis 挂了)的策略很清楚:TTL 加随机抖动、 多级缓存、限流降级、集群高可用。这些说出来就行,不好实测。

但「缓存和数据库谁先动」这个问题,是可以一行一行跑出来的。三组实验, 唯一的变量是「第二次删除发生在什么时候」:

=== 缓存一致性:先动缓存还是先动库 ===
--- 1. 先 DEL 缓存,再 UPDATE 库 ---
  写完并回写后           库=v2  缓存=v1  脏
--- 2. 先 UPDATE 库,再 DEL 缓存 ---
  写完并回写后           库=v2  缓存=v1  脏
--- 3. 延迟双删:DEL -> UPDATE -> 睡 300ms -> 再 DEL ---
  回写之后             库=v2  缓存=v1  脏
  第二次删之后           库=v2  缓存=None  干净

三个场景的时间线是这样的(读请求查库要 200ms,写请求 30ms 后就动手):

t=0     读请求:GET 未命中 -> 发 SQL,拿到快照 v1(还在路上)
t=30    写请求:改库 -> v2,然后删缓存(此时缓存本来就是空的)
t=200   读请求:把手里那张 200ms 前的快照 v1 写进缓存
        -> 缓存 v1,库 v2,脏

场景 2 是重点:很多人以为「先更新库、再删缓存」就万无一失, 但上面这条时间线里,删除发生在回写之前,照样脏。这不是理论推演, 是把 sleep 的位置摆好之后必然复现的。

场景 3 的解决办法是再删一次,且这次要晚于读请求的回写:

t=200   读请求回写完(缓存 v1)
t=330   第二次 DEL -> 把 v1 擦掉
        -> 下次读重新查库,拿到 v2,干净
方案脏窗口实测结果代价
先删缓存,再写库整个 TTL脏(v1 vs v2)还要多一次删除
先写库,再删缓存很小但非零也会脏(构造出交错时)业界标准做法
延迟双删≈ 第二次删除的延迟干净多一次删除 + 要估「读请求最长耗时」
订阅 binlog(Canal)接近 0—多一个组件,运维成本高
延迟双删的「延迟」怎么定:必须大于「一次读请求从查库到回写」的最长耗时。 定小了没效果(第二次删还是早于回写),定大了脏窗口变长。 工程上通常取读接口 p99 的 2 倍,或者干脆不睡、直接删两次 (第二次放在业务事务提交之后,利用提交本身的时间差)。
另外:先删缓存 + 延迟双删比先写库 + 延迟双删脏窗口更大, 因为前者在「删完到写库完成」之间,所有读请求都会把旧值读回来。

13.9分布式锁的四个坑

上面击穿那一节的互斥锁,其实就是一把分布式锁。它的四个坑,一次跑完:

=== 分布式锁 ===
1) SET NX PX 的原子性
   A: SET lock uuidA NX PX 3000 -> True
   B: SET lock uuidB NX PX 3000 -> None
   B 拿不到锁,值仍是 uuidA
2) 不会校验 value 就 DEL,会删掉别人的锁
   A 的锁 TTL = 2974 ms,等它自己过期...
   B 此时 SET NX 抢到锁 -> True value = uuidB
   A 的业务跑完了,直接 DEL lock(不校验)-> 1
   结果 B 的锁被删了,lock 还存在吗? 0
3) Lua 校验后删除
   A 用 Lua 删(value 不匹配)-> 0 (0 = 没删)
   B 用 Lua 删(value 匹配)  -> 1 (1 = 删掉了)
4) 锁过期但业务没跑完 -> 两个客户端同时持锁
   A 拿到锁(500ms 过期),业务要跑 1200ms
   600ms 时 B 尝试加锁 -> True
   A 还在跑,但锁已经是 B 的了

四个坑对应四行代码:

#坑实测现象正确写法
1SETNX + EXPIRE 两条命令中间宕机 → 锁永不过期SET k v NX PX 3000 一条命令
2业务跑完直接 DEL删掉了别人的锁(B 的锁消失)value 存唯一 token,删前校验
3「先 GET 判断再 DEL」判断和删除之间锁会过期Lua 脚本原子执行
4锁过期了业务还没跑完600ms 时 B 拿到锁 → 两个客户端同时持锁看门狗续期(Redisson 的 RLock)

第 3 条的正确写法(实测有效):

if redis.call('get', KEYS[1]) == ARGV[1] then
  return redis.call('del', KEYS[1])
else
  return 0
end
看门狗解决的是第 4 个坑,但它有自己的问题:Redisson 默认每 10 秒 (锁 TTL 的三分之一)续一次期,只要客户端进程活着,锁就一直续下去。 如果客户端假死(GC 停顿、网络分区),续期线程还在续,别的客户端永远拿不到锁。 这就是为什么还需要 lockWatchdogTimeout 上限 + 业务侧的幂等设计。
还有第五个坑(本章没实测但必须知道):主从架构下,A 在 master 上加锁成功, master 还没把这条命令同步给 slave 就挂了,slave 升主 —— B 在新 master 上又能加锁成功。 两个客户端同时持锁。Redis 官方的解法是 RedLock(多数派加锁), 但它要求至少 5 个独立的 master,运维成本很高,业界争议也大。 面试被问到「RedLock 可靠吗」,答「有争议,而且大多数业务根本用不到那个级别的一致性」是安全的。

13.10持久化:RDB 与 AOF 的真实代价

RDB 是快照,AOF 是日志——这句谁都会说。真正有信息量的是代价:

=== 持久化 ===
启动参数: --save "" --appendonly no(两者都关,纯内存)
写入 200000 个 key(约 22.9 MB)
BGSAVE 完成:493 ms,dump.rdb = 4.09 MB,status=ok
运行时 CONFIG SET appendonly yes -> aof_enabled=1
  appendfsync=always    3000 次 SET 耗时   1972 ms  (1521 次/秒)
  appendfsync=everysec  3000 次 SET 耗时    418 ms  (7172 次/秒)
  appendfsync=no        3000 次 SET 耗时    443 ms  (6777 次/秒)

换 redis-benchmark 压出来(-n 20000 -c 50)更直观:

配置QPSp50含义
纯内存(无持久化)609750.543 ms基线
AOF always251881.671 ms每条命令都 fsync,掉到 41%
AOF everysec550960.631 ms每秒 fsync 一次,几乎无损
AOF no568180.631 ms交给操作系统,可能丢 30 秒

两个值得记住的数字:

RDB 那边:22.9 MB 数据 → 4.09 MB 文件,压缩比 5.6 倍,耗时 493 ms。 但真正危险的时刻不是这 493ms,而是 fork 的那一下:

方案恢复速度丢数据代价
RDB快(直接载入内存)丢最后一次快照之后的全部fork 时内存翻倍(COW)
AOF always慢(重放命令)几乎不丢QPS 掉到 41%
AOF everysec慢最多 1 秒几乎无
混合(7.0 默认)快最多 1 秒—
「fork 会卡住主线程」到底卡多久:fork 本身只复制页表,不复制数据, 但页表越大越慢。经验值是每 GB 内存约 10–20 ms。 一个 32GB 的实例,fork 一下主线程要停 300–600ms——这一下足以让上游超时。 所以大实例要关掉自动 RDB(save ""),改在低峰手动触发或走从库持久化。

13.11速记卡与自查

把这一章压成五张卡,面试前扫一遍就行。

#卡内容(带数字)
1单线程为什么快 省掉线程切换、锁竞争;epoll 管连接。代价实测:DEBUG SLEEP 2 期间另一个连接 PING 等了 1978ms。所以禁 KEYS(441ms 一次堵死)用 SCAN(每批 3.3ms)。
2编码与阈值 hash 512(不是 128)、set intset 512 / listpack 128、zset 128、list 单节点 8185 字节。转换单向不可逆。
3过期 ≠ 删除 20 万 key 过期 12 秒后 DBSIZE 仍 200000、内存只掉 1.1MB。惰性(访问时删,TTL 命令也会触发)+ 定期(抽样,实测 4.1 秒清完 19.9 万)。
4缓存三问 穿透 → 空值缓存(串行 200→1,并发仍 200)+ 布隆(误判 0.29%);击穿 → 互斥锁(1 次,p99 145ms)vs 逻辑过期(1 次,p99 16.5ms);雪崩 → TTL 打散。
5一致性与锁 先删/后删都会脏(实测缓存 v1 库 v2);延迟双删干净。SET k v NX PX + Lua 校验 + 看门狗;主从下仍需 RedLock。
如果只记一句话:Redis 的所有「坑」都来自同一个前提—— 它把一切都放在一个线程、一块内存里。单线程所以一条慢命令会拖死全部; 内存所以过期不等于删除;数据结构为了省内存才在阈值处单向切换; 缓存为了快才允许短暂不一致。抓住这条主线,剩下的都是推论。

动手清单与自查表

#动手项可观察的判据(做到了会看到什么)
1 开两个 redis-cli,一个跑 DEBUG SLEEP 2,另一个同时 PING 第二个 PING 卡近 2 秒才返回——单线程不再是概念
2 用 SET k v 造 40 / 41 字节的值,各查一次 OBJECT ENCODING embstr → raw,MEMORY USAGE 从 64 跳到 65
3 把 key 拉长到 30 字节,再找 value 的 embstr 上限 上限从 40 掉到 11——key+value ≈ 41
4 hash 塞 512 和 513 个字段,各查编码 listpack → hashtable,再删回 1 个字段也不会退回去
5 造 10 万个带 TTL 的 key,DEBUG SET-ACTIVE-EXPIRE 0 后等过期 DBSIZE 不变、TTL 返回 -2——过期和删除是两件事
6 跑 breakdown,把 C 的逻辑过期改成「同步重建」 回源仍是 1 次,但 p99 从 16.5ms 涨回 100ms+——异步才是关键
7 把 13.8 场景 3 的 sleep(0.3) 改成 sleep(0.05) 第二次删早于回写 → 缓存又变回 v1(脏)——延迟必须够长
8 把 AOF 的 appendfsync 在 always / everysec 之间切换压测 QPS 25188 → 55096,p50 1.671 → 0.631 ms
反例实验(做了会记一辈子):把 13.9 第 2 步的 DELETE 换成「先 GET 判断 value 是不是自己的,再 DEL」。看起来已经校验了,但在 GET 和 DEL 之间让锁过期 (把业务耗时调到大于 TTL),你依然会删掉别人的锁。 「检查 + 修改」只要不是原子的,就一定有缝——这就是 Lua 存在的意义, 也是第 12 章「当前读」那些锁存在的意义。
卡住了怎么办去哪查
不知道某个 key 用的什么编码OBJECT ENCODING k;节点数用 DEBUG OBJECT k
不知道阈值被谁改过CONFIG GET *listpack* 一把捞出来,别背默认值
想确认过期到底删没删INFO stats 看 expired_keys;INFO memory 看 used_memory
想抓慢命令SLOWLOG GET 10(默认只记 >10ms 的);LATENCY DOCTOR
想找大 keyredis-cli --bigkeys;再对可疑 key 用 MEMORY USAGE
想验证锁被谁拿着GET 那个 key 的 value(token)+ PTTL 剩余时间