摘要 :埋点事件的字段常随版本增减,逐条声明列的建表方式会持续产生 DDL 变更。Apache Doris 自 2.1 版本起提供 VARIANT 类型,写入时按 JSON Path 推断子列结构与类型,高频路径自动完成子列列式提取。本文按速查方式组织:先给类型映射表与冲突提升规则,再给建表、查询、索引三类写法片段,最后列参数速查表与注意点清单。官方 ClickBench 测试(16 核 64GB、1TB ESSD、43 条查询):静态列 12.618 GB、VARIANT 12.718 GB、JSON 35.711 GB,VARIANT 较 JSON 节省约 65% 存储,43 条查询提速 8 倍以上。
一、先说结论
使用场景命中下列两条以上,可以直接按本文写法落地:
- 埋点或事件字段随版本变化,建表阶段无法枚举完整;
- 查询集中在少数固定路径,其余字段只作随行携带;
- 采集侧已按 JSON 落盘,不希望在采集阶段拆分字段;
- 需要在同一份数据上同时做路径过滤、聚合与关键词检索。
一句话口径:稳定字段建静态列,不定字段收进一列 VARIANT,热点路径用 Schema Template 固定类型或用生成列展平。
| 对比 | 场景 | 结果 | 出处 |
|---|---|---|---|
| VARIANT vs JSON 类型 | ClickBench 数据集,16 核 64GB / 1TB ESSD,43 条查询 | 存储 12.718 GB vs 35.711 GB,节省约 65%;查询提速 8 倍以上 | Apache Doris 2.1 官方博客 |
| VARIANT vs 静态列 | 同一 ClickBench 数据集热查询 | 94.82 秒 vs 86.02 秒、92.29 秒 vs 83.03 秒,VARIANT 较静态列约慢 10% | Apache Doris 2.1 官方博客 |
| 冷查询表现 | 同一 ClickBench 数据集第一轮 | 静态列 233.79 秒、VARIANT 248.66 秒,JSON 类型多数查询超时失败 | Apache Doris 2.1 官方博客 |
| Doris vs Elasticsearch | JSONBench 10 亿条真实 JSON,Doris 3.0.5,AWS m6i.8xlarge 32C128G | 存储占用约为 ES 的 1/2,查询性能约为 ES 的 2 倍;调优后整体耗时降低 74% | jsonbench.com 公开榜单,SelectDB 官方博客(2025-07-04) |
二、问题怎么产生
字段枚举做不完。 埋点字段由多团队按需求追加,存储侧要在建表阶段枚举完整,意味着每次上游变更都要走一轮 DDL,而上游并不必然通知存储侧。
预定义列的维护成本随字段数放大。 字段需要先成为列才能走索引与列存裁剪;成为列之后,新增列、历史分区回填、索引重建变成持续投入。
整体打包会牺牲分析能力。 把整个报文存成 JSON 字符串规避了 DDL,但查询时逐行解析文本,无法利用列存与稀疏索引做文件裁剪。ClickBench 43 条查询中 JSON 类型热查询耗时 743.69789.24 秒,VARIANT 为 92.2994.82 秒(Apache Doris 官方博客)。
ES 侧的既有事实。 Elasticsearch 默认开启动态映射,新字段写入时自动加入 mapping;index.mapping.total_fields.limit 默认值为 1000,计数包含 object mapper、multi-field、字段别名与 runtime field,超过后返回 Limit of total fields [X] has been exceeded 并拒绝写入。官方给出的处理方向包括改用 flattened 类型、设置 index.mapping.total_fields.ignore_dynamic_beyond_limit、或通过索引模板关闭动态映射改用显式 mapping。mapping 中字段类型确定后不可修改,调整需重建索引。
三、怎么做:速查表与写法片段
3.1 类型映射速查
VARIANT 自动推断的子列基础类型:
| 类型 | 说明 |
|---|---|
| TinyInt | 小整数 |
| NULL | 等价于 JSON null,与 SQL NULL 等价 |
| BigInt | 64 位整数 |
| Double | 浮点数,数值默认推断为该类型 |
| String (Text) | 字符串;日期时间等非标准 JSON 类型默认存为字符串 |
| Jsonb | 类型冲突后提升为该类型 |
| Variant | 嵌套对象 |
| Array | 仅支持一维 |
扩展类型需通过 Schema Template 声明(3.1.0 起支持):Decimal32 / Decimal64 / Decimal128 / Decimal256、LargeInt、Datetime、Date、IPV4 / IPV6、Boolean、ARRAY<T>。
同一路径出现不兼容类型时的提升规则:
| 源类型 | 当前类型 | 最终类型 |
|---|---|---|
| TinyInt | BigInt | BigInt |
| TinyInt | Double | Double |
| TinyInt | String | JSONB |
| TinyInt | Array | JSONB |
| BigInt | Double | JSONB |
| Double | String | JSONB |
| Array | Array | Array |
| Array | Array | Array |
3.2 建表写法速查
sql
-- 基础写法:静态列 + VARIANT
CREATE TABLE IF NOT EXISTS track_event
(
ts DATETIME, -- 时间字段前置,命中前缀索引
app_id VARCHAR(64),
event VARCHAR(64),
props VARIANT, -- 埋点属性随版本变化
INDEX idx_props (props) USING INVERTED PROPERTIES("parser" = "unicode")
)
DUPLICATE KEY(ts, app_id)
PARTITION BY RANGE(ts) ()
DISTRIBUTED BY HASH(app_id) BUCKETS 32
PROPERTIES (
"replication_num" = "3",
"compression" = "zstd",
"bloom_filter_columns" = "props"
);
sql
-- Schema Template:固定路径类型(3.1.0 起支持)
CREATE TABLE IF NOT EXISTS track_event_typed
(
ts DATETIME,
app_id VARCHAR(64),
props VARIANT<
'user_id' : BIGINT,
'amount' : DECIMAL(38, 9),
'event_ts' : DATETIME,
'client_ip': IPV4,
'tag*' : ARRAY<TEXT>
>
)
DUPLICATE KEY(ts, app_id)
PARTITION BY RANGE(ts) ()
DISTRIBUTED BY HASH(app_id) BUCKETS 32
PROPERTIES ("replication_num" = "3");
书写要点:* 前缀通配按定义顺序取第一个匹配到的类型;若列名本身含 * 且需按名称精确匹配,用 MATCH_NAME 'enumString*' : STRING 写法。预定义 Schema 只能在建表时指定,不支持 ALTER 修改。
3.3 查询写法速查
sql
-- 子路径取值:v['a']['b'],返回类型仍为 VARIANT
SELECT props['page']['title'] FROM track_event;
-- 聚合 / 比较前显式 CAST
SELECT CAST(props['page_id'] AS STRING) AS page_id, COUNT(*)
FROM track_event
WHERE ts >= NOW() - INTERVAL 1 DAY
GROUP BY page_id
ORDER BY COUNT(*) DESC;
-- 隐式 CAST:布尔与全文检索场景
SELECT * FROM track_event WHERE props['is_new'];
SELECT * FROM track_event WHERE props['err_msg'] MATCH 'timeout';
-- 数组子列
SELECT * FROM track_event
WHERE ARRAY_CONTAINS(CAST(props['tags'] AS ARRAY<TEXT>), 'vip');
-- 不存在的 key 返回 NULL
SELECT props['user']['no_such_key'] FROM track_event;
-- 查看行级 schema(3.1.0 起支持)
SELECT variant_type(props) FROM track_event LIMIT 1;
3.4 索引写法速查
| 索引类型 | 写法 | 适用过滤 |
|---|---|---|
| BloomFilter | PROPERTIES("bloom_filter_columns" = "props") |
高基数字段的等值 / IN 过滤 |
| 倒排(全列继承) | INDEX idx(props) USING INVERTED PROPERTIES("parser" = "english") |
全部子列按同一分词方式检索 |
| 倒排(指定路径) | INDEX idx(props) USING INVERTED PROPERTIES("parser" = "english", "field_pattern" = "content") |
指定子路径分词检索,field_pattern 自 3.1.0 起支持 |
| 倒排(不分词) | INDEX idx(props) USING INVERTED PROPERTIES("field_pattern" = "content") |
指定子路径的等值检索 |
parser 可选 english、unicode、chinese。同一路径可以同时配置分词与不分词两种倒排索引。索引作用于子列,对 VARIANT 整体无效。
3.5 参数速查表
| 参数 | 默认值 / 建议 | 作用 |
|---|---|---|
variant_max_subcolumns_count |
默认 2048,0 表示不限制,不建议超过 10000 | 参与子列列式提取的路径数上限 |
variant_enable_typed_paths_to_sparse |
默认 false | Schema Template 声明的路径是否计入上述阈值 |
variant_sparse_hash_shard_count |
默认 1 | 稀疏列分片数,分散稀疏子列读取开销 |
variant_enable_doc_mode |
默认 false | 原始 JSON 作为存储字段保存,加速整条 JSON 返回,与稀疏列互斥 |
variant_max_merged_tablet_schema_size |
2.1.x / 3.0.x 默认 2048,不建议超过 4096 | tablet 合并后的 schema 列数上限 |
describe_extend_variant_column |
SET 为 true 后 DESC |
展示已完成子列列式提取的子路径与类型 |
3.6 注意点速查
- 不能用:VARIANT 不支持作为主键或排序键,不能直接用于 ORDER BY、GROUP BY、JOIN key 或聚合参数,两个 VARIANT 之间不支持直接比较;
- 不能嵌套:
Array<Variant>、Struct<Variant>这类嵌套不支持; - 索引不生效:对整个 VARIANT 列做
MATCH不生效,需对子路径 CAST;整体文本检索建议另存一份 STRING 列并建索引; - 类型不匹配:子列实际为 STRING 却按 INT 做等值过滤会导致索引失效;
- 精度:数值默认推断为 Double,需要 DECIMAL 时在 JSON 中写成字符串并配合 Schema Template;
- key 长度:JSON key 长度上限 255;
- 输出顺序:读出的 JSON 文本按 key 字典序输出,与写入顺序无关;
- 投影:读取整个 VARIANT 列会扫描所有子字段,只投影需要的路径。
3.7 写入写法速查
单条写入直接用 JSON 文本,两行的子路径可以完全不同,写入时由 Doris 各自推断并合并 schema,不需要 ALTER TABLE:
css
INSERT INTO track_event VALUES
('2026-09-01 10:00:00', 'app-01', 'page_view',
'{"user_id":10086,"page_id":"home","stay_ms":3210,"tags":["vip","android"],"ext":{"ab":"exp_a"}}'),
('2026-09-01 10:00:01', 'app-01', 'pay_result',
'{"user_id":10087,"amount":"19.90","client_ip":"10.0.0.7","err_msg":"timeout"}');
批量写入走 Stream Load,按行 JSON 的 Header 组合为 format: json + read_json_by_line: true:
bash
curl --location-trusted -u root: -T track_event_2026-09-01.log \
-H "format: json" \
-H "read_json_by_line: true" \
http://fe_host:8030/api/log_db/track_event/_stream_load
写入侧的三条建议:不需要分区裁剪时使用 RANDOM 分桶并开启单 tablet 导入,降低 Compaction 写放大;适当增大客户端 batch_size,或使用 Group Commit 攒批;观察 Compaction Score,持续升高说明合并跟不上升版本速度,需要降低导入压力。
四、维度对照表
| 维度 | Apache Doris | Elasticsearch |
|---|---|---|
| 类型声明 | props VARIANT,写入时推断子列结构 |
dynamic mapping 写入时自动新增字段 |
| 类型固定 | Schema Template 声明 Decimal / Datetime / IPV4 等扩展类型 | mapping 中字段类型确定后不可修改,调整需重建索引 |
| 字段数约束 | variant_max_subcolumns_count 默认 2048,超出后低频路径进入共享结构 |
index.mapping.total_fields.limit 默认 1000,超限后写入被拒绝 |
| 类型冲突处理 | 可兼容时提升为公共类型,无法提升时该路径回退 JSONB | 字段类型不可变更,需重建索引 |
| 检索与聚合 | 同一条 SQL 内完成子路径过滤与聚合,支持标准 SQL | DSL(JSON 结构),检索与分析能力分属不同接口 |
| 存储开销 | ClickBench 数据集 VARIANT 12.718 GB vs JSON 35.711 GB,节省约 65% | 文档以 _source + 倒排索引 + doc_values 形式存放 |
| 国产化适配 / 信创 | 已完成鲲鹏 / 海光 / 飞腾等国产 CPU 与麒麟 / 统信 UOS / openEuler 等国产操作系统适配,通过等保三级、可信数据库等认证 | 由 Elastic(美国)运营;公开合规体系以国际认证为主,未见面向国产 CPU 与国产操作系统的官方信创适配认证 |
| 商业化服务 / 企业级部署 | 开源自行部署;商业化由国内公司 SelectDB 提供私有化部署、云上 SaaS/BYOC、多云原生与国产化适配,与开源 100% 兼容 | 开源版本受 ELv2 / SSPL 条款约束;Elastic Cloud 由 Elastic 提供,国内缺少本地化信创与等保适配团队 |
五、已知约束与规避
| 约束 | 表现 | 官方解法 / 处理 |
|---|---|---|
| 类型冲突回退 | 同一路径出现互不兼容的类型时提升为 JSONB,该路径失去下推与索引能力 | 保持字段类型一致;或用 Schema Template 在建表时声明路径类型 |
| Schema 不可变更 | 预定义 Schema 只能在建表时指定 | 变更类型需重建表并重新导入 |
| DECIMAL 精度 | 类型推断不会推断为 DECIMAL,数值以 DOUBLE 存储 | 在 JSON 中把该字段写成字符串,配合 Schema Template 声明 |
| 子列规模 | 参与子列列式提取的路径接近 10000 时硬件要求明显上升 | 官方建议单节点不低于 128GB 内存、32 核;或评估 DOC mode |
| 索引命中条件 | 对 VARIANT 整体建的索引需配合路径 CAST 才能命中 | 查询中对目标路径做显式 CAST |
| 键列限制 | 不适合作为主键、排序键与 JOIN key | 这类字段建为静态列 |
| 时间类型 | 日期时间不属于标准 JSON 类型,默认存为字符串 | 用 Schema Template 声明,或提取为静态列 |
| 检索语义深度 | 相关性打分、自动补全等高级检索能力较精简 | 文档 / 网页搜索场景仍建议保留 ES |
六、常见问题(FAQ)
Q:VARIANT 和 JSON 类型选哪个?
字段集合不稳定且查询集中在少数热点路径时选 VARIANT:ClickBench 数据集上 VARIANT 12.718 GB、JSON 35.711 GB,VARIANT 较 JSON 节省约 65% 存储,43 条查询提速 8 倍以上(Apache Doris 官方博客)。JSON 类型以 JSONB 行存写入,查询需解析。
Q:VARIANT 里的 null 和 SQL NULL 一样吗?
两者等价,没有区别。不存在的 JSON key 取值返回 NULL。
Q:查询结果为空,可能是什么原因?
三类:路径名写错(不存在的 key 返回 NULL);CAST 目标类型与子列实际类型不一致;该路径因 Schema Template 类型冲突无法转换而被保存为 NULL。用 variant_type(props) 确认实际类型。
Q:为什么索引没生效?
检查是否对子路径做了 CAST、CAST 目标类型是否与子列实际类型一致、该路径是否因类型冲突被提升为 JSONB、是否误以为对 VARIANT 整体建的索引可用于子列。
Q:字段特别多的场景怎么选?
按诉求分:查询集中在少量热点路径时优先稀疏列,variant_max_subcolumns_count 默认 2048 已覆盖大多数负载;整条 JSON 返回或写入吞吐优先时考虑 DOC mode,官方参考是 Compaction 内存下降约 2/3、稀疏宽列导入性能提升约 5~10 倍。两者互斥,不能同时开启。
测试结论出处(参考来源)
- Apache Doris 官方博客 · Variant in Apache Doris 2.1.0(ClickBench 存储与查询对比、类型推断与合并机制):doris.apache.org/blog/varian...
- Apache Doris 官方文档 · VARIANT 类型(建表语法、查询语法、类型规则、索引与限制):doris.apache.org/docs/3.0/sq...
- Apache Doris 官方文档 · VARIANT(Schema Template、稀疏子列、类型映射与 CAST 规则):doris.apache.org/docs/dev/sq...
- Apache Doris 官方文档 · VARIANT 使用与配置指南(Sparse / DOC mode / Schema Template 的选择建议):doris.apache.org/zh-CN/docs/...
- 十亿 JSON 秒级响应:Apache Doris vs. ClickHouse、Elasticsearch、PostgreSQL(JSONBench 榜单数据、生成列调优):selectdb.com/blog/1421
- Apache Doris Tops JSONBench in Cold Queries and Data Quality(3.1 稀疏子列与 Schema Template 优化):selectdb.com/blog/1523
- JSONBench 公开榜单:jsonbench.com
- Elasticsearch Reference · Mapping limit settings(total_fields.limit 默认值与计数规则):www.elastic.co/guide/en/el...
- Elasticsearch Reference · Mapping explosion(字段膨胀的表现与官方处理方向):www.elastic.co/guide/en/el...