土法炼钢兴趣小组的算法知识备份

【分布式系统百科】NewSQL 架构拆解:Spanner、CockroachDB 与 TiDB

目录

NewSQL 架构拆解:Spanner、CockroachDB 与 TiDB

某电商团队的订单库跑在 MySQL 上,单表 12 亿行,64 个分片散布在 16 台物理机。每到大促,DBA 手动执行扩容脚本:新建分片、配置路由、灰度迁移、双写校验——一整套流程需要 72 小时。更麻烦的是跨分片的事务:用户下单时需要同时扣减库存和写入订单,中间件层勉强用 XA 两阶段提交撑着,但超时重试导致的数据不一致每月至少出现两次。团队开始评估 NewSQL 数据库,希望用一套系统同时解决水平扩展和分布式事务的问题。

这个场景不是假设。它是过去十年里数百个技术团队反复遭遇的相同困境:关系模型和 SQL 的表达能力不可替代,NoSQL 放弃了事务和 SQL,分库分表中间件把复杂度推给了业务层。NewSQL 的核心主张是:你不需要在 SQL、ACID 和水平扩展之间做取舍。

本文拆解三个最具代表性的 NewSQL 系统——Google Spanner、CockroachDB 和 TiDB——从时钟机制、共识协议、存储架构到分布式 SQL 优化器,逐层对比它们的设计选择和工程取舍。


一、NewSQL 的定义与分类

1.1 什么是 NewSQL

2011 年,451 Research 的 Matthew Aslett 首次提出 NewSQL 这个术语。2016 年,Andrew Pavlo 和 Aslett 在 SIGMOD Record 上发表了 What’s Really New with NewSQL?,给出了学术界公认的定义:

NewSQL 是一类关系型数据库管理系统,既提供 NoSQL 系统对联机事务处理(OLTP(Online Transaction Processing))工作负载的可扩展性能,又维持关系模型和 SQL 的完整 ACID 保证。

这个定义的关键词有三个:关系模型(不是文档或键值)、ACID 事务(不是最终一致性)、水平可扩展(不是单机纵向扩展)。满足前两个的是传统关系型数据库,满足后两个的是分布式 NoSQL,三个都满足的才是 NewSQL。

1.2 为什么 NoSQL 不够

2007-2012 年的 NoSQL 浪潮解决了扩展性问题,但代价是放弃了太多东西。以 Cassandra 为例:没有跨分区事务,没有 JOIN,没有二级索引(后来加了但性能很差),查询模式必须在建表时确定。开发者被迫把原本属于数据库的逻辑搬到应用层:手动维护反规范化(Denormalization)副本、在代码里实现 Saga 模式的补偿事务、用消息队列保证最终一致性。

结果是:数据库的复杂度降低了,应用层的复杂度爆炸了。

另一个教训来自 MongoDB。MongoDB 直到 4.0 版本(2018 年)才支持多文档事务,4.2 才支持分片集群上的分布式事务。在此之前,任何涉及多个文档的原子操作都需要应用层自己保证。金融、电商、库存管理——这些最需要事务保证的场景,恰恰是 NoSQL 最难胜任的场景。

1.3 三种实现路径

Pavlo 和 Aslett 将 NewSQL 系统分为三类:

第一类:全新架构(New Architecture)。 从零开始设计分布式数据库内核。Spanner、CockroachDB、TiDB、VoltDB、MemSQL(现 SingleStore)都属于这一类。优势是架构完整,劣势是工程投入巨大。

第二类:透明分片中间件(Transparent Sharding Middleware)。 在已有的单机数据库上套一层分片和路由层。Vitess(YouTube 开源,基于 MySQL)、Apache ShardingSphere 属于这一类。优势是复用成熟的存储引擎,劣势是受限于底层数据库的能力(例如跨分片 JOIN 需要中间件自己实现)。

第三类:云数据库服务(Database-as-a-Service)。 Amazon Aurora、Google Cloud SQL 等。它们通过存储层的分布式改造来提升扩展性,但计算节点通常仍是单主写入,不完全满足 NewSQL 的多节点写入定义。Aurora 的多主版本(Multi-Master)和 Aurora DSQL 在模糊这个边界。

本文聚焦第一类,因为它们的架构创新最深,对分布式系统理论的应用也最彻底。


二、三大代表系统深度分析

2.1 Google Spanner

2.1.1 TrueTime 与外部一致性

Spanner 最著名的创新是 TrueTime API。传统分布式系统用 NTP 同步时钟,但 NTP 的误差可达数百毫秒。Spanner 在每个数据中心部署了 GPS 接收器和原子钟(Atomic Clock),两者互相校验。TrueTime 不返回一个时间点,而是返回一个区间 [earliest, latest],表示当前真实时间一定落在这个范围内。典型误差(epsilon)在 1-7 毫秒之间。

TrueTime 的关键 API 只有三个:

方法 返回值
TT.now() TTinterval: [earliest, latest]
TT.after(t) t 已确定过去则返回 true
TT.before(t) t 已确定未来则返回 true

Spanner 利用 TrueTime 实现了外部一致性(External Consistency),这是比线性一致性(Linearizability)更强的保证:如果事务 T1 在事务 T2 开始之前提交完成,那么 T1 的提交时间戳一定小于 T2 的提交时间戳。实现方式是 commit-wait:事务提交时,Leader 选择一个时间戳 s,然后等待直到 TT.after(s) 返回 true。等待时间就是 TrueTime 的不确定区间 epsilon。

