跳到主内容

用 JSONB 替代 MongoDB 实战:PostgreSQL 半结构化建模一文搞懂 ​

「这些字段后面肯定会变,先放 JSON 里吧」——这句话在业务早期是务实的捷径,但把数据丢给 MongoDB 之后,团队往往要付出新的代价:两套存储的事务一致性、跨源 JOIN 的应用层拼接、以及一个需要单独运维的高可用集群。而 PostgreSQL 的 JSONB 提供了第三种选择:用关系库的约束、事务与索引能力,承接文档型存储的灵活性。我们在订单中台用 JSONB 承接了商品动态属性、营销规则配置和第三方回调报文三类数据,两年下来服务稳定,没有为它们单独维护过一套文档库。

先划清一个概念:PG 有两种 JSON 类型。json 存储原始文本,写入快但每次查询都要重新解析;jsonb 在写入时解析为二进制展开格式,读取和索引都更快,但写入有解析开销,且会去除重复键、不保留键顺序、数字规范化存储。业务存储一律选 jsonb,json 类型基本只用于「原样保存再原样吐出」的报文转发场景。

选型对比:JSONB、MongoDB 与 EAV 宽表怎么选 ​

决定是否用 JSONB 之前,先看清三种主流方案的边界:

维度PostgreSQL JSONBMongoDBEAV / 预留宽列
事务一致性与主库同事务,强一致单文档原子,跨文档需自管与主库同事务
跨结构 JOIN原生支持应用层拼接原生支持
灵活度键值任意,支持嵌套键值任意,Schema 更自由预留列有上限
查询与索引GIN + 路径查询,能力强丰富但需单独学习依赖 B-tree,扩展差
运维成本零,复用现有 PG新增一套集群零
适用边界属性数适中、需关联业务数据大体量文档、写入吞吐优先属性少且可预见

判断标准可以浓缩为一句话:当半结构化数据需要与业务表同事务、做关联、参与报表时,JSONB 是运维成本最低的选择;当文档体量巨大、写入吞吐远超关系库承载、且不需要跨源事务时,MongoDB 才有不可替代性。EAV 模式(属性拆成行存)在属性超过几十个后查询会变得极其痛苦,现在多数场景已不值得新启用。

建模实操:从建表、索引到局部更新 ​

以商品动态属性为例,建模的核心原则是「稳定且需参与查询排序的字段拆列,易变的描述性字段进 JSONB」:

sql
CREATE TABLE sku (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    spu_id      BIGINT NOT NULL,
    price       NUMERIC(18,4) NOT NULL,      -- 高频查询字段:坚持拆列
    stock       INT NOT NULL,
    attrs       JSONB NOT NULL DEFAULT '{}', -- 动态属性:颜色、尺码、材质等
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- 查询一:操作符 ->> 取文本,-> 取 JSON 值
SELECT id, attrs ->> 'color' AS color
FROM sku
WHERE attrs ->> 'color' = '黑色';

-- 查询二:@> 包含查询,语义更严谨且能走 GIN 索引(推荐写法)
SELECT id FROM sku WHERE attrs @> '{"color": "黑色", "size": "XL"}';

-- GIN 索引:默认 jsonb_ops 支持全部包含类操作符
CREATE INDEX idx_sku_attrs ON sku USING GIN (attrs);
-- 若只使用 @> 包含查询,jsonb_path_ops 索引体积与构建速度更优
CREATE INDEX idx_sku_attrs_path ON sku USING GIN (attrs jsonb_path_ops);

-- 局部更新:jsonb_set 只改一个键,不要整包覆盖
UPDATE sku
SET attrs = jsonb_set(attrs, '{warehouse}', '"华东一仓"', true),
    updated_at = now()
WHERE id = 1001;

-- 路径查询:jsonb_path_query 支持 JSONPath 语法(SQL/JSON Path)
SELECT id FROM sku
WHERE jsonb_path_exists(attrs, '$.tags[*] ? (@ == "限量")');

三类高频操作值得单独说明。第一,->> 返回 text 而 -> 返回 jsonb/json,比较数字大小时用 -> 配合类型转换,比较文本时用 ->>,混用是新手翻车重灾区。第二,jsonb_set 的第三个参数 true 表示键不存在时创建,漏传会导致「想补字段却没补上」的静默 bug。第三,整包覆盖 SET attrs = '{"a":1}' 会丢失未带上的旧键,业务代码里务必「读出、合并、写回」或统一走 jsonb_set。

混合建模:JSONB 与结构化列的最佳分工 ​

真实业务里 JSONB 从来不是非此即彼,而是与结构化列混合分工。我们踩坑后沉淀的分工原则如下:

  • 参与过滤、排序、聚合的高频字段拆列:价格、状态、时间这类字段进了 JSONB,就要靠表达式索引和 GIN 硬撑,统计信息也更容易失准;拆成普通列后 B-tree、统计、执行计划全部自动受益。
  • 一对多但有限枚举的属性用数组列:TEXT[] 加 GIN 索引比 JSONB 数组更轻量,标签、类目路径都适合。
  • 纯展示、按需透出的描述性内容进 JSONB:商品详情的规格参数、接口回调的原始报文、A/B 实验的配置快照,读取时整包取出,无需细粒度索引。
  • 跨行聚合的指标不要进 JSONB:JSONB 内的数值无法被常规 GROUP BY 聚合,需要 jsonb_array_elements 展开后再算,大数据量下性能很差;这类指标必须物化成列或独立汇总表。

一个真实的反例:早期我们把营销活动的「满减门槛」存进了 JSONB,后来运营侧要求按门槛区间做活动筛选与统计,JSONB 数值过滤叠加展开计算的查询在大表上慢到超时。最终方案是把这些字段固化成 NUMERIC 列,历史数据用 attrs ->> 'threshold' 一次性回填。这个案例的教训是:字段的命运取决于它的查询方式,写进 JSONB 之前先问一句「运营会不会想按它做筛选」。

避坑总结 ​

  1. 永远选 jsonb 而不是 json。json 类型不支持索引与包含操作符,除非做报文原样转发,否则没有任何理由用它存业务数据。
  2. 数字精度与类型语义会悄悄变化。jsonb 按 numeric 规范化存储数字,尾随零丢失、超大整数精度受 NUMERIC 规则约束;对精度敏感的场景入 JSONB 前先转字符串。
  3. 大文档的更新是整值重写。jsonb 列在 TOAST 机制下更新任一键都可能触发整包重写与重压缩,几 MB 的大文档高频更新会把写放大放大几十倍;大文档考虑拆表或用独立键表。
  4. GIN 索引默认全键索引,注意体积与写放大。键基数巨大的动态 Schema 上,jsonb_path_ops 只索引值路径,体积与写入成本显著更低;两者按查询模式二选一,不必都建。
  5. 统计信息对 JSONB 内部值失准。规划器无法感知 JSONB 内键值的分布,复杂包含查询的行数估算偏差大;对性能敏感的固定键,要么拆列,要么用表达式索引给规划器一个统计锚点。

写在最后 ​

JSONB 的价值不在于「模仿 MongoDB」,而在于把灵活性和严谨性放进同一个数据库:事务、JOIN、行级权限、备份恢复全部复用现有设施,团队的心智负担和运维面同时收窄。掌握「拆列与进 JSON 的分工原则 + 包含查询与 GIN 索引的组合 + 局部更新的正确姿势」,绝大多数文档型需求都可以留在 PostgreSQL 里解决。先想清楚字段的查询命运,再决定它的存储形态,是这个话题下唯一的黄金法则。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。