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

MySQL 分片与 Vitess:路由、VSchema、重分片和跨分片事务

在单个 MySQL 实例中,表、索引、事务和约束通常都处于同一个存储引擎和同一个服务器边界内。分片之后,原本由 MySQL 直接完成的许多事情会变成分布式系统问题:

  • 一条 SQL 应该发送到哪个分片?
  • 一个逻辑表如何映射到多个物理表?
  • 主键是否仍然全局唯一?
  • 新增分片时,已有数据如何迁移?
  • 两个分片上的写入能否保持原子性?
  • 某个分片或副本故障时,路由和事务如何处理?

Vitess 的核心价值,不是把 MySQL 改造成一个拥有无限容量的单机数据库,而是在 MySQL 实例之上提供一层面向分片的拓扑管理、SQL 路由、复制流迁移和事务协调能力。它仍然受 MySQL 和分布式系统的基本边界约束:跨分片查询更昂贵,跨分片事务更复杂,自动扩容不能消除数据迁移成本。


一、先区分 MySQL 分区、MySQL 复制和 Vitess 分片

这三个概念经常被混用,但它们解决的问题不同。

1. MySQL 分区表:一个实例内的物理组织

MySQL 分区表仍然属于一个 MySQL 实例、一个数据库对象。从 SQL 语义看,应用访问的是一张表:

CREATE TABLE orders (
    id          BIGINT NOT NULL,
    customer_id BIGINT NOT NULL,
    created_at  DATETIME NOT NULL,
    amount      DECIMAL(12, 2) NOT NULL,
    PRIMARY KEY (id, created_at)
)
PARTITION BY RANGE COLUMNS (created_at) (
    PARTITION p2024 VALUES LESS THAN ('2025-01-01'),
    PARTITION p2025 VALUES LESS THAN ('2026-01-01'),
    PARTITION pmax  VALUES LESS THAN (MAXVALUE)
);

分区裁剪可以让优化器只访问满足条件的分区,但这些分区仍由同一个 MySQL 服务提供,使用同一个实例的 CPU、内存、连接和磁盘资源。分区不能把一张表自动扩展到多个 MySQL 集群。

2. MySQL 复制:数据副本和高可用

复制通常把一个实例的变更传递给其他实例。副本中的数据基本是同一份逻辑数据的复制品,常见用途是:

  • 主库故障后的切换;
  • 读流量分担;
  • 备份和分析;
  • 提供不同故障域中的副本。

复制解决的是“同一份数据如何拥有多个副本”,不是“如何把不同数据拆到不同数据库”。

3. 分片:把一个逻辑数据集拆成多个独立数据集

分片(sharding)通常把一张逻辑表的不同记录分配到不同 MySQL 集群。每个分片内部仍可使用主库、副本和故障转移,但分片之间保存的是不同的数据子集。

例如按照 customer_id 分片:

逻辑表 orders
 ├── shard -80   保存 keyspace_id ∈ [0x00, 0x80)
 └── shard 80-   保存 keyspace_id ∈ [0x80, 0x100)

这里的 -8080- 是 Vitess 常见的十六进制分片范围表示:

  • -80:从最小 keyspace id 到 0x80,不包含 0x80
  • 80-:从 0x80 到最大值;
  • -:未分片 keyspace 的唯一分片表示。

分片之后,每个分片只拥有局部数据。局部查询可以只访问一个分片,但没有天然的单机级全局索引、全局自增值或跨分片事务。


二、Vitess 的组成和一次请求的数据流

Vitess 的部署对象通常包括以下几类组件。

1. VTGate:SQL 入口和路由层

VTGate 对应用提供 MySQL 协议入口。应用通常连接 VTGate,而不是直接连接某个 MySQL 实例。

VTGate 主要负责:

  1. 接收客户端 SQL;
  2. 解析 SQL;
  3. 读取 VSchema;
  4. 根据表、条件和 Vindex 计算目标分片;
  5. 将 SQL 改写或拆分后发送给一个或多个 VTTablet;
  6. 合并多个分片的结果;
  7. 在事务模式允许时协调多个分片上的事务。

VTGate 并不是简单的 TCP 代理。它需要理解 SQL 的表名、条件、排序、聚合和事务边界。

2. VTTablet:某个 MySQL 实例的代理

每个参与 Vitess 拓扑的 MySQL 实例通常由一个 VTTablet 管理。VTTablet 负责:

  • 向 MySQL 建立连接;
  • 根据 tablet 类型决定是否允许读写;
  • 执行 VTGate 下发的查询;
  • 管理本地事务;
  • 参与复制流、迁移和故障切换;
  • 向拓扑系统报告自身状态。

常见 tablet 类型包括:

  • PRIMARY:当前分片的可写实例;
  • REPLICA:可读副本,通常追随主库;
  • RDONLY:只读实例,具体用途依部署而定;
  • SPAREDRAINED 等:用于维护或流量排空的状态。

“把查询发送到某个分片”与“把查询发送到该分片的哪个副本”是两个层次:

SQL
 ↓
VTGate:确定 shard
 ↓
