数据库事务和 ACID:重复扣款背后,事务到底在保护什么
重复扣款这类事故,表面上很像一个“按钮没禁用”的前端问题,往下拆却一定会碰到事务边界。两个请求都进来了,订单都创建了,扣款也都走了,这时候前端防重当然重要,但它挡不住服务端在并发和异常场景下把一组本该绑定在一起的操作做裂开。
真正让我补课的,不是“要不要把按钮置灰”,而是后端往下讲的那些词:事务、回滚、隔离级别、幂等。以前我把它们当成数据库章节里的术语,出了事故才意识到,前端每天点出去的“提交订单”“发优惠券”“扣库存”背后,全靠这些机制保证不会只成功一半。
后面从一个前端视角来理事务:事务究竟在保护什么,ACID 各自约束的是什么,隔离级别在防哪些并发问题,以及前端该怎么配合服务端,把重复提交和状态误导收住。
第一课:只成功一半
后端同事先讲了他们组之前遇到的一个问题,比这次重复扣款更吓人:一个发优惠券的活动,领券接口偶发性地"券领了,但账户余额没扣",财务对账时发现送出去的钱对不上,追查了两天,最后定位是一段没包在事务里的代码——两条 SQL 中间抛了异常,前一条已经提交,后一条没跑,数据就裂成两半了。
我以前一直觉得这是后端的事,跟我没关系。但那天听下来才意识到,我每天对接的接口背后全是这种结构:下单、支付、扣库存、发优惠券。前端看到的只是一个"提交订单"按钮,服务端里面可能做了很多步——校验库存、创建订单、扣减库存、生成支付单、记流水、可能还要发消息通知。这一长串操作里任何一步失败,前面已经做的都得撤销,否则就是数据不一致。
而"只成功一半"这件事,在没有事务保护时相当常见——网络抖动、进程被 kill、数据库连接超时、代码里一个没接住的异常,都能让一组操作半途而废。
从转账例子入门
后端同事在白板上写的例子是转账,我回来查资料发现所有教材也都用它,因为它足够纯粹。从 A 给 B 转 100 元:
1update account set balance = balance - 100 where id = 'A'; 2update account set balance = balance + 100 where id = 'B';
如果第一句成功,第二句失败,钱就丢了。
事务要保证这两步要么都成功,要么都失败:
1begin; 2update account set balance = balance - 100 where id = 'A'; 3update account set balance = balance + 100 where id = 'B'; 4commit;
失败时:
1rollback;
begin 到 commit 之间这一段,数据库会把它当成一个不可分割的整体。中途任意一句报错,应用层捕获到异常就执行 rollback,已经改过的行会被还原成事务开始前的样子,就像什么都没发生过。这就是发券旧案的正解——只要那两条 SQL 被 begin/commit 包起来,第二条失败时第一条会跟着回滚,绝不会出现"扣了余额没发券"或者"发了券没扣余额"的半截状态。
回滚靠的是 undo log:数据库改数据前先把旧值记进日志,回滚时照着日志把行恢复回去。知道这一层之后,"像什么都没发生过"就不是魔法了,是有账本的。
补课时我还搞明白了一个细节:自动提交(autocommit)。MySQL 默认 autocommit=1,意思是你不显式开事务时,每条单独的 SQL 都被当成一个独立事务自动提交了。所以单条 update 天然是原子的,问题只出在"多条语句要绑在一起"的时候。前端调试时如果直接用客户端连测试库改数据,改完没看到效果,有时候就是因为开了手动事务模式却忘了 commit,数据其实还卡在你这个会话的事务里没落地——我三月刚申请到测试库权限那阵就干过这事,还以为是权限问题,差点去找 DBA 理论。
ACID:四个字母各管一段
事务常说 ACID:
- Atomicity:原子性
- Consistency:一致性
- Isolation:隔离性
- Durability:持久性
原子性(Atomicity):事务里的操作要么全成功,要么全失败,不存在做一半的中间态。发券旧案缺的就是原子性。
一致性(Consistency):事务前后数据要符合业务规则。转账例子里,A 扣 100、B 加 100,转账前后两个账户的总额不变,这个"总额守恒"就是一种一致性约束。一致性其实是目的,原子性、隔离性、持久性是手段——它们一起保证数据库从一个合法状态迁移到另一个合法状态。
隔离性(Isolation):多个事务并发执行时互不干扰到不该干扰的程度。注意是"不该干扰的程度",不是完全隔离——完全隔离意味着所有事务串行排队,并发就没了。这也是后面隔离级别要解决的核心矛盾。
持久性(Durability):提交后的数据要持久保存,哪怕下一秒数据库进程崩了、机器断电了,重启后这笔数据也还在。数据库靠的是 redo log 这类机制——先把改动顺序写进日志落盘,再慢慢刷数据页,崩溃恢复时拿日志重放。补课那两周我拿着笔记去找那位后端同事对答案,他反手考了我一道:"commit 返回成功后断电,数据会丢吗?"我答得磕磕绊绊,回去又翻了一遍书才能利索地答上来:不会,这就是持久性的承诺,而它的代价是每次提交都要等日志真正落盘(innodb_flush_log_at_trx_commit=1),这也是高并发写入时的一个性能点。
这四个字母里,原子性和持久性相对好懂,真正在业务里反复咬人的是隔离性。
并发带来的三个名词
多个事务同时执行,可能出现:
- 脏读
- 不可重复读
- 幻读
脏读:读到了别人还没提交的数据。事务 B 读到了事务 A 改了但还没 commit 的值,结果 A 又回滚了,B 拿到的就是一个根本没存在过的"脏"数据。落到业务上:风控系统读到一笔还在处理、随时可能撤销的订单金额去算额度,A 一回滚,风控的判断就建立在幻觉之上。
不可重复读:同一个事务里,两次读同一行,结果不同。中间被别的已提交事务改了。
幻读:同一个事务里,两次范围查询,结果行数不同。区别在于不可重复读针对的是"同一行的值变了",幻读针对的是"行数变了"——别的事务插入或删除了符合条件的行,你第二次 count(*) 就多出来或少掉一些"幻影"。
我一开始老分不清不可重复读和幻读,后来记了个口诀:值变了是不可重复读,行多了少了是幻读。一个是 update 引起的,一个是 insert/delete 引起的。
这些问题不是背概念用的,它们会实打实影响订单、库存、账户这类业务。后端同事讲的另一个真实案例就是超卖:两个用户同时下单买最后一件库存,两个事务都先 select 到库存是 1,都判断"够",都去扣减,结果库存变成了 -1,超卖了。这背后就是隔离没做好——后面讲怎么解决。
隔离级别:严格和性能的拉锯
常见隔离级别:
- Read Uncommitted
- Read Committed
- Repeatable Read
- Serializable
这四级从上到下越来越严,能解决的问题也越多:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| Read Uncommitted | 可能 | 可能 | 可能 |
| Read Committed | 不会 | 可能 | 可能 |
| Repeatable Read | 不会 | 不会 | 可能(MySQL 下因 MVCC 基本规避) |
| Serializable | 不会 | 不会 | 不会 |
隔离越强,并发性能可能越低,因为更强的隔离要靠更多、更长的锁来保证,事务之间互相等待的概率就高。
数据库不会无脑选择最强隔离,因为性能和一致性要平衡。这里有个常被搞混的点:MySQL(InnoDB)默认是 Repeatable Read,而 PostgreSQL、Oracle 默认是 Read Committed。后端同事提起过他早年跨库做项目就栽过这个坑——在 MySQL 上习惯了"一个事务里反复读同一行结果不变",换到默认 Read Committed 的库上,同一个事务里两次查询拿到不同结果,业务逻辑算错了,排查半天才想起来是隔离级别默认值不一样。所以代码不能想当然地依赖默认隔离级别,关键业务最好在事务开头显式设置。
InnoDB 能在 Repeatable Read 下还保持不错的并发,靠的是 MVCC(多版本并发控制)——读操作读的是某个时间点的快照版本,不加锁,写操作改的是新版本,读写不互相阻塞。这也是为什么 MySQL 里普通 select 即使在严格隔离级别下也很少卡住。但要注意,MVCC 只管"快照读";如果你需要读到最新值并锁住它(比如下单扣库存那种),得用 select ... for update 走"当前读",这才是解决超卖的常用手段:
1begin; 2-- for update 会锁住这一行,别的事务想改它得排队 3select stock from product where id = 100 for update; 4-- 这里在应用层判断 stock 是否充足 5update product set stock = stock - 1 where id = 100; 6commit;
更轻量的做法是把判断直接写进 update 的条件里,靠数据库的行锁保证原子性,连显式 for update 都省了:
1update product set stock = stock - 1 where id = 100 and stock > 0; 2-- 然后看影响行数:affected rows = 0 就是没扣成功(库存不够),= 1 才是扣成功
这次复盘之后,我们项目里的扣库存基本都统一成了这种"乐观条件 update + 看影响行数"的写法,简单、不容易死锁,比先查再扣那一套省事多了。
说到死锁,这也是后端同事特地展开讲的一个词,我追问了一句。两个事务各自攥着对方要的锁互相等——事务 A 锁了商品 1 等商品 2,事务 B 锁了商品 2 等商品 1,谁也动不了。InnoDB 有死锁检测,会主动挑一个"代价小"的事务回滚掉,报 Deadlock found when trying to get lock,另一个继续走。所以后端代码里对这种报错通常要做重试;预防的土办法是让所有事务按同一个顺序拿锁——比如批量扣库存时先把商品 id 排个序再逐个处理,两个事务就不会形成互相等待的环。这个"按固定顺序加锁"的思路我觉得挺漂亮,记进了笔记。
顺带记一个后端同事反复强调的告诫:事务要短,别在事务里干耗时的事。他见过有人在事务中间调第三方支付接口,一等等两秒,锁被这个事务攥着两秒,别的请求全排队,高峰期直接把数据库连接池拖垮。事务只包数据库操作,外部调用放到事务外面,用状态机衔接。他还提了一嘴更大的话题:跨服务的操作(比如下单要同时动订单库和积分服务)根本没法靠一个数据库事务包住,业界常用"本地消息表 + 对账补偿"做最终一致性——先在本地事务里把业务数据和一条"待通知"消息一起落库,再由后台任务把消息投出去,失败就重投。我听得似懂非懂,但至少明白了一件事:单机事务保证的是强一致,跨服务只能追求最终一致,这两个词以后评审接口设计时我知道该问什么了。
回到那两次扣款:前端该做什么
绕了一大圈,回到我背的那半口锅。前端不直接写事务,但要理解接口不是简单保存。
用户点击"提交订单",前端应该做重复提交保护:
1if (submitting) return 2submitting = true
按钮禁用我当天就补上了。但后端同事说得很直白:真正保证订单不会重复创建,还要靠后端幂等和事务。前端这道禁用只能挡住"同一个页面手抖连点",挡不住的情况多了去了:用户网络慢以为没点上又点一次、请求超时前端自动重试、用户开了两个标签页各下一单、甚至有人抓包重放。这些场景里前端的 submitting 标记根本不在场。这次事故的用户就是超时重试触发的——第一个请求其实成功了,只是响应慢,前端超时后用户又点了一次。
我们最后跟后端约定的标准做法是"幂等键":前端在进入下单页时生成一个唯一的 requestId(比如 UUID),下单请求带上它,后端用这个 key 加唯一约束或 Redis 占位,同一个 key 只会真正创建一次订单,重复请求直接返回第一次的结果。
1// 进入结算页时生成一次,整个下单流程复用同一个,重试也用它 2const requestId = crypto.getRandomValues 3 ? genUUID() 4 : Date.now() + '-' + Math.random().toString(36).slice(2) 5 6function submitOrder() { 7 if (submitting) return 8 submitting = true 9 return request('/api/order/create', { 10 method: 'POST', 11 body: { ...orderData, requestId } 12 }).finally(() => { 13 submitting = false 14 }) 15}
关键点:重试时一定要复用同一个 requestId,如果每次请求都新生成一个,幂等就失效了——后端同事说这是他见过最常见的实现错误,写了幂等键却每次都换,等于没写。我第一版就差点这么写,requestId 放在了 submitOrder 函数里面,每次调用生成一个新的,被 code review 拦下来挪到了外面。
后端那边还加了一道兜底:订单表上给幂等键建唯一索引。就算 Redis 占位失效、代码有漏洞,两条一样的 requestId 想插进订单表,数据库的唯一约束也会把第二条拒掉。幂等靠约定,兜底靠约束,数据库层这道墙是最后的防线。
前端不能把"按钮禁用"当成最终一致性保障,它只是体验层的第一道防线,真正的防重在后端的幂等键 + 事务里。
还有一个前端侧的细节这次也一并理顺了:支付结果的展示。支付回调到账有延迟,用户付完款跳回订单页,订单状态可能还是"待支付"——这不是 bug,是那条"最终一致"链路还没走完。以前我们的页面就傻傻地显示"待支付",用户以为没付成功又去付一次,客服工单一堆。整改后改成:跳回后轮询订单状态接口,轮询期间显示"支付确认中",拿到终态再切换。前端要为"数据暂时不一致的窗口期"设计一个中间态的 UI,而不是假装这个窗口不存在。
接口错误要表达清楚
这次整改还捎带解决了一个我一直难受的问题:错误提示。事务失败后,接口要返回明确错误:
1{ 2 "code": "STOCK_NOT_ENOUGH", 3 "message": "库存不足" 4}
前端才能给用户正确提示。库存不足应该提示"手慢了,已抢光"并把按钮还原让用户能换商品,而不是弹个红框写"系统异常"让用户一脸懵地狂点重试——后者反而加重了后端压力。促销那晚就有这种工单:用户看到"系统异常",以为是网络问题,连点了七八次。
不要所有失败都返回"系统异常"。业务失败和系统失败处理方式不同:业务失败(库存不足、余额不够、活动已结束)是预期内的,前端要给具体可操作的提示,且不应该重试,重试也没用;系统失败(超时、500、数据库连接断了)才是真异常,可以提示"网络开小差,请重试"并允许重试。
整改时我在前端封装了一层统一处理,把这两类彻底分开:
1request('/api/order/create', { ... }) 2 .then(handleSuccess) 3 .catch((err) => { 4 if (err.bizCode) { 5 // 业务失败:后端明确返回了 code,照着提示,不重试 6 toast(err.message) 7 if (err.bizCode === 'STOCK_NOT_ENOUGH') refreshStock() 8 } else { 9 // 系统失败:网络/超时/5xx,提示可重试 10 toast('网络异常,请稍后重试') 11 } 12 })
这套约定推下去之后的第一个周末,客服反馈"用户说报错但看不懂啥错"的工单肉眼可见地少了。错误契约清晰,前端才能做出对的交互,事务在后端默默回滚,用户在前端看到一句人话——这才是一条完整的链路。
事故之后
重复扣款的那两个用户,后端手工退了款,幂等键方案在五一前上了线。促销第二波流量过来,同样的连点、同样的超时重试,订单表里再没出现过重复记录。
事务保证一组数据库操作作为整体执行,ACID 描述事务的基本特性,隔离级别处理并发下的数据问题——前端不需要会写这些,但接口为什么要防重复提交、为什么错误码要区分业务失败和系统失败、为什么支付结果需要一个"确认中"的中间态,答案都在这条链路里。