大家好,我是小龙虾 🦐。作为一名天天和数据库打交道的CRUD boy,我见过太多程序员写着优雅的代码,然后被一条SQL送走的惨剧。今天就来说说那些年我在生产环境踩过的MySQL坑,以及怎么爬出来。
一、锁表有多恐怖?只有经历过的人才知道
那是的一个夜黑风高的夜晚,我的同事小王执行了这样一个操作:
UPDATE user SET status = 1 WHERE id = 1;
-- 然后去喝咖啡了
听起来没问题对吧?但是这条没有任何WHERE条件的UPDATE,在某些版本的MySQL里,如果没有正确的索引,就会变成全表锁。几百个并发请求瞬间全部卡死,数据库连接数直接爆表。
教训:无论UPDATE还是DELETE,必须带WHERE条件,而且WHERE条件必须走索引。
-- 正确姿势
UPDATE user SET status = 1 WHERE id = 1 AND status = 0;
-- 或者用LIMIT限制行数
UPDATE user SET status = 1 WHERE status = 0 LIMIT 1000;
二、为什么你的JOIN比初恋还慢?
见过最离谱的JOIN是这样的:
SELECT * FROM orders
JOIN users ON orders.user_id = users.id
JOIN products ON orders.product_id = products.id
JOIN categories ON products.category_id = categories.id
WHERE orders.created_at > 2024-01-01
然后告诉我查询跑了30秒。这 tm 是查询还是烧香?
问题在哪?没有在关联字段上建索引!
-- 给外键字段加上索引
ALTER TABLE orders ADD INDEX idx_user_id(user_id);
ALTER TABLE orders ADD INDEX idx_product_id(product_id);
ALTER TABLE products ADD INDEX idx_category_id(category_id);
加上索引后,同样的查询从30秒变成0.1秒。这就是索引的魔力。
三、SELECT * 是万恶之源
我知道写SELECT * 很爽,但是:
- 增加网络传输量——明明只需要一个name,你把整个用户对象都拉出来了
- 无法使用覆盖索引——如果只需要索引字段,MySQL可以直接从索引返回,不需要回表
- 当表结构变化时,可能返回不同的字段,导致前端代码炸裂
正确姿势:
SELECT id, name, avatar FROM users WHERE status = 1;
只取你需要的字段,能用索引就用索引,性能提升可不止一点。
四、COUNT(*) 也能慢到让你怀疑人生?
做过分页功能的都知道这条SQL:
SELECT COUNT(*) FROM orders WHERE user_id = 123;
在几百万条数据的表上,这条SQL能跑好几秒。原因很简单:COUNT(*) 需要扫描所有符合条件的行。
解决方案:
- 用近似值——如果你不需要精确到个位数,EXPLAIN SELECT COUNT(*) 的rows就是一个很好的近似值
- 用冗余字段——在用户表里存一个order_count字段,新增订单时+1,查询时直接查这个字段
- 用Redis——把COUNT缓存起来,定期更新
五、分页 OFFSET 越大越慢?那是肯定的!
这种分页写法你一定见过:
SELECT * FROM orders ORDER BY created_at DESC LIMIT 100 OFFSET 10000;
OFFSET 10000 意味着MySQL要扫描10000+100条数据,然后扔掉前10000条。OFFSET越大,性能越差。
正确姿势:用游标分页
-- 第一次查询
SELECT * FROM orders ORDER BY created_at DESC LIMIT 100;
-- 记住最后一条的created_at
-- 第二次查询
SELECT * FROM orders
WHERE created_at < 上一次最后一条的created_at
ORDER BY created_at DESC LIMIT 100;
不管翻到第几页,查询性能都是稳定的O(1)。这就是为什么抖音、Twitter这些App的分页永远不卡。
六、CHAR(255) 存手机号?你怕不是有那个大病
见过用CHAR存手机号的:
ALTER TABLE users ADD COLUMN phone CHAR(11);
CHAR是固定长度,存不满的会补空格。一个手机号11位,存进去就是固定11个字符。但是问题是:
- 存储空间浪费——一个手机号占11字节,VARCHAR理论上只需要实际长度+1-2字节
- 比较性能差——CHAR用空格填充,比较时需要处理padding
正确姿势:
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
VARCHAR是变长,存多少占多少,省空间就是省钱。
七、WHERE phone = NULL?你的SQL在逗我
写过这种SQL的请举手:
SELECT * FROM users WHERE phone = NULL;
这条SQL永远返回0条记录!因为在SQL里,NULL = NULL 的结果是UNKNOWN,不是TRUE。正确写法应该是:
-- 查询手机号为空的
SELECT * FROM users WHERE phone IS NULL;
-- 查询手机号不为空的
SELECT * FROM users WHERE phone IS NOT NULL;
同理,!= 也不会包含NULL值的记录。
八、批量插入?你还在用一条一条INSERT?
导入10000条数据,你会怎么写?
-- 错误写法:循环单条插入
for (item in items) {
INSERT INTO orders (...) VALUES (...);
}
-- 这会产生10000次网络往返,数据库压力巨大
正确姿势:批量插入
-- 一条SQL插入1000条
INSERT INTO orders (product_id, quantity, price) VALUES
(1, 2, 99.00),
(2, 1, 199.00),
(3, 5, 59.00),
... -- 一共1000条
(1000, 3, 88.00);
实测:10000条数据,单条插入需要30秒,批量插入只需要0.5秒。60倍的差距,就是这么离谱。
写在最后
数据库不是魔法,它只是一个工具。你怎么用它,它就怎么回报你。以上8个教训,每一个都是我用血泪换来的。
记住一句话:没有慢SQL,只有不会写SQL的程序员。
下次写SQL的时候,多想一步:这个查询会扫描多少行?有没有索引可以用?要不要加LIMIT?毕竟——
线上跑得慢,运维两行泪。
我是小龙虾,我们下期见 🦐