
导读:50 万行靠 SQL,5000 万行靠索引和分区,1 亿行以上靠架构。多数人卡在"该不该跨"的犹豫上。本文以 MySQL/InnoDB 为例,其他数据库的具体机制不同(如 PostgreSQL 的 VACUUM、Oracle 的 CBO 行为),但分层判定的思路通用。
50 万、5000 万、1 亿,这是数据库优化里三道分水岭。
每跨过一道,你解决问题的方式就要换一种。
第一道门槛之前,你只需要把 SQL 写对。第二道之前,索引和分区是定海神针。第三道之后,单机优化全部失效,必须做架构升级。
我见过的项目里,大部分"页面慢"问题最后归到数据库。但真正卡住团队的,不是不会优化,是不知道现在这个慢,到底卡在哪一道门槛。
有人在 30 万行的表上反复折腾分库分表,过度设计;有人在 8000 万行的表上死磕加索引,buffer pool 早爆了。两种错都是同一个根因:没有判定模型。
网上 SQL 优化教程已经够多。这篇要给你的不是更多优化技巧,而是:每一道门槛的判定模型和体检三件套(症状、指标、手段),以及最后一张可以贴在墙上的决策表。
读完后,下次你看到 explain type=ALL,应该能快速锁定方向。
⚠️ 本文三个场景为基于工程常识的模拟构造,数字为合理推演范围,非真实公司案例。
一、第一道门槛:50 万行以内,靠 SQL 改写
先看场景。
电商平台的运营要做营销活动,需要拉一份"活跃用户的订单 + 标签"清单。开发同学写了这样一条 SQL:
SELECT *
FROM users u
JOIN user_orders uo ON u.id = uo.user_id
JOIN user_tags ut ON u.id = ut.user_id
WHERE u.status = 1
ORDER BY u.created_at DESC
LIMIT 100;
users 表 12 万行,user_orders 80 万行,user_tags 50 万行。users.status 上有索引,订单表和标签表的 user_id 也有索引。
页面打开 800 毫秒。运营嫌慢。
看 explain,问题在哪
跑一次 explain:
+----+-------+------+-------------+-----------+--------+---------------------------+
| id | table | type | possible_keys| key | rows | Extra |
+----+-------+------+-------------+-----------+--------+---------------------------+
| 1 | u | ALL | idx_status | NULL | 120000 | Using where; Using filesort|
| 1 | uo | ref | idx_user | idx_user | 7 | NULL |
| 1 | ut | ref | idx_user | idx_user | 4 | NULL |
+----+-------+------+-------------+-----------+--------+---------------------------+
users 表 type=ALL,rows=120000。possible_keys 里写着 idx_status,但 key 是 NULL,没走。
为什么?因为 status=1 的用户占了大约 70%。优化器一算:走索引要回表 8 万多次,还不如直接顺序读 12 万行 + filesort,反而更快。
这是第一道门槛的典型现场。
索引该走的不走,扫描该停的不停。
三招破
第一招:去掉 SELECT *
avatar_url、detail_json 这种大字段动辄几百字节,乘以结果集里的每一行,单次查询多传几十兆数据。改成只取需要的列:
SELECT u.id, u.name, u.email, uo.amount, ut.tag_id
FROM users u ...
单次查询的网络传输量降到原来的几十分之一。
第二招:用更精确的过滤条件,引导优化器换驱动表
MySQL 优化器会自己挑驱动表,SQL 里表的书写顺序它根本不看。但我们可以通过加更窄的过滤条件,让优化器算出来的"最优驱动表"变成我们想要的那张。
这一招的前提是和业务确认——运营真正需要的是最近 7 天的活跃用户,而不是全量历史。需求范围一缩,原本要扫 12 万 users 的全表问题就变成了"让 3 万订单驱动"的小问题:
SELECT u.id, u.name, uo.amount, ut.tag_id
FROM user_orders uo
INNER JOIN users u ON u.id = uo.user_id AND u.status = 1
INNER JOIN user_tags ut ON ut.user_id = u.id
WHERE uo.created_at > DATE_SUB(NOW(), INTERVAL 7 DAY)
ORDER BY u.created_at DESC
LIMIT 100;
最近 7 天的订单可能只有 3 万条,让 user_orders 通过 created_at 过滤先收窄到 3 万,优化器会自动选 user_orders 做驱动表(rows 估算最小)。如果还想强制控制,可以用 STRAIGHT_JOIN。
第三招:避免 N+1
很多 ORM 默认懒加载,主查询拿到 100 个用户,再去循环查每个用户的订单和标签,这是 N+1 的经典陷阱。Laravel、Hibernate、Django ORM 都有过这个坑。修法是手动 prefetch 或写显式 JOIN。
改完之后
改完三招,explain 长这样:
+----+-------+--------+--------------+-------+---------------------------------+
| id | table | type | key | rows | Extra |
+----+-------+--------+--------------+-------+---------------------------------+
| 1 | uo | range | idx_created | 30000 | Using where |
| 1 | u | eq_ref | PRIMARY | 1 | Using where |
| 1 | ut | ref | idx_user_id | 4 | Using temporary; Using filesort |
+----+-------+--------+--------------+-------+---------------------------------+
主表扫描从 12 万降到 3 万,回表次数相应缩减。虽然 rows 降了,但 filesort 仍存在(ORDER BY u.created_at 无法利用 uo 的索引天然排序),这是单条 SQL 改写的天花板,再快就要靠索引层了。
耗时从 800 毫秒降到大约 45 毫秒,约 18 倍。

