数据库基础体系 · 第 56/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

TiDB 分布式 SQL:计算存储分离、Raft、事务与扩缩容

TiDB 是一个兼容 MySQL 协议和常用 SQL 语法的分布式关系数据库。它的核心设计可以概括为:

  • TiDB Server 负责 SQL 计算:连接管理、SQL 解析、优化、分布式执行和事务协调。
  • TiKV 负责事务型行存储:数据按键范围切分为 Region,并通过 Raft 复制。
  • PD(Placement Driver)负责全局调度:维护集群元数据、分配时间戳、进行 Region 调度和副本放置。
  • TiFlash 提供列式副本:在需要分析型扫描时,可与 TiDB/TiKV 组成 HTAP 架构。

“计算存储分离”并不意味着计算节点完全不参与事务,也不意味着存储节点独立理解所有 SQL。更准确地说,它把 SQL 层、分布式事务层和数据持久化层拆开,使 SQL 计算节点可以近似无状态地横向扩展,而数据则由 TiKV 的 Region 和 Raft 集群负责可靠存储。


一、先建立整体模型:一条 SQL 如何穿过 TiDB

一个典型 TiDB 集群至少包含:

客户端
  |
  v
TiDB Server 1 ---\
TiDB Server 2 ----> SQL 解析、优化、分布式执行、事务协调
TiDB Server N ---/
  |
  +--> PD:时间戳、Region 元数据、调度
  |
  +--> TiKV:事务行存储、MVCC、Raft
  |
  +--> TiFlash:可选的列式副本和分析执行

1. TiDB Server:计算层

TiDB Server 通常通过 MySQL 协议接收连接。它主要完成以下工作:

  1. 解析 SQL;
  2. 绑定参数、检查表和列;
  3. 使用统计信息生成执行计划;
  4. 将可以下推的过滤、聚合、部分计算发送到 TiKV 或 TiFlash;
  5. 汇总各节点返回的中间结果;
  6. 参与事务的开始、提交、回滚和冲突处理。

TiDB Server 本身不以本地磁盘作为用户表的权威存储。因此,增加 TiDB Server,通常不会复制一份完整数据,而是增加 SQL 连接和计算能力。

但“无状态”是相对的:

  • TiDB Server 仍然维护客户端连接和会话状态;
  • 当前事务可能通过某个 TiDB Server 与 TiKV 交互;
  • 连接断开时,客户端需要处理事务失败或提交结果不确定;
  • 统计信息、系统表和集群配置仍会影响执行结果。

所以,TiDB Server 适合横向扩展,但不能把它理解成完全没有任何运行时状态的 HTTP 网关。

2. TiKV:分布式存储层

TiKV 保存用户数据和索引,并提供:

  • 按键范围组织的数据;
  • MVCC 多版本并发控制;
  • 事务读写;
  • Raft 副本复制;
  • Region 级别的分裂、合并和迁移;
  • 部分 SQL 算子的协处理执行。

TiKV 不是把整张表作为一个整体存储,而是把表和索引编码成键,再按键范围切分为多个 Region。

3. PD:集群控制面

PD 主要负责:

  • 记录 Region 与副本的位置信息;
  • 为事务提供全局时间戳;
  • 选举和维护 Region 调度所需的集群信息;
  • 根据负载、容量和副本约束移动 Region;
  • 在节点加入、下线或故障时调整副本分布。

PD 不保存用户表的完整数据。用户数据仍位于 TiKV,PD 保存的是“数据在哪里、集群应该如何调度”这类控制面信息。

4. TiFlash:列式副本

TiFlash 是 TiKV 数据的列式副本,适合大范围扫描、聚合和分析查询。它不是简单地把 TiKV 的行格式换一种存储,而是通过 Raft Learner 等机制从 TiKV 获取变更,再建立列式存储。

当表存在 TiFlash 副本并且优化器判断收益足够时,查询可能选择 TiFlash。事务型点查和小范围更新通常仍主要使用 TiKV。


二、计算存储分离到底分离了什么

1. 分离的是职责和扩展维度

在单体数据库中,新增一个数据库实例往往同时新增:

  • SQL 计算资源;
  • Buffer Pool;
  • 本地磁盘;
  • WAL;
  • 副本或复制职责。

TiDB 则允许分别扩展:

