PostgreSQL JSONB 实战:替代 MongoDB 的 5 个场景

引言

"商品表要加扩展属性,每个品类的字段都不一样,手机有内存和屏幕、服装有尺码和颜色、食品有保质期——要不我们引入 MongoDB 吧?" 这句话在架构评审会上出现过无数次。听起来很合理:灵活结构、文档存储、不用改表。但真把 MongoDB 引进来之后,团队往往在第三个月开始还债:商品基础信息在 PG、扩展属性在 Mongo,下单事务跨两个库没法保证一致;报表要 JOIN 订单和商品属性,只能在应用层拼;运维多一套副本集、备份、监控,DBA 又是另一套技能栈。

"有半结构化数据"不等于"需要一个文档数据库"。PostgreSQL 的 JSONB 类型把"文档的灵活性"和"关系数据库的能力"合在了一个库里:JSON 可以存任意结构,同时享受强类型校验、GIN 任意键索引、标准 SQL 查询和真正的 ACID 事务——商品基础列和扩展 JSON 在同一行、同一事务、同一条 JOIN 语句里。这篇文章讲清 JSONB 的真实能力边界和五个落地场景,回答那个反复出现的问题:到底什么时候该用 JSONB 扛住,什么时候才真的需要 MongoDB。


一、先搞清楚:JSONB 到底是什么,和 MongoDB 差在哪

1.1 json vs jsonb:一个存文本,一个存二进制

PG 有两种 JSON 类型,选错了性能差几个数量级:

维度jsonjsonb
存储原始文本(保留空格/键顺序/重复键)解析后的二进制(去空格、键去重、保最后一个值)
写入快(原样存)略慢(要解析)
查询每次查询都重新解析直接在二进制结构上操作
可索引不能建 GIN(或意义极小)可建 GIN
比较/包含不支持高效操作支持 @>、?、?、?&
使用建议只写不读的归档原文99% 的业务场景都用 jsonb
CREATE TABLE demo (raw json, parsed jsonb);
INSERT INTO demo VALUES ('{"b": 1, "a": 2}', '{"b": 1, "a": 2}');

SELECT raw FROM demo;     -- {"b": 1, "a": 2}     原文什么样就什么样
SELECT parsed FROM demo;  -- {"a": 2, "b": 1}     键被规范化重排

1.2 JSONB vs MongoDB:能力对照

维度PG JSONBMongoDB
文档模型行列中嵌 JSONB集合即文档库
查询语言标准 SQL + JSON 操作符/函数MQL(专有语法,聚合管道)
与关系数据 JOIN原生,一条 SQL$lookup(能力弱、性能与心智成本高)
事务文档和关系列同一事务,真 ACID单文档原子;多文档事务有额外成本和限制
文档任意键索引GIN(@>、?)多键索引
固定路径索引B-tree 表达式索引(小而快)单字段索引
类型/约束CHECK 约束、非空、外键、枚举都能约束 JSON 内容Schema 校验($jsonSchema),但无 JOIN/外键生态
水平扩展分区表/读写分离(单机为主)原生分片 Sharding 是优势
运维一套 PG 全搞定额外一套副本集/分片集群
适用规模文档是业务的"一部分"文档就是业务本体、海量文档、需水平分片

结论先行:JSONB 适合"关系数据为主、半结构化字段为辅"的绝大多数业务系统;MongoDB 适合"整个系统围绕海量 Schema-less 文档构建、天然需要分片"的场景(如内容平台的原始采集库、日志大库)。用 Mongo 存商品扩展属性、再用 PG 存订单——这是典型的双库事务陷阱,而这正是 JSONB 的主场。

1.3 必会的操作符速览

-- 取内容
detail -> 'name'            -- 返回 jsonb(保留类型):"张三"
detail ->> 'name'           -- 返回 text:张三
detail #> '{addr,city}'     -- 按路径取 jsonb
detail #>> '{addr,city}'    -- 按路径取 text

-- 包含/存在(GIN 可索引)
detail @> '{"city":"上海"}'::jsonb   -- 左侧包含右侧(最常用)
detail ? 'couponCode'                -- 是否含此顶层键
detail ?| ARRAY['a','b']             -- 含任意一键
detail ?& ARRAY['a','b']             -- 同时含多键

