double - buffer

16. 双空间轮换:S0/S1、双 dict 与蓝绿部署

0. 本章先解决什么问题

空间换时间是个大家族,系列里其实到处是它的成员:缓存拿内存换访问延迟(第 3 章)、索引拿磁盘换查找次数(第 2 章)、副本拿三倍存储换可用和读扩展(第 5 章)、池拿闲置资源换创建开销(第 9 章)、冗余字段拿双写换跨片查询(第 7 章)。本章讲这个家族里最精巧的成员,它在 JVM 的 survivor 区和 Redis 的双 dict 里长得几乎一样:

双空间轮换:
现役空间照常提供服务,
备用空间里完成一件昂贵的事(整理、重建、迁移、升级),
完成后原子地互换角色,旧空间清空待用。
买到的东西: 昂贵操作全程不打断服务,切换瞬间完成。
付出的东西: 一份备用空间的钱。

从字节尺度的 survivor 区,到文件、表、索引、进程、整套环境,这个骨架逐级放大,本章按尺度从小到大逐个拆。

双空间轮换的通用骨架

这张图怎么读

中间是骨架四步:备用空间重建、增量追平、原子切换、旧空间回收。上下两排是六个尺度的实例,每个都标出它的”切换动作”是什么:换指针、rename、改别名、切流量。

1. JVM 新生代的 S0/S1:复制算法完整拆解

1.1 为什么新生代偏偏用复制

垃圾回收的三种基础算法里(标记清除、标记整理、复制),新生代选了看起来最浪费的复制,依据是一条统计事实:

绝大多数对象朝生夕灭,一次 Minor GC 后存活的常常不到 10%。
标记清除: 要处理 90% 的尸体,还留一地碎片
标记整理: 要把存活对象在原地挪动压实,指针修正成本高
复制: 只搬 10% 的活人去新空间,剩下 90% 整块空间直接清零
-> 干的活和存活量成正比,存活越少越划算
附送: 搬过去是紧挨着放的,天然无碎片,
分配新对象只需要移动一根指针(指针碰撞,分配快到几条指令)

1.2 8:1:1:备用空间的钱怎么省到 10%

朴素复制算法要对半分空间,一半现役一半备用,50% 的常驻税太狠。HotSpot 用同一条统计事实把税砍到 10%:

新生代切成三块: Eden : S0 : S1 = 8 : 1 : 1(SurvivorRatio=8)
日常分配只用 Eden + 其中一块 survivor(合计 90% 可用)
另一块 survivor 空着当备用(10% 的税)
敢这么切的依据: 存活对象通常装得进 10% 的空间

1.3 一次 Minor GC 的完整流程

触发: Eden 满了

  1. 从 GC Roots 出发标记 Eden 和 from survivor 里的存活对象
  2. 把存活对象复制进 to survivor,每个对象的年龄加 1
    (年龄记在对象头 mark word 里,只有 4 个 bit,
    所以晋升阈值最大只能是 15,第 2 章对象头布局的伏笔在此兑现)
  3. 年龄到达阈值的不进 to,直接晋升老年代
    动态年龄判定: 不死等 15,to 里同龄对象的总大小
    超过 survivor 一半时,大于等于该年龄的整批提前晋升
  4. Eden 和 from 整块清空
  5. from 和 to 指针互换,本次的 to 成为下次的 from
    (轮换的核心动作: 没有第二次搬运,改两根指针角色就换完了)
    兜底: to 装不下这次的存活对象(存活率意外飙高),
    空间分配担保让老年代直接收下装不下的部分,
    担保也失败就触发 Full GC

1.4 为什么老年代不这么玩

复制算法的账单和存活率绑定:老年代的对象存活率高,复制等于每次搬运大半个堆,还要常备 50% 的备用空间。所以老年代换标记整理(Parallel、G1 的混合回收)或标记清除(CMS,碎片问题在第 14 章 GC 链里算过账)。同一个 JVM 里两代用两种算法,选型依据就是”存活率决定复制划不划算”这一条。

1.5 辨析:标记整理到底用不用两块空间

一个容易记混的点,值得单独钉死:

经典标记整理(Serial Old / Parallel Old 的 Full GC):
原地滑动压实,只有一块空间,没有 from/to。
流程: 标记存活 -> 计算每个对象压实后的新地址
-> 修正所有引用 -> 把对象滑向一端
代价是多趟扫描全程 STW,换来的正是”零备用空间税”,
它和复制算法的分工就是”付趟数还是付空间”
现代收集器的”整理”(G1 / ZGC / Shenandoah):
实现上是 region 之间的复制转移(evacuation):
把回收集里的存活对象整体复制到空闲 region,原 region 清空
-> 局部看就是复制算法,from/to 以 region 为粒度轮换
-> 所以 G1 必须常备一批空闲 region 当 to 空间
(预留比例默认 10%,和 survivor 的 10% 遥相呼应),
预留耗尽就是转移失败(evacuation failure),退化成
单线程 Full GC 收场,生产大忌
一句话: 宏观叫整理,微观是原地滑动还是跨区复制,两回事;
跨区复制的收集器,双空间的税一分都没少交

