线上系统一旦并发量上来,数据库往往是第一个扛不住的组件。很多团队的第一反应是加机器、换更贵的数据库实例,但实际上大部分高并发问题都源于几条写得不合理的SQL。本文通过一个真实的电商订单模块优化案例,讲清楚高并发场景下SQL性能问题的定位思路和解决方法,帮助你建立一套可复用的复杂查询优化思维。

一、问题定位:从现象到慢SQL
案例背景是一个日订单量百万级的电商平台,大促期间订单列表页响应时间从平时的200毫秒飙升到8秒以上,数据库CPU被打满,连接池频繁耗尽。遇到这类问题,第一步不是急着改代码,而是先找到罪魁祸首。我们通过两个手段快速锁定了慢SQL。
第一个手段是开启慢查询日志。MySQL中可以通过如下配置把执行超过1秒的SQL记录下来:
SET GLOBAL slow_query_log = ON; SET GLOBAL long_query_time = 1; SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
第二个手段是查询performance_schema和information_schema中的进程列表,观察当前正在执行的高消耗语句:
SELECT id, user, db, time, state, LEFT(info, 120) AS sql_head FROM information_schema.processlist WHERE command != 'Sleep' ORDER BY time DESC LIMIT 20;
两个手段结合后,我们发现占用时间最长的语句是订单列表页的核心查询:按用户ID加订单状态筛选,并按创建时间倒序分页。这条SQL单表数据量已经超过三千万行,而查询条件字段的组合方式导致优化器无法有效利用现有索引,最终走了全表扫描。全表扫描意味着每次查询都要读取大量无关数据页,在内存放不下时还会引发频繁的磁盘IO,高并发下多个这样的查询同时执行,buffer pool被反复冲刷,整个实例的性能都会被拖垮。
二、执行计划分析与索引优化
定位到慢SQL之后,下一步是用EXPLAIN查看执行计划,弄清楚数据库到底是怎么执行这条语句的:
EXPLAIN SELECT order_id, status, amount, create_time FROM t_order WHERE user_id = 88123 AND status IN (1, 2, 5) ORDER BY create_time DESC LIMIT 0, 20;
执行计划输出中,重点关注几个关键列。type列如果显示为ALL,说明是全表扫描;key列为NULL说明没有用到索引;rows列是预估扫描行数,如果接近表总行数,基本可以断定索引失效。这个案例中type正是ALL,扫描行数接近三千万,问题确凿。
解决方案是建立覆盖查询条件的联合索引。索引列的顺序有讲究:等值查询的user_id放在最前,排序列create_time放在最后,这样既能完成过滤又能天然满足排序要求,避免额外的文件排序操作:
ALTER TABLE t_order ADD INDEX idx_user_status_time (user_id, status, create_time);
这里需要理解最左前缀原则。联合索引相当于先把数据按第一列排序,相同值再按第二列排序,以此类推。查询条件必须从索引的最左列开始连续命中,后面的列才能被利用。如果查询只带了status而不带user_id,这个索引就用不上。另外,由于查询的四个字段全部包含在索引列中,这条查询还能形成覆盖索引,即直接从索引中返回数据而不需要回表查询主键索引,IO量进一步大幅减少。优化后该SQL的执行时间从平均5秒降到15毫秒以内。
索引不是越多越好。每多一个索引,写入时就要多维护一棵B+树,更新和插入的性能都会下降。像订单表这种写多读多的表,建议只保留高频查询路径对应的两三个索引,并且定期用sys.schema_unused_indexes之类的方式清理从未使用的冗余索引。
三、深分页与复杂查询的进一步优化
索引解决之后,运营后台导出报表时又暴露出深分页问题。当分页偏移量达到几十万时,即使有索引,LIMIT 500000, 20这种写法仍然要扫描并丢弃前50万行数据,性能急剧恶化。常见的优化方案是游标分页,也叫延迟关联法:
SELECT o.order_id, o.status, o.amount, o.create_time
FROM t_order o
INNER JOIN (
SELECT order_id FROM t_order
WHERE user_id = 88123
ORDER BY create_time DESC
LIMIT 500000, 20
) t ON o.order_id = t.order_id;
这个写法巧妙在子查询只走覆盖索引拿到20个主键值,避免了50万次回表,然后再用主键精确取出完整行数据,整体代价小得多。如果是面向用户的翻页场景,更推荐直接改成基于上一页最后一条记录位置的游标分页,即WHERE create_time < 上一页最后时间 LIMIT 20,这样每次都是常量级代价,无论翻到第几页性能都稳定。
复杂查询还有一个常见误区:一条巨型SQL里塞了多层子查询、多个JOIN和聚合函数,看起来一次交互搞定所有逻辑,实际执行计划极差且难以调优。遇到这类SQL,建议拆解思路——先把过滤性最强的条件落地成小结果集,再在这个小结果集上做关联和聚合。必要时可以把中间结果写入临时表,配合适当的索引分步执行。很多情况下,两条简单SQL的总耗时远低于一条 optimizer 无法优化的复杂语句。
四、架构层面的兜底手段
单条SQL优化到位后,如果并发量仍然超过单实例承载能力,就需要从架构上想办法。最常用的是读写分离:主库处理写请求,多个从库分担读请求,通过中间件或驱动层的路由规则自动分发。订单这类读多写少的场景,读写分离通常能带来数倍的吞吐提升。要注意的是主从延迟问题,对于用户自己下单后立刻查看订单的场景,这类查询必须强制走主库,否则可能出现订单刚创建却查不到的尴尬情况。
其次是对热点数据的缓存。订单列表页中用户维度、状态维度的统计数字,完全可以放到Redis中维护,用异步任务或binlog订阅来更新,查询时直接命中缓存,数据库压力瞬间下降一个数量级。此外还有分库分表方案,按用户ID做哈希分片,把单表三千万行拆成若干个几百万行的小表,单表索引高度降低,查询和维护成本都更可控。不过分库分表引入了分布式事务、跨片查询等复杂性,属于最后才动用的手段,建议在缓存和SQL优化都做扎实之后再考虑。
五、优化思维总结
回顾整个案例,可以提炼出一套通用的慢SQL优化流程:先用慢查询日志和进程列表定位问题语句,再用EXPLAIN分析执行计划找到扫描方式的问题,然后通过索引设计消除全表扫描和文件排序,接着处理深分页等特殊场景,最后在架构层面用读写分离、缓存和分片兜底。每一步都有明确的判断依据,而不是凭感觉加索引。
需要强调的是,优化前一定要有监控基线。没有量化指标就无法证明优化效果,也无法在性能回退时及时察觉。建议对核心SQL建立响应时间和扫描行数的监控面板,配合压测环境验证每一次索引变更。SQL优化本质上是理解数据访问路径的过程,掌握了执行计划的阅读方法和B+树索引的原理,面对任何慢查询都能有清晰的入手点。