⚠️ 这是基于"中端机器、buffer pool 命中率高于 90%、活跃用户最近 7 天有订单的占四分之一"的推演。换硬件、换数据分布,数字会浮动。但改完比改前快 10-20 倍这个量级,多数项目都能复现。
两个隐藏陷阱
在看体检三件套之前,先说两个比 SELECT * 还高频的坑。explain 不会直接说"你写错了",只会显示 type=ALL。
隐式类型转换:WHERE phone = 13800138000,如果 phone 列是 varchar,MySQL 会把整列转为数字再比较,索引直接失效。修法:把字面量加引号 WHERE phone = '13800138000'。
函数包裹:WHERE DATE(created_at) = '2026-04-01' 或 WHERE YEAR(created_at) = 2026,对索引列套函数后索引用不上。修法:改成范围条件 WHERE created_at >= '2026-04-01' AND created_at < '2026-04-02'。

第一道门槛的体检三件套
什么时候判定你"卡在第一道门槛"?
| 维度 | 阈值 |
|---|---|
| 症状 | 页面慢,但单条 SQL 在测试环境跑只有几十毫秒 |
| 指标 | 单表数据量 < 50 万;explain 里出现 type=ALL 或 rows > 10 万 |
| 手段 | 改写 SQL(去 SELECT *、精确过滤引导驱动表、避免 N+1) |
如果你的表在 50 万以内,QPS 也没上 500,遇到慢查询的第一反应永远应该是先看 SQL 写得对不对。加索引是后面的事。
但如果表继续涨到千万级,这套就撑不住了。

