news 2026/7/16 17:20:34

查询优化案例集:从线上故障到性能跃升的实战复盘‌

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
查询优化案例集:从线上故障到性能跃升的实战复盘‌

查询优化案例集:从线上故障到性能跃升的实战复盘‌

你有没有过那种凌晨三点被电话惊醒的经历?电话那头运维同事急得声音都在抖,说线上数据库CPU直接冲到百分之百,所有业务接口全部超时,整个系统彻底瘫痪。你迷迷糊糊爬起来打开监控,看到慢查询日志里几十条一模一样的SQL在疯狂执行,每条都要跑几十秒,瞬间睡意全无。

我做数据库优化这几年,这种场景遇到过不下十次。很多时候线上的性能灾难,根本不是什么高深的分布式架构问题,就是一条写得很糟糕的SQL引发的连锁反应。而大部分糟糕的SQL,都不是凭空出现的,背后藏着开发同学对数据库执行逻辑的误解、业务迭代留下的历史包袱,还有早期数据量小的时候埋下的隐患。今天这篇文章,我就把三个印象最深的线上查询优化案例完整复盘出来,没有空泛的理论,全是踩过坑、流过汗的真实经历,看完你下次遇到类似问题,至少能少走三天弯路。

一、千万级表分页优化:从三十秒到两百毫秒的逆袭

第一个案例发生在去年的电商大促期间,凌晨两点我接到紧急告警,订单后台的所有查询接口全部超时,数据库CPU使用率直接拉满到百分之百。我登上服务器一看,慢查询日志里全是同一条分页SQL,执行时间最长的达到了三十七秒。

这条SQL的业务逻辑很简单,运营同事要在后台分页查询所有订单,按创建时间倒序排列,支持按订单状态、用户手机号、支付方式筛选。原始的SQL写法是非常经典的OFFSET大分页:

sql

SELECT order_id, order_sn, user_id, order_amount, create_time, order_status

FROM orders

WHERE order_status = '已支付'

ORDER BY create_time DESC

LIMIT 100000, 20;

当时订单表里已经有一千二百万条数据,这条SQL要跳过十万条记录,再取后面的二十条。很多人都知道OFFSET大分页慢,但很少有人能说清楚它到底为什么慢。我用EXPLAIN看执行计划,发现虽然create_time字段上建了索引,但MySQL还是选择了全表扫描,扫描行数达到了一千二百万行。

为什么放着索引不用?我仔细分析了优化器的选择逻辑。如果直接走create_time的索引,因为是倒序排列,MySQL需要从最新的订单开始往前扫,扫到十万零二十条的时候,才能拿到最后要返回的二十条数据。但这十万条数据里,大部分订单状态都不是“已支付”,MySQL要不断地回表查询判断订单状态,这个过程的随机IO开销非常大。优化器评估下来,觉得全表扫描反而更快,于是直接放弃了索引,选择了全表扫描。一千二百万行的全表扫描,再加上排序操作,CPU直接被打满就不奇怪了。

很多人遇到这个问题,第一反应是把SQL改成子查询的写法,先在子查询里用索引查出order_id,再关联主表拿数据。但我试了之后发现,这种写法在有筛选条件的场景下,性能提升非常有限。因为子查询里还是要先扫十万条索引记录,开销依然很大。

真正的优化思路,必须跳出“OFFSET跳过数据”的逻辑。既然我们是按create_time倒序分页,那么每一页的最后一条记录,都有一个最小的create_time值,我们完全可以利用这个值作为下一页的查询条件,避免跳过前面的十万条数据。这种优化方式业内叫“书签分页”或者“键集分页”。

我先给表建了一个符合筛选条件的联合覆盖索引,把order_status、create_time、order_id三个字段组合起来:

sql

ALTER TABLE orders ADD INDEX idx_status_time_id (order_status, create_time DESC, order_id);

这个索引的好处是,所有order_status为“已支付”的记录,在索引里已经按create_time倒序排好了,MySQL可以直接从索引的头部开始取数据,完全不需要排序,也不需要回表。然后我把原来的分页SQL改写成基于上一页最后一条记录的查询方式:

sql

SELECT order_id, order_sn, user_id, order_amount, create_time, order_status

FROM orders

WHERE order_status = '已支付'

AND create_time < '上一页最后一条记录的create_time'

ORDER BY create_time DESC

LIMIT 20;

