事件流不是换个消息队列这么简单

发表于 2024-04-16 22:20 1971 字 10 min read

Spring AI学习笔记(四)工具调用和MCPSpring AI学习笔记(三)RAG从文档入库到回答Spring AI学习笔记(二)ChatClient从怎么调到怎么封装Spring AI学习笔记(一)它到底解决什么问题java新版本-java25学习笔记(四)用JFR和GC日志做一次体检java新版本-java25学习笔记(三)虚拟线程要和资源边界一起看java新版本-java25学习笔记(二)运行时基线先统一java新版本-java25学习笔记(一)LTS版本对比和学习路线主流AI Agent能力对比与工程选型我用Kiro做了个自己的工具站盘一盘虚拟线程我用Trae做了个AstrBot的AI角色扮演插件Python初学笔记(六)常用标准库先学这几个Python初学笔记(五)读写文件和处理异常Python初学笔记(四)函数让代码开始有结构Python初学笔记(三)条件、循环和推导式Python初学笔记(二)变量和基础类型比想象中重要Python初学笔记(一)先把环境和运行方式弄明白主流AI大模型能力对比Java 21和Spring Boot 3升级笔记(五)日志指标与可观测性Java 21和Spring Boot 3升级笔记(四)数据访问层适配Java 21和Spring Boot 3升级笔记(三)虚拟线程使用边界Java 21和Spring Boot 3升级笔记(二)Jakarta迁移要点Java 21和Spring Boot 3升级笔记(一)工程基线整理How Can We Tolerate Magic Values! Major Overhaul of JPA Specification!处理生僻字乱码:JPA框架对于Oracle的NVarchar2,NChar,NClob类型支持Redis Stream能不能当轻量消息队列用RocketMQ 5学习笔记:普通消息之外要看什么事件流不是换个消息队列这么简单Kubernetes学习笔记04:发布、排障和观测Kubernetes学习笔记03:配置、密钥和存储Kubernetes学习笔记02:Deployment、Service和IngressKubernetes学习笔记01:Pod和控制器mysql索引原理02--存储引擎索引的实现mysql索引原理01--索引的数据结构
This post is not yet available in English. Showing the original.
  现在再看 Java 后端系统,消息队列已经不是“削峰填谷”这么简单了。很多业务开始把日志、订单状态、库存变化、风控结果、用户行为都当成一条条事件来看。   这时候 Kafka、RocketMQ、Pulsar 这些中间件的讨论就会变多。但我觉得最容易被忽略的一点是:事件流不是把原来的消息队列换个名字。它改变的是系统之间传递事实的方式。...

前言

  现在再看 Java 后端系统,消息队列已经不是“削峰填谷”这么简单了。很多业务开始把日志、订单状态、库存变化、风控结果、用户行为都当成一条条事件来看。

  这时候 Kafka、RocketMQ、Pulsar 这些中间件的讨论就会变多。但我觉得最容易被忽略的一点是:事件流不是把原来的消息队列换个名字。它改变的是系统之间传递事实的方式。

  以前我们更习惯让 A 服务直接调用 B 服务,调用成功了就继续往下走。事件流更像是 A 服务把“我这里发生了什么”记录下来,B、C、D 服务各自订阅自己关心的事实。

消息和事件的区别

  很多项目里会把 message 和 event 混着叫。日常交流没问题,但设计系统时最好分清楚。

  消息更像是“我要你做一件事”。比如发送短信、创建账单、同步库存。消息的目标感很强,通常会有明确的消费方。

  事件更像是“这件事已经发生了”。比如订单已支付、库存已扣减、用户已注册、合同已审批。事件本身不命令谁做什么,它只是把事实发布出去。

这两个思路会影响代码设计。如果是命令式消息,我会更关心消费方是否执行成功。如果是事件,我会更关心事件定义是否稳定、是否可以被多个下游长期使用。

public record OrderPaidEvent(
        String orderNo,
        BigDecimal amount,
        LocalDateTime paidAt) {
}

像这种事件对象,字段最好表达已经发生的事实,而不是夹带太多下游动作。不要把事件命名成SendCouponMessage,然后又希望它承担订单支付事实,这样后面会越来越绕。

为什么又热起来

  事件流并不是新概念,但最近它在 Java 后端里明显更常见。我理解有几个原因。

第一是微服务拆分以后,同步调用链太长。一个订单接口如果同步调用支付、库存、积分、通知、报表,任何一个环节慢一点,用户都要等。

第二是数据实时性要求变高。以前报表可能晚上跑批,现在很多看板希望分钟级甚至秒级更新。事件流天然适合把业务变化持续推给下游。

第三是 CDC、流计算和湖仓这些工具成熟了。业务库变更可以通过 Debezium、Flink CDC 之类的工具进入消息系统,再接到实时数仓或搜索系统。

