原子性不等于保序:独立原子写操作到底要不要排序? 前阵子评审一个并发模块时团队里有人提出一个看起来特别基础的问题这几笔独立的原子写操作相互之间需不需要保序会议室里立刻分成两派。一派说当然不需要每笔都是原子的各自执行完不就行了另一派说肯定需要不然先执行后执行结果可能完全不一样数据怎么对得上。这个问题表面简单实际把原子性和顺序性两件事搅在一起了。我在多个项目里反复看过这类争论也踩过不少坑。这篇文章想把这个问题彻底拆清楚什么时候必须保序什么时候保序是多余的什么时候你以为在保序、其实需要的是原子性。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和T2T1读行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 版本裁决把谁先谁后变成谁的版本大可交换不适用时另一个常用思路是给操作附加一个版本号冲突时不再比较谁先到而是比较谁的版本大。最著名的模式是LWWLast-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 最后聊一点个人经验我越来越觉得讨论要不要保序之前应该先问另一句这笔操作如果不能按我期望的顺序到达系统会发生什么想清楚那个具体后果技术选型自然就出来了。很多系统之所以活得很累是因为把希望顺序执行当成了必须顺序执行给本可并发的操作硬加了一把大锁。以我这两年在一线带项目的体会每一个这个操作必须全局保序的需求都值得花半小时重新推演一遍它的失败场景。推演完你会发现真正必须全局保序的操作往往少得可怜而多数依赖都能被事务合并、版本裁决或者可交换设计优雅地解决掉。理解原子性和保序之间的区别比背会某个具体系统的配置项重要得多。