改完之后,我再用EXPLAIN看执行计划,type变成了range,扫描行数直接降到了二十行,Extra里显示Using index,完全走覆盖索引,不需要回表。原来三十多秒的查询,现在两百毫秒以内就能返回结果。

但这里还有一个细节要处理:如果同一秒内有多个订单,create_time的值完全相同,直接用create_time作为书签会出现重复或者漏数据的问题。这时候可以把order_id也加进去,作为第二排序条件,保证排序的绝对唯一性。最终优化后的SQL是这样的:

sql

SELECT order_id, order_sn, user_id, order_amount, create_time, order_status

FROM orders

WHERE order_status = '已支付'

AND (create_time < '上一页最后一条记录的create_time'

OR (create_time = '上一页最后一条记录的create_time'

AND order_id < '上一页最后一条记录的order_id'))

ORDER BY create_time DESC, order_id DESC

LIMIT 20;

这个优化方案上线之后,大促期间的订单后台分页接口再也没有出现过超时,数据库CPU使用率直接降到了百分之二十以下。后来我把这个方案推广到了所有后台分页场景,整个系统的慢查询数量直接减少了百分之六十。

很多人觉得大分页优化只能靠分库分表,但实际上在千万级数据量下,只要跳出OFFSET的思维定式,用书签分页的方式,配合精心设计的联合覆盖索引,完全可以把分页性能提升两个数量级,根本不需要急着上复杂的分库分表方案。

二、多表关联优化:从二十秒到一秒的JOIN逻辑重构

第二个案例来自一个用户画像分析系统,业务方反馈用户消费统计报表加载特别慢,每次导出数据都要等二十多秒,运营同事经常要加班等报表生成。我拿到对应的SQL一看,里面有五张表做关联,逻辑非常复杂,开发同学为了图省事,直接把所有表用LEFT JOIN连在一起,没有做任何关联顺序的控制。

原始的SQL大概是这样的:

sql

SELECT u.user_id, u.nickname,

COUNT(o.order_id) AS total_order,

SUM(o.order_amount) AS total_consume,

COUNT(c.coupon_id) AS used_coupon,

SUM(p.points) AS total_points

FROM users u

LEFT JOIN orders o ON u.user_id = o.user_id

LEFT JOIN coupons c ON u.user_id = c.user_id AND c.status = '已使用'

LEFT JOIN points p ON u.user_id = p.user_id AND p.type = '消费获得'

WHERE u.register_time >= '2024-01-01'

GROUP BY u.user_id;

当时users表有两百万数据,orders表有一千五百万数据,coupons表有八百万数据,points表有一千万数据。我用EXPLAIN看执行计划,发现MySQL的优化器选择了users表作为驱动表,然后依次关联orders、coupons、points三张表。但问题出在,orders表上的user_id索引区分度非常低,很多用户有几十上百条订单记录,关联之后中间结果集膨胀到了几千万行,后续的GROUP BY操作不得不创建临时表,排序开销大得惊人。

很多开发同学不知道,MySQL的嵌套循环连接算法,本质上是“驱动表拿一行数据,去被驱动表里找匹配的记录”。驱动表的选择直接决定了关联的总开销:驱动表的行数越少,被驱动表上的关联字段有索引,整个关联的性能就越高。如果驱动表选得不对,中间结果集疯狂膨胀,性能直接就崩了。

我仔细分析了业务逻辑,发现这个报表的核心需求是统计2024年之后注册的新用户的消费数据,这些新用户总共只有二十万,只占users表总数据量的十分之一。原来的SQL写法,是先把所有用户关联所有订单、优惠券、积分记录,最后再做GROUP BY统计,相当于做了大量无用的计算。

正确的优化思路,是把大表的聚合操作下推到各自的子查询里,先在子查询里按user_id完成聚合,把每个用户的统计结果先算出来,得到一个只有二十万行的小结果集,再用这个小结果集去做关联。这样关联的时候,每张表的中间结果集都只有二十万行,完全不会出现膨胀的问题。

我把SQL彻底重构,把每个大表的统计逻辑都拆成独立的子查询:

sql

SELECT u.user_id, u.nickname,

IFNULL(o.total_order, 0) AS total_order,

IFNULL(o.total_consume, 0) AS total_consume,

IFNULL(c.used_coupon, 0) AS used_coupon,

IFNULL(p.total_points, 0) AS total_points

FROM users u

