ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

原子性不等于保序:独立原子写操作到底要不要排序?

原子性不等于保序:独立原子写操作到底要不要排序?

前阵子评审一个并发模块时,团队里有人提出一个看起来特别基础的问题:这几笔独立的原子写操作,相互之间需不需要保序?会议室里立刻分成两派。一派说当然不需要,每笔都是原子的,各自执行完不就行了;另一派说肯定需要,不然先执行后执行结果可能完全不一样,数据怎么对得上。

这个问题表面简单,实际把"原子性"和"顺序性"两件事搅在一起了。我在多个项目里反复看过这类争论,也踩过不少坑。这篇文章想把这个问题彻底拆清楚:什么时候必须保序,什么时候保序是多余的,什么时候你以为在保序、其实需要的是原子性。

1. 先把"原子"和"顺序"拆开:它们原本不在一张床上

1.1 原子访问只约束"单笔操作",不约束"多笔操作之间"

所谓原子访问,最常见的是数据库里的一条语句、内存里的一次CAS、某个存储系统里的单key写入。原子性说的是:这一笔操作要么完整生效,要么完全不生效,外部观察者看不到中间状态。

注意,这里约束的范围非常明确——只有"这一笔"自己。它完全没有承诺这笔操作和另一笔操作之间的先后关系。

打个比方,原子访问就像银行柜台的两个独立窗口,每个窗口办一笔业务,无论中间是否断电,每笔业务要么办完要么不办。但两笔业务谁先谁后,柜台并没有承诺。你让窗口A先受理、窗口B后受理,和你让B先受理、A后受理,只要两笔业务不涉及同一个账户,结果没有差别。

而"保序"说的是另一件事:操作A必须在操作B之前发生,或者说外部观察者必须能区分出A和B的先后顺序。这个需求和原子性没有必然联系。一个系统可以把每笔操作都做得非常原子,但完全不保证操作之间的顺序;反过来,一个系统也可以明确规定了顺序,但每笔操作自身的原子性烂得一塌糊涂。

1.2 为什么人们总是把两者混为一谈

混在一起的根源,是很多存储系统的底层实现里,原子性和顺序共用了一套机制。

无论是数据库的预写日志,还是分布式系统里的复制日志,核心思路都是:先把操作按照某个顺序记录下来,然后按记录顺序执行。在这个模型里,"我按日志顺序把这些操作一条条应用下去"同时实现了两件事——任何一个操作应用时要么完成要么因为崩溃回滚干净,这是原子性;所有操作都在日志顺序下执行,这是顺序性。

所以你去看源码、看实现文档,会得出一个印象:原子操作和顺序操作是绑定的。但从抽象语义上看,它们是两个完全独立的维度。我见过不少团队因为被"日志模型"洗脑,潜意识里认为"想要原子性就必须保序,保序是达成原子性的必然代价",这个直觉在很多时候是错的。

原子性未必需要总序。比如一个能保证"写入要么全有要么全无"的存储,如果它只提供单key原子读改写,同时允许不同key之间乱序,那么它依然每个操作都原子。甚至一个小型存储引擎完全可以用"影子页+崩溃恢复"实现原子的单页写,但页之间完全没顺序。

真正把顺序和原子性绑在一起的,是"事务"这个抽象。事务天然要求一组操作作为一个整体,并且一般要求与其他事务之间有可串行化或某种隔离级别。可是事务是人为创造的重型工具,不是"多笔原子访问需要保序"的默认证据。

1.3 一个容易踩空的思维陷阱

有人会说:那如果两笔原子访问操作的是同一个数据项呢?比如先给计数器加1,再给计数器乘以2。这时候顺序当然有关系,而且两笔操作还都是原子的。

对,这正是问题有趣的地方。原子性只保证"每笔操作自身没有中间态",但完全不保证"多个操作之间不发生交错"。给计数器加1和乘以2,即便每笔操作都原子,也可能出现两种合理结果:原来是5,加1再乘2是12,乘2再加1是11。读者到底看到哪个,取决于两笔操作实际发生的顺序。

更微妙的是,如果这两笔操作由两个事务发起,数据库在保证可串行化时,只承诺"存在某个顺序,等价于按该顺序逐个执行",但不会告诉你是哪个顺序,也不会保证提交顺序就是执行顺序。这种"隐式的顺序裁决"恰恰是保序问题里最容易被误解的部分,下一节我会展开说。

2. 崩溃恢复视角:很多"保序"诉求其实是"原子性"诉求

2.1 扣库存和写订单:你以为缺顺序,其实缺的是"同生共死"

