事件驱动架构实战:解耦、可靠投递与跨服务一致性

发布时间:2026/10/27 0:26:02
事件驱动架构实战:解耦、可靠投递与跨服务一致性

事件驱动这个词,我第一次真正吃透是在一次订单接口重构里。当时那个下单接口串着七八个远程调用,扣库存、发券、加积分、写流水、刷报表、通知仓储,每加一个下游业务就要改一次主流程,发布还要拉上所有相关方对齐时间。后来我们把这条链拆成一条事件流,下单只负责写库和投递一个"订单已创建"的事实,其余动作由各自的服务订阅处理,接口耗时从几百毫秒压到了百毫秒以内,主流程的代码也从六百多行瘦到不足八十行。但同一套东西我也见过用反的:有人把所有调用都改成发消息,结果一个下单请求要等好几秒才在前端看到结果,出了问题连日志都串不起来。这篇内容就把事件驱动在软件开发里最实在的部分摊开讲:它到底改变了什么、事件该怎么建模、可靠投递和幂等怎么做、跨服务一致性怎么兜底、线上出事怎么查。适合已经写过业务代码、正准备把同步调用改成异步链路的中高级开发看,也适合想搞清楚"消息队列到底该在什么位置出现"的同学。

1. 从一次订单接口重构说起:事件驱动到底解决了什么

1.1 同步调用链是怎么把系统拖垮的

先还原一下当时的现场。下单主流程大致是这样:校验参数、查商品、算价格、调库存服务扣减、调营销服务核销券、调积分服务加积分、写订单库、调消息服务发短信、调报表服务写统计。这七八个调用里,只有前三个是必须同步拿到结果的,后面全是"通知性质"的动作,但代码里它们排成一列,任何一个慢一点,用户就在那儿转圈。最夸张的一次,报表服务因为一次全表统计卡了四秒,下单接口的 P99 直接跟着飙到四秒多,库存服务那边还以为请求方超时了,重试机制又把压力放大了一轮。

问题的根子不在于某个服务写得差,而在于调用方被迫承担了所有下游的可用性。A 调 B,B 调 C,C 挂了 B 就挂,B 挂了 A 就挂,这条链上每个节点的抖动都会沿着调用栈往上放大。这种结构还有个隐性成本:业务节奏被绑死了。营销团队想加个"下单后给用户打标签"的需求,得排期改主流程、走一遍回归、协调三个团队一起发版。一个本该是旁路的动作,硬生生挤进了主链路。

事件驱动的第一层价值就在这儿:它把"我要你做某件事"变成了"我告诉你发生了什么"。下单服务不再关心谁要消费这个事实、消费几次、消费失败怎么办,它只负责把"订单已创建"这件事可靠地广播出去,然后该返回就返回。下游服务自己决定订阅不订阅、什么时候处理、处理失败怎么补偿。发布节奏从此解耦,主流程只依赖自己的数据库。

1.2 事件与命令的区别:一句话讲清楚

很多人把消息和事件混为一谈,其实这俩在语义上差得很远,搞混了会直接导致架构跑偏。

命令是祈使句,事件是过去时。命令是"给这个用户加 100 积分",发送方知道接收方是谁、要它做什么、期望什么结果;事件是"订单 12345 已支付,金额 199 元",发送方只是在陈述一个已经发生的事实,不关心谁在听。命名上就能看出来:命令叫AddPoints、CreateOrder,事件叫OrderPlaced、PaymentCompleted。

这个区别不是文字游戏,它决定了几件事。第一,事件的发送方不该知道自己有多少订阅者,一旦你开始为"某个下游需要什么字段"去改事件结构,说明耦合又回来了。第二,事件必须是不可变的事实,已经发出去的OrderPlaced不能回头改语义,只能追加新的事件类型或者加版本号。第三,命令通常是一对一的,事件天然是一对多的,一个订单创建事件可能同时被积分、仓储、风控、搜索索引、数据仓库五六个消费者订阅,加一个消费者不需要通知任何人。

