前言
现在再看 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-paid、order-canceled、order-refunded。这样看起来清晰,但 topic 很快会变多,权限、监控、消费组管理都会复杂。
也有人喜欢一个领域一个 topic,比如order-event。不同事件通过字段区分。这样 topic 少很多,但消费者要能识别事件类型。
我比较倾向于按业务领域划分,再在事件体里保留eventType和eventVersion:
{
"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 设计、顺序、幂等和版本演进想清楚。
中间件可以换,事件模型一旦乱了,后面才是真的难收拾。
気に入ったならばコメントを残してくださいね~