Redis 深度教程 · 04
缓存工程
03 讲了过期与淘汰——TTL 是缓存的命门。这一章把 Redis 放回它最出名的岗位:缓存,以及缓存最难的部分——与数据库的一致性,和三种被频繁问到的失败模式。
本章你将建立的 schema
- 几种缓存模式与为什么业务侧多用旁路缓存(cache-aside)
- 双写一致性为什么删而不更新;延迟双删与 binlog/Canal 各自的取舍
- 穿透(penetration)/ 击穿(breakdown)/ 雪崩(avalanche)各自的触发机制与解法
- 热点 key 与大 key 的检测与工程处理
缓存工程建立在两块基石之上:01 的数据类型(String / Bitmap / 布隆过滤器位图)决定能用什么结构防御穿透;02 的单线程原子性(链 05 的分布式锁)决定怎样安全地串行重建热点 key。失败模式不是孤立的——读清机制之后,防御策略的选择会变成一个推导过程而不是记忆清单。
4.1缓存模式对比
四种缓存模式的分界线在于:DB 的读写由业务代码还是缓存层代劳,以及写操作是同步还是异步。
不同模式在一致性、写延迟、故障风险上差距显著。选错模式,后续所有调优都是在错误的约束下打补丁。
Cache-Aside(旁路缓存)是业务代码自己同时操作缓存和数据库——读先查缓存,miss 了再查 DB 并回填;写先更新 DB,再删缓存。缓存对 DB 完全透明,业务掌控两侧。
Read-Through 和 Write-Through 把 DB IO 交给缓存层(或 ORM 框架)代管:read-through 在 cache miss 时由框架自动从 DB 加载并填充;write-through 在写时同步更新 DB,写缓存和写 DB 在同一调用链完成,一致性更强但写延迟叠加。
Write-Behind(异步写回)只写缓存,由异步线程批量刷到 DB,写速度最快,但在缓存宕机或异步队列堆积时存在数据丢失风险。
| 模式 | DB 读写由谁负责 | 写延迟 | 一致性 | 主要风险 |
|---|---|---|---|---|
| Cache-Aside(旁路) | 业务代码 | 低(写 DB + 删缓存) | 最终一致,存在窗口 | 业务代码需维护两侧逻辑 |
| Read-Through | 缓存框架(读) | 低(同 Cache-Aside) | 与 Cache-Aside 相同 | 冷启动全部 miss;框架绑定 |
| Write-Through | 缓存框架(读写) | 高(同步写 DB) | 强一致 | 写延迟翻倍;冷 key 浪费写 |
| Write-Behind | 缓存框架(异步写) | 极低 | 弱,有丢失风险 | 宕机 / 队列积压丢数据 |
业务侧最常用 cache-aside,原因有三:① Redis 本身不内置对 DB 的读写逻辑,框架层实现 read/write-through 引入额外依赖;② cache-aside 下业务对缓存失效时机有完整控制;③ write-through 的写延迟对高并发写场景代价过高,而 write-behind 的丢失风险对金融类场景不可接受。
4.2双写一致性
写操作时删缓存而非更新缓存,是因为删是幂等的惰性失效,更新则在并发写下容易产生旧值覆盖;标准做法"先写 DB 再删缓存"仍有一条罕见竞态,延迟双删和 binlog 是两种不同力度的补救。
一致性问题在低并发下几乎不会暴露,一旦 QPS 上升或写操作密集,旧值在缓存里多存一个请求周期就可能造成脏读。弄清机制才能在方案选型时知道选哪个、放弃什么。
为什么删缓存而不是更新缓存
更新缓存的常见直觉:写 DB 的同时把新值也写入 Redis,这样缓存永远最新。实际上这个策略有两个隐患:
- 并发写的覆盖问题:两个线程同时写不同的值,更新 DB 的顺序和更新缓存的顺序无法保证一致,结果是缓存里可能留下更早的写操作的值,而 DB 里已经是更晚的写操作的值。
- 冷 key 的计算浪费:某个 key 被写了 100 次,但读只有 1 次——更新缓存意味着做了 99 次无人读取的计算与写入,而删缓存只是在有人读时才重算,是惰性求值。
删缓存是幂等操作:不管多少线程同时删,结果等价于缓存不存在,下一次读时按需回填。这与 Redis 单线程模型配合得恰到好处——DEL 命令是原子的,不存在"删了一半"的中间态。
先写 DB 再删缓存的残余竞态
标准做法的顺序:① 更新 DB → ② 删缓存。绝大多数情况下正确。但存在一条概率极低、却并非零的竞态路径:
- 读线程查缓存,miss,准备从 DB 读旧值(此时写线程还没开始)
- 写线程完成更新 DB + 删缓存
- 读线程从 DB 读到旧值(缓存已删,DB 已是新值——但读线程拿到的是写线程开始前的一致性快照),将旧值回填进缓存
结果:DB 里是新值,缓存里是旧值,持续到缓存 TTL 到期。这条路径需要读线程在写线程 DEL 缓存之后才执行回填,而读线程本身的 DB 查询在写线程之前就已发出——属于极窄的时序窗口,TTL 保底能修复,但强一致场景不能接受。
延迟双删
延迟双删(double-delete with delay):在"写 DB + 删缓存"之后,再 sleep 几百毫秒(估算读线程可能回填的最长时间),然后第二次删缓存。逻辑是:第一次删覆盖正常情况,第二次删清理掉那条竞态路径回填的旧值。
sleep 时长是经验估算,不是保证——如果读线程在第二次删之后才回填,问题仍然存在。延迟双删是 best-effort 方案,适合容忍短暂不一致的场景;对强一致场景,binlog 方案更可靠。sleep 期间写线程被阻塞,对写吞吐有直接影响,通常改成异步延迟任务来规避。
binlog / Canal 异步失效
Canal 伪装成 MySQL 从库,订阅 binlog 的 row-format 变更事件。流程:MySQL 提交事务 → binlog 落盘 → Canal 消费 binlog → 发送到 MQ(Kafka / RocketMQ)→ 消费者执行 DEL 失效缓存。
这条路径把缓存失效从写业务代码里剥离出来:业务代码只写 DB,缓存失效由独立的 Canal 消费链路完成。好处是不依赖 sleep 猜时间,且对 DB 事务提交有可靠的感知(binlog 在事务提交后才落盘)。代价是引入了 Canal + MQ 的额外依赖和运维成本,以及 MQ 到消费者之间的延迟(通常毫秒到几十毫秒)。
反过来的顺序(先删缓存再更新 DB)有一条更宽的竞态窗口:删完缓存到 DB 写完之间,任何读线程都会 miss 缓存、从 DB 读到旧值、把旧值回填进去——窗口长度等于 DB 写的耗时,远大于先写 DB 那条路径里的窗口(见 4.2 的想一想)。
"先删缓存再写 DB" vs "先写 DB 再删缓存",哪个一致性问题更小?从竞态窗口的长度和出现概率两个维度分析。
展开思路与答案
先写 DB 再删缓存的竞态窗口极窄:需要读线程在"写线程已删缓存之后"才执行回填,且读线程的 DB 查询必须在写线程之前发起——两个条件同时满足概率低,TTL 保底。
先删缓存再写 DB的竞态窗口宽得多:删完缓存到 DB 写完之间(通常几毫秒到几十毫秒),所有读线程都会 miss 缓存、读到 DB 旧值(因为 DB 还没写完)、把旧值回填——任何在这个窗口内的读线程都会产生脏数据。窗口长度等于 DB 写的耗时,远大于先写 DB 路径里的窗口。结论:先写 DB 再删缓存是标准做法,问题更小。
4.3缓存穿透(penetration)
穿透是查一个根本不存在的 key——缓存和 DB 都没有,每次查询都把压力直接打到 DB。
缓存只能缓存存在的数据;对于不存在的 key,缓存形同透明,DB 暴露在外。恶意请求或批量无效 ID 查询能用这条路径瞬间压垮 DB。
触发场景:用户查一个不存在的商品 ID(误操作或攻击构造的无效 ID)。缓存 miss → DB 查询 → 返回空 → 不回填缓存 → 下一次同样 miss → 同样打到 DB。
解法一:空值缓存
DB 返回空时,把空值("" 或特殊标记)也写入缓存,设一个较短的 TTL(比如 60 秒)。下次同样 ID 的请求直接命中缓存里的空值,不再穿透到 DB。代价是缓存里多了大量空 key,占用内存;TTL 过长则在数据从无到有时会产生短暂的假缺席(数据已入库但缓存里还是空)。
解法二:布隆过滤器(Bloom filter)
布隆过滤器(见 01 的位图/Bloom)在入口层过滤不存在的 ID:所有合法 ID 在系统初始化或写入时加入过滤器,请求进来先问过滤器——判定"不存在"时一定可信(无假阴性),直接返回空,不打 Redis 也不打 DB;判定"存在"时才继续走缓存路径(可能有假阳性,但概率可配置到极低)。
普通缓存(包括空值缓存)挡不住的场景:攻击者每次构造一个新的无效 ID,空值缓存只能保护"已经被查过一次"的 ID,第一次查询仍然穿透。布隆过滤器则在系统层面维护所有合法 ID 的集合,未出现过的 ID 直接被挡——无论 ID 是否被查过。关键差异:布隆过滤器没有假阴性("判定不存在"时 100% 可信),这一点保证了它作为门卫的有效性。
布隆过滤器有假阳性(把不存在的 ID 判成存在),但没有假阴性(把存在的 ID 判成不存在)。为什么防穿透恰恰依赖"无假阴性"这一特性?假阳性会带来什么后果?
展开思路与答案
防穿透的核心逻辑:过滤器判"不存在"时,直接拦截请求不去 DB。这个拦截动作的正确性依赖"判定不存在 → 真的不存在"——即无假阴性。若有假阴性,一个合法 ID 被误判为不存在,真实数据就被错误地屏蔽了,造成正常用户查不到数据。
假阳性的后果是:一个不存在的无效 ID 被误判为存在,请求继续往下走,到缓存层 miss,再到 DB 也查不到(DB 里本来就没有),最终返回空。假阳性不影响数据正确性,只是漏掉了一次本可拦截的穿透请求,代价是多了一次 DB 查询——可接受。
4.4缓存击穿(breakdown)
击穿是单个热点 key 在过期的那一瞬间,大量并发请求同时穿透缓存直接打到 DB——惊群效应。
穿透是打不存在的 key,击穿是打"一瞬间变成不存在"的热 key。两者触发源不同,解法也不一样。击穿的危险在于它在可预期的时刻(TTL 到期)爆发,且压力高度集中在单点。
典型场景:秒杀商品详情页,TTL 到期的瞬间几千个并发请求同时 miss,每个请求都去 DB 重建缓存,DB 被一次性打满。
解法一:互斥锁重建
利用分布式锁(见 05):miss 后抢分布式锁,抢到的线程查 DB 并回填缓存,抢不到的线程 sleep 短暂时间后重试缓存(此时缓存通常已被回填)。保证同一时刻只有一个线程做 DB 重建,其余等待。代价是有等待延迟,且如果重建慢,等待线程会积压。
解法二:逻辑过期(logical expiration)
TTL 不存在 Redis 的 EXPIRE 里,而是存在 value 的字段里(如 JSON 里附带一个 expire_at 时间戳)。物理上这个 key 永不过期(PERSIST 状态)。读到 value 后检查逻辑过期时间:
- 未过期:直接返回
- 已过期:返回旧值(不阻塞调用方),同时异步启动一个后台线程去 DB 重建并回填(用分布式锁保证只有一个线程做重建)
逻辑过期以一点陈旧性(重建完成前返回旧值)换取零惊群——调用方不等待,无堆积。适合允许短暂数据陈旧的场景(如商品列表、榜单)。
解法三:热点永不过期
对提前已知是热点的 key(如节假日促销商品),直接不设 TTL,在业务逻辑里主动失效(写 DB 后 DEL)。风险是热点 key 识别需要提前介入,且 key 一旦被误删不会自动补回。
互斥锁保证缓存里的数据一定是最新的(重建完成后才返回),但有等待延迟和锁竞争;逻辑过期保证零等待零惊群,但重建期间返回旧值——一致性换可用性。高并发读多写少场景(如商品详情、榜单)通常优先选逻辑过期;支付金额、库存余量等强一致场景选互斥锁或直接不走缓存。
4.5缓存雪崩(avalanche)
雪崩是大量 key 在同一时刻集中过期,或 Redis 整体不可用,导致所有请求同时砸到 DB。
击穿是单 key 的惊群,雪崩是全局的击穿——规模放大到整个缓存层,DB 承压是击穿的数百倍。
常见触发源:① 系统启动时批量预热,所有 key 用同一 TTL,到期时集体失效;② Redis 实例宕机,所有流量无缓存直接落 DB。
解法:TTL 加随机抖动
设置 TTL 时加上随机偏移:TTL = base + random(0, jitter)(如 base=300s,jitter=60s)。原本同批写入的 key 过期时间散开在 300–360s 之间,不会同时失效。这是最简单且最有效的雪崩预防手段。
int baseTtl = 300; // 基础 TTL (秒)
int jitter = 60; // 最大抖动范围
int finalTtl = baseTtl + ThreadLocalRandom.current().nextInt(jitter);
jedis.setex(key, finalTtl, value);
// 同批写入的 key 过期时间散布在 300–360s,不会集中失效
解法:多级缓存
在 Redis 前置一层本地缓存(如 Caffeine)。Redis 整体宕机时,本地缓存仍能吸收部分请求。代价是多了一层一致性问题(本地缓存无法感知 Redis 的失效事件,需要依赖 TTL 或主动广播)。
解法:熔断降级
在 Redis 不可用或 DB 压力过高时,熔断器(如 Sentinel / Resilience4j)触发降级:返回默认值、缓存快照或友好错误,阻止雪崩期间无意义的 DB 重试积累。
解法:高可用集群
Redis 宕机引发的雪崩用 Cluster 或哨兵模式(见 05)规避:主节点宕机时从节点自动晋升,服务中断窗口缩短到秒级甚至毫秒级。
4.6热点 key 与大 key 工程
热点 key 把单节点 CPU 打满,大 key 在读写和删除时阻塞单线程——两者都是单线程模型在生产中最常见的性能陷阱。
Redis 单线程执行(见 02)意味着一个慢命令能阻塞所有后续命令。热点 key 和大 key 分别从"频率"和"单次耗时"两个维度触发这个问题。
检测
Redis 内置了两个命令行工具:
redis-cli --hotkeys:需要maxmemory-policy设为 LFU 系列(allkeys-lfu或volatile-lfu),基于 LFU 频率估算热点 key。redis-cli --bigkeys:扫描全库找各类型里 encoding 大小最大的 key。注意:扫描本身是全量 SCAN,对生产环境有轻微影响,建议在从节点或低峰期执行。
热点 key 处理
单一 Redis 节点对某个热 key 的吞吐上限取决于单核 CPU。两种工程手段:
- 本地缓存(Caffeine):在应用进程内加一层 JVM 堆内缓存,热 key 在本地直接命中,不走网络不压 Redis。代价是多台机器各一份副本、失效需要主动广播(或 TTL 保底)。
- key 复制分散:把热 key 写成多份(如
product:123:0到product:123:N-1),读时随机选一份。N 个副本分布在 N 个不同的 Redis 节点(Cluster 中 key 带后缀后 hash 到不同槽),将单节点热点分散成 N 份。写时需要更新所有副本,适合读多写极少的场景。
大 key 处理
大 key 的危险不只在读(HGETALL 一个百万字段 hash 会占用主线程几十毫秒),还在删:直接 DEL 一个几百 MB 的 key 会同步释放内存,阻塞时间可达秒级。
正确做法:使用 UNLINK 替代 DEL。UNLINK(Redis 4.0+)把实际的内存释放放到后台异步线程(lazy free),主线程只解除引用后立即返回,延迟从秒级降到微秒级(链回 02 的 lazy free)。
# 错误做法:直接 DEL 大 hash,主线程同步释放内存,可能阻塞秒级
DEL big_hash_key
# 正确做法 1:UNLINK 异步释放
UNLINK big_hash_key
# 正确做法 2:对超大 hash 渐进拆分(每批 HSCAN + HDEL,控制每次 count)
# 避免单次 HGETALL 占用主线程
HSCAN big_hash_key 0 COUNT 200
HDEL big_hash_key field1 field2 ...
没有统一的阈值,经验标准:String value > 10KB,Hash/Set/ZSet 成员数 > 5000 或总大小 > 100KB,List 长度 > 10000。关键是"是否会导致单次命令耗时超出业务 SLA",而非固定数字——实测 slowlog 才是准确判断依据。
·自测
- cache-aside 的写路径为什么是"先更新 DB,再删缓存"而不是"先更新 DB,再更新缓存"?从并发写的角度说明更新缓存会产生什么问题。
- 布隆过滤器防缓存穿透依赖"无假阴性"特性。如果过滤器出现了假阴性(把存在的 ID 判成不存在),会发生什么?这与假阳性的后果有何不同?
- 逻辑过期方案在重建缓存期间返回旧值——这是一个设计上的权衡,不是缺陷。说明它与互斥锁方案在"一致性 vs 可用性"上的取舍,以及各自适合哪类场景。
- 设计判别:一个高并发商品详情页,既要防击穿又要防雪崩,应该如何组合 TTL 策略与缓存重建策略?说明各个手段分别挡住了哪种失败模式。
展开全部答案
1. 更新缓存在并发写场景下存在覆盖问题:线程 A 和线程 B 同时写不同值,更新 DB 的顺序(由 DB 串行保证)可能与更新缓存的顺序不同,导致缓存里留下更早写操作的值,而 DB 里已是更新后的值。删缓存是幂等操作,不管哪个线程先删,结果等价于缓存不存在,下一次读时按当时 DB 最新值回填,避免了顺序问题。此外,删缓存是惰性失效——不读就不重算,比更新缓存节省冷 key 的计算开销。
2. 假阴性(把存在的 ID 判成不存在)会导致合法数据被错误屏蔽:真实存在的商品被过滤器拦截,用户查不到,造成业务错误。假阳性(把不存在的 ID 判成存在)的后果是漏掉一次本可拦截的穿透,请求继续走缓存路径并最终从 DB 查到空值——多了一次 DB 查询,不影响数据正确性。两者性质完全不同:假阴性破坏正确性,假阳性降低效率。
3. 互斥锁:重建完成前后续请求等待,缓存里的数据始终是最新值(强一致),但有等待延迟,高并发时锁竞争可能积压请求。适合支付金额、库存余量等强一致场景。逻辑过期:重建期间立即返回旧值(弱一致),调用方无等待无积压(高可用),适合商品列表、榜单等允许短暂陈旧的读多写少场景。取舍本质:互斥锁选一致性放弃即时可用,逻辑过期选可用性接受短暂陈旧。
4. 组合方案:① TTL 随机抖动(base + random(0, jitter))——同批商品过期时间散开,防止集中过期引发雪崩;② 逻辑过期 + 异步重建——热点商品采用逻辑过期,物理永不过期,过期后异步一个线程重建,其余线程返回旧值,防止热点过期瞬间的惊群(击穿);③ 本地缓存(Caffeine)作为第一层——Redis 整体不可用时(雪崩二类触发),本地缓存仍能吸收流量;④ Redis Cluster / 哨兵——缩短单节点宕机导致的服务中断窗口。各手段分工:TTL 抖动防集中过期雪崩;逻辑过期防热 key 到期击穿;本地缓存防 Redis 宕机雪崩;集群 HA 缩短宕机窗口。
设计热点商品缓存方案
大促期间,某电商平台的爆款商品详情页 QPS 达 50,000+,商品信息更新频率约每 10 分钟一次。设计一套缓存方案,组合使用逻辑过期、TTL 随机抖动和本地缓存(Caffeine),说明每种手段分别挡住了哪种失败模式,以及三者结合后剩余的最弱一致性窗口是多少。
提示(不给全解)
先明确三种失败模式各自的触发条件:击穿 = 单热 key 过期瞬间;雪崩 = 批量过期或 Redis 整体不可用;穿透在本题中不是主要威胁(爆款有真实数据)。
逻辑过期解决击穿问题,但重建期间有陈旧窗口——这个窗口多长?取决于异步重建线程完成 DB 查询和写 Redis 的耗时。
TTL 随机抖动解决批量商品集中过期的雪崩,但逻辑过期下物理 key 永不过期——它的作用范围变成什么?(提示:考虑同一批商品的逻辑过期时间是否也需要抖动。)
本地缓存(Caffeine)在 Redis 不可用时兜底,但它的 TTL 通常设得比 Redis 短(几秒到几十秒),避免本地缓存过期不感知 Redis 里的失效。此时最弱一致性窗口 = Caffeine TTL + 逻辑过期重建耗时,两者相加是多少量级?