二、第二道门槛:50 万到 5000 万行,索引和分区
公司内部的审计系统,记录所有人对资源的操作(创建、读取、更新、删除、登录)。这张 audit_logs 表已经 3000 万行,单行平均 500 字节,总表 15 GB。
业务在跑这条 SQL:
SELECT *
FROM audit_logs
WHERE user_id = 8801234
AND created_at BETWEEN '2026-04-01' AND '2026-04-30'
ORDER BY created_at DESC
LIMIT 50;
idx_user_id(user_id) 早就加了。但耗时 P99 还是 2.3 秒。
这次问题在哪
跑 explain:
+----+--------------+------+-------------+--------+---------------------------+
| id | table | type | key | rows | Extra |
+----+--------------+------+-------------+--------+---------------------------+
| 1 | audit_logs | ref | idx_user_id | 280000 | Using where; Using filesort|
+----+--------------+------+-------------+--------+---------------------------+
rows=280000 怎么来的?
8801234 是个高频账号,可能是管理员或者跑批的服务账号,这类账号的历史日志远超平均水平。普通用户可能只有 30-100 条,他可能有 28 万条。
⚠️ 这个场景刻意选高频账号,是为了暴露问题。如果换成普通用户,rows 是几百,单列索引就够了,根本到不了第二道门槛。
走 idx_user_id 之后,要回表 28 万次去拿 detail_json 这种大字段,再过滤 4 月那一千多条。回表的 IO 代价 + filesort 的开销 + 大字段的网络传输,一起把耗时推到 2.3 秒。
“还是慢"的两个真原因(重点)
第二道门槛最坑的地方在于:你以为加完索引就够了,但还是慢。这里有两个常见的真原因,多数文章不讲。
真原因 1:低基数陷阱
有人看到 WHERE action = 'LOGIN' 慢,反手给 action 列加索引:
ALTER TABLE audit_logs ADD INDEX idx_action (action);
然后,还是慢。
为什么?action 列只有 5 个值(CREATE/READ/UPDATE/DELETE/LOGIN)。即使加了索引,每个值对应大约 600 万行。优化器一算:走索引后回表 600 万行的成本,远高于全表扫描 3000 万行(顺序 IO + 部分 buffer 命中)。直接放弃这个索引。
经验阈值:列基数 / 总行数 < 1%,就要慎加索引。性别、状态、布尔类型字段大多是低基数列,单独加索引基本无效。这是经验阈值,实际效果还取决于查询模式,比如 LIMIT 10 取少量行时,即使选择率低,索引仍可能有效。
真原因 2:统计信息过期
另一个隐蔽的坑。假设上周批量导入了 500 万行历史日志:
INSERT INTO audit_logs (...) -- 批量导入 500 万行
一周后查询,发现执行计划诡异:明明该走 idx_user_created 复合索引,优化器却选了全表扫。
为什么?MySQL 默认在表数据变化超过 10% 时触发统计信息更新(受 innodb_stats_auto_recalc 控制)。500w / 3000w ≈ 16%,确实超过阈值。但这里有个关键细节多数文章不讲:触发是后台异步的。从批量写入完成到统计信息真正刷新,会有几秒到几分钟的延迟窗口。如果业务在这个窗口里查询,优化器拿到的还是"老数据”,它对某些 user_id 的 rows 估算偏差几倍。成本估算偏差导致选错执行计划。
修法很简单:
ANALYZE TABLE audit_logs;
跑完之后,rows 估算修正回来,优化器才会做正确决策。
其他还有锁等待、buffer pool 命中率低、深分页(LIMIT 100000, 20)等也会让你"索引看着对但还是慢",这里只展开最常见的两个。
这两个真原因,是我在排查这个量级慢查询时见过最多的。比"加索引、用复合索引"这种基础知识更实用。
怎么破(手段)
定位了真原因,看解法。
第一招:复合索引(覆盖索引思路)
ALTER TABLE audit_logs DROP INDEX idx_user_id;
ALTER TABLE audit_logs ADD INDEX idx_user_created (user_id, created_at);
把 user_id 和 created_at 放进一个索引,用户和时间范围都能在索引层过滤,ORDER BY created_at 还能利用索引天然有序,不用 filesort。rows 从 28 万降到 1500。
第二招:按月分区
ALTER TABLE audit_logs
PARTITION BY RANGE (TO_DAYS(created_at)) (
PARTITION p202604 VALUES LESS THAN (TO_DAYS('2026-05-01')),
PARTITION p202605 VALUES LESS THAN (TO_DAYS('2026-06-01')),
...
);
以上语法基于 MySQL 5.7/8.0。
分区裁剪让 created_at BETWEEN '2026-04-01' AND '2026-04-30' 只扫 p202604 一个分区,从 3000 万降到 100 万。
⚠️ 上分区前先确认三件事,踩完坑再回退代价更大:(1) 分区键必须包含在主键和唯一索引里——audit_logs 原主键不含 created_at 的话,你得重建主键;(2) 跨分区查询可能比单分区更慢(ORDER BY、回表都受影响),分区数超 100 后维护成本也上去了;(3) MySQL 分区表不支持外键,有外键依赖就别碰分区。
第三招:保持统计信息新鲜
批量导入或大批量删除后,养成 ANALYZE TABLE 的习惯。生产环境如果对 binlog 同步敏感,加 LOCAL 选项避免写 binlog(LOCAL 是 NO_WRITE_TO_BINLOG 的别名,MySQL 5.7/8.0 均支持)。

