第 8 站练习答案:读照片,写当前,副作用另记账
← 本站索引 | 专题:隔离级别、Read View 与 undo
下面是 MySQL 8.4 / InnoDB 的参考解法。先在自己的两个连接中得到结果,再来比时间线;MVCC 不是背完术语就会自动在脑中开两个会话。
1|RC 与 RR:预期结果和时间线
两组实验都从 sales_order(order_id=1, status='pending', version_no=1) 开始。会话 B 在 A 的第一次普通读取后执行:
UPDATE sales_order
SET status = 'paid', version_no = version_no + 1
WHERE order_id = 1;会话 A 的脚本:
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
SELECT status, version_no FROM sales_order WHERE order_id = 1; -- pending | 1
-- 等 B 提交
SELECT status, version_no FROM sales_order WHERE order_id = 1; -- pending | 1
ROLLBACK;将第一行换成 READ COMMITTED 后,第二个 SELECT 预期为 paid | 2。
| 隔离级别 | A 第一次普通 SELECT | B 提交后 A 第二次普通 SELECT | 原因 |
|---|---|---|---|
| RR | `pending | 1` | `pending |
| RC | `pending | 1` | `paid |
若结果不同,先确认:两边是否真的在两个连接里、B 是否提交、A 是否意外 COMMIT 过、查询是否是普通 SELECT 而不是锁定读。
2|库存预留:条件 UPDATE 是最小的原子合同
先创建实验表:
CREATE TABLE IF NOT EXISTS inventory_item (
sku VARCHAR(64) PRIMARY KEY,
available_qty INT NOT NULL CHECK (available_qty >= 0)
) ENGINE = InnoDB;
INSERT INTO inventory_item (sku, available_qty)
VALUES ('keyboard', 5)
ON DUPLICATE KEY UPDATE available_qty = VALUES(available_qty);仅需保证“库存够才扣减”时,一条条件更新已经表达完整不变量:
UPDATE inventory_item
SET available_qty = available_qty - 3
WHERE sku = 'keyboard'
AND available_qty >= 3;
SELECT ROW_COUNT() AS reserved_rows;reserved_rows = 1 表示预留成功;为 0 表示库存不足或 SKU 不存在,调用方应区分这两种产品语义。两个会话同时运行这条语句,最多一个能将 5 减去 3,最终不会得到负数。这里不需要先普通 SELECT 再决定,数据库就是裁判。
3|需要复杂判断时:用当前读锁定,再迅速写入
若预留还需要读取仓库状态、计算价格或同时写多张表,才考虑行锁。会话 A:
START TRANSACTION;
SELECT available_qty
FROM inventory_item
WHERE sku = 'keyboard'
FOR UPDATE;
-- 读到 5 后,在同一短事务内完成必要的判断和写入。
UPDATE inventory_item
SET available_qty = available_qty - 3
WHERE sku = 'keyboard'
AND available_qty >= 3;
COMMIT;会话 B 同时执行相同脚本时会在锁定读/更新处等待 A。A 提交后,B 面对的是当前库存 2,条件不成立,ROW_COUNT() 为 0。锁住后不要去请求支付服务、等待用户点确认或下载一部纪录片;锁只能保护数据库内的不变量,不能替外部系统排队。
4|定位长事务与锁等待
在 A 执行 START TRANSACTION 和普通 SELECT 后先别提交,第三个连接运行:
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id,
trx_rows_locked, trx_rows_modified, trx_query
FROM information_schema.innodb_trx
ORDER BY trx_started;
SELECT ENGINE_TRANSACTION_ID, THREAD_ID, OBJECT_SCHEMA, OBJECT_NAME,
INDEX_NAME, LOCK_MODE, LOCK_STATUS, LOCK_DATA
FROM performance_schema.data_locks
WHERE OBJECT_SCHEMA = 'mvcc_lab';将 trx_mysql_thread_id 与应用连接池日志关联,记录:哪个请求创建了事务、持续多久、是否修改了行、能否安全结束。不要仅因“它很老”就直接 KILL;先判断是否正在进行批处理、迁移或关键结算。必要时的终止是事故处置,之后仍要修复事务边界。
5|数据库提交与消息发送:outbox 的最小骨架
一个事务不能原子覆盖 MySQL 和消息系统。参考的 outbox 形状如下:
CREATE TABLE IF NOT EXISTS inventory_outbox (
event_id BINARY(16) PRIMARY KEY,
event_type VARCHAR(100) NOT NULL,
payload JSON NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
published_at TIMESTAMP NULL
) ENGINE = InnoDB;
START TRANSACTION;
UPDATE inventory_item
SET available_qty = available_qty - 3
WHERE sku = 'keyboard' AND available_qty >= 3;
-- 应用程序仅在 ROW_COUNT() = 1 时生成唯一 event_id 并插入事件。
INSERT INTO inventory_outbox (event_id, event_type, payload)
VALUES (
UUID_TO_BIN(UUID()),
'inventory.reserved',
JSON_OBJECT('sku', 'keyboard', 'qty', 3)
);
COMMIT;独立 worker 读取 published_at IS NULL 的事件、发送消息、成功后标记 published_at。若“发送成功但标记失败”,worker 会重复发送,因此消费者必须按 event_id 幂等。这里的重复不是 bug 的借口,而是分布式边界的正常天气。
常见偏差
| 现象 | 常见原因 | 修正 |
|---|---|---|
| RR 实验第二次读到新值 | A 实际使用 RC、事务已结束,或使用了当前读 | 检查会话变量、事务边界和 SQL 形状 |
FOR UPDATE 没有等待 | 没有 START TRANSACTION、访问了不同记录或索引路径不同 | 用两个连接、同一主键、明确事务重做 |
innodb_trx 没有预期行 | 事务已经提交,或权限/观测范围受限 | 保持 A 开启,核对连接和权限 |
| 条件 UPDATE 影响 0 行 | 库存不足、SKU 不存在,或条件写错 | 让产品决定如何区分,再用查询或返回码表达 |
自检
- 我能用两个独立连接复现 RR 与 RC 的不同,而不是只读到一段解释。
- 我知道普通一致性读不会自动锁行,当前读才会参与锁协议。
- 条件 UPDATE 或锁定读中至少一种方案能证明库存不变量。
- MySQL 提交成功与消息送达之间,我设计了可重试、可幂等的桥梁。