数据库原子性不是免费午餐:从事务到分布式的一致性代价

发布时间:2026/10/9 3:35:16
数据库原子性不是免费午餐:从事务到分布式的一致性代价 很多刚接触数据库的人包括我以前刚入行那会儿看待“atomict”三个字都是带着光环的BEGIN 执行一串 SQLCOMMIT 收工中间哪条出错了ROLLBACK 一切恢复原样。好像数据库天然赠送了这么一项能力不需要你额外做什么事务就该是可靠的、隔离的、不留中间态的。这种“免费午餐”心态在低并发、小数据量、单机部署的场景下确实不太容易暴露问题但一旦业务复杂起来、流量上来、系统拆开你就会发现 atomic 的每一项保证都不是白给的账单会一张张送到你面前。这些年我在线上维护和架构设计里遇到太多次“明明开了事务数据还是对不上”的诡异事故也亲眼见过一个简单的原子更新在高并发下把服务性能拖垮。这篇文章我打算把“原子性不是免费午餐”这件事讲透它到底保证了什么、为了这个保证数据库付出了什么代价、分布式环境下这个代价为什么会加倍、以及最让人头疼的“假原子性”问题。如果你正在设计订单、支付、库存这类对一致性敏感的系统这篇文章应该对你有用。1. “免费午餐”的错觉从一次线上事故说起1.1 事故的现场还原先讲一个我实际参与排查的线上事故。某个运营后台提供了一个批量调整用户余额的功能操作员选中一批用户后台循环遍历每个用户对每个用户单独开启事务、执行余额变更、然后提交。代码逻辑大致长这样for (User user : userList) { try { transactionTemplate.execute(status - { userMapper.updateBalance(user.getId(), adjustAmount); accountLogMapper.insert(logRecord); }); } catch (Exception e) { log.error(用户{}调整失败, user.getId(), e); } }看起来没什么问题每个用户一个事务要么成功要么回滚失败就记录日志其他用户不受影响。但运营反馈的现场很诡异同一个批量任务里一部分用户余额变了另一部分没变再查变更日志发现日志表里有的记录了有的没记录。更麻烦的是财务那边对账发现总金额差了。1.2 问题到底出在哪第一次排查时团队的第一反应是怀疑数据库的事务没生效。其实事务生效了单条 SQL 的原子性也没问题问题出在“程序把一次业务操作拆成了多次独立事务而业务期望的是‘整批要么全成功要么全失败’”。这个案例里的“原子性”每个事务内部都成立但站在运营角度他们需要的是一次批量操作的原子性这完全不是数据库能主动提供的。数据库层面只认“一个事务”这个边界你告诉它每次循环一个事务它就老老实实每个用户单独保证。至于哪些用户的更新算同一批、哪一批失败后要不要回滚已成功的部分——数据库一概不管。所以说原子性从来不是“数据库帮你实现了你就拥有了它”。事务注解一加表面上该有的保障都有了实际上事务边界、业务边界、补偿策略、失败重试逻辑都需要你在代码里设计清楚。从这次事故之后我对“事务能保证业务正确”这句话产生了本能的怀疑后来也慢慢积累了一套判断 atomic 账单在哪里的方法后面几节慢慢展开。2. 原子性的本质它只保证事务内不保证业务内2.1 原子性到底承诺了什么原子性Atomicity是 ACID 里的 A。它的正式定义是一个事务中的所有操作要么全部成功提交要么全部失败回滚不能停留在中间状态。经典的转账例子最能说明问题BEGIN; UPDATE accounts SET balance balance - 100 WHERE user_id 1; UPDATE accounts SET balance balance 100 WHERE user_id 2; COMMIT;如果第二条 UPDATE 失败第一条绝不能留在“扣了钱但没到账”的状态数据库会用回滚把账户 1 的钱恢复回来。这个保证听起来太完美了但注意它只针对“一个事务”这个逻辑单位。假如你把扣款和入账拆成两个事务执行中间进程崩溃了那数据库无论如何操作都不会帮你把这两笔合起来回滚。很多人在这个点上产生了误解以为“用了事务 操作是原子的”于是把一些业务步骤一股脑塞进同一个事务里。事务本身没有错但事务的范围必须严格等于你希望原子化的业务范围多一条语句都不行少一条也不行。2.2 数据库凭什么做到原子性日志是第一笔账单要理解原子性为什么不是免费的就要先明白数据库是怎么实现“回滚”的。答案是日志。MySQL 的 InnoDB 引擎里每次数据页修改之前都要先写 undo log记录修改前的旧值提交之后还会写 redo log记录修改后的新值用于崩溃恢复。你执行一条 UPDATE数据库做的事情远比“改一行数据”多在内存中定位到目标行所在的缓冲页将旧值写入 undo log修改内存中的数据页将修改记录写入 redo log buffer在合适时机把 redo log 刷到磁盘。也就是说每次写入都要额外产生日志写盘。日志可以先缓冲、批量刷盘但最终为了崩溃恢复总有那么一次磁盘同步躲不过去。这就是第一笔账单原本你只想改一行数据但存储引擎为了让你能“回滚”、能“恢复”必须多写很多日志。SSD 时代顺序写日志的代价已经很小但绝不是零而且它会占用磁盘带宽和 IO 资源。数据量一大、写密集一来日志相关的瓶颈立刻就能感受到。2.3 原子性的边界跨库、跨服务一概不管更扎心的一点是单机数据库的原子性只覆盖它自己的存储引擎。同一个 MySQL 实例里的多张表、多个行可以放在一个事务里但一旦跨越数据库实例比如订单库和库存库分开部署事务就失效了。再比如你的事务里调用了第三方支付接口外部系统执行了扣款而本地数据库回滚了两边状态就必然不一致。所以正确的认知是原子性是有势力范围的。单库单事务内数据库是可靠保镖超出这个范围就需要分布式事务、消息队列、对账补偿这些更复杂的机制来弥补。而每一种机制背后都是真金白银的成本这就是本篇文章接下来要展开的“账单明细”。3. 第一笔账单锁、等待时间和并发冲突3.1 隔离要靠锁来换原子性和隔离性通常是绑定出现的。为了让多个事务并发执行时不产生混乱数据库必须提供隔离级别比如 MySQL 默认的 REPEATABLE READ。隔离级别的实现依赖什么依赖锁尤其是行锁、间隙锁、临键锁。拿前面那个转账事务来说事务 A 修改了账户 1 的余额在它提交之前事务 B 如果也想修改账户 1就必须等待。这个“等待”就是代价的直接体现并发被牺牲了吞吐量被打折了。在低并发下你无感可当同一个热点账户被大量请求同时更新时你会看到数据库的活跃线程迅速堆积TPS 直线下滑。不是数据库不行而是它在严格执行“同一时间只有一个事务能改这行数据”的规则——这是原子性的必然要求。如果允许两个事务同时改同一行一旦其中一个回滚另一个事务基于旧值做的判断就全错了原子性就崩了。3.2 锁等待与死锁两个高频线上错误做后端开发的人对这两个报错应该不陌生ERROR 1205 (HY000): Lock wait timeout exceededERROR 1213: Deadlock found when trying to get lock前者是事务等待锁超过阈值例如 MySQL 默认innodb_lock_wait_timeout是 50 秒超过直接放弃并回滚后者是数据库的死锁检测发现两个事务互相持有对方需要的锁主动牺牲其中一个。我见过不少团队遇到死锁后的第一反应是把超时时间调大这是典型的治标不治本。死锁的本质是锁的获取顺序不一致。比如事务 1 先改订单再改库存事务 2 先改库存再改订单两个事务同时执行各自持有一把锁、等待对方释放另一把就必然形成环路。正确做法是让所有事务以固定顺序获取资源都先改订单、再改库存环路自然消失。锁等待超时则往往和“事务太长”有关——某个事务从早到晚攥着一堆锁不释放别的事务只能在旁边排队。3.3 压缩这笔账单的实操经验针对锁这笔账单我常用的优化手段有三个缩短事务持续时间。事务里不要做远程调用不要等待用户输入只做必要的数据库读写就立刻提交。锁持有时间越短冲突窗口越小整体并发能力越高。控制事务内的数据量。一次 UPDATE 更新几百行和几万行锁的范围和持续时间天差地别。批量更新尽量拆成小块每个小块一个短事务。热点行拆分。最典型的是账户余额字段一个账户被高频修改就成了天然锁热点。可以把一个账户的余额拆成多条子记录写入时随机/轮询选择子账户操作读取时汇总。逻辑复杂了一些但换来了并发吞吐很多钱包类系统就是这么干的。如果你发现系统里锁等待和死锁频繁先别急着骂数据库先审视一下你的事务边界是不是太宽了、资源获取顺序是不是没规划、有没有办法拆散热点。这三板斧能解决绝大多数锁相关问题。4. 第二笔账单崩溃恢复与分布式协调4.1 崩溃不是小概率事件原子性的另一个隐藏成本是崩溃恢复。数据库进程不是永远不死的断电、OOM、机器重启、磁盘异常任何一个都能打断正在执行的事务。没有崩溃恢复机制你刚写到一半的数据就会让整张表处于中间状态。MySQL 靠 redo log 来做崩溃恢复。一个事务提交时它的 redo log 必须持久化到磁盘崩溃恢复时数据库会扫描 redo log把已经提交但还没写入数据页的更改重放一遍。这里有个细节为了在“事务提交”和“日志落盘”之间做协调InnoDB 采用了内部的两阶段提交——先写 redo log 并处于 PREPARE 状态再写入 binlog最后将 redo log 标记为 COMMIT。如果崩溃发生在不同阶段恢复逻辑会依据日志决定是回滚还是补提交。这套机制非常成熟但代价也很明确每次提交都可能触发一次磁盘 sync即便你用组提交技术批量刷盘在高并发下日志相关的 IO 依旧会成为一个可观测的瓶颈。你换来的是“任何时刻崩溃数据都不会处于中间状态”这个承诺。严格来说这是用磁盘 IO 买回来的原子性。4.2 分布式事务原子性的价格加倍单库事务已经要付日志、锁两笔账了分布式环境下原子性的成本会成倍增长。原因是原本由一份存储引擎完成的“全局协调”变成了网络问题多个数据库实例、多个服务之间没有共享的日志也没有共享的锁管理器你要让它们表现得像一个事务那样原子就必须引入一个协调者。最经典的两阶段提交2PC协议分两步第一轮准备阶段协调者问所有参与者“能不能提交”第二轮提交阶段协调者根据所有参与者的回答决定全体提交或全体回滚。听起来很合理但工程落地时你会发现这个协调者本身就是单点而且整个协议在等待期间会持有大量锁资源所有参与者的事务持续时间被拉长。实践中的 XA 事务性能糟糕到很多团队用了一次就不再碰。所以后来的分布式事务方案干脆不再追求“强一致原子性”转而用最终一致性和补偿机制。但请注意这不代表原子性免费了只是把账单换了一种方式支付你需要自己维护状态机、设计重试、处理幂等代价从数据库转移到了业务代码里。4.3 TCC 与 Saga换一种支付方式目前业界最常落地的两种方案是 TCC 和 Saga。TCC 把一次业务操作拆成 Try、Confirm、Cancel 三个阶段Try 阶段冻结资源Confirm 阶段确认扣减Cancel 阶段释放冻结资源。比如库存扣减Try 时不直接把库存扣掉而是先冻结 10 件后续确认成功则扣减这 10 件失败则释放冻结。这套机制能把跨服务的操作拆成可补偿的原子动作代价是要为每个操作写三套逻辑开发量明显上升而且要处理好“空回滚”和“悬挂”这类边界问题。Saga 则是把一个长事务拆成一系列本地事务序列每个本地事务完成后发布事件触发下一步如果某一步失败了就反向执行补偿操作把前面已经完成的操作逐个回滚。它的成本在于数据在中间过程中对其他系统是可见的你需要容忍一段时间的“中间状态”并且补偿操作的顺序必须严格可控。不管选哪种你都会发现一个共同点没有要钱不要命的选择只有拿什么付账的选择。强一致方案付的是性能和协调复杂度最终一致方案付的是开发成本和业务容忍度。5. 假原子性事务边界之外的东西没人替你保证5.1 经典翻车场景事务里发短信说一个每个做电商系统的人大概率踩过的坑事务内调用外部服务。伪代码长这样Transactional public void createOrder(OrderDTO dto) { orderMapper.insert(dto); stockService.deductStock(dto.getSkuId()); smsService.send(您已下单成功, dto.getUserId()); }这段代码表面看起来没问题订单插入、库存扣减、短信通知都在一个事务里任何一个失败全部回滚。但冷静想想stockService 和 smsService 都是远程 HTTP 调用它们根本不在数据库事务的控制范围里。如果短信服务已经把通知发出去了随后订单流程抛异常事务回滚了用户依然收到了“下单成功”的短信但订单表里根本没有这条记录。更复杂的情况是网络超时请求发出后没收到响应你不知道服务端到底执行了没有。这时候数据库事务只能回滚本地数据但远端可能已经扣了库存。这就是我所谓的“假原子性”数据库状态是原子的但你整个业务链条不是。这种不一致在即时对账时最容易暴露。核心原则其实很简单事务内绝对不能做远程调用。消息有网络延迟网络有不确定性而数据库事务对网络不确定性一无所知。真需要调外部服务应该先把本地事务提交再通过消息队列、事件总线等方式异步触发后续动作。5.2 本地消息表与 Outbox把外部调用搬进事务里那有没有办法让“写本地数据”和“对外发布事件”这两件事也保持原子性有经典方案是本地消息表也叫 Outbox 模式。思路是在同一个数据库事务里既写业务数据也写一张消息表事务提交后由后台任务把消息表中的记录投递到 MQ 或外部系统成功后再把消息标记为已发送。这样做的好处是业务数据和待发送消息的写入被同一个事务覆盖要么都成功要么都不成功。外部系统有没有收到消息则由投递任务配合重试机制来解决。比如订单创建时同一个事务往order表插一条记录、往outbox表插一条“订单创建事件”只要事务提交了这条事件就一定在本地后台任务轮询到它投递到支付或库存服务投递成功就置状态为已发送失败就重试。数据库的原子性被巧妙地用上了而且代价是加一张表和一个任务。5.3 幂等分布式原子性的最后一道防线分布式环境下谈论“原子性”其实很难做到真正的“要么全有要么全无”更多时候是靠幂等来兜底。幂等的意思是同一个操作执行一次和执行多次结果一样。为什么必须要有幂等因为重试是分布式系统的基本操作。一个网络请求超时了你会重发消息消费者处理到一半失败了会把消息重新拉起来支付回调可能重复推送。如果这些重复请求没有幂等保护就会造成重复扣款、重复发券、重复加库存。实现幂等的常用手段包括唯一索引去重订单表、支付流水表上建立业务单号作为唯一键重复插入直接报错状态机约束订单从“待支付”到“已支付”的流转只允许发生一次已支付状态下再次收到支付回调直接忽略前置查询校验处理逻辑前先查一下业务状态如果已经处理过就直接返回成功。我特别想强调状态机加唯一索引的组合它在实践中几乎能覆盖所有重复场景。数据库层面对唯一键的原子性约束是天然具备的你不需要额外加锁这是少数“便宜好用”的原子性利用方式。6. 编程语言里的 atomic从程序员的角度再看一遍账单6.1 CAS 的隐性代价“atomic”在编程语言里还有另一层含义原子变量、原子操作。比如 Java 的AtomicLong、AtomicReference以及底层依赖的 CASCompare-And-Swap指令。很多人会觉得用原子类就是高性能的无锁编程不用担心 synchronized 的锁开销简直是免费的午餐。实际上原子操作也有自己的账单。CAS 的原理很简单比较当前值是否等于预期值如果相等就更新为新值否则就自旋重试。它的第一笔代价就是“自旋”。高并发下多个线程同时争抢同一个原子变量大部分线程的 CAS 都会失败然后进入循环重试CPU 空转白白消耗算力。线程越多重试次数越爆炸吞吐量不升反降。还有个著名的 ABA 问题线程 1 读到变量值是 A准备 CAS线程 2 把值改成 B 又改回 A线程 1 的 CAS 发现值还是 A就认为没人改过于是理所当然地更新了。很多逻辑在这种场景下会出隐性 bug解决方式是引入版本号或AtomicStampedReference但这又增加了额外的内存和比较开销。6.2 伪共享和缓存行atomic 的物理账单再往下挖一层原子操作在 CPU 层面的成本更加“物理”。现代 CPU 为了保证多核缓存一致性使用了缓存一致性协议如 MESI。一个 CPU 核心修改了某个缓存行里的数据其他核心里持有的同一缓存行副本都要失效下次读取时必须重新从内存或共享缓存加载。更恶心的是伪共享问题。假设两个线程分别操作两个不同的原子变量但它们在内存里恰好位于同一条缓存行上通常 64 字节。线程 A 修改变量 1会导致整条缓存行失效线程 B 读取变量 2 时发现缓存行失效了只能重新加载。明明是操作不同的变量却互相拖后腿性能损耗比直接使用锁还难看。解决伪共享的常见手法是填充 padding把变量之间的距离撑大到 64 字节以上让它们处于不同缓存行。某些框架里会看到这样的代码Contended volatile long value;这个注解在 JDK 内部类里出现过目的就是避免伪共享。但你看为了用上原子变量我们连 CPU 缓存行布局都要开始关心了这还叫免费吗6.3 无锁不是没锁是换了一种锁我的总结是无锁编程从来不是“没有锁”而是把锁的载体从程序员可见的互斥量变成了 CPU 底层的总线锁和缓存一致性协议。你表面上避开了synchronized的阻塞和上下文切换实际上引入了自旋、重试、缓存抖动这一整套新的开销。只有在争用很低的场景下无锁结构才真正占便宜争用激烈时它的表现往往比一把粗粒度锁更差。在实际项目里我的建议是默认用常用且被验证过的并发容器和锁方案只有在 profiling 证明并发确实成了瓶颈并且你理解原子操作的底层代价时才考虑引入无锁结构。用 Java 的话如果发现AtomicLong在高争用下性能差可以先换成LongAdder它内部通过分段计数来分散竞争代价是读取时需要汇总不一定总能拿到实时精确值。这也是一个典型的“用一致性换取性能”的权衡和分布式系统里的最终一致颇有异曲同工之处。7. 我的实际操作体会花账单买安心7.1 给事务画一张边界记录表经历过的原子性事故多了以后我养成了一个习惯在系统设计阶段给每个关键业务链路画一张“事务边界记录表”。表里大概包含这几列业务动作、事务起止点、涉及哪些数据表、是否有远程调用、失败后的补偿方案、幂等键是什么。不需要画得多复杂但一定要逼自己写清楚每一行。这个表的价值在于它会把“假原子性”问题赤裸裸地暴露出来。比如你写着写着发现“订单创建”这个事务里混了一个 HTTP 调用你就会立刻意识到这里有不一致风险从而主动拆事务、引消息队列、设计补偿。很多线上事故其实在设计阶段就能避免只是大多数人没有强迫自己把边界想清楚的这一步。7.2 判断原子性代价是否可控的检查清单最后分享一份我一直在用的检查清单判断一个方案里的原子性代价是否可控事务内部是否只包含必要的数据库读写没有外部调用和耗时操作事务的锁范围和时间是否可控热点数据是否已经拆分是否清楚失败后靠什么恢复是数据库回滚还是业务补偿还是消息重试每条关键链路是否有幂等键或状态机约束重复请求不会造成副作用跨服务写入是否依赖了本地消息表或其他可靠事件机制团队是否知道当前方案的“账单”长什么样而不是只知道事务注解。这张清单不解决具体技术怎么实现但它能帮你在一开始就避开那些无法收场的原子性设计。atomic 不是免费午餐但只要你知道账单在哪、怎么付、值不值它就是一顿合理的午餐。怕的是你从头到尾以为它是免费的直到线上数据对不上的那一刻才被迫用最贵的姿势补交学费。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询