SQLite 的 B-Tree 是怎么让查询变快的?大一也能看懂数据库索引原理

2865 字
14 分钟
SQLite 的 B-Tree 是怎么让查询变快的?大一也能看懂数据库索引原理

你八成用过 SQLite,只是自己没意识到:微信的聊天记录、浏览器的 Cookie、手机上不少 App 的本地数据,底下都是它。数据库课上老师会讲 B-Tree、讲索引,但大多数人第一次真正体会到「索引」俩字的分量,是在一个百万行的表上没建索引、查询卡了十几秒,然后加了条 CREATE INDEX,同样的查询变成几毫秒。

十几秒到几毫秒,上千倍的差距,中间没有任何魔法,只有一种叫 B-Tree 的数据结构在干活。这篇文章就把它讲明白——不堆数学公式,用你能用上的方式讲。看完你会知道:为什么有序比无序快得多、为什么 B-Tree 偏偏选「胖矮」的树形、以及怎么用 EXPLAIN QUERY PLAN 亲手验证索引有没有生效。

适合人群:学过一点 SQL(哪怕只会 SELECT),对「为什么快」好奇的大一大二同学 你需要:能跑 sqlite3 命令就行,装 Python 就自带

一、先搞懂问题:没有索引时,查询在干什么?#

先建立一个直觉:数据库查询慢,慢在哪。

假设有一张 100 万行的用户表,你执行:

SELECT * FROM users WHERE username = 'zhang3';

如果 username 这一列上没有索引,SQLite 只有一条路可走:把整张表从头到尾扫一遍,每读一行就问一次「这行的 username 等于 zhang3 吗?」直到扫完。这种操作叫全表扫描(Full Table Scan)。

听起来很笨,但你想想:数据在磁盘上本来就是一串无差别的行,查询条件又只匹配其中一行,不逐行看一遍,它怎么知道哪行是你想要的?这就好比老师点名,全班名单没排序时,只能从第一页翻到最后一页找人。

全表扫描的时间复杂度是 O(N),数据翻一倍,查询时间跟着翻一倍。几十行无所谓,百万行、千万行时就是灾难。而且磁盘 I/O 很贵——它不是从内存拿数据那种纳秒级的速度,每读一块数据都是实打实的耗时。

要快,思路其实很朴素:别把所有数据都翻一遍,先想办法快速缩小范围。这就要请出索引了。

二、从有序性说起:二分查找为什么快#

在讲 B-Tree 之前,先想想一个生活经验:字典为什么能快速查到字?

因为字典是按拼音排序的。查「程」字你不会从 a 翻到 z,而是直接翻到中间偏后的位置,再看一眼决定往前还是往后。这就是二分查找:每翻一次,剩余候选直接砍掉一半。

对比一下就很直观:

查找方式100 万条数据大约要看的次数前提条件
逐个翻(全表扫描)约 100 万次无
二分查找约 20 次数据必须有序

100 万除以 2,除 20 次就只剩 1 条了——这就是有序的威力,20 万倍和 20 次的差距。

但数组上的二分查找有个致命麻烦:插入太痛苦。往有序数组中间插一个元素,后面的元素全部要后移一位。数据库是又要查、又要频繁写的,纯数组方案撑不住。

链表呢?插入方便了,但链表没法二分——它不支持「跳到中间」。所以问题变成:能不能有一种结构,既保持有序、又方便插入删除、还能快速定位? B-Tree 就是对这个问题最经典的回答。

三、B-Tree:一棵被刻意养「胖」的树#

B-Tree 的思路:把数据组织成一棵多叉的、按块组织的树。它有两个关键设定,缺一不可。

第一,按「页(Page)」存取,一个节点就是一页。

数据库和磁盘打交道,不是按字节抠,而是按块读——SQLite 默认一页 4KB。所以 B-Tree 的每个节点就设计成正好一页大小:一次磁盘 I/O,读回一整个节点,里面的几百个键一次性拿到内存,再在内存里比较,几乎不花时间。

第二,一个节点里放很多键,所以树特别「胖」、特别矮。

每个节点内部是一小段有序的键,中间夹着指向下一层的指针。一个 4KB 的页,装几百个键毫无压力。粗算一下,一个节点分出几百叉,三层结构就能覆盖几亿行数据:

  • 第 1 层(根节点):1 页,几百个分支
  • 第 2 层:几百页,每页又分几百个分支
  • 第 3 层(叶子):几百万页,每页装着实际数据

于是查找任何一条数据,磁盘 I/O 次数 = 树的高度 ≈ 3 次。对比全表扫描几十万次的 I/O,这就是「十几秒变几毫秒」的全部秘密:不是算得更快了,而是跳过去的次数多了。分层查找在数据量大时碾压线性遍历,说的就是这种场景。

