数据库基础体系 · 第 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)
这里的 -80 和 80- 是 Vitess 常见的十六进制分片范围表示:
-80:从最小 keyspace id 到0x80,不包含0x80;80-:从0x80到最大值;-:未分片 keyspace 的唯一分片表示。
分片之后,每个分片只拥有局部数据。局部查询可以只访问一个分片,但没有天然的单机级全局索引、全局自增值或跨分片事务。
二、Vitess 的组成和一次请求的数据流
Vitess 的部署对象通常包括以下几类组件。
1. VTGate:SQL 入口和路由层
VTGate 对应用提供 MySQL 协议入口。应用通常连接 VTGate,而不是直接连接某个 MySQL 实例。
VTGate 主要负责:
- 接收客户端 SQL;
- 解析 SQL;
- 读取 VSchema;
- 根据表、条件和 Vindex 计算目标分片;
- 将 SQL 改写或拆分后发送给一个或多个 VTTablet;
- 合并多个分片的结果;
- 在事务模式允许时协调多个分片上的事务。
VTGate 并不是简单的 TCP 代理。它需要理解 SQL 的表名、条件、排序、聚合和事务边界。
2. VTTablet:某个 MySQL 实例的代理
每个参与 Vitess 拓扑的 MySQL 实例通常由一个 VTTablet 管理。VTTablet 负责:
- 向 MySQL 建立连接;
- 根据 tablet 类型决定是否允许读写;
- 执行 VTGate 下发的查询;
- 管理本地事务;
- 参与复制流、迁移和故障切换;
- 向拓扑系统报告自身状态。
常见 tablet 类型包括:
PRIMARY:当前分片的可写实例;REPLICA:可读副本,通常追随主库;RDONLY:只读实例,具体用途依部署而定;SPARE、DRAINED等:用于维护或流量排空的状态。
“把查询发送到某个分片”与“把查询发送到该分片的哪个副本”是两个层次:
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
设:
- 是业务分片键,例如
customer_id; - 是 Vindex 计算函数;
- 是 64 位 keyspace id;
- 分片集合为 ;
- 每个分片 对应一个不重叠的 keyspace id 区间 。
路由条件是:
则请求被路由到 。
正确的分片拓扑要求:
并且对于任意 :
也就是说,所有 keyspace id 都应落在某个分片中,且不能同时属于两个分片。
2. Hash Vindex 的典型过程
假设使用内置 hash Vindex,概念上可以表示为:
其中 是 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"
}
]
}
}
}
这里的含义是:
commercekeyspace 是分片的;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
}
]
}
}
}
这里需要区分两件事:
email_lookup提供从email到user_id的查找路径;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 需要:
- 向所有相关分片发送局部查询;
- 得到每个分片的
COUNT(*); - 把局部计数相加。
如果局部结果为:
shard -80: 12
shard 80-: 9
全局结果为:
对于 SUM、COUNT 等部分聚合,通常可以做这种合并。但并不是所有 SQL 都能安全地逐分片执行再合并。
3. ORDER BY、LIMIT 和全局结果
例如:
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 具备共同的路由函数:
当两张表的相关记录总能映射到同一 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. 分片键的选择条件
分片键 至少应满足以下条件:
- 路由可计算:请求通常能从 SQL 条件中得到 ;
- 分布足够均匀:不同取值不会长期集中到少数分片;
- 业务关联性强:常见查询和事务尽量围绕同一个分片键;
- 生命周期稳定:记录创建后不应频繁改变分片键;
- 可迁移:数据量增长和分片扩容时可以按它迁移。
例如以 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 用来标识一次业务操作。无论是事务协调器重试、客户端重试还是恢复流程,都不能因为重复执行而再次扣款。
如果业务允许最终一致,通常还可以采用:
- 在源分片提交扣款和事件;
- 通过可靠消息或 CDC 传递入账事件;
- 目标分片幂等消费;
- 失败时重试或进入补偿队列。
这不再是 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 或分片键
这会改变记录归属函数:
必须迁移数据、追赶变更、切换读写并验证旧路由不再接收新的业务流量。
九、重分片:从两个分片扩成四个分片
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
一行记录的目标位置由:
和目标范围共同决定。
阶段三:持续复制变更
历史数据复制期间,源分片仍可能有新的 INSERT、UPDATE 和 DELETE。迁移系统必须持续读取源变更并应用到目标,否则“历史数据复制完成”并不等于“目标数据正确”。
状态可以抽象为:
目标已复制位点 = p_target
源当前变更位点 = p_source
延迟 = p_source - p_target
只有当延迟足够小,并且校验通过,才适合进入切换阶段。这里的位点不是简单的时间戳;实际系统会使用复制流能识别的事务或 GTID/binlog 进度。
阶段四:切换读流量
先将一部分读流量切换到目标分片,或更新读取路由,使目标开始承担新拓扑下的读取。
这一步可以发现:
- 目标数据是否完整;
- 查询结果是否符合预期;
- 新旧 VSchema 是否一致;
- 副本延迟是否会导致明显读不一致;
- 目标实例容量是否足够。
阶段五:切换写流量
写流量切换比读流量更敏感。目标分片开始接收新的写入后,源分片不能继续作为同一范围的独立写入源,否则会产生双写和冲突。
理想的切换需要保证:
旧源范围停止写入
目标范围开始写入
中间没有未处理的源变更
实际 workflow 会通过路由、复制流、事务位点和短暂的流量切换来完成这个过程,而不是让应用自行同时写源和目标。
阶段六:清理旧源数据
确认新路由稳定、目标数据完整并且已过观察期后,才能停止旧复制流并清理旧范围中的数据。
清理操作风险很高。过早删除源数据,会使以下恢复路径变得困难:
- 切换后发现目标数据不完整;
- 业务查询结果异常;
- 迁移脚本漏处理某种 DDL 或特殊数据;
- 需要回退到旧路由。
4. 迁移不是“改几个分片名”
错误的简化方式是:
把 VSchema 中的 -80 改成 -40 和 40-80
如果只改路由而不迁移数据,会出现:
VTGate 根据新范围查目标 shard
目标 shard 尚无对应记录
查询返回空
如果先复制数据但没有切断旧写入,则可能出现:
源记录被更新
目标只复制了旧版本
切换后读取旧值
如果源和目标同时接收写入,则可能出现:
源和目标各有不同版本
后续复制无法确定最终值
所以重分片的本质不是元数据修改,而是:
十、重分片算例:一个订单如何迁移
假设:
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;
但总数相等仍可能掩盖:
- 一条记录丢失,另一条重复;
- 某些列值不同;
- 删除没有复制;
- 只有特定租户的数据错误。
更可靠的校验可以分层进行:
- 统计各目标范围的记录数;
- 按分片键范围计算聚合值;
- 抽样比较主键和关键业务列;
- 对迁移范围计算分批哈希;
- 检查复制流错误、延迟和重试;
- 检查切换后新旧路由查询结果。
例如按时间分批做业务侧校验:
SELECT
COUNT(*) AS cnt,
COALESCE(SUM(amount), 0) AS total_amount
FROM orders
WHERE id >= 100000
AND id < 110000;
这只能作为辅助校验。COUNT 和 SUM 同时相等,也不代表每一行完全相同,但比只比较总行数更容易发现大范围异常。
十二、故障路径:路由故障、MySQL 故障和事务故障
1. VTTablet 或 MySQL 主库故障
当某分片的主库故障时,Vitess 的故障处理通常涉及:
- 发现主库不可用;
- 从副本中选择候选新主;
- 确认复制位置和数据状态;
- 更新拓扑中的主库记录;
- 让 VTGate 刷新路由;
- 排空旧主或隔离旧实例;
- 恢复业务写入。
这不是瞬时无损切换的保证。故障期间可能出现:
- 正在执行的请求失败;
- 事务结果不确定;
- 连接池仍持有旧连接;
- 副本延迟导致读到旧数据;
- 客户端重试造成重复写入。
所以写操作应使用业务幂等键,例如订单号、支付流水号或转账号。
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
如果 users 和 orders 因为分片键设计而位于同一 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。应依次确认:
- 应用连接的是哪个 VTGate;
- keyspace 是否为 sharded;
- 查询表是否在 VSchema 中;
- 该列是否配置了正确的 Vindex;
- 实际 keyspace id 是否落在预期 shard;
- 目标 shard 是否有可用的 PRIMARY/REPLICA tablet;
- 迁移 workflow 是否处于切换中;
- MySQL 中物理表和数据是否存在。
2. 区分三类错误
路由错误
表现为:
- 查询一直为空,但确认其他分片有数据;
- 写入落到了错误的 shard;
- 同一键查询前后结果不一致。
重点检查 VSchema、Vindex、shard range 和是否在迁移中错误修改了路由。
数据复制错误
表现为:
- 目标分片缺行;
- 目标数据版本落后;
- VReplication 报错或持续延迟;
- DDL、字符集、主键或特殊类型导致复制失败。
重点检查复制流状态、错误日志、位点推进和目标表结构。
副本一致性或 tablet 选择错误
表现为:
- 写入主库后马上读不到;
- 读请求偶发返回旧值;
- 主库故障切换后部分请求失败。
重点检查请求是否被发送到延迟副本、事务是否结束、读一致性策略和 tablet 类型选择。
3. 查询计划和 scatter 识别
生产排查中应观察:
- 查询是否只命中一个 shard;
- 是否被拆成多个 shard 请求;
- 每个 shard 的延迟和返回行数;
- 结果合并是否成为瓶颈;
- 是否存在单个热点 shard。
“SQL 在应用日志中只出现一次”不代表数据库只执行了一次。VTGate 可能把一条逻辑 SQL 拆成多个物理请求。
十五、如何设计围绕分片的事务边界
可以把业务事务集合记为 ,把每个操作涉及的 shard 集合记为 。
当:
事务是单分片事务,通常可以直接依赖 InnoDB。
当:
就必须明确选择一种语义:
- 拒绝跨分片事务;
- 使用多分片事务并接受其提交边界;
- 使用 2PC;
- 拆成事件和补偿流程;
- 重构分片键,使相关操作重新落在同一 shard。
例如,一个购物车结算流程涉及:
订单、库存、支付账户
如果三者按同一 customer_id 路由,事务可能局部化,但库存通常还需要按 sku 访问,热门商品会造成另一种热点。此时不能只看“能否放在一个事务里”,还要看:
- 哪些操作必须原子;
- 哪些状态允许最终一致;
- 失败后如何补偿;
- 是否需要全局库存锁;
- 热点是否会集中到单个分片。
分片键是事务边界的一部分,而不仅是存储容量参数。
十六、生产取舍:什么时候适合 Vitess
Vitess 更适合以下类型的系统:
- 已经使用 MySQL,并希望按业务维度扩展数据容量;
- 需要统一的 MySQL 协议入口和分片路由;
- 可以明确选择稳定的分片键;
- 绝大多数事务可以限制在单个分片;
- 接受通过 workflow 完成在线迁移;
- 有能力运行拓扑、tablet、复制和故障恢复系统。
它不适合被当作以下问题的自动解决器:
- 任意 SQL 都要拥有单机级全局语义;
- 任意两张表都要求外键和级联;
- 所有请求都必须跨全局数据集实时排序;
- 业务没有稳定的访问键;
- 不愿处理迁移、回滚、校验和幂等重试;
- 希望分片后完全隐藏网络、延迟和部分失败。
一个可控的 Vitess 设计通常呈现为:
应用明确携带分片键
↓
VTGate 进行单分片路由
↓
事务尽量局部化
↓
跨分片操作采用明确的 2PC 或最终一致流程
↓
扩容通过在线复制、追平和切换完成
分片的核心不是“把一台 MySQL 变成很多台 MySQL”,而是重新定义数据的归属、查询的边界、事务的边界和运维的状态机。Vitess 通过 VSchema 和 VTGate 把这些规则集中表达出来,再通过复制流和 workflow 把拓扑变化变成可观察、可验证的迁移过程;但数据分布、全局约束和跨分片原子性仍然需要系统设计者明确承担。
系列导航与关联阅读
- 系列入口:数据库完整学习路线:从关系模型、事务索引到分布式与向量检索
- 上一篇:MySQL 协议与客户端:握手、Prepared Statement、连接池和取消
- 下一篇:MySQL 升级与迁移:兼容检查、DDL、字符集、灰度和回滚
- 延伸:MySQL 分区表:Range、List、Hash、裁剪、维护和适用边界
- 延伸:数据库复制、分片与高可用:一致性、路由、故障转移和扩容
官方资料
本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。

评论
0 条讨论