我在某个模拟项目里遇到过一个很典型的例子。业务流程是:用户下单时,先扣减库存,再生成订单记录。最初设计是两笔独立的原子写操作,分别写库存服务和订单服务。上线后最怕的不是并发,而是崩溃。

设想一下:扣库存成功了,生成订单之前进程崩溃。结果是库存减少,但用户没有订单。用户再点一次下单,库存又被扣掉一次,最后库存负数、订单缺失。排查时团队的第一反应是"这两笔写要保序,先写订单再扣库存不就没事了?"

可仔细推演你会发现,无论哪个在前,只要第二笔没执行,系统都会出现不一致。先写订单再扣库存,崩溃时订单有了但库存没扣,超卖照样发生。这里真正的问题不是"顺序错了",而是"两笔操作必须同时生效或同时失效"。满足这个需求的是事务原子性,不是顺序。

正确做法一般有两种:把两笔写放进同一个本地事务或分布式事务里,借助事务的原子提交保证"要么都成功要么都失败";或者不做强一致,用补偿机制对账,发现缺订单就补单、发现多扣库存就退款。无论哪种方案,核心都不在"先后顺序",而在"失败边界"。

2.2 加余额和写流水:先写哪个都救不了你

再举一个我常用来给团队解释的例子:给账户加余额,同时写一条流水记录。如果两者不在同一事务里,会发生什么?

方案一:先加余额,再写流水。结果:余额加了,流水没写上,审计对不上账。 方案二:先写流水,再加余额。结果:流水在,余额没加,用户投诉钱没到账。

两端都失败还好,最怕的是只失败一端。所以这根本不是把操作顺序调整一下就能解决的,它的本质是"两个状态变更需要作为一个不可分割的单元提交"。你需要的是一笔包含多个操作的大事务,或者说一个合并后的复合原子操作。

这也是为什么很多业务早期"拆得越细越灵活",后期又要把多个原子访问包进一个大事务——不是因为原子操作之间需要保序,而是因为它们之间存在"整体成功或整体失败"的约束。保序只决定结果长什么样,原子性决定结果是否存在。

2.3 那么什么时候"保序"真的有用

真正需要保序的需求,通常是"B操作的结果依赖A操作完成后的状态"。

最典型的是:先写一个标记状态为"处理中",再处理具体业务;处理完成后把状态改为"已完成"。如果系统允许"处理中"的操作和"已完成"的操作并发执行,可能出现业务还没处理完,另一个流程就看到"已完成"状态。这种场景下,你要求的是A必须在B之前被其他观察者看见,属于严格的顺序约束。

判断方法很简单:如果两笔操作里的第二笔,需要读取第一笔写入后的值才能得到正确结果,那么它们之间存在读-写依赖,需要排序。如果两笔操作互不读取对方写入的内容,只是"业务上希望一起发生",那么哪怕它们看起来有先后关系,实际上缺的也是原子性或最终一致性,不是顺序。

比如用户的昵称修改和头像修改,两笔独立写。无论哪个先执行,用户最终看到的都是新昵称加新头像,中间状态顶多是新昵称配旧头像。如果业务能接受这个中间态,那两笔写完全不需要保序;如果不能接受,你也别想着排序,应该把它们合成一个原子操作。

3. 单机数据库的做法:可串行化并不要求物理保序

3.1 可串行化的真实含义

单机数据库是理解保序问题最好的实验室,因为它的并发控制机制相当成熟且看得见摸得着。

先说"可串行化"这个术语。它指的不在于"数据库真的把事务按某个顺序逐个执行",而在于"并发执行的结果,等价于这些事务按某个特定的串行顺序执行后的结果"。数据库完全可以同时在两个CPU核上执行事务T1和T2,T1读行A、T2读行B,彼此数据不重叠,最终效果和T1、T2顺序执行完全一致。

关键点在于:可串行化不承诺你观察到的任何顺序关系。它只承诺"存在一个合法的顺序,使得结果等价于按这个顺序执行"。至于这个顺序是T1在T2前还是T2在T1前,数据库可能基于锁的竞争情况、时间戳的大小、MVCC版本号,把顺序悄悄定了。提交时间早的事务不一定在串行顺序里排前面。

这直接回答了我开头的问题:在多笔原子访问互不冲突的前提下,数据库确实不需要给它们排出一个确定的全局顺序,因为任意顺序都等价。只有发生冲突时,才需要选择一个顺序来保证可串行化。

3.2 无冲突操作天然可交换,强行保序只会浪费性能