改完之后
+----+------------+-------+------------------+------+------------------+
| id | table | type | key | rows | Extra |
+----+------------+-------+------------------+------+------------------+
| 1 | audit_logs | range | idx_user_created | 1500 | Using index cond |
+----+------------+-------+------------------+------+------------------+
加上分区裁剪,实际只扫 p202604 分区里的 1500 行索引项。耗时从 2.3 秒降到大约 80 毫秒,约 29 倍。

50ms 到 300ms 之间是合理浮动区间,取决于 buffer pool 命中率和磁盘 IO。
第二道门槛的体检三件套
| 维度 | 阈值 |
|---|---|
| 症状 | 索引看着都加了,单条查询还是几百毫秒到几秒 |
| 指标 | 单表数据量 500 万 ~ 5000 万;索引选择率 < 5%;P99 > 500ms |
| 手段 | 复合索引(覆盖)、分区、保持统计信息新鲜 |
但当订单表涨到一亿行,单机 CPU 90%、IOPS 饱和,索引救不了你了。账算不过来了。

三、第三道门槛:1 亿行以上,架构调整
电商订单系统,业务跑了 4 年,加上双十一峰值压力,orders 表已经 1.2 亿行,60 GB。该做的优化都做了:复合索引、按月分区、ANALYZE 跑得勤快。单机依然吃不消。
来看一下生产指标:
| 指标 | 数值 | 状态 |
|---|---|---|
| 机器 | 8C32G + 1TB SSD | — |
| 读写比 | 7:3 | — |
| QPS 峰值 | 8000(双十一) | 高 |
| 单条查询 P99 | 200ms | 已优化 |
| CPU 峰值 | 90%(持续高位) | ⚠️ |
| IOPS | 42000/50000(饱和度 84%) | ⚠️ |
| buffer pool | 24GB 满载,命中率 91% | ⚠️ |
| 连接池 | 最大 1500,高峰排队 30-50ms | ⚠️ |
每个数字单独看都不算糟。合在一起,单机已经接近天花板。
这次问题在哪
第二道门槛靠的是减少扫描行数(从 28 万降到 1500),效果立竿见影。但当数据规模到了这个量级,三个瓶颈同时卡住。
每次查询只扫 1500 行,但 8000 QPS × 1500 行 = 1200 万行/秒的 IO 压力。
索引命中率再高,1.2 亿行 × 500 字节 = 60 GB 的活跃数据集装不进 24 GB 内存,热数据淘汰频繁。
单条 P99 已经压到 200ms,业务页面却要拼接 5-10 个查询,叠加起来就是 1-2 秒的页面延迟。
这不是优化能解决的问题,是容量瓶颈。
何时真正该跨
判断"是不是真的到了第三道门槛",看 4 个信号:
| 信号 | 阈值 |
|---|---|
| 单机 IOPS | > 硬件上限 80%(持续 30 分钟以上,不是尖刺) |
| 数据月增长率 | > 15%(半年翻倍) |
| 读写混合 QPS | > 5000 且无法拆分时间窗 |
| 单查询 P99 | 已优化到 < 200ms 但页面还是慢 |
任意满足两条,就该升级架构了。
阈值是"中端机器(8C32G + 1TB SSD)“的参考。如果你用 32C128G + NVMe,所有数字翻倍。
四件武器
读写分离
读写比 7:3 意味着 70% 流量可以分到从库。