顺便一提,「数据库索引为什么用 B-Tree 而不是二叉搜索树」的答案就藏在这里:二叉树每个节点只有 2 叉,100 万行数据树高约 20 层,也就是 20 次磁盘 I/O;B-Tree 三层搞定。矮胖永远比瘦高省磁盘,磁盘 I/O 次数才是数据库真正的命门。

四、一次查找 + 一次插入,到底发生了什么#

光有结构还不够,B-Tree 能长期保持高效,靠的是一套自维护机制。挑两件最核心的说。

查找:从根节点开始,拿目标键和节点内的键比一比,决定走哪个分支,落到下一层再比,重复到叶子。整个过程像在商场找店:先看楼层索引图锁定楼层,再看楼层导览锁定位置,两次指路就到位,不需要把每层每个店都逛一遍。

插入与分裂:新数据按大小插到对应的叶子页。但页的容量是有限的——当一页塞满再插入时,就从中间劈开,变成两页,再把中间那个键上提到父节点当路标。这叫节点分裂(split)。父节点也可能因此被塞满,继续往上分裂,最坏情况根节点分裂、树长高一层。

这套机制妙在哪:树是向上长的。根节点分裂才长高,而绝大多数分裂发生在叶子层,所以树高增长极其缓慢——几亿行数据的树,高度大概率还是个位数。插入再多,查找依然是「每层一次 I/O」的水平。

下面两张图对照着看:

一次三层的 B-Tree 查找路径:根节点到内部节点再到叶子节点,高亮每次磁盘 I/O 读到的页
一次三层的 B-Tree 查找路径:根节点到内部节点再到叶子节点,高亮每次磁盘 I/O 读到的页

叶子页装满后分裂成两页、中间键上提为父节点路标的前后对比
叶子页装满后分裂成两页、中间键上提为父节点路标的前后对比

五、五分钟实验:亲手验证索引生效#

理解原理之后,最爽的部分是自己动手验证。整个实验五分钟,但印象会比看十篇文章都深。

第一步:造一张没有索引的表。

Terminal window
sqlite3 test.db
CREATE TABLE users (id INTEGER PRIMARY KEY, username TEXT, score INTEGER);
-- 用递归 CTE 快速插入 100 万行
WITH RECURSIVE cnt(x) AS (
SELECT 1 UNION ALL SELECT x+1 FROM cnt WHERE x < 1000000
)
INSERT INTO users (username, score)
SELECT 'user' || x, abs(random() % 100) FROM cnt;

第二步:看一眼查询计划。

EXPLAIN QUERY PLAN 会告诉你 SQLite 打算怎么执行这条查询,不用真执行:

EXPLAIN QUERY PLAN SELECT * FROM users WHERE username = 'user500000';

输出里出现 SCAN users ——SCAN 就是全表扫描,一百万行一格格爬。

第三步:建索引,再看。

CREATE INDEX idx_username ON users (username);
EXPLAIN QUERY PLAN SELECT * FROM users WHERE username = 'user500000';

输出变成 SEARCH users USING INDEX idx_username (username=?) ——SEARCH 加 USING INDEX,说明它顺着 B-Tree 直接定位去了。

再给 score 建个索引,对比两种条件,你能看清「索引对什么条件有用」:

CREATE INDEX idx_score ON users (score);
EXPLAIN QUERY PLAN SELECT * FROM users WHERE score > 90;

score > 90 同样能走索引——B-Tree 的叶子是按 score 排序的,大于 90 的数据在树上是一段连续区间,顺着扫这一段就行。这就是为什么范围查询也吃索引:排序结构不但能定位「等于」,还能定位「区间」。

查询方式EXPLAIN 关键词100 万行大约的行读取量
无索引等值查询SCAN users~1,000,000
有索引等值查询SEARCH ... USING INDEX~20(树高次 I/O + 1 行)
有索引范围查询SEARCH ... USING INDEX满足条件的行数

把表补一句题外话:索引也不是越多越好,后面会讲。

六、三个大一大二最容易踩的索引误区#

知道「要建索引」只是及格线,这几个坑才是拉开差距的地方:

1. 对索引列套函数,索引直接失效。

WHERE upper(username) = 'USER1' -- 走不了 idx_username
WHERE username = 'USER1' -- 能走

索引存的是 username 原始值的顺序,套了 upper() 之后,SQLite 没法在有序结构里查「upper 之后等于 USER1 的值」,只能老老实实全表扫。同理,列上做运算、隐式类型转换(拿 TEXT 列和数字比)都会踩这个坑。

2. 模糊查询 LIKE '%xxx' 基本没用。