VTGate/拓扑:选择 PRIMARY、REPLICA 或其他 tablet 类型
 ↓
VTTablet
 ↓
MySQL

如果事务中发生写入,后续读取通常必须满足事务和一致性要求,不能随意切换到尚未追上的副本。

3. Topology Server:保存集群事实

Vitess 需要一个拓扑存储系统保存以下信息:

  • keyspace 和 shard;
  • tablet 的地址和类型;
  • 主库记录;
  • VSchema;
  • 迁移 workflow 的状态;
  • 其他集群元数据。

拓扑系统是控制平面的事实来源。VTGate 依赖它进行路由,VTTablet 依赖它判断自身角色,运维工具依赖它推进迁移和故障处理。

拓扑系统本身不是业务数据存储,也不是替代 MySQL binlog 的数据复制系统。它记录“数据应该如何组织、节点处于什么角色”,而不是保存业务表的全部记录。

4. VTOrc 和控制工具

在采用相应组件的部署中,VTOrc 可用于检测 MySQL 复制拓扑和故障并协助恢复。vtctld/vtctldclient 用于管理 keyspace、tablet、VSchema 和迁移 workflow。

具体命令和参数会随 Vitess 版本变化,生产环境应以所部署版本的命令帮助为准:

vtctldclient --help
vtctldclient GetKeyspaces
vtctldclient GetTablets
vtctldclient GetWorkflows --keyspace commerce

这些命令用于观察控制平面状态,不等价于验证业务数据已经正确迁移。数据校验仍需结合迁移工具的校验结果、源目标计数和业务抽样。


三、路由的核心:从分片键到 keyspace_id

1. 路由必须回答什么问题

对于一条查询,路由层至少要确定:

逻辑表 + 查询条件
        ↓
分片键值
        ↓
keyspace_id
        ↓
覆盖该 keyspace_id 的 shard
        ↓
该 shard 中合适的 tablet

设:

  • xx 是业务分片键,例如 customer_id
  • f(x)f(x) 是 Vindex 计算函数;
  • k=f(x)k = f(x) 是 64 位 keyspace id;
  • 分片集合为 S1,S2,,SnS_1, S_2, \dots, S_n
  • 每个分片 SiS_i 对应一个不重叠的 keyspace id 区间 RiR_i

路由条件是:

kRik \in R_i

则请求被路由到 SiS_i

正确的分片拓扑要求:

i=1nRi=[0,264)\bigcup_{i=1}^{n} R_i = [0, 2^{64})

并且对于任意 iji \ne j

RiRj=R_i \cap R_j = \varnothing

也就是说,所有 keyspace id 都应落在某个分片中,且不能同时属于两个分片。

2. Hash Vindex 的典型过程

假设使用内置 hash Vindex,概念上可以表示为:

k=H(customer_id)k = H(\text{customer\_id})

其中 HH 是 Vitess 使用的确定性哈希映射,输出 keyspace id。应用不需要自行实现这个哈希函数,但必须保证:

  • 同一个分片键值总是得到同一个 keyspace id;
  • VSchema 中的 Vindex 配置在路由和迁移期间保持一致;
  • 目标分片范围不重叠且覆盖完整范围。

假设:

customer_id = 42
H(42) = 0x35...

因为:

0x35... < 0x80...

所以记录进入 -80

如果将来把两个分片扩成四个分片,H(42) 不应因为扩容而改变;改变的是 keyspace id 区间边界,记录根据新边界迁移到新的目标分片。

这也是为什么“分片键哈希值”和“分片数量”不是同一个概念。哈希函数通常保持不变,分片数量通过调整范围实现。


四、VSchema:描述逻辑数据模型和路由规则

1. VSchema 不是 MySQL DDL

MySQL 的 DDL 描述物理表:

CREATE TABLE users (
    id   BIGINT NOT NULL PRIMARY KEY,
    name VARCHAR(100) NOT NULL
);

VSchema 描述 Vitess 如何理解这些表:

  • 哪些表属于哪个 keyspace;
  • keyspace 是否分片;
  • 使用哪个 Vindex;
  • 哪一列是表的主 Vindex;
  • 哪些表是 reference table;
  • 哪些 vindex 可用于查询路由或查找。

因此,MySQL 表真实存在于各个分片的 MySQL 实例中,而 VSchema 存在于 Vitess 控制平面中。两者必须保持相互兼容,但不是同一份元数据。

2. 一个最小的 VSchema 示例

假设有两个逻辑表:

CREATE TABLE users (
    id   BIGINT NOT NULL PRIMARY KEY,
    name VARCHAR(100) NOT NULL
);

CREATE TABLE orders (
    id          BIGINT NOT NULL PRIMARY KEY,
    customer_id BIGINT NOT NULL,
    amount      DECIMAL(12, 2) NOT NULL,
    PRIMARY KEY (id)
);

一个简化的分片 VSchema 可以写成:

{
  "sharded": true,
  "vindexes": {
    "user_hash": {
      "type": "hash"
    }
  },
  "tables": {
    "users": {
      "column_vindexes": [
        {
          "column": "id",
          "name": "user_hash"
        }
      ]
    },
    "orders": {
      "column_vindexes": [
        {
          "column": "customer_id",
          "name": "user_hash"
        }
      ]
    }
  }
}

这里的含义是:

  • commerce keyspace 是分片的;
  • users.id 使用 user_hash
  • orders.customer_id 使用同一个 user_hash
  • 一个用户及其订单会根据同一个 keyspace id 路由。

如果请求是:

SELECT *
FROM orders
WHERE customer_id = 42;

VTGate 可以计算:

user_hash(42) = k

然后根据 k 找到唯一分片。

如果请求是:

SELECT *
FROM orders
WHERE amount > 100;

没有给出能够计算分片位置的条件,VTGate 通常无法提前确定单一分片,只能向多个分片发送查询,再合并结果。这就是 scatter query,即广播或散射查询。

3. 主 Vindex 和辅助 Vindex

表可以配置多个 column Vindex。排在路由意义上的主要位置的 Vindex 通常用于确定记录所在的分片;其他 Vindex 可以用于:

  • 为非分片键建立查询映射;
  • 根据邮箱、外部用户号等业务键查找主键;
  • 支持唯一性检查;
  • 在查询条件不含主分片键时减少扫描范围。

例如,业务经常使用 email 查询用户:

{
  "vindexes": {
    "user_hash": {
      "type": "hash"
    },
    "email_lookup": {
      "type": "lookup_unique",
      "params": {
        "table": "customer_email_idx",
        "from": "email",
        "to": "user_id",
        "keyspace": "commerce"
      }
    }
  },
  "tables": {
    "users": {
      "column_vindexes": [
        {
          "column": "id",
          "name": "user_hash"
        },
        {
          "column": "email",
          "name": "email_lookup",
          "unique": true
        }
      ]
    }
  }
}

这里需要区分两件事:

  1. email_lookup 提供从 emailuser_id 的查找路径;
  2. unique 语义是否真正被正确维护,取决于 Vindex 类型、配置和写入流程。

Vindex 不是自动替代 MySQL 唯一索引的魔法。应用仍应在真实表上建立合适的局部唯一索引,并理解“全局唯一”需要由 Vindex、集中式约束或业务写入策略共同实现。

4. VSchema 不会改变 MySQL 的局部约束范围

如果两个分片中都有:

UNIQUE KEY uk_email (email)

它只保证每个分片内部不重复:

shard -80: alice@example.com
shard 80-: alice@example.com

两个分片可以分别插入相同邮箱,MySQL 并不知道它们属于同一个逻辑表。

因此,以下说法是错误的:

在所有分片的 MySQL 表上建立同名唯一索引,就能得到全局唯一约束。

正确的判断方式是先问:唯一值能否通过确定性的路由键映射到同一个分片?如果不能,就需要 lookup vindex、集中式序列/约束服务,或接受业务层最终一致的冲突处理。


五、哪些 SQL 可以精准路由,哪些会变成 scatter

1. 单分片查询

这是最理想的情况:

SELECT id, name
FROM users
WHERE id = 42;

因为 users.id 是主 Vindex,VTGate 可以计算一个 keyspace id,并只访问一个 shard。

写入也是类似:

INSERT INTO users (id, name)
VALUES (42, 'Alice');

如果主键值由应用或 Vitess 的序列机制提供,VTGate 可以根据 id 路由写入。

对于订单:

SELECT id, amount
FROM orders
WHERE customer_id = 42
  AND id = 1001;

因为 customer_id 可用于计算分片,查询可定向到一个 shard。id 是该分片内部的主键条件,不能单独证明记录在哪个分片。

2. 无分片键条件的查询

SELECT COUNT(*)
FROM orders
WHERE amount > 100;

这条语句对每个分片都可能产生结果。VTGate 需要:

  1. 向所有相关分片发送局部查询;
  2. 得到每个分片的 COUNT(*)
  3. 把局部计数相加。

如果局部结果为:

shard -80: 12
shard 80-: 9

全局结果为:

12+9=2112 + 9 = 21

对于 SUMCOUNT 等部分聚合,通常可以做这种合并。但并不是所有 SQL 都能安全地逐分片执行再合并。

3. ORDER BYLIMIT 和全局结果

例如:

SELECT id, amount
FROM orders
ORDER BY amount DESC
LIMIT 10;

每个分片至少需要产生自己的候选结果,VTGate 再进行归并排序。若每个分片返回 10 行,再从合并结果中取全局前 10 行,结果可能正确;但执行代价和内存代价会随分片数增加。

更复杂的表达式、窗口函数、用户变量、某些子查询和聚合组合,可能受到 Vitess 支持范围和执行器能力的限制。不能因为某条 SQL 在单个 MySQL 上合法,就推断它能在分片 keyspace 上获得相同的全局语义。

4. 跨分片 Join

考虑:

SELECT u.name, o.amount
FROM users AS u
JOIN orders AS o ON o.customer_id = u.id
WHERE u.id = 42;

