前阵子团队聚餐,新来的实习生突然问我:”老师,您说分布式系统的一致性到底该怎么保证?我看网上都说CAP定理,但真到写代码的时候,感觉还是一头雾水。”他这一问,把我拉回了三年前那个折腾订单系统的深夜。当时我们为了解决一个库存超卖问题,整整熬了三个通宵,最后发现罪魁祸首竟是一个看似无害的重试机制。今天,我就结合这些年的踩坑经历,聊聊分布式一致性这个”说起来容易做起来难”的话题。

理论很美,现实很骨感

记得第一次接触CAP定理时,我信心满满地在架构评审会上说:”我们要强一致性,所以牺牲可用性!”结果被老板一句话怼回来:”用户下单页面打不开,你负责?”那一刻我才明白,CAP不是非黑即白的选择题,而是灰度空间的平衡艺术。

CAP定理真正的精髓在于:在网络分区(P)不可避免的前提下,一致性(C)和可用性(A)是连续光谱,不是二元开关。我们早期做金融转账系统时,为了追求强一致,用了两阶段提交(2PC)。结果一次机房网络抖动,所有事务协调器卡住,整个系统停摆20分钟。用户疯狂刷新页面,客服电话被打爆。那次之后我深刻认识到,强一致性的代价往往是系统的脆弱性

后来我们转向BASE理论(基本可用、软状态、最终一致性),才发现这才是互联网业务的真谛。但”最终”到底是多久?5秒?5分钟?这取决于业务容忍度。我们的订单系统要求用户在支付后10秒内看到订单状态更新,这个”10秒”就是业务给技术划的红线。

一致性模型:不是非黑即白的选择

在实际落地中,我发现把一致性分成几个等级来讨论更清晰:

1. 强一致性:就像银行转账,A扣款成功的那一刻,B必须立即到账。实现方式包括2PC、3PC、Paxos/Raft等。我们只在核心账务场景使用,配合完善的降级预案。

2. 读己之写一致性:用户刚发的微博,自己刷新一定能看到。这个看似简单,在读写分离架构下却容易踩坑。我们曾经遇到过主从延迟导致用户重复提交的问题,后来通过在Session里记录时间戳,强制读主库解决了。

3. 会话一致性:在同一个会话内保证一致性。电商购物车场景常用,用户添加商品后,结算页必须显示最新数据。

4. 最终一致性:系统保证在没有新更新的情况下,最终所有副本会达成一致。这是我们使用最广泛的模型,也是今天讨论的重点。

落地实战:订单系统的最终一致性方案

三年前那个深夜,我们的电商系统在大促期间出现了库存超卖。问题出在订单服务和库存服务之间没有做好一致性保障。订单创建成功了,但库存扣减消息丢了。用户付款后才发现没货,投诉量暴增。

痛定思痛,我们设计了一套基于可靠消息的最终一致性方案。核心思想是:本地事务+消息表+定时补偿

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
30
31
32
33
34
35
36
37
38
39
40
// 订单服务核心代码片段
@Service
public class OrderService {
@Autowired
private OrderMapper orderMapper;
@Autowired
private MessageQueueClient mqClient;
@Autowired
private TransactionMessageMapper messageMapper;

@Transactional
public CreateOrderResult createOrder(OrderDTO orderDTO) {
// 1. 创建订单(本地事务)
Order order = buildOrder(orderDTO);
orderMapper.insert(order);

// 2. 插入消息到本地消息表(同事务)
TransactionMessage msg = new TransactionMessage();
msg.setBusinessId(order.getId());
msg.setTopic("stock-deduction");
msg.setBody(JSON.toJSONString(buildStockDeduction(order)));
msg.setStatus(MessageStatus.PENDING);
messageMapper.insert(msg);

// 3. 事务提交后发送消息(关键!)
// 这里用TransactionSynchronization保证在事务成功后发送
TransactionSynchronizationManager.registerSynchronization(
new TransactionSynchronizationAdapter() {
@Override
public void afterCommit() {
mqClient.sendMessage(msg);
// 发送成功后更新消息状态
messageMapper.updateStatus(msg.getId(), MessageStatus.SENT);
}
}
);

return CreateOrderResult.success(order.getId());
}
}

库存服务消费端:

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
30
@Component
@RocketMQMessageListener(topic = "stock-deduction", consumerGroup = "stock-service")
public class StockDeductionListener implements RocketMQListener<String> {
@Autowired
private StockService stockService;
@Autowired
private IdempotencyChecker idempotencyChecker;

@Override
public void onMessage(String message) {
StockDeductionDTO dto = JSON.parseObject(message, StockDeductionDTO.class);

// 幂等性检查(防止消息重复消费)
if (idempotencyChecker.isProcessed(dto.getOrderId())) {
log.warn("重复消费消息,订单ID: {}", dto.getOrderId());
return;
}

try {
// 扣减库存(本地事务)
stockService.deductStock(dto);
// 标记为已处理
idempotencyChecker.markProcessed(dto.getOrderId());
} catch (Exception e) {
// 业务异常,记录日志并告警,等待人工介入
log.error("库存扣减失败,订单ID: {}", dto.getOrderId(), e);
throw e; // 抛出异常触发消息重试
}
}
}

文章插图

