凌晨两点,一条自动化补货流程被触发了。库存服务先把某件商品从“可售 1 件”改成“可售 0 件”,订单服务随后创建订单,通知服务再给用户发送成功消息。几乎同一时刻,另一个机房也接到了购买请求。两个请求都很快,两个页面也都显示“提交成功”,可仓库里明明只有一件货。
这类问题很容易被一句“数据库不一致”带过,可真正排查时,我们必须问得更具体:两次并发写入是不是覆盖了彼此?一次业务更新是不是被拆成了几个可见的中间状态?副本之间是不是还没同步完?用户为什么看不到自己刚提交的内容?数据库说“写入成功”,究竟代表写进了内存、磁盘,还是多个故障域?
你会发现,一致性不是一个总开关,而是一组可以分别讨论的承诺。这一章我们就沿着一条自动化流程,从并发写、读取、副本传播、网络分区一路走到持久性与法定人数。学完之后,你不仅能说清“最终一致”,更能为具体业务写出可验证的一致性要求。

这一章里的“一致性”主要指并发与复制环境中,读写结果是否满足预先约定的规则。它和 ACID 中强调事务前后业务约束成立的“一致性”有关,却不是完全相同的概念。讨论方案前先说明自己说的是哪一种,否则大家可能使用同一个词,却在解决不同的问题。
回到开头的补货流程。只说“我要强一致”仍然不够,因为系统至少面对五个不同问题。
这五个问题之间有关联,却不能相互替代。三个副本都保存了同一个旧值,持久性可能很好,数据却不新;所有请求都读主节点,读到的值可能很新,可如果确认前没有落盘,断电后仍可能丢失。
项目里最危险的要求往往是“尽量实时”“不要不一致”“最好别丢”。我们可以把它们改写成测试人员能执行的承诺:
一旦承诺能被观察和测试,我们才有资格讨论锁、事务、版本号、会话令牌或法定人数。下面先从最常见的丢失更新开始。
假设一条风控自动化规则当前是:订单金额超过 5000 元时进入人工审核,文档版本为 7。运营甲想把阈值改成 6000 元,运营乙想把适用地区扩展到华东。两人几乎同时读取了版本 7,又各自在自己的页面上点击保存。
如果客户端提交的是整份旧文档,甲先保存成版本 8,乙随后仍拿版本 7 为基础覆盖整份文档,甲改过的阈值就可能悄悄恢复成 5000 元。这叫丢失更新。问题不在于服务器没有排列请求顺序,而在于第二次写入没有证明自己的前提仍然成立。

悲观方案的思路是“冲突很可能发生,所以先阻止别人进入”。客户端修改前取得写锁或租约,其他修改者等待、超时或转为只读。它适合冲突频繁、操作时间短、失败重做代价很高的场景,例如给稀缺库存分配唯一占用权。
不过,分布式锁不是多写一行“加锁”就结束了。你还要处理锁持有者崩溃、租约过期、网络暂停、重复释放和脑裂。若业务操作可能持续几分钟,让所有人一直等锁,吞吐量与可用性都会迅速下降。实践中通常会给锁设置租约,并用单调递增的隔离令牌拒绝过期持有者继续写入,而不是只相信“我曾经拿到过锁”。
乐观方案假设冲突不常发生。每条文档携带 version,更新时把“我读到的版本”放进条件。数据库只有在当前版本仍为 7 时才执行更新,并把版本加到 8;若条件不成立,影响行数为 0,客户端就知道发生了冲突。
const result = await rules.updateOne(
{ ruleId: "risk-review", version: 7 },
{
$set: { threshold: 6000 },
$inc: { version: 1 }
}
)
if (result.modifiedCount === 0) {
throw new Error("版本冲突:请重新读取后再决定是否重试")
}这里最重要的不是某个数据库的语法,而是“检查条件”和“执行修改”必须是同一个原子操作。先查版本、回到应用层判断、再无条件更新,中间仍然可能插入另一个写入。
先读取文档及其版本号,把版本号当成这次编辑所依赖的前提,而不是一个只用于展示的字段。
提交时把旧版本写进更新条件,并在同一次原子更新中修改业务字段、递增版本号。
条件失败后重新读取最新文档,判断两个改动是否能自动合并。不同字段的独立修改可能合并,同一阈值的两个不同值通常需要用户决定。
重试必须设置上限并加入退避。高冲突热点持续立即重试,只会把有用流量变成重试风暴,此时应考虑排队、分片或悲观控制。
下面的实验中,甲和乙都从版本 7 开始。先切换策略,再依次点击两人的保存按钮。你会看到“直接覆盖”如何丢掉改动,以及“条件更新”怎样把冲突变成可以处理的失败。
乐观控制不会自动决定“哪个业务值正确”,它只是阻止冲突被隐藏。检测到冲突之后,你仍要选择拒绝、重读重试、字段级合并、保留多版本,或交给用户裁决。对“余额扣减”这类不可随意合并的操作,拒绝旧版本往往比“最后写入者获胜”更安全。
现在把目光从两次写入转向一次业务更新。一个订单中有商品明细、优惠金额和应付总额。自动化流程先新增商品,隔了几十毫秒才重算总额。如果读请求恰好落在这段间隔里,就会看到“新商品 + 旧总额”。每个字段单独看都是真实值,拼在一起却不满足业务公式。

