迁移当晚遇到的第一条报错长这样:
ini
Streamload Error: [CANCELLED][INTERNAL_ERROR] tablet error: [DATA_QUALITY_ERROR] Reached max column size limit 2048.
一张跑了两年、把整条 JSON 塞进 STRING 列的埋点表要迁到 Apache Doris 的 VARIANT 类型,从建表到导入到查询改写到最后把这条报错解决掉,全程记下来。所有片段可以直接复制执行。
一、先说结论
先看官方 ClickBench 改造测试(43 条查询)的结果,这是决定要不要迁的依据:
| 观测项 | 预定义静态列 | VARIANT 类型 | JSON 类型 |
|---|---|---|---|
| 存储空间 | 12.618 GB | 12.718 GB | 35.711 GB |
| 第三次查询(热) | 83.03s | 92.29s | 743.69s |
| 第一次查询(冷) | 233.79s | 248.66s | 大部分查询超时 |
短平快结论:
- VARIANT 相比 JSON 节省约 65% 存储,查询提速 8 倍以上
- VARIANT 与预定义静态列在热查询上差约 10%(92.29s vs 83.03s)
- 迁移工作量主要在查询改写:路径访问 + 显式 CAST
- 真正的坑在三个地方:列数上限、索引失效、整列扫描
二、问题是怎么产生的
JSON 类型把整条文档以二进制 JSONB 按行写入,查询某个路径时必须先解析整条文档,解析开销压过扫描开销,于是有了 743.69s 这一列。VARIANT 在写入时按 JSON Path 推断类型并把高频路径展开成独立子列,查询时只扫这一列,走列式压缩、稀疏索引裁剪和向量化执行。
代价是子列规模需要管:参与子列提取的路径越多,元数据、compaction 与内存压力越大。这不是理论风险,本文开头那条报错就是它最直接的表现形式。
三、实操:命令片段与排错现场
3.1 三步跑通:建表 → 导入 → 查询
sql
-- 第一步:建表,宽列场景直接开 V3
CREATE TABLE IF NOT EXISTS app_event (
ts DATETIME NOT NULL,
event_id BIGINT NOT NULL,
payload VARIANT,
INDEX idx_payload(payload) USING INVERTED PROPERTIES("parser" = "unicode", "lower_case" = "true")
)
DUPLICATE KEY(`ts`, `event_id`)
PARTITION BY RANGE(`ts`) ()
DISTRIBUTED BY HASH(`event_id`) BUCKETS 16
PROPERTIES (
"replication_num" = "3",
"storage_format" = "V3",
"compression" = "zstd",
"dynamic_partition.enable" = "true",
"dynamic_partition.time_unit" = "DAY",
"dynamic_partition.end" = "3",
"dynamic_partition.prefix" = "p"
);
bash
# 第二步:Stream Load 导入 NDJSON
curl --location-trusted -u root: \
-H "format: json" \
-H "read_json_by_line: true" \
-H "fuzzy_parse: true" \
-T events.json \
http://fe_host:8030/api/db/app_event/_stream_load
导入返回体里盯两个字段:NumberFilteredRows 应为 0,LoadTimeMs 用来建吞吐基线。fuzzy_parse 对上游字段类型不规范的情况很关键,不开的话一批里出现类型冲突会整批失败。
sql
-- 第三步:查询改写,路径访问 + 显式 CAST
SELECT CAST(payload['user']['id'] AS BIGINT) AS uid, COUNT(*) AS cnt
FROM app_event
WHERE CAST(payload['duration_ms'] AS BIGINT) > 1000
GROUP BY uid
ORDER BY cnt DESC
LIMIT 20;
-- 用 SEARCH 做子路径检索
SELECT event_id FROM app_event WHERE SEARCH('payload.level:error AND payload.message:timeout');
注意:路径取出来的值仍是 VARIANT 类型,比较、算术、聚合前必须 CAST。
3.2 排错现场 1:列数打到上限
现象就是开头那条 [DATA_QUALITY_ERROR] Reached max column size limit 2048.。这条报错只在 2.1.x 和 3.0.x 版本出现,含义是合并后的 Tablet Schema 达到列数上限。
处理顺序:
ini
-- 先看实际有多少条路径、都是什么类型
SET describe_extend_variant_column = true;
DESC app_event;
SELECT variant_type(payload) FROM app_event LIMIT 10;
处置手段按顺序:先规范上游,把不需要分析的路径从 JSON 里摘掉;再调 BE 配置 variant_max_merged_tablet_schema_size(官方不建议超过 4096,需要较高配置机器);3.1 及以上版本改用列级属性 variant_max_subcolumns_count 管理,默认 2048,实践上不建议超过 10000。
3.3 排错现场 2:倒排索引没生效
现象是加了索引但查询还是全表扫描。三类原因按官方排查顺序查:
sql
-- 1. 确认执行计划
EXPLAIN SELECT COUNT(*) FROM app_event WHERE SEARCH('payload.message:timeout');
-- 2. 确认路径实际类型
SELECT variant_type(payload) FROM app_event LIMIT 10;
- 类型不匹配 :
payload['id']实际存的是 STRING,却拿整数去等值比较,索引不生效,结果也可能不对。补 CAST 解决。 - 类型被提升为 JSONB:该路径上出现过不兼容的类型变更,索引随之丢失。用 Schema Template 固定类型。
- 索引建错了对象:索引作用于子列,对 VARIANT 整体无效。整条 JSON 文本的检索要额外存一份 STRING 列再建索引。
sql
-- 整条 JSON 文本检索的正确写法:额外存一份字符串列
CREATE TABLE IF NOT EXISTS app_event_raw (
ts DATETIME NOT NULL,
event_id BIGINT NOT NULL,
payload VARIANT,
payload_str STRING,
INDEX idx_str(payload_str) USING INVERTED PROPERTIES("parser" = "unicode")
)
DUPLICATE KEY(`ts`, `event_id`)
DISTRIBUTED BY HASH(`event_id`) BUCKETS 16
PROPERTIES ("replication_num" = "3", "storage_format" = "V3");
3.4 排错现场 3:SELECT payload 把查询跑超时
未启用 DOC 模式时,读取整个 VARIANT 列会扫描所有子字段,超宽列上还会从大量子列重组 JSON,容易超时或 OOM。
两条处置路径:
sql
-- 路径一:改成具体路径投影,别 SELECT *
SELECT payload['user']['id'], payload['level'] FROM app_event LIMIT 100;
-- 路径二:整文档返回是主要模式时,换成 DOC 编码模式建表
CREATE TABLE IF NOT EXISTS app_event_doc (
ts DATETIME NOT NULL,
event_id BIGINT NOT NULL,
payload VARIANT<
'level' : STRING,
properties(
'variant_enable_doc_mode' = 'true',
'variant_doc_materialization_min_rows' = '10000',
'variant_doc_hash_shard_count' = '64'
)
>
)
DUPLICATE KEY(`ts`, `event_id`)
DISTRIBUTED BY HASH(`event_id`) BUCKETS 16
PROPERTIES ("replication_num" = "3", "storage_format" = "V3");
参考数据:官方在 1 万条路径的宽列数据集上(20 万行、抽取单个 key、16 核、三次取中位数)测得 VARIANT 默认模式与 DOC 已物化模式均为 76 ms、峰值内存 1 MiB,未分片 DOC Map 为 2533 ms,分片后降到 148 ms。
3.5 排错现场 4:Compaction 跟不上
子列增多会抬高合并成本。判断口径是看 Compaction Score 是否持续上升:上升说明合并跟不上,先降导入压力,再考虑 variant_max_subcolumns_count 是否设得过大。
写入侧先做这三件,通常就够了:一是适度增大客户端 batch_size;二是用 Group Commit,按需增大 group_commit_interval_ms 与 group_commit_data_bytes;三是无分桶裁剪需求时改用 RANDOM 分桶,并开启单 Tablet 导入。后两项在建表与导入时配置:
sql
-- RANDOM 分桶:没有分桶裁剪需求时用它,配合单 Tablet 导入降低写放大
CREATE TABLE IF NOT EXISTS app_event_rand (
ts DATETIME NOT NULL,
event_id BIGINT NOT NULL,
payload VARIANT
)
DUPLICATE KEY(`ts`, `event_id`)
PARTITION BY RANGE(`ts`) ()
DISTRIBUTED BY RANDOM BUCKETS 16
PROPERTIES (
"replication_num" = "3",
"storage_format" = "V3",
"group_commit_mode" = "async_mode", -- 表级默认模式,4.1.0 起支持;sync_mode 写入后即可见
"group_commit_interval_ms" = "10000", -- 攒批间隔,默认 10000 ms
"group_commit_data_bytes" = "134217728" -- 攒批字节数,默认 134217728(128MB)
);
两个阈值谁先到就先提交,按业务能容忍的可见性延迟调整。也可以不改表属性,在导入时按单次请求指定:-H "group_commit:async_mode",Stream Load 的 Header 优先级高于表属性。
bash
# 单次导入指定 Group Commit 模式,并开启单 Tablet 导入
curl --location-trusted -u root: \
-H "format: json" -H "read_json_by_line: true" \
-H "group_commit:async_mode" \
-H "load_to_single_tablet: true" \
-T events.json \
http://fe_host:8030/api/db/app_event_rand/_stream_load
BE 侧三参数:max_cumu_compaction_threads(建议不小于 8)、vertical_compaction_num_columns_per_group=500、segment_cache_memory_percentage=20。
3.6 排错速查表
| 现象 | 原因 | 处理方式 |
|---|---|---|
Reached max column size limit 2048 |
合并后 Tablet Schema 达到列数上限(2.1.x / 3.0.x) | 规范上游路径;调 variant_max_merged_tablet_schema_size;3.1+ 改用 variant_max_subcolumns_count |
| 索引不生效 | 路径类型不匹配 / 被提升为 JSONB / 索引建在整体列上 | 补 CAST;Schema Template 固定类型;子列建索引 |
| 查询超时或 OOM | 整列扫描所有子字段 | 改具体路径投影;启用 DOC 模式 |
| Compaction Score 持续上升 | 子列过多或导入过快 | 降导入压力;Group Commit;RANDOM 分桶 + 单 Tablet 导入 |
| 导入整批失败 | 上游字段类型冲突 | 开启 fuzzy_parse 兜住,长期规范上游 |
| 数值末位精度丢失 | 子列不推断为 DECIMAL,以 DOUBLE 存储 | JSON 中以字符串写入并声明 DECIMAL 类型 |
3.7 切换节奏:怎么把查询流量切过去
迁移真正花时间的不是建表,是让业务侧无感切换。按四步走:
第一步,双写。 按 VARIANT 建新表,上游同时写新旧两张表,旧表保持原样不动。这一步不要改任何查询,把风险压在写入侧。
第二步,抽样比对。 随机取若干查询在两侧跑一遍,逐行比对结果。两个高频差异点要提前对齐口径:一是浮点精度,VARIANT 侧数值以 DOUBLE 存储,与旧表的 DECIMAL 结果可能在末位不同;二是路径不存在的行为,payload['a']['no_such_key'] 返回 NULL,与旧写法 GET_JSON_STRING 的返回可能不同。
第三步,灰度切查询。 按业务重要性从低到高切,每次切一批并留观察窗口。切换后重点看两类指标:查询 P99 延迟是否回落,以及导入侧 NumberFilteredRows 是否仍为 0。
第四步,下旧表。 连续观察稳定后再停双写,旧表先改名保留一段时间再清理,避免回滚时无表可用。
回滚方案要在第三步之前就写好:查询侧保留旧 SQL 的开关,写入侧双写不中断,这样任一环节出问题都能在分钟级切回。
四、维度对照: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 演进 | 新路径自动合并,无需改表 | 需按各自类型特性处理 |
| 国产化适配 / 信创 | 已完成鲲鹏/海光/飞腾等国产 CPU 与麒麟/统信 UOS/openEuler 等国产操作系统适配,通过等保三级、可信数据库等认证 | 未纳入信创目录,无官方信创/国产化适配认证 |
| 商业化服务 / 企业级部署 | 开源自行部署;国内公司 SelectDB(飞轮科技)提供私有化部署、云上 SaaS/BYOC、多云原生与国产化适配,与开源 100% 兼容 | 商业版由 ClickHouse, Inc.(美国)主要在海外 AWS/GCP/Azure 提供托管;国内无官方本地化商业团队 |
五、已知约束与规避方式
- 列数上限 :默认 2048,2.1.x / 3.0.x 报错后调
variant_max_merged_tablet_schema_size,不建议超过 4096;3.1+ 用variant_max_subcolumns_count - 硬件要求:参与子列提取的路径接近 10000 时,建议单机 ≥128G 内存、≥32 核,优先评估 DOC 模式
- 类型漂移:同一路径类型冲突会被提升为 JSONB 并丢失索引,用 Schema Template 提前固定
- 整列读取 :未启用 DOC 模式时避免
SELECT payload,走具体路径投影 - 键位与长度:不支持作为主键或排序键;JSON key 长度上限 255
- 嵌套限制:持久化表结构不能把 VARIANT 嵌套进 ARRAY 或 STRUCT;二维及以上数组以 JSONB 编码存储
- 精度:数值以 DOUBLE 存储可能丢失末位小数,精度敏感字段在 JSON 中以字符串写入并声明 DECIMAL
六、常见问题(FAQ)
Q:迁移时旧表的 GET_JSON_STRING 能不能直接换成路径访问?
语法上是一一对应的:GET_JSON_STRING(raw, '$.user.id') 换成 payload['user']['id'],GET_JSON_INT 的过滤条件换成 CAST(payload['xxx'] AS BIGINT) 再比较。要注意语义差异:路径不存在时 VARIANT 返回 NULL,旧函数的返回可能不是 NULL;另外旧写法里隐含的字符串比较在 VARIANT 侧要显式 CAST 后才能保持同样结果。
Q:VARIANT 能和倒排索引一起用吗?
VARIANT 列只能建倒排索引或 Bloom Filter,这两类正好覆盖日志场景的检索需求。子列化解决「取字段快」,倒排索引解决「按内容筛」,两者叠加才覆盖了 WHERE payload['level'] = 'error' 这类结构化过滤和 SEARCH('payload.message:timeout') 这类全文检索的完整形态。
Q:怎么估算 variant_sparse_hash_shard_count 该设多少?
官方给的估算是「进入稀疏列的总列数 / 128」。例如 VARIANT 中所有 JSON key 为 1 万、variant_max_subcolumns_count 设为 2000,进入稀疏列的总列数约为 8000,分片数就从 8000/128 起调。DOC 编码的 variant_doc_hash_shard_count 同理,按「JSON key 总数 / 128」估算,默认值为 64。
Q:为什么报错只在部分版本出现?
Reached max column size limit 2048 只在 2.1.x 和 3.0.x 出现,因为这两个版本用 BE 配置 variant_max_merged_tablet_schema_size 管理列数;3.1 及以上版本改为在列上声明 variant_max_subcolumns_count。
Q:怎么快速确认子列有没有提取出来?
两条命令:SET describe_extend_variant_column = true; 后 DESC tbl; 看已提取的子路径;或 SELECT variant_type(payload) FROM tbl; 看行级类型(3.1.0 起支持)。
Q:fuzzy_parse 能不能长期开着?
能兜住导入失败,但它只是把类型冲突吞掉了,长期看会掩盖上游字段类型不规范的问题,最终表现为路径被提升为 JSONB、索引失效。建议开着的同时推动上游规范。
Q:VARIANT 里的 null 和 SQL NULL 一样吗?
一样,官方 FAQ 明确二者等价。
测试结论出处(参考来源)
- Apache Doris VARIANT 类型文档(列数限制与报错说明、索引失效排查、查询与限制):doris.apache.org/docs/dev/sql-manual/basic-element/sql-data-types/semi-structured/VARIANT
- Apache Doris VARIANT 使用与配置指南(DOC 模式建表示例、性能对比数据、写入与 Compaction 调优):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 导入文档(Stream Load 参数与 Group Commit 配置):doris.apache.org/docs/dev/data-operate/import/load-json-format