找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

470

积分

0

好友

66

主题
发表于 昨天 00:33 | 查看: 3| 回复: 0

使用 Raft 共识算法结合单机 KV 引擎构建分布式 KV 存储,是一种被广泛采用的架构模式,例如 TiKV 和 CockroachDB。此方式同样适用于构建高可用的控制面节点。本文将深入探讨在这种架构下,如何高效地实现 Raft Snapshot。

1 Raft 与 Snapshot

1.1 Raft 基础

Raft 共识算法在网络上已有大量优秀解析。本文仅简述核心概念。 图片 图 - Raft 复制状态机 [1]

上图展示了一组 Raft 复制状态机。需要明确的是,Raft 本身只是一个共识算法。

  • 我们期望实现高可用的业务逻辑,称为状态机。
  • 状态机的每个操作,经由共识算法处理,形成日志,持久化后最终应用到业务状态机。
  • 外部调用者称为客户端。
  • 整个共识算法、持久化层与状态机,统称为服务端。

1.2 Raft KV 系统设计

基于 Raft 的抽象,一个典型的“Raft + 单机KV引擎”系统可设计如下: 图片 图 - 基于 Raft 的分布式 KV 存储

其中:

  • 状态机:即单机存储引擎,例如 LSM 类型的 KV 数据库 RocksDB。
  • 日志:每次 KV 读写操作,如 Put、Del。它们也可以是一组批处理操作。
  • 客户端:KV 调用者,发起读或写请求。
  • 服务端:包含整个状态机、Raft 框架及日志系统。

服务端对外暴露的接口通常包括:

  • KV Get
  • KV Put
  • KV Del
  • KV Batch Get/Put/Del

1.3 Raft Snapshot

(注:此处指 Raft 日志压缩快照,而非 MVCC 数据库快照) 为何需要 Snapshot?根据上述设计,业务状态机在任意时刻的状态,本质上都是由从 0 开始的所有日志顺序 Apply 构建而成。这意味着,从 0 重放日志也能重建出完全一致的状态机。

随着操作不断累积,日志不能无限增长。因此需要在某个时刻为状态机创建快照。一旦快照完成,无论是 Leader 还是 Follower,都可以基于此快照及后续的日志来重建状态机。Raft Snapshot 的核心作用正是完成日志压缩。

1.4 Raft 框架的 Snapshot 接口

Raft 框架不理解应用层状态机的具体语义,因此必须由用户来实现自己的 SnapshotLoader 和 SnapshotSaver。框架则负责调用时机、数据流的持久化或网络传输

  • 在 Save Snapshot 过程中,用户需将自身状态机序列化为数据流,由 Raft 框架负责持久化或传输给其他 Follower。
  • 在 Load Snapshot 过程中,用户需将数据流反序列化,恢复为自己的状态机。

例如,baidu/braft 要求用户实现以下快照接口:

// user defined snapshot generate function, this method will block on_apply.
// user can make snapshot async when fsm can be cow(copy-on-write).
virtual void on_snapshot_save(::braft::SnapshotWriter* writer,
                              ::braft::Closure* done);
// user defined snapshot load function
virtual int on_snapshot_load(::braft::SnapshotReader* reader);

而 Go 语言中流行的 hashicorp/raft 框架也定义了类似的接口:

type FSM interface {
    // Apply is called once a log entry is committed by a majority of the cluster.
    Apply(*Log) interface{}
    // Snapshot returns an FSMSnapshot used to: support log compaction, to
    // restore the FSM to a previous state, or to bring out-of-date followers up
    // to a recent log index.
    Snapshot() (FSMSnapshot, error)
    // Restore is used to restore an FSM from a snapshot.
    Restore(snapshot io.ReadCloser) error
}

1.5 Raft Snapshot 调用时机

Save Snapshot 的触发时机通常有:

  1. 由定时任务触发。
  2. 当日志积累到一定数量后触发。无论是 Leader 还是 Follower,都需要 Save Snapshot 以压缩日志条目。

