4、分布式一致性:分布式事务与幂等性设计

聊到分布式系统,一致性是个绕不开的话题。我早年做单体应用时,事务就是个begin transactioncommit的事。但到了微服务架构下,数据散落在不同节点,怎么保证大家看到的数据是一样的?嗯,这就是我们今天要啃的硬骨头。

核心矛盾:分布式系统追求高可用,就不得不面对网络分区和节点故障。CAP理论告诉我们,一致性(C)、可用性(A)、分区容错性(P)三者不可兼得。实际生产中,我们通常选择AP,然后通过最终一致性来弥补。

4.1 分布式事务:从2PC到Saga

分布式事务,说白了就是让多个数据库的操作要么全成功,要么全失败。我见过不少团队一上来就上2PC,结果被性能坑得很惨。

4.1.1 两阶段提交(2PC)

2PC是最经典的方案。它有个协调者,负责问所有参与者:“准备好了吗?”

  • 第一阶段(准备阶段):协调者问所有节点,能不能提交。每个节点写日志、加锁,然后回复Yes或No。
  • 第二阶段(提交/回滚):如果所有人都说Yes,协调者通知提交。但凡有一个说No,就全部回滚。

我在项目中遇到过一个问题:协调者挂了怎么办?参与者会一直锁着资源,直到超时。这就是2PC的阻塞问题。所以,2PC适合短事务、低并发的场景,比如跨行转账。

避坑指南:我曾经在一个高并发订单系统里用2PC,结果数据库连接池被打满。后来发现,2PC的锁持有时间太长,并发一上来就死锁。记住,2PC不适合长事务和高并发。

4.1.2 TCC(Try-Confirm-Cancel)

TCC是对2PC的改进。它把每个操作拆成三步:Try(预留资源)、Confirm(确认执行)、Cancel(取消回滚)。

举个例子,下单扣库存:

  • Try:冻结库存,比如用户下单10件,先把这10件锁住,不让别人用。
  • Confirm:用户支付成功,真正扣减库存。
  • Cancel:用户取消订单,释放冻结的库存。

TCC的好处是不用锁数据库,性能比2PC好。但坏处是业务侵入性强——你得为每个操作写三个接口。我建议,只有核心链路才用TCC,非核心业务用最终一致性就够了。

4.1.3 Saga模式

Saga是另一种思路。它把一个长事务拆成多个本地事务,每个本地事务都有对应的补偿操作。如果某个步骤失败,就反向执行补偿操作。

比如一个旅游订单:订机票 → 订酒店 → 租车。如果酒店订失败了,就取消机票(补偿操作)。

Saga有两种实现方式:

  • 编排式(Choreography):每个服务监听事件,自己决定下一步。适合简单流程。
  • 协调式(Orchestration):有个协调者统一调度。适合复杂流程,但协调者可能成为单点。

个人经验:我习惯用协调式Saga,因为流程可视化,出问题好排查。但要注意,补偿操作必须是幂等的,否则重复执行会出大问题。

4.2 最终一致性方案

你想想看,如果每个操作都要强一致,系统性能肯定上不去。所以,很多场景我们接受最终一致性——数据在一段时间后达到一致。

常见的实现方式:

  • 本地消息表:把要发送的消息先存到本地数据库,然后异步发送。如果发送失败,定时任务重试。
  • 消息队列(MQ):用RocketMQ或Kafka,生产者发消息,消费者消费。MQ自带重试机制。
  • 事件驱动:服务A发布事件,服务B订阅事件。事件丢失了怎么办?嗯,加个事件回溯机制。

我在项目中遇到过一个问题:消息重复消费。比如支付成功消息发了两次,结果用户账户被加了两次钱。怎么解决?幂等性设计。

4.3 幂等性设计

幂等性,说白了就是同一个操作执行多次,结果是一样的。比如支付接口,用户点了一次和点了十次,只扣一次钱。

实现幂等性的常见方法:

  • 唯一键约束:数据库表加唯一索引,比如订单号。重复插入会报错,但不会产生重复数据。
  • 状态机:订单状态从“待支付”到“已支付”,只能单向流转。如果已经是“已支付”,再次支付就忽略。
  • 去重表:专门建一张表存请求ID,处理前先查一下有没有处理过。

核心原则:幂等性设计要覆盖所有入口。我曾经漏掉了MQ的重试消息,结果数据对不上,查了两天才找到原因。记住,任何可能重复执行的地方,都要做幂等。

4.4 分布式锁

分布式锁,就是让多个服务互斥地访问某个资源。比如秒杀场景,多个节点同时扣库存,不加锁就超卖了。

4.4.1 Redis分布式锁

Redis实现分布式锁,最常用的是SETNX命令。但要注意几个坑:

  • 死锁问题:锁必须设置过期时间,防止客户端挂了锁不释放。
  • 锁续期:如果业务执行时间超过锁的过期时间,锁就自动释放了。可以用Redisson的看门狗机制自动续期。
  • Redlock算法:Redis官方推荐的方案,用多个Redis实例来保证锁的高可用。但实现复杂,我一般不用。
// 伪代码示例:Redis分布式锁
String lockKey = "order:123:lock";
String requestId = UUID.randomUUID().toString();
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, 30, TimeUnit.SECONDS);
if (locked) {
    try {
        // 执行业务逻辑
    } finally {
        // 释放锁时,只释放自己的锁
        if (requestId.equals(redisTemplate.opsForValue().get(lockKey))) {
            redisTemplate.delete(lockKey);
        }
    }
}

避坑指南:我曾经用DEL直接删锁,结果删了别人的锁。后来改成用Lua脚本,先判断再删除,原子操作才安全。

4.4.2 ZooKeeper分布式锁

ZooKeeper实现分布式锁,用的是临时顺序节点。每个客户端创建一个临时顺序节点,最小的那个节点获得锁。

优点:

  • 没有死锁问题(临时节点,客户端挂了自动删除)
  • 公平锁(按顺序排队)

缺点:

  • 性能不如Redis(ZooKeeper写操作是强一致的,性能开销大)
  • 羊群效应(大量客户端同时监听同一个节点,惊群)

我个人的习惯是:高并发场景用Redis锁,对一致性要求极高的场景用ZooKeeper锁。比如秒杀用Redis,配置中心用ZooKeeper。

4.5 知识体系总览

下面这张图,是我梳理的分布式一致性知识体系。你可以把它当成一个检查清单,做架构设计时对照着看。

分布式一致性知识体系 分布式一致性 分布式事务 2PC TCC Saga 最终一致性 本地消息表 MQ 事件驱动 幂等性设计 唯一键约束 状态机 去重表 分布式锁 Redis锁 ZooKeeper锁 核心原则 CAP权衡 BASE理论 选择策略:核心链路用强一致,非核心用最终一致 高并发用Redis锁,高可靠用ZooKeeper锁

最后说一句,分布式一致性没有银弹。我见过不少团队,为了追求完美的一致性,把系统搞得极其复杂,最后反而更不稳定。我的建议是:先想清楚业务到底需要多强的一致性,再选方案。很多时候,最终一致性加上良好的补偿机制,就足够了。

专注资料整理