上周五晚上,我正在美滋滋地规划周末吃什么,突然钉钉响了。运营同事发来一张截图,说后台有个查询报表的接口超时了,让我赶紧看看。
我心里咯噔一下——这报表查询平时挺正常的啊,怎么会突然超时?打开监控一看,好家伙,执行时间直接飙到了28秒。这SQL是我半年前写的,当时测试的时候明明跑得挺快,怎么现在变成这样了?
带着一脸问号,我开始排查。然后我发现了一个让我肠子都悔青了的事实:这个SQL里有个JOIN,我一直以为它很简单,实际上它正在疯狂地拖慢我的数据库。
你以为的JOIN,可能是性能杀手
先给你们看看简化后的SQL长什么样:
SELECT u.name, o.amount, p.title
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
JOIN products p ON o.product_id = p.id
WHERE u.status = 1;
看起来很正常对吧?users表左join订单表,再inner join商品表。谁能想到这玩意儿能跑28秒?
EXPLAIN一看,我整个人都傻了。orders表扫描了整整80万行,users表倒是用上了索引,但问题出在products表——它没有索引,而且因为JOIN顺序的原因,驱动表选错了,导致了一场数据灾难。
很多人以为JOIN就是简单的表连接,画个箭头连起来就完事了。但实际上,JOIN的执行过程比你想象的要复杂得多。数据库需要决定用哪张表作为驱动表(driving table),用哪张表作为被驱动表(driven table),选择哪个索引,以及采用哪种JOIN算法(Nested Loop Join、Hash Join还是Sort Merge Join)。每一个选择都直接影响查询性能。
索引不是你想加,想加就能加
说到索引,我发现一个很有意思的现象:很多开发者在建表的时候,压根儿不会主动去想索引的事。只有当查询变慢了,才会想起来要加索引。这种被动式的索引管理,迟早要出问题。
给大家看个真实的例子:
-- 用户表
CREATE TABLE users (
id BIGINT PRIMARY KEY,
email VARCHAR(100),
status TINYINT DEFAULT 0,
created_at DATETIME
);
-- 订单表
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
user_id BIGINT,
amount DECIMAL(10,2),
status TINYINT DEFAULT 0
);
-- 给orders表的user_id加索引
CREATE INDEX idx_orders_user_id ON orders(user_id);
这个索引看起来没问题吧?但如果你经常这样查:
SELECT * FROM orders
WHERE user_id = 123
AND status = 1
ORDER BY created_at DESC
LIMIT 10;
那你可能需要的是一个联合索引:(user_id, status, created_at)。单独的user_id索引能加速过滤,但遇到范围查询(status)和排序(created_at)的时候,还是会回表,性能照样上不去。
那些年我们踩过的坑
说起来都是泪,我总结了几个最常见的SQL性能坑:
坑一:SELECT * 上瘾
这个我承认,我以前也喜欢SELECT *,觉得省事。但当你join了五六张表,SELECT *能把整个数据库都拖慢。定向取字段,让数据库少干活,这才是正解。
坑二:隐式类型转换
看看这段代码:
SELECT * FROM users WHERE id = '123';
id是BIGINT类型,但传进去的是字符串'123'。数据库需要对每一行的id做类型转换才能比较,这会导致索引失效,全表扫描。不要笑,这种代码我见过不止一次了。
坑三:分页的深渊
SELECT * FROM orders ORDER BY id LIMIT 1000000, 10;
当你开始用OFFSET做深度分页的时候,数据库跟你说拜拜了。它要先扫描前100万行,然后扔掉,只留最后10行。解决方案是用上一页最后一条记录的id做条件:WHERE id > last_id LIMIT 10。这才是正确的分页姿势。
怎么治?
说了这么多问题,该上解决方案了。
第一,养成用EXPLAIN的习惯。每次写完SQL,第一件事就是EXPLAIN看看执行计划。别等线上报警了再来补救,那黄花菜都凉了。
第二,了解你的数据。表有多少行?字段基数多大?查询频率如何?这些信息直接影响索引设计。没有最好的索引,只有最适合的索引。
第三,监控慢查询。MySQL的slow query log是个好东西,把那些执行时间超过1秒的SQL都记录下来,定期review,你会发现自己一直在犯同样的错误。
第四,适时考虑读写分离和分库分表。如果单库已经撑不住了,别硬扛,该拆就拆。但记住,这是最后手段,不是首选方案。很多时候,优化一下SQL比加机器管用多了。
最后说两句
回到开头那个故事。最后我是怎么解决的?加了个联合索引,改了查询方式,执行时间从28秒降到了40毫秒。28秒到40毫秒,将近700倍的提升,就靠一个索引。
技术这东西就是这样,有时候你以为的小问题,其实藏着大坑。那个看起来简单的JOIN,背后可能是一场性能灾难。等你踩过了,爬出来了,才会真正理解那句话:写SQL容易,写好SQL难。
好了,不说了,我要去吃周末的第一顿饭了。