每组互不冲突的操作,其实都是"可交换"的:先执行A再执行B,与先执行B再执行A,最终结果一模一样。因为A没读到B写的数据,B也没读到A写的数据,交换顺序不改变任何一方的输入。

数据库正是利用这一点大幅提升并发能力的。同一张表里,不同行的UPDATE可以完全并发执行,只要它们不触碰同一行索引;不同表的写也可以并行提交。如果数据库强行把所有原子写操作都排成一个全序队列,每个事务都必须等待前一个事务结束才能开始,那并发能力会断崖式下跌。

我在实际调优时遇到过反例。某个业务把几十个本来完全独立的数据项写入,都塞进一个长事务里,理由是"保证顺序一致"。结果事务持锁时间变长,锁等待曲线直接起飞。后来拆成多个独立事务,并发能力立刻上来了。为什么没问题?因为这些写入之间没有数据依赖,任意顺序的交错执行,最终状态完全一样。保序在这里不是不需要,而是根本没有意义——就像要求两个互不相干的快递员必须按到达邮局的顺序送件,纯属多此一举。

3.3 一个经典的反例:为什么"全序队列"不是万能的

这里要纠正一个常见误解:以为只要把所有操作排成一个全局队列,问题就都解决了。

假设有两个事务: 事务T1:读取账户余额,然后基于读取结果做一次扣减。 事务T2:往同一账户追加一笔入账。

如果把T1和T2按"到达队列的先后"执行,看起来没问题。但考虑一个更复杂的案例:两条SQL分别更新表里满足不同条件的行,更新完成后各自读取对方的更新结果。这种循环依赖一旦出现,不管你怎么排队,都无法同时满足两边——除非允许其中一个事务失败重来。

这就是为什么数据库除了锁机制,还需要死锁检测和事务回滚。如果"排队"能解决一切,死锁就不会存在了。排队解决的只是"多个操作并发时谁先执行"的问题,而像写偏斜这类需要穿插检测的异常,靠排一个静态的队是防不住的。这是保序思想在并发控制里的一个内在边界:顺序是把并发问题转化为线性问题的手段,但有些并发冲突的本质是"双方都基于旧值做决策",线性化解决不了,得靠串行化图检测或冲突重试。

换句话说,保序是数据库用来达到某种一致性结果的手段,而不是目的。目的永远是可串行化或你选择的隔离级别下,状态仍然合法。

4. 分布式副本的特殊性:为什么副本希望所有写入都进同一个队列

4.1 两个副本各自排一遍,会得到两套完全不同的历史

到了分布式环境,问题形态变了。单机数据库里,顺序通常由中央的锁管理器或MVCC版本分配器决定;分布式系统里,多个副本是物理分开的,每个副本都要独立执行同一批写入操作。

这时候"每笔操作原子"远远不够。假设一个三副本系统收到了三笔写入A、B、C。副本1决定先A后B再C,副本2决定先B后A再C,副本3决定先C后B再A。如果这三笔写入之间存在任何数据冲突,三个副本最终状态就会分叉。更可怕的是,每笔写入本身可能都正确执行了,事务也提交了,但副本间数据不一致。

这个问题的本质原因是:分布式系统缺少一个天然的、全局统一的"执行顺序参考系"。每个副本的CPU时钟不同,网络到达顺序不同,调度时机不同。如果不人为地建立一套公共顺序,副本之间的最终状态只能是各说各话。

所以我常跟团队说:在分布式复制场景里,"顺序"不是可选的业务需求,而是维持多副本一致性的基础设施。这不是因为"业务上需要先A后B",而是因为"所有副本必须用同一个顺序应用操作,否则副本就散了"。

4.2 全序日志:把问题从"怎么排序"变成"听谁的"

主流强一致复制系统的做法,是把所有写操作统一送进一份日志,由主节点或共识算法为每条操作分配一个递增的全局序号,所有副本严格按照这个序号依次应用。这样,无论哪个副本先收到操作,它都不会自作主张决定顺序,而是等待序号裁决。

这个方案屏蔽了网络延迟和调度差异,把"多副本各自乱序执行"收敛为"所有副本按同一顺序执行"。

代价也很明显:全局唯一日志就是全局瓶颈。写入吞吐量受限于单份日志的排号速度,跨地域延迟也会直接传导到每次写入上。因此很多系统会把数据按key或按分区做切分,让每个分区有自己独立的日志,同一分区内全序,不同分区之间自由并发。

