从被测试小姐姐追着怼到运维小哥点赞:我在项目管理系统的 MySQL 优化实战

一、接手老项目:被现实狠狠上了一课

几年前,我接手了公司内部的项目管理系统。这系统跑了三年,用户从几十人涨到三千多,数据量跟着爆炸 ------ 任务表 1000 万条,用户表 50 万,项目表 10 万。本以为 "内部系统随便搞搞",结果第一周就被测试小姐姐堵在茶水间:"导出任务列表要 10 分钟?用户都快把键盘敲烂了!"

当晚试运行,果然卡成 PPT:

  • 导出功能一选 "全选项目" 就转圈,浏览器直接假死

  • 查看某个复杂项目的任务详情页,加载 5 秒才出数据

  • 下午批量更新 2000 条任务状态,整个系统直接卡死 1 分钟,运维小哥冲过来:"你是不是把库锁了?!"

行,看来得动真格了。作为八年 Java 老鸟,咱别的不会,调优还是有点套路的 ------ 先从慢查询日志开始挖。

二、导出功能卡成 PPT?慢查询日志揪出 "罪魁祸首"

场景:每周五用户导出全部门任务报表,筛选条件:

"项目属于 A 部门或负责人是张三" + "近一年创建" + "包含所有字段"

第一步:抓慢 SQL

打开慢查询日志(设置long_query_time=1秒),导出功能的 SQL 赫然在列:

sql 复制代码
SELECT * FROM tasks 
WHERE (project_id IN (SELECT id FROM projects WHERE department='A部门') 
       OR assignee_id=(SELECT id FROM users WHERE name='张三')) 
  AND create_time >= '2023-06-01'
ORDER BY update_time DESC 
LIMIT 0, 5000;  -- 看似有分页,实际扫描了800万行!

问题诊断

  1. SELECT * 回表灾难 :任务表有 30 个字段,索引只包含create_time,每次查询都要回表取所有字段,IO 爆炸。
  2. OR 条件索引失效project_idassignee_id都没有索引,MySQL 只能全表扫描,再用内存过滤数据。
  3. 子查询嵌套:层层嵌套的子查询,MySQL 优化器根本玩不转,变成串行执行。

优化三步走:

1. 干掉 SELECT *,只查需要的字段(和前端小姐姐 battle 后,确定导出只需要 10 个核心字段):

sql 复制代码
SELECT id, task_name, status, project_id, assignee_id, create_time, update_time, priority, deadline, description 
FROM tasks 
WHERE (project_id IN (12, 34, 56)  -- 提前查好A部门的project_id,避免子查询
       OR assignee_id=123)  -- 张三的user_id是123,写死避免子查询
  AND create_time >= '2023-06-01'
ORDER BY update_time DESC 
LIMIT 0, 5000;

2. 给查询条件加组合覆盖索引

scss 复制代码
CREATE INDEX idx_task_export ON tasks(assignee_id, project_id, create_time, update_time, 
                                     status, priority, deadline, description);

(注意:按查询顺序排序,把区分度高的字段放前面,create_time这种范围查询放最后)

3. 导出分批次,前端做 "加载更多"(和产品经理磨了 2 小时,用户其实能接受分页导出):

每次导出最多 5000 条,用LIMIT offset, 5000,但 offset 超过 10 万后变慢?改用书签分页

sql 复制代码
-- 上一次导出最后一条的update_time是'2024-05-30 15:00:00'
SELECT ... 
WHERE update_time < '2024-05-30 15:00:00'  -- 利用索引快速定位
ORDER BY update_time DESC 
LIMIT 5000;

优化后效果:导出 5000 条数据从 12 秒→1.2 秒,测试小姐姐说:"现在可以边导出边摸鱼了,好评!"

三、详情页加载像蜗牛?索引设计的坑比代码里的 BUG 还多

场景:查看某个任务详情(包含负责人姓名、项目名称),接口响应时间 5 秒 +

对应的 SQL 是:

vbnet 复制代码
SELECT t.*, u.name AS assignee_name, p.name AS project_name 
FROM tasks t 
LEFT JOIN users u ON t.assignee_id = u.id 
LEFT JOIN projects p ON t.project_id = p.id 
WHERE t.id = 12345;  -- 任务ID是主键,按道理应该很快啊?

EXPLAIN 分析发现问题

  • 主键查询很快(id=12345走主键索引),但LEFT JOIN 全走了全表扫描

    因为users表虽然有 10 万条,但assignee_id=12345确实存在,为啥还全表扫描?

    哦!原来users表的主键是user_id,但这里用t.assignee_id = u.idassignee_idtasks表的字段,和users表的id是外键关系,但users表没有为id加索引? 不,users.id是主键,本身就有索引啊!

