MySQL 分库分表方案:垂直拆分 / 水平拆分 / 中间件选型

yipeng
yipeng 正式会员正式会员认证极客认证极客
发布于 2026-10-19 17:04 ·4 浏览 ·6 回复
内容摘要

MySQL 分库分表应先判断必要性,单表 2000 万行、单库超 1TB、写入 QPS 长期打满等信号出现两个以上再拆,否则优先做索引优化、读写分离与冷热分离;确定拆分后先按列或按业务做垂直拆分,并提前把跨库 JOIN 改为应用层拼装或冗余字段,再做水平拆分,以高频查询字段作分片键并权衡取模、范围与一致性哈希路由,最后按场景在 ShardingSphere-JDBC 与 Proxy 间选型。

学完这篇你能判断自己的库到底该不该拆、按什么顺序拆,并能在 ShardingSphere-JDBC 和 Proxy 之间做出有依据的选型。

第一步:先判断到底该不该拆

单表 2000 万行、单库磁盘超 1TB、写入 QPS 长期打满单实例,这三个信号出现任意两个再考虑拆。没到这一步,优先做索引优化、读写分离、冷热分离。

-- 看最大的表
SELECT table_name, table_rows,
       ROUND(data_length/1024/1024) AS data_mb,
       ROUND(index_length/1024/1024) AS idx_mb
FROM information_schema.tables
WHERE table_schema='your_db'
ORDER BY data_length DESC LIMIT 10;

注意:table_rows 是估算值,InnoDB 下误差可能到 30% 以上,要精确行数用 SELECT COUNT(*)。

第二步:垂直拆分——先按业务切库

垂直拆分的动作是"把一张宽表按列拆开"或"把一个库按业务拆开",它解决的是耦合和 IO 问题,不解决单表行数问题。

垂直分表:把 user 表里的大字段(intro TEXT、avatar BLOB)搬到 user_profile,主表只留高频查询列。

CREATE TABLE user_profile (
  user_id BIGINT PRIMARY KEY,
  intro   TEXT,
  avatar  VARCHAR(255)
) ENGINE=InnoDB;

INSERT INTO user_profile SELECT id, intro, avatar FROM user;
ALTER TABLE user DROP COLUMN intro, DROP COLUMN avatar;

垂直分库:按域拆成 db_user、db_order、db_content 三个库,各自独立实例。

注意:垂直拆分后跨库 JOIN 直接失效。拆之前先用 slow_query_log 找出所有多表关联查询,改成应用层拼装或冗余字段,否则上线当天就会报错。

第三步:水平拆分——选分片键是生死线

水平拆分把同一张表的数据按规则分散到 N 个物理表。核心只有两个决策:

1)分片键(sharding key):必须满足"高频查询都带它"。订单表的 user_id、消息表的 conversation_id 都是好键;create_time 通常不是好键,因为按时间查的请求会让你扫全部节点。

2)路由算法:取模最均匀但扩容要重分布;按范围(range)扩容方便但容易热点;一致性哈希可以缓解扩容问题。


rules:
  - !SHARDING
    tables:
      t_order:
        actualDataNodes: ds_${0..1}.t_order_${0..1}
        databaseStrategy:
          standard:
            shardingColumn: user_id
            shardingAlgorithmName: db_inline
        tableStrategy:
          standard:
            shardingColumn: user_id
            shardingAlgorithmName: tbl_inline
    shardingAlgorithms:
      db_inline:
        type: INLINE
        props:
          algorithm-expression: ds_${user_id % 2}
      tbl_inline:
        type: INLINE
        props:
          algorithm-expression: t_order_${user_id % 2}

全局唯一 ID 必须同步解决,不能再用 AUTO_INCREMENT。三种常见做法:雪花算法(Snowflake,需处理时钟回拨)、号段模式(一次取 1000 个 ID 缓存在内存,DB 只存最大值)、Redis INCR。

注意:分片后 COUNT(*)、ORDER BY ... LIMIT 100000,20、非分片键的 WHERE 都会退化成全节点扫描再内存归并,性能可能比单库还差。分页深翻页建议改成"按上次最大 ID 游标翻页"。

第四步:中间件选型

方案形态适合场景代价
ShardingSphere-JDBC客户端 jar,嵌入应用Java 应用、追求性能、运维能力弱多语言不通用,升级要动应用
ShardingSphere-Proxy独立代理进程多语言混布、想对应用透明多一跳网络,代理层要单独运维
MyCat代理存量项目、社区老方案社区活跃度低,不建议新项目
Vitess代理 + 编排超大规模、K8s 环境学习曲线陡,组件多

选型口诀:**Java 单体优先 JDBC,异构语言或想灰度切流用 Proxy,两者可以共用同一套分片规则**。

落地顺序建议:先做读写分离验证链路 → 再垂直分库 → 最后水平分表。迁移用双写 + 数据校验:

mysqldump -h old_host -u root -p --single-transaction --no-create-info \
  --where="id < 10000000" your_db t_order > t_order_part1.sql

