作者侯盛鑫

关于 Apache Pulsar

Apache Pulsar 是 Apache 软件基金会顶级项目,是下一代云原生分布式消息流平台,集消息、存储、轻量化函数式计算为一体,采用计算与存储分离架构设计,支持多租户、持久化存储、多机房跨区域数据复制,具有强一致性、高吞吐、低延时及高可扩展性等流数据存储特性。

GitHub 地址:http:///apache/pulsar/

在进行 Schema 管理前需保证 Pulsar 正常收发的使用没有问题。首先明确一下什么是 Schema ?

在数据库中 Schema 是数据的组织和结构,如果把 Pulsar 比做关系型数据库,那么 Topic 就存储着关系型数据库磁盘文件中的字节,而 Schema 就起着将关系型数据库磁盘文件里的字节转成有具体的类型数据库表一样的作用,属于数据表的元信息。那在消息队列中我们为什么需要 Schema 管理呢?下面我们带着疑问来看 Pulsar Schema 的使用。

问题背景

当前消息队列整体系统可用性趋于稳定,但是在使用过程中,上下游数据的安全性还没有得到有效保障,举个栗子:

type TestCodeGenMsg struct {
- Orderid int64 `json:"orderid"`
+ Orderid string `json:"orderid"`
Uid int64 `json:"uid"`
Flowid string `json:"flowid"`
}

这种“不兼容”的格式将破坏大多数下游服务,因为他们期望数字类型但现在得到一个字符串。我们不可能提前知道会造成多少损害。例子中,人们很容易将责任归咎于“沟通不畅”或“缺乏适当的流程”。

首先在开发中 API 被视为微服务架构中的一等公民,因为 API 是一种契约,有较强的约束性,任何协议改动都能提前很快感知,但是消息队列的事件消费往往并不能快速做出响应和测试,当大规模修模型时,尤其是涉及到写数据库时,很可能造成和 API 失败一样的负面结果。这里我推荐 Gwen Shapira 之前写了一篇文章,介绍了数据契约和schema管理[1],我们期望基于简单的兼容策略管理 Schema 的变化,让数据安全地演进,解耦团队并允许他们独立快速行动开发。这就是我们为什么需要 Schema 管理。

期望达到的目标

基于兼容策略,管理起 schema,让数据安全地演进,如:

type TestCodeGenMsg struct {    Orderid     int64     `json:"orderid"`    Uid         int64     `json:"uid"`    Flowid      string    `json:"flowid"`+   Username    string    `json:"username"`}

如下则不通过:

//校验不通过
type TestCodeGenMsg struct {
- Orderid int64 `json:"orderid"`
+ Orderid string `json:"orderid"`
Uid int64 `json:"uid"`
Flowid string `json:"flowid"`
}

我们如何用

消息模型和 API 之间的主要区别在于,事件及其模型的存储时间很长。一旦您升级完所有调用此 API 的应用程序从 v1 --> v2 ,您就可以放心地假设使用 v1 的服务已经消失了。这可能需要一些时间,但通常以周而不是年来衡量。但是对于可以永远存储旧版本消息队列的事件,情况并非如此。需要考虑以下问题:我们首先升级谁——消费者还是生产者?新的消费者能否处理仍然存储在 Pulsar 中的旧事件?在升级消费者之前,我们需要等待吗?老消费者能否处理新生产者编写的事件?

Pulsar Schema 定义了一些兼容性规则,这些规则涉及我们可以在不破坏消费者的情况下对Schema 进行哪些更改,以及如何处理不同类型 Schema 更改的升级。具体怎么做呢?我们需要首先在 broker 上确认我们是否支持自动演进和当前 namespace 下的 schema 兼容策略,其中兼容策略有:点击详情[2],或参考如下表格:

博文推荐|有效管理数据安全性—— Pulsar Schema 管理_python

我们通过 CLI 进行操作

// 查询当前namespace是否支持schema自动演进
./pulsar-admin namespaces get-is-allow-auto-update-schema tenant/namespace

// 如果不支持则打开
./pulsar-admin namespaces set-is-allow-auto-update-schema --enable tenant/namespace

// 查询当前namespace的schema演进策略
./pulsar-admin namespaces get-schema-compatibility-strategy tenant/namespace

