外观
用 JSONB 替代 MongoDB 实战:PostgreSQL 半结构化建模一文搞懂
「这些字段后面肯定会变,先放 JSON 里吧」——这句话在业务早期是务实的捷径,但把数据丢给 MongoDB 之后,团队往往要付出新的代价:两套存储的事务一致性、跨源 JOIN 的应用层拼接、以及一个需要单独运维的高可用集群。而 PostgreSQL 的 JSONB 提供了第三种选择:用关系库的约束、事务与索引能力,承接文档型存储的灵活性。我们在订单中台用 JSONB 承接了商品动态属性、营销规则配置和第三方回调报文三类数据,两年下来服务稳定,没有为它们单独维护过一套文档库。
先划清一个概念:PG 有两种 JSON 类型。json 存储原始文本,写入快但每次查询都要重新解析;jsonb 在写入时解析为二进制展开格式,读取和索引都更快,但写入有解析开销,且会去除重复键、不保留键顺序、数字规范化存储。业务存储一律选 jsonb,json 类型基本只用于「原样保存再原样吐出」的报文转发场景。
选型对比:JSONB、MongoDB 与 EAV 宽表怎么选
决定是否用 JSONB 之前,先看清三种主流方案的边界:
| 维度 | PostgreSQL JSONB | MongoDB | EAV / 预留宽列 |
|---|---|---|---|
| 事务一致性 | 与主库同事务,强一致 | 单文档原子,跨文档需自管 | 与主库同事务 |
| 跨结构 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 之前先问一句「运营会不会想按它做筛选」。
避坑总结
- 永远选 jsonb 而不是 json。json 类型不支持索引与包含操作符,除非做报文原样转发,否则没有任何理由用它存业务数据。
- 数字精度与类型语义会悄悄变化。jsonb 按 numeric 规范化存储数字,尾随零丢失、超大整数精度受 NUMERIC 规则约束;对精度敏感的场景入 JSONB 前先转字符串。
- 大文档的更新是整值重写。jsonb 列在 TOAST 机制下更新任一键都可能触发整包重写与重压缩,几 MB 的大文档高频更新会把写放大放大几十倍;大文档考虑拆表或用独立键表。
- GIN 索引默认全键索引,注意体积与写放大。键基数巨大的动态 Schema 上,jsonb_path_ops 只索引值路径,体积与写入成本显著更低;两者按查询模式二选一,不必都建。
- 统计信息对 JSONB 内部值失准。规划器无法感知 JSONB 内键值的分布,复杂包含查询的行数估算偏差大;对性能敏感的固定键,要么拆列,要么用表达式索引给规划器一个统计锚点。
写在最后
JSONB 的价值不在于「模仿 MongoDB」,而在于把灵活性和严谨性放进同一个数据库:事务、JOIN、行级权限、备份恢复全部复用现有设施,团队的心智负担和运维面同时收窄。掌握「拆列与进 JSON 的分工原则 + 包含查询与 GIN 索引的组合 + 局部更新的正确姿势」,绝大多数文档型需求都可以留在 PostgreSQL 里解决。先想清楚字段的查询命运,再决定它的存储形态,是这个话题下唯一的黄金法则。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。