// Spanner commit-wait 伪代码
func commitWait(txn *Transaction) {
    s := TT.now().latest  // 选择时间戳
    txn.commitTimestamp = s
    // 等待不确定区间过去,确保 s 确实在过去
    for !TT.after(s) {
        sleep(1 * time.Millisecond)
    }
    // 此时可以安全地将事务标记为已提交
    txn.apply()
}

这种机制的代价是每个读写事务至少增加 epsilon(约 5-7ms)的提交延迟。但换来的是全球范围内的事务排序,不依赖任何中心化的时间戳服务。

Spanner 跨分片提交路径深度剖析

Spanner 的读写事务提交路径结合了 TrueTime、两阶段提交(2PC)和 Paxos 三种机制。以一个跨两个 Split 的事务为例(例如从用户 A 的账户扣款并向用户 B 的账户加款),完整的提交流程如下:

  1. 事务执行阶段:客户端通过 Spanner 的事务 API 发起读写操作。所有涉及的 Split 的 Leader 被标识为事务参与者。其中一个被选为事务协调者(Coordinator)。
  2. Prepare 阶段(2PC 第一阶段):协调者向所有参与者发送 Prepare 请求。每个参与者在本地获取写锁,将 Prepare 记录通过 Paxos 复制到自身 Split 的多数副本,然后回复 Prepared。
  3. Commit 阶段(2PC 第二阶段 + TrueTime):协调者收到所有参与者的 Prepared 回复后,选择一个提交时间戳 commit_ts = TT.now().latest。然后执行 Commit-Wait:等待直到 TT.after(commit_ts) 为真。等待时间即为 TrueTime 的不确定区间 epsilon(通常 5-7ms)。等待结束后,协调者通过 Paxos 复制 Commit 记录,并通知所有参与者提交。
  4. 释放锁:所有参与者释放写锁,事务完成。
sequenceDiagram
    participant C as 客户端
    participant Coord as Coordinator<br/>(Split-A Leader)
    participant Part as Participant<br/>(Split-B Leader)
    participant TT as TrueTime

    C->>Coord: 提交事务(修改 Split-A 和 Split-B)

    Note over Coord,Part: 2PC Prepare 阶段
    Coord->>Part: Prepare 请求
    Part->>Part: 获取写锁,Paxos 复制 Prepare 日志
    Part-->>Coord: Prepared

    Coord->>TT: TT.now()
    TT-->>Coord: [earliest, latest]
    Coord->>Coord: commit_ts = latest

    Note over Coord: Commit-Wait:等待不确定区间过去
    Coord->>TT: 轮询 TT.after(commit_ts)
    TT-->>Coord: false(仍在不确定区间内)
    Coord->>TT: 轮询 TT.after(commit_ts)
    TT-->>Coord: true(commit_ts 确定在过去)

    Note over Coord,Part: 2PC Commit 阶段
    Coord->>Coord: Paxos 复制 Commit 记录
    Coord->>Part: Commit(commit_ts)
    Part->>Part: Paxos 复制 Commit 记录,释放写锁
    Part-->>Coord: Committed
    Coord-->>C: 事务提交成功

该时序图揭示了 Spanner 提交路径的三层保障机制。最外层是两阶段提交协议,保证跨 Split 的原子性——要么全部提交,要么全部中止。中间层是 Paxos 复制,保证每个 Split 内部的 Prepare 和 Commit 记录在多数副本上持久化,即使 Leader 故障也能恢复。最内层是 TrueTime 的 Commit-Wait,通过等待不确定区间消逝来保证外部一致性——任何在物理时间上晚于本事务的后续事务,其 commit_ts 一定大于本事务的 commit_ts。这三层机制共同构成了 Spanner 全球一致性的技术基石,代价是每个跨分片事务至少需要一轮 Prepare 往返(跨数据中心时数十毫秒)加上 epsilon 的 Commit-Wait(5-7ms)。

2.1.2 存储与分片架构

Spanner 的底层存储使用 Colossus(Google 内部的分布式文件系统,GFS 的继任者)。数据按主键范围划分为多个分片(Split),每个 Split 是一个独立的 Paxos 组(Paxos Group)。Split 默认大小约 4GB,当数据增长时自动分裂。

每个 Paxos 组有一个长期 Leader(Leader 租约默认 10 秒)。读写事务在 Leader 上执行,只读事务可以在任意副本上执行(利用快照读(Snapshot Read),无需加锁)。快照读的前提是副本的数据版本足够新——Spanner 通过 safe time 机制保证:每个副本维护一个 t_safe,表示该副本已经应用了所有时间戳 <= t_safe 的事务。

Spanner 还引入了目录(Directory)的概念,允许将频繁一起访问的数据放在同一个 Split 中。更独特的是交错表(Interleaved Table):子表的行物理上与父表的对应行存储在一起。这不是逻辑上的外键关系,而是物理存储的保证。

-- Spanner 的交错表定义
CREATE TABLE Users (
    UserId   INT64 NOT NULL,
    UserName STRING(100)
) PRIMARY KEY (UserId);

CREATE TABLE Orders (
    UserId  INT64 NOT NULL,
    OrderId INT64 NOT NULL,
    Amount  FLOAT64
) PRIMARY KEY (UserId, OrderId),
  INTERLEAVE IN PARENT Users ON DELETE CASCADE;

这个设计确保 UserId=42 的用户数据和他的所有订单数据在同一个 Split 内,JOIN 查询不需要跨网络。

2.1.3 只读事务的无锁优化

Spanner 的只读事务(Read-Only Transaction)是一个精巧的设计:它们不需要获取任何锁,也不需要两阶段提交。客户端声明一个读时间戳 t_read(通常是 TT.now().latest),然后在任意副本上读取 t_read 时刻的快照。

