返回

MySQL索引优化实战:解决用户历史记录慢查询

mysql

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 的输出结果截图 (虽然这里看不到图,但我们能推断出大致情况)。慢,总得有原因。

为什么会慢?刨根问底

这个查询慢,通常是几个环节出了问题,尤其是数据量大的时候:

  1. 筛选用户记录慢 (WHERE ubh.userId = ?) : user_book_history 表里可能有海量的用户阅读记录。如果没有针对 userId 字段建立索引,数据库为了找到特定用户的记录,就得像查字典没目录一样,一行一行地去比对 userId,这叫“全表扫描”(Full Table Scan)。数据越多,这个过程越耗时。EXPLAIN 输出里的 type 列如果是 ALL,基本就实锤了是全表扫描。

  2. 排序慢 (ORDER BY ubh.createDate DESC) : 找到了特定用户的所有记录后,还得按照 createDate 倒序排列。如果数据库没法利用索引直接拿到排好序的数据,它就得在内存里或者更糟,在磁盘上(当数据量太大内存装不下时)进行额外的排序操作,这叫“文件排序”(Filesort)。这玩意儿开销挺大的。EXPLAIN 输出的 Extra 列如果包含 Using filesort,那排序就是个拖后腿的。

  3. 连接操作 (INNER JOIN ... ON ubh.bookId = b.id) : 查询需要根据 user_book_history 里的 bookIdbooks 表里捞对应的图书信息。虽然 books 表的 id 字段是主键,天然有索引,查找效率很高。但是,如果在第一步筛选用户记录时效率低下,导致参与 JOINubh 记录数量庞大,那么即使 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 变成 refrows 列(估计扫描行数)会显著减少。但这并未解决排序 的问题。如果筛选出的用户记录仍然很多,ORDER BY createDate DESC 还是可能触发 Using filesort

方案二:终极武器 - (userId, createDate) 复合索引

要同时解决筛选和排序的问题,复合索引是王道。

  • 原理 : 创建一个包含 userIdcreateDate 两个字段的复合索引。这个索引的厉害之处在于:
    1. 筛选优化 : MySQL 可以利用索引的前缀部分 (userId) 来快速定位特定用户的记录,跟方案一效果类似。
    2. 排序优化 : 对于定位到的这些用户记录,它们在索引内部已经是按照 createDate 排好序的(或者反序,数据库处理起来都很高效)。这样,ORDER BY ubh.createDate DESC 操作可以直接利用索引的有序性,避免了昂贵的 Filesort 。MySQL 这叫利用索引完成排序(Index-based sorting)。
  • 操作 :
    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 可能是 refrows 减少,并且 Extra 列里不再出现 Using filesort 。查询速度会有质的飞跃。
  • 进阶使用技巧 :
    • 覆盖索引 (Covering Index) : 如果 SELECT 列表里的所有字段都恰好包含在所使用的索引中(包括 SELECT 列表的 ubh.bookId, ubh.position, ubh.completedDate, ubh.userId 以及用于 JOINORDER BYubh.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);
      
      创建这样的“宽索引”需要权衡,它会占用更多存储空间,并可能增加写操作的成本。但对于读密集且性能要求高的查询,值得考虑。EXPLAINExtra 列如果显示 Using index,就表示用上了覆盖索引。
    • 检查索引基数 (Cardinality) : 使用 SHOW INDEX FROM user_book_history; 查看索引的 Cardinality 值。这个值反映了索引列中唯一值的估算数量。如果 userId 的基数很高(意味着用户很多,每个用户的记录相对分散),那么 idx_userid 或复合索引的效果会非常好。如果基数很低(比如系统只有少量固定用户),索引效果可能不那么显著。

方案三:优化 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 类型字段时效果明显。

安全第一:加索引的注意事项

加索引是好事,但也得注意几点:

  1. 写操作成本 : 索引加速了读(SELECT),但会拖慢写操作(INSERT, UPDATE, DELETE)。因为每次增删改数据,相关的索引也得跟着更新维护。表上的索引越多,写操作的负担就越重。
  2. 存储空间 : 索引本身也是要占用磁盘空间的。索引越多、越复杂(比如包含很多列的复合索引,或者对长字符串列建索引),占的地方就越大。
  3. 不是越多越好 : 不要觉得给所有列都加上索引就万事大吉了。要根据实际的查询场景,创建最有针对性的索引。过多的、不必要的索引反而会是累赘。
  4. 线上操作需谨慎 : 在生产环境的数据库上添加索引,尤其是大表,可能会锁表或者消耗大量系统资源,影响线上服务。建议在业务低峰期进行,或者使用支持在线 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 结果和实际查询耗时,就能直观感受到优化效果。通常,加上正确的索引后,这类查询的速度能提升几个数量级。