不少文档数据库能保证单文档更新原子执行,这也是为什么建模时常把需要一起变化、一起读取的数据放进同一个订单聚合。一次更新同时追加商品并重算总额,读取者只能看到更新前或更新后的完整文档,不会看到中间状态。
但“把所有东西塞进一份巨型文档”也不是答案。文档会无限增长,热点写入互相争用,不同生命周期的数据难以独立归档。我们真正要找的是业务不变量的边界:哪些字段必须同时成立,哪些信息允许稍后派生。
await orders.updateOne(
{ orderId: "O-1048", version: 12 },
{
$push: { items: { sku: "A17", quantity: 1, price: 80 } },
$inc: { total: 80, version: 1 }
}
)这段更新能保护单个订单文档,却没有自动保护库存文档、支付记录和通知任务。跨聚合时可以使用数据库提供的多文档事务,也可以把流程拆成带状态的步骤。关键是不要假装中间状态不存在。
假设下单要依次完成“预占库存、创建支付单、发送通知”。我们可以先写入一个拥有唯一 operationId 的流程记录,每个步骤只在前置状态满足时推进,并让所有外部调用都能按这个 ID 去重。失败时,流程进入“待重试”或“待补偿”,而不是留下一个无法解释的半成品。
{
"operationId": "checkout-20260811-0007",
"orderId": "O-1048",
"state": "INVENTORY_RESERVED",
"completedSteps": ["CREATE_ORDER", "RESERVE_INVENTORY"],
"nextStep": "CREATE_PAYMENT",
"retryCount": 1,
"version": 4
}这里的目标不是把分布式流程伪装成一个瞬间完成的大事务,而是做到三件事:每一步可判定、重复执行不产生第二份副作用、故障后知道从哪里继续。如果补偿不是严格逆操作,也要把业务后果说清楚。例如释放库存能撤销预占,但通知已经被用户看到,就只能再发更正消息。
“NoSQL 没有事务”是一个过度概括。不同产品、不同部署模式和不同操作范围提供的事务能力并不相同;有的支持单文档原子更新,有的支持多文档事务,有的把可调一致性作为主要能力。设计时要核对你实际使用的操作边界,不能只凭“NoSQL”这个类别下结论。
复制让系统能够容忍节点故障、分担读取压力,也带来了传播时间。写入先在一个节点成功,另一个节点可能稍后才收到。用户从不同副本读取时,就可能在一小段时间内看到不同版本。
“最终一致”描述的是一种收敛性质:当新的更新停止,并且复制、冲突处理与修复机制持续工作,副本最终会收敛到兼容的状态。它不等于“一定在几秒内一致”,也不等于“冲突会自动得到业务正确的结果”。传播上限、失败重试、反熵修复和冲突规则仍然需要单独设计与监控。
很多业务不需要所有用户同时看到最新值,却很在意一个人的操作不能前后倒退。常见的会话保证包括:

粘性会话是最直观的做法:让用户持续访问刚才写入的节点。但节点故障或负载迁移时,保证就容易中断。更稳妥的办法是让写入返回版本令牌,后续请求携带“我至少要看到版本 42”。接收请求的副本若尚未追上,可以短暂等待、转发到更新副本,或明确返回“数据同步中”,而不是悄悄给出更旧结果。
在工程沟通中,“强一致”经常表示“成功写入后马上能读到最新值”,但这个说法仍可能遗漏并发语义。更精确的承诺是线性一致:每个操作看起来都在调用与返回之间的某一瞬间生效,并且符合真实时间顺序。它适合库存唯一扣减、唯一名称注册、租约所有权等需要单一权威判断的操作。
序列一致也要求所有参与者看到同一个操作总顺序,但这个顺序不一定尊重现实时间。若甲的写入已经明确完成,乙之后才开始读取,线性一致不允许乙看到更早状态;序列一致的抽象排序可能没有这条真实时间约束。对普通业务文档不必背术语,可在设计锁、选主与并发对象时,这个差别非常重要。
一致性越强,通常需要更多协调:联系更多节点、等待更稳妥的确认、在分区时拒绝不确定操作。协调会增加延迟,也会让一部分故障转化成错误响应。反过来,允许读取旧值或稍后合并,能让更多节点独立响应,却把冲突检测、补偿与用户解释留给业务层。
我们不该按“整个系统”选一个等级,而应该按数据和操作分类:
一个实用的分层方法是:浏览和推荐可以读派生副本,用户会话增加“读到自己所写”,真正做不可逆决策时回到权威数据并使用条件写。这样不是把一致性随意调低,而是把最昂贵的保证放在最需要它的边界上。
假设北京与上海各有一个库存副本,网络突然中断。两边都还能接收请求,而库存只剩最后一件。如果两边都在无法联系对方时承诺下单成功,系统保住了响应,却可能超卖;如果某一边因为无法确认全局最新状态而拒绝写入,系统守住了单一库存判断,却没有让每个可达节点都成功响应。

CAP 真正约束的是网络分区存在时,线性一致与“每个请求都能由仍可达的节点在有限时间内得到非错误响应”不能同时保证。分区容错不是平时从菜单里勾掉的一项;只要节点之间可能丢包、延迟失控或彼此隔离,系统就必须定义分区期间的行为。
所以,“CA、CP、AP 三选一”适合做入门记忆,却容易误导实际设计。没有分区时,系统仍然要在一致性与延迟之间权衡;出现分区时,同一个系统也可以按操作做不同选择。账户扣款可以拒绝不确定写入,商品评论可以先在本地接收并稍后合并。
一致性优先的系统会把不确定性暴露成等待、超时或拒绝。比如只有持有有效租约的主节点能扣库存,失去多数节点联系后,旧主节点停止写入。用户可能暂时无法下单,但不会收到两个互相冲突的最终承诺。
可用性优先并不表示“忽略错误”,而是先接受本地写入,记录足够的版本与操作信息,待网络恢复后检测并解决冲突。评论、收藏集合可用并集等方式合并;库存超卖却无法靠简单并集修好,必须预留配额、排队确认、取消订单或人工补偿。
CAP 中的“可用”不是日常口语里的高可用指标,也不是“页面还能打开”。它要求每个到达未故障节点的请求都最终得到非错误响应。一个系统可以达到很高的服务可用率,却在分区时主动拒绝少量需要强一致的写入;这并不矛盾。
一致性回答不同操作之间怎样排列,持久性回答已经确认的结果能否熬过进程崩溃、断电、磁盘故障或主节点切换。自动化系统里的临时进度、缓存和财务流水,对持久性的要求显然不同。
内存确认最快,但进程崩溃就可能丢;追加日志后再确认能从日志重放;日志调用同步刷盘后再确认,可以缩小断电丢失窗口;再等待不同节点、不同机架或不同可用区的副本确认,则能抵抗更大的故障范围。每向外扩一层,通常都会增加写入延迟与故障时的拒绝概率。

复制也不自动等于持久。主节点在内存中接收写入后立即返回,副本还没收到就发生切换,新主节点上仍然可能没有这次写入。反过来,多数副本已经确认也不等于有异地灾备:如果三份副本都在同一台宿主机或同一个故障域,它们可能一起消失。
把确认策略做成“每类写入的业务决定”比全库一刀切更灵活,但也要限制可选组合,避免开发者为了快而随手使用最弱确认。
在多副本系统中,我们不一定要等所有副本。设某个数据分区有 N 份副本,一次写入要等 W 份确认,一次读取要收集 R 份响应。若 R + W > N,任意读集合与已确认写集合至少有一个交点。