这里有一个关键细节:只读事务可以在 Follower 副本上执行,但前提是该副本的 t_safe >= t_read。如果不满足,副本会等待直到条件满足。这意味着只读事务的延迟取决于副本的复制延迟(通常在几毫秒量级)。

只读事务的实际意义很大:在大部分 OLTP 工作负载中,读操作占 80-95%。让读操作不加锁、不走 Leader、不参与两阶段提交,直接大幅提升了系统吞吐量。

2.2 CockroachDB

2.2.1 没有 TrueTime 的外部一致性

CockroachDB 的设计目标是做一个”开源版 Spanner”,但它面临一个根本限制:普通数据中心没有 GPS 和原子钟。CockroachDB 使用混合逻辑时钟(HLC(Hybrid Logical Clock))作为替代方案。

HLC 结合了物理时钟和逻辑计数器。物理部分来自 NTP 同步,逻辑部分保证因果序。CockroachDB 假设集群中所有节点的时钟偏差(Clock Skew)不超过一个可配置的上限(默认 500ms,但实际生产环境中 NTP 同步良好时偏差通常在 10ms 以内)。

问题是:如果两个事务在不同节点上并发执行,时钟偏差可能导致观察到的顺序与真实的物理顺序不一致。CockroachDB 用两个机制处理这个问题:

不确定性窗口(Uncertainty Window)。 当事务 T 在节点 A 上读取一个键时,如果发现该键被另一个节点 B 上的事务以时间戳 ts 写入,而 ts 落在 T 的不确定性窗口 [T.readTimestamp, T.readTimestamp + maxClockOffset] 内,T 不确定这个写入是在自己开始之前还是之后发生的。此时 T 会把自己的读时间戳推进到 ts + 1 并重试读取。

读刷新(Read Refresh)。 当事务的读时间戳被推进后,它需要验证之前读取的所有值在新的读时间戳下是否仍然有效。如果某个之前读过的键在 [oldTimestamp, newTimestamp] 之间被修改了,事务必须中止并重试。

// CockroachDB 不确定性窗口检查伪代码
func checkUncertainty(txn *Transaction, key Key, writeTS hlc.Timestamp) error {
    if writeTS.Less(txn.readTimestamp) {
        // 写入明确在事务开始之前,正常读取
        return nil
    }
    if writeTS.Less(txn.maxTimestamp) {
        // 写入落在不确定区间内,推进读时间戳
        txn.readTimestamp = writeTS.Next()
        // 尝试刷新之前的读集
        if err := txn.refreshReads(); err != nil {
            return ErrRetry // 刷新失败,必须重试
        }
        return nil
    }
    // 写入明确在事务开始之后,忽略
    return nil
}

这种设计的代价是:在时钟偏差较大时,事务重试率会上升。但好处是不需要任何特殊硬件。

2.2.2 Range 分片与 Raft

CockroachDB 的数据按键范围(Key Range)划分为多个 Range,每个 Range 默认 512 MB(旧版本为 64 MB)。每个 Range 构成一个 Raft 组。所有写操作通过 Raft 协议复制到三个或五个副本。

Range 的分裂和合并是自动的。当一个 Range 的大小超过阈值时,系统选择一个中间键将其分裂为两个 Range。合并在相邻 Range 都很小时触发。整个过程对应用层透明。

CockroachDB 的一个独特特性是地理分区(Geo-Partitioning)。你可以在表级别或行级别指定数据的地理位置约束:

-- CockroachDB 地理分区示例
ALTER TABLE users PARTITION BY LIST (region) (
    PARTITION us_east VALUES IN ('us-east'),
    PARTITION eu_west VALUES IN ('eu-west'),
    PARTITION ap_south VALUES IN ('ap-south')
);

ALTER PARTITION us_east OF TABLE users
    CONFIGURE ZONE USING constraints = '[+region=us-east]';

这使得属于 us-east 的用户数据的 Raft Leader 和副本都优先放在美国东部的节点上,降低读写延迟。

2.2.3 多活架构

CockroachDB 的所有节点都是对等的(Symmetric),没有全局单点。任何节点都可以接收 SQL 请求,充当该请求的网关(Gateway)节点。网关节点解析 SQL,生成分布式执行计划,协调跨 Range 的读写,然后将结果返回给客户端。

这和 Spanner 的 F1 架构有相似之处,但 CockroachDB 更彻底:没有独立的元数据服务,没有独立的调度器。元数据(哪个 Range 在哪个节点上)通过 Gossip 协议在所有节点间传播。

2.3 TiDB

2.3.1 存算分离架构

TiDB 的最大架构特征是存算分离(Disaggregated Storage and Compute)。整个系统由三个独立组件构成:

TiDB Server(SQL 层)。 无状态的 SQL 引擎,负责解析 SQL、生成执行计划、协调分布式事务。因为无状态,可以任意水平扩展,前面挂负载均衡器即可。

TiKV(行存储层)。 分布式键值存储,数据按键范围划分为 Region(默认 96 MB),每个 Region 由一个 Raft 组管理。底层使用 RocksDB 作为单机存储引擎。TiKV 提供 MVCC(多版本并发控制(Multi-Version Concurrency Control))和事务 API。

TiFlash(列存储层)。 列式存储引擎,用于分析型(OLAP(Online Analytical Processing))查询。TiFlash 通过 Raft Learner 机制从 TiKV 异步复制数据,将行格式转换为列格式存储。Learner 不参与 Raft 投票,不影响写入延迟。

此外还有一个核心组件:

PD(Placement Driver)。 集群的”大脑”,负责三件事:存储集群元数据(哪个 Region 在哪个 TiKV 节点上)、为事务分配全局单调递增的时间戳(TSO(Timestamp Oracle))、根据负载和容量进行 Region 调度(迁移、分裂、合并)。

客户端 -> [负载均衡] -> TiDB Server (无状态,N 个)
                            |
              +-------------+-------------+
              |                           |
          TiKV (行存)                TiFlash (列存)
          Multi-Raft                 Raft Learner
          RocksDB                    DeltaTree
              |                           |
              +-------------+-------------+
                            |
                     PD (元数据 + 调度 + TSO)

存算分离的直接好处是弹性伸缩:可以独立扩展计算层(加 TiDB Server)和存储层(加 TiKV 节点),不需要同时扩展两者。代价是每次 SQL 查询都需要通过网络访问存储层,增加了一次 RPC 的延迟(通常在亚毫秒级)。

2.3.2 Percolator 事务模型

TiDB 的分布式事务基于 Google 的 Percolator 模型。Percolator 最初由 Daniel Peng 和 Frank Dabek 在 2010 年发表的论文中提出,设计目标是在 Bigtable 上实现跨行事务。TiDB 将这个模型适配到 TiKV 上。

Percolator 使用两阶段提交(2PC(Two-Phase Commit)):

Prewrite 阶段。 事务选择一个主键(Primary Key),对所有修改的键执行 Prewrite 操作。每个键上写入一条锁(Lock)记录和数据记录。如果检测到冲突(其他事务的锁或更新的版本),事务中止。

Commit 阶段。 事务向 PD 获取提交时间戳,然后先提交主键(写入 Write 记录并删除锁),再异步提交其余键。主键提交成功即事务成功——即使此时部分从键的锁还未清除。

// TiDB Percolator 2PC 简化流程
func (txn *TwoPhaseCommitter) execute() error {
    // 1. 获取开始时间戳
    startTS := txn.pd.GetTimestamp()
    
    // 2. Prewrite 所有键
    primary := txn.keys[0] // 选择主键
    for _, key := range txn.keys {
        if err := txn.prewrite(key, primary, startTS); err != nil {
            return err // 冲突,事务中止
        }
    }
    
    // 3. 获取提交时间戳
    commitTS := txn.pd.GetTimestamp()
    
    // 4. 提交主键
    if err := txn.commit(primary, startTS, commitTS); err != nil {
        return err
    }
    
    // 5. 异步提交从键(主键已提交,事务逻辑上已成功)
    go func() {
        for _, key := range txn.keys[1:] {
            txn.commit(key, startTS, commitTS)
        }
    }()
    
    return nil
}

TiDB 在原始 Percolator 模型上做了多项优化。其中最重要的两个是:

悲观锁模式(Pessimistic Locking)。 原始 Percolator 是纯乐观事务:冲突在提交时才检测。在高冲突工作负载下,大量事务在 Prewrite 阶段才发现冲突而中止,浪费了前面所有的计算。TiDB 4.0 引入了悲观锁,在 DML 执行时就加锁,行为更接近 MySQL 的 InnoDB。

Async Commit 与 1PC 优化。 在特定条件下(事务涉及的键数量较少),TiDB 可以在 Prewrite 阶段完成后直接返回成功,不需要等待 Commit 阶段。提交时间戳通过所有参与键的 min_commit_ts 推导,不需要再次访问 PD。

2.3.3 TiFlash 与 HTAP

TiFlash 是 TiDB 实现 HTAP(Hybrid Transactional/Analytical Processing)的关键组件。传统做法是 OLTP 和 OLAP 使用不同的数据库,中间用 ETL 管道同步数据。延迟通常在小时级别。TiFlash 通过 Raft Learner 做实时同步,延迟在秒级甚至亚秒级。

TiFlash 使用 DeltaTree 引擎存储数据。DeltaTree 的设计融合了 B+ 树的读取性能和 LSM-Tree 的写入性能:新数据写入 Delta 层(类似 LSM-Tree 的 MemTable),后台合并到 Stable 层(列式排列的有序数据)。

TiDB 的优化器能够自动决定一个查询应该走 TiKV(行存)还是 TiFlash(列存),或者两者混合。这个选择基于代价估算:点查和小范围扫描走 TiKV,全表扫描和聚合走 TiFlash。用户也可以通过 Hint 手动指定:

-- 强制使用 TiFlash
SELECT /*+ READ_FROM_STORAGE(TIFLASH[orders]) */
    region, SUM(amount)
FROM orders
WHERE order_date >= '2025-01-01'
GROUP BY region;

-- 强制使用 TiKV
SELECT /*+ READ_FROM_STORAGE(TIKV[orders]) */
    order_id, amount
FROM orders
WHERE order_id = 12345;

三、共性与差异对比表

Spanner、CockroachDB 与 TiDB 架构对比

下表从核心维度对比三个系统的设计选择:

维度 Spanner CockroachDB TiDB
共识协议 Multi-Paxos Raft Multi-Raft
存储引擎 SSTable on Colossus Pebble(LSM-Tree) RocksDB(LSM-Tree)
SQL 兼容性 Google 标准 SQL PostgreSQL 线协议 MySQL 线协议
时间戳机制 TrueTime(GPS + 原子钟) HLC(NTP) TSO(中心化分配)
一致性级别 外部一致性 可串行化(Serializable) 快照隔离(SI),可选 RC
分片单元 Split(约 4GB) Range(512 MB) Region(96 MB)
HTAP 支持 有限(Spanner Graph 等演进中) 有限(CDC 到分析系统) 原生(TiFlash 列存)
架构模式 存算紧耦合 存算一体 存算分离
部署模式 Google Cloud 专属 自部署 / 云服务 自部署 / 云服务
开源 否(Cloud Spanner 为托管服务) 是(BSL 许可证) 是(Apache 2.0)