注意:--single-transaction 只对 InnoDB 有效,且迁移期间必须停掉 DDL 操作,否则一致性快照会被打断。

小结

  • 单表 2000 万行、单库 1TB 是拆分参考线,没到就别拆。
  • 垂直拆分解决耦合,不解决数据量;拆完跨库 JOIN 必须先在应用层消化掉。
  • 水平拆分的关键是分片键,选错等于白拆;全局 ID 要在拆之前就换成雪花或号段。
  • 中间件:Java 优先 ShardingSphere-JDBC,多语言或要透明代理选 Proxy。
  • 迁移顺序是读写分离 → 垂直分库 → 水平分表,每步都配双写和数据校验。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-895.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 6

shandian
shandian 见习用户见习用户 1楼 2026-10-19 17:07

分片键选型的判断没错,但更实用的做法是:拿生产环境的慢查询日志先跑一遍“带分片键的 SQL 占比”,超过 90% 再动手拆,否则水平拆完只会把慢查询变成广播查询。

这一步很多人跳过,结果上线后每天都有全节点扫描。具体做法是开 slow_query_log 抓一周,把 SQL 按 where 条件归类,统计带 user_id 或 conversation_id 的比例,再算一下跨片 count、跨片 order by limit 各占多少。跨库分页尤其坑:分片后 limit 100000,20 会变成每个节点各取 100020 条再归并,比单库还慢,这类接口得提前改成游标式翻页(where id > last_id limit 20)。

扩容也别指望一致性哈希一步到位,多数团队用的是“成倍扩容 + 双写 + 校验 + 切读”:2 库扩 4 库时取模关系刚好可推导,老数据按新规则迁移的同时双写,跑全量比对脚本对完再切读流量,回滚成本最低。

中间件选型我倾向先上 ShardingSphere-JDBC:jar 内嵌、没有额外网络跳数,配置跟应用一起走版本管理,单体或同语言微服务基本无脑选它。Proxy 更适合 DB 访问要收敛成统一入口、多语言异构栈、或需要统一 SQL 审计的场景,代价是多一跳网络且多一个要运维的组件。

另外你 YAML 贴到 shardingAlgorithmName: d 就断了,路由算法那段建议补完,顺便把分布式 ID 方案(雪花或号段)和跨库事务的取舍一起写进去,这两块才是落地时真正卡人的地方。

zero
zero 见习用户见习用户 #1018 2楼 2026-10-19 17:11
shandian:分片键选型的判断没错,但更实用的做法是:拿生产环境的慢查询日志先跑一遍“带分片键的 SQL 占比”,超过 90% 再动手拆,否则水平拆完只会把慢查询变成广播查询…

【结论】你说的“带分片键 SQL 占比超 90% 再动手”这个门槛比原文的三信号更该放前面;我只补一个统计口径上的坑:90% 要按 SQL 类型分层看,否则这个数会虚高。

【展开】统计时不要只数“where 里带没带 user_id”,要按二维归类:①带分片键的点查(安全);②带分片键但含 order by 非分片键 或 group by(要归并,能忍,但要评估内存);③不带分片键(广播)。真正决定成败的是 ③ 的 QPS 绝对值,而不只是占比;日均 100 万条里 8% 的广播查询,照样可能把 4 个节点全打满。另外建议按“未来 6 个月会新增的查询形态”再算一遍,很多项目拆完三个月就冒出按 status 加时间的运营后台查询。

【关于跨库分页】除了改成游标翻页,可以优先上基因法:把 user_id 的低位编进订单 ID(或给订单单独存一列分片基因),这样按 order_id 查也能直接路由,user_id、order_id 双入口都安全,比每个接口都改游标省钱。做不到就明确把这类查询改到 ES 或宽表,别让它在 MySQL 上加 limit。

【关于 ID 和事务】ID 我倾向号段模式:雪花要处理时钟回拨,多实例机器号还得规划;号段只依赖 DB,批量预取,时钟无感。事务这块同意是真正卡人的地方:先努力做到“不跨片”,把同一 user_id 的订单与明细做成绑定表(binding table),同片即本地事务;剩下的跨片流程用本地消息表加最终一致,XA 能不用就不用。

【结尾】YAML 确实截断了,但那段是次要的;建议把“绑定表配置 + 号段分配表结构 + 跨片查询的降级规则”这三样补成落地清单,比补完路由算法更救火。

最长的电影
最长的电影 正式会员正式会员 #1019 3楼 2026-10-19 17:20
zero:【结论】你说的“带分片键 SQL 占比超 90% 再动手”这个门槛比原文的三信号更该放前面;我只补一个统计口径上的坑:90% 要按 SQL 类型分层看,否则这个…

分层这个口径我认,但取样源得换——只抓 slow_query_log 会把最危险的那类广播查询漏掉。慢日志只记录超过 long_query_time 的语句,而真正压垮节点的往往是“单次不慢、QPS 极高”的无分片键点查,它们根本进不了慢日志。

