PostgreSQL笔记16: 索引基础——概念、类型、创建与最佳实践

概述

索引是关系型数据库中最为核心的性能优化手段。对于查询操作而言,索引如同书籍的目录,能够帮助数据库快速定位到目标数据所在的位置,而无需逐页翻阅整本"书"。在没有索引的情况下,PostgreSQL 必须执行顺序扫描(Sequential Scan)------即逐行遍历整个表,直到找到匹配的行。随着表数据量的增长,这种扫描方式的效率呈线性下降。

本文将从索引的基本概念、常见类型、创建语法及注意事项入手,帮助读者建立索引知识的基础框架。后续文章将深入探讨索引的底层实现原理(如 B-Tree 结构、页分裂机制)以及更高级的优化策略。

索引的作用与性能对比

顺序扫描 vs. 索引扫描

在没有索引的情况下,PostgreSQL 对表的查询只能采用顺序扫描方式,即从表的第一个数据块开始,逐行读取直至末尾。这种方式的时间复杂度为 O ( n ) O(n) O(n),其中 n n n 为表的数据行数。

而有了索引之后,查询优化器可以选择索引扫描(Index Scan) ,通过索引结构快速定位到目标行的物理存储位置(即 CTID,包含数据块编号与块内偏移量),从而将查询复杂度降低至 O ( log ⁡ n ) O(\log n) O(logn) 级别。

性能对比演示

以下通过一个简单的实验来直观感受索引带来的性能提升:

sql 复制代码
-- 开启查询计时
\timing on

-- 创建测试表
CREATE TABLE test_index (
    id INT
);

-- 插入100万行数据(利用 generate_series)
INSERT INTO test_index (id)
SELECT generate_series(1, 1000000);

-- 无索引时的查询(顺序扫描)
SELECT * FROM test_index WHERE id = 99999;
-- 执行时间示例:约 55 ms(取决于硬件配置)

此时表中尚无索引,PostgreSQL 只能进行顺序扫描。接下来创建索引并再次执行相同的查询:

sql 复制代码
-- 创建 B-Tree 索引(默认索引类型)
CREATE INDEX idx_test_index_id ON test_index (id);

-- 再次执行查询
SELECT * FROM test_index WHERE id = 99999;
-- 执行时间示例:约 0.5 ms

从 55 ms 降至 0.5 ms,性能提升超过 100 倍。随着数据量的进一步增长和查询条件的复杂化,这一差距会更加显著。

CTID:索引与数据行的纽带

PostgreSQL 中每个表行都有一个隐式的系统列 CTID,它记录了该行所在的数据块编号(Block Number)块内偏移量(Offset) 。索引中存储的并非完整的数据行,而是"索引键值 + 对应的 CTID"这样一个键值对。当查询通过索引找到匹配的键值后,数据库便可根据 CTID 直接定位到数据行的物理存储位置,从而完成数据的检索。

索引类型

PostgreSQL 提供了多种索引方法(Index Methods),包括 B-tree、Hash、GiST、SP-GiST、GIN、BRIN 以及扩展模块中的 Bloom 。默认情况下,CREATE INDEX 命令创建的是 B-tree 索引,它适用于绝大多数常见场景。

B-Tree 索引

B-tree(多路平衡树)是 PostgreSQL 的默认索引类型。任何可以被排序为明确定义的线性顺序的数据类型都可以使用 B-tree 索引。

B-tree 索引支持以下运算符的查询条件:

  • 等值比较:=
  • 范围比较:<<=>>=
  • 组合条件:BETWEENIN
  • IS NULLIS NOT NULL
  • 前缀匹配的模式查询:LIKE 'foo%'~ '^foo'(需注意 locale 设置)

B-tree 索引还可以用于返回已排序的数据,在某些场景下可避免额外的显式排序操作。

PostgreSQL B-tree 索引是一种多级树结构 ,树的每一层均可作为页面的双向链表。索引的第一个段文件起始位置存储着一个固定的元页面(Metapage) 。树的最底层为叶子页面(Leaf Pages) ,存储指向表行的指针(即 CTID);其余层级为内部页面(Internal Pages),存储指向下一层级的指针。通常情况下,超过 99% 的页面为叶子页面。

Hash 索引

Hash 索引存储的是索引列值的 32 位哈希码,因此仅支持等值比较(= 运算符)。它不适用于范围查询或排序操作。

GiST、SP-GiST、GIN、BRIN

这些索引类型分别适用于不同的特殊场景:

  • GiST(Generalized Search Tree):一种通用的索引基础设施,支持几何数据类型、全文搜索、最近邻搜索等。
  • SP-GiST(Space-Partitioned GiST):适用于非平衡数据结构,如四叉树、kd-tree 等。
  • GIN(Generalized Inverted Index):通用倒排索引,适用于数组、全文搜索等包含多个键值的数据类型。
  • BRIN(Block Range Index):块范围索引,适用于数据物理分布与值域高度相关的超大表。

常见索引类型详解

主键索引(Primary Key Index)

主键约束会自动在对应列上创建一个唯一索引。主键索引具有以下特性:

  • 值必须唯一(不可重复)
  • 不可为空NOT NULL
sql 复制代码
CREATE TABLE test_pk (
    id INT PRIMARY KEY
);

-- 查看表上的索引
\d test_pk
-- 输出中可见 "test_pk_pkey" PRIMARY KEY, btree (id)