几个值得展开的对比点:

时钟机制的取舍。 Spanner 的 TrueTime 需要专用硬件,但换来了最强的一致性保证和最小的不确定窗口。CockroachDB 的 HLC 不需要特殊硬件,但必须处理不确定性窗口中的事务重试。TiDB 的 TSO 是中心化方案,性能上限取决于 PD 的吞吐量(单节点约每秒分配几百万个时间戳),但实现最简单,没有时钟偏差问题。

共识协议的选择。 Spanner 选择 Paxos 因为 Google 内部有深厚的 Paxos 工程经验(Chubby、Megastore)。CockroachDB 和 TiDB 选择 Raft 因为 Raft 的可理解性更好。TiDB 的 Multi-Raft 是指每个 Region 运行独立的 Raft 组,Region 数量可达百万级。

SQL 兼容性的代价。 CockroachDB 兼容 PostgreSQL 线协议但不是完整的 PostgreSQL——某些高级特性(如存储过程的 PL/pgSQL 方言、某些扩展)不完全支持。TiDB 兼容 MySQL 协议但同样有差异(如不支持外键约束的实际执行、触发器功能有限、部分函数行为不同)。这些差异在迁移时是最主要的障碍。

3.1 提交路径的一致性保证对比

三个系统的提交路径差异,本质上源于它们对”全局时间”这一基本问题的不同回答:

Spanner 的 TrueTime 路径获取 TT.now() → commit_ts = latest → Paxos 复制 Prepare → 收集 Prepared → Commit-Wait(等待 TT.after(commit_ts)) → Paxos 复制 Commit → 完成。外部一致性保证来源于 Commit-Wait 的物理等待——通过等待不确定区间消逝,确保任何物理上更晚的事务看到的时间戳一定更大。代价是每个事务增加约 5-7ms 的固定延迟,但这个延迟与网络距离无关,只取决于 GPS/原子钟的精度。

CockroachDB 的 HLC 路径获取 HLC 时间戳 → 执行读写(检测不确定性窗口冲突)→ Parallel Commit(Staging 状态写入 Raft)→ 异步确认。CockroachDB 没有 Commit-Wait,而是通过不确定性窗口(默认 500ms)和读刷新机制来处理时钟偏差。当事务遇到不确定性窗口内的写入时,需要推进读时间戳并刷新之前的读集,可能导致事务重试。CockroachDB 24.1 引入了 Parallel Commit 优化,在 Prewrite 阶段即可标记事务为 Staging 状态,将提交延迟从两轮 Raft 降低到一轮。

TiDB 的 TSO 路径从 PD 获取 start_ts → 执行读写 → 从 PD 获取 commit_ts → Prewrite(加锁)→ Commit Primary → 异步 Commit Secondary。TSO 方案用中心化的时间戳分配器替代了物理时钟同步,完全消除了时钟偏差问题。代价是每个事务需要两次 PD 往返(获取 start_ts 和 commit_ts),增加约 1-2ms 延迟。PD 的 TSO 吞吐量(单节点约每秒数百万个时间戳)决定了系统的事务频率上限。

3.2 分布式事务状态机

三个系统的分布式事务都遵循相似的状态转移模型,但在中止路径和恢复机制上有差异。以下状态图展示了一个通用的分布式事务从开始到终结的完整生命周期:

stateDiagram-v2
    [*] --> Begin : 客户端发起事务

    Begin --> Executing : 获取时间戳(start_ts / read_version)
    Executing --> Executing : 执行读写操作

    Executing --> Preparing : 客户端提交(发起 2PC)
    Executing --> Aborted : 读写冲突检测失败

    Preparing --> Committing : 所有参与者 Prepared 成功
    Preparing --> Aborted : 任一参与者 Prepare 失败(锁冲突/超时)

    Committing --> Committed : Coordinator 写入 Commit 记录<br/>(Spanner: Paxos / TiKV: Raft / FDB: Log Server)
    Committing --> Aborted : Coordinator 故障且未持久化 Commit

    Committed --> [*] : 异步清理锁,释放资源
    Aborted --> [*] : 回滚 Prepare 记录,释放锁

该状态图揭示了分布式事务的两个关键决议点。第一个是 Preparing → Committing 的转移,取决于所有参与者的 Prepare 结果——这是 2PC 协议的核心约束,任一参与者的 Prepare 失败都会导致整个事务中止。第二个是 Committing → Committed 的转移,取决于 Coordinator 是否成功持久化 Commit 记录——在 Spanner 中是 Paxos 复制,在 TiDB/TiKV 中是 Primary Key 的 Raft 日志写入,在 FoundationDB 中是 Log Server 的多副本写入。一旦 Commit 记录持久化成功,事务的结果即不可撤销,即使后续的异步清理(释放锁、通知参与者)失败也不影响事务的正确性。


四、存算分离 vs 存算一体

这两种架构模式代表了分布式数据库设计中最根本的取舍之一。

4.1 存算一体(Coupled Storage and Compute)

Spanner 和 CockroachDB 采用存算一体架构:SQL 引擎和存储引擎运行在同一个进程(或至少同一台机器)上。数据分片的 Leader 节点同时负责 SQL 计算和数据存储。

优势: - 本地读取。SQL 引擎直接读取本地磁盘上的数据,没有额外的网络开销。对于点查(Point Lookup)场景,延迟更低。 - 简化部署。一个二进制文件包含所有功能,运维复杂度更低。

