做后端开发的这几年,我遇到最多的设计问题之一就是系统之间怎么通信。早期项目里,A系统的数据一变,就得硬编码调用B系统、C系统的接口,改一个接口就要联动改好几个调用方。中间只要有一个系统升级,整个调用链就跟着紧张。后来真正把发布-订阅用起来,才算是把这个问题彻底理顺了。发布-订阅的核心思想,是把消息的生产者和消费者彻底拆开:生产者只负责把消息发到某个“主题”上,不用关心谁会去读;消费者按需订阅自己关心的主题,自己去拉消息。双方互不知道对方的存在,自然也就谈不上硬编码依赖。这篇文章不打算讲教科书式的概念,而是从我实际踩坑、落地的角度出发,把发布-订阅的核心机制、代码实现、以及最常遇到的故障排查方法一次讲清楚。无论你是正在做系统拆分、微服务改造,还是只是想让自己模块之间的通信更清爽,这篇都值得看完。1. 发布-订阅的底层逻辑与场景判断1.1 从一次订单重构说起假设有一个电商系统,用户下单成功后,系统至少要干这几件事:扣减库存、给用户发通知、给会员体系累积积分、把订单数据推给数据分析。如果按点对点的写法,订单服务里会写类似这样的代码:def create_order(order_data): # 保存订单 save_order(order_data) # 调库存服务 inventory_service.reduce_stock(order_data["items"]) # 调通知服务 notify_service.send_message(order_data["user"], "下单成功") # 调积分服务 points_service.add_points(order_data["user"], 100) # 调数据分析 analytics_service.push(order_data)这种写法的致命问题,不是代码长,而是耦合。每一次新增下游,比如要增加一个订单同步到财务系统的需求,就得回来改订单服务的代码。而且下单是强主链路,任何一个下游系统响应慢或者超时,用户的订单操作就会被拖慢。库存服务一抖动,用户连下单都成功不了,这显然不合理。用发布-订阅改造以后,订单服务只做一件事:在自己的事务里,把“订单已创建”这个事件发到一个订单主题上。库存、通知、积分、数据分析各自订阅这个主题,自己去拉消息。新增财务同步?财务系统自己订阅就行,订单服务一行代码都不用动。库存服务挂了,最多是消息在队列里等它回来,用户该下单下单,完全不受影响。这就是发布-订阅最核心的价值:划清边界,削峰填谷,让上下游各自演进。真正用起来之后,你会发现架构里很多“说不清谁该调谁”的问题,最后都能归到“你发布事件,我订阅事件”这八个字上。1.2 它和点对点队列、观察者模式到底差在哪很多人把发布-订阅和消息队列、观察者模式混在一起,搞不清边界。其实它们的差别很实际:观察者模式是最早的“发布订阅雏形”,但它基本限定在单机、进程内,Subject直接持有Observer引用,消息传递是同步的。比如前端框架里的事件绑定,就是典型的观察者模式。它解决的是代码层面的解耦,不解决服务之间的通信问题。点对点队列(Point-to-Point)虽然也有消息中间件的参与,但一条消息从入队到出队,最终只会被一个消费者消费。典型场景是任务分发,比如一批短信发送任务,多个worker各自领任务,每个任务只执行一次。这种模式保证“竞争消费”,但不适合做广播通知。发布-订阅的核心特征是广播:一个主题上的一条消息,可以被多个订阅方各自收到一份,互不影响。如果把主题比作一个公共频道,点对点队列是“一个任务给一个人干”,发布订阅是“一条新闻所有关注的人都看到”。维度观察者模式点对点队列发布-订阅通信范围进程内跨进程/跨机器跨进程/跨机器消息去向绑定观察者一条消息一个消费者一条消息所有订阅方通信方式同步为主异步异步持久化能力无支持支持适用场景UI事件绑定、状态流转任务分发、负载均衡广播通知、事件驱动我自己的经验是,接手一个架构时先判断它要解决的到底是“任务分配”还是“事件广播”,这决定了你上消息中间件还是上事件总线。用错模式,后面维护成本会翻倍。2. 核心机制拆解:主题、订阅关系与消息流转2.1 四个基础角色一次标准的发布订阅通信,包含四个角色:生产者(Producer):负责发布消息的一方,只需要知道往哪个主题发,不感知下游是谁。消费者(Consumer):负责订阅主题,消费消息的一方。多个消费者可以组成一个消费者组,组内竞争消费,组间广播。主题(Topic):消息的逻辑分类,相当于一个消息容器。所有消息都发到对应的主题上,消费者只拉自己订阅的主题。消息代理(Broker):承接消息存储、转发、分发的中间层。它承担“媒婆”的职责,把生产者和消费者隔离开。我用一个生活类比来理解这四个角色:主题是电台的节目频道,生产者是主持人,只负责在频道里说话,消费者是收音机前的听众,选择自己喜欢的频道收听。中间负责信号发射和传输的,就是消息代理。主持人不知道有多少听众在听,听众也不关心主持人背后怎么生产内容。在实际系统中,消费者不是直接“收到”消息的,而是主动去代理那里拉取(Pull)。这和很多人的直觉相反。大多数消息中间件采用的模式是消费者通过长轮询来拉消息,而不是代理推给消费者。拉模式的好处是消费者可以控制处理速度,按自己的能力消费,天然就对消费速度有调节作用。推模式最大的问题在于,消费者处理能力弱时,代理全速推送会导致消费端内存被打爆,处理速度跟不上就只能丢消息。2.2 主题的粒度与订阅关系的设计主题粒度设计得好不好,直接决定系统后续好不好维护。我见过最糟糕的做法,是把所有业务事件全发到同一个主题上,消费者拉下来以后自己判断类型。这样做的后果是:一个消费者必须处理全站所有类型的事件,逻辑上越来越臃肿,而且不同团队之间会互相干扰。正确做法应该是按业务域划分主题,比如订单主题、支付主题、用户主题,一个主题里再通过事件类型(Event Type)区分具体的业务动作,比如OrderCreated、PaymentSucceeded。另外,订阅关系一般分为两种:一种是广播订阅,同一主题下的所有消费者都能收到全量消息;另一种是分组订阅,多个消费者实例组成一个Group,一条消息只会被全局其中一个实例消费。很多消息中间件支持的实际上是分组订阅的模式,通过多个Group实现“广播”的效果。这个区别一定要弄清楚,否则你会在并发场景下看到很诡异的行为:明明起了两个消费者实例,结果只有一台在消费,另一台完全闲着,就是因为它们被分到了同一个Group里。我遇到过这样一个问题:运营系统发一个公告,想广播给所有用户端,结果两个消费者实例同时消费这个主题,把公告内容一人一半分掉了,用户端有的收到有的没收到。排查到最后才发现是消费组的语义理解错了。所以设计订阅关系时,一定要先弄清楚“你是要大家各拿一份,还是要把任务分开各干一部分”。2.3 可靠性机制:确认、重试与死信发消息容易,难的是在故障条件下保证消息不丢、不重复、最终被处理。这里有三道闸门需要理解。第一道是生产端确认。发送消息时,代理返回成功确认才算真正写入;如果没有确认,生产者需要决定是否重发。但重发会引入重复风险,这里就涉及“至少一次”还是“至多一次”的语义选择。第二道是消费端确认。消费者拿到消息处理后,主动向代理发送ack确认,代理才会把这条消息标记为已消费。如果处理失败,消费者不回ack,消息会被重新投递。这个机制是“不丢消息”的基石,但代价是消费端必须做好幂等,否则重试就会变成重复处理。第三道是死信队列。当一条消息重试了多次仍然失败,说明它可能是一条“问题消息”,不应该无限重试拖死后面的正常消息。通常的实践是把它丢进一个单独的死信主题,等人工排查后决定是修复重投还是丢弃。我处理过的线上事故里,有不少都是靠死信队列里的原始消息找回现场证据的。比如某条订单数据一直处理失败,如果没有死信机制,它就会反复被消费,然后在业务日志里留下大片大片的报错。3. 动手实操:从代码到生产方案3.1 先写一个轻量事件总线如果系统还没到分布式规模,只是几个服务之间要做解耦,那么自己实现一个事件总线就够了。我曾经在模块间通信的初期这么干过,成本低,逻辑直观。先展示核心实现,用Python写一个进程内的事件总线:import threading import collections from typing import Callable, Dict, List class EventBus: def __init__(self): self._subscribers: Dict[str, List[Callable]] = {} self._lock = threading.Lock() def subscribe(self, event_type: str, handler: Callable): with self._lock: if event_type not in self._subscribers: self._subscribers[event_type] = [] self._subscribers[event_type].append(handler) def unsubscribe(self, event_type: str, handler: Callable): with self._lock: if event_type in self._subscribers: self._subscribers[event_type].remove(handler) def publish(self, event_type: str, data=None): handlers = [] with self._lock: handlers = list(self._subscribers.get(event_type, [])) for handler in handlers: try: handler(data) except Exception as e: # 实际项目里这里要接入统一异常上报 print(f"handler error: {e}")这个实现有几个要点值得说明一下。第一,publish的时候先把订阅者列表拷贝出来再遍历,是为了避免在处理过程中有人动态取消订阅导致遍历出错。第二,每个handler的异常在内部捕获掉,是防止一个订阅者出问题把整个发布链路打断,实际项目里还应该引入统一的异常上报机制。当然,这种进程内总线的局限也很明显:没有持久化、没有跨机器的能力、进程重启消息就丢了。所以它只能用在单体架构的内部模块解耦,一旦你要做服务级别的通信,就得换真正的消息中间件。3.2 接入消息中间件的典型流程生产环境我做过的落地方式,基本都遵循一套固定流程。第一步,确定主题命名规范。主题名要能看出业务域,比如order-event、payment-event,不要用order、data这种含糊的名字。第二步,确定消息体结构。建议统一用包含消息ID、事件类型、产生时间、业务数据的JSON结构,给未来排查留好线索。第三步,确定消费方式。如果下游有多个系统要响应同一个事件,每个系统使用独立的消费者组;如果只是希望多个实例分摊压力,就共用一个消费者组。第四步,配置ack和重试策略。消费失败时不应该无限重试,一般控制在3到5次,超过就进死信队列。这里我用代码展示一个消费者端的基本姿势(伪代码,具体API看你用的中间件版本):def handle_order_created(message): order_id = message.data['order_id'] try: process_order(order_id) message.ack() except RetriableError: message.requeue(delay=30) except Exception: message.reject(dead_letter=True)这段逻辑非常实用。RetriableError代表这次失败是暂时性的,比如下游数据库连接抖动,可以延迟重试;其他异常说明可能消息本身有问题,直接进死信都比反复重试消耗资源强。我在多个项目里都用这套分类策略,线上消息处理的稳定性提升非常明显。3.3 参数与配置的实战心得配置消息中间件时,有几个参数是我每次都要反复检查和调优的:批量拉取大小。消费者每次拿走多少条消息,不是越大越好。太大会导致单批处理时间变长,消息在内存里积压;太小又会造成网络往返过多。日常项目里,批量拉取大小定位在几十条比较平衡。并发消费者数。一个主题下的消费者实例数量,决定了消费的并行度。注意消费者实例数乘以单实例的处理能力,要能覆盖业务高峰的数据量,最好留出20%到30%的余量。消费超时与重试间隔。消费超时时间设置要大于业务处理的最长时间,否则会发生消息明明在处理,却被代理认为超时又重新投递,造成重复处理。我之前就栽过这个坑:一个批量任务处理要40秒,超时时间设置成了30秒,结果同一批数据每次都被投递了两三遍。注意:消费超时时间必须结合业务最坏情况来设置,宁可大一点也不要因为超时触发大量重复投递。重复消息比慢消息更难排查。另外,重试间隔也不能设置得太短。我见过有人把重试间隔设成3秒,结果下游服务故障持续了5分钟,期间所有消息都在疯狂重试,光重试流量就把下游又打垮了一轮。合理的做法是使用指数退避策略,比如第一次重试延迟10秒,第二次30秒,第三次1分钟,给下游恢复留出时间窗口。4. 常见问题与排查技巧实录4.1 消息丢失:查三个环节消息丢失是发布订阅场景下最头疼的问题。排查的时候,我按“生产端、中间件、消费端”三个环节逐个排查。生产端:确认这条消息发送时是否收到了确认响应。很多消息丢失的根源,是生产者在发送时没有确认发送成功,或者发送失败后没有重发机制。看生产日志里有没有报错信息,是最快的定位方式。中间件:确认消息是否落盘,主题是否有过期时间。如果主题的保存策略设得太短,还没等消费者拉走,消息就可能被清掉。特别是存量很大的时候,主题的存储策略清理逻辑超乎寻常地快,需要仔细核对配置。消费端:确认消费者是否有ack。如果消费者为了省事,拉下来消息直接处理,处理完又忘记ack,代理就会反复投递同一条消息,从日志上看像是“丢了”,其实是“卡住了”。消费日志里查最近有没有反复处理同一条消息,能快速判断是不是这个原因。另外还有一个很容易被忽视的场景:消息被写进了错误的分区。很多消息中间件支持根据业务ID把消息哈希到固定分区,如果某些消息没有拿到分区键,就会全部堆到默认分区,造成消费乱序或者部分消息一直不被匹配的消费者处理。排查时需要确认消息的分区分布是否均匀。4.2 重复消费:幂等是唯一的解药只要用的是至少一次语义,重复消费就一定会发生。这不是bug,而是机制的一部分。消费者宕机、ack超时、网络抖动,都会导致同一条消息被投递多次。应对重复消费的唯一方法,就是让处理逻辑幂等。常见的做法有几种:给消息带上全局唯一ID,消费方根据这个ID查重表,处理过就不再处理。很多系统中都用一张消息去重表来实现这个目标。利用业务天然的唯一键来做幂等,比如订单号、支付流水号,在数据库里建唯一索引,重复插入直接报冲突,catch住即可。用状态机约束:一条数据只有在特定状态才能执行特定操作,状态更新使用条件更新,确保重复执行不会带来重复效果。我在做财务对账系统时,经历过一次典型的重复消费事故:某个回调事件被重复投递,导致一笔账被记了两次。没有幂等设计,排查过程极其痛苦。那之后我把所有消费逻辑都默认当成“会重复发生”来设计,每次都是先查一次,再操作一次,宁多一次查询,不冒重复风险。还有一个细节经验:幂等表本身要跟业务操作放在同一个事务里。如果先查重、再处理业务、最后写幂等记录,中间任意一步失败都会留下漏洞。正确的姿势是业务数据更新和幂等标记写入在同一次数据库事务里完成,这样才能保证一致性。4.3 消息积压:先处理再深究消息积压实操上,我用的是先恢复再排查的思路。第一步,扩容消费者实例。消费者组里加实例,是最快的横向扩容手段。如果部署在容器里,直接在消费组里多起两三个实例就能立竿见影。前提是下游的数据库、接口等还扛得住并发增长,否则积压没解决,先把下游打垮。第二步,检查消费逻辑有没有阻塞点。很多时候积压不是消费能力不够,而是消费逻辑里有一个慢调用卡住了,比如调了一个超时需要30秒的外部接口。这种时候要给外部调用加超时和熔断,别让一个慢依赖拖垮整条消费链路。第三步,积累积压量特别大时,可以先暂停一部分业务无关的消息订阅,优先处理核心业务数据。等积压恢复正常,再把订阅拉起来。这种策略需要在业务侧和团队间达成一致,不是技术层面能单独决定的,但是救命的手段。我印象最深的一次线上事故,就是业务做了一波大促活动,订单量是平时的几十倍,结果消费端数据库连接池被打满。当时先做了实例扩容,又紧急调整了消息批量拉取的策略,把一批拉取的消息数量降下来,让数据库有喘息时间,这才慢慢把积压消化掉。复盘时发现,真正的根因是消费逻辑里一个分组查询没有加索引,数据量一大就把数据库拖慢了。所以说,遇到积压不要只想到扩容,一条慢SQL带来的积压,扩容再多也没用。关于积压还有一个被忽略的点:消费者实例数并不能无限增加。消费者组里实例多了之后,每个实例分到的分区数量会减少。当实例数超过分区数时,多出来的实例就完全空闲,不会帮助消费。所以在扩容前先看清楚自己的主题有几个分区,再决定起多少个消费者实例。写这篇文章的时候,我回想起自己从单模块调用、到写事件总线、再到底层接消息中间件这一路踩坑过来的过程。发布订阅这套模式,技术实现上不算难,难的是对业务边界的理解和事件粒度的设计。我个人的经验是,开始不用追求一步到位,先从最笨的消息广播做起,把核心链路跑通,再逐步引入分组、死信、幂等这些机制,系统会一步步变得像样。最后再分享一个小技巧,所有线上关于消息的排查,都要第一时间把原始消息内容完整留档,这是你事后还原现场最重要的依据,也是我在接手几个维护了多年的旧系统之后反复确认过的教训。
做后端开发的这几年,我遇到最多的设计问题之一就是系统之间怎么通信。早期项目里,A系统的数据一变,就得硬编码调用B系统、C系统的接口,改一个接口就要联动改好几个调用方。中间只要有一个系统升级,整个调用链就跟着紧张。后来真正把发布-订阅用起来,才算是把这个问题彻底理顺了。发布-订阅的核心思想,是把消息的生产者和消费者彻底拆开:生产者只负责把消息发到某个“主题”上,不用关心谁会去读;消费者按需订阅自己关心的主题,自己去拉消息。双方互不知道对方的存在,自然也就谈不上硬编码依赖。这篇文章不打算讲教科书式的概念,而是从我实际踩坑、落地的角度出发,把发布-订阅的核心机制、代码实现、以及最常遇到的故障排查方法一次讲清楚。无论你是正在做系统拆分、微服务改造,还是只是想让自己模块之间的通信更清爽,这篇都值得看完。1. 发布-订阅的底层逻辑与场景判断1.1 从一次订单重构说起假设有一个电商系统,用户下单成功后,系统至少要干这几件事:扣减库存、给用户发通知、给会员体系累积积分、把订单数据推给数据分析。如果按点对点的写法,订单服务里会写类似这样的代码:def create_order(order_data): # 保存订单 save_order(order_data) # 调库存服务 inventory_service.reduce_stock(order_data["items"]) # 调通知服务 notify_service.send_message(order_data["user"], "下单成功") # 调积分服务 points_service.add_points(order_data["user"], 100) # 调数据分析 analytics_service.push(order_data)这种写法的致命问题,不是代码长,而是耦合。每一次新增下游,比如要增加一个订单同步到财务系统的需求,就得回来改订单服务的代码。而且下单是强主链路,任何一个下游系统响应慢或者超时,用户的订单操作就会被拖慢。库存服务一抖动,用户连下单都成功不了,这显然不合理。用发布-订阅改造以后,订单服务只做一件事:在自己的事务里,把“订单已创建”这个事件发到一个订单主题上。库存、通知、积分、数据分析各自订阅这个主题,自己去拉消息。新增财务同步?财务系统自己订阅就行,订单服务一行代码都不用动。库存服务挂了,最多是消息在队列里等它回来,用户该下单下单,完全不受影响。这就是发布-订阅最核心的价值:划清边界,削峰填谷,让上下游各自演进。真正用起来之后,你会发现架构里很多“说不清谁该调谁”的问题,最后都能归到“你发布事件,我订阅事件”这八个字上。1.2 它和点对点队列、观察者模式到底差在哪很多人把发布-订阅和消息队列、观察者模式混在一起,搞不清边界。其实它们的差别很实际:观察者模式是最早的“发布订阅雏形”,但它基本限定在单机、进程内,Subject直接持有Observer引用,消息传递是同步的。比如前端框架里的事件绑定,就是典型的观察者模式。它解决的是代码层面的解耦,不解决服务之间的通信问题。点对点队列(Point-to-Point)虽然也有消息中间件的参与,但一条消息从入队到出队,最终只会被一个消费者消费。典型场景是任务分发,比如一批短信发送任务,多个worker各自领任务,每个任务只执行一次。这种模式保证“竞争消费”,但不适合做广播通知。发布-订阅的核心特征是广播:一个主题上的一条消息,可以被多个订阅方各自收到一份,互不影响。如果把主题比作一个公共频道,点对点队列是“一个任务给一个人干”,发布订阅是“一条新闻所有关注的人都看到”。维度观察者模式点对点队列发布-订阅通信范围进程内跨进程/跨机器跨进程/跨机器消息去向绑定观察者一条消息一个消费者一条消息所有订阅方通信方式同步为主异步异步持久化能力无支持支持适用场景UI事件绑定、状态流转任务分发、负载均衡广播通知、事件驱动我自己的经验是,接手一个架构时先判断它要解决的到底是“任务分配”还是“事件广播”,这决定了你上消息中间件还是上事件总线。用错模式,后面维护成本会翻倍。2. 核心机制拆解:主题、订阅关系与消息流转2.1 四个基础角色一次标准的发布订阅通信,包含四个角色:生产者(Producer):负责发布消息的一方,只需要知道往哪个主题发,不感知下游是谁。消费者(Consumer):负责订阅主题,消费消息的一方。多个消费者可以组成一个消费者组,组内竞争消费,组间广播。主题(Topic):消息的逻辑分类,相当于一个消息容器。所有消息都发到对应的主题上,消费者只拉自己订阅的主题。消息代理(Broker):承接消息存储、转发、分发的中间层。它承担“媒婆”的职责,把生产者和消费者隔离开。我用一个生活类比来理解这四个角色:主题是电台的节目频道,生产者是主持人,只负责在频道里说话,消费者是收音机前的听众,选择自己喜欢的频道收听。中间负责信号发射和传输的,就是消息代理。主持人不知道有多少听众在听,听众也不关心主持人背后怎么生产内容。在实际系统中,消费者不是直接“收到”消息的,而是主动去代理那里拉取(Pull)。这和很多人的直觉相反。大多数消息中间件采用的模式是消费者通过长轮询来拉消息,而不是代理推给消费者。拉模式的好处是消费者可以控制处理速度,按自己的能力消费,天然就对消费速度有调节作用。推模式最大的问题在于,消费者处理能力弱时,代理全速推送会导致消费端内存被打爆,处理速度跟不上就只能丢消息。2.2 主题的粒度与订阅关系的设计主题粒度设计得好不好,直接决定系统后续好不好维护。我见过最糟糕的做法,是把所有业务事件全发到同一个主题上,消费者拉下来以后自己判断类型。这样做的后果是:一个消费者必须处理全站所有类型的事件,逻辑上越来越臃肿,而且不同团队之间会互相干扰。正确做法应该是按业务域划分主题,比如订单主题、支付主题、用户主题,一个主题里再通过事件类型(Event Type)区分具体的业务动作,比如OrderCreated、PaymentSucceeded。另外,订阅关系一般分为两种:一种是广播订阅,同一主题下的所有消费者都能收到全量消息;另一种是分组订阅,多个消费者实例组成一个Group,一条消息只会被全局其中一个实例消费。很多消息中间件支持的实际上是分组订阅的模式,通过多个Group实现“广播”的效果。这个区别一定要弄清楚,否则你会在并发场景下看到很诡异的行为:明明起了两个消费者实例,结果只有一台在消费,另一台完全闲着,就是因为它们被分到了同一个Group里。我遇到过这样一个问题:运营系统发一个公告,想广播给所有用户端,结果两个消费者实例同时消费这个主题,把公告内容一人一半分掉了,用户端有的收到有的没收到。排查到最后才发现是消费组的语义理解错了。所以设计订阅关系时,一定要先弄清楚“你是要大家各拿一份,还是要把任务分开各干一部分”。2.3 可靠性机制:确认、重试与死信发消息容易,难的是在故障条件下保证消息不丢、不重复、最终被处理。这里有三道闸门需要理解。第一道是生产端确认。发送消息时,代理返回成功确认才算真正写入;如果没有确认,生产者需要决定是否重发。但重发会引入重复风险,这里就涉及“至少一次”还是“至多一次”的语义选择。第二道是消费端确认。消费者拿到消息处理后,主动向代理发送ack确认,代理才会把这条消息标记为已消费。如果处理失败,消费者不回ack,消息会被重新投递。这个机制是“不丢消息”的基石,但代价是消费端必须做好幂等,否则重试就会变成重复处理。第三道是死信队列。当一条消息重试了多次仍然失败,说明它可能是一条“问题消息”,不应该无限重试拖死后面的正常消息。通常的实践是把它丢进一个单独的死信主题,等人工排查后决定是修复重投还是丢弃。我处理过的线上事故里,有不少都是靠死信队列里的原始消息找回现场证据的。比如某条订单数据一直处理失败,如果没有死信机制,它就会反复被消费,然后在业务日志里留下大片大片的报错。3. 动手实操:从代码到生产方案3.1 先写一个轻量事件总线如果系统还没到分布式规模,只是几个服务之间要做解耦,那么自己实现一个事件总线就够了。我曾经在模块间通信的初期这么干过,成本低,逻辑直观。先展示核心实现,用Python写一个进程内的事件总线:import threading import collections from typing import Callable, Dict, List class EventBus: def __init__(self): self._subscribers: Dict[str, List[Callable]] = {} self._lock = threading.Lock() def subscribe(self, event_type: str, handler: Callable): with self._lock: if event_type not in self._subscribers: self._subscribers[event_type] = [] self._subscribers[event_type].append(handler) def unsubscribe(self, event_type: str, handler: Callable): with self._lock: if event_type in self._subscribers: self._subscribers[event_type].remove(handler) def publish(self, event_type: str, data=None): handlers = [] with self._lock: handlers = list(self._subscribers.get(event_type, [])) for handler in handlers: try: handler(data) except Exception as e: # 实际项目里这里要接入统一异常上报 print(f"handler error: {e}")这个实现有几个要点值得说明一下。第一,publish的时候先把订阅者列表拷贝出来再遍历,是为了避免在处理过程中有人动态取消订阅导致遍历出错。第二,每个handler的异常在内部捕获掉,是防止一个订阅者出问题把整个发布链路打断,实际项目里还应该引入统一的异常上报机制。当然,这种进程内总线的局限也很明显:没有持久化、没有跨机器的能力、进程重启消息就丢了。所以它只能用在单体架构的内部模块解耦,一旦你要做服务级别的通信,就得换真正的消息中间件。3.2 接入消息中间件的典型流程生产环境我做过的落地方式,基本都遵循一套固定流程。第一步,确定主题命名规范。主题名要能看出业务域,比如order-event、payment-event,不要用order、data这种含糊的名字。第二步,确定消息体结构。建议统一用包含消息ID、事件类型、产生时间、业务数据的JSON结构,给未来排查留好线索。第三步,确定消费方式。如果下游有多个系统要响应同一个事件,每个系统使用独立的消费者组;如果只是希望多个实例分摊压力,就共用一个消费者组。第四步,配置ack和重试策略。消费失败时不应该无限重试,一般控制在3到5次,超过就进死信队列。这里我用代码展示一个消费者端的基本姿势(伪代码,具体API看你用的中间件版本):def handle_order_created(message): order_id = message.data['order_id'] try: process_order(order_id) message.ack() except RetriableError: message.requeue(delay=30) except Exception: message.reject(dead_letter=True)这段逻辑非常实用。RetriableError代表这次失败是暂时性的,比如下游数据库连接抖动,可以延迟重试;其他异常说明可能消息本身有问题,直接进死信都比反复重试消耗资源强。我在多个项目里都用这套分类策略,线上消息处理的稳定性提升非常明显。3.3 参数与配置的实战心得配置消息中间件时,有几个参数是我每次都要反复检查和调优的:批量拉取大小。消费者每次拿走多少条消息,不是越大越好。太大会导致单批处理时间变长,消息在内存里积压;太小又会造成网络往返过多。日常项目里,批量拉取大小定位在几十条比较平衡。并发消费者数。一个主题下的消费者实例数量,决定了消费的并行度。注意消费者实例数乘以单实例的处理能力,要能覆盖业务高峰的数据量,最好留出20%到30%的余量。消费超时与重试间隔。消费超时时间设置要大于业务处理的最长时间,否则会发生消息明明在处理,却被代理认为超时又重新投递,造成重复处理。我之前就栽过这个坑:一个批量任务处理要40秒,超时时间设置成了30秒,结果同一批数据每次都被投递了两三遍。注意:消费超时时间必须结合业务最坏情况来设置,宁可大一点也不要因为超时触发大量重复投递。重复消息比慢消息更难排查。另外,重试间隔也不能设置得太短。我见过有人把重试间隔设成3秒,结果下游服务故障持续了5分钟,期间所有消息都在疯狂重试,光重试流量就把下游又打垮了一轮。合理的做法是使用指数退避策略,比如第一次重试延迟10秒,第二次30秒,第三次1分钟,给下游恢复留出时间窗口。4. 常见问题与排查技巧实录4.1 消息丢失:查三个环节消息丢失是发布订阅场景下最头疼的问题。排查的时候,我按“生产端、中间件、消费端”三个环节逐个排查。生产端:确认这条消息发送时是否收到了确认响应。很多消息丢失的根源,是生产者在发送时没有确认发送成功,或者发送失败后没有重发机制。看生产日志里有没有报错信息,是最快的定位方式。中间件:确认消息是否落盘,主题是否有过期时间。如果主题的保存策略设得太短,还没等消费者拉走,消息就可能被清掉。特别是存量很大的时候,主题的存储策略清理逻辑超乎寻常地快,需要仔细核对配置。消费端:确认消费者是否有ack。如果消费者为了省事,拉下来消息直接处理,处理完又忘记ack,代理就会反复投递同一条消息,从日志上看像是“丢了”,其实是“卡住了”。消费日志里查最近有没有反复处理同一条消息,能快速判断是不是这个原因。另外还有一个很容易被忽视的场景:消息被写进了错误的分区。很多消息中间件支持根据业务ID把消息哈希到固定分区,如果某些消息没有拿到分区键,就会全部堆到默认分区,造成消费乱序或者部分消息一直不被匹配的消费者处理。排查时需要确认消息的分区分布是否均匀。4.2 重复消费:幂等是唯一的解药只要用的是至少一次语义,重复消费就一定会发生。这不是bug,而是机制的一部分。消费者宕机、ack超时、网络抖动,都会导致同一条消息被投递多次。应对重复消费的唯一方法,就是让处理逻辑幂等。常见的做法有几种:给消息带上全局唯一ID,消费方根据这个ID查重表,处理过就不再处理。很多系统中都用一张消息去重表来实现这个目标。利用业务天然的唯一键来做幂等,比如订单号、支付流水号,在数据库里建唯一索引,重复插入直接报冲突,catch住即可。用状态机约束:一条数据只有在特定状态才能执行特定操作,状态更新使用条件更新,确保重复执行不会带来重复效果。我在做财务对账系统时,经历过一次典型的重复消费事故:某个回调事件被重复投递,导致一笔账被记了两次。没有幂等设计,排查过程极其痛苦。那之后我把所有消费逻辑都默认当成“会重复发生”来设计,每次都是先查一次,再操作一次,宁多一次查询,不冒重复风险。还有一个细节经验:幂等表本身要跟业务操作放在同一个事务里。如果先查重、再处理业务、最后写幂等记录,中间任意一步失败都会留下漏洞。正确的姿势是业务数据更新和幂等标记写入在同一次数据库事务里完成,这样才能保证一致性。4.3 消息积压:先处理再深究消息积压实操上,我用的是先恢复再排查的思路。第一步,扩容消费者实例。消费者组里加实例,是最快的横向扩容手段。如果部署在容器里,直接在消费组里多起两三个实例就能立竿见影。前提是下游的数据库、接口等还扛得住并发增长,否则积压没解决,先把下游打垮。第二步,检查消费逻辑有没有阻塞点。很多时候积压不是消费能力不够,而是消费逻辑里有一个慢调用卡住了,比如调了一个超时需要30秒的外部接口。这种时候要给外部调用加超时和熔断,别让一个慢依赖拖垮整条消费链路。第三步,积累积压量特别大时,可以先暂停一部分业务无关的消息订阅,优先处理核心业务数据。等积压恢复正常,再把订阅拉起来。这种策略需要在业务侧和团队间达成一致,不是技术层面能单独决定的,但是救命的手段。我印象最深的一次线上事故,就是业务做了一波大促活动,订单量是平时的几十倍,结果消费端数据库连接池被打满。当时先做了实例扩容,又紧急调整了消息批量拉取的策略,把一批拉取的消息数量降下来,让数据库有喘息时间,这才慢慢把积压消化掉。复盘时发现,真正的根因是消费逻辑里一个分组查询没有加索引,数据量一大就把数据库拖慢了。所以说,遇到积压不要只想到扩容,一条慢SQL带来的积压,扩容再多也没用。关于积压还有一个被忽略的点:消费者实例数并不能无限增加。消费者组里实例多了之后,每个实例分到的分区数量会减少。当实例数超过分区数时,多出来的实例就完全空闲,不会帮助消费。所以在扩容前先看清楚自己的主题有几个分区,再决定起多少个消费者实例。写这篇文章的时候,我回想起自己从单模块调用、到写事件总线、再到底层接消息中间件这一路踩坑过来的过程。发布订阅这套模式,技术实现上不算难,难的是对业务边界的理解和事件粒度的设计。我个人的经验是,开始不用追求一步到位,先从最笨的消息广播做起,把核心链路跑通,再逐步引入分组、死信、幂等这些机制,系统会一步步变得像样。最后再分享一个小技巧,所有线上关于消息的排查,都要第一时间把原始消息内容完整留档,这是你事后还原现场最重要的依据,也是我在接手几个维护了多年的旧系统之后反复确认过的教训。