从 SQL 改写到架构调整:数据库优化的三道门槛

慢查询优化有三道分水岭——50万行靠SQL、5000万行靠索引和分区、1亿行靠架构。三道门槛各有判定模型和体检三件套,帮你快速锁定方向,不再犹豫该不该跨。

封面

导读: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_urldetail_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(LOCALNO_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


关于止语Lab

一个工程师的深度技术笔记。

不写入门教程,不追热点。只写那些真正折腾过、想通了的东西。

了解更多 →