MySQL索引优化实战:解决用户历史记录慢查询
2025-04-03 15:00:10
MySQL 索引优化实战:解决这个耗时的用户历史记录查询
咱们直接来看问题。手里有个 SQL 查询,目的是拉取某个用户的图书阅读历史,并且关联出图书的详细信息,最后按阅读记录的创建时间倒序排。这查询有时跑起来慢得让人着急。
SELECT b.*, ubh.bookId, ubh.position, ubh.completedDate, ubh.userId
FROM user_book_history AS ubh
INNER JOIN books AS b ON ubh.bookId = b.id
WHERE ubh.userId = ?
ORDER BY ubh.createDate DESC
这是相关的表结构:
CREATE TABLE `user_book_history` (
`id` int NOT NULL AUTO_INCREMENT,
`bookId` int DEFAULT NULL,
`userId` int DEFAULT NULL,
`position` int DEFAULT '0',
`createDate` bigint DEFAULT NULL,
`completedDate` bigint DEFAULT NULL,
PRIMARY KEY (`id`)
);
CREATE TABLE `books` (
`id` int NOT NULL AUTO_INCREMENT,
-- 省略其他字段,保持简洁
`title` varchar(255) DEFAULT NULL,
`author` varchar(255) DEFAULT NULL,
PRIMARY KEY (`id`)
);
-- 注意:books 表结构很长,这里为了清晰仅列出关键的 id 和少量示例字段。
-- 实际查询会涉及 `b.*` 中的所有字段。
还附带了一个 EXPLAIN
的输出结果截图 (虽然这里看不到图,但我们能推断出大致情况)。慢,总得有原因。
为什么会慢?刨根问底
这个查询慢,通常是几个环节出了问题,尤其是数据量大的时候:
-
筛选用户记录慢 (
WHERE ubh.userId = ?
) :user_book_history
表里可能有海量的用户阅读记录。如果没有针对userId
字段建立索引,数据库为了找到特定用户的记录,就得像查字典没目录一样,一行一行地去比对userId
,这叫“全表扫描”(Full Table Scan)。数据越多,这个过程越耗时。EXPLAIN
输出里的type
列如果是ALL
,基本就实锤了是全表扫描。 -
排序慢 (
ORDER BY ubh.createDate DESC
) : 找到了特定用户的所有记录后,还得按照createDate
倒序排列。如果数据库没法利用索引直接拿到排好序的数据,它就得在内存里或者更糟,在磁盘上(当数据量太大内存装不下时)进行额外的排序操作,这叫“文件排序”(Filesort)。这玩意儿开销挺大的。EXPLAIN
输出的Extra
列如果包含Using filesort
,那排序就是个拖后腿的。 -
连接操作 (
INNER JOIN ... ON ubh.bookId = b.id
) : 查询需要根据user_book_history
里的bookId
去books
表里捞对应的图书信息。虽然books
表的id
字段是主键,天然有索引,查找效率很高。但是,如果在第一步筛选用户记录时效率低下,导致参与JOIN
的ubh
记录数量庞大,那么即使books
表查找快,整体连接的开销也会累积起来。
从提供的 EXPLAIN
截图(假设内容符合预期)来看,最可能的问题就是 user_book_history
表缺少合适的索引,导致 WHERE
条件过滤慢,并且触发了 Filesort
。
对症下药:优化方案
核心思路就是加索引。索引就像书的目录,能帮数据库快速定位到需要的数据行,避免全表扫描和文件排序。
方案一:为 userId
创建索引
最直接的想法,既然按 userId
筛选慢,那就给它加个索引。
- 原理 : 在
user_book_history
表的userId
列上创建索引。这样数据库就能通过索引这本“目录”,迅速定位到指定userId
的所有记录,大大减少扫描的行数。 - 操作 :
这里的ALTER TABLE user_book_history ADD INDEX idx_userid (userId); -- 或者使用 CREATE INDEX 语法: -- CREATE INDEX idx_userid ON user_book_history (userId);
idx_userid
是索引的名字,你可以自己取个有意义的。 - 效果 : 大幅提升
WHERE ubh.userId = ?
的执行效率。EXPLAIN
输出里的type
可能会从ALL
变成ref
,rows
列(估计扫描行数)会显著减少。但这并未解决排序 的问题。如果筛选出的用户记录仍然很多,ORDER BY createDate DESC
还是可能触发Using filesort
。
方案二:终极武器 - (userId, createDate)
复合索引
要同时解决筛选和排序的问题,复合索引是王道。
- 原理 : 创建一个包含
userId
和createDate
两个字段的复合索引。这个索引的厉害之处在于:- 筛选优化 : MySQL 可以利用索引的前缀部分 (
userId
) 来快速定位特定用户的记录,跟方案一效果类似。 - 排序优化 : 对于定位到的这些用户记录,它们在索引内部已经是按照
createDate
排好序的(或者反序,数据库处理起来都很高效)。这样,ORDER BY ubh.createDate DESC
操作可以直接利用索引的有序性,避免了昂贵的 Filesort 。MySQL 这叫利用索引完成排序(Index-based sorting)。
- 筛选优化 : MySQL 可以利用索引的前缀部分 (
- 操作 :
注意索引列的顺序很重要:ALTER TABLE user_book_history ADD INDEX idx_userid_createdate (userId, createDate); -- 或者: -- CREATE INDEX idx_userid_createdate ON user_book_history (userId, createDate);
userId
在前,createDate
在后,正好匹配查询语句里WHERE
先过滤userId
,然后ORDER BY createDate
的逻辑。 - 效果 : 这是针对这个特定查询最优的索引策略。
EXPLAIN
的结果应该会显示使用了这个复合索引(key
列),type
可能是ref
,rows
减少,并且Extra
列里不再出现Using filesort
。查询速度会有质的飞跃。 - 进阶使用技巧 :
- 覆盖索引 (Covering Index) : 如果
SELECT
列表里的所有字段都恰好包含在所使用的索引中(包括SELECT
列表的ubh.bookId
,ubh.position
,ubh.completedDate
,ubh.userId
以及用于JOIN
和ORDER BY
的ubh.userId
,ubh.createDate
,ubh.bookId
),MySQL 甚至都不需要回表(回到主键索引去查找完整的行数据),直接从索引里就能拿到所有需要的信息。这对性能是极大的提升。看看能不能调整复合索引让它变成覆盖索引:
创建这样的“宽索引”需要权衡,它会占用更多存储空间,并可能增加写操作的成本。但对于读密集且性能要求高的查询,值得考虑。-- 尝试创建覆盖索引 (注意包含所有 udb 表中需要的列) ALTER TABLE user_book_history ADD INDEX idx_userid_createdate_cover (userId, createDate, bookId, position, completedDate); -- 或者 -- CREATE INDEX idx_userid_createdate_cover ON user_book_history (userId, createDate, bookId, position, completedDate);
EXPLAIN
的Extra
列如果显示Using index
,就表示用上了覆盖索引。 - 检查索引基数 (Cardinality) : 使用
SHOW INDEX FROM user_book_history;
查看索引的Cardinality
值。这个值反映了索引列中唯一值的估算数量。如果userId
的基数很高(意味着用户很多,每个用户的记录相对分散),那么idx_userid
或复合索引的效果会非常好。如果基数很低(比如系统只有少量固定用户),索引效果可能不那么显著。
- 覆盖索引 (Covering Index) : 如果
方案三:优化 JOIN
操作 - bookId
索引
虽然 books.id
有主键索引,但在 user_book_history
表的 bookId
列上加索引有时也能起点作用。
- 原理 : 在
user_book_history
表的bookId
列上建立索引。当 MySQL 的查询优化器选择先驱动books
表(虽然在这个查询中不太可能,通常会先用WHERE
过滤ubh
),或者在某些特定的连接算法下,这个索引可能会被用到。不过,对于当前这个WHERE userId = ?
非常明确的查询,优化器大概率会先过滤ubh
表,此时ubh.bookId
的索引作用相对有限,因为books.id
的主键索引已经足够高效了。 - 操作 :
ALTER TABLE user_book_history ADD INDEX idx_bookid (bookId); -- 或者: -- CREATE INDEX idx_bookid ON user_book_history (bookId);
- 效果 : 对这个特定查询的性能提升可能不大,优先级低于方案二的复合索引。但如果还有其他查询场景是根据
bookId
来查找或连接user_book_history
,那这个索引就很有价值了。
方案四:精简 SELECT
列表
这个不是索引优化,但也是常用的查询优化手段。
- 原理 : 你在查询里写了
SELECT b.*, ...
。这意味着要从books
表里拖出所有列的数据,即使你的应用程序后续根本用不到其中的某些列(比如amazonBuyUrl
,authorDesc
,body
这些可能很大的字段)。这会增加网络传输量,消耗更多数据库和应用程序的内存,还可能影响缓存效率。 - 操作 : 改写
SELECT
子句,只选择你真正需要的列。例如:SELECT b.id, b.title, b.author, -- 只选需要的 books 列 ubh.bookId, ubh.position, ubh.completedDate, ubh.userId FROM user_book_history AS ubh INNER JOIN books AS b ON ubh.bookId = b.id WHERE ubh.userId = ? ORDER BY ubh.createDate DESC
- 效果 : 减少数据传输量,降低 I/O 和内存消耗,整体上可能提升查询和后续处理的速度。特别是在
books
表有很多宽列或者TEXT
/BLOB
类型字段时效果明显。
安全第一:加索引的注意事项
加索引是好事,但也得注意几点:
- 写操作成本 : 索引加速了读(
SELECT
),但会拖慢写操作(INSERT
,UPDATE
,DELETE
)。因为每次增删改数据,相关的索引也得跟着更新维护。表上的索引越多,写操作的负担就越重。 - 存储空间 : 索引本身也是要占用磁盘空间的。索引越多、越复杂(比如包含很多列的复合索引,或者对长字符串列建索引),占的地方就越大。
- 不是越多越好 : 不要觉得给所有列都加上索引就万事大吉了。要根据实际的查询场景,创建最有针对性的索引。过多的、不必要的索引反而会是累赘。
- 线上操作需谨慎 : 在生产环境的数据库上添加索引,尤其是大表,可能会锁表或者消耗大量系统资源,影响线上服务。建议在业务低峰期进行,或者使用支持在线 DDL 的工具/数据库版本(如 MySQL 5.6+ 支持 Online DDL,但具体操作和影响仍需评估)。最好在测试环境充分验证索引的效果和添加过程的影响。
验证效果:再看 EXPLAIN
添加了索引之后(特别是方案二的复合索引),务必重新运行 EXPLAIN
命令来分析这个查询:
EXPLAIN SELECT b.*, ubh.bookId, ubh.position, ubh.completedDate, ubh.userId
FROM user_book_history AS ubh
INNER JOIN books AS b ON ubh.bookId = b.id
WHERE ubh.userId = ? -- 用一个实际的 userId 替换 ?
ORDER BY ubh.createDate DESC;
重点关注 EXPLAIN
输出的几列:
table
:ubh
(user_book_history)。type
: 应该从ALL
变成ref
(表示使用了非唯一性索引进行查找)。possible_keys
: 可能用到的索引列表。key
: 实际决定使用的索引名(应该是你新加的idx_userid_createdate
)。key_len
: 使用的索引长度。ref
: 显示哪个常量或列被用来跟索引做比较(这里应该是const
,代表WHERE
子句里的常量?
)。rows
: 估算的扫描行数,应该大大减少了。Extra
: 关键! 不应再包含Using filesort
。如果使用了覆盖索引,会显示Using index
。
对比加索引前后的 EXPLAIN
结果和实际查询耗时,就能直观感受到优化效果。通常,加上正确的索引后,这类查询的速度能提升几个数量级。