事务隔离级别与 MVCC¶
30 秒版(开场)¶
InnoDB 默认 REPEATABLE READ + MVCC。普通一致性读复用快照,不会因为别的事务新插入而出现新的结果行;锁定读/DML 读取当前可用版本并加锁,范围条件通常用 next-key lock(记录+间隙) 阻止并发插入。不要把两种读法都笼统解释成“RR 靠间隙锁防幻读”。生产关键词:长事务、undo 链、间隙锁死锁。
3 分钟版(精讲深度)¶
- 是什么:事务 ACID 中 I(隔离)由隔离级别定义;MVCC 用 undo log 多版本 + Read View 实现非锁定一致性读。
- 为什么:读写互斥锁吞吐低;快照读提高并发;写仍需锁保证正确性。
- 怎么做:RR/RC 的选择取决于语义和锁冲突;余额正确性来自条件更新、锁和事务不变量,不是“银行就选某个隔离级别”。避免长事务撑大 undo;高并发范围写需评估 next-key lock。
10 分钟版(原理 + 图示)¶
隔离级别现象
| 级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | 可能 | 可能 | 可能 |
| READ COMMITTED | 否 | 可能 | 可能 |
| REPEATABLE READ | 否 | 否 | 一致性读靠固定快照;锁定范围读通常靠 next-key lock |
| SERIALIZABLE | 否 | 否 | 否 |
flowchart LR
Read[普通 SELECT] --> MVCC[Read View 快照读]
LockRead[SELECT FOR UPDATE] --> XLock[X 锁 / Next-Key]
Write[UPDATE/DELETE] --> XLock
MVCC --> Undo[undo log 版本链]
MVCC:每行隐藏列 DB_TRX_ID、DB_ROLL_PTR;Read View 判定版本可见性。RR 默认在事务内第一次一致性读时建立快照并复用(也可显式以 consistent snapshot 开始);RC 每次一致性读建立新快照。锁定读不是复用这个历史快照。
当前读与锁:FOR UPDATE、FOR SHARE(旧写法 LOCK IN SHARE MODE)和 DML 会读取当前可用版本并加锁;Record Lock 锁索引记录,Gap Lock 锁间隙,Next-Key = 记录+前方间隙,用于阻止锁定范围内插入。唯一索引等值命中通常只需记录锁;范围扫描的锁范围取决于实际使用的索引。RC 通常禁用 gap locking,但外键和重复键检查等仍有例外。
生产场景¶
- 余额扣减:
BEGIN; SELECT balance FROM account WHERE id=1 FOR UPDATE; UPDATE ...当前读防并发超扣。 - 报表统计:RC 的不同语句可能看到其各自语句开始前已提交的新数据;RR 的普通一致性读默认复用第一次一致性读建立的快照。
- 批量删
WHERE status=0 LIMIT 1000:RR 下可能 gap lock 相邻范围,与插入死锁——改 RC 或小批次。
排查与工具¶
| 工具 | 用途 |
|---|---|
SHOW ENGINE INNODB STATUS |
死锁、锁等待 |
information_schema.innodb_trx |
长事务 trx_started |
performance_schema.data_locks |
8.0 锁详情 |
SELECT @@transaction_isolation |
当前级别 |
路径:死锁日志 → 两事务 next-key 冲突 → 是否 RR + 范围更新 → 改索引精确行或降 RC。
架构取舍¶
| 方案 | 适用 | 不适用 |
|---|---|---|
| RR 默认 | 通用 OLTP | 高并发插入+范围查 |
| RC | 减少间隙锁、Oracle 习惯 | 需防不可重复读 |
| 乐观锁 version | 读多冲突少 | 高冲突扣库存 |
| 悲观锁 FOR UPDATE | 强一致扣减 | 长临界区 |
| 分库分表 | 锁粒度缩小 | 跨库无 MVCC |
深挖问答¶
- 快照读和当前读? → 普通 SELECT vs 锁定读/DML。
- RR 如何避免幻读? → 快照读 MVCC;当前读 next-key lock。
- undo log 作用? → 回滚、MVCC 旧版本;长事务阻止 purge 致空间膨胀。
- 幻读例子? → 事务 A 两次
SELECT COUNT(*)之间 B 插入满足条件行。 - Go sql.Tx 隔离? → 可请求
db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead}),但驱动/数据库是否支持该级别、如何映射以及不支持时返回什么错误都要实测,不能只看 Go 常量。
反模式与事故¶
- 事务内调 HTTP 30s——锁/Read View 持有,阻塞 purge 与别的事务。
- 无合适索引的
UPDATE会扫描并锁住大量记录/间隙,效果可能接近“整表被堵”,但不是简单的一把 table lock。 - 把“RR 防幻读”背成所有读法完全相同:一致性读靠固定快照,锁定读靠 next-key lock;混用快照读与当前读时要明确各自语义。
- 误以为 GORM 会把整个 HTTP 请求自动包成一个事务——默认事务主要保护单次 create/update/delete;多步业务不变量仍需显式事务、超时和隔离策略。
代码示例¶
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return err
}
defer tx.Rollback()
var balance int64
err = tx.QueryRowContext(ctx,
"SELECT balance FROM account WHERE id = ? FOR UPDATE", id).Scan(&balance)
// 检查余额后 UPDATE,Commit