等等,搞错了!LEFT JOIN 的驱动表是tasks,对于每条tasks记录,去users表找u.id = t.assignee_id,这时候users.id是主键,应该走主键索引啊? 但 EXPLAIN 显示users表的访问类型是const(主键命中),那为啥还是慢?

哦,破案了!不是 JOIN 慢,是SELECT *太慢! tasks表 30 个字段,users表 20 个,projects表 15 个,一次查询返回 65 个字段,大部分是冗余的!前端其实只需要 3 个关联字段(负责人姓名、项目名称、项目状态),却查了所有字段,网络传输和内存解析都耗时严重。

优化方案:

  1. *拒绝 SELECT ,只查必要字段
sql 复制代码
SELECT 
  t.id, t.task_name, t.status, 
  u.name AS assignee_name, 
  p.name AS project_name, p.status AS project_status 
FROM tasks t 
LEFT JOIN users u ON t.assignee_id = u.id  -- u.id是主键,天然有索引
LEFT JOIN projects p ON t.project_id = p.id  -- p.id是主键,天然有索引
WHERE t.id = 12345;  -- 走tasks主键索引,秒级返回
  1. 给 JOIN 字段加索引(虽然主键已有索引,但确认一下是否需要冗余索引)

    其实这里不需要,因为主键索引已经是最有效的索引。但如果是非主键的外键字段 ,比如tasks表的assignee_id不是主键,只是普通字段,就需要加索引:

sql 复制代码
ALTER TABLE tasks ADD INDEX idx_assignee_id (assignee_id);  -- 让JOIN更快找到关联记录

优化后效果:详情页加载从 5 秒→300ms,运维小哥路过瞅了眼:"哟,这次没把连接池占满,不错啊!"

四、批量更新踩了表锁大坑:凌晨三点被运维 call 醒的教训

场景:项目经理批量标记 2000 条任务为 "已完成",执行:

ini 复制代码
BEGIN TRANSACTION;
UPDATE tasks SET status='DONE', update_time=NOW() 
WHERE project_id=123 AND status='DOING';  -- 以为很简单,结果整个系统卡住!
COMMIT;

当晚 11 点,刚躺下就接到运维电话:"数据库连接数爆了!全是等待锁的事务!"

紧急登录服务器,用SHOW ENGINE INNODB STATUS查看,发现大量LOCK WAIT,锁类型是表锁

问题分析
project_idstatus都没有索引,这条 SQL 扫描了全表 1000 万条数据(虽然实际符合条件的只有 2000 条),InnoDB 无法使用行锁,退化成表锁 ,所有对tasks表的读写都被阻塞。

优化步骤:

  1. 给过滤条件加组合索引
go 复制代码
CREATE INDEX idx_project_status ON tasks(project_id, status); 
-- 注意顺序:把区分度低的`project_id`(一个项目可能有10万条任务)放前面,`status`(只有几种状态)放后面?  
不,反过来!因为`status='DOING'`区分度更高(只有20%的数据是DOING),应该把**等值查询的字段放前面**,范围查询放后面。但这里两个都是等值,所以按查询顺序:`(project_id, status)`,因为SQL里先写`project_id`。
  1. 分批次更新,控制每次更新量

    即使有了索引,一次更新 2000 条数据,锁持有时间还是太长。改成每次更新 500 条,循环 4 次

ini 复制代码
// Java代码示例,用LIMIT分批
int batchSize = 500;
int offset = 0;
do {
    String sql = "UPDATE tasks SET status='DONE', update_time=NOW() " +
                 "WHERE project_id=123 AND status='DOING' " +
                 "LIMIT " + batchSize + " OFFSET " + offset;
    offset += batchSize;
    // 每次更新后提交事务,释放锁
    executeUpdateAndCommit(sql);
} while (offset < totalCount);  // totalCount提前查好符合条件的记录数
  1. 避免在事务中做无关操作

    原来的事务里还包含了记录操作日志、发送通知等逻辑,现在把数据库更新和业务逻辑分离

  • 先快速完成数据库更新(500 条 / 次,事务耗时 < 10ms)

  • 再通过消息队列异步处理日志和通知

优化后效果:批量更新不再阻塞系统,运维小哥第二天发来消息:"昨晚终于没熬通宵,谢了兄弟,奶茶安排!"

五、分库分表?小公司别盲目跟风,单表优化才是王道

