成为全栈·基础补充·数据库索引:为什么加了索引还是慢

成为全栈·基础补充·数据库索引:为什么加了索引还是慢

这个项目的数据库里有 11 个索引。

然后我做了一件事:把它们全看了一遍,发现它们全部是 UNIQUE 索引------为唯一性约束建的,一个都不是为查询性能建的。

也就是说:这个项目在"查询性能"这件事上的索引投入是零。

那它为什么没慢?因为数据量小到不需要。而这恰好是这篇文章想讲的事------索引不是"加上就快",它是一笔需要先测量再投入的账。

这篇不讲索引的原理,只讲三件我在这个项目里真实遇到的事。

索引真正解决的是"找到哪几行"

先纠正一个常见的说法:索引不是"让查询变快",是"让数据库少读很多行"。

数据存在磁盘上,按页(page)组织。一张表有一百万行,如果没有索引,要找满足条件的那一行,数据库只能一页一页读过去------因为它不知道数据长什么样。

有索引之后,数据库可以用一棵平衡树(B+ 树)定位到具体的页,然后只读那一页。

这个区别在数据量小的时候几乎看不出来。 一千行数据全表扫描,也就是读几十页,比走树还快。

所以"加了索引还是慢"这件事,第一个要问的是:数据量到底有多少。

行数 全表扫描 走索引
1000 几十页,扫完可能更快 走树反而多几次 IO
10 万 几千页,明显慢 走树快
1000 万 几十万页,慢到不可接受 走树快

而这个转折点(几百行到几千行)在开发环境几乎永远达不到。 你的测试库有 20 篇文章,生产有 20000 篇------你在开发环境做的所有性能观察,在生产都可能不成立。

这个项目的 11 个索引都是 UNIQUE

看真实定义:

sql 复制代码
CREATE UNIQUE INDEX `uniq_username` ON `users` (`username`);
CREATE UNIQUE INDEX `uniq_email` ON `users` (`email`);
CREATE UNIQUE INDEX `uniq_article_slug` ON `articles` (`slug`);
CREATE UNIQUE INDEX `uniq_like` ON `likes` (`user_id`,`article_id`);
CREATE UNIQUE INDEX `uniq_favorite` ON `favorites` (`user_id`,`article_id`);
CREATE UNIQUE INDEX `uniq_view_history` ON `view_history` (`user_id`,`article_id`);
CREATE UNIQUE INDEX `uniq_article_tag` ON `article_tags` (`article_id`,`tag_id`);
CREATE UNIQUE INDEX `uniq_article_dedup` ON `article_view_dedup` (`article_id`,`dedup_key`);
CREATE UNIQUE INDEX `uniq_token_hash` ON `refresh_tokens` (`token_hash`);
CREATE UNIQUE INDEX `uniq_category_slug` ON `categories` (`slug`);
CREATE UNIQUE INDEX `uniq_tag_slug` ON `tags` (`slug`);

注意最后三个之外,全都是"业务唯一性"的表达:用户名唯一、一个用户对一篇文章只能点赞一次。

而那些"为查询建的索引"------比如"查某分类下按发布时间排序的文章"需要的 (category_id, published_at) 复合索引------一个都没有。

为什么?因为 UNIQUE 索引顺带也能加速查询 。SQLite 会为 UNIQUE 索引建一个隐式的查找用途,所以 WHERE username = ? 这种查询本来就不慢。

这个"顺带"让问题变得隐蔽 :你以为"数据库有索引所以查询有保证",但那个索引是为约束建的,它的列顺序和你的查询条件未必匹配。

比如 uniq_like(user_id, article_id) 能加速"查某用户点赞了哪些文章",但不能 加速"查某篇文章被谁点赞"------列顺序反了。

复合索引的列顺序规则

而这个项目里最热的查询恰恰是后者:

sql 复制代码
-- 文章列表页:要某分类下按发布时间倒序的文章
SELECT * FROM articles
WHERE category_id = ?
  AND deleted_at IS NULL
ORDER BY published_at DESC
LIMIT 20;

这条查询需要 (category_id, published_at) 的复合索引,而它不存在。

复合索引有个常被忽略的规则:

最左前缀 。(a, b, c) 能加速 WHERE a=?、WHERE a=? AND b=?、WHERE a=? AND b=? AND c=?,但不能 加速 WHERE b=?。

所以如果建了 (category_id, published_at),那它对上面的查询有效,对"按发布时间查全站最新"(没有 category 条件)无效。

而"按浏览量排热门"那条查询需要 (view_count)------又是一个不同的索引。

这就是索引设计最麻烦的地方:每个查询需要自己的索引,而索引不能互相替代。

加索引之前:先看执行计划

现在讲"为什么加了索引还是慢"的第二类原因。

我在这个项目里遇到过一次:给 refresh_tokens 加了索引,但查 token 的场景仍然慢。

原因不是索引没生效,是索引生效了,但数据库判断"走索引更慢"。

看这个查询:

