3. Avro Schema 编写实战

好,咱们直接进入正题。上一章我们把 Avro 的基础概念和 Schema 的 JSON 骨架过了一遍。这一章,我带你手写几个最核心的类型。说白了,你只要把这五种类型玩熟了,日常 90% 的序列化场景都能搞定。

我个人习惯把 Avro 的类型分成两类:基础类型(string, int, long 这些)和 复杂类型(record, enum, array, map, union)。基础类型没啥好讲的,就是 JSON 里写个字段名加个类型名。复杂类型才是真正体现 Schema 设计能力的地方。

核心观点:Avro 的复杂类型不是用来炫技的,而是用来精确描述业务模型的。你写得越准确,下游消费方就越省心。

3.1 record 类型:一切模型的基石

record 就是 Avro 里的“对象”或“结构体”。你想想看,任何业务实体——用户、订单、商品——最终都会映射成一个 record。

一个完整的 record 包含三要素:name(名称)、namespace(命名空间,可选但强烈建议)、fields(字段列表)。

{
  "type": "record",
  "name": "User",
  "namespace": "com.example.user",
  "fields": [
    { "name": "userId", "type": "string" },
    { "name": "age", "type": "int" },
    { "name": "email", "type": ["null", "string"], "default": null }
  ]
}

这里有个细节:email 字段的类型是 ["null", "string"],这就是 union 的写法。我建议你给所有可空字段都加上 default 值。为什么?因为 Avro 的 Schema 兼容性检查会用到它。我曾经在线上遇到过因为少写一个 default,导致旧数据反序列化新 Schema 时直接抛异常——那叫一个惨。

我的习惯:所有 record 字段,只要业务上允许为空,就写成 ["null", "实际类型"] 并给 default: null。这能省掉你 80% 的兼容性头疼。

3.2 enum 类型:有限选项的优雅表达

enum 用来表示一组固定的取值。比如订单状态、用户角色、支付方式。Avro 的 enum 比 Java 的 enum 更轻量,它只关心符号名,不关心序号。

{
  "type": "enum",
  "name": "OrderStatus",
  "namespace": "com.example.order",
  "symbols": ["PENDING", "PAID", "SHIPPED", "DELIVERED", "CANCELLED"]
}

嗯,这里要注意:symbols 的顺序不要轻易改变。虽然 Avro 的 enum 不依赖 ordinal,但如果你在 Schema 演进时删除了某个 symbol,或者插入了新 symbol 到中间位置,下游消费方可能会收到意料之外的值。我建议你只在末尾追加新 symbol,并且永远不要删除已存在的 symbol——除非你确定所有历史数据都已经被重写了。

避坑指南:我曾经在项目中把 PAID 改成了 PAYMENT_COMPLETED,结果下游的消费者直接反序列化失败。因为 Avro 的 enum 是按符号名匹配的,不是按位置。改名等于删旧加新,属于破坏性变更。

3.3 array 类型:有序列表

array 就是数组,里面每个元素的类型必须一致。写法很简单:

{
  "type": "array",
  "items": "string"
}

或者嵌套复杂类型:

{
  "type": "array",
  "items": {
    "type": "record",
    "name": "LineItem",
    "fields": [
      { "name": "productId", "type": "string" },
      { "name": "quantity", "type": "int" },
      { "name": "price", "type": "double" }
    ]
  }
}

array 在 Avro 里没有固定长度限制,也没有“最大元素数”的约束。如果你需要限制,那得在业务层做校验。我个人习惯在 Schema 里用 array 表示“零个或多个”的场景,比如订单的商品明细、日志的标签列表。

3.4 map 类型:键值对容器

map 的键永远是 string,值可以是任意类型。写法:

{
  "type": "map",
  "values": "long"
}

这个例子表示一个从字符串到长整型的映射。实际业务中,我常用 map 来存“属性扩展”或“动态配置”。比如:

