Vitess 分库分表深度实战:从 VSchema 路由解析、VReplication 在线迁移到 Online DDL 的工程全解
当单机 MySQL 撑不住业务写入时,几乎所有团队都会走到同一条岔路口:要么换 NewSQL 分布式数据库,要么在 MySQL 之上加一层分片中间件。前者的代价是迁移风险与生态割裂,后者则保留了 MySQL 全部能力,但把分布式系统的复杂度搬到了中间件层。Vitess 是这条路走得最远的工程实现——它承载过 YouTube 每秒数百万查询的量级,如今也是 PlanetScale 的商业底座。
真正值得研究的不是"Vitess 能分片"这件事,而是它如何在不修改 MySQL 内核、不强要求应用层改造的前提下,把一个单机 SQL 语义撕开成一分布式语义,并且让"重分片"这种传统上必须停机的操作变成一条命令。
一、拓扑:四层结构各自解决什么问题
Vitess 不是单一进程,而是四个职责切得很干净的组件:
- VTGate:无状态 SQL 代理,负责解析 SQL、改写、路由、聚合结果。它是唯一对应用暴露的入口,MySQL 协议兼容。
- VTTablet:每个 MySQL 实例前的 sidecar,管理 schema、执行查询、负责主从切换与数据复制。
- Topology Service(通常是 etcd 或 ZooKeeper):存储 keyspace/shard/tablet 的元信息与锁。
- vtctld:控制面 API 与命令行入口(
vtctlclient)。
这个切分的关键价值在于 VTGate 无状态且可水平扩展。分片规则变了,不需要重启任何 MySQL,只需要更新存储在 topology 里的 VSchema——VTGate 通过 watch 机制秒级感知。相比之下,很多自研分库分表方案把路由表写死在应用配置里,改一次分片要发一次全量应用重启。
一个最小拓扑可以这样定义:
# keyspace = 逻辑库,shard = 物理分片,每片一主两从
vtctlclient CreateKeyspace -sharding_column_name=user_id \
-sharding_column_type=UINT64 user
vtctlclient CreateShard user/-80
vtctlclient CreateShard user/80-
vttablet -init_keyspace=user -init_shard=-80 ... &
vttablet -init_keyspace=user -init_shard=80- ... &
wait
# 启动 tablet:一行就把 MySQL 纳入 Vitess 管理
vttablet \
-topo_implementation=etcd2 -topo_global_server_address=10.0.0.1:2379 \
-tablet-path=zone1-0000000100 \
-init_keyspace=user -init_shard=-80 \
-init_tablet_type=replica \
-mysqlctl_socket=/tmp/mysqlctl.sock \
-port=15100 -grpc_port=16100
二、VSchema:把"路由"从代码里搬到元数据里
Vitess 的核心抽象是 VSchema(Vitess Schema),一份描述"逻辑表如何映射到物理分片"的 YAML。它解决的是分片中最痛的问题:如何知道一条 SQL 该去哪个分片。
{
"sharded": true,
"vindexes": {
"hash": { "type": "hash" },
"unicode_loose_md5": { "type": "unicode_loose_md5" },
"user_seq": { "type": "sequence" },
"name_user_map": {
"type": "lookup_hash",
"params": { "table": "name_user_map",
"from": "name", "to": "user_id" },
"owner": "user"
}
},
"tables": {
"user": {
"column_vindexes": [
{ "column": "user_id", "name": "hash" },
{ "column": "email", "name": "unicode_loose_md5" },
{ "column": "name", "name": "name_user_map" }
],
"auto_increment": {
"column": "user_id",
"sequence": "user_seq"
}
},
"user_order": {
"column_vindexes": [
{ "column": "user_id", "name": "hash" }
]
}
}
}
这里有三件值得注意的工程细节:
1. Vindex 是"函数",不是"列"。 hash 是函数式 Vindex(functional vindex),由列值直接算出分片;lookup_hash 是查找式 Vindex(lookup vindex),需要额外查一张映射表才能定位。前者零开销,后者多一跳网络。把 email 也设成 Vindex 的意义在于:WHERE email = 'x' 的查询不必 scatter 到所有分片,而是先查 name_user_map 拿到 user_id,再单点命中。
2. 同一张表可以有多个 Vindex。 查询规划器会根据 WHERE 子句里出现的列,选出最具选择性的那个 Vindex。如果 WHERE 里给的列不在任何 Vindex 中,这条 SQL 就只能 scatter-gather——Vitess 会并行发到所有分片,在 VTGate 侧合并。这就是为什么限流要配 --queryserver-config-max-result-size。
3. Sequence 解决自增主键。 分布式环境下单机 AUTO_INCREMENT 会导致主键冲突。Vitess 用一张特殊的表模拟 sequence(底层是 INSERT ... ON DUPLICATE KEY UPDATE 的批分配), tablet 一次领一批号段缓存在本地,避免每次插入都跨网络。这是典型的用号段预分配换性能的思路。
三、查询计划:什么时候能下推,什么时候不能
理解 Vitess 性能的关键,是搞清楚 SQL 在 VTGate 里被改写成什么样。用 EXPLAIN 前缀可以直接看到:
-- 单分片查询:直接下推到某一片
EXPLAIN SELECT * FROM user WHERE user_id = 42;
-- {
-- "OperatorType": "Route",
-- "Variant": "EqualUnique",
-- "Keyspace": {"Name": "user", "Sharded": true},
-- "FieldQuery": "select * from `user` where 1 != 1",
-- "Query": "select * from `user` where user_id = 42",
-- "Vindex": "hash",
-- "Values": ["42"],
-- "TableName": "user"
-- }
-- 跨分片聚合:能下推的部分下推,不能的在 VTGate 做
EXPLAIN SELECT COUNT(*), SUM(amount) FROM user_order WHERE status = 'PAID';
第二条会产生散布查询,VTGate 把 count(*) 和 sum(amount) 下推给每个分片做局部聚合,然后在自己进程里做归并。这是可行的,因为这两个算子满足结合律。
但下面这些会退化:
-- 跨分片 JOIN 且关联键不是同一 Vindex 列:VTGate 需要落盘做 hash join
SELECT * FROM user u JOIN user_order o ON u.city = o.city;
-- 跨分片 ORDER BY + LIMIT:必须全局排序,VTGate 要在内存里归并全量结果
SELECT * FROM user ORDER BY created_at DESC LIMIT 10;
-- 跨分片事务:默认不允许,需显式声明
BEGIN; ... 涉及多个 shard 的写 ...; COMMIT;
对最后一点必须给出实战建议:Vitess 支持单分片自动提交式事务和显式 multi-shard 事务(内部走两阶段提交),但后者代价巨大——持有跨分片锁、协调开销随参与分片数线性增长。正确做法是在建模阶段让事务边界落在同一个分片内:把强一致相关的表设计成共享同一个 column_vindexes 主键列(即"共置/co-located 表族"),Vitess 就能把这类 JOIN 和事务整体下推到单片执行。user 和 user_order 都以 user_id 做 Vindex,正是这个意图。
四、VReplication:不停机重分片的底层机制
分片方案最难的不是初始分片,而是业务跑起来后如何从 2 片扩到 8 片。Vitess 给出的答案是 VReplication——一套基于 binlog 的、声明式的数据复制工作流。它不是简单的 mysqldump 搬运,而是一个持续同步 + 可校验 + 可切换的状态机。
工作流引擎的核心是一组 rule:
{
"rules": [{
"match": "user",
"filter": "select * from user where user_id % 8 < 4"
}],
"source": {
"keyspace": "user", "shard": "0",
"filter": { "rules": [{ "match": "user",
"filter": "select * from user" }] }
}
}
它的执行过程是这样的:
- Copy 阶段:先对源端做一致性快照(在 RR 隔离级别下
SELECT,记录 GTID 位点),全量搬运历史数据。 - Replicate 阶段:从记录的 GTID 开始消费 binlog row image,把增量变更以
INSERT/UPDATE/DELETE形式重放到目标端。这里的要点是 VReplication 用主键做幂等:增量回放与全量拷贝可以重叠,重复执行同一行不会产生错误数据。 - 校验与等待:
vtctlclient VDiff会对源和目标做逐行 hashes 比较,这是切流前必须做的一步。 - SwitchTraffic:原子切流。Vitess 通过 topology 里的分布式锁 + 短暂停止源端写入来完成切换,应用层完全无感知,故障窗口是毫秒级的。
一条完整命令就是这样:
# 从 1 片拆成 4 片:80、-80 表示按 hash 区间切分
vtctlclient Reshard Create user.mv "0" "-80,80-" -- --tablet_types=replica,rdonly
vtctlclient VDiff user.mv # 必须先校验通过
vtctlclient Reshard SwitchTraffic user.mv -- --tablet_types=replica,rdonly,primary
vtctlclient Reshard Complete user.mv # 确认后清理源端反向复制
工程经验有两条值得强调:
第一,SwitchTraffic 之前强烈建议在主从副本(replica,rdonly)上先切流跑一段时间。因为主库的写路径和从库的读路径在 SQL 兼容性上表现可能不同——如果目标端缺索引,第一次故障往往不是写入报错,而是 VReplication 回放 SQL 变慢导致复制 lag 被拉大。
第二,VReplication 的吞吐瓶颈常常在目标端的写放大上。如果目标表二级索引过多,拷贝阶段会明显变慢。实战中可以先用 Workflow 的机制在 target 端保留最少索引,等拷贝完成后再用下面的 Online DDL 补索引。
五、Online DDL:为什么不在 MySQL 上直接 ALTER?
分片后表数量成倍增加(user × 64 片 = 64 份物理表)。直接在每个分片上 ALTER TABLE 会瞬间打满所有 MySQL 的 IO 与复制链路。Vitess 提供自己的 Online DDL,通过 @@ddl_strategy 选择后端:
-- 策略可选:direct / gh-ost / pt-online-schema-change / online(默认)
SET @@ddl_strategy = 'gh-ost --max-load=Threads_running=50 --critical-load=Threads_running=200';
ALTER TABLE `user` ADD COLUMN nickname VARCHAR(64) NOT NULL DEFAULT '';
-- 查看全集群进度:一次看所有分片的执行状态
SHOW vitess_migrations LIKE 'user' \G
它的执行模型是:先把 DDL 作为一条迁移记录写入 topology 中的 _vt.schema_migrations 表,然后由每个 tablet 并发(受 --ddl-allow-concurrent 之类阈值限制)按分片串行执行。关键点在于 Vitess 会在每个分片执行前自动做延迟副本保护——如果 gh-ost 后端发现某分片主从延迟超过阈值,会先 throttle,避免批量 DDL 引发的复制雪崩。
这也是我认为 Vitess 架构最值得借鉴的一点:它把"分布式操作"建模成了"带状态的、可观测、可暂停、可回滚的工作流",而不是一堆 shell 脚本的裸执行。SHOW vitess_migrations 能直接看到每个分片处于 queued / running / complete / failed 哪个阶段,失败时可以 ALTER VITESS_MIGRATION ... RETRY。
六、什么时候不该用 Vitess
说几点务实的判断,避免把分片当成万能解:
- 数据量低于单实例上限(比如 OLTP 场景 2TB 以内):分片带来的运维复杂度和 JOIN 受限,远比多花钱买大实例痛苦。先考虑读写分离 + 分区表。
- 业务的查询模式高度不可预测(分析型、adhoc 多维聚合):Vitess 是 OLTP 定位,跨片 scatter 会把任意一次全表扫描放大 N 倍。这类场景应该走 HTAP 或独立数仓。
- 强跨行事务是常态:如果业务必须频繁做跨用户维度的转账类事务,Vitess 的 multi-shard 2PC 会成为性能瓶颈。这种情况下更适合重新审视数据模型,或者选择真正为分布式事务设计的系统。
- 团队没有专门 DBRE:分片集群的故障排查需要理解 VTGate 计划、VReplication lag、拓扑一致性等多个层面,学习曲线不可低估。
结语
Vitess 真正有价值的并非"分库分表"这个动作本身,而是它把三件传统上高风险的操作——路由变更、数据再平衡、schema 演进——都做成了元数据驱动、幂等可重放、可观测的工作流。
如果要从 Vitess 身上提炼一条可以迁移到其他系统的设计原则,那就是:把 cluster-wide 的变更状态显式持久化,而不是让它隐式地藏在命令执行过程里。当你能用 SHOW vitess_migrations 看到一个跨 64 个分片的 DDL 处于何处、能随时 RETRY 时,"变更"就从赌博变成了工程。
对于已经重度依赖 MySQL 生态、又面临写入瓶颈的团队,Vitess 提供的这条渐进式路径——先中间件、再分片、按需重分片——在工程投入和风险控制上的平衡,至今仍然是相当务实的选择。

发表评论 取消回复