LEFT JOIN (

SELECT user_id, COUNT(order_id) AS total_order, SUM(order_amount) AS total_consume

FROM orders

WHERE create_time >= '2024-01-01'

GROUP BY user_id

) o ON u.user_id = o.user_id

LEFT JOIN (

SELECT user_id, COUNT(coupon_id) AS used_coupon

FROM coupons

WHERE status = '已使用' AND use_time >= '2024-01-01'

GROUP BY user_id

) c ON u.user_id = c.user_id

LEFT JOIN (

SELECT user_id, SUM(points) AS total_points

FROM points

WHERE type = '消费获得' AND create_time >= '2024-01-01'

GROUP BY user_id

) p ON u.user_id = p.user_id

WHERE u.register_time >= '2024-01-01';

同时我给每个子查询的表都建了对应的联合覆盖索引:orders表建(user_id, create_time, order_amount),coupons表建(user_id, status, use_time),points表建(user_id, type, create_time, points)。所有子查询的聚合操作都可以直接在覆盖索引里完成,不需要回表,也不需要额外排序。

优化之后,报表的执行时间从二十多秒直接降到了一秒以内,运营同事导出报表再也不用加班等待。这个案例让我深刻意识到,多表关联优化的核心,从来不是加几个索引那么简单,而是要控制中间结果集的大小,把大的聚合操作下推,让每一步关联的结果集都尽可能小。很多复杂的慢查询,只要把逻辑拆解开,把大操作拆成小操作,性能就能得到质的提升。

三、IN子查询优化:从十五秒到三百毫秒的逻辑改写

第三个案例来自一个社交平台的消息通知系统,用户反馈消息列表加载很慢,经常要等十几秒才能看到新消息。我查到对应的SQL,发现里面有一个IN子查询,里面嵌套了另一个子查询,开发同学本来是想简化代码,结果写出了一个性能灾难。

原始的SQL是这样的:

sql

SELECT msg_id, sender_id, content, create_time

FROM messages

WHERE receiver_id = 12345

AND is_deleted = 0

AND sender_id IN (

SELECT user_id FROM friends

WHERE owner_id = 12345 AND status = '已通过'

)

ORDER BY create_time DESC

LIMIT 50;

当时messages表有八百万条数据,friends表有五百万条数据。我用EXPLAIN看执行计划,发现MySQL把这个IN子查询做了半连接优化,但优化的方式非常糟糕:它先把friends表里符合条件的所有用户ID查出来,生成一个临时表,然后遍历messages表的所有记录,去和这个临时表做匹配。相当于messages表做了全表扫描,扫描行数达到了八百万行,查询时间超过了十五秒。

很多开发同学都遇到过IN子查询慢的问题,网上很多教程会告诉你把IN改成JOIN就好了,但不是所有场景下直接改JOIN都能解决问题。我一开始直接把IN改成了INNER JOIN,结果发现性能并没有明显提升,因为关联之后还是要扫描大量数据。

我仔细分析了业务逻辑,发现这个查询的本质是:查询当前用户收到的、来自已通过好友的最新五十条未删除消息。原来的SQL写法,是先筛选好友,再去消息表里匹配,相当于把筛选条件的顺序搞反了。消息表里receiver_id等于当前用户的记录,总共只有三万条,远小于好友表的总数据量。如果我们先从消息表里把这三万条记录查出来,再去和好友表做关联,扫描的行数会少得多。

我重新调整了查询逻辑,先在消息表里用覆盖索引快速拿到当前用户的最新五十条消息,再去好友表里判断发送者是不是好友,这样完全不需要扫描整个消息表。最终改写后的SQL是这样的:

sql

SELECT m.msg_id, m.sender_id, m.content, m.create_time

FROM (

SELECT msg_id, sender_id, content, create_time

FROM messages

WHERE receiver_id = 12345 AND is_deleted = 0

ORDER BY create_time DESC

LIMIT 100

) m

WHERE EXISTS (

SELECT 1 FROM friends

WHERE owner_id = 12345

AND user_id = m.sender_id

AND status = '已通过'

)

ORDER BY m.create_time DESC

LIMIT 50;

同时我给messages表建了联合覆盖索引(receiver_id, is_deleted, create_time DESC, msg_id, sender_id, content),给friends表建了联合索引(owner_id, status, user_id)。改写之后,子查询m只需要从消息表里取最新的一百条记录,然后用这一百条记录去好友表里做存在性判断,整个查询的扫描行数不到两百行,执行时间直接降到了三百毫秒以内。

