163 lines
7.7 KiB
Markdown
163 lines
7.7 KiB
Markdown
---
|
||
tags: [MQ, Schema, 消息序列化, Kafka]
|
||
create time: 2026-05-24 19:52
|
||
---
|
||
|
||
# MQ Schema 管理与演进
|
||
|
||
## 概述
|
||
|
||
消息队列中,Producer 和 Consumer 通过"约定"消息格式来通信。当业务迭代导致消息结构变更时,如果没有统一的 Schema 管理机制,线上就会出现序列化/反序列化失败、数据丢失、静默错误等问题。Schema Registry 提供了集中化的 Schema 管理、版本控制和兼容性校验能力,是大规模消息系统不可或缺的基础设施。
|
||
|
||
## 正文
|
||
|
||
### 为什么需要 Schema 管理
|
||
|
||
想象一个电商系统:订单服务往 Kafka 发送订单消息,下游有 5 个消费者。某天订单服务给消息体加了一个 `discount` 字段,但没通知下游。结果:
|
||
|
||
- 某些消费者用强类型反序列化,直接报错崩溃
|
||
- 某些消费者用 JSON 解析,虽然没报错但忽略了新字段,数据不一致
|
||
- 某些老版本消费者尝试反序列化,拿到的 `discount` 值为零,业务逻辑出错
|
||
|
||
> [!question]
|
||
> 如果你的系统只有 2 个服务、消息格式一年都不变一次,还需要 Schema Registry 吗?想一想"现在不需要"和"未来不需要"的区别。
|
||
|
||
这些问题的根源在于:**Producer 和 Consumer 之间缺少一个权威的消息格式定义和变更协调机制**。Schema 管理要解决的核心问题就是:
|
||
|
||
1. **消息格式的"单一事实来源"** —— 所有服务对同一份 Schema 达成共识
|
||
2. **版本演进的可控性** —— 字段增删改必须经过兼容性校验
|
||
3. **生产环境的安全网** —— 不兼容的变更在发布前就被拦截,而不是在线上爆炸
|
||
|
||
### Schema Registry 的作用
|
||
|
||
Schema Registry 是一个独立的服务,扮演消息格式"中间人"的角色。它的核心职责有三个:
|
||
|
||
**集中管理 Schema**。所有 Topic 的消息格式都注册到 Registry,Producer 发消息前必须先注册 Schema,Consumer 拉消息时根据 Schema ID 动态获取 Schema 来反序列化。任何一方都不能"自说自话"。
|
||
|
||
**版本控制**。每次 Schema 变更都会生成一个新版本,保留历史记录。可以回溯到任意版本,也可以比较两个版本之间的差异。
|
||
|
||
**兼容性校验**。这是最关键的能力。当 Producer 尝试注册一个新版本的 Schema 时,Registry 会根据配置的兼容性策略,自动判断新旧 Schema 是否兼容。不兼容直接拒绝注册,从源头阻止破坏性变更。
|
||
|
||
### 主流编码格式对比
|
||
|
||
| 格式 | 可读性 | 体积 | 演进支持 | 典型场景 |
|
||
|:---:|:---:|:---:|:---:|------|
|
||
| JSON | 高 | 大 | 弱 | 调试、小规模系统、前后端交互 |
|
||
| Avro | 低 | 小 | 强 | Kafka 生态首选、大数据管道 |
|
||
| Protobuf | 低 | 小 | 强 | gRPC、跨语言高性能通信 |
|
||
| Thrift | 低 | 小 | 中 | 内部 RPC、Facebook 生态 |
|
||
|
||
JSON 的优势在于人可以直接读,调试方便,但它没有原生的 Schema 概念——你可以在 JSON 里随便加字段删字段,编译器不会报错,运行时才发现问题。Avro 和 Protobuf 都要求预先定义 Schema(`.avsc` / `.proto` 文件),序列化时只传数据不传 Schema,体积小得多,而且有完善的字段编号机制来支持 Schema 演进。
|
||
|
||
> [!question]
|
||
> Avro 和 Protobuf 都是二进制格式,都支持 Schema 演进,那 Kafka 生态为什么更偏爱 Avro?(提示:想想"读时 Schema"和"写时 Schema"的区别)
|
||
|
||
### Schema 兼容性策略
|
||
|
||
兼容性策略决定了什么样的 Schema 变更是允许的。这是 Schema 管理的核心规则:
|
||
|
||
| 策略 | 含义 | 允许的变更 | 风险 |
|
||
|------|------|------|------|
|
||
| **向前兼容** (Forward) | 新 Schema 能读旧数据 | 只能给新字段加默认值 | Consumer 升级后能读老数据 |
|
||
| **向后兼容** (Backward) | 旧 Schema 能读新数据 | 只能删除有默认值的字段 | Consumer 未升级也能读新数据 |
|
||
| **完全兼容** (Full) | 同时满足前向和向后 | 只能加有默认值的字段 | 最安全,但变更空间最小 |
|
||
| **无兼容** (None) | 任意变更 | 不限制 | 线上随时可能炸 |
|
||
|
||
实际生产中最常见的是 **Backward 兼容**——因为 Consumer 的升级通常滞后于 Producer。你加新字段时带上默认值,老版本 Consumer 反序列化时会忽略新字段,不会报错。等 Consumer 也升级后,就能利用新字段了。
|
||
|
||
> [!question]
|
||
> 为什么实际生产中 Backward 比 Forward 更常用?如果你的场景是 Consumer 先升级、Producer 后升级呢?
|
||
|
||
### Confluent Schema Registry 工作原理
|
||
|
||
Confluent Schema Registry 是 Kafka 生态中最广泛使用的 Schema 管理方案,核心概念有三个:
|
||
|
||
- **Subject**:Schema 的逻辑分组,通常以 `{topic-name}-key` 或 `{topic-name}-value` 命名
|
||
- **Schema ID**:每个注册的 Schema 都有一个全局唯一 ID,写入消息时只带 ID,不带完整 Schema
|
||
- **Registry API**:RESTful 接口,支持 Schema 的注册、查询、兼容性检查
|
||
|
||
工作流程如下:
|
||
|
||
```mermaid
|
||
sequenceDiagram
|
||
participant Producer
|
||
participant Registry as "Schema Registry"
|
||
participant Broker as "Kafka Broker"
|
||
participant Consumer
|
||
|
||
Producer->>Registry: POST /subjects/order-value/versions
|
||
Note right of Registry: 校验兼容性策略
|
||
Registry-->>Producer: 返回 Schema ID (如 42)
|
||
|
||
Producer->>Broker: 发送消息 [Magic Byte + Schema ID 42 + Avro 数据]
|
||
Broker->>Consumer: 拉取消息
|
||
|
||
Consumer->>Registry: GET /schemas/ids/42
|
||
Registry-->>Consumer: 返回 Schema 定义
|
||
Consumer->>Consumer: 根据 Schema 反序列化 Avro 数据
|
||
```
|
||
|
||
关键细节:消息体的前 5 个字节是固定的——1 字节 Magic Byte(固定为 0)+ 4 字节 Schema ID。Consumer 读到这 5 个字节就知道该用哪个 Schema 来反序列化。Schema 本身会缓存在 Consumer 本地,不需要每次都去 Registry 拉取。
|
||
|
||
### Go 代码示例:使用 Avro 编解码
|
||
|
||
```go
|
||
package main
|
||
|
||
import (
|
||
"fmt"
|
||
"log"
|
||
|
||
"github.com/linkedin/goavro/v2"
|
||
)
|
||
|
||
func main() {
|
||
// 定义 Avro Schema
|
||
schema := `{
|
||
"type": "record",
|
||
"name": "Order",
|
||
"fields": [
|
||
{"name": "order_id", "type": "string"},
|
||
{"name": "amount", "type": "double"},
|
||
{"name": "discount", "type": ["null", "double"], "default": null}
|
||
]
|
||
}`
|
||
|
||
// 创建 Codec(编解码器)
|
||
codec, err := goavro.NewCodec(schema)
|
||
if err != nil {
|
||
log.Fatal(err)
|
||
}
|
||
|
||
// 编码:Go map -> Avro 二进制
|
||
order := map[string]interface{}{
|
||
"order_id": "ORD-2026001",
|
||
"amount": 299.99,
|
||
"discount": map[string]interface{}{"double": 30.0},
|
||
}
|
||
binary, err := codec.BinaryFromNative(nil, order)
|
||
if err != nil {
|
||
log.Fatal(err)
|
||
}
|
||
fmt.Printf("Avro 编码后 %d 字节,原始 JSON 约 %d 字节\n", len(binary), 80)
|
||
|
||
// 解码:Avro 二进制 -> Go map
|
||
decoded, _, err := codec.NativeFromBinary(binary)
|
||
if err != nil {
|
||
log.Fatal(err)
|
||
}
|
||
fmt.Printf("解码结果: %v\n", decoded)
|
||
}
|
||
```
|
||
|
||
这段代码展示了 Avro 的核心用法:先定义 Schema(JSON 格式的 `.avsc`),再用 Codec 做编解码。注意 `discount` 字段用了 union 类型 `["null", "double"]`,并设了默认值 `null`——这就是保证向后兼容的关键。即使老版本的消息里没有 `discount` 字段,反序列化时也会得到 `null`,不会报错。
|
||
|
||
在实际 Kafka 场景中,你还需要把 Schema 注册到 Registry,拿到 Schema ID,然后在消息体前面拼上 Magic Byte + Schema ID,再发到 Broker。Consumer 端反过来:先读 5 字节头,从 Registry 获取 Schema,再解码。
|
||
|
||
## 关联笔记
|
||
|
||
- [[06-高级特性/19-MQ-消息过滤与路由|MQ 消息过滤与路由]]
|
||
- [[06-高级特性/21-MQ-消息压缩与批处理|MQ 消息压缩与批处理]]
|
||
- [[07-主流MQ对比/22-Kafka|Kafka]]
|
||
- [[12-架构与实战/48-MQ-客户端-SDK-最佳实践|MQ 客户端 SDK 最佳实践]]
|