需求 主要扩展对象
更多客户端连接、SQL 并发 TiDB Server
更高事务读写吞吐、更多数据 TiKV
更大的分析扫描能力 TiFlash
数据分布和副本调度 PD 集群

这种分离的直接收益是:计算资源和存储资源不必按照相同速度增长。

例如,数据量不变但连接数从 1 万增加到 3 万,可以优先增加 TiDB Server;如果数据量和写入量同时增长,则需要增加 TiKV,并让 Region 重新分布。

2. 分离并没有消除网络成本

一条分布式 SQL 的路径可能是:

客户端
  -> TiDB Server
  -> PD 查询 Region 位置
  -> 多个 TiKV Region Leader
  -> TiDB Server 汇总结果
  -> 客户端

因此,计算存储分离带来的成本包括:

  • SQL 层到 TiKV 的网络往返;
  • 跨 Region 查询的多个并发请求;
  • 分布式聚合和排序的中间结果传输;
  • 跨 Region 事务的锁和提交消息;
  • 远程存储访问相对于本地 Buffer Pool 的延迟。

TiDB 通过 Region 路由、批量请求、协处理下推和并行执行降低这些成本,但不能使网络消失。


三、Region:TiDB 分布式存储的基本分片单位

1. 表和索引如何变成 TiKV 的键

TiDB 不直接把“表的一行”交给 TiKV,而是将表行和索引编码为键。例如,逻辑上可以表示为:

表行:
t{table_id}_r{handle} -> 行数据

索引:
t{table_id}_i{index_id}_{encoded_columns}_{handle} -> 空值或索引值

具体编码格式属于实现细节,不能依赖其字符串形式做业务逻辑。但重要性质是:

  • 同一个索引的键具有有序性;
  • 主键或聚簇索引会影响行键的排列;
  • TiKV 按键范围而不是按 SQL 表名切分数据;
  • 二级索引也需要被分布式存储和维护。

Region 是一个连续键范围。例如:

Region A: [key_0000, key_5000)
Region B: [key_5000, key_9000)
Region C: [key_9000, key_FFFF)

实际边界由系统根据数据规模和调度状态动态变化。

2. Region 的状态

一个 Region 通常包含:

  • 一个 Region ID;
  • 一个键范围;
  • 多个副本 Peer;
  • 一个 Raft Group;
  • 一个 Leader;
  • 其他 Follower 或 Learner。

同一 Region 的副本保存相同的数据范围,但不同 Region 保存不同的键范围。

因此,TiDB 的分片粒度不是固定的“一个表一个分片”,而是动态的键范围分片。

3. Region 分裂

当一个 Region 过大,或者写入、扫描负载集中时,系统可以把它分裂为两个 Region:

分裂前:
R1: [A, Z)

分裂后:
R1: [A, M)
R2: [M, Z)

分裂的核心约束是:

  1. 两个新 Region 的范围不重叠;
  2. 两个范围合起来覆盖原范围;
  3. 原 Region 的 Raft 数据和元数据要安全转换;
  4. PD 更新 Region 位置后,TiDB 才能按新边界路由请求。

客户端不应缓存 Region 边界作为永久信息。TiDB/TiKV 客户端会处理 Region miss、epoch 变化和 Leader 变化,并重新向 PD 获取路由信息。


四、Raft:Region 副本如何保持一致

1. Raft 的复制对象是日志,不是 SQL

对一个 Region 而言,Raft 组中的副本不会直接复制一条 SQL 文本,而是复制状态机日志。日志代表对该 Region 数据状态的修改。

例如,某事务对一个 Region 中的键执行写入:

k1: old -> new

Leader 会将对应的状态变更追加到 Raft 日志,并复制给 Follower。多数副本确认后,该日志才能被认为提交。

2. 多数派条件

设一个 Raft 组有 NN 个副本,则多数派大小为:

Q=N2+1Q = \left\lfloor \frac{N}{2} \right\rfloor + 1

在最多 ff 个副本故障时仍能提交,需要:

N2f+1N \ge 2f + 1

例如:

  • 3 副本:多数派为 2,通常可容忍 1 个副本故障;
  • 5 副本:多数派为 3,通常可容忍 2 个副本故障;
  • 2 副本:多数派为 2,任意一个副本故障都会导致无法继续提交。

这里的“容忍”有边界:副本必须是不同故障域中的独立故障。如果 3 个副本都位于同一台物理机或同一个故障域,形式上的 3 副本并不能提供相同的实际可用性。

