做数据治理和用户画像这类项目,最头疼的往往不是数据量大,而是表结构怎么设计都兜不住业务方的需求。今天要加一个标签,明天要加一个渠道来源,后天又冒出几个第三方平台的同步状态,每次改动都是 ALTER TABLE,DBA 光评估历史数据的迁移成本就要花掉大半天。后来我在 KingbaseES 上把一个核心业务表改造成 JSON 字段承载扩展属性,才真正把这块硬骨头啃下来。这篇博文就把我在 KingbaseES 里使用 JSON/JSONB 的完整经验整理出来,包括存储选型、查询语法、索引优化、真实踩坑和一份百万行数据的性能实测,给正在关系型数据库和 JSON 之间犹豫的团队一个可以直接参考的结论。
1. 为什么我在关系型数据库里给"用户属性"留了一个 JSON 字段
1.1 固定 schema 的窘境
之前接手的用户画像平台,源数据来自 APP、小程序、H5 三个端,每个端上报的字段差异非常大。业务方一开始只提了 20 个固定字段,做到第三个月涨到了 60 个,后面还有一堆"可能有用"的动态标签。最痛苦的是每增加一个字段,都要走一次表结构变更流程。在千万级行数的表上执行 DDL,即使底层已经做了优化,历史数据的默认值回填还是会占用大量资源,一不小心就拖慢主库。
更麻烦的是查询逻辑。一张表里十几个可空字段,每个查询都要写一大段 COALESCE 和 IS NOT NULL,可读性极差。团队里每次来新人,光理解这些字段的业务含义就要花掉一周。后来我意识到,问题的根源在于把"业务字段"和"未来可能成为字段的元信息"混在同一层设计里了。有些属性天生就是动态的、松散的、不可枚举的,硬塞进固定列只会让模型越来越臃肿。
1.2 JSON 字段适合承接哪类数据
经过这个项目,我总结出几类非常适合放进 JSON 字段的数据形态:
- 扩展属性:用户偏好、设备信息、运营标签、渠道参数,这类数据不同用户差异极大,而且会持续变化。
- 配置和规则:风控阈值、推荐策略、消息模板参数,天然是嵌套结构,拆成多张表反而难维护。
- 第三方接口返回的原始报文:对接外部系统时,经常需要把完整响应存下来用于对账,同时抽几个关键字段做查询条件。
- 多态数据:同一张表要存不同类型的事件或对象,比如"操作日志"中不同动作的扩展信息完全不同。
这些数据有一个共同点:结构不稳定,但单个字段的读取频率不低。用 JSON 承载,既能保证主表结构长期稳定,又不会丢失原始信息。
1.3 什么情况下不该用 JSON
JSON 不是万能药。我见过有团队把订单金额、商品 ID 也塞进 JSONB,结果后面做关联统计时痛不欲生。如果你要存的数据满足以下任一条件,请老老实实用普通列:
- 需要参与 JOIN 或频繁做 GROUP BY;
- 有外键、非空、枚举等强约束要求;
- 某个单值会被高频更新,比如计数器、状态流转;
- 查询条件非常固定且对响应时间极度敏感。
一句话:固定、强约束、高频更新的数据走关系模型;动态、松耦合、低更新频率的扩展信息才适合 JSON。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. JSON 和 JSONB 的存储差异:选错类型后期会很痛苦
2.1 两种类型的本质区别
KingbaseES 同时提供了 JSON 和 JSONB 两种 JSON 相关类型,看起来很像,但它们的工作方式完全不同。
JSON 类型保存的是输入文本的精确副本。你写的键顺序、重复键、空格换行,它都原封不动存下来。每次查询要使用时,数据库都必须现场对字符串做一次完整解析,就像每次看文件都要先打开压缩包解压一样,灵活但开销大。
JSONB 类型在插入时就把 JSON 文本解析成二进制格式,键会被排序,重复键会去重,空白会被移除。查询时直接基于解析后的结构做读取,不需要重复解析,而且它支持 GIN 索引。如果用一句话总结:json 是拍照存档,jsonb 是拆解归档后随取随用。
2.2 键顺序、重复键和空白的处理差异
很多人第一次接触 JSONB 时会被"键顺序变化"吓到,这其实是它正常工作的一部分。看个例子:
sql复制SELECT '{"b": 1, "a": 2}'::jsonb;
-- 输出可能变成 {"a": 2, "b": 1}
-- json 类型则原样输出 {"b": 1, "a": 2}
重复键的行为差异更值得注意。json 类型会保留两个 "a",而 jsonb 只保留最后一个:
sql复制SELECT '{"a": 1, "a": 2}'::jsonb;
-- 结果:{"a": 2}
如果应用层依赖原始报文里的键顺序或重复键做签名校验,用 jsonb 就会踩坑。但这种需求属于少数,绝大多数业务场景中,键顺序没有任何意义。
2.3 类型转换的隐式行为
在 KingbaseES 中,字符串到 JSON 类型的转换需要显式 ::jsonb 或 ::json,没有隐式转换。这个设计避免了 SQL 中很多模糊歧义。实际操作时,如果通过 JDBC 的 setString 传入 JSON 字符串,最好在 SQL 里写清楚目标类型:
sql复制UPDATE user_profile
SET ext_attrs = ?::jsonb
WHERE user_id = ?;
另外注意,jsonb 的等值比较是语义比较,{"a":1} 和 { "a" : 1 } 被认为是相等的,但 json 类型按文本比较,认为它们不相等。这些细节会在程序升级或数据对账时突然跳出来咬人。
2.4 选型结论
我的结论很直接:新表默认用 JSONB,除非你有"必须原样保留报文、键顺序和重复键都不能变"的硬性要求。JSONB 查询更快、支持索引、支持更新操作,99% 的场景比 JSON 更合适。如果表里已经用了 json 类型但业务需要查询性能,建议迁移到 jsonb,迁移成本并不高,执行一次带类型转换的 ALTER TABLE 即可,但建议在业务低峰期操作,并做好备份。
3. 从建表到查询:KingbaseES JSON 功能全景实操
3.1 建表、写入与基础校验
以用户扩展属性为例,我会把核心稳定字段留在普通列,把动态属性放进 JSONB:
sql复制CREATE TABLE user_profile (
user_id BIGINT PRIMARY KEY,
base_name TEXT NOT NULL,
ext_attrs JSONB NOT NULL DEFAULT '{}'::JSONB,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_at TIMESTAMP NOT NULL DEFAULT now()
);
插入数据时直接写 JSON 文本,注意必须显式转成 jsonb:
sql复制INSERT INTO user_profile (user_id, base_name, ext_attrs)
VALUES (
1,
'张三',
'{
"channel": "app",
"tags": ["new_user", "high_value"],
"preferences": {"language": "zh-CN", "theme": "dark"},
"risk_score": 0.12
}'::jsonb
);
如果你希望某些键必须有,或者某些值类型必须是数字,可以在表上增加 CHECK 约束:
sql复制ALTER TABLE user_profile
ADD CONSTRAINT chk_risk_score_number
CHECK (jsonb_typeof(ext_attrs -> 'risk_score') = 'number');
jsonb_typeof 返回 object、array、string、number、boolean、null。这样能把数据质量校验下沉到数据库,应用层写错了直接报错,不进入表中。
3.2 常用查询操作符速查
JSON 查询最基础的操作符是 -> 和 ->>,不少新手分不清它们。
->返回 JSONB 类型,比如ext_attrs -> 'channel'返回"app"(带引号的 JSON 字符串)。->>返回 TEXT 类型,比如ext_attrs ->> 'channel'返回app(纯文本,没有引号)。
多级嵌套用 #> 和 #>>,传给它们一个路径数组:
sql复制SELECT ext_attrs #>> '{preferences, language}' AS lang
FROM user_profile
WHERE user_id = 1;
完整操作符可以整理成一张表,后面写查询时对照查:
| 操作符 | 作用 | 典型示例 |
|---|---|---|
-> |
取 JSON 字段,返回 jsonb | ext_attrs -> 'channel' |
->> |
取 JSON 字段,返回 text | ext_attrs ->> 'channel' |
#> |
按路径取 JSON 值 | ext_attrs #> '{preferences,language}' |
#>> |
按路径取 text | ext_attrs #>> '{preferences,language}' |
@> |
左侧 JSON 是否包含右侧(递归) | ext_attrs @> '{"tags":["new_user"]}' |
<@ |
右侧是否包含左侧 | ext_attrs <@ '{"channel":"app"}' |
? |
顶层键是否存在 | ext_attrs ? 'tags' |
?| |
任一键存在 | ext_attrs ?| array['a','b'] |
?& |
所有键存在 | ext_attrs ?& array['a','b'] |
|| |
合并两个 jsonb(浅层覆盖) | ext_attrs || '{"k":"v"}' |
- |
删除键 | ext_attrs - 'old_key' |
3.3 函数与条件判断
除了操作符,经常配合使用的函数还有:
sql复制-- 判断字段是否存在并取默认值
SELECT ext_attrs ->> 'channel',
COALESCE(ext_attrs ->> 'nickname', 'anonymous')
FROM user_profile;
-- 展开对象为多行,适合做统计
SELECT key, value
FROM user_profile, jsonb_each(ext_attrs)
WHERE user_id = 1;
-- jsonb_each 返回 (key text, value jsonb)
-- 展开数组
SELECT jsonb_array_elements(ext_attrs -> 'tags') AS tag
FROM user_profile
WHERE user_id = 1;
如果 KingbaseES 版本支持 SQL/JSON 路径函数,还可以用 jsonb_path_exists、jsonb_path_query 做更复杂的路径匹配,例如判断是否存在 $.preferences.theme == 'dark' 的元素。路径表达式非常灵活,适合写复杂规则校验,但别过度使用,复杂 JSONPath 的可读性其实不如拆成几步 SQL。
3.4 更新 JSON 字段的正确姿势
JSONB 支持局部更新,但需要借助函数。最常见的需求是修改某个键的值:
sql复制UPDATE user_profile
SET ext_attrs = jsonb_set(ext_attrs, '{risk_score}', '0.85'::jsonb, true)
WHERE user_id = 1;
jsonb_set 的第四个参数表示如果路径不存在是否自动创建。删除键用 - 操作符:
sql复制UPDATE user_profile
SET ext_attrs = ext_attrs - 'risk_score'
WHERE user_id = 1;
需要提醒的是,|| 合并操作是浅层合并,不会递归合并内部对象。例如 '{"a":{"b":1}}'::jsonb || '{"a":{"c":2}}'::jsonb 的结果是 {"a":{"c":2}},左侧的 b 会被覆盖掉。如果要深层次递归合并内部对象,需要自己写函数或用 jsonb_set 逐层指定路径。这个坑我在做配置合并时踩过,排查了很久才发现是浅层覆盖问题。
4. 让 JSON 查询也能跑进毫秒级:GIN 索引与表达式索引
4.1 为什么普通 B-tree 索引对 JSON 无效
关系型数据库的 B-tree 索引依赖一个有序的比较逻辑,但 JSONB 的常见查询不是"找某个值等于什么",而是"某个 JSON 文档里是否包含某个结构"或"某个键是否存在"。这类子结构判断如果用 B-tree,就得把整个 JSON 文档拿出来逐一比对,没有任何预排序可以利用。所以 JSONB 要加速靠的是 GIN 索引,它的核心思想不是排序,而是把一个 JSON 对象"拆碎"成多个可检索的条目,再针对条目建索引。
4.2 JSONB GIN 索引与 jsonb_path_ops 的选择
KingbaseES 为 JSONB 提供了两种 GIN 索引操作符类,默认不写参数时使用的是 jsonb_ops:
sql复制CREATE INDEX idx_user_profile_attrs_gin
ON user_profile USING gin (ext_attrs);
这种索引对 @>、?、?|、?& 等操作都有效,适合"既要判断包含关系,又要判断键是否存在"的混合场景。
如果业务中绝大多数查询都是 @> 包含判断,可以用 jsonb_path_ops 操作符类:
sql复制CREATE INDEX idx_user_profile_attrs_pathops
ON user_profile USING gin (ext_attrs jsonb_path_ops);
jsonb_path_ops 只保存 JSON 路径信息,索引会比默认的更小,构建和维护开销也低,同一条包含查询走它的速度通常比默认索引更快。但它不支持 ?、?|、?& 这类键存在判断。如果你的查询里有"判断某个 key 是否存在"这种需求,就不能用 jsonb_path_ops,老老实实用默认操作符类。
4.3 常用字段提取到表达式索引
GIN 索引对"包含结构"的查询很有效,但如果你的查询是精确比较 JSON 里某个字段的值,比如 ext_attrs ->> 'channel' = 'app',那最合适的其实是表达式索引:
sql复制CREATE INDEX idx_user_profile_channel
ON user_profile ((ext_attrs ->> 'channel'));
查询时只要表达式完全一致,优化器就能走索引:
sql复制SELECT user_id
FROM user_profile
WHERE ext_attrs ->> 'channel' = 'app';
注意,表达式必须和建索引时的表达式严格一致,多包一层函数或者少写一层括号都可能导致索引失效。比如建索引用了 ext_attrs ->> 'channel',查询里写成 (ext_attrs ->> 'channel')::text,优化器可能就认不出来了。
4.4 EXPLAIN 验证与索引失效场景
我每建完一个 JSON 索引,都会跑一遍 EXPLAIN ANALYZE 确认优化器真的用了它:
sql复制EXPLAIN ANALYZE
SELECT user_id
FROM user_profile
WHERE ext_attrs @> '{"risk_score": 0.12}';
输出里应该能看到 Bitmap Index Scan 或 Index Scan,如果还是 Seq Scan,那就是索引没有被用上。最常见的几种失效原因:
- 对 JSON 字段做函数处理后进行比较,导致表达式与索引不一致;
- 隐式类型不一致,比如
ext_attrs -> 'risk_score' = '0.12',左侧jsonb与右侧unknown的比较可能被优化器解析成普通等值比较而不是包含关系; - 查询的数据量占全表比例太高,优化器认为全表扫描更划算。
遇到这种情况,先把类型转换写明确,再检查表达式是否和索引完全一致。
5. 项目实战中踩过的 JSON 大坑(附带排查思路)
5.1 JSON 类型没法直接建 GIN 索引
有次接手一张旧表,里面用的是 json 类型,业务方反馈查询越来越慢。我直接写 CREATE INDEX ON t USING gin (json_col),结果报错,提示 json 类型没有默认的 GIN 操作符类。这个限制的根源是 json 本身没有解析结构,GIN 无从拆解。
解决办法是建表达式索引,在索引里先转成 jsonb:
sql复制CREATE INDEX idx_old_json_expr
ON t USING gin ((json_col::jsonb));
但这样做等于每次更新都在索引层做一次解析转换,写入开销会变大。如果这张表不是"必须保留原始文本"的场景,我更建议直接把字段类型改成 jsonb,一劳永逸。
5.2 类型不匹配导致的查询慢与报错
这是我见过最频繁的问题。JSON 里的数字在取出时是文本,直接和数字比较会报错:
sql复制-- 报错:operator does not exist: text > numeric
SELECT *
FROM user_profile
WHERE ext_attrs ->> 'risk_score' > 0.5;
必须显式转换:
sql复制SELECT *
FROM user_profile
WHERE (ext_attrs ->> 'risk_score')::numeric > 0.5;
还有一种隐蔽情况:@> 判断时,"risk_score": 0.12 和 "risk_score": "0.12" 在 JSONB 中是两个完全不同的类型。业务方写入时一个接口传的是数字,另一个接口传的是字符串,最后查询条件怎么都匹配不上,这种脏数据很难排查。建议在写入入口做严格校验,数据库侧用 CHECK 约束卡住类型。
5.3 重复键、键顺序和备份恢复的坑
用 jsonb 之后,如果下游系统拿到的数据键顺序和原来不一致,不要惊讶,这是正常行为。但如果下游做的是字符串 diff,就会产生大量"假变更"。我在对接数仓的时候就因为键顺序差异导致很多无效增量同步。
另一个坑出现在备份恢复和数据迁移。用逻辑导出工具导出再导入时,jsonb 字段会以规范化形式输出,键顺序同样可能与源库不一致。如果业务的恢复验证脚本是逐字比较 JSON 文本,这些差异会直接导致校验失败。正确的做法是恢复后按语义比较,或者在导出前就明确 JSONB 的规范化规则。
5.4 别把 JSON 当万能表:一致性怎么办
JSONB 的灵活也带来了约束缺失。它没有外键,不能强制引用完整性,甚至不能强制某个字段必须存在。我的经验是:凡是需要作为核心关联条件的字段,必须抽成普通列,不要让 JOIN 条件直接作用在 JSON 内部。曾经有个业务在 JSONB 里存了"城市 ID",等要做区域维度汇总时,发现一部分行的城市 ID 是数字,另一部分是字符串,还有一部分键的拼写大小写不一致,数据处理脚本里塞满了各种兜底逻辑,苦不堪言。
如果结构已经不可避免用了 JSON,至少要建立数据质量巡检任务,定期用 SQL 扫描异常类型和异常值,尽早发现问题。
6. 100 万行真实数据的性能测试复盘
6.1 测试环境与数据构造
为了验证不同方案的实际效果,我在 KingbaseES 测试环境里做了一组压测。环境是 8 核 16G 内存、SSD 磁盘,单表数据量 100 万行。每行的 ext_attrs 随机包含 5 到 8 个键,包括 channel、tags、risk_score、preferences 等,模拟真实业务写入。
造数直接用 generate_series 加随机函数,数据量可控,这里不详细展开。造完数后分别建了三种索引:普通 B-tree(用于对照)、默认 GIN(jsonb_ops)、jsonb_path_ops GIN。
6.2 等值查询、范围查询与更新对比
测试了几组典型查询,结果如下(毫秒):
| 查询场景 | 无索引 | 默认 GIN | jsonb_path_ops | 表达式索引 |
|---|---|---|---|---|
@> 包含 risk_score=0.12 |
682 | 2.4 | 1.6 | 不适用 |
? 判断 tags 键是否存在 |
655 | 3.1 | 无法使用 | 不适用 |
ext_attrs ->> 'channel' = 'app' |
718 | 走 GIN 但慢 | 走 GIN 但慢 | 1.9 |
单行更新 jsonb_set 某一个键 |
约 0.2 | 约 0.3 | 约 0.3 | 约 0.3 |
可以看出,纯包含查询用 jsonb_path_ops 最快,同时需要键存在判断时默认 GIN 更综合,而高频单字段过滤一定要配合表达式索引,否则即使走了 GIN,效果也不理想。
6.3 索引大小与维护成本
同样 100 万行数据,索引本身的体积差异也值得关注:
| 索引类型 | 大小约 |
|---|---|
| 默认 GIN (jsonb_ops) | 86 MB |
| jsonb_path_ops GIN | 52 MB |
| 表达式索引(channel 字段) | 19 MB |
jsonb_path_ops 比默认 GIN 小 40% 左右,这对大表的缓存命中率影响很大。另外,GIN 索引的维护成本比 B-tree 高,批量导入大量 JSONB 数据时,建议先删除索引,导入完成后再重建,通常比边导入边维护索引快一大截。
6.4 对结果的分析
从实测看,KingbaseES 的 JSONB 加上合适索引,在百万行级别完全能支撑在线业务的单点查询和过滤场景。关键是要把查询模式想清楚:如果你的业务主要做"包含关系"过滤,比如从 JSON 里筛选满足某些条件的对象,直接上 jsonb_path_ops;如果还要验证"某个 key 是否存在",用默认 jsonb_ops;如果某个键被当成高频筛选条件,那就再补一条表达式索引。
有一点要时刻记住:JSONB 的更新是整行重写,大字段的更新代价远高于普通列,所以高频更新的单值不要塞进 JSONB。如果避免不了,至少把更新频率最高的字段拆到普通列,JSONB 里只保留低频扩展属性。
如果让我重新设计这个模块,我依然会选择 JSONB,但会在写入入口加更多约束。开发同学起初觉得 JSON 字段不需要审,后来一致认为,反而是 JSON 字段最需要约束,因为数据库不再替你挡那些"无心之失"。在 KingbaseES 里把 JSON 用好,核心不是学会语法,而是想清楚哪些数据可以松,哪些数据必须紧,这个边界划清楚了,性能和灵活性的矛盾就自然消失了。
