一个没有名字的持久化设计:五个经典思想的排列组合
最近在 review 一个布谷鸟过滤器分片的持久化方案:槽位环、脏槽位、双代文件、index 位图翻转,一整套组合拳。review 的人问了句"这套设计叫什么名字",我卡住了——它没有名字,因为它是五个经典思想的排列组合。每个零件单独拿出来都有出处,拼在一起就成了"自研"。
这篇文章把这五个思想逐个拆开,讲清楚它们各自从哪来、在哪些经典系统里出现过、为什么长成那个样子。下次你遇到类似设计,可以直接报出人名和论文,而不是"我们内部叫它 V4"。
1. 背景:这套拼装设计长什么样
先把被拼装的对象摆出来。一套布谷鸟过滤器分片的持久化机制,核心元素五个:
- 槽位(slot)+ 循环推进:N 个布谷鸟过滤器排成一个环,当前槽位写满就序列化落盘、推进到下一个槽位,覆盖掉最旧的——形成滚动时间窗口
- 脏槽位(dirty slot):每个槽位带一个 dirty 标记,
dirty=true表示该槽位的磁盘文件落后于内存状态 - 活跃代/双代交替:每个槽位维护两个"代"文件(A/B)轮流写,任意时刻磁盘上永远有一份完整的旧版本
- 检查点(checkpoint):定期把内存状态固化到磁盘,崩溃后从最近的检查点恢复,而不是从头重建
- 先数据后索引:先写数据文件,再翻转 index 文件里的位图;位图翻转之前,新数据文件只是无人引用的孤儿
一句话概括运行时逻辑:写满槽位 → 序列化到非活跃代文件 → fsync → 翻转 index 位图 → fsync index → 推进槽位指针。崩溃后读 index 位图,按位图加载每代文件,dirty 的槽位走恢复逻辑。
这套东西没有任何一篇论文完整描述过它。但它的每个零件,都至少有三十到六十年历史。
Tip
"没有名字"不等于"没有出处"。工业界的存储设计绝大多数是经典思想的拼装,区别只在于设计者知不知道零件的学名。
2. 槽位环:Ring Buffer 的六十年
环形缓冲区(Ring Buffer / Circular Buffer)是计算机科学里最古老的抽象之一,老到没有明确的发明人——磁鼓存储器时代,数据载体本身就是物理的环。它的核心技巧就一个:固定大小数组 + 下标取模回绕。
type Ring struct {
buf []entry
head int // 写指针,只增不减
tail int // 读指针,只增不减
mask int // 容量为 2 的幂时,取模退化为位与
}
func (r *Ring) Push(e entry) {
r.buf[r.head&r.mask] = e
r.head++
}
func (r *Ring) Pop() entry {
e := r.buf[r.tail&r.mask]
r.tail++
return e
}
上面代码的两个细节
细节一:mask——取模退化为位与。 容量是 2 的幂时,x % capacity 等价于 x & (capacity - 1),mask 就是预计算好的 capacity - 1。原理:2 的幂在二进制里是 1 后面跟一串 0(8 = 0b1000),减 1 得到低位全 1(7 = 0b0111);而对 2^k 取模,本质就是扔掉高位、只留最低 k 位——十进制的直觉是 12345 % 100 = 45,保留最后两位数字。按位与 0b0111 干的正是这件事:
N = 8, mask = 7 = 0b0111
head = 9 → 9 % 8 = 1 0b1001 & 0b0111 = 0b0001 = 1 ✓
head = 15 → 15 % 8 = 7 0b1111 & 0b0111 = 0b0111 = 7 ✓
head = 16 → 16 % 8 = 0 0b10000 & 0b00111 = 0 ✓
前提是容量必须取 2 的幂——否则 N-1 的低位不是全 1,等价关系直接崩掉(N=6 时 9 % 6 = 3,而 9 & 5 = 1)。所以 kfifo 分配时向上取整到最近的 2 的幂,Disruptor 构造时强制校验、不是 2 的幂直接抛异常。收益很直接:取模在 CPU 上要几十个周期,位与只要 1 个,而 Push/Pop 是最热路径。Java HashMap 的 hash & (cap - 1) 是同一个技巧。
细节二:head/tail 单调递增。 两个指针是无界递增的原始计数,永不回绕——判断空满只需比较两个单调值,不存在"指针绕了一圈怎么区分新旧"的歧义。真正落到数组上的下标由位与现场算出,天然回绕。一句话:逻辑上无界递增,物理上按位与回绕。
经典案例一:Linux 内核 kfifo
内核里的 include/linux/kfifo.h 是教科书级实现。最精妙的一点:单生产者单消费者场景下完全无锁。
原理不神秘:head 只有生产者写、消费者读;tail 只有消费者写、生产者读。每根指针都满足"单写多读",配合内存屏障保证可见性,锁就是多余的。这个前提一旦被打破(两个生产者),无锁立刻失效,必须自己加同步。这里的门道值得单独展开:为什么单写多读就敢不用锁,以及两个生产者进场后怎么把锁补回来。
经典案例二:LMAX Disruptor
伦敦外汇交易所 LMAX 开源的 Disruptor,把环形缓冲做到了极致:用 序列号 + 缓存行填充 替代锁,单线程顺序写 journal,每秒处理六百万订单。Martin Fowler 写过一篇著名的架构文章,值得一看。
它的环形数组本质没变,变的是周边:消费者各自持有消费位点,生产者只管推进自己的序列号,冲突检测退化为"我的序列号有没有被最慢的消费者追上"。锁没了,等待也没了,吞吐量对 ArrayBlockingQueue 形成碾压。
经典案例三:TCP 滑动窗口
TCP 的序号空间是一个逻辑上的环(回绕后通过窗口比较判断新旧)。发送窗口在序号空间上滑动,窗口外的数据要么还没资格发、要么早已确认——容量有限的视图 + 自动淘汰旧数据,和环形缓冲是同一个思想在不同层的投影。
回到槽位环
槽位环是 Ring Buffer 的"只写不读"变体:没有消费者在另一端弹出数据,写入本身就完成淘汰。写满当前槽位 → 推进指针 → 下次覆盖最旧的。一个固定容量的数组,天然实现了"只保留最近 N 份快照"的时间窗口语义。
Tip
一句话概括:环形结构把"容量有限"和"自动淘汰"绑成了买一送一。你不用写任何 GC 逻辑,覆盖即回收。
3. 脏槽位:从 Linux 脏页到 InnoDB writeback
"内存内容和磁盘内容不一致的页"叫脏页(Dirty Page)。这个"脏"字用得极准:不是数据错了,而是 两份拷贝版本不一致,欠磁盘一次回写(writeback)。脏页标记本质上是一种 一致性记账——系统随时能回答"哪些页需要回写"这个问题。
经典案例一:Linux page cache
Linux 的 write() 系统调用只做一件事:把数据拷进 page cache、给页打上 dirty 标志,然后立刻返回。真正落盘是 flusher 线程(每块设备一组)的后台工作。两个关键参数控制节奏:
vm.dirty_background_ratio:脏页占比超过它,后台异步回写启动,应用无感vm.dirty_ratio:脏页占比超过它,写者被 同步阻塞,亲手把脏页刷下去——系统的最后防线
这套设计的收益是 批量摊薄 I/O:一万次小写入聚合成少数几次大回写,磁盘顺序性能拉满。代价是掉电窗口——最多丢 dirty_ratio 那么多的数据,数据库们受不了,所以它们宁愿绕过 page cache 自己管日志。
经典案例二:InnoDB buffer pool
InnoDB 在 buffer pool 里改页,改完的页挂进 flush list(按最老修改排序)。它没有一次刷全量,而是搞了个 fuzzy checkpoint:每次稳定推进"最老脏页的修改点",崩溃恢复时只需要从这个点开始重放 redo 日志——前面的日志可以安全丢弃。
对比之下,干净关库时的 sharp checkpoint(刷掉全部脏页、推进 checkpoint 到最新)才是一次性全量。线上库几个月不重启,靠 fuzzy checkpoint 把恢复时间压在常数级。
回到脏槽位
槽位的 dirty=true 就是同一种记账:内存里的过滤器比磁盘上的代文件新。它的两个用途和前辈们一模一样——
- 运行时:决定哪些槽位需要触发序列化,干净的跳过,脏的还债
- 恢复时:崩溃后内存没了,dirty 标记告诉恢复逻辑"这个槽位的文件不可全信",走重放或重建路径
Tip
dirty 不是一种状态,是一张欠条。欠条的存在让"什么时候还"变成可调度的策略问题,而不是正确性问题。
4. 双代交替:Double Buffering 家谱
双缓冲(Double Buffering)的核心是三步舞:更新非活跃份 → 原子切换指针 → 旧份变成新的非活跃份。读者永远看到一份完整副本,切换是原子的,更新不是。
经典案例一:图形学前/后缓冲
最广为人知的双缓冲。GPU 在 back buffer 上画下一帧,画完 swap 到 front buffer 显示。不这么做会怎样?撕裂(tearing)——屏幕上半帧是旧画面、下半帧是新画面,因为显示器扫描输出时画布正在被改。vsync 让 swap 对齐显示器的垂直回扫,triple buffering 再垫一层,让渲染和显示彻底解耦。
注意 swap 的语义:它只交换指针(或句柄),不拷贝像素。O(1) 的原子切换,是整个模式的命根子。
经典案例二:蓝绿部署
运维界的双缓冲:两套对等生产环境,蓝在线服务,绿部署新版本;验证通过后负载均衡器把流量从蓝切到绿。切流量是秒级原子操作,出问题切回来也是秒级。和前后缓冲的区别只有一个:蓝绿切完往往把旧环境保留一阵当回滚保险,图形学的 back buffer 则立刻复用画下一帧。
经典案例三:LevelDB immutable memtable
存储引擎里最教科书的双缓冲。LevelDB 的写入先落到 memtable(跳表);memtable 写满后 原地转身 成 immutable memtable——从此只读,等后台 minor compaction 把它刷成 SST 文件;与此同时新的 memtable 立刻顶上继续收写。
写路径永远有地方写(新 memtable),刷盘逻辑永远面对一份冻结的快照(immutable),两边互不干扰。这不是特例,RocksDB、Pebble、Badger 全是这个模式。
经典案例四:Redis BGSAVE 的 fork + COW
Redis 做 RDB 快照不显式维护两份副本——fork() 一下子进程,子进程遍历的是 fork 瞬间的完整内存快照;父进程继续处理写请求,写到的页被内核 copy-on-write 复制出去。没有两份显式缓冲,但操作系统替你实现了快照语义:旧版本供读者,新写入发生在别处,和双缓冲完全同构。
回到双代文件
槽位的 A/B 两代文件轮流写:非活跃代接收新序列化结果,fsync 完成后翻转 index 位图完成切换。读路径(包括崩溃恢复路径)永远能拿到一份完整的旧代文件。崩溃发生在任何时刻,最多损失"正在写的那一份",旧的一份永远完好——这就是敢对着一份数据文件做覆盖式写入的底气。
Tip
双缓冲解决的是"读和写抢同一块数据"。切换必须是原子的(一个指针、一次 rename、一个位图翻转),更新可以慢悠悠。
5. 崩溃恢复的两条路线:Checkpoint vs Shadow Paging
前四个思想都在讲"平时怎么干活",最后一个讲"崩溃之后怎么办"。这个问题历史上分成了两大门派。
路线 A:WAL + checkpoint
先写日志(Write-Ahead Logging):改动以追加方式顺序写进日志文件,快;数据页爱什么时候刷什么时候刷。崩溃后从日志 重放(replay)恢复。理论基础是 C. Mohan 1992 年的 ARIES 算法(IBM),至今仍是关系数据库恢复的圣经。
WAL 有个天生的病:日志无限增长,崩溃后重放时间也无限增长。checkpoint 就是治这个病的——定期把"日志推进到这里为止"的界限固化下来:
- PostgreSQL 每
checkpoint_timeout或 WAL 写满max_wal_size触发一次:刷脏页、把 checkpoint 位置写进控制文件,之后的恢复只重放这点之后的 WAL - InnoDB 的 fuzzy checkpoint 前面讲过,把重放窗口压在常数级
槽位设计里的"检查点"语义相同:定期固化内存状态,崩溃后从最近的检查点起步,而不是从创世区块重放。
路线 B:Shadow Paging
另一派压根不重放。影子分页(Shadow Paging):要改一页时,不改原地,把新版本写到别处;所有新页就绪后,原子切换根指针。崩溃后旧根指针仍然有效——整个事务等于没发生过,恢复是 O(1) 的。
这套东西的祖师爷是 IBM 的 System R(1970 年代,SQL 的诞生地)。它最初的恢复机制就是 shadow paging,后来同项目里的 WAL 思路胜出——shadow paging 的代价是随机 I/O(新页写到新位置)、碎片化、以及改一个叶子页要复制整条根到叶的路径。高写入场景下被 WAL 打得没有还手之力。
但故事没完。低写入、要快照、要原子性的场景,shadow paging 全面复兴:
- ZFS:全 COW 文件系统,任何修改都写新块。根指针叫 uberblock,存放在一个 环形数组 里循环写入。数据块全部落盘后,新 uberblock 才写进下一个槽位;崩溃后扫描整个数组,取校验通过且 transaction group 最大的那个。注意这里——环形数组 + 双代交替 + 先数据后索引 三合一,和槽位设计结构上完全同构。Btrfs 的 superblock 多副本轮换同理(取 generation 最高)
- BoltDB(etcd 的底层存储):最贴身的案例。文件头固定两个 meta page(page 0 和 page 1)轮换:事务提交时,脏页写到新位置,然后 meta page 写入新的根页号 + txid + 校验和,下次事务写另一个 meta。打开数据库时读两个 meta,校验 magic/version/checksum,取 txid 更大且合法的那个。单页损坏最多废一个 meta,另一个还能用
"先数据后索引"的真身
现在可以点题了:槽位设计里"先写数据文件、后翻转 index 位图"的顺序约束,就是 shadow paging 的原子切根。
数据文件是新版本页,index 位图是根指针。位图翻转 之前,新数据文件是无人引用的孤儿——崩溃了也只是垃圾文件,不破坏任何一致性;翻转 之后,新文件转正,旧文件变成下一轮的非活跃代。所有一致性风险被压缩到"翻转那一个字节写没写完"上,而双代结构保证即使翻转写坏了,旧代仍然完好。
两条路线怎么选
| 维度 | WAL + checkpoint | Shadow paging |
|---|---|---|
| 写路径 | 顺序追加,快 | 随机写 + 版本复制,慢 |
| 崩溃恢复 | 重放日志,耗时与日志量相关 | 直接用旧根,O(1) |
| 空间开销 | 日志需要归档/清理 | 旧版本占空间,依赖 GC |
| 适用场景 | 高写入 OLTP | 低写入、快照、原子发布 |
Warning
别把两条路线当对立教派,实际系统经常混用:ZFS 是 shadow paging 的根 + 事务意图日志的辅助;槽位设计是 shadow paging 的切换 + checkpoint 的节奏。工程只问"哪段风险用哪种武器"。
6. bytes 位图与一个 proto 常见误解
最后一个零件:index 位图的编码。它带出一个 protobuf 类型系统的高频误解。
有人看到 bytes 以为是"一个字节",进而推理出"位图只能管 8 个布谷鸟过滤器"。错。bytes 是任意长度的字节串,Go 里对应 []byte,不是单个 byte。protobuf 里定长的标量类型是 fixed32/fixed64,bytes 天生变长。
位图的容量由编码逻辑动态决定:
// encodeBits 把 N 个槽位的布尔状态编码成 ceil(N/8) 字节的位图
func encodeBits(bits []bool) []byte {
out := make([]byte, (len(bits)+7)/8)
for i, b := range bits {
if b {
out[i/8] |= 1 << (i % 8)
}
}
return out
}
容量公式:
代入几档典型部署规模:
| 部署规模 | 分片数 | 每分片布谷鸟数 (Length) | 槽位数 (Length+1) | 总位数 | replica_bits 占用 |
|---|---|---|---|---|---|
| 小型 | 4 | 2 | 3 | 12 | 2 字节 |
| 中型 | 16 | 4 | 5 | 80 | 10 字节 |
| 大型 | 32 | 8 | 9 | 288 | 36 字节 |
加载时的校验也印证了动态性——检查的是"字节数 × 8 是否覆盖期望位数",而不是某个固定上限:
if len(idx.ReplicaBits)*8 < wantBits {
return fmt.Errorf("replica_bits size %d*8 < required bits %d",
len(idx.ReplicaBits), wantBits)
}
所以位图层面 不存在任何数量限制,它随分片数和槽位数自动伸缩。真正约束布谷鸟数量的是业务配置校验:Length 取值范围 2~100、分片数 1~50——那是业务约束,不是编码约束。
Warning
proto 选型直觉:哈希、签名、位图、序列化结构——一切"长度可变"的数据用 bytes。fixed32/fixed64 是定长数值场景(IP、时间戳、高精度 ID)的专利。搞混这两类,review 时会被问出这篇博客开头那个问题。
小结
回到开头那句话:这套设计没有名字,因为它是排列组合——
| 设计元素 | 理论锚点 | 经典系统 | 搜索关键词 |
|---|---|---|---|
| 槽位 + 循环推进 | 环形缓冲区 | kfifo、Disruptor、TCP 窗口 | ring buffer, circular buffer |
| 脏槽位 | 脏页 / writeback | Linux page cache、InnoDB | dirty page, writeback, flush list |
| 双代交替 | 双缓冲 | 前后缓冲、蓝绿部署、LevelDB、Redis | double buffering, immutable memtable |
| 检查点 | WAL checkpoint | PostgreSQL、InnoDB、ARIES | checkpoint, write-ahead logging |
| 先数据后索引 | 影子分页 | System R、ZFS、BoltDB | shadow paging, copy-on-write |
方法论就一条:新设计 = 旧思想的重新组合。组合是创新,但组合的前提是你认识零件。每个经典思想都自带几十年的踩坑记录和精确术语,知道学名,等于拿到了前人踩坑笔记的目录——不知道学名,你就得自己把坑再踩一遍。
你写下的下一行"新"代码,大概率是某个五十年前思想的第 N 次转世。花十分钟查清它的名字,比给团队内部再发明一个"V4"值钱得多。
最后更新:2026-09-04