成为全栈·基础补充·数据库索引:为什么加了索引还是慢
这个项目的数据库里有 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 有没有出现你以为的那个索引。
这一步能抓到一个常见误解:你以为加了索引就一定用了。 而实际情况可能是:
- 索引列顺序不匹配(最常见)
- 数据库判断全表扫描更划算
- 索引被统计信息误导了
而"你以为用了"和"实际用了"之间的差距,是索引优化里最隐蔽的一类问题。 因为你加了索引、测试也快了(在数据量小的时候),只有生产数据量上来才暴露。
小结
索引这件事,本项目沉淀下来的判断是:
- 索引解决的是"少读很多行",不是"让查询变快"。 而小表上它可能更慢。
- 复合索引有最左前缀规则,列顺序决定它能加速哪些查询。
- 索引可能是为约束建的,它顺带能查,但列顺序未必匹配你的查询。
- 用
EXPLAIN QUERY PLAN看清SCAN还是SEARCH,别靠猜。 - "加了索引还是慢"要先确认瓶颈在数据库------用常量查询测一次就知道。
- UNIQUE 索引的第一用途是正确性 ,而它恰好也能加速查询------但这不是反过来。
第 6 条我觉得是最重要的。教科书讲索引时几乎只讲性能,而真实项目里最有价值的那些索引,往往是为了让某个错误状态无法表达才建的。
这也是为什么这篇没有一整节讲 B+ 树。它的原理不复杂,而难的是判断"要不要建、建在哪、什么时候该删"------而这些都不是查手册能得到的。
下一篇讲缓存的层次与失效。它和索引有一个共同点:两者都是"用一致性换性能",而取舍的位置不一样。
延伸阅读
如果这篇文章对你有帮助,欢迎订阅我的 CSDN 专栏 「成为全栈」:
🔗 专栏地址:https://blog.csdn.net/fungleo/category_13204651.html
📦 本系列配套代码仓库:https://github.com/fengcms/become-a-full-stack-developer