ts 复制代码
await db
  .select({ user: users })
  .from(wechatIdentities)
  .innerJoin(users, eq(users.id, wechatIdentities.userId))
  .where(and(eq(wechatIdentities.appId, appId), eq(wechatIdentities.openId, openId)))

wechat_identities 上有 uniq_wechat_identity(app_id, open_id),看起来完全够用。

而真正的问题在另一条查询:

sql 复制代码
-- refresh_tokens 的清理任务:查所有已过期的
SELECT * FROM refresh_tokens WHERE revoked_at IS NULL;

这里没有任何索引列匹配。数据库只能全表扫描------而 refresh_tokens 是增长最快的表(每次登录刷新都插一条)。

而这正是"加了索引还是慢"的典型形态:你优化了某条查询,但瓶颈在另一条你没注意到的查询上。

SQLite 让你能看到这件事:

sql 复制代码
EXPLAIN QUERY PLAN SELECT * FROM refresh_tokens WHERE revoked_at IS NULL;

输出会是:

text 复制代码
SCAN refresh_tokens          -- ← 全表扫描,SCAN 就是"没有索引可用"

关键词是 SCAN 和 SEARCH。

输出 含义
SEARCH 表 ... USING INDEX 名 走了索引,好
SCAN 表 全表扫描,慢
SCAN 表 ... USING COVERING INDEX 全表扫描,但用的是覆盖索引(够用)

一个 EXPLAIN QUERY PLAN 就能回答"这条查询到底走没走索引",而这比看代码猜要可靠得多。

三个"加了索引仍然慢"的真实原因

把这一节的东西收一下,"加了索引还是慢"实际是这几种:

一、索引的列顺序不匹配。

uniq_like(user_id, article_id) 无法加速 WHERE article_id = ?。这是最常见的一种,因为索引是为约束设计的,不是为查询设计的。

二、索引被覆盖,但数据库选择不用。

比如 WHERE revoked_at IS NULL 命中了某条索引,但数据库估算"这个条件匹配大部分行,走索引要回表 N 次,不如全表扫"。这是对的决策,而你的直觉会觉得"我有索引啊"。

三、瓶颈根本不在数据库。

这个项目的登录接口曾经很慢,我一开始以为要加索引。后来发现慢在 bcrypt------12 轮哈希是有意为之的成本,不是 bug。

加索引解决不了 CPU 密集的计算。 而这类问题在 profiling 里的表现和"数据库慢"很像:都是"接口响应慢"。

判断方法很简单:把查询换成常量(比如 WHERE id = 1)再测一次。 如果还是慢,瓶颈不在查询。

索引有代价,而且不只是一个

最后讲为什么"不轻易加索引"。

一、写变慢。 每次 INSERT/UPDATE/DELETE 都要维护索引。而这个项目的 likes 表写入频率很高(用户狂点赞),每个索引都在放大写入成本。

二、占空间。 索引是完整的数据结构,一个复合索引可能和表本身一样大。

三、优化器可能选错。 索引越多,优化器的选择空间越大,在你数据分布变化之后可能选到一个曾经正确、现在错误的计划。

四、UNIQUE 索引不只是性能手段。

这一条值得单独说:这个项目的 UNIQUE 索引同时承担业务约束。

uniq_like(user_id, article_id) 保证一个用户对一篇文章只能点赞一次------这不是"性能优化",这是数据完整性 。而 M1-32 讲的微信身份唯一索引同理:它让"一个微信号对应两个会员"在数据库层面不可能发生。

如果为了"减少索引数量"而删掉某个 UNIQUE 索引,你会失去一个正确性保证。 这是本项目明确不做的事。

一条我一开始想省掉的事

M1-32 那个微信建号的并发处理,第一版我是这样写的:

ts 复制代码
// 第一版
let user = await findByOpenId(openId);
if (!user) {
  const userId = await createUser();        // 先插
  await createWechatIdentity(userId);       // 再插映射
  user = await findById(userId);
}

看起来有"先查后插",我以为够了。

漏掉的是:如果两个请求同时执行,会各创建一个 user,然后各自插一条映射。 而如果没有 UNIQUE 索引,数据库会欣然接受这两条重复的映射------两条记录,一个微信,两个会员。

而我加那个唯一索引的理由不是"这样查得快",而是"这样不可能重复"。

这个区别在 M1-32 里讲并发时说过一次,但当时我没意识到它和索引教程的关系。现在回头看,那条 UNIQUE 索引是这个功能里最重要的一个决定------而它和性能优化完全无关。

索引的第一用途往往不是性能,是让某个错误状态在数据库层面无法表达。 这可能是"索引"这件事在实践中和教科书讲得最不一样的地方。

亲手跑一次 EXPLAIN

前面说的都是原理。真正上手只需要一个命令,而这个项目用的是 SQLite,所以直接在本地跑:

bash 复制代码
cd flutter-app
sqlite3 ../node-backend/drizzle/local.db "EXPLAIN QUERY PLAN SELECT * FROM articles WHERE slug = 'x';"

