202510
data-engineering

ClickHouse 日志系统中的 Schema 演进:策略与实践

在高吞吐量日志系统中,如何演进 ClickHouse 的 Schema 以保证查询性能和数据一致性?本文深入探讨了从表结构设计到具体变更操作的完整策略,确保下游分析不中断。

在采用 ClickHouse、Kafka 和 Vector 构建的高性能日志系统中,海量数据的持续写入与高效查询是核心优势。然而,随着业务的迭代,日志内容的结构(Schema)必然会发生变化——新增字段、修改类型或废弃字段。与 Elasticsearch 的动态映射(Dynamic Mapping)不同,ClickHouse 强依赖于预定义表结构,这使得 Schema 演进成为一个棘手但必须妥善处理的工程挑战。若处理不当,轻则导致数据丢失,重则可能引发下游数据分析、监控告警的全面中断。

本文将深入探讨在 ClickHouse 日志系统中进行 Schema 演进的实用策略,旨在提供一套兼顾查询性能、数据一致性与操作便利性的完整方案。

1. 核心困境:固定 Schema 与业务多变性的矛盾

ClickHouse 的查询性能很大程度上源于其列式存储和预定义结构。这就带来了第一个设计抉择:是为每种日志创建一个独立的、严格定义的表,还是使用一张包含动态列的通用宽表?

  • 严格分表示范:为 login_logpayment_log 分别建表。

    • 优点:结构清晰,查询性能最优,字段类型明确。
    • 缺点:随着日志类型增多,表数量急剧膨胀,管理成本极高。每次新增一种日志或为已有日志新增字段,都需要 DDL 操作。
  • 通用宽表示范:业界主流方案,如 Bilibili、Uber 等公司的实践,均采用此模式。其核心思想是,将所有日志写入一张大表,表中包含少量公共字段和用于存储动态信息的“载荷”字段。

    • 公共字段:通常包括 timestamp, service_name, level, trace_id 等在所有日志中都存在的关键索引字段。
    • 载荷字段:用于存放非结构化的、多变的业务信息。常见的实现有两种:
      1. Map 类型:使用 Map(String, String)Map(String, LowCardinality(String)) 来存储键值对。优点是直观,缺点是在海量数据下,特定键的查询性能弱于专用列。
      2. 双数组(Parallel Arrays)模型:使用两组并行的数组,如 attribute_keys Array(String)attribute_values Array(String)。这种方式在查询时通过数组索引定位,性能通常优于 Map 类型,是 Uber 等公司采用的方案。

对于大多数高吞吐量日志系统,“公共字段 + 双数组载荷”的混合模式是兼顾性能与灵活性的最佳选择。它将高频查询字段固化为列以保证性能,同时为业务自定义内容提供了灵活的扩展空间。

2. 安全的 Schema 演进操作框架

基于混合模式的表结构,我们可以定义一套标准流程来应对不同的 Schema 变更场景。

场景一:新增字段

这是最常见的场景。在双数组模型下,新增字段无需修改 ClickHouse 的表结构。

操作步骤

  1. 采集端更新:在 Vector 或应用的日志采集逻辑中,直接向 attribute_keysattribute_values 数组中添加新的键值对。
  2. 下游感知:新增的字段虽然能被写入,但下游的数据消费者(如分析师、BI 报表)无法直接感知。为此,可以创建一个物化视图(Materialized View)或定期任务,来维护一张“元数据字典表”,通过 SELECT DISTINCT arrayJoin(attribute_keys) FROM logs 来汇总所有出现过的字段名。

场景二:废弃字段

废弃字段同样不需要修改表结构,关键在于确保下游不再依赖它。

操作步骤

  1. 通知与观察:发布字段废弃通知,并设定一个观察期(例如一个月)。在此期间,监控引用了该字段的查询和报表。
  2. 采集端移除:观察期结束后,在 Vector 或应用层面停止发送该字段。
  3. 数据自然淘汰:随着 ClickHouse 表中旧数据的 TTL (Time-To-Live) 过期,包含废弃字段的数据分区将被自动删除,无需手动清理。

