Day 14|Doris 宽表与半结构化数据:VARIANT、稀疏列、Schema Template 与 Wide Table Storage V3

前面的十三天,我们已经把 Doris 的基本坐标系、架构、表模型、分区分桶、导入、索引、物化视图和高并发点查逐层搭了起来。到这里,很多人会遇到一类很现实的问题:业务字段开始变得越来越难以提前定义。
日志平台每天会接入新的应用;埋点平台每个版本都可能新增事件属性;IoT 设备不同型号拥有不同传感器;用户画像会不断增加新的标签;广告、风控、AI 应用还会把大量上下文以 JSON 形式送进数据平台。刚开始时,一张表可能只有几十个字段,半年后可能已经膨胀到几百列,甚至出现数千、数万个可能的字段路径。
如果每增加一个字段都执行一次 ALTER TABLE,Schema 变化会逐渐变成系统维护负担。如果把所有内容都塞进字符串 JSON,Schema 维护压力降低了,但查询时又需要反复解析整段 JSON。字段越来越多以后,存储元数据、索引、Compaction、Segment 打开、冷数据随机读等成本也会持续增长。
Doris 近几个版本针对这类问题形成了一套完整能力链:VARIANT 负责吸收动态 JSON,Subcolumnization 把热点路径转成列式子列,Sparse Columns 控制长尾字段数量,Schema Template 固定关键业务路径的类型和索引,Storage Format V3 进一步降低超宽表的 Segment 元数据开销。
这一天的目标,是把这几项能力放到同一套建模方法中理解。学完以后,你应该能够回答五个问题:
- 哪些动态字段应该进入
VARIANT,哪些字段仍然适合普通列; - JSON Path 数量持续增长时,何时需要启用 Sparse Columns;
- 哪些业务字段值得进入 Schema Template;
- Doris 4.1 的 Storage Format V3 解决了宽表的哪类底层问题;
- 面对日志、遥测、用户画像、业务宽表时,怎样设计一张长期可治理的表。
一、先理解“宽表问题”到底从哪里来
很多团队把“宽表”简单理解成“列很多的表”。这个定义太粗。真正需要警惕的情况,是字段数量、字段稀疏度、字段变化速度和查询模式同时发生变化。
举一个埋点平台的例子。最开始只有几种事件:
{
"event": "login",
"user_id": 1001,
"ts": 1723000000,
"os": "Android"
}
后来商品详情增加实验分组:
{
"event": "product_view",
"user_id": 1001,
"ts": 1723000100,
"product_id": 888,
"exp_group": "B",
"recommend_model": "v7"
}
再过几个月,直播、广告、会员、搜索、风控、推荐、活动页都开始往埋点里增加自己的字段。全局看下来,可能出现几千个 JSON Path;但任何一条事件只携带其中十几个或几十个字段。此时表具有两个非常典型的特征:
- Schema 很宽:所有可能出现的字段非常多;
- 数据很稀疏:单行真正有值的字段很少。
这类结构用传统静态列存储,会出现大量 NULL;用纯 JSON 字符串存储,又失去了列式数据库最重要的一部分能力。Doris 的 VARIANT 设计正是为了处理这一类“结构变化快、查询仍然需要列式性能”的数据。
你提供的原培训材料已经把这一问题放在 Schema Change 章节中讨论:面对完全不可预测的 JSON 或上千字段的宽表,推荐用 Variant 吸收长尾字段;游戏日志热更新案例也采用“固定公共列 + Variant 扩展列”的方式承接新活动字段。这个方向是正确的,Day 14 会把它扩展成一套更完整的工程方法。

二、VARIANT:动态 JSON 进入列式数据库的入口
Apache Doris 4.x 官方对 VARIANT 的定义非常清晰:它面向结构会随时间变化的 JSON。数据写入时,Doris 会解析文档中的 JSON Path,为每条路径推断类型,并把适合独立存储的路径写成列式子列。
这一步叫 Subcolumnization。