我在评审代码时有个简单的判断标准:如果某个"消息"的发送方在代码里写了if (consumer == xxx)之类的分支,或者事件 payload 里塞了大量只有某一个下游才用得上的字段,那基本可以判定这是伪装成事件的命令,早晚要出问题。

1.3 哪些场景适合事件驱动,哪些场景别硬上

我见过太多团队一上来就喊"全面事件化",最后收不了场。下面这张表是我自己总结的判断依据,可以直接拿去对照。

场景特征建议原因
一个动作要触发多个下游,且下游互不关心彼此事件驱动,收益最大解耦彻底,加消费者零成本
下游处理耗时长(发短信、生成报表、调外部接口)事件驱动主流程不必等待,失败可重试
下游允许延迟(秒级到分钟级)事件驱动最终一致可接受
用户需要立即看到结果(余额、库存余量)同步调用异步会让前端体验崩坏
需要跨表强事务(扣款与记账必须同生共死)同库事务或 Saga,不要指望消息消息只能做到最终一致
简单的增删改查,只有一个下游直接调用引入中间件纯属自找麻烦
团队没有消息中间件的运维和排障能力先别上出问题时无人能定位,比同步更糟

再补一句实在话:事件驱动不是免费的。它把"调用链上的复杂度"转移到了"基础设施的复杂度"上。你不再需要担心 A 调 B 超时,但你要开始担心消息积压、重复消费、乱序、消息丢失、消费者 rebalance、事件契约演进。这些坑一个都躲不掉,只是从开发阶段挪到了运维阶段。所以在动手之前,先确认团队里至少有一个人能看懂消息中间件的堆积曲线和消费位点。

2. 落地前的选型与事件建模

2.1 三种承载形态怎么选

事件驱动在实现层面大致分三种形态,选错了后面会一直别扭。

第一种是进程内事件总线,就是 Spring 的ApplicationEventPublisher或者自己写个观察者列表。它没有网络开销,同步或异步(配个线程池)都行,适合单体应用内部解耦,比如"用户注册成功后发欢迎邮件"这种。缺点是进程一重启事件就没了,跨进程完全用不了。我的经验是:只要还是单体,优先用它,别急着上中间件。

第二种是消息队列型中间件,特点是队列模型、消费完即删、支持路由和死信。适合任务分发、命令下发、需要精细路由的场景。它的短板是历史消息不保留,想回溯几乎不可能,堆积能力也有限。

第三种是日志型事件流平台,特点是追加写、按分区有序、消息保留一段时间可重复消费、支持多消费组各自独立推进位点。适合事件量大、需要回放、需要多个下游各自按节奏消费的场景。它的短板是运维门槛高一些,分区数规划不好会热点,消费位点管理需要专人盯。

选择上我给个粗暴的判断顺序:单体内部先用进程内事件;需要跨进程但不要求回放、量级中等,用队列型;事件需要被多个系统长期订阅、要回放做数据修复、量级到十万级每秒以上,再考虑日志型。别因为"以后可能会用到"就提前上最重的那套,我见过一个日订单量几千的系统上了日志型集群,三个节点常年闲着,运维还得每周巡检。

2.2 事件信封:把元数据和业务数据分开

事件结构我一直坚持"信封 + 载荷"两层。信封放所有事件都有的通用字段,载荷放这个事件特有的业务字段。这样做的好处是序列化、埋点、traceId 透传、幂等去重这些通用逻辑可以统一处理,不用每个事件各写一遍。

{ "eventId": "01HX7Q2K9M3N8PQR5T6V", "eventType": "order.placed", "eventVersion": 2, "occurredAt": "2024-05-11T10:23:41.512Z", "producer": "order-service", "aggregateId": "ORD-20240511-000871", "traceId": "b3f1c9a2e4d7", "causationId": "01HX7Q2K9M3N8PQQ1A2B", "payload": { "orderId": "ORD-20240511-000871", "userId": "U-88213", "amount": 19900, "currency": "CNY", "items": [{ "skuId": "S-1024", "qty": 2 }] } }

