
我先说个真实经历。那年做电商库存扣减改造单库拆成了多库原来在Spring本地事务里跑得好好的扣减逻辑一上多实例马上出问题库存明明只有10件并发下单却能扣出15件的负数。后来排查下来分布式环境下原来单机数据库那套ACID约束根本没法跨节点生效这时候就得换一套思路这套思路就是BASE理论。BASE不是什么高深论文里的概念它是eBay工程师Dan Pritchett在2008年提出的分布式系统设计原则全称是Basically Available基本可用、Soft State软状态、Eventually Consistent最终一致。这篇文章不打算只讲定义我想结合分布式事务、分布式锁、缓存、存储这些每天都会碰到的场景说说BASE在工程里到底怎么落地什么时候该用它什么时候还是要老老实实上强一致方案。适合正在做分布式架构、踩过分布式事务坑的开发同学对准备分布式相关面试的人来说理解这些实例也比背概念管用得多。1. 一次库存扣减事故让我重新理解“一致性”1.1 事故重现单机事务在多节点下突然失效当时的系统结构很简单订单服务三个实例库存服务单独拆出去数据库按业务做了分库。扣减库存的代码大概是这样的逻辑先查库存是否充足再执行update扣减然后插入订单记录。在单体架构下这三点可以在一个本地事务里完成靠数据库的行锁保证并发安全。拆了库之后问题就来了。订单库和库存库是两个物理库本地事务只能覆盖其中一个库跨库操作必须通过服务调用。三个订单实例同时收到下单请求各自去调库存服务库存服务自己也要处理并发。虽然每个库内部仍有事务但“扣库存”和“建订单”这两个动作之间出现了间隙无法再靠单一数据库的ACID保证要么全成、要么全败。最终的结果就是超卖。10件库存六个并发请求都通过了库存校验等到真正执行update的时候又互相覆盖最后库存变成了负数。这不是代码水平问题而是分布式本身的约束变了原来完全不需要考虑的“一致性”问题现在必须作为一个独立的设计维度去处理。1.2 分布式系统绕不开的两个事实这个事故背后是分布式系统的两个底层事实。第一个是故障常态化网络会超时、节点会宕机、时钟会漂移设计时必须假设这些情况随时可能发生。第二个是跨节点保持一致性的代价呈指数级增长因为每个节点都在独立运行要协调它们的状态就需要引入额外的协议、额外的锁、额外的通信这些都会拖慢系统。CAP理论说的P分区容错性是无法回避的网络分区不是“会不会发生”而是“什么时候发生”。当分区发生时分布式系统必须在一致性C和可用性A之间做选择。BASE就是在这种约束下被提出来的放弃强一致换取可用性和性能数据状态允许暂时不一致但要在后续通过异步手段收敛到一致。1.3 BASE不是理论是工程取舍Dan Pritchett在eBay面对的是海量数据和高并发流量他提出BASE的思路很务实在超大规模场景下强一致事务的代价高到无法接受那就用最终一致来替代。他的文章标题叫《BASE: An Acid Alternative》直接表明这是ACID之外的另一条路。但BASE从来不是要替代ACID的“终极方案”而是一个工程取舍框架。资金账户余额这种场景你依然需要强一致但订单状态、库存扣减、点赞计数这类场景完全可以用BASE来设计。关键是要搞清楚什么时候取什么、舍什么以及舍弃之后用什么机制补回来。下面几节我会把BASE的三个要素逐一拆开说清楚它们到底意味着什么。2. 拆解BASE基本可用、软状态、最终一致到底在说什么2.1 基本可用核心功能保得住非核心可以降级“基本可用”最容易被人误解成“系统马马虎虎能用就行”实际上它的意思是系统在遇到故障或峰值流量时保证核心功能可用允许非核心功能降级或暂时不可用但不能整体退出服务。最典型的例子是秒杀。平时你可以看商品详情、写评论、看排行榜但秒杀开始的一瞬间这些非核心接口很可能被降级有的直接返回兜底数据有的干脆关掉系统把大部分算力都留给下单链路。用户感受到的只是页面简单了一些、评论看不了但下单、支付这些关键链路还是通的。这正是基本可用的核心不是所有能力都在而是最核心的能力必须扛住。这里要注意区分BASE的基本可用和CAP里的可用性。CAP的A指系统在有限时间内能对请求给出非错误响应粒度更粗BASE的基本可用则强调SLA层面的能力裁剪是对用户体感的一种整体设计粒度更细也更贴近业务。2.2 软状态中间状态是被允许的但必须可收敛软状态指的是系统运行过程中各节点的数据可以处于不一致的中间状态这个中间状态不是bug而是设计出来的正常现象。打个比方两个人转账A账号扣了100元B账号还没到账。此时此刻如果去查B的余额发现没变系统里其实出现了一个“转账中”的中间态。对B来说看起来像是钱凭空消失了但这个状态被系统允许存在它是软状态。工程上允许软状态不等于可以放任不管。你必须设计出清晰的状态机让中间状态都有明确的迁移路径最终都能收敛到终态。订单状态就是一个典型例子创建、支付中、已支付、发货中、已完成、已取消、退款中、已退款。每个状态都必须有下一步动作不允许出现一个“卡死”的状态没有任何超时规则。凡是设计状态机时没想清楚“这个状态超时后怎么办”的地方上线后大概率会出问题。2.3 最终一致一致性本质是个时间窗口问题最终一致的定义是在没有新的更新操作发生时系统中所有副本经过一段时间后最终会达到一致状态。这句话里有几个重点值得抠一下。“没有新的更新操作”说明一致是动态的只要有新写入就又开始下一轮收敛“经过一段时间”说明一致性不是瞬时的这中间存在一个窗口期。这个窗口期究竟多长取决于网络延迟、复制延迟、消息投递延迟、重试间隔等因素。MySQL主从复制可能有几百毫秒到几秒的延迟跨机房的数据同步可能要几秒甚至更久分布式缓存与数据库之间的同步窗口以毫秒到秒计。再往细了说最终一致其实有很多变体。Dynamo论文里提到过读己之写一致性、会话一致性、单调读一致性、因果一致性等。它们的强度从低到高比纯“最终一致”要好用很多。比如读己之写用户提交订单后马上刷新必须能看到自己刚创建的订单看不到会造成恐慌。实现上通常让这类请求强制走主库或者从库读取失败时回源主库。做系统设计时如果能把承诺精确到具体哪几种一致性语义比笼统说一句“我们最终一致”要清晰得多。3. 为什么分布式事务不能全都上2PCBASE背后的现实约束3.1 两阶段提交的问题协调者单点、阻塞和锁时间既然BASE放弃强一致那能不能反过来所有分布式事务都用两阶段提交2PC解决问题理论上可以但在真正的生产环境2PC会带来三个很现实的问题。首先是协调者单点。2PC依赖一个协调者来协调所有参与者协调者一旦宕机参与者只能干等整个事务卡住。其次是阻塞问题Prepare阶段所有参与者都要持锁资源只要有一个节点慢整个事务就只能陪着等数据库连接池和行锁都会被拖垮。第三是跨地域网络延迟如果参与者在不同机房2PC的每次往返都可能要几十毫秒一个事务下来几百毫秒没了在核心下单链路上根本扛不住。3PC、TCC这类方案对2PC做了一些优化但本质上的协调复杂度和锁问题并没有彻底消失。所以大规模互联网业务宁可选择“不一致一会儿然后自己修回来”也不愿意让整条链路为了一个极端的强一致目标付出惨痛的性能代价。3.2 CAP与BASE的关系P不可回避C和A只能选BASE和CAP是一对容易混淆的概念很多面试回答也容易踩坑。CAP说的是在发生网络分区P时系统必须在C和A之间做取舍。BASE可以理解成面对这种取舍的一个具体倾向优先保可用性用软状态和最终一致来补一致性。但这里有个容易误解的地方BASE系统并不总是AP它只是更倾向于在故障或高压场景下降低一致性约束。在没有分区的情况下系统完全可以同时满足C和A。更准确的说法是BASE系统在P发生时主动降级一致性避免整个系统不可用等网络恢复后再把数据收敛到一致。用大白话说BASE把“要么全成要么全败”的原子性换成了“先响应后修正”的可用性。用户在操作刚完成的那一瞬间页面上的数据可能有一点点滞后但很快会变成对的这种体感在大多数业务里是可以被接受的。3.3 性能与成本的直接对比看一组简单的对比就能理解为什么互联网公司普遍选择BASE单机事务ACID延迟低、吞吐高但受限于单库容量无法无限扩展。XA/2PC强一致保障最好但延迟依赖最慢参与者锁冲突严重吞吐低。BASE消息中间件本地事务立即提交异步投递消息吞吐高但需要额外建设补偿、幂等、对账机制。互联网核心链路动不动就有几万QPS如果每笔订单都要跨三个系统做强一致事务光是锁竞争和协调通信就足以让系统雪崩。BASE的异步模型天然适合这种高吞吐场景代价是系统复杂度从“事务”转移到了“可靠的异步消息定时补偿对账”。这不是偷懒而是把同一个一致性目标换了一种成本更低、扩展性更好的实现方式。4. BASE在四个高频场景里的落地套路4.1 Redis分布式锁过期时间本身就是一种软状态分布式锁是BASE思想很典型的一个体现。用Redis做分布式锁最经典的命令是SET lock:order:1001 1 NX PX 30000这个命令在key不存在时才能写入成功同时设置30秒过期时间。拿到锁的线程处理完业务后要把锁删掉。删除时的正确姿势是先用Lua比较value是否还是自己的标识再删除防止误删别人的锁if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这里的过期时间其实就是BASE里软状态的体现允许锁状态在某个时间点失效。如果业务执行超过30秒锁被自动释放其他线程可以进入临界区这段期间会短暂出现两个线程同时操作同一份数据的情况。系统没有因此卡死而是用“锁过期”的方式换取了继续处理请求的能力。面试里常问的Redisson看门狗机制本质上就是把这个软状态窗口尽量压缩默认30秒的超时时间会被后台线程自动续期业务没执行完锁不会提前释放。但即便加了看门狗也不能把分布式锁当成万能的锁只能解决协作进程之间的互斥解决不了业务层面的重复提交、覆盖写等问题。最终数据不出错靠的还是业务幂等和数据库约束。4.2 Seata的AT/TCC与消息事务从强一致向最终一致演进Seata是Java生态里最常见的分布式事务框架它几种模式的演进很能体现BASE思想的落地。AT模式是对2PC的一种优化。它的做法是各分支事务先用本地事务完成业务提交同时写一条undo_log日志全局事务管理器在二阶段决定是提交还是回滚如果回滚就根据undo_log反向补偿。由于第一阶段本地事务已经提交锁不会长时间被占用吞吐量比标准2PC好很多但数据在全局层面是存在“中间状态”的这符合软状态的思想。TCC模式则更贴近BASE。TCC把每个事务操作拆成Try、Confirm、Cancel三个阶段Try阶段冻结资源Confirm阶段真正提交Cancel阶段释放资源。比如库存服务Try就是先冻结5件库存Confirm就是把这5件扣掉Cancel就是把冻结的5件还回去。这个设计就是典型的软状态补偿业务方自己控制一致性灵活性最高。还有一种更轻量但非常实用的方案是本地消息表消息队列。核心思路是业务表和消息表放在同一个本地事务里提交保证业务操作和消息投递记录要么同时成功要么同时失败。提交之后通过一个异步任务把消息投递到MQ消费方处理成功后再回调确认。如果没有收到确认定时扫描任务就重发消息直到成功。消息表结构可以参考这样CREATE TABLE message_retry ( id BIGINT PRIMARY KEY AUTO_INCREMENT, msg_id VARCHAR(64) NOT NULL COMMENT 全局唯一消息ID, biz_type VARCHAR(32) NOT NULL COMMENT 业务类型, payload_json TEXT NOT NULL COMMENT 消息体, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待发送 1已发送 2已确认 3死信, retry_count INT NOT NULL DEFAULT 0, next_retry_time DATETIME NOT NULL, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_msg_id (msg_id) ) COMMENT 可靠消息投递表;这种方案没有全局锁没有协调者本地事务提交就返回成功剩下的靠MQ和定时任务慢慢补齐性能非常优秀。很多订单、积分、消息通知类业务用这套方案已经足够。4.3 订单与库存的最终一致链路状态机补偿幂等把前几节的思路落到一个具体业务上订单与库存。这也是网上问得最多的分布式事务场景之一。流程可以这样拆解创建订单写订单库状态为“待支付”调用库存服务冻结库存库存状态变为“锁定”用户支付成功收到支付回调订单状态改为“已支付”库存服务把“锁定”的库存真正扣减通知发货系统发货。这个流程里每一步之间都存在着软状态窗口。订单可能在“待支付”但库存已经冻结也可能支付回调已经到了订单库还没来得及更新。这些都是正常状态关键在于状态机的设计要完整每个状态都有超时后的动作。比如“待支付”超过30分钟自动关单并释放冻结库存“已支付”超过10分钟还没扣减库存则触发重试告警。幂等是最终一致里十分重要的一环。支付回调可能重复投递重试任务也是重复消费的所以必须用同一个请求ID做幂等去重。最简单的方式就是在表上加唯一约束比如ALTER TABLE order_payment ADD UNIQUE KEY uk_request_id (request_id);这里的requestId要从入口透传到底层每次重试都带上同一个值。如果重试时换了一个新的ID幂等就直接失效了。所以分布式环境下全局唯一ID的生成很重要通常用雪花算法生成既保证趋势递增也保证全局唯一。补偿任务一般用分布式定时调度来做扫描超时未完成的订单触发关单或者重试。消息队列先投递失败也没关系定时任务会把消息表里的待发送记录重新捞出来再发。最后再加一道T1对账每天凌晨比对订单库和库存库的数据找出漏网的不一致数据自动修复或者人工介入。4.4 缓存、存储与副本同步最终一致的大本营BASE思想其实无处不在分布式存储就是最终一致的大本营。MySQL主从复制、HDFS副本同步、MinIO对象存储的多副本都不可能做到完全的实时一致都存在一个同步窗口。应对方式有很多。半同步复制会等至少一个从节点返回ack数据才提交可以把主从延迟窗口压到很低RPO也接近零。读己之写一致性则是在从库读到旧数据时强制回源主库保证用户立刻能看到自己刚写的值。Quorum机制也很常用比如N3个副本写W2、读R2读写集合必然有交集配合版本号就能读到相对新的值。Dynamo这类系统还会使用版本向量、读修复、反熵等手段把不一致窗口进一步收敛。缓存与数据库的一致性也同理。最常用的Cache Aside模式读请求先读缓存没有就查库再回填写请求先更新数据库然后删缓存。这个模式下缓存和数据库之间也有一段时间会不一致但通过延迟双删、设置过期时间等手段可以把窗口压到很短。理解这些实现背后的逻辑之后你会发现BASE不是某个具体技术的名字而是一种极其普遍的系统设计思想。消息队列的延迟、缓存的过期、副本的异步同步全都是BASE在工程里的具体体现。5. 实战中怎么判断这个场景该用BASE还是ACID5.1 三把尺子面对一个具体业务场景怎么快速判断应该用BASE还是ACID我的经验是拿三把尺子量一下。第一把尺子失败后能不能补偿如果这笔操作失败后可以通过逆向操作修复比如订单取消、库存释放、退款原路返回那就优先考虑BASE。如果补偿成本极高或者根本没有补偿路径比如直接扣减余额并出账那就得考虑强一致。第二把尺子不一致窗口内用户能不能接受社交软件点赞量差几分钟才更新用户无感知订单支付后几分钟内还没发货用户能理解但支付页面显示“扣款成功”下一秒钱包余额没变用户马上就要投诉了。对用户体感敏感的数据窗口要尽力压缩或者干脆不走BASE。第三把尺子遇到网络分区时系统是更看重可用性还是更看重一致核心交易链路如果选择在故障时拒绝写入可以用2PC或强一致如果希望继续接受用户请求就不能让每个写操作都受阻只能选择BASE然后靠异步和补偿兜底。5.2 场景化选型表场景推荐模型原因资金扣款、账户余额强一致/ACID补偿成本高对一致极敏感订单状态流转最终一致补偿状态机完整可逆向操作库存扣减最终一致预占/补偿可冻结、可释放超卖可追点赞、收藏、浏览量计数最终一致允许短时不一致追求高吞吐缓存与数据库一致性最终一致读多写少延迟双删可收敛搜索引擎索引更新最终一致索引构建延迟可接受日志收集与离线计算最终一致统计结果无需实时精确这张表不是死的。同一个场景在不同公司、不同业务阶段选型可能完全不同。比如小额贷的余额系统和大型电商的余额系统对一致性的要求就不一样。关键是拿前面的尺子去量而不是照抄表。5.3 硬指标RTO/RPO、SLA与一致性违约成本光说“用最终一致”还不够要把不一致变成一个可度量、可验收的指标。我做设计时一般会写清楚几个数字一致性窗口W即某个操作成功到所有副本可见的最长时间比如MQ消息延迟30秒内、MySQL从库延迟1秒内、缓存双删延迟5秒内修复时间即对账任务多久跑一轮发现偏差后多久能修复一般设计成30分钟到2小时RPO与RTO即极端故障下允许丢失多少数据、系统多久恢复。把这些数字写进设计文档后BASE就不再是一个含糊的口号而是可以被测试、被监控、被审计的工程指标。当你发现一致性窗口超过预期时就该考虑优化复制链路、增加告警条数或者调整重试频率了。6. 生产里的坑与我目前的实操心得6.1 坑一只做最终一致忘了“可收敛”设计有一段时间我带的订单服务出过一个线上事故支付回调已经成功但后续的库存扣减一直失败重试又因为下游接口bug反复失败订单就一直卡在“已支付未发货”的状态用户投诉了一整天。问题根源就是当时只想着“用最终一致”却没给每个状态设计好超时后的收敛动作。从那以后我养成了一个习惯设计状态机时把每个中间状态列出来逐一追问“这个状态超时了怎么办重试失败达到上限怎么办”必须给每个状态找到一个出口。没有超时规则的状态就是定时炸弹迟早会在线上炸一次。6.2 坑二幂等只依赖数据库唯一键重试却不全量覆盖幂等设计也踩过坑。当时我们在订单支付回调上加了唯一约束但重试任务的代码每次重试都会重新生成一个requestId结果唯一键约束被绕过了重复回调还是重复处理了。后来才意识到幂等不是加个唯一键这么简单重试必须复用同一个业务ID而且这个ID要从最上层接口一直透传到最底层的数据库操作里链路中任何一环生成了新ID幂等就形同虚设。另外重试的频率也要控制。无脑重试会把下游系统打挂尤其是故障恢复后几十万条积压消息同时重发下游瞬间雪崩。现在我一般用指数退避加最大重试次数超过上限进入死信队列留给人去处理。6.3 坑三锁过期引发了并发覆盖以为分布式锁解决了一切有一次我们用Redis分布式锁保护一个写操作结果在低概率情况下还是出现了数据覆盖。排查半天发现是业务线程执行时间超过了锁的过期时间锁被自动释放第二个线程进入了临界区两个线程同时写同一条记录。虽然每个写操作本身是原子的但业务逻辑的顺序被打乱了最终结果就错了。所以我现在对自己的团队有个铁律分布式锁只负责互斥不负责一致性要保证最终结果正确还得靠幂等、版本号或者数据库约束做兜底。锁过期导致的并发问题只有业务逻辑加上乐观锁或唯一键约束才能真正免疫。6.4 我对BASE工程化的阶段总结做过好几轮分布式改造之后我对BASE的工程化体会总结起来就三件事重试、幂等、对账。重试解决临时失败幂等解决重复执行对账解决漏网之鱼。把这三件事做好了最终一致才真正落地否则只是嘴上说说。最后再分享一个建议不要在产品文档里写“最终一致”就结束了最好写清楚“数据在什么窗口内一致窗口边界怎么保证窗口外怎么修复”。这个习惯帮我避过很多次上线事故也让我跟业务方沟通时少了很多扯皮。无论做订单、库存还是存储只要你能回答清楚这三个问题BASE这套思路你就可以放心用了。