4. Avro Schema 详解:Record、Enum、Array、Map、Union 类型定义与嵌套

Avro Schema 是 Schema Registry 的基石。你想想看,如果连 Schema 都写不明白,那后面的序列化、兼容性检查全都会跑偏。这一章,我把 Avro 里最常用的几种复杂类型——Record、Enum、Array、Map、Union——掰开揉碎讲清楚,顺便聊聊嵌套怎么写才不踩坑。

4.1 Record 类型:最核心的结构体

Record 说白了就是 Avro 里的“对象”或“结构体”。你在 Java 里写一个 class,在 Protobuf 里写一个 message,在 Avro 里就写一个 Record。

一个 Record 必须包含三样东西:name(名称)、namespace(命名空间)、fields(字段列表)。我个人习惯把 namespace 写成公司域名倒序,跟 Java 包名一个套路,这样在跨团队协作时不容易冲突。

核心要点:Record 的 name 在 namespace 内必须唯一。如果两个 Record 同名但 namespace 不同,Avro 会认为它们是不同的类型。

来看一个实际例子。假设我们要定义用户信息:

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

这里有个细节:email 字段用了 Union 类型 ["null", "string"],并且给了默认值 null。为什么?因为 Avro 在读取数据时,如果 Schema 新增了一个字段,而旧数据里没有这个字段,就会用默认值填充。没有默认值的话,反序列化直接报错。我在项目中遇到过好几次因为漏写 default 导致线上兼容性故障,嗯,后来我养成了习惯——所有可空字段必须给默认值。

4.2 Enum 类型:枚举值就该有名字

Enum 在 Avro 里用来表示一组固定的常量。比如用户状态、订单类型、错误码等等。我个人觉得 Enum 比用字符串硬编码要安全得多,至少拼写错误在编译期就能发现。

{
  "type": "enum",
  "name": "UserStatus",
  "namespace": "com.example.user",
  "symbols": ["ACTIVE", "INACTIVE", "BANNED"]
}

注意:Enum 的 symbols 列表一旦发布,不能删除已有的符号,只能追加。否则会导致旧数据反序列化失败。我曾经见过一个团队删掉了某个枚举值,结果下游消费端直接崩溃——因为历史消息里还存着那个值。

Enum 的默认值也很重要。如果你在 Schema 里声明了 "default": "ACTIVE",那么当读取的二进制数据里没有这个字段时,Avro 会用默认值填充。这在 Schema 演进时非常有用。

4.3 Array 与 Map:集合类型的正确姿势

Array 和 Map 是 Avro 里最常用的两种集合类型。Array 对应列表,Map 对应字典。写法上都很直观,但有几个坑需要留意。

4.3.1 Array 类型

Array 的写法是 {"type": "array", "items": "..."}。items 指定数组里每个元素的类型,可以是基本类型,也可以是复杂类型。

{
  "type": "record",
  "name": "Order",
  "namespace": "com.example.order",
  "fields": [
    {"name": "orderId", "type": "string"},
    {"name": "items", "type": {
      "type": "array",
      "items": {
        "type": "record",
        "name": "OrderItem",
        "fields": [
          {"name": "productId", "type": "string"},
          {"name": "quantity", "type": "int"},
          {"name": "price", "type": "double"}
        ]
      }
    }}
  ]
}

你看,Array 里嵌套了一个 Record,这是非常常见的模式。我建议把内层的 Record 单独定义,而不是写成匿名内嵌。为什么?因为如果其他地方也要引用这个 OrderItem,单独定义就能复用,而且 Schema 的可读性会好很多。

4.3.2 Map 类型

Map 的写法是 {"type": "map", "values": "..."}。注意,Map 的 key 永远是 string,你不能改成 int 或其他类型。这是 Avro 的设计限制,习惯了就好。

{
  "type": "record",
  "name": "Product",
  "namespace": "com.example.product",
  "fields": [
    {"name": "productId", "type": "string"},
    {"name": "attributes", "type": {
      "type": "map",
      "values": "string"
    }}
  ]
}

这个例子里的 attributes 字段,可以用来存产品的自定义属性,比如颜色、尺寸、材质等等。Map 的 values 也可以是复杂类型,比如嵌套一个 Record 或另一个 Map。

小技巧:如果你需要 Map 的 key 不是 string,可以用 Array 加一个 key-value 的 Record 来模拟。虽然麻烦一点,但能绕过 Avro 的限制。

4.4 Union 类型:多选一的艺术

Union 是 Avro 里最灵活也最容易出错的类型。它用 JSON 数组表示,数组里的每个元素都是一个 Schema。比如 ["null", "string"] 表示这个字段可以是 null 或 string。

