前面我们已经把小满商店的业务规则写进了查询与条件表达式:只有在售商品才能下单,库存不能扣成负数,订单金额要由明细计算,付款状态也要与订单状态相互对应。可一旦真正开始写数据,一个更棘手的问题就出现了——这些规则通常不只落在一条 SQL 上。
假设促销进行到最后,商品 5「65W 氮化镓充电器」只剩 1 件。系统要创建订单、写入订单明细、扣减库存、记录库存流水,还可能创建一条待付款记录。若第四步报错,前三步却已经生效,数据库里会留下一个没有流水的库存变化;若两个顾客同时看到库存为 1,又都顺利扣减,最终甚至可能卖出 2 件。
单条 SQL 写得正确,并不等于一组 SQL 合起来也正确。事务解决「中途失败怎么办」,并发控制解决「别人同时动同一份数据怎么办」。这两件事共同决定了业务规则在压力之下是否仍然成立。
这一章以 MySQL 8.4 的默认事务存储引擎 InnoDB 为主。涉及 PostgreSQL 时会明确标注差异,不要把不同数据库的默认隔离级别、锁行为与 DDL 规则直接互换。
事务的第一道难题不是写 START TRANSACTION,而是决定从哪里开始、到哪里结束。边界太小,业务会只完成一半;边界太大,锁会被持有太久,等待和死锁随之增加。
小满商店一次「确认下单」至少包含下面几条不可拆开的规则:
这些数据库修改应当放进同一个事务。相反,等待顾客输入短信验证码、调用物流接口、生成大文件、发送短信,都不适合夹在事务中间。事务打开后应尽快读、校验、写入并结束,不能让数据库一边持锁,一边等人或网络。
可以把边界判断压缩成一句话:只要其中一步失败后,前面已经完成的数据库修改就不应该保留,这些步骤就属于同一个事务。
事务边界也不是越大越保险。假设后台一次给 20 万件商品调整状态,把全部操作塞进单个事务,会制造大量 undo、长时间占用锁,并让失败后的回滚变得很慢。批处理往往要按可恢复的业务单元拆批,并额外设计进度记录;这与「一张订单必须整体成功」是两种问题。