通配符开头时,前缀是未知的——而 B-Tree 定位靠的正是前缀。LIKE 'user%' 可以走索引(前缀 user 是确定的,相当于区间查询),LIKE '%user' 只能全表扫。

3. 无脑给每列都建索引。

每个索引都是一棵独立的 B-Tree,写入(INSERT/UPDATE/DELETE)时每棵树都要同步维护,空间也翻着涨。查询快了,写入慢了——这是一笔 trade-off,不是免费午餐。经验法则:按真实查询建索引,WHERE 和 ORDER BY 里高频出现的列优先,用不上的坚决不建。

还有一条「心法」:索引列尽量单调递增(比如自增 id、时间戳),新数据总落在树的最右边,很少触发中间分裂;而用随机值(比如随机 UUID)当主键,插入位置全随机,页分裂会频繁很多,写入性能肉眼可见地下滑。这也是很多老手选主键时会绕开随机 UUID 的原因。

七、结语:从「会用」到「知道为什么」#

回头看,索引加速的全部逻辑其实就三句话:数据有序才能二分定位;按页组织才能少吃磁盘 I/O;B-Tree 用「胖矮」的树形把这两点同时做到了。

这篇文章只能算入门——B-Tree 还有 range scan 的页内遍历、WAL 日志、SQLite 实际使用的 B+Tree 变体(数据全存在叶子层、叶子之间串成链表)等进阶话题,值得你顺着兴趣继续挖。好消息是,工具你已经有了:EXPLAIN QUERY PLAN 随时能告诉你查询在干什么。

下次写课程项目,如果发现列表页越刷越卡,别急着换语言、加机器——先跑一句 EXPLAIN,看看 SCAN 在哪。从「能用」到「知道为什么快」,这一步往往就是课设和别人拉开差距的地方。

想继续玩的话,附两份材料:

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

赞助
SQLite 的 B-Tree 是怎么让查询变快的?大一也能看懂数据库索引原理
https://www.kshare.top/posts/2026-sqlite-btree索引原理-为什么查询快/
作者
Kshare
发布于
2026-10-06
许可协议
CC BY-NC-SA 4.0
相关文章 智能推荐
1
Turso libSQL:分布式 SQLite 如何重定义云数据库边界
软件推荐 Turso 是一款基于 libSQL(SQLite 开源分支)构建的分布式数据库服务,其核心价值在于:将 SQLite 的轻量级特性与云数据库的分布式能力完美结合。
2
PocketBase 自建后端服务:Docker 部署替代 Firebase
部署教程 PocketBase 是 Go 编写的开源后端即服务,集数据库、认证、文件存储、管理面板于一身。本文用 Docker 一行部署,替代 Firebase/Supabase,后端开发从未如此简单。
3
MinIO 仓库已归档:2026 自托管 S3 对象存储替代品怎么选?(Garage/SeaweedFS/RustFS 横评)
技术科普 MinIO 社区版 2026-04-25 正式归档,只读、无补丁、无镜像发布。自托管 S3 该怎么办?本文梳理 MinIO 事件全貌,深度横评 Garage、SeaweedFS、RustFS、Ceph 四个替代方案,并给出迁移与选型建议。
4
2026 VPS 新手保姆级教程:从选购一台服务器到部署第一个服务
技术科普 2026 年 VPS 怎么买怎么用?本文从零讲起:什么是 VPS、按用途怎么选配置(CPU/内存/硬盘/带宽/流量)、下单前必看的坑(续费价/禁代理/IP被封)、拿到机器后第一步 SSH 登录与安全加固,再手把手用面板部署第一个服务。
5
IPFS 核心团队停摆:去中心化存储还能选什么?(2026)
技术科普 IPFS 主力维护团队 Shipyard 将于 2026-09-30 停摆。本文梳理停摆原因、受影响的组件与公开网关,并对比 Iroh、自托管、Filecoin 等去中心化存储替代方案,帮你判断下一步怎么选。
随机文章 随机推荐
Profile Image of the Author
Kshare
Hello, I'm Kshare.
重要公告
非常抱歉!本站此前接入的 Adsterra 广告联盟存在严重问题:其脚本会在访问者不知情的情况下强制跳转到第三方劫持页面(如 verify11.click 等恶意域名),严重影响了大家的访问体验与安全。我们已第一时间彻底移除了 Adsterra 的全部广告代码,并向因此受到困扰的访客致以最诚挚的歉意。目前站内仅保留安全的自营推广链接(无脚本、无弹窗、无跳转),其余广告投放保持关闭,感谢大家的理解与支持!
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
218
分类
9
标签
283
总字数
555,774
运行时长
0 天
最后活动
0 天前

文章目录