分布式环境下的一致性与幂等性
一、从一个“资金不翼而飞”的案例说起
想象一个令人心惊的场景:用户通过支付平台向商家转账100元。流程如下:
- 支付系统调用银行A的接口,从用户账户扣款100元。
- 然后,支付系统调用银行B的接口,向商家账户增加100元。
然而,在步骤1成功后,步骤2由于网络抖动失败了。结果:用户的100元已被扣除,但商家并未收到。数据不一致 发生了,用户资金受损。
在单机数据库中,我们可以用数据库事务(BEGIN; ... COMMIT;)轻松保证“扣款”和“加款”的原子性。但在分布式系统中,“用户账户”和“商家账户”是独立的服务,分属不同的数据库,传统的事务模型失效了。
这就是分布式系统面临的核心挑战:如何在网络不可靠、服务可能故障的前提下,保证跨服务的数据最终一致? 同时,在重试机制下,如何避免因重复请求导致的数据错误?
二、基石一:分布式事务与最终一致性
分布式事务的解决方案有多种,其核心是在性能、复杂度和一致性强度之间做权衡。
1. 强一致性方案(难度高,性能代价大)
- 两阶段提交(2PC)
- 角色:一个协调者(Coordinator)和多个参与者(Participant,即各资源管理器)。
- 阶段一(准备阶段):协调者询问所有参与者:“是否可以提交?” 参与者执行事务所有操作(写redo/undo日志),锁定资源,但不提交,然后返回“就绪”或“失败”。
- 阶段二(提交/回滚阶段):如果所有参与者都返回“就绪”,协调者发送“提交”命令,所有参与者正式提交;如果任一参与者返回“失败”或超时,协调者发送“回滚”命令,所有参与者回滚。
- 缺点:同步阻塞:在准备阶段,所有参与者的资源都被锁定。单点问题:协调者宕机可能导致参与者一直锁定资源。数据不一致:在阶段二,如果协调者发送完部分提交指令后宕机,会导致部分参与者提交,部分未提交。
2PC追求的是ACID的A(原子性),但在分布式环境下代价高昂,通常用于内部数据库集群(如MySQL Cluster),较少用于微服务间调用。
2. 最终一致性方案(主流,更实用)
其核心思想是接受暂时的中间状态,但通过一系列措施确保数据最终一致。这是互联网公司更常用的模式。
TCC(Try-Confirm-Cancel)
- 本质:一个业务层面的2PC。
- 三个阶段:
- Try:尝试执行。完成所有业务的检查,并预留必要的业务资源。例如,检查账户状态是否正常,并将转账金额100元在用户账户中“冻结”起来(而非直接扣除)。
- Confirm:确认执行。真正执行业务操作,使用Try阶段预留的资源。本例中,将用户冻结的100元扣除,并增加到商家账户。Confirm操作需满足幂等性。
- Cancel:取消执行。释放Try阶段预留的资源。例如,将用户冻结的100元解冻。Cancel操作也需满足幂等性。
- 优势:由业务逻辑控制,灵活性高,避免了数据库层长时间锁表。
- 挑战:对业务有侵入性,需要为每个操作实现Try、Confirm、Cancel三个接口。
基于消息队列的最终一致性(最常用)
- 核心思路:将分布式事务拆解为一系列本地事务,通过消息队列的可靠性来驱动后续操作。
- 流程(以转账为例):
- 本地事务1:在支付系统的数据库中,执行
INSERT INTO local_transaction ...记录转账流水,状态为“进行中”。同时,向消息队列发送一条“扣款成功,待加款”的消息。这两个操作必须在同一个数据库事务中,保证要么都成功,要么都失败。 - 投递消息:消息队列保证将消息可靠地投递给商家账户服务。
- 本地事务2:商家账户服务消费消息,执行给商家加款的操作。成功后,更新本地流水状态。
- 补偿机制:如果步骤3失败,或商家服务始终没有消费消息,则需要一个对账作业定期扫描流水表,发现状态为“进行中”但已超时的流水,进行人工或自动干预(如冲正交易)。
- 本地事务1:在支付系统的数据库中,执行
三、基石二:幂等性 —— “一次”与“多次”的哲学
幂等性是指:同一个操作,执行一次与执行多次,对系统状态的影响是完全相同的。
- 为什么需要幂等? 因为网络是不可靠的。客户端调用超时后可能会重试;消息队列可能因网络抖动而重复投递。如果没有幂等设计,一次支付可能被重复扣款,造成资金损失。
如何实现幂等性?
- 数据库唯一索引:最直接的方式。如创建订单时,传入订单号作为唯一键。重复插入会失败,从而保证订单不会重复创建。
- 状态机:业务数据带有状态。如订单状态为“已支付”时,任何再次支付的请求都会被忽略。
- Token机制(防重令牌):
- 客户端先向服务端申请一个全局唯一的Token。
- 客户端执行业务请求时带上此Token。
- 服务端在处理前,先检查该Token是否已被使用(如写入Redis)。如果已使用,则拒绝请求;如果未使用,则标记为已使用,然后执行业务逻辑。
- 悲观锁/乐观锁:通过版本号等机制,确保更新操作是基于最新数据的,避免重复更新。
幂等性是实现最终一致性的重要保障。在TCC的Confirm/Cancel阶段,在消息队列的重试机制中,都必须保证幂等,否则重试会导致严重的数据错误。
四、实战案例剖析:秒杀系统中的库存扣减
秒杀是检验一致性和幂等性设计的终极考场。
- 挑战:秒杀100件商品,不能超卖(卖超过100件),也尽量不能少卖。
- 方案:
- 缓存原子操作保证一致性:库存数量预热到Redis中,利用Redis的
DECR(递减)或LUA脚本的原子性执行扣减。这保证了在单点上的并发安全。 - 数据库最终兜底:Redis扣减后,发送异步消息到MQ,由消费者将扣减结果持久化到数据库。此处采用最终一致性。
- 幂等性防止重复扣减:每个秒杀请求携带一个唯一的“秒杀令牌”(Token)。在Redis扣减前,先检查该令牌是否已处理过,防止用户重复提交或客户端重试导致库存多扣。
- 事务补偿:部署一个定时对账任务,核对Redis中的库存与数据库中的最终记录是否一致,发现不一致时进行告警和修复。
- 缓存原子操作保证一致性:库存数量预热到Redis中,利用Redis的
五、技术是手段,业务是目的
通过本系列的五篇文章,我们系统地构建了一个高并发、高可用系统的技术体系:
- 第一篇(削峰异步) 和 第二篇(缓存) 解决了性能问题。
- 第三篇(分片) 解决了容量问题。
- 第四篇(弹性设计) 解决了可用性问题。
- 本篇(一致性与幂等性) 解决了正确性问题。
最终的心法是:所有技术方案的选择,都必须回归业务本质。
- 对于读取场景,通常采用最终一致性,通过缓存等手段提升性能。
- 对于核心的写入场景(如资金、库存),则需结合强一致性(如数据库事务、Redis原子操作)和幂等性设计,确保万无一失。
- 对于复杂的跨服务写入,TCC或消息队列+最终一致性是更务实的选择。
在分布式的复杂世界里,没有银弹。真正的架构能力,在于深刻理解业务需求,在一致性、可用性、性能之间做出最合理的权衡。
