强一致性 vs 最终一致性
2023年双十一,我们的秒杀系统出现了 37 笔超卖。
用户下单成功、支付成功,但库存已经没了。排查了整整 2 个小时,发现是缓存和数据库之间的数据不一致导致的:Redis 里显示还有库存,但 MySQL 里已经扣到 0 了。
37 笔订单,涉及金额 12 万元。
这不是一个简单的 bug,这是一个架构问题:我们选了最终一致但没做兜底。
今天,我们把强一致性和最终一致性彻底讲透。
一、两种一致性的本质定义
一致性(Consistency)在分布式系统里有多种定义,这里讨论的是数据一致性——即多个副本之间的数据是否相同。
强一致性(Strong Consistency)
定义:任何时刻,所有客户端读到的都是同一个(最新的)值。写操作完成后,任何后续读操作都返回写入的值。
强一致性保证:
T0: 写入 X = 5
T1: 任何节点读取 X → 必须返回 5
T2: 写入 X = 10
T3: 任何节点读取 X → 必须返回 10
典型实现:
- 2PC/3PC 分布式事务
- Raft/Paxos 共识算法
- 同步复制的主从架构
最终一致性(Eventual Consistency)
定义:如果不再有新写入,那么最终所有副本会收敛到同一个值。但在收敛之前,不同客户端可能读到不同的值。
最终一致性保证:
T0: 写入 X = 5
T1: 节点A读 X → 可能返回 5 或旧值
T2: 节点B读 X → 可能返回 5 或旧值
T3: 写入停止
T4: 经过足够长的时间
T5: 所有节点读取 X → 一定返回 5
典型实现:
- DNS 系统(域名解析的 TTL 更新)
- Cassandra 的宽松一致性
- Redis 主从异步复制
- 消息队列的异步消费
【架构权衡】
强一致性和最终一致性的核心权衡是延迟与可用性。强一致性需要同步确认(每次写入都要多数派确认),延迟高但数据准。最终一致性允许异步复制(写入后立即返回),延迟低但数据可能有窗口期。
二、最终一致性的五种变体
很多人以为"最终一致"就是"不管了,等着就行"。其实最终一致性有非常精细的分类:
2.1 因果一致性(Causal Consistency)
保证有因果关系的操作顺序一致,无因果关系的操作可以乱序。
场景:微博评论
- 用户A发了一条微博(因果关系:先发才能评论)
- 用户B看到微博后评论(因果关系:评论依赖微博存在)
- 用户C看到评论(无因果关系:可能看不到)
因果一致性保证:B一定在A之后看到,但C的看到时间不确定
2.2 读己之所写(Read Your Writes)
保证自己写入的数据自己一定能看到。
场景:修改个人资料
- 用户A修改昵称为"张三"
- 用户A刷新页面
- 必须看到"张三",不能还看到旧昵称
读己之所写是最终一致系统中最基本的要求
如果用户改完看不到自己的改动,会非常困惑
2.3 会话一致性(Session Consistency)
保证在同一个会话内满足读己之所写。会话结束后,下一个会话可能看到旧数据。
场景:电商购物车
- 用户A在会话中把商品加入购物车
- 刷新页面,必须看到商品在购物车里
- 会话结束后,下次访问可能短暂看不到(下一个会话开始前)
会话一致性好比:你在一个店里逛,你的购物车状态你自己说了算
2.4 单调读一致性(Monotonic Read)
保证如果客户端读取了某个值,后续读取不会读到更旧的值。
场景:社交Feed
- 第一次读:看到帖子A、B
- 第二次读:必须看到 A、B 以及可能的新帖子
- 绝对不能:第二次读只看到 A(比第一次还少)
单调读 = 不会出现"时光倒流"现象
2.5 单调写一致性(Monotonic Write)
保证同一个客户端的写操作按顺序执行,不会乱序。
场景:日志写入
- 写日志 L1
- 写日志 L2
- 最终状态必须是 L1 + L2,不会出现只有 L2 的状态
单调写保证:写操作的串行化
📖 一致性模型强度层次
最强 ────────────────────────────── 最弱
线性一致性(Linearizability)
↓
顺序一致性(Sequential Consistency)
↓
因果一致性(Causal Consistency)
↓
读己之所写(Read Your Writes)
↓
会话一致性(Session Consistency)
↓
单调读(Monotonic Read)
↓
单调写(Monotonic Write)
↓
最终一致性(Eventual Consistency)
从线性一致性到最终一致性,一致性强度递减,但性能(延迟)递增。
三、强一致性实现:从 Raft 到 2PC
3.1 Raft 共识算法
Raft 是强一致性的工业标准实现,核心是Leader 复制:
graph TD
A[客户端写入] --> B[Leader 节点]
B --> C[并行发送给所有 Follower]
C --> D{收到多数派确认?}
D -->|是| E[应用到状态机<br/>返回客户端成功]
D -->|否| F[返回客户端失败<br/>触发重新选举]
// Raft 写入流程简化实现
public class RaftNode {
public AppendEntriesResult append(String key, String value) {
// 1. 如果不是 Leader,转发到 Leader
if (state != State.LEADER) {
return redirectToLeader();
}
// 2. 构造日志条目
LogEntry entry = new LogEntry(term, key, value);
log.append(entry);
// 3. 并行发送 AppendEntries 到所有 Follower
List<Future<Boolean>> responses = sendToAllFollowers(entry);
// 4. 等待多数派确认
int successCount = 1; // Leader 自己算一票
for (Future<Boolean> f : responses) {
if (f.get(timeout)) {
successCount++;
}
}
// 5. 多数派确认后,应用到状态机
if (successCount > totalNodes / 2) {
stateMachine.apply(entry);
return SUCCESS;
}
return FAILURE;
}
}
代价分析:
3.2 同步复制 vs 异步复制
同步复制(强一致):
写入 → Leader → 等待所有 Follower 确认 → 返回成功
延迟 = N × RTT(N=节点数,RTT≈1~5ms)
半同步复制(折中):
写入 → Leader → 等待至少一个 Follower 确认 → 返回成功
延迟 = 1~2 × RTT
异步复制(最终一致):
写入 → Leader → 立即返回成功(后台同步到 Follower)
延迟 = 0(客户端感知)
MySQL 的 binlog 复制模式就是这三种的典型代表:
-- 同步复制(safe mode)
set rpl_semi_sync_source_wait_point = 'AFTER_SYNC';
-- 写入 binlog → 同步到从库 → 从库确认 → 提交事务 → 返回
-- 异步复制
set rpl_semi_sync_source_wait_point = 'AFTER_COMMIT';
-- 写入 binlog → 提交事务 → 返回(后台同步到从库)
四、生产事故:最终一致性踩坑实录
4.1 事故一:缓存双写导致超卖(开篇案例)
根因分析:
1. 库存服务:先扣 Redis,再扣 MySQL(错误顺序)
2. 如果 Redis 扣成功,MySQL 扣失败 → Redis 有库存但 MySQL 没库存
3. 另一个请求查到 Redis 有库存,下单成功,但库存实际为 0
正确做法:
- 方案A:先扣 MySQL,再扣 Redis(但 Redis 失败需要回补 MySQL)
- 方案B:只扣 MySQL,用 Canal 同步到 Redis
- 方案C:用分布式事务(Seata)保证两者原子性
⚠️
"缓存双写"是生产环境中最常见的一致性陷阱。缓存和数据库是两个独立的存储系统,没有天然的事务保证。正确的做法是:用 Canal/Binlog 同步(消费方可靠性),或者接受最终一致(窗口期内可能有偏差)。
4.2 事故二:库存缓存击穿
场景:Redis 库存为 0,但数据库里还有库存(Redis 被错误设置为 0)
时间线:
T0: 误操作把 Redis 库存设为 0
T1: 用户查询库存 → Redis 返回 0 → 商品显示"缺货"
T2: 大量用户看到缺货 → 转化率暴跌
T3: 运营发现 → 手动修复 Redis
T4: 30 分钟后才恢复正常
教训:
- 缓存设置需要有变更审批流程
- 需要有缓存与数据库的一致性监控
- 需要有缓存的快速回源机制
4.3 事故三:消息丢失导致数据不一致
场景:订单完成后发送消息到 MQ,消费者扣库存
问题链条:
- 订单服务:订单落库成功,发送 MQ 消息(但 MQ 消息发送失败)
- 消息没发出去 → 库存永远不扣
- 用户付了钱,商品没预留
正确做法:
- 使用 RocketMQ 事务消息(half message 机制)
- 或者使用本地消息表(订单落库 + 消息落库在同一个事务)
五、工程选型矩阵
六、一致性检测工具
6.1 缓存数据库一致性检测
-- 定期对比 Redis 和 MySQL 的库存数据
SELECT
p.id, p.stock AS db_stock,
GET(p.id) AS cache_stock,
CASE WHEN p.stock != GET(p.id) THEN '不一致' ELSE '一致' END AS status
FROM products p
WHERE p.updated_at > DATE_SUB(NOW(), INTERVAL 5 MINUTE);
6.2 跨机房复制延迟检测
// 写入带时间戳的行,定期检测各节点时间戳差距
public class ReplicaLagMonitor {
public void detectLag() {
// 在主库写入带时间戳的检测行
String ts = String.valueOf(System.currentTimeMillis());
master.execute("INSERT INTO lag_test (ts) VALUES (?)", ts);
// 各从库检测延迟
for (Replica replica : replicas) {
String replicaTs = replica.query("SELECT ts FROM lag_test ORDER BY id DESC LIMIT 1");
long lag = System.currentTimeMillis() - Long.parseLong(replicaTs);
if (lag > MAX_ACCEPTABLE_LAG) {
alert("从库 " + replica + " 延迟 " + lag + "ms,超过阈值");
}
}
}
}
七、工程代价评估
【架构权衡】
选强一致还是最终一致,本质上是在回答一个问题:这个数据不一致的代价有多高?
- 扣款/库存:代价极高 → 强一致
- Feed/评论:代价低 → 最终一致
- 配置/路由:代价极高 → 强一致
但现实往往更复杂:同一个系统里,不同数据需要不同的一致性级别。 订单支付链路选强一致,商品评论选最终一致,配置中心选强一致。这才是一个成熟系统的做法。
八、落地 Checklist