跳转至

状态上移,原子下沉:包装优于修改的设计取舍

改动一段已经在跑的代码,成本从来不在键盘上,而在回车之后——你可能会悄悄打破一些没人记得、却真实存在的契约。

这句话听起来像鸡汤,但它背后是一个可以工程化的判断:新需求应该用新的代码单元承接,而不是给稳定模块加状态、加分支。翻译成设计原则,就是开闭原则(Open-Closed Principle,OCP):对扩展开放,对修改关闭。落到具体手法,就是 组合 / 协调优于内嵌式修改

这篇文章用一个真实的 Go 代码案例——WalSnapshot 重构为 filterSnapshot + 上层位图——把这条原则拆成三层。

1. 改动已有代码,成本在哪

修改一段稳定运行的代码,风险来自三处:

  • 回归风险:已有调用方依赖当前行为。这里最典型的是磁盘上的文件名格式——改掉它,旧数据就读不回来了。
  • 状态耦合:往稳定模块里塞新状态(比如「哪个 gen 是活跃代」),会把原本简单的并发模型搞复杂。
  • 测试失效:已有测试覆盖的是旧契约,行为一变,测试就从守护者变成摆设。

所以更稳的路径是:老代码保持原子职责不动,新需求放进新单元里承接——包装一层、组合一下、在上层协调。

2. 执行与决策分离

案例里的关键,是把「决定做什么」和「如何安全地做」拆成两层。

birdsnest/snapshot.go
// filterFileName 计算指定槽位与代的文件名。
// gen 仅允许取 0 或 1:gen=0 → bird_filter_NNNNN;gen=1 → bird_filter_NNNNN.1。
func filterFileName(path string, index uint16, gen int) string {
    name := snapFilePrefix + fmt.Sprintf("%05d", index)
    if gen == 1 {
        name += replicaSuffix
    }
    return filepath.Join(path, name)
}

职责切分得很干净:

职责 不负责
上层(分片协调层) 持有 index 位图,决策 哪一代活跃、何时切换 不碰文件 I/O 细节
filterSnapshot / filterFileName 按给定的 (槽位, 代) 原子执行 读写(tmp + Sync + rename) 不感知「哪一代活跃」

filterFileName 是纯函数:无状态、无副作用,输入决定输出。注释里写得明白——「活跃代由上层指定,本结构不感知」。

这就是「自己只做原子化工作」的含义:每个函数的契约小到可以单独证明正确

3. 为什么「状态上移、原子下沉」扩展友好

扩展点在上层

以后要从双代变三代、加校验和、加 WAL 回滚,改的都是协调层的策略,filterFileName / writeFile 一行不用动——它们的能力(按参数写一个文件)是策略无关的。

崩溃一致性边界清晰

底层只保证 单次写 的原子性:rename 前先 fsync,崩溃后只会留下旧文件或完整新文件。跨文件 的一致性(双代切换时新旧两个文件 + 位图的一致)由上层用协议保证。

两个层次的故障模型互不污染,排查问题时能快速定位该问哪一层。

可测试性

纯函数和「按指令读写」的无状态结构,测试时不需要模拟任何协调逻辑;协调层的测试又可以把底层当成确定性黑盒。

如果当初把位图塞进 filterSnapshot,两层逻辑缠在一起,两类测试都会变难写。

4. 小结

一句话概括:把「决定做什么」(策略、状态、协调)往上收,把「如何安全地做一件事」(原子操作、纯函数)往下沉。

扩展时新增一个协调者或包装一层,而不是给稳定的底层加状态、加分支——这就是包装优于修改的本质。

之前把 WalSnapshot(让单个 wal 文件既管存储、又隐含「只保留最后一条」的策略)重构掉,换成 filterSnapshot + 上层位图,正是这条原则的一次实际应用。


最后更新:2026-09-06

评论