切换必须原子:双缓冲家族的命根子
04-1 列了双缓冲的四个案例——前后缓冲、蓝绿部署、LevelDB immutable memtable、Redis BGSAVE——像一家人拍了张合影。这篇逐个拆开看共同骨架,再看 COW 这个不显形的亲戚。所有案例的生死都系在同一件事上:切换那一步的原子性。
1. 三步舞
双缓冲的套路永远是三步:
慢的必须是 ①,快的必须是 ②。把任何一部分 ① 的活儿漏进 ②,切换就不原子了,读者就会看到半新半旧的缝合怪。图形学的撕裂(tearing)就是这种事故的可见形态:显示器扫到一半画布被换掉,上半帧旧、下半帧新。
2. 切换的物理载体逐个拆
四个案例的 ② 长得不一样,这正是值得看的地方——原子性最终要落在某个物理动作上:
| 案例 | 原子切换的载体 | 谁保证原子 |
|---|---|---|
| 图形学前/后缓冲 | 页翻转:写一次 GPU 寄存器 | 硬件 |
| 蓝绿部署 | 负载均衡器改路由表 | LB 单点 |
| LevelDB | mem_ 指向新表的一行赋值 | DB mutex |
| Redis BGSAVE | fork() 返回的那个瞬间 | 内核 |
前后缓冲:交换的不是像素是指针(页翻转),O(1) 且硬件原子;vsync 让这次翻转对齐显示器的回扫窗口,等于给切换挑了个没人看的时刻。
蓝绿:真正原子的是 LB 上的路由变更,秒级生效秒级回滚。注意反例——DNS 切换就不原子(TTL 内新旧并存),所以严肃的蓝绿都建立在 LB 或服务发现上。不是随便换个指向都叫原子切换,得看谁在替你"一步完成"。
LevelDB:memtable 写满,DB mutex 里一行指针赋值把它转正为 immutable,新 memtable 顶上。刷盘(minor compaction)在后台慢悠悠处理 immutable——① 和 ② 的时长差着四个数量级,正是这个差值给了"边写边读"的空间。RocksDB、Pebble、Badger 全是这个模式。
Redis:fork 的原子性由内核保证;父进程继续处理写请求,写到的页被 copy-on-write 复制出去,子进程遍历的是 fork 瞬间的完整快照。妙在 另一份缓冲从未显式存在——它是被写操作"分裂"出来的。这也是快照期间 Redis 怕写放大的原因:COW 的账单按分裂页数结算。
3. COW:隐形的双缓冲
把 COW 单独拎出来,因为它换了个问题:不问"怎么维护两份",问"怎么让旧份天然不动"。
fork、ZFS 的数据块、甚至 Git 分支,全是一个思路:读的人拿旧份,写的人落到新份,两份的边界由写动作即时划分。对比传统双缓冲"预先准备两份、轮流用",COW 是惰性版:第二份只在真的发生分歧时才存在。
上一篇 的 shadow paging 就是 COW 加持久化语义——旧页不动、新页别处写、根指针一切,事务等于没发生过。
4. 旧份的去留:保险还是耗材
切换完成后旧份怎么办,四家给出两种答案:
- 回滚保险:蓝绿把旧环境留一阵,出问题秒切回来;BoltDB 的另一个 meta page 留着崩溃兜底
- 立即复用:back buffer 立刻开始画下一帧;LevelDB 的 immutable 刷完盘就释放
判据就一条:旧份对读者还有没有残余价值。蓝绿的旧环境上还挂着长连接和缓存,有残余价值;back buffer 的上一帧已经扫出去了,是耗材。
5. 一句检验标准
看任何"边用边更新"的设计,问一句:
你的 swap,能不能一步完成?
- 能:页翻转、mutex 里的一行指针赋值、fork、
rename(2)(写临时文件后原子改名——文件界的双缓冲,配置下发和原子更新全靠它) - 不能:多步配置下发、带传播延迟的 DNS、跨多个服务的联动变更——崩溃窗口就在那两步之间,设计的剩余工作量全在给这个窗口上保险:对账、回滚、灰度
Tip
双缓冲解决"读写抢同一块数据",代价全押在切换的原子性上。检验一个双缓冲设计,不看 ①③ 写得多优雅,看 ② 有没有一个明确的、不可分割的物理载体。
小结
四个案例一个骨架:更新非活跃份、原子切换、旧份退役。差异全在 ② 的物理载体——硬件寄存器、路由表、一行指针赋值、一次 fork——以及旧份的处置哲学:保险还是耗材。COW 把两份缓冲的边界改成惰性划分,是这套思想的最优变形。
一句话概括:双缓冲的一切设计工作量,都是在把"慢"圈进 ①,把"一步"留给 ②。至此 04-1 埋的存储思想支线全部展开:恢复路线、writeback 经济学、双缓冲命根子——无锁系列讲完了"并发怎么写",存储系列讲完了"掉电怎么活"。
最后更新:2026-09-08