拿一个常见的分区键值存储举例:同一个key的写操作会被路由到同一个分区,该分区内部严格按日志顺序应用;不同key落在不同分区,则可以完全并行执行。用户看到的隔离级别可能只是单key级别的原子性,跨key操作没有全局顺序。这种设计在大量业务场景里完全够用,而且吞吐量做得非常高。它证明了一件事:保序不是全有或全无的,粒度可以精细到"冲突域内部有序,冲突域外部自由"。

4.3 因果顺序:比全序弱,但比没有强

全序是最强的顺序保证,但它不是唯一的选择。有一类场景只需要因果顺序,不要求全局总序。

举一个很常见的例子:用户先发一条动态文本,再发一条带图评论。这两次写入之间存在因果依赖——评论之所以存在,是因为用户先看到了动态。从正确性角度,任何副本都不能让评论先于动态出现,否则用户会看到一条扑空的评论。

但另一对操作之间可以没有因果链:用户A修改昵称,用户B修改头像,这两者互不相关。因果一致性只要求有因果关系的操作保持顺序,没有因果关系的不排序。这比全局全序轻量得多,因为涉及不同分区的无因果操作可以并发执行,不再需要经过同一个日志排号器。

实现因果顺序的常用办法是逻辑时钟或混合逻辑时钟。每笔写入带上一个时间戳,时间戳包含操作依赖的上一个时间戳信息。副本在应用一条写入前,先检查它的所有因果前驱是否都已就位。这套机制的实质是:把顺序关系从"物理排队"变成"依赖跟踪"。它不再让所有操作排队,而是只确保每笔操作的输入在它执行前已经可见。

顺带提一句,分布式系统里也有一些方案走得更远,比如干脆允许副本以任意顺序应用操作,但让操作本身设计成可交换的。这就是接下来要说的话题。

5. 让乱序无害化的三条实用路线:可交换、版本裁决、幂等

5.1 可交换操作:乱序之后结果不变的设计

既然保序有开销、有瓶颈,那能不能从源头消灭"对顺序的依赖"?能,只要把操作设计成满足交换律:对任意状态S,先执行A再执行B得到的状态,与先执行B再执行A得到的状态相同。

最典型的可交换操作是加法:对一组计数器执行若干次增量,无论顺序怎么打乱,最终计数和一样。集合的并集也可交换,无论并集的顺序如何,最终集合元素集合相同。

这类操作在分布式系统里价值极大。它们天然具备了"乱序无害"的属性,副本收到操作的先后顺序无论如何,最终收敛到同一个状态。很多协作编辑系统、社交点赞计数服务、监控指标聚合系统,底层大量使用可交换数据结构,就是为了绕开全序日志的瓶颈。

我自己在某项目里做过一个统计模块,最初设计是每个事件都带全局序号,写入统一走日志,结果吞吐上不去。后来改成每台机器维护本地计数器,定期把增量上报聚合。因为加法可交换,汇总顺序完全无所谓,吞吐直接翻了好几倍。这个例子最能说明"可交换"带来的工程红利。

5.2 版本裁决:把"谁先谁后"变成"谁的版本大"

可交换不适用时,另一个常用思路是给操作附加一个版本号,冲突时不再比较谁先到,而是比较谁的版本大。

最著名的模式是LWW(Last-Write-Wins),也就是"后写覆盖"。每个写入带一个时间戳或单调递增版本号,副本收到同一数据项的多个版本时,直接选版本号最大的那个作为最终值。

乍一看这好像还是需要"顺序"——最大的版本号怎么确定?关键区别在于:版本号比较是可交换的。副本1先收到版本3再收到版本5,副本2先收到版本5再收到版本3,最后两个副本都保留版本5。虽然写入到达顺序相反,但基于版本大小做出的裁决一致。

这就是把"硬顺序约束"转化为"标量比较"的经典手法。顺序不再需要系统强制实现,而是编码进数据里,每个到达者自己判断谁该赢。实现起来也不复杂:数据库表里加一列版本号,更新时校验版本号,或者用条件更新语句直接保证只有版本号大的写入才会覆盖。

我记得在某项目里做并发领取活动资格时,就用这招替代了排队。每人领取后活动剩余数量作为会计数,加一个全局版本号,写冲突时如果自己的版本号低就重试读取最新版本。整个系统完全不需要一个全局写队列,并发量轻松放大。

5.3 幂等性与"重复执行"问题的边界

还有一个经常被放进保序话题里的工具是幂等性。先澄清一下边界:幂等处理的是重试和重复,不解决顺序颠倒。

给账户发一张优惠券,接口设计成幂等的,意思是同一笔请求无论发多少次,用户只收到一张券。这解决的是"网络超时重试、消息重复投递"的问题,和"两张券的发放顺序"没有关系。

