4.4 事务与锁 本章收口在并发秩序上:前面所有写操作在多用户同时按下按钮时会互相踩踏,事务保证「要么全成要么全不算」,锁保证「同一行同时只有一个人在改」。读完本节,你应当能说出三个经典并发现场各自的安全方案,并明白 2.1 下单例子里那个事务块为什么必须存在。 一、没有事务的世界有多危险 回看 2.1 的下单流程:扣库存、建订单、发通知三步。没有事务时,第二步失败(比如字段超长插入报错)后第一步的扣减已经生效——库存少了、订单没有,账对不上,而且没有任何报错提醒用户。事务的作用就是把这组写操作捆成一个原子单位: 两条纪律:事务里只放数据库操作,HTTP 调用、发短信这类慢而不可回滚的动作放事务外(或丢进队列,见第 8 章);
本章收口在并发秩序上:前面所有写操作在多用户同时按下按钮时会互相踩踏,事务保证「要么全成要么全不算」,锁保证「同一行同时只有一个人在改」。读完本节,你应当能说出三个经典并发现场各自的安全方案,并明白 2.1 下单例子里那个事务块为什么必须存在。
回看 2.1 的下单流程:扣库存、建订单、发通知三步。没有事务时,第二步失败(比如字段超长插入报错)后第一步的扣减已经生效——库存少了、订单没有,账对不上,而且没有任何报错提醒用户。事务的作用就是把这组写操作捆成一个原子单位:
use think\facade\Db; // 写法一:闭包事务——推荐日常使用,异常自动回滚 Db::transaction(function () use ($userId, $book, $num) { Order::create([...]); Db::name('book')->where('id', $book->id)->dec('stock', $num)->update(); }); // 写法二:手动控制——流程里有分步判断时用 Db::startTrans(); try { $order = Order::create([...]); if (! $order) { throw new \RuntimeException('订单创建失败'); } Db::name('book')->where('id', $book->id)->dec('stock', $num)->update(); Db::commit(); } catch (\Throwable $e) { Db::rollback(); throw $e; // 上抛让上层统一处理(记录日志、返回信封错误) }
两条纪律:事务里只放数据库操作,HTTP 调用、发短信这类慢而不可回滚的动作放事务外(或丢进队列,见第 8 章);事务尽量短——包住的代码每多一毫秒,锁就多持有悬停一毫秒,高峰期的连接池与行锁都会受累。
事务解决了「全成或全不算」,但还有一个更细的问题:两个用户同时读到库存 1、都通过校验、都扣减——库存变负。校验与扣减之间有时间窗,要堵住它得在读的时候就上锁:
Db::startTrans(); try { // 锁模式:读这一行时加排他锁,别的事务读同一行会等待 $book = Db::name('book')->where('id', $bookId)->lock(true)->find(); if ($book['stock'] < $num) { throw new \RuntimeException('库存不足'); } Db::name('book')->where('id', $bookId)->dec('stock', $num)->update(); Order::create([...]); Db::commit(); } catch (\Throwable $e) { Db::rollback(); throw $e; }
lock(true) 生成 FOR UPDATE。这是悲观锁:假设冲突常发生,先锁后用。适合冲突概率高的场景——秒杀、热门商品下单。代价是并发吞吐被锁串行化,锁范围必须严格压到单行(where 条件精确到主键),锁全表级条件等于自造瓶颈。
冲突少的场景(普通商品、后台编辑)更适合乐观锁:不加锁,写回时检查「数据有没有被别人改过」,改过就重试或失败:
// 表上加 version 字段,读取时带上 $book = Book::find($bookId); $currentVersion = $book->version; // 更新条件带上版本号,命中说明期间没人动过 $affected = Db::name('book') ->where('id', $bookId) ->where('version', $currentVersion) ->update([ 'stock' => $book->stock - $num, 'version' => $currentVersion + 1, ]); if ($affected === 0) { // 被人抢先一步:重试读取并重走流程,或直接提示用户 }
乐观锁的账单在重试率:冲突越多,重试越频繁,极端场景反而不如悲观锁。两类锁的选择就是一道预估题:冲突概率乘以单次成本,哪边便宜用哪边。拿不准时从乐观锁起步,压测(第 8 章)暴露热点再局部换悲观锁。
| 现场 | 冲突特征 | 推荐方案 | 关键点 |
|---|---|---|---|
| 秒杀扣库存 | 极高,热点单行 | 悲观锁 + 短事务 | 锁单行、事务外做通知 |
| 普通下单 | 低,常规商品 | 乐观锁版本号 | 影响行数为零即重试 |
| 钱包扣款 | 中,且不可错 | 事务 + 悲观锁 | 金额校验在锁内完成 |
| 后台资料编辑 | 极低 | 乐观锁提示冲突 | 交互上给「他人已修改」提示 |
背景:练习项目的下单已包事务,但校验在锁外。操作:把库存读取改为事务内 lock(true) 查询,校验与扣减同事务;用两个终端并发模拟同一本书只剩一本时的两笔下单(或写个并发脚本);对比加锁前后库存负数出现的概率。结果示例:加锁后并发下单稳定地一成一败,库存永不为负。解读:注意失败那笔的用户体验——返回「库存不足」而不是报错,说明锁失败路径也要按业务语义返回。变式:把同一流程改造成乐观锁版本,压一下同样的并发,记录两种方案在你们环境里的重试率与耗时,写成三行结论。
⚠️ 常见坑:事务里抛出的异常必须接住并回滚——闭包事务自动回滚的前提是异常没被你在里面吞掉。事务内 try 住异常却不回滚就 commit,是最隐蔽的资金类 bug 来源。
💡 关键直觉:并发问题的本质是「读与写之间隔着时间窗」。事务压缩窗口内操作的整体性,锁把窗口焊死,乐观锁在写回时验窗——三种工具是同一个问题的三种回答。
MySQL 默认的可重复读隔离级别下,事务里反复读同一行拿到的是同一份快照——这正是为什么「先查再改」的两步在并发下不可靠:查询读到的是快照,扣减写的却是现状,中间的时间窗只有锁能焊死,版本号比对是另一条验窗的路。理解了这层,就明白本节的三种工具不是并列的口味选项,而是对同一个快照问题的不同回答。
数据站台走完,业务数据终于又快又稳。下一站视图车间:数据怎么变成用户看见的页面。