Py学习  »  DATABASE

MySQL 上亿数据表优化后,接口不卡了,系统也稳了

鸭哥聊Java • 8 月前 • 204 次点击  

MySQL 真的是一门“老少皆宜”的数据库,用得久了就知道,它最大的优点不是性能,而是皮实耐用。但有时候,这种“皮实”也可能给你带来灾难——尤其当你面对的是一个上亿条数据的大表。

前段时间,我们项目中的一个 MySQL 实例每天凌晨都会报 SLA 告警,说主从延迟严重。查了一圈,发现罪魁祸首竟然是一个自动清理历史数据的定时任务。而这个任务每天都在硬刚上亿条数据的 arrival_record 表,搞得 MySQL 主从都喘不过气。

你没听错,每晚执行的 delete 操作让我们的数据库差点“原地升天”。

慢查询里的“罪魁祸首”

用 pt-query-digest 把一周的 mysql-slow.log 啪一扔,输出结果让我有点上头。慢查询总耗时 25403 秒,最慢的 SQL 执行了 266 秒,平均每条慢查询执行了 5 秒,还平均扫描了 1766 万行数据。

其中,select 和 delete 操作针对的都是一个叫 arrival_record 的表。

我们抓了其中最频繁的 select,看起来很简单:

select count(*) 
from arrival_record 
where product_id=26 
  and receive_time between '2019-03-25 14:00:00' and '2019-03-25 15:00:00' 
  and receive_spend_ms>=0;

但别被这“平平无奇”的 SQL 语句骗了,它单次扫描的数据有时候能飙到 5600 万行,堪比扫一遍某些大厂的员工花名册。

执行计划一看,嘿,好家伙:

id: 1
select_type: SIMPLE
table: arrival_record
type: ref
key: IXFK_arrival_record
rows: 32261320
filtered: 3.70
Extra: Using index condition; Using where

扫了三千多万行!

那这个表长什么样?我也去看了眼表结构,果然没让我失望:

KEY IXFK_arrival_record (product_id, station_no, sequence, receive_time, arrival_time)

一个五字段的复合索引,但 select 的条件只有 product_id 和 receive_time,连 station_no 都没带,那复合索引自然也就派不上太大用场了。

而且 product_id 这字段选择性巨差,基数低得可怜,唯一能救命的 receive_time 又是排在索引的第四位,你说它能快到哪里去?

不甘心就只能背锅,我们决定“动刀”

既然复合索引没用上,我们直接重新设计索引结构。

抓了一波 select 语句的真实 where 条件,发现很多都是这样:

where sequence='2019-03-27 08:40' 
  and product_id=24 
  and station_no='V4205'

OK,这说明 sequence 和 station_no 是高频字段,而且这些字段组合起来的过滤能力还不错。于是我们决定重建复合索引,从原来的:

(product_id, station_no, sequence, receive_time, arrival_time)

变成:

idx_product_id_sequence_station_no (product_id, sequence, station_no)

并且给 receive_time 单独建一个索引:

idx_receive_time (receive_time)

重建索引之前当然不能贸然直接上生产,搞坏了领导第一个找你喝茶。

我们先用 mydumper 把生产表备份出来,压缩之后从 48G 缩到了 1.2G,我真是头一次佩服这工具的压缩能力。然后在测试环境还原、删除旧索引、建新索引,一顿操作猛如虎。

使用 online ddl 和 pt-osc 分别测试重建索引,最终发现 online ddl 更快,比 pt-osc 节省了将近一半时间,给我们上线打了个强心针。

再看执行计划,一秒钟感受到优化的魅力

加了新的索引之后,我立刻跑了同样的 SQL:

explain select count(*) 
from arrival_record 
where product_id=26 
  and receive_time between '2019-03-25 14:00:00' and '2019-03-25 15:00:00' 
  and receive_spend_ms>=0;

结果:

key: idx_receive_time
rows: 291448
Extra: Using index condition; Using where

扫描行数直接从三千万降到二十九万!

这优化效果,简直是脱胎换骨,比中年男人做完体检后突然决定每天跑五公里还要励志。

delete 语句的另一条命:小批量删除

虽然 select 优化了,但 delete 依旧像个憨憨,每晚那几十秒的大删除一来,主从立马开始喘气。我们尝试给 receive_time 加上索引,效果确实好了一些,但单次删除的记录数一多,执行时间还是猛涨。

后来痛定思痛,我们决定彻底改变删除策略,用“小步快跑”的方式解决大问题。

从之前:

delete from arrival_record where receive_time '2019-03-01';

变成现在:

SELECT MAX(idINTO @need_delete_max_id 
FROM arrival_record 
WHERE receive_time '2019-03-01';

DELETE FROM arrival_record 
WHERE id < @need_delete_max_id 
LIMIT 20000;

每次删 20000 条,删完等 0.5 秒,然后继续删下一批。应用层判断 row_count 是否为 0,是就停,不是就继续删。现在的效果是:

  • 每次 delete 执行时间 < 1 秒
  • 再也没有主从延迟告警
  • DBA 的头发也少掉了几根

这才是对大表 delete 最优雅的打开方式。

一点体会

整个过程干下来,我最大的体会就是:MySQL 的问题,索引八成能解决,剩下两成交给“削峰填谷”策略。

而作为开发,我们不能一味依赖 DBA 和运维,尤其是像 Spring Boot + MyBatis Plus 这种 ORM 框架,很容易一不小心就把全表扫给写进代码里。

记得我改完那段 SQL 后,专门在代码里加了点注释,提醒后人一句:

// ❗❗❗ 慎重修改该 SQL,数据量非常大,依赖 idx_receive_time 索引!
// 改了别忘去 explain 一下!!!

写代码就像打牌,能赢的前提是你知道手里的牌是什么,哪些好打,哪些别碰。MySQL 里也是一样,写 SQL 之前最好心里清楚索引长什么样。

再啰嗦两句

如果你也是在维护一个数据量爆炸的系统,建议记住这几条:

  1. 慢查询日志一定要开pt-query-digest 会是你最好的朋友。
  2. 复合索引不是越多越好,别让框架自动建了你就用,得看条件能不能覆盖。
  3. delete 别一次性梭哈,你不是在双十一抢手机,数据库也需要喘气。
  4. 主从延迟=命门,主库出事切换到从库,延迟一大,切换失败,掉线出事故分分钟。

写到这里我都忍不住给自己倒杯咖啡,毕竟这场“大表优化之战”,赢得实在不容易。

最后,我为大家打造了一份deepseek的入门到精通教程,完全免费: https://www.songshuhezi.com/deepseek


同时,也可以看我写的这篇文章《DeepSeek满血复活,直接起飞!》来进行本地搭建。

对编程、职场感兴趣的同学,可以链接我,微信:yagebug  拉你进入“程序员交流群”。
🔥鸭哥私藏精品 热门推荐🔥

鸭哥作为一名老码农,整理了全网最全《Java高级架构师资料合集》
资料包含了《IDEA视频教程》《最全Java面试题库》、最全项目实战源码及视频》及《毕业设计系统源码》总量高达  650GB 。全部免费领取!全面满足各个阶段程序员的学习需求。

Python社区是高质量的Python/Django开发社区
本文地址:http://www.python88.com/topic/180795