做分布式系统这几年,我最大的感受是:大部分故障都是因为“太想把所有数据都保持一致”而拖垮的。业务正在大促,订单量翻了几倍,结果你坚持要求每个库存扣减都要实时同步到所有副本,数据库锁越拉越长,最终整个链路被拖死——这种事故我见过不止一次。而BASE原则,就是专门用来解决这种困境的。BASE原则与高可用系统几乎是绑定出现的,它的核心思路不是“怎么做强一致”,而是“怎么适当妥协”。这篇博客我会结合自己在一线踩坑的经验,把BASE的基本可用、软状态、最终一致性这三件事拆开讲清楚,包括哪些场景该妥协、到底怎么妥协、妥协之后怎么把账算平。
1. BASE原则到底在说什么
1.1 从ACID到BASE:一次理念的翻转
很多刚接触分布式的同学容易把BASE理解成ACID的对立面,这个说法不够准确。ACID解决的是单机事务的可靠性问题,强调的是原子性、一致性、隔离性、持久性;而BASE解决的是跨服务的分布式场景下,系统怎么在不牺牲可用性的前提下处理数据一致性的问题。
CAP理论把分布式系统推到了一个没法回避的三岔路口:当网络出现分区时,你必须在一致性和可用性之间做取舍。BASE的本质,就是在这个路口上先选了可用性,然后再用“软状态”和“最终一致性”把失去的强一致“补”回来一部分。
一句话概括:ACID是“每一步都严格确认”,BASE是“先答应干活,后面慢慢核对”。这更像是一次业务理念的翻转,而不是技术方案的倒退。
1.2 三句口诀:基本可用、软状态、最终一致性
BASE是Basically Available(基本可用)、Soft State(软状态)、Eventually Consistent(最终一致性)三个词的缩写。这三者是一套完整的处理思路,不能拆开单独看。
- 基本可用:系统在极端情况下损失部分非核心功能,但核心功能仍然可用。
- 软状态:允许系统存在数据不一致的中间状态,这种状态是暂时的、有期限的。
- 最终一致性:经过一段时间,经过消息、重试、补偿等手段,数据最终能够对齐。
可以用一个生活化的例子来理解:你和朋友约在餐厅碰头,严格一致性的要求是两人必须同一秒出现在门口;BASE的做法是,你先到就先进去坐着,对方晚几分钟到也没关系,反正最终你们会在同一张桌子上吃饭。这里的“先进去坐着”就是基本可用,“对方还没到”就是软状态,“最终在餐桌上碰头”就是最终一致性。
1.3 适合BASE的场景,和坚决不能用BASE的场景
BASE不是万能钥匙。我在给团队做技术方案的时候,第一件事永远是判断业务场景能不能接受最终一致性。
适合用BASE的场景,典型特征是数据最终账平即可,短期内允许偏差:电商的订单状态流转、商品的浏览量与点赞数、用户积分的累计、优惠券的发放与回滚、社交平台的Feed流,这些场景即使出现几秒甚至几分钟的延迟,用户完全感知不到,就算感知到了也不会造成资金损失。
坚决不能用BASE的场景,典型特征是强一致是硬性要求:账户余额的扣减、证券交易的撮合、医疗系统中的处方数据、库存扣减中涉及超卖的部分。这类场景如果先答应再补账,就不是“补偿”了,而是直接变成资损事故或合规事故。很多团队犯的错,就是试图用BASE解决所有问题,最后把转账都做成最终一致,出事以后才发现根本赔不起。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 基本可用:优先保住系统活下来
2.1 “基本可用”不是“不可用”,而是降级后的可用
很多人一听“基本可用”就觉得是砍功能,这理解偏差很大。基本可用强调的是:当系统面临超出预期的压力时,宁可用降低体验的方式让系统继续服务,也不能直接拒绝所有请求。
举个例子。某个电商平台在大促期间,商品详情页的实时问答模块被热点数据打爆,如果坚持要让问答模块强一致、实时刷新,可能整个详情页都会挂。合理的做法是:问答模块做降级处理,先返回缓存的旧内容,或者直接隐藏入口,保证用户最核心的浏览和下单路径不受影响。用户压根不会注意到问答案例是旧的,但如果整个页面打不开,那就直接失去一笔订单。
对系统来说,“活下来”和“活得好”是两回事。基本可用关注的首先是前者。你可以在活下来的基础上再逐步恢复体验,但一旦系统整体宕机,所有体验都等于零。这个优先级顺序,必须在设计阶段就明确下来。
2.2 限流、熔断与降级的落地姿势
基本可用落到实操层面,靠的是三件套:限流、熔断、降级。这三者经常被混在一起说,但职责完全不同。
限流是“控制进入系统的流量”。常见的算法有令牌桶和漏桶。令牌桶允许一定程度的突发流量,适合应对秒杀场景;漏桶则以固定速率处理请求,适合保护下游数据库这种承受能力固定的资源。我常用的方案是Guava RateLimiter或者Sentinel,具体限流阈值不是拍脑袋定的,而是根据压测数据来:比如数据库连接池最大50个连接,单请求平均耗时20ms,那单机QPS阈值就大致定在1500左右,留出至少30%的余量。
熔断是“当某个依赖已经出现故障时,快速失败,不再继续调用”。熔断器一般有三个状态:关闭、打开、半开。当错误率超过阈值(比如5秒内错误率超过50%),熔断器打开,后续请求直接返回降级结果,不再打到下游;经过一段冷却时间,进入半开状态,放少量请求探测下游是否恢复,恢复了就关闭熔断器。这里有个细节:熔断的阈值必须按依赖区分设置,不能一套参数走天下。高延迟敏感的服务阈值要小,容忍度低的接口阈值要更小。
降级是“主动牺牲非核心功能,保障核心功能”。降级方案必须在系统上线前就做好,不能故障发生了再临时改代码。我见过最典型的反面案例是:线上数据库连接池被打满,运维同学临时去改代码下线一个非核心功能,结果代码改完还没发布,系统已经挂了。降级方案应该是配置化的,比如通过配置中心一键关闭某个开关,而不是靠发版。
2.3 业务上如何定义“可接受的降级”
技术上的降级动作好做,难的是业务上怎么定义哪些功能可以降、降到什么程度。这里必须由技术和业务负责人一起拍板,形成一份明确的降级矩阵。
降级矩阵可以按“核心程度”和“降级策略”两个维度来梳理。拿一个典型的交易系统来说:
| 功能模块 | 核心程度 | 降级策略 |
|---|---|---|
| 用户登录 | 核心 | 降级为短信验证码登录,不做风控拦截 |
| 商品详情 | 核心 | 返回缓存数据,评论、问答改为异步加载 |
| 下单提交 | 核心 | 保证主流程,赠品、包装服务取消选择 |
| 库存查询 | 核心 | 返回缓存库存,不保证绝对精准 |
| 优惠券计算 | 非核心 | 高峰时段关闭自动推荐,用户手动输入 |
| 消息通知 | 非核心 | 短信、push全部延后发送 |
关键是降级以后要有对应的“恢复预案”。比如缓存库存降级以后,后台要有一个定时任务持续回源数据库刷新缓存,同时准备一个开关,能在数据库压力回落后迅速切回实时库存模式。降级的动作要能快速执行,也要能快速撤销。
3. 软状态:允许中间状态存在
3.1 软状态的核心是“临时不一致”
软状态是BASE里最容易被忽略、但最值得我们花时间去设计的部分。它指的是:数据在传输和处理的过程中,系统允许副本之间、服务之间存在短暂的、临时的数据不一致状态。
举个例子,用户下单支付成功后,订单状态从“待支付”变成“支付成功”,但此时订单服务还没把结果同步给积分服务,积分还没给用户加上——从全系统的视角看,数据和数据之间就是不一致的。这个不一致状态不是bug,它是整个异步链路中的必然经过点。设计的关键在于:这个状态要“软”,要有明确的期限和兜底机制,不能一直不一致下去。
软状态的核心载体是状态机。订单的每一个状态,能转移到哪个状态,都是预先定义好的。这样即使某个环节出现异常,系统也知道当前状态可以怎么往回收。
3.2 缓存过期、异步任务与临时标记
软状态在实际工程里最常见的三个体现方式:缓存过期、异步任务、临时标记。
缓存过期是最常见的软状态。商品详情页里展示的销量是缓存里的旧数据,用户看到的不是最新值,但系统保证了缓存会在指定时间过期并重新加载。这里要特别注意缓存穿透和雪崩:大量缓存同时过期会导致流量直接打到数据库。解决办法是给过期时间加一个随机偏移量,比如基础过期时间5分钟,再加上0到60秒的随机值,避免整体失效。
异步任务是另一个典型。比如用户上传头像后,系统先立即显示本地预览,后台异步执行图片压缩和分发。在异步任务完成之前,不同用户看到的头像可能不一样,这就是软状态。设计异步任务时要注意队列的可靠性和消费者的幂等性,任务失败要有重试机制,且重试时不能重复处理。
临时标记则常见于分布式事务的中间阶段。比如“预扣库存”方案中,订单创建后先锁定库存,但订单还没支付,库存的状态就是“预占”而不是“已扣减”。这个标记就是软状态的核心体现,后续要么支付成功转为正式扣减,要么超时释放库存。
3.3 软状态下的超时与重试设计
软状态一定伴随超时和重试,但这部分恰恰是最容易出问题的。很多线上事故不是强一致导致的,而是重试机制设计得不合理导致的。
重试的第一原则是指数退避加抖动。固定间隔重试容易引发“重试风暴”:某一时刻下游故障,所有调用方同时重试,把故障的服务直接打死。正确做法是每次重试的时间间隔成倍增加,比如第一次重试等1秒,第二次等2秒,第三次等4秒,同时加上一定的随机抖动,避免所有请求在同一时刻发起重试。
重试的第二原则是必须幂等。这个我在后面实践部分会详细讲,这里先说结论:任何被重试的操作,都要保证执行一次和执行一百次的结果一样。否则重试就是事故放大器。
超时设计的经验值方面,我的做法是把调用超时拆成“连接超时”和“读取超时”,分别设置。连接超时一般设1到3秒,读取超时取决于接口的最长响应时间,通常设2到5秒,不能一个超时配置用全年。超时之后立即触发降级或重试,不能干等。
4. 最终一致性:把账算平的艺术
4.1 最终一致性不是只有一种形态
很多人把最终一致性当做一个大箩筐,什么场景都往里装,其实最终一致性内部还能细分出不同的强度等级。从弱到强大致有:因果一致性、读己之写一致性、单调读一致性、单调写一致性。
因果一致性要求有因果关系的事件,不同节点上的观察顺序要一致。比如用户先发了一条微博,再评论这条微博,那评论必须在微博之后出现,不能反过来。读己之写一致性要求用户提交更新后,总能读到自己的更新结果,比如用户改完头像,自己刷新页面看到的必须是新头像,但别的用户晚一点看到新头像是可以接受的。单调读一致性要求同一个用户多次读取,数据不会出现“倒退”,比如第一次读到库存还剩10件,第二次读就不能变回9件。
实际选型时,要根据业务对一致性的需求精确选择强度等级。不是所有场景都需要最强的一致性,也不是一把梭到底用最弱的。过度选择强一致会白白牺牲性能,过度选择弱一致又可能导致业务上无法接受的现象。
4.2 消息队列加本地消息表:最朴素的最终一致性方案
实现最终一致性的方案很多,我用了几年下来,觉得最可控、最容易被团队成员理解的还是本地消息表。
原理很简单:在业务数据库里建一张消息表,业务操作和写消息表在同一个本地事务中完成。比如订单服务在同一个事务里插入订单数据、插入一条“需要通知库存服务扣库存”的消息,然后有一个异步任务把消息表中的消息投递到MQ。投递成功的消息标记为“已发送”,投递失败的消息留在表里等待定时重试。
这个方案之所以稳,是因为它把“业务操作”和“记录要做什么操作”绑定在一个本地事务里,不会出现业务成功了但消息丢失的情况。很多团队直接用事务消息,像RocketMQ的事务消息,本质上也是类似的思路:先发送半消息,执行本地事务,再提交或回滚半消息。
踩过的坑是:一定要给消息表增加一个唯一业务ID,比如orderId。这样消费者即使重复收到了消息,也能通过唯一ID判断是否已经处理过,直接丢弃,避免重复扣款、重复发券等事故。
4.3 对账与补偿:最终一致性的“安全网”
消息队列能做的是保证正常路径下数据最终一致,但没法保证极端情况下不出问题:消息丢失、消息重复、消费者代码bug、数据库宕机……所以对账是最终一致性方案里绝对不能省略的一环。
对账的思路很简单粗暴:定期把全链路的业务数据和状态拉出来比对。比如每天凌晨跑一个对账任务,把订单表、支付流水表、库存流水表关联起来,找出“已支付但订单状态异常”“订单已关闭但库存未释放”等异常数据。对账发现的异常,再走补偿任务修复。
补偿操作要遵循两个原则:正向补偿要幂等、反向补偿要可追溯。正向补偿就是重试执行未完成的操作,必须保证重复执行结果一样;反向补偿是执行一个与失败操作相反的操作,比如支付失败后把预占库存释放,这个操作必须记录下来,方便出问题时定位是谁在什么时间做了补偿。
这个环节最容易被业务方忽视,因为“看起来不是核心链路”。但恰恰是对账系统,才是真正兜住最终一致性这个承诺的底线。没有对账的最终一致性,就像是走路不系鞋带,摔不摔跟头全看运气。
5. 工程实践:一个电商下单场景的完整拆解
5.1 场景设定与链路分析
用一个最典型的电商下单场景把前面说的内容串起来。假设用户在前端提交一个订单,涉及四个核心服务:订单服务、库存服务、支付服务、用户积分服务。
传统的强一致方案是:订单服务同步调用库存服务锁定库存,同步调用支付服务发起扣款,再同步调用积分服务增加积分,任何一个环节失败就整体回滚。听起来很完美,但在大促场景下,这种做法会让一个下单请求的链路耗时达到几百毫秒甚至更久,而且任何一个下游抖动,都会导致整个下单流程失败。
用BASE原则重新设计之后,链路变成:订单服务先创建订单并插入一条“预占库存消息”,返回用户“下单成功”的反馈,然后异步去占用库存、异步发起支付、异步增加积分。这样下单接口的响应时间被压缩到几十毫秒,系统的吞吐能力大幅提升。
5.2 下单流程里的BASE落点
订单创建这个环节,体现的是基本可用。用户点击下单,系统先保证“订单能创建成功”这个核心诉求,至于积分能不能立刻到账、库存能不能同步扣减,全部交给异步逻辑。即使积分服务挂了,用户的订单照常创建,积分后续再补。
订单和库存之间的数据,体现的是软状态。订单表的状态是“待支付”,库存表里对应的商品可能已经预占但还没正式扣减,两个表之间短期不一致。这个状态通过一个“预占标记”来标识,后续根据支付结果决定是正式扣减还是释放。
库存、支付、积分三个服务之间的数据同步,体现的是最终一致性。通过本地消息表加MQ,保证每个异步操作最终都被执行;通过定时任务扫描消息表,保证失败的消息能重试;通过每日对账,保证漏掉的消息也能被发现并补偿。
5.3 数据一致性的保障方案细节
下面给出一个可以直接落地的方案细节。
库存预占消息表的结构大致是这样:
code复制CREATE TABLE t_message_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
business_type VARCHAR(32) NOT NULL COMMENT '业务类型:STOCK_OCCUPY / PAY_NOTIFY / POINT_ADD',
business_id VARCHAR(64) NOT NULL COMMENT '业务唯一ID,如orderId',
payload TEXT NOT NULL COMMENT '消息体,JSON格式',
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,
update_time DATETIME NOT NULL,
UNIQUE KEY uk_business (business_type, business_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
订单创建时,用一个本地事务同时插入订单表和消息表。然后一个定时任务每秒钟扫描一次待发送的消息,投递到MQ。投递成功的更新状态为已发送,失败的按照指数退避更新下次重试时间。
库存服务消费到消息后,执行库存预占操作,执行成功的返回一个明确的ACK,消息标记为已完成。这里的关键是幂等:库存服务里建一张库存流水表,同一个orderId和skuId组合只能存在一条预占流水,重复消息来了直接返回成功,但是不支持重复扣减。幂等方案我一般用唯一索引实现,靠数据库约束兜底,比代码判断更可靠。
6. 常见问题与排查技巧实录
6.1 高频问题速查表
| 问题现象 | 可能原因 | 排查思路 |
|---|---|---|
| 消息表消息积压不消费 | 消费者挂了或消费速度跟不上 | 查看消费者日志、检查MQ消费组堆积量 |
| 消息重复导致重复操作 | 消费者没有做幂等处理 | 检查是否有唯一索引或业务幂等表 |
| 数据长时间不一致 | 重试超过阈值被丢弃 | 扫描消息表状态为超限的记录,人工介入 |
| 下游服务被重试打垮 | 重试没有退避或没有熔断 | 检查重试间隔配置,加入熔断器 |
| 库存超卖 | 预占和扣减逻辑没有加锁 | 用数据库行锁或分布式锁保护扣减动作 |
6.2 一次真实的事故复盘
我之前负责过一个积分系统改造,上线后不久出现了一个典型问题:用户下单之后,积分一直没到账,最后排查发现是消息表里的消息状态更新逻辑有问题。投递成功之后,消费者处理完成,但回写消息状态这一步失败了,导致消息一直停留在“已发送”状态,定时扫描又只扫描“待发送”状态的消息,这条消息就被永远漏掉了。
这个问题的根因是成功路径上依赖了多条写操作,但没有保证原子性。后来把方案改成了:消费者处理成功后,把处理结果写进一张独立的处理流水表,订单的积分状态通过流水表推导,消息表的状态只做参考不做依据。即使消息状态更新失败,对账任务也能通过流水表发现并补偿。
这类问题的排查思路其实很通用:不要依赖单一标记判断流程是否完成,尽量用多个维度的数据互相校验。消息表说“已发送”,处理流水表说“已处理”,业务表说“已到账”,三层都对齐,才能确定这条链路真的没问题。
6.3 监控与告警的落地建议
做了这么多年系统,我最大的体会是:最终一致性方案能不能真正“睡得着觉”,取决于监控做得到不到位。我建议至少要覆盖这几个指标:
- 消息积压数:MQ中每个消费组的积压数量,超过阈值立即告警,积压意味着数据一致性的时间窗口被拉长。
- 消息重试次数分布:重点监控重试超过5次的消息,这些消息大概率是有问题的,继续重试只会浪费资源。
- 不一致数据扫描结果:对账任务每次执行后,统计发现的不一致数据量,异常波动要告警。
- 补偿任务执行成功率:补偿任务本身也会失败,需要监控。
告警阈值不要拍脑袋定。比如消息积压,如果日常500条以内是正常的,那阈值就设在1000条,给足缓冲。告警要带上下文信息,最简单的也要带上业务ID和消息内容摘要,否则收到告警还要去翻日志,耽误黄金处理时间。
另外强调一个细节:重试不是越勤越好。我见过很多团队把重试间隔设成1秒、2秒、3秒的等差序列,看起来服务很勤劳,实际上给下游造成了巨大的压力,下游本来只有一点小抖动,硬是被重试打成了大故障。退避间隔应该结合下游的恢复周期来定,一般建议从5秒起步,翻倍增长,最大间隔不超过5分钟,超过最大次数进入人工处理队列。
最后聊点实在的
BASE原则说到底不是一套死板的技术规范,而是一种系统设计时的价值排序:在资源有限的情况下,你愿意牺牲什么来保住什么。我自己做过不少最终一致性的方案,最深的体会是,技术上实现最终一致性其实不难,难的是让团队每个成员都理解“哪些数据允许暂时不一致,哪些必须严格一致”,并且把这条边界写进设计文档、写进评审清单。
一个比较实用的建议:做一个内部的“一致性等级自查表”,在每次技术方案评审时过一遍。这个表里列清楚业务场景、允许的最大不一致时间窗口、不一致状态下用户可能感知到的现象、对应的一致性兜底方案。表格填清楚了,很少会出现上线后因为不一致方向设计错误而导致的事故。
最后再分享一个我自己的小习惯:不管设计方案时想得多周全,上线后头三个月一定要人工定期翻一翻对账日志。工具再自动化,也比不上人对数据的敏感度。看到一条奇怪的异常数据,往深挖一层的收获,往往比读十篇技术文档都大。这就是BASE这条“妥协艺术”里,最需要长期修炼的功夫。
