44.10 事务与隔离级别的实现 (锁 / MVCC)
一句话
隔离级别的实现只由两件事决定:快照从哪一刻取(事务级还是语句级)与冲突什么时候检测(写时还是提交时)——RC 用语句级快照,RR/SI 用事务级快照,串行化额外检测“读-写”冲突。
MVCC 的三件套
| 组件 | 作用 | 没有它会怎样 |
|---|---|---|
| 版本链 | 每行保留多个版本(commit_ts + 值) | 读写互斥,只能靠锁 |
| 快照 / Read View | 读的时候“看见到哪个时刻为止的提交” | 读到未提交或读到未来 |
| 冲突检测 / 回滚 | 提交时发现“我写的东西被别人改过” | 丢失更新、写偏斜 |
package main
import (
"fmt"
"sort"
"sync"
)
type ver struct {
commitTS int64
val string
}
type store struct {
mu sync.Mutex
data map[string][]ver
ts int64
}
func newStore() *store { return &store{data: map[string][]ver{}} }
// 在 ts 时刻提交一个版本
func (s *store) commitAt(writes map[string]string) int64 {
s.mu.Lock()
defer s.mu.Unlock()
s.ts++
for k, v := range writes {
s.data[k] = append(s.data[k], ver{s.ts, v})
}
return s.ts
}
// 快照读:只看 commitTS <= snapshot 的最新版本
func (s *store) readAt(k string, snapshot int64) (string, bool) {
s.mu.Lock()
defer s.mu.Unlock()
vs := s.data[k]
for i := len(vs) - 1; i >= 0; i-- {
if vs[i].commitTS <= snapshot {
return vs[i].val, true
}
}
return "", false
}
// 快照隔离下的提交:写-写冲突检测(first-committer-wins)
func (s *store) commitSI(snapshot int64, writes map[string]string) (int64, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for k := range writes {
for _, v := range s.data[k] {
if v.commitTS > snapshot {
return 0, false // 我快照之后有人改过这个 key
}
}
}
s.ts++
for k, v := range writes {
s.data[k] = append(s.data[k], ver{s.ts, v})
}
return s.ts, true
}
func main() {
// ---------- ① 不可重复读:RR 用事务快照,RC 用语句快照 ----------
s := newStore()
s.commitAt(map[string]string{"x": "v1"})
rrSnapshot := s.ts // T1 开始(RR:整个事务用这个快照)
s.commitAt(map[string]string{"x": "v2"}) // T2 改了并提交
rr, _ := s.readAt("x", rrSnapshot)
rc, _ := s.readAt("x", s.ts) // RC:每条语句取“当前已提交”
fmt.Printf("① 不可重复读 RR 读到 %s,RC 读到 %s(数据库实际是 v2)\n", rr, rc)
// ---------- ② 幻读:快照读看不到新插入的行 ----------
s2 := newStore()
s2.commitAt(map[string]string{"user:1": "a"})
snap := s2.ts
s2.commitAt(map[string]string{"user:2": "b"}) // 别的事务插入
var oldKeys, newKeys []string
for k := range s2.data {
if v, ok := s2.readAt(k, snap); ok && v != "" {
oldKeys = append(oldKeys, k)
}
if _, ok := s2.readAt(k, s2.ts); ok {
newKeys = append(newKeys, k)
}
}
sort.Strings(oldKeys)
sort.Strings(newKeys)
fmt.Printf("② 幻读(快照隔离)旧快照看到 %v,当前能看到 %v\n", oldKeys, newKeys)
// ---------- ③ 写偏斜:两个事务各读各写,SI 不检测 ----------
s3 := newStore()
s3.commitAt(map[string]string{"x": "1", "y": "1"})
snap3 := s3.ts
// T1:读 y=1,写 x=0
y1, _ := s3.readAt("y", snap3)
_, ok1 := s3.commitSI(snap3, map[string]string{"x": "0"})
// T2:读 x=1(旧快照),写 y=0
x1, _ := s3.readAt("x", snap3)
_, ok2 := s3.commitSI(snap3, map[string]string{"y": "0"})
fx, _ := s3.readAt("x", s3.ts)
fy, _ := s3.readAt("y", s3.ts)
fmt.Printf("③ 写偏斜:T1 读到 y=%s 写 x=0(提交=%v),T2 读到 x=%s 写 y=0(提交=%v)\n", y1, ok1, x1, ok2)
fmt.Printf(" 最终 x=%s y=%s —— 两个事务写的是不同的行,SI 的写-写检测抓不到\n", fx, fy)
// ---------- ④ 丢失更新:两个事务写同一行,第二个被拒 ----------
s4 := newStore()
s4.commitAt(map[string]string{"balance": "100"})
snap4 := s4.ts
_, okA := s4.commitSI(snap4, map[string]string{"balance": "80"})
_, okB := s4.commitSI(snap4, map[string]string{"balance": "60"})
fmt.Printf("④ 丢失更新:先提交者=%v,后提交者=%v(第二个被写-写冲突拒绝)\n", okA, okB)
}
本机实测(macOS,Apple M1,Go 1.27):
① 不可重复读 RR 读到 v1,RC 读到 v2(数据库实际是 v2)
② 幻读(快照隔离)旧快照看到 [user:1],当前能看到 [user:1 user:2]
③ 写偏斜:T1 读到 y=1 写 x=0(提交=true),T2 读到 x=1 写 y=0(提交=true)
最终 x=0 y=0 —— 两个事务写的是不同的行,SI 的写-写检测抓不到
④ 丢失更新:先提交者=true,后提交者=false(第二个被写-写冲突拒绝)
读数:
| 场景 | 结果 | 说明 |
|---|---|---|
| 不可重复读 | RR 读到 v1、RC 读到 v2 | 差别只在“快照是事务级还是语句级” |
| 幻读 | 旧快照只有 user:1,当前有 user:1,user:2 | 快照隔离下“读不到新行”= 用 MVCC 消除了幻读,但代价是可能读到旧世界 |
| 写偏斜 | 两个事务都提交,最终 x=0 y=0 | SI 只检测“写同一行”,不检测“读写交叉” |
| 丢失更新 | 后提交者被拒 | 写-写冲突检测生效(first-committer-wins) |
🔑 快照隔离(SI)防住了丢失更新与脏读,但防不住写偏斜——写偏斜需要串行化快照隔离(SSI,检测“读写依赖环”)或显式加锁。这就是“RR 够用”这句话的危险之处:它在多数业务里够用,在“约束跨两行”的场景(余额、库存、名额)里不够。
四种隔离级别的实现差异
| 级别 | 快照粒度 | 写冲突检测 | 读是否加锁 | 仍可能出现 |
|---|---|---|---|---|
| 读未提交 | 无(读最新) | 无 | 无 | 脏读 |
| 读已提交 RC | 每条语句 | 写时(行锁) | 无 | 不可重复读、幻读 |
| 可重复读 RR | 每个事务 | 写时(行锁 + 间隙锁) | 无(快照读) | 写偏斜(若用 SI 实现) |
| 串行化 | 事务 | 读写都检测(SSI/2PL) | 可能 | 只有串行化冲突时回滚 |
⚠️ 同样是“RR”,MySQL InnoDB 用“快照读 + 间隙锁”,PostgreSQL 的 RR 实际是 SI(不阻止写偏斜);Oracle 的“可串行化”是 SI + SSI。名字一样,语义不一样——跨库迁移时必须看文档,而不是看级别名。
反模式
| 反模式 | 后果 |
|---|---|
| 以为 RR 就不会有并发问题 | 写偏斜照样发生(本节③) |
| RC 下先查后改,不做条件更新 | 检查与更新之间有窗口,超卖 |
| 长事务持有快照 | undo 无法回收,版本链膨胀,查询越来越慢 |
用 SELECT ... FOR UPDATE 代替一切 | 锁竞争、死锁率上升(44.11) |
| 把“读已提交”当“没有脏读就够了” | 业务逻辑可能在两条语句之间看到不同世界 |
| 跨库迁移不看隔离级别实现 | 从 MySQL 迁到 PG,写偏斜从“不发生”变成“偶发” |
自检
Q1 为什么“可重复读”能消除不可重复读,却不能消除写偏斜?
▸ 答案:可重复读保证“同一个事务里读到的快照不变”,所以两次读同一行结果一致。但写偏斜的问题是“两个事务读的是不同行、写的是不同行”:T1 读 y 写 x,T2 读 x 写 y,两个事务没有写同一行,写-写冲突检测(which 只看被写的行)永远抓不到。要防它,必须在“读”上也建立依赖检测(SSI)或对读到的行加锁(SELECT ... FOR UPDATE)。
Q2 RC 与 RR 在高并发下怎么选?
▸ 答案:RC 的锁持有时间短、undo 回收快、并发度高,但同一事务内可能读到不同版本,业务要能容忍“不可重复读”。RR 提供事务级一致性视图,适合“一个事务内多次读同一批数据要给出一致结论”的场景(报表、对账),但长事务会拖住 undo 回收。多数互联网业务默认 RC,把“需要一致性”的部分用单条 SQL(带条件更新)或显式锁解决。
Q3 写偏斜的经典例子是“两个医生同时值班,各自请假”,为什么加一个 SELECT ... FOR UPDATE 就能解决?
▸ 答案:因为写偏斜的根源是“读的时候没锁”。两个事务各自读到“还有 2 人在岗”,然后各自把自己设为离岗。如果 SELECT count(*) ... FOR UPDATE 对“在岗记录”加锁,第二个事务会等第一个提交,重新读到“只剩 1 人”,从而拒绝请假。代价是串行化:所有修改值班状态的事务排队。另一种解法是把约束物化(比如“值班人数”单独一行,用条件更新 SET n=n-1 WHERE n>1),把跨行约束变成单行原子操作。
Q4 用 RC(读已提交)+ 行锁,为什么仍然可能死锁?
▸ 答案:因为死锁来自 “加锁顺序不一致”,与隔离级别无关:两个事务分别先锁 A 再锁 B、先锁 B 再锁 A,就会互相等待。RC 只是减少间隙锁、降低概率,不消除环路。解法是统一加锁顺序、缩短事务、并接受 “死锁检测 + 重试”。
Q5 隔离级别与 “重试 + 幂等” 怎么配合?
▸ 答案:高隔离级别(RR/Serializable)会把冲突变成失败(死锁、序列化失败),应用必须能重试;重试要求操作幂等(否则重复扣款)。所以 “提高隔离级别” 的前提是 “业务可重试 + 幂等键”,否则只是把并发问题变成用户可见的错误。