为什么 JSONB 值得关注
在现代后端开发中,半结构化数据无处不在——用户配置、API 日志、商品属性、设备遥测数据。传统做法是用 MySQL 的 TEXT 字段存 JSON 字符串,查询时靠 LIKE 硬扫,性能惨不忍睹。
PostgreSQL 的 JSONB 类型从根本上解决了这个问题:它以二进制格式存储 JSON,支持丰富的查询操作符,配合 GIN 索引可以实现毫秒级的键值查找。这让你在不牺牲查询性能的前提下,享受 Schema-less 的灵活性。
建表与数据准备
先建一张模拟电商商品表,attributes 列使用 JSONB 类型:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL,
price NUMERIC(10, 2) NOT NULL,
attributes JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 插入测试数据
INSERT INTO products (name, price, attributes) VALUES
('MacBook Pro 16', 18999.00, '{"brand": "Apple", "cpu": "M4 Pro", "ram": 32, "storage": 1024, "tags": ["laptop", "premium", "apple-silicon"], "in_stock": true}'),
('ThinkPad X1 Carbon', 12800.00, '{"brand": "Lenovo", "cpu": "Intel Ultra 7", "ram": 16, "storage": 512, "tags": ["laptop", "business"], "in_stock": true}'),
('Dell XPS 15', 11500.00, '{"brand": "Dell", "cpu": "Intel Ultra 7", "ram": 32, "storage": 1024, "tags": ["laptop", "creative"], "in_stock": false}'),
('MacBook Air 15', 10499.00, '{"brand": "Apple", "cpu": "M3", "ram": 16, "storage": 512, "tags": ["laptop", "thin-light", "apple-silicon"], "in_stock": true}'),
('ROG Strix G16', 13999.00, '{"brand": "ASUS", "cpu": "Intel i9-14900HX", "ram": 32, "storage": 2048, "tags": ["laptop", "gaming"], "in_stock": true}');
|
核心查询操作符
JSONB 提供了一组强大的操作符,理解它们的差异是高效查询的基础:
| 操作符 |
含义 |
示例 |
-> |
按键取值,返回 JSONB |
attributes->'brand' |
->> |
按键取值,返回文本 |
attributes->>'brand' |
@> |
包含检测(最常用) |
attributes @> '{"brand":"Apple"}' |
? |
键是否存在 |
attributes ? 'cpu' |
| `? |
` |
任一键存在 |
?& |
所有键存在 |
attributes ?& array['cpu','ram'] |
实战查询示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
|
-- 查找所有 Apple 品牌且在售的商品
SELECT name, price
FROM products
WHERE attributes @> '{"brand": "Apple", "in_stock": true}';
-- 查找内存大于等于 32GB 的商品
SELECT name, attributes->>'cpu' AS cpu, attributes->'ram' AS ram
FROM products
WHERE (attributes->>'ram')::int >= 32;
-- 查找带有 "gaming" 标签的商品
SELECT name, jsonb_array_elements_text(attributes->'tags') AS tag
FROM products
WHERE attributes->'tags' @> '"gaming"';
|
GIN 索引:性能飞跃的关键
没有索引时,上述所有查询都需要全表扫描。对于百万级数据,这可能是秒级的。GIN 索引能让查询降到毫秒级。
创建 GIN 索引
1
2
3
4
5
|
-- 方式一:默认 GIN 索引(支持 @>, ?, ?|, ?& 操作符)
CREATE INDEX idx_products_attributes ON products USING GIN (attributes);
-- 方式二:指定 jsonb_path_ops(仅支持 @>,但索引更小、查询更快)
CREATE INDEX idx_products_attributes_path ON products USING GIN (attributes jsonb_path_ops);
|
两种方式的取舍:
- 默认 GIN:支持所有 JSONB 查询操作符,索引体积较大,适合需要多种查询方式的场景
- jsonb_path_ops:仅支持
@> 包含查询,索引体积可小 30%-50%,查询速度更快,适合以包含查询为主的场景
验证索引是否生效
1
2
3
|
EXPLAIN ANALYZE
SELECT name, price FROM products
WHERE attributes @> '{"brand": "Apple"}';
|
执行计划中如果看到 Bitmap Index Scan on idx_products_attributes,说明索引生效了。如果看到 Seq Scan,说明索引没被使用,需要检查查询条件。
表达式索引:针对特定键优化
如果你经常查询某个特定键,可以建表达式索引,效率更高:
1
2
3
4
5
6
7
|
-- 针对单个键建 B-Tree 索引(比 GIN 更精准)
CREATE INDEX idx_products_brand ON products ((attributes->>'brand'));
CREATE INDEX idx_products_ram ON products (((attributes->>'ram')::int));
-- 这样查询可以走 B-Tree 索引
SELECT name FROM products WHERE attributes->>'brand' = 'Apple';
SELECT name FROM products WHERE (attributes->>'ram')::int >= 32;
|
性能对比:百万级数据实测
插入 100 万条测试数据后对比:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
|
-- 生成 100 万条随机数据
INSERT INTO products (name, price, attributes)
SELECT
'Product_' || g,
(random() * 20000 + 100)::numeric(10,2),
jsonb_build_object(
'brand', (ARRAY['Apple','Dell','Lenovo','ASUS','HP'])[1 + floor(random()*5)::int],
'ram', (ARRAY[8,16,32,64])[1 + floor(random()*4)::int],
'storage', (ARRAY[256,512,1024,2048])[1 + floor(random()*4)::int],
'in_stock', random() > 0.3,
'tags', jsonb_build_array(
(ARRAY['laptop','desktop','gaming','business'])[1 + floor(random()*4)::int]
)
)
FROM generate_series(1, 1000000) g;
|
无索引查询:
1
2
3
4
5
|
-- 全表扫描,约 1200ms
EXPLAIN ANALYZE
SELECT count(*) FROM products
WHERE attributes @> '{"brand": "Apple", "in_stock": true}';
-- Seq Scan on products (actual time=0.0..1185.3 rows=140234 loops=1)
|
有 GIN 索引后:
1
2
3
4
5
|
-- 索引扫描,约 8ms,提升 150 倍
EXPLAIN ANALYZE
SELECT count(*) FROM products
WHERE attributes @> '{"brand": "Apple", "in_stock": true}';
-- Bitmap Index Scan on idx_products_attributes (actual time=0.3..7.8 rows=140234 loops=1)
|
生产环境实用技巧
1. 索引膨胀与维护
JSONB 数据频繁更新会导致 GIN 索引膨胀(类似 B-Tree 但更严重)。定期重建索引:
1
2
3
4
5
|
-- 在线重建索引(不阻塞写入)
REINDEX INDEX CONCURRENTLY idx_products_attributes;
-- 查看索引大小
SELECT pg_size_pretty(pg_relation_size('idx_products_attributes'));
|
2. 部分索引:只索引需要的数据
如果只关心在售商品,可以建部分索引,大幅减小索引体积:
1
2
3
|
CREATE INDEX idx_products_in_stock_attrs
ON products USING GIN (attributes)
WHERE (attributes->>'in_stock')::boolean = true;
|
3. 避免在 JSONB 中存储大对象
JSONB 单列建议不超过 1KB。如果存储了大数组或深层嵌套对象,GIN 索引会急剧膨胀。拆分到关联表更合理:
1
2
3
4
5
6
7
8
9
10
|
-- 不推荐:把所有规格塞进一个 JSONB
attributes = '{"specs": [{"k":"cpu","v":"M4"}, {"k":"gpu","v":"10-core"}, ...100项]}'
-- 推荐:拆到关联表
CREATE TABLE product_specs (
product_id INT REFERENCES products(id),
spec_key VARCHAR(50),
spec_value VARCHAR(200)
);
-- 对 spec_key, spec_value 建 B-Tree 索引
|
4. 使用 pg_trgm 扩展实现模糊搜索
JSONB 内文本的模糊匹配需要 pg_trgm 扩展:
1
2
3
4
5
6
7
8
9
|
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- 对 JSONB 中的文本字段建 trgm 索引
CREATE INDEX idx_products_name_trgm
ON products USING GIN ((attributes->>'brand') gin_trgm_ops);
-- 现在可以高效模糊查询
SELECT name FROM products
WHERE attributes->>'brand' % 'Aple'; -- 容错匹配
|
5. 在应用层正确传参
在 Python (psycopg) 中安全传参:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
import psycopg
import json
conn = psycopg.connect("dbname=mydb user=myuser")
cur = conn.cursor()
# 方式一:用 json.dumps 将字典转为 JSON 字符串
brand_filter = json.dumps({"brand": "Apple"})
cur.execute(
"SELECT name, price FROM products WHERE attributes @> %s::jsonb",
(brand_filter,)
)
# 方式二:psycopg3 原生支持 dict → jsonb 适配
cur.execute(
"SELECT name, price FROM products WHERE attributes @> %s",
({"brand": "Apple"},)
)
for row in cur.fetchall():
print(row)
|
Node.js (pg) 中同理:
1
2
3
4
5
6
7
8
9
10
|
const { Pool } = require('pg');
const pool = new Pool({ connectionString: 'postgres://user:pass@localhost/mydb' });
// pg 驱动会自动序列化对象为 JSON
const result = await pool.query(
`SELECT name, price FROM products WHERE attributes @> $1`,
[{ brand: 'Apple', in_stock: true }]
);
console.log(result.rows);
|
小结
| 方案 |
适用场景 |
索引类型 |
| 默认 GIN |
通用 JSONB 查询(@>, ?, ?|) |
GIN (default) |
| jsonb_path_ops |
仅用 @> 包含查询,追求极致性能 |
GIN (jsonb_path_ops) |
| 表达式索引 |
频繁查某个键,需要范围/等值查询 |
B-Tree on expression |
| 部分索引 |
只关心满足条件的子集数据 |
GIN + WHERE clause |
| pg_trgm |
JSONB 文本字段模糊搜索 |
GIN (gin_trgm_ops) |
核心原则:先确认查询模式,再选索引策略。如果你的查询 90% 是 @> 包含查询,用 jsonb_path_ops 就对了。如果需要灵活查询多个键,用默认 GIN。如果只查某个固定键,表达式 B-Tree 索引是最优解。
JSONB 不是银弹——当数据结构稳定且查询模式固定时,传统的关系表 + B-Tree 索引仍然更快。但对于半结构化、Schema 可能变化的场景,JSONB + GIN 是目前最好的方案。