3. 一次写入的基本路径

假设某 Region 的 Leader 位于 TiKV-1,Follower 位于 TiKV-2 和 TiKV-3:

TiDB Server
    |
    v
TiKV-1(Leader)
    | \
    |  \
    v   v
TiKV-2 TiKV-3(Follower)

一次已经准备提交的 Region 写入大致经历:

  1. TiDB/TiKV 客户端根据 Region 路由找到 Leader;
  2. TiKV-1 接收写入;
  3. TiKV-1 追加 Raft 日志;
  4. TiKV-1 将日志复制到 TiKV-2、TiKV-3;
  5. 至少一个 Follower 确认后形成多数派;
  6. Leader 标记日志已提交;
  7. 各副本按顺序应用日志;
  8. TiKV 向上层返回结果。

Raft 保证的是同一个 Raft 组内的日志顺序和副本一致性。它本身不负责跨 Region 事务的原子提交;跨 Region 事务需要事务协议协调多个 Raft 组。

4. Leader 故障路径

如果 TiKV-1 的 Leader 故障:

  1. 其他副本停止接受旧 Leader 的有效写入;
  2. Raft 组中的可用副本发起选举;
  3. 某个满足条件的副本成为新 Leader;
  4. 客户端请求旧 Leader 时可能得到 Not Leader、Epoch Not Match 或超时;
  5. TiDB/TiKV 客户端刷新 Region 路由;
  6. 请求被重试到新 Leader。

如果剩余副本不能形成多数派,数据通常仍然存在于某些副本上,但该 Region 不能继续提交新的写入。这个“不可写”是避免脑裂和丢失已提交数据的代价。

5. Raft 一致性与读请求

普通强一致读通常需要从当前 Leader 或经过一致性确认的路径读取,不能仅仅因为某个 Follower “看起来有数据”就认为它一定包含最新提交。

在特定场景下,TiDB 也支持 stale read 等弱化读取新鲜度的方式,从历史时间戳读取数据。这类读取可以避开部分强一致路径,但业务必须接受读取延迟,不能把它与最新读混用。


五、时间戳与 MVCC:事务为什么能看到某个版本

1. MVCC 的基本思想

MVCC(Multi-Version Concurrency Control,多版本并发控制)为同一个逻辑键保存多个版本:

key = user:1

提交时间 100:name = Alice
提交时间 120:name = Bob
提交时间 150:name = Carol

一个事务在时间戳 ss 上读取时,通常寻找满足:

commit_tsscommit\_ts \le s

且最大的版本:

v=argmaxcommit_tss(commit_ts)v = \arg\max_{commit\_ts \le s}(commit\_ts)

例如:

  • 事务开始时间戳 s=130s=130:读到 Bob;
  • 事务开始时间戳 s=160s=160:读到 Carol;
  • 未提交的版本没有合法的 commit timestamp,因此不能被普通快照直接读到。

TiKV 的 MVCC 实现会维护数据版本、锁和写入信息。具体底层列族和编码属于实现细节,但业务可观察到的关键行为是:事务读取的是符合快照时间戳的已提交版本,并需要处理未提交锁。

2. TSO:全局时间戳分配

PD 提供 TSO(Timestamp Oracle),为事务分配单调递增的时间戳。可以把事务的时间关系抽象为:

start_ts < commit_ts

其中:

  • start_ts:事务读取快照的逻辑时间;
  • commit_ts:事务提交版本的逻辑时间。

TSO 不是把所有事务串行执行,而是提供一个全局可比较的时间坐标,使不同 TiKV 节点上的版本能够被比较和排序。

3. 时间戳不是墙上时钟

TSO 的时间戳主要用于事务版本排序,不等同于应用所理解的 Unix 时间,也不保证每次事务提交都与物理时钟完全相同。

业务中的创建时间、更新时间应显式使用时间类型和相应函数;不要把事务时间戳当成业务事件发生时间。


六、TiDB 事务:从单 Region 写入到跨 Region 两阶段提交

1. 单机事务与分布式事务的差别

如果一个事务涉及的所有键都位于同一个 Region,可以在一个 Raft 组内完成存储层操作。

但一个普通事务通常可能同时修改:

  • 多个主键;
  • 多个二级索引;
  • 多个 Region;
  • 甚至多个表。

此时,单个 Raft 组的多数派提交不足以保证全局原子性。必须同时满足:

