Skip to content

MySQL 事务与并发:锁定读取、幂等和失败回滚 ​

本轮仅全文静态审计,未安装依赖、启动服务、连接数据库或执行任何示例。下文实测、RED/GREEN及平台结果均为此前历史证据;本轮修改后的脚本未运行,命令与输出作为学习步骤和预期保留。

原子性与隔离级别 ​

前置:表约束。库存扣减和预留写入必须全部提交或全部回滚,否则会出现扣了库存却没有记录。InnoDB事务提供原子性;持久性仍依赖数据库配置、存储和恢复策略,不能用“一次COMMIT”替代备份。

MySQL 8.4.7默认REPEATABLE READ。普通SELECT通常读一致性快照,FOR UPDATE是锁定读取,读取当前可锁定版本;不要把两者混成“一个事务内所有SELECT都一样”。READ COMMITTED每次一致性读取可看到新的已提交快照,减少部分间隙锁场景,但没有消除所有锁或死锁。SERIALIZABLE更强但可能增加阻塞,不能当所有竞争的无代价修补。

项目对每次reserve显式启动READ COMMITTED事务,而不是改全局默认。事务之间看不到彼此未提交的扣减,写入仍需要锁协调。应用在一个连接上串行执行该事务;不同线程各建自己的连接,不共享游标。

最后库存的竞争 ​

错误算法是两个连接各读到stock=1,然后都执行UPDATE/INSERT。即使每句SQL本身原子,跨语句业务检查仍不原子。项目先用主键sku执行SELECT ... FOR UPDATE,持有产品行排他锁至COMMIT/ROLLBACK,第二个连接等待,第一个提交后第二个读到0,返回insufficient stock。

可在两个mysql终端演示,先确保book存在:

sql
-- 会话A
START TRANSACTION;
SELECT stock FROM products WHERE sku='book' FOR UPDATE;
-- 暂不提交;完成观察后执行 ROLLBACK;
sql
-- 会话B
SET SESSION innodb_lock_wait_timeout=1;
START TRANSACTION;
SELECT stock FROM products WHERE sku='book' FOR UPDATE;
ROLLBACK;

B在A持锁时收到1205。测试脚本使用真正的独立连接和线程屏障验证“两个请求争一个pen仅一人成功”,并核对最终库存0、记录1条,不用单线程正例冒充并发验证。查询条件必须有合适索引,范围锁与扫描范围可能放大阻塞。此实验以主键等值查存在行,不证明任意范围和间隙锁场景。

幂等键与参数不匹配 ​

request_id主键是调用方的一次业务意图标识。相同ID、sku、quantity重放返回replayed=true,不重复扣减;相同ID配不同参数返回错误,而不是误报旧成功。数据库唯一约束是最终防线,不能仅在内存set中去重。已存在请求必须仍可关联原产品,此模型没有物理删除产品或预留的功能。

跨sku同时复用同一ID时,不同产品锁不能互相串行化,唯一键仍会拒绝其中一个事务并整体回滚;应用可能收到1062。调用方应避免跨业务意图复用ID。对提交响应丢失的情况,使用原ID和原参数重新查询或调用,以确认结果;生成新ID重试会重复业务。

回滚、死锁与重试边界 ​

verify.py在UPDATE成功后注入RuntimeError,断言库存仍为3且没有fail请求,证明except路径确实rollback。CHECK 3819由数据库拒绝负库存;输入验证更早拒绝零、负数、超长键。客户端异常、断连、锁等待或提交失败都不能直接假定成功或失败,尤其COMMIT结果丢失应走幂等确认。

1205是锁等待超时,1213是检测到死锁后选为牺牲事务。两者不是同一种错误。默认超时可能只回滚当前语句,因此应用统一显式回滚整个业务事务;死锁后也从头重建事务。若要自动重试,只对可重试错误做有界次数和退避,每次从事务起点开始,并保留相同幂等键;验证错误、库存不足、参数冲突不应盲重试。本课程CLI直接非零退出,未实现隐式重试策略。

多行更新按一致顺序锁行、保持事务短、不要持锁等待用户输入/远程HTTP,可降低死锁概率但不能保证永不死锁。诊断用SHOW ENGINE INNODB STATUS与performance_schema锁表,见运维排错。

研究依据:锁定读取、死锁处理。

自检练习:题4至6。提示:标出锁释放点与事务归属;pen经过verify后为0,竞争前先重置为1并用新请求ID;解答另列。

继续阅读