算法各论在 垃圾回收算法

2. Redis 的双 dict:同一骨架的渐进版

第 12 章从摊销角度拆过渐进式 rehash,这里换双空间视角对齐骨架:

ht[0] 现役表照常服务,ht[1] 按新容量建好当备用
搬迁分两个驱动源逐桶推进(每次操作顺路搬 + 定时任务补刀)
期间读查两表、写只进 ht[1](保证 ht[0] 只减不增,迁移必收敛)
搬完: ht[1] 转正成 ht[0],旧表释放
(又是改指针换角色,和 from/to 互换同一个动作)

和 S0/S1 摆在一起,差异只有一处,而且由各自的约束决定:

  • S0/S1:一次性搬完。GC 本来就在 STW 里,没有”服务不能停”的约束,
    一口气搬完反而简单
  • 双 dict:渐进搬。Redis 的事件循环停一秒就是事故(第 1 章铁律),
    所以把搬迁摊碎,代价是两表并存期每个操作都背双表查找
  • 判据:重建期间服务能不能停。能停就一次搬完,
    不能停就渐进搬,渐进就必须回答”新写入进哪边”
    (两家答案一致: 只进新空间,旧空间只出不进,保证收敛)

3. 文件尺度:AOF 重写和所有”临时文件加 rename”

第 4 章讲过 AOF 重写的机制,双空间视角下它是文件级轮换:

旧 AOF 继续追加(现役),fork 子进程把当前数据集
写成一个临时新文件(备用,写时复制让备用空间免费起步,第 10 章)
期间的新写命令进重写缓冲(这就是骨架里的”增量追平”零件)
新文件写完 + 追平增量 -> rename 原子替换旧文件

rename 是这一尺度的原子切换点:POSIX 保证同文件系统内 rename 原子生效,读者要么看到完整的旧文件要么看到完整的新文件,不存在中间态。
RDB 的 bgsave 同款(先写 temp 文件再 rename)、配置文件的安全更新同款(写临时文件、fsync、rename),“永远不原地改文件”是这一尺度的通用纪律。

4. 表和索引尺度:影子表 DDL 与 reindex

4.1 MySQL 大表在线改结构:影子表方案

大表直接 ALTER 的风险:长时间锁表或长事务拖垮复制。gh-ost、pt-osc 这类工具的方案是表级双空间:

  1. 建影子表: 和原表同构的新表,在它身上执行 DDL(改的是空表,瞬间完成)
  2. 存量拷贝: 分批把原表数据拷进影子表(第 11 章的批处理,控速防压垮)
  3. 增量追平: 拷贝期间原表还在被写,
    pt-osc 用触发器把原表的增删改同步进影子表,
    gh-ost 改订 binlog 流回放(第 5 章复制机制的复用,侵入更小)
  4. cutover: 短暂锁定,rename 原子交换两张表名,旧表退役

MySQL 8.0 给加列这类变更开了 instant 通道(改元数据即完成),但大改(改类型、改主键)仍靠影子表。四步和骨架逐一对应:备用重建、增量追平、原子切换、旧空间回收。

4.2 Elasticsearch:reindex 加别名

第 7 章留过一个尾巴:ES 主分片数建索引时定死,改分片只能重建。标准操作正是双空间:

应用从头到尾只访问别名 orders(不直连真实索引名)
新建 orders_v2(新的分片数/mapping)-> _reindex 灌数据
-> 别名原子地从 orders_v1 切到 orders_v2 -> 删 v1

别名就是第 8 章的间接层,切换的原子性外包给了”改一条翻译表”。这里三章咬合成一句话:分片数定死的代价(7),靠双空间重建(16)加间接层切换(8)来支付。

5. 进程和环境尺度:reload 与蓝绿

NGINX reload(进程级):
新配置起一批新 worker 接管新连接(备用空间就位),
旧 worker 不再 accept,处理完存量连接后退出(排水式回收)
-> 配置变更全程不断流。第 1 章排查清单里”reload 期间
偶发一批连接同时超时”,就是旧 worker 收尾时的现象
蓝绿部署(环境级):
蓝环境跑现网,绿环境部署新版本并预热验证,
负载均衡把流量一次性切到绿;出问题一键切回蓝(秒级回滚)
对比金丝雀: 蓝绿是全量切换 + 双倍机器 + 秒回滚,
金丝雀是按比例灰度 + 少量额外机器 + 逐步放量,
前者买回滚速度,后者买风险暴露的渐进性
数据库连接串、K8s 的 Deployment 滚动其实都在这条谱系上,
区别只是备用空间的粒度(整套环境 / 一批 Pod / 一个进程组)