由于两个表使用同一个分片键和同一个 Vindex,VTGate 可以把这次查询路由到同一个 shard,成为局部 Join。

而下面的查询没有明显的单分片边界:

SELECT u.name, SUM(o.amount)
FROM users AS u
JOIN orders AS o ON o.customer_id = u.id
GROUP BY u.id, u.name;

如果没有更强的路由条件,可能需要跨分片执行和合并。数据量、Join 方式和 Vitess 版本支持会直接影响可行性。

分片设计中的一个重要原则因此可以形式化为:

经常 Join 的实体尽量使用相同的分片键\text{经常 Join 的实体} \Rightarrow \text{尽量使用相同的分片键}

这不是性能口号,而是为了让 Join 具备共同的路由函数:

fu(u.id)=fo(o.customer_id)f_u(u.id) = f_o(o.customer\_id)

当两张表的相关记录总能映射到同一 shard 时,Join 可以退化为分片内 Join;否则就会引入分布式 Join 或应用层拼接。


六、分片键、主键和全局 ID 不是一回事

1. 主键唯一性通常只是分片内唯一

如果每个分片都有:

CREATE TABLE orders (
    id BIGINT NOT NULL PRIMARY KEY,
    customer_id BIGINT NOT NULL
);

那么 MySQL 只保证:

同一个 shard 内 id 不重复

它不保证:

所有 shard 合起来 id 不重复

因此,下面两条记录可能同时存在:

shard -80: id = 1001
shard 80-: id = 1001

如果应用把 id 当作全局公开标识,就必须额外设计。

2. 常见全局 ID 方案

常见方案包括:

  • 应用生成 UUID 或其他随机 ID;
  • 使用 Snowflake 类时间序列 ID;
  • 使用 Vitess sequence 表和相关 VSchema 配置;
  • 让 ID 的某些位包含时间、机器或分片信息。

每种方案都要分别考虑:

  • 是否全局唯一;
  • 是否趋势递增;
  • 是否暴露时间或机器信息;
  • 是否适合索引插入;
  • 是否会形成热点;
  • 生成服务不可用时如何处理。

不能因为用了自增主键,就认为分片后仍然有全局自增语义。MySQL 的 AUTO_INCREMENT 通常只在单个实例或单个分片范围内提供递增保证;全局严格递增会引入集中协调和吞吐瓶颈。

3. 分片键的选择条件

分片键 xx 至少应满足以下条件:

  1. 路由可计算:请求通常能从 SQL 条件中得到 xx
  2. 分布足够均匀:不同取值不会长期集中到少数分片;
  3. 业务关联性强:常见查询和事务尽量围绕同一个分片键;
  4. 生命周期稳定:记录创建后不应频繁改变分片键;
  5. 可迁移:数据量增长和分片扩容时可以按它迁移。

例如以 tenant_id 分片适合多租户系统,但如果某个租户远大于其他租户,就会出现热分片。以 user_id 哈希可以均匀分布,但按时间查询全站订单会变成 scatter。没有一个分片键能同时优化所有访问模式。


七、分片内事务和跨分片事务

1. 单分片事务仍然由 MySQL 保证

如果事务中的所有操作都路由到同一个 shard:

START TRANSACTION;

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

INSERT INTO account_events(account_id, amount)
VALUES (1, -100);

COMMIT;

只要两张表的相关记录都在同一个 shard,事务可以由该 shard 的 MySQL/InnoDB 提供原子性、隔离性和持久性。

这通常是 Vitess 最容易获得、代价也最低的事务模型。

2. 为什么跨分片事务困难

假设转账涉及两个分片:

账户 A 在 shard -80
账户 B 在 shard 80-

事务需要同时执行:

UPDATE accounts SET balance = balance - 100 WHERE id = A;
UPDATE accounts SET balance = balance + 100 WHERE id = B;

两个 MySQL 实例各自可以提交本地事务,但单独提交不能保证全局原子性。可能出现:

1. shard -80 提交扣款;
2. shard 80- 提交前连接断开;
3. 结果:A 已扣款,B 未入账。

这不是 SQL 语法问题,而是跨资源提交协议问题。

3. Vitess 的事务模式边界

Vitess 支持不同的事务模式,具体默认值和可用配置取决于部署版本和 VTGate 配置。理解时应区分以下语义:

SINGLE

事务只能涉及一个 shard。若同一个事务尝试访问多个 shard,VTGate 会拒绝或报错。

这适合明确要求业务事务必须局部化的系统,也能较早暴露错误的分片设计。

MULTI

允许事务涉及多个 shard,但并不自动等价于严格原子的分布式提交。多个分片分别执行本地事务,提交协调存在部分提交风险。

典型失败路径是:

事务 T
 ├── shard A: PREPARE/本地完成
 ├── shard B: 本地完成
 ├── VTGate 提交 A:成功
 ├── VTGate 提交 B:网络错误
 └── 全局状态需要恢复或人工处理

因此,允许多分片事务不代表可以无条件使用“跨分片强一致转账”。

TWOPC

