前言

数据库与缓存双写(或读写分离)极大提升了系统吞吐,但也带来了臭名昭著的“数据不一致”问题:脏读、过期数据、穿透、回源风暴、并发覆盖等。在高并发、复杂场景(订单、库存、账户、计费、风控)下,如何在性能与一致性之间取得最优平衡,是架构师必须掌握的核心能力。

本文从一致性模型、写读路径、工程化手段与失败恢复四个维度,分场景给出“强一致”和“最终一致”两大落地路线,并提供架构图、关键代码与上线检查清单。

一、一致性模型与约束

  • 强一致:写成功后,任何读都必须返回最新值。适用于资金、安全、强事务场景。
  • 因果/会话一致:同一会话内读到自己写。适用于用户偏好、草稿等弱事务业务。
  • 最终一致:在受控时间内收敛到一致。适用于大多数读多写少、可容忍短暂过期的业务。

一致性目标需与性能(P99延迟)、可用性(SLA)、成本(链路复杂度)共同评估,按域划分:强一致域(核心交易)与最终一致域(报告、榜单、聚合)。

二、常见架构模式与读写时序

核心抉择点:

  • 写时同步更新缓存(强一致)还是异步更新(最终一致)。
  • 读未命中是否需要读穿数据库并回填缓存,是否要加“重建保护”(互斥/单飞)。
  • 删除缓存 vs 更新缓存:更新粒度大、成本高时更倾向删除;简单KV则更新。

三、方案选型对比(写路径)

方案 一致性 吞吐 复杂度 典型场景
DB写成功后同步回写Redis 高(近强一致) 核心交易的热点配置、账户快照
延迟双删(写前del、写后延迟del) 读多写少、短暂过期可接受
Outbox + 可靠消息(同库事务) 最终一致(可证明) 中高 订单、库存、积分等异步扩散
Binlog订阅(Canal/Flink CDC) 最终一致 很高 中高 老系统改造、跨语言多消费者
逻辑版本/CAS + Lua原子更新 排他写、并发覆盖风险场景

关键结论:

  • “写DB+删缓存”优于“写DB+更新缓存”在高并发下的冲突概率,但需防止删失败/延迟窗口内脏读,常配合延迟二次删除或消息对账。
  • Outbox 是“可验证”的最终一致基线方案,优先级高于“最佳努力通知”。
  • Binlog 订阅适合异构/历史系统,但注意延迟与重放幂等。

四、强一致落地方案(事务读穿 + 回写)

特征:写路径同步更新缓存;读路径命中即返回,未命中则直读DB并回写;热点键采用互斥重建。

4.1 时序与关键点

  • 事务提交后再回写缓存,避免脏写;缓存设置合理TTL并带版本号/时间戳字段。
  • 读未命中时通过分布式互斥(如 Lua+SET NX PX)保护缓存重建,防击穿。
  • 写多副本场景用逻辑版本(version)防覆盖;读取携带版本以便写失败回滚。

4.2 Lua 互斥重建脚本(简化示例)

1
2
3
4
5
6
-- 获取互斥锁并设置过期,避免重建风暴
if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2]) then
return 1
else
return 0
end

4.3 Java 伪代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 写:DB 成功 -> 回写 Redis
@Transactional
public void updateUser(User user) {
userRepository.update(user); // MySQL
String cacheKey = "user:" + user.getId();
redis.set(cacheKey, serialize(user), ttlSeconds);
}

// 读:Cache -> DB -> 回填(带互斥)
public User getUser(Long id) {
String cacheKey = "user:" + id;
String cached = redis.get(cacheKey);
if (cached != null) return deserialize(cached);

String lockKey = "lock:" + cacheKey;
if (tryLock(lockKey, requestId, 3000)) {
try {
User db = userRepository.findById(id);
if (db != null) redis.setex(cacheKey, ttlSeconds, serialize(db));
return db;
} finally {
unlock(lockKey, requestId);
}
} else {
// 短暂等待后重试,避免并发击穿
sleep(50);
return getUser(id);
}
}

适用:强事务域、风控阈值、价格/限购等需强一致的数据。

五、最终一致落地方案(Outbox/CDC 驱动)

5.1 Outbox + 可靠消息

  • 在同一本地事务内:更新业务表 + 写入 outbox 事件表。
  • 事件轮询器可靠投递到 MQ;消费者按事件重建/删除缓存。
  • 幂等键:事件ID或业务ID+版本,避免重复投递/消费。
