状态上移,原子下沉:包装优于修改的设计取舍
改动一段已经在跑的代码,成本从来不在键盘上,而在回车之后——你可能会悄悄打破一些没人记得、却真实存在的契约。
这句话听起来像鸡汤,但它背后是一个可以工程化的判断:新需求应该用新的代码单元承接,而不是给稳定模块加状态、加分支。翻译成设计原则,就是开闭原则(Open-Closed Principle,OCP):对扩展开放,对修改关闭。落到具体手法,就是 组合 / 协调优于内嵌式修改。
这篇文章用一个真实的 Go 代码案例——WalSnapshot 重构为 filterSnapshot + 上层位图——把这条原则拆成三层。
1. 改动已有代码,成本在哪
修改一段稳定运行的代码,风险来自三处:
- 回归风险:已有调用方依赖当前行为。这里最典型的是磁盘上的文件名格式——改掉它,旧数据就读不回来了。
- 状态耦合:往稳定模块里塞新状态(比如「哪个 gen 是活跃代」),会把原本简单的并发模型搞复杂。
- 测试失效:已有测试覆盖的是旧契约,行为一变,测试就从守护者变成摆设。
所以更稳的路径是:老代码保持原子职责不动,新需求放进新单元里承接——包装一层、组合一下、在上层协调。
2. 执行与决策分离
案例里的关键,是把「决定做什么」和「如何安全地做」拆成两层。
// 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