假设我们创建一张事件表:
CREATE TABLE event_log (
event_time DATETIME(3),
user_id BIGINT,
event_name VARCHAR(64),
props VARIANT
)
DUPLICATE KEY(event_time, user_id, event_name)
PARTITION BY RANGE(event_time) ()
DISTRIBUTED BY HASH(user_id) BUCKETS 16;
写入:
{
"device": {"os": "Android", "brand": "Xiaomi"},
"page": "/goods/888",
"exp": {"group": "A"},
"metrics": {"clicks": 3}
}
查询时仍然可以用 JSON Path:
SELECT
props['device']['os'] AS os,
props['exp']['group'] AS exp_group,
CAST(props['metrics']['clicks'] AS INT) AS clicks
FROM event_log
WHERE event_time >= '2026-08-01'
AND props['device']['os'] = 'Android';
从 SQL 视角看,我们仍在访问 JSON。存储层看到的情况已经发生变化:device.os、exp.group、metrics.clicks 等路径可以形成独立子列,每个子列拥有自己的编码、压缩、ZoneMap,以及可选索引。查询 props['device']['os'] 时,系统没有必要重复解析整段原始 JSON。
这就是 VARIANT 的关键价值:Schema 可以继续变化,热点查询路径仍然进入列式执行链路。
2.1 类型推断和类型提升
动态 Schema 最大的麻烦之一,是同一个 Path 在不同批次里可能出现不同类型。
例如:
{"score": 10}
{"score": 10.5}
{"score": "unknown"}
前两种可以通过类型提升得到统一数值类型;第三种已经与数值类型发生冲突。Doris 会尽量做兼容类型扩展,无法形成稳定类型时可能回退到更通用的 JSONB 表达。这种回退意味着该路径后续的谓词下推、统计信息和索引能力都会受到影响。
所以,VARIANT 能吸收 Schema 漂移,但生产设计仍然需要治理关键路径。灵活性解决接入问题,稳定类型解决核心查询问题。 这正是 Schema Template 存在的原因,后面会详细展开。
2.2 哪些字段应该继续做普通列
使用 VARIANT 时,很多人容易走向另一个极端:既然动态 JSON 很方便,就把所有字段都放进一个 props VARIANT。
生产环境里,更稳妥的做法通常是把字段分成三类:
| 字段类型 | 推荐存储 | 原因 |
|---|---|---|
| 分区列、分桶列、主键、时间、租户 ID | 普通列 | 决定物理数据组织和高频裁剪 |
| 高频过滤、Join、排序、核心指标 | 普通列或 Schema Template 路径 | 类型和查询语义需要长期稳定 |
| 长尾属性、实验字段、设备扩展、事件扩展 | VARIANT | Schema 变化快,单行字段稀疏 |
例如埋点表里,event_time、user_id、event_name 适合做实体列;设备固件版本、实验参数、推荐上下文、扩展标签可以放入 VARIANT。
这种设计能让 Doris 的分区、分桶、Prefix Index、CBO 统计信息和 Join 策略继续围绕稳定字段工作,同时让长尾字段保持灵活。
三、当 JSON Path 越来越多:Sparse Columns 解决长尾字段膨胀
默认 VARIANT 会为热点路径生成子列。官方文档当前默认的子列预算是每个 VARIANT 列 2048 个,由 variant_max_subcolumns_count 控制。
如果业务只有几十到几百个常见 Path,这个模式非常自然。问题出现在超宽 JSON:广告特征、车联网遥测、用户画像、可观测 Trace、风控特征等业务,Path 总数可能轻松达到几千甚至几万。
如果所有 Path 都变成独立物理子列,会带来新的成本:
- Segment 元数据越来越大;
- 写入阶段需要维护更多列结构;
- 索引和统计信息增加;
- Compaction 需要处理更多列;
- 长尾字段几乎从来不查询,却长期占据独立列资源。
Doris 3.1 引入 Sparse Columns,解决的正是这一问题。