ACID 常被背成原子性、一致性、隔离性、持久性,但真正有用的是知道它们在数据库外部能观察到什么。
如果订单已经插入,但扣库存失败,ROLLBACK 会撤销本事务之前做出的数据修改。其他会话不会看到一张半成品订单在提交后永久留下。
不过,原子性只覆盖你放进事务的数据库操作。若应用在提交前已经向外部短信平台发出通知,数据库回滚并不能把短信收回来。跨系统动作通常需要「本地事务 + 待发送事件」一类额外设计,而不是误以为一个本地事务能回滚整个互联网。
「订单总额不能为负」可以由列类型、CHECK 约束或写入 SQL 保证;「订单必须至少有一条明细」跨越了多行与多个写入步骤,通常由事务流程保证。若程序把错误金额写进 orders.total_amount,事务仍然可能非常完整地提交这份错误数据。
所以一致性不是「用了事务就永远正确」,而是:事务从一个合法状态出发,执行一组正确的规则后,提交到另一个合法状态。
并发下,数据库会组合快照、行锁、索引范围锁、冲突检测等机制。普通读取可能看到旧版本,写入可能等待另一个事务,严格隔离还可能主动让某个事务失败并要求重试。隔离级别决定的不是「有没有事务」,而是事务允许看见什么、哪些并发结果必须被阻止。
COMMIT 返回成功之前,应用不能对外宣称订单已经落库;返回成功之后,即使数据库进程意外退出,InnoDB 也会利用日志与恢复机制重做已提交修改、撤销未提交修改。持久性依赖数据库、操作系统、存储设备和相关参数共同工作,因此生产配置不能只看 SQL 语法。
「接口超时」不等于「提交失败」。连接在 COMMIT 附近断开时,客户端可能不知道服务器是否已经提交。对下单这类操作,应使用唯一业务请求号或其他幂等设计,重试前先确认结果,不能盲目再插入一张订单。
MySQL 新连接默认通常是 autocommit = 1。此时每一条成功执行的语句各自构成一个事务,并在语句结束后自动提交。
SELECT @@autocommit AS autocommit_enabled;+--------------------+
| autocommit_enabled |
+--------------------+
| 1 |
+--------------------+这解释了为什么下面的补救没有效果:
UPDATE products
SET stock = stock - 1
WHERE product_id = 5;
-- 过了一会儿才发现扣错商品
ROLLBACK;第一条 UPDATE 结束时已经自动提交,后面的 ROLLBACK 没有可撤销的内容。自动提交适合彼此独立的单条修改,却不适合需要一起成功的下单流程。
在保持 autocommit = 1 的连接里,可以临时开启一个多语句事务:
START TRANSACTION;
-- 一组需要共同成功的 SELECT / INSERT / UPDATE / DELETE
COMMIT;发现任何条件不满足时,以 ROLLBACK 结束:
START TRANSACTION;
UPDATE products
SET stock = stock - 2
WHERE product_id = 5
AND stock >= 2;
-- 应用检查受影响行数,若不是 1:
ROLLBACK;START TRANSACTION 只影响当前事务。提交或回滚之后,连接仍回到原先的自动提交模式。它与长期执行 SET autocommit = 0 不一样。
SET autocommit = 0;
UPDATE products
SET status = '下架'
WHERE product_id = 6;
COMMIT;
SET autocommit = 1;关闭自动提交后,COMMIT 或 ROLLBACK 结束当前事务,下一条事务性语句又会进入新事务。若连接在最后一个事务未提交时断开,MySQL 会回滚未提交修改。
连接池会复用会话。若一段代码改了 autocommit、隔离级别或留下未结束事务却没有恢复状态,下一个请求可能继承意外的会话配置。更稳妥的做法是由连接池或数据访问层统一管理开始、提交、回滚与连接归还。
COMMIT 与 ROLLBACK 之后发生了什么COMMIT 让本事务的修改成为已提交状态,并释放事务持有的 InnoDB 锁。ROLLBACK 撤销本事务的修改,也释放相应锁。可以用两个会话观察「提交才对外可见」。会话 A 修改商品但暂不提交:
-- 会话 A
START TRANSACTION;
UPDATE products
SET unit_price = 159.00
WHERE product_id = 5;
SELECT product_id, unit_price
FROM products
WHERE product_id = 5;+------------+------------+
| product_id | unit_price |
+------------+------------+
| 5 | 159.00 |
+------------+------------+同一事务能看见自己的修改。与此同时,会话 B 的普通查询不会看见 A 未提交的 169.00,它会依照自身隔离级别读取一个已提交版本。等 A 执行 COMMIT 后,新建立的可见性边界才把这次改价交给其他事务。
事务最容易理解的方式,是先找出业务中「提交前后必须成立」的不变量。小满商店有两组特别直观的对应关系:
products.stock 应减少 2,同时 inventory_movements.quantity 留下一条 -2 的销售出库流水。库存变化可以写成一条容易核对的等式:
出库前库存 32 + 本次销售出库流水 (-2) = 出库后库存 30付款也有自己的对应关系:
订单 107 应付金额 137.00
成功付款金额 137.00
订单提交后状态 已支付下面的图只借用「一边减少、另一边增加、合计关系不被破坏」来表现不变量。正文仍以小满商店的库存、流水、订单与付款为准,不引入新的持久业务表。