场景三:修改字段类型(例如 String -> Int)

这是最具挑战性的场景,直接在 ClickHouse 中使用 ALTER TABLE ... MODIFY COLUMN 对大数据表进行操作,风险极高且可能耗时巨大。安全的做法是采用“滚动更新”策略。

操作步骤

  1. 创建新字段:为新类型创建一个新的载荷字段,并采用版本化命名,例如,将 user_id (String) 修改为数值类型,则新增一个 user_id_v2 (Int)。
    -- 假设原先是双数组模型,现在为了一个高频查询的数值字段,我们将其提升为物理列
    ALTER TABLE logs ADD COLUMN user_id_v2 Nullable(UInt64);
    
  2. 双写阶段:在 Vector 的 VRL (Vector Remap Language) 转换逻辑中,同时写入新旧两个字段。对于新日志,写入 user_id_v2;对于无法转换的旧格式日志,保持 user_id 的写入。
    # Vector VRL 伪代码
    # 尝试将 .user_id 转换为整数
    int_user_id, err = to_int(.user_id)
    if err == null {
      .user_id_v2 = int_user_id
    }
    # 保留旧字段以实现兼容
    .user_id_str = .user_id 
    
  3. 提供统一查询视图:为确保下游查询的平滑过渡,可以创建一个视图,将新旧字段合并,对外提供一个统一的访问入口。
    CREATE VIEW logs_view AS
    SELECT
        ...,
        -- 如果新字段不为空则用新字段,否则用旧字段(需要从数组中提取)
        ifNull(user_id_v2, CAST(attributes.values[indexOf(attributes.keys, 'user_id')] AS Nullable(UInt64))) AS user_id
    FROM logs;
    
  4. 迁移下游应用:通知所有数据消费者,将其查询目标从底层表切换到 logs_view,或直接更新其查询逻辑以使用新字段 user_id_v2
  5. 停止旧字段写入:在所有下游应用完成迁移后,更新 Vector 配置,停止写入旧的 user_id 字段。
  6. 清理(可选):旧字段将随数据 TTL 自动消失。如果需要立即清理,可以在一个低峰时段执行 ALTER TABLE logs DROP COLUMN ...,但这通常没有必要。

3. Kafka 与 Vector 的关键作用

在这个演进框架中,ClickHouse 只负责存储,而大部分的 Schema 管理工作都前移到了数据管道中。

  • Vector:作为数据转换的核心引擎,Vector 的 Remap 语言(VRL)提供了强大的数据处理能力。所有 Schema 的变更逻辑,如双写、类型转换、字段重命名等,都应在 Vector 中集中实现。这避免了修改散落在各个微服务中的日志生成代码,极大地降低了变更的复杂度和风险。

  • Kafka:作为数据总线,Kafka 在此处的价值是解耦和缓冲。当 ClickHouse 因为 Schema 不匹配(例如,在严格模式下)而拒绝写入时,数据并不会丢失,而是保留在 Kafka topic 中。这为我们提供了宝贵的时间窗口:可以先暂停 ClickHouse 的数据消费物化视图,在 Vector 中修复转换逻辑,部署更新,然后再恢复视图,从上次中断的 offset 继续消费,保证了数据的完整性。

结论

在 ClickHouse 日志系统中管理 Schema 演进,成功的关键在于将问题从“数据库变更”转变为“数据管道治理”。通过采用“公共字段 + 动态载荷”的混合表设计,并利用 Vector 执行具体的转换逻辑,同时依靠 Kafka 提供数据缓冲和容错能力,我们可以构建一个既能保证极致查询性能,又足以应对业务长期演进的、真正意义上的可扩展日志平台。这个过程强调的是沟通、流程和工具的结合,而非单一的 DDL 操作。