表达式索引把计算结果保存为索引键。查询比较 lower(email) 时,可以搜索预先计算的键,而不用对整张表逐行执行 lower。代价是索引创建和维护阶段需要完成计算与存储。它不会修改原始 email,也不保证任何查询都会选择这个索引,更不意味着表达式文本有一点不同就必然失去匹配。

索引计算后的含义

lower(email) 索引保存的是与行关联的规范化结果,使用相同计算含义的等值条件可以成为索引条件。直接比较原始 email 的语义不同,不会自动获得这条搜索路径。同样,先 trim 再 lower 增加了一项操作,现有索引不能假定去除空白永远不会改变数据。因此是否匹配要看计算本身,而不是只看它们都涉及 email 列。

PostgreSQL 匹配解析后的表达式,并进行某些简化。多加几层括号本身不会破坏匹配,因此查询不需要与索引定义逐字完全相同。反过来,数学上等价的写法也不一定都能被识别为相同。应检查计划中的实际条件和索引表达式定义,不能只做表面字符串比较,也不能承诺规划器能够证明任意表达式等价。

唯一表达式索引还能执行大小写规范化后的唯一性规则。这是在改变允许存储的数据,不只是提速。已有行规范化后发生冲突,会阻止索引创建;未来插入也会面对新约束。单纯 lower 不是邮箱等价性的完整定义,其他文本还可能受 locale 和 collation 影响。应先明确业务规范化规则,再决定把哪一种计算结果作为约束依据。

让被索引的值保持稳定

索引表达式中的函数必须满足 PostgreSQL 的不可变性要求:结果应依赖输入参数,而不能随着当前时间或外部状态自行变化。now() 不能被当成永远稳定的计算值建索引。为了绕过检查而把函数随意标为 IMMUTABLE,并不会让函数真的不可变,反而可能使已存索引键与后续计算结果不一致,产生比慢查询更严重的正确性问题。

表达式在插入及需要维护索引的更新时计算。即使规划器从不选它,存储和维护成本仍然存在。修改自定义函数行为或规范化策略时,也必须考虑已经保存在索引里的旧结果;仅替换函数代码不会重新计算所有键。重建索引和验证已有数据,应当属于这次语义变更的一部分,而不是上线后发现异常才补做。

坏掉的样子

ORM 重构可能把条件改成 lower(trim(email)),索引却仍保存 lower(email)。查询结果仍正确,但执行器可能扫描更多行。另一种问题是排序规则或自定义函数改变后,代码仍依赖旧键。排查时要对照最终生成的 SQL、索引定义、相关函数行为与目标环境,不能仅根据源代码中的一个方法名推断数据库收到什么。

把唯一表达式索引当成无害的性能调整也很危险。已有规范化重复值会让创建失败,未来写入则受到新的唯一约束。应审计并按业务语义处理冲突,不能为了建索引悄悄删除记录。生产创建还需要选择合适的方式并验证完整结束;目录里存在一个索引名称,不足以证明它已经成功构建并可用,失败的并发构建可能留下需要处理的状态。

快速验证

保存为 expression.sql,执行 psql -X -v ON_ERROR_STOP=1 -f expression.sql,连接可丢弃的 PostgreSQL 18 数据库。实验的一万个地址使用保留的 example.test 域名,表和索引都是临时对象,不会接触真实账户。异常块用于检查错误边界,而不只是展示一条成功查询。

\set ON_ERROR_STOP on
BEGIN;
CREATE TEMP TABLE accounts (id integer, email text NOT NULL);
INSERT INTO accounts SELECT g, 'User'||g||'@example.test' FROM generate_series(1,10000) AS g;
CREATE UNIQUE INDEX account_email_folded ON accounts (lower(email));
ANALYZE accounts;
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT id FROM accounts WHERE ((lower(email)))='user42@example.test';
EXPLAIN (COSTS OFF)
SELECT id FROM accounts WHERE lower(trim(email))='user42@example.test';
DO $$ BEGIN
  INSERT INTO accounts VALUES (10001,'USER42@example.test');
  RAISE EXCEPTION 'case-folded duplicate accepted';
EXCEPTION WHEN unique_violation THEN RAISE NOTICE 'folded duplicate rejected';
END $$;
DO $$ BEGIN
  EXECUTE 'CREATE INDEX invalid_clock ON accounts ((now()))';
  RAISE EXCEPTION 'non-immutable index expression accepted';
EXCEPTION WHEN invalid_object_definition THEN RAISE NOTICE 'non-immutable expression rejected';
END $$;
DO $$ BEGIN
  IF (SELECT id FROM accounts WHERE lower(email)='user42@example.test') IS DISTINCT FROM 42 THEN
    RAISE EXCEPTION 'expression lookup failed';
  END IF;
END $$;
ROLLBACK;

PostgreSQL 18.6 在 lower(email) 外多加括号时仍使用 account_email_folded,增加 trim 的版本则采用顺序扫描。插入四十二号用户邮箱的大写重复形式,被唯一表达式索引拒绝;尝试给 now() 建索引,也因不可变性要求失败。最后的断言确认受支持的查找仍返回编号四十二,避免只观察错误消息而忘记正向行为。

异常处理只捕获预期数据库错误,本应失败却意外成功时脚本会报错。回滚清理全部实验对象。这些结果把无害语法变化与计算含义变化区分开,但没有覆盖全部 Unicode 规范化、所有排序规则、生产建索引锁行为或任意表达式等价性。那些结论需要各自的真实数据和具体操作,不能从这个小夹具直接推导。

来源