两阶段提交(Two-Phase Commit,2PC)把提交过程拆成准备阶段和提交阶段:

阶段 1:Prepare
  协调者询问所有参与者能否提交
  每个参与者持久化“已准备”状态,但暂不最终提交

阶段 2:Commit
  协调者下发最终提交
  各参与者完成提交

若某个参与者在 Prepare 阶段失败,协调者可以要求回滚;若所有参与者准备成功,协调者可以推进提交。

2PC 的核心性质是:在协调者和参与者都遵守协议、恢复流程完整的前提下,避免普通多分片提交的随意部分提交。但它不是没有代价:

  • 参与者在 prepared 状态可能持有锁;
  • 协调者故障会延长恢复时间;
  • 网络分区可能造成事务阻塞;
  • 日志、超时和清理都更复杂;
  • 参与分片越多,失败概率和协调成本越高。

2PC 也不能修复错误的业务重试。如果客户端在不确定提交结果时盲目重试非幂等请求,仍可能造成重复扣款或重复写入。

4. 分布式事务的正确建模

对于跨分片转账,若选择 2PC,逻辑上应保证参与者操作具备明确的幂等标识,例如:

CREATE TABLE transfer_steps (
    transfer_id BIGINT NOT NULL,
    account_id  BIGINT NOT NULL,
    amount      DECIMAL(12, 2) NOT NULL,
    status      VARCHAR(20) NOT NULL,
    PRIMARY KEY (transfer_id, account_id)
);

transfer_id 用来标识一次业务操作。无论是事务协调器重试、客户端重试还是恢复流程,都不能因为重复执行而再次扣款。

如果业务允许最终一致,通常还可以采用:

  1. 在源分片提交扣款和事件;
  2. 通过可靠消息或 CDC 传递入账事件;
  3. 目标分片幂等消费;
  4. 失败时重试或进入补偿队列。

这不再是 ACID 跨分片事务,而是业务级最终一致性。两者不能混称。


八、VSchema 与分片迁移中的状态一致性

VSchema 变化可能影响路由,因此不能把它当作普通配置文件随意修改。

例如,原来:

"users": {
  "column_vindexes": [
    { "column": "id", "name": "user_hash" }
  ]
}

后来把主 Vindex 改成 tenant_hash。即使 MySQL 表结构没有变化,某个 id 的路由结果也可能改变。此时如果数据尚未按新规则搬迁,VTGate 会把查询发送到错误分片,表现为:

  • 查询不到本应存在的数据;
  • 写入重复记录;
  • 更新影响行数为 0;
  • 同一逻辑记录在两个分片各有一份。

因此:

改变分片键或主 Vindex 不是单纯的 VSchema 更新,而是数据迁移和路由切换项目。

需要区分两种变化:

1. 只增加可用于查询的辅助 Vindex

如果新增的是经过维护和验证的辅助 lookup Vindex,主分片归属不变,风险主要是索引构建、维护和查询结果正确性。

2. 改变主 Vindex 或分片键

这会改变记录归属函数:

gold(row)gnew(row)g_{\text{old}}(row) \ne g_{\text{new}}(row)

必须迁移数据、追赶变更、切换读写并验证旧路由不再接收新的业务流量。


九、重分片:从两个分片扩成四个分片

1. 重分片解决什么问题

重分片(resharding)是改变一个 sharded keyspace 的分片拓扑,并把已有数据迁移到新分片的过程。

例如初始拓扑:

源:
  -80
  80-

目标拓扑:

目标:
  -40
  40-80
  80-c0
  c0-

原来的 -80 会被拆成:

-40
40-80

原来的 80- 会被拆成:

80-c0
c0-

注意,拆分依据是 keyspace id 区间,不是直接按照某个 MySQL 表的物理页或主键顺序简单切割。

2. 为什么可以在线迁移

Vitess 的在线迁移通常利用 MySQL 复制流或 VReplication 机制。抽象过程如下:

源分片 MySQL
    │
    ├── 复制已有历史数据
    │
    └── 持续复制新增变更
            ↓
       目标分片 MySQL

目标分片先复制历史数据,随后持续追赶源分片产生的变更。这样可以把一次性停机复制变成“先复制、再追平、最后短时间切换”。

复制流必须携带足够的位点或事务进度信息,才能判断:

目标已经复制到源的哪个位置

这也是在线迁移能够判断“是否接近切换”的基础。

3. 重分片的典型阶段

不同 Vitess 版本和 workflow 类型的命令名称、选项会有所差异,但状态转换通常可以理解为以下阶段。

阶段一:准备目标分片

创建目标 shard 和对应 tablet,完成:

  • MySQL 实例准备;
  • 表结构部署;
  • 主键和索引检查;
  • 目标副本拓扑准备;
  • VSchema 与目标路由关系准备。

此时目标分片可以存在,但不能提前接收普通业务写入,否则迁移工具无法单独管理数据来源。

阶段二:复制历史数据

对每个目标分片,按 keyspace id 范围从源分片复制符合条件的记录。

例如:

源 -80 → 目标 -40
源 -80 → 目标 40-80

