Skip to content

工程案例剖析:etcd、TiDB 与 Spanner 的取舍

本文是分布式系统系统学习系列的 L3 实战篇。前置:28. 共识算法:从 Paxos 到 Raft 的推导31. 数据分片与一致性哈希。 学完可以配合面试题食用:21-分布式数据库 TiDB Percolator13-etcd-raft-linearizable-read18-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');
  1. TiDB 解析 SQL -> 生成执行计划 -> 定位 id=42 属于哪个 Region
  2. TiDB 向 PD 查询该 Region 的 leader 所在的 TiKV 节点
  3. TiDB 通过 gRPC 向 TiKV leader 发送 Prewrite 请求(写锁和值)
  4. TiKV 写入锁后,返回 Prewrite 成功
  5. TiDB 向 TiKV leader 发送 Commit 请求
  6. 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% 的系统不需要这个级别的保障。

四行对比表

维度etcdTiDBSpanner
副本协议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. 从单体到微服务架构演进

参考

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1