一、痛点:电商扩展字段的两种旧方案都不够用
做电商中台的同学几乎都会遇到同一个问题:商品、订单、优惠券这些核心表,总有一些"说不准"的扩展属性。比如商品的颜色/容量/是否支持 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);
六、踩坑记录
- 整字段覆盖更新会丢数据:并发下两个请求各自读出 attrs,改完再整字段 UPDATE,后提交的会覆盖先提交的。要改子字段就用 jsonb_set:
UPDATE product SET attrs = jsonb_set(attrs, '{color}', '"银色"', true) WHERE id = 1;布尔参数 create_missing 表示键不存在时是否创建。 - 脏数据防不住:attrs 是自由 JSON,可能被写入数组、字符串等非法结构。必须加 CHECK 约束:
ALTER TABLE product ADD CONSTRAINT ck_product_attrs_object CHECK (jsonb_typeof(attrs) = 'object'); - 键顺序与重复键:jsonb 不保序、自动去重,别依赖保存时的顺序做展示。
- 大 JSON 更新贵:每次更新都是整行重写,attrs 里塞几百个字段时,热点行的写放大很严重。扩展字段控制在"低频读、低频改、不参与复杂聚合"的范围内。
- 统计信息滞后:大批量导入后手动 ANALYZE product;,否则优化器可能选错 JSONB 索引。
- jdbcType 映射坑:MyBatis 里不要把 attrs 声明成 OTHER/JDBCType 期望驱动自动处理,直接 String 最稳,必要处加 ::jsonb 转型。
七、什么时候不该用 JSONB
JSONB 不是银弹,以下场景应优先建普通列:
- 字段参与 JOIN、聚合、排序、范围查询(如按价格区间筛、按时间排序);
- 字段高频更新(如库存、状态);
- 字段需要强约束(外键、非空、唯一)。
最佳实践总结:稳定且高频使用的属性提列为真实列;易变、低频、仅偶发筛选的属性放 JSONB;筛选条件固定且高频的 JSONB 子字段,用表达式索引或直接提列;所有写入必须过结构校验。这样既有关系型的严谨,又有文档型的灵活,才是 JSONB 的正确打开方式。