Sparse 的核心思想非常容易理解:
热点 Path 继续以独立子列存在;长尾 Path 进入共享稀疏存储。
比如一个用户画像系统拥有 20,000 个属性键,但真正被频繁分析的字段只有:
regionagemember_leveldevice_osactive_7dpay_levelrisk_levelchannel
这几十、几百个 Path 继续保持列式子列,过滤和聚合可以走高效路径。其余上万个偶尔访问的字段进入 Sparse Storage。
3.1 用 variant_max_subcolumns_count 控制“热点预算”
可以把这个参数理解成:最多允许多少个路径享受独立列式子列待遇。
热点预算需要结合真实查询控制在合适范围。预算过大时,长尾字段重新回到超宽列问题;预算过小时,一些真正高频的字段也会进入 Sparse,导致读取成本提高。
调参前先做业务统计:
- 统计过去 7 天、30 天 SQL 中访问最多的 JSON Path;
- 计算访问频率和过滤选择性;
- 确认最常访问的几十到几百个字段;
- 根据这些字段数量设置初始预算;
- 再通过 Profile 和真实并发验证。
3.2 Sparse Sharding:长尾字段也不能都挤在一个物理列
如果长尾 Path 数量达到数万,一个共享 Sparse Column 也可能成为读放大热点。Doris 4.1 继续加入 Sparse Sharding:通过 variant_sparse_hash_shard_count 把长尾 Path 按 Hash 分散到多个物理 Sparse Column。
示意配置:
CREATE TABLE user_feature_wide (
uid BIGINT,
features VARIANT<
'user_id' : BIGINT,
'region' : STRING,
properties(
'variant_max_subcolumns_count' = '2048',
'variant_sparse_hash_shard_count' = '32'
)
>
)
DUPLICATE KEY(uid)
DISTRIBUTED BY HASH(uid) BUCKETS 32
PROPERTIES (
"storage_format" = "V3"
);
这类配置非常适合“Path 总量巨大、热点 Path 数量相对有限”的业务。官方 4.1 Release Notes 直接把车联网遥测、广告画像、用户特征、埋点日志和安全日志列为适用场景。
四、Schema Template:让关键 JSON Path 进入可治理状态
VARIANT 自动推断能够解决大量动态字段,但核心业务字段通常需要更强约束。
订单金额如果某批数据突然从数值变成字符串,分析结果会受到影响;user_id 如果一部分数据写成数字、一部分写成字符串,索引和 Join 都会变得复杂;日志里的 trace_id、service_name、level 也往往需要统一类型和固定索引。
Schema Template 用来处理这些关键路径。

它允许你在 VARIANT 定义中声明部分 Path 的稳定类型,例如:
CREATE TABLE payment_event (
event_time DATETIME(3),
props VARIANT<
'user_id' : BIGINT,
'order_id' : BIGINT,
'amount' : DECIMAL(18,2),
'country' : STRING
>
)
DUPLICATE KEY(event_time)
DISTRIBUTED BY HASH(event_time) BUCKETS 16;
Schema Template 有三个非常重要的工程价值。
4.1 类型稳定
关键 Path 不再完全依赖每批写入时的自动推断。这样可以降低类型漂移造成的查询不确定性,也便于数据质量校验。
4.2 索引稳定
Doris 当前支持对特定 VARIANT Path 配置 Path-specific Index。需要对某个关键字段长期建立倒排索引时,先把这个 Path 放入 Schema Template,会让类型和索引定义更加可控。
比如日志 message 需要全文检索,service_name 需要等值过滤,trace_id 需要精确查询,这些字段都很适合进入治理范围。
4.3 团队协作稳定
大型企业的日志、埋点和画像平台往往有几十个数据生产团队。如果完全依赖自动推断,同一路径命名、类型和含义可能逐渐失控。Schema Template 可以承担“关键字段契约”的角色:
- 统一字段名;
- 统一数据类型;
- 统一业务含义;
- 统一索引策略;
- 统一上线评审。
这里有一个很重要的边界:模板只定义关键 Path。 如果把所有可能出现的几千个字段都写入模板,维护压力又会回到传统超宽 Schema。长尾字段继续交给 VARIANT 和 Sparse 处理即可。
五、Wide Table Storage Format V3:解决“打开文件之前就已经很贵”的问题
当表达到数百、数千甚至上万列时,查询慢的原因有时还没走到真正的数据扫描阶段。
Apache Doris 4.1 新增 Wide Table Storage Format V3。它解决的是一个非常底层的问题:旧 Segment 格式把大量 Column Metadata 集中放在 Footer 中。打开 Segment 时,系统需要先读取和反序列化 Footer。列越多、Segment 越多,这个“开门成本”越高。
即使 SQL 只访问两个字段,也可能先为几千列的元数据付费。

