资讯详情

资讯详情

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

事件驱动这个词我第一次真正吃透是在一次订单接口重构里。当时那个下单接口串着七八个远程调用扣库存、发券、加积分、写流水、刷报表、通知仓储每加一个下游业务就要改一次主流程发布还要拉上所有相关方对齐时间。后来我们把这条链拆成一条事件流下单只负责写库和投递一个订单已创建的事实其余动作由各自的服务订阅处理接口耗时从几百毫秒压到了百毫秒以内主流程的代码也从六百多行瘦到不足八十行。但同一套东西我也见过用反的有人把所有调用都改成发消息结果一个下单请求要等好几秒才在前端看到结果出了问题连日志都串不起来。这篇内容就把事件驱动在软件开发里最实在的部分摊开讲它到底改变了什么、事件该怎么建模、可靠投递和幂等怎么做、跨服务一致性怎么兜底、线上出事怎么查。适合已经写过业务代码、正准备把同步调用改成异步链路的中高级开发看也适合想搞清楚消息队列到底该在什么位置出现的同学。1. 从一次订单接口重构说起事件驱动到底解决了什么1.1 同步调用链是怎么把系统拖垮的先还原一下当时的现场。下单主流程大致是这样校验参数、查商品、算价格、调库存服务扣减、调营销服务核销券、调积分服务加积分、写订单库、调消息服务发短信、调报表服务写统计。这七八个调用里只有前三个是必须同步拿到结果的后面全是通知性质的动作但代码里它们排成一列任何一个慢一点用户就在那儿转圈。最夸张的一次报表服务因为一次全表统计卡了四秒下单接口的 P99 直接跟着飙到四秒多库存服务那边还以为请求方超时了重试机制又把压力放大了一轮。问题的根子不在于某个服务写得差而在于调用方被迫承担了所有下游的可用性。A 调 BB 调 CC 挂了 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_updatedpayload 就是整行数据的 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 status0 AND next_retry_atNOW() // ORDER BY id LIMIT 200 FOR UPDATE SKIP LOCKED ListOutboxEvent 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 兜底同时消费端启动时做一次全量对账拉取。这个教训我记到现在任何事件驱动的缓存都必须有兜底过期时间事件只是加速手段不是唯一数据源。最后分享个小技巧。我们后来在发件箱投递器和消费端各加了一个影子对账任务每天凌晨按业务主键抽样一千条记录比对写入时间和下游最后处理时间不一致的自动生成修复任务。这个小工具上线后数据不一致的问题从用户投诉才发现变成了第二天早上自动发现并修复省了大量排查时间。它实现起来不复杂但对事件驱动系统的长期健康度提升非常明显。我个人在实际项目里的体会是事件驱动真正难的不是把消息发出去而是让所有人对什么是一个事件失败之后谁来兜底数据不一致时以谁为准这几个问题有统一答案。技术方案只占三成剩下七成靠约定和纪律。所以每次启动这类改造我都会先花半天时间把事件命名规范、信封结构、幂等要求、死信处理流程写成文档让所有人签字确认之后再动手写第一行代码。这半天省下来的时间后面会以周为单位还给你。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →