cow - mvcc

10. 写时复制与多版本:fork、MVCC 与不可变

0. 本章先解决什么问题

很多场景需要一份”不动的数据”:备份要一个时间点的快照,读操作想不加锁也不被写打扰,共享数据想安全地发给别人。老实拷贝一份当然不动了,但拷贝太贵。这一族机制的思路:

先共享,不拷贝;谁要写,谁在写的那一刻付拷贝的钱。
读方永远看到一个完整的时间点视图,写方的代价按需支付。

它有两种形态,本章各讲透一半:

  • 写时复制 COW:写的时候把数据拷一份改,改完替换(fork、COW List)
  • 多版本 MVCC:写的时候把旧值留在版本链上,读方按自己的时间点挑版本

写时复制与多版本的通用结构

这张图怎么读

左边是 COW 形态:读方共享原数据,写方拷一份修改后替换。右边是多版本形态:每次写在链上挂一个新版本,不同读方按各自的时间点看不同版本。下方是四个主要实例。

1. Linux fork 的写时复制:一切的原型

fork 系统调用复制出一个子进程,语义上子进程拥有父进程内存的完整副本。真拷贝几个 GB 的内存要几秒,实际的实现是骗术:

  • fork 时:只复制页表(第 2 章的翻译表),
    父子的页表指向同一批物理页,所有页标成只读
    -> fork 本身只花”拷页表”的时间,毫秒级
  • 之后:任何一方写某页 -> 触发缺页异常(写只读页)
    -> 内核当场把那一页拷一份,两边页表各指各的,恢复可写
    -> 只有真被写到的页才付拷贝钱,没写的永远共享

这就是第 2 章缺页四种结局里的第三种。fork 因此把”要一份完整内存快照”的成本从”内存总量”降到”快照期间的写入量”,下一节 Redis 把这个便宜用到了极致。

2. Redis 的 fork 快照:便宜的代价要算清

2.1 谁在用 fork

BGSAVE 生成 RDB、AOF 重写、全量复制的 RDB 生成(第 4、5 章都见过),全靠同一招:

fork 子进程 -> 子进程眼里的内存被”冻结”在 fork 那一刻
(它只读不写,看到的就是快照点视图)
父进程继续服务,写到哪页哪页触发 COW 拷贝

2.2 两笔要算的账

  • 账一:fork 那一瞬的停顿。
    拷页表的耗时和实例内存成正比,10 GB 实例的页表就有约 20 MB,
    fork 瞬间主线程停顿几十到几百毫秒
    (第 1 章”偶发一批请求同时超时”的元凶名单里它排前排)
  • 账二:快照期间的内存膨胀。
    父进程每写一页就实际多占一页,写入越猛、快照拖越久,
    膨胀越狠,最坏理论翻倍。
    maxmemory 配到物理内存 90% 又赶上高峰期 BGSAVE,
    OOM killer 直接送走整个实例,这是 Redis 生产事故的经典剧本

2.3 对应的纪律

  • 大实例拆小:fork 成本和实例大小线性相关,
    10 GB 一个不如 2 GB 五个(这也是集群化的隐藏收益)
  • 错峰:快照安排在写入低谷;主库关持久化、从库做持久化也是常见分工
  • 留内存余量:maxmemory 别顶着物理内存配,给 COW 留出膨胀空间
  • 关透明大页:THP 开着时 COW 的粒度从 4 KB 变 2 MB,
    写一个字节拷 2 MB,膨胀和延迟双双放大,
    Redis 启动日志里那条 THP 警告说的就是这件事

3. CopyOnWriteArrayList:COW 在集合里的形态

代码块JAVA · 11 行收起展开
// 写路径: 加锁,整个数组拷一份,改完替换引用
public boolean add(E e) {
    synchronized (lock) {
        Object[] es = getArray();
        es = Arrays.copyOf(es, es.length + 1);  // 全量拷贝
        es[es.length - 1] = e;
        setArray(es);                            // volatile 引用一换
        return true;
    }
}
// 读路径: 完全无锁,拿到哪个数组读哪个

三个语义后果,正反都要记:

  1. 读写互不阻塞: 读永远读某个完整数组,写在副本上折腾
  2. 迭代器是快照: 创建时抓住当时的数组引用,
    遍历期间的增删一概看不见,也永远不抛 ConcurrentModificationException
    (对照 ArrayList 迭代中修改直接炸,这里是”炸都不炸,就是旧”)
  3. 弱一致: add 完立刻迭代,旧迭代器里没有它
    适用判据一句话: 读多写极少 + 容忍读到旧值。
    监听器列表、路由配置这类”月改动、秒读取”的数据是主场
    反噬同样明显: 每次写 O(n) 拷贝 + 双倍瞬时内存 + GC 压力,
    大列表高频写用它等于自杀

源码细节在 CopyOnWriteArrayList

4. MySQL MVCC:多版本形态的完整机器

MVCC 是”读不阻塞写、写不阻塞读”的全部来源,把它拆成三个零件。

4.1 零件一:隐藏列和 undo 版本链

InnoDB 每行末尾藏着两列:

trx_id: 最后修改这行的事务编号
roll_ptr: 指向 undo log 里这行的上一个版本
每次更新: 旧版本进 undo,新行的 roll_ptr 指过去
-> 一行的历史像链表一样串在 undo 里,链头是最新版

4.2 零件二:ReadView,一张”拍照时刻”的活跃名单

事务做快照读时生成 ReadView,四个字段:

  • m_ids:拍照瞬间还活跃(未提交)的事务编号集合
  • min_trx_id:活跃里最小的
  • max_trx_id:下一个将要分配的编号
  • creator_trx_id:我自己的编号

4.3 零件三:可见性判定,沿链找我该看的版本

拿着 ReadView 从链头往下走,对每个版本的 trx_id 判定:

trx_id == creator_trx_id -> 我自己改的,可见
trx_id < min_trx_id -> 拍照前就提交了的老事务,可见
trx_id >= max_trx_id -> 拍照后才开始的新事务,不可见
min <= trx_id < max:
在 m_ids 里 -> 拍照时还没提交,不可见
不在 m_ids 里 -> 拍照前已提交,可见
不可见就沿 roll_ptr 走向更老的版本,直到找到可见的一版

4.4 隔离级别的差异 = 拍照时机的差异

读已提交 RC: 每条语句拍一张新照
-> 别人提交了,我下一句就看见(所以有不可重复读)
可重复读 RR: 事务第一条快照读拍一张,全程用它
-> 别人怎么提交我都看拍照时刻的世界(可重复读成立)
快照读 vs 当前读的边界要立牢:
普通 SELECT 走上面的版本链(快照读,不加锁)
增删改、SELECT … FOR UPDATE 是当前读,
读最新版本并加锁,RR 下还要加间隙锁挡幻读
(锁的地盘在 ,此处只划边界)

4.5 多版本的账单:长事务

版本不是免费仓库。一个事务开着不提交,它的 ReadView 就钉着一个很老的时间点,undo 里所有比它新的旧版本都不能清理:

  • 长事务危害三连:undo 膨胀(磁盘涨)、
    版本链变长(每次快照读都要沿链走更远,查询变慢)、
    还顺手阻塞 DDL
  • 监控:information_schema.innodb_trx 里跑了几小时的事务,
    多半是忘了提交的连接或巨型批处理

MVCC 的完整语境在 事务

5. 不可变:把”写时复制”推到极限的静态形态

COW 是”写时才拷”,不可变是”干脆禁止写”,每次修改都必须产生新对象:

String / Integer / BigDecimal: 不可变,所以能被无限共享、
能进上一章的常量池、能安全做 HashMap 的 key
(可变 key 改了字段,hashCode 变了,存进去的桶就找不回来了)
final 字段 + 不可变对象是并发共享的第一选择:
不用锁、不用 volatile,安全性来自”根本没有写”
(final原理 讲它的内存语义)
代价同 COW: 高频”修改”= 高频新对象。
循环里 String 拼接产生 O(n) 个中间对象,
StringBuilder 就是给这个反噬打的补丁(可变的工作区,最后定型)

放大到存储系统,“不可变文件”是同一思想:第 4 章 LSM 的 SSTable、Kafka 的段文件、ES 的 Lucene 段,全是写成之后永不原地改,更新靠追加新文件加后台合并。不可变让并发读、缓存、复制全部免锁,代价统一转嫁给合并(compaction)。

6. 一张对照表收拢

实例形态拷贝/版本的粒度谁付钱反噬
fork + COW写时复制4 KB 页快照期间的写方内存尖刺、fork 停顿
COW List写时复制整个数组每个写操作写 O(n)、GC 压力
MVCC多版本写方记 undo,读方走链长事务版本堆积
不可变对象禁止写整个对象每次”修改”高频改产生对象洪流
不可变文件追加+合并文件/段后台 compaction写放大、空间放大

共同结构一句话:读方拿到一个时间点的一致视图,写方付复制或版本的钱;账单的形状(尖刺、堆积、洪流)由粒度和写频率决定。

7. 联系实际:排查清单

  • 现象:Redis 高峰期内存突然接近翻倍,甚至被 OOM kill

  • 方向:BGSAVE/重写撞上写高峰,COW 膨胀;查快照时间点和写入曲线

  • 现象:Redis 每次 BGSAVE 瞬间慢查询集中出现

  • 方向:fork 拷页表的停顿,和实例大小成正比;拆实例或错峰

  • 现象:MySQL 磁盘涨得比数据快,查询越来越慢

  • 方向:查长事务,undo 清不掉、版本链变长

  • 现象:RR 事务里明明别人提交了,我查还是旧值

  • 方向:机制本身,ReadView 钉在第一条快照读;要看新值就当前读或重开事务

  • 现象:服务里一个 List 读性能极好但写一下卡一下,老年代涨

  • 方向:COW List 被用在了高频写场景,换 ConcurrentLinkedQueue 或加锁集合

  • 现象:循环字符串拼接把接口拖慢

  • 方向:不可变的对象洪流,StringBuilder

8. 学完本章你能解决什么问题

  1. COW 和多版本的共同结构是什么,两种形态差在哪?
  2. fork 为什么毫秒级就能”复制”几个 GB,代价押后到了哪里?
  3. BGSAVE 的两笔账怎么算,四条纪律各对哪笔账?
  4. THP 为什么会放大 COW 的代价?
  5. COW List 的迭代器为什么不抛 CME,代价是什么语义?
  6. 版本链、ReadView 四字段、可见性判定怎么配合出”读不阻塞写”?
  7. RC 和 RR 的差异为什么可以归结成一句”拍照时机”?
  8. 长事务的三连危害是什么,去哪查?
  9. 不可变对象为什么能免锁共享,StringBuilder 在补什么反噬?
  10. SSTable、Kafka 段、Lucene 段共享的”不可变文件”思想换来了什么?

核心一句话:先共享后按需付钱,读方永远看一个完整时间点;COW 把钱付在写的瞬间,MVCC 把钱记在版本链上,不可变干脆禁止写;账单的尖刺、堆积、洪流三种形状,决定了每个实例的适用边界。

延伸阅读