{
  "type": "record",
  "name": "Product",
  "fields": [
    { "name": "productId", "type": "string" },
    { "name": "attributes", "type": { "type": "map", "values": "string" } }
  ]
}

这样,每个商品可以携带任意数量的自定义属性,而不需要提前定义字段。灵活,但也要小心:map 的键值对是无序的,而且键不能重复。如果你需要有序的键值对,那就用 array 包 record 的方式模拟。

3.5 union 类型:多选一的利器

union 是 Avro 里最灵活也最容易用错的类型。它用 JSON 数组表示,数组里的每个元素都是一个合法的 Avro 类型。序列化时,实际数据只能匹配其中一种。

{
  "type": "record",
  "name": "Notification",
  "fields": [
    {
      "name": "payload",
      "type": ["null", "string", {
        "type": "record",
        "name": "EmailPayload",
        "fields": [
          { "name": "subject", "type": "string" },
          { "name": "body", "type": "string" }
        ]
      }]
    }
  ]
}

这个 payload 字段可以是 null、普通字符串、或者一个 EmailPayload 对象。你想想看,这种设计在消息通知场景下有多方便?一条消息可以携带不同格式的内容。

重要规则:union 里的类型顺序是有意义的。Avro 在序列化时,会按顺序尝试匹配。所以 ["null", "string"] 和 ["string", "null"] 虽然逻辑等价,但序列化后的二进制表示不同。我建议你始终把 null 放在 union 的第一个位置,这是业界惯例。

3.6 综合示例:一个完整的订单 Schema

把上面这些类型串起来,写一个真实的订单 Schema:

{
  "type": "record",
  "name": "Order",
  "namespace": "com.example.order",
  "fields": [
    { "name": "orderId", "type": "string" },
    { "name": "userId", "type": "string" },
    { "name": "status", "type": {
      "type": "enum",
      "name": "OrderStatus",
      "symbols": ["PENDING", "PAID", "SHIPPED", "DELIVERED", "CANCELLED"]
    }},
    { "name": "items", "type": {
      "type": "array",
      "items": {
        "type": "record",
        "name": "LineItem",
        "fields": [
          { "name": "productId", "type": "string" },
          { "name": "quantity", "type": "int" },
          { "name": "unitPrice", "type": "double" }
        ]
      }
    }},
    { "name": "metadata", "type": ["null", {
      "type": "map",
      "values": "string"
    }], "default": null },
    { "name": "couponCode", "type": ["null", "string"], "default": null }
  ]
}

这个 Schema 覆盖了 record、enum、array、map、union 五种类型。你可以直接拿去用,或者作为模板修改。

3.7 类型选择决策树

为了帮你快速决策,我画了一张图:

Avro 复杂类型选择决策树 数据是什么结构? 固定字段的对象 有限选项的枚举值 多个值/键值对 → 使用 record → 使用 enum 有序列表? 是 → 使用 array 否 → 使用 map 可空/多类型? → 使用 union 提示:union 可以与其他类型组合使用,例如 ["null", "record"] 表示可空对象 实际项目中,一个 Schema 往往同时包含多种复杂类型

3.8 类型对比速查表

类型 适用场景 键/索引 值类型 可空性
record 业务实体、结构化对象 字段名(字符串) 任意类型 通过 union 实现
enum 状态、角色、分类等有限选项 符号名(字符串) 无(自身就是值) 通过 union 实现
array 有序列表、批量数据 整数索引 单一类型 通过 union 实现
map 动态属性、配置项、扩展字段 字符串(唯一) 单一类型 通过 union 实现
union 可空字段、多态数据 无(按顺序匹配) 多种类型之一 本身支持

我的建议:刚开始写 Avro Schema 时,先画一个简单的类图或 ER 图,再翻译成 Schema。这样不容易漏字段,也能提前发现哪些字段需要 union 或 map。我每次写 Schema 前都会在草稿纸上画五分钟,比直接上手写 JSON 快得多。