PostgreSQL 提供两种 JSON 存储类型,它们承担的表示成本不同。json 验证并保留输入文本,jsonb 把文本转换成拆解后的二进制值。两者都能提取字段和路径。选择类型时,需要回答的是是否必须保留文本形态、是否反复处理文档,以及真实查询需要哪些算子,而不是简单判断能不能读取内部字段。

保留文本还是保留结构值

json 会保留无意义空白、对象键的顺序和重复对象键。处理函数仍需要解析这些文本,遇到重复键时,以最后一个值作为有效值。因此,存储保留了重复项,并不代表每个消费方都会得出同样解释。不要把重复键当成应用契约中有意义的数据结构,否则跨语言解析时很容易产生分歧。

jsonb 去掉无意义空白和对象键顺序,同一个对象键只保留最后一次出现的值。这里说的是对象:数组仍然保留元素顺序和重复元素。数组的包含判断有自己的匹配规则,不能因为包含查询把两个数组视为匹配,就反推存储时发生了排序或去重。表示、相等比较和包含关系是三个需要分别理解的问题。

这种二进制格式属于 PostgreSQL,并不是 BSON,也不是可跨系统使用的签名格式。jsonb 输出的文本没有承诺遵循某个外部 JSON 规范化算法。如果签名覆盖原始请求字节,应另外保留这些字节,例如使用 bytea,并针对该表示验证签名。json 保留的是接收后的 JSON 文本;客户端编码转换和协议处理可能已改变网络上的原始字节,因此不能把它当成任意请求的逐字节归档。

两种类型都支持路径提取

两者都支持用 -> 提取 JSON 值,用 ->> 提取文本,以及用 #>、#>> 读取嵌套路径。读取 json 内部字段时,不需要先把整篇文档强制转换成 jsonb。性能区别在于重新解析文本还是处理已经拆解的表示,而不是 json 缺少这些操作符。

jsonb 还支持结构包含、键存在判断和 GIN 等索引策略。从两种类型提取出的标量表达式,也可以按实际需要建立适当的表达式索引。能否使用索引仍取决于准确的表达式、算子和负载,选了 jsonb 不代表任意嵌套条件都会变快。经常参与关联、约束或排序的字段,往往更适合独立的有类型列,以明确数据契约并方便优化。

输入成本和处理成本需要同时考虑。jsonb 转换时要做额外工作,某些文档占用的空间也可能更大。两种类型接受的输入几乎相同,但并非完全一致:PostgreSQL 的 numeric 范围限制和更严格的 Unicode 处理,会让某些可以存成 json 文本的输入无法转换成 jsonb。迁移现有列前,应检查有代表性的真实载荷,特别是极大数字和转义字符,不能只用一个短小示例推断整个数据集。

坏掉的样子

把 json 改为 jsonb,可能破坏导出程序依赖的原始表示。换类型也不等于执行应用 schema 校验:合法的 JSON 字符串、数字或数组仍然是 JSON,即使业务只接受对象。需要哪些字段、字段能取什么值,应通过实际结构和业务约束表达,不能把语法有效误当成业务有效。

更隐蔽的问题是默认所有解析器对重复键的解释一致。数据库采用最后值,并不能证明上游签名程序或下游读取程序采用了同一规则。需要一致理解的边界,应拒绝有歧义的输入。读取结果时,也要区分路径缺失、JSON null 和 SQL NULL;把三者全部转换成没有说明的空字符串,会丢掉排查和校验所需的信息。

快速验证

把下面内容保存为 json-types.sql,在可丢弃的 PostgreSQL 18 数据库中执行 psql -X -v ON_ERROR_STOP=1 -f json-types.sql。脚本只在事务内创建一张临时表,末尾回滚,数据和对象都不会留下。

\set ON_ERROR_STOP on
BEGIN;
CREATE TEMP TABLE sample (raw json, parsed jsonb);
INSERT INTO sample VALUES ('{ "b": 2, "a": 1, "a": 3 }','{ "b": 2, "a": 1, "a": 3 }');
SELECT raw::text, parsed::text, raw->>'a' AS json_a, parsed->>'a' AS jsonb_a FROM sample;
SELECT '{"user":{"name":"Ada"}}'::json #>> '{user,name}' AS json_path;
DO $$ BEGIN
  IF (SELECT raw::text FROM sample) IS DISTINCT FROM '{ "b": 2, "a": 1, "a": 3 }' THEN
    RAISE EXCEPTION 'json text was changed';
  END IF;
  IF (SELECT parsed FROM sample) IS DISTINCT FROM '{"a":3,"b":2}'::jsonb THEN
    RAISE EXCEPTION 'unexpected jsonb value';
  END IF;
  IF (SELECT raw->>'a' FROM sample) IS DISTINCT FROM '3' THEN
    RAISE EXCEPTION 'json extraction did not use the last duplicate key';
  END IF;
  IF '[2,1]'::jsonb = '[1,2]'::jsonb THEN
    RAISE EXCEPTION 'jsonb array order was ignored';
  END IF;
  IF '[2,1,2]'::jsonb = '[2,1]'::jsonb THEN
    RAISE EXCEPTION 'jsonb duplicate array elements were ignored';
  END IF;
END $$;
DO $$ BEGIN
  PERFORM '{broken'::json;
  RAISE EXCEPTION 'invalid JSON accepted';
EXCEPTION WHEN invalid_text_representation THEN RAISE NOTICE 'invalid JSON rejected';
END $$;
ROLLBACK;

PostgreSQL 18.6 的实测结果是:raw 保持原来的空白文本;parsed 显示包含 a=3、b=2 的规范化对象;两种提取表达式都返回 3。直接在 json 上读取嵌套路径,也能得到 Ada。断言检查这些语义,并分别验证数组相等比较会考虑顺序与重复元素;捕获到预期的非法输入错误后,会打印“invalid JSON rejected”提示。如果错误类型变化或无效 JSON 被接受,脚本不会把它默认为成功。

这个实验验证表示与提取行为,并没有比较速度或存储效率。迁移生产列前,还要分别测量真实文档大小、写入频率和查询计划,确认转换失败数据的处理方式,以及哪些表示信息允许丢失。只有把这些取舍说明清楚后,才能决定是否替换原始数据;不能仅凭示例成功就删除仍然需要用于回放或签名校验的原文。

来源