Skip to content

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 错误写法:先更新缓存,再更新数据库

这会在失败场景下造成永久不一致

  1. 写请求把新值写入缓存成功。
  2. 写数据库失败(超时、回滚、主从切换等)。
  3. 后续读都命中缓存,看到一个数据库不存在的“幻值”。

因此,除非缓存是主存储(write-through 且有强事务保障),否则不要“先写缓存再写库”。

2.2 写路径竞态:先删缓存,再写数据库(Delete Then Update)

这是很多人直觉会写的顺序,但它有明显竞态:

  1. 线程 A 删除缓存。
  2. 线程 B 读请求未命中缓存,回源数据库读到旧值,并回填缓存。
  3. 线程 A 更新数据库为新值。

结果:缓存里是旧值,且会持续到缓存过期,读到的都是旧数据。

2.3 推荐顺序:先写数据库,再删缓存(Update Then Delete)

这能避免 2.2 的“必现竞态”,但仍可能出现短暂不一致:

  1. 线程 A 更新数据库提交成功。
  2. 线程 B 在 A 删除缓存之前读取缓存,命中旧值。
  3. 线程 A 删除缓存。

这个不一致窗口一般很短,工程上通过“删除缓存重试、延迟双删、消息驱动失效”把窗口进一步缩小并可观测。

2.4 更隐蔽的竞态:并发回填覆盖新值

即使写路径是“更新库 → 删缓存”,读路径仍可能把旧值回填回去:

  1. 线程 B 读未命中,开始回源数据库(此时读到旧值)。
  2. 线程 A 更新数据库提交,并删除缓存。
  3. 线程 B 把旧值写入缓存(回填发生在 A 之后)。

结果:缓存再次变旧。这个问题不能只靠“顺序正确”解决,通常要引入 版本号、逻辑过期、互斥回填 等机制。

3. 标准解法一:旁路缓存的“正确读写姿势”

3.1 读路径:穿透、击穿、雪崩的处理

读路径推荐分三层说明:

  • 缓存穿透:查不存在的 key。用布隆过滤器(Bloom Filter)或空值缓存(短 TTL)解决。
  • 缓存击穿:热点 key 过期瞬间大量并发回源。用互斥(分布式锁 / 本地 singleflight)或逻辑过期解决。
  • 缓存雪崩:大量 key 同时过期。用 TTL 随机抖动、分批预热、热点隔离解决。

3.2 写路径:更新数据库后删除缓存

推荐的最小写路径是:

  1. 更新数据库(事务提交成功)。
  2. 删除缓存(允许失败,失败要可重试或可补偿)。

3.3 延迟双删:缩小竞态窗口,但不是银弹

延迟双删常见做法:

  1. 更新数据库。
  2. 删除缓存。
  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)。
  • 乱序:先到的新事件可能被旧事件覆盖,因此事件里最好带 versionupdated_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 兜底。