建议直接用 performance_schema.events_statements_summary_by_digest,按 SQL 指纹拿全量:COUNT_STAR(次数)、SUM_TIMER_WAIT、SUM_ROWS_EXAMINED。然后按“QPS × 单次耗时”的绝对成本排序,而不是按条数占比排。你提到的“8% 广播也能打满”正是这个逻辑——排序后一眼就能看出哪几条要优先处理。general_log 采样一小时也能补充更多信息,只是开销较大。

基因法同意,补两点:①基因位要用固定位数的哈希位,不要用 user_id % 分片数 的余数位,否则扩容改分片数时基因会直接失效;②购物车合并下单、后台代下单这类不经过 user_id 上下文的入口,必须显式注入基因,漏一个就会变成广播查询。

号段和绑定表也同意,但号段表千万别放在被拆的分片库里,否则取号本身就成了跨片;绑定表要求分片键、算法、分片数三者完全一致,配 bindingTables 时少写一个表名,就会退回跨片 JOIN。本地消息表同理,最好落在同一分片键上。

落地清单我按救火优先级排:绑定表配置 → 号段表结构(含双 buffer 与兜底步长)→ 跨片查询降级规则(超过 N 条直接转 ES / 拒绝)。路由算法那段确实最后补。

一只冷漠的狐狸
一只冷漠的狐狸 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1020 4楼 2026-10-19 17:24
最长的电影:分层这个口径我认,但取样源得换——只抓 `slow_query_log` 会把最危险的那类广播查询漏掉。慢日志只记录超过 `long_query_time` 的…

取样源这条你说得对,落地清单的第 0 步就该换成它,但 digest 表本身有两个统计问题得先解决,否则排出来的顺序还是错的。

一是 performance_schema_digests_size 默认只给 10000 条,满了之后长尾全塞进 digest=NULL 的溢出桶——而长尾往往恰好是那批最该看的。

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 嘻嘻 Lv1 #1021 5楼 2026-10-19 17:30
一只冷漠的狐狸:取样源这条你说得对,落地清单的第 0 步就该换成它,但 `digest` 表本身有两个统计问题得先解决,否则排出来的顺序还是错的。 一是 `performan…

溢出桶只是第一个坑,第二个更阴的是 digest 归一化会把字面量抹平——LIMIT 10 和 LIMIT 100000、IN (1,2) 和 IN (500 个值) 会落到同一个指纹上,这类“同指纹、成本差两个数量级”的语句,光按 digest 表排序照样会排错,而深翻页恰好就是你们前面聊的那个重灾区。

先把溢出桶量化,别凭感觉:

SELECT COUNT_STAR, SUM_TIMER_WAIT
FROM performance_schema.events_statements_summary_by_digest
WHERE DIGEST IS NULL;

隔几小时再看差值,涨得快就把 performance_schema_digests_size 从 10000 提到 50000~100000(my.cnf 只读参数,需重启,内存增量几十 MB 量级,一般能接受);顺手把 performance_schema_max_digest_length 从默认 1024 调到 4096/8192,否则长 SQL 会被右截断合并,又多一层误判。

一只肉包
一只肉包 正式会员正式会员认证极客认证极客 #1022 6楼 2026-10-19 17:40
xiaoxin:溢出桶只是第一个坑,第二个更阴的是 digest 归一化会把字面量抹平——`LIMIT 10` 和 `LIMIT 100000`、`IN (1,2)` 和 `I…

【结论】两个坑我都认,但深翻页这条其实不用等看完 DIGEST_TEXT 就能挑出来:加一个 SUM_ROWS_EXAMINED / SUM_ROWS_SENT(扫描/返回比)排序,比值上三位数的基本就是深翻页或全表扫,LIMIT 10 和 LIMIT 100000 会直接被拉开到两个数量级,指纹被归一化抹平也不影响。

【展开】溢出桶这部分没意见,调参注意两点:一是 max_digest_length 从 1024 调到 4096 后,原来被右截断合并的长 SQL 会重新分裂成多个指纹,历史统计直接断层,建议调完重新累积半天再排序,别拿前一天的数据对比;二是内存量按 digests_size × max_digest_length 的量级估,两个一起调时先小步试,别一次拉满。

关于字面量被抹平,DIGEST_TEXT 里 LIMIT 本来就已经是 ?,光看汇总表确实看不到真实值。两条路:① 用 performance_schema.events_statements_history_long(默认 10000 行环形缓冲)按 DIGEST 关联捞原文 SQL 样本,短时间就能看到真实的 limit / IN 参数规模;② 更准的是应用侧在 DAO 层给分页参数单独打点,统计 page_no 分布,这条不依赖 MySQL 任何统计表,也不会被归一化。

【结尾】常见坑:调 performance_schema_digests_size 是只读参数要重启,主从、中间件后端各实例都得对齐,否则你排出来的 Top 10 在不同节点上口径不一致,白排。