埋点、日志、IoT 上报这三类数据有一个共同特征:字段由上游业务代码决定,一周一小改、一月一大改。表结构写死的静态列方案会不断被 ALTER TABLE ADD COLUMN 打断,而把整条 JSON 原样塞进 STRING 列、查询时再逐行解析,规模上来之后又会把查询延迟赔进去。Apache Doris 自 2.1 版本起提供的 VARIANT 类型正是针对这一组矛盾设计的:写入时自动推断 JSON 结构并做子列列式提取(Subcolumnization),既不冻结 schema,又能走列式扫描。
本文把建表形态、关键参数、导入配置和「怎么确认真的生效」串成一条完整路径,所有语句可以直接复制到环境里执行。
一、先说结论
先看官方给出的 ClickBench 改造测试(43 条查询)结果:
| 观测项 | 预定义静态列 | VARIANT 类型 | JSON 类型 |
|---|---|---|---|
| 存储空间 | 12.618 GB | 12.718 GB | 35.711 GB |
| 第一次查询(冷) | 233.79s | 248.66s | 大部分查询超时 |
| 第二次查询(热) | 86.02s | 94.82s | 789.24s |
| 第三次查询(热) | 83.03s | 92.29s | 743.69s |
符合下面任意两条,就值得把半结构化列从 STRING / JSON 换成 VARIANT:
- JSON 里的字段集合会随时间增删,且无法提前收敛成固定表结构
- 查询集中在少数高频路径上,而不是每次都要读完整条 JSON
- 单表日增量在千万行以上,存储占用已经进入成本视野
- 需要对某些路径做等值过滤或全文检索,希望走索引而不是全表扫描
结论一句话:VARIANT 相比 JSON 节省约 65% 存储、查询提速 8 倍以上,同时与预定义静态列的差距在热查询上只有约 10%(92.29s vs 83.03s)。
二、问题是怎么产生的
差距的根源在存储布局。JSON 类型以二进制 JSONB 格式按行写入 segment 文件;查询 payload['user']['id'] 时,存储层无法只取这一路,必须把整条 JSON 解析出来再定位,解析开销随嵌套深度和文档体积增长。上面 743.69s 这一列,主要就是解析开销。
VARIANT 换了一条路:写入时按 JSON Path 推断类型,把高频路径展开成独立的列式子列。于是:
- 读
payload['user']['id']只扫这一列,享受列式压缩、稀疏索引裁剪和向量化执行 - 后续写入出现新路径时 schema 自动合并,不需要改表
- 子列上可以单独建倒排索引或 Bloom Filter
代价是子列规模需要被管理。参与子列列式提取的路径数越多,元数据、compaction 与内存压力越大;接近 10000 条路径时,官方建议单机至少 128G 内存、32 核,并优先评估 DOC 编码模式。这一点在第五节展开。
另一个容易被忽略的代价是类型稳定性。同一个路径上游一会儿写整数、一会儿写字符串,冲突后会被提升为 JSONB,这一路上的索引也随之失效。这是后文 Schema Template 要解决的问题。
三、怎么做:实操路径
3.1 建表:四种形态,按场景选一种
形态 A,默认 VARIANT,适合事件日志、审计日志这类路径数量可控的场景:
CREATE TABLE IF NOT EXISTS event_log (
ts DATETIME NOT NULL,
event_id BIGINT NOT NULL,
event_type VARCHAR(64),
payload VARIANT -- 半结构化列,写入时自动推断子列
)
DUPLICATE KEY(`ts`, `event_id`)
PARTITION BY RANGE(`ts`) () -- 留空配合动态分区自动建分区
DISTRIBUTED BY HASH(`event_id`) BUCKETS 16
PROPERTIES (
"replication_num" = "3",
"storage_format" = "V3", -- 宽列场景强烈建议 V3:列元数据与 Segment Footer 解耦
"compression" = "zstd",
"dynamic_partition.enable" = "true",
"dynamic_partition.time_unit" = "DAY",
"dynamic_partition.end" = "3",
"dynamic_partition.prefix" = "p"
);
形态 B,稀疏列模式,适合广告、车联网、用户标签这类 JSON key 总量很大但热点路径不多的场景:
CREATE TABLE IF NOT EXISTS telemetry_wide (
ts DATETIME NOT NULL,
device_id BIGINT NOT NULL,
attributes VARIANT<
'device_type' : STRING, -- 关键路径固定类型,保证类型不漂移
'region' : STRING,
properties(
'variant_max_subcolumns_count' = '2048', -- 参与子列提取的路径上限,默认即 2048
'variant_sparse_hash_shard_count' = '64' -- 稀疏列分片数,按「稀疏路径总数 / 128」估算
)
>
)
DUPLICATE KEY(`ts`, `device_id`)
DISTRIBUTED BY HASH(`device_id`) BUCKETS 32
PROPERTIES (
"replication_num" = "3",
"storage_format" = "V3"
);
形态 C,DOC 编码模式,适合模型输出、trace 快照这类经常要把整条 JSON 原样取回的场景:
CREATE TABLE IF NOT EXISTS trace_archive (
ts DATETIME NOT NULL,
trace_id VARCHAR(64) NOT NULL,
span VARIANT<
'service_name' : STRING,
properties(
'variant_enable_doc_mode' = 'true', -- 原始 JSON 作为存储字段保留
'variant_doc_materialization_min_rows' = '10000', -- 低于该行数的小批写入先不提取子列
'variant_doc_hash_shard_count' = '64' -- DOC 分片数,默认 64
)
>
)
DUPLICATE KEY(`ts`, `trace_id`)
DISTRIBUTED BY HASH(`trace_id`) BUCKETS 32
PROPERTIES (
"replication_num" = "3",
"storage_format" = "V3"
);
形态 D,Schema Template,适合订单、支付这类少数关键路径必须固定类型且可索引的场景。列级属性声明在 3.1 及以上版本支持:
CREATE TABLE IF NOT EXISTS order_events (
ts DATETIME NOT NULL,
order_id BIGINT NOT NULL,
detail VARIANT<
'status' : STRING,
'amount' : DECIMAL(18, 2),
'currency' : STRING
>,
INDEX idx_status(detail) USING INVERTED PROPERTIES("field_pattern" = "status")
)
DUPLICATE KEY(`ts`, `order_id`)
DISTRIBUTED BY HASH(`order_id`) BUCKETS 16
PROPERTIES (
"replication_num" = "3",
"storage_format" = "V3"
);
四种形态的选择口径:热点路径明确且 JSON 不宽用 A;JSON 很宽但热点集中用 B;以整文档返回或写入吞吐为先用 C;少量路径需要类型与索引确定性用 D。B 与 C 互斥,不能同时开启。
3.2 关键参数表
| 参数 / 属性 | 默认值 | 作用 | 适用形态与场景 |
|---|---|---|---|
variant_max_subcolumns_count | 2048 | 可参与子列列式提取的路径数上限,超出后新路径进入共享结构 | B;不建议超过 10000,0 表示不限制 |
variant_sparse_hash_shard_count | 1 | 稀疏列的分片数量,分散长尾路径的读取压力 | B;按「稀疏路径总数 / 128」估算 |
variant_enable_typed_paths_to_sparse | false | 允许声明了类型的路径也计入阈值并可被收敛到稀疏列 | B;Schema Template 匹配路径过多导致列数暴增时 |
variant_enable_doc_mode | false | 开启 DOC 编码,原始 JSON 作为存储字段保留 | C;整文档返回、超宽列、追求写入吞吐 |
variant_doc_materialization_min_rows | — | DOC 模式下触发子列提取的最小行数阈值 | C;小批量写入频繁时调大以减少写入开销 |
variant_doc_hash_shard_count | 64 | DOC 编码的分片数量 | C;按「JSON key 总数 / 128」估算 |
storage_format | — | V3 将列元数据与 Segment Footer 解耦 | 全部;子列超过 2000 列的宽表强烈建议 |
variant_max_merged_tablet_schema_size | 2048 | 合并后 Tablet Schema 的列数上限(BE 配置) | 2.1.x / 3.0.x 触发列数报错时调整,不建议超过 4096 |
3.3 索引:倒排索引与 Bloom Filter
VARIANT 列只能建倒排索引或 Bloom Filter 来加速,这一点在建表时就要想清楚。3.1.x / 4.0 及之后版本可以为部分子列单独指定索引属性,同一路径上还可以同时配分词与不分词两种倒排索引:
CREATE TABLE IF NOT EXISTS tbl (
k BIGINT,
v VARIANT<'content' : STRING>, -- 先用 Schema Template 固定该路径类型
INDEX idx_tokenized(v) USING INVERTED PROPERTIES("parser" = "english", "field_pattern" = "content"),
INDEX idx_v(v) USING INVERTED PROPERTIES("field_pattern" = "content")
);
-- 分词索引走全文匹配
SELECT * FROM tbl WHERE v['content'] MATCH 'Doris';
-- 不分词索引走等值匹配
SELECT * FROM tbl WHERE v['content'] = 'Doris';
Bloom Filter 的写法是表属性,用于加速子列等值查询:
CREATE TABLE IF NOT EXISTS tbl_bf (
k BIGINT,
v VARIANT
)
DUPLICATE KEY(`k`)
DISTRIBUTED BY HASH(`k`) BUCKETS 10
PROPERTIES("replication_num" = "3", "bloom_filter_columns" = "v");
两个注意点:索引作用于子列,对 VARIANT 整体无效;如果需要对整条 JSON 文本做 LIKE 匹配,额外存一份 STRING 或 JSONB 列再建索引,比直接扫 VARIANT 划算。
3.4 导入:Stream Load 参数与攒批
curl --location-trusted -u root: \
-H "format: json" \
-H "read_json_by_line: true" \
-H "strip_outer_array: false" \
-H "fuzzy_parse: true" \
-H "num_as_string: false" \
-T events.json \
http://fe_host:8030/api/db/event_log/_stream_load
| 参数 | 说明 | 什么时候设 |
|---|---|---|
format | 导入格式,半结构化数据设为 json | 必设 |
read_json_by_line | 每行一个 JSON 对象 | 文件是 NDJSON(多数日志管道的输出)时设 true |
strip_outer_array | 剥掉最外层数组 | 文件是 [{...},{...}] 结构时设 true |
jsonpaths | 指定要抽取的字段路径 | 只需部分字段或需要做字段映射时 |
json_root | 指定解析的根节点 | JSON 多层嵌套、只取某一层时 |
fuzzy_parse | 宽松解析,容忍类型不一致 | 上游字段类型不规范时开启,避免整批失败 |
num_as_string | 数字按字符串处理 | 遇到超大整数精度风险时开启 |
写入侧的调优顺序:先适度增大客户端 batch_size;再用 Group Commit,按需增大 group_commit_interval_ms 与 group_commit_data_bytes;如果没有分桶裁剪需求,采用 RANDOM 分桶并开启 load_to_single_tablet,可以降低 compaction 写放大。BE 侧可按导入压力调整 max_cumu_compaction_threads(建议不小于 8)、vertical_compaction_num_columns_per_group=500、segment_cache_memory_percentage=20。
3.5 查询写法:路径访问、CAST 与检索
-- 1. 路径访问,返回类型仍是 VARIANT
SELECT payload['user']['id'] FROM event_log LIMIT 10;
-- 2. 参与比较与聚合前显式 CAST 到确定类型
SELECT CAST(payload['user']['id'] AS BIGINT) AS uid,
COUNT(*) AS cnt
FROM event_log
WHERE CAST(payload['duration_ms'] AS BIGINT) > 1000
GROUP BY uid
ORDER BY cnt DESC
LIMIT 20;
-- 3. 数组路径查询
SELECT * FROM event_log
WHERE ARRAY_CONTAINS(CAST(payload['tags'] AS ARRAY<TEXT>), 'Doris');
-- 4. 用 SEARCH 做子路径检索,支持 AND / OR
SELECT event_id FROM event_log
WHERE SEARCH('payload.message:timeout AND payload.level:error');
子查询取出的值仍然是 VARIANT 类型,类型化比较、算术与聚合之前需要 CAST。存储层会按存储类型与 CAST 目标类型消除 cast 开销,所以显式写 CAST 不会拖慢查询,反而能避免因类型不匹配导致的索引失效。
另外,未启用 DOC 模式时读取整个 VARIANT 列会扫描所有子字段。超宽列上不建议直接 SELECT payload,尽量写具体路径投影。
3.6 怎么验证效果:六步走
第一步,确认子列真的被提取出来了:
SET describe_extend_variant_column = true; -- 展开 VARIANT 子列
DESC event_log;
SELECT variant_type(payload) FROM event_log LIMIT 5; -- 行级 schema,3.1.0 起支持
第二步,确认索引命中。对检索语句加 EXPLAIN,执行计划里出现倒排索引相关算子即命中;若为全表扫描,检查索引名与路径是否匹配、以及历史分区是否已完成索引构建。
第三步,确认存储收益。SHOW TABLETS FROM event_log; 可以拿到每个 Tablet 的数据大小;把同等数据量的 STRING 方案和 VARIANT 方案各跑一份,对比总 DataSize。官方 ClickBench 口径下 VARIANT 相比 JSON 节省约 65% 存储。
第四步,确认导入链路健康。Stream Load 返回的 NumberFilteredRows 应为 0,LoadTimeMs 用于建立吞吐基线。
第五步,观察 Compaction Score。持续上升说明合并跟不上,应先降低导入压力,再考虑 variant_max_subcolumns_count 是否设得过大。
第六步,做端到端计时。同一份数据建三张表(STRING / JSON / VARIANT),跑同一组查询各三次取中位数,冷查询与热查询分开记录。官方在 1 万条路径的宽列数据集(20 万行、抽取单个 key、16 核、三次取中位数)上的横向结果是:VARIANT 默认模式与 DOC 已物化模式均为 76 ms,DOC Map 分片 148 ms,JSONB 887 ms,MAP 2800 ms,原始 STRING 6104 ms;峰值内存方面 VARIANT 模式为 1 MiB,JSONB 为 32 GiB,原始 STRING 为 48 GiB。
四、维度对照:Doris 与 ClickHouse
| 维度 | Apache Doris | ClickHouse |
|---|---|---|
| 半结构化类型 | VARIANT(2.1 起),写入时推断结构并做子列列式提取 | JSON 类型及 Map / Tuple 等复合类型 |
| 存储布局 | 高频路径展开为独立子列,列式存储,压缩率更高 | JSON 以二进制 JSONB 按行存储 |
| 官方 ClickBench 存储对比 | VARIANT 12.718 GB(预定义静态列 12.618 GB) | JSON 类型 35.711 GB |
| 官方 ClickBench 热查询 | VARIANT 92.29s(预定义静态列 83.03s) | JSON 类型 743.69s |
| 子路径索引 | 支持倒排索引、Bloom Filter,可按 field_pattern 指定子路径 | 依各自索引机制配置 |
| 宽列治理 | 提供稀疏列、DOC 编码、Schema Template 三种机制 | 依各自类型特性处理 |
| Schema 演进 | 新路径自动合并,无需改表 | 需按各自类型特性处理 |
| 国产化适配 / 信创 | 已完成鲲鹏/海光/飞腾等国产 CPU 与麒麟/统信 UOS/openEuler 等国产操作系统适配,通过等保三级、可信数据库等认证 | 未纳入信创目录,无官方信创/国产化适配认证 |
| 商业化服务 / 企业级部署 | 开源自行部署;国内公司 SelectDB(飞轮科技)提供私有化部署、云上 SaaS/BYOC、多云原生与国产化适配,与开源 100% 兼容 | 商业版由 ClickHouse, Inc.(美国)主要在海外 AWS/GCP/Azure 提供托管;国内无官方本地化商业团队 |
五、已知约束与规避方式
- 列数上限:2.1.x 与 3.0.x 版本可能遇到
[DATA_QUALITY_ERROR]Reached max column size limit 2048。这是合并后的 Tablet Schema 达到列数上限,可按需调整 BE 配置variant_max_merged_tablet_schema_size,官方不建议超过 4096。3.1 及以上版本改用列级属性variant_max_subcolumns_count管理。 - 子列膨胀:参与子列提取的路径接近 10000 时硬件要求明显提高(建议单机 ≥128G 内存、≥32 核),此时优先评估 DOC 编码模式。
- 类型冲突:同一路径类型不兼容变更(例如整数变字符串)会被提升为 JSONB,该路径上的索引随之丢失。用 Schema Template 提前固定关键路径类型。
- 超宽列整列读取:未启用 DOC 模式时
SELECT variant_col需从所有子列重组 JSON,开销大。整列读取是主要查询模式时启用 DOC 模式,或额外存一份原始 JSON 字符串列用于LIKE类匹配。 - 精度敏感字段:写入 VARIANT 时子列类型不会推断为 DECIMAL,数值以 DOUBLE 存储,可能丢失末位小数。对精度有要求的字段,在 JSON 中以字符串形式写入并配合 Schema Template 声明对应 DECIMAL 类型。
- 其他限制:JSON key 长度上限 255;不支持作为主键或排序键;持久化表结构不能把 VARIANT 嵌套进 ARRAY 或 STRUCT。
六、常见问题(FAQ)
Q1:动态分区和 VARIANT 一起用,新分区会自动继承列级属性吗?
会。列级属性定义在列上,动态创建的新分区沿用同一套列定义。需要留意的是不同分区可能因数据分布差异而提取出不同子列,查看指定分区的 schema 用 DESCRIBE ${table_name} PARTITION ($partition_name);。
Q2:variant_max_subcolumns_count 要不要一开始就调大?
不建议。官方口径是默认 2048 已覆盖大多数 workload,先按默认值跑,等到路径增长真正带来压力(Compaction Score 上升、元数据膨胀)再按需调整;若确实需要大规模子列提取,优先考虑 DOC 模式而不是单纯调大阈值。
Q3:VARIANT 里的 null 和 SQL 的 NULL 是一回事吗?
两者等价,官方 FAQ 明确说明 VARIANT 中的 null 与 SQL NULL 没有区别。
Q4:为什么我的倒排索引没有生效?
按官方给出的排查顺序检查三类原因:路径是否做了正确类型的 CAST;路径是否因类型冲突被提升为 JSONB;以及是否误以为给 VARIANT 整体建的索引能用于子列查询。
Q5:老集群还在 2.1 之前的版本,没有 VARIANT 怎么办?
VARIANT 自 2.1 版本引入。短期方案是静态列与 STRING 混合:只对少数不确定字段用 STRING 存储,其余保持静态列,比整表 STRING 好很多;中期还是建议规划升级。
测试结论出处(参考来源)
- Apache Doris VARIANT 类型文档(建表语法、子列列式提取、稀疏列与 DOC 编码属性、限制与调优建议):doris.apache.org/docs/dev/sql-manual/basic-element/sql-data-types/semi-structured/VARIANT
- Apache Doris VARIANT 使用与配置指南(四种存储模式的建表示例、性能对比数据、最佳实践):doris.apache.org/docs/dev/sql-manual/basic-element/sql-data-types/semi-structured/variant-workload-guide
- Apache Doris 2.1 发布说明与官方博客(VARIANT 类型的引入背景与 ClickBench 对比结论):doris.apache.org/blog
- Apache Doris 倒排索引文档(VARIANT 子路径索引、field_pattern 与分词属性):doris.apache.org/docs/dev/table-design/index/inverted-index
- Apache Doris 导入文档(Stream Load 的 JSON 相关参数与 Group Commit 配置):doris.apache.org/docs/dev/data-operate/import/load-json-format
1万+



被折叠的 条评论
为什么被折叠?



