alitrack

JSON 塞在字符串列里,DuckDB 三条 SQL 摊开成表

数据库里那种存 JSON 的 VARCHAR 列,几乎是每个做数据的人都绕不开的东西:Postgres 表里的事件 payload、采集任务落库的 API 响应、CSV 导出里的 metadata 列。查的时候只能一个字段一个字段地抠,每抠一次,整份 JSON 就得重新解析一遍。

DuckDB 官方 10 月 16 日发了一篇博客,专门回答这个老问题:这类列不需要先导出成文件再 read_json 读回来,三条 SQL 就地转成强类型的列。我在本地 DuckDB 1.6.0.dev343 上把博客里的每一段代码都跑了一遍,全部成立,还顺带踩到一个博客只提了一句的坑。

先给结论:JSON 列真正的代价是每查一次就重新解析一次;转成类型列之后,这份功只需要做一遍。

● ● ●

老办法:一个字段一个字段抠

DuckDB 的 JSON 抽取操作符 ->> 接受键名或 JSON 路径,返回 VARCHAR,缺的键返回 NULL:

SELECT
    id,
    payload ->> '$.user.country' AS country,
    payload ->> 'action' AS action,
    (payload ->> 'duration_ms')::INTEGER AS duration_ms
FROM events;

拿博客给的样例数据跑,结果没问题:

┌───────┬─────────┬──────────┬─────────────┐
│  id   │ country │  action  │ duration_ms │
├───────┼─────────┼──────────┼─────────────┤
│     1 │ NL      │ click    │        NULL │
│     2 │ BE      │ view     │        1200 │
│     3 │ NULL    │ purchase │        NULL │
└───────┴─────────┴──────────┴─────────────┘

两个毛病:字段一多,SQL 又长又难维护;更重要的是每抽取一个字段,那份 JSON 就被完整解析一次,三个字段就是三次解析。说实话,偶尔回答一个问题无所谓,天天跑的报表受不了。

● ● ●

三步法:结构自动推断,类型自己修

整条流水线长这样:

JSON 列转类型化列的三步流水线

JSON 列转类型化列的三步流水线

第一步:推断整列的结构

json_group_structure 是 json_structure 的聚合版本,把一列里所有 JSON 值的结构合并成一个:

SELECT json_group_structure(payload::JSON) AS structure
FROM events;

注意任一行出现过的键都会被抓到,包括只有第二行才有的 duration_ms 和只有第三行才有的 items:

{"user":{"id":"UBIGINT","country":"VARCHAR"},"action":"VARCHAR",
 "ts":"VARCHAR","duration_ms":"UBIGINT",
 "items":[{"sku":"VARCHAR","qty":"UBIGINT"}]}

表大的时候不必全表扫,官方建议抽样推断,比如 FROM events USING SAMPLE 10_000 ROWS。我在本地试了把样本压到 2 行,抽到的两行恰好都没有 duration_ms,推断结果里这一列直接消失了。抽样省的是扫描,漏的是稀有键——键的分布越稀疏,样本要越大,这是官方博客只用一句话带过、实测才会记住的细节。

第二步:修类型

推断结果需要手工修两处:JSON 没有时间戳类型,ts 出来是 VARCHAR;整数出来是偏宽的 UBIGINT。结构本身就是一份 JSON 文档,所以直接用 json_merge_patch 打补丁,只写要改的键,没写的保持推断值:

SET VARIABLE payload_structure = (
    SELECT json_merge_patch(
        json_group_structure(payload::JSON),
        '{
            "user": {"id": "INTEGER"},
            "ts": "TIMESTAMP",
            "duration_ms": "INTEGER",
            "items": [{"sku": "VARCHAR", "qty": "INTEGER"}]
        }'
    )
    FROM events
);

第三步:一次解析,摊开成列

json_transform(别名 from_json)按给定结构把 JSON 值转成 DuckDB 的 STRUCT,unnest 再把结构体的顶层字段摊成列:

CREATE TABLE events_typed AS
    SELECT id, unnest(json_transform(payload, getvariable('payload_structure')))
    FROM events;

DESCRIBE events_typed;

本地跑出来的表结构,嵌套的 user 和数组里的 items 都各归其位:

│ id          │ INTEGER                             │
│ action      │ VARCHAR                             │
│ user        │ STRUCT(country VARCHAR, id INTEGER) │
│ ts          │ TIMESTAMP                           │
│ duration_ms │ INTEGER                             │
│ items       │ STRUCT(sku VARCHAR, qty INTEGER)[]  │

从这里开始,JSON 只在装表时解析了一次。之后的列是普通 DuckDB 列,按类型存储和压缩,查询根本碰不到 JSON 解析器。字段访问也变成了原生的路径写法:

SELECT id, "user".id AS user_id, "user".country, action, ts
FROM events_typed;

● ● ●

脏数据:宽松版静默置空,严格版当场报错

默认的 json_transform 遇到转不动的值不报错,直接给 NULL:

SELECT json_transform('{"qty": "two"}', '{"qty": "INTEGER"}');
-- {'qty': NULL}

对脏数据友好,但也可能把问题藏起来。调结构的时候换 json_transform_strict,本地实测它对同样的输入当场抛错:

Invalid Input Error: Failed to cast value to numerical: "two"

官方建议的节奏是:先用严格版把结构调对,确认哪些脏值可以容忍后,再切回宽松版上生产。

● ● ●

那原始列该存 VARCHAR 还是 JSON?

DuckDB 的 JSON 类型底层存储和 VARCHAR 完全一样,大小和扫描速度没有差别。它多给的是写入时的校验(非法文档进不来)和抽取函数不用处理畸形输入的保证。

两种做法的差别不在存储,在什么时候付解析的钱:

逐字段抽取与转类型列的成本对比

逐字段抽取与转类型列的成本对比

如果这个列会被反复查询,原始列是 VARCHAR 还是 JSON 其实无所谓,要紧的是字段有没有被抽出来。查询期抽取,每个查询都重付一遍解析的钱;装表时转一次,这笔钱只付一次。

回到开头那种 VARCHAR 列:它不必继续当「只能抠着查」的半成品数据。三条 SQL,推断、修补、摊开,它就成了真正能跑满速的表。官方把方法写清楚了,剩下的只是你愿不愿意让那列 JSON 只被解析一次。

参考来源

  1. 01
    From JSON Strings to Typed Columns — The DuckDB team, 2026-10-16

https://duckdb.org/2026/10/16/json-to-typed-columns.html

  1. 01
    Reconciling JSON in DuckDB, One Patch at a Time(json_merge_patch 专题)

https://duckdb.org/2026/08/18/json-reconciling-v2.html

  1. 01
    本地验证环境:DuckDB 1.6.0.dev343(Python 包),所有 SQL 与输出均为实测

你手头那列 JSON 一般怎么处理?A 一直用抽取操作符凑合;B 早就导出成文件再读回来;C 转成过类型列但吃过后缀名对不上的亏。评论区说说,我看看哪种最常见。