
如果你维护的 Java 服务超过两个并且和别人约定过 MQ 里的消息长什么样大概率经历过这种场面下游同事跑来问“你们发过来的订单消息字段是不是偷偷改了”你一脸懵翻代码、翻测试环境日志最后发现是有个同事上线时在消息体里多加了一个字段还把某个字段类型改了。消息中间件从来不管你 payload 的内部结构它只负责把字节从 A 搬到 B。于是怎么在 Java 里给 MQ 消息体定义一套可校验、可演进、跨团队可沟通的契约就成了每个消息驱动系统绕不开的工程问题。这篇文章聊的就是这件事用JSON Schema给MQ 消息体建模在 Producer 和 Consumer 两侧落下结构校验并分享我在 Java 项目里的完整落地方式。1. 为什么要在 MQ 消息体上套一层 JSON Schema1.1 没有契约的消息系统乱起来只是时间问题先说一个很实际的现象很多人给 MQ 消息定义“结构”的方式是建一个 Java DTO 类然后把对象序列化成 JSON 发出去。这种做法在单体里没问题一旦服务拆分、团队拆分问题就来了。第一个问题是跨语言。你这边发的是 Java 序列化后的 JSON下游可能是 Go、Python 写的消费者。消费者拿不到你的OrderEvent.class它只能靠文档或者抓一条测试消息猜结构。猜的时间长了两边对字段的理解就会悄悄分叉。第二个问题是演进无据。Java DTO 里加一个字段编译能过、测试能过发到 Broker 里也没人拦。可这个字段是必填还是选填老消费者能否兼容没有一份共享的、人眼可读的“消息说明书”这些全靠默契。第三个问题是故障排查看不到全貌。线上出了一条异常消息你想知道它当初发出来时到底长什么样、是否符合定义你只能去 Broker 里捞原文再看。而“是否符合定义”这件事在没有任何校验的情况下根本无从判断。JSON Schema 的价值恰恰在于它不是 Java 里的强类型而是一份独立于任何语言的 JSON 文档描述“消息体这个 JSON 必须长成什么样”。你可以在设计期用它当契约文档在运行期用它做校验在评审期直接看 diff。消息中间件不管的事让 Schema 来管。1.2 JSON Schema 到底解决谁的痛点有人可能会问用 Protobuf、Avro 不也能定义消息结构吗它们甚至还能做二进制序列化。为什么偏要用 JSON Schema我的理解是工具的取舍要看场景。如果是高吞吐、强契约、对性能极其敏感的核心链路Protobuf/AVRO 配合 Schema Registry 确实是好方案。但如果你的团队主要在用 JSON消息体复杂度中等上下游还可能有非 Java 服务JSON Schema 的侵入感会低很多。对比一下几个方案的特点方案结构定义运行时校验跨语言上手成本Java DTO 序列化弱无差最低Protobuf / Avro强编译期强好中高JSON Schema中强强可插拔好低我尤其看中 JSON Schema 的两点。第一描述即文档。Schema 文件本身就是人眼可读的字段有没有 optional、取值范围是多少一目了然。第二校验是运行时可以动态挂载的。你不需要把所有消费者都改成强类型反序列化在消费端对一个 JSON string 做校验成本低得多。1.3 引入成本和回报怎么权衡这套东西也不是没有成本。最直接的成本在于你需要维护一份额外的 Schema 文件并且要让团队习惯“消息变更先改 Schema 再动代码”。如果只是一两个内部服务消息也少完全靠 Java 类硬撑也行。但一旦消息种类超过十种、消息需要被多个消费者订阅或者你们经历过因为字段误改导致的故障再回头看这点维护成本简直太划算了。我见过不少系统线上排查问题最大的障碍就是“不知道这条消息本来该是什么样”。有了 Schema等于给每条消息配了一份图纸。2. JSON Schema 消息体建模先想清楚再写2.1 版本号与命名先解决“这是谁的契约”给 MQ 消息定义 JSON Schema第一步不是写properties而是定命名和版本。我踩过最大的坑就是大家传一个message-schema.json文件改来改去也不知道当前线上跑的是什么版本。我的习惯是每个事件一个独立 Schema 文件文件名带上领域名和版本号。比如订单创建事件文件名就叫order-created-v1.json如果未来加了字段、改了结构不要在原文件上大改新增一个order-created-v2.json在 Schema 内部用$id明确契约的唯一标识。这样无论是排查问题还是做消息头路由都能迅速定位到对应的定义。{ $schema: https://json-schema.org/draft/2020-12/schema, $id: https://schema.example.internal/order/order-created-v1, title: OrderCreatedEventV1, type: object }$schema表示这个文件遵循哪个 JSON Schema 标准版本$id是给这条契约发的一个“身份证号”。在 Java 侧使用 2020-12 或者 2019-09 都能得到主流校验库的良好支持。2.2 字段约束哪些关键词真正有用写的时候不要贪多。JSON Schema 的关键词很多但放到 MQ 消息体这个场景里真正高频且有价值的主要是这么几组基础类型type、properties、required。这是必须的。注意type要区分integer和numberinteger只接受整型金额字段如果可能带小数就用number。边界约束minimum、maximum、exclusiveMinimum、minLength、minItems。这些用来卡业务底线比如数量不能小于 1。结构约束additionalProperties。这是消息治理里最关键的开关。设为false之后消息体里一旦出现 Schema 里没定义的字段校验直接失败能拦住那些“悄悄加一个字段”的行为。格式约束format、pattern。format可以表达uuid、date-time等语义但要注意在 Java 侧默认不一定生效后面会说怎么开启。分支和常量const、enum、oneOf。适合表示事件类型、状态机这类取值有限集的字段。一个比较典型的 MQ 消息体 Schema 长这样{ $schema: https://json-schema.org/draft/2020-12/schema, $id: https://schema.example.internal/order/order-created-v1, title: OrderCreatedEventV1, type: object, properties: { eventId: { type: string, format: uuid }, eventTime: { type: string, format: date-time }, orderId: { type: string, minLength: 1 }, userId: { type: string, minLength: 1 }, amount: { type: number, exclusiveMinimum: 0 }, items: { type: array, minItems: 1, items: { type: object, properties: { skuId: { type: string, minLength: 1 }, quantity: { type: integer, minimum: 1 } }, required: [skuId, quantity], additionalProperties: false } } }, required: [eventId, eventTime, orderId, userId, amount, items], additionalProperties: false }required列表一定要精简。只有业务上没这个字段就完全无法消费的字段才放进required。比如userId如果是必填但某个历史流程拿不到用户 ID这个字段就不应该必填。消息契约里多写一个 required等于给下游加了一道硬约束宁少勿多。2.3 消息演进兼容加字段破坏性变更换版本消息体的演进规则我建议在团队里白纸黑字定下来这是用 JSON Schema 之后一定会遇到的决策点。新增一个 optional 字段属于兼容性变更。老消费者反序列化时忽略未知字段即可把字段加进原 Schema 文件放宽版本内部兼容即可。这时候additionalProperties如果设成false老消费者那边校验会失败吗不会因为老消费者校验的是它本地部署的 Schema 文件不是生产环境最新的一份。所以没问题。修改字段类型、删除字段、把一个 optional 字段改成 required都属于破坏性变更。正确做法是建v2文件新老消费者按版本各自处理。如果 Broker 里会同时存在新旧消息你会发现消息头里带上版本号这个习惯有多重要这个在第 4 章展开。这里还要注意一个细节JSON Schema 的exclusiveMinimum在不同草案里语义不同。在老草案 Draft-07 里它是布尔值配合minimum使用在 2019-09 和 2020-12 里它直接就是个数字。如果你用的是 2020-12像我上面那样写exclusiveMinimum: 0没问题但如果混用旧草案就会踩坑。3. Java 侧落地库选型与工具封装3.1 主流校验库怎么选Java 生态里能校验 JSON Schema 的库不少但真正值得放进生产环境的其实就几个。我主要用于这三个做对比库活跃度标准支持API 友好度适合场景com.networknt:json-schema-validator高Draft 4/6/7/2019-09/2020-12高大多数业务项目com.github.everit-org:json-schema中Draft 4/6/7中老项目或轻量需求com.github.java-json-tools:json-schema-validator低Draft 4/6/7低基本不推荐新项目如果没有特殊历史包袱我建议直接用networknt的json-schema-validator。原因很直接规范支持全、维护频率高、和 Jackson 集成顺畅而且同作者在做 OpenAPI 相关生态很多配置能力是共通的。它的校验错误信息里会带 JSON Path 路径排查字段问题时非常方便。3.2 依赖引入和 schema 文件组织Maven 项目里加入依赖dependency groupIdcom.networknt/groupId artifactIdjson-schema-validator/artifactId version1.5.4/version /dependency版本号以 Maven Central 上最新的 1.x 为准。注意这个库内部会依赖 Jackson建议和你的项目 Jackson 版本对齐免得出现父包冲突。Schema 文件放在src/main/resources/schemas/目录下每个事件一个文件。这样打包进 jar 之后代码里通过 classpath 路径加载非常干净不需要额外引入远程配置中心。如果团队已经用了配置中心也可以把 Schema 文件发布到配置中心统一管理但起步阶段我还是推荐直接放工程里改动走 Git 评审历史可追溯。3.3 封装一个带缓存的 SchemaValidator直接在校验的地方每次读文件、建工厂、解析 Schema性能上扛不住。正确姿势是进程内缓存JsonSchema实例用ConcurrentHashMap做资源定位就行因为 Schema 文件在运行期基本不会变。我封装的工具类长这样import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.networknt.schema.JsonSchema; import com.networknt.schema.JsonSchemaFactory; import com.networknt.schema.SchemaValidatorsConfig; import com.networknt.schema.SpecVersion; import com.networknt.schema.ValidationMessage; import java.io.InputStream; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; public class JsonMessageValidator { private static final ObjectMapper MAPPER new ObjectMapper(); private static final MapString, JsonSchema SCHEMA_CACHE new ConcurrentHashMap(); private JsonMessageValidator() { } public static void validate(String schemaPath, String messageBody) { JsonSchema schema SCHEMA_CACHE.computeIfAbsent(schemaPath, path - { SchemaValidatorsConfig config new SchemaValidatorsConfig(); // 关键让 format 关键字真正参与校验 config.setFormatAssertionsEnabled(true); JsonSchemaFactory factory JsonSchemaFactory.getInstance( SpecVersion.VersionFlag.V202012, config ); try (InputStream is JsonMessageValidator.class.getResourceAsStream(path)) { if (is null) { throw new IllegalArgumentException(Schema 资源不存在: path); } return factory.getSchema(is); } catch (Exception e) { throw new IllegalStateException(加载 Schema 失败: path, e); } }); try { JsonNode node MAPPER.readTree(messageBody); SetValidationMessage errors schema.validate(node); if (!errors.isEmpty()) { throw new IllegalArgumentException(消息体未通过 Schema 校验: errors); } } catch (IllegalArgumentException e) { throw e; } catch (Exception e) { throw new IllegalStateException(消息体不是合法 JSON, e); } } }几个要点说清楚。第一SCHEMA_CACHE是静态的进程内所有线程共享。ConcurrentHashMap#computeIfAbsent能保证同一个 Schema 路径只会被解析一次。这里我把Factory放在 lambda 里创建每次只是创建工厂缓存的是JsonSchema实例。第二校验和 JSON 解析分开捕获异常。MAPPER.readTree可能抛出 Jackson 的JsonProcessingException这说明消息体根本不是合法 JSON而schema.validate返回的校验错误说明消息体是 JSON 但不符合契约。两类问题定位路径不同异常类型分开更利于排查。第三config.setFormatAssertionsEnabled(true)很关键。按 JSON Schema 规范format默认只是“注解”不做校验。你不开这个开关format: uuid等于摆设写了个非法 UUID 的消息也能通过。调用方式很简单JsonMessageValidator.validate(/schemas/order-created-v1.json, messageBody);校验失败会抛出IllegalArgumentException错误信息里带了具体字段路径比如$.amount: 100.5 is not greater than 0 $.items: minimum items is 1, but found 0别小看这些路径信息它告诉我这条消息具体是哪个字段不合法比只看“校验失败”四个字有用得多。4. Producer 和 Consumer 两侧的接入姿势4.1 Producer 发送前校验把脏数据挡在门外Producer 侧校验的核心思路就一句话进 Broker 之前的每一条消息都要经过一次 Schema 校验校验不通过宁可抛异常也不要发出去。为什么宁可抛异常因为消息一旦进了 Broker就已经扩散出去了。消费者可能已经读到、日志可能已经落盘、别的团队可能已经基于它做了补偿操作你收不回来。所以最划算的拦截点就是入口。我用 RabbitMQ 做过一次接入大概长这样public void sendOrderCreated(OrderEvent event) { try { String body MAPPER.writeValueAsString(event); JsonMessageValidator.validate(/schemas/order-created-v1.json, body); rabbitTemplate.convertAndSend( order.exchange, order.created, body, message - { message.getMessageProperties().getHeaders().put(X-Schema, order-created-v1); return message; } ); } catch (IllegalArgumentException e) { // 这里建议做指标上报和告警而不是吞掉 metricRegistry.counter(mq.message.invalid).inc(); throw new OrderEventPublishException(订单事件校验失败, e); } }在校验和发送之间加指标计数非常关键。我当时上线之后发现虽然业务代码写得已经比较规范但测试环境里依然有几百条消息过不了校验大部分是userId为空的边缘场景。如果没有指标这东西上线后你根本不知道它拦住了多少脏数据。有一点要提醒不要在代码里把所有异常都吞掉后继续发消息。如果一条消息连基本的结构都不满足发送出去只会让问题在消费者那边以更隐蔽的方式爆发。宁可让生产链路报错、让上游感知到问题也不要静静地把坏消息发出去。4.2 Consumer 消费时校验兜住历史脏数据Consumer 侧校验的必要性很多人一开始不理解Producer 已经校验过了消费者为什么还要校验一遍真实世界的消息链路没有那么理想。你消费的 Topic 里可能混着好几个历史版本的消息可能有的 Producer 根本没有接入校验可能消息在中间被别的服务转发时改过字段。Consumer 这层校验本质是接收方对自己业务逻辑的最后一道保护。我在 Kafka 项目里通常会这样写KafkaListener(topics order.created, groupId order-sync) public void onMessage(ConsumerRecordString, String record) { String schemaVersion resolveSchemaVersion(record); try { JsonMessageValidator.validate(/schemas/ schemaVersion .json, record.value()); // 校验通过才进入真正的业务处理 handleOrderCreated(record.value()); } catch (IllegalArgumentException e) { // 绝不能静默吞掉建议进死信或者错误表 deadLetterSender.send(record, e); throw e; } }这里要特别强调失败处理策略。校验失败的消息最忌讳的做法是 catch 住之后打一行日志就继续往下走因为业务逻辑拿到的是一个残缺对象容易产生脏数据。我一般会把这部分消息打到死信队列或者独立错误表里保留原始消息体和失败原因后续统一排查和重放。从接入顺序来看我建议团队先把 Consumer 侧校验做了再推 Producer 侧。原因很简单Consumer 侧只影响你自己不会阻塞别人Producer 侧校验一旦上线可能会拦到一些历史消息需要处理对端团队的配合问题。4.3 用消息头传递 Schema 版本把契约带到消费端前面说了文件命名带版本号但要真正落地版本化消息还得让消费者知道“我现在收到的是哪个版本”。最简单可靠的办法是在 MQ 消息头里带上 Schema 版本标识。Kafka 的 Header 是天然的放置点record.headers().add(X-Schema, order-created-v1.getBytes(StandardCharsets.UTF_8));RabbitMQ 则放在 message properties 的 headers 里message.getMessageProperties().getHeaders().put(X-Schema, order-created-v1);消费者拿到消息头之后再决定用哪份 Schema 文件校验private String resolveSchemaVersion(ConsumerRecordString, String record) { Header header record.headers().lastHeader(X-Schema); if (header null) { // 兼容老消息默认走 v1 return order-created-v1; } String schemaName new String(header.value(), StandardCharsets.UTF_8); // 注意这里要做白名单校验不能直接用外部值拼路径 if (SCHEMA_WHITE_LIST.contains(schemaName)) { return schemaName; } throw new IllegalArgumentException(未知 Schema 版本: schemaName); }为什么强调白名单因为从消息头拿到的值属于不可信输入直接拼文件路径有被恶意指到其他资源的风险。一个简单的SetString白名单就能挡掉这类问题。这样做还有一个额外好处新老版本消息混跑时消费者可以按版本走不同的处理分支。比如 v1 用旧逻辑、v2 用新逻辑彻底避免顺序和版本错乱。5. 性能、兼容性与踩坑实录5.1 校验性能的账要这么算很多人一听“每条消息都要校验”第一反应是性能行不行。我在项目里做过简单的压测结论是可以接受。在常规虚拟机下对一个包含八九个字段、带数组嵌套的中等复杂 JSON 消息做一次完整校验耗时大约在几百微秒到一两毫秒之间。相比序列化、网络传输、Broker 读写的时间这部分占比很小。而且通过缓存JsonSchema实例解析开销被摊薄到第一次加载后续校验走的是内存里的对象树。要想让性能更稳还有两个实用技巧。技巧一尽量只做一次 JSON 解析。我在工具类里直接传字符串给MAPPER.readTree再把JsonNode传给 schema 校验全程只解析一次。如果你在业务代码里先反序列化成OrderEvent校验时又转一次字符串就白白浪费了两次解析。技巧二批量消息场景降低校验频率。如果单条消息只有几十字节业务上可以容忍也可以做采样校验比如每 100 条校 1 条。但我个人不推荐因为校验的目的就是兜结构错误采样意味着可能有漏网之鱼。真到了需要牺牲校验保吞吐的场景你应该考虑的是换消息格式而不是砍掉校验。5.2 我踩过的几个典型问题第一个是format 校验不生效。最早我用 networknt 库时写了format: uuid结果随便传123都能通过校验。折腾半天才发现这个库默认把 format 当注解处理必须显式开启 format assertion。这就是我在工具类里写config.setFormatAssertionsEnabled(true)的原因。第二个是exclusiveMinimum旧草案语义差异。项目里有同事从网上抄了一段 Schema用的是 Draft-07 的写法minimum: 0, exclusiveMinimum: true但我们的$schema声明的是 2020-12。在 2020-12 里exclusiveMinimum直接就是数字这种写法等于没有约束。这类问题特别隐蔽因为不是不校验而是校验的语义和你以为的不一样。第三个是additionalProperties: false对嵌套对象也要单独设置。很多人只在顶层开了additionalProperties: false结果消息里嵌套的 items 对象里多出来的字段完全没被拦住。JSON Schema 的additionalProperties只作用于它所在的properties层级不会递归应用到子对象所以嵌套对象要自己单独写。第四个是$ref相对路径找不到。如果 Schema 里用了内部$ref引用其他 Schema 文件一定要给根 Schema 设置$id并且相对引用要基于$id解析否则很容易报找不到引用地址。刚上手时可以把所有约束都写在一个文件里避免不必要的$ref复杂度。5.3 常见问题速查表现象原因解法format 校验不生效默认 format 是注解而非断言SchemaValidatorsConfig 开启 format assertions校验报错但找不到具体字段没有开启 format 或错误信息不清晰升级 networknt 库检查错误里的 JSON Path多个字段报错不知先处理哪个错误信息散落收集 Set 后按字段路径排序输出多了一个字段就报错开了additionalProperties: false确认是否需要严格模式必要时放宽旧消息消费失败消息版本和本地 Schema 不匹配消息头带版本号按版本选择 Schema校验开销高每次重新解析 Schema 文件进程内缓存 JsonSchema 实例还有一个容易忽视的问题消息体 Schema 的required一旦定下来尽量不要在后续版本里无脑追加。我见过团队在 v2 里把一堆字段加进 required结果下游处理历史消息时全部失败。新增必要字段的正确姿势是新增字段时设成 optional给下游一个过渡期等所有消费者都处理过新版本消息之后再在下一个版本里把它挪进 required。最后说一点我自己的落地习惯。我会先把 Consumer 侧的校验做起来再往 Producer 推因为消费者校验只影响自己风险小、见效快。Schema 文件一定要走 Git 评审每次改动看 diff基本能挡住 80% 的字段误改。校验失败的指标要接监控和告警不然你根本不知道线上有多少坏消息在被静默处理。这套东西真正难的不是技术选型而是让团队把“消息结构也是契约”这件事当回事只要这个认知建立起来后续所有的问题都有了解法。