它是什么
并发的事务会触碰同一份数据,隔离(isolation) 就是关于一个事务能看到其他事务的多少内容的承诺。更强的隔离隐藏更多的异常,也需要更多的协调。MVCC(多版本并发控制)是标准的实现方式:不覆盖一行,而是保留多个带时间戳的版本,所以读者读到的是一个旧的、一致的版本,永远不会阻塞写者。在分布式数据库里,“哪个版本是当前的”取决于时钟,这就是混合逻辑时钟和 TrueTime 出场的地方。
Important
核心要点: 隔离是一个阶梯,不是一个开关,而且大多数数据库的默认级别弱于 serializable。MVCC 让读者和写者互不阻塞地运行;在分布式系统里,它还需要可比较的时间戳,这是一个时钟问题,靠有界不确定性的时钟来解决。
何时需要它
“用什么隔离级别,可能发生什么异常?“是一个标准的数据库追问,而”怎么在多个 shard 之间得到一致的快照?“是它的分布式版本。点出 write skew(写偏斜) 是资深的信号。
Note
面试信号: 大多数候选人知道脏读和不可重复读。描述 write skew(两个事务各自读到某个条件,各自写入的东西使对方的读失效),并指出 snapshot isolation 不能防止它,就把有经验的人和只会背书的人区分开了。
隔离阶梯
| 级别 | 防止 | 仍然允许 | 典型默认值 |
|---|---|---|---|
| Read uncommitted | 什么也不防 | 脏读(看到未提交的数据) | 少见 |
| Read committed | 脏读 | 不可重复读(一个事务里两次读之间某行变了) | PostgreSQL、Oracle |
| Repeatable read / snapshot isolation | 脏读和不可重复读;每个事务看到一个固定的快照 | Write skew 和一些幻读 | MySQL(InnoDB),以及 PostgreSQL 的”repeatable read” |
| Serializable | 以上全部:结果等价于某种一次一个的顺序 | 无(代价是中止或阻塞) | 需要主动开启 |
各个数据库的术语不尽相同(PostgreSQL 的 repeatable read 就是 snapshot isolation;MySQL 的细节有别),所以要说出保证本身,而不只是名字。
算例:write skew。 一家医院要求至少有一位医生值班。Alice 和 Bob 都在值班。每人执行:读取值班医生的数量(2),看到至少是 2,于是把自己标记为下班。在 snapshot isolation 下,两个事务看到同一个快照(count = 2),谁也没写对方的行,所以两个都提交,结果是没有医生值班。Serializable 隔离(或者对读过的行显式加锁、或一个约束)可以防止它。模式是:一个事务基于一些数据做决定,而另一个并发事务在不写同样的行的情况下改变了这些数据。
MVCC
每次写入创建该行的一个新版本,并打上事务的时间戳,而不是原地更新。读者按它的时间戳读取:对每个 key,取不晚于它的最新版本。
| 属性 | 效果 |
|---|---|
| 读者永不阻塞写者,写者也永不阻塞读者 | 长报表不会卡住写入 |
| 一致的快照是免费的 | 一个事务在它所有的读里看到同一个时间点 |
| 写者只在真正的写-写冲突上冲突 | 对同一行的并发写入:一个等待或中止 |
| 旧版本会累积 | 一个后台进程(vacuum、垃圾回收)移除没有任何事务还能看到的版本 |
为什么在分布式数据库里用 MVCC 而不用锁? 在单机上,读锁很便宜。跨机器时,每把锁都是一次网络对话,每个读写冲突都是一次跨节点的停顿。MVCC 把”100 个 range 的一致快照”变成”在时间戳 T 读每个 range”,读者和写者之间不需要任何协调。
跨 shard 的事务
写入多个 shard 的事务需要一个原子的决定。一种生产模式是在共识之上叠加两阶段提交:
- 第一次写入创建一个临时版本(write intent),并在同一个 shard 上创建一个
PENDING的事务记录。 - 对其他 shard 的写入创建指回事务记录的 intent。每个 intent 本身都是经共识提交的。
- 提交就是一次写入:把事务记录翻转成
COMMITTED。在那个瞬间,事务被持久地、原子地决定了。 - 客户端得到确认;intent 被异步地重写为普通版本。遇到 intent 的读者会检查事务记录并就地解析它。
因为协议状态(intent、投票、决定)全都存放在复制的 shard 里,任何一台机器的死亡都不会让系统陷入不确定。这条记录就是这个事务。
时钟:让时间戳可比较
“不晚于 T 的最新版本”,只有当时间戳在各机器之间可比较时才有意义,而完全同步的时钟是不存在的(NTP 把时钟保持在大约几毫秒内,而不是微秒)。
| 方式 | 思路 | 代价 |
|---|---|---|
| TrueTime(Spanner) | 硬件(GPS 和原子钟)把时钟不确定性限定在几毫秒;系统在提交时等待不确定性过去,让时间戳顺序与真实时间的顺序一致 | 需要特殊硬件;每次提交都要付出等待 |
| 混合逻辑时钟(HLC) | 一个结合了物理时间和逻辑计数器的时间戳,加上一个不确定性窗口;读到落在其不确定性窗口内的版本的读会以更高的时间戳重启 | 不需要特殊硬件;只在真正接近时才付出代价 |
| 时间戳 oracle | 一个服务发放递增的时间戳 | 一个单一瓶颈,但简单 |
同一个不变量,付出方式不同:TrueTime 在每次提交时付出,HLC 只在两个事件靠得很近时付出。
Follower 读。 一个 range 的 leader 会定期发布一个 closed timestamp:承诺不会再有任何新写入落在 T 或之前。之后任何 follower 都可以在本地服务不晚于 T 的读。这让远程区域的读成为快速且略微过期的,而且是按契约如此,这是复制延迟的一种有纪律的版本。
Warning
生产实践: 在 serializable 隔离下,冲突会以客户端可见的重试(中止)形式出现。应用必须捕获可重试的错误并重新运行事务,因此事务必须是幂等的。更高的隔离不是免费的;它把成本从异常转移到了重试上。
如何选择
| 需求 | 选择 | 原因 |
|---|---|---|
| 默认的 OLTP,容忍某些异常 | Read committed 或 snapshot isolation | 开销低 |
| 跨多行的不变量(至少一人值班、不能透支) | Serializable,或锁住读过的行,或一个约束 | 防止 write skew |
| 不阻塞写入的长报表 | MVCC 快照读 | 读者永不阻塞 |
| 跨 shard 的一致快照 | 带时间戳的 MVCC 读 | 在 T 时刻读每个 shard |
| 跨 shard 的原子写入 | 叠加在共识之上的 2PC | 没有哪个协调者的死亡会让任何人陷入僵局 |
| 远程区域的读,可接受过期 | 在 closed timestamp 上做 follower 读 | 本地读,过期有界 |
Caution
常见错误: 以为”repeatable read”或”snapshot”就等于 serializable。它们防止了人们熟悉的那些异常,却仍然允许 write skew。如果一个正确性不变量跨越多行,要说出你需要的级别,以及你怎么强制它。
相关
- 演练:设计分布式数据库 — 在一个设计里同时用到 MVCC、write intent 和 follower 读。
- 演练:设计限时抢购系统 — 极端写争用下的跨行不变量(不能超卖)。
- 参考:分布式事务模式 — 2PC、saga,以及何时放弃跨服务的原子性。