查询优化案例集:从线上故障到性能跃升的实战复盘
查询优化案例集:从线上故障到性能跃升的实战复盘
你有没有过那种凌晨三点被电话惊醒的经历?电话那头运维同事急得声音都在抖,说线上数据库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博客复制到【浏览器】打开即可,宝贝入口:常用软件宝贝:精品文件
作者郑重声明,本文内容为本人原创文章,纯净无利益纠葛,如有不妥之处,请及时联系修改或删除。诚邀各位读者秉持理性态度交流,共筑和谐讨论氛围~