劣势: - 资源耦合。计算密集型查询和 I/O 密集型写入竞争同一台机器的 CPU 和内存。 - 弹性受限。需要同时扩展计算和存储,即使只有一边是瓶颈。扩展时需要迁移数据分片,过程缓慢。

4.2 存算分离(Disaggregated Storage and Compute)

TiDB 是存算分离的代表:TiDB Server(计算层)和 TiKV/TiFlash(存储层)是独立的进程,可以部署在不同的机器上。

优势: - 独立弹性。可以在不动存储层的情况下快速扩展计算层(秒级),也可以在不动计算层的情况下扩展存储层。 - 资源隔离。OLTP 和 OLAP 查询可以路由到不同的 TiDB Server 实例,互不干扰。 - 云原生友好。计算层可以用轻量级容器,存储层可以用大容量持久化存储,各取所需。

劣势: - 网络开销。每次查询至少需要一次计算层到存储层的 RPC 调用。在同一数据中心内通常在 100-500 微秒,但相比本地磁盘读取仍有增量。 - 协调复杂度。两层之间的生命周期管理、故障处理、版本兼容性都需要额外的工程投入。

4.3 混合演进

值得注意的是,这两种架构并非泾渭分明。CockroachDB 虽然是存算一体,但正在探索存算分离的方向(如将冷数据卸载到对象存储)。TiDB 虽然是存算分离,但通过协处理器(Coprocessor)下推机制,将部分计算推到 TiKV 层执行,减少网络传输——本质上是”选择性地耦合”。

Spanner 的演进更有意思。它最初是存算紧耦合的,但随着 Google 内部基础设施的演进(特别是 Borg 调度器和 Colossus 的成熟),Spanner 的存储层实际上已经在物理上与计算层分离,由 Colossus 统一管理。这是一种”物理上分离、逻辑上耦合”的混合模式。


五、分布式 SQL 优化器挑战

分布式数据库的 SQL 优化器面临的问题比单机数据库复杂一个数量级。核心难点是:网络是最大的成本因素

5.1 跨网络的代价估算

单机数据库的代价模型主要考虑磁盘 I/O 和 CPU。分布式数据库还必须考虑网络传输的数据量和网络延迟。一个在单机上最优的执行计划,放到分布式环境中可能是灾难性的。

经典的例子是嵌套循环连接(Nested Loop Join)。在单机上,如果驱动表很小且被驱动表有索引,嵌套循环连接是最优选择。但在分布式环境中,驱动表的每一行都可能触发一次到远端节点的 RPC,网络延迟会放大 N 倍。

5.2 分布式 JOIN 策略

分布式数据库通常支持以下几种 JOIN 策略:

广播连接(Broadcast Join)。 将较小的表复制到所有节点上,让每个节点本地执行 JOIN。适合一个大表和一个小表的场景。代价是小表的网络传输量 = 小表大小 x 节点数。

洗牌连接(Shuffle Join / Hash Join)。 两个表都按 JOIN 键做 Hash 分区,发送到相同的节点上执行本地 JOIN。适合两个大表的场景。代价是两个表的数据都需要在网络上重分布。

索引连接(Index Join / Colocated Join)。 如果两个表按相同的键分片,且 JOIN 键是分片键,则数据已经在同一个节点上,无需网络传输。Spanner 的交错表专门为这种场景设计。

协处理器下推(Coprocessor Pushdown)。 TiDB 特有的机制:将过滤、聚合、TopN 等算子下推到 TiKV 层执行,只返回结果集。大幅减少计算层和存储层之间的数据传输量。

-- 这个查询中 TiDB 会将 WHERE 和 SUM 下推到 TiKV
SELECT customer_id, SUM(amount)
FROM orders
WHERE status = 'completed'
  AND order_date >= '2025-01-01'
GROUP BY customer_id
HAVING SUM(amount) > 10000;

在 TiDB 的执行计划中,可以通过 EXPLAIN 看到 cop[tikv] 标记的算子就是被下推到 TiKV 协处理器执行的:

EXPLAIN SELECT customer_id, SUM(amount) FROM orders
WHERE status = 'completed' GROUP BY customer_id;

-- 输出(简化):
-- +------------------------------+-------+------+-----------------------------+
-- | id                           | count | task | operator info               |
-- +------------------------------+-------+------+-----------------------------+
-- | HashAgg                      | 8000  | root | group by: customer_id       |
-- | └─TableReader                | 8000  | root | data: Selection, HashAgg    |
-- |   └─HashAgg                  | 8000  | cop  | group by: customer_id       |
-- |     └─Selection              | 10000 | cop  | eq(status, "completed")     |
-- |       └─TableFullScan        | 50000 | cop  | table: orders               |
-- +------------------------------+-------+------+-----------------------------+

cop 任务在 TiKV 端执行,先过滤再局部聚合,传回 TiDB Server 的只是局部聚合结果。

5.3 分布式环境中的统计信息

代价估算依赖准确的统计信息。在分布式环境中,统计信息的收集和维护更困难:

TiDB 使用自动收集机制:当表的修改行数超过阈值(默认为表总行数的一定比例)时,后台触发统计信息重新收集。但在高频写入场景下,统计信息可能滞后,导致优化器选择错误的执行计划。

5.4 错误执行计划的案例

一个真实场景:某表有 1 亿行,按 user_id 分片。查询是 SELECT * FROM t WHERE city = 'Shanghai'。优化器估计 city = 'Shanghai' 的选择率是 1%(100 万行),选择了全表扫描 + 过滤。实际上 city = 'Shanghai' 占了 40%(4000 万行),应该走索引扫描,或者这个查询本身就需要在 city 列上建索引。