Union 有几个硬性规则:

  • 不能有重复类型:比如 ["string", "string"] 是非法的。
  • null 通常放在第一位:这是 Avro 社区的惯例,但不是强制要求。我个人习惯把 null 放第一位,因为这样读起来更自然——"这个字段可能为空"。
  • Union 里的类型顺序很重要:Avro 序列化时,会按照 Union 里类型的顺序来编码。如果生产者和消费者对 Union 类型的顺序不一致,反序列化就会出错。

来看一个复杂的 Union 例子:

{
  "type": "record",
  "name": "Notification",
  "namespace": "com.example.notify",
  "fields": [
    {"name": "channel", "type": ["null", "string"], "default": null},
    {"name": "payload", "type": [
      "null",
      {"type": "record", "name": "EmailPayload", "fields": [
        {"name": "subject", "type": "string"},
        {"name": "body", "type": "string"}
      ]},
      {"type": "record", "name": "SmsPayload", "fields": [
        {"name": "phone", "type": "string"},
        {"name": "message", "type": "string"}
      ]}
    ], "default": null}
  ]
}

这个 Notification 的 payload 字段可以是 null、EmailPayload 或 SmsPayload。这种写法在消息路由场景里非常常见。我曾经在做一个多通道通知系统时,就用 Union 来区分邮件、短信和推送通知,效果很好。

避坑指南:Union 里的 Record 如果定义了相同的 name,即使 namespace 不同,Avro 也可能报错。我建议给每个 Union 成员 Record 起一个独一无二的名字,比如 EmailPayload、SmsPayload,而不是都叫 Payload。

4.5 嵌套:组合的艺术

Avro Schema 支持任意深度的嵌套。你可以把 Record 套 Record,Array 套 Record,Map 套 Union,怎么组合都行。但嵌套太深会让 Schema 变得难以维护,我个人建议控制在 3 层以内。

来看一个综合例子,把前面讲的所有类型都用上:

{
  "type": "record",
  "name": "OrderEvent",
  "namespace": "com.example.order",
  "fields": [
    {"name": "eventId", "type": "string"},
    {"name": "eventType", "type": {
      "type": "enum",
      "name": "EventType",
      "symbols": ["CREATED", "PAID", "SHIPPED", "DELIVERED"]
    }},
    {"name": "order", "type": {
      "type": "record",
      "name": "Order",
      "fields": [
        {"name": "orderId", "type": "string"},
        {"name": "userId", "type": "string"},
        {"name": "items", "type": {
          "type": "array",
          "items": {
            "type": "record",
            "name": "OrderItem",
            "fields": [
              {"name": "productId", "type": "string"},
              {"name": "quantity", "type": "int"},
              {"name": "price", "type": "double"}
            ]
          }
        }},
        {"name": "metadata", "type": {
          "type": "map",
          "values": "string"
        }},
        {"name": "discount", "type": ["null", {
          "type": "record",
          "name": "Discount",
          "fields": [
            {"name": "code", "type": "string"},
            {"name": "amount", "type": "double"}
          ]
        }], "default": null}
      ]
    }}
  ]
}

这个 Schema 里包含了:

  • 一个 Enum(EventType)
  • 一个 Record(Order)嵌套了另一个 Record(OrderItem)
  • 一个 Array(items)
  • 一个 Map(metadata)
  • 一个 Union(discount,可以是 null 或 Discount Record)

你看,所有类型都组合在一起了。这种 Schema 在实际项目中非常典型,几乎涵盖了 80% 的消息场景。

4.6 知识体系总览

下面这张图把 Avro Schema 的复杂类型和嵌套关系梳理清楚了,你可以对照着看:

Avro Schema 复杂类型与嵌套关系 Record(根) Enum(枚举) Array(数组) Map(字典) Union(联合) items: Record values: String 成员: Record Record 嵌套 Record 关键说明: 1. Record 是根节点,所有复杂类型都从 Record 的 fields 开始。 2. Array 的 items 和 Map 的 values 可以是任意类型(包括 Record、Union 等)。 3. Union 的成员不能重复,null 通常放在第一位。 4. 嵌套深度建议控制在 3 层以内,过深会影响可读性和维护性。

4.7 实战建议

最后,分享几个我在项目中积累的经验:

  • 先画图再写 Schema:我习惯先在白板上画出数据模型的关系图,确定哪些字段是 Record、哪些是 Array、哪些是 Union,然后再动手写 JSON。这样不容易漏字段。
  • Union 里的 null 一定要给 default:否则 Schema 演进时,新增的 Union 字段会让旧数据反序列化失败。这个坑我踩过两次,现在每次写 Union 都会检查 default。
  • Enum 的 symbols 顺序不要轻易改:虽然 Avro 允许追加,但删除或重排序会导致兼容性问题。如果你非要改,建议新建一个 Enum 并废弃旧的。
  • 嵌套 Record 尽量单独定义:不要把所有 Record 都写在 fields 里。单独定义后,其他地方也能引用,而且 Schema 更清晰。