以订单 107 的付款确认为例,先锁住待支付订单,再写付款事实与订单状态:
START TRANSACTION;
SELECT order_id, status, total_amount
FROM orders
WHERE order_id = 107
FOR UPDATE;
INSERT INTO payments (
order_id,
paid_at,
amount,
payment_method,
status
)
VALUES (
107,
CURRENT_TIMESTAMP,
137.00,
'微信',
'成功'
);
如果条件更新影响 0 行,可能是订单已经被其他请求处理、订单金额不一致,或状态早已变化。应用必须 ROLLBACK,让刚插入的付款记录一起消失,而不是留下付款与订单状态互相矛盾的半成品。真实支付回调还应使用支付渠道流水号或业务请求号做幂等保护;本课程的九表尚未提供该字段,下一章会继续说明怎样用索引与约束把这类入口守得更牢。
库存同样不能只改一个数字。下面的条件更新把「仍在售、库存足够」与扣减合成一个原子动作:
UPDATE products
SET stock = stock - 2
WHERE product_id = 5
AND status = '在售'
AND stock >= 2;若受影响行数为 1,说明商品存在、在售且库存足够,并已经完成扣减;接下来必须在同一事务写入 quantity = -2 的销售出库流水。若受影响行数为 0,至少有一个条件不满足,程序不能继续创建一张假装成功的订单。
下面把小满商店的一次下单串起来。顾客 1「苏小满」购买商品 5 两件、商品 6 一件。假定下单前数据如下:
SELECT product_id, product_name, unit_price, stock, status
FROM products
WHERE product_id IN (5, 6)
ORDER BY product_id;+------------+----------------+------------+-------+---------+
| product_id | product_name | unit_price | stock | status |
+------------+----------------+------------+-------+---------+
| 5 | 65W 氮化镓充电器 | 169.00 | 32 | 在售 |
| 6 | 编织数据线 | 39.00 | 210 | 在售 |
+------------+----------------+------------+-------+---------+本例没有订单级优惠和运费,订单总额按成交单价计算:169.00 × 2 + 39.00 × 1 = 377.00。
START TRANSACTION;
SELECT product_id, product_name, unit_price, stock, status
FROM products
WHERE product_id IN (5, 6)
ORDER BY product_id
FOR UPDATE;这里有三个细节:
FOR UPDATE 取得用于后续修改的排他性行锁,避免核对完库存到真正扣减之间被另一笔订单抢走。ORDER BY product_id 让不同订单尽量按同一顺序申请锁,降低互相反向等待的概率。status 与 stock,不能只确认第一件商品。INSERT INTO orders (
customer_id,
order_date,
status,
total_amount
)
VALUES (
1,
CURRENT_TIMESTAMP,
'待支付',
377.00
);
SET @new_order_id = LAST_INSERT_ID();LAST_INSERT_ID() 在当前连接中取得刚生成的自增值。事务期间不能把后续 SQL 切到连接池的另一条连接上,否则会话变量、事务与刚生成的编号都不再属于同一上下文。
INSERT INTO order_items (
order_id,
product_id,
quantity,
unit_price,
discount_rate
)
VALUES
(@new_order_id, 5, 2, 169.00, 0.0000),
(@new_order_id, 6, 1, 39.00, 0.0000);这里把 unit_price 写入订单明细,而不是以后每次都去关联 products.unit_price。商品明天涨价,不应改写今天订单的成交事实。
UPDATE products
SET stock = stock - 2
WHERE product_id = 5
AND status = '在售'
AND stock >= 2;
SELECT ROW_COUNT() AS affected_rows;+---------------+
| affected_rows |
+---------------+
| 1 |
+---------------+UPDATE products
SET stock = stock - 1
WHERE product_id = 6
AND status = '在售'
AND stock >= 1;
SELECT ROW_COUNT() AS affected_rows;+---------------+
| affected_rows |
+---------------+
| 1 |
+---------------+交互式 SQL 中可以看见结果;应用代码应读取驱动返回的受影响行数。只要任意一次不是 1,立即 ROLLBACK,并把「商品不存在、已下架或库存不足」作为业务失败返回。不要把 IF ... THEN 直接写在普通 SQL 脚本中;MySQL 的流程控制语句只在存储程序等特定语境里可用。
INSERT INTO inventory_movements (
product_id,
employee_id,
movement_type,
quantity,
moved_at,
note
)
VALUES
(5, NULL, '销售出库', -2, CURRENT_TIMESTAMP,
CONCAT('订单 ', @new_order_id, ' 自动出库')),
(6, NULL, '销售出库', -1, CURRENT_TIMESTAMP,
CONCAT('订单 'quantity 使用负数表示出库,便于求和核对。这里的 employee_id 为 NULL 表示由顾客下单流程自动触发;如果课程数据库为该列设置了非空约束,就应约定一个系统操作员,而不是临时绕过约束。
COMMIT;
SELECT product_id, stock
FROM products
WHERE product_id IN (5, 6)
ORDER BY product_id;+------------+-------+
| product_id | stock |
+------------+-------+
| 5 | 30 |
| 6 | 209 |
+------------+-------+SELECT
o.order_id,
o.customer_id,
o.status,
o.total_amount,
SUM(oi.quantity * oi.unit_price * (1 - oi.discount_rate)) AS detail_amount
FROM orders AS o
JOIN order_items AS
+----------+-------------+-----------------+--------------+---------------+
| order_id | customer_id | status | total_amount | detail_amount |
+----------+-------------+-----------------+--------------+---------------+
| 115 | 1 | 待支付 | 377.00 | 377.00 |
+----------+-------------+-----------------+--------------+---------------+
教学中把每一步展开是为了看清边界。真实应用应由同一个服务方法和同一条数据库连接管理事务,并在任何 SQL 异常、校验失败或超时分支里确保回滚。
有时一项可选操作失败,不必推翻之前所有必需操作。保存点相当于在当前事务内做一个可回退标记。
假设订单与库存已经处理完,系统接着尝试记录付款。付款记录暂时失败时,业务允许保留待付款订单:
START TRANSACTION;
-- 此处省略:锁商品、创建订单、写明细、扣库存、写流水
SAVEPOINT before_payment;
INSERT INTO payments (
order_id,
paid_at,
amount,
payment_method,
status
)
VALUES (
@new_order_id,
CURRENT_TIMESTAMP,
377.00,
'微信',
'成功'
);如果付款记录分支不符合预期,可以回到保存点:
ROLLBACK TO SAVEPOINT before_payment;
UPDATE orders
SET status = '待支付'
WHERE order_id = @new_order_id;
RELEASE SAVEPOINT before_payment;
COMMIT;ROLLBACK TO SAVEPOINT 撤销保存点之后的数据修改,但不结束事务;保存点之前的订单与扣库存仍可继续提交。RELEASE SAVEPOINT 只是移除标记,不会提交任何数据。
保存点有三条容易误解的规则:
ROLLBACK 会回滚整个事务。更重要的是,保存点只适合「事务仍然可继续」的失败。InnoDB 检测到死锁并选中当前事务作为牺牲者时,整个事务通常已经被回滚,原来的保存点也不存在;这时必须从事务开头重试。
MySQL 与 PostgreSQL 对普通语句报错后的事务状态也有明显差异。MySQL 中不少错误只撤销失败的那条语句,事务仍可能继续,但具体行为要看错误类型;PostgreSQL 事务块内只要一条语句报错,事务就会进入失败状态,后续普通语句不会继续执行,必须整体 ROLLBACK,或事先设置保存点后执行 ROLLBACK TO SAVEPOINT 才能恢复。跨数据库的数据访问层不能假设「捕获异常后直接跑下一条 SQL」在两边都成立。
单人操作时,先查库存再扣库存看起来很自然:
SELECT stock
FROM products
WHERE product_id = 5;
-- 程序判断 stock >= 1 后
UPDATE products
SET stock = stock - 1
WHERE product_id = 5;问题在于查询和更新之间存在空隙。库存只剩 1 时,会话 A 与 B 都可能先读到 1,都判断可以买,然后各扣一次。事务不是自动消除竞争的魔法;若普通查询没有锁住将要修改的行,两个事务仍可能依据同一个旧判断行动。
会话 A 把价格改成 1.00,但还没提交;会话 B 就读到这个值。若 A 随后回滚,B 读到的是一个从未正式存在过的状态。MySQL 的 READ UNCOMMITTED 允许此类现象,而常用的更高隔离级别会阻止它。
同一事务内,会话 A 两次查询商品价格。两次查询之间,会话 B 修改价格并提交,于是 A 得到不同结果。MySQL 与 PostgreSQL 的 READ COMMITTED 都可能出现这种变化,因为每条语句通常取得新的已提交快照。
会话 A 两次统计「库存低于 5 的在售商品」,会话 B 在中间插入一条符合条件的新商品并提交,A 第二次看到的行集合变多,这个新出现的行就像「幻影」。范围查询是否稳定不仅与名称上的隔离级别有关,也与数据库实现有关。
会话 A 与 B 都读取库存 10。A 在程序里算出 9 并写回,B 也算出 9 并写回,最终库存是 9,而不是 8。后一次写入覆盖了前一次效果。
尽量把「读—计算—写」改成数据库内的原子表达式:
UPDATE products
SET stock = stock - 1
WHERE product_id = 5
AND stock >= 1;数据库会让同一行上的写入串行取得锁,并在轮到该语句时重新依据当前可写版本判断 stock >= 1。如果业务确实要先读多个字段再做复杂决策,则使用 SELECT ... FOR UPDATE 把读取升级为锁定读。
还有一种更隐蔽的问题:两个事务修改不同的行,所以没有直接写写冲突,但它们共同破坏了跨行规则。比如仓库规定「同一类商品至少保留一件可售」,两个管理员分别下架不同商品;各自快照里都还能看到另一件在售,于是两笔修改都提交,最终一件也不剩。
这类规则可能需要锁住代表规则的稳定行、扩大锁定范围、建立能表达规则的约束,或使用可串行化隔离并准备重试。只锁「自己将要改的那一行」不一定够。

SQL 标准描述了四个隔离级别,但数据库可以提供比标准最低要求更强的保证。不能只背一张通用表,还要看具体实现。
MySQL 查看当前会话隔离级别:
SELECT @@transaction_isolation AS isolation_level;+-----------------+
| isolation_level |
+-----------------+
| REPEATABLE-READ |
+-----------------+为下一笔事务设置隔离级别:
SET TRANSACTION ISOLATION LEVEL READ COMMITTED;
START TRANSACTION;
-- 本事务使用 READ COMMITTED
COMMIT;或者设置当前会话之后的事务:
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;隔离越严格,通常需要付出更多等待、冲突检测或重试成本,但「级别越高就一定越慢」也过于简单。真正的代价取决于读写比例、查询范围、索引、事务时长与数据库实现。
InnoDB 默认是 REPEATABLE READ,PostgreSQL 默认是 READ COMMITTED。同一段包含两次普通 SELECT 的事务,迁移后可能从「两次看同一快照」变成「第二次能看到其他事务刚提交的结果」。反方向迁移时,也可能因 InnoDB 的范围锁行为出现更多插入等待。
不要用「我们一直用默认值」代替设计。先说明业务需要稳定快照、最新已提交值,还是可串行化结果,再决定隔离级别与显式锁。
MVCC 是多版本并发控制。它的核心思路不是给每次普通查询都加一把会阻塞写入的锁,而是让读取根据自己的可见性规则选择某个行版本。
在 InnoDB 中,更新一行时会形成可用于回溯旧值的信息;普通一致性读依据读视图判断哪个版本对当前事务可见。于是会话 A 正在修改商品 5 且尚未提交时,会话 B 的普通 SELECT 通常不必等待 A,而是读取符合自己快照的已提交旧版本。

普通查询通常是快照读:
SELECT product_id, stock
FROM products
WHERE product_id = 5;锁定读要取得当前可锁定版本:
SELECT product_id, stock
FROM products
WHERE product_id = 5
FOR UPDATE;UPDATE 与 DELETE 也属于要面对当前可写状态的操作。它们不能只改快照里的历史版本,而要在必要时等待其他写事务结束,再处理最新可用版本。
这在 InnoDB 默认 REPEATABLE READ 中会产生一个很容易困惑的现象:同一事务的普通 SELECT 可能继续看到旧快照,而随后 SELECT ... FOR UPDATE 却拿到较新的当前版本。两者服务的目的不同,不能把普通读取结果与锁定读取结果混在一起推理复杂业务。
只读事务看似没有锁住商品,却可能长期持有旧快照,使数据库必须保留它仍可能访问的旧版本。后台报表开着事务跑几个小时,会增加历史版本清理压力。事务应尽量短,分页导出也不要无意识地保持一个巨大而长期的事务。
读旧版本可以降低读写互相阻塞,但两个事务都要更新商品 5 时,仍必须解决谁先取得写锁。MVCC 与锁不是二选一:现代数据库通常让它们合作,一个负责可见性,一个负责保护当前修改与业务冲突点。
锁的范围会直接影响并发度。
InnoDB 常被概括为「行级锁」,更准确地说,许多行锁落在索引记录上。锁定读、UPDATE 与 DELETE 会沿执行计划扫描索引,并对扫描到的记录或范围取得相应锁。若没有合适索引,一条看似只想改少量行的语句可能扫描并锁住远超预期的记录。
UPDATE products
SET status = '下架'
WHERE category_id = 4
AND status = '在售';如果缺少能支持筛选的索引,数据库需要检查很多行;并发影响也会扩大。这正是本章最后会引向索引的原因:索引不只决定查询速度,也决定锁能否准确抵达冲突点。
InnoDB 的行锁不会因为锁得多就自动升级成表锁,但「不升级」不等于「影响一定小」。一次全表扫描仍可能逐步锁住大量记录。判断锁范围时要看执行计划和实际使用的索引,而不是只数 WHERE 条件。
SELECT ... FOR UPDATE:先锁定,再依据当前值决策当业务必须先读后写,而且决策不能仅靠一条条件更新完成时,使用锁定读。
START TRANSACTION;
SELECT product_id, unit_price, stock, status
FROM products
WHERE product_id = 5
FOR UPDATE;
-- 应用校验状态、库存及其他规则
UPDATE products
SET stock = stock - 2
WHERE product_id = 5;
COMMIT;在事务结束之前,其他会话对这条记录发起冲突的更新或锁定读通常会等待。普通一致性读仍可能从 MVCC 旧版本返回,不要据此误判「锁没生效」。
如果订单决策依赖商品 5 与 6,就应该在同一事务里锁住两行并确认确实返回两行:
SELECT product_id, stock, status
FROM products
WHERE product_id IN (5, 6)
ORDER BY product_id
FOR UPDATE;若规则依赖一个范围,例如「某分类最多只能有 20 件预售商品」,只锁住即将修改的商品可能不够,因为另一个事务可以处理同分类的另一行。此时要重新设计冲突点:锁定分类代表行、使用能表达上限的计数行,或选择可串行化隔离并处理失败重试。
只执行一条 SELECT ... FOR UPDATE,却没有显式事务包住后续更新,语句结束后那笔自动提交事务也结束,锁立即释放。正确模式必须让锁定读与修改处在同一个事务、同一连接中。
FOR UPDATE 不是越多越好报表、商品详情页等纯读取通常应使用普通快照读。无差别加 FOR UPDATE 会把本可并行的读取变成锁竞争,还可能增加死锁。只有在「读出的值将决定本事务随后怎样修改」且存在并发冲突时才加锁。
下面用清晰的会话时序观察锁如何阻止超卖。假设促销进行一段时间后,商品 5 当前库存只剩 1。
-- 会话 A
START TRANSACTION;
SELECT product_id, stock
FROM products
WHERE product_id = 5
FOR UPDATE;+------------+-------+
| product_id | stock |
+------------+-------+
| 5 | 1 |
+------------+-------+-- 会话 B
START TRANSACTION;
SELECT product_id, stock
FROM products
WHERE product_id = 5
FOR UPDATE;此时 B 不会立即拿到旧库存并继续,而是等待 A 结束。
-- 会话 A
UPDATE products
SET stock = stock - 1
WHERE product_id = 5;
COMMIT;A 提交释放锁后,B 的锁定读继续,并面对库存已经为 0 的当前版本。B 应结束自己的下单:
+------------+-------+
| product_id | stock |
+------------+-------+
| 5 | 0 |
+------------+-------+-- 会话 B
ROLLBACK;这段时序有一个非常重要的结论:等待结束不代表业务一定还能成功。数据库只负责让冲突有顺序,轮到 B 时,B 必须基于新状态重新校验库存。
如果除了库存没有其他复杂判断,可以直接尝试:
UPDATE products
SET stock = stock - 1
WHERE product_id = 5
AND status = '在售'
AND stock >= 1;并发执行时,最终只有一个请求能得到 ROW_COUNT() = 1;其他请求等待后重新判断条件,得到 0。相比「先普通查询,再无条件更新」,这条写法更短,也缩小了竞争窗口。
锁等待本身不是死锁。A 锁住商品 5,B 等 A 提交,这是单向等待,A 可以继续。死锁是等待形成了环。

假设两张订单都要修改商品 5 与 6,但顺序相反。
-- 会话 A
START TRANSACTION;
UPDATE products SET stock = stock - 1 WHERE product_id = 5;
-- 已锁住商品 5-- 会话 B
START TRANSACTION;
UPDATE products SET stock = stock - 1 WHERE product_id = 6;
-- 已锁住商品 6接下来 A 想改商品 6,只能等 B;B 又想改商品 5,只能等 A:
-- 会话 A:等待 B 释放商品 6
UPDATE products SET stock = stock - 1 WHERE product_id = 6;-- 会话 B:等待 A 释放商品 5,形成闭环
UPDATE products SET stock = stock - 1 WHERE product_id = 5;InnoDB 默认会检测死锁,选择一个事务作为牺牲者并回滚,使另一个事务继续。被选中的会话会收到类似错误:
ERROR 1213 (40001): Deadlock found when trying to get lock;
try restarting transaction所有下单流程都按 product_id 从小到大锁商品:
SELECT product_id, stock
FROM products
WHERE product_id IN (5, 6)
ORDER BY product_id
FOR UPDATE;统一顺序不能保证世界上再无死锁,但能消除大量由反向取锁造成的环。
正确的重试单位是整个业务事务,不是只重跑报错的最后一条 SQL。死锁牺牲者之前的修改已经被回滚,单独重跑末尾语句会丢失前置步骤。
应用层重试逻辑可以表示为:
最多尝试 3 次:
1. 开始事务
2. 重新读取并锁定当前数据
3. 重新校验库存与状态
4. 执行全部写入
5. 提交
若遇到死锁或可重试的序列化失败:
回滚,短暂随机退避,然后从第 1 步重来
若遇到库存不足、参数错误、唯一约束冲突等确定性失败:
不盲目重试,返回对应结果随机退避能减少一批请求立刻以同样节奏再次相撞。每次重试都必须重新读取数据,因为上一轮的判断已经过期。
死锁错误常见为 1213,意味着数据库发现等待环并主动挑选牺牲者。锁等待超时常见为 1205,表示等待超过阈值;InnoDB 默认对锁等待超时的回滚范围与死锁并不完全相同。应用不应猜测事务还剩什么,最安全的处理通常是显式回滚,并按业务策略决定是否整体重试。
在 MySQL 中,很多数据定义语句会隐式提交,例如常见的 CREATE TABLE、ALTER TABLE、DROP TABLE、CREATE INDEX、TRUNCATE TABLE。它们通常在执行前结束当前事务,有些还在执行后形成新的提交边界。
下面这段脚本不能达到「改价格与加字段一起回滚」的目的:
START TRANSACTION;
UPDATE products
SET unit_price = unit_price * 0.9
WHERE category_id = 4;
ALTER TABLE products
ADD COLUMN promotion_note VARCHAR(200);
ROLLBACK;执行 ALTER TABLE 时,前面的价格更新已经可能被隐式提交。最后的 ROLLBACK 无法回到最初状态。
同样,在一个尚未结束的事务里再次执行 START TRANSACTION,MySQL 不会创建嵌套事务,而会隐式提交当前事务再开始新的事务。若需要局部回退,使用保存点;不要拿第二个 START TRANSACTION 模拟嵌套。
MySQL 的 CREATE TEMPORARY TABLE 与 DROP TEMPORARY TABLE 不一定触发普通意义上的隐式提交,但这些建表、删表动作本身也不能靠回滚撤销。换句话说,「没隐式提交」不等于「所有效果都满足事务原子性」。
PostgreSQL 的许多普通 DDL 可以放在事务块内并随事务回滚,这是两者非常显著的差异。编写迁移脚本时,必须按目标数据库逐项确认,不能把「DDL 都不能回滚」当成跨数据库真理。
生产上通常把结构迁移与订单业务事务分开:迁移工具管理 DDL,业务代码管理 DML。即使某数据库支持事务性 DDL,也还要考虑 DDL 取得的强锁以及对在线流量的影响。
MySQL 的事务能力与存储引擎有关。MySQL 8.4 默认存储引擎是 InnoDB,它提供事务、提交、回滚、崩溃恢复、行级锁与一致性读,是本章所有示例的前提。
查看小满商店核心表的引擎:
SELECT
table_name,
engine
FROM information_schema.tables
WHERE table_schema = DATABASE()
AND table_name IN (
'customers',
'products',
'orders',
'order_items',
'payments',
'inventory_movements'
)
ORDER BY table_name;+---------------------+--------+
| table_name | engine |
+---------------------+--------+
| customers | InnoDB |
| inventory_movements | InnoDB |
| order_items | InnoDB |
| orders | InnoDB |
| payments | InnoDB |
| products | InnoDB |
+---------------------+--------+如果同一业务事务里混入不支持事务的表,ROLLBACK 可能只撤销 InnoDB 表,而无法撤销另一张表上的修改,原子性就被撕开了。不要只确认 orders,应确认整个事务触达的所有表。
将旧表转换为 InnoDB 的语法是:
ALTER TABLE products ENGINE = InnoDB;但这是一项 DDL,会涉及隐式提交、表重建或元数据锁等问题。它应该作为经过评估的迁移执行,而不是偷偷塞进正在下单的业务事务。
事务问题经常表现为「SQL 突然很慢」,但根因可能只是它在等另一条连接释放锁。
SHOW ENGINE INNODB STATUS\G输出中的 LATEST DETECTED DEADLOCK 会展示参与事务、持有与等待的锁以及被回滚的一方。它适合定位最近一次死锁,但不是完整历史;长期监控还需要把死锁信息采集到日志或可观测系统。
MySQL 自带的 sys 视图可以快速建立阻塞链:
SELECT
waiting_pid,
waiting_query,
blocking_pid,
blocking_query,
wait_age
FROM sys.innodb_lock_waits
ORDER BY wait_age DESC;更底层的信息可以从 Performance Schema 的 data_locks、data_lock_waits,以及 information_schema.innodb_trx 中取得。排查时不要一看到等待就立即杀连接;先确认事务归属、业务影响与是否能安全重试。
不要把「把锁等待超时调得很大」当成修复。超时只决定愿意等多久,不能消除错误的事务边界、缺失索引或反向加锁。
下面的练习都使用小满商店的统一表结构。先自己写,再展开参考思路。
下面的流程有什么问题?请改成能抵抗并发抢购的版本。
SELECT stock
FROM products
WHERE product_id = 5;
-- 应用看到 stock = 1
INSERT INTO orders (
customer_id, order_date, status, total_amount
)
VALUES (
1, CURRENT_TIMESTAMP, '待支付', 169.00
);
UPDATE products
SET stock = stock - 1
WHERE product_id = 5订单与明细已经准备好。尝试写付款记录,若付款步骤需要撤销,则保留订单并把状态设为「待支付」。应该怎样使用保存点?
会话 A 依次修改商品 5 → 6,会话 B 依次修改 6 → 5。请写出一种降低死锁概率的改法。
START TRANSACTION;
UPDATE products
SET status = '下架'
WHERE product_id = 6;
ALTER TABLE products
ADD COLUMN shelf_note VARCHAR(100);
ROLLBACK;会话 A 执行 SELECT ... FOR UPDATE 锁住商品 5 后,会话 B 的普通 SELECT 仍立即返回一个旧库存。锁是不是失效了?
写完一段事务代码,可以按下面的顺序检查:
FOR UPDATE 或更合适的原子更新保护?事务把多条修改组成一个命运共同体,并发控制让这些共同体在同时运行时仍守住规则。但到这里还有两块拼图没有展开:索引决定数据库要扫描并锁住多大范围,约束决定哪些错误状态根本不允许提交。 下一章就从这两块开始,把「依靠程序自觉」进一步变成数据库可以执行的防线。
关键不是只加上 START TRANSACTION,而是把库存判断与扣减合成条件更新,并检查影响行数。任何后续插入失败都必须回滚整笔事务。
保存点之后发生可恢复的分支失败时才这样做。若订单或库存等必需步骤失败,应直接回滚整个事务。
此外,应用仍要捕获死锁错误并从事务开头有限重试,因为统一顺序只能降低概率,不能代替重试机制。