跳转至

切换必须原子:双缓冲家族的命根子

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

评论