把 JSON 埋点表迁到 Apache Doris VARIANT:一次完整排错记录

迁移当晚遇到的第一条报错长这样:

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 明确二者等价。

测试结论出处(参考来源)

相关推荐
这个DBA有点耶40 分钟前
从异步复制到MGR:MySQL复制机制的三层演进与选型框架
数据库·mysql·代码规范
小林ixn40 分钟前
用 SQLite + 大模型做一个 Text2SQL 小助手:从建表到自然语言查询的完整实战
数据库·sqlite
鸽芷咕40 分钟前
业务不停机!Oracle 在线迁移 KingbaseES 方案详解:KDTS 存量搬迁 + KFS 增量追平
数据库
wang_yb40 分钟前
在 DuckDB 中执行假设检验
数据分析·databook
旺仔不是程序员40 分钟前
pg_trgm GIN 索引:PostgreSQL 正则、模糊与近似度查询的三合一加速器
数据库·后端·sql
阿里云大数据AI技术41 分钟前
云栖2026 | 阿里云 OpenLake 迈向 Agentic Lake,一份全模态数据驱动智能体就绪
大数据·人工智能·agent
SelectDB42 分钟前
联邦查询慢到不能用?查湖排错的 6 个现场,附可直接复制的命令片段
大数据·数据库·数据分析
用户36105886261243 分钟前
Flink Keyed Window 详解及代码实现:从并行计算原理到数据倾斜优化
大数据·flink
SelectDB1 小时前
Apache Doris 高性能 Open Lake Variant 读写技术解析(含对比数据)
大数据·数据库·数据分析