hstore 扩展把文本键与文本值组成的扁平映射存进一个 PostgreSQL 值,其中值还可以为空。它适合不同记录拥有不同可选描述属性的情况,但灵活性不会免除重要键定义、值校验和按查询设计索引的工作。权限开关、价格和凭据都需要认真建模,不能因为装在一列里,就被当成没有后果的普通备注。
分清缺失、空值与文本
键不存在,与键存在但值为空,是两种状态。-> 对这两种情况都会返回 SQL NULL;? 用来确认键是否出现,defined 则检查它是否对应非空值。带引号的字符串“NULL”只是普通文本。整列本身还可以是 SQL NULL;如果不需要这个额外状态,应把映射列声明为 NOT NULL,再明确单个属性如何表示未知或缺失。
键不能是空值,键值对的顺序也没有语义。重复写同一个键不会生成列表,而只会留下其中一个值,且没有保证留下哪一个。需要确定性处理时,应在存储前拒绝重复键。应用应使用驱动支持的适配方式或参数化构造函数,不能把不可信字符串直接拼接成 hstore 字面量。驱动是否自动读写字典取决于实际支持和配置,不是任何语言环境都成立的承诺。
映射仍然可以有约束
CHECK 可以检查映射并限制指定属性,所以“数据库无法限制 hstore 内容”的说法不正确。但 SQL CHECK 在表达式结果为未知时也允许通过,单纯比较一个可能为空的提取结果,可能意外放行缺失键或空值。应明确出现条件、空值政策和允许值,再分别测试这些边界。需要时,提取键上的唯一表达式索引也可以约束重复值,并非只有普通列才能声明这种规则。
经常参与关联或业务约束的重要字段,仍然通常更适合独立的有类型列。理由是结构更清楚、操作更简单,而不是映射完全不能校验。增加一个可选键也许不需要改表,但改变键的含义仍可能需要历史数据检查、回填、客户端协调和索引调整。灵活性把这些决策移到另一处,并没有让迁移成本自然消失,更不能保证所有已有记录已经满足新含义。
对齐操作符与转换规则
GIN 和 GiST 支持 hstore 的包含与键存在操作符。提取文本再比较则属于另一种表达式,可能需要单独的 B-tree 表达式索引。给整个映射建 GIN,不意味着所有键值计算都自动有索引。应比较应用最终发出的 SQL、条件选择性和实际计划,再决定是否值得承担存储与写入开销;小表选择顺序扫描,也不能单独证明索引无效。
标准 hstore 到 jsonb 的转换把非空值保留为 JSON 字符串,把空值变成 JSON null,不会猜测文本“12”应该成为数字。反方向没有内置、对称的 jsonb 到 hstore 强制转换;任意嵌套 JSON 需要先定义允许的形状以及具体转换政策。不能把两个方向都描述成一条语句就能无损来回的免费迁移。存储大小也应由真实数据测量,不能承诺 hstore 总是更小。
坏掉的样子
文本价格默认按文本比较,显式转成数字时又可能因非法输入失败。将重要属性迁入正式列前,应先识别并处理不合法数据。能够解析为 hstore,只说明映射语法有效,不能证明键和值符合业务规则。反过来,为从来没有查询过的属性不断增加索引,也可能只让写入更贵,而没有带来有用的读取收益。
操作符可以表达只修改某个键,却不承诺每个键独立加锁,也不承诺底层只原地改写几个字节。两位调用方同时修改同一行,仍需要一致的并发政策。针对键的修改宜直接用数据库表达式完成,而不是把旧映射读到客户端后再整份覆盖;后一种方式可能把别人的无关修改一起写回旧版本。这里讨论的是更新方式,不能用“键很多”推导出各键互不影响。
快速验证
在全新的 PostgreSQL 18 可丢弃数据库中运行下面完整 SQL,要求服务器已有 hstore 扩展文件,但数据库尚未启用扩展。使用允许创建扩展的角色,遇到非预期错误应停止。事务内创建扩展与临时数据,有意测试边界错误,最后回滚清除自己创建的全部对象,不依赖既有偏好表或业务记录。
BEGIN;
CREATE EXTENSION hstore;
CREATE TEMP TABLE prefs (
id integer PRIMARY KEY,
attrs hstore NOT NULL,
CHECK (NOT (attrs ? 'tier') OR
(defined(attrs,'tier') AND attrs->'tier' IN ('basic','pro')))
);
INSERT INTO prefs
SELECT g,hstore('theme',CASE WHEN g%100=0 THEN 'dark' ELSE 'light' END)
FROM generate_series(1,10000) AS g;
CREATE INDEX prefs_attrs_gin ON prefs USING gin(attrs);
ANALYZE prefs;
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF)
SELECT id FROM prefs WHERE attrs @> 'theme=>dark'::hstore;
SELECT attrs ? 'note' AS present,defined(attrs,'note') AS nonnull,
attrs->'note' AS value
FROM (VALUES (''::hstore),('note=>NULL'::hstore),('note=>"NULL"'::hstore)) s(attrs);
UPDATE prefs SET attrs=attrs || hstore('tier','pro') WHERE id=1;
UPDATE prefs SET attrs=attrs-'theme'::text WHERE id=1;
DO $$ BEGIN
INSERT INTO prefs VALUES (10001,'tier=>gold');
RAISE EXCEPTION 'invalid tier accepted';
EXCEPTION WHEN check_violation THEN RAISE NOTICE 'invalid tier rejected';
END $$;
DO $$ BEGIN
INSERT INTO prefs VALUES (10002,'tier=>NULL');
RAISE EXCEPTION 'null tier accepted';
EXCEPTION WHEN check_violation THEN RAISE NOTICE 'null tier rejected';
END $$;
SELECT 'count=>12,note=>NULL'::hstore::jsonb AS converted;
DO $$ BEGIN
EXECUTE 'SELECT ''{"theme":"dark"}''::jsonb::hstore';
RAISE EXCEPTION 'unexpected reverse cast';
EXCEPTION WHEN cannot_coerce THEN RAISE NOTICE 'no built-in reverse cast';
END $$;
DO $$ BEGIN
IF (SELECT count(*) FROM prefs WHERE attrs @> 'theme=>dark') <> 100 OR
(SELECT attrs FROM prefs WHERE id=1) IS DISTINCT FROM 'tier=>pro'::hstore THEN
RAISE EXCEPTION 'map query or update mismatch';
END IF;
IF 'count=>12,note=>NULL'::hstore::jsonb IS DISTINCT FROM
'{"count":"12","note":null}'::jsonb THEN
RAISE EXCEPTION 'conversion changed value types';
END IF;
END $$;
ROLLBACK;
包含查询应返回一百行。缺失键的存在与非空标记是假、假;显式空值是真、假;普通字符串是真、真。删除键明确转换成 text,因为减号也支持另一个 hstore 参数;未指定类型的字面量可能选中不同重载。编号一最后只保留 tier=pro,两次非法等级插入都必须打印对应拒绝提示,反向转换必须报告不存在。转换结果中的 count 是 JSON 字符串,note 是 JSON null。最终断言核对这些结果,不依赖规划器选择哪种访问节点,也不把某个固定耗时当作通过标准。
来源
- PostgreSQL 18 hstore,映射操作符、空值、索引与转换,已核验 2026-10-15。
- PostgreSQL 18 约束,CHECK 与空值语义,已核验 2026-10-15。
- PostgreSQL 18 表达式索引,提取表达式索引与唯一性,已核验 2026-10-15。
- PostgreSQL 18 JSON 类型,JSON 值类型及行级并发注意事项,已核验 2026-10-15。