这个手法的祖师爷在图形学:显卡的双缓冲,前台帧在屏幕上显示,后台帧悄悄绘制,绘制完垂直同步时交换指针,观众永远看不到画了一半的画面。“别让用户看到中间态”从像素到机房,四十年没变过。

6. 通用骨架和它的三个关键件

骨架四步:

  1. 备用空间重建(贵的事在这里做,服务无感)
  2. 增量追平(可选件,见下)
  3. 原子切换(指针互换 / rename / 改别名 / LB 切流量)
  4. 旧空间回收(立即清零 / 排水退场 / 留着当回滚保险)
  • 关键件一:原子切换点。所有实例的切换最后都收敛成
    “改一个指针或一条翻译表”,因为只有单点修改能做到原子。
    切换点越往间接层上放,切换越便宜(第 8 章的又一笔收益)
  • 关键件二:增量追平。重建期间数据还在变就必须有
    (重写缓冲、触发器、binlog 回放、双写),
    数据静止(STW 里的 GC)就不用。
    追平必须能收敛: 新写入只进新空间(dict、S0/S1),
    或拷贝速率持续大于写入速率(DDL、迁移),
    否则备用空间永远追不上现役,切换遥遥无期
  • 关键件三:回收时机。立即清零最省钱(Eden),
    排水最平滑(NGINX 旧 worker),
    留一段当回滚保险最稳(蓝绿的旧环境、DDL 的旧表),
    按”切错了退不退得回去”选

7. 账单

  1. 备用空间的常驻或峰值成本:
    S 区常年吃掉新生代 10%;影子表要一倍磁盘峰值;
    蓝绿要一倍机器。省税的方向只有一个: 用统计规律缩小备用区
    (8:1:1 的精算就是范本)
  2. 追平不收敛的风险: 写入速率超过拷贝速率,迁移永不完成。
    上线大表 DDL 前先估算: 拷贝吞吐、当前写入 QPS、预计时长
  3. 切换的边角: rename 的原子性只在同一文件系统内成立;
    LB 切流量要处理在途请求(排水);
    双表并存期的双倍查找、双倍内存是明码标价的过渡税
  4. 回滚窗口的数据: 切换后新空间接了新写入,
    再切回旧空间时这段数据怎么办(蓝绿回滚的经典难题,
    答案通常是数据层不参与蓝绿,只轮换无状态层)

8. 联系实际:排查清单

  • 现象:Minor GC 后老年代增长异常快

  • 方向:survivor 太小或动态年龄判定频繁触发,对象过早晋升;
    看 GC 日志里的晋升年龄分布,调 SurvivorRatio 或阈值

  • 现象:GC 日志出现 promotion failed

  • 方向:to 区装不下且老年代担保失败,紧跟着就是 Full GC;
    查是否有突发大批量中等寿命对象(缓存刷新、批任务)

  • 现象:大表 DDL 跑了三天进度停在 99%

  • 方向:增量追平速率贴着写入速率,收敛极慢;
    低峰执行或限流业务写入

  • 现象:reindex 后查询结果新旧混杂

  • 方向:有代码绕过别名直连了旧索引名,全局搜真实索引名

  • 现象:蓝绿切换后旧环境还有流量

  • 方向:长连接没排水,或 DNS/客户端缓存了旧地址(第 8 章翻译表
    失效收敛的老问题)

  • 现象:Redis 平稳期每个命令略变慢且内存略涨

  • 方向:正在 rehash 的双表并存期,过渡税,等它搬完

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

  1. 复制算法的成本和什么成正比,为什么它配新生代不配老年代?
  2. 8:1:1 怎么把备用空间的税从 50% 砍到 10%,依据是什么?
  3. 一次 Minor GC 的五步是什么,15 岁上限和动态年龄各从哪来?
  4. 经典标记整理和 G1 的 evacuation 谁在用双空间,谁在原地滑动?
  5. S0/S1 一次搬完、双 dict 渐进搬,分岔的判据是什么?
  6. “临时文件加 rename”为什么是文件更新的通用纪律?
  7. 影子表 DDL 的四步怎么对应骨架,gh-ost 复用了第几章的机制?
  8. reindex 的原子切换靠什么,哪三章在这里咬合?
  9. 蓝绿和金丝雀各买什么,数据层为什么通常不参与蓝绿?
  10. 增量追平的收敛条件是什么,不收敛长什么样?

核心一句话:双空间轮换用一份备用空间买”昂贵操作不打断服务 + 切换原子完成”,骨架四步(重建、追平、切换、回收)从 survivor 区一路放大到整套环境;切换点收敛于改指针或翻译表,追平必须收敛,备用区大小靠统计规律精算。

延伸阅读