所以现在谈事件流,不只是后端服务之间解耦,还会牵扯到数据平台、观测系统和业务分析。

Java 项目里的第一层封装

  我不建议业务代码到处直接调用 KafkaTemplate 或 RocketMQTemplate。这样写一开始很快,但后面会散落很多 topic、tag、key、序列化细节。

更稳一点的做法是先定义业务事件发布接口:

public interface DomainEventPublisher {

    void publish(String topic, String key, Object event);
}

业务代码只表达自己要发布什么事件:

eventPublisher.publish(
        "order.event",
        order.getOrderNo(),
        new OrderPaidEvent(order.getOrderNo(), order.getAmount(), now));

真正的消息中间件实现可以放到基础设施层。这样后面要加日志、指标、重试、序列化版本控制,都不用把业务代码翻一遍。

Topic 不要随便拆

  做事件流时很容易遇到一个问题:topic 到底怎么设计?

有的人喜欢一个事件一个 topic,比如order-paidorder-canceledorder-refunded。这样看起来清晰,但 topic 很快会变多,权限、监控、消费组管理都会复杂。

也有人喜欢一个领域一个 topic,比如order-event。不同事件通过字段区分。这样 topic 少很多,但消费者要能识别事件类型。

我比较倾向于按业务领域划分,再在事件体里保留eventTypeeventVersion

{
  "eventType": "ORDER_PAID",
  "eventVersion": 1,
  "eventId": "evt_0001",
  "occurredAt": "事件发生时间",
  "payload": {}
}

这样既不会让 topic 爆炸,也能给事件演进留下空间。当然,如果某个事件量特别大,或者安全边界完全不同,也可以单独拆 topic。

顺序问题要提前想

  事件流里最容易被低估的是顺序。

比如同一个订单,可能先支付,再退款。消费者如果先收到退款事件,再收到支付事件,状态就会乱。消息中间件一般只能保证同一个分区或同一个队列内有序,所以 key 的选择很重要。

订单事件可以用订单号做 key,用户事件可以用用户 ID 做 key。这样同一实体的事件大概率进入同一个分区,顺序才有保障。

但这里也有取舍。如果某个超级大客户的事件特别多,用客户 ID 做 key 可能导致分区热点。所以 key 不是随便选一个字段,而是要结合业务一致性和数据分布一起看。

幂等比重试更重要

  很多人一聊消息可靠性就先聊重试。我觉得更应该先聊幂等。

在真实系统里,消息重复是很正常的。生产者可能重发,消费者可能处理完但提交 offset 失败,中间件也可能因为故障恢复导致重复投递。

所以消费者不要假设“每条消息只会来一次”。对于订单状态、积分发放、库存流水这类逻辑,必须有业务幂等。

常见做法是保存事件 ID 或业务流水号:

if (eventRecordRepository.existsByEventId(event.eventId())) {
    return;
}

这段逻辑看起来普通,但它比调大重试次数更重要。没有幂等,重试只是把一次错误变成多次错误。

事件版本要有耐心

  事件一旦发布出去,就会被下游依赖。字段不能像内部 DTO 那样想改就改。

新增字段通常比较安全,删除字段和改语义就很危险。比如原来amount表示实付金额,后来想改成订单总金额,这就不是简单字段变化,而是语义变化。

我更倾向于事件有版本号。小变更可以兼容旧版本,大变更就发新事件类型。消费者也要允许自己暂时只处理认识的版本。

switch (event.eventVersion()) {
    case 1 -> handleV1(event);
    case 2 -> handleV2(event);
    default -> log.warn("unknown event version: {}", event.eventVersion());
}

这会让代码多一点,但比线上某个下游突然解析失败要好。

监控不能只看积压

  消息系统监控经常只看消费积压。积压当然重要,但不够。

我会至少看这些指标:

1.生产发送成功率。 2.发送耗时。 3.消费成功率。 4.消费耗时。 5.消费积压。 6.死信数量。 7.单个分区或队列是否热点。

尤其是事件流场景,下游消费者可能很多。一个 topic 看起来没问题,不代表所有消费组都健康。每个消费组都应该有自己的告警边界。

小结

  事件流不是简单换一个消息队列,也不是把同步接口全部改成异步就结束。

  它真正要解决的是系统之间如何传递事实、如何降低调用耦合、如何让实时数据流动起来。对 Java 后端来说,最重要的不是马上引入多复杂的平台,而是先把事件定义、发布封装、topic 设计、顺序、幂等和版本演进想清楚。

  中间件可以换,事件模型一旦乱了,后面才是真的难收拾。

If you enjoyed this, leave a comment~

© 2019 - 2026 VincentHo @VincentHo
Powered by theme astro-koharu · Inspired by Shoka