主题
工程案例剖析:etcd、TiDB 与 Spanner 的取舍
本文是分布式系统系统学习系列的 L3 实战篇。前置:28. 共识算法:从 Paxos 到 Raft 的推导、31. 数据分片与一致性哈希。 学完可以配合面试题食用:21-分布式数据库 TiDB Percolator、13-etcd-raft-linearizable-read、18-distributed-clock-truetime-spanner-hlc
为什么读这三个系统
学完共识算法、一致性哈希、分布式事务这些概念后,最直接的困惑是:这些理论在真实系统里怎么组合? etcd、TiDB 和 Spanner 恰好代表了三种典型的取舍路径:
- etcd 把 Raft 做到极致,但主动限制自己只做元数据存储
- TiDB 用 Raft + Percolator 事务 + Region 调度,把分布式 KV 做成了兼容 MySQL 的分布式数据库
- Spanner 用 TrueTime + Paxos 组 + 全球部署,用硬件解决了大部分时钟问题
三个系统面对的约束不同,架构决策也不同。对比着看,比单独读任何一个的文档都更有收获。
etcd:Raft 的元数据专家
etcd 的设计哲学很明确:不做通用 KV,只做分布式系统的控制面存储。这个定位直接决定了它的一系列设计约束。
为什么限制 2GB 总容量
etcd 的底层存储是 boltDB,一个嵌入式的单机 KV 引擎。Raft 日志复制要求每一条日志都会被持久化,而 etcd 的 snapshot 机制会把整个 boltDB 的当前状态序列化出来。如果数据量太大,snapshot 的生成和传输都会卡住 Raft 的流水线。
官方文档建议总 key 数量不超过 1 万个,总大小不超过 2GB。这不是 bug,是设计意图:etcd 存的是"群原子"级的元数据,不是业务数据。
线性读走 ReadIndex 而不是 Raft Log
etcd 的读请求有三种模式:
go
// 模式1:串行读(最快,不保证线性一致)
// 直接读 store,不提 Raft
value := store.Get(key)
// 模式2:ReadIndex 读(线性一致,不写日志)
// 1. 向 Raft leader 提交 ReadIndex 请求
// 2. leader 确认当前 commit index(不需要写日志)
// 3. 等 applied index 追上 commit index 后返回
func (n *node) ReadIndex(ctx context.Context, rctx []byte) error {
// 实际在 etcd/raft/ 中通过 readState 实现
n.step(ctx, pb.Message{Type: pb.MsgReadIndex, Entries: []pb.Entry{{Data: rctx}}})
return nil
}
// 模式3:Raft Log 读(真正写一条日志,最慢)
// 只有 ReadIndex 不可用时(如 leader 刚上任)才走ReadIndex 的巧妙在于:不写日志,只问 commit index。这是线性一致读的"廉价版"——还是需要跟 Raft 通信,但绕过了磁盘写。对比一下,如果每次读都写一条 Raft 日志,etcd 的吞吐量会直接崩掉。
小 value 约束
etcd 的 value 建议不超过 1.5MB,实际生产环境大部分在 1KB 以内。原因很简单:Raft 的日志复制要把整条数据广播给所有 follower,value 太大意味着网络带宽被 Raft 日志吃满,正常业务请求反而排不上队。
TiDB:从 KV 到 SQL 的存算分离
TiDB 做的事情比 etcd 复杂得多:它把"兼容 MySQL 的分布式数据库"拆成了两层——TiDB 做 SQL 计算层,TiKV 做分布式 KV 存储层。
三层架构
SQL: TiDB 层(无状态)
|
Placement Driver (PD) —— 调度大脑
|
v
KV: TiKV 节点集群(多副本)PD 是 TiKV 的 etcd——它用 etcd 存储自己的元数据,然后调度 TiKV 的数据分布。
Region 与 Raft 组
TiKV 把数据按 key range 切分成 Region(默认 96MB),每个 Region 是一个独立的 Raft 组。这样设计的效果:
- 每个 Region 自动选主,写请求走 leader,读请求可以走 follower(一致性级别可选)
- Region 大小超过阈值时自动分裂,低于阈值时合并
- PD 负责在节点之间搬迁 Region,做负载均衡和故障恢复
go
// 伪代码:Region 分裂判断
type Region struct {
StartKey []byte
EndKey []byte
Peers []Peer // Raft 组成员
Size int64
}
func (r *Region) ShouldSplit() bool {
// 默认阈值 96MB,可配置
return r.Size > 96*1024*1024
}
func (r *Region) Split() (*Region, *Region) {
mid := findSplitKey(r) // 按实际数据分布找切分点
left := r.copyWithRange(r.StartKey, mid)
right := r.copyWithRange(mid, r.EndKey)
// 两个新 Region 各自组成新的 Raft 组
// 旧组的 peer 平分到两个新组
return left, right
}Percolator 事务
TiDB 的事务模型来自 Google 的 Percolator 论文,核心思路是乐观锁 + 分布式两阶段提交 + 时间戳选择器 (TSO)。
PD 作为全局授时节点,分配单调递增的 TSO。事务开始时拿一个 start_ts,提交时拿一个 commit_ts。如果两个事务的 start_ts 和 commit_ts 区间重叠,Percolator 的写写冲突检测通过检查 primary lock 来判断。
TiDB 默认使用乐观事务,写冲突时客户端重试。对于高冲突场景,可以开启悲观锁(在 TiKV 层加锁,类似 MySQL 的 SELECT ... FOR UPDATE)。
一台 TiDB 怎么处理一条 INSERT
简化版流程:
sql
INSERT INTO users (id, name) VALUES (42, 'Alice');- TiDB 解析 SQL -> 生成执行计划 -> 定位 id=42 属于哪个 Region
- TiDB 向 PD 查询该 Region 的 leader 所在的 TiKV 节点
- TiDB 通过 gRPC 向 TiKV leader 发送 Prewrite 请求(写锁和值)
- TiKV 写入锁后,返回 Prewrite 成功
- TiDB 向 TiKV leader 发送 Commit 请求
- TiKV 提交后,返回 Commit 成功给 TiDB,TiDB 返回给客户端
这个过程中,TiKV 的 Raft 组保证了数据在多个副本之间的一致复制。
Spanner:用钱解决一致性问题
Spanner 是 Google 的全球级分布式数据库,它的设计前提是其他系统不敢想的:每台机器配了 GPS 时钟和原子钟。
TrueTime 的精度
TrueTime 返回的不是一个精确时间,而是一个时间区间 [earliest, latest],保证真实时间落在区间内。Spanner 的典型区间宽度是 1-7ms。
go
// TrueTime 伪代码
type TrueTime struct {
Earliest time.Time
Latest time.Time
}
func (tt *TrueTime) Now() TrueTime {
// 从本机的 GPS/原子钟获取时间
// 加上安全边界(误差上界)
return TrueTime{
Earliest: localTime - maxError,
Latest: localTime + maxError,
}
}
// Spanner 提交事务时,用 TT.latest 作为 commit timestamp
// 这样即使不同机器的时钟有偏差,commit_ts 也不会违法
func (t *Transaction) Commit() {
// 等待 TT.now().latest < start_ts 的 TT.earliest
// 确保所有事务的 commit_ts 不会进入过去
t.commitTS = t.TT.now().Latest
// 执行 Paxos 写入
// 等待 commit_ts 对应的真实时间过去(commit wait)
sleep(t.commitTS - t.TT.now().Earliest)
}Paxos 组 + 目录
Spanner 的数据被组织成目录(directory),目录是数据移动的最小单位。一个 Paxos 组管理多个目录,每个读操作可以在任意副本上执行(基于 TrueTime 保证线性一致)。
Spanner 对外暴露的数据库是"所有目录的集合",但内部可以灵活地将目录在 Paxos 组之间迁移,以做负载均衡和故障隔离。这一点和 TiKV 的 Region 调度非常像,区别在于 Spanner 的迁移单位是目录(业务语义),而 TiKV 的迁移单位是 Region(按 key range 切分)。
为什么大部分系统不学 Spanner
TrueTime 的时间和金钱成本都很高:GPS 天线和原子钟的硬件开销,commit wait 带来的延迟开销。Google 需要全球部署的 Spanner,是因为它的广告系统、Bigtable 等产品需要跨地域强一致。99% 的系统不需要这个级别的保障。
四行对比表
| 维度 | etcd | TiDB | Spanner |
|---|---|---|---|
| 副本协议 | Raft(单组) | Raft(每 Region 一组) | Paxos(每目录一组) |
| 事务模型 | 不支持多 key 事务 | Percolator(乐观/悲观) | TrueTime + 2PC |
| 时钟依赖 | 系统时钟(不依赖精度) | TSO 集中授时 | TrueTime 硬件时钟 |
| 扩展单位 | 无(单 Raft 组) | Region(96MB) | 目录(业务语义) |
没有银弹
这三个系统放在一起看,最清晰的结论是:没有哪个选择是"更好"的,只有"更合适"的。
- etcd 选择不做分布式事务,不存大 value,不跨机器扩展——换来的是简单、可靠、低延迟。它对自己的定位很清楚:元数据存储,不是数据库。
- TiDB 选择兼容 MySQL 协议,用 Raft 处理副本,用 Percolator 处理事务——换来的是"在 MySQL 生态里获得水平扩展能力"。代价是分布式事务的延迟开销和写冲突场景下的重试复杂度。
- Spanner 选择用硬件解决时钟问题——换来的是全球级强一致和无需重试的分布式事务。代价是硬件成本和 commit wait 的额外延迟。
选型的时候,先问自己:我需要跨地域强一致吗?我的数据量级需要自动分片吗?我能接受事务的额外延迟吗? 答案出来了,该用 etcd 还是 TiDB 还是开发一个自己的方案,就清楚了。
常见误区与小结
- 误区:etcd 是 KV 数据库,可以当 Redis 用。 错。etcd 的容量限制和并发性能远低于 Redis,它的定位是存配置和元数据,不是缓存业务数据。
- 误区:TiDB 兼容 MySQL,所以迁移成本为零。 错。TiDB 的乐观事务默认和 MySQL 的悲观事务行为不同,高并发自增主键场景会出现明显的性能差异。
- 误区:Spanner 这么强,为什么不用? 成本。TrueTime 的硬件和 commit wait 延迟,只适合全球级应用。单机房部署用 Spanner 是杀鸡用牛刀。
- 误区:Raft 比 Paxos 好。 错。Raft 是被人理解的 Paxos,不是更优秀的 Paxos。Paxos 的多组管理和灵活度在某些场景下更优。
- 误区:知道原理就够了,不需要看源码。 这三个系统任何一个的源码都值得读。etcd 的 raft 库是 Go 语言 Raft 实现的事实标准,TiKV 的 raft-rs 是 Rust 生态的参考实现。
小结
本文从 etcd、TiDB 和 Spanner 三个系统出发,展示了分布式理论在真实工程中的落地取舍。etcd 的"少即是多"、TiDB 的"兼容+扩展"、Spanner 的"用钱隔离时钟问题",本质都是在约束条件下做最优解。工程没有银弹,但有清晰的权衡。
下一篇预告:微服务 27. 从单体到微服务架构演进