Region A:提交
Region B:提交
Region C:提交

不能出现 A、B 已提交而 C 永久未提交的可见状态。

2. 两阶段提交的抽象流程

TiDB 的事务实现基于 Percolator 风格的 MVCC 事务,并使用两阶段提交(2PC)保证跨 Region 原子性。下面用抽象步骤表示。

假设事务:

BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT;

假设 id=1id=2 分属于两个 Region。

阶段一:Prewrite

事务先选择一个主键作为 primary lock,其他键作为 secondary keys。

Region A:
  写入 primary lock
  写入待提交值

Region B:
  写入 secondary lock
  写入待提交值

此时写入值仍不是对普通快照可见的最终提交版本。其他事务遇到锁时,需要根据锁信息判断:

  • 锁属于哪个事务;
  • 主锁在哪里;
  • 事务是否已提交;
  • 是否可以等待、回滚或清理。

阶段二:Commit

当所有参与 Region 都成功 Prewrite 后,事务得到一个 commit_ts,然后提交 primary key:

Region A:
  primary lock -> committed write

Region B:
  secondary lock 仍可根据 primary 状态异步清理或完成提交

事务的提交状态由 primary key 的提交记录作为重要依据。这样,即使客户端在提交过程中断开,后续参与者仍可以通过事务状态判断该事务是提交还是回滚。

3. 为什么不能先写一半再提交另一半

反例:

Region A:扣款成功
Region B:加款失败

如果系统直接把每个 Region 的成功视作全局成功,账户总余额就会减少 100。这违反了转账事务的原子性。

2PC 的核心不是让每一步都不失败,而是让失败发生时,其他参与者能够依据统一的事务状态完成回滚、等待或提交收尾。

4. 2PC 的代价

2PC 带来额外成本:

  • 需要多个 Region 参与网络交互;
  • 需要写锁;
  • 需要等待所有参与者完成 Prewrite;
  • 需要提交阶段的额外消息;
  • 发生故障时,锁清理和事务恢复可能延迟。

因此,跨 Region 写入的代价通常高于单 Region 写入。表设计、主键设计和事务边界会影响这一成本,但不能为了减少跨 Region 事务而破坏数据模型中的必要原子性。

TiDB 在特定条件下还可能使用 1PC、异步提交等优化路径。它们是对满足条件的事务的性能优化,不应改变应用对原子性和提交结果的正确性假设。


七、隔离级别:TiDB 的事务并不等于串行化

1. 默认隔离语义

TiDB 默认事务隔离级别通常为 REPEATABLE-READ。在 TiDB 的实现中,它主要表现为基于事务快照的 Snapshot Isolation(SI)语义。

这意味着:

  • 事务通常基于自己的快照读取;
  • 已提交版本按照时间戳可见;
  • 写入同一键的事务会发生冲突;
  • 事务不必阻塞所有普通快照读;
  • 不能自动推导出完整 Serializable(可串行化)保证。

部署版本和配置可能影响可用隔离级别,因此应在目标集群上确认:

SELECT @@session.transaction_isolation;
SELECT @@global.transaction_isolation;

某些版本也支持 READ-COMMITTED 等级别,具体行为应以对应版本文档和集群配置为准。

2. 写冲突示例

两个事务都读取同一个余额:

初始 balance = 100

T1:读取 balance = 100
T2:读取 balance = 100

T1:写入 balance = 50
T2:写入 balance = 80

如果 T1 先提交,T2 再写同一个键,T2 不能简单地覆盖 T1。TiDB 会检测写冲突,事务可能返回冲突错误,需要回滚或由客户端重试。

应用不能把“事务提交失败”当成数据库异常到可以忽略;它表示本次事务没有完成,需要重新执行业务逻辑。

3. 写偏差反例:SI 不保证可串行化

考虑值班约束:至少一名医生必须在岗。

初始状态:

doctor_a.on_call = true
doctor_b.on_call = true

T1:

读取:doctor_b 在岗
写入:doctor_a 改为不在岗

T2:

读取:doctor_a 在岗
写入:doctor_b 改为不在岗

如果两个事务的读取都来自相同或相容快照,并且它们修改的是不同的键:

T1:doctor_a = false
T2:doctor_b = false

二者可能都提交,最终违反“至少一人值班”的约束。这就是典型写偏差。因为两个事务没有写同一个键,普通写冲突检测未必能阻止它们。