两个从库平均承担 70% 读流量。主库压力降到原来的 30%(写)+ 不再扛读。
但这里有个坑:从库是异步复制(MySQL 默认行为,半同步需配置 plugin),毫秒到秒级的延迟不可避免。如果业务对一致性敏感(比如下单后立刻看订单详情),那部分读必须走主库。这块要在路由层做"主库读"的兜底机制。
Redis 热数据缓存
业务观察一下:90% 的订单查询集中在最近 7 天。把这部分搬到 Redis:
最近 7 天订单 → Redis(约 200 万行,TTL 24 小时)
7 天前订单 → MySQL
90% 的请求直接 5 毫秒返回。MySQL QPS 从 8000 降到 800,一个数量级。
订单系统对一致性敏感,要选合适的缓存策略:写时同步删缓存(Cache Aside)是最常见做法;如果场景里有跨表更新,加一道延迟双删兜底(写库 → 删缓存 → 隔几百毫秒再删一次,覆盖并发读 miss 时回填的旧值)。具体策略选型展开够再写一篇,这里点到为止。
连接池调优
生产指标里"高峰排队 30-50ms"看着不大,但 5-10 个串行查询拼接时,排队延迟也被串行叠加。这是另一个隐藏瓶颈。
常规做法:
- 按业务拆连接池(读池、写池、批量任务池分开),避免批量导入抢占在线查询连接
- 缩短空闲连接超时(
wait_timeout从默认 8 小时调到 5 分钟),及时释放僵尸连接 - 引入连接复用中间件(如 ProxySQL),在应用和 MySQL 之间做连接池收敛
高峰排队从 30-50ms 压到 < 5ms,对"页面拼接 5-10 个查询"的场景,总延迟减少 200-400ms。
冷热分离 + 归档
订单系统里,180 天前的数据基本是"低频长尾”,查的人少,但占着空间。归档:
CREATE TABLE orders_archive LIKE orders;
INSERT INTO orders_archive SELECT * FROM orders WHERE created_at < DATE_SUB(NOW(), INTERVAL 180 DAY);
DELETE FROM orders WHERE created_at < DATE_SUB(NOW(), INTERVAL 180 DAY);
⚠️ 生产环境绝对不能这样直接 DELETE。 1.2 亿行表上的大批量 DELETE 会产生:(1) 巨大的 undo log(可能撑爆 ibdata1);(2) 长时间行锁和 MDL 锁,阻塞所有 DML;(3) binlog 暴涨,主从延迟到分钟级。正确做法:分批删除,每批 1 万行 +
SLEEP(0.1),整体跑几个小时。或者直接用pt-archiver这种成熟工具,它内置了限速、批量、检查点恢复。
主表从 1.2 亿降到 4000 万。buffer pool 命中率从 91% 提升到 99%,活跃数据集塞得下内存了。
整体架构

改完之后
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 主库 CPU 峰值 | 90% | 35% |
| 读延迟 P99 | 200ms | 15ms(命中缓存)/ 50ms(从库) |
| MySQL QPS(主库) | 8000 | 800 |
| buffer pool 命中率 | 91% | 99% |
| IOPS 饱和度 | 84% | 32% |

