44.19 分布式数据库 (Spanner / TiDB / CockroachDB)
一句话
分布式数据库的差别本质是**“时间戳从哪来”**:Spanner 用 TrueTime 等一个不确定窗口、TiDB 向中心 TSO 要一个、CockroachDB 用本地 HLC 加“不确定就重启”——时间戳方案决定了提交延迟、跨区域写代价与故障行为。
三种时间戳方案
| 方案 | 代表 | 怎么保证顺序 | 提交要等什么 | 代价 |
|---|---|---|---|---|
| TrueTime | Spanner | GPS/原子钟给出 [earliest, latest] 区间 | 等不确定窗口过去(2ε) | 写延迟直接加上 2ε |
| 中心授时(TSO) | TiDB | 单点分配单调时间戳 | 一次到 TSO 的 RTT | TSO 是逻辑单点(要高可用) |
| HLC + 不确定性重启 | CockroachDB | 混合逻辑时钟 + 最大偏移 | 不等待,但读写冲突时重启事务 | 冲突多时重试率高 |
| 存储层多副本 | Aurora | 不需要全局时间戳 | 等多数派(4/6)落盘 | 只解决单区域高可用 |
代码:提交延迟模型
package main
import "fmt"
const (
epsilonMs = 7.0 // TrueTime 不确定窗口(量级,实际取决于部署)
raftMs = 1.0 // 区域内一次 Raft 复制(量级)
)
type arch struct {
name string
clock string
// commitWait: 提交前必须等的时间;replicaRTT: 复制往返;tsoRTT: 取时间戳往返
commitWait float64
replicaRTT float64
tsoRTT float64
}
var archs = []arch{
{"Spanner", "TrueTime", 2 * epsilonMs, raftMs, 0},
{"TiDB", "中心 TSO", 0, raftMs, 0.2},
{"CockroachDB", "HLC", 0, raftMs, 0},
{"Aurora", "无全局时间戳", 0, raftMs / 2, 0}, // 只等存储多数派
}
func main() {
fmt.Printf("模型:TrueTime ε=%.0fms(提交等待 2ε)、区域复制 %.1fms、TSO 往返 %.1fms\n\n",
epsilonMs, raftMs, 0.2)
fmt.Printf("%-14s %-16s %-12s %-14s %s\n", "系统", "时钟", "单区域写", "跨区域写(+50ms)", "故障时")
for _, a := range archs {
single := a.commitWait + a.tsoRTT + a.replicaRTT
cross := single + 50 // 跨区域多一次广域往返(示意)
fail := "选主 Raft,RPO=0"
switch a.name {
case "Spanner":
fail = "Paxos 选主,RPO=0,靠 TrueTime 防脑裂"
case "TiDB":
fail = "PD 调度 + Raft,RPO=0;TSO 需高可用"
case "CockroachDB":
fail = "Raft,RPO=0;冲突多时事务重启"
case "Aurora":
fail = "存储层 6 副本,跨 AZ 可写;无跨区域强一致"
}
fmt.Printf("%-14s %-16s %-12s %-14s %s\n", a.name, a.clock,
fmt.Sprintf("%.1fms", single), fmt.Sprintf("%.1fms", cross), fail)
}
fmt.Println("\n=== ε 对提交延迟的直接影响(Spanner 型)===")
for _, e := range []float64{1, 4, 7, 10} {
fmt.Printf("ε=%-4.0fms → 提交等待 %5.1fms,单区域写延迟 %5.1fms\n",
e, 2*e, 2*e+raftMs)
}
fmt.Println("\n(模型只含主要项;真实延迟还受队列、锁冲突、跨分片 2PC 影响)")
}
本机实测(macOS,Apple M1,Go 1.27):
模型:TrueTime ε=7ms(提交等待 2ε)、区域复制 1.0ms、TSO 往返 0.2ms
系统 时钟 单区域写 跨区域写(+50ms) 故障时
Spanner TrueTime 15.0ms 65.0ms Paxos 选主,RPO=0,靠 TrueTime 防脑裂
TiDB 中心 TSO 1.2ms 51.2ms PD 调度 + Raft,RPO=0;TSO 需高可用
CockroachDB HLC 1.0ms 51.0ms Raft,RPO=0;冲突多时事务重启
Aurora 无全局时间戳 0.5ms 50.5ms 存储层 6 副本,跨 AZ 可写;无跨区域强一致
=== ε 对提交延迟的直接影响(Spanner 型)===
ε=1 ms → 提交等待 2.0ms,单区域写延迟 3.0ms
ε=4 ms → 提交等待 8.0ms,单区域写延迟 9.0ms
ε=7 ms → 提交等待 14.0ms,单区域写延迟 15.0ms
ε=10 ms → 提交等待 20.0ms,单区域写延迟 21.0ms
(模型只含主要项;真实延迟还受队列、锁冲突、跨分片 2PC 影响)
读数:
| 系统 | 单区域写 | 跨区域写 | 关键结论 |
|---|---|---|---|
| Spanner | 15.0ms | 65.0ms | 2ε 是硬成本:ε 越大越安全,延迟越高 |
| TiDB | 1.2ms | 51.2ms | 无提交等待,但每次提交多一个 TSO RTT |
| CockroachDB | 1.0ms | 51.0ms | 本地时钟最快,代价是冲突时重启事务 |
| Aurora | 0.5ms | 50.5ms(无强一致保证) | 存储层复制,只保证单区域高可用 |
🔑 Spanner 的 15ms 不是“慢”,是“买了外部一致性”:它用
2ε的等待换“任意两次提交的时间戳不会乱序”。TiDB 用中心 TSO 把等待换成一次 RTT,CockroachDB 用重启把等待换成重试率——三种方案都在为“全局有序”付代价,只是付在不同的地方。
分片与复制:三个概念要对上
| 概念 | Spanner | TiDB | CockroachDB |
|---|---|---|---|
| 分片单位 | Split / Tablet | Region | Range |
| 复制协议 | Paxos | Raft | Raft |
| 元数据/调度 | Placement Driver | PD | Gossip + 元数据 Range |
| 事务实现 | 2PC + TrueTime | 2PC(乐观 + Percolator 式) | 2PC + HLC |
| SQL 层 | 自研(类 SQL) | TiDB(MySQL 兼容) | 自研(PG 兼容) |
⚠️ “分片”不等于“分库分表”:分库分表由应用/中间件决定路由,跨分片事务在应用层;分布式数据库由内核管理分片与副本,跨分片事务在内核里(代价是延迟更高、但应用透明)。
四代演进:每一代在绕开什么
分布式数据库不是「更大的 MySQL」,它是为了绕开单机的一个不可能三角。
单机数据库的不可能三角
| 目标 | 想要什么 | 代价 |
|---|---|---|
| 持久化 | 每次写都落盘,宕机能自愈 | 每次提交至少一次 fsync |
| 事务隔离 | 并发事务互不干扰,最强可到可串行化 | 锁 / MVCC 版本链,写冲突要等待或回滚 |
| 高性能 | 读与写都尽量快 | 前两项都要妥协:纯内存最快但没有持久化;持久化 + 强隔离必然有等待 |
三者拉不满,只能选比例。MySQL 的取舍是「持久化 ×1 + 高性能 ×0.8 + 事务隔离 ×0.5」:持久化不让(核心库没有持久化就没有意义),性能次之,隔离级别做成开关交给业务选(44.10 事务与隔离级别)。
🔑 这个三角解释了为什么「加机器」解决不了写:写要定序,定序要单点。分布式数据库的全部努力,都是想办法在保持定序的同时把写分散出去——代价是时钟、跨分片事务、网络往返。
四代
| 代 | 做法 | 代表 | 解决了什么 | 代价 |
|---|---|---|---|---|
| 一 | 中间件分库分表,SQL 在中间件改写 | Cobar、MyCat、ShardingSphere | 单机容量与读扩展 | 跨分片 JOIN / 事务在中间件做,SQL 能力受限 |
| 二 | 放弃关系模型,KV + 分片 | HBase、Cassandra | 写入与线性扩展 | 没有 SQL、没有跨行事务,冗余与一致性业务自己扛 |
| 三 | LSM 存储 + 分布式事务 + SQL 兼容 | Spanner、TiDB、OceanBase、CockroachDB | SQL 与水平扩展兼得 | 写放大、跨分片事务延迟、运维复杂(见本节上半部分) |
| 四 | 计算存储分离,存与算独立扩缩 | Aurora、PolarDB | 成本与人月 | 网络成为瓶颈;强一致仍限单区域 |
🔑 第四代的核心价值常被说反:计算存储分离不是「高性能」技术,是「低成本」技术。InnoDB 本来就为慢磁盘设计,把磁盘和 MySQL 进程绑在同一台物理机上,只会浪费磁盘性能;分离之后计算按需扩、存储共享一份,成本才降下来。Aurora 论文自己就写明:瓶颈会转移到数据库层与存储层之间的网络。附带的技术收益是主从延迟从「数百毫秒到数秒」降到百毫秒以内,因为传的是 redo log,而不是等事务提交后再同步行变更。
读写分离只扩读,不扩写
| 架构 | 总 QPS | 写入能力 | 说明 |
|---|---|---|---|
| 单机 | 1 万 | 1 万 | 基线 |
| 一主四从 | 5 万 | 1 万 | 读占 80%,可以被复制分担;写仍只有一个主 |
天花板就在这里:能写入的节点永远只有一个。「写不动」才是引入分片或 NewSQL 的唯一理由,读不够只需要加从库(44.13 主从复制与读写分离)。
选哪一代
| 你的情况 | 选择 |
|---|---|
| 数据 < 2TB、写 < 5000 TPS | 单机 MySQL + 从库,别引入分布式数据库 |
| 需要分片但团队小、SQL 简单 | 客户端分片或中间件(44.14 分库分表) |
| 强 SQL、跨分片事务、有 DBA 团队 | NewSQL(TiDB / OceanBase) |
| 已在云上、成本敏感、单区域强一致够用 | 云上数据库(Aurora / PolarDB) |
| 超大规模写入、能接受无跨行事务 | KV / 列族(HBase / Cassandra) |
反模式
| 反模式 | 后果 |
|---|---|
| 用 Spanner 型数据库跑跨区域高频写 | 每次写都付 2ε + 广域 RTT,吞吐上不去 |
| 把 TiDB 的 TSO 部署成单点 | TSO 挂 = 全集群无法取时间戳 |
| 在 CockroachDB 上跑高冲突热点写 | 事务反复重启,吞吐骤降 |
| 以为“分布式数据库 = 无限扩展” | 跨分片事务、热点分片仍会打满 |
| 用 Aurora 当跨区域强一致库 | 它只保证单区域内的高可用与一致 |
| 忽略时钟回拨/漂移 | HLC 的“不确定重启”窗口变大,冲突率上升 |
自检
Q1 为什么 Spanner 的提交要等 2ε,而不是 ε?
▸ 答案:TrueTime 返回的是一个区间 [t−ε, t+ε],真实时间可能落在其中任何一点。提交时如果只等到 t+ε,另一个节点可能刚在 t+ε 之前读到更晚的时间;为了保证“提交时间戳一定小于任何后续事务读到的当前时间”,必须等到区间的上界确定过去——也就是当前真实时间 ≥ latest = t+ε 之后才能返回。从取时间戳到等待结束,最坏需要 2ε 的跨度。
Q2 TiDB 的 TSO 与 Spanner 的 TrueTime,本质区别是什么?
▸ 答案:TrueTime 是物理时钟 + 不确定区间,靠“等待窗口”把不确定性消掉;TSO 是中心化的逻辑时钟,靠“单点授时”直接给出单调递增的时间戳,不需要等待,但每次取时间戳多一次 RTT,且 TSO 必须高可用(它挂了就没人能分配时间戳)。一个是“用时间换确定性”,一个是“用一次往返换确定性”。
Q3 一个业务要在三个地域都低延迟写入,还要求强一致,这可能吗?
▸ 答案:物理上做不到“既低延迟又强一致写入”:强一致写必须让多数派确认,跨地域的多数派必然包含一个远距离副本,延迟下界就是“到最远多数派副本的 RTT”。可行的是:① 每个地域一个分区(用户就近写自己的分区),只在跨分区事务时付广域延迟;② 接受最终一致(本地写 + 异步复制),用冲突解决策略;③ 用“主地域写 + 只读副本”牺牲部分地域的写延迟。
Q4 为什么 “跨地域强一致” 必然带来写延迟?
▸ 答案:因为强一致写要等多数派确认:跨地域时一次提交至少一个 RTT(甚至两个),地理距离决定了下限(如跨太平洋 150ms+)。任何 “跨地域强一致还要求低写延迟” 的承诺,要么偷偷放宽了一致性,要么只在同地域内成立。
Q5 怎么区分 “真分布式数据库” 与 “中间件分库分表”?
▸ 答案:看事务与元数据:真分布式库有全局事务(TSO/TrueTime)、自动分裂与调度、副本一致性协议;中间件方案通常只做 “路由 + 聚合”,跨分片事务靠 2PC/补偿,扩容要人工迁移。判据是 “跨分片事务是否由数据库内部保证”,而不是 “有没有 SQL 代理”。