摘要 :网关访问日志的字段随版本增减时,预定义全部列的建表方式会持续产生 DDL 变更。Apache Doris 自 2.1 版本起提供 VARIANT 类型,写入时按 JSON Path 推断子列结构与类型,高频路径自动完成子列列式提取。本文给出一套可照抄的执行步骤:静态列与 VARIANT 混用的建表语句、Stream Load 导入 JSON Lines 的 Header 组合、按子路径取值与倒排检索的写法、Schema Template 与生成列两种子列物化方式,以及子列推断结果的核对命令。官方 ClickBench 测试(16 核 64GB、1TB ESSD、43 条查询):静态列 12.618 GB、VARIANT 12.718 GB、JSON 35.711 GB,VARIANT 较 JSON 节省约 65% 存储,43 条查询提速 8 倍以上。
一、先说结论
下列情形命中两条以上,可以按本文步骤做一轮配置验证:
- 网关或应用日志的字段集合随版本变化,建表阶段无法枚举完整;
- 查询集中在少数固定路径(如
status、upstream、region),其余字段只是随行携带; - 采集侧已按 JSON Lines 落盘,不希望在采集阶段做字段拆分与映射;
- 需要对部分路径同时做等值过滤与关键词检索;
- 已在使用 ES dynamic mapping,遇到单索引字段数上限或 mapping 规模持续增长。
公开口径的数字对照如下,均为公开基准实测值,不构成通用承诺:
| 对比 | 场景 | 结果 | 出处 |
|---|---|---|---|
| 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 数据集 | 存储 12.718 GB vs 12.618 GB,热查询 92.29~94.82 秒 vs 83.03~86.02 秒 | 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,但查询时需要逐行解析文本,无法利用列存的 ZoneMap 与稀疏索引做文件裁剪。ClickBench 43 条查询中,JSON 类型冷查询多数因超时或 OOM 失败,热查询耗时 743.69~789.24 秒,VARIANT 为 92.29~94.82 秒(Apache Doris 官方博客)。
ES 侧的既有事实。 Elasticsearch 默认开启动态映射,文档中出现的新字段会在写入时自动加入 mapping。为限制 mapping 膨胀,index.mapping.total_fields.limit 默认值为 1000,计数包含 object mapper、multi-field、字段别名与 runtime field;超过该值后 Elasticsearch 返回 Limit of total fields [X] has been exceeded 并拒绝写入。官方给出的处理方向包括改用 flattened 类型承载键集合不稳定的对象、设置 index.mapping.total_fields.ignore_dynamic_beyond_limit、或通过索引模板关闭动态映射改用显式 mapping。此外,mapping 中的字段类型确定后不可修改,类型调整需要重建索引。
三、怎么做:实操路径
3.1 环境与版本口径
示例参数取值均以下列环境为参照:
- 集群:Apache Doris 3.1,3 FE + 6 BE,FE HTTP 端口 8030,BE HTTP 端口 8040;
- 数据来源:网关按 JSON Lines 落盘的访问日志,稳定字段为
ts、host、method、uri、status,其余字段随版本变化; - 提交方式:Stream Load,HTTP PUT,
format: json+read_json_by_line: true; - 核对方式:
SET describe_extend_variant_column = true后执行DESC,配合variant_type()查看行级 schema。
3.2 建表:静态列与 VARIANT 混用
类型稳定、每次都查的字段建成静态列;结构不定的部分整体交给一列 VARIANT。时间字段放在首位,按时间范围的过滤可直接命中前缀索引。
sql
CREATE TABLE IF NOT EXISTS gw_access_log
(
ts DATETIME, -- 时间字段前置,按时间范围过滤命中前缀索引
host VARCHAR(128),
method VARCHAR(16),
uri VARCHAR(512),
status INT,
attrs VARIANT, -- 随版本变化的字段整体收下
INDEX idx_attrs (attrs) USING INVERTED PROPERTIES("parser" = "unicode")
COMMENT 'attrs 文本子列的全文检索'
)
DUPLICATE KEY(ts, host)
PARTITION BY RANGE(ts) ()
DISTRIBUTED BY HASH(host) BUCKETS 24
PROPERTIES (
"replication_num" = "3",
"compression" = "zstd",
"bloom_filter_columns" = "attrs",
"dynamic_partition.enable" = "true",
"dynamic_partition.time_unit" = "DAY",
"dynamic_partition.start" = "-7",
"dynamic_partition.end" = "3",
"dynamic_partition.prefix" = "p",
"dynamic_partition.buckets" = "24"
);
INDEX idx_attrs (attrs) USING INVERTED 建在 VARIANT 列上后,该列的全部子列继承相同的分词属性,parser 可选 english、unicode、chinese。bloom_filter_columns 指定 VARIANT 列用于加速子列的等值 / IN 过滤。
3.3 导入:JSON Lines 的 Header 组合
网关日志通常按行落盘为一条 JSON,Stream Load 按下列 Header 提交即可,字段名与列名一致的键会被直接写入对应列:
bash
curl --location-trusted -u root: -T gw_access_2026-09-01.log \
-H "format: json" \
-H "read_json_by_line: true" \
-H "max_filter_ratio: 0.1" \
http://fe_host:8030/api/log_db/gw_access_log/_stream_load
单条写入用于功能验证时直接用 JSON 文本,两行 attrs 的子路径可以完全不同,写入时由 Doris 各自推断并合并 schema,不需要 ALTER TABLE:
sql
INSERT INTO gw_access_log VALUES
('2026-09-01 10:00:00', 'api.example.com', 'GET', '/order/list', 200,
'{"upstream":"order-svc-7d9","rt_ms":37,"region":"cn-north","retry":0,"ua":"okhttp/4.9"}'),
('2026-09-01 10:00:01', 'api.example.com', 'POST', '/pay/create', 503,
'{"upstream":"pay-gw","rt_ms":1204,"err":"connection reset by peer","tags":["timeout","retry"]}');
3.4 查询:子路径取值、CAST 与倒排检索
子路径返回的仍是 VARIANT,聚合与比较前需要 CAST 到具体类型;谓词下推到存储层后,与 CAST 目标类型一致时使用类型化的子列做过滤。
sql
SELECT CAST(attrs['upstream'] AS STRING) AS upstream,
COUNT(*) AS req_cnt,
AVG(CAST(attrs['rt_ms'] AS DOUBLE)) AS avg_rt,
COUNT(DISTINCT CAST(attrs['region'] AS STRING)) AS region_cnt
FROM gw_access_log
WHERE ts >= NOW() - INTERVAL 6 HOUR
AND status >= 500
AND CAST(attrs['rt_ms'] AS DOUBLE) > 1000
GROUP BY upstream
ORDER BY req_cnt DESC;
数组子列取值后 CAST 为 ARRAY<TEXT>,再用 ARRAY_CONTAINS 过滤;文本子列走倒排索引用 MATCH;高基数字段的等值 / IN 过滤走 BloomFilter:
sql
-- 数组子列
SELECT ts, uri FROM gw_access_log
WHERE ARRAY_CONTAINS(CAST(attrs['tags'] AS ARRAY<TEXT>), 'timeout');
-- 文本子列全文检索
SELECT ts, uri, CAST(attrs['err'] AS STRING) AS err
FROM gw_access_log
WHERE CAST(attrs['err'] AS STRING) MATCH 'connection reset'
ORDER BY ts DESC
LIMIT 100;
-- 等值 / IN 过滤
SELECT * FROM gw_access_log WHERE attrs['upstream'] = 'pay-gw';
3.5 子列物化:Schema Template 与生成列
Schema Template 自 3.1.0 版本起支持,在建表时把指定路径固定为扩展类型,可声明 Decimal(Decimal32 / Decimal64 / Decimal128 / Decimal256)、LargeInt、Datetime、Date、IPV4 / IPV6、Boolean 与一维 ARRAY<T>,并支持 * 前缀通配:
sql
CREATE TABLE IF NOT EXISTS gw_access_log_typed
(
ts DATETIME,
host VARCHAR(128),
status INT,
attrs VARIANT<
'client_ip' : IPV4,
'amount' : DECIMAL(38, 9),
'event_time': DATETIME,
'tag*' : ARRAY<TEXT>
>,
INDEX idx_ip (attrs) USING INVERTED PROPERTIES("field_pattern" = "client_ip"),
INDEX idx_tags(attrs) USING INVERTED PROPERTIES("parser" = "english", "field_pattern" = "tag*")
)
DUPLICATE KEY(ts, host)
PARTITION BY RANGE(ts) ()
DISTRIBUTED BY HASH(host) BUCKETS 24
PROPERTIES ("replication_num" = "3");
同一路径可以同时配置分词与不分词两种倒排索引,field_pattern 指定目标子路径。若 JSON 实际类型与声明类型冲突且无法转换,该路径保存为 NULL。
少数路径承担绝大部分查询时,可用生成列把它们展平为静态列,展平后的列还能作为分区列或排序键使用:
sql
CREATE TABLE IF NOT EXISTS gw_access_log_flat
(
ts DATETIME,
upstream VARCHAR(100) GENERATED ALWAYS AS (get_json_string(attrs, '$.upstream')) NULL,
region VARCHAR(100) GENERATED ALWAYS AS (get_json_string(attrs, '$.region')) NULL,
attrs VARIANT NOT NULL
)
DUPLICATE KEY(ts, upstream)
PARTITION BY RANGE(ts) ()
DISTRIBUTED BY HASH(upstream) BUCKETS 24
PROPERTIES ("replication_num" = "3");
3.6 VARIANT 相关参数
| 参数 / 写法 | 建议取值 | 作用 | 适用场景 |
|---|---|---|---|
variant_max_subcolumns_count |
默认 2048,0 表示不限制,不建议超过 10000 | 控制参与子列列式提取的路径数上限,超出后低频路径进入共享结构 | 路径总数多、长尾占比高的可观测数据 |
variant_enable_typed_paths_to_sparse |
默认 false | 控制 Schema Template 声明的路径是否也计入上述阈值 | 模板路径多且多数低频 |
variant_sparse_hash_shard_count |
默认 1 | 控制稀疏列的分片数,把稀疏子列分散存储以提升查询性能 | 稀疏子列数量较多 |
variant_max_merged_tablet_schema_size |
2.1.x / 3.0.x 默认 2048,不建议超过 4096 | tablet 合并后的 schema 列数上限 | 导入报 Reached max column size limit |
bloom_filter_columns |
指定 VARIANT 列名 | 在该列上建 BloomFilter,加速子列等值 / IN 过滤 | trace_id、client_ip 这类高基数字段 |
INDEX ... USING INVERTED PROPERTIES("parser" = ...) |
english / unicode / chinese | VARIANT 列上的倒排索引,文本子列可被 MATCH 检索 | 需要对报文内容做关键词检索 |
3.7 验证步骤
第一步,查看已完成子列列式提取的子路径与类型:
sql
SET describe_extend_variant_column = true;
DESC gw_access_log;
DESCRIBE gw_access_log PARTITION (p20260901);
第二步,查看行级 schema,确认类型推断结果:
sql
SELECT variant_type(attrs) FROM gw_access_log LIMIT 10;
第三步,确认写入条数与过滤比例,再抽查子路径取值:
sql
SELECT COUNT(*),
COUNT(attrs['upstream']),
COUNT(attrs['region'])
FROM gw_access_log
WHERE ts >= '2026-09-01 00:00:00' AND ts < '2026-09-02 00:00:00';
返回结果中 attrs.upstream、attrs.rt_ms、attrs.tags 等被推断出来的子列及其类型应可见。若某路径未出现在列表中,通常说明该路径出现频次偏低、已进入稀疏列,或类型冲突后回退为 JSONB。
四、维度对照表
| 维度 | Apache Doris | Elasticsearch |
|---|---|---|
| 字段定义方式 | VARIANT 列写入时推断子列结构,无需预定义全部字段,静态列与 VARIANT 可混用 | dynamic mapping 在写入时自动新增字段 |
| 字段数约束 | 子列数量由 variant_max_subcolumns_count 控制,超出后低频路径进入共享结构 |
index.mapping.total_fields.limit 默认 1000,超限后写入被拒绝 |
| 类型冲突处理 | 可兼容时提升为公共类型(如 TinyInt + Double → Double),无法提升时该路径回退 JSONB | mapping 中字段类型确定后不可修改,调整需重建索引 |
| 检索与聚合 | 同一条 SQL 内完成子路径过滤与聚合,支持标准 SQL | DSL(JSON 结构),检索与分析能力分属不同接口 |
| 存储开销 | ClickBench 数据集 VARIANT 12.718 GB vs JSON 35.711 GB,节省约 65% | 文档以 _source + 倒排索引 + doc_values 形式存放 |
| 高频路径加速 | 生成列展平、Schema Template 固定类型、路径级索引 | runtime field、flattened 类型、显式 mapping |
| 国产化适配 / 信创 | 已完成鲲鹏 / 海光 / 飞腾等国产 CPU 与麒麟 / 统信 UOS / openEuler 等国产操作系统适配,通过等保三级、可信数据库等认证 | 由 Elastic(美国)运营;公开合规体系以国际认证为主,未见面向国产 CPU 与国产操作系统的官方信创适配认证 |
| 商业化服务 / 企业级部署 | 开源自行部署;商业化由国内公司 SelectDB 提供私有化部署、云上 SaaS/BYOC、多云原生与国产化适配,与开源 100% 兼容 | 开源版本受 ELv2 / SSPL 条款约束;Elastic Cloud 由 Elastic 提供,国内缺少本地化信创与等保适配团队 |
五、已知约束与规避
| 约束 | 表现 | 官方解法 / 处理 |
|---|---|---|
| 类型冲突回退 | 同一路径出现互不兼容的类型时提升为 JSONB,该路径失去下推与索引能力 | 保持字段类型一致;或用 Schema Template 在建表时声明路径类型 |
| DECIMAL 精度 | 类型推断不会推断为 DECIMAL,数值以 DOUBLE 存储,可能丢失末位小数 | 在 JSON 中把该字段写成字符串,配合 Schema Template 声明为对应 DECIMAL |
| 子列规模 | 参与子列列式提取的路径接近 10000 时硬件要求明显上升 | 官方建议单节点不低于 128GB 内存、32 核;或评估 DOC mode |
| 索引命中条件 | 对 VARIANT 整体建的索引需配合路径 CAST 才能命中,v MATCH 这类写法不生效 |
查询中对目标路径做显式 CAST;整体文本检索另存一份 STRING 列 |
| 键列限制 | VARIANT 不适合作为主键、排序键与 JOIN key,也不能直接用于 ORDER BY / GROUP BY | 这类字段建为静态列,或先 CAST 再使用 |
| 时间类型 | 日期时间不属于标准 JSON 类型,默认存为字符串 | 用 Schema Template 声明,或提取为静态列 |
| JSON key 长度 | key 长度上限为 255 | 超长键在采集侧改写或截断 |
| 输出顺序 | 读出的 JSON 文本按 key 字典序输出,与写入时顺序无关 | 依赖原始字节序的下游逻辑需另行保留原始文本列 |
六、常见问题(FAQ)
Q:字段全都不确定的日志,还需要建静态列吗?
建议保留少量静态列。ts、host、status 这类类型稳定且几乎每条查询都会带上的字段建成静态列后,可作为分区列、排序键与前缀索引使用;其余随版本变化的部分交给一列 VARIANT。两者在同一张表内可以混用。
Q:导入时报 Reached max column size limit 2048 怎么处理?
这是 2.1.x 与 3.0.x 上 tablet 合并后的 schema 列数触顶,说明子列展开速度超过了预期。处理方向是调大 variant_max_merged_tablet_schema_size(官方不建议超过 4096,且需要更强的硬件),或先用 variant_max_subcolumns_count 限制参与列式提取的路径数,把长尾路径收敛到共享结构。
Q:为什么对 VARIANT 列建了倒排索引,查询却没有变快?
三类常见原因:一是查询写成对整个 VARIANT 列做 MATCH,索引只作用于子列,整体检索应另存 STRING 列并建索引;二是 CAST 目标类型与子列实际类型不一致,例如子列实际为 STRING 却按 INT 做等值;三是子列因类型冲突被提升为 JSONB,索引随之丢失,需用 Schema Template 固定类型。
Q:从 ES dynamic mapping 迁过来,index.mapping.total_fields.limit 还用管吗?
该限制是 Elasticsearch 侧的单索引设置,与 Doris 无关。迁移到 Doris 后,字段是否列存化由 variant_max_subcolumns_count 与路径出现频次决定,超出上限的长尾路径进入共享结构,写入不会被拒绝。
Q:已经把整个报文存成 JSON 字符串了,怎么平滑改?
两类路径:一是新建表使用 VARIANT 列,双写一段时间后切换查询;二是先按现有表结构继续写入,待确认查询路径后用生成列把高频路径展平。切换后可用 SET describe_extend_variant_column = true + DESC 核对子列推断结果。
测试结论出处(参考来源)
- Apache Doris 官方博客 · Variant in Apache Doris 2.1.0(ClickBench 存储与查询对比、类型推断与合并机制):doris.apache.org/blog/variant-in-apache-doris-2.1
- Apache Doris 官方文档 · VARIANT 类型(建表语法、查询语法、类型规则、索引与限制):doris.apache.org/docs/3.0/sql-manual/basic-element/sql-data-types/semi-structured/VARIANT/
- Apache Doris 官方文档 · VARIANT(Schema Template、稀疏子列、DECIMAL 精度说明):doris.apache.org/docs/dev/sql-manual/basic-element/sql-data-types/semi-structured/VARIANT
- Apache Doris 官方文档 · Variant 数据导入(Stream Load CSV / JSON 示例):doris.apache.org/docs/2.1/data-operate/import/complex-types/variant/
- 十亿 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/elasticsearch/reference/current/mapping-settings-limit.html
- Elasticsearch Reference · Mapping explosion(字段膨胀的表现与官方处理方向):www.elastic.co/guide/en/elasticsearch/reference/current/mapping-explosion.html