-- 修改
detail || '{"vip":true}'::jsonb      -- 合并/覆盖键
detail - 'vip'                       -- 删除键
jsonb_set(detail, '{addr,city}', '"北京"')  -- 按路径更新

-> 返回 jsonb、->> 返回 text 的区别非常重要:后续要做数值比较/排序时用 ->> 再 ::bigint::numeric 转型;继续做包含判断用 ->。


二、场景一:灵活字段——商品扩展属性(最经典场景)

2.1 表设计:固定列 + JSONB 扩展列

CREATE TABLE product (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    category_id BIGINT NOT NULL,
    name        TEXT NOT NULL,
    price       NUMERIC(10,2) NOT NULL,   -- 固定、强一致、参与交易的列独立出来
    status      SMALLINT NOT NULL DEFAULT 0,
    attrs       JSONB NOT NULL DEFAULT '{}'::jsonb,  -- 各品类的差异化属性
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

INSERT INTO product(category_id, name, price, attrs) VALUES
-- 手机
(10, '某旗舰手机', 5999.00, '{"ram":"16GB","storage":"512GB","screen":6.8,"battery":5000}'),
-- 服装
(20, '纯棉T恤', 99.00, '{"size":["S","M","L","XL"],"color":["白","黑"],"material":"纯棉"}'),
-- 食品
(30, '进口牛奶', 69.00, '{"volume":"1L","shelfLifeDays":365,"origin":"新西兰"}');

设计纪律:不是所有字段都往 JSONB 里扔。 遵循"三进三不进":

放进 JSONB独立成列
品类差异大、结构不固定(内存/尺码/保质期)参与事务一致性的(价格、库存、状态)
主要用于展示、很少参与 WHERE/JOIN高频查询/排序/外键关联(name、category_id)
新增频繁、改结构不想 DDL需要强类型/唯一约束/CHECK 的字段

价格放 JSONB 里会怎样?金额聚合要写 SUM((attrs->>'price')::numeric)、无法直接建外键、无法享受类型保护,还可能被写入字符串 "5999元"——交易字段必须独立。

2.2 查询:属性包含、存在性、按属性值排序

-- ① 查支持 512GB 存储的手机(@> 包含,走 GIN)
SELECT id, name, attrs ->> 'storage' AS storage
FROM product
WHERE category_id = 10
  AND attrs @> '{"storage":"512GB"}';

-- ② 查所有有"产地"属性的食品(? 键存在)
SELECT id, name FROM product WHERE category_id = 30 AND attrs ? 'origin';

-- ③ 手机按屏幕尺寸降序(路径提取 + 转型,用表达式 B-tree 索引)
SELECT name, (attrs ->> 'screen')::numeric AS screen
FROM product
WHERE category_id = 10
ORDER BY (attrs ->> 'screen')::numeric DESC;

-- ④ 同时含多个属性(?&)
SELECT name FROM product
WHERE attrs ?& ARRAY['ram', 'storage', 'battery'];

2.3 索引:固定路径用 B-tree,任意属性用 GIN

-- GIN:任意键的 @>/? 查询兜底
CREATE INDEX idx_product_attrs_gin ON product USING gin (attrs);

-- B-tree 表达式:高频固定路径,比 GIN 小且快(支持范围/排序)
CREATE INDEX idx_product_screen ON product
    (((attrs ->> 'screen')::numeric));
CREATE INDEX idx_product_category_storage ON product
    (category_id, ((attrs ->> 'storage')::text));

规划:全表几万个 SKU、属性查询模式随品类变化 → GIN 一个就够;某属性变成高频固定筛选项(如前台筛屏幕尺寸)→ 补 B-tree 表达式索引。

2.4 用 CHECK 约束给 JSON 内容立规矩

JSONB 灵活不等于可以乱写,必要的约束照样能加在数据库层:

-- 服装类必须有 size 数组;食品类必须有保质期
ALTER TABLE product ADD CONSTRAINT chk_attrs_shape CHECK (
    (category_id <> 20 OR jsonb_typeof(attrs -> 'size') = 'array')
    AND
    (category_id <> 30 OR (attrs ->> 'shelfLifeDays') ~ '^\d+$')
);

-- 禁止出现意外的顶层键(白名单过严可只在核心品类使用)
-- ALTER TABLE product ADD CONSTRAINT chk_attrs_keys CHECK (
--     attrs ?& ARRAY['ram'] OR NOT (category_id = 10)
-- );

三、场景二:日志/事件存储——半结构化 + 可查询的落地方案

3.1 为什么日志适合 JSONB

应用日志、业务事件(用户行为、审计、状态流转)的特点是字段多且随版本变化:v1 有 userId,v2 加了 traceId,v3 又加了设备信息。建成宽表要不停 DDL,建成独立键值表查询痛苦。JSONB 一行存下整个事件:

CREATE TABLE biz_event (
    id         BIGINT GENERATED ALWAYS AS IDENTITY,
    event_type TEXT NOT NULL,
    user_id    BIGINT,
    occurred_at TIMESTAMPTZ NOT NULL DEFAULT now(),  -- 高频过滤列独立
    payload    JSONB NOT NULL DEFAULT '{}'::jsonb
);

INSERT INTO biz_event(event_type, user_id, payload) VALUES
('order_paid', 8821, '{"orderNo":"SO20260919001","amount":199.00,"channel":"app","items":3,"traceId":"abc123"}'),
('login', 8821, '{"ip":"10.2.3.4","device":"iPhone","success":true}');

3.2 查询与索引

-- 按 JSON 内字段过滤 + 时间范围
SELECT occurred_at, payload ->> 'orderNo' AS order_no
FROM biz_event
WHERE event_type = 'order_paid'
  AND occurred_at > now() - INTERVAL '7 days'
  AND payload @> '{"channel":"app"}'
ORDER BY occurred_at DESC
LIMIT 50;

-- 统计 app 渠道支付事件的平均金额(聚合 JSONB 字段)
SELECT date(occurred_at) AS d,
       count(*) AS cnt,
       avg((payload ->> 'amount')::numeric) AS avg_amount
FROM biz_event
WHERE event_type = 'order_paid' AND payload @> '{"channel":"app"}'
GROUP BY date(occurred_at)
ORDER BY d DESC;
-- GIN + 时间复合考虑:一般给 event_type/occurred_at 建 B-tree,给 payload 建 GIN
CREATE INDEX idx_event_time ON biz_event(event_type, occurred_at DESC);
CREATE INDEX idx_event_payload_gin ON biz_event USING gin (payload);

3.3 边界:海量日志请配合分区/专用库

JSONB 不是让你把 PG 当 ELK 用:

数据量级/用途建议
业务事件/审计(千万~几亿行,要和业务数据关联)PG JSONB + 声明式分区(按月 RANGE) + 热数据保留策略
机器指标/海量访问日志(每天数亿行,纯全文检索分析)ClickHouse / ELK / Loki 更合适

JSONB 日志表的正确姿势:PARTITION BY RANGE (occurred_at),按月 ATTACH/DETACH,老分区压缩或直接 DROP——JSONB 负责"结构化可查",分区负责"生命周期",不要让日志表无限膨胀。


四、场景三:配置存储——版本化、可审计、可事务更新

4.1 替代配置文件/零散配置表

系统配置、营销规则、租户参数的特点:结构因配置项而异、要灰度、要回滚、要记录谁改了什么。用 JSONB 一行一个配置对象:

CREATE TABLE sys_config (
    id          BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    config_key  TEXT NOT NULL UNIQUE,
    value       JSONB NOT NULL,
    version     INT NOT NULL DEFAULT 1,
    updated_by  TEXT NOT NULL,
    updated_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    CHECK (value IS NOT NULL)
);

INSERT INTO sys_config(config_key, value, updated_by) VALUES
('order.timeout', '{"payTimeoutSec":1800,"confirmTimeoutDays":7}', 'ops'),
('campaign.618', '{"enabled":true,"rules":{"fullReduction":[{"amount":300,"minus":50}]}}', 'marketing');

4.2 原子更新:只改 JSON 里的一个键

-- jsonb_set:路径级更新,整条 UPDATE 是事务原子的
UPDATE sys_config
SET value = jsonb_set(value, '{rules,fullReduction,0,minus}', '60'),
    version = version + 1,
    updated_at = now()
WHERE config_key = 'campaign.618';

-- || 合并顶层键(新增/覆盖)
UPDATE sys_config
SET value = value || '{"grayPercent":20}'::jsonb,
    version = version + 1
WHERE config_key = 'campaign.618';

配合 Nacos 配置中心的分工:Nacos 管热推送的运行参数,sys_config 表管需要和业务数据强一致、要审计/版本/事务的配置(如营销规则与订单引用同一事务)。

4.3 配置快照:历史版本可回滚

CREATE TABLE sys_config_history (LIKE sys_config INCLUDING ALL);

-- 每次更新前把旧值插入历史(触发器或应用层显式写)
INSERT INTO sys_config_history
SELECT * FROM sys_config WHERE config_key = 'campaign.618';

回滚 = 把历史版本的 value 写回当前表,一条事务完成。MongoDB 当然也能做类似设计,但配置和业务数据同库时,PG 可以在一个事务里"改配置 + 写业务流水",Mongo 需要应用层补偿。


五、场景四:嵌套深层查询——JSONPath 与数组展开

当 JSON 嵌得深(订单快照里有商品数组、商品里有优惠明细),箭头操作符写起来吃力,PG 提供 SQL/JSON 标准的 JSONPath:

5.1 样例数据

CREATE TABLE order_snapshot (
    id BIGINT PRIMARY KEY,
    data JSONB NOT NULL
);

INSERT INTO order_snapshot VALUES
(1, '{
  "orderNo":"SO001", "userId":8821,
  "items":[
    {"sku":"A1","name":"手机","price":5999,"promos":[{"type":"coupon","minus":200}]},
    {"sku":"B2","name":"耳机","price":399,"promos":[]}
  ]}');

5.2 jsonb_path_query:用 JSONPath 表达式深入数组

-- 取所有商品名称($.items[*].name)
SELECT jsonb_path_query(data, '$.items[*].name') AS item_name
FROM order_snapshot WHERE id = 1;
-- "手机" / "耳机"(两行)

-- 找价格 > 1000 的商品
SELECT jsonb_path_query(data, '$.items[*] ? (@.price > 1000)') AS expensive
FROM order_snapshot WHERE id = 1;

-- 用过滤器:有优惠券的商品名
SELECT jsonb_path_query(data,
    '$.items[*] ? (@.promos.size() > 0).name')
FROM order_snapshot;

常用 JSONPath 语法:

语法含义
$根
.a.b / $."a-b"键(特殊键加引号)
[*] / [0,2]数组全部/指定下标
? (条件)过滤器,@ 代表当前元素
@.price > 100条件表达式
.size()数组/对象长度

5.3 一组常用函数的分工

SELECT
  jsonb_path_query(data, '$.items[*].name')                          -- 返回每个匹配(多行)
FROM order_snapshot;

SELECT jsonb_path_query_first(data, '$.items[*].sku') FROM order_snapshot;  -- 只取第一个
SELECT jsonb_path_exists(data, '$.items[*] ? (@.sku == "A1")') FROM order_snapshot;  -- 是否存在(布尔,适合 WHERE)
SELECT jsonb_path_match(data, 'exists($.items[*] ? (@.price > 5000))') FROM order_snapshot;

WHERE 里优先用 jsonb_path_exists(只判存在,不拉数据,配合 GIN 的 @@ jsonpath 操作符还能走索引):

-- PG12+:JSONPath 谓词配合 GIN(需要 jsonb_path_ops)
SELECT id FROM order_snapshot
WHERE data @? '$.items[*] ? (@.sku == "A1" && @.price > 5000)';

5.4 数组展开成行:jsonb_array_elements + 横向关联

要把 JSON 数组变成关系行做 GROUP BY/JOIN,用 jsonb_array_elements(或 jsonb_to_recordset):

SELECT o.id,
       item ->> 'sku'   AS sku,
       (item ->> 'price')::numeric AS price
FROM order_snapshot o,
     LATERAL jsonb_array_elements(o.data -> 'items') AS item;
-- 一行订单展开成两行商品,后续可正常 WHERE/GROUP BY/JOIN
-- 直接按数组元素结构转记录集(字段要显式声明类型)
SELECT x.sku, x.price
FROM order_snapshot,
     LATERAL jsonb_to_recordset(data -> 'items')
         AS x(sku text, name text, price numeric);

展开后就是普通关系表,能和任何物理表 JOIN——这是 JSONB 相对纯文档库最实用的能力:文档随时可以"临时关系化"参与 SQL 计算。


六、场景五:与关系表混合——文档与关系在一条 SQL 里

这是决定"为什么不必上 Mongo"的压轴场景。商品表有 JSONB 属性,订单/库存/评价都是关系表:

6.1 JSONB 与关系表 JOIN

-- 查"512GB 手机"的近 30 天销量,按属性+关系数据联合过滤聚合
SELECT p.id, p.name,
       p.attrs ->> 'storage' AS storage,
       count(oi.id) AS sold_cnt,
       sum(oi.quantity) AS sold_qty
FROM product p
JOIN order_item oi ON oi.product_id = p.id
JOIN "order" o ON o.id = oi.order_id
WHERE p.category_id = 10
  AND p.attrs @> '{"storage":"512GB"}'
  AND o.created_at > now() - INTERVAL '30 days'
  AND o.status = 'COMPLETED'
GROUP BY p.id, p.name, storage
ORDER BY sold_qty DESC;

同一条 SQL 里:GIN 索引过滤 JSONB 属性、B-tree 走订单时间、JOIN 走外键索引、聚合走标准 SQL——这在 Mongo 里要用 $lookup + 聚合管道拼一大段专有代码,而且订单与商品的写入无法在一个事务里。

6.2 事务一致性:关系列与 JSON 同生共死

-- 下单:订单行 + 订单快照(含商品当时的属性快照)同一事务写入
BEGIN;
INSERT INTO "order"(id, user_id, amount, status)
VALUES (1001, 8821, 5999.00, 'CREATED');

INSERT INTO order_snapshot(id, data)
VALUES (1001, jsonb_build_object(
    'orderNo', 'SO1001',
    'userId', 8821,
    'items', (
        SELECT jsonb_agg(jsonb_build_object(
            'sku', p.id, 'name', p.name, 'price', p.price,
            'attrs', p.attrs))
        FROM product p WHERE p.id IN (10, 20)
    )
));

-- 扣减关系库存……任何一步失败全部回滚,订单和快照永远一致
COMMIT;

jsonb_build_object / jsonb_agg 可以在 INSERT 时直接把关系数据构造成 JSON 快照——写入时文档化、查询时关系化,结构由你按场景选。

6.3 与 MyBatis / Hibernate 集成

MyBatis(推荐:TypeHandler 直接映射):

// JSONB 列在 Java 侧就是 Map/POJO
public class Product {
    private Long id;
    private String name;
    private Map<String, Object> attrs;   // 或自定义 Attrs POJO
}
@MappedTypes({Map.class})
public class JsonbTypeHandler extends BaseTypeHandler<Map<String, Object>> {
    private static final ObjectMapper MAPPER = new ObjectMapper();

    @Override
    public void setNonNullParameter(PreparedStatement ps, int i,
                                    Map<String, Object> param, JdbcType jdbcType)
            throws SQLException {
        PGobject obj = new PGobject();
        obj.setType("jsonb");
        try { obj.setValue(MAPPER.writeValueAsString(param)); }
        catch (JsonProcessingException e) { throw new SQLException(e); }
        ps.setObject(i, obj);                    // ★ 必须以 PGobject(jsonb) 传入
    }

    @Override
    public Map<String, Object> getNullableResult(ResultSet rs, String col)
            throws SQLException {
        return parse(rs.getString(col));
    }
    // 其余两个重载略;parse:null 安全,用 MAPPER.readValue
}
<resultMap id="productMap" type="Product">
    <id property="id" column="id"/>
    <result property="attrs" column="attrs"
            typeHandler="com.xxx.typehandler.JsonbTypeHandler"/>
</resultMap>

<!-- JSONB 条件用 ::jsonb 显式转型,参数经 TypeHandler 绑定 -->
SELECT * FROM product
WHERE category_id = #{categoryId}
  AND attrs @> #{attrs,typeHandler=com.xxx.typehandler.JsonbTypeHandler}::jsonb

两个高频坑:① 用 setString 传 JSON 给 jsonb 列会报 column is of type jsonb but expression is of type character varying,必须包 PGobject(type=jsonb) 或连接串加 stringtype=unspecified;② JSONB 内字段做条件时 SQL 里写 #{...}::jsonb 显式转型。

Hibernate(Spring Boot JPA):用 @JdbcTypeCode(SqlTypes.JSON)(Hibernate 6 / Boot 3 标准方式):

@Entity
@Table(name = "product")
public class Product {   // Hibernate 6(Spring Boot 3)无需旧版 @TypeDef/@Type
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    @JdbcTypeCode(SqlTypes.JSON)
    @Column(columnDefinition = "jsonb")
    private Map<String, Object> attrs;
}

七、什么时候 JSONB 不够、真的该上 MongoDB

诚实地划边界,避免"手里拿锤子看什么都是钉子":

信号说明
文档是业务本体而非附属整个系统围绕海量文档(CMS 原始内容库、爬虫数据、用户上报的自由结构数据),关系特征很弱
需要原生水平分片单集合 TB~PB 级、写入 QPS 远超单机,MongoDB 分片内建;PG 要靠 Citus/分库分表
文档模型天然聚合管道大量针对嵌套数组的专有聚合,且不需要跨集合事务/JOIN
多区域/异地文档副本MongoDB Atlas 地理分片等托管能力

反过来,只要你的 JSON 数据需要和订单/用户/库存发生 JOIN、需要和关系数据在同一事务里、团队不想维护第二套数据库,JSONB 就是更优解。实践中 80% 喊着要上 Mongo 的业务,落在"商品扩展属性/配置/事件"这一类,JSONB 全部能优雅接住。

性能量级参考(JSONB GIN,普通 SSD 单实例)

查询数据量无索引GIN 索引
attrs @> 固定属性500 万商品400~900ms(全扫)2~8ms
payload 任意键存在1 亿事件秒级全扫5~20ms(位图扫描)
固定路径数值排序500 万全扫+排序表达式 B-tree 毫秒级

八、常见问题

8.1 JSONB 写得很频繁,性能会不会很差?

JSONB 写入要解析+二进制化,比纯标量列重,但远没有传言夸张:单行小文档(<1KB)写开销在微秒级,OLTP 完全可承受。真正要注意的是 GIN 索引的写入放大——一个 JSONB 列上建了 GIN,每次插入要维护倒排项。写多读少的表用 WITH (fastupdate=on)(默认开),批量导入先 DROP 索引再重建,别在同一列上重复建多个 GIN。

8.2 JSONB 里的字段能不能设默认值/非空?

可以,通过 CHECK 和提取表达式实现:ALTER TABLE product ADD CONSTRAINT chk_ram CHECK ((attrs->>'ram') IS NOT NULL OR category_id <> 10);默认值在列级 DEFAULT '{}'::jsonb,新键默认用 jsonb_set(..., true) 的 create-if-missing 参数,或应用层统一构造。但复杂约束(跨表、唯一)不要硬塞 JSON——那说明这个字段该独立成列。

8.3 更新 JSONB 一个键会不会重写整个文档?

PG 的 MVCC 下 UPDATE 本来就是写新版本行;jsonb_set/|| 在逻辑上只改目标键,但物理上整行重写(TOAST 过大的 jsonb 会被线外存储,未变化的 TOAST 页可以复用,大文档场景影响较小)。所以不要把 JSONB 当高频计数器用(每秒 +1 的字段独立成列或用专用计数表),它适合"整体读出、偶尔整体/局部更新"的文档。

8.4 JSONB 存数组后数组很大,查询有什么最佳实践?

单文档控制在 KB 级,别把无界数组(一个用户的所有订单、一篇文章的全部评论)塞进一个 JSONB——大数组的解析、TOAST、GIN 维护都会劣化,而且并发更新同一文档会造成行锁竞争。数组元素超过几百个就该拆成子表(一对多关系),JSONB 存的是"快照型、有界、随主记录一起读写"的集合。

8.5 JSONB 和直接用 Redis 存 JSON 怎么分工?

Redis 是内存缓存,解决热数据低延迟读写、TTL 过期、计数器等;JSONB 是持久化的数据库能力,支持复杂查询、事务、JOIN。常见组合:MySQL/PG 是事实来源(JSONB 落库),Redis 缓存热点文档(序列化 JSON,旁路缓存模式),不要把 Redis 当可查询文档库用——它没有 GIN,也不保证和关系数据的事务一致。

8.6 团队已经有 MongoDB 了,要不要迁回 JSONB?

看两个信号:① Mongo 里的集合是否频繁被 $lookup/双写 和 PG 数据联动(联动越多越该迁回,事务和 JOIN 是硬痛点);② Mongo 集群是否真的用到了分片红利。只是存商品属性/配置这种中小规模文档,迁回 JSONB 的收益主要是简化架构和事务一致,可用双写灰度 + 读切换逐步迁移;若 Mongo 在承担海量独立文档存储且运行健康,不必为了"技术栈统一"而迁。


九、总结

JSONB 使用速查卡

决策:JSON 是业务的"附属部分"且要和关系数据联动 → JSONB
      文档是业务本体 + 海量 + 需原生分片 → MongoDB
类型:99% 用 jsonb(不要用 json)
取数:-> 取 jsonb / ->> 取 text / #> 按路径
谓词:@> 包含  ? 键存在  ?|  ?&
深查:jsonb_path_query/exists + $.items[*] ? (@.price>1000)
展开:jsonb_array_elements + LATERAL(文档临时关系化)
更新:jsonb_set(路径,值) / || 合并 / - 删键
索引:任意键 GIN(attrs);固定路径 B-tree 表达式 ((attrs->>'x')::numeric)
纪律:交易/高频/需约束的列独立;JSONB 放差异大、展示型、有界文档
集成:MyBatis 用 PGobject TypeHandler;JPA 用 @JdbcTypeCode(SqlTypes.JSON)

一句话

"要不要为 JSON 引入 MongoDB"这个问题的正确问法是:半结构化数据和你的核心关系数据要不要在同一个事务、同一套 JOIN、同一个备份里。商品有按品类变化的扩展属性、日志事件随版本加字段、营销规则要原子改一个键并留版本、订单快照里嵌着商品数组、销量统计要把 JSON 属性和订单表联起来聚合——这五个场景的共同特征是"文档是关系数据的附属部分",它们需要的不是第二套数据库,而是 JSONB:用 json 而不是 jsonb 存文本是第一个要避开的坑,因为 jsonb 存的是解析后的二进制,重复查询不再重新解析、还能建 GIN;设计上守住"三进三不进"——差异大、展示型、结构随版本变的字段进 JSONB,价格库存状态这类参与事务、高频过滤排序、需要类型和唯一约束的字段独立成列;查询时 @>、? 负责包含与键存在且能被 GIN 加速,jsonb_path_query 用 JSONPath 深入嵌套数组,jsonb_array_elements 配合 LATERAL 能随时把文档展开成关系行去 GROUP BY 和 JOIN,jsonb_set 和 || 让局部更新成为一个原子事务,CHECK 约束还能给 JSON 内容立类型规矩——一套能力下来,文档的灵活和关系数据库的严谨同时到手。真正需要 MongoDB 的信号只有几个:文档本身就是业务本体、单集合 TB/PB 级需要原生分片、几乎不需要跨集合事务与 JOIN;而商品扩展属性、配置、审计事件这一类中小规模、强联动的文档,用 JSONB 接住的收益是架构上少一整套数据库:少一次跨库事务的妥协、少一段应用层拼装的 JOIN、少一个副本集的备份监控和学习成本。最后落地别忘两个工程细节:MyBatis 必须用 PGobject 包装 jsonb 参数(否则就是经典的 type jsonb but expression varchar 报错),GIN 索引用 fastupdate 和批量导入先删后建来消化写入代价——JSONB 给你的是"一个库里两种自由",用纪律用好它,比多养一个数据库划算得多。

给团队的建议

项建议
选型先判断文档与关系数据是否需要事务/JOIN;需要就 JSONB
建模交易字段独立成列,差异化展示字段进 attrs;单文档 KB 级、数组有界
索引默认一个 GIN 兜底,高频固定路径补 B-tree 表达式索引
查询简单路径用箭头,深层用 JSONPath,需要关系化就 LATERAL 展开
约束用 CHECK 给核心品类 JSON 内容立规矩
日志JSONB + 按月分区 + 保留策略,海量机器日志交给专用分析库
接入MyBatis TypeHandler(PGobject)/ JPA @JdbcTypeCode(SqlTypes.JSON)
演进某 JSON 字段变成高频强一致字段 → 独立成列;文档量到 PB 级 → 再评估 Mongo

互动话题:你们有没有过"为了灵活字段引入 MongoDB,后来又怀念 SQL"的经历?JSONB 在生产里扛过多大的数据量?评论区聊聊。


参考资料


标题:PostgreSQL JSONB 实战:替代 MongoDB 的 5 个场景
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/09/24/1789828084719.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消