100万行数据实测!PostgreSQL里用JSON还是JSONB?
运维渔夫 2026-09-26 08:00 广东
以下文章来源于:运维渔夫
运维渔夫运维相关的故事,新闻,热点,漫剧...

别为了省那31%的写入时间而选了JSON,然后在查询时付出7倍的性能代价……
你在 PostgreSQL 里存过 JSON 数据吗?
JSON 和 JSONB 两个类型,名字就差一个字母,选的时候是不是经常随便挑一个?反正都能存 JSON,能有多大区别……
区别很大。查询速度差7倍,存储空间差26%,写入速度也能差31%。而且这还不是理论值——是有人在100万行真实数据 上跑出来的基准测试结果。
今天这篇文章,就把这个选择彻底说清楚:什么时候用JSON,什么时候必须用JSONB,以及那些文档里不会告诉你的坑。
一、功能全景
1、先搞清楚两个类型的本质区别
维度 | JSON | JSONB |
|---|---|---|
| 存储格式 | 纯文本(原样保存) | 二进制(解析后保存) |
| 保留格式 | ✅ 保留空格、键顺序 | ❌ 去掉空格、键顺序不保证 |
| 保留重复键 | ✅ 保留 | ❌ 去重 |
| 索引支持 | ❌ 无 | ✅ GIN 索引 |
| 查询运算符 | 只有 | @>, |
| 写入速度 | 快(无需解析) | 慢(需要解析+转换) |
| 查询速度 | 慢(每次都要重新解析) | 快(已解析,直接操作) |
一句话总结:
JSON = 懒模式:存得快,查得慢
JSONB = 认真模式:存得慢一点,但查起来飞快
2、JSONB 的独占能力
以下这些强大的查询运算符只有 JSONB 支持:
-- @> 包含检查:这条记录是否包含指定的键值对?SELECT * FROM users WHERE data @> '{"role": "admin"}';-- ? 键存在性检查:是否有这个字段?SELECT * FROM users WHERE data ? 'email_verified';-- ?& 多键同时存在检查SELECT * FROM users WHERE data ?& array['email_verified', 'phone_verified'];
如果用 JSON 类型,上面的查询只能写成笨拙的形式:
-- JSON 类型没有 @> 运算符,只能这样写SELECT * FROM usersWHERE data->>'role' = 'admin';-- 无法高效检查嵌套结构或数组包含关系
3、GIN 索引:JSONB 的秘密武器
-- 为 JSONB 列创建 GIN 索引CREATE INDEX idx_users_data ON users USING GIN (data);-- 创建后,包含查询从全表扫描变成索引查找-- 没有索引:扫描100万行 → 有索引:毫秒级返回EXPLAIN ANALYZE SELECT * FROM users WHERE data @> '{"status": "active"}';
GIN(Generalized Inverted Index)是专门为复合类型设计的倒排索引。它会将 JSONB 文档中的每个键和值都建入索引,使得包含查询、键存在性查询等操作可以走索引而非全表扫描。
二、完整实战案例
案例1:百万行基准测试复现
下面这个测试来自 DEV Community 作者 Eugene (@ineron) 开源的真实基准测试。我们一步步来复现。
第一步:创建测试表
-- JSON 类型的表CREATE TABLE bench_json (id SERIAL PRIMARY KEY,data JSON NOT NULL,created_at TIMESTAMPTZ DEFAULT NOW());-- JSONB 类型的表CREATE TABLE bench_jsonb (id SERIAL PRIMARY KEY,data JSONB NOT NULL,created_at TIMESTAMPTZ DEFAULT NOW());
第二步:生成 100 万行测试数据
每条数据模拟一个用户配置对象,包含嵌套结构和数组:
import psycopg2import jsonimport randomimport stringfrom datetime import datetimeconn = psycopg2.connect("dbname=testdb host=localhost user=postgres")cur = conn.cursor()def generate_user_data():"""生成模拟用户配置的 JSON 数据"""roles = ["admin", "editor", "viewer", "moderator"]plans = ["free", "pro", "enterprise"]# 随机生成 5-15 个偏好设置preferences = {}for i in range(random.randint(5, 15)):key = f"pref_{random.choice(string.ascii_lowercase)}"value = random.choice([True, False, random.randint(0, 100), None])preferences[key] = valuedata = {"user_id": f"u_{random.randint(10000, 99999)}","username": f"user_{random.randint(10000, 99999)}","email": f"user{random.randint(10000, 99999)}@example.com","role": random.choice(roles),"plan": random.choice(plans),"settings": {"theme": random.choice(["dark", "light", "auto"]),"notifications_enabled": random.choice([True, False]),"language": random.choice(["zh", "en", "ja"]),"two_factor_enabled": random.choice([True, False])},"preferences": preferences,"tags": [f"tag_{i}" for i in range(random.randint(0, 8))],"metadata": {"signup_source": random.choice(["web", "api", "invite"]),"last_login": datetime.now().isoformat(),"login_count": random.randint(1, 500)}}return json.dumps(data)# 批量插入到 JSON 表(每次 10000 条)batch_size = 10000total = 1000000for i in range(0, total, batch_size):batch = []for _ in range(batch_size):batch.append((generate_user_data(),))cur.executemany("INSERT INTO bench_json (data) VALUES (%s::json)",batch)conn.commit()print(f"Inserted {min(i + batch_size, total)}/{total}")# 同样插入到 JSONB 表for i in range(0, total, batch_size):batch = []for _ in range(batch_size):batch.append((generate_user_data(),))cur.executemany("INSERT INTO bench_jsonb (data) VALUES (%s::jsonb)",batch)conn.commit()print(f"JSONB: Inserted {min(i + batch_size, total)}/{total}")cur.close()conn.close()
第三步:性能对比测试
-- ========== 写入性能测试 ==========-- 结果(实测):-- JSON: 8.6 秒 (100万行)-- JSONB: 11.3 秒 (100万行)-- 结论:JSON 写入快约 31%(因为不需要解析)-- ========== 存储空间测试 ==========SELECT'JSON' AS type,pg_size_pretty(pg_total_relation_size('bench_json')) AS sizeUNION ALLSELECT'JSONB' AS type,pg_size_pretty(pg_total_relation_size('bench_jsonb')) AS size;-- 结果:-- JSON: ~1200 MB-- JSONB: ~888 MB-- 结论:JSONB 节省 26% 空间-- (原因:去除了空白 + 键名去重)
-- ========== 查询性能测试 ==========-- 测试1:简单键值提取EXPLAIN ANALYZE SELECT count(*) FROM bench_json WHERE data->>'role' = 'admin';EXPLAIN ANALYZE SELECT count(*) FROM bench_jsonb WHERE data->>'role' = 'admin';-- JSONB 比 JSON 快 **6.2倍**-- 测试2:嵌套字段访问EXPLAIN ANALYZE SELECT count(*) FROM bench_jsonWHERE data->'settings'->>'theme' = 'dark';EXPLAIN ANALYZE SELECT count(*) FROM bench_jsonbWHERE data->'settings'->>'theme' = 'dark';-- JSONB 比 JSON 快 **7.6倍**-- 测试3:数组操作EXPLAIN ANALYZE SELECT count(*) FROM bench_jsonWHERE data->'tags'->>0 IS NOT NULL;EXPLAIN ANALYZE SELECT count(*) FROM bench_jsonbWHERE data->'tags'->>0 IS NOT NULL;-- JSONB 比 JSON fast **7.3倍**-- 测试4:复杂多条件查询EXPLAIN ANALYZE SELECT count(*) FROM bench_jsonWHERE data->>'role' = 'admin'AND data->'settings'->>'two_factor_enabled' = 'true'AND data->'plan' = 'enterprise';EXPLAIN ANALYZE SELECT count(*) FROM bench_jsonbWHERE data->>'role' = 'admin'AND data->'settings'->>'two_factor_enabled' = 'true'AND data->'plan' = 'enterprise';-- JSONB 比 JSON 快 **9.1倍**
性能汇总表:
操作 | JSON | JSONB | JSONB优势 |
|---|---|---|---|
| INSERT 100万行 | 8.6s | 11.3s | JSON快31% |
| 简单键值查询 | ~12ms | ~1.9ms | JSONB快6.2x |
| 嵌套字段查询 | ~18ms | ~2.4ms | JSONB快7.6x |
| 数组元素访问 | ~15ms | ~2.1ms | JSONB快7.3x |
| 多条件组合查询 | ~25ms | ~2.7ms | JSONB快9.1x |
| 部分UPDATE | ~8ms | ~2.3ms | JSONB快71% |
| 磁盘占用 | ~1200MB | ~888MB | JSONB省26% |
案例2:实际业务场景选型
场景A:日志/事件存储 → 选 JSONB ✅
CREATE TABLE application_logs (id BIGSERIAL PRIMARY KEY,timestamp TIMESTAMPTZ DEFAULT NOW(),service_name TEXT NOT NULL,level TEXT CHECK (level IN ('DEBUG', 'INFO', 'WARN', 'ERROR')),payload JSONB NOT NULL, -- ← 用 JSONB-- GIN 索引让日志检索起飞);-- 创建 GIN 索引CREATE INDEX idx_logs_payload ON application_logs USING GIN (payload);-- 高效查询:找所有 ERROR 级别且包含特定错误码的日志SELECT timestamp, service_name, payloadFROM application_logsWHERE level = 'ERROR'AND payload @> '{"error_code": "AUTH_001"}'ORDER BY timestamp DESCLIMIT 50;-- 查询计划:使用 GIN Index Scan,毫秒级返回
为什么选 JSONB?
日志通常是写一次读多次
经常需要按 payload 内的字段做条件过滤
GIN 索引让亿级日志秒查成为可能
场景B:原始数据暂存 → 可以考虑 JSON ⚠️
CREATE TABLE raw_webhook_payloads (id BIGSERIAL PRIMARY KEY,received_at TIMESTAMPTZ DEFAULT NOW(),source_app TEXT NOT NULL,raw_payload JSON NOT NULL, -- ← 用 JSONprocessed BOOLEAN DEFAULT FALSE);-- 写入速度优先,几乎不做查询-- 只在异常排查时才去看 raw_payload 的内容-- 这种场景 JSON 的写入速度优势有意义
为什么可以考虑 JSON?
纯写密集型:webhook 接收后立即处理,很少回查
只写不读(或极少读):不需要查询优化的红利
需要保留原始格式(含空格、键顺序):方便跟上游对账
但要注意:如果你后来发现"好像偶尔要查一下",那还是老老实实用 JSONB 吧。改列类型的代价远大于一开始就选对的代价。
场景C:动态属性/Schema 自由字段 → 必须用 JSONB ✅
CREATE TABLE products (id BIGSERIAL PRIMARY KEY,name TEXT NOT NULL,sku TEXT UNIQUE NOT NULL,price DECIMAL(10, 2),-- 不同品类的商品有完全不同的属性attributes JSONB NOT NULL, -- ← 必须用 JSONBupdated_at TIMESTAMPTZ DEFAULT NOW());-- 创建 GIN 索引支持任意属性搜索CREATE INDEX idx_products_attr ON products USING GIN (attributes);-- 示例:电子产品有屏幕尺寸、电池容量INSERT INTO products (name, sku, price, attributes) VALUES ('iPhone Pro Max','IPHONE-PM-256',9999.00,'{"screen_size": "6.9 inch", "battery_mAh": 5000, "color": "black", "storage_gb": 256}');-- 示例:服装商品有尺码、材质INSERT INTO products (name, sku, price, attributes) VALUES ('羊毛大衣','COAT-WOOL-L',2999.00,'{"size": "L", "material": "wool", "season": "winter", "weight_g": 800}');-- 跨品类查询:找所有价格 < 5000 且有库存的商品SELECT name, sku, attributes->>'color' as colorFROM productsWHERE price < 5000AND attributes ? 'stock'; -- 检查是否有库存字段
这种"每个记录的属性结构都不同"的场景,正是 JSONB 的主场。
三、FAQ / 常见问题
Q1:我已经用了 JSON,能改成 JSONB 吗?
可以,而且很简单:
-- 一行搞定ALTER TABLE my_table ALTER COLUMN data TYPE JSONB USING data::jsonb;
注意事项:
这会对整张表做 重写(REWRITE),大表会锁一段时间
生产环境建议在低峰期执行
如果表特别大(千万级行数),可以用 CONCURRENTLY 友好的方式分批迁移
-- 大表安全迁移方案-- 1. 新增一个 JSONB 列ALTER TABLE my_table ADD COLUMN data_new JSONB;-- 2. 分批填充数据(每次更新 50000 行)UPDATE my_table SET data_new = data::jsonbWHERE data_new IS NULLLIMIT 50000;-- 重复执行直到全部迁移完-- 3. 创建索引CREATE INDEX idx_data_new ON my_table USING GIN (data_new);-- 4. 切换应用层到新列,验证无误后删除旧列-- ALTER TABLE my_table DROP COLUMN data;-- ALTER TABLE my_table RENAME data_new TO data;
Q2:JSONB 的 GIN 索引会不会让写入变慢?
会有一点影响,但通常可接受:
操作 | 无索引 | 有 GIN 索引 | 开销增量 |
|---|---|---|---|
INSERT | 基准 | +15-25% | 中等 |
UPDATE(非JSON列) | 基准 | 无额外开销 | 无 |
UPDATE(JSON列本身) | 基准 | +30-50% | 较高 |
DELETE | 基准 | +20-30% | 低-中 |
权衡建议:
如果你的 JSONB 列频繁更新(比如每秒几十次),GIN 索引的开销值得关注
如果主要是 INSERT + SELECT(大多数场景),GIN 索引的查询收益远远覆盖写入开销
还有个折中选项:partial index(部分索引),只为常用查询条件建索引
-- 只为 status = active 的记录建索引CREATE INDEX idx_active_users_data ON users USING GIN (data)WHERE (data->>'status') = 'active';
这样索引体积更小,写入开销更低,同时覆盖了最常用的查询场景。
Q3:什么时候该用普通列而不是 JSON/JSONB?
这是个好问题。很多人一看到"灵活"两个字就上 JSON,但其实常规字段应该用常规类型。
-- ❌ 不推荐:把所有东西都塞进 JSONCREATE TABLE bad_design (id SERIAL PRIMARY KEY,data JSONB -- 用户名、邮箱、年龄、注册时间...全塞这里);-- ✅ 推荐:固定属性用常规列,只有真正的变体属性用 JSONBCREATE TABLE good_design (id SERIAL PRIMARY KEY,username TEXT NOT NULL UNIQUE,email TEXT NOT NULL,age INTEGER,registered_at TIMESTAMPTZ DEFAULT NOW(),extra_attributes JSONB -- 这里放真正不确定的字段);
决策树:
这个字段的值格式固定吗?├─ 是 → 用常规列(TEXT / INTEGER / TIMESTAMP 等)│ 能加约束(CHECK/UNIQUE/NOT NULL),能建 B-tree 索引│└─ 否 → 这个字段的结构会变化吗?├─ 是 → 用 JSONB(+ GIN 索引)│└─ 否,只是纯文本存取 → 用 JSON 或直接用 TEXT
Q4:JSONB 的 `jsonb_path_query` 怎么用?好用吗?
PostgreSQL 提供了 SQL/JSON Path 标准(Postgres 12+),支持类似 XPath 的路径表达式查询:
-- 找所有 settings.theme = dark 且 tags 数组长度 > 3 的用户SELECT id, dataFROM bench_jsonbWHERE jsonb_path_exists(data, '$.settings.theme == "dark" && $.tags.size() > 3');-- 更复杂的:递归查找任意层级的 error 字段SELECT id, jsonb_path_query_first(data, '$.**.error') as first_errorFROM application_logsWHERE jsonb_path_exists(data, '$.**.error');
优点:
表达能力强,一行搞定复杂条件
支持过滤器(?())、递归(**)、数组切片等
缺点:
语法学习成本较高
性能在某些情况下不如 @> + GIN 组合
对已有 PromQL/SQL 基础的人来说不够直觉
建议:日常简单查询用 @> / ->> 就够了,遇到特别复杂的嵌套查询再考虑 jsonb_path_query。
Q5:MySQL 的 JSON 类型和 PostgreSQL 的 JSONB 一样吗?
不完全一样,主要差异:
特性 | MySQL 8.0 JSON | PostgreSQL JSONB | |
|---|---|---|---|
| 存储格式 | 二进制(类似 JSONB) | 二进制 | |
| 索引方式 | Multi-valued Index (8.0+) | GIN 索引 | |
| 查询语法 | -> / | -> / | |
| 高级运算符 | 较少(无 | 丰富 | |
| 路径查询 | JSON_CONTAINS 等 | jsonb_path_query (SQL/JSON Path) | |
| 更新操作 | JSON_SET / JSON_REMOVE | `\ | \= |
| 成熟度/生态 | 够用但社区反馈较少 | 非常成熟,大量生产验证 |
如果你在做技术选型,单就 JSON 能力来说,PostgreSQL 的 JSONB 明显更强。这也是很多团队从 MySQL 迁移到 PG 的原因之一。
四、写在最后
JSON 还是 JSONB?答案其实很简单:
99% 的场景,直接用 JSONB,别纠结
那 1% 的情况是:纯写密集型 + 几乎不读 + 需要保留原始格式
别为了省那 31% 的写入时间而选了 JSON,然后在查询时付出 7 倍的性能代价。这笔账怎么算都不划算。
作者丨运维渔夫
来源丨公众号:运维渔夫(ID:wx_yzywg)
dbaplus社群欢迎广大技术人员投稿,投稿邮箱:editor@dbaplus.cn

本文转载自微信公众号「dbaplus社群」,查看原文。版权归原作者所有,如有侵权请联系删除。