分类:数据库与中间件 · 作者:管理员 · 发布于 2026-10-28 · 阅读 0 · 标签:PostgreSQL / JSONB / GIN索引 / MyBatis / 电商

一、痛点:电商扩展字段的两种旧方案都不够用

做电商中台的同学几乎都会遇到同一个问题:商品、订单、优惠券这些核心表,总有一些"说不准"的扩展属性。比如商品的颜色/容量/是否支持 NFC,订单的赠品信息/发票抬头,这些字段今天有,明天可能就删,不同品类差异还极大。

旧方案无非两种:

  • 加列:每来一个新属性就 ALTER TABLE 加一列。表越来越宽,100 列里 80 列是空的,索引和行宽都失控,DBA 看了血压飙升。
  • EAV(实体-属性-值):拆成 attribute 表,一行一个属性。查询要自连接拼行转列,代码里全是 Map 组装,性能与可维护性双输。

PostgreSQL 的 JSONB 提供了第三条路:把不确定的扩展属性整体存进一个 JSON 字段,需要筛选时用 GIN 索引加速。本文以一个"商品属性"场景为例,讲清楚 JSONB 的建模、查询、索引和踩坑。

二、为什么是 JSONB 而不是 JSON

PG 同时提供 json 和 jsonb 两种类型,核心区别:

  • json:按原文存储,保留键顺序、允许重复键,查询时每次都要重新解析文本。
  • jsonb:存储为分解后的二进制格式,查询快,但不保留键顺序、自动去重(保留最后一个值),写入时有额外转换开销。

业务字段读取频率远高于写入,所以扩展属性几乎总是选 jsonb。建表示例:

CREATE TABLE product (
    id          BIGSERIAL PRIMARY KEY,
    name        TEXT NOT NULL,
    base_price  NUMERIC(10,2) NOT NULL,
    attrs       JSONB NOT NULL DEFAULT '{}'::jsonb,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);

-- GIN 索引:核心!后面详解
CREATE INDEX idx_product_attrs ON product USING GIN (attrs jsonb_path_ops);

写入一条带扩展属性的商品:

INSERT INTO product (name, base_price, attrs)
VALUES ('智能手环 Pro', 299.00,
        '{"color":"黑色","storage":"32GB","supportNfc":true}'::jsonb);

三、常用操作符速查

JSONB 的操作符很多,日常最常用的就这几个:

  • ->:取字段,返回 JSONB(带引号);->>:取字段,返回文本。
  • #> / #>>:按路径取嵌套字段,如 attrs #>> '{spec,weight}'。
  • @>:左侧 JSONB 是否包含右侧(包含查询,重点)。
  • ?:键是否存在;?| 任一存在;?& 全部存在。
-- 查所有黑色商品
SELECT id, name FROM product
WHERE attrs @> '{"color":"黑色"}'::jsonb;

-- 查带 storage 键的商品
SELECT id, name FROM product WHERE attrs ? 'storage';

-- 取某个字段的值
SELECT id, attrs->>'color' AS color FROM product WHERE id = 1;

四、Spring Boot + MyBatis 的映射写法

PG 的 JDBC 驱动把 jsonb 返回为字符串,实体里直接用 String 字段接最省事:

public class Product {
    private Long id;
    private String name;
    private BigDecimal basePrice;
    private String attrs; // 内部就是 JSON 字符串
}
<insert id="insert" parameterType="com.example.mall.Product">
    INSERT INTO product (name, base_price, attrs)
    VALUES (#{name}, #{basePrice}, #{attrs}::jsonb)
</insert>

<select id="selectByAttr" resultType="com.example.mall.Product">
    SELECT id, name, base_price, attrs
    FROM product
    WHERE attrs @> #{jsonFilter}::jsonb
</select>

两个要点:

  • 写入时一定要加 ::jsonb 显式转型,否则参数类型推断可能把字符串当成 text 插入失败。
  • 应用层统一用 Jackson 做对象与 JSON 字符串互转,不要在 SQL 里拼 JSON,避免注入和格式错误。

五、索引优化:jsonb_path_ops 与表达式索引

这是最容易被写错的地方。默认 GIN 索引支持 @>、?、?|、?& 四种操作符;改成 jsonb_path_ops 后只支持 @>,但索引体积更小、查询更快。属性筛选场景几乎只用 @>,所以上面的建表语句直接用了 jsonb_path_ops。

但 GIN 索引对下面这种写法完全无效:

-- 走了函数/操作符解包,GIN 用不上
SELECT id, name FROM product
WHERE attrs->>'color' = '黑色';

如果这个字段是高频查询条件,应该把它提升为普通列,或者建表达式索引:

CREATE INDEX idx_product_attr_color ON product ((attrs->>'color'));

同理,jsonb 不适合做模糊搜索。真要 ILIKE 搜商品名,用 pg_trgm 扩展:

CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_product_name_trgm ON product USING GIN (name gin_trgm_ops);

六、踩坑记录

  1. 整字段覆盖更新会丢数据:并发下两个请求各自读出 attrs,改完再整字段 UPDATE,后提交的会覆盖先提交的。要改子字段就用 jsonb_set:UPDATE product SET attrs = jsonb_set(attrs, '{color}', '"银色"', true) WHERE id = 1; 布尔参数 create_missing 表示键不存在时是否创建。
  2. 脏数据防不住:attrs 是自由 JSON,可能被写入数组、字符串等非法结构。必须加 CHECK 约束:ALTER TABLE product ADD CONSTRAINT ck_product_attrs_object CHECK (jsonb_typeof(attrs) = 'object');
  3. 键顺序与重复键:jsonb 不保序、自动去重,别依赖保存时的顺序做展示。
  4. 大 JSON 更新贵:每次更新都是整行重写,attrs 里塞几百个字段时,热点行的写放大很严重。扩展字段控制在"低频读、低频改、不参与复杂聚合"的范围内。
  5. 统计信息滞后:大批量导入后手动 ANALYZE product;,否则优化器可能选错 JSONB 索引。
  6. jdbcType 映射坑:MyBatis 里不要把 attrs 声明成 OTHER/JDBCType 期望驱动自动处理,直接 String 最稳,必要处加 ::jsonb 转型。

七、什么时候不该用 JSONB

JSONB 不是银弹,以下场景应优先建普通列:

  • 字段参与 JOIN、聚合、排序、范围查询(如按价格区间筛、按时间排序);
  • 字段高频更新(如库存、状态);
  • 字段需要强约束(外键、非空、唯一)。

最佳实践总结:稳定且高频使用的属性提列为真实列;易变、低频、仅偶发筛选的属性放 JSONB;筛选条件固定且高频的 JSONB 子字段,用表达式索引或直接提列;所有写入必须过结构校验。这样既有关系型的严谨,又有文档型的灵活,才是 JSONB 的正确打开方式。