单表多大才该分库分表:判据与方案

单表 800 万行就该分表吗

「MySQL 单表超过 500 万行性能就急剧下降,必须分表」——这句话在网上流传了十几年,而且每年都有人照着做,结果把一张本来跑得好好的表拆成八张,查询逻辑复杂了一倍,性能却没变好。

真相是:InnoDB 单表能撑到什么规模,跟行数关系不大,跟「单行多大、索引多少、查询能不能走索引、内存能不能装下热数据」关系很大。一张 100 字节宽的表,800 万行也就 800MB,完整塞进 InnoDB Buffer Pool 毫无压力;一张 5KB 宽、带八个索引的表,200 万行就能到 10GB 以上,每次查询都在磁盘上随机读。

所以判断要不要分表,第一件事不是看行数,而是看这三件事:表有多大、热数据能不能驻留内存、以及现在慢的到底是哪几条 SQL。这篇讲的是怎么用数据回答这个问题,以及确认要分之后,垂直拆分和水平拆分该怎么选。

第一步:量出真实体积,别数行数

SHOW TABLE STATUS 里的 Data_length 常年不准(尤其是开了 innodb_file_per_table 且做过大量删除之后),要看准确数字得走 information_schema 配合页统计:

SELECT
  table_name,
  table_rows,
  ROUND(data_length / 1024 / 1024, 1)       AS data_mb,
  ROUND(index_length / 1024 / 1024, 1)      AS index_mb,
  ROUND((data_length + index_length) / 1024 / 1024, 1) AS total_mb,
  ROUND(data_length / NULLIF(table_rows, 0)) AS avg_row_bytes
FROM information_schema.tables
WHERE table_schema = 'zz1984'
ORDER BY (data_length + index_length) DESC
LIMIT 20;

这里 avg_row_bytes 是关键指标。如果它超过 1000 字节,那这张表的瓶颈一定先到——因为 InnoDB 的一页是 16KB,除掉页头页尾大约能放 15KB 的有效数据,一行 5KB 意味着一页只能放 3 行,一次范围扫描的页数会暴涨。这种情况先考虑垂直拆分(把大字段挪走),而不是水平分表。

如果 index_mb 比 data_mb 还大,说明索引建多了,先去清理冗余索引(这部分前面单独写过),往往能立竿见影——索引体积减小意味着同样的 Buffer Pool 能缓存更多数据页。

然后算热数据比。Buffer Pool 有多大:

SHOW VARIABLES LIKE 'innodb_buffer_pool_size';

SELECT
  pool_size,
  ROUND(pool_size / 1024 / 1024 / 1024, 1) AS pool_gb,
  ROUND(free_buffers * 16 / 1024, 1) AS free_mb_gap
FROM (
  SELECT variable_value AS pool_size FROM performance_schema.global_status
  WHERE variable_name = 'Innodb_buffer_pool_pages_total'
) t, (
  SELECT variable_value AS free_buffers FROM performance_schema.global_status
  WHERE variable_name = 'Innodb_buffer_pool_pages_free'
) f;

用「表总大小 / Buffer Pool 大小」估算驻留率。这个比值小于 1:整张表都能装进内存,那 800 万行还是 2000 万行对查询延迟几乎没影响,分表纯属自找麻烦。比值在 1 到 3 之间:热数据能装下,靠索引和查询优化就能撑,属于「可以不动」的区间。大于 5:每次查询大概率在磁盘上随机读,这时才该认真考虑拆分。

第二步:确认慢的是「扫描量」而不是「并发」

很多人把「网站变慢」等同于「表太大」,但表大会导致的慢只有一种:单条 SQL 的扫描行数太多。如果是并发打满,表再小也一样慢。

用 EXPLAIN ANALYZE(MySQL 8.0.18+)看真实执行量:

EXPLAIN ANALYZE
SELECT id, title, created
FROM typecho_contents
WHERE type = 'post' AND created > UNIX_TIMESTAMP('2026-01-01')
ORDER BY created DESC
LIMIT 20;

