做了几年PostgreSQL相关的数据治理工作,我一直觉得JSONB是个让人又爱又恨的类型。爱它是因为灵活,想存什么存什么;恨它是因为一旦字段多了、数据杂了,想搞清楚"这张表里哪些字段是真正被填了的、哪些字段全是空的"就成了个大麻烦。最近正好有项目需要把订单表里JSONB扩展字段做一轮数据质量巡检,我就写了个通用的非空字段统计函数,在这里把完整的踩坑思路和代码实现分享一下。
这篇文章适合正在用PostgreSQL、有JSONB字段、并且需要做数据质量分析或者字段治理的开发者。无论你是刚接触JSONB不久,还是已经写过不少相关查询,这篇内容都能提供一些可以立刻上手的方案,也会讲清楚那些文档里不会明说的判定细节。
1. 为什么需要给JSONB做"非空字段统计"
1.1 来自真实数据巡检的需求
先说具体的业务背景。我们有一张用户行为订单表,核心业务字段是固定的那十几列,但业务方为了支持快速迭代,把大量扩展属性都塞进了一个叫 ext_info 的JSONB字段里。里面存过用户的设备信息、渠道来源、偏好标签、活动参与记录,甚至还有前端埋点带上来的一些临时参数。三个月跑下来,这个JSONB里到底出现过多少种key、每个key的填充率是多少,根本没人能说清楚。
这时候就有人提出了需求:"帮我统计一下,JSONB字段里哪些字段是空的,哪些字段有值,最好能一次性统计出所有字段的填充率。"听起来很简单,但实际操作起来,比想象中麻烦得多。原因在于JSONB不像普通表字段,它没有一个固定的schema,你想知道它有哪些字段,必须先扫描一遍数据才能拿到key列表,然后再逐个统计非空情况。两个步骤都得做全表遍历。
1.2 字段治理和结构变更前的摸底
除了日常巡检,这类统计在表结构变更评估时也特别有用。比如业务方说"我想把 ext_info 里的 user_level 字段提升成独立的表字段",那你在做变更之前就必须知道这个字段的填充率到底是多少。如果填充率不到30%,那提升成独立字段后,大量行的该列都是NULL,不仅浪费存储空间,还容易让下游误以为数据缺失是bug。
另一个常见场景是文档型数据迁移。当你需要把PostgreSQL里的JSONB数据同步到其他存储系统,或者反过来把别的系统的JSON数据导入PostgreSQL时,"哪些字段是可靠的、哪些字段基本是空的"这个信息,直接决定了迁移方案的取舍。有的字段可能只出现在历史数据里,新数据根本不写了,这些情况都能通过非空统计暴露出来。
1.3 这种"空"远比想象中复杂
真正动手之后你会发现,"非空"这两个字在JSONB上的定义比在普通字段上复杂得多。一个JSONB字段,某个key可能:
- 完全不存在(键缺失)
- 存在,但值是JSON格式的
null - 存在,值是空字符串
"" - 存在,值是空对象
{}或者空数组[] - 存在,是有实际意义的值
这些情况在业务上的"空"含义完全不一样,但在SQL层面,它们的表现各有不同。普通字段你只需要判断 IS NULL 和 IS NOT NULL,JSONB却得借助函数仔细区分。这一点我会在后面专门展开讲,因为这里混淆了,统计结果就全错了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 统计前必须搞懂的三个JSONB判定边界
2.1 SQL NULL、键缺失、JSON null 三者截然不同
新手最容易犯的错误,就是把JSONB取出来的值和普通字段一样用 IS NULL 来判断。直接说结论:在PostgreSQL里,data -> 'some_key' 取出来的结果,分三种情况:
| 情况 | 表达式结果 | IS NULL 判断 |
|---|---|---|
| 键不存在 | SQL NULL | 为真 |
| 键存在,值为JSON null | jsonb的 'null' 值 |
为假 |
| 键存在,值正常 | jsonb值 | 为假 |
也就是说,如果一个key的值是JSON null,你用 col -> 'key' IS NULL 来判断,会得到"非空"的错误结论。反过来,如果你用 col ? 'key' 来判断键是否存在,它只会判断键存不存在,不会告诉你值是null还是有实际内容。
我在项目里就吃过这个亏。第一次写统计SQL时,用的是 WHERE (ext_info -> 'user_level') IS NOT NULL 来统计非空,结果把一堆JSON null的数据全算进去了,填充率虚高了好几个百分点。说白了,JSON null在JSONB里是一个实实在在的值,它不等于SQL NULL,也不等于键不存在。
2.2 用 jsonb_typeof 把JSON null揪出来
要准确区分"键存在但值是JSON null"这种情况,唯一的可靠手段是 jsonb_typeof() 函数。这个函数返回一个文本值,表示参数的类型:
sql复制SELECT jsonb_typeof('{"a": 1}'::jsonb -> 'a'); -- number
SELECT jsonb_typeof('{"a": null}'::jsonb -> 'a'); -- null
SELECT jsonb_typeof('{"a": {}}'::jsonb -> 'a'); -- object
SELECT jsonb_typeof('{"a": []}'::jsonb -> 'a'); -- array
SELECT jsonb_typeof('{"b": 1}'::jsonb -> 'a'); -- NULL(因为键缺失返回SQL NULL)
注意最后一行:如果键缺失,jsonb_typeof() 输入的是SQL NULL,返回值也是SQL NULL。所以判断"非空值"的条件,应该是:
sql复制jsonb_typeof(col -> 'key') IS NOT NULL
AND jsonb_typeof(col -> 'key') <> 'null'
第一个条件排除了键缺失的情况,第二个条件排除了JSON null的情况。两个条件同时满足,才说明这个key在某一行的JSONB里是有实际值的。
2.3 空字符串、空数组、空对象到底算什么
这是另一个需要提前定义好的问题。从技术上讲,空字符串、空数组、空对象都不是null,它们都是真实的值。jsonb_typeof('""'::jsonb) 返回 string,jsonb_typeof('[]'::jsonb) 返回 array,jsonb_typeof('{}'::jsonb) 返回 object。
但从业务上看,空字符串往往意味着用户没填,空数组往往意味着没有标签,空对象可能意味着子结构还没初始化。这个要不要算作"空",完全取决于你的业务口径。我建议在统计函数里预留一个参数,让调用方决定是否把空字符串、空数组、空对象剔除。默认严格模式只排除键缺失和JSON null,可选模式可以进一步排除 ''、[]、{}。
3. 先别急着写函数:一条SQL就能做顶层统计
3.1 用 jsonb_each 横向展开全部key
在封装成通用函数之前,如果你只想快速看一眼某个JSONB字段的顶层key分布,根本不需要写函数,一条SQL就够了。核心工具是 jsonb_each(),它把一个JSONB对象展开成多行,每行包含一个key和一个value:
sql复制SELECT kv.key, kv.value
FROM orders, jsonb_each(ext_info) AS kv
LIMIT 10;
这相当于把每一行的JSONB里的所有顶层字段全部"拍平",然后和原表的其他字段一起参与运算。注意这里的写法用了隐式 LATERAL 连接,PostgreSQL允许在FROM里直接调用返回集合的函数,会自动按行展开。
3.2 用 FILTER 子句统计非空计数
拿到展开后的key和value之后,统计就简单了。对每个key,统计它出现的总行数,以及其中非空值的行数:
sql复制SELECT
kv.key,
COUNT(*) AS total_rows,
COUNT(*) FILTER (
WHERE jsonb_typeof(kv.value) IS NOT NULL
AND jsonb_typeof(kv.value) <> 'null'
) AS non_null_count
FROM orders, jsonb_each(ext_info) AS kv
GROUP BY kv.key
ORDER BY non_null_count DESC;
FILTER 是PostgreSQL里做条件计数最优雅的写法,比 SUM(CASE WHEN ... THEN 1 ELSE 0 END) 可读性强多了。上面这条SQL,就能返回整个表里JSONB每个顶层key的总出现次数和非空次数。
3.3 从顶层字段延伸到嵌套路径
如果JSONB里还有嵌套对象,你可以用同样的思路再进一步。把 jsonb_each() 换成一层递归展开,就能统计二级路径。比如 ext_info 里有个 device_info 对象,里面还有 os_type、browser 这些子字段,用递归CTE把它们也榨出来:
sql复制WITH RECURSIVE unpack AS (
SELECT ARRAY[kv.key] AS path, kv.value
FROM orders, jsonb_each(ext_info) AS kv
UNION ALL
SELECT u.path || kv.key, kv.value
FROM unpack u, jsonb_each(u.value) AS kv
WHERE jsonb_typeof(u.value) = 'object'
)
SELECT
array_to_string(u.path, '.') AS full_path,
COUNT(*) AS total_rows,
COUNT(*) FILTER (
WHERE jsonb_typeof(u.value) IS NOT NULL
AND jsonb_typeof(u.value) <> 'null'
) AS non_null_count
FROM unpack u
GROUP BY u.path
ORDER BY non_null_count DESC;
这条SQL会把所有嵌套到最深层的标量字段路径都统计出来。数组内部的字段它不会继续展开,因为数组里可能是多个对象,每个对象的结构未必一致,展开逻辑会复杂很多。我建议在统计阶段先别碰数组内部,那是另一个层面的分析任务。
4. 封装成通用函数:完整代码与设计思路
4.1 函数签名与返回结构
单条SQL虽然快,但每次换个JSONB字段都要改SQL,太麻烦。我把统计逻辑封装成了可复用函数,返回表结构设计成四列:字段名、非空计数、总行数、非空占比。这样无论业务方是要看绝对值还是看比例,都能直接得到结果。
sql复制CREATE OR REPLACE FUNCTION jsonb_non_null_field_stats(
tbl_name regclass,
jsonb_col text,
ignore_empty_values boolean DEFAULT false,
sample_percent integer DEFAULT NULL
)
RETURNS TABLE (
field_name text,
non_null_count bigint,
total_rows bigint,
non_null_ratio numeric
)
LANGUAGE plpgsql
AS $$
DECLARE
col_ident text;
all_keys text[];
key_value text;
empty_filter text := '';
BEGIN
col_ident := format('%I', jsonb_col);
IF ignore_empty_values THEN
empty_filter := 'AND jsonb_typeof(value) NOT IN (''string'', ''array'', ''object'')';
END IF;
...
END;
$$;
参数说明:
tbl_name使用regclass类型,好处是能自动处理schema前缀,比如传public.orders,同时还能防SQL注入,因为regclass类型转换会校验表是否存在。jsonb_col是JSONB字段名,函数内部用format('%I', ...)转成带引号的合法标识符,同样是为了防止字段名注入。ignore_empty_values控制前面说的"空字符串/空数组/空对象"是否排除。sample_percent是采样百分比,用于大数据量场景下的快速摸底,我会在后面性能部分说明。
4.2 两步走:先收集key集合,再逐key统计
函数的核心逻辑是两步。第一步,扫描表里所有JSONB数据,用 jsonb_object_keys() 把所有出现过的key去重收集到一个数组里。第二步,遍历这个数组,对每个key执行一次条件计数。
sql复制EXECUTE format(
'SELECT ARRAY(SELECT DISTINCT jsonb_object_keys(%s) FROM %s%s)',
col_ident,
tbl_name,
CASE WHEN sample_percent IS NOT NULL
THEN format(' TABLESAMPLE SYSTEM (%s)', sample_percent)
ELSE '' END
) INTO all_keys;
FOREACH key_value IN ARRAY all_keys LOOP
RETURN QUERY EXECUTE format(
'SELECT %L,
COUNT(*) FILTER (
WHERE jsonb_typeof(%s -> %L) IS NOT NULL
AND jsonb_typeof(%s -> %L) <> ''null''%s
),
COUNT(*),
ROUND(
COUNT(*) FILTER (
WHERE jsonb_typeof(%s -> %L) IS NOT NULL
AND jsonb_typeof(%s -> %L) <> ''null''%s
)::numeric / COUNT(*),
4
)
FROM %s',
key_value,
col_ident, key_value, col_ident, key_value,
CASE WHEN ignore_empty_values THEN
' AND jsonb_typeof(' || col_ident || ' -> ' || quote_literal(key_value) || ')
NOT IN (''string'',''array'',''object'')'
ELSE '' END,
col_ident, key_value, col_ident, key_value,
CASE WHEN ignore_empty_values THEN ... END,
tbl_name
);
END LOOP;
这里有一个值得注意的边界情况:如果某个key在JSONB里出现的位置有时是字符串值、有时是JSON对象,那 jsonb_object_keys() 收集出来的是不同的key吗?不是。jsonb_object_keys() 只遍历顶层,只要你是同一个key,不管值是什么类型,它只会出现一次。但如果你有一个key叫 data,在某些行里它是字符串,在另一些行里它是对象,那它的值类型在不同行里不同,统计非空计数时我们会按同一种口径处理,这没问题。
4.3 动态SQL里的细节:%L、%I、quote_literal 用在哪
动态SQL是plpgsql里最容易写错的部分。我自己踩过的坑包括:
%I用于标识符(表名、列名),它会自动加双引号,同时处理大小写问题。%L用于字面量,适合传入字符串值,比如key的名称,它会自动加单引号并转义内部的单引号。regclass类型在format('%s', tbl_name)时输出的是带schema的完整表名,这样动态SQL里能直接用。- 在拼接
ignore_empty_values那一段过滤条件时,一定要用quote_literal()来包裹key值,避免key本身含有单引号时把SQL打断。
写动态SQL时养成一个好习惯:在函数里先拼好一个 debug_query text 变量,必要时可以直接 RAISE NOTICE '%', debug_query 打印出来检查拼接结果。我在开发阶段就靠这个发现了两个拼接错误。
4.4 把嵌套对象统计也做成可选功能
顶层统计只能覆盖一层。如果JSONB里有二级对象,我上面那条递归CTE是可以复用的。不过把它塞进plpgsql函数里会更复杂,因为递归CTE的结果需要缓存到一个临时表里再统计。这里给出一个嵌入式方案,直接在函数里跑一次递归CTE,然后把结果用临时表存起来再返回,适合需要定期跑全量指标的场景。
sql复制CREATE TEMP TABLE tmp_jsonb_unpacked AS
WITH RECURSIVE unpack AS (
SELECT ARRAY[kv.key] AS path, kv.value
FROM tbl, jsonb_each(col) AS kv
UNION ALL
SELECT u.path || kv.key, kv.value
FROM unpack u, jsonb_each(u.value) AS kv
WHERE jsonb_typeof(u.value) = 'object'
)
SELECT array_to_string(path, '.') AS full_path, value FROM unpack;
之后就能从临时表里按 full_path 做同样的 GROUP BY 统计了。不过要注意,一次函数调用只处理一层递归,如果业务场景里有很深的多层嵌套,这个方案会随着层数加深而显著变慢,需要谨慎使用。
5. 千万级表上的优化实践
5.1 全表聚合为什么慢
写一个能跑的统计函数不难,难的是在千万级甚至亿级表上还能跑完。统计分析的本质决定了它必须把整张表过一遍,因为你要知道"哪些key出现过",就必须看到所有的JSONB数据,这个没有任何索引可以绕过去。所以这里的优化方向不是"用索引避免全表扫描",而是"尽量降低扫描代价"。
一条经验:在千万级表上统计顶层key,如果每个JSONB平均20个key,那么展开后的行数就是2亿行,这个量级会让 GROUP BY key 的排序和哈希聚合吃不少内存。如果机器内存不够,甚至可能落盘到临时文件,那跑起来就非常难受了。
5.2 抽样统计:用1%的数据换一个大致结论
很多统计场景其实不需要100%精确。比如数据质量巡检,你是想看整体趋势,不是做审计对账。这种情况下,抽样统计是性价比最高的方案。PostgreSQL自带的 TABLESAMPLE 子句直接用就行,有两种抽样方法:
| 方法 | 原理 | 特点 |
|---|---|---|
BERNOULLI(n) |
逐行按概率抽取 | 更均匀,但扫描成本高 |
SYSTEM(n) |
按数据块抽取 | 速度快,但可能偏差稍大 |
我在函数里加的 sample_percent 参数,就是通过把 TABLESAMPLE SYSTEM (百分比) 拼进动态SQL来实现的。实践下来,在亿级表上用2%的采样,跑一轮统计只需要几十秒到几分钟,结果和全量统计的偏差基本在1%以内。
需要注意,TABLESAMPLE 不能直接跟在 regclass 后面,得先给表取个合适的名字。在动态SQL里可以写成:
sql复制SELECT ARRAY(
SELECT DISTINCT jsonb_object_keys(ext_info)
FROM orders TABLESAMPLE SYSTEM (2)
)
5.3 物化视图与增量统计表:让结果可复用
统计函数跑出来的结果,如果每次都要重算,那成本依然很高。更合理的做法是:把统计结果落成一张表,定期刷新。有几个方案:
方案一,物化视图。把统计SQL固化成物化视图,需要更新时执行 REFRESH MATERIALIZED VIEW CONCURRENTLY。适合统计口径比较固定的场景。
方案二,统计结果表。函数每次跑完,把结果 INSERT INTO jsonb_field_stats_snapshot,保留历史快照,这样还能对比不同时间点的字段填充率变化。我很推荐这个方案,因为数据质量趋势本身就是重要指标。
方案三,触发器增量维护。如果你想实时维护非空计数,可以在业务表的 INSERT/UPDATE/DELETE 触发器里,对变更行的JSONB字段做差异计算,把 non_null_count 和 total_rows 同步到统计表。这个方案实现成本最高,但实时性最好。我目前没采用,因为触发器对业务表的写入路径影响太大,而统计指标本身不需要实时。
5.4 JSONB的GIN索引在统计这件事上的边界
很多人会有个误解,觉得给JSONB字段建了GIN索引,统计就会变快。其实GIN索引对等值查询、包含查询(如 @>、?)非常有效,但对"统计某个key的非空数量"这种全表聚合查询没有任何帮助。因为即使你通过索引能快速知道哪些行包含某个key,你还是得回表读取每一行的value才能判断它是不是JSON null。
如果你的统计场景很固定,比如只关心 ext_info 里固定几个key的填充率,那可以考虑建一个部分索引来加速:
sql复制CREATE INDEX idx_orders_ext_user_level
ON orders ((ext_info -> 'user_level'))
WHERE ext_info ? 'user_level';
这样如果查询只统计 user_level,并且能走到这个索引,就能大大减少扫描的数据量。但这种方案只适合key数量很少、且固定的场景,不适合做全字段普查。
6. 真实数据上踩过的7个坑
6.1 键缺失被当成非空值
这是我踩的第一个坑,也是最多人会踩的坑。如果你直接用 COUNT(col -> 'key') 来统计非空,那么当 col -> 'key' 是SQL NULL时,COUNT 会忽略它;但如果值是JSON null,它会算进去。反过来说,如果你用 COUNT(*) FILTER (WHERE col ? 'key'),那键存在但值为JSON null的也被算成"有值"了。两种写法都会让统计结果失真。
正确做法还是回到 jsonb_typeof(col -> 'key') IS NOT NULL AND jsonb_typeof(col -> 'key') <> 'null',没有捷径。
6.2 JSON null混进统计结果
业务系统在写入JSONB时,经常会有这种代码:后端枚举值没取到,就把 null 写进JSON里。这个 null 在业务上明确代表"没有值",但在技术上它就是个合法存在的JSON值。这导致一个JSONB字段很可能同时存在三种情况:键缺失、键存在但值为null、键存在且值正常。如果你的统计函数没有把JSON null单独过滤出来,那统计出的"填充率"是虚高的。
6.3 键名大小写和前后空格
JSONB的key是严格区分大小写的,而且不会自动trim空格。业务方可能在某个版本里写的是 UserName,后来另一个版本改成了 username,JOSNB会认为这是两个完全不同的key。在统计函数里,这两个会作为两行分别返回。我建议在统计前先跑一个key的分布查询,看有没有疑似重复的key名。如果发现"大小写差异"或者"前后空格"的相似key,得先跟业务方确认是不是同一种含义,需要在源头统一数据格式。
6.4 同一字段在不同行里的类型不一致
jsonb_object_keys() 只会收集key的名称,不会区分值类型。比如有个key叫 score,早期版本写入的是数字 90,后来某次接口改动之后写入的是字符串 "90"。在统计非空时,这两种都算非空,但如果后续要做数值聚合,就会遇到类型转换的错误。我建议统计函数里可以额外输出一个 value_types 列,用 array_agg(DISTINCT jsonb_typeof(value)) 聚合一下,这样一眼就能看出哪些字段存在类型不一致的问题。具体实现可以加到返回表里:
sql复制ARRAY_AGG(DISTINCT jsonb_typeof(kv.value)) FILTER (
WHERE jsonb_typeof(kv.value) IS NOT NULL
) AS value_types
6.5 大量key导致的内存压力
如果JSONB里的key数量非常多(比如超过几百个),那么 SELECT DISTINCT jsonb_object_keys(...) 的结果集会非常大,而且每个key还要单独执行一次 RETURN QUERY EXECUTE,导致函数累计执行时间非常长。这时候我建议分两步:第一步先把key收集到一张临时表,第二步在临时表上遍历。这样至少能避免反复扫描原表。另外,如果key的数量实在太多,说明这个JSONB字段的schema已经失控了,应该考虑规范化拆分,而不是继续用JSONB硬扛。
6.6 统计结果如何落库
函数返回的结果是实时的,但如果你的下游系统要每天拉取这个指标,我建议把函数封装成一个定时任务,把结果写入快照表。快照表的设计可以简单一点:
sql复制CREATE TABLE jsonb_field_stats_snapshot (
stat_date date NOT NULL DEFAULT CURRENT_DATE,
table_name text NOT NULL,
field_name text NOT NULL,
non_null_count bigint,
total_rows bigint,
non_null_ratio numeric
);
这样每天跑一次,还能做历史趋势对比,比每次都现算有意义得多。
6.7 PostgreSQL版本差异
本文用到的 jsonb_each、jsonb_object_keys、jsonb_typeof、TABLESAMPLE 这些都是PostgreSQL自带功能,从9.4引入JSONB开始就有了,9.5之后基本都可用。但有两个细节要留意:一是 COUNT(*) FILTER 语法是在9.4引入的,用老版本的得改写成 SUM(CASE WHEN ... THEN 1 ELSE 0 END);二是 TABLESAMPLE 的 REPEATABLE 子句在更早版本不可用,如果需要可复现的抽样结果,建议使用 TABLESAMPLE SYSTEM (n) REPEATABLE (seed) 并确认版本支持。
我是在PostgreSQL 16上验证的这些逻辑,函数写完后用在了生产环境的订单表上。实测一个4000万行、JSONB里平均30个字段的表,全量统计耗时在2分钟左右,抽样2%后时间降到了10秒以内,效果还是很明显的。
如果你也在做类似的JSONB字段数据质量分析,建议直接把文章里的函数抄过去改一改,先跑一版抽样结果看看key的分布。等确认哪些key值得重点关注之后,再决定是定期全量统计,还是针对固定key建索引加速。JSONB这玩意看着自由,但自由一定是有代价的,统计字段填充率就是你要为这份自由付出的第一笔债。
