java

关注公众号 jb51net

关闭
首页 > 软件编程 > java > SpringBoot PostgreSQL JSONB字段

SpringBoot使用PostgreSQL的JSONB字段的实践教学

作者:(farerboy)

本文主要和大家重点探讨在 Spring Boot 应用中使用 PostgreSQL 的 JSONB 类型替代 MongoDB 来支持动态扩展字段的实践,文中的示例代码讲解详细,感兴趣的小伙伴可以了解下

先说说我见到的真实情况。某业务系统的核心客户表有二十多个基础字段,这很正常。后来为了支持各种行业客户,陆陆续续往里加了快八十个扩展列。这些列里大部分租户根本用不到,常年 NULL。再往后连索引都不好建,开发时看一眼实体类都嫌烦。

你要是和业务方说“这种扩展需求我们用 MongoDB 吧”,对方多半没意见,但等你把订单、客户、对账这些强关系数据也搬过去,事务和报表就会反过来折磨你。

实际上很多团队没意识到:手头 PostgreSQL 里的 jsonb,就能让关系表在保持核心结构稳定不变的同时,把那些“说不清什么时候增加”的字段塞进去。不是要替代 MongoDB,而是给大多数企业应用一条更平滑的路。

到底是加列、EAV,还是上 MongoDB

遇到这种“灵活字段”需求,我一般劝人先别急着加列。

加物理列是最直观,也是最不可持续的。一次上线加五十个扩展列,那是物理 DDL,不是你 Java 里加几个属性。在晚上发版排期的时候,DBA 看着你这张表锁表,大概率不会给你通过。

也有团队用 EAV,也就是属性表:一张表专门存 entity_id、attr_name、attr_value。写数据的时候确实天下太平,可查询的时候噩梦就来了。想还原一条业务对象,得 join 十几行属性;想按自定义字段过滤,要么做一堆子查询,要么行转列,SQL 写到后面自己都不想维护。

MongoDB 呢?文档结构确实灵活,多租户动态表单也好做。但公司里通常还有一堆报表和既有业务跑在 PostgreSQL 上,你把客户、订单一部分挪去 MongoDB,另一部分还在关系库里,跨库再搞个同步,前后端联调时分布式事务问题一个接一个。

JSONB 的意义在于:你不需要在整个系统层面推倒重来,只是把“容易变的扩展属性”集中放到一个 jsonb 字段里,其他核心字段继续用普通列。想要事务、join、报表都没问题,想按 JSONB 内部字段过滤时也可以建索引。它不是银弹,但对大多数“核心关系不变,只有扩展属性老变”的场景,是综合成本最低的选择。

不过话说回来,如果你真是在做一个以文档为核心、几乎没有强关联查询的产品,那 MongoDB 会更自然。JSONB 也解决不了“必须对整个 jsonb 做大范围聚合分析”这类问题,这种场景还是老老实实拆成显式列更踏实。

JSONB 存储与索引的几个基础点

PostgreSQL 里的 json 和 jsonb 是两种东西。json 是文本存,jsonb 是解析后的二进制格式。jsonb 插入的时候会解析、去掉重复 key、内部重新排序,所以它没法保证你写入时的 key 顺序和你读出来时一样。

正常开发不用纠结,直接选 jsonb。

看个小例子:

create table product (
    id          bigint generated by default as identity primary key,
    name        varchar(200) not null,
    category_id bigint not null references category(id),
    attrs       jsonb not null default '{}'::jsonb,
    created_at  timestamptz not null default now()
);
insert into product (name, category_id, attrs)
values (
    '智能手表',
    1,
    '{"color": "黑色", "battery": {"capacity": 300, "unit": "mAh"}, "tags": ["智能", "运动"]}'::jsonb
);

读取方式比较自由:

-- 取字段为文本
select attrs->>'color' from product where id = 1;
-- 取整块 JSON 对象
select attrs->'battery' from product where id = 1;
-- 看数组长度
select jsonb_array_length(attrs->'tags') from product where id = 1;

要让这类查询跑得快,核心是 GIN 索引。

create index idx_product_attrs_gin
    on product using gin (attrs jsonb_path_ops);

这里有区分:

所以如果你的查询模式主要是“某个属性等于某个值”,直接用 jsonb_path_ops。如果还经常查“这个字段在不在”,那就老老实实用默认 gin(attrs)。

另外,GIN 索引不是万能的,后文会提一句。别以为建了 GIN 就能把所有 JSONB 查询都优化掉。

Spring Boot 里用 JPA/Hibernate 映射 JSONB

Spring Boot 3 自带的 Hibernate 6,其实已经内置了 JSON 映射,不需要再折腾自定义 UserType。

实体大概这么写:

@Entity
@Table(name = "product")
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    @Column(name = "category_id")
    private Long categoryId;

    @JdbcTypeCode(SqlTypes.JSON)
    @Column(columnDefinition = "jsonb")
    private ProductAttrs attrs;

    // getter/setter 省略
}

ProductAttrs 就是一个普通 POJO:

public class ProductAttrs {

    private String color;
    private Battery battery;
    private List<String> tags;

    // getter/setter 省略
}

Hibernate 会自动把你这个 POJO 序列化成 JSON,写进 jsonb 字段,读取时再反序列化回来。Spring Boot 的 web 项目默认有 Jackson,所以这个场景不需要额外引依赖。

如果你不想定义 POJO,也可以直接用 Map<String, Object>。这样做原型阶段很爽,但代码里到处是强转,后面接手的同事会很痛苦。我的习惯是:能定义结构还是定义结构,哪怕字段偶尔多一点。实在是很动态的表,才用 Map。

另外,如果你的项目还在用 Spring Boot 2.x / Hibernate 5,建议优先升到 Boot 3。老版本想用 JSONB 需要自己写 UserType,网上教程一大堆,但那是历史包袱,不值得继续学。

更新 JSONB:能写 SQL 就别整条读出来改

JPA 映射很方便,但它默认处理方式比较粗暴:更新一个 JSONB 字段时,会把整个对象读出来,在 Java 内存里改好,再整体 update。

这有两个问题:

  1. 并发下容易互相覆盖。两个人同时读了一个实体的 attrs,各改各的 key,后提交的人会把先提交的人那次修改覆盖掉。
  2. 文档很大时,全量写的代价没必要。

所以生产环境里我更喜欢用 MyBatis 或 JdbcTemplate 写原生 SQL,直接调用 PostgreSQL 的 jsonb_set 去做局部更新。

MyBatis 插入时可能要先用一个 TypeHandler,核心逻辑很简单:

public class JsonbTypeHandler extends BaseTypeHandler<Object> {

    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void setNonNullParameter(PreparedStatement ps, int i, Object parameter,
                                    JdbcType jdbcType) throws SQLException {
        try {
            PGobject jsonb = new PGobject();
            jsonb.setType("jsonb");
            jsonb.setValue(objectMapper.writeValueAsString(parameter));
            ps.setObject(i, jsonb);
        } catch (JsonProcessingException e) {
            throw new SQLException("can not convert to jsonb", e);
        }
    }

    // getNullableResult 的三个重载基本都一样:拿 PGobject 后把 value 反序列化回去
}

插入 SQL 就像这样:

<insert id="insertProduct" parameterType="Product"
        useGeneratedKeys="true" keyProperty="id">
    insert into product(name, category_id, attrs)
    values (#{name}, #{categoryId},
            #{attrs, jdbcType=OTHER, typeHandler=JsonbTypeHandler})
</insert>

不想用 TypeHandler 也可以,在 service 层先把对象序列化成 JSON 字符串,SQL 里写 #{jsonString}::jsonb,效果差不多。

局部更新时,jsonb_set 是主力:

update product
set attrs = jsonb_set(attrs, '{color}', '"红色"'::jsonb, true)
where id = #{id};

注意第二个参数是 text[],语法上写的是数组字面量。如果你要在 MyBatis 里动态传路径,路径要传成 {battery, capacity} 这种,不是 'battery.capacity'。

<update id="patchAttrs">
    update product
    set attrs = jsonb_set(attrs, #{path}::text[], #{newValue}::jsonb, true)
    where id = #{id}
</update>

很多人会在 newValue 这里踩坑。jsonb_set 的第三个参数必须是 jsonb,不是纯文本。如果你想把 color 改成 红色,Java 里传进来的字符串必须带引号,也就是:

objectMapper.writeValueAsString("红色")
// 得到的是 "红色"

不然 PG 会把 红色 当成一个标识符,直接报语法错误。

如果只是想给顶层加一个 key,用 || 合并更直接:

update product
set attrs = attrs || '{"stockUnit": "台"}'::jsonb
where id = #{id};

删除某个 key 就用减号:

-- 删除顶层 key
update product set attrs = attrs - 'color' where id = #{id};
-- 删除嵌套路径
update product set attrs = attrs #- '{battery, capacity}' where id = #{id};

但有一个坑必须记着:|| 只是顶层合并,不会递归合并。比如:

'{"battery": {"price": 1}}'::jsonb || '{"battery": {"brand": "xx"}}'::jsonb

结果不是把两个 battery 里的 key 合并起来,而是用后面的整个 {"brand":"xx"} 替换了原来的 battery。想要保旧 key 再改新 key,只能用 jsonb_set 精确指定路径。

JSONB 查询:几个实用姿势

最常见的查询是“属性值等于什么”,用 @> 包含操作符:

select * from product
where attrs @> '{"color": "黑色"}';

在 MyBatis 里参数可以传一个 JSON 字符串进去:

<select id="searchByAttr" resultType="Product">
    select * from product
    where attrs @> #{jsonFilter}::jsonb
</select>

如果你只想知道某个 key 存不存在,默认 GIN 索引下可以这么写:

select * from product
where attrs ? 'color';

但这类查询不要指望 jsonb_path_ops 索引能帮你。

如果 JSONB 内部是个数组,很多人喜欢把数组取出来再判断:

-- 这种写法在 GIN 索引上经常帮不上忙
select * from product where attrs->'tags' ? '智能';

更推荐直接用包含运算符:

select * from product
where attrs @> '{"tags": ["智能"]}';

这句话表达的是“attrs 里的 tags 数组包含元素 智能”。语义上没问题,也能更有效地利用 jsonb GIN 索引。

再说说路径查询。想查嵌套对象里的值:

select * from product
where attrs #>> '{battery, capacity}' = '300';

#>> 只能取出文本。如果 capacity 在 JSONB 里是数值,那比较的时候要转换:

select * from product
where (attrs #>> '{battery, capacity}')::numeric > 300;

不过这种路径查询本身没法直接用 GIN 索引。如果这条路径查询频率很高,可以针对它建一个表达式索引:

create index idx_product_battery_capacity
    on product (((attrs #>> '{battery, capacity}')::numeric));

查询条件必须和索引里的表达式完全一致,上面那条 where (attrs #>> '{battery, capacity}')::numeric > 300 就能命中索引。

同样,如果经常按某个固定 key 排序,比如查价格区间,与其傻傻地 GIN 一把梭,不如建立一个 B-tree 表达式索引:

create index idx_product_price on product (((attrs->>'price')::numeric));

查询时这样写:

select id, name, attrs->>'price' as price
from product
where attrs @> '{"brand": "Apple"}'
  and (attrs->>'price')::numeric between 100 and 500
order by (attrs->>'price')::numeric desc
limit 20;

这里的高频字段是“price”,用普通 B-tree 表达式索引可能比 GIN 更直接。

关系列和 JSONB 混用,有几件事别做

首先,别把业务外键塞进 JSONB 里面。比如 category_id、owner_id 这种以后要 join、要统计、要约束的字段,一定要放普通列。如果你图省事塞进 jsonb,将来外键约束没法加,关联查询也写不爽,数据不一致了更是查不出来。

JSONB 只放两类东西:

如果某些数据是要和其他表做关联的“主数据”,那应该拆成独立列,或者单独建关联表。不要因为看到 JSONB 支持数组就把关联 ID 全塞进去,那样最后受苦的是你自己。

JSONB 和普通列在同一行里,事务性完全没问题。你可以在一个事务里先更新 product 的普通列,再去 update 它的 jsonb 字段,最后一起 commit,谁都不会丢。

但并发更新这里要说得更细一点。PostgreSQL 的锁是行级锁,不是 jsonb 内部某个 key 的锁。两个事务同时更新同一行的不同 jsonb key,后到的事务会等前一个事务结束。这不是什么坏事,至少不会发生丢失更新。真正危险的是应用层“先 select 整个实体,改完再 update 全字段”这种写法。并发场景下一不小心就会覆盖别人的修改。

如果是在应用里先读出来判断,再决定改不改,建议在事务里加 select ... for update,或者干脆用 jsonb_set 写成一条原子 SQL,别在 Java 内存里搞“读改写”。

索引和性能调优经验

先纠正一个认知:JSONB 的 GIN 索引不是唯一选择,也不是所有 JSONB 查询都必须靠它。

我见过有人不管什么条件都建一个 gin(attrs jsonb_path_ops),然后写 attrs @> '{"brand": "Apple"}',这确实挺好。但遇到按某个 key 做范围查询时,GIN 就使不上劲了,这时候 B-tree 表达式索引才是正解。

再比如 attrs ? 'color' 这种查询,你用 jsonb_path_ops,索引根本不会理你。必须回退到默认 GIN。

还有一些操作本身就是反模式:比如想把 JSONB 转成文本然后 like:

-- 这种写法几乎不可能走索引,也不推荐
select * from product
where attrs::text like '%黑色%';

真要模糊搜索,也得先想清楚是搜哪个 key,然后针对那个 key 做 pg_trgm 表达式索引。不过这种场景本来就少,多数情况下更好的做法是在业务设计上避免。

还有一点要注意:JSONB 字段里的数据类型要稳定。同一个字段,今天存数字 300,明天存字符串 "300",不光表达式索引白建,应用层反序列化也会随机出问题。JSONB 灵活是指字段可以增删,不是让你同一个字段的类型换来换去。

我自己在一个大约五百万行的商品表上做过测试。当时没建 GIN,查 attrs @> '{"brand": "Apple"}' 大概要 1200 多毫秒,基本是全表扫。加了 gin(attrs jsonb_path_ops) 之后,同一条查询降到几毫秒。但这个数据只能当参考,索引效果和你的数据分布关系很大。如果表里几十万行都包含同一个品牌,这个 GIN 项会特别“稠密”,查询效果也不会太好。遇到这种极端情况,不如把那个高频品牌字段抽出来当普通列,然后建普通索引。

另外,深度分页配上 JSONB 排序容易让数据库做无用功。别老是用 offset 翻页,特别是数据量大以后。用游标分页,把上次分页结果里的价格和主键作为下次查询条件,会比 limit 20 offset 100000 舒服得多:

select id, name, attrs->>'price' as price
from product
where attrs @> '{"brand": "Apple"}'
  and (attrs->>'price')::numeric < #{lastPrice}
order by (attrs->>'price')::numeric desc
limit 20;

如果价格会重复,最好再把 id 也带进游标条件里,否则翻页可能漏数据。

别把 JSONB 当成 MongoDB 平替

JSONB 能做到现在的程度,最大的价值不是让你彻底不用 MongoDB,而是让你在同一个 PostgreSQL 实例里,把“稳定的关系核心”和“不稳定的扩展属性”组合在一起。订单核心结构照旧用列,自定义优惠信息、物流备注、买家标签这类东西放进 JSONB,既不用动表结构,又还能用 SQL 统计。

这也对设计者提了个要求:你要清楚哪些字段未来一定会参与查询,哪些只是存档。对参与查询的 JSONB 字段,要么约束好类型,要么建立对应的表达式索引;对只是存档的字段,别去为它写一堆复杂查询。

做技术选型最怕的不是选错,而是一个方案稍微好用就到处乱用,最后把数据模型搞成一团浆糊。如果有人再问你“要不要为了支持自定义字段,直接上 MongoDB”,你先看看他现在数据库是不是 PostgreSQL。也许一个 jsonb 列已经能省掉后面大堆麻烦事。

以上就是SpringBoot使用PostgreSQL的JSONB字段的实践教学的详细内容,更多关于SpringBoot PostgreSQL JSONB字段的资料请关注脚本之家其它相关文章!

您可能感兴趣的文章:
阅读全文