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。最终断言核对这些结果,不依赖规划器选择哪种访问节点,也不把某个固定耗时当作通过标准。

来源