如果你的数据以 JSON 形式写入,ClickHouse 提供了多种存储方式,从完全类型化的列到原始 String。具体选择取决于你的 schema 结构有多稳定,以及你是否需要字段级查询。
范围: 本页介绍存储 JSON 数据时的 schema 设计决策。不涵盖 JSON 输入/输出格式、JSON 函数 或查询语法。有关 JSON 列类型本身的更多背景信息,请参见 在适当情况下使用 JSON。
前提: 你应熟悉 ClickHouse 表创建、MergeTree 的基础知识以及列类型语法。
快速决策
- 如果每个字段都有已知且稳定的类型,并且 schema 很少变动 → 类型化列
- 如果大多数字段都是稳定的,但某一部分是动态的或不可预测的 → 混合方案 (类型化列 + JSON)
- 如果整个结构都是动态的,并且键会在不同记录之间出现或消失 → 原生 JSON 列
- 如果动态字段是键值对,且值类型一致 (例如字符串标签、数值指标)
→ 选择
Map而不是 JSON - 如果你只是存储和读取 JSON blob,而不进行字段级查询 → 不透明 String 存储
方法说明
类型化列
适用场景: JSON 结构在设计阶段已完全明确。各条记录之间的字段和类型保持不变。即使是复杂的嵌套结构 (如对象数组、嵌套 Map) ,也可以用 Array、Tuple 和 Nested 类型来表示。
权衡取舍: schema 变更需要执行 ALTER TABLE。如果不更新 schema,插入时出现的额外字段会被静默丢弃。
设置、验证与注意事项
设置
CREATE TABLE events
(
`timestamp` DateTime,
`service` LowCardinality(String),
`level` Enum8('DEBUG' = 1, 'INFO' = 2, 'WARN' = 3, 'ERROR' = 4),
`message` String,
`host` LowCardinality(String),
`duration_ms` UInt32
)
ENGINE = MergeTree
ORDER BY (service, timestamp)验证
-- 确认列类型符合预期
DESCRIBE TABLE events FORMAT Vertical
-- 通过插入和查询来验证 schema 能否处理你的数据
INSERT INTO events FORMAT JSONEachRow
{"timestamp":"2025-03-19 10:00:00","service":"api","level":"INFO","message":"request handled","host":"node-1","duration_ms":42}
SELECT service, level, duration_ms FROM events WHERE service = 'api'注意事项
- 如果你使用
JSONEachRow插入 JSON 数据,而 JSON 中包含 schema 里不存在的字段,ClickHouse 默认会静默丢弃这些字段。如果你希望改为报错,请将input_format_skip_unknown_fields设置为0。
混合方案 (类型化列 + JSON)
适用场景: 一组核心字段是稳定的 (时间戳、ID、状态码) ,但部分载荷是动态的。比如用户自定义属性、标签、元数据或扩展字段,这些字段会因记录而异。
权衡: 类型化列可获得完整性能,JSON 列则提供灵活性。不过,JSON 列中的动态部分仍会带来插入开销和存储成本。
设置、验证与注意事项
设置
CREATE TABLE events
(
`timestamp` DateTime,
`service` LowCardinality(String),
`level` Enum8('DEBUG' = 1, 'INFO' = 2, 'WARN' = 3, 'ERROR' = 4),
`message` String,
`host` LowCardinality(String),
`duration_ms` UInt32,
`attributes` JSON(
max_dynamic_paths = 256,
`http.status_code` UInt16,
`http.method` LowCardinality(String),
SKIP REGEXP 'debug\..*'
)
)
ENGINE = MergeTree
ORDER BY (service, timestamp)验证
-- 插入样本数据并查看推断出的路径
INSERT INTO events FORMAT JSONEachRow
{"timestamp":"2025-03-19 10:00:00","service":"api","level":"INFO","message":"request handled","host":"node-1","duration_ms":42,"attributes":{"http.status_code":200,"http.method":"GET","user.region":"eu-west","custom_tag":"abc"}}
SELECT JSONAllPathsWithTypes(attributes)
FROM events
FORMAT PrettyJSONEachRow注意事项
- 对于你预先已知的 JSON 路径,请使用 类型提示。类型提示会绕过判别列,并将该路径像普通类型化列一样存储,具有相同性能且没有额外开销。
- 对于你永远不会查询的路径 (如调试元数据、内部追踪 ID) ,使用
SKIP或SKIP REGEXP可以节省存储并减少子列数量。 - 将
max_dynamic_paths设置为与你实际查询的不同路径数量相匹配。默认值 (1024) 适用于大多数场景。如果动态部分较少,可以适当调低。 - 不要将
max_dynamic_paths设为高于 10,000。较高的值会增加资源消耗并降低效率。
原生 JSON 列
适用场景: 数据结构确实难以预测,不同记录中的键可能会不断出现或消失。适合用于用户生成的 schema、插件系统,或无法控制上游 schema 的数据湖摄取场景。
权衡取舍: 插入速度比类型化列慢。读取整个对象也比 String 慢。子列管理还会带来额外的存储开销。但对于针对特定路径的字段级查询,这种方式表现良好。
设置、验证和注意事项
设置
CREATE TABLE dynamic_events
(
`id` UInt64,
`ts` DateTime DEFAULT now(),
`data` JSON(
max_dynamic_paths = 512,
`event_type` LowCardinality(String),
`version` UInt8
)
)
ENGINE = MergeTree
ORDER BY (data.event_type, ts)将完整的 JSON 文档插入 JSON 列时,请使用 JSONAsObject 格式。它会将每一行输入视为一个完整的 JSON 对象,并将其映射到该列。
验证
INSERT INTO dynamic_events (id, data) FORMAT JSONEachRow
{"id": 1, "data": {"event_type": "click", "version": 2, "page": "/home", "button_id": "cta-1"}}
{"id": 2, "data": {"event_type": "purchase", "version": 1, "item_id": "SKU-99", "amount": 49.99, "currency": "USD"}}
-- 检查 ClickHouse 检测到的路径及其类型
SELECT JSONAllPathsWithTypes(data) FROM dynamic_events FORMAT PrettyJSONEachRow
-- 查询特定路径
SELECT data.page FROM dynamic_events WHERE data.event_type = 'click'注意事项
- 如果没有类型提示,ClickHouse 会根据每个路径最先看到的值来推断类型。如果某条记录中的
score是"10"(字符串) ,而另一条记录中是10(整数) ,该路径就会生成一个判别列,查询也会变慢。对于类型已知的路径,建议添加提示。 - 当路径数量超过
max_dynamic_paths时,溢出值会移入共享数据结构,从而导致查询性能下降。可使用JSONDynamicPaths()进行监控,并将该限制控制在 10,000 以下。 - 每个动态路径最多支持
max_dynamic_types(默认值为 32) 种不同的数据类型。如果单个路径超过这个限制,额外类型会回退到共享的 Variant 存储。除非同一字段在你的数据中存在非常不一致的类型,否则这种情况通常影响不大。
不透明 String 存储
适用场景: JSON 文档以整体形式存储和读取,然后传给应用、归档,或转发到下游。在 ClickHouse 内不做字段级筛选或聚合。
权衡: 写入最快,schema 也最简单。但如果不在运行时解析 (JSONExtract 系列) ,就无法进行字段级查询;而这种做法在大规模场景下会比较慢。
设置、验证和注意事项
设置
CREATE TABLE raw_events
(
`id` UInt64,
`received` DateTime DEFAULT now(),
`payload` String
)
ENGINE = MergeTree
ORDER BY (received)验证
INSERT INTO raw_events (id, payload) VALUES
(1, '{"type":"click","page":"/home"}'),
(2, '{"type":"purchase","item":"SKU-99","amount":49.99}')
-- 确认数据能够完整往返
SELECT payload FROM raw_events WHERE id = 1
-- 验证在需要时仍可临时解析字段
SELECT JSONExtractString(payload, 'type') AS event_type FROM raw_events注意事项
- 如果需求发生变化,后续需要字段级查询,就得使用类型化列或 JSON 列新建一张表,再对数据进行回填。如果你有任何可能需要查询单个字段的情况,建议一开始就改用混合方案。
JSONExtract函数会在每次查询时解析字符串。用于临时探索还可以,但不适合生产环境中的仪表盘或高 QPS 工作负载。- 如果 JSON 载荷较大,可考虑对 String 列使用压缩编解码器 (
ZSTD) ——它的压缩效果很好。
对比
| 维度 | 类型化列 | 混合方案 | 原生 JSON | String |
|---|---|---|---|---|
| 写入吞吐量 | 最快 | 快 | 中等 | 最快 |
| 字段级查询 | 最快 | 快 (类型化) ;良好 (带 hint 的 JSON) | 良好 (带 hint) ;较慢 (Dynamic) | 慢 (运行时解析) |
| 整体对象读取 | 快 | 中等 | 慢 | 最快 |
| 存储效率 | 最佳 | 良好 | 中等 | 良好 (压缩效果好) |
| schema 灵活性 | 无 (ALTER TABLE) |
部分 (核心固定,尾部灵活) | 完全 | 完全 |
| 复杂度 | 低 | 中 | 中等偏高 | 低 |
何时更适合使用 Map
如果你的动态字段是同类型的键值对——也就是所有值都属于同一种类型——那么 Map(String, T) 会比 JSON 列更简单、更高效。常见示例包括:字符串标签 (Map(String, String)) 、数值指标 (Map(String, Float64)) 和功能开关 (Map(String, Bool)) 。
CREATE TABLE tagged_events
(
`timestamp` DateTime,
`service` LowCardinality(String),
`tags` Map(String, String) -- e.g., {"env": "prod", "region": "us-east-1", "team": "platform"}
)
ENGINE = MergeTree
ORDER BY (service, timestamp)Map 支持键级筛选 (tags['env'] = 'prod') ,存储成本低于 JSON,并且避免了 JSON 类型的子列开销。请注意,默认情况下,按键查找会线性扫描整个 Map——对于较小的标签集这完全没问题,但如果 Map 包含 100+ 个键,建议考虑使用with_buckets serialization。当值包含混合类型或结构存在嵌套时,请使用 JSON;当数据是扁平的键值对且值类型统一时,请使用 Map。
- 在适当情况下使用 JSON — 何时使用 JSON 列类型而非其他方案
- JSON 数据类型参考 — 类型提示、SKIP、max_dynamic_paths 和内部信息函数的完整语法
- 选择数据类型 — 数据类型选择的一般指导
- A New Powerful JSON Data Type for ClickHouse — 深入解析 JSON 类型的存储架构
- JSON 格式参考 — JSON 数据的输入/输出格式 (JSONEachRow、JSONAsObject 等)