跳到正文

缓存设计 ​

一句话:缓存用一份可能过期的副本换延迟和数据库吞吐,代价是失效窗口、击穿风险和一套独立的故障面。

适用:Redis 7.x/8.x、Caffeine 3.x;MySQL 8.0+。

为什么需要 ​

数据库的读延迟在毫秒级,热点数据反复查库会把连接池和 IO 打满。缓存在内存里放一份副本,把绝大多数读请求挡在数据库之前。

但缓存一引入,「数据从哪里读、写完谁来失效、缓存挂了怎么办」就都变成了业务问题。缓存不是加速插件,而是一致性设计的一部分。

核心机制 ​

Cache Aside:最常用的读写路径 ​

读:查缓存,命中返回;未命中查数据库,回填缓存,再返回。 写:先更新数据库,再删除缓存。

为什么删缓存而不是更新缓存?更新缓存会遇到并发写顺序问题:A 先更新数据库、B 后更新,但 B 先写缓存、A 后写缓存,缓存就停在了 A 的旧值。删除是幂等的,下一次读自然回填最新值。代价是删除后到回填之间有一个短窗口。

延迟双删仍然不是强一致 ​

典型时序:先删缓存,更新数据库,延迟一段时间再删一次。

第一次删除后,另一个读请求可能读到旧库值并回填;第二次删除把这份旧值清掉。延迟时间要覆盖「读库 + 回填」,常见经验值是 200ms–1s,但它是估计值,不是保证。

即使双删,仍可能出现:读请求在第二次删除之后才回填旧值。所以延迟双删只是降低概率,不是强一致。要求强一致的场景不要加缓存,或者用版本号/CDC 让缓存更新可排序。

三种写策略的准确含义 ​

策略写路径一致性主要代价
Cache Aside应用写库,再删缓存最终一致失效窗口、并发回填旧值
Read/Write Through应用只写缓存,缓存层同步写库较强写放大、缓存层成为关键路径
Write Behind应用只写缓存,缓存层异步批量写库最终一致,窗口最长宕机丢未刷数据、刷写失败

Write Behind(异步写回)不是「先写缓存再同步写库」。它写完缓存就返回,后台线程按批或按时间把脏数据刷到数据库。优点是写延迟最低、能把随机写合并成顺序写;代价是缓存和数据库之间存在明显的未刷窗口,缓存进程崩溃会丢掉这段数据。它适合能容忍丢失、可重放的场景,不适合订单、支付这类以数据库为准的数据。

穿透、击穿、雪崩 ​

  • 穿透:key 在缓存和数据库都不存在,每次都打库。用空值缓存(短 TTL)或布隆过滤器挡住「一定不存在」的请求。
  • 击穿:单个热点 key 过期,大量请求同时回源。用互斥锁让一个请求回源,其他请求等待;或者逻辑过期,后台异步刷新。
  • 雪崩:大量 key 同时过期,或 Redis 整体不可用。给 TTL 加随机抖动、做多级缓存、对回源做限流。

Canal:把失效从业务代码里拿出去 ​

业务只写数据库,Canal 订阅 binlog,把变更投递到 MQ,由消费者删除或更新缓存。好处是业务代码不需要同时记得「写库 + 删缓存」,坏处是链路更长,需要处理 binlog 延迟和消费重复。

Big Key 与 Hot Key ​

Big Key 是 value 很大(经验阈值 >10MB,不是官方标准),会阻塞 Redis 单线程、打满网卡、让迁移变慢。处理方式是拆分(hash 分片)、压缩、只存 ID。

Hot Key 是单个 key 的 QPS 极高,会把某个节点的 CPU 或带宽打满。处理方式是本地缓存、多副本、读写分离、key 拆分。本地缓存要接受「多实例过期时间不一致」带来的短暂不一致。

淘汰策略 ​

Redis 的 maxmemory-policy 常见取值:

  • noeviction:内存满后写入报错。这是 Redis 的默认值,不是 LRU。
  • allkeys-lru / allkeys-lfu / allkeys-random:在所有 key 中淘汰。
  • volatile-lru / volatile-lfu / volatile-random / volatile-ttl:只在设置了过期时间的 key 中淘汰。

LFU 从 Redis 4.0 起提供,适合「访问频率差异大」的热点数据;LRU 适合「最近访问更重要」的场景。maxmemory-samples 默认 5,采样越多越接近真实 LRU/LFU,但 CPU 开销更高。

最小上手 ​

bash
# 写一个带 TTL 的缓存
redis-cli SET product:1 '{"id":1,"name":"demo"}' EX 300

# 互斥锁:只让一个请求回源
redis-cli SET lock:product:1 1 NX PX 10000

# 观察命中率和淘汰
redis-cli INFO stats | grep -E 'keyspace_hits|keyspace_misses|evicted_keys'
redis-cli CONFIG GET maxmemory-policy

Cache Aside 回源伪代码(省略错误处理):

text
v = redis.get(key)
if v != null: return v
if redis.set(lock_key, "1", NX, PX=10000):
    try:
        v = redis.get(key)          # 双重检查
        if v != null: return v
        v = db.query(key)
        redis.set(key, v, EX=300 + random(0, 300))
        return v
    finally:
        redis.del(lock_key)
sleep(50ms); retry

生产动作 ​

  • 内存与淘汰:maxmemory 默认 0(不限制),生产必须设置;maxmemory-policy=noeviction 时写满会报错,缓存场景通常改成 allkeys-lru 或 volatile-lru。
  • TTL 抖动:基础 TTL + 随机 0–300 秒,避免同一批 key 同时过期。
  • 空值缓存:TTL 建议 30–60 秒,防止穿透又不长期占用内存。
  • 互斥锁:锁 TTL 要大于一次回源的最长时间;拿不到锁的请求要有限等待,不能无限重试。
  • Big Key 排查:redis-cli --bigkeys;Hot Key 排查:redis-cli --hotkeys(需要 LFU 策略)。
  • 读写分离延迟:项目经验可参考每 3 秒检查主从时间差,超过 5 秒切主库,连续 3 次正常后切回;阈值要按业务容忍度调整。主库事务内读必须继续走主库,否则会刚写完读不到。
  • 降级:Redis 不可用时,回源必须同时限流;否则缓存层一挂,数据库会被瞬间打穿。

边界 ​

  • 要求强一致的账务、库存扣减,不要把缓存当读源;缓存只能做加速,最终判断回数据库。
  • Write Behind 不适合不可丢失的数据,除非缓存本身有持久化、复制和故障恢复设计。
  • 布隆过滤器只能判断「一定不存在/可能存在」,不能删除元素,也不能枚举集合。
  • 本地缓存会放大多实例不一致;对一致性敏感的数据不要放本地。
  • 缓存不是数据库的备份;RDB/AOF 保护的是缓存自身,不是业务数据的最终副本。

讲清楚 ​

缓存的核心不是「查不到就写一条」,而是接受一份可能过期的副本。最常用的是 Cache Aside:读未命中回源回填,写先更新数据库再删缓存;删而不是更新,是为了避免并发写把旧值写回缓存。延迟双删只能降低不一致概率,不能保证强一致。Write Behind 是写完缓存就返回、后台异步刷库,延迟最低但会丢未刷数据。再往上就是三防:布隆挡穿透、互斥锁挡击穿、TTL 抖动挡雪崩。真正要求强一致的场景,应该不加缓存或直接读库。

把一门技术讲透到既懂也会用