输出里重点看 actual rows 和 loops 的乘积。loops 大于 1 说明这一层被重复执行了多次,总扫描量 = actual rows × loops。如果这个数在几十万到几百万,而最终只返回 20 行,那就是典型的「扫描量过大」,分表或者加合适的索引都能解决。

但要注意 LIMIT 20 会掩盖真相。优化器看到 LIMIT 后可能只扫描到够 20 行就停,实际 actual rows 只有 20 多。判断真实扫描量的办法是去掉 LIMIT 再 EXPLAIN ANALYZE 一次(在从库上跑,或者加 WHERE 1=0 只看计划),对比两个数字。这个差异经常能解释「为什么单个查询很快,但翻到第 50 页就卡住」。

另一个必须查的指标是 Buffer Pool 命中率:

SHOW GLOBAL STATUS LIKE 'Innodb_buffer_pool_read%';
-- read_requests / (read_requests + reads) 就是命中率
-- 低于 99% 说明有大量物理读

命中率 99.9% 以上而查询仍然慢,通常是索引或 SQL 写法问题;命中率 95% 以下,那才是数据量真的超过内存了,分表才有意义。

垂直拆分:先做这个,成本低收益高

垂直拆分的意思是「按列拆」。一张文章表如果同时存着标题、正文、摘要、封面、SEO 描述、点击数、评论数,那它其实混了三种访问模式:列表页只读标题和点击数(高频、小字段),详情页读正文(低频、大字段),后台编辑读写全部(极低频)。

把 content 这种 TEXT 大字段拆到独立的 typecho_contents_body 表,列表页的查询就能少读大量数据页。收益非常直接:

-- 拆之前:列表页每行都要跳过 TEXT 的溢出页
SELECT cid, title, created, views FROM typecho_contents
WHERE type = 'post' ORDER BY created DESC LIMIT 20;

-- 拆之后:主表变窄,同样 16KB 一页能放更多行
ALTER TABLE typecho_contents_body
  ADD PRIMARY KEY (cid);

-- 详情页才 JOIN
SELECT c.*, b.text FROM typecho_contents c
LEFT JOIN typecho_contents_body b ON b.cid = c.cid
WHERE c.cid = 1786;

注意 InnoDB 对大字段的处理:TEXT/BLOB 如果超过约 768 字节(取决于行格式),会被存到「溢出页」里,主键页上只留 20 字节的指针。所以拆走大字段之后,主表的行会变得非常紧凑,一页能放几百行,范围扫描的页数直接降一到两个数量级。

垂直拆分的三个注意点:一是拆之前确认应用层能接受多一次 JOIN 或者多一次查询,如果代码里到处是 SELECT *,拆完会直接报错;二是拆的时候 INSERT 要改成一个事务里插两张表,否则会出现有主表无正文的脏数据;三是备份脚本要同步改,漏备份正文表是最容易犯的错。

水平拆分:什么时候真的需要,以及按什么键拆

垂直拆分做完、索引也清理过、慢 SQL 也优化过,单表还是撑不住,才轮到水平拆分。这时候的判据很明确:单表超过 2000 万行,或者单表体积超过 Buffer Pool 的 5 倍,并且已经确认没有优化空间。

按什么键拆是核心决策。三种常见方案:

按用户 ID 取模。shard = user_id % 8。优点是数据分布均匀、查询能精确路由。缺点是如果业务有「按时间查全站最新文章」这种需求,就得扫全部 8 张表再排序,分页尤其痛苦。

按时间分表。contents_2026_10、contents_2026_11。优点是范围查询友好、冷数据能整表归档或删除、单表体积可控。缺点是「热点写」全集中在最新那张表上,而且业务代码要动态算表名。

按业务主键哈希。取 cid 的 CRC32 后取模。适合「按 ID 查」为主、且不关心时间维度的场景。

对内容站来说,通常的组合是按时间分区/分表 + 按 ID 哈希做二级路由,因为读多写少、以时间倒序列表为主。但如果只是想解决「老数据占空间」,最省事的其实是按时间归档:把三年前的文章整表导出到一个 archive 库,主库只留近三年的。这样不用改任何查询逻辑,只是归档部分的访问走另一个库。

