数据库事务隔离级别:这次怎么落地的
对账脚本和线上下单并发跑,同一笔库存前后两次读出来的数对不上——这时候才意识到,隔离级别不是数据库文档里的四个名词。
以前做订单库存系统时,线上偶发"库存对不上":运营手工对账脚本跑出来的 available_qty,和订单服务里同一时刻查出来的值差几行。查日志没有报错,也没有死锁,就是同一个 product_id,两次 SELECT 结果不一致。
一开始怀疑缓存,Redis 排除了;再怀疑读写分离延迟,主库直查也一样。最后把两个会话在 MySQL 里并排复现,问题才落到事务隔离上。
这篇文章不写 ANSI 标准全文,只记这次排查里真正用到的:三种并发异常怎么复现、MySQL 和 PostgreSQL 默认行为差在哪、我们最后怎么定隔离级别和事务边界。
先搞清楚:到底怕哪几种读异常
教材里四个隔离级别,日常开发真正要对线的就三种异常:
| 异常 | 人话 | 我们踩到的场景 |
|---|---|---|
| 脏读 | 读到别人还没提交的改动 | 这次没遇到——MySQL InnoDB 默认就不允许 |
| 不可重复读 | 同一事务里两次读同一行,值变了 | 对账脚本第一次读库存 100,第二次变 97 |
| 幻读 | 同一条件两次查,行数变了 | 统计"待发货订单"时中间插进新单,汇总少一行 |
隔离级别就是在性能和能防住哪些异常之间做取舍。完全串行化最稳,也最难扛并发。
我们的环境和默认值
当时栈很简单:
- MySQL 5.7 + InnoDB,Spring Boot +
@Transactional - 部分报表走 PostgreSQL 9.6(历史原因,只读从业务库同步)
- 连接池 HikariCP,没有显式设隔离级别——也就是吃数据库默认
两个库的默认不一样,这是后面踩坑的根源:
-- MySQL InnoDB 默认 Repeatable Read
SELECT @@transaction_isolation;
-- REPEATABLE-READ
-- PostgreSQL 默认 Read Committed
SHOW transaction_isolation;
-- read committed
MySQL 文档写 Repeatable Read “理论上可能幻读”,但 InnoDB 在 RR 下用 next-key gap lock,实际把幻读也挡掉了(代价是锁范围更大)。PostgreSQL 的 RC 只保证读已提交,同一事务内多次读可以不一致。
我们的问题出在:对账任务按 RR 写的(在 MySQL 上),但有一段统计 SQL 跑在 PG 从库上,默认 RC——两边对"同一时刻库存快照"的理解本来就不是一回事。
复现:两个会话就够了
不用上压测工具,开两个 mysql 客户端就能演示不可重复读(把隔离级别临时改成 RC):
-- 会话 1
SET SESSION transaction_isolation = 'READ-COMMITTED';
START TRANSACTION;
SELECT qty FROM stock WHERE product_id = 1001; -- 假设 100
-- 会话 2
START TRANSACTION;
UPDATE stock SET qty = 97 WHERE product_id = 1001;
COMMIT;
-- 回到会话 1
SELECT qty FROM stock WHERE product_id = 1001; -- 97,和第一次不同
COMMIT;
改回 InnoDB 默认 RR 再跑一遍,会话 1 里两次读都是 100——但这不是免费午餐,后面会说到 gap lock 和死锁。
幻读在 RC 下也很好复现:
-- 会话 1:统计待发货
SET SESSION transaction_isolation = 'READ-COMMITTED';
START TRANSACTION;
SELECT COUNT(*) FROM orders WHERE status = 'PAID' AND ship_date IS NULL; -- 42
-- 会话 2:新订单支付完成
INSERT INTO orders (..., status) VALUES (..., 'PAID');
COMMIT;
-- 会话 1 再查
SELECT COUNT(*) FROM orders WHERE status = 'PAID' AND ship_date IS NULL; -- 43
COMMIT;
亲眼看到 COUNT 变掉,才理解为什么财务侧要求"跑报表时数字不能飘"。
这次实际怎么改的
没有一刀切升到 Serializable——那会把订单接口 latency 打爆。按场景拆:
1. 对账、报表:固定快照读
MySQL 侧对账脚本显式开 RR,并且缩短事务——只包必要的 SELECT,不在事务里做 HTTP 调用或写文件:
SET SESSION transaction_isolation = 'REPEATABLE-READ';
START TRANSACTION;
-- 批量读 stock / orders,导出 CSV
COMMIT;
PostgreSQL 报表改用 REPEATABLE READ 只读事务,或者更省事:pg_dump 一致性快照 / 从延迟从库读(接受固定延迟但换稳定数字)。我们选了只读 RR 事务,延迟可接受。
BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY;
SELECT ...;
COMMIT;
2. 下单扣库存:RC + 行锁,而不是全靠隔离级别
订单路径不需要 repeatable read 语义,需要的是别超卖。InnoDB 默认 RR 也能用 SELECT ... FOR UPDATE,但 gap lock 在热点 SKU 上更容易死锁。
Spring 里对扣库存方法单独降隔离级别:
@Transactional(isolation = Isolation.READ_COMMITTED)
public void deductStock(Long productId, int qty) {
Stock row = stockRepo.findByIdForUpdate(productId); // SELECT ... FOR UPDATE
if (row.getAvailable() < qty) {
throw new InsufficientStockException();
}
row.setAvailable(row.getAvailable() - qty);
}
FOR UPDATE 锁的是行,语义比"把整个事务升到 Serializable"清楚,冲突面也小。
3. 连接池级别别乱设
曾经有人在 HikariCP 上配了全局 transactionIsolation=TRANSACTION_REPEATABLE_READ,结果所有只读查询也背着 RR 的 gap lock。后来改成连接池默认跟随数据库,只在需要的 @Transactional 上覆盖。
# application.yml — 不在池级别硬编码隔离级别
spring:
datasource:
hikari:
# transaction-isolation 留空,用 MySQL 默认
maximum-pool-size: 20
踩过的坑
RR 不是银弹,死锁会多。 改成热点 SKU 上 FOR UPDATE 之后,监控里 LATEST DETECTED DEADLOCK 日志明显多了。InnoDB 会自动回滚其中一个事务,业务侧必须捕获死锁重试:
@Retryable(value = DeadlockLoserDataAccessException.class, maxAttempts = 3)
@Transactional(isolation = Isolation.READ_COMMITTED)
public void deductStock(...) { ... }
长事务 + RR = 锁很久。 有一版对账代码在 START TRANSACTION 和 COMMIT 之间加了 Excel 生成,事务持锁几十秒。SHOW ENGINE INNODB STATUS 里能看到锁等待飙红。原则很简单:事务里只做数据库该做的事。
别用隔离级别替代业务约束。 即使用 RR,“先查余额再扣款"如果两次读之间没有锁,照样可以超扣。该上 FOR UPDATE 或乐观锁版本号(UPDATE ... WHERE version = ?)就上,隔离级别只管"读能不能看到并发改动”,不管你的业务逻辑有没有间隙。
迁移数据库要重测,不是换个方言那么简单。 MySQL RR 和 PostgreSQL RC 的"感觉"差很多。从 MySQL 迁 PG 时我们有一批报表 SQL 没改,上线第一周就出过一次汇总偏差,根因就是默认隔离级别变了。
怎么选:一张表够用了
| 场景 | 我们用的 | 原因 |
|---|---|---|
| 普通 CRUD、单条更新 | 默认(MySQL RR / PG RC) | 不折腾 |
| 扣库存、扣余额 | RC + FOR UPDATE | 行锁语义清晰,少 gap lock |
| 对账、批次报表 | RR 只读短事务 | 同一快照内数字一致 |
| 强一致批量迁移 | Serializable 或表锁 | 极少用,只在维护窗口 |
没有"最佳隔离级别",只有你的读写模式能不能被这一档兜住。
收束
这次折腾下来,我对隔离级别的理解从"背四个英文单词"变成了三件事:
- 先复现——两个 SQL 会话比看十篇博客管用;
- 默认不等于合适——MySQL 和 PostgreSQL 的默认值不同,跨库报表要特别小心;
- 隔离级别和锁策略一起设计——扣库存靠行锁,报表靠短 RR,别指望 Serializable 一把梭。
事务隔离仍然是数据库里最绕的概念之一,但至少在我们这套订单系统里,把它从"PPT 概念"变成可配置的工程参数之后,对账差异投诉基本消失了。剩下的死锁和慢查询,是索引和事务粒度的问题——那是另一篇索引优化的活了。
版权声明: 本文首发于 指尖魔法屋-数据库事务隔离级别:这次怎么落地的(https://blog.thinkmoon.cn/post/3-database-transaction-isolation-practice/) 转载或引用必须申明原指尖魔法屋来源及源地址!
评论
使用 GitHub 账号登录后即可留言,支持 Markdown。