比如两笔业务操作:操作A给用户发放满100减20的券,操作B给用户发放满200减50的券。如果业务规则要求两张券同时生效并按顺序叠加,那么乱序后结果可能不同。幂等性在这里帮不上忙,因为它是针对单一操作的重复执行设计的,无法保证操作A和操作B之间的因果关系。

所以工程上正确的组合方式应该是:用可交换设计消除部分顺序依赖,用版本号裁决剩余冲突,用幂等性处理重试,三者各管一段。谁也别指望某个单一机制包打天下。

6. 实操判断法:三步定位你真正需要的保证

6.1 先画依赖关系,再看崩溃场景

每次团队里再有人问"这两笔操作要不要保序",我都会建议按下面三步走。绝大多数争论,走到第二步就消失了。

第一步:找依赖。操作B是否读取了操作A写入的数据?如果A和B的因果链路存在,B必须在A之后被其他事务看见。如果A和B读写的数据集完全不相交,那它们之间天然不存在顺序必要性,强行保序只是心理安慰。

第二步:看交错容忍度。即使A和B没有直接读写依赖,它们并发交错执行后,多笔结果是否可能违反业务规则?比如两次库存扣减并发执行,如果都会读取旧库存并做扣减,那它们实际上存在"读-改-写"冲突,需要靠并发控制把它串行化;如果每次扣减都是原子的CAS式条件更新,数据库内部已经决定好了顺序,业务侧不需要再操心。

第三步:模拟崩溃。在A成功后、B执行前来一次宕机,状态是否不可接受?如果答案是"不可接受",这往往是原子性问题而非顺序问题,正确解是事务、对账或补偿,而不是调整先后顺序。

6.2 决策矩阵:你到底需要哪种保证

我把常见业务诉求和对应的技术机制放在一张表里,方便对照:

表现诉求真实需求推荐方案
一批操作必须同生共死原子性单事务、分布式事务、Saga补偿
后续操作必须看到前序结果因果顺序逻辑时钟、依赖跟踪、必要时全序日志
同一数据项并发更新不丢失读-改-写串行化行级锁、CAS条件更新、乐观锁重试
多副本最终一致收敛性全序日志、可交换数据结构、版本裁决
重试/重复投递不产生副作用幂等性业务幂等键、本地去重记录

这张表是我排错时的第一参考。大多数"要不要保序"的争论,最后要么滑到"需要原子性"那一行,要么滑到"需要因果顺序"那一行,真正需要"无差别全局保序"的反而很少。

6.3 一些顺手就能用上的工具技巧

真到了需要保序的时候,有几种落地方案,按成本从低到高排列:

  • 条件更新:一条带WHERE条件的UPDATE语句,相当于让数据库帮忙裁决顺序,失败就重试。适合大多数读-改-写场景。
  • 版本号/逻辑时钟:每次写入带一个单调递增的版本号,比较版本大小代替比较物理时间。注意别用机器物理时钟做裁决,不同机器之间的时钟偏移会让你头疼。
  • 单分区队列:同一业务主键的所有写操作路由到同一个分区,分区内严格有序。这是性能与顺序之间的性价比之选。
  • 全局全序日志:只在跨分区事务或强一致要求极高的时候启用,代价是写入吞吐可能受限。
  • 可交换设计:在业务允许的层面尽量用加法、并集这类可交换操作,从根上消灭顺序依赖。

另外提醒一个细节:如果项目里已经在用逻辑时钟或版本号,记得把版本号字段设为唯一键或索引的一部分,否则高并发下可能出现"版本号相同但内容不同"的脏数据。这个坑我在某个模拟项目里踩过。当时给每个用户维护一个版本号,更新时直接读出来加一后写回,结果两条并发请求读到了同一个版本号,后写覆盖导致一条更新丢失。之后改成用数据库的原子自增生成版本号,再也没出过这个问题。

6.4 最后聊一点个人经验

我越来越觉得,讨论"要不要保序"之前,应该先问另一句:"这笔操作如果不能按我期望的顺序到达,系统会发生什么?"想清楚那个具体后果,技术选型自然就出来了。很多系统之所以活得很累,是因为把"希望顺序执行"当成了"必须顺序执行",给本可并发的操作硬加了一把大锁。

以我这两年在一线带项目的体会,每一个"这个操作必须全局保序"的需求,都值得花半小时重新推演一遍它的失败场景。推演完你会发现,真正必须全局保序的操作往往少得可怜,而多数依赖都能被事务合并、版本裁决或者可交换设计优雅地解决掉。理解原子性和保序之间的区别,比背会某个具体系统的配置项重要得多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进