数字基于"7:3 读写比 + 90% 缓存命中 + 180 天归档"的假设。命中率不到 90% 时,效果会打折,长尾查询多的业务可能只有 60-70%。
第三道门槛的体检三件套
| 维度 | 阈值 |
|---|---|
| 症状 | 索引和分区都最优了,单机指标全在告警线 |
| 指标 | 单表 > 5000 万;CPU > 80% 或 IOPS > 硬件 80%;QPS > 5000 |
| 手段 | 读写分离、热数据缓存、连接池调优、冷热分离归档 |
其他方向:分库分表(ShardingSphere/Vitess)、NewSQL(TiDB)、云原生数据库(Aurora/PolarDB)也是常见路径。选哪条要看团队现状(运维能力、迁移成本、是否已上云)。这里不展开,但选型时心里要有这张地图,别把"读写分离 + 缓存"当成唯一答案。
但这一切都建立在你能判断出"现在该跨"。怎么判断?看下面这张表。
四、决策表:怎么判断现在该跨哪一道
把三道门槛压成一张表。
| 数据量 | 读写比 | QPS | 推荐方案 | 行动信号(该升级了) |
|---|---|---|---|---|
| < 50 万 | 任意 | < 500 | SQL 改写 | type=ALL 且 rows > 10w |
| 50w ~ 5000w | 读 > 写 | < 3000 | 复合索引 + 分区 | 回表 > 10w 或 P99 > 500ms |
| 50w ~ 5000w | 写 > 读 | < 3000 | 分区 + 写入批量化 | 索引维护耗时 > 查询耗时 |
| > 5000w | 读 » 写 | > 3000 | 读写分离 + 缓存 | 缓存命中率 > 70%、从库延迟可接受 |
| > 5000w | 混合高 | > 5000 | 分库分表 | 单机 IOPS > 硬件 80% 或 buffer 命中 < 90% |
| > 1 亿 | 任意 | 任意 | 冷热分离 + 归档 | 月增长率 > 15%(半年翻倍) |
怎么读这张表(三步走)
第 1 步:查数据量段
先大致估算一下你的表多少行,对照"数据量"列。
- 50 万以内,别想别的,先把 SQL 写对
- 50 万到 5000 万,索引和分区是主战场
- 5000 万到 1 亿,看读写比和 QPS 选架构方向
- 1 亿以上,必须考虑冷热分离
第 2 步:分流读写比
读写比决定架构升级方向:
- 读 » 写(电商商品页、内容分发)→ 读写分离 + 缓存收益最大
- 写 » 读(日志系统、IoT 数据)→ 分区 + 批量化是关键
- 混合高(订单系统、社交动态)→ 分库分表是终极方案
第 3 步:看行动信号
行动信号是"客观可观察的指标",不是主观感受。
看到 type=ALL 而且 rows > 10w,第一道门槛没过;看到 P99 > 500ms 但 SQL 已经写对了,该上复合索引;看到 IOPS > 硬件上限 80%,该做架构升级;看到月增长率 > 15%,半年后必须冷热分离。

三个常见误用
误用 1:跨太早
数据量 30 万,QPS 200,看到 P99 偶尔 800 毫秒就开始考虑分库分表。这是过度设计。先把 SQL 写对,90% 的情况下 P99 能压到 50 毫秒以下。
误用 2:跨太晚
数据量已经 8000 万,单机 IOPS 80%+,团队还在加索引。加再多索引,buffer pool 装不下,索引维护本身的写放大反而成新瓶颈。该停手了。
误用 3:方向选错
读写比 7:3 但 QPS 只有 1500,盲目上分库分表。读多写少的场景,读写分离 + 缓存的性价比高 10 倍。分库分表带来的事务复杂度、跨表查询难度会让团队跌入新的坑。

这张表的边界
⚠️ 是参考值,不是绝对值。
阈值之间有 ±30% 的弹性区间。50 万和 70 万之间没有本质差异,靠近阈值时看趋势。
行宽是隐含维度——10 万行 × 10KB 的宽表,buffer pool 占用比 1000 万行 × 50 字节的窄表还高,阈值要相应左移。
业务复杂度也是隐含维度。复杂 JOIN(5 表关联)会让阈值左移;简单 KV 查询会让阈值右移。
在两个相邻方案之间犹豫时,永远选低成本的。先 SQL 改写,再索引,再架构。除非有明确的趋势判断需要提前布局。
五、不是技术难,是决策难
回到开头的三个数字:50 万、5000 万、1 亿。
三个数字代表三种思维方式:SQL 层处理写法问题,索引层对付数据分布,架构层应对容量天花板。每跨一次,思维方式都要变。
技术手段网上都有。难的是判断你现在到底卡在哪一层、什么时候真的该跨。多数团队卡住,不是因为不会优化,是没把这件事当成判定问题。总以为是"再加一个索引就能救"。
下次你看到 explain type=ALL,记得问一句:我是不是还能在 SQL 层解决?
如果答案是不能,再问一遍:那是要跨到索引层,还是该开始评估架构升级了?
每一次回答,都是一次门槛前的取舍。你的表现在卡在哪一道?欢迎评论区聊聊场景,下一篇可以挑典型的展开。
原文发布于 止语Lab