一、接手老项目:被现实狠狠上了一课
几年前,我接手了公司内部的项目管理系统。这系统跑了三年,用户从几十人涨到三千多,数据量跟着爆炸 ------ 任务表 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万行!
问题诊断:
- SELECT * 回表灾难 :任务表有 30 个字段,索引只包含
create_time
,每次查询都要回表取所有字段,IO 爆炸。 - OR 条件索引失效 :
project_id
和assignee_id
都没有索引,MySQL 只能全表扫描,再用内存过滤数据。 - 子查询嵌套:层层嵌套的子查询,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.id
,assignee_id
是tasks
表的字段,和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 个关联字段(负责人姓名、项目名称、项目状态),却查了所有字段,网络传输和内存解析都耗时严重。
优化方案:
- *拒绝 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主键索引,秒级返回
-
给 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_id
和status
都没有索引,这条 SQL 扫描了全表 1000 万条数据(虽然实际符合条件的只有 2000 条),InnoDB 无法使用行锁,退化成表锁 ,所有对tasks
表的读写都被阻塞。
优化步骤:
- 给过滤条件加组合索引:
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`。
-
分批次更新,控制每次更新量 :
即使有了索引,一次更新 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提前查好符合条件的记录数
-
避免在事务中做无关操作 :
原来的事务里还包含了记录操作日志、发送通知等逻辑,现在把数据库更新和业务逻辑分离:
-
先快速完成数据库更新(500 条 / 次,事务耗时 < 10ms)
-
再通过消息队列异步处理日志和通知
优化后效果:批量更新不再阻塞系统,运维小哥第二天发来消息:"昨晚终于没熬通宵,谢了兄弟,奶茶安排!"
五、分库分表?小公司别盲目跟风,单表优化才是王道
当任务表涨到 5000 万条时,领导说:"要不要分库分表?隔壁部门都搞了!" 我冷静分析:
-
先做单表优化:
- 垂直拆分 :把
description
(长文本)、attachment_urls
(JSON 数组)等不常用字段移到tasks_extra
表,主表字段从 30→15,索引体积减少 60%。 - 删除冗余索引:之前为了优化不同查询加了 12 个索引,现在精简到 5 个高频使用的组合索引,写入性能提升 30%。
- 调整 InnoDB 参数 :增大
innodb_buffer_pool_size
(从 2G→4G,占服务器内存 70%),缓存更多数据页,命中率从 70%→92%。
- 垂直拆分 :把
-
读写分离缓解读压力 :
搭建主从复制,把导出、统计等读操作路由到从库,主库专注写操作,成本几乎为 0(用现成的服务器),扛住了 3 倍读流量。
-
分表时机:当单表超过 1 亿条,且单库磁盘 IO 成为瓶颈时再考虑 :
现在 5000 万条,单表优化后读写性能依然稳定,分库分表带来的跨表查询复杂度、分布式事务、全局 ID 生成等问题,反而会增加维护成本。领导听了觉得有道理:"行,先把现有优化做好,别搞虚的!"
六、八年总结:这五条黄金法则比任何教程都有用
- 慢查询日志是第一战场 :
每次优化先开慢查询日志,用EXPLAIN + SHOW WARNINGS
分析执行计划,搞清楚是全表扫描、回表过多、索引失效 还是锁竞争,别凭感觉写 SQL。 - 索引不是越多越好,是 "刚刚好" 最好 :
给同事讲过最惨的案例:有个兄弟给每个字段都加了索引,结果写入性能暴跌 40%------ 因为每次插入 / 更新都要维护所有索引。正确做法:只给高频查询的字段加组合索引,覆盖常用查询场景 ,定期用SHOW INDEX
检查冗余索引。 - 批量操作记得 "分批次 + 事务控制" :
不管是导出、更新还是插入,一次性搞几万条数据就是找死!分批次处理(每次 1000-5000 条),每个批次一个小事务,减少锁持有时间,避免把 InnoDB 变成 "锁表大师"。 - 和业务 "狼狈为奸" 比技术优化更有效 :
比如导出功能,和产品经理商量后增加 "字段筛选""时间范围默认近 30 天",用户实际导出的数据量减少 80%,比任何 SQL 优化都管用。技术是为业务服务的,别自己闭门造车! - 小公司别迷信 "分库分表" 银弹 :
90% 的性能问题都能通过单表优化(索引、SQL 写法、事务设计)+ 读写分离解决。分库分表是 "核武器",带来的复杂度远超想象,先把基础打牢,数据量没到千万级别别轻易碰。
结语:优化的本质是 "理解你的数据"
这半年的优化历程,让我最深的感悟是:MySQL 优化不是炫技,而是理解业务对数据的操作模式。你得知道用户怎么查、怎么改、什么时候量大、什么字段最关键,就像老中医看病,得 "望闻问切"------
-
望:看慢查询日志、EXPLAIN 执行计划
-
闻:听业务需求,知道哪些操作是高频、哪些是低频
-
问:问 DBA 数据库配置、问运维服务器性能瓶颈
-
切:切中要害,从最影响用户体验的地方开始优化
现在,项目管理系统的性能终于稳了:导出秒级响应,详情页瞬间加载,批量操作不再卡顿,连老板都说:"现在审批任务快多了,你们技术部这次立了大功!"
其实,哪有什么大功,不过是踩了无数坑后,终于学会了和 MySQL "好好说话"------ 用它喜欢的方式写 SQL,给它合适的索引,别让它做无用功。毕竟,数据库就像女朋友,你对它好,它才会对你好,对吧?(笑)
希望我的实战经验能帮到正在调优路上挣扎的你,记住:优化没有标准答案,只有最适合你业务场景的解法。干就完了,遇到问题别慌,慢慢分析,总有解决的办法!