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)会把冲突变成失败(死锁、序列化失败),应用必须能重试;重试要求操作幂等(否则重复扣款)。所以 “提高隔离级别” 的前提是 “业务可重试 + 幂等键”,否则只是把并发问题变成用户可见的错误。

关联