数据库基础体系 · 第 38/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

Redis 缓存体系:一致性、穿透、击穿、雪崩和多级缓存

Redis 常被用作缓存,但“把数据放进 Redis”并不等于建立了可靠的缓存体系。缓存同时引入了副本、过期、并发回源、故障降级和多层失效传播等问题。

本文围绕五个主题展开:

  • 缓存一致性:缓存副本与数据库事实之间如何定义、产生和收敛一致关系;
  • 缓存穿透:请求访问了不存在的数据,导致请求持续落到数据库;
  • 缓存击穿:某个高热度键失效,大量并发请求同时回源;
  • 缓存雪崩:大量缓存同时失效,或缓存整体不可用,造成系统级回源压力;
  • 多级缓存:本地缓存、Redis 和数据库组成多层数据路径后,如何处理命中、失效和故障。

示例默认 Redis 为缓存层,数据库为最终事实来源。除非特别说明,Redis 命令语义以官方稳定版本公开文档为准;示例中的事务边界、部署方式和一致性承诺不会被隐含扩大。


一、先定义缓存到底是什么

1. 缓存是可重建的副本

设数据库中的事实值为:

D(k,t)D(k,t)

表示键 k 在时间 t 的数据库值;Redis 缓存值为:

C(k,t)C(k,t)

缓存的基本关系是:

C(k,t)D(k,t)C(k,t) \approx D(k,t)

这里的“约等于”不是数学上的模糊表达,而是说明缓存通常不是事实来源。缓存可以:

  • 因 TTL 到期而不存在;
  • 因淘汰而不存在;
  • 因 Redis 故障而不可访问;
  • 暂时保存数据库的旧值;
  • 保存“这个键不存在”的结果,即负缓存。

因此,缓存设计首先要回答:

缓存允许旧到什么程度?缓存不可用时能否访问数据库?哪些数据不能被缓存?

2. 命中、未命中与回源

一个典型读请求经过以下状态:

请求
  │
  ├─ 缓存命中:返回缓存值
  │
  └─ 缓存未命中
       │
       ├─ 查询数据库
       ├─ 写回缓存
       └─ 返回数据库值

“回源”就是缓存未命中后访问数据库、文件系统或其他事实来源。

缓存命中率通常定义为:

HitRate=HH+MHitRate = \frac{H}{H+M}

其中:

  • HH:缓存命中次数;
  • MM:缓存未命中次数。

命中率高不代表系统一定安全。例如,一个热点键每分钟失效一次,在失效瞬间可能产生大量并发回源;总体命中率仍然很高,但数据库会被瞬时打垮。


二、缓存一致性:先确定“要保证什么”

1. 几种不同的一致性目标

“缓存一致性”不是单一标准,至少要区分以下目标。

读取不返回旧值

如果数据库写入已经成功,之后的读请求不能再返回旧缓存值。这是接近强一致的要求,通常需要:

  • 写数据库与缓存更新之间存在严格协调;
  • 或者读请求能验证缓存版本;
  • 或者写入后在一段时间内绕过缓存。

普通的 Cache-Aside 模式本身不自动提供这一保证。

最终一致

数据库更新后,缓存可能短时间仍是旧值,但在有限时间内会更新或失效:

Δ>0,tt+ΔC(k,t)=D(k,t)\exists \Delta > 0,\quad t' \le t+\Delta \Rightarrow C(k,t') = D(k,t')

这里的 Δ\Delta 是可接受的收敛时间,但只有在失效消息可靠、回源成功、没有旧值覆盖新值等条件成立时,这个结论才成立。

有界陈旧

如果系统允许缓存最多旧 Δ\Delta 时间,则必须同时控制:

  • 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

每一步的含义是:

  1. 先查 Redis;
  2. 命中则直接返回;
  3. 未命中才访问数据库;
  4. 查询成功后写入 Redis;
  5. 查询不到时可写入短时间负缓存。

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"
  }
}

更新时,只有版本更高的结果才允许写入缓存。抽象规则是:

vincomingvcached允许更新v_{\text{incoming}} \ge v_{\text{cached}} \Rightarrow \text{允许更新}

vincoming<vcached拒绝更新v_{\text{incoming}} < v_{\text{cached}} \Rightarrow \text{拒绝更新}

这能防止“旧读结果覆盖新读结果”。但它要求:

  • 数据库或消息中存在可比较的单调版本;
  • 写入缓存时能执行比较和更新;
  • 比较与写入必须是一个原子操作。

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. 大量键在相近时间过期

