数据库基础体系 · 第 38/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。
Redis 缓存体系:一致性、穿透、击穿、雪崩和多级缓存
Redis 常被用作缓存,但“把数据放进 Redis”并不等于建立了可靠的缓存体系。缓存同时引入了副本、过期、并发回源、故障降级和多层失效传播等问题。
本文围绕五个主题展开:
- 缓存一致性:缓存副本与数据库事实之间如何定义、产生和收敛一致关系;
- 缓存穿透:请求访问了不存在的数据,导致请求持续落到数据库;
- 缓存击穿:某个高热度键失效,大量并发请求同时回源;
- 缓存雪崩:大量缓存同时失效,或缓存整体不可用,造成系统级回源压力;
- 多级缓存:本地缓存、Redis 和数据库组成多层数据路径后,如何处理命中、失效和故障。
示例默认 Redis 为缓存层,数据库为最终事实来源。除非特别说明,Redis 命令语义以官方稳定版本公开文档为准;示例中的事务边界、部署方式和一致性承诺不会被隐含扩大。
一、先定义缓存到底是什么
1. 缓存是可重建的副本
设数据库中的事实值为:
表示键 k 在时间 t 的数据库值;Redis 缓存值为:
缓存的基本关系是:
这里的“约等于”不是数学上的模糊表达,而是说明缓存通常不是事实来源。缓存可以:
- 因 TTL 到期而不存在;
- 因淘汰而不存在;
- 因 Redis 故障而不可访问;
- 暂时保存数据库的旧值;
- 保存“这个键不存在”的结果,即负缓存。
因此,缓存设计首先要回答:
缓存允许旧到什么程度?缓存不可用时能否访问数据库?哪些数据不能被缓存?
2. 命中、未命中与回源
一个典型读请求经过以下状态:
请求
│
├─ 缓存命中:返回缓存值
│
└─ 缓存未命中
│
├─ 查询数据库
├─ 写回缓存
└─ 返回数据库值
“回源”就是缓存未命中后访问数据库、文件系统或其他事实来源。
缓存命中率通常定义为:
其中:
- :缓存命中次数;
- :缓存未命中次数。
命中率高不代表系统一定安全。例如,一个热点键每分钟失效一次,在失效瞬间可能产生大量并发回源;总体命中率仍然很高,但数据库会被瞬时打垮。
二、缓存一致性:先确定“要保证什么”
1. 几种不同的一致性目标
“缓存一致性”不是单一标准,至少要区分以下目标。
读取不返回旧值
如果数据库写入已经成功,之后的读请求不能再返回旧缓存值。这是接近强一致的要求,通常需要:
- 写数据库与缓存更新之间存在严格协调;
- 或者读请求能验证缓存版本;
- 或者写入后在一段时间内绕过缓存。
普通的 Cache-Aside 模式本身不自动提供这一保证。
最终一致
数据库更新后,缓存可能短时间仍是旧值,但在有限时间内会更新或失效:
这里的 是可接受的收敛时间,但只有在失效消息可靠、回源成功、没有旧值覆盖新值等条件成立时,这个结论才成立。
有界陈旧
如果系统允许缓存最多旧 时间,则必须同时控制:
- TTL;
- 更新延迟;
- 消息积压;
- 故障恢复时间;
- 回源和重建失败后的重试策略。
仅设置 EXPIRE 并不能严格证明“最多旧 TTL 时间”,因为写回缓存的时间、消息延迟和并发竞态都会影响实际陈旧时间。
2. TTL 不等于一致性机制
Redis 的过期时间决定键何时失效,但它不理解数据库事务。
例如:
数据库已经提交新值
Redis 仍然保存旧值
旧值 TTL 还剩 300 秒
在 TTL 到期前,Redis 仍可能返回旧值。因此,TTL 主要用于限制缓存生命周期和清理空间,不是数据库更新的通知机制。
Redis 对过期键采用惰性删除与主动过期检查等机制。客户端看到过期键时,其语义相当于键不存在;但不要把某个精确的后台删除时刻当成业务一致性事件。
可以用以下命令观察过期状态:
redis-cli SET user:42 '{"name":"old"}' EX 60
# 预期输出:OK
redis-cli TTL user:42
# 预期输出:一个不大于 60 的非负整数
redis-cli GET user:42
# 预期输出:{"name":"old"}
redis-cli DEL user:42
# 预期输出:1
redis-cli TTL user:42
# 预期输出:-2,表示键不存在
TTL 返回:
- 非负数:剩余秒数;
-1:键存在但没有过期时间;-2:键不存在。
三、Cache-Aside 的完整流程与竞态
Cache-Aside 是最常见的缓存模式:应用自己读取缓存、访问数据库并写回缓存。
1. 读流程
伪代码如下:
value = Redis.GET(key)
if value exists:
return value
value = DB.SELECT(key)
if value exists:
Redis.SET(key, serialize(value), EX cache_ttl)
else:
Redis.SET(key, "__NULL__", EX negative_ttl)
return value
每一步的含义是:
- 先查 Redis;
- 命中则直接返回;
- 未命中才访问数据库;
- 查询成功后写入 Redis;
- 查询不到时可写入短时间负缓存。
Redis 的 SET 可以同时设置值和过期时间,避免先 SET、后 EXPIRE 之间因进程崩溃而留下永久键:
redis-cli SET user:42 '{"id":42,"name":"Alice"}' EX 60
# 预期输出:OK
2. 典型写流程
最常见的写流程是:
DB.UPDATE(...)
Redis.DEL(key)
即先提交数据库,再删除缓存。它比“先删缓存,再更新数据库”更容易理解,但仍不是严格一致的。
3. 旧值回填竞态
考虑缓存键 user:42:
初始:
DB = old
Redis = 不存在
两个线程并发执行:
T1:查询数据库,读到 old
T2:更新数据库为 new
T2:删除 Redis
T1:把 old 写回 Redis
最终状态:
DB = new
Redis = old
如果 T1 写入的旧值带有较长 TTL,缓存可能长时间错误。
这说明:
“数据库更新成功后删除缓存”可以减少旧缓存存在时间,但不能阻止已经开始的旧读请求在之后回填旧值。
4. 删除缓存的其他竞态
如果采用:
T1:删除 Redis
T2:读取数据库,读到 old
T3:数据库更新为 new
T2:把 old 写入 Redis
最终仍然可能得到旧缓存。
因此,单纯调整“先删缓存还是后删缓存”不能从理论上消除所有竞态。工程中常见的“延迟双删”是:
删除缓存
更新数据库
等待一段时间
再次删除缓存
第二次删除有机会清理并发读线程回填的旧值,但它依赖经验性延迟:
- 延迟太短,旧读线程可能还没写回;
- 延迟太长,旧值窗口变大;
- 删除消息丢失时仍然无法收敛;
- 进程在第二次删除前崩溃时仍可能留下旧值。
所以延迟双删是降低概率的工程方案,不是严格一致性证明。
四、使缓存更新可验证:版本、失效消息与写入顺序
1. 给缓存值附带版本
可以把数据库版本或更新时间写入缓存:
{
"version": 108,
"value": {
"id": 42,
"name": "Alice"
}
}
更新时,只有版本更高的结果才允许写入缓存。抽象规则是:
这能防止“旧读结果覆盖新读结果”。但它要求:
- 数据库或消息中存在可比较的单调版本;
- 写入缓存时能执行比较和更新;
- 比较与写入必须是一个原子操作。
Redis 中可以使用 Lua 脚本把“读取当前版本、比较、写入”放在一次原子执行中。Redis 脚本执行期间,其他客户端命令不会交错执行;但脚本也不能执行阻塞或长时间操作。
示意脚本:
local current = redis.call('GET', KEYS[1])
if not current then
redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3])
return 1
end
local current_version = tonumber(string.match(current, '"version":(%d+)'))
local incoming_version = tonumber(ARGV[1])
if incoming_version >= current_version then
redis.call('SET', KEYS[1], ARGV[2], 'EX', ARGV[3])
return 1
end
return 0
该示例假设 JSON 格式固定,生产代码不应依赖脆弱的字符串匹配解析复杂 JSON。更稳妥的做法是把版本和值分开存储,或使用可明确解析的编码格式。
2. 数据库事务与缓存操作不是同一个事务
下面两步通常不在同一个原子事务中:
BEGIN
UPDATE database
COMMIT
Redis.DEL(key)
如果 COMMIT 成功但应用进程在删除 Redis 前崩溃,旧缓存仍然存在。
常见的可靠化方式是事务后写出失效事件:
数据库事务:
更新业务表
写入 outbox 失效事件
提交
后台投递器:
读取 outbox
删除或更新 Redis
标记事件已处理
其中 outbox 表与业务更新处于同一个数据库事务,避免“业务提交成功但失效事件没有记录”。
如果使用 Redis Pub/Sub 传播失效消息,要注意其边界:订阅者断线期间可能错过消息。Pub/Sub 适合低延迟通知,但不能天然提供可靠重放。需要持久化、重试和消费进度时,应设计基于 Streams 或其他消息系统的失效事件处理,并处理重复消费。
3. 失效事件应当幂等
“删除键”通常天然幂等:
DEL key
DEL key
重复执行不会产生额外业务效果。
“写入缓存”则可能不是幂等的,尤其当事件乱序时:
事件 109:写入 new
事件 108:写入 old
因此事件消费者应使用版本检查,或者只发送删除事件,让下一次读请求从数据库重建。
五、缓存穿透:请求根本不存在的键
1. 穿透的定义
缓存穿透是指大量请求访问数据库和业务事实中都不存在的键:
请求 id = -1
├─ Redis 没有
├─ 数据库也没有
└─ 每次请求都再次查询数据库
它与“缓存未命中”不同:
- 普通未命中:查询后能得到真实数据,写回缓存即可;
- 穿透:查询结果始终为空,若不缓存空结果,请求会重复打到数据库。
攻击者可以构造大量随机 ID,使数据库承受无效查询。
2. 负缓存
最直接的方案是缓存不存在结果:
redis-cli SET user:not-found:999999 __NULL__ EX 10
# 预期输出:OK
读取逻辑:
cached = GET key
如果 cached == "__NULL__":
返回“资源不存在”
如果 cached 存在:
返回真实值
否则:
查询数据库
负缓存 TTL 通常应短于正常数据 TTL,因为“当前不存在”可能在未来变成“已创建”。负缓存值还必须和真实业务值可区分,不能把合法字符串 "__NULL__" 当作特殊标记。
3. 布隆过滤器
布隆过滤器用多个哈希函数和位数组判断某个键“可能存在”:
- 返回“不存在”:一定不存在;
- 返回“可能存在”:可能存在,也可能是误判。
因此它有:
- 假阳性:把不存在的键判断成可能存在;
- 正常设计下没有假阴性:已加入且位数组未损坏的键不应被判断为不存在。
过滤器可以在请求到 Redis 和数据库之前拦截明显无效的 ID,但要处理:
- 新数据写入时及时加入;
- 删除数据时,普通布隆过滤器不易删除;
- 数据重建时重新加载;
- 过滤器自身不可用时的降级;
- 误判只能增加后续查询,不能作为数据真实性证明。
布隆过滤器不能替代数据库约束,也不能单独实现一致性。
4. 参数校验与权限检查
如果资源 ID 的合法范围是正整数,那么:
id <= 0
可以在应用层直接拒绝,不必访问 Redis 或数据库。类似地,租户、权限和资源归属检查也可以减少无意义查询。
但不要只依赖参数校验防穿透。合法格式的随机 ID 仍然可能不存在,仍需负缓存或过滤器。
六、缓存击穿:一个热点键失效
1. 击穿与穿透的区别
缓存击穿通常指:
一个本来被大量访问的热点键
在某个时间点失效
大量并发请求同时发现未命中
所有请求一起查询数据库
穿透针对的是“不存在的数据”;击穿针对的是“存在但失效的热点数据”。
例如某商品详情每秒有一万次请求,缓存 TTL 到期后,若所有请求都执行:
GET miss
SELECT database
SET cache
数据库会接收大量相同查询。
2. 互斥重建
一种方式是只允许一个请求负责回源,其他请求等待或短暂重试。
锁可以用 Redis 的条件写入和过期时间实现:
redis-cli SET lock:rebuild:product:42 token-abc NX EX 5
返回:
OK:获得锁;(nil):锁已存在,未获得锁。
伪代码:
value = Redis.GET(product:42)
if value exists:
return value
if Redis.SET(lock_key, random_token, NX, EX 5) == OK:
try:
value = DB.SELECT(product 42)
Redis.SET(product:42, value, EX 60)
return value
finally:
仅当锁的值仍然是自己的 random_token 时删除锁
else:
sleep(短时间)
重试 Redis.GET
释放锁不能无条件执行:
DEL lock:rebuild:product:42
因为可能出现:
T1 获得锁,锁 TTL 到期
T2 获得新锁
T1 执行完毕,删除了 T2 的锁
正确释放需要比较令牌并删除,比较和删除必须原子执行。可用 Lua:
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
锁 TTL 必须覆盖正常重建时间,或者设计续期;但续期、进程暂停、网络分区和锁误删都会增加复杂度。锁只能减少并发回源,不能保证数据库更新与缓存的一致性。
3. 逻辑过期与后台刷新
另一种方案是不让请求看到“物理不存在”,而是在值内部记录逻辑过期时间:
{
"expire_at": 1710000000,
"value": {
"id": 42,
"stock": 7
}
}
读取时:
Redis 中有值
├─ expire_at 未到:直接返回
└─ expire_at 已到:
├─ 返回旧值,异步刷新
└─ 只有一个后台任务执行刷新
这实际上是“允许短时间返回旧值”换取稳定性,适合商品详情、配置、排行榜等可以容忍陈旧的数据。
它不适合:
- 余额;
- 库存扣减结果;
- 权限;
- 已撤销的安全凭证;
- 明确要求不可读旧值的数据。
4. 预热不是一致性保证
在已知热点键即将失效前主动刷新,可以减少击穿概率。但预热任务失败、实例重启、热点变化时仍会发生未命中,因此必须配合互斥重建或逻辑过期。
七、缓存雪崩:大量键或缓存系统同时失效
“雪崩”有两类常被混用的含义。
1. 大量键在相近时间过期
假设有 个缓存键,它们的 TTL 都是 3600 秒,并且在同一批任务中写入:
10:00:00 全部写入
11:00:00 大量同时过期
此时数据库压力近似集中在很短时间内。若每个键平均有 个并发请求,瞬时回源量可能接近:
并不等于平时的平均请求量。
TTL 加随机抖动
可以令实际 TTL 为:
其中:
- :基础 TTL;
- :抖动范围;
- :0 到 之间的随机数。
例如:
基础 TTL = 3600 秒
随机抖动 = 0 到 300 秒
实际 TTL = 3600~3900 秒
这样只能打散过期时间,不能解决:
- 所有键被同一批任务立即删除;
- Redis 整体不可用;
- 数据库本身已经过载;
- 热点键单独击穿。
2. Redis 整体不可用或大规模丢失
如果 Redis 连接超时、主节点故障、网络隔离或集群故障,所有请求可能同时绕过缓存访问数据库:
Redis 故障
└─ 每个请求都回源
└─ 数据库连接池耗尽
└─ 接口超时和重试放大
这时问题已不是“过期时间设计”,而是缓存依赖故障。
应用至少要区分:
- Redis 明确返回 miss;
- Redis 连接超时;
- Redis 返回错误;
- 数据库查询为空;
- 数据库查询失败。
不能把所有错误都简单当成 miss 后无限回源。应设置:
- 数据库连接池和查询超时;
- 回源并发上限;
- 按接口或资源限流;
- 熔断与快速失败;
- 可接受时返回旧值;
- Redis 故障期间的降级策略。
“缓存故障时直接访问数据库”只有在数据库容量确实能够承受时才安全。
八、缓存空值、真实值与错误必须分开
以下三种状态不能混淆:
1. Redis 命中真实值
2. Redis 命中“资源不存在”的负缓存
3. Redis 或数据库访问失败
错误示例:
Redis.GET 发生超时
应用把它当成 miss
应用查询数据库
数据库也超时
应用反复重试
这会把基础设施故障转化为回源风暴。
一个更清晰的状态模型是:
CACHE_HIT(value)
CACHE_NEGATIVE
CACHE_MISS
CACHE_ERROR
DB_FOUND(value)
DB_NOT_FOUND
DB_ERROR
只有 CACHE_MISS 才适合正常回源;CACHE_ERROR 应进入降级、限流或熔断逻辑;DB_ERROR 不能被写成负缓存,否则会把临时故障伪装成“数据不存在”。
九、多级缓存:L1、本地缓存、L2 Redis 与数据库
1. 基本结构
典型多级缓存如下:
客户端
│
应用实例
│
├─ L1:进程内本地缓存
│
├─ L2:Redis
│
└─ L3:数据库
读取流程:
先查 L1
├─ 命中:返回
└─ 未命中
再查 Redis
├─ 命中:写入 L1,返回
└─ 未命中
查询数据库
├─ 成功:写入 Redis,再写入 L1
└─ 不存在:写入短 TTL 负缓存
若每层独立命中概率为 ,在忽略异常和并发的简化模型下,数据库访问概率为:
数据库命中率 不会减少数据库访问次数,因为到达数据库的请求已经需要访问它;它只是描述数据库查询是否能返回业务数据。
L1 可以减少网络往返和 Redis QPS,但它也引入了每个应用实例各自持有旧副本的问题。
2. 多级缓存的失效竞态
假设:
L1 = old
Redis = old
DB = new
只删除 Redis:
DEL Redis:key
并不会自动删除其他应用实例中的 L1。之后请求可能直接从 L1 返回 old。
因此,多级缓存至少需要同时考虑:
数据库更新
├─ 删除或更新 Redis
└─ 通知所有应用实例删除 L1
通知可以使用:
- Redis Pub/Sub:低延迟,但断线期间可能丢消息;
- Redis Streams:可持久化消费记录,但需要处理消费组、积压、重复和重放;
- 数据库 CDC 或可靠消息系统:更适合需要可靠传播的场景。
L1 失效消息处理必须幂等:
收到 user:42 失效消息
└─ 删除本地 user:42
重复删除不会产生业务副作用。消息处理失败时,需要记录、重试或在本地 TTL 到期后自行收敛。
3. L1 的 TTL 应短于 L2
通常可设置:
L1 TTL = 5 秒
Redis TTL = 60 秒
这不是硬性规则,但它表达了一个常见取舍:
- L1 访问快,但实例间不容易实时同步;
- L2 共享程度更高,可以作为实例间的共同缓存;
- L1 TTL 越长,失效消息丢失时的陈旧窗口越大。
如果业务不允许返回旧值,短 TTL 也不够,必须在读取时做版本验证,或在写入后绕过缓存。
4. 多级缓存的击穿会分层发生
L1 未命中并不一定危险,因为请求可能命中 Redis;但如果大量实例的 L1 同时失效,而 Redis 也刚好失效,就会形成多层级联:
L1 同时失效
└─ 请求集中到 Redis
Redis 热点键失效
└─ 多个实例同时回源数据库
Redis 故障
└─ 所有实例绕过 Redis 回源数据库
所以互斥重建应明确作用范围:
- 每个实例本地锁:只能合并单实例请求;
- Redis 分布式锁:可以在多个实例间合并请求,但要处理锁安全;
- 数据库或网关层限流:控制最终回源总量。
十、一个可运行的 Redis 缓存读写示例
下面用 redis-cli 展示一个简化的商品缓存流程。假设:
- Redis 在本机
6379端口; - 数据库操作用注释表示,实际系统应替换为数据库事务;
product:42是正常缓存;product:missing:999是负缓存。
1. 写入缓存并设置 TTL
redis-cli DEL product:42
# 预期输出:0 或 1
redis-cli SET product:42 '{"id":42,"name":"keyboard","price":199}' EX 60
# 预期输出:OK
redis-cli GET product:42
# 预期输出:{"id":42,"name":"keyboard","price":199}
SET ... EX 60 将写入和设置过期时间合并为一个命令,避免应用在两条命令之间崩溃。
2. 模拟负缓存
redis-cli SET product:missing:999 __NULL__ EX 10
# 预期输出:OK
redis-cli GET product:missing:999
# 预期输出:__NULL__
应用读取时必须显式判断 __NULL__,不能把它当成真实商品数据。
3. 模拟热点键互斥锁
redis-cli SET lock:product:42 worker-a NX EX 5
# 第一个执行者预期输出:OK
redis-cli SET lock:product:42 worker-b NX EX 5
# 第二个执行者预期输出:(nil)
五秒后锁自动过期,避免持锁进程崩溃造成永久阻塞。但这不意味着锁一定安全:如果重建时间超过五秒,其他进程可能获得新锁,旧进程必须避免再删除或覆盖新持有者的锁。
十一、常见失败方案与失败表现
1. 只设置很长 TTL
失败表现:
数据库已更新
Redis 长时间返回旧值
原因是 TTL 只是自动过期,不是更新通知。
2. 只做缓存删除,不做并发控制
失败表现:
删除后数据库 QPS 突然升高
同一热点键被数百个请求同时查询
原因是删除只制造了 miss,没有合并并发回源。
3. 把数据库空结果当作错误,不缓存
失败表现:
随机无效 ID 持续访问数据库
数据库查询大量返回空结果
应结合参数校验、负缓存和布隆过滤器。
4. Redis 出错时无条件回源
失败表现:
Redis 集群故障
数据库连接池迅速耗尽
应用线程堆积并超时
缓存故障路径必须有回源限流、超时和降级,而不是简单地把异常转换为 miss。
5. L1 只设置 TTL,不传播失效
失败表现:
某实例一直返回旧值
Redis 已经是新值
其他实例正常
原因是 L1 是进程私有副本,Redis 删除不会自动通知它。
6. 用 Pub/Sub 作为唯一一致性依据
失败表现:
应用实例短暂断线
恢复后继续使用旧 L1 数据
Pub/Sub 断线期间的消息不会自动为该订阅者补发。需要可靠收敛时,应使用可重放消息、版本校验、较短 L1 TTL 或定期校准。
十二、如何诊断问题属于穿透、击穿还是雪崩
应同时观察缓存、应用和数据库三侧,而不能只看 Redis 命中率。
1. 穿透的信号
- 不存在键数量异常;
- 数据库查询大量返回空;
- 请求键高度随机;
- Redis 对这些键没有对应的负缓存;
- 数据库 QPS 随无效请求增长。
可以记录:
资源类型
请求 ID 是否合法
查询结果 found / not_found / error
是否命中负缓存
不要把完整用户输入无控制地作为日志字段,否则可能造成日志放大。
2. 击穿的信号
- 单个键访问频率极高;
- 该键在某个时间点发生过期或删除;
- 未命中后大量相同 SQL 同时出现;
- Redis 总体仍然可用;
- 数据库压力集中在少数资源。
应关联查看:
key
命中率
过期时间
回源并发数
回源耗时
重建锁竞争次数
3. 雪崩的信号
- 大量键在相近时间过期;
- Redis QPS、连接错误或超时同时异常;
- 数据库整体 QPS 突增,而不是少数键突增;
- 多个接口同时变慢;
- 应用线程池、连接池和重试数同步增长。
Redis 侧可先查看基础统计:
redis-cli INFO stats
redis-cli INFO memory
redis-cli INFO clients
命令输出中的指标名称和含义应以当前 Redis 版本文档为准。生产环境还应结合慢日志、客户端超时、网络监控、数据库连接池和应用请求链路分析,不能仅凭 keyspace_hits 与 keyspace_misses 下结论。
十三、部署边界:单实例、主从、集群都不自动解决一致性
1. Redis 单实例
单实例内,单条命令具有原子执行语义;多个命令之间仍可能被其他客户端插入。例如:
GET key
判断
SET key value
这三步不是一个不可分割的业务操作。
需要条件更新时,可使用:
SET ... NX或XX;WATCH、MULTI、EXEC进行乐观事务控制;- Lua 脚本执行多步原子逻辑。
Redis 事务的 EXEC 会按顺序执行事务队列中的命令,但事务本身不是数据库式回滚事务;某个命令运行时错误,不会自动撤销此前已经执行的命令。
2. 主从和集群
主从复制通常是异步的。写入主节点后,立即从副本读取可能读到旧值。若业务要求“写后读自己的写入”,需要:
- 写后继续读主节点;
- 按请求携带路由信息;
- 等待满足特定复制条件;
- 或使用应用层版本校验。
Redis Cluster 解决的是数据分片和部分故障转移问题,不会自动协调数据库与缓存之间的一致性,也不会自动让多个 L1 缓存失效。
因此必须明确:
缓存部署高可用 ≠ 缓存与数据库强一致
缓存主从复制 ≠ 数据库事务同步
Redis Cluster ≠ 全局缓存一致性协议
十四、如何选择策略
可以按数据特征组合策略,而不是套用一种模式。
1. 不存在且变化少的资源
适合:
参数校验
+ 负缓存
+ 可选布隆过滤器
+ 较短负缓存 TTL
2. 存在且访问量高、允许短暂旧值
适合:
逻辑过期
+ 后台刷新
+ 单键互斥重建
+ TTL 抖动
3. 更新频繁且不能读旧值
应谨慎使用普通 Cache-Aside。可考虑:
写数据库后可靠发布失效事件
+ 版本校验
+ 关键读请求绕过缓存或读主库
+ 明确写后读策略
如果无法接受失效消息延迟、丢失和并发回填带来的窗口,就不能把普通 Redis 缓存当作强一致读取源。
4. 多实例、低延迟要求高
可以增加 L1,但必须同时设计:
L1 本地 TTL
+ L1 失效通知
+ 消息丢失后的重新收敛
+ 版本或时间戳校验
+ Redis 故障时的回源限额
结语
缓存体系的核心不是某个 GET、SET 或 TTL 参数,而是围绕副本建立完整的数据流和故障路径:
数据库提交
→ 缓存更新或失效事件
→ Redis 副本变化
→ L1 副本失效
→ 并发请求回源
→ 失败时限流、降级与恢复
- 一致性关注缓存值何时可能旧、如何收敛以及是否允许旧读;
- 穿透关注不存在的数据如何避免反复查询事实来源;
- 击穿关注单个热点键失效时如何合并并发回源;
- 雪崩关注大量键同时失效或缓存整体故障时如何限制系统级压力;
- 多级缓存关注多个副本之间的失效传播、版本关系和故障边界。
只有把这些状态、竞态和恢复路径明确下来,Redis 才是可控的缓存层,而不是隐藏在数据库前面的另一处不确定性。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:Redis 复制、Sentinel 与 Cluster:槽位、故障转移和一致性
- 下一篇:Redis 分布式协调:锁、Lua、限流、Pub/Sub 与 Streams
- 延伸:Redis 数据结构完整指南:String、Hash、List、Set、ZSet 与 Stream
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论