看走索引用时,对照结果里有三个关键词:

text 复制代码
SEARCH articles USING INDEX uniq_article_slug (slug=?)          ← 走了唯一索引
SEARCH articles USING COVERING INDEX uniq_article_slug (slug=?)  ← 覆盖索引,连回表都省了
SCAN articles                                                     ← 全表扫描

COVERING INDEX 是这里最值得认识的一个词。

它的含义是:索引里已经包含了这次查询需要的全部列,所以数据库不需要回表取数据。

而这个项目里有一个真实的例子能说明它的价值------refresh_tokens 表:

ts 复制代码
await db
  .select({ user: users })
  .from(wechatIdentities)
  .innerJoin(users, eq(users.id, wechatIdentities.userId))
  .where(and(eq(wechatIdentities.appId, appId), eq(wechatIdentities.openId, openId)))

uniq_wechat_identity(app_id, open_id) 覆盖了 WHERE 条件里的两个列,但 SELECT 的是 users 表的字段,所以必须回表------不是覆盖索引。

而如果是这样查:

ts 复制代码
await db.select().from(wechatIdentities).where(...)

那 SELECT * 就正好被索引覆盖了,数据库能完全不回表。

同一条查询语句,改一下 SELECT 的内容,可能就从"要回表"变成"不用回表"。 这是覆盖索引唯一需要你记住的实践知识。

判断慢查询的一个小技巧

最后说一个我常用的排查顺序:

第一步,把查询条件换成常量。

sql 复制代码
-- 原来
SELECT * FROM articles WHERE slug = ?;
-- 换成
SELECT * FROM articles WHERE slug = 'x';

如果两种写法速度差不多,说明瓶颈在读取数据本身(或者根本不在这里) ;如果常量快很多,说明慢在按索引查找的过程中------比如索引选择性差,或者优化器压根没用它。

第二步,看 EXPLAIN QUERY PLAN 有没有出现你以为的那个索引。

这一步能抓到一个常见误解:你以为加了索引就一定用了。 而实际情况可能是:

  • 索引列顺序不匹配(最常见)
  • 数据库判断全表扫描更划算
  • 索引被统计信息误导了

而"你以为用了"和"实际用了"之间的差距,是索引优化里最隐蔽的一类问题。 因为你加了索引、测试也快了(在数据量小的时候),只有生产数据量上来才暴露。

小结

索引这件事,本项目沉淀下来的判断是:

  1. 索引解决的是"少读很多行",不是"让查询变快"。 而小表上它可能更慢。
  2. 复合索引有最左前缀规则,列顺序决定它能加速哪些查询。
  3. 索引可能是为约束建的,它顺带能查,但列顺序未必匹配你的查询。
  4. 用 EXPLAIN QUERY PLAN 看清 SCAN 还是 SEARCH,别靠猜。
  5. "加了索引还是慢"要先确认瓶颈在数据库------用常量查询测一次就知道。
  6. UNIQUE 索引的第一用途是正确性 ,而它恰好也能加速查询------但这不是反过来。

第 6 条我觉得是最重要的。教科书讲索引时几乎只讲性能,而真实项目里最有价值的那些索引,往往是为了让某个错误状态无法表达才建的。

这也是为什么这篇没有一整节讲 B+ 树。它的原理不复杂,而难的是判断"要不要建、建在哪、什么时候该删"------而这些都不是查手册能得到的。

下一篇讲缓存的层次与失效。它和索引有一个共同点:两者都是"用一致性换性能",而取舍的位置不一样。

延伸阅读


如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:

🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html

📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer

相关推荐
codeejun10 分钟前
每日一Go·MySQL-11、覆盖索引 & 回表原理
javascript·数据库·mysql
Decay__1 小时前
21_MySQL 优化案例集 —— 慢 SQL 优化、索引重构与参数调整收益对比
运维·数据库·sql·mysql·架构
挖掘狂人2 小时前
MySQL 深分页优化:limit 100000, 10 慢在哪,怎么救
后端·sql·mysql
codigger2 小时前
MySQL 深分页优化:limit 100000, 10 慢在哪,怎么救
数据库·sql·mysql·程序员
Decay__11 小时前
18_MySQL 智能巡检方案 —— 巡检项清单 / 一键巡检脚本 / 健康评分
运维·数据库·sql·mysql·架构
凤凰院凶涛QAQ13 小时前
《MySQL内核拆解:从SQL到InnoDB》(四) : 联合查询 & 视图
数据库·sql·mysql
张小姐的猫13 小时前
【MySQL】访问 & 数据流动原理
android·数据库·mysql·adb·1024程序员节
这个DBA有点耶17 小时前
信创场景下数据同步软件怎么选?对比开源CDC与原生闭环工具的结构性差异
数据库·mysql·架构
多客:duokelijie18 小时前
代练订单系统选型思路:跨品类游戏接入、订单分层管理、自动结算逻辑拆解!
数据库·mysql·微信小程序·前端框架·uni-app·php·持续部署