几个字段值得单独说。eventId必须全局唯一,最好用带时间前缀的有序 ID,它既是去重键也是排障时的检索键。occurredAt是事件发生的时间,不是发送时间,也不是消费时间,后面算端到端延迟全靠它。traceId从入口一路透传下来,消费端把它写进日志的 MDC,这样从一次下单到积分到账,整条链能在日志系统里串起来,出问题时省下的不是十分钟而是半天。causationId指向触发本次事件的上一个事件,做链路追溯和死循环检测时特别有用。

eventVersion我建议从第一天就加上。事件的契约演进是必然的,加字段是最安全的做法(消费端忽略未知字段),但一旦要改字段语义或者删字段,没有版本号就只能靠人肉沟通,那场面我经历过一次,两边团队互相指责了三天。规则定死:只加不改不删,需要改就升版本号,新旧版本并行一段时间,等所有消费者都升级完再下线旧版。

2.3 事件粒度与命名规范

粒度是建模里最容易出错的地方。我见过有人直接把数据库的一张表当成事件发出去,叫order_row_updated,payload 就是整行数据的 JSON。这种做法短期很爽,长期是灾难:表结构一改所有下游都受影响,未知字段还可能带出用户手机号、身份证这类不该外泄的数据,而且下游根本分不清这次更新是"支付成功"还是"客服改了备注"。

正确的做法是发领域事件,也就是站在业务视角描述"发生了一件有意义的事"。订单从待支付变成已支付,发PaymentCompleted;订单被取消,发OrderCancelled。两者 payload 完全不同,下游看事件类型就知道该走哪条逻辑,不需要自己去 diff 前后状态。

命名上我推荐领域对象 + 过去分词的格式,全小写点号分隔,比如order.placed、payment.refunded、inventory.reserved。统一约定成动词过去式,全组人一看就知道这是事实而不是指令。聚合根 ID 单独放信封里的aggregateId,这样消费端做顺序性路由时不用去 payload 里翻。

另外一个细节是事件要够用但别贪多。payload 里放消费者"做决策所必需"的字段,比如积分服务需要 userId 和本次消费金额来计算积分,那就放这两个;不需要整张订单的所有明细。字段越少,契约越稳定,泄露风险越小。真要拿全量数据,让消费端回查订单服务的接口,用aggregateId换详情,虽然多一次调用,但契约耦合度低得多。

2.4 同步与异步的边界怎么划

这条边界我建议在动手前就画在文档上,并且让产品和前端一起确认,不然后面一定扯皮。

必须同步的部分:用户操作后需要立即展示结果的(余额、库存余量、优惠后的价格)、需要在同一个数据库事务里保证原子性的(扣减余额与写流水)、需要在入口做校验并拒绝的(风控拦截、参数合法性)。这些放进事件流就是自找麻烦,用户点完按钮看到"处理中,请稍候刷新"的体验,转化率会教你做人。

可以异步的部分:通知类(短信、站内信、推送)、统计类(报表、数据仓库入仓)、索引类(搜索索引更新、缓存刷新)、旁路风控(事后审核、异常检测)、跨域协作(发券、加积分、通知仓储)。这些动作的共同特征是可以延迟、失败可重试、失败不影响主流程结果。

还有一种中间态需要特别处理:用户希望看到结果但可以容忍短暂延迟,比如"下单后积分几分钟内到账"。这种场景我通常用"前端先展示写入成功的确定状态,异步结果通过轮询或者推送补上",同时给用户一个明确的文案。技术上不难,难的是产品经理愿不愿意接受"不是立刻到账",这个沟通要提前做。

3. 可靠投递、幂等消费与顺序性处理

3.1 事务性发件箱:把写库和发消息绑成一个事务