一行记录的目标位置由:

k=f(sharding key)k = f(\text{sharding key})

和目标范围共同决定。

阶段三:持续复制变更

历史数据复制期间,源分片仍可能有新的 INSERTUPDATEDELETE。迁移系统必须持续读取源变更并应用到目标,否则“历史数据复制完成”并不等于“目标数据正确”。

状态可以抽象为:

目标已复制位点 = p_target
源当前变更位点 = p_source
延迟 = p_source - p_target

只有当延迟足够小,并且校验通过,才适合进入切换阶段。这里的位点不是简单的时间戳;实际系统会使用复制流能识别的事务或 GTID/binlog 进度。

阶段四:切换读流量

先将一部分读流量切换到目标分片,或更新读取路由,使目标开始承担新拓扑下的读取。

这一步可以发现:

  • 目标数据是否完整;
  • 查询结果是否符合预期;
  • 新旧 VSchema 是否一致;
  • 副本延迟是否会导致明显读不一致;
  • 目标实例容量是否足够。

阶段五:切换写流量

写流量切换比读流量更敏感。目标分片开始接收新的写入后,源分片不能继续作为同一范围的独立写入源,否则会产生双写和冲突。

理想的切换需要保证:

旧源范围停止写入
目标范围开始写入
中间没有未处理的源变更

实际 workflow 会通过路由、复制流、事务位点和短暂的流量切换来完成这个过程,而不是让应用自行同时写源和目标。

阶段六:清理旧源数据

确认新路由稳定、目标数据完整并且已过观察期后,才能停止旧复制流并清理旧范围中的数据。

清理操作风险很高。过早删除源数据,会使以下恢复路径变得困难:

  • 切换后发现目标数据不完整;
  • 业务查询结果异常;
  • 迁移脚本漏处理某种 DDL 或特殊数据;
  • 需要回退到旧路由。

4. 迁移不是“改几个分片名”

错误的简化方式是:

把 VSchema 中的 -80 改成 -40 和 40-80

如果只改路由而不迁移数据,会出现:

VTGate 根据新范围查目标 shard
目标 shard 尚无对应记录
查询返回空

如果先复制数据但没有切断旧写入,则可能出现:

源记录被更新
目标只复制了旧版本
切换后读取旧值

如果源和目标同时接收写入,则可能出现:

源和目标各有不同版本
后续复制无法确定最终值

所以重分片的本质不是元数据修改,而是:

数据复制+变更追平+路由切换+旧数据清理\text{数据复制} + \text{变更追平} + \text{路由切换} + \text{旧数据清理}


十、重分片算例:一个订单如何迁移

假设:

Vindex: hash(customer_id)
源分片:
  -80
  80-
目标分片:
  -40
  40-80
  80-c0
  c0-

某条订单:

id = 1001
customer_id = 42

计算得到:

hash(42) = 0x35...

迁移前:

0x35... ∈ -80

因此订单在源分片 -80

迁移后:

0x35... ∈ -40

因此订单应在目标分片 -40

再假设迁移期间发生更新:

UPDATE orders
SET amount = 99.00
WHERE customer_id = 42 AND id = 1001;

正确的在线迁移必须保证该更新最终按复制流顺序应用到目标。可能的中间状态是:

t1:目标完成历史复制,amount = 80.00
t2:源执行 UPDATE,amount = 99.00
t3:复制流传递 UPDATE
t4:目标应用 UPDATE,amount = 99.00
t5:切换写路由

如果 t3 未完成就切换写流量,目标读取到的仍可能是 80.00,这就是切换前必须检查复制延迟和事务位点的原因。

还要注意更新分片键的情况:

UPDATE orders
SET customer_id = 900
WHERE id = 1001;

如果 hash(42)hash(900) 落在不同 shard,这不是普通的单行更新,而是“跨分片移动记录”:

旧 shard 删除旧位置
新 shard 插入新位置

它可能需要跨分片事务,或者由应用实现可恢复的迁移流程。正因为如此,生产系统通常尽量选择创建后不变的分片键。


十一、重分片期间的 DDL 和数据校验

1. DDL 不是普通数据复制

表结构变化要考虑:

  • 源表和目标表是否一致;
  • 新列是否有默认值;
  • 旧版本 VTTablet 是否能解析新结构;
  • 复制流是否能处理该 DDL;
  • 切换前后读写代码是否兼容。

一种更安全的演进顺序是:

先增加兼容列
→ 部署能同时兼容新旧结构的应用
→ 回填数据
→ 切换读写逻辑
→ 最后删除旧列

不要在复制流正在追赶时直接执行不可逆的破坏性 DDL,除非已经确认所用 Vitess 版本和 workflow 对该操作有明确支持。

2. 计数相等不等于数据相等

最基本的校验是按分片范围比较记录数:

SELECT COUNT(*) FROM orders;

但总数相等仍可能掩盖:

  • 一条记录丢失,另一条重复;
  • 某些列值不同;
  • 删除没有复制;
  • 只有特定租户的数据错误。