需要避免这种情况时,可以:

  • 显式锁定代表约束的同一行;
  • 将约束状态集中到一个需要更新的键;
  • 使用更强的隔离语义或应用层串行化;
  • 重新设计数据模型,使冲突在存储层表现为同键写冲突。

关键点是:REPEATABLE-READ 的名字不能直接等同于“所有并发异常都被消除”。

4. 悲观事务与乐观事务

TiDB 支持乐观事务和悲观事务两类模型。

乐观事务通常先读取和记录修改,提交时检测冲突。适合冲突较少的场景,但冲突发生后可能需要重新执行整个事务。

悲观事务在执行更新时更早获取锁,使后续冲突事务等待或失败。适合冲突概率高、业务不希望在提交阶段才发现冲突的场景,但会增加锁等待和死锁处理复杂度。

示例:

SET SESSION tidb_txn_mode = 'pessimistic';

BEGIN;
SELECT balance
FROM accounts
WHERE id = 1
FOR UPDATE;

UPDATE accounts
SET balance = balance - 100
WHERE id = 1;

COMMIT;

这段 SQL 的含义是:

  1. 设置当前会话使用悲观事务模式;
  2. 开始事务;
  3. 对目标行加锁并读取;
  4. 执行更新;
  5. 提交事务。

前置条件是目标表和事务配置支持该用法,且应用必须正确处理锁等待、超时、死锁和提交失败。FOR UPDATE 不是“永久锁”,事务提交或回滚后锁会释放。


八、一个可观察的事务示例

下面的示例使用两个客户端会话,说明快照读和写冲突。假设已经连接到 TiDB,并且当前数据库可写。

先在会话 A 执行:

DROP TABLE IF EXISTS demo_balance;

CREATE TABLE demo_balance (
    id      INT PRIMARY KEY,
    balance INT NOT NULL
);

INSERT INTO demo_balance VALUES (1, 100);

场景一:普通快照读不被未提交写入阻塞

会话 A:

BEGIN;
UPDATE demo_balance
SET balance = 50
WHERE id = 1;

此时会话 A 尚未提交。

会话 B:

BEGIN;
SELECT balance
FROM demo_balance
WHERE id = 1;

会话 B 通常读到已提交的旧版本:

balance
-------
100

会话 B 不应读到会话 A 尚未提交的 50。这体现了 MVCC 的快照读语义。

如果会话 A 执行:

COMMIT;

会话 B 已经开始的事务是否看到 50,取决于其快照建立时机和具体读取语义;不能简单地把“提交后再执行 SELECT”理解成一定刷新到最新版本。若要开始一个新的快照,应结束当前事务并重新开始:

COMMIT;
BEGIN;

SELECT balance
FROM demo_balance
WHERE id = 1;

场景二:同一键写入产生冲突

重新准备数据:

TRUNCATE TABLE demo_balance;
INSERT INTO demo_balance VALUES (1, 100);

会话 A:

BEGIN;
UPDATE demo_balance
SET balance = 80
WHERE id = 1;

会话 B:

BEGIN;
UPDATE demo_balance
SET balance = 70
WHERE id = 1;

会话 B 可能等待会话 A 的锁,也可能根据锁等待超时、事务配置或冲突检查结果返回错误。会话 A 提交后,会话 B 不应无条件认为自己的更新仍然有效;它必须检查数据库返回结果,并根据业务决定回滚、重试或提示用户。

一个安全的客户端重试流程应当是:

执行事务
  |
  +-- COMMIT 成功:业务成功
  |
  +-- 明确的写冲突/死锁:回滚并重新执行事务
  |
  +-- 连接断开、提交结果未知:查询事务状态或使用业务幂等设计

“提交请求超时”尤其不能直接等同于“提交失败”。请求可能已经到达集群并完成提交,只是响应没有返回客户端。


九、SQL 执行:哪些计算可以下推

TiDB 的分布式 SQL 执行通常不是把所有行搬到 TiDB Server 再计算,而是尽量将计算发送到数据所在的 TiKV 或 TiFlash。

例如:

SELECT customer_id, SUM(amount)
FROM orders
WHERE order_time >= '2025-01-01'
GROUP BY customer_id;

一个合理的执行过程可能是:

  1. TiDB Server 识别 orders 的 Region;
  2. 向多个 TiKV Region 发起并行请求;
  3. order_time >= ... 过滤下推到存储节点;
  4. 在存储节点进行部分聚合;
  5. TiDB Server 汇总各 Region 的部分结果;
  6. 返回最终的 SUM

