主题 13Redis 原理与缓存
Redis 是八股里最容易背成口号的一章:「单线程所以快」「三种缓存问题」 「八种淘汰策略」——每句都能背,每句都说不出证据。这一章用一台真实的 Redis 8.10.3 跑 13 组实验,把每句口号换成一个数字。
特别是缓存那部分:穿透、击穿、雪崩三个词在简历上长得一模一样,
但它们的解法完全不同。这一章用 200 个并发线程把「回源了几次」数出来——
200 次和 1 次之间隔着的就是你和别人的差距。
13.1这道题为什么必考
面试官问 Redis,不是想听「内存数据库、支持持久化、五种数据类型」。他想知道的是: 你有没有在真机上被它坑过。坑过的人,说话会带数字。
| 你听到的问题 | 它其实在问 | 说错的代价 |
|---|---|---|
| 「Redis 为什么快」 | 单线程 + 内存 + IO 多路复用,以及单线程的代价 | 只会说「因为它是内存的」→ 追问「那 Memcached 也是内存」 |
| 「五种数据类型底层」 | SDS / listpack / 跳表 / intset 的切换阈值 | 背得出「ziplist」,说不出 Redis 7 之后已经换成 listpack |
| 「缓存穿透怎么解决」 | 空值缓存 vs 布隆过滤器,各自挡得住什么 | 两个词都会说,分不清哪个挡「串行刷」哪个挡「并发打」 |
| 「缓存击穿呢」 | 互斥锁 vs 逻辑过期,延迟差多少 | 答「加锁」,说不出一加锁 p99 反而变差 |
| 「怎么保证缓存一致」 | 先删还是后删,脏数据窗口有多大 | 答「先更新库再删缓存就没问题」→ 实测也会脏 |
| 「分布式锁怎么实现」 | SET NX PX + Lua 校验 + 续期 | 只会 setnx + expire 两条命令 |
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 单实例):
| 场景 | QPS | p50 延迟 | 说明 |
|---|---|---|---|
| SET(纯内存) | 60975 | 0.543 ms | 单条命令一次往返 |
| GET(纯内存) | 64516 | 0.527 ms | 读比写略快 |
| SET + pipeline 16 | 273972 | 2.471 ms | QPS 涨 4.5 倍,但单条延迟变大 |
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
整理成一张对照表:
| 类型 | 小数据编码 | 大数据编码 | 切换条件 | 关键配置 |
|---|---|---|---|---|
| string | int / embstr | raw | 看长度 | 无 |
| list | listpack | quicklist | 单节点超 8KB | list-max-listpack-size |
| hash | listpack | hashtable | 字段数 > 512 或字段值 > 64 字节 | hash-max-listpack-entries |
| set | intset / listpack | hashtable | 全整数超 512,或非整数超 128 | set-max-intset-entries |
| zset | listpack | skiplist | 成员数 > 128 或成员 > 64 字节 | zset-max-listpack-entries |
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 就少一个字节。
④ 转换是单向的
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
两个关键事实:
TTL全返回-2(键已不存在),但DBSIZE还是 200000—— 过期时间到了 ≠ 内存被回收了;used_memory只从 17.6 掉到 16.5 MB(掉的是 TTL 字典本身),数据体还在。
而且注意 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。
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 的里挑 | 剩余寿命最短 | 同上 |
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 | 无,第一道就该有 |
| 限流 | 高频攻击 | 低频慢刷 | 可能误伤正常用户 |
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
这三行值得逐列读:
- A 无保护:200 个请求全部打到库,200 次 × 100ms 的数据库压力。 更糟的是它没报错——只是数据库 CPU 突然 100%,然后整个系统雪崩。
- B 互斥锁:回源降到 1 次,但
p99 145.6 ms比 A 还差。 因为 199 个线程在自旋等锁,谁抢到谁才 18ms 返回,排在最后的要等完整一轮。 「加锁保护数据库」的代价是把延迟转嫁给了用户。 - C 逻辑过期:回源也是 1 次,但
p99 只有 16.5 ms,比 B 好 8.8 倍。 因为它谁都不等——所有请求立刻拿到旧值,只有一个后台线程去重建。
写法上的差别只有一句:B 把「等」放在请求线程里,C 把「等」放到了后台线程里。
C 逻辑过期的关键三步:
1) 不设 TTL,把过期时间写进 value:{"data": ..., "exp": 1762...}
2) 读到的 exp 已过期 -> 尝试拿 rebuild 锁
3) 拿到锁的线程【另起一个线程】重建,自己立刻返回旧值
| 方案 | 回源次数 | p99 | 数据新鲜度 | 适合 |
|---|---|---|---|---|
| 无保护 | 200 | 109 ms | 实时 | 只有 QPS 极低的场景才敢用 |
| 互斥锁 | 1 | 145.6 ms | 实时(拿到的是新值) | 对一致性要求高、能接受排队 |
| 逻辑过期 | 1 | 16.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 | — | 多一个组件,运维成本高 |
另外:先删缓存 + 延迟双删比先写库 + 延迟双删脏窗口更大, 因为前者在「删完到写库完成」之间,所有读请求都会把旧值读回来。
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 的了
四个坑对应四行代码:
| # | 坑 | 实测现象 | 正确写法 |
|---|---|---|---|
| 1 | SETNX + 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
lockWatchdogTimeout 上限 + 业务侧的幂等设计。
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)更直观:
| 配置 | QPS | p50 | 含义 |
|---|---|---|---|
| 纯内存(无持久化) | 60975 | 0.543 ms | 基线 |
AOF always | 25188 | 1.671 ms | 每条命令都 fsync,掉到 41% |
AOF everysec | 55096 | 0.631 ms | 每秒 fsync 一次,几乎无损 |
AOF no | 56818 | 0.631 ms | 交给操作系统,可能丢 30 秒 |
两个值得记住的数字:
always只有基线的 41%:因为每条命令都要等一次磁盘 fsync。everysec和no几乎没差别(55096 vs 56818)。 这一点反直觉:no并不比everysec快,因为后者是后台线程每秒刷一次, 不阻塞主线程。所以生产上没必要为了性能选no—— 白白放弃「最多丢 1 秒」的保证,换不来什么。
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 秒 | — |
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。 |
动手清单与自查表
| # | 动手项 | 可观察的判据(做到了会看到什么) |
|---|---|---|
| 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 |
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 |
| 想找大 key | redis-cli --bigkeys;再对可疑 key 用 MEMORY USAGE |
| 想验证锁被谁拿着 | GET 那个 key 的 value(token)+ PTTL 剩余时间 |