假设有 NN 个缓存键,它们的 TTL 都是 3600 秒,并且在同一批任务中写入:

10:00:00 全部写入
11:00:00 大量同时过期

此时数据库压力近似集中在很短时间内。若每个键平均有 qiq_i 个并发请求,瞬时回源量可能接近:

Qorigini=1NqiQ_{\text{origin}} \approx \sum_{i=1}^{N} q_i

并不等于平时的平均请求量。

TTL 加随机抖动

可以令实际 TTL 为:

TTL=T+U(0,J)TTL = T + U(0,J)

其中:

  • TT:基础 TTL;
  • JJ:抖动范围;
  • U(0,J)U(0,J):0 到 JJ 之间的随机数。

例如:

基础 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 负缓存

若每层独立命中概率为 h1,h2,h3h_1,h_2,h_3,在忽略异常和并发的简化模型下,数据库访问概率为:

P(DB)=(1h1)(1h2)P(\text{DB})=(1-h_1)(1-h_2)

数据库命中率 h3h_3 不会减少数据库访问次数,因为到达数据库的请求已经需要访问它;它只是描述数据库查询是否能返回业务数据。

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_hitskeyspace_misses 下结论。


十三、部署边界:单实例、主从、集群都不自动解决一致性

1. Redis 单实例

单实例内,单条命令具有原子执行语义;多个命令之间仍可能被其他客户端插入。例如:

GET key
判断
SET key value

这三步不是一个不可分割的业务操作。

需要条件更新时,可使用:

  • SET ... NXXX
  • WATCHMULTIEXEC 进行乐观事务控制;
  • Lua 脚本执行多步原子逻辑。

Redis 事务的 EXEC 会按顺序执行事务队列中的命令,但事务本身不是数据库式回滚事务;某个命令运行时错误,不会自动撤销此前已经执行的命令。

2. 主从和集群

主从复制通常是异步的。写入主节点后,立即从副本读取可能读到旧值。若业务要求“写后读自己的写入”,需要:

  • 写后继续读主节点;
  • 按请求携带路由信息;
  • 等待满足特定复制条件;
  • 或使用应用层版本校验。

Redis Cluster 解决的是数据分片和部分故障转移问题,不会自动协调数据库与缓存之间的一致性,也不会自动让多个 L1 缓存失效。

因此必须明确:

缓存部署高可用 ≠ 缓存与数据库强一致
缓存主从复制 ≠ 数据库事务同步
Redis Cluster ≠ 全局缓存一致性协议

十四、如何选择策略

可以按数据特征组合策略,而不是套用一种模式。

1. 不存在且变化少的资源

适合:

参数校验
+ 负缓存
+ 可选布隆过滤器
+ 较短负缓存 TTL

2. 存在且访问量高、允许短暂旧值

适合:

逻辑过期
+ 后台刷新
+ 单键互斥重建
+ TTL 抖动

3. 更新频繁且不能读旧值

应谨慎使用普通 Cache-Aside。可考虑:

写数据库后可靠发布失效事件
+ 版本校验
+ 关键读请求绕过缓存或读主库
+ 明确写后读策略

如果无法接受失效消息延迟、丢失和并发回填带来的窗口,就不能把普通 Redis 缓存当作强一致读取源。

4. 多实例、低延迟要求高

可以增加 L1,但必须同时设计:

L1 本地 TTL
+ L1 失效通知
+ 消息丢失后的重新收敛
+ 版本或时间戳校验
+ Redis 故障时的回源限额

结语

缓存体系的核心不是某个 GETSET 或 TTL 参数,而是围绕副本建立完整的数据流和故障路径:

数据库提交
  → 缓存更新或失效事件
  → Redis 副本变化
  → L1 副本失效
  → 并发请求回源
  → 失败时限流、降级与恢复
  • 一致性关注缓存值何时可能旧、如何收敛以及是否允许旧读;
  • 穿透关注不存在的数据如何避免反复查询事实来源;
  • 击穿关注单个热点键失效时如何合并并发回源;
  • 雪崩关注大量键同时失效或缓存整体故障时如何限制系统级压力;
  • 多级缓存关注多个副本之间的失效传播、版本关系和故障边界。

只有把这些状态、竞态和恢复路径明确下来,Redis 才是可控的缓存层,而不是隐藏在数据库前面的另一处不确定性。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。