凌晨两点,监控大屏上一串红色告警像血珠一样滚过。订单服务调用支付网关超时,本地事务已回滚,但支付平台那头却扣款成功。用户没收到货,钱却没了。这不是某个新手才会踩的坑,而是后端系统里最昂贵、最隐蔽的幽灵:数据一致性。你以为加了数据库事务就万事大吉?跨服务、跨库、跨网络的调用链上,ACID的“A”早就碎成了一地鸡毛。
今天不聊泛泛而谈的“CAP理论”,也不抄书上的“两阶段提交”。我们就站在实际工程的泥泞里,掰开揉碎讲清楚:什么时候该死磕事务,什么时候必须放弃事务去写补偿代码,以及怎样才能写出不让自己半夜惊醒的补偿逻辑。
事务的边界,比你想的更窄
单体应用时代,一个@Transactional注解确实能解决90%的问题。数据库的ACID保证了你对单库多行数据的更新要么全部生效,要么全部回滚。但一旦系统拆成微服务,或者你开始操作Redis、MongoDB、Elasticsearch、消息队列,数据库事务的势力范围就立刻缩水成一个小小的孤岛。
很多人犯的第一个错误,就是试图用本地事务去包住远程调用。比如在订单创建的@Transactional方法里,直接调用支付接口或发MQ消息。你以为是“要么都成功,要么都失败”,实际上Spring事务在远程调用结束后才提交,如果远程调用成功但事务回滚了,对方那边已经产生的订单或扣款就成了“僵尸数据”。更隐蔽的是,长事务会锁住数据库行,拖垮整个系统的吞吐量,而分布式事务的协调者本身又会成为新的单点。
所以,第一步要认清现实:没有任何一种技术能让你在分布式环境下获得和单机数据库一样的ACID。你能做的,是在“强一致”和“最终一致”之间做取舍,然后用工程手段把不一致的时间窗口缩到最小,把不一致的后果兜到最底。
分布式事务,是银弹还是银样镴枪头
聊到跨服务一致性,必然绕不开分布式事务方案。XA协议的两阶段提交(2PC)是个经典中的经典,但它有个致命的卡点:同步阻塞。第一阶段所有参与者锁定资源,第二阶段协调者决定提交或回滚。只要协调者宕机,所有参与者只能傻等,整个分布式系统直接瘫痪。所以2PC在企业级生产环境里几乎绝迹,只适合单点扩张性要求不高的特定场景。
后来大家转向TCC(Try-Confirm-Cancel)和Saga。TCC把每个事务操作拆成三个接口:Try阶段冻结资源,Confirm阶段真正扣减,Cancel阶段释放冻结。TCC的优点是没有全局锁,性能好;缺点是要为每个业务写三套逻辑,侵入性极强,而且还得搞定幂等、悬挂、空回滚这些地狱级难题。Saga则是一条长流程,正向操作一个接一个执行,一旦某一步失败,就反向执行补偿操作。它比TCC简单,但Saga没有隔离性,中间状态对其它服务是可见的,你得自己用业务手段兜住。
这些方案都是好东西,但我不建议一上来就框架选型。先问自己:业务真的需要分布式事务吗?很多时候,你以为需要,其实用一张状态机加一张本地消息表就能解决。分布式事务是昂贵的手术,能吃药就别开刀。
补偿机制的本质:承认失败,但负责收尾
补偿不是回滚,而是“向前纠正”。数据库回滚是把数据变回原样,补偿却是执行一系列新的操作,让系统最终达到一个业务上正确的状态。比如支付超时了,你不可能让支付平台把钱退回来就完事,你还得把订单状态改成“支付失败”,释放库存,通知用户,也许还要给运营发一条工单。这一串动作,就是一个补偿流程。
补偿机制的设计,核心是“反正终态要正确”。追求强一致时,补偿是兜底;追求最终一致时,补偿是主路径。无论哪种,你都要回答三个问题:怎么发现需要补偿?靠定时任务扫描超时订单,还是靠MQ延迟消息?怎么执行补偿?同步调用、异步重试,还是事件驱动?怎么确认补偿成功?有没有人工介入的逃生舱?
这三个问题想不清楚,补偿代码就会越写越脏。我见过太多系统,补偿逻辑散落在各种定时任务里,字段状态瞎几把改,最后变成了靠人肉翻数据库。合格的补偿机制,应该像一个自动巡航系统:一旦偏离航线,它自动校准,如果校准失败,它发出警报,但绝不撞山。
设计一次靠谱的补偿,从状态机开始
不要一上来就写补偿方法。先把你的业务流程画成一张状态机图。就拿订单来说:待支付 → 已支付 → 已发货 → 已完成,再加上失败分支:待支付 → 支付失败,已支付 → 退款中 → 已退款。每个状态之间必须有明确的触发条件和操作。
状态机是补偿设计的骨架,它决定了哪些状态是合法的,哪些状态转移是允许的。有了状态机,你就能一眼看出:一个订单从“已支付”到“已取消”是非法转移,必须走“退款”流程。补偿逻辑就能自然地挂在状态机上,而不是散落在if-else里。
状态机落地时,建议把状态字段单独拎出来建一张状态流转表,记录每次变更的前置状态、目标状态、操作人/系统、时间戳。你的补偿逻辑永远要基于“当前状态”而不是“历史记忆”。比如补偿任务扫描到待支付且超时30分钟的订单,先尝试将状态从待支付原子性地更新为支付超时中。如果更新行数为0,说明别的线程已经处理过了,直接跳过。这种CAS(Compare and Swap)式的状态变更,是防止补偿和正常流程打架的最廉价手段。
幂等:补偿的基石,没有之一
补偿动作本身会失败,失败了就要重试,重试就会产生重复执行。所以补偿逻辑的第一铁律就是幂等。一个补偿操作,无论执行多少次,效果必须和执行一次完全相同。否则,你本来想把10块钱退回给用户,重试十次就倒欠用户90块了。
幂等设计有几个常用姿势。一是利用数据库唯一键:比如退款流水表里,以order_id + refund_seq建唯一索引,重复插入直接报错或忽略。二是利用状态机的原子操作:上面说的CAS更新,就是天然幂等,因为第二次更新匹配不到旧状态。三是利用业务标识:比如调用支付平台退款时,带上一个全局唯一的refund_request_no,支付平台会记住这个编号,重复请求直接返回第一次的结果。
千万别把幂等寄托在“延迟几秒看结果”或“概率性事件”上。幂等是数学问题,不是玄学问题。只要你的补偿路径上存在重试机制,就必须为每个步骤设计严格的幂等约束。我给你个犀利的标准:如果一个补偿方法跑了两遍,你除了日志里多两行记录之外,不应该感受到任何其它差别。
补偿的重试、延迟和退避
补偿最常见的引爆点就是重试节奏。有人用固定间隔重试,每5秒一次,重试10次就放弃。这对应的是简单场景,比如网络闪断,立即重试能很快恢复。但更复杂的失败,比如目标服务宕机、数据库连接池被占满,立即重试只会加剧雪崩。重试不是越快越好,而是要尊重的对方系统的恢复节奏。
一个推荐的模式是“指数退避 + 抖动量”。第一次失败等1秒,第二次等2秒,第三次等4秒,最大到5分钟,然后保持间隔。抖动(random jitter)是为了防止多个补偿任务同时醒来,对下游造成脉冲式冲击。同时,每一轮重试之间,最好把补偿任务的状态机推进一下,比如从待补偿变成第一次重试、第二次重试,直到超过最大次数进入补偿失败。
进入“补偿失败”状态怎么办?千万别自动销毁。补偿失败的最终归途应该是人工,或者叫“告警”,但绝不是静默。你需要一个落地的记录表,把失败的业务ID、补偿参数、失败原因、尝试次数全部存下来,然后通知值班人员。人工介入是系统最后的保险丝,在复杂系统里,承认机器不够聪明,比假装系统自愈更可靠。
对账:最后的沉默哨兵
即使你的补偿机制做得再完美,也必然存在漏网之鱼。比如网络分区导致的“双写半边成功”,或者某个消息队列丢了一条事件。此时,对账体系就是那道看不见的防线。对账的本质是:定期(比如每天凌晨)把不同系统里的同一笔业务数据进行比对,用“总和不变”的守恒定律来发现不一致。
做对账要注意三点。第一,对账的粒度要落到流水,不只是汇总;比如订单表中的应结金额、支付平台的实收金额、账单明细,三边账缺一不可。第二,对账要有独立的存储和任务调度,不要挂在业务数据库里;否则业务库一挂,对账也死了。第三,对账要能自动触发补偿流程,或者至少生成差异工单给到运营。没有闭环的对账,只是事后验尸。
有个容易被忽视的细节:对账时间窗的选择。如果上游系统有延迟写入,比如支付回调可能晚到两小时,那么对账比对的是“截至某一时刻的最终状态”,而不是“实时状态”。所以对账查询要考虑“慢节点”,给数据一个缓冲期。否则你天天对出假差异,狼来了喊多了,真差异就没人在意了。
本地消息表:简单可靠的最终一致方案
除了事务和纯补偿,还有一种被老工程师偏爱、看起来土但极其稳健的做法:本地消息表。它的思路是:把“业务操作”和“发消息”放在同一个本地事务里。比如创建订单时,同时插入一条order_message记录,状态为待发送。事务提交后,一个异步Worker扫描这张表,将消息通过MQ发送到下游,下游消费成功后将消息状态置为已发送。
这套方案的精髓在于:利用本地事务保证了业务数据和消息记录要么一起成功,要么一起回滚。之后哪怕MQ宕机、网络抖动,消息记录都还在数据库里,Worker起来后继续重发,直到成功。这就是典型的“最终一致”,不需要2PC,也不需要TCC的繁琐接口。
缺点也显而易见:消息表和业务表的耦合,以及Worker的轮询压力。但如果你把消息表独立成一张schema,并且用scan_lock配合CAS来防止多Worker并发消费,它的稳定性能超过大多数分布式事务框架。别瞧不起土办法,架构的本质是用最小代价解决最大问题。
实践中的陷阱:悬挂、空补偿和反向补偿
补偿机制的坑比你想象的多。先说“悬挂”:TCC和Saga里,如果Try阶段成功但Confirm阶段一直没调用,资源一直被冻结着,这就叫悬挂。解决方法是给每个请求一个全局唯一的事务ID,Confirm和Cancel都要带上这个ID,框架或业务层要能识别出“这个事务还没开始到Confirm,不能Cancel”。补偿动作必须与事务实例绑定,不能泛泛地按业务ID操作。
再说“空补偿”:Saga中,如果一个操作还没执行成功,就因为前面的步骤失败而触发了对它的补偿,此时补偿不能瞎搞。例如订单服务还没成功创建订单,退款补偿就已经来了,你绝对不能执行“取消订单”操作,那样会把一个不存在的订单搞出负库存。空补偿需要判断“是否存在正向操作记录”,没有记录就不要执行补偿。
最后说说“反向补偿的级联效应”。A调B,B调C,C失败了,于是B先补偿,A再补偿。但补偿的顺序必须和正向顺序严格相反,而且每一级的补偿本身也要有状态记录。如果B的补偿依赖C的补偿结果,那你就得设计补偿之间的依赖关系。补偿流程本质上也是一个微服务编排,它也要有状态机、幂等、重试、超时。别把补偿当作一把梭哈的代码,它是另一个需要精心设计的系统。
从“能用”到“好用”:一致性治理的最终形态
把所有技术方案堆上去,不代表系统就能高枕无忧。你需要建立一整套一致性治理的文化和工具。比如,给每个可能产生不一致的风险点打上标签,形成一致性风险清单;为每个补偿流程定义SLA,比如“退款在24小时内完成”;定期做故障演练,模拟支付超时、MQ丢失、数据库宕机,看看补偿链路是否真的能兜住。
数据一致性不是某个团队某个模块的事,它是整条调用链上所有人的共同责任。后端开发真的要把“事务”和“补偿”这两个词刻进骨子里。事务是减法,把坏的结果撤销;补偿是加法,把错的结果修对。两者配合,才能让系统在分布式世界里走得稳。
还是那句话:没有银弹,只有纪律。把状态机画清楚,把幂等写严格,把对账跑起来,把补偿的失败路径打通到人工,你就能在凌晨两点,看着别人家系统告警满天飞,而你只是安静地点开日志,补上那一小段完全符合预期的重试记录。这才叫真正的“数据一致性做得好”。