先还原一个场景一笔订单要同时扣库存、扣账户余额、加积分放在单体应用里是一条数据库事务解决的问题拆成微服务之后就麻烦了——数据库事务管不到其他服务的表扣款成功但库存没扣、订单生成但积分没加这一类“看起来成功实则半成品”的问题就成了分布式事务的日常噩梦。BASE理论就是在这种背景下被广泛采用的妥协方案它不做强一致而是靠最终一致性和柔性事务让可用性优先的业务场景在有限的不一致窗口下稳定运行。这篇内容适合后端开发、架构师以及所有被分布式事务折磨过的人我会把BASE彻底讲透并给出从最终一致性到柔性事务落地的完整思路和避坑经验。1. 为什么一拆服务事务就控制不住了1.1 本地事务的边界在哪本地事务之所以可靠是因为数据库在底层做了三件事要么全部提交要么全部回滚的原子性锁和隔离级别提供的隔离性redo/undo日志支持的持久性与回滚能力。这些机制全部作用在同一份数据文件上协调成本很低。你可以把本地事务想象成一个房间里的多个人同时干活大家共享同一套照明和通风系统谁出了岔子管理员按一下开关就能把所有状态恢复原位。但是服务拆分之后订单表在订单服务的数据里库存表在库存服务的数据里账户余额表又在账户服务的数据里。它们之间没有共享的锁管理器没有跨库的undo日志更没有全局事务ID把所有操作串成一条线。每个服务只能保证自己数据库里的那部分操作是原子的跨服务之后“要么全部成功要么全部失败”变成了多方协商问题。这也是为什么很多人刚把单体拆成微服务时第一个崩溃点往往不是接口性能而是原来靠一条SQL就能完成的更新现在要跨两个甚至四个服务协作。1.2 两阶段提交的代价同步阻塞和协调者单点两阶段提交在教科书里被列为分布式事务的经典解法准备阶段让所有参与者把资源和锁都捏在手里提交阶段再统一放行。思路本身没有错问题是它为了强一致把“等待”放在了明面上任何一个参与者网络抖动其他所有参与者的锁都必须一直占着等它协调者一旦宕机整个事务状态没人知道是该继续提交还是回滚。这种模型在局域网低延迟环境也许能跑到了公网跨机房跨云场景长事务把数据库连接和行锁占住十几秒业务基本就垮了。打个比方两个人约好同时关门才能把房间彻底锁住一个人已经关上门了另一个人在路上堵车管理员又突然断电那两个人只能一直站在各自门口干等。正因为这些代价工程上很少拿两阶段提交做跨业务链路的默认方案更常见的做法是把事务范围尽量压缩在单库内或者直接接受BASE提出的另一条路。1.3 从CAP到BASE路径选择CAP定理把分布式系统的三个诉求摆在一起一致性、可用性、分区容错性。网络分区会持续发生所以P不是“要不要”的问题而是默认前提在分区发生时C和A只能二选一。BASE正是选择可用性这条路径上的工程化回答它把强一致放宽为最终一致用基本可用换取整体业务的大部分可用性让系统在故障或流量压力下不至于全部停摆。注意BASE不是CAP的替代品而是CAP在AP场景下的具体落地思想。它没有取消一致性问题只是把一致性的保证从“操作发生时”挪到了“一段时间后”并且要求这段时间是可控、可观测、可恢复的。明白这一点后面所有方案都不会跑偏。2. 看懂BASE三要素再谈能不能落地2.1 基本可用把“全部挂掉”变成“部分降级”基本可用就是系统在异常或高并发场景下整体仍然是活的只是局部功能打了折扣。电商大促时搜索栏和推荐位可以降级优先保住下单和支付链路数据库读流量太大可以让读请求走从库接受短暂的延迟数据流量激增时限流把多余请求快速失败而不是让整个服务连环崩掉。这和我们平时说的“高可用”有区别高可用是追求系统不挂基本可用是承认在某些极端情况下系统会“带伤工作”但这个伤必须可控。用户看到的是页面稍慢、部分非核心功能暂不可用而不是请求全部超时。BASE的“基本”两个字恰恰是在给可用性划一个能接受的底线。2.2 软状态中间态是被设计出来的不是意外软状态指的是系统允许存在中间状态而这个中间状态不影响业务流程的继续推进。比如订单状态是“支付确认中”库存状态是“预占中”积分状态是“待入账”。在严格ACID的世界里支付、库存、积分必须同一时刻一起变化但BASE允许它们各自在不同时间点达到最终状态。这里最容易产生的误解是软状态不等于状态可以脏。“支付确认中”和“流转异常”是有本质区别的前者有明确的业务含义可以被查询、被重试、被恢复后者是错误状态需要告警和人工介入。设计软状态的关键是给每个中间状态定义清楚流转条件和超时规则比如超时30秒还未确认成功的支付系统自动发起补偿。把中间状态当成一等公民来设计而不是放任自流这是柔性事务和“乱写状态”的分水岭。2.3 最终一致性收敛的时钟和业务可接受窗口最终一致和强一致的区别在于线性时间点上用户读到什么。强一致要求任何时刻所有副本都是最新值最终一致只承诺在没有新写入的静止状态下经过一段时间后所有副本都会收敛到同一个值。这里我必须强调“最终”不是模糊的借口它由重试策略、消息可靠性、消费速度和补偿流程共同决定工程上要为这个收敛过程设定上限。比如一个异步任务每5秒扫描一次消息表消息中间件消费后返回ACK数据同步窗口是2秒那整体收敛上限大概就是7秒。如果重试达到最大次数后消息进入死信队列并触发告警这个有界性就是可观测的。收敛时间越短系统设计越接近强一致代价也越高收敛时间越长业务上能接受的场景就越有限。你需要明确告诉团队这个业务能接受多少秒的不一致。说不出来就别上最终一致。3. 本地消息表、MQ事务、TCC、Saga四种柔性方案的真实使用感受3.1 本地消息表被低估的可靠方案本地消息表的核心是把业务数据和一条消息放在同一个本地事务里写库。业务更新成功后消息表里同时多了一条待发送记录发送动作由后台任务完成任务扫描消息表把未发送的记录投递到MQ收到消费确认后再更新消息状态。这套方案看起来土但可靠性极高因为业务表和消息表在同一个数据库里事务保证“要么都成、要么都不成”。我在实际项目中用过的消息表字段一般包括消息ID、业务ID、消息体、状态、重试次数、下次重试时间。扫描时只捞状态为待发送且重试次数未超上限的记录并且一定要分批limit否则消息积压时会把表扫爆。本地消息表的缺点是每多一个消费者就要多维护一张表和多一套扫描逻辑但它不依赖任何中间件特性通用性最强。如果团队里MQ支持的事务消息功能不够成熟本地消息表永远是那个不会出大错的底线方案。3.2 MQ事务消息把“消息表扫描任务”搬进中间件事务消息本质上就是把本地消息表里的消息表和扫描任务搬到消息中间件内部去实现。发送方先发一条半消息这个消息对消费者不可见发送方接着执行本地事务本地事务成功就提交半消息失败就回滚如果发送方在本地事务执行过程中崩溃中间件会定时回查发送方确认这条消息对应的本地事务到底是什么状态再决定最终投递还是丢弃。这个思路很优雅它把最容易出错的消息可靠投递问题交给中间件解决业务侧只需要保证本地事务和消息确认动作正确。但前提是消息中间件必须支持事务消息否则回查机制缺失消息就会在发送方崩溃时悬空。另一个容易被忽略的细节是事务消息只保证“消息一定发出”不保证“消费者一定处理成功”消费端的幂等和异常重试仍然要自己写。很多团队在这里栽跟头以为是用了事务消息就万事大吉其实那才刚走完一半。3.3 TCCTry、Confirm、Cancel的收益与成本TCC是业务侵入最强的方案也是最适合资金交易类场景的方案。它要求对每个参与方写三个方法Try做资源预留Confirm做真正扣减Cancel做释放回滚。比如扣余额Try阶段先把金额冻结Confirm阶段才把冻结金额划走Cancel阶段解冻。三个方法都必须保证幂等还要处理两个非常隐蔽的边界问题。第一个是空回滚Try还没执行Cancel却来了Cancel要能识别出“根本没有预留过”然后直接跳过否则会多释放一笔不存在的资源。第二个是悬挂Cancel先执行完Try随后才到Try要拒绝执行否则资源已经被解冻Try又把它冻一次系统里就会冒出一笔永远释放不掉的冻结资金。这两个问题通常靠一张事务控制表记录Try、Confirm、Cancel的到达次序和状态来解决没有这张表TCC在并发场景下就是定时炸弹。3.4 Saga正向执行加反向补偿的长事务方案Saga把一个大事务拆成一组带正反操作的小事务。正操作按顺序执行任何一步失败就从失败点往回执行对应的反操作。比如下单成功、扣款失败就执行取消订单和释放库存的补偿。Saga有两种组织方式协同式是事件驱动每个服务监听事件自己决定下一步编排式由一个协调器统一调度业务状态可控性更强实践中我更推荐编排式至少出了问题你知道去哪看日志。Saga的难点不在框架而在于反操作是否真正语义可逆。库存扣减可以加回库存但“发送过的短信”“推送过的通知”就没有干净的反操作这种场景就不该放进Saga链路里。另外Saga的补偿操作本身也会失败所以每个补偿动作也要支持重试和幂等补偿失败后要能进入人工处理队列。很多项目在Saga上翻车都是因为只设计了正流程没认真设计反流程。3.5 最大努力通知最轻量但也最需要兜底最大努力通知算是BASE家族里最轻量的一种主要用于那些不参与核心事务、但结果需要送达的场景比如支付成功后通知积分系统加积分、通知会员系统更新等级。它的思路是不断重试回调按指数退避增加间隔重试到成功或者达到最大次数为止最后仍然失败的靠对账兜底。别指望它提供严格一致性它只能把成功率推到足够高真正的保底必须来自数据对账。使用最大努力通知时接收方同样要做幂等因为重试必然带来重复请求。它的价值在于把一致性要求从“必须同时成功”降级为“最终尽量成功”并且用重试和退避把失败概率压到极低适合那些晚几分钟用户完全无感知的业务。3.6 五种方案对比方案核心机制业务侵入一致性强度典型场景本地消息表业务表消息表同库事务异步扫描投递低最终一致可靠通用兜底中间件能力不足时MQ事务消息半消息本地事务中间件回查低最终一致较可靠主流电商下单、库存扣减TCCTry/Confirm/Cancel三段式高趋向实时一致账户余额、支付冻结Saga本地事务序列反向补偿中最终一致窗口可控长流程业务、订单全链路最大努力通知重试回调指数退避极低尽力送达积分、通知、状态同步我在小团队里的选择顺序通常是优先用MQ事务消息没有事务消息支持就退回本地消息表碰到账务类硬约束才上TCC长流程则用Saga编排积分通知类直接用最大努力通知解决。4. 一个下单场景的BASE落地实录状态机、幂等与对账4.1 场景定义与架构决策拿一个很常见的电商下单场景举例用户创建订单、扣减库存、扣减账户余额、增加积分。我当时的方案不是用一个万能分布式事务把四个服务全部包进去而是分层处理订单创建和库存扣减之间用MQ事务消息保证“订单在、消息就在”账户扣款走同步调用失败就触发Saga补偿取消订单积分加送用最大努力通知晚几秒入账用户完全感知不到。这个决策背后的逻辑是每一段路只使用满足该段一致性要求的最简方案而不是让整个链路都承担最强的TCC负担。账户余额是强校验数据所以必须用同步确认加补偿库存是多卖一件少一件但允许预占语义所以最终一致就够积分是事后福利晚一点没关系所以最大努力通知足够。把一致性边界画在正确的位置成本和收益才能同时最优。4.2 核心代码事务消息发送与本地事务确认我用Java风格伪代码还原事务消息发送端的核心逻辑// 伪代码创建订单并确认事务消息 public void createOrderAndSendStockMsg(Order order) { String msgId mqProducer.prepare(STOCK_DEDUCT, buildStockPayload(order)); try { orderDao.create(order); // 本地事务订单和关联数据一起写入 mqProducer.commit(msgId); // 本地事务成功确认消息可投递 } catch (Exception e) { mqProducer.rollback(msgId); // 本地事务失败丢弃半消息 throw e; } }这个场景里prepare发出的半消息对消费者不可见只有本地事务提交后消息才会真正投递。如果进程在orderDao.create之后、commit之前崩溃中间件会回查本地事务表发现订单没有成功创建就会自动回滚消息库存服务永远不会收到扣减指令。4.3 幂等这块不做好柔性方案就是定时炸弹柔性事务引入的第一条规则就是所有消费链路必须幂等。因为无论是“至少一次”还是“恰好一次”现实中的极端情况都会造成重复投递。判断幂等的锚点最好是业务ID而不是消息ID业务ID是订单号、流水号这类有业务含义的字段消息ID每次重投都可能变化。我一般会建一张去重流水记录表以业务ID做唯一索引处理前先插入占位记录唯一键冲突就说明已经处理过。数据库唯一键是保证幂等的最终手段Redis的setnx只能做前置判断不能兜底。消费端代码会像下面这样// 伪代码库存扣减消费端幂等 public void handleStockDeduct(StockDeductMsg msg) { try { if (deductRecordDao.existByBizId(msg.getBizId())) { return; // 已处理过 } stockDao.deduct(msg.getStockId(), msg.getCount()); deductRecordDao.insert(msg.getBizId(), msg.getOrderId(), DONE); } catch (DuplicateKeyException e) { // 并发下insert唯一索引冲突说明另一线程已处理 log.warn(duplicate deduct msg: {}, msg.getBizId()); } }关键点在于先插入唯一记录再改库存或者把插入动作作为处理成功的标记。如果先查后改两个并发线程可能在查询时都认为没处理过然后各自扣一次库存这种事故我真实遇到过。4.4 两个线上翻车事故复盘第一个事故是重复消息导致库存多扣。现象是消费端处理超时触发重投两个线程同时处理同一条消息都查幂等表发现不存在前后脚完成了扣减。根因是我当时用了“先查再插”的校验逻辑唯一的索引约束形同虚设。修复方式就是上面代码里的方案直接把唯一索引插入作为幂等锚点插入成功才算处理成功冲突就退出。第二个事故是补偿任务重复执行。Saga补偿流程里库存已经释放了但补偿线程在收到ack之前超时重试导致同一笔取消操作执行了两次。库存服务又给用户加回了两次库存。根因是补偿操作没有自己的幂等日志。修复方式是给每次补偿也建一条补偿流水以业务ID加补偿步骤做唯一索引执行前先插入占位这样无论重试多少次只能真正执行一次。补偿操作本身也必须被补偿这是Saga方案里最容易被忽略的一层。5. 什么业务必须强一致什么业务放心用BASE5.1 四个问题判断业务边界BASE不是所有场景的银弹我判断一个业务能不能最终一致会先问四个问题第一A成功B失败后用户是否直接感知到异常第二是否涉及账户余额这类强校验数据第三不一致窗口是否影响下一环节的正确性比如库存扣超了会不会导致超卖第四是否有对账兜底能力。如果四个问题答案都是“可控”BASE方案就可以上只要任何一条不满足就老老实实退回强一致设计。一个反例是资金流水记录。流水一旦丢失对账时账实不符整个财务系统都会出问题这种数据就必须强一致。另一个反例是库存扣减后立即发货的场景如果库存数据不一致导致仓库超发实物损失就不可逆需要用预占加最终扣减的语义来设计而不是简单的异步通知。5.2 业务场景与方案建议业务场景建议方案原因账户余额扣款TCC或本地事务对账强校验数据不允许短暂负余额库存预占扣减MQ事务消息预占语义允许短暂中间态但不可超卖订单状态全链路Saga编排长流程需要清晰补偿路径积分/优惠券发放最大努力通知晚几分钟用户无感知资金流水记录强一致必须永不丢失、账实相符5.3 最终一致必须配齐对账、监控和告警最终一致性方案的可运行性不取决于设计时画得多漂亮而取决于监控和对账有多细致。每次消息消费失败、每次重试超限、每次补偿失效都要有告警通道。我会在系统里定时跑对账任务把消息表、流水表、业务表做交叉比对比如找出已支付但库存没扣的订单、已扣库存但订单已取消的记录。这些“漏网之鱼”要能自动重推推不动就进人工处理列表。没有对账的最终一致就是裸奔这句话是我踩了很多坑之后最想告诉别人的。BASE给了你一个宽松的一致性模型但它要求你用更严格的生产纪律去兜住这个宽松。6. 我对BASE理论的重新理解我对BASE理论的重新理解是最终一致不等于可以随便不一致而是把一致性的保证从数据库约束层面提升到了业务编排层面。你要有能力定义每个中间状态有能力重试和补偿有能力在对账中发现问题。BASE落地这件事最考验人的其实是工程纪律每次新增一个异步链路同时想清楚状态流转、幂等记录、补偿动作和对账任务这四件事少一件线上迟早翻车。如果让我给新人一个务实的建议我会说能拆的事务先拆掉能不用的分布式事务尽量不用把一致性边界画小比任何花哨的方案都管用。实在绕不开的时候优先选消息可靠投递类方案账务类硬约束才去碰TCC或Saga。分布式事务没有银弹BASE只是一个带着约束的务实选择看懂它的边界你才能真正用好它。