更可靠的校验可以分层进行:

  1. 统计各目标范围的记录数;
  2. 按分片键范围计算聚合值;
  3. 抽样比较主键和关键业务列;
  4. 对迁移范围计算分批哈希;
  5. 检查复制流错误、延迟和重试;
  6. 检查切换后新旧路由查询结果。

例如按时间分批做业务侧校验:

SELECT
    COUNT(*) AS cnt,
    COALESCE(SUM(amount), 0) AS total_amount
FROM orders
WHERE id >= 100000
  AND id < 110000;

这只能作为辅助校验。COUNTSUM 同时相等,也不代表每一行完全相同,但比只比较总行数更容易发现大范围异常。


十二、故障路径:路由故障、MySQL 故障和事务故障

1. VTTablet 或 MySQL 主库故障

当某分片的主库故障时,Vitess 的故障处理通常涉及:

  1. 发现主库不可用;
  2. 从副本中选择候选新主;
  3. 确认复制位置和数据状态;
  4. 更新拓扑中的主库记录;
  5. 让 VTGate 刷新路由;
  6. 排空旧主或隔离旧实例;
  7. 恢复业务写入。

这不是瞬时无损切换的保证。故障期间可能出现:

  • 正在执行的请求失败;
  • 事务结果不确定;
  • 连接池仍持有旧连接;
  • 副本延迟导致读到旧数据;
  • 客户端重试造成重复写入。

所以写操作应使用业务幂等键,例如订单号、支付流水号或转账号。

2. VTGate 故障

VTGate 通常是无状态或近似无状态的路由层,可以部署多个实例并由负载均衡器分发连接。但“无状态”不表示一个正在进行的客户端 TCP 连接可以无条件迁移到另一个 VTGate。

如果 VTGate 在事务中途故障,客户端可能失去连接,无法知道事务是否已经提交。应用必须把以下情况视为不确定结果:

客户端超时
≠
数据库一定没有提交

对于非幂等写入,应通过业务唯一键查询最终状态,而不是直接再次执行同一写操作。

3. 拓扑系统故障

拓扑系统不可用时,VTGate 可能仍使用缓存继续服务一段时间,但无法可靠获知新的主库切换、VSchema 更新或迁移状态。持续依赖过期拓扑会造成:

  • 路由到旧主库;
  • 把写请求发送到已排空 tablet;
  • 使用旧 VSchema 解析查询;
  • 迁移切换状态不一致。

拓扑系统属于控制平面,但它的可用性仍会影响数据平面恢复和路由正确性。

4. 跨分片事务协调者故障

在多分片事务中,协调者故障尤其需要关注“提交结果不确定”和“prepared 事务残留”:

参与者 A:已 Prepare
参与者 B:已 Prepare
协调者:发送 Commit 前故障

恢复系统必须根据持久化的事务状态决定提交或回滚,不能简单按客户端重试处理。否则可能出现:

  • 事务长期持锁;
  • 后续写入阻塞;
  • 一部分资源已提交而应用未感知;
  • 恢复操作与原事务重复执行。

这也是跨分片事务数量应受到控制的原因之一。


十三、常见误解和对应失败表现

误解一:分片后所有 SQL 仍然像单机一样执行

实际表现:

SELECT * FROM orders WHERE status = 'pending';

可能触发所有分片查询。随着分片数量增加,连接数、网络传输和结果合并成本都会增长。

应在数据访问层明确区分:

  • 需要分片键的点查;
  • 单分片范围查询;
  • 允许 scatter 的后台任务;
  • 不应在线执行的全局查询。

误解二:VSchema 会自动保证外键和全局约束

VSchema 主要描述路由和逻辑模型,不会让多个独立 MySQL 实例自动拥有单机范围的外键约束。跨分片外键检查和级联删除需要应用或专门的数据一致性机制。

例如:

删除 user 42

如果 usersorders 因为分片键设计而位于同一 shard,删除可以局部完成;如果订单表按另一分片键分布,则级联删除会成为跨分片操作,不能假定 MySQL 外键能解决。

误解三:增加分片后数据会自动均匀分布

增加目标分片不会自动搬运已有数据。即使迁移正确,哈希分布也只能在统计意义上趋于均匀,不能消除:

  • 单个租户特别大的热点;
  • 某些业务键被高频访问;
  • 某些查询天然集中到一个范围;
  • 不均匀的数据历史。

误解四:id 条件一定可以定位分片

只有当 id 是该表用于分片路由的 Vindex 列,并且查询条件足以计算 Vindex 时,才可以精准路由。

如果表按 customer_id 分片:

SELECT *
FROM orders
WHERE id = 1001;

单独的 id 不能告诉 VTGate 记录在哪个 shard,除非额外配置了可用的 lookup Vindex。否则通常需要 scatter,或查询被拒绝,具体取决于 Vitess 的查询能力和配置。

误解五:开启 MULTI 就获得了跨分片强一致事务

MULTI 允许一个事务访问多个 shard,不代表普通提交具备 2PC 的原子提交保证。需要严格原子性时,应确认部署的事务模式、参与者类型、超时、恢复和运维流程,并评估 2PC 的锁持有和故障成本。

