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 代理”。

关联