这比把所有订单行传回 TiDB Server 更节省网络带宽。

可以使用执行计划观察下推情况:

EXPLAIN
SELECT customer_id, SUM(amount)
FROM orders
WHERE order_time >= '2025-01-01'
GROUP BY customer_id;

在执行计划中,可能看到类似 IndexRangeScanTableRangeScanSelectionHashAgg 等算子。不同版本和统计信息下,具体计划会变化,不能只根据算子名称推断性能。

需要注意几个边界:

  • 不是所有 SQL 函数都能完全下推;
  • 跨 Region 的排序、全局聚合和大表连接仍需要汇总;
  • 统计信息过期可能导致错误的索引或连接顺序选择;
  • 数据分布倾斜时,单个 Region 可能成为瓶颈;
  • TiFlash 是否被选中取决于副本状态、代价估算和查询形态。

EXPLAIN ANALYZE 可以观察实际执行耗时和行数,但它会真正执行查询,因此对写操作、昂贵查询和生产环境使用应谨慎:

EXPLAIN ANALYZE
SELECT COUNT(*)
FROM orders
WHERE order_time >= '2025-01-01';

十、扩容:增加 TiDB、TiKV 和 TiFlash 分别意味着什么

1. 增加 TiDB Server

增加 TiDB Server 的主要作用是增加 SQL 层容量:

负载均衡器
  |
  +--> TiDB Server 1
  +--> TiDB Server 2
  +--> TiDB Server 3

新 TiDB Server 不需要先复制全量表数据。它启动后可以通过 PD 和 TiKV 访问已有数据。

扩展 TiDB Server 主要缓解:

  • 客户端连接数;
  • SQL 解析和优化;
  • 分布式请求发起;
  • 结果汇总;
  • 部分计算压力。

它不会直接增加 TiKV 磁盘容量,也不会自动提高单个热点 Region 的写入上限。

2. 增加 TiKV

增加 TiKV 节点后,PD 可以将 Region 副本调度到新节点。过程通常包括:

  1. 新 TiKV 向集群注册;
  2. PD 发现集群容量和负载变化;
  3. PD 选择需要迁移的 Region;
  4. 通过 Raft 快照或日志复制建立目标副本;
  5. 目标副本追上进度;
  6. 调整 Peer 或 Leader 分布;
  7. 原节点上的副本在确认安全后移除。

扩容不是“立即把所有请求平均分给新节点”。已有 Region 必须经过副本迁移和 Leader 调度,数据分布才逐渐改变。

因此,扩容期间可能同时产生:

  • Raft 复制流量;
  • 快照生成和应用开销;
  • 磁盘 IO;
  • 网络带宽压力;
  • Leader 迁移带来的短暂抖动。

生产扩容应观察 Region health、Leader 分布、存储 IO、Raft 延迟和调度进度,而不是只确认“节点已经出现在集群列表中”。

3. 增加 TiFlash

增加 TiFlash 节点主要增加列式副本和分析能力。它需要为目标表配置 TiFlash 副本,副本建立完成后,优化器才可能选择 TiFlash。

一个查询计划使用 TiFlash,并不表示所有查询都使用 TiFlash:

  • 点查通常更适合 TiKV;
  • 宽表大范围扫描和聚合可能适合 TiFlash;
  • 副本未追平时,不能假设分析结果已经达到预期的新鲜度;
  • TiFlash 的资源扩展与 TiKV 事务写路径不是同一件事。

十一、缩容和故障处理:删除节点不是直接关机

1. 安全缩容的目标

缩容必须先让集群把该节点上的副本迁移到其他节点,并保证:

  • 每个 Region 仍满足副本数量;
  • Raft 组仍能形成多数派;
  • 数据副本已经完整;
  • Leader 和 Follower 分布符合故障域要求;
  • 调度任务完成或达到可接受状态。

如果直接停止承载大量 Region 的 TiKV:

  • 部分 Region 可能暂时没有 Leader;
  • 如果剩余副本不足多数派,Region 将不可写;
  • 正在执行的事务可能超时、回滚或进入提交结果不确定状态;
  • 调度压力可能同时转移到其他节点。

2. 故障与恢复的区别

短暂故障通常可以通过 Raft 选举和客户端重路由恢复服务。