误解六:迁移完成后可以立即删除旧数据

切换成功只说明新路由已经生效,不代表已经证明所有业务路径、历史查询、重试和后台任务都正确。过早删除旧数据会降低回滚能力,应在确认以下条件后再清理:

  • workflow 状态完成;
  • 复制流没有错误;
  • 目标数据通过校验;
  • 新路由查询结果正确;
  • 业务写入已稳定运行;
  • 没有仍指向旧范围的客户端或任务。

十四、诊断路由和迁移问题

1. 先确认拓扑事实

遇到“查不到数据”时,不应先修改 VSchema。应依次确认:

  1. 应用连接的是哪个 VTGate;
  2. keyspace 是否为 sharded;
  3. 查询表是否在 VSchema 中;
  4. 该列是否配置了正确的 Vindex;
  5. 实际 keyspace id 是否落在预期 shard;
  6. 目标 shard 是否有可用的 PRIMARY/REPLICA tablet;
  7. 迁移 workflow 是否处于切换中;
  8. MySQL 中物理表和数据是否存在。

2. 区分三类错误

路由错误

表现为:

  • 查询一直为空,但确认其他分片有数据;
  • 写入落到了错误的 shard;
  • 同一键查询前后结果不一致。

重点检查 VSchema、Vindex、shard range 和是否在迁移中错误修改了路由。

数据复制错误

表现为:

  • 目标分片缺行;
  • 目标数据版本落后;
  • VReplication 报错或持续延迟;
  • DDL、字符集、主键或特殊类型导致复制失败。

重点检查复制流状态、错误日志、位点推进和目标表结构。

副本一致性或 tablet 选择错误

表现为:

  • 写入主库后马上读不到;
  • 读请求偶发返回旧值;
  • 主库故障切换后部分请求失败。

重点检查请求是否被发送到延迟副本、事务是否结束、读一致性策略和 tablet 类型选择。

3. 查询计划和 scatter 识别

生产排查中应观察:

  • 查询是否只命中一个 shard;
  • 是否被拆成多个 shard 请求;
  • 每个 shard 的延迟和返回行数;
  • 结果合并是否成为瓶颈;
  • 是否存在单个热点 shard。

“SQL 在应用日志中只出现一次”不代表数据库只执行了一次。VTGate 可能把一条逻辑 SQL 拆成多个物理请求。


十五、如何设计围绕分片的事务边界

可以把业务事务集合记为 TT,把每个操作涉及的 shard 集合记为 S(T)S(T)

当:

S(T)=1|S(T)| = 1

事务是单分片事务,通常可以直接依赖 InnoDB。

当:

S(T)>1|S(T)| > 1

就必须明确选择一种语义:

  1. 拒绝跨分片事务;
  2. 使用多分片事务并接受其提交边界;
  3. 使用 2PC;
  4. 拆成事件和补偿流程;
  5. 重构分片键,使相关操作重新落在同一 shard。

例如,一个购物车结算流程涉及:

订单、库存、支付账户

如果三者按同一 customer_id 路由,事务可能局部化,但库存通常还需要按 sku 访问,热门商品会造成另一种热点。此时不能只看“能否放在一个事务里”,还要看:

  • 哪些操作必须原子;
  • 哪些状态允许最终一致;
  • 失败后如何补偿;
  • 是否需要全局库存锁;
  • 热点是否会集中到单个分片。

分片键是事务边界的一部分,而不仅是存储容量参数。


十六、生产取舍:什么时候适合 Vitess

Vitess 更适合以下类型的系统:

  • 已经使用 MySQL,并希望按业务维度扩展数据容量;
  • 需要统一的 MySQL 协议入口和分片路由;
  • 可以明确选择稳定的分片键;
  • 绝大多数事务可以限制在单个分片;
  • 接受通过 workflow 完成在线迁移;
  • 有能力运行拓扑、tablet、复制和故障恢复系统。

它不适合被当作以下问题的自动解决器:

  • 任意 SQL 都要拥有单机级全局语义;
  • 任意两张表都要求外键和级联;
  • 所有请求都必须跨全局数据集实时排序;
  • 业务没有稳定的访问键;
  • 不愿处理迁移、回滚、校验和幂等重试;
  • 希望分片后完全隐藏网络、延迟和部分失败。

一个可控的 Vitess 设计通常呈现为:

应用明确携带分片键
        ↓
VTGate 进行单分片路由
        ↓
事务尽量局部化
        ↓
跨分片操作采用明确的 2PC 或最终一致流程
        ↓
扩容通过在线复制、追平和切换完成

分片的核心不是“把一台 MySQL 变成很多台 MySQL”,而是重新定义数据的归属、查询的边界、事务的边界和运维的状态机。Vitess 通过 VSchema 和 VTGate 把这些规则集中表达出来,再通过复制流和 workflow 把拓扑变化变成可观察、可验证的迁移过程;但数据分布、全局约束和跨分片原子性仍然需要系统设计者明确承担。


系列导航与关联阅读

官方资料

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