分表之后必然遇到的四个问题

跨表分页。ORDER BY created DESC LIMIT 100000, 20 在分表之后需要每张表都取 LIMIT 100020, 20 再归并,成本是单表的 N 倍。解决办法是「记住上一页的游标」——用 WHERE created < ?last_created 代替 OFFSET,这样每张表只取 20 行。代价是不能跳页。

跨表聚合。COUNT(*)、SUM(views) 这类统计要扫全部分表。要么在应用层归并,要么单独维护一张统计表(每次写入时更新),要么接受「统计数字有延迟」。第三种最实用——内容站的阅读数延迟几分钟没人会在意。

跨表事务。如果一次业务操作要同时写两张分表(比如作者和文章分在不同库),本地事务就失效了。这种场景要么保证相关数据落在同一个分片(把 author_id 也作为路由键),要么引入最终一致性方案(消息队列 + 补偿)。能用第一种就别用第二种,分布式事务的复杂度是分表本身的好几倍。

全局唯一 ID。自增主键在分表后会冲突。方案有:按步长分配(表 1 从 1 开始步长 8,表 2 从 2 开始)、用雪花算法生成 64 位 ID、或者用号段服务批量取 ID。

最省的替代方案:分区表

如果只是想让单表「逻辑上还是一个表、物理上分开存」,MySQL 原生的分区表可能是比自己写分表逻辑更省事的方案:

ALTER TABLE typecho_contents
PARTITION BY RANGE (created) (
  PARTITION p2024 VALUES LESS THAN (UNIX_TIMESTAMP('2025-01-01')),
  PARTITION p2025 VALUES LESS THAN (UNIX_TIMESTAMP('2026-01-01')),
  PARTITION p2026 VALUES LESS THAN (UNIX_TIMESTAMP('2027-01-01')),
  PARTITION pmax  VALUES LESS THAN MAXVALUE
);

分区表的优势是查询逻辑完全不用改,SQL 层面还是一张表,MySQL 自动做分区裁剪(EXPLAIN 的 partitions 列会显示扫描了哪几个分区)。删除老数据只要 ALTER TABLE ... DROP PARTITION p2024,瞬间完成,比 DELETE 几百万行快无数倍。

但有硬限制:分区键必须包含在主键里。如果主键是 cid,那按 created 分区就必须把主键改成 (cid, created),这个改动会影响所有依赖主键的代码。另外 MySQL 8.0 的分区表不支持外键,如果业务用了外键约束,得先去掉。

决策清单

把上面所有内容收成一张判断表,遇到「要不要分表」的问题按顺序走:

一、先看 avg_row_bytes。超过 1000 字节 → 做垂直拆分,把大字段挪走,大概率就够了。

二、再看 index_mb > data_mb。是 → 清理冗余索引,这一步往往能省 30% 以上空间。

三、算「表总大小 / Buffer Pool 大小」。小于 3 → 不用拆,去做 SQL 和索引优化。

四、看 EXPLAIN ANALYZE 的 actual rows × loops。如果是几百万行只返回 20 行 → 缺索引,不是缺分表。

五、以上都做过还是慢,且单表超过 2000 万行 → 才考虑分表。优先选分区表,其次按时间归档,最后才是应用层分表。

这套顺序的价值在于:它把「分表」从第一选项挪到了最后。绝大多数喊着要分表的站,走完前四步问题就解决了,而且改动量小得多、回滚也容易。分表是不可逆的架构决策,一旦分了再想合回去,成本比重写一遍还高。

真要动手之前,还有一件事:先在从库上完整演练一遍。把所有分表脚本、数据迁移、应用改动、回滚方案在从库上跑通,记录每一步耗时。主库上的操作窗口是按从库的耗时乘 2 估的,不是拍脑袋定。

Last modification:October 22nd, 2026 at 01:28 pm

Leave a Comment