永久丢失则需要依赖其他副本重新建立副本。如果故障导致某个 Region 只剩下少于多数派的可用副本,系统可能无法继续提交;如果数据副本全部丢失,则需要备份恢复,Raft 本身不能从不存在的副本恢复数据。

因此,Raft 高可用不是备份替代品:

  • Raft 保护在线副本的一致性和节点故障;
  • 备份保护误删、逻辑错误、勒索、全副本损坏和灾难性故障。

十二、扩缩容为什么不能解决所有性能问题

1. 单 Region 热点

如果大量请求集中访问同一个主键范围,所有请求可能落到同一个 Region:

写入 key:
1000001
1000002
1000003
...

即使集群中增加很多 TiKV 节点,该 Region 的 Leader 仍然可能成为瓶颈。

热点可能来自:

  • 单调递增主键导致写入集中在尾部;
  • 某个库存行被高频更新;
  • 业务把所有租户汇总到一行;
  • 某个时间范围被集中扫描;
  • 二级索引写入集中。

Region 分裂可以增加分片数量,但如果热点始终集中于一个范围,分裂未必能解决问题。还可能需要:

  • 使用更均匀的键分布;
  • 将高频计数拆分为多个桶;
  • 将单行热点改成可并行累加的多行;
  • 重新设计访问模式;
  • 在不破坏事务约束的前提下拆分业务事务。

这些是数据模型问题,不能仅靠加机器修复。

2. 跨 Region 事务

把一个业务事务拆到多个 Region 会增加 2PC 参与者数量。假设事务涉及 mm 个 Region,粗略地说,事务协调需要处理多个参与者的 Prewrite 和 Commit,而不是只和一个 Raft 组交互。

这不意味着“跨 Region 事务一定错误”,而是说明:

  • 事务边界越大,失败路径越复杂;
  • 锁等待范围越大;
  • 网络延迟对提交时间影响越明显;
  • 冲突和重试成本可能上升。

业务数据需要跨多个 Region 保持原子性时,应使用数据库事务;不能为了性能把必要的原子操作拆成互不关联的异步更新。


十三、路由变化和常见错误

TiDB 的路由不是永久静态的。Region 可能分裂、合并、迁移,Leader 也可能因为故障或调度发生变化。

因此请求可能遇到:

  • Region Not Found;
  • Not Leader;
  • Epoch Not Match;
  • Region 正在迁移;
  • Leader 选举中的暂时超时;
  • 事务锁等待超时;
  • 写冲突;
  • 提交结果未知。

这些错误不应全部用同一种方式处理。

1. 路由类错误

TiDB/TiKV 客户端通常会刷新 Region 缓存并重试请求。应用层一般不需要自己维护 Region 到节点的映射。

2. 事务冲突类错误

事务写冲突、死锁、锁等待超时需要回滚当前事务。若业务允许,应该从 BEGIN 重新执行整个事务,而不是只重试最后一条 SQL。

错误的重试方式:

UPDATE 扣款成功
UPDATE 加款失败
只重试加款

这可能破坏业务原子性。

正确的抽象方式是:

重新开始事务
  -> 读取最新状态
  -> 重新校验业务条件
  -> 执行完整扣款和加款
  -> 重新提交

3. 提交结果未知

以下情况不能直接判断事务没有提交:

客户端发送 COMMIT
数据库已经提交
网络在响应返回前断开
客户端收到超时

应用需要:

  • 查询事务状态;
  • 使用业务请求 ID 或幂等键;
  • 设计可重复执行的业务操作;
  • 避免因为超时而盲目重复扣款。

十四、部署边界与数据一致性取舍

1. 副本数和故障域

副本数应结合故障域设计。把 3 个副本放在 3 台不同机器上,与把 3 个副本放在同一物理机的不同进程中,可靠性完全不同。

需要考虑:

  • 物理机;
  • 可用区;
  • 机架;
  • 网络分区;
  • 存储设备;
  • PD 和 TiDB Server 的独立性。

PD、TiDB Server、TiKV 的高可用目标不同:

  • TiDB Server 可以通过多个实例和负载均衡提高接入可用性;
  • TiKV 通过 Region Raft 副本保证数据副本一致性;
  • PD 需要自身形成高可用集群,才能持续提供调度和时间戳服务。

2. 强一致与低延迟不能同时无限优化

