资讯动态

多笔原子访问之间需要保序吗?原子性不等于有序性

发布时间:2026/10/18 12:08:30 来源:尧图企业网站定制
1. 从一次线上事故说起原子性不等于有序性先讲一个我实际踩过的坑。当时在做一个交易系统的库存扣减模块核心逻辑很简单用户下单扣库存写流水。为了追求极致的并发性能我们把扣库存和写流水拆成了两个独立的原子操作分别用缓存和数据库来承载。压测的时候数据一切正常结果一上线线上就开始偶发出现“库存扣了但流水对不上”的账单差异。排查到最后问题不在数据丢失而在顺序。两个原子操作本身都成功了但它们之间的执行顺序在极端并发下出现了乱序扣库存的操作先于某个读操作执行而流水写入却落到了那个读操作后面。读操作读到的库存已经是最新的但流水还停留在旧状态——下游对账系统拿这两个数据一比对直接判定异常。这个事故让我彻底想明白了一件事原子性解决的是“要么全做要么全不做”它从来就没有承诺过“多笔原子访问之间一定按某种顺序发生”。而“保序”这个问题牵扯出的其实是并发编程和数据库隔离级别里最容易被忽视的一层隐语义。这篇文章我想系统地把“多笔原子访问相互之间需要保序吗”这件事拆开结合事务、隔离级别、分布式系统、缓存一致性这些场景说说什么时候必须保序什么时候可以放掉顺序以及判断的底层依据到底是什么。2. 先厘清概念什么是“原子访问”什么是“保序”2.1 原子访问的三种粒度讨论这个问题之前得先把“原子访问”这个概念圈清楚。不同语境下它指的东西完全不一样混为一谈就会得出错误结论。单条指令级别的原子性比如CPU的一条CAS指令、一条原子汇编指令。这种原子性由硬件保证执行过程不可分割但多核之间依然存在内存可见性的顺序问题。单次操作级别的原子性比如Redis里一个INCR、一个SETNX数据库里一条UPDATE语句。这类操作本身不会部分成功但多笔这样的操作之间并没有内建的顺序承诺。事务级别的原子性一组操作要么全部提交要么全部回滚这是数据库事务ACID里的A。事务与外部的隔离性则是由隔离级别控制的和原子性不是一回事。这三种粒度里前两种关注的是“单笔操作会不会碎”后一种关注的是“几笔操作之间的相互可见性”。而“保序”这个问题恰恰要跨过单笔操作的范畴去讨论多笔操作之间的顺序关系。2.2 “保序”到底在保什么“保序”在日常讨论中经常被混淆成好几个不同的意思语义具体含义典型场景一致性顺序所有观察者看到的操作顺序相同分布式共识、全序广播因果顺序有因果关系的操作必须按因→果的顺序被观察到支付必须先于退款偏序关系只保证部分操作之间的顺序允许无关联操作并行数据库快照隔离程序顺序单个线程/协程内代码的先后顺序编程语言内存模型注意这四种“保序”的强度和代价完全不同。一致性顺序是最强的要求全局所有节点看到完全一样的顺序通常要靠分布式共识协议比如Raft、Paxos来实现性能开销非常大。因果顺序只约束有依赖关系的操作相对便宜。偏序关系则是最常见也最经济的它允许无关操作并行只锁定关键路径上的先后关系。所以“多笔原子访问相互之间需要保序吗”这个问题本质上不能一刀切地回答“需要”或“不需要”而要先问清楚这些原子访问之间有没有因果依赖如果有依赖的粒度又是什么3. 单机数据库场景事务隔离级别与顺序的关系3.1 从隔离级别看顺序承诺在单机数据库里事务与事务之间的顺序关系其实是由隔离级别隐式决定的。很多开发者以为开了事务就等于有了全序保证这是个很深的误解。拿四种标准隔离级别来说读未提交Read Uncommitted连原子性都未必感知得到A事务未提交的修改能被B读到顺序完全是乱的。读已提交Read Committed只保证读到的都是已提交的数据但两次读取之间可能因为其他事务的提交而发生不可重复读顺序上只有“提交后可见”这一层弱约束。可重复读Repeatable Read在同一事务内多次读取同一数据的结果稳定。这时候事务内部视角来看外部提交对自己的影响被屏蔽了顺序被“冻结”在事务快照里。可串行化Serializable这是唯一在标准层面承诺了“某种全序”的隔离级别——所有并发事务的执行结果等价于某个串行执行顺序。可以看出隔离级别其实就是在回答一个问题在多大程度上你需要感知其他事务的操作顺序可串行化之所以最贵是因为它在“保序”上做得最彻底。3.2 一个经典例子为什么快照隔离下还会出现写偏序快照隔离Snapshot Isolation常被PostgreSQL的默认RR实际实现采用是一个很好的研究样本。它用MVCC实现读操作走快照写操作走冲突检测性能很好。但它有一个著名的问题——写偏序Write Skew。假设有两条约束账户A余额和账户B余额之和不能小于0。现在两个事务并发执行事务1if (AB 0) then B B - 50 事务2if (AB 0) then A A - 50两个事务都基于同一个快照判断“AB 0”成立然后各自扣减。在快照隔离下两个事务的写集不冲突一个改B一个改A所以都能提交。但最终AB变成了负数约束被打破。这里的关键就是两个原子事务之间没有定义一个明确的顺序——事务1先提交还是事务2先提交对最终结果来说都需要“后提交的那个”重新校验约束才合理。快照隔离不提供这种顺序因此出现了异常。这个例子特别适合回答标题里的问题当多笔原子访问之间存在共享约束时它们之间不仅要保序还要以“串行化”的强度来保序否则业务逻辑就会被并发穿透。3.3 实践中如何判断“要不要保序”我自己在数据库设计上有一个比较实用的判断框架遇到类似问题就套三条业务上是否存在先后依赖比如必须先创建订单后生成支付单这是因果顺序必须保。依赖是“强一致”还是“最终一致”如果下游允许短暂看到中间态那弱顺序就够了反之需要同步等待。约束检查的粒度横跨几条数据单行约束靠唯一索引、行锁就能保顺序跨行跨表的约束就必须用事务更高的隔离级别或者用显式锁来强制串行化。注意测试环境能通过的顺序假设不一定能在生产环境的并发下成立。凡是牵涉资金、库存、库存水位、超卖控制这类场景一律按“必须保序串行化”来设计否则迟早出事。4. 分布式场景两笔原子操作之间的顺序问题4.1 分布式事务中的“原子”和“顺序”是两顶帽子到了分布式系统里问题会变得更加复杂。很多微服务架构里“一笔业务操作”被拆分成了跨服务的多次RPC调用每一笔调用自身是原子的但多笔调用之间没有任何内建的顺序保证。举个出租车订单的例子乘客下单 - 创建订单服务原子操作1 司机接单 - 更新订单状态服务原子操作2如果这两个服务之间没有共享同一个数据库事务那么“创建订单”和“司机接单”这两个原子操作之间就存在一个天然的窗口下游乘客端先看到了订单已创建再看到司机已接单这算正常但如果网络发生重试、消息队列乱序、或者两个操作由不同服务并行处理乘客端可能先看到“司机已接单”然后才看到“订单创建中”——这在用户界面层就表现为状态倒退。解决这个问题有几种常见思路最终一致 状态机把订单设计成状态机接单操作只能从“待接单”态迁移到“已接单”态。如果“创建订单”还没有完成接单操作会被状态校验挡住。这样做的本质是用业务状态机定义了一个偏序关系把“保序”的负担从基础设施转移到了业务逻辑。分布式锁 / 租约对同一个业务实体的修改操作加锁强行把并发操作串行化。代价是性能下降和潜在的锁竞争热点。版本号 / 乐观锁每次修改带版本号版本号低的操作禁止覆盖版本号高的操作。这保证的是“写写顺序”——后写者不能覆盖先写者的成果但不保证“读写顺序”。4.2 缓存与数据库双写的顺序陷阱分布式架构里最容易踩的顺序坑就是缓存和数据库的双写。很多团队用“先更新数据库再删除缓存”这种经典模式来保证最终一致但这里有一个隐藏前提更新数据库和删除缓存这两个原子操作之间必须存在明确的先后顺序且这个顺序需要被所有并发路径遵守。实际代码里经常出现的问题是这样的线程A更新数据库为v2 - 删除缓存 线程B删除缓存 - 更新数据库为v3如果线程A删完缓存后线程B把库改成v3随后又有读请求把v3写回缓存紧接着线程A的删除缓存请求才到达——那么缓存里就留下了v3库也是v3看起来一致。但换个时序线程A更新数据库为v2 线程B更新数据库为v3后发先至先于A提交 线程A删除缓存 线程B删除缓存如果数据库层面v3先生效、v2后生效而缓存删除又是A先B后那么最终缓存在删除后会被后续读请求填充为v2数据库最新值但库里的最新值实际是v3——对账的时候又对不上了。这个问题的根治思路有两类一类是引入消息队列把数据库变更事件按提交顺序发给消费端去更新缓存另一类是给数据加版本号写入缓存时比较版本号只有新版本才能覆盖旧版本。本质都是把“顺序”从隐式假设变成显式约束。4.3 分布式共识与全序广播什么时候才真的需要“全局保序”如果系统里所有节点都必须以相同顺序观察到同一批操作那就不是“业务上保序”的问题了而是需要引入全序广播Total Order Broadcast。常见载体就是共识算法Raft的日志复制天然具备全序性所有节点按相同顺序应用日志条目ZooKeeper的ZAB协议也是这个思路。但是为所有操作都建立一个全局顺序是个代价极高的事情吞吐量往往会被压到单机水平以下。实际系统里绝大多数场景不需要全局全序只需要按业务实体分片后的局部全序。比如同一把订单号的操作用一致性哈希路由到同一个节点节点内按顺序处理不同订单号之间完全并行。这样既保住了因果关系又避免了全局瓶颈。5. 无锁编程与内存模型原子指令之间还要不要保序5.1 CPU重排与内存屏障把视角再往下沉一层到了处理器级别。现代CPU为了提升指令级并行会对内存访问进行重排编译器也会做指令重排。这就导致即使是多笔“原子指令”它们的可见顺序也可能和代码书写顺序不一致。经典例子是如下两个线程线程1: data 42; // 普通写 flag.store(true); // 原子写 线程2: if (flag.load()) { assert(data 42); }如果flag是普通的原子布尔没有加内存屏障这个断言在弱内存模型如ARM、PowerPC下可能失败线程2先看到flag true但data的写入还没有被线程1的写缓冲刷新出去于是读到了旧值。这时候原子访问之间不仅要保序还需要用内存屏障来显式保序。C11的std::atomic提供了memory_order_relaxed / acquire / release / seq_cst这些选项relaxed完全不保序只保证原子性。acquire/release保证同一原子变量上的读写存在因果顺序形成偏序。seq_cst全局一致顺序最贵。我平时写无锁队列或者并发计数器的时候会反复问自己一个问题我到底需要哪个级别的顺序如果只是统计次数relaxed就够了如果要做“发布数据-通知消费”这种模式至少需要release/acquire如果系统里多个线程都依赖同一个全局顺序那就只能seq_cst。5.2 一个锁无关的引用计数实例我写过一个无锁的共享指针实现里面就涉及多笔原子访问之间的顺序问题。共享指针的每个实例包含一个指向控制块的指针和两个计数字段强引用计数和弱引用计数。释放时强引用计数归零要销毁对象但销毁动作必须等所有弱引用也释放完。这里有一个必须保序的操作链1. 先递增弱引用计数原子操作1 2. 再读取强引用计数判断是否归零原子操作2 3. 如果强引用计数为0则禁止从弱引用提升为强引用原子操作3这三者之间必须严格按因果顺序执行否则一个线程可能看到强引用计数为0后开始析构而另一个线程还在尝试从那时的弱引用提升强引用导致悬垂指针。为此我在操作1和操作2之间插入了acquire在操作3前加了release实测才稳定下来。经验无锁代码里顺序比原子性更容易出错。审查别人的无锁代码时我第一眼看的往往不是“原子性对不对”而是“内存序够不够”。6. 最终答案什么时候必须保序什么时候可以放掉把前面这些场景收敛一下回到标题本身多笔原子访问相互之间需要保序吗我的回答是取决于它们之间是否存在因果依赖以及这种依赖的强度要求是什么。具体来说可以用一个判定表来做快速决策场景是否有因果依赖保序强度要求实现手段示例两个完全不相关的原子计数器无不需要保序relaxed原子操作同一条业务记录上的多个状态流转有强因果必须保序数据库事务状态机校验跨服务创建订单后接单有强因果必须保序版本号/状态机/分布式锁先写数据库后删缓存有弱因果最终一致即可消息队列削峰版本比对多个节点看到的全局顺序需要一致强因果全序广播Raft/ZABCPU指令级别的数据依赖有强因果内存屏障acquire/release语义放掉顺序的场景其实也很多。比如统计埋点每个埋点事件是独立的原子追加谁先谁后根本不重要比如记录用户访问次数的计数器只要最终数值对就行中间每个增量之间的顺序没有任何业务意义。在这些场景强行保序纯粹是给自己背包袱。6.1 一个工程上的速成口诀我把自己多年的经验浓缩成一句话给团队新人的时候反复讲原子性保证“不碎”顺序保证“不错”两者互相独立。需要“不错”的地方才聊保序且保序要精确到因果链上。落实到代码审查时我会按下面几步走列出所有涉及同一份可变状态的原子操作画出它们之间的因果依赖图对每一条依赖明确下游能容忍的“乱序窗口”是多大如果窗口必须为零就直接上事务/锁/状态机别再考虑优化跳过如果窗口允许非零就用版本号、消息队列、事件溯源等手段把“乱序修正”成本降到最低。6.2 最后的建议做系统设计最怕的不是不懂概念而是把“原子”当“全序”用或者反过来把“全序”当“原子”用。前者会造成隐含的顺序假设线上偶发翻车后者会造成严重的性能浪费架构越来越重。我个人在实际项目里的体会是真正成熟的设计不是把顺序加到满而是把因果依赖压缩到最小然后只在那条最窄的路径上显式保序。比如订单系统里一个订单的状态流转必须是串行的但不同订单之间完全并行库存表里单SKU的扣减必须保序但不同SKU互不阻塞。用最小的保序范围换取最大的并发能力这才是平衡的艺术。希望这篇文章能把“多笔原子访问相互之间需要保序吗”这个问题彻底讲透。下次再有人用“我加了原子操作顺序肯定没问题”这种说法说服你你可以把那句话接下去原子操作保证的是不碎顺序保证的是不错这是两件完全不同的事。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