例如 N = 5、W = 3、R = 3。写入确认的三个副本和读取联系的三个副本不可能完全错开,因为两组一共需要六个位置,而系统只有五份副本。读取比较版本时,至少能接触到一份参与过成功写入的副本。
若还希望两个并发写法定人数必然相交,则要满足 2W > N,也就是 W 超过半数。这能为检测或序列化冲突提供基础。常见的均衡配置是奇数副本配多数读、多数写,但它绝不是唯一选择:
W = N, R = 1:写慢且对副本故障敏感,读可以很快。W = 1, R = N:写确认快,读取要联系全部副本,写入的持久性窗口更弱。W = 1, R = 1:延迟和可用性较好,却可能读不到最新值,需要业务接受旧读。R + W > N 只证明集合有交点,不会凭空提供完整的强一致性。系统还要能识别哪个版本更新、正确处理并发版本、使用稳定的副本集合,并避免“本应写给某副本却临时写到别处”破坏计算前提。超时、失效检测和跨数据中心的本地法定人数也会改变实际保证,必须以具体产品语义为准。
还要注意,N 是这条数据的复制因子,不是集群总节点数。一百个节点的集群里,某个分区可能仍然只有三份副本。把集群节点数代进法定人数公式,是很常见也很隐蔽的错误。
现在我们重新设计开头的自动化下单流程。目标不是让所有环节都使用最昂贵的保证,而是让每一步都拥有恰当的承诺。
为购买请求生成全局唯一的操作标识。重试时继续使用同一个标识,库存、订单和通知服务都用它去重,避免网络超时后重复创建副作用。
库存扣减使用条件写:只有当前可售数大于零且版本符合预期时,才能原子减一。冲突失败属于正常业务结果,不把旧快照直接覆盖回去。
把订单状态建模成明确状态机,例如“已创建、库存已预占、支付处理中、已确认、待补偿”。状态迁移携带版本条件,旧任务不能让状态倒退。
将发送通知设计为幂等步骤。通知记录与业务状态之间若不能使用同一事务,就保存可重试的待发送事件;消费者处理成功后再标记,失败则按上限退避重试。
这个方案仍可能让用户看到“正在确认”,也可能在网络分区时拒绝最后一件库存的订单。但这些状态是系统有意暴露的业务语义,不是数据悄悄错乱。好的分布式设计不是让故障消失,而是让故障进入可判断、可恢复、可解释的路径。
最终一致只说明在更新停止、传播和修复继续的前提下会收敛,不自带固定时限。若产品需要 99.9% 的更新在 2 秒内可见,就把它作为单独的延迟目标监控。
最后写入者获胜能让副本选出一个结果,却可能丢掉并发业务意图。机器时钟还可能漂移。它适合可以整体替换、丢失一次并发修改也可接受的数据,不适合余额、库存和需要合并的协作内容。
事务边界内仍要理解隔离级别、条件更新和重试。跨服务副作用也不会因为数据库事务自动回滚。事务解决的是它覆盖范围内的问题,不会扩张成整个业务世界的原子性。
副本更多通常提高容灾能力,却会增加传播、修复和协调成本。读取是否新,取决于路由、读关注级别、版本比较和会话保证,而不是只看副本数量。
公式只描述集合交叠。冲突版本怎样比较、删除标记怎样传播、失败节点恢复后怎样修复、跨机房选择的是全局还是本地法定人数,都会影响最终语义。
这一章从并发修改开始,逐步拆开了更新一致性、逻辑读一致性、副本一致性、会话保证与持久性。版本号条件更新让冲突不再被无声覆盖;合适的聚合边界让读取者看不到业务半成品;版本令牌能在最终一致的复制上补足“读到自己所写”和单调读;幂等状态机与补偿让跨聚合流程可以恢复。
CAP 提醒我们,网络分区时不可能既让每个可达节点都成功响应,又维持单一的实时顺序。法定人数用集合交点提供可调的读写协调,但公式之外仍有版本、冲突、故障域与产品语义。持久性则是另一条轴:确认发生在内存、磁盘还是多个故障域,决定成功写入能抵抗多大的故障。
真正成熟的设计不会只在方案文档里写“使用最终一致”或“选择 CP”。它会明确:谁可以读到多旧的数据,写冲突怎样暴露,哪一步能重试,哪一步要补偿,成功确认代表保存到哪里,网络断开时哪些操作拒绝、哪些操作继续。把这些句子写清楚,我们才是在设计一致性,而不是给数据库贴标签。
展示页面可以读取就近副本,但同一用户写入后携带最低版本令牌。真正支付或确认库存时回到权威路径,不以可能陈旧的展示结果做不可逆决定。
按数据价值选择确认级别。临时推荐结果可以低成本确认,订单与账务则等待可靠落盘和足够副本;同时记录复制延迟、冲突率、条件失败率和补偿积压。