尝试插入重复值或空值将触发错误:

sql 复制代码
INSERT INTO test_pk VALUES (1);  -- 成功
INSERT INTO test_pk VALUES (1);  -- ERROR: duplicate key value violates unique constraint
INSERT INTO test_pk VALUES (NULL);  -- ERROR: null value in column "id" violates not-null constraint

唯一索引(Unique Index)

唯一索引与主键索引类似,要求列值唯一,但允许为空值(NULL

sql 复制代码
CREATE TABLE test_unique (
    id INT
);

CREATE UNIQUE INDEX idx_unique_id ON test_unique (id);

INSERT INTO test_unique VALUES (1);    -- 成功
INSERT INTO test_unique VALUES (1);    -- ERROR: duplicate key value violates unique constraint
INSERT INTO test_unique VALUES (NULL); -- 成功
INSERT INTO test_unique VALUES (NULL); -- 成功(NULL 不等于 NULL)

需要注意的是,在 SQL 标准中,NULL 不等于任何值(包括 NULL 本身),因此唯一索引允许存在多个 NULL 值。

表达式索引(Expression Index / Functional Index)

表达式索引(亦称函数索引)的索引列不一定是表的原始列,而是基于一个或多个列计算的函数或标量表达式。当查询条件中使用了相同的表达式时,优化器便可利用该索引。

适用场景 :查询中经常对列应用函数(如 LOWERUPPER、日期截断等),且这些函数为不可变(IMMUTABLE) 的。

sql 复制代码
CREATE TABLE test_expr (
    info TEXT
);

-- 常规索引无法支持大小写不敏感的查询
CREATE INDEX idx_info ON test_expr (info);

-- 查询无法使用 idx_info(因为条件中使用了函数)
SELECT * FROM test_expr WHERE LOWER(info) = 'hello';

-- 创建表达式索引
CREATE INDEX idx_info_lower ON test_expr (LOWER(info));

-- 此时查询可以使用 idx_info_lower
SELECT * FROM test_expr WHERE LOWER(info) = 'hello';

表达式索引的语法要求:当表达式仅为函数调用时,括号可省略;否则需要用括号将表达式括起。

sql 复制代码
-- 函数调用:括号可省略
CREATE INDEX idx1 ON test_expr (LOWER(info));

-- 一般表达式:必须加括号
CREATE INDEX idx2 ON test_expr ((info || 'suffix'));

表达式索引的维护成本相对较高,因为每次插入或更新(非 HOT 更新)时都需要重新计算表达式值。但在查询时,表达式值已存储在索引中,无需重新计算。

多列索引(Multicolumn Index)

多列索引是在表的多个列上定义的索引。当查询条件同时涉及这些列时,多列索引可以一次性过滤多个条件,避免多次索引扫描或回表操作。

sql 复制代码
CREATE TABLE test_multi (
    id INT,
    info TEXT
);

-- 创建多列索引(最多支持 32 列)
CREATE INDEX idx_multi ON test_multi (id, info);

-- 以下查询可以利用该索引
SELECT * FROM test_multi WHERE id = 100 AND info = 'xxx';
SELECT * FROM test_multi WHERE id = 100;  -- 也可利用(最左前缀原则)
-- 以下查询无法利用该索引(未使用索引的最左列)
SELECT * FROM test_multi WHERE info = 'xxx';

多列索引遵循最左前缀匹配 原则:查询条件必须包含索引定义中最左侧的列,才能使用该索引。在设计多列索引时,应将选择性较高(即唯一值较多)的列放在前面。

部分索引(Partial Index)

部分索引(Partial Index)是在表的子集 上建立的索引,该子集由一个条件表达式(称为谓词 Predicate )定义。索引中仅包含满足该谓词的行

适用场景

  1. 排除常见值:如果某个值出现在大多数行中,查询优化器通常不会使用索引(因为选择性太低),将这些行排除在索引之外可以减小索引体积。
  2. 仅索引热点数据:如果业务查询只关注表中的一小部分数据(如"未结订单"),可仅对该子集建立索引。
sql 复制代码
CREATE TABLE test_partial (
    id INT
);

-- 插入100万行数据(假设大部分查询只关注 id < 50000 的行)
INSERT INTO test_partial (id) SELECT generate_series(1, 1000000);

-- 创建部分索引:仅索引 id < 50000 的行
CREATE INDEX idx_partial ON test_partial (id) WHERE id < 50000;

-- 查询可以使用该部分索引
SELECT * FROM test_partial WHERE id = 100;

-- 查询无法使用该部分索引(id 不在索引的谓词范围内)
SELECT * FROM test_partial WHERE id = 60000;

部分索引的优势在于节省存储空间降低维护成本------索引更新操作仅需在满足谓词条件时进行。

索引的创建与删除

创建索引

CREATE INDEX 命令的基本语法如下:

sql 复制代码
CREATE [ UNIQUE ] INDEX [ CONCURRENTLY ] [ [ IF NOT EXISTS ] name ]
    ON table_name [ USING method ]
    ( { column_name | ( expression ) } [ ASC | DESC ] [, ...] )
    [ WHERE predicate ];

各参数说明:

  • UNIQUE:创建唯一索引,系统会在索引创建时及每次数据变更时检查重复值。
  • CONCURRENTLY:在线创建索引,不阻塞表的并发写入操作。
  • IF NOT EXISTS:若同名索引已存在,则不抛出错误。
  • USING method:指定索引方法,默认为 btree
  • WHERE predicate:创建部分索引的谓词条件。

命名规则 :若未显式指定索引名称,PostgreSQL 会自动生成一个名称,规则为 {表名}_{列名}_idx(或类似模式)。需要注意的是,PostgreSQL 中的标识符(包括索引名)最大长度为 63 个字符,超长名称会被自动截断。

删除索引

sql 复制代码
DROP INDEX index_name;

由于 PostgreSQL 中索引名在同一 Schema 下是唯一的,因此无需指定表名。

索引的维护成本与创建时机

索引的两面性

索引虽然能大幅提升查询性能,但并非"多多益善"。索引的维护成本体现在以下方面:

  1. 存储空间:索引本身需要占用额外的磁盘空间。
  2. 写入性能损耗 :每次 INSERTUPDATEDELETE 操作都需要同步更新索引。更新操作即使只修改了未被索引的列,也可能需要更新索引中的物理指针(指向新行版本)。
  3. MVCC 版本链:在 PostgreSQL 的 MVCC 机制下,每次更新都会产生新的行版本,索引也需要为每个新版本维护对应的索引项,这可能导致"版本抖动(Version Churn)"。

何时应该创建索引

以下场景适合创建索引:

  • 高选择性列:列中唯一值较多(如身份证号、邮箱、ID 等),索引能够有效过滤大部分数据。
  • **频繁出现在 WHEREJOINORDER BYGROUP BY 子句中的列。
  • 稀疏访问模式 :业务查询仅关注表中的部分数据,适合使用部分索引

以下场景不适合创建索引:

  • 低选择性列:列中唯一值极少(如性别,只有"男"/"女"),索引无法有效过滤数据,反而增加了维护开销。
  • 频繁更新的表:索引的维护成本可能超过查询收益。
  • 小表:顺序扫描的开销本身很低,索引带来的收益微乎其微。

在线创建索引:CREATE INDEX CONCURRENTLY

默认情况下,CREATE INDEX 会持有表上的排他锁(Access Exclusive Lock) ,阻塞所有写入操作(INSERTUPDATEDELETE),直到索引创建完成。对于生产环境中的大表,这可能导致长时间的写入阻塞。

PostgreSQL 提供了 CONCURRENTLY 选项来在线创建索引

sql 复制代码
CREATE INDEX CONCURRENTLY idx_name ON table_name (column);

使用 CONCURRENTLY 时,PostgreSQL 不会阻塞表的并发写入操作。但需要注意以下限制:

  1. 创建速度较慢:需要执行两次表扫描,并等待所有可能修改或使用该索引的事务结束。
  2. 创建失败时可能留下无效索引,需要手动清理。
  3. 不能在事务块内执行。

API 速览

本节梳理了本文涉及的核心 PostgreSQL SQL 命令与系统功能。

CREATE INDEX

属性 说明
所属 PostgreSQL DDL 命令
语法 `CREATE [ UNIQUE ] INDEX [ CONCURRENTLY ] [ [ IF NOT EXISTS ] name ] ON table_name [ USING method ] ( { column_name
参数 UNIQUE:唯一索引;CONCURRENTLY:在线创建;IF NOT EXISTS:条件创建;USING method:索引方法(默认 btree);WHERE predicate:部分索引谓词
返回值 无(DDL 命令)
适用版本 PostgreSQL 8.0+(CONCURRENTLY 自 8.2 引入,持续增强)

示例

sql 复制代码
-- 基本 B-Tree 索引
CREATE INDEX idx_employees_last_name ON employees (last_name);

-- 唯一索引
CREATE UNIQUE INDEX idx_employees_email ON employees (email);

-- 表达式索引
CREATE INDEX idx_employees_lower_email ON employees (LOWER(email));

-- 多列索引
CREATE INDEX idx_employees_name_dept ON employees (last_name, department_id);

-- 部分索引
CREATE INDEX idx_employees_active ON employees (last_name) WHERE status = 'ACTIVE';

-- 在线创建索引
CREATE INDEX CONCURRENTLY idx_employees_hire_date ON employees (hire_date);

DROP INDEX

属性 说明
所属 PostgreSQL DDL 命令
语法 `DROP INDEX [ CONCURRENTLY ] [ IF EXISTS ] name [, ...] [ CASCADE
参数 CONCURRENTLY:在线删除;IF EXISTS:条件删除;CASCADE:级联删除依赖对象
返回值 无(DDL 命令)
适用版本 PostgreSQL 8.0+

示例

sql 复制代码
DROP INDEX idx_employees_last_name;
DROP INDEX CONCURRENTLY IF EXISTS idx_employees_hire_date;

\d --- 查看表结构(psql 元命令)

属性 说明
所属 psql 客户端元命令
语法 \d table_name
功能 显示指定表的列信息、索引、约束等

示例

sql 复制代码
\d test_index
-- 输出示例:
--         Table "public.test_index"
--  Column |  Type   | Collation | Nullable | Default
-- --------+---------+-----------+----------+---------
--  id     | integer |           |          |
-- Indexes:
--     "idx_test_index_id" btree (id)

系统列 CTID

属性 说明
所属 PostgreSQL 系统列(隐式)
类型 tid(Tuple ID)
内容 数据块编号(Block Number)+ 块内偏移量(Offset)
用途 索引中存储的物理行指针,用于精确定位数据行

示例

sql 复制代码
SELECT ctid, * FROM test_index WHERE id = 99999;
-- ctid 示例输出:(100, 5) 表示第100个数据块的第5个偏移位置

Demo 简单示例

以下是一个完整的 Node.js 示例,演示了 PostgreSQL 索引的创建、查询性能对比以及各类索引的使用。

运行说明

环境要求

  • Node.js 16+
  • PostgreSQL 12+
  • 已安装 pg

安装依赖

bash 复制代码
npm init -y
npm install pg

启动方式

bash 复制代码
node index.js

完整代码

js 复制代码
const { Client } = require('pg');

// PostgreSQL 连接配置
const client = new Client({
    host: 'localhost',
    port: 5432,
    database: 'testdb',
    user: 'postgres',
    password: 'postgres',
});

async function runDemo() {
    await client.connect();
    console.log('Connected to PostgreSQL');

    try {
        // ============================================================
        // 1. 创建测试表并插入数据
        // ============================================================
        await client.query(`
            DROP TABLE IF EXISTS demo_index CASCADE;
            CREATE TABLE demo_index (
                id INT,
                info TEXT,
                status VARCHAR(20)
            );
        `);
        console.log('Table "demo_index" created.');

        // 插入 50 万行测试数据
        await client.query(`
            INSERT INTO demo_index (id, info, status)
            SELECT
                generate_series(1, 500000),
                'info_' || generate_series(1, 500000),
                CASE WHEN generate_series(1, 500000) % 10 = 0 THEN 'INACTIVE' ELSE 'ACTIVE' END
        `);
        console.log('Inserted 500,000 rows.');

        // ============================================================
        // 2. 无索引时的顺序扫描(Sequential Scan)
        // ============================================================
        const beforeNoIndex = Date.now();
        const res1 = await client.query(
            'SELECT * FROM demo_index WHERE id = 123456;'
        );
        const afterNoIndex = Date.now();
        console.log(`[No Index] Query time: ${afterNoIndex - beforeNoIndex} ms`);

        // ============================================================
        // 3. 创建 B-Tree 索引
        // ============================================================
        // PostgreSQL 原生指令:
        // CREATE INDEX idx_demo_index_id ON demo_index (id);
        await client.query('CREATE INDEX idx_demo_index_id ON demo_index (id);');
        console.log('Index "idx_demo_index_id" created.');

        const beforeWithIndex = Date.now();
        const res2 = await client.query(
            'SELECT * FROM demo_index WHERE id = 123456;'
        );
        const afterWithIndex = Date.now();
        console.log(`[With Index] Query time: ${afterWithIndex - beforeWithIndex} ms`);

        // ============================================================
        // 4. 表达式索引(Expression Index)
        // ============================================================
        // PostgreSQL 原生指令:
        // CREATE INDEX idx_demo_index_lower_info ON demo_index (LOWER(info));
        await client.query(`
            CREATE INDEX idx_demo_index_lower_info ON demo_index (LOWER(info));
        `);
        console.log('Expression index "idx_demo_index_lower_info" created.');

        const beforeExpr = Date.now();
        const res3 = await client.query(
            "SELECT * FROM demo_index WHERE LOWER(info) = 'info_123456';"
        );
        const afterExpr = Date.now();
        console.log(`[Expression Index] Query time: ${afterExpr - beforeExpr} ms`);

        // ============================================================
        // 5. 部分索引(Partial Index)
        // ============================================================
        // PostgreSQL 原生指令:
        // CREATE INDEX idx_demo_index_active ON demo_index (id) WHERE status = 'ACTIVE';
        await client.query(`
            CREATE INDEX idx_demo_index_active ON demo_index (id)
            WHERE status = 'ACTIVE';
        `);
        console.log('Partial index "idx_demo_index_active" created.');

        // 查询可以使用部分索引(status = 'ACTIVE' 且 id 在索引范围内)
        const beforePartial = Date.now();
        const res4 = await client.query(
            "SELECT * FROM demo_index WHERE id = 123456 AND status = 'ACTIVE';"
        );
        const afterPartial = Date.now();
        console.log(`[Partial Index - ACTIVE] Query time: ${afterPartial - beforePartial} ms`);

        // 查询无法使用部分索引(status = 'INACTIVE' 不在索引谓词范围内)
        const beforePartialMiss = Date.now();
        const res5 = await client.query(
            "SELECT * FROM demo_index WHERE id = 123456 AND status = 'INACTIVE';"
        );
        const afterPartialMiss = Date.now();
        console.log(`[Partial Index - INACTIVE] Query time: ${afterPartialMiss - beforePartialMiss} ms`);

        // ============================================================
        // 6. 查看索引信息
        // ============================================================
        // PostgreSQL 原生指令:\d demo_index
        const indexInfo = await client.query(`
            SELECT
                indexname,
                indexdef
            FROM pg_indexes
            WHERE tablename = 'demo_index';
        `);
        console.log('\n=== Indexes on "demo_index" ===');
        indexInfo.rows.forEach(row => {
            console.log(`  ${row.indexname}: ${row.indexdef}`);
        });

        // ============================================================
        // 7. 查看查询计划(EXPLAIN)
        // ============================================================
        const explainResult = await client.query(`
            EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
            SELECT * FROM demo_index WHERE id = 123456;
        `);
        console.log('\n=== EXPLAIN (with index) ===');
        console.log(explainResult.rows.map(r => r['QUERY PLAN']).join('\n'));

    } catch (err) {
        console.error('Error:', err.message);
    } finally {
        await client.end();
    }
}

runDemo();

代码说明

  1. 连接与建表 :使用 pg 驱动连接 PostgreSQL,创建 demo_index 表并插入 50 万行测试数据。
  2. 性能对比 :分别在无索引和有索引的情况下执行相同的 WHERE id = ? 查询,对比执行时间。
  3. 表达式索引 :在 LOWER(info) 上创建索引,演示函数索引的用法。
  4. 部分索引 :仅对 status = 'ACTIVE' 的行创建索引,演示部分索引的过滤效果。
  5. 索引信息查询 :通过 pg_indexes 系统视图查看表上的所有索引定义。
  6. 执行计划分析 :使用 EXPLAIN (ANALYZE, BUFFERS) 查看索引扫描的详细执行计划。

技术点总结

  • 顺序扫描 vs. 索引扫描:通过实际计时数据直观对比两种扫描方式的性能差异。
  • B-Tree 索引:PostgreSQL 默认索引类型,支持等值和范围查询。
  • 表达式索引:在函数或表达式结果上建立索引,支持大小写不敏感查询等场景。
  • 部分索引:仅在表子集上建立索引,节省空间并降低维护成本。
  • 执行计划分析 :使用 EXPLAIN 验证索引是否被使用。

多语言示例程序扩展

本文基于上面的 Node.js 示例,扩展提供 GoPythonJava 三种语言的完整实现。所有示例均演示了 PostgreSQL 索引的创建、查询性能对比以及表达式索引、部分索引的使用方式。

通用环境要求

  • PostgreSQL 12+,已创建测试数据库(如 testdb
  • 各语言对应的数据库驱动(详见各节)
  • 表结构统一为:
sql 复制代码
CREATE TABLE demo_index (
    id INT,
    info TEXT,
    status VARCHAR(20)
);

插入 50 万行测试数据:

sql 复制代码
INSERT INTO demo_index (id, info, status)
SELECT
    generate_series(1, 500000),
    'info_' || generate_series(1, 500000),
    CASE WHEN generate_series(1, 500000) % 10 = 0 THEN 'INACTIVE' ELSE 'ACTIVE' END;

索引创建语句(按顺序执行):

sql 复制代码
CREATE INDEX idx_demo_index_id ON demo_index (id);
CREATE INDEX idx_demo_index_lower_info ON demo_index (LOWER(info));
CREATE INDEX idx_demo_index_active ON demo_index (id) WHERE status = 'ACTIVE';

Go 示例程序

运行说明
  • 环境 :Go 1.18+,安装驱动:go get github.com/lib/pq
  • 启动go run main.go
  • 修改连接字符串中的数据库凭据。
完整代码
go 复制代码
package main

import (
    "database/sql"
    "fmt"
    "log"
    "time"

    _ "github.com/lib/pq"
)

func main() {
    // 连接字符串:host=localhost port=5432 user=postgres password=postgres dbname=testdb sslmode=disable
    connStr := "postgres://postgres:postgres@localhost:5432/testdb?sslmode=disable"
    db, err := sql.Open("postgres", connStr)
    if err != nil {
        log.Fatal(err)
    }
    defer db.Close()

    if err := db.Ping(); err != nil {
        log.Fatal(err)
    }
    fmt.Println("Connected to PostgreSQL")

    // 1. 删除并重建表(若需要)
    _, err = db.Exec(`DROP TABLE IF EXISTS demo_index CASCADE`)
    if err != nil {
        log.Fatal(err)
    }
    _, err = db.Exec(`CREATE TABLE demo_index (
        id INT,
        info TEXT,
        status VARCHAR(20)
    )`)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("Table created")

    // 插入 50 万行
    _, err = db.Exec(`
        INSERT INTO demo_index (id, info, status)
        SELECT
            generate_series(1, 500000),
            'info_' || generate_series(1, 500000),
            CASE WHEN generate_series(1, 500000) % 10 = 0 THEN 'INACTIVE' ELSE 'ACTIVE' END
    `)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("Inserted 500,000 rows")

    // 2. 无索引查询
    start := time.Now()
    row := db.QueryRow(`SELECT * FROM demo_index WHERE id = 123456`)
    var id int
    var info string
    var status string
    if err := row.Scan(&id, &info, &status); err != nil {
        log.Fatal(err)
    }
    elapsed := time.Since(start)
    fmt.Printf("[No Index] Query time: %v ms\n", elapsed.Milliseconds())

    // 3. 创建索引 (id)
    _, err = db.Exec(`CREATE INDEX idx_demo_index_id ON demo_index (id)`)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("Index idx_demo_index_id created")

    start = time.Now()
    row = db.QueryRow(`SELECT * FROM demo_index WHERE id = 123456`)
    if err := row.Scan(&id, &info, &status); err != nil {
        log.Fatal(err)
    }
    elapsed = time.Since(start)
    fmt.Printf("[With Index] Query time: %v ms\n", elapsed.Milliseconds())

    // 4. 表达式索引
    _, err = db.Exec(`CREATE INDEX idx_demo_index_lower_info ON demo_index (LOWER(info))`)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("Expression index idx_demo_index_lower_info created")

    start = time.Now()
    row = db.QueryRow(`SELECT * FROM demo_index WHERE LOWER(info) = 'info_123456'`)
    if err := row.Scan(&id, &info, &status); err != nil {
        log.Fatal(err)
    }
    elapsed = time.Since(start)
    fmt.Printf("[Expression Index] Query time: %v ms\n", elapsed.Milliseconds())

    // 5. 部分索引
    _, err = db.Exec(`CREATE INDEX idx_demo_index_active ON demo_index (id) WHERE status = 'ACTIVE'`)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("Partial index idx_demo_index_active created")

    start = time.Now()
    row = db.QueryRow(`SELECT * FROM demo_index WHERE id = 123456 AND status = 'ACTIVE'`)
    if err := row.Scan(&id, &info, &status); err != nil {
        log.Fatal(err)
    }
    elapsed = time.Since(start)
    fmt.Printf("[Partial Index - ACTIVE] Query time: %v ms\n", elapsed.Milliseconds())

    // 查询无法使用部分索引 (INACTIVE)
    start = time.Now()
    row = db.QueryRow(`SELECT * FROM demo_index WHERE id = 123456 AND status = 'INACTIVE'`)
    if err := row.Scan(&id, &info, &status); err != nil {
        // 可能无数据,忽略
    }
    elapsed = time.Since(start)
    fmt.Printf("[Partial Index - INACTIVE] Query time: %v ms\n", elapsed.Milliseconds())

    // 6. 查看索引定义
    rows, err := db.Query(`
        SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'demo_index'
    `)
    if err != nil {
        log.Fatal(err)
    }
    defer rows.Close()
    fmt.Println("\n=== Indexes on 'demo_index' ===")
    for rows.Next() {
        var idxName, idxDef string
        if err := rows.Scan(&idxName, &idxDef); err != nil {
            log.Fatal(err)
        }
        fmt.Printf("  %s: %s\n", idxName, idxDef)
    }

    // 7. EXPLAIN 分析
    var plan string
    err = db.QueryRow(`
        EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
        SELECT * FROM demo_index WHERE id = 123456
    `).Scan(&plan)
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("\n=== EXPLAIN (with index) ===")
    fmt.Println(plan)
}
技术点总结
  • 使用 database/sql + lib/pq 驱动操作 PostgreSQL。
  • 时序测量采用 time.Since
  • 执行 EXPLAIN 并获取文本结果。
  • 错误处理遵循 Go 惯例。

Python 示例程序

运行说明
  • 环境 :Python 3.8+,安装依赖:pip install psycopg2-binary
  • 启动python demo.py
  • 修改连接参数(host, port, database, user, password)。
完整代码
python 复制代码
import psycopg2
import time

def main():
    conn = psycopg2.connect(
        host="localhost",
        port=5432,
        database="testdb",
        user="postgres",
        password="postgres"
    )
    conn.autocommit = True
    cur = conn.cursor()
    print("Connected to PostgreSQL")

    # 1. 创建表
    cur.execute("DROP TABLE IF EXISTS demo_index CASCADE")
    cur.execute("""
        CREATE TABLE demo_index (
            id INT,
            info TEXT,
            status VARCHAR(20)
        )
    """)
    print("Table created")

    # 插入 50 万行
    cur.execute("""
        INSERT INTO demo_index (id, info, status)
        SELECT
            generate_series(1, 500000),
            'info_' || generate_series(1, 500000),
            CASE WHEN generate_series(1, 500000) % 10 = 0 THEN 'INACTIVE' ELSE 'ACTIVE' END
    """)
    print("Inserted 500,000 rows")

    # 2. 无索引查询
    start = time.time()
    cur.execute("SELECT * FROM demo_index WHERE id = 123456")
    row = cur.fetchone()
    elapsed = (time.time() - start) * 1000
    print(f"[No Index] Query time: {elapsed:.2f} ms")

    # 3. 创建索引 (id)
    cur.execute("CREATE INDEX idx_demo_index_id ON demo_index (id)")
    print("Index idx_demo_index_id created")

    start = time.time()
    cur.execute("SELECT * FROM demo_index WHERE id = 123456")
    row = cur.fetchone()
    elapsed = (time.time() - start) * 1000
    print(f"[With Index] Query time: {elapsed:.2f} ms")

    # 4. 表达式索引
    cur.execute("CREATE INDEX idx_demo_index_lower_info ON demo_index (LOWER(info))")
    print("Expression index idx_demo_index_lower_info created")

    start = time.time()
    cur.execute("SELECT * FROM demo_index WHERE LOWER(info) = 'info_123456'")
    row = cur.fetchone()
    elapsed = (time.time() - start) * 1000
    print(f"[Expression Index] Query time: {elapsed:.2f} ms")

    # 5. 部分索引
    cur.execute("CREATE INDEX idx_demo_index_active ON demo_index (id) WHERE status = 'ACTIVE'")
    print("Partial index idx_demo_index_active created")

    start = time.time()
    cur.execute("SELECT * FROM demo_index WHERE id = 123456 AND status = 'ACTIVE'")
    row = cur.fetchone()
    elapsed = (time.time() - start) * 1000
    print(f"[Partial Index - ACTIVE] Query time: {elapsed:.2f} ms")

    start = time.time()
    cur.execute("SELECT * FROM demo_index WHERE id = 123456 AND status = 'INACTIVE'")
    row = cur.fetchone()
    elapsed = (time.time() - start) * 1000
    print(f"[Partial Index - INACTIVE] Query time: {elapsed:.2f} ms")

    # 6. 查看索引定义
    cur.execute("SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'demo_index'")
    print("\n=== Indexes on 'demo_index' ===")
    for idx_name, idx_def in cur.fetchall():
        print(f"  {idx_name}: {idx_def}")

    # 7. EXPLAIN 分析
    cur.execute("EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT * FROM demo_index WHERE id = 123456")
    plan = cur.fetchone()[0]
    print("\n=== EXPLAIN (with index) ===")
    print(plan)

    cur.close()
    conn.close()

if __name__ == "__main__":
    main()
技术点总结
  • 使用 psycopg2 驱动,autocommit=True 简化事务管理。
  • 时间测量使用 time.time() 转换为毫秒。
  • EXPLAIN 结果以文本形式获取并打印。
  • 简洁的错误处理(实际生产需增强)。

Java 示例程序

运行说明
  • 环境 :JDK 11+,Maven 或直接添加 PostgreSQL JDBC 驱动(org.postgresql:postgresql:42.6.0
  • 编译运行 :使用 Maven 或 javac -cp postgresql-42.6.0.jar Demo.javajava -cp .:postgresql-42.6.0.jar Demo(Windows 用 ; 分隔)
  • 修改连接 URL 中的用户名/密码/数据库名。
完整代码
java 复制代码
import java.sql.*;
import java.util.Properties;

public class Demo {
    public static void main(String[] args) {
        String url = "jdbc:postgresql://localhost:5432/testdb";
        Properties props = new Properties();
        props.setProperty("user", "postgres");
        props.setProperty("password", "postgres");

        try (Connection conn = DriverManager.getConnection(url, props)) {
            System.out.println("Connected to PostgreSQL");

            // 1. 创建表
            try (Statement stmt = conn.createStatement()) {
                stmt.execute("DROP TABLE IF EXISTS demo_index CASCADE");
                stmt.execute("""
                    CREATE TABLE demo_index (
                        id INT,
                        info TEXT,
                        status VARCHAR(20)
                    )
                """);
                System.out.println("Table created");

                // 插入 50 万行
                stmt.execute("""
                    INSERT INTO demo_index (id, info, status)
                    SELECT
                        generate_series(1, 500000),
                        'info_' || generate_series(1, 500000),
                        CASE WHEN generate_series(1, 500000) % 10 = 0 THEN 'INACTIVE' ELSE 'ACTIVE' END
                """);
                System.out.println("Inserted 500,000 rows");
            }

            // 2. 无索引查询
            long start = System.currentTimeMillis();
            try (Statement stmt = conn.createStatement();
                 ResultSet rs = stmt.executeQuery("SELECT * FROM demo_index WHERE id = 123456")) {
                if (rs.next()) {
                    // 读取数据(略)
                }
            }
            long elapsed = System.currentTimeMillis() - start;
            System.out.println("[No Index] Query time: " + elapsed + " ms");

            // 3. 创建索引 (id)
            try (Statement stmt = conn.createStatement()) {
                stmt.execute("CREATE INDEX idx_demo_index_id ON demo_index (id)");
                System.out.println("Index idx_demo_index_id created");
            }

            start = System.currentTimeMillis();
            try (Statement stmt = conn.createStatement();
                 ResultSet rs = stmt.executeQuery("SELECT * FROM demo_index WHERE id = 123456")) {
                if (rs.next()) {}
            }
            elapsed = System.currentTimeMillis() - start;
            System.out.println("[With Index] Query time: " + elapsed + " ms");

            // 4. 表达式索引
            try (Statement stmt = conn.createStatement()) {
                stmt.execute("CREATE INDEX idx_demo_index_lower_info ON demo_index (LOWER(info))");
                System.out.println("Expression index idx_demo_index_lower_info created");
            }

            start = System.currentTimeMillis();
            try (Statement stmt = conn.createStatement();
                 ResultSet rs = stmt.executeQuery("SELECT * FROM demo_index WHERE LOWER(info) = 'info_123456'")) {
                if (rs.next()) {}
            }
            elapsed = System.currentTimeMillis() - start;
            System.out.println("[Expression Index] Query time: " + elapsed + " ms");

            // 5. 部分索引
            try (Statement stmt = conn.createStatement()) {
                stmt.execute("CREATE INDEX idx_demo_index_active ON demo_index (id) WHERE status = 'ACTIVE'");
                System.out.println("Partial index idx_demo_index_active created");
            }

            start = System.currentTimeMillis();
            try (Statement stmt = conn.createStatement();
                 ResultSet rs = stmt.executeQuery("SELECT * FROM demo_index WHERE id = 123456 AND status = 'ACTIVE'")) {
                if (rs.next()) {}
            }
            elapsed = System.currentTimeMillis() - start;
            System.out.println("[Partial Index - ACTIVE] Query time: " + elapsed + " ms");

            start = System.currentTimeMillis();
            try (Statement stmt = conn.createStatement();
                 ResultSet rs = stmt.executeQuery("SELECT * FROM demo_index WHERE id = 123456 AND status = 'INACTIVE'")) {
                if (rs.next()) {}
            }
            elapsed = System.currentTimeMillis() - start;
            System.out.println("[Partial Index - INACTIVE] Query time: " + elapsed + " ms");

            // 6. 查看索引定义
            System.out.println("\n=== Indexes on 'demo_index' ===");
            try (Statement stmt = conn.createStatement();
                 ResultSet rs = stmt.executeQuery("SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'demo_index'")) {
                while (rs.next()) {
                    System.out.printf("  %s: %s%n", rs.getString(1), rs.getString(2));
                }
            }

            // 7. EXPLAIN 分析
            try (Statement stmt = conn.createStatement();
                 ResultSet rs = stmt.executeQuery("EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT * FROM demo_index WHERE id = 123456")) {
                System.out.println("\n=== EXPLAIN (with index) ===");
                while (rs.next()) {
                    System.out.println(rs.getString(1));
                }
            }

        } catch (SQLException e) {
            e.printStackTrace();
        }
    }
}
技术点总结
  • 使用 JDBC 驱动,通过 DriverManager 获取连接。
  • 使用 try-with-resources 自动释放资源。
  • 时间测量使用 System.currentTimeMillis()
  • 执行 EXPLAIN 并逐行打印结果。

多语言对比总结

语言 驱动/库 特点
Go lib/pq 编译型,错误处理显式,性能优异
Python psycopg2 动态类型,代码简洁,适合快速原型
Java JDBC 类型安全,资源管理规范,适合企业应用

以上三种语言实现均与 Node.js 示例逻辑一致,可直接运行并观察索引带来的性能提升。

项目难点与解决方案

核心难点

  • 索引维护成本与查询收益的权衡:在生产环境中,不恰当的索引设计可能导致写入性能严重下降,而查询性能却未获得显著提升。
  • 在线建索引的复杂性CREATE INDEX CONCURRENTLY 虽然不阻塞写入,但创建速度较慢、可能留下无效索引,且不能在事务块中执行。
  • 标识符长度限制:PostgreSQL 对象名最大长度为 63 个字符,超长名称会被自动截断,可能导致不同对象生成相同的截断名称,引发命名冲突。

解决方案

  1. 索引设计原则:优先在高选择性列上创建索引;对于低选择性列(如性别、状态枚举),评估是否适合使用部分索引。
  2. 部分索引与表达式索引的合理运用:针对特定的查询模式,使用部分索引缩小索引范围,使用表达式索引支持函数查询。
  3. 在线建索引的规范流程 :在生产环境中使用 CREATE INDEX CONCURRENTLY 时,需监控创建进度,并在失败后及时清理无效索引。
  4. 命名规范 :制定索引命名规范(如 idx_{表名}_{列名}),避免依赖自动命名,同时确保总长度不超过 63 个字符。

广度

本文覆盖了 PostgreSQL 索引的基本概念主要类型 (B-Tree、唯一索引、表达式索引、多列索引、部分索引)、创建与删除语法在线建索引 以及索引的选择性评估,为读者建立了完整的索引知识入门框架。

深度

深入探讨了 B-Tree 索引的底层数据结构 (多级树结构、元页面、叶子/内部页面)、MVCC 下的版本链与索引维护机制 以及部分索引与表达式索引的适用场景与限制

复杂度

索引设计涉及存储空间写入性能查询性能 三者的平衡,需要结合业务查询模式、数据分布特征和系统负载进行综合评估。CREATE INDEX CONCURRENTLY 的引入进一步增加了索引管理的复杂度。

官方文档

参考链接

总结

本文系统性地介绍了 PostgreSQL 索引的核心概念、主要类型及其适用场景。索引通过 B-Tree 等多级树结构将查询复杂度从 O ( n ) O(n) O(n) 降低至 O ( log ⁡ n ) O(\log n) O(logn),但同时也带来了存储空间和写入维护的额外开销。

在实际生产中,索引设计需遵循"高选择性优先"原则,并结合部分索引、表达式索引等高级特性进行针对性优化。CREATE INDEX CONCURRENTLY 提供了在线建索引的能力,但需谨慎处理其性能与失败恢复问题。

索引的底层实现(如 B-Tree 的页分裂、MVCC 版本链下的索引维护)是深入理解索引行为的关键。后续文章将深入探讨索引的内部原理、扫描方式及查询优化器的索引选择策略。

相关推荐
ysa0510301 小时前
c++常用自带函数用法与注意
c++·笔记·算法
用户3169353811831 小时前
"批量"删除-sql
数据库
瀚高PG实验室2 小时前
SQL优化案例:使用分区表优化SQL查询性能
linux·服务器·数据库·sql·microsoft·postgresql
无聊的菜鸟2 小时前
TMS320F2806X学习笔记(一)—— 了解片上资源
笔记·ti·c2000·f2806x
SelectDB2 小时前
15 分钟搭建 PostgreSQL + Apache Iceberg + Apache Doris 的湖仓分析平台
数据库
SelectDB2 小时前
科大讯飞可观测性底座:从 ES/Loki 到 Apache Doris 的可观测存储分析升级实践
数据库
SelectDB2 小时前
Cisco WebEx 数据平台统一改造:基于 Apache Doris / SelectDB 替换 Trino、Pinot、Iceberg 及 Kyuubi
数据库
数据技术说2 小时前
MySQL 和 PostgreSQL 的 CDC 方案到底有什么区别?
数据库·架构
SelectDB3 小时前
灵犀科技统一数据服务平台:基于 Apache Doris / SelectDB 实现存储降本 60%、计算提效 10 倍
数据库