1
2
3
4
5
6
7
8
9
-- 事件表示例
CREATE TABLE outbox_event (
id BIGINT PRIMARY KEY,
aggregate_id BIGINT NOT NULL,
type VARCHAR(64) NOT NULL,
payload JSON NOT NULL,
status TINYINT NOT NULL DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Transactional
public void changeStock(Long skuId, int delta) {
stockRepo.add(skuId, delta); // 业务更新
outboxRepo.append(new Event("SKU_STOCK_CHANGED", skuId, payload));
}

// 投递器:保障至少一次
public void deliver() {
List<Event> events = outboxRepo.fetchPending(limit);
for (Event e : events) {
mq.send(e);
outboxRepo.markDone(e.getId());
}
}

// 消费者:重建/删除缓存(带幂等)
public void onMessage(Event e) {
if (idempotentRepo.seen(e.getId())) return;
switch (e.type) {
case "SKU_STOCK_CHANGED":
redis.del("sku:" + e.aggregateId);
break;
}
idempotentRepo.mark(e.getId());
}
  • 无需改动业务写路径;通过订阅 binlog 触发缓存更新/删除。
  • 需做好 schema 演进、重放窗口控制与幂等消费。

适用:系统耦合度高、难以改写;跨系统数据汇聚、搜索构建、报表聚合。

六、读路径优化与缓存治理

  • 缓存穿透:对不存在数据反复回源。治理:布隆过滤器、空值短TTL、接口校验。
  • 缓存击穿:热点Key过期瞬间高并发重建。治理:互斥锁/单飞、逻辑过期+后台异步刷新。
  • 缓存雪崩:大量Key同一时间过期。治理:TTL随机抖动、分区过期、热Key预热。
  • 热点Key保护:读多写少数据使用“读写分离+逻辑过期”降低写放大。

七、并发控制与幂等设计

  • 逻辑版本/CAS:更新条件携带版本,防覆盖。
  • 幂等键:消息ID、业务ID+版本,落表/缓存去重。
  • 原子性:使用 Lua 构建原子读改写;避免多次网络往返。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
-- Lua 原子更新带版本
local key = KEYS[1]
local newVal = ARGV[1]
local expectVer = tonumber(ARGV[2])
local cur = redis.call('HGETALL', key)
local curVer = 0
for i=1,#cur,2 do
if cur[i] == 'ver' then curVer = tonumber(cur[i+1]) end
end
if curVer == expectVer then
redis.call('HSET', key, 'val', newVal, 'ver', expectVer + 1)
return 1
else
return 0
end

八、失败场景与恢复策略

  • 写DB成功但删缓存失败:重试队列/补偿任务;Outbox对账。
  • MQ丢失或重复:开启持久化、消费幂等、死信重投、最大重试次数与人工干预通道。
  • 重建风暴:限流/舱壁;热点Key隔离;异步预热;逻辑过期后台刷新。
  • 版本漂移:写入时携带版本并校验;失败重试回读最新状态。

九、性能与稳定性评估指标

  • 写入P99延迟、读命中率、回源比例、重建耗时、互斥锁冲突率。
  • 事件积压时长、死信数、幂等冲突率。
  • 缓存体积、热点分布、过期抖动曲线。

十、实施清单(上线前 Checklist)

  1. 明确一致性域:强一致域与最终一致域名单与边界。
  2. 选型基线:强一致用“事务读穿+回写”;最终一致优先 Outbox。
  3. 幂等策略:消息ID/业务ID+版本,重复消费零副作用。
  4. 缓存治理:TTL随机抖动、热点互斥、布隆过滤器、逻辑过期策略。
  5. 监控告警:命中率、回源率、重建耗时、事件积压、死信、锁冲突。
  6. 压测基线:热点与均匀双场景;升压到1.5倍峰值;观察P99与抖动。
  7. 故障演练:MQ停机、Redis重启、DB主从切换、热点突发等演练剧本。

十一、典型落地组合

  • 强一致:核心交易域采用“直读DB+回写缓存+互斥重建+版本控制”。
  • 最终一致:订单/库存采用“Outbox + 可靠消息 + 幂等消费者 + 删缓存”。
  • 老系统改造:Canal 订阅 binlog 驱动缓存/索引更新,配幂等与重放窗口。

十二、FAQ

  • Q:写DB、删缓存谁先?
    • A:通常“先写DB,后删缓存”,失败补偿与延迟二次删除,避免先删导致短暂脏读窗口变大。
  • Q:更新还是删除缓存?
    • A:更新粒度大/复杂度高时删更稳;简单KV、强一致域可以直接更新。
  • Q:如何防止热点Key击穿?
    • A:互斥锁、逻辑过期、后台刷新、热点隔离与限流配合。

本文为架构师级别的实践手册,建议结合业务分域选择基线方案:强一致以“读穿+回写”为主,最终一致以“Outbox/CDC”为主;在此之上,以幂等、互斥、限流与监控为安全网,获得性能与一致性的最优解。