部分索引只为满足谓词的行保存索引项。需要的子集很小时,它可以缩小索引,但也可以表达只对这个子集生效的完整性规则。例如,同一个座位允许保留多条历史预订,却只能有一条当前有效预订。部分唯一索引可以把这条规则放在数据库边界上执行,而不只是提高某条查询的速度。
成员资格随谓词变化
CREATE INDEX ... WHERE active 只包含 active 为 true 的行,false 和 null 都不满足条件。是否允许 null 是独立的模型选择;下面实验把 active 定义为非空,使每条预订明确处于有效或无效状态。索引键也不必就是谓词中出现的列:这里用 seat 作为键,用 active 决定哪些行参与。
普通写入会改变成员资格。无效记录变成有效记录时,必须进入索引并接受检查;有效记录失效后,则离开当前有效子集。这个边界不是一旦建索引就永远固定的历史分界线。每次写入仍要判断谓词,状态切换也有维护成本,所以索引更小不意味着所有更新都免费,更不能无条件承诺写入一定更快。
唯一性可以只针对子集
在 seat 上建立 WHERE active 的唯一索引,会拒绝同一个座位出现两条有效记录,却允许任意多条无效历史记录。即使没有 SELECT 使用这个索引,数据库仍会执行唯一性检查。因此查询加速与数据完整性是保留索引的两种不同理由,不能只看扫描计数为零就把承担约束的索引删除。
应用先查“有没有”再插入,比数据库约束弱,因为另一个事务可能在检查与写入之间插入。唯一索引会在 PostgreSQL 中协调冲突写入,失败方需要处理唯一性错误或明确选择的冲突策略。下面夹具按确定顺序验证拒绝行为;并发协调属于数据库约束契约,不代表已经实现支付、扣款和跨服务预订的完整业务流程。
查询也要表达同一边界
规划器把部分索引用于访问路径时,必须能够证明查询条件蕴含索引谓词。仅按 seat 查询,并不能证明调用方只需要有效记录,因为历史记录也可能匹配。显式查询 active 记录才可能符合条件,但小表仍可能采用更便宜的顺序扫描。逻辑上相似的表达式,也只有在规划器能够识别其蕴含关系时才有用,不能仅凭人眼觉得相近。
也不要用数百个互相重叠的部分索引模拟分区。规划与维护成本会增长,优化器不会自动把这些独立索引当成一套协调的分区结构。谓词应来自稳定的工作负载或明确完整性需求,再通过真实查询与状态转换验证。建立索引不是给数据打上一个永不变化的标签,而是维护一个持续受规则约束的子集。
坏掉的样子
查询忘写有效状态条件时,可能把旧预订和当前预订一起返回,而唯一索引本身完全没有失效。反过来,应用把这个索引误当成全表唯一,也可能在业务层错误拒绝合法历史。应写清规则约束的是所有行还是当前子集,并同时测试这两类数据。否则列表页、恢复旧记录和导入流程很容易对同一座位产生不同理解。
重新激活失败是另一个关键边界。把旧记录改回 active,可能与当前持有人冲突。失败后旧记录应该仍保持无效,并向调用方报告冲突,不能悄悄替换已有有效预订。批量导入与状态迁移也必须接受同样检查,不能因为它们是运维任务就绕开当前有效唯一性;这样才能让不同写入入口共享同一规则。
快速验证
保存为 partial-unique.sql,使用 psql -X -v ON_ERROR_STOP=1 -f partial-unique.sql 连接可丢弃的 PostgreSQL 18 数据库。实验只创建会话临时预订表,不接触真实座位库存;所有记录和状态变化都在代码中给出。
\set ON_ERROR_STOP on
BEGIN;
CREATE TEMP TABLE reservations (id integer PRIMARY KEY, seat text NOT NULL, active boolean NOT NULL);
CREATE UNIQUE INDEX one_active_seat ON reservations (seat) WHERE active;
INSERT INTO reservations VALUES (1,'A1',true),(2,'A1',false),(3,'A1',false);
DO $$ BEGIN
INSERT INTO reservations VALUES (4,'A1',true);
RAISE EXCEPTION 'two active reservations accepted';
EXCEPTION WHEN unique_violation THEN RAISE NOTICE 'active duplicate rejected';
END $$;
UPDATE reservations SET active=false WHERE id=1;
INSERT INTO reservations VALUES (4,'A1',true);
DO $$ BEGIN
UPDATE reservations SET active=true WHERE id=2;
RAISE EXCEPTION 'reactivation bypassed uniqueness';
EXCEPTION WHEN unique_violation THEN RAISE NOTICE 'conflicting reactivation rejected';
END $$;
SELECT active,count(*) FROM reservations GROUP BY active ORDER BY active;
SELECT id FROM reservations WHERE active AND seat='A1';
DO $$ BEGIN
IF (SELECT id FROM reservations WHERE active AND seat='A1') IS DISTINCT FROM 4 THEN
RAISE EXCEPTION 'wrong active reservation';
END IF;
IF (SELECT count(*) FROM reservations WHERE NOT active) <> 3 THEN
RAISE EXCEPTION 'history was not preserved';
END IF;
END $$;
ROLLBACK;
最初的有效重复被拒绝,而两条历史重复成功保留。把一号预订设为无效后,四号预订可以取得该座位;再把二号重新激活则失败,因为四号仍有效。最后应有三条无效记录、一条有效记录,有效标识为四。断言同时检查当前持有人与历史数量,避免只验证拒绝消息而遗漏错误的数据变更。
这些检查在 PostgreSQL 18.6 通过。异常块只捕获预期的唯一性违反;如果本应失败的操作意外成功,后面的不同异常会让脚本失败。最后回滚移除夹具。实验验证子集成员资格与唯一性转换,不证明支付原子性、分布式库存一致性,也没有测量巨大历史表上的性能收益。
来源
- PostgreSQL 部分索引,谓词成员资格与部分唯一性,已核验 2026-09-10。
- PostgreSQL CREATE INDEX,唯一索引检查与谓词,已核验 2026-09-10。