这是事件驱动里最经典的坑,也是我见过最多人栽跟头的地方。业务代码通常是这个顺序:

orderMapper.insert(order); // 1. 写库成功 eventPublisher.send(event); // 2. 发消息,结果网络超时

第 2 步失败会怎样?订单写进去了,但下游全不知道——库存没扣、积分没加、报表没统计。反过来如果先发消息再写库,可能消息发出去了但事务回滚,下游收到一个根本不存在的订单。这两种情况都不致命但不一致,而系统中一旦出现"账对不上"的数据,后面修起来极其痛苦。

解决办法是事务性发件箱(Outbox)。核心思路很朴素:把"要发的事件"和"业务数据"放进同一个数据库事务里落盘,然后由独立的投递器去扫表发送。这样要么业务数据和事件一起成功,要么一起失败,原子性由数据库本地事务保证。

CREATE TABLE outbox_event ( id BIGINT PRIMARY KEY AUTO_INCREMENT, event_id VARCHAR(64) NOT NULL UNIQUE, event_type VARCHAR(128) NOT NULL, aggregate_id VARCHAR(64) NOT NULL, payload JSON NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待发送 1已发送', retry_count INT NOT NULL DEFAULT 0, next_retry_at DATETIME NOT NULL, created_at DATETIME NOT NULL, sent_at DATETIME NULL, KEY idx_status_retry (status, next_retry_at) );

业务代码改成一个事务里两件事:

@Transactional public void placeOrder(Order order) { orderMapper.insert(order); outboxMapper.insert(OutboxEvent.of("order.placed", order.getOrderId(), buildPayload(order))); }

投递器是独立线程或独立服务,滚动扫表发送:

@Scheduled(fixedDelay = 200) public void relay() { // SELECT * FROM outbox_event WHERE status=0 AND next_retry_at<=NOW() // ORDER BY id LIMIT 200 FOR UPDATE SKIP LOCKED List<OutboxEvent> batch = outboxMapper.lockPending(200); for (OutboxEvent e : batch) { try { producer.send(e.getEventType(), e.getAggregateId(), e.getPayload(), e.getEventId()); outboxMapper.markSent(e.getId(), LocalDateTime.now()); } catch (Exception ex) { outboxMapper.retryLater(e.getId(), backoff(e.getRetryCount())); } } }

几个实操细节必须注意。FOR UPDATE SKIP LOCKED是关键,多实例部署时它能避免两个投递器抢同一条记录,MySQL 8 以上都支持,写错了会出现重复投递。发件箱表要定期清理,我一般按"已发送且超过 7 天"归档到历史表然后删除,不然这张表会膨胀到几十 G,索引都走不动。投递器本身也要监控,一旦它停了,业务看起来一切正常,但下游全部静默失联,这种故障最隐蔽——我建议给发件箱积压量设一个告警阈值,比如待发送记录超过 1000 条持续三分钟就报警。

如果团队用日志型中间件且有 CDC 能力,也可以不做应用侧投递器,直接抓数据库变更日志投递,代码更干净,但要处理好表结构变更和全量快照的边界问题,中小团队我一般建议先上手应用侧投递器,逻辑直观、出问题好查。

3.2 幂等消费的三种做法与一个关键顺序问题

消息中间件的主流语义是"至少一次",也就是说重复投递是常态而非异常。网络抖动、消费者重启、rebalance、投递器重试,任何一个环节都可能让同一条事件被处理两次。所以幂等不是可选项,是必选项。

我常用的三种做法,按可靠性排序:

第一种是唯一索引兜底。业务表上建一个基于业务键的唯一约束,比如积分流水表用(user_id, order_id)做唯一键。重复消费时插入直接抛冲突,捕获后当成功处理。这个方案最可靠,因为它把幂等性交给了数据库,应用层怎么写都不会错,代价是要求业务表本身有唯一语义。

第二种是去重表,专门建一张consumed_event(event_id, consumer_group, created_at)表,event_id + consumer_group做唯一键。消费前先INSERT IGNORE,影响行数为 0 说明处理过,直接返回。这里有个至关重要的顺序问题:去重记录和业务处理必须在同一个事务里。

@Transactional public void onOrderPlaced(OrderPlacedEvent e) { int inserted = dedupMapper.insertIgnore(e.getEventId(), "point-service"); if (inserted == 0) { log.info("duplicate event ignored, eventId={}", e.getEventId()); return; } pointService.addPoints(e.getPayload().getUserId(), calcPoints(e)); }

如果先插去重记录、再提交、然后处理业务,中间进程崩了,事件就被标记成"已处理"但实际上没处理,这叫消息黑洞,比重复消费严重得多。反过来如果先处理业务再插去重记录,中间崩了会重复处理。只有放同一个事务里才能两头兼顾。这一点我在代码评审里逮到过三次,每次都有人觉得"差不多就行了"。

第三种是Redis 的 SETNX 加过期时间,适合对性能要求高、能容忍极小概率重复的场景。SET key 1 NX EX 86400,返回成功才处理。它的风险在于 Redis 和数据库不在一个事务里,Redis 主从切换或者内存淘汰都可能导致去重失效,所以只能作为性能优化的辅助手段,不能作为唯一保障。

另外提醒一句,幂等要按消费组维度设计,而不是全局维度。同一个 eventId 被积分服务和报表服务各消费一次是正常的,去重表的唯一键必须带上消费组标识,否则第二个服务会永远消费不到。

3.3 顺序性:分区键选错的代价

乱序是另一个高频坑。同一个订单先发OrderPlaced再发OrderCancelled,如果消费端先收到取消再收到创建,最后状态就是错的。解决思路只有一个:让同一实体的事件走同一个分区。

具体做法是在发送时指定分区键,绝大多数中间件都支持:

producer.send(event.getEventType(), event.getAggregateId(), payload);

第二个参数是 key,中间件会对它做哈希然后落到固定分区,同一 key 的事件天然有序。aggregateId用订单号、用户 ID、商品 ID 都行,取决于你的顺序需求边界在哪。如果业务要求"同一用户的所有订单事件有序",那就用 userId 做 key;如果只要求"同一订单内部有序",用 orderId 就够了,分区压力更小。

这里有个必须想清楚的取舍:分区键的粒度决定了并行度。用 userId 做 key,一个大促期间头部用户的订单全落在一个分区,那个分区就成了瓶颈,消费者再扩容也没用,因为同一分区的消息只能被同一个消费者实例处理。我遇到过一次,某个测试账号被脚本刷了几十万单,全压在一个分区上,消费延迟飙到二十分钟。后来改成 orderId 做 key,因为业务上确实只需要订单内部有序,问题立刻消失。

还有一点:不要追求全局有序。全局有序意味着单分区,等于放弃了水平扩展能力,吞吐量会被死死压在单机水平。真正需要全局有序的场景极少,我做了这么多年只遇到过一个对账批次处理的场景,用的是带序列号的单分区队列,而且量级很小。

3.4 重试策略、退避与死信队列

消费失败必须有重试,但重试策略写不好会把下游打死。我踩过最典型的一次是消费端遇到数据库连接池耗尽,代码里写了个while(true)加立即重试,结果三万条消息在十秒内把重试队列塞爆,数据库雪崩,整个集群跟着挂。

正确的做法是指数退避加随机抖动。第一次失败等 1 秒,第二次 2 秒,第三次 4 秒,最多退到 5 分钟,重试次数控制在 5 到 8 次。加抖动的目的是避免大量消息在同一时刻集体重试形成新的尖峰:

long delay = Math.min(BASE_MS * (1L << retryCount), MAX_MS); long jitter = ThreadLocalRandom.current().nextLong(delay / 2 + 1); scheduleRetry(event, delay + jitter);

超过最大重试次数后进死信队列。死信不是垃圾桶,必须有配套的告警和处理流程。我的习惯是:死信堆积超过 10 条就触发告警,值班同学当天必须处理完,处理方式要么是修复问题后重新投递,要么是确认业务上可以丢弃并记录原因。让死信队列长期堆积而无人处理,等于给系统埋了一颗定时炸弹。

还有一类消息要区别对待:毒消息。比如反序列化失败、字段格式不合法、必填字段缺失,这类错误重试一百次也不会成功,反而会拖慢整个消费组。我的处理方式是捕获反序列化异常后直接进死信,不参与重试,同时在日志里打出原始消息体,方便人工分析。

4. 跨服务一致性:Saga、CQRS 与事件溯源

4.1 Saga 的两种编排方式与补偿设计

事件驱动解决了解耦,但引入了一个新问题:一个业务动作横跨多个服务,中间失败了怎么回滚?数据库的本地事务只能管住一个库,跨服务就得靠 Saga。

Saga 的思路是把一个长事务拆成一串本地事务,每个本地事务配一个补偿操作,任何一步失败就按相反顺序把前面的步骤补偿掉。比如下单场景:订单服务创建订单(补偿:取消订单)→ 库存服务预占库存(补偿:释放库存)→ 支付服务扣款(补偿:退款)→ 积分服务加积分(补偿:扣回积分)。

实现上有两种编排方式。**编排式(Orchestration)**是找一个协调者,由它按顺序调用各个服务,出错时由它发起补偿。好处是流程集中在一处,看得见全貌,调试方便;坏处是协调者容易变成新的耦合中心。**协同式(Choreography)**是每个服务订阅事件,做完自己的事再发下一个事件,出错时发补偿事件。好处是没有中心节点,天然去中心化;坏处是流程散落在各处,新人接手时根本拼不出完整链路,出了问题排查像在走迷宫。

我的选择标准很简单:流程步骤超过四步、或者有复杂条件分支,用编排式;步骤少且线性,用协同式。中小团队我几乎都推荐编排式,因为可维护性的权重远高于架构优雅。

补偿操作的设计有几个必须守住的底线。补偿必须幂等,因为补偿本身也可能失败重试。补偿不能真的"撤销",多数时候只能"冲正",比如退款而不是把支付记录删掉。补偿要考虑补偿失败怎么办,我一般会记录补偿失败记录并人工介入,同时把订单标记成"异常待处理",绝不允许流程卡在中间态无人管。

还有一个容易被忽略的点:Saga 没有隔离性。中间状态对其他事务是可见的,比如订单已经创建但库存还没预占成功,这时候用户去查订单会看到一个"待确认"的订单。解决办法是在状态机里加语义锁,用PENDING、CONFIRMING这类中间状态把记录"锁"住,其他操作看到中间状态就拒绝或者等待。这套状态机设计得好不好,直接决定 Saga 用起来顺不顺。

4.2 CQRS 读模型与重建

CQRS 是把写模型和读模型分开,写侧只负责接收命令、校验、发事件,读侧订阅事件构建专门为查询优化的视图。典型场景是商品搜索:写侧商品数据存在关系库,读侧把商品信息投影到搜索引擎里,查询走搜索、写入走库。

用事件驱动做 CQRS 有个天然优势:读模型可以从头重建。某次投影逻辑写错了,导致搜索索引里的数据全错,你只需要把消费位点重置到某个时间点,重新消费一遍事件流,索引就恢复正确了。这个能力在排查线上数据问题时价值巨大,我用它救过一次场——某次价格字段的单位换算写错了,导致搜索页显示的价格全是错的,重置位点重放两小时的事件流,问题解决,全程没动线上数据。

但重建有几个坑要提前想好。第一,事件流必须保留足够长的时间,如果中间件只保留三天,你想重建一周前的数据就没料了,所以关键业务的读模型要定期做全量快照。第二,重建期间不能影响线上读取,一般是建一个新的索引,重建完成后再切别名。第三,重建时的消费速度要限流,全速重放会把下游数据库打爆,我一般按正常消费速率的 3 到 5 倍来控制。

读模型延迟导致的"写后读不一致"也是必须处理的。用户刚下了单,跳转到订单列表却查不到,因为投影还没跟上。我的处理方式有两种:一是写侧返回时带上版本号或者时间戳,前端轮询直到读到不早于该版本的数据;二是对"用户自己的数据"这类强一致要求高的查询,直接读主库,不走读模型。哪种都不算优雅,但比让用户以为下单失败强得多。

4.3 事件溯源的适用边界

事件溯源(Event Sourcing)是把状态本身也存成事件序列,不再存当前状态,而是通过重放事件推导出当前状态。它的好处是完整的审计轨迹、天然的时间旅行能力、可以推导出任意时刻的快照。

但我必须说清楚:绝大多数业务系统不需要事件溯源。它带来的复杂度是几何级上升的——查询要额外建投影、事件版本迁移极其痛苦、快照机制要自己实现、团队学习成本高。我见过一个团队给一个普通的 CMS 系统上了事件溯源,结果每次改需求都要写事件升级脚本,三个人的团队维护得苦不堪言。

真正适合它的场景有明确特征:需要完整审计(金融账务、医疗记录)、需要时点回溯(对账、纠纷处理)、业务本身就是事件流(风控规则引擎、游戏行为回放)。判断标准我总结成一句话:如果"过去发生了什么"和"现在是什么"同等重要,才考虑事件溯源;如果只关心现在,就别碰。

即使上了,也建议用混合模式:核心聚合根用事件溯源,其他部分用传统的状态存储加事件通知。全量事件溯源的系统我只在很小范围的账务模块里见过成功案例,那还是因为该模块的业务规则二十年没变过。

5. 可观测性与线上排障实录

5.1 三张必须有的看板

事件驱动系统最大的问题是"静默失败"——主流程一切正常,但下游悄悄不干活了,没有任何报错,只有用户投诉时才发现积分已经三天没到账。所以我给每个事件驱动系统都要求配三张看板,缺一不可。

第一张是生产与消费速率对比图。横轴时间,纵轴每秒消息数,生产曲线和消费曲线画在一起。两条线长期背离就意味着积压开始,这是最早期的信号,比积压量告警提前十几分钟。

第二张是消费延迟曲线,也就是 lag。我一般看两个指标:消息堆积条数,以及端到端延迟(当前时间减去事件occurredAt)。后者更贴近用户体验,因为条数受消息大小影响,延迟是实打实的。阈值设成业务可容忍延迟的一半,比如业务要求一分钟内到账,那延迟超过三十秒就告警。

第三张是失败率与死信堆积图。按消费者分组统计消费失败率和死信队列长度,任何一个消费者组的失败率超过 1% 持续五分钟,或者死信堆积超过 10 条,立刻告警。这张图能抓住绝大部分逻辑错误和下游依赖故障。

除了这三张,traceId全链路透传是排障的生命线。发送端把 traceId 写进事件信封,消费端从信封里取出来放进日志上下文,这样在日志系统里用一次下单的 traceId 就能搜出从入口到所有下游消费的完整链路。没有这个能力,跨服务的异步排障基本等于盲人摸象。

5.2 常见问题速查表

这张表是我这几年攒下来的,基本都是真出过的问题,可以直接拿去当值班手册。

现象最可能的原因排查动作处理方式
消费延迟持续上涨消费者处理变慢或实例数不足看单条处理耗时、看下游依赖 RT、看分区数先扩容消费者,再看下游是否有慢查询
消息重复消费消费超时触发重投、rebalance、投递器重试查去重表命中率、看消费耗时是否接近超时配置缩短单批处理量,完善幂等
消费完全停止消费者线程卡死、位点提交失败、实例异常看消费者心跳、看是否有死锁线程栈重启实例,排查阻塞点
同一订单状态错乱分区键不一致导致乱序检查发送时 key 是否统一用 aggregateId统一分区键,必要时暂停消费修正数据
反序列化失败事件契约变更、字段类型不兼容看去信队列原始消息体加兼容字段,毒消息转死信
事件丢失发送未走发件箱、消息未持久化对账:业务表条数 vs 下游消费条数补发,并补齐发件箱机制
下游收到的数据比库里旧消费乱序或跨分区顺序问题对比事件时间戳与库内更新时间用版本号做乐观并发控制
单个分区消费特别慢分区键热点看各分区 lag 分布换更细粒度的 key,或做二级拆分

5.3 我踩过的五个坑

第一个坑是消息体过大。早期有同事把整张订单加上所有商品明细、用户信息、物流信息全塞进事件,单条消息到了 200KB。消息中间件对单条大小有上限,超了直接拒绝,而且大消息会拖慢整个分区的吞吐。后来我们把 payload 压到只留必要字段,大对象改成"事件带 ID,消费端回查",单条消息降到 2KB 以内,吞吐立刻上了一个台阶。经验是:事件体控制在 10KB 以内,超过就说明你把查询结果当事件发了。

第二个坑是消费者 rebalance 导致重复消费。消费实例扩缩容或者心跳超时都会触发分区重新分配,重新分配时会从上次提交的位点重新消费,重复不可避免。我们的处理是缩短单批处理条数、降低单条处理耗时,让位点提交更频繁;同时把消费端的手动提交改成"处理完一批再提交",而不是"拉到就提交",避免消息还没处理完位点就推进了。后者是最危险的情况,会导致消息真丢。

第三个坑是开发环境共用 topic。有次测试同学在开发环境压测,把消息打到了和生产同一个 topic 上,结果生产环境的积分服务给测试用户加了一堆积分。这个坑的教训很直接:环境隔离必须做到 topic 级别,哪怕在 topic 名字后加环境后缀,也不要图省事共用。另外消费组名也要带环境标识,不然会出现开发环境的消费者抢生产消息的情况。

第四个坑是事件契约变更没通知下游。有同事在order.placed的 payload 里把amount从"分"改成了"元",字段名没变,下游毫无察觉,结果积分算出来放大了 100 倍。这个事故之后我们定了死规矩:字段含义变更等于破坏性变更,必须升版本号,同时所有事件的 schema 统一放在一个公共仓库里维护,改 schema 要提 PR,下游团队必须 review 通过才能合并。

第五个坑是本地缓存和事件不同步。有个服务在内存里缓存了商品价格,靠订阅price.changed事件来刷新。某次消费端重启了两分钟,期间的价格变更事件丢了(当时还没做持久化订阅),缓存里的价格一直是旧的,用户看到的价格和结算价格不一致,投诉了一大片。修复方式是缓存加 TTL 兜底,同时消费端启动时做一次全量对账拉取。这个教训我记到现在:任何事件驱动的缓存都必须有兜底过期时间,事件只是加速手段,不是唯一数据源。

最后分享个小技巧。我们后来在发件箱投递器和消费端各加了一个"影子对账"任务:每天凌晨按业务主键抽样一千条记录,比对写入时间和下游最后处理时间,不一致的自动生成修复任务。这个小工具上线后,数据不一致的问题从"用户投诉才发现"变成了"第二天早上自动发现并修复",省了大量排查时间。它实现起来不复杂,但对事件驱动系统的长期健康度提升非常明显。

我个人在实际项目里的体会是,事件驱动真正难的不是把消息发出去,而是让所有人对"什么是一个事件""失败之后谁来兜底""数据不一致时以谁为准"这几个问题有统一答案。技术方案只占三成,剩下七成靠约定和纪律。所以每次启动这类改造,我都会先花半天时间把事件命名规范、信封结构、幂等要求、死信处理流程写成文档,让所有人签字确认之后再动手写第一行代码。这半天省下来的时间,后面会以周为单位还给你。