当任务表涨到 5000 万条时,领导说:"要不要分库分表?隔壁部门都搞了!" 我冷静分析:

  1. 先做单表优化

    • 垂直拆分 :把description(长文本)、attachment_urls(JSON 数组)等不常用字段移到tasks_extra表,主表字段从 30→15,索引体积减少 60%。
    • 删除冗余索引:之前为了优化不同查询加了 12 个索引,现在精简到 5 个高频使用的组合索引,写入性能提升 30%。
    • 调整 InnoDB 参数 :增大innodb_buffer_pool_size(从 2G→4G,占服务器内存 70%),缓存更多数据页,命中率从 70%→92%。
  2. 读写分离缓解读压力

    搭建主从复制,把导出、统计等读操作路由到从库,主库专注写操作,成本几乎为 0(用现成的服务器),扛住了 3 倍读流量。

  3. 分表时机:当单表超过 1 亿条,且单库磁盘 IO 成为瓶颈时再考虑

    现在 5000 万条,单表优化后读写性能依然稳定,分库分表带来的跨表查询复杂度、分布式事务、全局 ID 生成等问题,反而会增加维护成本。领导听了觉得有道理:"行,先把现有优化做好,别搞虚的!"

六、八年总结:这五条黄金法则比任何教程都有用

  1. 慢查询日志是第一战场
    每次优化先开慢查询日志,用EXPLAIN + SHOW WARNINGS分析执行计划,搞清楚是全表扫描、回表过多、索引失效 还是锁竞争,别凭感觉写 SQL。
  2. 索引不是越多越好,是 "刚刚好" 最好
    给同事讲过最惨的案例:有个兄弟给每个字段都加了索引,结果写入性能暴跌 40%------ 因为每次插入 / 更新都要维护所有索引。正确做法:只给高频查询的字段加组合索引,覆盖常用查询场景 ,定期用SHOW INDEX检查冗余索引。
  3. 批量操作记得 "分批次 + 事务控制"
    不管是导出、更新还是插入,一次性搞几万条数据就是找死!分批次处理(每次 1000-5000 条),每个批次一个小事务,减少锁持有时间,避免把 InnoDB 变成 "锁表大师"。
  4. 和业务 "狼狈为奸" 比技术优化更有效
    比如导出功能,和产品经理商量后增加 "字段筛选""时间范围默认近 30 天",用户实际导出的数据量减少 80%,比任何 SQL 优化都管用。技术是为业务服务的,别自己闭门造车!
  5. 小公司别迷信 "分库分表" 银弹
    90% 的性能问题都能通过单表优化(索引、SQL 写法、事务设计)+ 读写分离解决。分库分表是 "核武器",带来的复杂度远超想象,先把基础打牢,数据量没到千万级别别轻易碰。

结语:优化的本质是 "理解你的数据"

这半年的优化历程,让我最深的感悟是:MySQL 优化不是炫技,而是理解业务对数据的操作模式。你得知道用户怎么查、怎么改、什么时候量大、什么字段最关键,就像老中医看病,得 "望闻问切"------

  • :看慢查询日志、EXPLAIN 执行计划

  • :听业务需求,知道哪些操作是高频、哪些是低频

  • :问 DBA 数据库配置、问运维服务器性能瓶颈

  • :切中要害,从最影响用户体验的地方开始优化

现在,项目管理系统的性能终于稳了:导出秒级响应,详情页瞬间加载,批量操作不再卡顿,连老板都说:"现在审批任务快多了,你们技术部这次立了大功!"

其实,哪有什么大功,不过是踩了无数坑后,终于学会了和 MySQL "好好说话"------ 用它喜欢的方式写 SQL,给它合适的索引,别让它做无用功。毕竟,数据库就像女朋友,你对它好,它才会对你好,对吧?(笑)

希望我的实战经验能帮到正在调优路上挣扎的你,记住:优化没有标准答案,只有最适合你业务场景的解法。干就完了,遇到问题别慌,慢慢分析,总有解决的办法!

相关推荐
HelloWord~1 小时前
SpringSecurity+vue通用权限系统2
java·vue.js
让我上个超影吧1 小时前
黑马点评【基于redis实现共享session登录】
java·redis
00后程序员1 小时前
提升移动端网页调试效率:WebDebugX 与常见工具组合实践
后端
HyggeBest1 小时前
Mysql的数据存储结构
后端·架构
TobyMint2 小时前
golang 实现雪花算法
后端
G探险者2 小时前
【案例解析】一次 TIME_WAIT 导致 TPS 断崖式下降的排查与优化
后端
BillKu2 小时前
Java + Spring Boot + Mybatis 插入数据后,获取自增 id 的方法
java·tomcat·mybatis
全栈凯哥2 小时前
Java详解LeetCode 热题 100(26):LeetCode 142. 环形链表 II(Linked List Cycle II)详解
java·算法·leetcode·链表
chxii2 小时前
12.7Swing控件6 JList
java
寒山李白2 小时前
MySQL复杂SQL(多表联查/子查询)详细讲解
sql·mysql·子查询·多表联查