Redis和数据库的一致性问题
1. 一致性问题的本质
1.1 先明确“真源”
在“Redis + 数据库”架构里,必须先约定谁是最终真源:
- 数据库为真源(最常见):
Redis只是加速读取的缓存,允许短暂不一致,但要可控、可修复。 Redis为真源:Redis里是主数据,数据库只是异步落盘或离线分析。这会引入持久化、故障切换与一致性更复杂的问题,面试中需要主动强调成本与风险。
本文默认 数据库为真源,并以最常见的 Cache Aside(旁路缓存)讨论一致性问题与工程解法。
1.2 面试里说的“一致性”到底指什么
一致性不是抽象口号,而是具体读写语义:
- 读己之写(Read Your Writes):我刚更新的数据,下一次我自己读能否立刻看到。
- 单调读(Monotonic Reads):同一用户连续读到的版本不能倒退。
- 有界陈旧(Bounded Staleness):允许旧数据,但要给出最大陈旧窗口(例如
<= 1s)。
缓存场景里最常见的目标是:有界陈旧 + 最终一致,并保证“不可见数据绝不能被看到”(权限、封禁、删除等属于强约束)。
1.3 旁路缓存的基本套路
旁路缓存的两个基本动作是:
- 读:先读缓存,未命中再读数据库,并回填缓存。
- 写:先写数据库,再删除缓存(或发消息让别人删除缓存)。
看起来简单,但一旦存在并发与失败重试,就会出现“脏读”“回写覆盖”“缓存与库永久不一致”等典型问题。
2. 常见错误写法与竞态分析
2.1 错误写法:先更新缓存,再更新数据库
这会在失败场景下造成永久不一致:
- 写请求把新值写入缓存成功。
- 写数据库失败(超时、回滚、主从切换等)。
- 后续读都命中缓存,看到一个数据库不存在的“幻值”。
因此,除非缓存是主存储(write-through 且有强事务保障),否则不要“先写缓存再写库”。
2.2 写路径竞态:先删缓存,再写数据库(Delete Then Update)
这是很多人直觉会写的顺序,但它有明显竞态:
- 线程 A 删除缓存。
- 线程 B 读请求未命中缓存,回源数据库读到旧值,并回填缓存。
- 线程 A 更新数据库为新值。
结果:缓存里是旧值,且会持续到缓存过期,读到的都是旧数据。
2.3 推荐顺序:先写数据库,再删缓存(Update Then Delete)
这能避免 2.2 的“必现竞态”,但仍可能出现短暂不一致:
- 线程 A 更新数据库提交成功。
- 线程 B 在 A 删除缓存之前读取缓存,命中旧值。
- 线程 A 删除缓存。
这个不一致窗口一般很短,工程上通过“删除缓存重试、延迟双删、消息驱动失效”把窗口进一步缩小并可观测。
2.4 更隐蔽的竞态:并发回填覆盖新值
即使写路径是“更新库 → 删缓存”,读路径仍可能把旧值回填回去:
- 线程 B 读未命中,开始回源数据库(此时读到旧值)。
- 线程 A 更新数据库提交,并删除缓存。
- 线程 B 把旧值写入缓存(回填发生在 A 之后)。
结果:缓存再次变旧。这个问题不能只靠“顺序正确”解决,通常要引入 版本号、逻辑过期、互斥回填 等机制。
3. 标准解法一:旁路缓存的“正确读写姿势”
3.1 读路径:穿透、击穿、雪崩的处理
读路径推荐分三层说明:
- 缓存穿透:查不存在的 key。用布隆过滤器(
Bloom Filter)或空值缓存(短 TTL)解决。 - 缓存击穿:热点 key 过期瞬间大量并发回源。用互斥(分布式锁 / 本地 singleflight)或逻辑过期解决。
- 缓存雪崩:大量 key 同时过期。用 TTL 随机抖动、分批预热、热点隔离解决。
3.2 写路径:更新数据库后删除缓存
推荐的最小写路径是:
- 更新数据库(事务提交成功)。
- 删除缓存(允许失败,失败要可重试或可补偿)。
3.3 延迟双删:缩小竞态窗口,但不是银弹
延迟双删常见做法:
- 更新数据库。
- 删除缓存。
- 延迟
T毫秒后再次删除缓存。
第二次删除用于覆盖 2.4 中的“并发回填覆盖”场景。T 一般取决于读路径最大耗时上界,例如 T = p99(db_read) + p99(cache_set)。
延迟双删的关键风险是:它依赖“延迟任务一定执行”,因此要么走可靠队列,要么走可重试的定时任务,不建议用线程 sleep 草率实现。
4. 标准解法二:消息驱动的缓存失效
4.1 为什么要用消息:把“删缓存失败”变成可观测的异步任务
写路径同步删缓存有两个问题:
- 缓存服务抖动会拉高写接口延迟,甚至让写请求失败。
- 删除失败容易被吞掉,导致缓存长期陈旧且无人知晓。
用 MQ(如 Kafka)做“缓存失效事件”后,删除动作可重试、可告警、可堆积观测。
4.2 Transactional Outbox:保证“写库成功就一定发出失效事件”
经典难点是“数据库更新成功,但消息没发出去”,或者“消息发出但数据库回滚”。推荐的工程做法是 事务外箱(Transactional Outbox):
- 业务事务里同时写入业务表与 outbox 表。
- 由后台任务扫描 outbox 表,把事件可靠投递到 MQ。
- 消费者收到事件后删除缓存(幂等)。
CREATE TABLE outbox_event (
id BIGINT NOT NULL AUTO_INCREMENT,
event_type VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
status TINYINT NOT NULL, -- 0:待投递 1:已投递
created_at DATETIME(3) NOT NULL,
PRIMARY KEY (id),
KEY idx_status_time (status, created_at)
) ENGINE=InnoDB;
写事务示意:
START TRANSACTION;
UPDATE user SET name = 'alice' WHERE user_id = 1;
INSERT INTO outbox_event(event_type, payload, status, created_at)
VALUES ('USER_UPDATED', JSON_OBJECT('user_id', 1), 0, NOW(3));
COMMIT;
4.3 基于 binlog 的 CDC:把“缓存失效”当作数据变更订阅
如果系统已建设 CDC(Change Data Capture),例如订阅 MySQL binlog:
- 变更一旦提交,就会出现在 binlog。
- CDC 组件解析变更事件,驱动缓存失效或缓存回填。
CDC 的优点是侵入小、覆盖面广;缺点是链路更长、运维复杂度更高,且需要处理重复、乱序、延迟与回放。
4.4 消费端必须回答的三件事:幂等、乱序、重试
- 幂等:同一个
user_id更新事件重复投递不应产生副作用,删除缓存天然幂等,但“回填缓存”就必须做版本控制(见 5.2)。 - 乱序:先到的新事件可能被旧事件覆盖,因此事件里最好带
version或updated_at。 - 重试与死信:删除失败要重试,重试次数超过阈值进入死信队列(DLQ),并触发告警。
5. 标准解法三:版本号与 CAS,防止旧值覆盖新值
5.1 引入版本号的原则
要解决 2.4 的“并发回填覆盖”,本质是:缓存写入必须能判断“我写的是不是最新版本”。
常见做法:
- 数据库表增加
version(乐观锁)或使用updated_at(毫秒级时间)。 - 缓存 value 携带版本号,例如
{"v":123,"data":...}。
5.2 Redis Lua:只允许写入更大版本
下面示例用 Lua 保证原子比较与写入:
# KEYS[1] = cache key
# ARGV[1] = newVersion
# ARGV[2] = newValue
# ARGV[3] = ttlSeconds
local key = KEYS[1]
local newVersion = tonumber(ARGV[1])
local newValue = ARGV[2]
local ttl = tonumber(ARGV[3])
local old = redis.call('GET', key)
if not old then
redis.call('SETEX', key, ttl, newVersion .. '|' .. newValue)
return 1
end
local sep = string.find(old, '|')
local oldVersion = tonumber(string.sub(old, 1, sep - 1))
if newVersion >= oldVersion then
redis.call('SETEX', key, ttl, newVersion .. '|' .. newValue)
return 1
end
return 0
要点:
- 把“比较版本 + 写缓存”做成一个原子操作,避免并发窗口。
- 版本来源必须单调递增(例如数据库的
version字段),不要用本地时间戳随便拼。
5.3 什么时候需要版本号,什么时候不需要
- 只做删缓存:一般不需要版本号,因为
DEL幂等且不会把旧值写回去。 - 会回填或预热缓存:强烈建议引入版本号,否则迟到的回填可能覆盖新值。
6. 什么时候必须强一致:缓存要“让路”
有些场景不能用“最终一致”糊弄:
- 权限与封禁:例如用户被封禁后,敏感数据不能再被任何人读到。
- 资金类强一致:例如扣款余额、库存严控等(即使做缓存,也要把一致性机制讲成强一致方案)。
工程上常见策略是:这些关键读直接走数据库或走带事务语义的存储,并把缓存限定为“非关键字段”或“只读加速”。
7. TTL、逻辑过期与热点治理(面试加分点)
7.1 TTL 不是一致性方案,但能给出“上界”
TTL 不能保证正确性,但能保证“最坏多久会变对”。建议:
- TTL 加随机抖动:例如
baseTTL ± random(0, 60s),防止雪崩。 - 热点 key 单独策略:热点数据可以更短 TTL + 主动预热。
7.2 逻辑过期:用“旧值可用 + 后台刷新”抗击穿
逻辑过期的关键思想是:
- 缓存里存
{data, expireAt},即使过期也先返回旧值。 - 只让一个请求触发后台刷新,其他请求继续用旧值,保证高可用与低延迟。
逻辑过期牺牲的是一致性,换来的是稳定性,适合“允许短暂陈旧”的业务。
8. 方案选型对比
| 方案 | 一致性水平 | 复杂度 | 典型问题 | 适用场景 |
|---|---|---|---|---|
| 更新 DB → 删除缓存 | 最终一致(短窗口) | 低 | 删除失败导致长期旧值 | 绝大多数业务 |
| 延迟双删 | 最终一致(更小窗口) | 中 | 依赖延迟任务可靠执行 | 中等一致性要求 |
| MQ 失效通知 | 最终一致(可观测可重试) | 中高 | 乱序、重复、堆积 | 中大型系统主流 |
| Transactional Outbox | 最终一致(更可靠) | 高 | 扫描投递、补偿复杂 | 强可靠事件链路 |
| binlog CDC 失效/回填 | 最终一致(全局覆盖) | 高 | 链路长、延迟、运维重 | 数据平台成熟团队 |
| 版本号 + CAS 回填 | 有界陈旧(更稳) | 中高 | 版本来源、序列化开销 | 有回填/预热需求 |
9. 面试精简回答
Redis 和数据库一致性本质是双写问题,我会先明确数据库是最终真源,缓存只做加速。旁路缓存的写路径用“更新数据库后删除缓存”,避免先删缓存再写库导致的竞态;为应对并发读回填覆盖,我会用延迟双删或引入版本号,必要时用 Lua 做 CAS 防止旧值覆盖新值。为了让“删缓存失败”可重试可观测,我更推荐用 MQ 做缓存失效通知,生产端用 Transactional Outbox 或 binlog CDC 保证提交后一定能发出事件,消费端处理幂等、乱序与重试。对于权限、封禁这类强约束数据,读路径必须校验或直接走数据库,不能靠 TTL 兜底。