在分布式环境中,错误的执行计划代价更高:全表扫描意味着所有节点都要参与,网络传输 4000 万行数据到一个节点做过滤。这个查询可能在单机上跑 10 秒,在分布式环境中跑 300 秒。

CockroachDB 提供了 EXPLAIN ANALYZE 来帮助诊断这类问题,TiDB 也提供了类似的功能和慢查询日志。


六、与分库分表(MySQL 中间件)对比

6.1 中间件方案概览

在 NewSQL 数据库成熟之前,最常见的 MySQL 水平扩展方案是分库分表中间件:

Vitess。 YouTube 开源,Google 内部大规模使用。架构是 VTGate(代理层)+ VTTablet(MySQL 管理层)+ MySQL 实例。支持自动分片、在线 DDL(Data Definition Language)、连接池。生态成熟,但跨分片事务能力有限。

Apache ShardingSphere。 国内社区驱动,支持 Sharding-JDBC(嵌入应用的客户端分片)和 Sharding-Proxy(独立代理)两种模式。功能全面但架构相对复杂。

ProxySQL。 主要定位是 MySQL 代理和连接池,分片能力有限。

6.2 NewSQL 相比中间件的优势

分布式事务的透明性。 中间件的跨分片事务通常依赖 XA 协议或应用层 Saga 模式,可靠性和性能都有限。NewSQL 数据库在存储层原生支持分布式事务,应用层无需感知。

自动数据均衡。 中间件的分片策略在建表时确定(Hash 或 Range),数据倾斜后需要手动重新分片。NewSQL 数据库自动分裂、合并、迁移数据分片。

全局二级索引。 中间件的索引是分片级别的,查询非分片键时必须扇出(Fan-out)到所有分片。NewSQL 数据库可以维护全局二级索引(TiDB 支持,CockroachDB 支持)。

在线 DDL。 中间件的 DDL 需要逐个分片执行,过程复杂且容易出错。TiDB 实现了在线 DDL 变更(基于 Google F1 的 Online Schema Change 算法),对业务无感。

6.3 中间件的优势

生态成熟度。 MySQL 生态经过 25 年积淀,监控、备份、审计、安全合规的工具链非常完善。NewSQL 数据库的工具链仍在追赶。

运维简单性。 对于不需要跨分片事务的场景(大部分分片策略合理的 OLTP 系统),中间件 + MySQL 的运维成本反而更低。DBA 只需要管理 MySQL 实例,中间件层相对轻量。

风险可控。 MySQL InnoDB 是经过几十年生产验证的存储引擎。NewSQL 数据库虽然已经大规模部署,但在极端场景下(如大事务、热点行、冷启动)的行为可能不如 MySQL 可预测。

6.4 迁移复杂度

从分库分表中间件迁移到 NewSQL 数据库不是简单的切换。主要障碍包括:


七、MySQL 迁移路径

7.1 TiDB 的 MySQL 兼容性

TiDB 的设计目标之一是”MySQL 迁移零修改”。在多数情况下,使用 MySQL Connector 的应用可以直接连接 TiDB。但”兼容”不等于”完全一致”。

兼容的部分: - MySQL 线协议(大部分客户端驱动无需修改) - 标准 SQL 语法(SELECT、INSERT、UPDATE、DELETE、JOIN、子查询等) - 大部分内置函数 - 事务语法(BEGIN / COMMIT / ROLLBACK) - 用户权限系统(GRANT / REVOKE)

不兼容或有差异的部分: - 自增 ID(Auto Increment)的行为不同。TiDB 的自增 ID 全局唯一但不保证连续。每个 TiDB Server 预分配一段 ID 范围,多个 Server 并发写入时 ID 不单调递增。 - 外键约束(FOREIGN KEY)。TiDB 在较新版本中开始支持外键,但早期版本只解析语法不实际执行约束检查。 - 临时表(Temporary Table)的实现有限制。 - 某些 MySQL 特有的函数或语法(如 GET_LOCK()FOUND_ROWS() 的行为差异)。 - 存储过程和触发器的支持有限。 - SELECT ... FOR UPDATE 在悲观事务模式下行为与 MySQL 一致,在乐观模式下行为不同。

7.2 CockroachDB 的 PostgreSQL 兼容性

CockroachDB 兼容 PostgreSQL 线协议(pgwire),支持 PostgreSQL 的 SQL 方言。对于从 PostgreSQL 迁移的应用,兼容度较高。对于从 MySQL 迁移的应用,需要做 SQL 语法层面的转换。

主要差异包括: - 不完全支持 PL/pgSQL 存储过程语言 - 部分 PostgreSQL 扩展不可用(如 PostGIS 有部分支持) - 系统表和元数据查询的行为可能不同 - 序列(SEQUENCE)的行为差异

7.3 数据迁移工具

TiDB 提供了一套完整的迁移工具链:

TiDB Lightning。 用于全量数据导入。支持从 MySQL/MariaDB 的 SQL 或 CSV 文件导入,也支持从 Mydumper 导出的文件导入。Lightning 有两种模式:Local 模式直接写入 TiKV(速度最快,约 500 GB/小时),Tidb 模式通过 SQL 接口写入(兼容性最好但较慢)。

DM(Data Migration)。 用于从 MySQL/MariaDB 到 TiDB 的全量 + 增量实时迁移。DM 读取 MySQL 的 binlog,解析后写入 TiDB。支持表级别的过滤、列映射和分库分表的合并。

IMPORT INTO。 TiDB 7.0 引入的 SQL 语法级别的导入功能,支持从 S3、GCS 等对象存储直接导入 CSV/Parquet 文件。