这里我特意把子查询里的LIMIT设成了100,而不是50,是因为这一百条消息里可能有一部分发送者不是好友,多取一些可以保证最终筛选出来的有效好友消息能拿到五十条。这个小细节既保证了业务逻辑正确,又把性能开销控制到了最低。

这个案例给我的启发是,很多子查询慢的根本原因,不是IN关键字本身有问题,而是优化器选择了错误的驱动顺序,把大表当成了驱动表。这时候不要迷信优化器的自动选择,手动调整查询逻辑,把小结果集作为驱动表,往往能得到远超预期的性能提升。

这三个案例都是我在不同项目里真实遇到的线上故障,它们有一个共同的特点:没有用到任何高深的技术,没有上昂贵的硬件升级,也没有做复杂的分库分表,只是通过读懂执行计划,理清数据之间的关联逻辑,调整SQL的执行顺序,加上几个精心设计的索引,就把查询性能提升了几十甚至上百倍。

很多开发同学遇到慢查询,第一反应是加缓存、上中间件、做分库分表,却忽略了最基础的SQL本身的优化。实际上大部分线上的性能问题,根源都出在SQL写得不合理上。掌握查询优化的思路,不是为了应付面试,而是真的能在关键时刻帮你保住系统的稳定性,帮你在凌晨三点不用熬夜加班处理故障。我希望这些真实的复盘案例,能让你下次遇到慢查询的时候,不再手足无措,能冷静地一步步定位问题,最终找到最优的解决方案。

💡注意:本文所介绍的软件及功能均基于公开信息整理,仅供用户参考。在使用任何软件时,请务必遵守相关法律法规及软件使用协议。同时,本文不涉及任何商业推广或引流行为,仅为用户提供一个了解和使用该工具的渠道。

你在生活中时遇到了哪些问题?你是如何解决的?欢迎在评论区分享你的经验和心得!

希望这篇文章能够满足您的需求,如果您有任何修改意见或需要进一步的帮助,请随时告诉我!

感谢各位支持,可以关注我的个人主页,找到你所需要的宝贝。

博文入口:山峰哥-CSDN博客复制到【浏览器】打开即可,宝贝入口:常用软件宝贝:精品文件

作者郑重声明,本文内容为本人原创文章,纯净无利益纠葛,如有不妥之处,请及时联系修改或删除。诚邀各位读者秉持理性态度交流,共筑和谐讨论氛围~

发现夸克网盘链接

是否打开?
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/16 17:17:56

Dijkstra算法Java实战:从邻接矩阵到邻接表的性能跃迁

1. 初识Dijkstra算法&#xff1a;从生活场景到代码实现 想象你站在一个陌生的城市地铁站&#xff0c;手里拿着一张复杂的线路图&#xff0c;需要找到去目的地的最快路线。这种寻找最短路径的问题&#xff0c;正是Dijkstra算法的用武之地。作为解决 单源最短路径问题 的经典算…

作者头像 李华
网站建设 2026/7/16 17:16:25

Windows下Claude Code原生CLI安装与深度配置指南

1. 项目概述&#xff1a;这不是一个“AI插件”&#xff0c;而是一套原生集成的智能开发工作流Windows系统上安装配置Claude Code&#xff0c;绝不是简单下载个.exe双击运行就完事的事。我从去年底开始在三个不同规模的开发团队里落地这套工具&#xff0c;从最初把它当成VS Code…

作者头像 李华
网站建设 2026/7/16 17:14:49

bibisco小说写作软件:终极开源工具助你轻松创作

bibisco小说写作软件&#xff1a;终极开源工具助你轻松创作 【免费下载链接】bibisco Novel writing software 项目地址: https://gitcode.com/gh_mirrors/bi/bibisco 你是否正在寻找一款功能强大、完全免费的开源小说写作软件&#xff1f;bibisco正是你梦寐以求的创作工…

作者头像 李华
网站建设 2026/7/16 17:14:45

BLHeli开源电调固件终极指南:从零开始掌握无人机电机控制

BLHeli开源电调固件终极指南&#xff1a;从零开始掌握无人机电机控制 【免费下载链接】BLHeli BLHeli for brushless ESC firmware 项目地址: https://gitcode.com/gh_mirrors/bl/BLHeli BLHeli是一款专为无刷电机电调设计的开源固件&#xff0c;为航模爱好者和无人机开…

作者头像 李华