// 这么多策略,总有一款适合你
./pulsar-admin namespaces set-schema-compatibility-strategy -c FORWARD_TRANSITIVE tenant/namespace

生产者

然后接入生产者,首先看下面示例:

package main
import (
"context"
"fmt"
"/apache/pulsar-client-go/pulsar"
)
type TestSchema struct {
Age int `json:"age"`
Name string `json:"name"`
Addr string `json:"addr"`
}
const AvroSchemaDef = "{\"type\":\"record\",\"name\":\"test\",\"namespace\":\"CodeGenTest\",\"fields\":[{\"name\":\"age\",\"type\":\"int\"},{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"addr\",\"type\":\"string\"}]}"
var client *pulsar.Client
func main() {
// 创建client
cp := pulsar.ClientOptions{
URL: "pulsar://xxx.xxx.xxx.xxx:6650",
OperationTimeout: 30 * time.Second,
}

var err error
client, err = pulsar.NewClient(cp)
if err != nil {
fmt.Println("NewClient error:", err.Error())
return
}
defer client.Close()

if err := Produce(); err != nil{
fmt.Println("Produce error:", err.Error())
return
}

if err := Consume(); err != nil{
fmt.Println("Consume error:", err.Error())
return
}
}


func Produce() error {

// 创建schema
properties := make(map[string]string)
pas := pulsar.NewAvroSchema(AvroSchemaDef, properties)
po := pulsar.ProducerOptions{
Topic: "persistent://test/schema/topic",
Name: "test_group",
SendTimeout: 30 * time.Second,
Schema: pas,
}

// 创建生产者
producer, err := client.CreateProducer(po)
if err != nil {
fmt.Println("CreateProducer error:", err.Error())
return err
}
defer producer.Close()

// 写消息
t := TestSchema{
Age: 10,
Name: "test",
Addr: "test_addr",
}

id, err := producer.Send(context.Background(), &pulsar.ProducerMessage{
Key: t.Age,
Value: t,
EventTime: time.Now(),
})
if err != nil {
fmt.Println("Send error:", err.Error())
return err
}

fmt.Println("msgId:", id)
}

以上 demo 完成了一个带有 schema 的生产者,我们翻阅生产者 ProducerOptions 类(struct),发现有 Schema 成员,于是知道需要传入一个 Schema 对象进去。我们接着到 new 一个 Schema 对象出来,通过:

properties := make(map[string]string)
jas := pulsar.NewAvroSchema(jsonAvroSchemaDef, properties)

我们创建除了一个 Avro 类型的 schema,除此之外还有很多,例如:json、pb 等,可根据需求自己选择如果您有兴趣阅读更多相关内容,Martin Kleppmann 写了一篇很好的博客文章,比较不同数据格式中的模型演变[3]。然后来看一下是什么把数据结构进行了限制。其中有一个常量如下:

const jsonAvroSchemaDef = "{\"type\":\"record\",\"name\":\"test\",\"namespace\":\"CodeGenTest\",\"fields\":[{\"name\":\"age\",\"type\":\"int\"},{\"name\":\"name\",\"type\":\"string\"},{\"name\":\"addr\",\"type\":\"string\"}]}"

展开来看:

{
"type":"record",
"name":"test",
"namespace":"Test",
"fields":[
{
"name":"age",
"type":"int
},
{
"name":"name",
"type":["null","string"] // 表示可选字段
},
{
"name":"addr",
"type":"string"
"default":"beijing", // 表示默认字段
}
]
}

这是一个 avro schema(所有的校验类型都用这个写法),其中 fields 表示需要的字段名以及类型,同时要设置 schema 的 name 并指定 namespace,才能使用兼容性策略。关于 avro 的语法介绍可参考专栏[4],以及如下表类型:

博文推荐|有效管理数据安全性—— Pulsar Schema 管理_编程语言_02

消费者

首先看代码:

func Consume(ctx context.Context) error {
cas := pulsar.NewAvroSchema(AvroSchemaDef, properties)
consumer, err := client.Subscribe(pulsar.ConsumerOptions{
Topic: "persistent://base/test/topic",
SubscriptionName: "test",
Type: pulsar.Failover,
Schema: cas,
})
if err != nil {
return err
}
defer consumer.Close()

for {
msg, err := consumer.Receive(ctx)
if err != nil {
return err
}

t := TestSchema{}
if err := msg.GetSchemaValue(&t);err != nil{
continue
}

consumer.Ack(msg)
fmt.Println("msgId:", (), " Payload:", string(msg.Payload()), " t:", t)
}
}

