凌晨两点半,告警群里突然炸了——某个核心报表查询的耗时从 200ms 飙升到 30 秒,数据库 CPU 直接打满,紧接着一大片业务接口开始超时。这种场景对做后端和数据库运维的人来说,应该都不陌生。很多同学这时候的第一反应是“这条 SQL 太慢了,加个索引吧”,但真正的问题往往不在一条 SQL 上,而是一整条链路上多个环节共同作用的结果。SQL 调优从来不是碰运气式地加索引、改写法,而是从索引设计、SQL 书写、执行计划分析到高并发场景下的系统级治理,一整套可以复现、可以量化、可以验证的方法论。
这篇文章我就结合自己做过的真实案例,把 SQL 调优的全链路梳理一遍。内容主要面向有一定开发经验、经常会和数据库打交道,但还没系统整理过调优思路的同学。文章会从慢 SQL 的定位说起,再到索引设计的核心原则,接着是 SQL 写法的实操优化,最后落到高并发场景下数据库的整体治理策略。每部分都会给到可以直接落地的方案和参数,也会把我在实际踩坑中总结的教训放进去,希望能帮你少走一些弯路。
1. 慢 SQL 从哪里来:别急着改 SQL,先把瓶颈定位准
很多调优失败的项目,问题不在于优化手段不对,而在于一开始就把方向搞错了。拿到一条慢 SQL,先别急着加索引或者改写法,应该先回答三个问题:这条 SQL 是偶发慢还是持续慢?瓶颈在数据库内部还是在应用与数据库之间的链路上?是单条 SQL 慢拖累了整体,还是整体负载过高导致这条 SQL 变慢?
这三个问题看似基础,但能把它们彻底搞清楚的人并不多。我见过不少案例,表面上是某条 SQL 执行计划走了全表扫描,优化师加完索引后效果立竿见影,结果过了两周同样的告警又来了,因为业务量增长后,另一个环节又成了瓶颈。没有全局的定位,就没有真正的调优。
1.1 从慢查询日志开始:找到那些“慢性子”SQL
数据库本身提供了最直接的排查入口,就是慢查询日志。以 MySQL 为例,在配置文件中开启慢日志并设置阈值是第一步:
ini复制slow_query_log = 1
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 1
log_queries_not_using_indexes = 1
这里 long_query_time = 1 表示执行时间超过 1 秒的 SQL 都会被记录,log_queries_not_using_indexes 会额外记录那些没有走索引的查询。这个阈值可以根据业务情况调整,如果业务本身比较简单、查询普遍在 50ms 以内,建议把阈值设成 0.5 秒,尽早暴露问题;如果业务负载本身比较重,可以先用 2 秒观察几天,再逐步收紧。
拿到慢日志后,推荐优先关注两类 SQL:执行频率高但单次耗时中等的 SQL(比如 100ms 到 500ms),和执行频率低但单次耗时极高的 SQL(比如超过 5 秒)。前者的总耗时贡献往往比后者更大,属于“温水煮青蛙”型问题;后者则容易引发连接池耗尽等连锁故障。
注意:慢日志不是开得越久越好。长时间开着全量慢日志会带来额外的 IO 开销,生产环境建议用
pt-query-digest之类的工具定期分析慢日志,并把历史日志归档清理,没必要一直留存原始文件。
1.2 执行计划:读懂了才知道 SQL 慢在哪一步
慢日志只能告诉你“谁慢”,要搞清楚“为什么慢”,必须看执行计划。MySQL 里最常用的就是 EXPLAIN:
sql复制EXPLAIN SELECT o.order_id, u.nickname, o.amount
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.status = 1
ORDER BY o.created_at DESC
LIMIT 20;
执行计划里需要重点关注的字段有这几个:
| 字段 | 含义 | 你需要警惕什么 |
|---|---|---|
| type | 访问类型 | 如果出现 ALL(全表扫描),基本可以断定索引没走对 |
| key | 实际使用的索引 | 为 NULL 说明没有可用索引 |
| rows | 预估扫描行数 | 这个值和实际扫描行数差距过大,说明统计信息可能过期 |
| filtered | 过滤比例 | 值越低,说明扫描了大量无用数据 |
| Extra | 附加信息 | 出现 Using filesort、Using temporary 说明排序和分组没有利用好索引 |
举个例子,我排查过一条分页查询,业务方反馈“第 100 页以后加载明显变慢”。EXPLAIN 显示查询虽然走了索引,但 Extra 里有 Using filesort,进一步分析发现是 ORDER BY 的字段和 WHERE 条件用的索引不是同一个,导致每次都要把中间结果集拉到内存排序。后来调整了复合索引的字段顺序,让 WHERE 和 ORDER BY 共用一个索引,排序的额外开销就消失了。
一定要记住一个原则:执行计划里的预估行数和实际执行耗时一定要配合看。我见过 rows 显示 1 万行但实际执行了 3 秒的 SQL,root cause 是表统计信息长期未更新,优化器低估了数据量,选错了驱动表。这种情况下,单纯从 SQL 层面优化收效甚微,ANALYZE TABLE 刷一下统计信息可能就解决了。
1.3 从应用到数据库的全链路视角
有些慢 SQL 查了很久才发现,SQL 本身执行只要 20ms,但加上网络往返、连接池排队、ORM 框架映射,整个接口就变成了 1 秒。这种问题从慢日志和执行计划里都看不出来,必须把视角拉到全链路。
以 Java 应用为例,我一般会从三层去排查:
- 应用的数据库连接池状态:连接池是否已经打满?是否有大量线程在等待获取连接?Druid 或 HikariCP 的监控指标里,
activeCount和waitCount是核心指标。 - 数据库侧的连接与线程状态:
SHOW PROCESSLIST能直接看到当前正在执行的 SQL,如果发现大量Waiting for table metadata lock,可能就是业务系统里有未提交的长事务锁住了元数据。 - 网络层与中间件:如果应用和数据库之间还有 Proxy(如 ProxySQL、MyCat、ShardingSphere),还要关注中间件本身的 CPU、内存和连接转发延迟。
有一个典型的全链路问题:应用侧的 ORM 配置了 Batch Fetching,看起来是一条接口请求,实际批量发出了几十条查询语句。这时候慢 SQL 日志里可能一条超阈值的 SQL 都找不到,但数据库的 QPS 却高得离谱。排查这类问题,一定要结合 APM 工具里的 SQL 调用链来定位,而不是死盯着单条慢 SQL。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 索引设计:好索引是调优的地基,但设计错误反而拖垮性能
索引是 SQL 调优里最重要的单点因素,没有之一。但我也要泼一盆冷水:索引不是越多越好,设计不合理的索引,轻则浪费写入性能,重则让优化器选错执行计划,反而把快查询拖成慢查询。
索引设计的核心不是“给查询字段加索引”,而是为具体的查询模式设计合理的索引结构。设计之前,先把表上的核心查询列出来,分析每个查询的 WHERE、JOIN、ORDER BY、GROUP BY 字段,找到它们之间的组合关系,再决定怎么建索引。
2.1 复合索引的列顺序:不只是区分度的事
复合索引的列顺序是索引设计里最容易被忽视、也最容易出错的点。很多同学知道“最左前缀原则”,以为只要把常用查询字段放到左边就行。实际还远远不够。
举个例子,有一张订单表,常见的查询模式有两个:
- 按
user_id+status查询订单列表 - 按
user_id+created_at查询某段时间内的订单
有人会分别建 (user_id, status) 和 (user_id, created_at) 两个索引,这当然没错,但如果这两个查询都很频繁,可以评估是不是建一个 (user_id, status, created_at) 的复合索引就能同时覆盖。这里的关键是:第二个查询的条件 user_id 固定时,(user_id, status, created_at) 也能通过 user_id 定位到具体用户,再通过 created_at 过滤时间范围,并不需要 status 参与。
列顺序的具体原则,我会按这样的优先级来权衡:
- 等值条件优先:WHERE 里
=的字段放在最前面,这类字段能最大程度缩小扫描范围。 - 区分度高的列放在区分度低的列之前:比如
user_id区分度远高于status,一般把user_id放前面。但这不是绝对的,如果某个查询几乎总是传固定的status,那status放前面反而能减少索引树的比较开销。 - 范围条件(>、<、BETWEEN)尽量放在后面:因为范围条件之后的列无法走索引匹配合并,范围条件本身也只用到索引的一部分。
- 排序字段和分组字段尽量纳入索引:如果查询里有
ORDER BY created_at而索引里正好包含created_at,就能避免Using filesort。
关于热词里提到的 between and 用法,这里多说一句:BETWEEN ... AND ... 在 MySQL 里等价于 >= 和 <= 的组合,在索引使用上是典型的范围条件。如果一个复合索引是 (a, b, c),查询条件 WHERE a = 1 AND b BETWEEN 10 AND 20 AND c = 3,索引只能用到 a 和 b 两部分,c 的等值条件无法直接利用索引定位,需要在回表后过滤。理解了这一点,你就知道为什么复合索引里范围字段要尽量靠后放了。
2.2 覆盖索引与回表:一次查询的 IO 能少一次是一次
InnoDB 的二级索引叶子节点只保存索引字段和主键值,如果查询需要的字段不在索引里,就要根据主键回表聚簇索引再取一次数据。回表不是灾难,但回表次数一多,IO 成本就上来了。
覆盖索引的思路是:把查询需要的字段都放进索引里,让二级索引直接提供全部数据,避免回表。
比如有一个常见的查询:
sql复制SELECT user_id, order_no, status
FROM orders
WHERE user_id = 1001 AND status = 1;
如果建立一个 (user_id, status, order_no) 的复合索引,那么这条查询就可以做到索引覆盖,不需要回表。注意这里我把 order_no 放到了索引末尾,因为它不参与 WHERE 过滤,但参与 SELECT 返回,放进去就能让查询免于回表。
覆盖索引的收益在统计类查询上尤其明显。比如需要统计每个用户待支付订单的数量:
sql复制SELECT user_id, COUNT(*) FROM orders WHERE status = 0 GROUP BY user_id;
如果能建一个 (status, user_id) 的索引,这条查询在扫描索引时就能完成 WHERE 过滤和 GROUP BY 分组,整个查询都不碰数据行,在高并发场景下对磁盘 IO 的节省非常可观。
当然,覆盖索引不是越多越好,尤其不要把特别大的字段(比如很长的 VARCHAR、TEXT)塞进索引。那样索引体积会急剧膨胀,叶子节点页能容纳的记录数变少,扫描和写入成本都会上升。
2.3 索引失效的常见场景:改一行代码索引就废了
建了索引不代表优化器就会用它。我梳理了几个高频的索引失效场景,每个都在实际工作中踩过:
- 对索引字段使用函数:
WHERE DATE(created_at) = '2024-01-01'会让created_at上的索引失效。正确的写法是WHERE created_at >= '2024-01-01 00:00:00' AND created_at < '2024-01-02 00:00:00',这样既能用上索引,也不会漏数据。 - 隐式类型转换:字段是 VARCHAR,查询条件却传了数字,比如
WHERE phone = 12345678901。MySQL 会把字段转成数字再比较,索引就废了。这类问题在核对接口入参时尤其要小心。 - LIKE 前缀模糊匹配:
LIKE '%keyword'无法走索引,LIKE 'keyword%'可以。热词里有人问 SQL 怎么去重查询,其实DISTINCT也是一样的逻辑,如果去重字段上有索引,DISTINCT走索引会比较高效,反之则会在临时表上做去重。 - OR 条件中有一个字段没索引:比如
WHERE a = 1 OR b = 2,如果a有索引而b没有,优化器可能选择全表扫描。解决办法是把OR拆成两个查询用UNION ALL合并,或者给b也加上索引。这里要注意热词里提到的“SQL 当中同时有 AND 和 OR 的执行顺序”:AND的优先级高于OR,所以WHERE a = 1 OR b = 2 AND c = 3实际执行的是a = 1 OR (b = 2 AND c = 3),如果业务本意是(a = 1 OR b = 2) AND c = 3,一定要加上括号,否则不仅结果可能错,索引使用也会受影响。 - 对索引字段做运算:
WHERE price * 1.1 > 100,索引失效。改写为WHERE price > 100 / 1.1就能恢复索引有效性。
排查索引是否失效最直接的方式还是 EXPLAIN,看 type 字段是否从 ref/range 退化成 ALL,以及 key 是否为 NULL。
2.4 冗余索引清理:一堆“看似有用”的索引正在拖慢写入
生产环境里还有一种常见问题:历史同事为了某次临时需求加了个索引,后来需求下线了,索引却留了下来。索引不是免费午餐,每次 INSERT、UPDATE、DELETE 都要额外维护索引结构,索引越多,写入放大越严重。
我处理过一个订单表,表上有 12 个索引,其中两个索引 (user_id, status) 和 (user_id) 明显重复——前者已经能覆盖后者的功能,后者就是冗余索引。删掉冗余索引后,写操作的延迟下降了约 15%。
清理索引的正确姿势是:
- 用
sys.schema_unused_indexes(MySQL 8.0)或performance_schema查询长期未使用的索引。 - 结合慢日志和业务代码,确认哪些索引真的没用。
- 先在测试环境删掉冗余索引,跑一遍全量回归用例,再灰度到生产。
注意:即使索引长期未被
EXPLAIN引用,也不代表完全没用。有些查询量很小的低频接口可能几个月才跑一次,但关键时刻必须依赖那个索引。清理前一定要和业务方确认。
3. SQL 写法层面的高性价比优化:不动表结构也能提升几个量级
索引设计完了,接下来看 SQL 本身。很多性能问题的根源其实不在索引,而是 SQL 写法本身就不够“数据库友好”。这一节我会挑一些实用性最强的优化点,每个都是可以直接套用到代码里的。
3.1 让优化器省点劲:正确使用 JOIN、IN 与 EXISTS
JOIN、IN、EXISTS 的选用一直是争论话题,但很多结论放到具体场景里才有意义。我的经验是:
- 小表驱动大表:无论是 JOIN 还是 IN,尽量让小结果集做驱动表。MySQL 的优化器在多数情况下会自动选择小表驱动大表,但使用
STRAIGHT_JOIN可以强制指定驱动顺序。不过我不建议一上来就用STRAIGHT_JOIN,除非你确认优化器的选择是错的。 - IN 适合子查询结果集较小且外层表较大的情况:比如
WHERE user_id IN (SELECT user_id FROM vip_users WHERE expire_time > NOW()),vip 用户数量通常远小于总用户数,IN有优势。 - EXISTS 适合外层表较小、子查询结果集很大的情况:因为
EXISTS是边查边判断,只要子查询找到一条满足条件的记录就会停止。 - JOIN 表数量尽量控制在三张以内:超过三张表的 JOIN 会让优化器的计算空间急剧膨胀,也更容易产生中间结果集,难以预估性能。这时候需要考虑的是:是不是该把数据冗余到一张宽表里了?
这里可以提一句热词里的“sql with as 用法”:WITH AS(公共表表达式)能把复杂的子查询逻辑拆成可读性更好的多个步骤,也方便同一结果集被多处引用。但要注意,WITH AS 创建的临时结果集不是每次都能被优化器物化复用,在某些版本下如果同一个 CTE 被多次引用,可能被执行多次。所以用 WITH AS 的主要收益是逻辑清晰,性能提升还得看执行计划。
3.2 分页深翻页的破局:延迟关联比 LIMIT 大偏移更靠谱
深翻页是 Web 业务里最典型的性能杀手之一。LIMIT 100000, 20 看起来只取 20 条,但数据库要先把前 100000 行扫描出来再丢弃,扫描成本完全浪费。
有两个常用的优化方案:
方案一:延迟关联
sql复制SELECT t.id, t.title, u.nickname
FROM blog t
JOIN users u ON t.user_id = u.id
JOIN (
SELECT id FROM blog
WHERE status = 1
ORDER BY created_at DESC
LIMIT 100000, 20
) tmp ON t.id = tmp.id;
核心逻辑是:先用一个尽可能轻量的查询(只查主键 id)把目标行定位出来,再通过主键 JOIN 回原表取完整数据。由于定位查询走的是覆盖索引,不涉及回表,深翻页的代价会大幅降低。
方案二:基于游标的分页
如果场景允许(比如 App 端下拉加载),可以直接用上次查询的最后一条记录的排序字段作为过滤条件:
sql复制SELECT id, title, created_at
FROM blog
WHERE status = 1 AND created_at < '2024-06-01 10:00:00'
ORDER BY created_at DESC
LIMIT 20;
这种方案每次查询都是固定的索引范围扫描,不管翻到第几页,性能都是一样的。缺点是没法跳页,但对于无限滚动的信息流场景,这恰恰是最合理的交互方式。
3.3 批量操作的正确姿势:把循环单条改成批量提交
热词里多次提到“批量调优”,这也是很多后端系统最常见的性能问题来源。比如业务代码里用 for 循环执行 1000 次单条 INSERT,每次 INSERT 都要经历一次 SQL 解析、执行计划和事务提交。改成批量插入后,耗时能下降一到两个数量级。
以 MySQL 为例,批量插入最常见的写法是:
sql复制INSERT INTO orders (user_id, order_no, amount, status, created_at)
VALUES
(1001, 'A001', 99.00, 0, NOW()),
(1002, 'A002', 129.00, 0, NOW()),
(1003, 'A003', 39.00, 0, NOW());
这里有几个实际经验:
- 单条批量的大小不是越大越好:一次插入 1000 行通常是性能甜点区。超过 5000 行后,单个事务过大反而会导致 binlog 变大、主从复制延迟增加。
- 批量 UPDATE 要注意条件的稳定性:如果批量更新涉及不同条件,建议拆成多个语句并独立评估执行计划。
- DELETE 大批量数据要分批加 LIMIT:直接
DELETE FROM log_table WHERE created_at < '2023-01-01'会锁大量行,甚至拖垮主库。推荐循环执行DELETE FROM log_table WHERE created_at < '2023-01-01' LIMIT 1000;直到影响行数为 0。
热词里有人提到的“并行 sql 优化”,本质上也是批量思想的一种延伸。如果多个查询之间没有依赖关系,可以用并发线程同时执行,把串行时间压缩成并行时间。要控制并发度,不要把数据库的 IO 和 CPU 直接打满。
3.4 排序与分组:临时表不是你想要的答案
当 EXPLAIN 里出现 Using temporary 或 Using filesort 时,意味着优化器无法直接从索引里拿到有序数据,需要在内存或磁盘上额外操作。
优化排序和分组的核心思路是:让索引天然提供有序结果。ORDER BY 和 GROUP BY 的字段如果能和 WHERE 条件组合成一个复合索引,并且顺序匹配,优化器就能直接从索引树遍历出结果,连排序都省了。
比如这样的查询:
sql复制SELECT user_id, status, COUNT(*) AS cnt
FROM orders
WHERE created_at >= '2024-01-01'
GROUP BY user_id, status
ORDER BY user_id, status;
如果建立一个 (created_at, user_id, status) 的复合索引,WHERE 用 created_at 过滤后,GROUP BY 和 ORDER BY 的数据顺序基本就是索引顺序,临时表排序的代价可以大幅消除。
另外,DISTINCT 在某些场景下也可以用 GROUP BY 替代,但这取决于优化器对两者的处理逻辑。多数数据库对 DISTINCT 和 GROUP BY 的处理是等价的,没有绝对优劣。关键是看字段组合上有没有合适的索引,有索引的情况下两种写法都很快,没有索引的情况下两种写法都会在临时表上执行去重或分组操作。
4. 高并发场景的系统级调优:单条 SQL 优化之后,才是真正的开始
当单条 SQL 的执行计划已经优化得接近理论最优,QPS 依然扛不住时,问题就从“SQL 调优”升级成了“系统调优”。高并发场景下的数据库治理,核心不是让每条 SQL 变得更快,而是让数据库在有限的计算资源下处理更多请求。这需要从连接管理、流量控制、读写分离和上下游协同几个维度综合设计。
4.1 连接池与超时:把数据库从“连接风暴”里救出来
高并发下最常见的故障不是 SQL 本身慢,而是连接池被慢 SQL 拖垮。
假设应用连接池最大连接数是 200,突然有 50 个慢 SQL 各占 3 秒,那么这 200 个连接很快耗竭,后续所有请求都进入等待。数据库端看到的现象就是连接数被打满、线程全部卡在执行慢 SQL,新的查询连不上。
解决方案要从两端同时入手:
应用端:
- 设置合理的连接池大小,HikariCP 推荐
maximumPoolSize = 10起始,再根据需要调整。连接池不是越大越好,每个连接都占用数据库的线程和内存资源。 connectionTimeout不要设置过长,一般 3 秒以内。宁可快速失败,也不要让请求无限排队。validationTimeout和leakDetectionThreshold用来检查连接泄漏,防止慢查询把连接带崩。
数据库端:
- 设置
max_connections上限,并留出监控告警的空间。 innodb_lock_wait_timeout和lock_wait_timeout要显式设置,避免事务锁等待无限挂起。- 慢查询隔离:如果业务场景允许,可以把复杂的报表查询、离线统计查询放到独立的只读实例或者专门的从库上,避免它们和线上核心链路争抢连接和 IO。
我在实践中最大的感触是:高并发场景下,比“SQL 调优”更紧急的是“故障隔离”。先把慢查询关进笼子里,再慢慢优化调优,系统稳定是第一优先级。
4.2 读写分离与多级缓存:让数据库少干活才是最大的优化
一个数据库实例的写能力和读能力是共享同一份计算资源的。业务发展到一定规模后,读写分离几乎是必经之路。
读写分离的落地有一个前提:业务能接受一定程度的主从复制延迟。读从库的数据可能比主库慢几十毫秒甚至更多。很多团队在这个问题上栽过跟头:刚写入的数据读不到,用户刷新就看不到了。
我的实践建议是:
- 核心强一致读走主库:比如支付结果、订单状态变更后的立即查询。
- 弱一致读走从库:比如用户浏览记录、内容列表、统计报表。
- 用中间层路由:在 DAO 层或数据访问中间件上通过注解或配置实现读写分离,避免每个业务方自己判断该走哪个库。
除了读写分离,缓存是另一个更激进的“让数据库少干活”的手段。高并发场景下,热点数据的命中直接决定数据库的存活率:
- 缓存预热:活动开始前就把热点数据预先写入 Redis,避免活动启动瞬间大量请求穿透到数据库。
- 缓存击穿与穿透:单个热点 key 过期瞬间的大量请求、或者查询不存在的数据的恶意请求,都要在缓存层做保护。可以针对空值做短暂缓存(比如 30 秒),也可以使用限流组件限制单 key 的并发回源。
- 缓存雪崩:大量 key 同时过期会导致数据库瞬时压力。解决方式是给缓存过期时间加随机抖动,比如
base + random(0, 300)秒。
缓存不是用来替代 SQL 调优的,它和 SQL 调优是互为补充的关系。索引优化解决的是“查询怎么走更快”,缓存解决的是“能不能根本不查数据库”。高并发压测环境下,我一般会先保证 SQL 本身的执行效率,再通过缓存把热点流量挡在数据库外面,二者叠加才能扛住真正的峰值流量。
4.3 从 SQL 调优到上下游协同:消息队列与批量落库的取舍
高并发场景还有一个常被忽略的维度:上游流量怎么进数据库。不少系统在数据库层面优化了半天,流量一上来照样扛不住,问题出在上游没有做削峰填谷。
热词里提到“kafka 高并发消息处理办法”,这里正好可以展开说明。消息队列(Kafka、RocketMQ)的核心价值是把突发的峰值流量先承接住,让下游按可控的速度消费。落到数据库层面,关键技术点在于消费端如何高效落库。
我见过两种典型的错误消费模式:
- 每条消息单独 INSERT:消费者一条消息落一次库,在高吞吐下数据库写入压力巨大。
- 消费频率过高:每秒钟 flush 一次批数据,造成大量小事务提交。
正确的做法是批量攒批 + 周期性提交:
- 在消费者内存里攒够一批消息(比如 500 条),一次性批量 INSERT。
- 或者每隔固定时间(比如 2 秒)触发一次批量落库。
- 事务大小要控制在合理区间,避免单事务过大。采用“500 条或 2 秒哪个先到就提交”的策略。
另外,高并发下要尽量把“实时强一致”的请求变成“最终一致”。比如一个下单流程,如果要求每一步都实时写库和读库,数据库压力会非常大。但如果我们把订单创建、支付回调、库存扣减等步骤拆分到不同的消息队列里异步处理,主链路只保留必要的写操作,数据库的峰值压力会被大幅摊平。
4.4 冷热数据分离:让热数据活在内存里
最后聊聊数据架构层的调优。当一张表的数据量达到亿级、甚至十亿级以后,单纯靠加索引已经很难根治性能问题——因为即使走索引,每个索引页从磁盘加载到内存的代价也会越来越大。
冷热数据分离的常规做法是:
- 归档历史数据:把 3 个月前的订单数据迁移到独立的归档库或历史表。线上热表只保留近期数据,数据量骤减,索引体积也会小很多,缓存命中率显著提升。
- 按时间分表:比如按月分表或者按年分表,查询条件里带上时间范围时,直接路由到对应分表。这里要注意,分表策略一定要和业务查询模式匹配,如果业务主要按用户维度查询,按用户 ID 取模分表通常比按时间分表更合理。
- 数据生命周期管理:定义清晰的数据冷热标准和迁移任务,避免手动 SQL 搬数据的高风险操作。
我参与过一个项目,订单主表数据量过亿,日增几十万条。所有常规索引优化、缓存优化都用上了,还是时不时出现告警。后来把 6 个月前的数据归档到独立库,热表数据量降到了三千万以内,查询性能立刻回升了两个量级。冷热分离不是数据库 DBA 专属的手段,后端开发在系统设计阶段就该把数据生命周期考虑进去。
高并发场景下的 SQL 调优,本质上是一场“全局资源调度”的实战。从索引设计到 SQL 写法,再到连接池、缓存、读写分离、消息队列、冷热数据治理,每层都可能是瓶颈所在。你在做调优时,最忌讳的就是眉毛胡子一把抓。我的建议是先定位瓶颈层,再逐层优化,每做一步都要有明确的指标对比(响应时间、QPS、扫描行数、临时表数量等),看看是否真正解决了当下的问题。
拿我自己来说,做了这么多年的性能优化,最大的体会是:SQL 调优到后期,拼的已经不是 SQL 技巧本身了,而是对业务的理解和对数据链路的全局把握。一个索引设计得再巧妙,如果业务的数据访问模式变了,一样会失效。所以保持对线上业务和数据指标的关注,比死记硬背各种优化规则重要得多。这也是为什么我建议所有做后端开发的同学,哪怕不专职做 DBA,也一定要自己动手在测试库上跑一遍 EXPLAIN,亲手验证一次索引从命中到失效的全过程——这种体感是任何文档都替代不了的。