Load Snapshot 的时机主要有两个:

  1. Raft 节点启动时,需要加载最近一次的快照。
  2. Follower 节点日志落后 Leader 过多,且 Leader 已对落后的日志进行了压缩,此时 Leader 必须将完整的快照传输给 Follower。Follower 加载快照后,再 Apply 后续的日志。

1.6 注意:Raft FSM 调用的串行性质

在 braft 和 hashicorp/raft 中,框架调用用户状态机 FSM 的 ApplySnapshotRestore 等方法一定是串行的。这意味着:

  • Snapshot 操作类似于一种“中断”,其函数必须快速返回,否则会阻塞后续的 Apply 操作。
  • Snapshot 过程中,无论直接或间接,都不能再次调用 FSM 本身的操作(例如执行一个写操作),否则会导致死锁。

正是这一性质引出了核心问题:究竟应该如何实现 Snapshot,才能在性能与磁盘 I/O 之间取得最佳平衡?

2 方案 A:锁定并全量 Dump

此方案适用于无法快速获取快照的内存状态机。其流程如下:

  1. Raft 框架调用 do snapshot
  2. 用户锁住整个状态机,阻塞后续所有的客户端请求。
  3. 在内存中复制一份状态机的完整副本。
  4. 开启异步线程,将内存副本 dump 到 Raft 框架提供的 I/O Writer。
  5. 无需等待异步线程结束,立即解锁状态机,使 do snapshot 调用快速返回。

适用场景

  • 状态机数据量不大,全内存存储。
  • 本地存储引擎本身不支持 MVCC 快照读。

不适用场景

  • 无法接受内存复制期间的请求阻塞。
  • 数据量过大,容易导致 OOM。

这是一个最朴素的方案。如果数据量仅在数百 MiB 级别且对延迟不敏感,该方案完全可行。

3 方案 B:MVCC 引擎创建快照并异步迭代

根据 Raft 算法,日志必定是顺序 Apply 的。但生成快照的过程并不意味着必须完全阻塞后续日志的 Apply。许多框架之所以串行调用 FSM,是为了确保生成快照时,所有已提交的日志都已被处理,即快照的生成不应影响日志的应用顺序。

因此,如果底层使用了支持 MVCC 快照读的本地存储数据库(如 RocksDB),就可以在 Raft 触发 do snapshot 时,立即获取一个快照读句柄,随后立刻返回,不阻塞后续日志的 Apply。同时,利用这个句柄,异步地将所有 KV 对迭代并 dump 到 Raft 框架的 Data Writer 中。 图片

适用场景

  • 本地数据引擎原生支持 MVCC 快照读。
  • dump 生成的 Snapshot 文件可直接作为数据库的物理备份。

缺点

  • 数据会被额外写入 Snapshot 文件,产生写放大和空间放大。
  • 本质上,同一份 KV 数据分别写入了 Raft Log、Raft Snapshot 和 Local KV Database 三处。
  • 当数据量极大时,空间放大和写放大问题将变得不可接受。

本方案是一种相当有效的折中方案。根据笔者调研,etcd 等系统采用了类似方式。其优点是利于备份、迁移和节点重建,管理直观。

4 方案 C:存储引擎即快照

更进一步,我们可以直接将本地 KV Database 视作快照本身,从根本上消除 Raft Snapshot 阶段的写放大与空间放大。

4.1 实现

考虑以下步骤:

  1. Raft 框架通知需要生成 Snapshot。
  2. 我们仅记录少量元数据(如当前 Apply Index、快照状态等)。
  3. Snapshot 函数立即返回,相当于向 Raft 框架写入一个“空”的快照。

虽然我们提交了一个“空”快照,但作为业务状态机,我们明确知道所有 KV 数据都切实存在于数据库中。相应地,在加载这个“空”快照时,只有两种情况:

情况一:自身进程重启,首次启动时加载 Snapshot

由于我们知道自己的 KV Database 一定存储了比上次快照点更新的数据,因此 Load Snapshot 时无需任何操作,直接在现有数据库基础上继续 Apply 后续日志即可。

情况二:Follower 加载 Leader 传输过来的 Snapshot

此时,我们重载 Raft 框架读取 Snapshot 的操作。将方案 B 中读取本地 Snapshot 文件,改为基于 Leader 本地 KV Database 进行全量快照读并流式传输。这对 Follower 是透明的,它接收全量数据后,可将其直接 ingest 到自身的 KV Database 中。 图片 图:KV Database 即 Snapshot

百度开源的 BaikalDB 数据面便采用了此种方式 [5][6][7]。

4.2 重要前提:FSM Log 必须是幂等的

方案 C 数据正确性的一个核心前提,是 KV 状态机的 Log 设计必须是幂等的。考虑以下场景:

  1. Leader 在 log_id=10004 处生成快照。
  2. Leader 继续提交并 Apply 日志 10005~10006
  3. 一个新 Follower 加入,因日志差距过大,开始加载 Leader 在 log_id=10004 时的快照。
  4. 此时,新的写请求被提交并 Apply,产生 log_id=10007
  5. Follower 加载的快照,其全量 KV 数据版本实际上是 @10006,但 Raft 框架认为该快照对应于 log_id=10004
  6. Follower 加载快照后,Raft 框架会再次 Apply 日志 10005~10007

因此,一条日志可能被多次 Apply 到状态机,这就要求 Log 的设计必须是幂等的。由于我们的 Log 本身就是 KV 的 Put、Del 操作,所以能保证数据的最终一致性。

4.3 优缺点

优点

  1. 彻底消除了大数据量下 Snapshot 的写放大和空间放大,性能显著提升。
  2. 设计巧妙,直接利用了 FSM Log 的幂等性。

缺点

  1. 实现复杂度较高,需要深入理解并严谨推演。
  2. 对于需要全量备份集群的场景,需另行设计备份机制。

5 小结

本文聚焦于 Raft KV 系统中 Snapshot 的实现。在简述 Raft 原理后,探讨了主流 Raft 框架提供的用户层接口及实现注意事项。随后,详细列举并分析了三种 Snapshot 实现方案的原理与优劣。

笔者认为,对于一般的元数据系统,若数据规模可接受,采用方案 B 更为直观和便于维护。对于海量数据存储节点,亟需减少 Snapshot 开销以提升性能时,可考虑实现更为巧妙的方案 C

6 其他讨论

实现一个完整的 Raft KV 系统,还有诸多有趣议题值得探讨:

  • 读请求是否需要经过 Raft 共识?如何保证线性一致性读?
  • Raft 节点管理:是先添加节点再同步数据,还是先同步数据再加入集群?
  • 扩展到 Multi-Raft 架构后,如何优化网络与存储层?

参考

[1] Raft 论文 - https://raft.github.io/raft.pdf [2] Raft 算法解析 - https://www.calvinneo.com/2019/03/12/raft-algorithm/ [3] C++ raft 框架 baidu/braft https://github.com/baidu/braft [4] Go raft 框架 hashicorp/raft https://github.com/hashicorp/raft [5] baidu/BaikalDB https://github.com/baidu/BaikalDB [6] braft 快照的实现 https://luobuda.github.io/2022/02/15/braft-snapshot%E5%AE%9E%E7%8E%B0/ [7] BaikalDB基于raft和rocksdb的快照机制的实现原理 https://github.com/baidu/BaikalDB/issues/105




上一篇:React2Shell漏洞紧急修复引发Cloudflare全球服务中断
下一篇:网页性能优化与2026年性能预算基准:应对数字鸿沟的技术选择
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2025-12-9 01:07 , Processed in 0.096107 second(s), 38 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2025 云栈社区.

快速回复 返回顶部 返回列表