我们可以看到,如果使用 schema 的话,我们最后要用 GetSchemaValue() 方法反序列化消息,才能真正保证安全,整个生产消费的框架大体如此。之后我们就涉及到一个概念,即 schema 演进:schema 原理 Schema 的工作流程,如图:

博文推荐|有效管理数据安全性—— Pulsar Schema 管理_编程语言_03

Confluent 公司在 Kafka 中开发了一个独立于 broker 协调的 schema registry server 。它的工作流程是:

•我们向 Kafka 发送数据时,需要先向 Schema Registry 注册 schema,然后序列化发送到 Kafka 里;•Schema Registry server 为每个注册的 schema 提供一个全局唯一 ID,分配的 ID 保证单调递增,但不一定是连续的;•当我们需要从 Kafka 消费数据时,消费者在反序列化前,会先判断 schema 是否在本地内存中,如果不在本地内存中,则需要从 Schema Registry 中获取 schema,否则,无需获取。

Pulsar 不同的是:•Pulsar 自带的 schema 演进的管理,并把相关 schema 信息存储在 bookie 上;•schema 信息不在 Pulsar 的消息协议里;•消费端需要自己传入 schema。

虽然其原理也是和 Kafka 类似,但是 Pulsar 采用 schema server 和 broker 不分离的设计,schema 的信息存储在 bookie 上,这样解决了 schema server 高可用的问题,其中 schema 演进的兼容检测是在 broker 侧进行的(这里不是说的序列化和反序列化)。

博文推荐|有效管理数据安全性—— Pulsar Schema 管理_python_04

那客户端做了什么?根据如上我们得知,最后保证 schema 安全的实际上是一次相应类型的 decode 和 encode 的检查,从源码看,在创建生产者和消费者过程中,都会对传入的schema进行检测,这里是一个独立的消息结构。

博文推荐|有效管理数据安全性—— Pulsar Schema 管理_java_05

而消费者用到的方法实际上就是我们刚才说的 decode() 方法。

博文推荐|有效管理数据安全性—— Pulsar Schema 管理_编程语言_06

相应类型只需要实现 schema 接口:

type Schema interface {
Encode(v interface{}) ([]byte, error)
Decode(data []byte, v interface{}) error
Validate(message []byte) error
GetSchemaInfo() *SchemaInfo
}

具体实现可参考 Pulsar Go Client 相关文件[5],其中有多种序列化数据类型的实现。

补充

Schema 作为 Pulsar Topic 的元数据可以提供给 Pulsar SQL 进行使用,Pulsar SQL 存储层实现了 Presto connector 的接口,Schema 会作为 Presto payload 的元数据在 SQL 层进行展示,大大方便了我们查看消息,数据分析等工作,以上同我补充中所说的就是我们需要 Schema 管理的理由。感谢阅读。

作者简介

我叫侯盛鑫,也可以我叫大云,目前就职于伴鱼基础架构,负责消息队列的维护与相关开发,Rust 日报小组中的菜鸡成员,喜欢研究存储,服务治理等方向。初次接触 Pulsar 就对存储和计算分离的结构所吸引,顺滑的生产者消费者接入和高吞吐让我好奇这个项目的实现,期望之后能在 Pulsar 的相关功能中做些贡献。


引用链接

[1]​介绍了数据契约和 schema 管理:https://www.confluent.io/blog/schemas-contracts-compatibility

[2]​ 点击详情: https://pulsar.apache.org/docs/zh-CN/next/schema-evolution-compatibility/#schema-%E5%85%BC%E5%AE%B9%E6%80%A7%E6%A3%80%E6%9F%A5%E7%AD%96%E7%95%A5

[3]​比较不同数据格式中的模型演变: https://martin.kleppmann.com/2012/12/05/schema-evolution-in-avro-protocol-buffers-thrift.html

[4]​ 参考专栏: https://zhuanlan.zhihu.com/p/24803426

[5]​ 相关文件: https:///apache/pulsar-client-go/blob/master/pulsar/schema.go

博文推荐|有效管理数据安全性—— Pulsar Schema 管理_编程语言_07