分布式 KV 存储对比:etcd、TiKV 与 FoundationDB
一个典型的架构评审场景:团队需要一个分布式键值存储来承载新系统的元数据。候选方案三个——etcd、TiKV、FoundationDB。三者都提供强一致性保证,都经过大规模生产验证,但设计目标截然不同。选错了,轻则性能不达标,重则数据丢失后无法恢复。这篇文章从架构、实现、性能和局限四个维度,把三个系统拆开来看。
一、etcd:为协调而生的小型 KV
1.1 设计定位
etcd 诞生于 2013 年的 CoreOS 项目,最初目的是为分布式系统提供一个可靠的配置存储和服务发现组件。2014 年 Kubernetes 选择 etcd 作为其唯一的状态存储后端,etcd 成为云原生基础设施中最关键的组件之一。
etcd 的设计哲学可以用一句话概括:正确性优先,功能克制。它不试图成为通用数据库,不追求极致吞吐量,只做一件事——提供一个强一致、高可用的小规模键值存储,适合存储集群元数据、配置信息和协调状态。
1.2 架构剖析
etcd 的架构核心是一个单 Raft 组(Single Raft Group)。整个集群的所有数据都由同一个 Raft 实例管理,所有写请求必须经过 Leader 节点,由 Leader 将日志条目复制到多数节点后才确认提交。
Client Request
|
v
+-------------------+
| gRPC Server | <-- 客户端接入层
+-------------------+
|
v
+-------------------+
| KV Server | <-- 请求路由与鉴权
+-------------------+
|
v
+-------------------+
| MVCC Module | <-- treeIndex (B-Tree in memory)
| Revision-based | + Backend (BoltDB on disk)
+-------------------+
|
v
+-------------------+
| Raft Module | <-- WAL + Snapshot
+-------------------+
|
v
+-------------------+
| BoltDB/bbolt | <-- B+Tree, mmap, single-writer
+-------------------+
MVCC 机制:etcd v3 引入了基于修订版本号(Revision)的多版本并发控制。每次写操作会生成一个全局递增的 Revision,旧版本不会被立即删除,而是保留到压缩(Compaction)操作执行。内存中的 treeIndex 维护了 key 到 Revision 的映射,实际的键值数据存储在 BoltDB 中。
// etcd 内部的 keyIndex 结构(简化)
type keyIndex struct {
key []byte
modified revision // 最后修改的 revision
generations []generation // 每个 generation 包含创建到删除的 revision 列表
}
type revision struct {
main int64 // 全局递增的事务 ID
sub int64 // 同一事务内的操作序号
}Watch 机制:etcd 的 Watch 是其最重要的特性之一。客户端可以监听某个 key 或前缀的变更事件,etcd 保证事件按 Revision 顺序投递,不会丢失、不会乱序。Watch 的实现依赖于 MVCC 的历史版本——当 Watcher 指定一个起始 Revision 时,etcd 可以从该 Revision 开始回放所有变更。
// Watch 使用示例
watchChan := client.Watch(context.Background(), "/services/",
clientv3.WithPrefix(),
clientv3.WithRev(lastKnownRevision),
)
for watchResp := range watchChan {
for _, event := range watchResp.Events {
switch event.Type {
case mvccpb.PUT:
fmt.Printf("PUT %s -> %s\n", event.Kv.Key, event.Kv.Value)
case mvccpb.DELETE:
fmt.Printf("DELETE %s\n", event.Kv.Key)
}
}
}Lease 机制:etcd 提供 Lease(租约)来实现 TTL 语义。一个 Lease 可以绑定多个 key,当 Lease 过期或被撤销时,所有绑定的 key 自动删除。这是实现服务注册、分布式锁和 Leader 选举的关键原语。
1.3 存储引擎:BoltDB/bbolt
etcd 使用 bbolt(BoltDB 的社区维护分支)作为底层存储。BoltDB 是一个纯 Go 实现的嵌入式键值数据库,基于 B+ 树结构,使用 mmap 将数据文件映射到内存。
BoltDB 的关键特征:
- 单写者模型:同一时刻只允许一个写事务,读事务不受限制。这意味着写吞吐量存在天然上限。
- COW(Copy-on-Write)语义:写事务不会修改原有页面,而是分配新页面写入,提交时原子更新元数据。这保证了读事务看到的是一致的快照。
- mmap:整个数据文件通过 mmap 映射到进程地址空间。读取数据时直接通过指针访问,无需额外的缓冲区管理。
- 无 WAL:BoltDB 自身不使用 WAL。但 etcd 在 Raft 层有独立的 WAL,数据安全由 Raft 日志保证。
这些特性决定了 etcd 的性能天花板。写入路径是:客户端请求 → Raft 日志复制 → Apply 到 BoltDB → 返回客户端。每一步都是串行的。
1.4 性能特征
etcd 官方给出的参考数据(3 节点集群,SSD 存储):
| 指标 | 数值 |
|---|---|
| 读延迟(线性读) | 10-20 ms |
| 读延迟(串行读) | < 1 ms |
| 写延迟 | 10-30 ms |
| 写吞吐量 | ~10,000 QPS |
| 推荐数据量上限 | 2 GB(默认),最大 8 GB |
| 单个 Value 上限 | 1.5 MB |
| Watch 连接数 | 数千级别 |
线性读(Linearizable Read)需要经过 Raft 确认当前 Leader 仍然有效(通过 ReadIndex 或 LeaseRead),因此延迟高于串行读。在需要强一致性保证的场景中,线性读是默认选择。
etcd 线性读 ReadIndex 流程
etcd 的线性读实现是理解其一致性保证的关键路径。客户端发起线性读时,etcd 不能直接从本地状态机读取——因为 Leader 可能已经被网络分区隔离,不再是真正的 Leader。ReadIndex 机制通过一次轻量级的 Raft 交互来确认 Leader 身份的有效性:
sequenceDiagram
participant C as 客户端
participant L as Leader
participant F1 as Follower-1
participant F2 as Follower-2
C->>L: 线性读请求 Get("/service/config")
L->>L: 记录当前 CommitIndex 作为 ReadIndex
Note over L: 需要确认自身仍为合法 Leader
L->>F1: 心跳(确认 Leader 身份)
L->>F2: 心跳(确认 Leader 身份)
F1-->>L: 心跳 ACK
F2-->>L: 心跳 ACK
Note over L: 多数派确认,Leader 身份有效
L->>L: 等待 AppliedIndex >= ReadIndex
L->>L: 从状态机读取 "/service/config"
L-->>C: 返回值(保证线性一致性)
该时序图展示了 ReadIndex 协议的核心逻辑:Leader 先记录当前的 CommitIndex 作为读取的下界,然后通过一次心跳广播确认自身仍被多数派承认。只有在收到多数派的心跳确认后,Leader 才能确定自己没有被新 Leader 取代,此时从状态机中读取的数据一定包含了所有已提交的写入。这个额外的心跳往返是线性读延迟高于串行读的根本原因——串行读跳过了身份确认步骤,直接从本地状态机读取,可能读到过期数据。
1.5 已知局限
单 Raft 组瓶颈:所有数据由一个 Raft 实例管理。Leader 是所有写请求的唯一入口,写吞吐量无法通过增加节点来扩展。增加 Follower 节点只能提高读吞吐(如果启用了 Learner 或串行读)。
Compaction 问题:etcd 保留的历史版本会持续占用磁盘空间。Compaction 操作回收旧版本占用的空间,但 BoltDB 的 freelist 管理存在碎片化问题。极端情况下,即使 Compaction 后磁盘空间使用量也不会显著下降,需要通过 defrag 操作重新整理数据文件。defrag 期间节点不可用。
# 手动触发 Compaction
etcdctl compaction --physical 12345
# 整理碎片
etcdctl defrag --endpoints=http://127.0.0.1:2379Watch 事件可靠性:虽然 Watch 保证事件有序投递,但如果 Watcher 指定的起始 Revision 已被 Compaction 回收,Watch 会失败并返回 ErrCompacted。客户端需要处理这种情况,通常是重新全量获取数据后重建 Watch。
大 Value 性能退化:当单个 Value 接近 1.5 MB 上限时,Raft 日志的复制和持久化开销显著增大。Kubernetes 中偶尔出现的 CRD 对象过大问题会直接影响 etcd 集群稳定性。
二、TiKV:面向大规模数据的分布式 KV
2.1 设计定位
TiKV 由 PingCAP 于 2016 年开源,最初作为分布式数据库 TiDB 的存储引擎。与 etcd 不同,TiKV 的设计目标是支撑 TB 乃至 PB 级别的数据量,提供水平扩展能力和分布式事务(Distributed Transaction)支持。2019 年 TiKV 成为 CNCF 毕业项目。
TiKV 既可以作为 TiDB 的后端存储使用,也可以作为独立的分布式键值存储系统,通过 Raw KV API 或 Txn KV API 直接访问。
2.2 架构剖析
TiKV 的核心架构由三个组件构成:TiKV 节点(TiKV Store)、Placement Driver(PD)和客户端(TiKV Client)。
+-------------+
| PD | <-- 元数据管理,TSO 时间戳分配
| (etcd内嵌) | Region 调度,负载均衡
+------+------+
|
+---------------+---------------+
| | |
+-----v-----+ +-----v-----+ +-----v-----+
| TiKV | | TiKV | | TiKV |
| Store 1 | | Store 2 | | Store 3 |
| | | | | |
| +-------+ | | +-------+ | | +-------+ |
| |Region1| | | |Region1| | | |Region1| |
| |(Leader)| | | |(Follow)| | | |(Follow)| |
| +-------+ | | +-------+ | | +-------+ |
| +-------+ | | +-------+ | | +-------+ |
| |Region2| | | |Region3| | | |Region2| |
| |(Follow)| | | |(Leader)| | | |(Leader)| |
| +-------+ | | +-------+ | | +-------+ |
| | | | | |
| RocksDB | | RocksDB | | RocksDB |
| (Raft+KV) | | (Raft+KV) | | (Raft+KV) |
+-----------+ +-----------+ +-----------+
Multi-Raft:TiKV 将整个 key 空间按范围(Range)划分为多个 Region,默认每个 Region 96 MB。每个 Region 对应一个独立的 Raft 组,拥有自己的 Leader 和 Follower 副本。不同 Region 的 Raft 日志复制并行执行,互不干扰。这是 TiKV 实现水平扩展的核心机制——Region 数量随数据量增长,每个 Region 的 Raft 操作独立,总吞吐量随节点数线性增长。
当一个 Region 的数据量超过阈值(默认 96 MB)时,TiKV 会自动执行 Region Split,将一个 Region 拆分为两个。反过来,当相邻 Region 的数据量过小时,可以执行 Region Merge 合并。
Placement Driver(PD):PD 是 TiKV 集群的大脑,承担三个核心职责:
- TSO(Timestamp Oracle):为分布式事务提供全局唯一且单调递增的时间戳。每次事务开始和提交时都需要从 PD 获取时间戳。
- Region 元数据管理:维护所有 Region 的路由信息——每个 Region 的 key 范围、副本分布和 Leader 位置。
- 调度决策:根据各节点的负载、存储容量和 Region 分布,自动执行 Region 迁移、Leader 转移和 Split/Merge 操作。
PD 自身是一个小型 etcd 集群(内嵌 etcd),通过 Raft 保证元数据的一致性。
2.3 分布式事务:Percolator 模型
TiKV 的分布式事务实现基于 Google Percolator 论文(2010 年发表于 OSDI)。Percolator 本质上是一个基于快照隔离(Snapshot Isolation,SI)的两阶段提交(2PC)协议,但不依赖传统的中心化事务协调器。
事务流程如下:
- Begin:客户端从 PD 获取一个 start_ts(起始时间戳)。
- Read:使用 start_ts 读取数据的对应版本(MVCC 快照读)。
- Prewrite:将所有修改写入各 Region。每个 key 的修改以 Lock 记录的形式写入,其中一个 key 被选为 Primary Key,其余为 Secondary Key。Secondary Key 的 Lock 中记录 Primary Key 的位置。
- Commit:从 PD 获取 commit_ts(提交时间戳)。先提交 Primary Key——将其 Lock 记录替换为 Write 记录。Primary 提交成功即视为整个事务提交成功。随后异步提交所有 Secondary Key。
// Percolator 事务流程(伪代码)
func (txn *Transaction) Commit() error {
// 选择 primary key
primary := txn.mutations[0]
secondaries := txn.mutations[1:]
// Prewrite: 先写 primary,再写 secondaries
if err := txn.prewrite(primary, primary.Key); err != nil {
return err // 冲突,事务中止
}
for _, m := range secondaries {
if err := txn.prewrite(m, primary.Key); err != nil {
return err
}
}
// 获取 commit_ts
commitTS := txn.pd.GetTimestamp()
// Commit primary
if err := txn.commitPrimary(primary, commitTS); err != nil {
return err
}
// 异步 commit secondaries(即使失败也不影响事务结果)
go func() {
for _, m := range secondaries {
txn.commitSecondary(m, commitTS)
}
}()
return nil
}这个设计的关键优势在于:Primary Key 的提交是原子性的(单 Region 内的 Raft 操作),事务的提交/中止状态由 Primary Key 的记录决定。即使 Secondary Key 的异步提交失败,后续读取时可以通过查找 Primary Key 的状态来判断事务是否已提交,并做相应的清理(Resolve Lock)。
以下时序图展示了 TiKV Percolator 两阶段提交的完整流程,以一个跨 Region 的转账事务为例(从账户 A 扣款,向账户 B 加款):
sequenceDiagram
participant C as TiKV Client
participant PD as PD(TSO)
participant R_A as Region-A(账户 A)
participant R_B as Region-B(账户 B)
C->>PD: 获取 start_ts
PD-->>C: start_ts = 100
Note over C,R_B: Prewrite 阶段(加锁 + 写数据)
C->>R_A: Prewrite(key=A, value=balance-100,<br/>primary=A, start_ts=100)
R_A->>R_A: 检查冲突:CF_WRITE 无更新版本,CF_LOCK 无锁
R_A->>R_A: 写入 CF_LOCK: Lock(A, start_ts=100, primary=A)<br/>写入 CF_DEFAULT: (A, 100) → balance-100
R_A-->>C: Prewrite 成功
C->>R_B: Prewrite(key=B, value=balance+100,<br/>primary=A, start_ts=100)
R_B->>R_B: 检查冲突,写入 Lock 和 Data
R_B-->>C: Prewrite 成功
C->>PD: 获取 commit_ts
PD-->>C: commit_ts = 105
Note over C,R_B: Commit 阶段(先提交 Primary)
C->>R_A: Commit(key=A, start_ts=100, commit_ts=105)
R_A->>R_A: 删除 CF_LOCK(A)<br/>写入 CF_WRITE: (A, 105) → start_ts=100
R_A-->>C: Commit 成功(事务已决议)
Note over C,R_B: 异步提交 Secondary
C-->>R_B: Commit(key=B, start_ts=100, commit_ts=105)
R_B->>R_B: 删除 CF_LOCK(B)<br/>写入 CF_WRITE: (B, 105) → start_ts=100
该时序图清晰展示了 Percolator 事务的关键设计:两个时间戳(start_ts 用于快照读,commit_ts 用于确定事务顺序)均由中心化的 PD TSO 分配,保证全局单调递增。Primary Key 的 Commit 是整个事务的决议点——一旦 Primary 的 Write 记录写入成功,事务即不可撤销。Secondary Key 的异步提交即使失败也不影响事务正确性,后续读取者遇到残留的 Lock 时会查询 Primary Key 的状态来判断该事务是否已提交。
MVCC 存储布局:TiKV 在 RocksDB 中为每个用户 key 维护三个列族(Column Family):
| 列族 | 内容 | 用途 |
|---|---|---|
| CF_DEFAULT | (key, start_ts) → value |
存储实际数据 |
| CF_LOCK | key → lock_info |
存储事务锁信息 |
| CF_WRITE | (key, commit_ts) → write_info |
存储提交记录 |
读取时,先查 CF_WRITE 找到对 start_ts 可见的最新 commit_ts,再通过 commit_ts 对应的 start_ts 到 CF_DEFAULT 中取出实际 value。
2.4 Coprocessor:计算下推
TiKV 提供 Coprocessor 框架,允许将计算逻辑下推到存储层执行。TiDB 在执行 SQL 查询时,会将过滤条件(WHERE)、聚合函数(SUM/COUNT/AVG)和 TopN 操作下推到 TiKV 的 Coprocessor 中执行,减少网络传输的数据量。
传统模式:
TiDB: SELECT count(*) FROM t WHERE age > 18
→ TiKV 返回所有 age > 18 的行
→ TiDB 在内存中计算 count
下推模式:
TiDB: SELECT count(*) FROM t WHERE age > 18
→ TiKV Coprocessor 在本地执行 filter + count
→ TiKV 只返回 count 结果
这对于大数据量的聚合查询性能提升非常显著。一个扫描 1000 万行的 COUNT 查询,如果不下推需要在网络上传输数 GB 的数据;下推后只传输一个整数。
2.5 存储引擎:RocksDB
TiKV 在每个节点上使用两个 RocksDB 实例:
- RaftDB:存储 Raft 日志。所有 Region 的 Raft 日志共享同一个 RocksDB 实例。
- KvDB:存储实际的键值数据。包含 CF_DEFAULT、CF_LOCK、CF_WRITE 三个列族。
RocksDB 是基于 LSM-Tree(Log-Structured Merge-Tree)的存储引擎。写入时数据先进入 MemTable(内存中的有序结构),MemTable 写满后 flush 到磁盘生成 SST 文件。后台 Compaction 进程定期合并多层 SST 文件,清理删除标记(Tombstone)和旧版本。
LSM-Tree 的优势是写入吞吐高(顺序写磁盘),劣势是读放大(可能需要查询多层 SST 文件)和空间放大(Compaction 前后数据共存)。TiKV 通过 Bloom Filter、Block Cache 和合理的 Compaction 策略来缓解这些问题。
2.6 性能特征
TiKV 的性能随集群规模水平扩展。以下是典型配置下的参考数据:
| 指标 | 数值 |
|---|---|
| 单节点写吞吐(Raw KV) | ~80,000 QPS |
| 单节点读吞吐(Raw KV) | ~120,000 QPS |
| 写延迟 P99(Txn KV) | 10-30 ms |
| 读延迟 P99(Point Get) | 1-5 ms |
| 单集群容量 | PB 级别 |
| Region 默认大小 | 96 MB |
写吞吐的线性扩展来源于 Multi-Raft:每个 Region 的 Raft 组独立运行,增加节点意味着增加可并行处理的 Region 数量。但需要注意的是,热点 Region(某个 key 范围被频繁访问)仍然是单 Raft 组的瓶颈,PD 的调度能力在极端热点场景下可能不够及时。
2.7 已知局限
Raft 开销:每个 Region 维护一个独立的 Raft 状态机,包括心跳、日志复制和 Leader 选举。当集群中 Region 数量达到数十万甚至百万时,Raft 的心跳和选举流量会占用显著的 CPU 和网络资源。TiKV 通过批量心跳(Raft Batch)和合并小 Region 来缓解,但 Region 数量仍然是需要关注的运维指标。
Compaction 风暴:RocksDB 的后台 Compaction 在数据量大时可能产生大量磁盘 I/O,影响前台读写延迟。这个问题在写入负载高且磁盘带宽有限的场景中尤为明显。TiKV 提供了 Compaction 限速和优先级控制参数,但调优需要经验。
热点 Region
问题:如果业务的访问模式集中在少量 key 范围,对应的
Region 会成为瓶颈。PD 可以通过 Leader 转移和 Region
分裂来分散热点,但响应速度有限。对于自增 ID 类的写入模式(如
INSERT INTO t VALUES (auto_increment_id, ...)),TiKV
的 key 编码方式会导致写入集中在最后一个
Region,形成写热点。TiDB 通过 SHARD_ROW_ID_BITS
等参数来打散热点。
PD 单点风险:虽然 PD 是一个多节点的 Raft 集群,但 TSO 服务只由 PD Leader 提供。PD Leader 切换时,所有正在进行的事务需要等待新 Leader 上线才能继续获取时间戳。PD 的高可用性直接影响 TiKV 集群的可用性。
三、FoundationDB:确定性模拟驱动的分层架构
3.1 设计定位
FoundationDB 于 2009 年创立,2015 年被 Apple 收购,2018 年以 Apache 2.0 协议开源。2021 年发表于 SIGMOD 的论文《FoundationDB: A Distributed Unbundled Transactional Key Value Store》系统性地阐述了其设计理念。
FoundationDB 的核心哲学是”解耦”(Unbundled):将事务处理和数据存储完全分离,通过分层架构让上层系统(Layer)在一个提供严格可串行化(Strict Serializability)事务保证的键值存储之上构建各种数据模型。Apple 的 CloudKit、Snowflake 的元数据服务都基于 FoundationDB 构建。
与 etcd 和 TiKV 不同,FoundationDB 最突出的工程特色是确定性模拟(Deterministic Simulation)测试框架。FoundationDB 团队坚信:分布式系统的正确性无法通过人工推理保证,必须通过穷举式的模拟测试来验证。这个测试框架能够在单进程中模拟完整的多节点集群,注入各种故障场景(网络分区、磁盘故障、进程崩溃),并以确定性方式重放故障序列。
3.2 架构剖析
FoundationDB 的架构由五种角色构成:
Client
|
v
+----------+ +----------+
| Proxy |--->| Resolver | <-- 事务冲突检测
+----+-----+ +----------+
|
v
+----+-----+
| Sequencer| <-- 全局事务排序
+----+-----+
|
v
+----+-----+
|Log Server| <-- 持久化事务日志(WAL)
+----+-----+
|
v (异步)
+----+-----+
|Storage | <-- 服务读请求,保存数据快照
|Server |
+----------+
Coordinator:集群的引导节点,存储集群配置信息。使用 Paxos 在多个 Coordinator 之间达成共识,确定当前活跃的 Sequencer。
Sequencer:全局唯一的事务排序器。Sequencer 为每个事务分配提交版本号(Commit Version),这个版本号决定了事务的全局顺序。Sequencer 是单点——但 FoundationDB 的恢复机制可以在 Sequencer 故障后秒级完成切换。
Proxy:事务处理的入口。客户端将事务的读写操作发送到 Proxy,Proxy 负责从 Sequencer 获取版本号,向 Resolver 检查冲突,向 Log Server 写入事务日志。多个 Proxy 并行处理事务。
Resolver:事务冲突检测器。Resolver 维护一个近期事务的读写集合(Read Conflict Range 和 Write Conflict Range),对新提交的事务进行乐观并发控制(Optimistic Concurrency Control,OCC)检查。如果两个事务的写集与读集存在冲突,后提交的事务被中止。
Log Server:事务日志的持久化存储。Log Server 使用类似 Paxos 的复制协议将事务日志写入多个副本。一旦日志持久化成功,事务即被视为已提交。
Storage Server:服务读请求并维护数据快照。Storage Server 从 Log Server 异步拉取事务日志,应用到本地存储(一个基于 SQLite 修改的 B-Tree 引擎,称为 SSD Engine)。注意:Storage Server 的数据可能稍滞后于 Log Server,但读请求通过版本号机制保证一致性——Storage Server 只会返回已应用到本地的版本。
读写路径分离:写请求的路径是 Client → Proxy → Resolver(冲突检测)→ Log Server(持久化)。读请求的路径是 Client → Storage Server(直接读取)。读写完全解耦,读不阻塞写,写不阻塞读。
以下时序图展示了 FoundationDB 一个写事务从提交到持久化的完整流水线:
sequenceDiagram
participant C as 客户端
participant P as Proxy
participant Seq as Sequencer
participant R as Resolver
participant LS as Log Server
participant SS as Storage Server
C->>P: 提交事务(读集 + 写集)
P->>Seq: 请求 commit_version
Seq-->>P: commit_version = 1042
P->>R: 冲突检测(读集的 key 范围 + read_version)
R->>R: 检查 [read_version, commit_version] 区间<br/>是否有其他事务写入了相同 key 范围
R-->>P: 无冲突
P->>LS: 写入事务日志(commit_version=1042, mutations)
LS->>LS: 复制到多数 Log Server 副本
LS-->>P: 持久化确认
P-->>C: 事务提交成功
Note over LS,SS: 异步:Storage Server 拉取并应用日志
LS-->>SS: 推送事务日志
SS->>SS: 应用 mutations 到本地 B-Tree
该时序图揭示了 FoundationDB 事务提交的四个关键阶段。版本分配阶段由单点 Sequencer 提供全局唯一的 commit_version,保证所有事务的全序关系。冲突检测阶段由 Resolver 基于乐观并发控制检查事务的读集与其他已提交事务的写集是否存在交集。持久化阶段将事务日志写入 Log Server 的多数副本——一旦持久化成功,事务即被视为已提交。最后,Storage Server 异步从 Log Server 拉取日志并应用到本地存储,这个异步过程不影响事务的提交延迟。
3.3 确定性模拟测试
FoundationDB 的确定性模拟(Deterministic Simulation)框架是整个系统最具创新性的部分。它的核心思想:将所有非确定性操作(网络 I/O、磁盘 I/O、时钟、随机数)抽象为可替换的接口,在测试时用确定性的模拟实现替换。
// FoundationDB 中的网络抽象(概念性代码)
class INetwork {
public:
virtual Future<Void> delay(double seconds) = 0;
virtual Future<Reference<IConnection>> connect(NetworkAddress) = 0;
virtual double now() = 0;
// ...
};
// 生产环境使用真实网络
class Net2 : public INetwork { /* ... */ };
// 测试环境使用模拟网络
class Sim2 : public INetwork {
// 所有操作由确定性调度器控制
// 相同的随机种子产生相同的执行序列
};模拟器可以在单进程中运行完整的多数据中心集群,并注入以下故障:
- 网络分区(任意节点对之间)
- 网络延迟和丢包
- 进程崩溃和重启
- 磁盘故障(写入失败、数据损坏)
- 时钟偏移
由于所有操作都是确定性的,当模拟器发现 bug 时,可以用相同的随机种子精确重放故障序列,极大降低了调试难度。FoundationDB 团队声称,该框架在生产部署前已发现并修复了数百个仅在极端故障组合下才会触发的 bug。
3.4 事务模型
FoundationDB 提供严格可串行化(Strict Serializability)的事务保证,这是最强的一致性级别——等同于所有事务在某个全局时间线上串行执行,且事务的顺序与实际发生的时间一致。
事务使用 OCC(乐观并发控制):事务执行期间不加锁,提交时由 Resolver 检查冲突。冲突检测基于读冲突范围(Read Conflict Range)和写冲突范围(Write Conflict Range)。
# FoundationDB Python 客户端示例
import fdb
fdb.api_version(710)
db = fdb.open()
@fdb.transactional
def transfer(tr, from_acct, to_acct, amount):
from_bal = int(tr[from_acct])
to_bal = int(tr[to_acct])
if from_bal < amount:
raise ValueError("Insufficient funds")
tr[from_acct] = str(from_bal - amount).encode()
tr[to_acct] = str(to_bal + amount).encode()
# @fdb.transactional 装饰器自动处理:
# 1. 事务开始(获取 read_version)
# 2. 事务提交(获取 commit_version,冲突检测)
# 3. 冲突重试(自动重试被中止的事务)5 秒事务限制:FoundationDB 强制要求每个事务必须在 5 秒内完成。这个限制不是技术缺陷,而是刻意的设计决策。长事务会阻碍系统恢复——如果允许长事务存在,Resolver 需要维护更大的冲突检测窗口,Log Server 需要保留更多日志。5 秒的限制使得系统在任何故障后都能在有界时间内完成恢复。
10 MB 事务大小限制:单个事务的读写数据总量不能超过 10 MB。这同样是为了限制事务日志的大小和冲突检测的开销。对于批量数据加载,应用需要自行拆分为多个小事务。
3.5 Layer 概念
FoundationDB 将自身定位为一个”存储基元”(Storage Primitive),鼓励在其之上构建各种数据模型。这些上层抽象被称为 Layer。
- Record Layer:Apple 开发并开源的结构化存储层,提供类似关系数据库的 Schema、索引和查询能力。CloudKit 的后端基于 Record Layer 构建。Record Layer 将 Protocol Buffers 编码的记录存储为 FoundationDB 的键值对,并在同一个 FoundationDB 事务中原子性地维护二级索引。
- Document Layer:提供 MongoDB 兼容的文档数据库接口。已被标记为实验性项目。
- 目录和子空间(Directory & Subspace):提供多租户的 key 空间隔离。每个租户的数据映射到不重叠的 key 前缀,在物理层面实现隔离。
Layer 的设计哲学是:将事务性键值存储做到极致,上层的数据模型和查询语义由 Layer 提供。这与 TiKV 的”既可以是存储引擎也可以是独立数据库”的定位有本质区别。
3.6 存储引擎
FoundationDB 的 Storage Server 使用一个称为 SSD Engine 的定制存储引擎。早期版本基于 SQLite 的 B-Tree 修改而来(去掉了 SQL 解析层,只保留存储和索引部分),后续版本引入了 Redwood(一个新的 B+Tree 实现)来替代 SQLite B-Tree。
SSD Engine 的特点:
- B-Tree 结构:与 RocksDB 的 LSM-Tree 不同,B-Tree 更适合读密集型工作负载,读放大更低。
- 异步持久化:Storage Server 从 Log Server 异步拉取事务日志并应用。数据的持久化由 Log Server 保证,Storage Server 只是数据的缓存和索引。即使某个 Storage Server 丢失全部数据,也可以从 Log Server 重新恢复。
- 无 WAL:Storage Server 自身不需要 WAL,因为事务日志已经由 Log Server 持久化。
3.7 性能特征
FoundationDB 的官方基准测试数据(使用官方 benchmarking tool):
| 指标 | 数值 |
|---|---|
| 单节点随机读 | ~100,000 QPS |
| 单节点随机写 | ~50,000 QPS |
| 读延迟 P99 | 1-5 ms |
| 写延迟 P99(含事务提交) | 15-25 ms |
| 集群容量 | TB 级别 |
| Key 大小上限 | 10 KB |
| Value 大小上限 | 100 KB |
| 事务大小上限 | 10 MB |
| 事务时间上限 | 5 秒 |
FoundationDB 的读写路径解耦使得读性能可以通过增加 Storage Server 独立扩展。写性能受限于 Sequencer(单点)和 Log Server 的复制开销,但在实际部署中,写吞吐量随 Proxy 和 Log Server 数量线性增长,瓶颈通常在磁盘 I/O。
3.8 已知局限
5 秒事务窗口:对于需要长时间运行的事务(如批量数据迁移、大范围扫描),5 秒的限制是一个硬约束。应用层需要自行实现分批处理逻辑,将一个逻辑上的大操作拆分为多个小事务。
Key/Value 大小限制:Key 上限 10 KB,Value 上限 100 KB。对于需要存储大对象(如图片、文件)的场景,应用需要将大对象拆分为多个小块存储。
Sequencer 单点:虽然 Sequencer 故障后可以秒级恢复,但在故障窗口内所有写事务会失败。这是一个有意的设计折衷——用单点简化全局排序的逻辑,用快速恢复弥补可用性影响。
运维复杂度:FoundationDB 的角色较多(Coordinator、Sequencer、Proxy、Resolver、Log Server、Storage Server),部署和调优需要理解各角色的资源需求。相比 etcd 的三节点部署,FoundationDB 的运维门槛明显更高。
社区生态:尽管 Apple 已经开源了 FoundationDB,但其社区活跃度远不及 etcd 和 TiKV。文档相对有限,第三方工具和集成较少。
四、架构差异对比
| 维度 | etcd | TiKV | FoundationDB |
|---|---|---|---|
| 共识协议 | 单 Raft 组 | Multi-Raft(每 Region 一个) | Paxos(Log Server) |
| 存储引擎 | BoltDB/bbolt(B+Tree) | RocksDB(LSM-Tree) | SSD Engine / Redwood(B-Tree) |
| 数据分片 | 无分片,全量复制 | Range 分片(Region) | Range 分片(Shard) |
| 事务支持 | Mini-Transaction(STM) | 分布式事务(Percolator 2PC) | OCC 严格可串行化 |
| 一致性级别 | 线性一致性 | 快照隔离(SI)/ 线性一致性 | 严格可串行化 |
| 读写分离 | 读可走 Follower(串行读) | 读走 Leader / Follower Read | 完全解耦(读走 SS,写走 LS) |
| 开发语言 | Go | Rust | C++ / Flow |
| 时钟机制 | Raft 任期 | TSO(PD 分配) | Sequencer 版本号 |
| 测试方法 | 单元测试 + 集成测试 | 单元测试 + Chaos 测试 | 确定性模拟测试 |
| 许可证 | Apache 2.0 | Apache 2.0 | Apache 2.0 |
4.1 隔离级别与延迟的权衡分析
三个系统选择了截然不同的一致性与隔离级别,每个选择都有明确的延迟代价:
etcd:线性一致性,读延迟高。etcd 的线性读需要一次额外的 Raft 心跳往返来确认 Leader 身份,典型延迟 10-20ms。串行读可以亚毫秒响应,但只保证读到已应用的数据,不保证读到最新提交的数据。etcd 不提供事务隔离级别的概念——它的 Mini-Transaction(STM)是单次原子操作,不支持多语句交互式事务,因此不存在隔离级别的选择问题。代价是应用无法在多个读写操作之间维持一致性快照。
FoundationDB:严格可串行化(SSI),写延迟高。FoundationDB 提供最强的隔离保证——等价于所有事务串行执行且顺序与物理时间一致。这个保证的代价是写路径长:事务提交需要经过 Proxy → Sequencer(版本分配)→ Resolver(冲突检测)→ Log Server(持久化),典型写延迟 15-25ms。但读延迟极低(1-5ms),因为读直接走 Storage Server,不参与事务提交路径。冲突事务需要客户端重试,高冲突场景下有效吞吐会显著下降。
TiKV:快照隔离(SI),读写延迟均衡。TiKV 基于 MVCC 实现快照隔离,事务读取 start_ts 时刻的一致性快照,不受并发写入影响。点读延迟 1-5ms(走 Region Leader),事务写延迟 10-30ms(Prewrite + Commit 两轮 Raft 复制)。快照隔离弱于可串行化——它不能防止写偏斜(Write Skew)异常。TiDB 在此基础上提供了悲观锁模式来缓解高冲突场景的重试问题,但不提升到可串行化级别。
4.2 相同操作的跨系统实现对比
以”原子递增计数器”(read-modify-write)为例,对比三个系统的实现方式和语义差异:
etcd 实现——使用 Mini-Transaction(Compare-and-Swap):
// etcd:原子递增计数器
resp, _ := client.Get(ctx, "counter")
oldVal, _ := strconv.Atoi(string(resp.Kvs[0].Value))
newVal := strconv.Itoa(oldVal + 1)
// CAS:只有当 ModRevision 未变时才更新
txnResp, _ := client.Txn(ctx).
If(clientv3.Compare(clientv3.ModRevision("counter"), "=", resp.Kvs[0].ModRevision)).
Then(clientv3.OpPut("counter", newVal)).
Else(clientv3.OpGet("counter")).
Commit()
// 如果 If 条件失败,需要从 Else 结果重试etcd 的 CAS 语义是单次原子操作,冲突时需要应用层自行重试。没有事务上下文,不能在 CAS 操作中同时读写多个不相关的 key 并保证原子性。
TiKV 实现——使用 Percolator 分布式事务:
// TiKV:在事务中递增计数器
txn, _ := client.Begin()
val, _ := txn.Get(ctx, []byte("counter"))
oldVal, _ := strconv.Atoi(string(val))
txn.Set([]byte("counter"), []byte(strconv.Itoa(oldVal+1)))
err := txn.Commit(ctx)
// 冲突时 Percolator 在 Prewrite 阶段检测到锁冲突,返回错误TiKV 的事务可以包含多个读写操作,start_ts 确定读快照,commit_ts 确定写入的全局顺序。冲突检测发生在 Prewrite 阶段,如果目标 key 上已有其他事务的锁或更新版本的写入,当前事务被中止。
FoundationDB 实现——使用 OCC 事务(自动重试):
# FoundationDB:原子递增计数器
@fdb.transactional
def increment(tr):
val = tr[b"counter"]
old_val = int(val) if val else 0
tr[b"counter"] = str(old_val + 1).encode()
# @fdb.transactional 装饰器自动处理冲突重试FoundationDB 的 @fdb.transactional
装饰器在事务冲突时自动重试整个函数。Resolver 在 commit
时检查读集与其他事务写集的冲突——如果在
tr[b"counter"]
读取之后、事务提交之前,另一个事务修改了
counter,当前事务被中止并自动重试。严格可串行化保证了重试后的结果等价于某个串行执行顺序。
4.3 Leader 故障时的行为对比
Leader 故障是分布式系统最关键的容错场景。三个系统的应对策略反映了各自架构的根本差异:
etcd:Leader 故障后,剩余 Follower 通过
Raft 选举产生新 Leader。选举超时默认
1000-1500ms(election-timeout 参数),加上新
Leader 需要提交一个空日志条目确认
Leadership,总故障恢复时间通常在 2-5
秒。恢复期间所有写请求和线性读请求失败,串行读可以继续(但可能读到过期数据)。由于是单
Raft 组,Leader 故障影响整个集群的所有数据。
TiKV:某个 Region 的 Leader 故障只影响该
Region 的读写,其他 Region 不受影响——这是 Multi-Raft
架构的核心优势。单个 Region 的选举超时约 10
秒(raft-election-timeout-ticks x
raft-base-tick-interval),恢复时间通常在 10-30
秒。但如果 PD Leader 故障,TSO
服务中断,所有事务型操作暂停。PD 的 Leader 切换通过内嵌 etcd
的 Raft 完成,通常在数秒内恢复。
FoundationDB:Sequencer 是全局单点,故障后由 Coordinator 通过 Paxos 选举新 Sequencer。FoundationDB 的恢复设计目标是秒级完成:新 Sequencer 启动后,从 Log Server 读取最近的事务日志,确定恢复点,然后招募新的 Proxy、Resolver 和 Log Server 角色。整个过程通常在 5-10 秒内完成。恢复期间所有写事务失败,读请求可以继续(Storage Server 独立服务读请求)。FoundationDB 的 5 秒事务时间限制正是为了保证恢复时的日志回放量有界。
五、性能特征对比
5.1 延迟
三个系统的延迟特征有本质差异:
- etcd 的写延迟由 Raft 共识决定,典型值 10-30 ms。线性读需要一次 Raft 交互确认 Leader 身份,延迟与写相当。串行读直接从本地状态机读取,延迟 < 1 ms 但不保证最新数据。
- TiKV 的点读延迟 1-5 ms(直接走 Region Leader),事务写延迟 10-30 ms(包含 Prewrite 和 Commit 两轮 Raft 复制)。扫描操作的延迟取决于数据量和是否跨 Region。
- FoundationDB 的读延迟 1-5 ms(直接走 Storage Server),写延迟 15-25 ms(经过 Proxy → Resolver → Log Server 的完整路径)。
5.2 吞吐
- etcd:受限于单 Raft 组,写吞吐天花板约 10,000 QPS,与集群规模无关。
- TiKV:随节点数线性扩展。单节点 Raw KV 写入约 80,000 QPS,10 节点集群可达 500,000+ QPS。
- FoundationDB:写吞吐随 Proxy 和 Log Server 数量扩展。单集群实测可达数十万 QPS 写入。
5.3 容量
| 系统 | 推荐容量上限 | 硬性限制 |
|---|---|---|
| etcd | 2 GB | 8 GB(--quota-backend-bytes) |
| TiKV | 无上限(PB 级别) | 受物理资源限制 |
| FoundationDB | 无上限(TB 级别) | 受物理资源限制 |
六、使用场景对比与选型建议
6.1 选 etcd 的场景
- 集群元数据存储:Kubernetes 的 Pod、Service、ConfigMap 等对象存储。数据量小(通常 < 1 GB),对一致性和 Watch 有强需求。
- 服务发现与注册:服务实例向 etcd 注册地址,消费者通过 Watch 实时感知变化。Lease 机制自动清理失效注册。
- 分布式锁与 Leader 选举:etcd 的 Lock 和 Election API 提供了开箱即用的分布式协调原语。
- 配置中心:存储和分发应用配置,Watch 机制实现配置的实时推送。
关键判断标准:数据量 < 2 GB,读写 QPS < 10,000,需要 Watch 和 Lease 语义。
6.2 选 TiKV 的场景
- TiDB 的存储后端:TiDB 的行数据和索引数据都存储在 TiKV 中。这是 TiKV 最主要的使用场景。
- 大规模键值存储:数据量超过 etcd 上限,需要水平扩展的 KV 存储。如用户画像、特征存储、时序数据索引。
- 需要分布式事务的 KV 场景:多个 key 的原子性更新,跨 Region 的事务保证。
- 替代 Redis 集群:对于持久化需求强、数据量大、对一致性有要求的场景,TiKV 可以替代 Redis Cluster。
关键判断标准:数据量 GB-PB 级别,需要水平扩展吞吐,需要分布式事务。
6.3 选 FoundationDB 的场景
- 需要最强一致性保证:严格可串行化是所有一致性级别中最强的,适合金融、计费等对正确性要求极高的场景。
- 构建自定义数据模型:FoundationDB 的 Layer 架构允许在强事务 KV 之上构建关系模型(Record Layer)、文档模型、图模型等。
- 对系统可靠性要求极高:确定性模拟测试框架赋予了 FoundationDB 极高的可靠性信心。Apple 将其用于 iCloud 后端不是偶然。
- 元数据服务:Snowflake 使用 FoundationDB 存储元数据,利用其事务能力保证元数据操作的原子性。
关键判断标准:需要严格可串行化事务,数据模型灵活性要求高,对系统可靠性有极端要求。
6.4 选型决策树
数据量是否 < 2 GB?
├── 是 → 是否需要 Watch / Lease?
│ ├── 是 → etcd
│ └── 否 → 需要事务?
│ ├── 是 → FoundationDB
│ └── 否 → etcd 或 Redis
└── 否 → 是否需要分布式事务?
├── 是 → 一致性需求?
│ ├── 严格可串行化 → FoundationDB
│ └── 快照隔离即可 → TiKV
└── 否 → 是否需要 SQL?
├── 是 → TiDB(基于 TiKV)
└── 否 → TiKV Raw KV API
七、各系统的已知局限总结
7.1 etcd 的局限
- 容量天花板:8 GB 的硬限制意味着 etcd 不适合存储业务数据。Kubernetes 大规模集群(5000+ 节点)中 etcd 的数据量可能接近这个上限。
- 写吞吐瓶颈:单 Raft 组的写吞吐约 10,000 QPS,无法通过增加节点扩展。
- Compaction 与 Defrag:历史版本的清理和磁盘整理可能导致服务中断。
- 跨数据中心部署延迟:Raft 共识需要多数节点确认,跨数据中心的网络延迟直接影响写延迟。
7.2 TiKV 的局限
- Region 管理开销:百万级 Region 的心跳和调度对 PD 和 TiKV 都是负担。
- 热点处理:写热点会导致单 Region 的 Raft Leader 成为瓶颈,自动分裂和调度的响应速度有限。
- GC(垃圾回收):MVCC 的旧版本数据需要定期 GC,GC 不及时会导致存储膨胀和读性能下降。
- PD 依赖:PD 不可用时 TiKV 无法获取时间戳,事务型操作会失败。
7.3 FoundationDB 的局限
- 事务约束:5 秒时间窗口和 10 MB 大小限制要求应用层适应这些约束。
- Key/Value 大小限制:10 KB Key 和 100 KB Value 的限制在某些场景下需要额外的编码和拆分策略。
- 运维复杂度:多角色部署、配置文件管理和性能调优需要深入理解系统架构。
- 社区与生态:相比 CNCF 生态中的 etcd 和 TiKV,FoundationDB 的第三方工具、文档和社区支持相对薄弱。
- 客户端语言支持:官方提供 C、Python、Go、Java、Ruby 绑定,但部分语言的客户端成熟度和功能完整度不如 etcd 和 TiKV。
参考文献
- etcd 官方文档 — https://etcd.io/docs/
- TiKV 官方文档 — https://tikv.org/docs/
- Zhou, J. et al. “FoundationDB: A Distributed Unbundled Transactional Key Value Store.” SIGMOD 2021 — https://www.foundationdb.org/files/fdb-paper.pdf
- Ongaro, D. and Ousterhout, J. “In Search of an Understandable Consensus Algorithm.” USENIX ATC 2014 — https://raft.github.io/raft.pdf
- Peng, D. and Dabek, F. “Large-scale Incremental Processing Using Distributed Transactions and Notifications.” OSDI 2010 — https://research.google/pubs/pub36726/
- bbolt (BoltDB fork) GitHub — https://github.com/etcd-io/bbolt
- RocksDB 官方文档 — https://rocksdb.org/docs/
- Apple FoundationDB Record Layer — https://github.com/FoundationDB/fdb-record-layer
- Huang, D. et al. “TiDB: A Raft-based HTAP Database.” VLDB 2020 — https://vldb.org/pvldb/vol13/p3072-huang.pdf
| 上一篇 | 下一篇 |
|---|---|
| Ceph 与 CRUSH | NewSQL 架构拆解 |