这套方案的关键在于:消息表和订单表在同一个数据库事务中,保证了”订单创建成功则消息一定记录成功”。即使消息发送失败,我们还有定时任务扫描PENDING状态的消息重新发送。

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
30
31
32
33
34
// 定时补偿任务
@Component
public class MessageCompensator {
@Autowired
private TransactionMessageMapper messageMapper;
@Autowired
private MessageQueueClient mqClient;

@Scheduled(cron = "0 */1 * * * ?") // 每分钟执行
public void resendPendingMessages() {
// 查询超过5分钟还在PENDING状态的消息
List<TransactionMessage> pendingMsgs =
messageMapper.findByStatusAndBefore(
MessageStatus.PENDING,
LocalDateTime.now().minusMinutes(5)
);

for (TransactionMessage msg : pendingMsgs) {
try {
mqClient.sendMessage(msg);
messageMapper.updateStatus(msg.getId(), MessageStatus.SENT);
log.info("补偿发送消息成功,消息ID: {}", msg.getId());
} catch (Exception e) {
log.error("补偿发送消息失败,消息ID: {}", msg.getId(), e);
// 失败次数过多则标记为DEAD,人工介入
if (msg.getRetryCount() > 10) {
messageMapper.updateStatus(msg.getId(), MessageStatus.DEAD);
// 发送告警
alertService.sendAlert("消息多次发送失败: " + msg.getId());
}
}
}
}
}

那些年我们踩过的坑

坑1:时钟不同步导致的数据错乱
我们曾用”最后写入获胜”策略解决并发更新,结果两台服务器时钟相差30秒,导致新数据被旧数据覆盖。排查了整整两天,最后发现是NTP服务配置错误。教训:分布式系统必须依赖逻辑时钟(如版本号、时间戳+机器ID),而不是物理时钟

1
2
3
4
5
6
// 使用版本号实现乐观锁
@Update("UPDATE inventory SET stock = stock - #{quantity}, version = version + 1 " +
"WHERE product_id = #{productId} AND version = #{version}")
int deductStockWithVersion(@Param("productId") Long productId,
@Param("quantity") Integer quantity,
@Param("version") Long version);

文章插图

坑2:重试风暴拖垮整个系统
网络抖动时,消息队列疯狂重试,库存服务的QPS瞬间飙升10倍,直接打崩数据库。我们后来加入了指数退避+熔断机制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 重试策略配置
@Bean
public RetryTemplate retryTemplate() {
RetryTemplate template = new RetryTemplate();
// 指数退避:1s, 2s, 4s, 8s...
ExponentialBackOffPolicy backOff = new ExponentialBackOffPolicy();
backOff.setInitialInterval(1000);
backOff.setMultiplier(2);
backOff.setMaxInterval(30000);
template.setBackOffPolicy(backOff);

// 最多重试5次
SimpleRetryPolicy retry = new SimpleRetryPolicy();
retry.setMaxAttempts(5);
template.setRetryPolicy(retry);

return template;
}

坑3:消息顺序性保证的代价
有个业务场景要求”先下单后支付”的消息必须顺序处理。我们最初用单个消费者保证顺序,结果消费速度上不去。后来发现80%的消息其实不需要严格顺序,于是改成业务ID取模分片+局部有序

1
2
3
// 按订单ID取模选择队列,保证同一订单的消息有序
int queueId = orderId % 8; // 8个队列
mqClient.sendOrderlyMessage(msg, queueId);

监控与兜底:一致性不能只靠相信

再完美的方案也可能出问题,所以我们建立了一套对账体系:

  1. 实时对账:用Flink实时比对订单和库存流水,发现不一致立即告警
  2. 日终对账:每天凌晨全量比对,生成差异报表
  3. 人工介入:对于无法自动修复的差异,提供运营后台手动处理
1
2
3
4
5
6
-- 每日对账SQL示例
SELECT o.order_id, o.status, s.stock_status
FROM orders o
LEFT JOIN stock_records s ON o.order_id = s.order_id
WHERE o.create_time >= CURDATE()
AND (o.status != s.stock_status OR s.order_id IS NULL)

监控指标上,我们重点关注:

  • 消息积压量:超过阈值就扩容消费者
  • 死信队列数量:反映业务异常率
  • 对账差异率:核心指标,超过0.01%就要启动紧急预案

写在最后

回顾这些年的实践,我最大的感悟是:分布式一致性没有银弹,只有适合业务场景的平衡点。强一致性听起来很美,但代价往往是系统的复杂度和脆弱性;最终一致性虽然简单,但需要业务方配合做补偿设计。

给正在踩坑的同行几点建议:

  1. 先搞清楚业务容忍度:支付系统差一分钱都不行,但社交点赞少一个用户可能无感知
  2. 监控先于优化:没监控就谈一致性,就像闭着眼睛开车
  3. 保持简单愚蠢:能用最终一致就不要上2PC,能用本地消息表就不要搞事务消息
  4. 设计降级预案:当一致性无法保证时,如何让业务损失最小化

那个折腾订单系统的深夜,最后我们在白板前写下了这句话:”Consistency is a journey, not a destination.”(一致性是旅程,不是终点)。它至今贴在我们的办公区,提醒着我们:在分布式世界里,完美的一致性或许不存在,但持续改进的方案永远在路上。

现在每当团队讨论一致性方案时,我都会先问三个问题:业务能容忍多少延迟?数据不一致的最大损失是什么?监控和回滚方案准备好了吗?这三个问题想清楚了,方案也就水到渠成。毕竟,架构设计不是炫技,而是让系统在不确定的世界里,尽可能确定地运行。