契约设计基础:Schema定义、数据类型约定、版本号管理、兼容性策略

好,咱们进入契约设计的核心部分。说白了,实时特征场景里的契约,就是一套大家必须遵守的“数据规矩”。没有规矩,不成方圆。我在好几个项目里都见过,因为一开始没把契约定清楚,后面联调、上线、排查问题,那叫一个痛苦。

今天咱们就掰开揉碎,把契约设计的四个基础模块讲透:Schema定义、数据类型约定、版本号管理、兼容性策略。这四个东西,是实时特征契约的“四梁八柱”。

Schema定义:把数据长什么样说清楚

Schema,就是数据的“身份证”。它规定了每条数据有哪些字段,每个字段叫什么名字,是什么类型,是必须填还是可选。

我个人习惯,在实时特征场景里,用Avro Schema或者Protobuf来定义。为什么?因为它们自带IDL(接口描述语言),能生成强类型的代码,而且序列化效率高。

举个例子,一个“用户实时点击特征”的Avro Schema长这样:

{
  "type": "record",
  "name": "UserClickFeature",
  "namespace": "com.example.feature",
  "fields": [
    {"name": "user_id", "type": "string", "doc": "用户唯一标识"},
    {"name": "item_id", "type": "string", "doc": "物品ID"},
    {"name": "click_time", "type": "long", "doc": "点击时间戳(毫秒)"},
    {"name": "click_type", "type": {"type": "enum", "name": "ClickType",
      "symbols": ["VIEW", "CLICK", "BUY"]}, "doc": "点击类型"},
    {"name": "extra_info", "type": ["null", "string"], "default": null,
      "doc": "扩展信息"}
  ]
}

你看,这里面有几个关键点:

  • 字段名:统一用下划线命名法,别一会儿驼峰一会儿蛇形。
  • 类型:明确是string、long还是枚举。枚举值要提前定好,别后面加个“UNKNOWN”搞得大家都要改。
  • 默认值:对于可选字段,一定要给default。不然下游消费老数据时会报错。
  • doc:写清楚这个字段是干嘛的。别偷懒,三个月后你自己都忘了。
我的小技巧: 在Schema里加一个“_version”字段,记录这条数据是哪个版本产生的。排查问题时,这个字段能救命。

数据类型约定:别让类型成为“坑”

数据类型约定,是契约里最容易出问题的地方。我遇到过最离谱的一次,上游把用户ID从string改成了int,下游消费端直接抛异常,整个特征链路断了半小时。

所以,数据类型约定要遵循几个原则:

  1. 时间戳统一用long:别用string存时间,格式五花八门。统一用毫秒级long,时区统一用UTC。
  2. ID类字段用string:用户ID、订单ID,哪怕看起来是数字,也用string。因为有些ID可能带字母、带下划线。
  3. 金额用long或decimal:别用float/double,精度会丢。用long存“分”,或者用decimal。
  4. 枚举用enum:别用int表示状态,0代表什么、1代表什么,没人记得住。用enum,代码可读性高,IDE还能自动补全。

下面这个表格,是我在项目里常用的数据类型映射:

业务含义 推荐类型 不推荐类型 原因
用户ID string int/long ID可能带字母或前导0
时间戳 long (毫秒) string 格式统一,方便计算
金额 long (分) float/double 避免精度丢失
状态 enum int 可读性强,不易出错
扩展字段 map<string, string> string (JSON) 结构化,方便查询
注意: 千万别在实时链路上用JSON string存复杂结构。解析开销大,而且Schema没法校验。用map或者嵌套record更靠谱。

版本号管理:给契约一个“身份证号”

版本号管理,是契约演进的“方向盘”。没有版本号,你根本不知道线上跑的是哪个版本的契约。

我建议用语义化版本号:主版本号.次版本号.修订号。

  • 主版本号:不兼容的变更。比如删字段、改字段类型。
  • 次版本号:向后兼容的新增。比如加一个可选字段。
  • 修订号:内部修正,不影响接口。比如改doc描述。

版本号放在哪里?两个地方:

  1. Schema文件里:作为元数据字段。
  2. 消息头里:每条消息都带上版本号。这样下游消费时,可以根据版本号做不同的处理。

举个例子,消息头的格式:

{
  "schema_version": "2.1.0",
  "data": {
    "user_id": "u12345",
    "click_time": 1700000000000
  }
}

这样,下游拿到消息,先看版本号,再决定怎么解析。我曾经在一个项目里,就是因为没在消息头里带版本号,导致新旧数据混在一起,排查了整整两天。

兼容性策略:让新旧版本“和平共处”

兼容性策略,是契约设计的“安”。实时特征场景里,上下游不可能同时升级。所以,你必须定义好:什么样的变更算兼容?什么样的算不兼容?

常见的兼容性策略有三种:

策略 定义 适用场景 例子
向后兼容 新版本可以读旧版本的数据 消费者先升级,生产者后升级 新增可选字段
向前兼容 旧版本可以读新版本的数据 生产者先升级,消费者后升级 删除字段(用默认值填充)
完全兼容 同时满足向后和向前兼容 上下游独立升级,互不影响 新增可选字段 + 删除字段有默认值

我个人建议,在实时特征场景里,尽量追求完全兼容。因为上下游的升级节奏很难对齐。你想想看,一个特征链路可能涉及十几个服务,每个服务都有自己的发布窗口。如果每次变更都要求上下游同时升级,那项目就别干了。

核心原则: 只加不减,加可选不加必填。这样能最大程度保证兼容性。

那如果实在要删字段呢?我的做法是:

  1. 先把字段标记为废弃(deprecated),但保留在Schema里。
  2. 通知所有下游,告诉他们这个字段要删了,让他们尽快迁移。
  3. 等所有下游都确认迁移完了,再在下一个主版本里删掉。

这个过程,我称之为“温柔地删除”。别上来就删,会出人命的。

知识体系总览

下面这张图,把契约设计的基础模块串起来了。你可以把它当作一个“检查清单”,每次设计契约时,对着过一遍。

契约设计基础模块总览 契约设计基础 Schema定义 字段名、类型、默认值 数据类型约定 时间戳、ID、金额 版本号管理 语义化版本号 兼容性策略 向后/向前/完全 核心原则 只加不减,加可选不加必填 版本号放在Schema和消息头两个地方 变更前先通知所有下游,温柔地删除 目标:让上下游独立演进,互不阻塞

嗯,到这里,契约设计的基础模块就讲完了。这四个东西,是你在设计任何实时特征契约时,都必须考虑的。别嫌麻烦,前期多花点时间把契约定清楚,后期能省下十倍的时间去排查问题。

我记得有一次,一个同事问我:“为什么你们的特征链路从来没出过兼容性问题?”我说:“因为我们在设计契约时,就把兼容性策略写进了Schema里,而且每次变更都走评审流程。” 说白了,就是把“规矩”定在前面,而不是出了问题再补。