如果副本分布在跨可用区或跨地域环境:

  • Raft 多数派确认会受到跨域网络延迟影响;
  • 跨 Region 事务的 2PC 会进一步放大延迟;
  • 网络分区可能使某些 Region 暂时不可写;
  • 通过弱一致或 stale read 降低读取延迟时,新鲜度会下降。

因此,副本布局、事务范围和读一致性必须根据业务选择,而不能只说“副本越远越安全”或“所有请求都读最近节点”。


十五、用一个完整场景串起所有机制

假设有订单表:

CREATE TABLE orders (
    id          BIGINT PRIMARY KEY,
    customer_id BIGINT NOT NULL,
    amount      DECIMAL(18, 2) NOT NULL,
    status      VARCHAR(20) NOT NULL,
    created_at  DATETIME NOT NULL,
    KEY idx_customer_time (customer_id, created_at)
);

执行:

BEGIN;

INSERT INTO orders
    (id, customer_id, amount, status, created_at)
VALUES
    (10001, 7, 88.00, 'PAID', NOW());

UPDATE customer_account
SET balance = balance - 88.00
WHERE customer_id = 7;

COMMIT;

系统可能经历如下过程:

  1. TiDB Server 解析 SQL,并为 INSERTUPDATE 生成执行计划;
  2. 订单主键、订单二级索引、账户行被编码成 TiKV 键;
  3. 这些键根据范围被定位到一个或多个 Region;
  4. TiDB 为事务获取 start_ts
  5. 订单行、索引和账户余额形成事务写集合;
  6. 如果涉及多个 Region,执行跨 Region Prewrite;
  7. 各相关 Region 内部通过 Raft 复制写入;
  8. 所有参与者准备成功后,事务获得 commit_ts
  9. 提交 primary key,事务状态变为已提交;
  10. 后续快照读根据自己的读取时间戳看到正确版本;
  11. 如果期间某个 Leader 故障,客户端刷新路由,Raft 组重新选举;
  12. 如果发生写冲突,事务回滚或由客户端完整重试。

这个过程说明了几层机制的边界:

  • 计算存储分离解决 SQL 计算和数据存储的独立扩展;
  • Region解决数据的分片和分布;
  • Raft解决单个 Region 副本之间的一致性;
  • MVCC 和 TSO解决多版本读取和全局版本排序;
  • 2PC解决跨 Region 事务原子性;
  • PD解决路由、调度和时间戳服务;
  • TiFlash则为特定查询提供列式分析副本。

任何一个机制都不能替代其他机制。Raft 不能单独完成跨 Region 事务,MVCC 不能单独提供可串行化,增加 TiDB Server 不能修复热点 Region,增加副本也不能替代备份。


十六、理解 TiDB 时最容易混淆的几个边界

1. “分布式”不等于每条 SQL 都自动线性扩展

查询是否能扩展,取决于:

  • 数据是否均匀分布;
  • 是否存在热点 Region;
  • 过滤条件能否命中索引;
  • 算子能否下推;
  • 是否需要全局排序或聚合;
  • 是否涉及大量跨 Region 数据交换。

2. “Raft 已复制”不等于“跨 Region 事务已提交”

Raft 只能说明某个 Region 内的日志形成多数派。一个事务涉及多个 Region 时,还要经过事务协议的全局提交过程。

3. “Repeatable Read”不等于“Serializable”

TiDB 默认快照隔离可以避免脏读,并提供稳定的快照读取,但写偏差等异常仍然可能出现。需要更强约束时,应通过锁、模型设计或更强的隔离策略实现。

4. “增加节点”不等于“立即均匀”

TiKV 扩容后的副本迁移和 Leader 调度需要时间,且会消耗网络、磁盘和 CPU。只有数据和 Leader 分布完成后,扩容收益才会充分体现。

5. “客户端超时”不等于“事务一定失败”

尤其是 COMMIT 阶段,客户端可能无法区分:

提交尚未发生

和:

提交已经发生,但响应丢失

这要求应用采用事务状态确认、幂等键和可安全重试的业务设计。

TiDB 的核心价值不在于把传统单机数据库简单复制到多台机器,而在于把 SQL、分布式存储、副本一致性、MVCC 事务和动态调度组合成一个可扩展的关系数据库系统。理解这些组件之间的职责边界,才能正确判断一条 SQL 的性能、一个事务的失败路径,以及一次扩缩容操作真正改变了什么。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。