B-tree vs GIN vs GiST 索引:实用的 PostgreSQL 指南
B-tree、GIN 与 GiST 索引对比:用决策表为过滤、搜索、JSONB 字段、地理查询和高基数列选择合适的 PostgreSQL 索引。

当你选择索引时,真正要决定的是什么
大多数 PostgreSQL 的索引问题都是这样开始的:在 1,000 行时列表很快,但到了 1,000,000 行就变慢了;或者测试时搜索框很快,上线后却变成几秒钟的停顿。遇到这种情况,很容易问“哪个索引最好?”。更好的问题是:“这个界面到底让数据库做什么?”
同一张表可能需要不同类型的索引,因为不同的界面以不同的方式读取它。一个视图按单个 status 过滤并按 created_at 排序;另一个做全文检索;再一个检查 JSON 字段是否包含某个键;还有一个在地图上查找某点附近的项目。这些是不同的访问模式,所以没有一种索引能在所有场景都胜出。
选择索引时,你其实在决定应用如何访问数据。你主要是做精确匹配、范围查询和排序吗?还是在文档或数组中搜索内容?你是在问“这个位置附近有什么?”还是“这个范围重叠吗?”答案决定了 B-tree、GIN 或 GiST 哪个更合适。
用通俗的话说 B-tree、GIN 和 GiST
选索引更多地取决于查询如何使用列,而不是列的类型。PostgreSQL 根据操作符来选择索引,比如 =、<、@> 或 @@,而不是根据列是“text”还是“json”。这就是为什么同一字段在不同屏幕上可能需要不同索引的原因。
B-tree:用于有序查找的高性能选择
B-tree 是默认且最常见的选择。它适合按精确值过滤、按范围过滤或需要特定排序的场景。
典型例子是一个按 status 过滤并按 created_at 排序的管理列表。在 (status, created_at) 上建 B-tree 索引既能加速过滤也能优化排序。B-tree 也是实现唯一约束(unique constraints)的常用工具。
GIN:当每行包含许多可搜索键时速度快
GIN 适用于“这一行是否包含某个词/值?”的问题,其中一行可能对应多个键。常见示例是全文搜索(文档包含若干词)以及 JSONB/数组的包含(JSON 包含某个键/值)。
想象一个客户记录有 JSONB 的 preferences 对象,某个界面需要过滤出 preferences 包含 { "newsletter": true } 的用户,这类查找就是 GIN 擅长的。
GiST:灵活地处理范围、地理和相似性
GiST 是一种通用框架,用于那些不适合简单排序的数据类型。它天然适合范围(重叠、包含)、几何和地理查询(附近、包含)以及某些相似性搜索。
当在 B-tree、GIN、GiST 三者之间抉择时,先把你最忙的界面用到的操作符写下来。通常看清操作符后,合适的索引类型就很明显了。
常见界面(过滤、搜索、JSON、地理)的决策表
大多数应用只需要几种索引模式。诀窍是把界面行为与查询使用的操作符匹配起来。
| 屏幕模式 | 典型查询形态 | 最佳索引类型 | 示例操作符 |
|---|---|---|---|
| 简单过滤(status、tenant_id、email) | 大量行,用等值缩小范围 | B-tree | = IN (...) |
| 日期/数值范围过滤 | 时间窗口或最小/最大 | B-tree | >= <= BETWEEN |
| 排序 + 分页(Feed、管理列表) | 先过滤再 ORDER BY ... LIMIT | B-tree(通常是复合索引) | ORDER BY created_at DESC |
| 高基数列(user_id、order_id) | 非常选择性的查找 | B-tree | = |
| 全文搜索框 | 在字段中搜索文本 | GIN | @@(在 tsvector 上) |
| “包含”文本搜索 | 像 %term% 这样的子串匹配 | 通常没有(或需要 trigram 等特殊设置) | LIKE '%term%' |
| JSONB 包含(tags、flags、properties) | 匹配 JSON 形状或键/值 | GIN(针对 jsonb) | @> |
| JSONB 单键相等 | 经常按某个 JSON 键过滤 | 针对表达式的 B-tree | (data->>'plan') = 'pro' |
| 地理接近 / 半径内 | “离我近”或地图视图 | GiST(PostGIS geometry/geography) | ST_DWithin(...) <> |
| 范围、重叠(调度、价格区间) | 区间重叠检查 | GiST(range 类型) | && |
| 低选择性过滤(布尔、微小枚举) | 大多数行都匹配 | 索引通常帮助不大 | is_active = true |
当不同端点的查询模式不同,两个索引可以共存。例如,一个管理列表可能需要 (tenant_id, created_at) 的 B-tree 来加速排序,而搜索页面需要在 tsvector 上的 GIN。只有当两个查询模式都很常见时才保留两者。
如果不确定,从操作符开始看。索引在数据库能用它跳过大量表行时才有意义。
过滤与排序:B-tree 通常胜出
对于大多数常见屏幕,B-tree 是稳妥的选择。如果你的查询是“按某列等于某值过滤,可能还要排序,然后显示第 1 页”,B-tree 通常是优先尝试的方向。
等值过滤是经典场景。像 status、user_id、account_id、type 或 tenant_id 这些列在仪表盘和管理面板中经常出现。B-tree 索引能直接跳到匹配值。
范围过滤也适合 B-tree。当按时间或数值范围过滤时,有序结构很有帮助:created_at >= ...、price BETWEEN ...、id > ...。如果 UI 提供“最近 7 天”或“$50 到 $100”的筛选,B-tree 正好能发挥作用。
排序与分页是 B-tree 最能省力的地方。如果索引顺序与 ORDER BY 匹配,PostgreSQL 往往可以直接返回已排序的行,而不用在内存中对大集合进行排序。
-- A common screen: "My open tickets, newest first"
CREATE INDEX tickets_user_status_created_idx
ON tickets (user_id, status, created_at DESC);
复合索引有一条简单规则:PostgreSQL 只能高效使用索引的最左前缀。想成“从左到右”。对于 (user_id, status, created_at),那些按 user_id(可选再按 status)过滤的查询会受益;只按 status 过滤的查询通常不会。
当屏幕只关心数据的一个切片时,部分索引是很好的升级。常见切片有“仅活跃行”、“未软删除”或“最近活动”。它们让索引更小、更快。
高基数列与额外索引的代价
高基数列有很多唯一值,如 user_id、order_id、email 或精确到秒的 created_at。这类列的索引通常很有效,因为过滤可以迅速把结果缩到表的一个小切片。
低基数列相反:布尔值和小枚举比如 is_active、status IN ('open','closed') 或 plan IN ('free','pro')。对这些列建索引往往令人失望,因为每个值匹配大量行;PostgreSQL 可能选择顺序扫描,因为通过索引跳转仍需读取很多表页。
另一个微妙成本是行的回表(fetching rows)。即便索引快速找到匹配的 ID,数据库通常还得访问表以取得其他列。如果查询只需要少数字段,覆盖索引(covering index)可以帮忙,但它也会让索引更大、更昂贵去维护。
每个额外索引都会增加写成本。插入要写入每个索引;更新会更新所涉索引项。盲目添加“以防万一”的索引会拖慢整个应用,而不仅仅是某一个界面。
实用建议:
- 每个繁忙表从 1-2 个主力索引开始,基于真实的过滤和排序。
- 优先针对出现在
WHERE和ORDER BY的高基数列。 - 对布尔值和微小枚举要谨慎,除非它们与另一个选择性高的列组合。
- 只有在能明确指出要加速的具体查询时才新增索引。
举例:按 assignee_id(高基数)过滤的工单列表受益于索引,而单独按 is_archived = false 则通常不会。
搜索界面:全文、前缀与“包含”
搜索框看起来简单,但用户期望很多:多词、词形变化和合理的排名。在 PostgreSQL 中,这通常是全文搜索:把文本存成 tsvector(预处理文本),用 tsquery(把用户输入解析成术语)来查询。
对于全文搜索,GIN 是常用默认,因为它在回答“这个文档包含这些词吗?”时很快。代价是写操作更重:插入和更新行通常更耗时。
GiST 也能用于全文搜索。它通常更小、更新成本更低,但读取速度常慢于 GIN。如果数据频繁变动(例如事件表),读写之间的权衡会很重要。
前缀搜索不是全文搜索
前缀搜索指“以...开头”,比如按 email 前缀搜索客户。这不是全文搜索的擅长项。对前缀模式,B-tree 索引(通常配合合适的 operator class)可以帮忙,因为它与字符串排序方式匹配。
对于类似 ILIKE '%error%' 的“包含”搜索,B-tree 通常无能为力。这时需要 trigram 索引或其它搜索方案。
当用户既要过滤又要全文搜索
大多数真实场景会把搜索和过滤结合:status、assignee、时间范围、tenant 等。一个实用的配置是:
- 在
tsvector列上建 GIN(或有时 GiST)索引。 - 对最有选择性的过滤列(例如
account_id、status、created_at)建 B-tree 索引。 - 严格遵守“保持最小化”原则,因为太多索引会让写操作变慢。
举例:支持工单界面搜索 “refund delayed” 并过滤 status = 'open' 和某个 account_id。全文搜索返回相关行,B-tree 帮 PostgreSQL 快速缩小到正确的账号和状态。
JSONB 字段:在 GIN 与有针对性的 B-tree 之间选择
JSONB 很适合灵活的数据,但如果把它当普通列来处理,会导致查询变慢。核心决策很简单:你是在“整个 JSON 中搜索”还是只在少数路径上反复过滤?
对于像 metadata @> '{"plan":"pro"}' 这样的包含查询,通常首选 GIN 索引。它专门用于“文档是否包含某个形状?”的场景,并支持键存在检查如 ?、?|、?&。
如果应用主要按一两个 JSON 路径过滤,有针对性的表达式 B-tree 索引通常更快更小。它也在你需要对提取值排序或做数值比较时更有用。
-- Broad support for containment and key checks
CREATE INDEX ON customers USING GIN (metadata);
-- Targeted filters and sorting on one JSON path
CREATE INDEX ON customers ((metadata->>'plan'));
CREATE INDEX ON events (((payload->>'amount')::numeric));
经验法则:
- 当用户搜索多个键、标签或嵌套结构时,用 GIN。
- 当用户反复按某些稳定路径过滤时,用表达式 B-tree。
- 索引出现在真实界面上,而不是全部都索引。
- 如果性能依赖几个你总用的 JSON 键,考虑把它们提升为真实列。
举例:支持工单屏幕可能按 metadata->>'priority' 过滤并按 created_at 排序。为 JSON 的优先级路径和普通的 created_at 建索引。除非用户也搜索标签或嵌套属性,否则跳过广泛的 GIN 索引。
地理与范围查询:GiST 最合适的场景
地理和范围类屏幕通常让 GiST 成为显而易见的选择。GiST 擅长回答“这个东西是否重叠、包含或靠近另一个东西?”而不是“这个值是否等于某个值?”。
地理数据通常表示为点(门店位置)、线(路线)或多边形(配送区域)。常见界面包括“我附近的门店”、“10 公里内的岗位”、“在地图框内显示项目”或“某地址是否在服务区内?”通过 GiST 索引(通常借助 PostGIS 的 geometry/geography 类型)能加速这些空间操作,让数据库跳过大多数行而不是检查每个几何体。
范围类型也类似。PostgreSQL 有 daterange、int4range 等类型,典型问题是重叠:“这个预订是否与已有预订冲突?”或“显示在某周内活跃的订阅”。GiST 能有效支持重叠和包含运算符,因此在日历、排班和可用性检查中很常见。
B-tree 在地理类页面中仍有用处。很多页面先按 tenant、status 或时间过滤,然后才应用空间条件并排序。例如:“只看我公司过去 7 天的配送,按距离最近排序”。GiST 处理空间部分,B-tree 处理选择性过滤和排序。
如何按步骤选择索引
索引选择主要看操作符,而不是列名。同一列可能因你使用的是 =、>、LIKE 'prefix%'、全文搜索、JSON 包含或地理距离而需要不同索引。
把查询按清单读一遍:WHERE 决定哪些行合格,JOIN 决定表如何连接,ORDER BY 决定输出顺序,LIMIT 决定你实际需要多少行。最好的索引通常是能帮你最快找到前 20 行的那个。
一个适用于大多数界面简单流程:
- 写下屏幕使用的精确操作符(例如:
status =、created_at >=、name ILIKE、meta @>、ST_DWithin)。 - 从匹配最有选择性过滤或默认排序的索引开始。如果屏幕按
created_at DESC排序,就从这里开始。 - 只有在经常一起出现相同过滤时才加复合索引。先放等值列,再放范围列,最后放排序键。
- 当你总是过滤到子集时用 partial index;当你查询计算值时用表达式索引(如对大小写不敏感的查找用
lower(email))。 - 用
EXPLAIN ANALYZE验证。只有当它显著减少执行时间并减少读取行数时才保留。
具体示例:一个支持仪表盘按 status 过滤并按最新排序。(status, created_at DESC) 的 B-tree 是强力第一选择。如果同一屏幕还按 JSONB 标志 meta @> '{"vip": true}' 过滤,那是不同的操作符,通常需要单独的 JSON 专用索引。
常见错误(浪费时间并拖慢写入)
一个常见失望来源是为错误的操作符选“正确”的索引。PostgreSQL 只有在查询与索引建立的用途相匹配时才能使用它。如果你的应用用 ILIKE '%term%',在该文本列上建普通 B-tree 索引不会被用到,你依然要扫描表。
另一个陷阱是做巨大的多列索引“以防万一”。它们看起来安全,但维护成本高,且往往不符合真实的查询模式。如果最左列在过滤中没有被使用,其余列可能也帮不上忙。
低选择性列也容易被过度索引。对布尔值如 is_active 或只有少数值的 status 建索引通常没太大作用,除非把索引做成 partial 来匹配你实际查询的切片。
JSONB 也有自己的坑。广泛的 GIN 索引在灵活过滤上很好,但许多 JSONB 路径检查用表达式索引会更快。如果屏幕总是按 payload->>'customer_id' 过滤,为该表达式建索引通常比为整个文档建索引更小更快。
最后,额外索引会增加写操作成本。在频繁更新的表(如工单或订单)上,每次插入和更新都要维护每个索引。
在添加索引前,先停下来想一想:
- 这个索引是否匹配查询实际使用的操作符?
- 能否用一个或两个更聚焦的索引替代宽泛的多列索引?
- 是否应做成 partial index 以避免低选择性带来的噪音?
- 对于 JSONB,是不是表达式索引更合适?
- 该表是否写入密集,索引成本是否超过了读取收益?
在添加(或保留)索引前的快速检查
在创建新索引前,弄清楚应用具体做了什么。一个“挺好”的索引常常变成降低写入性能且占用更多存储的负担。
从你最常用的三个屏幕(或 API 端点)入手,写下它们的精确查询形态:过滤、排序以及用户输入内容。很多“索引问题”其实是“查询不明确”的问题,尤其是在不说清操作符就争论 B-tree vs GIN vs GiST 时。
一个简单清单:
- 选出 3 个真实屏幕并列出它们确切的
WHERE和ORDER BY模式(包括方向和 NULL 处理)。 - 确认操作符类型:相等(
=)、范围(>、BETWEEN)、前缀、包含、重叠或距离。 - 为常见屏幕模式各选一个索引,测试后只保留那些显著减少时间或读取的索引。
- 如果表写入多,规则要严格:额外索引会成倍增加写入成本并增加 vacuum 压力。
- 功能变更后重新检查。新过滤、新默认排序或从“以开头”改为“包含”都可能让旧索引失效。
示例:仪表盘新增默认排序 last_activity DESC。如果你只对 status 建了索引,过滤可能仍快,但排序会增加额外工作量。
例子:将真实应用界面映射到合适索引
决策表只有在你能把它映射到真实界面时才有用。以下是三个常见屏幕及其通常对应的索引。
| 屏幕 | 典型查询模式 | 通常合适的索引 | 原因 |
|---|---|---|---|
| 管理列表:过滤 + 排序 + 文本搜索 | status = 'open' 加 created_at 排序,再在 title/notes 中搜索 | 在 (status, created_at) 上建 B-tree,同时在 tsvector 上建 GIN | 过滤与排序用 B-tree,全文搜索通常用 GIN。 |
| 客户档案:JSON 偏好 + 标志 | prefs->>'theme' = 'dark' 或检测某个键是否存在 | 对 JSONB 列用 GIN(用于灵活键查找),或针对 1-2 个常用键用表达式 B-tree | 根据是否查询很多键或只是少数热键来选择。 |
| 附近位置:距离 + 分类过滤 | 在 X 公里内的地点,按 category_id 过滤 | geometry/geography 上的 GiST 和 category_id 上的 B-tree | GiST 处理距离/范围,B-tree 处理常规过滤。 |
把这个方法应用到 UI 上:
- 列出所有会缩小结果的控件(过滤)。
- 标注默认排序。
- 明确搜索行为(全文、以开头还是包含)。
- 标出“特殊”字段(JSONB、地理、范围)。
下一步:把索引作为构建流程的一部分
好的索引遵循你的界面:用户点的过滤、期望的排序和他们实际使用的搜索框。在开发时把索引当成一种习惯,你就能避免大多数性能惊喜。
让它可复现:识别某个屏幕运行的 1-3 个查询,添加与之匹配的最小索引,用真实数据测试,然后移除不明显带来收益的索引。
如果你在构建内部工具或客户门户,尽早规划索引需求,因为这类应用常常通过增加更多过滤和更多列表屏幕来增长。如果你使用 AppMaster (appmaster.io) 构建,建议把每个屏幕的过滤和排序配置视为具体的查询契约,然后只为这些真实点击匹配的索引进行添加。
常见问题
先把你最繁忙的屏幕在 SQL 层面写清楚:WHERE 用了什么操作符,ORDER BY 如何,LIMIT 是多少。B-tree 常用于相等、范围和排序;GIN 适合“包含某项/某值”类检查(如全文或 JSONB 包含);GiST 适合重叠、距离或“靠近/在内”这类的查询。
当你按精确值过滤、按范围筛选或需要按特定顺序返回结果时,B-tree 是合适的选择。它常用于后台列表、仪表盘和分页场景,查询形态通常是“过滤、排序、限制”。
当每行可能匹配多个键或术语,且你的查询在问“这行是否包含 X?”时,使用 GIN。它是全文搜索(在 tsvector 上用 @@)和 JSONB/数组包含(如 @> 或键存在检查)的常见默认选择。
GiST 适合那些不能用简单顺序表示的数据,查询关注点是距离、重叠或包含关系。常见场景包括 PostGIS 的“附近/在某半径内”查询以及 PostgreSQL 的范围类型(检查重叠等)。
如果查询同时有过滤和排序,先把用于相等比较的列放前面,再放范围列,最后放排序列。例如 (user_id, status, created_at DESC) 在总是按 user_id 和 status 过滤且按最新排序时效果很好;如果只按 status 过滤,它通常帮不了多少。
当某个屏幕总是只关注数据子集时,偏置索引(partial index)很有用,例如“只看 open tickets”或“未软删除”。这样索引更小、更快,也避免为不关心的行付出索引维护成本。
对布尔值或少数枚举值直接建普通索引通常效果有限,因为每个值匹配大量行,PostgreSQL 可能会选择顺序扫描。当这些列与高选择性的列组合使用时,或把索引做成 partial 来匹配具体切片,才更有意义。
当你需要对整个 JSONB 文档做灵活的包含或键检查时,用 GIN 索引。当你反复按 1-2 个稳定路径过滤或排序时,针对这些路径建表达式 B-tree 索引通常更小更快,例如 (metadata->>'plan') 或把提取值转成数值再索引。
对于“以...开头”的搜索(例如 email LIKE 'abc%'),B-tree 可以利用字符串的顺序来加速。对于“包含”搜索(例如 ILIKE '%abc%' 或 LIKE '%term%'),普通 B-tree 一般无效,需要 trigram 索引或不同的搜索设计。
建索引时,做最小化且针对具体、高流量查询模式的索引;用 EXPLAIN ANALYZE 验证在真实数据量下是否显著减少执行时间或读取行数。写入密集的表尤其要谨慎,多余索引会放慢写入并增加维护负担。