V3 的主要变化有三项。
5.1 Column Metadata 从 Footer 中拆出
V3 让 Footer 只保存轻量指针,真正的 Column Metadata 进入独立区域。查询打开 Segment 时,可以先读取一个很小的 Footer,然后按需加载实际访问列的元数据。
这项优化特别适合:
- 数百到数千列传统宽表;
- 拥有大量 VARIANT 子列的表;
- 对象存储上的冷查询;
- AI、车联网、遥测等随机读取较多的场景。
5.2 数值类型默认使用更直接的编码路径
V3 对 INT、BIGINT 等数值类型默认采用 Plain Encoding,并与 LZ4/ZSTD 压缩配合,降低大批量读取中的解码 CPU 成本。
5.3 String / JSONB 使用新的 Binary Plain Encoding
新的字符串布局减少额外 offset 结构,进一步控制超宽场景下的空间和解析成本。
创建时显式指定:
CREATE TABLE event_v3 (
id BIGINT,
event_time DATETIME(3),
attrs VARIANT
)
DUPLICATE KEY(id)
DISTRIBUTED BY HASH(id) BUCKETS 32
PROPERTIES (
"storage_format" = "V3"
);
这里要特别注意版本边界:Storage Format V3 从 Apache Doris 4.1.0 开始支持。 当前官网在 2026-08-17 显示 Latest 为 4.1.3,Stable 为 4.0.8。因此,如果生产环境仍在 4.0.8,VARIANT、Sparse Columns、Schema Template 可以继续使用,但 V3 需要等升级到 4.1 系列后再评估。
官方文档提供了一个明确测试条件:7,000 列、10,000 个 Segment 的宽表中,Segment 打开时间从 65 秒降到 4 秒,打开阶段内存从 60 GB 降到 1 GB 以下。这个数字只能用于理解 V3 所解决的问题,生产系统不能直接照搬,需要用自己的列数、Segment 数量、存储介质和查询模式重新测试。
六、VARIANT、Sparse、Schema Template、V3 应该怎样组合
这四项能力分别解决不同层次的问题:
| 能力 | 解决的问题 | 主要收益 | 主要代价 |
|---|---|---|---|
| VARIANT | Schema 经常变化 | 动态接入 + Path 列式分析 | 写入时解析与子列维护 |
| Sparse Columns | Path 总数过大且长尾明显 | 控制物理子列数量 | 长尾 Path 查询成本更高 |
| Schema Template | 关键 Path 需要稳定类型和索引 | 类型、索引、治理稳定 | 模板需要版本管理 |
| Storage Format V3 | 超宽表 Segment 元数据膨胀 | 降低打开延迟和内存 | 需要 Doris 4.1+ |
组合方式可以参考下面的决策树。

模式 A:普通事件日志
Path 数量几百,字段变化快,高频查询集中在几十个字段。
建议:
- 固定列:时间、应用、服务、用户、事件名;
props VARIANT:扩展字段;- 关键日志字段建立倒排索引;
- 4.1 新表优先评估 V3。
模式 B:广告特征 / IoT 遥测 / 超宽画像
Path 数量几千到几万,绝大多数列长期为 NULL,高频字段只有几十到几百个。
建议:
- VARIANT;
- 控制
variant_max_subcolumns_count; - 开启 Sparse Columns;
- 4.1 根据长尾读取压力设置 Sparse Sharding;
- 4.1 配合 V3。
模式 C:支付 / 订单 / 核心设备属性
字段仍有变化,但少数路径必须保证类型稳定,且需要稳定索引。
建议:
- 关键公共字段做实体列;
- 扩展字段使用 VARIANT;
user_id、amount、country、device_id等进入 Schema Template;- 对关键 Path 配置索引;
- 长尾明显时继续叠加 Sparse。
模式 D:经常返回完整 JSON 文档
Doris 4.1 的官方 VARIANT Workload Guide 还给出 DOC mode。它更偏向写入效率和整文档返回:写入阶段先保留完整文档结构,子列化可以延迟到后续 Compaction。
DOC mode 与 Sparse Columns 的目标不同,当前 4.1 文档明确要求两者二选一。Day 14 把 DOC mode 放在扩展内容中,核心原因是整套课程的主线更偏向分析、过滤、聚合和索引;如果你的实际系统有大量 SELECT props 返回完整文档的请求,DOC mode 值得单独做 POC。
