在 PostgreSQL 中,查询是否使用某个索引,与查询是否需要锁住这个索引,并不是一回事。一个容易被忽略的事实是:查询访问一张表时,会为该表的每个索引取得锁,即使最终执行计划完全没有使用其中大部分索引。
这意味着索引不仅消耗磁盘、缓存和写入吞吐,还会占用共享锁表中的条目。对索引数量很多、分区很多或并发事务很长的系统,这种开销可能从“几乎不可见”突然变成容量问题。
执行计划没有使用,不等于没有加锁
假设一张表有 16 个索引,而查询最终只选择顺序扫描。直觉上,这条查询似乎只需要处理表本身;但 PostgreSQL 在规划和执行查询时,需要防止相关关系对象被并发删除或修改,因此会锁住表及其索引。
这里的锁通常不是“阻止其他查询读取”的行锁,而是关系级锁。普通 SELECT 获取的锁可以与大多数日常读写并存,但会与某些 DDL 操作冲突。例如,当一个长事务持续持有这些锁时,试图删除索引或修改表结构的会话可能一直等待。
真正值得注意的是数量:
- 一张表的索引越多,一次访问需要登记的关系锁越多。
- 一条语句连接多张宽索引表时,锁条目会继续累积。
- 分区表会把问题放大,因为查询可能涉及多个分区及其本地索引。
- 锁通常要到事务结束才释放,因此长事务会延长占用时间。
因此,“这个索引没有出现在 EXPLAIN 中”只能说明执行器没有用它查找数据,不能说明它没有锁成本。
用两个会话观察全部索引锁
可以这样实践。下面的实验适用于测试环境,会创建一张表和 16 个索引。请勿直接在生产数据库中运行。
先在会话 A 中执行:
DROP TABLE IF EXISTS lock_demo;
CREATE TABLE lock_demo (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id integer NOT NULL,
payload text NOT NULL DEFAULT ''
);
DO $$
BEGIN
FOR i IN 1..16 LOOP
EXECUTE format(
'CREATE INDEX lock_demo_payload_%s_idx ON lock_demo ((payload || %L))',
i,
i::text
);
END LOOP;
END
$$;
INSERT INTO lock_demo (tenant_id, payload)
SELECT n % 10, 'row-' || n
FROM generate_series(1, 1000) AS n;
BEGIN;
SELECT pg_backend_pid() AS session_a_pid;
SELECT count(*) FROM lock_demo;
记下 session_a_pid,并保持事务打开。这个查询很可能采用顺序扫描,可以确认执行计划:
EXPLAIN (ANALYZE, COSTS OFF)
SELECT count(*) FROM lock_demo;
随后在会话 B 中,将下面的 12345 替换为会话 A 的 PID:
SELECT
c.relname,
c.relkind,
l.mode,
l.granted
FROM pg_locks AS l
JOIN pg_class AS c ON c.oid = l.relation
WHERE l.pid = 12345
AND c.relname LIKE 'lock_demo%'
ORDER BY c.relkind, c.relname;
结果中不仅会出现 lock_demo,还会出现主键索引和刚创建的 16 个索引。它们没有参与 count(*) 的数据定位,却仍然拥有关系锁。
实验完成后,在会话 A 中释放事务,再清理对象:
ROLLBACK;
DROP TABLE lock_demo;
为什么“16 个索引”会演变成系统容量问题
PostgreSQL 使用共享内存维护锁信息。max_locks_per_transaction 很容易被误解成“每个事务最多只能拿这么多锁”,但它更接近共享锁表的容量规划参数:系统按预期事务数和平均锁对象数准备空间,单个事务可能超过这个平均值,只要共享池仍有空间。
风险通常来自乘法,而不是某一张表:
并发事务数 × 每个事务访问的表/分区数 × 每个关系的索引数
例如,一个批处理事务访问 40 个分区,每个分区有 12 个索引,仅关系对象这一层就可能产生数百个锁。多个这样的事务并发运行时,锁表压力会迅速上升。与此同时,未提交事务还可能阻塞索引维护和表结构变更。
可以用下面的查询寻找当前持有关系锁最多的后端:
SELECT
l.pid,
a.usename,
a.state,
a.xact_start,
count(*) AS relation_locks,
left(a.query, 100) AS current_query
FROM pg_locks AS l
JOIN pg_stat_activity AS a ON a.pid = l.pid
WHERE l.locktype = 'relation'
GROUP BY l.pid, a.usename, a.state, a.xact_start, a.query
ORDER BY relation_locks DESC
LIMIT 20;
还可以盘点用户表的索引数量,优先检查异常值:
SELECT
schemaname,
tablename,
count(*) AS index_count
FROM pg_indexes
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
GROUP BY schemaname, tablename
ORDER BY index_count DESC, schemaname, tablename
LIMIT 30;
这些查询只能提供线索。锁数量高不一定代表故障,大型迁移、分区维护和批处理本来就可能持有许多锁;关键是结合事务时长、DDL 等待和业务并发判断。
索引治理比单纯调大参数更重要
遇到锁容量压力时,直接提高 max_locks_per_transaction 可能暂时缓解问题,但它需要更多共享内存,并且通常要求重启数据库。更重要的是,这个参数无法消除过量索引、过细分区和长事务带来的持续成本。
落地时可以按以下顺序检查:
- 统计每张热点表及每个分区的索引数量。
- 结合
pg_stat_user_indexes检查长期很少扫描的索引,但不要仅凭扫描次数直接删除约束索引或低频关键索引。 - 检查功能重叠的单列索引、复合索引和表达式索引。
- 缩短批处理事务,避免在事务中等待外部 API、人工确认或慢速文件处理。
- 在压测环境复现高并发和 DDL 操作,再评估是否调整锁相关配置。
索引从来不是免费的。除了每次写入都要维护它之外,每次查询还可能为它占用一个关系锁。把索引数量视为并发与运维容量的一部分,才能避免“查询根本没用这个索引,为什么它仍然影响系统”这类意外。