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 满了
- 从 GC Roots 出发标记 Eden 和 from survivor 里的存活对象
- 把存活对象复制进 to survivor,每个对象的年龄加 1
(年龄记在对象头 mark word 里,只有 4 个 bit,
所以晋升阈值最大只能是 15,第 2 章对象头布局的伏笔在此兑现) - 年龄到达阈值的不进 to,直接晋升老年代
动态年龄判定: 不死等 15,to 里同龄对象的总大小
超过 survivor 一半时,大于等于该年龄的整批提前晋升 - Eden 和 from 整块清空
- 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 这类工具的方案是表级双空间:
- 建影子表: 和原表同构的新表,在它身上执行 DDL(改的是空表,瞬间完成)
- 存量拷贝: 分批把原表数据拷进影子表(第 11 章的批处理,控速防压垮)
- 增量追平: 拷贝期间原表还在被写,
pt-osc 用触发器把原表的增删改同步进影子表,
gh-ost 改订 binlog 流回放(第 5 章复制机制的复用,侵入更小) - 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. 通用骨架和它的三个关键件
骨架四步:
- 备用空间重建(贵的事在这里做,服务无感)
- 增量追平(可选件,见下)
- 原子切换(指针互换 / rename / 改别名 / LB 切流量)
- 旧空间回收(立即清零 / 排水退场 / 留着当回滚保险)
- 关键件一:原子切换点。所有实例的切换最后都收敛成
“改一个指针或一条翻译表”,因为只有单点修改能做到原子。
切换点越往间接层上放,切换越便宜(第 8 章的又一笔收益) - 关键件二:增量追平。重建期间数据还在变就必须有
(重写缓冲、触发器、binlog 回放、双写),
数据静止(STW 里的 GC)就不用。
追平必须能收敛: 新写入只进新空间(dict、S0/S1),
或拷贝速率持续大于写入速率(DDL、迁移),
否则备用空间永远追不上现役,切换遥遥无期 - 关键件三:回收时机。立即清零最省钱(Eden),
排水最平滑(NGINX 旧 worker),
留一段当回滚保险最稳(蓝绿的旧环境、DDL 的旧表),
按”切错了退不退得回去”选
7. 账单
- 备用空间的常驻或峰值成本:
S 区常年吃掉新生代 10%;影子表要一倍磁盘峰值;
蓝绿要一倍机器。省税的方向只有一个: 用统计规律缩小备用区
(8:1:1 的精算就是范本) - 追平不收敛的风险: 写入速率超过拷贝速率,迁移永不完成。
上线大表 DDL 前先估算: 拷贝吞吐、当前写入 QPS、预计时长 - 切换的边角: rename 的原子性只在同一文件系统内成立;
LB 切流量要处理在途请求(排水);
双表并存期的双倍查找、双倍内存是明码标价的过渡税 - 回滚窗口的数据: 切换后新空间接了新写入,
再切回旧空间时这段数据怎么办(蓝绿回滚的经典难题,
答案通常是数据层不参与蓝绿,只轮换无状态层)
8. 联系实际:排查清单
-
现象:Minor GC 后老年代增长异常快
-
方向:survivor 太小或动态年龄判定频繁触发,对象过早晋升;
看 GC 日志里的晋升年龄分布,调 SurvivorRatio 或阈值 -
现象:GC 日志出现 promotion failed
-
方向:to 区装不下且老年代担保失败,紧跟着就是 Full GC;
查是否有突发大批量中等寿命对象(缓存刷新、批任务) -
现象:大表 DDL 跑了三天进度停在 99%
-
方向:增量追平速率贴着写入速率,收敛极慢;
低峰执行或限流业务写入 -
现象:reindex 后查询结果新旧混杂
-
方向:有代码绕过别名直连了旧索引名,全局搜真实索引名
-
现象:蓝绿切换后旧环境还有流量
-
方向:长连接没排水,或 DNS/客户端缓存了旧地址(第 8 章翻译表
失效收敛的老问题) -
现象:Redis 平稳期每个命令略变慢且内存略涨
-
方向:正在 rehash 的双表并存期,过渡税,等它搬完
9. 学完本章你能解决什么问题
- 复制算法的成本和什么成正比,为什么它配新生代不配老年代?
- 8:1:1 怎么把备用空间的税从 50% 砍到 10%,依据是什么?
- 一次 Minor GC 的五步是什么,15 岁上限和动态年龄各从哪来?
- 经典标记整理和 G1 的 evacuation 谁在用双空间,谁在原地滑动?
- S0/S1 一次搬完、双 dict 渐进搬,分岔的判据是什么?
- “临时文件加 rename”为什么是文件更新的通用纪律?
- 影子表 DDL 的四步怎么对应骨架,gh-ost 复用了第几章的机制?
- reindex 的原子切换靠什么,哪三章在这里咬合?
- 蓝绿和金丝雀各买什么,数据层为什么通常不参与蓝绿?
- 增量追平的收敛条件是什么,不收敛长什么样?
核心一句话:双空间轮换用一份备用空间买”昂贵操作不打断服务 + 切换原子完成”,骨架四步(重建、追平、切换、回收)从 survivor 区一路放大到整套环境;切换点收敛于改指针或翻译表,追平必须收敛,备用区大小靠统计规律精算。