-- TiDB IMPORT INTO 示例
IMPORT INTO orders
FROM 's3://my-bucket/orders/*.csv'
WITH fields_terminated_by=',',
     skip_rows=1,
     thread=8;

7.4 常见迁移陷阱

陷阱一:隐式类型转换。 MySQL 的隐式类型转换规则非常宽松(如 '1' + 1 = 2),TiDB 尽量兼容但不保证所有边界情况一致。

陷阱二:排序行为。 MySQL 的 GROUP BY 隐式添加 ORDER BY(在 MySQL 8.0 之前),TiDB 不保证这个行为。依赖隐式排序的应用可能得到不同的结果顺序。

陷阱三:事务大小限制。 TiDB 单个事务的大小限制默认为 100 MB(可配置)。MySQL 的大批量 INSERT 或 UPDATE 如果超过这个限制会失败。迁移前需要将大事务拆分为多个小事务。

陷阱四:执行计划差异。 前面提到,相同的 SQL 在 MySQL 和 TiDB 上可能选择不同的执行计划。建议使用 TiDB 的 SQL Replay 工具,在迁移前用生产流量回放测试。

陷阱五:字符集和排序规则。 TiDB 支持 utf8mb4 但部分排序规则(Collation)的实现可能与 MySQL 有细微差异,特别是涉及多语言文本比较时。


八、选型建议

8.1 决策矩阵

选择 NewSQL 数据库不是技术对比那么简单。以下是一个基于实际考量的决策框架:

考量维度 选 Spanner 选 CockroachDB 选 TiDB
数据规模 PB 级,无上限 TB-PB 级 TB-PB 级
一致性需求 需要外部一致性 需要可串行化 快照隔离足够
SQL 生态 可接受新 SQL 方言 已有 PostgreSQL 技术栈 已有 MySQL 技术栈
部署环境 Google Cloud 多云或自建 多云或自建
团队经验 Google Cloud 运维经验 Go/PostgreSQL 经验 MySQL DBA 经验
HTAP 需求 不是核心需求 不是核心需求 实时分析是核心需求
成本模型 接受按用量计费 需要自主控制成本 需要自主控制成本
许可证 商业(Cloud 服务) BSL(3 年后转 Apache 2.0) Apache 2.0

8.2 什么时候不该用 NewSQL

NewSQL 不是银弹。以下场景不建议使用:

数据量不大。 如果单个 MySQL 实例的容量(通常 2-5 TB)和性能就能满足需求,引入分布式数据库只会增加运维复杂度而没有收益。

写入模式是追加为主。 日志、时序数据、事件流——这些场景的写入模式简单,不需要复杂事务,时序数据库(TimescaleDB、InfluxDB)或日志系统(ClickHouse、Elasticsearch)更合适。

强依赖 MySQL/PostgreSQL 特有功能。 如果应用大量使用存储过程、触发器、物化视图、特定的扩展插件,迁移到 NewSQL 的改造成本可能超过收益。

团队没有分布式系统运维经验。 NewSQL 数据库的运维复杂度远高于单机数据库。Region 调度、热点处理、网络分区应对、版本升级——这些都需要专业知识。在团队不具备这些能力时贸然引入可能适得其反。

延迟要求极端。 如果应用对 P99 延迟有亚毫秒级的硬性要求(如高频交易系统),分布式数据库的网络开销和共识协议延迟可能无法满足。这类场景更适合优化过的单机数据库或内存数据库。

8.3 渐进式迁移策略

对于大多数团队,建议采用渐进式迁移而非一次性切换:

  1. 影子流量测试。 将生产查询同时发送到 MySQL 和 NewSQL,比较结果和延迟,不影响线上业务。
  2. 读流量切换。 先将只读查询路由到 NewSQL,写入仍走 MySQL。通过 DM 等工具保持数据同步。
  3. 写流量切换。 确认读流量无异常后,逐步切换写入流量。
  4. 反向同步保底。 切换初期保持 NewSQL 到 MySQL 的反向同步,方便快速回滚。

参考文献

  1. Pavlo, A. & Aslett, M. “What’s Really New with NewSQL?” SIGMOD Record, 45(2), 2016. https://15721.courses.cs.cmu.edu/spring2024/papers/01-intro/pavlo-newsql-sigmodrec2016.pdf
  2. Corbett, J. C., et al. “Spanner: Google’s Globally-Distributed Database.” OSDI ’12, 2012. https://research.google/pubs/pub39966/
  3. Taft, R., et al. “CockroachDB: The Resilient Geo-Distributed SQL Database.” SIGMOD ’20, 2020. https://dl.acm.org/doi/10.1145/3318464.3386134
  4. Huang, D., et al. “TiDB: A Raft-based HTAP Database.” VLDB ’20, 2020. https://vldb.org/pvldb/vol13/p3072-huang.pdf
  5. Peng, D. & Dabek, F. “Large-scale Incremental Processing Using Distributed Transactions and Notifications.” OSDI ’10, 2010. https://research.google/pubs/pub36726/
  6. Shute, J., et al. “F1: A Distributed SQL Database That Scales.” VLDB ’13, 2013. https://research.google/pubs/pub41344/
  7. Corbett, J. C., et al. “Spanner: Becoming a SQL System.” SIGMOD ’17, 2017. https://research.google/pubs/pub46103/
  8. Kulkarni, S., et al. “CockroachDB: Architecture of a Geo-Distributed SQL Database.” Cockroach Labs, 2024. https://www.cockroachlabs.com/docs/stable/architecture/overview.html

上一篇 下一篇
分布式 KV 存储对比 CRDT 理论

By .