跳转至

一个没有名字的持久化设计:五个经典思想的排列组合

最近在 review 一个布谷鸟过滤器分片的持久化方案:槽位环、脏槽位、双代文件、index 位图翻转,一整套组合拳。review 的人问了句"这套设计叫什么名字",我卡住了——它没有名字,因为它是五个经典思想的排列组合。每个零件单独拿出来都有出处,拼在一起就成了"自研"。

这篇文章把这五个思想逐个拆开,讲清楚它们各自从哪来、在哪些经典系统里出现过、为什么长成那个样子。下次你遇到类似设计,可以直接报出人名和论文,而不是"我们内部叫它 V4"。

1. 背景:这套拼装设计长什么样

先把被拼装的对象摆出来。一套布谷鸟过滤器分片的持久化机制,核心元素五个:

  1. 槽位(slot)+ 循环推进:N 个布谷鸟过滤器排成一个环,当前槽位写满就序列化落盘、推进到下一个槽位,覆盖掉最旧的——形成滚动时间窗口
  2. 脏槽位(dirty slot):每个槽位带一个 dirty 标记,dirty=true 表示该槽位的磁盘文件落后于内存状态
  3. 活跃代/双代交替:每个槽位维护两个"代"文件(A/B)轮流写,任意时刻磁盘上永远有一份完整的旧版本
  4. 检查点(checkpoint):定期把内存状态固化到磁盘,崩溃后从最近的检查点恢复,而不是从头重建
  5. 先数据后索引:先写数据文件,再翻转 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 HashMaphash & (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 就是同一种记账:内存里的过滤器比磁盘上的代文件新。它的两个用途和前辈们一模一样——

  1. 运行时:决定哪些槽位需要触发序列化,干净的跳过,脏的还债
  2. 恢复时:崩溃后内存没了,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 就是治这个病的——定期把"日志推进到这里为止"的界限固化下来:

  • PostgreSQLcheckpoint_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 类型系统的高频误解。

message ShardingIndex {
    // ... 其他字段
    bytes replica_bits = 4;
}

有人看到 bytes 以为是"一个字节",进而推理出"位图只能管 8 个布谷鸟过滤器"。bytes 是任意长度的字节串,Go 里对应 []byte,不是单个 byte。protobuf 里定长的标量类型是 fixed32/fixed64bytes 天生变长。

位图的容量由编码逻辑动态决定:

// 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 + 1)
字节数 = ceil(总位数 / 8)

代入几档典型部署规模:

部署规模 分片数 每分片布谷鸟数 (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 选型直觉:哈希、签名、位图、序列化结构——一切"长度可变"的数据用 bytesfixed32/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

评论