资讯动态

Redis发布订阅实战:分类信息平台的事件广播架构与避坑指南

发布时间:2026/10/23 8:29:15 来源:尧图企业网站定制
做信息分类平台的同学对这套流程应该不陌生一条房源信息被房东改了价格业务方A要触发价格变更提醒业务方B要更新搜索索引业务方C要做运营统计。如果每次变更都靠各业务方轮询数据库延迟高不说数据库压力也吃不消如果所有业务方都直接调用接口通知系统耦合又太重。Redis发布订阅模式恰好就是解决这类“一条消息被多个系统同时消费”问题的轻量级方案。这篇文章会结合我在分类信息平台场景下的实际经验把Redis发布订阅从原理、应用设计到线上踩坑完整拆一遍。不管你是后端开发、中间件维护者还是正在做系统解耦设计的技术负责人只要遇到实时通知、广播、缓存失效这类需求这篇文章都值得收藏。1. 为什么分类信息平台需要发布订阅机制1.1 海量信息流转背后的实时协作难题58同城这类信息分类平台核心业务就是各种各样的信息流转用户发布房源、招聘方更新职位、商家修改服务信息。每一次信息变更都会牵连到很多下游环节。拿一条二手房源来说房东在后台改价格前台下单的广告位需要更新展示搜索系统需要重新计算排序权重用户收藏列表里要展示最新状态运营后台还要记录这次变更行为。在这些系统没有解耦之前最直接的做法就是各业务方定时去源数据库扫描捞取“最近10分钟内变更过”的记录。这个方案一开始还能用数据量上来以后问题就非常明显。第一是延迟不可控一个扫描任务跑起来可能要几分钟等它发现变更再触发后续流程用户刷新页面看到的可能还是老价格第二是数据库压力大热门城市的热门类目变更记录每天都是千万级别每个业务方都来扫一遍相当于给数据库做了数倍的重复查询第三是业务逻辑碎片化每个业务方都要自己维护“怎么判断一条记录有变更”的逻辑很容易出现口径不一致。这时候需要的是一个“变更事件广播”的机制核心系统只负责在数据变更时发一条消息出去其他所有关心的系统各自订阅收到消息后按照自己的逻辑处理互不干扰。如果把选择面放开能用的消息组件其实不少但很多团队在初期往往不会直接上重型消息队列而是先找一个轻量、可靠、团队里大多数人已经熟练的组件来顶住Redis的发布订阅功能就进入了候选名单。1.2 为什么选 Redis 发布订阅而不是直接上消息队列在很多技术讨论里提到“消息”两个字大部分人的第一反应就是Kafka、RocketMQ或者RabbitMQ。不可否认这些专业消息中间件在持久化、堆积能力、消息回溯、事务性保障等方面都非常强但它们也带来了额外的运维成本和学习成本。Kafka依赖ZooKeeper或KRaft机制集群规模上来之后Topic分区、副本同步、磁盘水位这些运维细节都够喝一壶的。而Redis发布订阅有它独特的生态位置。如果公司的Redis已经是核心基础设施集群和运维体系都比较成熟那么使用发布订阅功能几乎没有额外的组件依赖。Redis的发布订阅模型极其简单发布者往channel发消息订阅者从channel收消息中间没有复杂的ACK机制、没有消费位点管理、没有持久化存储。这个简单性在某些场景下反而是巨大的优势——代码写起来不容易出错问题排查链路短性能也足够高。具体到信息分类平台的实时通知场景很多消息本质上属于“即发即弃”的实时信号。比如缓存失效通知、业务配置变更提醒、用户端长连接的心跳触发这类消息丢一条不会造成灾难性后果订阅端如果没收到完全可以通过下次轮询或者定期全量刷新来兜底。这种“允许少量丢失、不允许明显延迟、要求极简接入”的场景和Redis发布订阅的特性几乎完美匹配。如果为了这类诉求直接上Kafka等于杀鸡用了牛刀还要为它单独准备一套Topic规范、消费组管理规则对很多中小团队来说是纯粹的负担。我见过不少团队把Redis发布订阅和消息队列混为一谈最后在“消息丢了为什么查不到”这个问题上纠结半天。这不是技术本身的问题是对组件的定位没有弄清楚。Redis发布订阅适合做实时信号通道不适合做数据管道这个边界想清楚后续的设计才不会跑偏。2. Redis发布订阅的核心机制与能力边界2.1 发布订阅模型的底层工作方式Redis发布订阅的官方名称叫PUB/SUB从Redis 2.0版本开始就有了这个功能。它由三个基本命令构成PUBLISH负责往指定频道发布消息SUBSCRIBE负责订阅一个或多个频道PSUBSCRIBE则支持按照频道名的通配符模式来订阅收到匹配频道名的消息后也能正常接收。它的工作逻辑可以用一个不太严谨但很好懂的类比来解释就像电台广播。发布者就是电台主持人他对着话筒说话不知道有多少人在听也不关心谁在听订阅者就是收音机用户只要把频率调到对应频道就能收到内容中途打开收音机的人只能收听打开之后的内容之前广播过的内容一概收不到。Redis服务器内部为发布订阅维护了一套独立的频道映射关系每个频道名对应一组订阅者连接。当PUBLISH命令到达服务器时Redis会找到该频道的所有订阅者把消息逐条推送给这些连接。注意这里用的是推模式和普通Redis命令的请求-响应模式完全不同。发布者PUBLISH命令执行完成后Redis只是把消息推送给了当前所有在线订阅者立即返回成功不会确认订阅者是否真的收到了消息更不会做任何形式的重试。用Redis客户端执行命令来观察是最直观的。打开两个redis-cli窗口第一个窗口执行SUBSCRIBE channel:test可以看到进入订阅模式后命令行提示符变成类似SUBSCRIBE的状态第二个窗口执行PUBLISH channel:test hello第一个窗口会立刻打印出包含频道名和消息内容的结果。实测下来这种模式的传播延迟通常在亚毫秒级别比业务方定期扫描数据库快了不止一个量级。2.2 与消息队列的本质差异Redis发布订阅经常被拿来和消息队列做对比但是这里存在一个关键差异消息队列通常是队列模型一条消息被一个消费组里的某一个消费者取走处理完后提交位点下一条消息才会被继续消费消息本身是持久化的消费者宕机重启后还能从上次的位点继续消费。而Redis发布订阅是广播模型同一条消息会同时推送给所有订阅者而且消息本身不落地没有ACK机制没有消费位点订阅者离线期间发布的消息直接就丢了。这个差异决定了两个组件的选型分界线。如果业务需求是“每一条消息都必须至少被处理一次处理失败要能重试”那应该选消息队列而不是Redis发布订阅。反过来如果是“所有在线节点都要实时知道这件事”那消息队列反而难以实现因为消息队列的消息默认是被消费组竞争消费的只有一条能被消费到想做广播还得为每个节点单独建消费组成本并不低。我见过很多团队踩过同一个坑把发布订阅当成“没有持久化但是很快的消息队列”来用。典型症状就是消费者进程夜里重启了一下刚好错过夜里发布的消息第二天数据报表出现差异。改用消息队列或者给发布订阅加一层落盘补偿机制之后问题才彻底解决。用哪个组件本身不是问题问题在于一开始对组件能力边界的预期就和真实机制不一致。2.3 哪些业务场景适合接入发布订阅结合分类信息平台的实际业务我总结了四类适合接入Redis发布订阅的场景。第一类是缓存失效广播。多实例部署的服务里如果某个实例更新了数据库中的数据需要把其他实例中对应的Redis缓存或本地缓存及时清理掉直接遍历所有实例逐个通知不现实通过发布订阅广播一条“某key已变更”的消息所有实例收到后自行删除本地缓存是最标准的做法。第二类是配置与开关实时推送。管理后台修改了一个业务开关或者策略配置希望线上所有实例在数秒内感知变更。使用发布订阅把更新后的配置广播出去每个实例收到后更新自己的内存配置副本比依赖配置中心轮询更实时也能在配置中心不可用时作为降级方案。第三类是长连接服务的事件触发。平台有很多维持长连接的服务比如用户端的消息推送通道、WebSocket实时通知服务。当后端业务系统产生了一个需要推送给指定用户的事件时可以通过发布订阅把这条事件广播到所有长连接网关节点由持有对应用户连接的节点执行真正的推送。这样业务系统不需要维护用户连接与网关节点的映射关系。第四类是数据变更后的业务联动。这也是文章开头提到的场景一条核心业务数据发生变化后搜索索引更新、推荐引擎刷新、运营埋点记录等多方都能实时感知和行动。这类场景对消息的可靠性和实时性要求属于中等即使偶尔丢一条变更通知后续通过定时补偿任务也能保证最终一致放在Redis发布订阅里是合适的。3. 基于信息平台场景的发布订阅落地架构3.1 整体架构与数据流设计在实际工程中直接让业务方各自调用Redis的SUBSCRIBE命令去订阅业务频道会导致订阅管理非常混乱。发消息的人要记清楚每一个业务频道名收消息的人要自己处理反序列化、错误重试、日志记录公共逻辑散落在各个业务代码里。一个成熟的做法是在发布订阅外面封装一层统一的事件总线模块把原始Redis发布订阅包在内部对业务方只暴露Publish和Subscribe两类API。我在信息平台场景里设计过一个简化版的事件总线整个数据流是这样运作的核心业务系统在完成数据库写入后在自己的事务边界内调用事件总线的Publish方法传入一个业务事件对象比如EventTypeHousePriceChanged、BizID123456、Payload包含变更前后的价格。事件总线模块负责把事件对象序列化成JSON字符串加上事件ID和发生时间戳然后通过PUBLISH命令发布到固定的Redis频道频道名通常由事件类型映射而成比如 event:house:price_changed。下游的多个业务系统在启动阶段通过客户端SDK调用Subscribe方法声明自己关心的事件类型。SDK内部维护一个事件类型与处理函数的映射表收到Redis推送的原始消息后统一反序列化成事件对象根据事件类型派发给对应的处理函数。处理函数执行过程中如果抛异常SDK会记录错误日志并按照预设的重试策略进行有限次重试。所有订阅的入口都走同一个Redis连接管理组件业务方甚至不需要知道Redis服务器地址和频道名。发消息的人和收消息的人之间完全解耦发布者不需要关心订阅者是谁也不需要知道订阅者有几个人这是事件驱动架构最核心的收益。3.2 订阅端基础模块的Go实现示例考虑到国内很多后端的订阅消费逻辑都集中在Go服务里我给出一个订阅端的基础代码示例展示如何把Redis订阅连接和业务回调整合到一起。下面的代码主要借助go-redis库完成。package pubsub import ( context encoding/json fmt sync time github.com/go-redis/redis/v8 ) type Event struct { ID string json:id Type string json:type Timestamp int64 json:timestamp Payload interface{} json:payload } type Handler func(ctx context.Context, event *Event) error type EventBus struct { client *redis.Client mu sync.RWMutex handlers map[string]Handler } func NewEventBus(redisAddr string) *EventBus { client : redis.NewClient(redis.Options{ Addr: redisAddr, DialTimeout: 5 * time.Second, ReadTimeout: 30 * time.Minute, // 订阅连接需要长超时 }) return EventBus{ client: client, handlers: make(map[string]Handler), } } func (b *EventBus) Subscribe(eventType string, h Handler) { b.mu.Lock() defer b.mu.Unlock() b.handlers[eventType] h }上面代码里有两个细节需要额外说明。第一创建Redis客户端时ReadTimeout必须设置成较大的值最好是几十分钟甚至不设置超时因为订阅连接会长期阻塞等待服务端推送消息如果使用默认的读超时连接会在短时间内被判定为超时断开。第二订阅连接应当和普通读写命令的连接分开不要共用一个连接池里的连接来执行SUBSCRIBE否则会阻塞该连接上的正常命令执行。接下来是启动订阅循环的核心代码这一步最关键func (b *EventBus) Run(ctx context.Context, channels ...string) error { pubsub : b.client.Subscribe(ctx, channels...) defer pubsub.Close() // 等待订阅确认 if _, err : pubsub.Receive(ctx); err ! nil { return err } ch : pubsub.Channel() for { select { case -ctx.Done(): return nil case msg, ok : -ch: if !ok { return fmt.Errorf(subscribe channel closed) } b.dispatch(ctx, msg.Channel, msg.Payload) } } } func (b *EventBus) dispatch(ctx context.Context, channel, payload string) { // 根据频道名反推事件类型或者直接从payload里解析 var event Event if err : json.Unmarshal([]byte(payload), event); err ! nil { // 记录解析失败日志 return } b.mu.RLock() handler, ok : b.handlers[event.Type] b.mu.RUnlock() if !ok { return } // 用独立的goroutine执行回调避免一个handler卡住影响后续消息 go func() { defer func() { if r : recover(); r ! nil { // 记录panic日志 } }() handler(ctx, event) }() }值得说明的是这里把每个handler放到独立goroutine里执行是为了防止某个业务回调因为外部依赖慢导致阻塞拖累整条订阅消息的处理。但这样也带来了并发处理导致的消息乱序问题如果业务对顺序有严格要求需要改成串行处理或者对同一业务ID的消息做分区处理。比如一台机器上同一个房源的事件最好都落到同一个goroutine里串行执行。业务方调用时只需要完成两步第一步在初始化阶段注册回调第二步启动Runbus : NewEventBus(localhost:6379) bus.Subscribe(house:price:changed, func(ctx context.Context, event *Event) error { // 真正处理价格变更的业务逻辑 return nil }) // 这里可以用事件类型的集合生成对应的Redis频道也可以订阅一个大通配频道 go bus.Run(ctx, event:house)3.3 发布端的封装手段与序列化方案发布端相对简单核心就是把业务事件对象序列化后通过PUBLISH发到对应的频道。但这里有两个容易踩坑的细节值得专门说一下。第一个是关于频道命名的规范。我在实际工作中发现频道名设计得不好后期会非常痛苦。一开始就使用固定的三段式命名比较稳妥event:业务域:事件类型比如 event:house:price_changed、event:user:register_success。这样做的原因是Redis支持PSUBSCRIBE按模式订阅团队内部如果有需要全量事件做监控或数据清洗的系统可以订阅 event:*订阅一个模式就能收到所有业务域的事件。如果频道名只有自定义的短码这种全局视角就得不到了。第二个是关于序列化协议的选型。信息分类平台内部服务之间往往使用多种语言开发Java和Go之间消息互相流转这种场景下JSON是最稳妥的通用格式人可读、排查问题方便、各语言都有成熟库支持代价是序列化后的体积会比二进制格式大一些。对于发布订阅这种以实时信号为主的场景消息体通常只有几百字节JSON的体积劣势完全可以接受。下面是一个发布端示例func (b *EventBus) Publish(ctx context.Context, eventType string, payload interface{}) error { event : Event{ ID: generateEventID(), Type: eventType, Timestamp: time.Now().Unix(), Payload: payload, } data, err : json.Marshal(event) if err ! nil { return err } channel : buildChannel(eventType) return b.client.Publish(ctx, channel, data).Err() }这里需要补充一个从业务侧看到的小细节。payload字段不要直接塞整个业务大对象尽量只放核心标识比如业务ID、变更字段和更新后的关键值。因为发布订阅没有消费位点如果订阅端收到消息后处理失败还需要自行到数据库或者详情接口重新拉取全量数据消息本身只需要起到“提醒你去查一下”的作用就够了把大量字段塞进消息里反而会浪费Redis网络带宽JSON序列化和反序列化也会增加耗时。我见过有人把一条房源详情几百个字段全塞进事件里一次发布消息几十KB性能和必要性都是问题。3.4 模式订阅与业务频道的匹配策略在设计频道时有两种订阅策略可以选择一种是精确订阅固定的业务频道一种是用PSUBSCRIBE订阅一个带通配符的模式比如 event:*。模式订阅有个显著好处新增一种业务事件类型时订阅端不需要改代码重新订阅新事件发布会自动被模式匹配捕获。坏处是收到消息后需要通过消息体里的类型字段再次路由多一层匹配逻辑而且模式订阅的匹配动作在Redis服务端会消耗一些CPU事件量很大时会对Redis单实例产生额外的压力。在信息平台这种业务域多、事件类型更新频繁的环境里我更推荐订阅端面向事件类型注册回调而底层统一使用模式订阅。理由也很直接每新增一种事件发布端只是多了一个频道名映射不需要发布订阅模块本身做任何变更订阅端注册一个回调函数即可真正的消息分发逻辑都是通用的。增加事件类型的成本因此降到最低业务方也更愿意把关键变更都广播出来。如果订阅端只需要接收某一个事件类型的消息比如搜索系统只关心房源上下架不需要订阅event:*这个全量模式那是可以按事件类型构建精确频道名进行订阅的。实际经验是先统计消费端到底关心哪几类事件再去决定采用精确订阅还是模式订阅。如果消费的事件类型超过5个直接用模式订阅省心得多。4. 稳定性治理线上运行绕不开的关键问题4.1 连接断开与自动重连机制Redis发布订阅在实际运行中最常遇到的故障是订阅连接意外断开。连接断开的原因很多网络抖动、Redis服务端超时回收空闲连接、Redis实例发生主从切换、客户端进程被重启。断开后如果客户端没有重连机制就永远收不到消息了而且这种故障通常比较隐蔽因为很多监控只会关注Redis命令的P99耗时和错误率订阅连接假死并不一定会在业务指标上立刻暴露。这里必须理解Redis发布订阅的机制它没有消费位点重连成功后只会收到断开期间之后发布的新消息断开期间错过的消息完全没有补发通道。所以重连的速度至关重要每多断开一秒就多丢失一秒内的所有实时消息。在go-redis中其实官方推荐的做法不是自己写for循环重连而是直接使用pubsub.Channel()方法这个方法内部会对断开的连接进行自动重连并且重新订阅之前订阅过的频道。我自己在项目里的做法是在Run函数外层再套一层带backoff的重试机制防止Redis实例长时间不可用时客户端进入高频重试的死循环把Redi的故障又放大成客户端的CPU和网络风暴。另外订阅连接最好设置TCP层的keepalive参数让操作系统定期发送探测包确认连接依然存活。redis.Client的连接参数里可以设置这个选项不然一次长时间没有消息推送的安静连接可能被中间的网络设备悄然掐断而客户端毫不知情。很多连接假死问题最终都排查到了这个细节上。4.2 慢消费者导致的消息积压与连接阻塞每次提到Redis发布订阅官方文档里都会有一句很关键的话如果一个订阅者的处理速度跟不上消息的生产速度Redis会持续把消息推送到该订阅者的输出缓冲区导致该订阅者的输出缓冲区不断膨胀。缓冲区积压到一定程度Redis会强制断开这个慢消费者的连接极端情况下还可能导致Redis本身的内存压力上升。用生活场景来类比就像一个人点了一堆外卖却吃不完外卖不断往家里送家里很快就塞满了最后只能把门关掉不再接收。Redis对慢消费者的处理机制基本就是这个思路。在实际业务中慢消费者最常见的成因是回调处理函数里做了耗时很长的外部调用比如订阅了房源变更事件收到一条就去调用一个响应需要好几秒的推荐系统接口接口一抖动处理速度立刻跟不上。我在第四章的代码示例里用了goroutine做异步回调能缓解单条消息阻塞的问题但如果消息量真的很大异步也会导致goroutine大量堆积内存飙升最终整个进程被拖垮。解决思路通常是三层配合。第一层控制单个事件类型的订阅粒度不要让一个订阅端接收与自己无关的高频事件第二层给回调处理设置超时和并发上限超过阈值直接丢弃消息并记录告警依靠定期的全量补偿任务兜底第三层进行消息量预估对于超过每秒千条的事件流慎重使用Redis发布订阅尽早切换到具备堆积能力的消息队列。4.3 消息丢失之后的兜底补偿设计Redis发布订阅不保证消息可靠投递这是机制层面的先天属性所以设计系统时必须把“消息会丢”当作默认假设。从这个前提出发可靠性的核心就不是“怎么让Redis保证不丢消息”而是“消息丢了之后怎么发现和找回”。在信息平台场景里最常用也最有效的手段是定时对账补偿。例如房源价格变更事件订阅端是搜索索引系统。除了接收实时变更事件外索引系统每隔一段时间会从源数据库扫描一次增量变更把近5分钟内更新过的房源全部重新同步一遍。实时事件负责让数据秒级生效定时任务负责兜底修复遗漏两者配合达到最终一致。在实际运行中这个兜底任务的扫描间隔一般是1到5分钟扫描频率太高会给数据库增加不必要的压力太低则会出现用户感知的延迟。另外可以在Redis中简单记录最近一段时间发布事件的数量统计比如每分钟发布总数、每个事件类型的发布数订阅端同时记录自己的消费数。通过定时对比两边的数值可以快速发现消费端是否出现了大量丢消息的情况。这个方案比较粗糙但胜在实现成本极低在还没有建全链路监控体系时够用。需要提醒的是千万不要尝试用Redis的持久化功能来补救发布订阅的消息丢失那是无效的。RDB和AOF持久化只能恢复Key中的数据发布订阅消息本身不写入任何数据结构PUBLISH之后消息就只在内存中流转不落盘不存在重启后恢复消息的可能。如果业务需求是重启后还能找回消息直接上消息队列才是正确选择。4.4 主从架构下的发布订阅正确用法信息分类平台的Redis基本都不是单点部署而是采用主从加哨兵或Cluster集群模式。这里有个非常重要的知识点Redis的发布订阅在主从模式下订阅命令只能连接主节点执行。原因从设计上可以理解从节点不能接受写命令PUBLISH属于写操作只能发给主节点SUBSCRIBE虽然本身不是写操作但为了保证订阅者能收到所有消息也应当连接主节点。在Cluster集群模式下情况会更复杂一些。Redis Cluster对发布订阅的处理方式和普通KV命令不一样PUBLISH消息会被广播到集群中的所有节点而不是只发送到某个Key对应的槽位节点。这就意味着即使订阅者和发布者连接的是不同的集群节点订阅者也能收到消息。但是实际使用时强烈建议计算好频道名的哈希值让订阅连接和发布连接固定路由到一致的节点上避免在集群环境里依赖跨节点广播的高延迟路径。我自己踩过一个相关的坑把订阅客户端配置到了从节点的地址上结果发现时好时坏。主从切换后会有一段时间收不到消息排查了很久才发现是客户端把SUBSCRIBE发到了从节点。原因是在某些Redis客户端库的拓扑刷新逻辑里会把从节点地址也暴露给订阅连接。解决办法是显式配置只允许订阅连接使用主节点地址不要从集群拓扑里自动挑选节点。5. 常见问题与排障实录5.1 订阅端莫名其妙收不到消息怎么排查收不到消息是发布订阅最经典的问题。按照我的排查经验可以按下面的顺序逐步确认。先检查最基础的发布者PUBLISH命令执行后返回的数值是多少。如果返回1说明Redis服务端认为当前有1个订阅者收到了消息如果返回0说明根本没有订阅者在频道上执行PUBLISH的人以为发成功了实际并没有任何客户端能收到。这个返回值很多开发同学会忽略但它是最快的信号。确认有订阅者后再检查订阅者是否真的进入了订阅模式。在Redis的客户端列表里执行CLIENT LIST可以看到当前连接的db和sub属性从sub属性的值可以判断该连接当前订阅了几个频道。如果sub是0说明客户端进程虽然在运行但订阅连接并没有生效。接下来检查频道名是否一致。这是最隐蔽的错误来源发布的时候用的是event:house:priceChanged订阅的时候写的是event:house:price_changed肉眼看起来都像但Redis对频道名做的是完全匹配。如果是通过模式订阅还需要检查通配符的写法是否有歧义。建议在项目启动时打印一份订阅频道清单和发布时的频道规则定期核对。最后检查网络和连接。如果上面步骤都正常但依然收不到可以打开Redis的慢日志看是否有异常也可以从发布端和订阅端同时抓包确认Redis服务器是否真的把数据包推送出来了。客户端连接假死、代理中间层缓存了旧连接也是可能的原因。5.2 消息延迟突然上升卡在哪了发布订阅的延迟测量比较简单在事件里加上发布时间戳订阅端收到后用当前时间减一下就是传播延迟。正常情况下这个值应该在毫秒级别。通过告警发现延迟从毫秒级上升到秒级需要重点确认几条链路。第一段是发布者的PUBLISH命令是否被阻塞。Redis是单线程模型如果Redis实例上有慢查询命令在跑比如KEYS、大Key的DEL操作、或者很多并发量很大的SORT命令发布命令会在服务端排队等待延迟自然上升。这种问题可以用redis-cli --latency命令观察实时延迟再结合SLOWLOG查慢命令。第二段是网络路径。如果Redis客户端和服务器之间存在跨机房调用或者经过了一层代理网关网络的抖动也会直接反映到消息延迟上。可以对比同时刻正常Redis读写命令的耗时如果所有命令都变慢了基本可以判断问题不在发布订阅本身。第三段是订阅端的处理能力。订阅端收到消息后并不是马上就执行回调如果回调函数里用了同步阻塞的锁或者上一波消息还没处理完回调队列已经排满新消息就会一直等在那里延迟自然很高。在业务日志里为每个事件记录“收到时间”和“开始处理时间”两个时间戳不断对比就能看出延迟到底发生在回调排队阶段还是Redis阶段。5.3 多个订阅端重复处理消息导致数据被覆盖Redis发布订阅天然会把消息广播给所有订阅者这会带来一个有趣的副作用如果同一个服务部署了多个实例并且每个实例都订阅了相同的频道那么一条消息到达后所有实例都会收到这份消息并且都会执行各自的回调逻辑。对于做幂等处理的场景比如清本地缓存这没有问题所有实例都清理一遍也合理。但对于执行动作类的场景比如发送短信通知或者调用外部接口多个实例同时执行就会造成重复影响。这是一个在设计阶段就经常被忽略的问题。我在项目里见过最典型的案例是一个商家服务部署了20个实例实例都订阅了“商品信息变更”事件收到事件后每个实例都会去检查自己内存中是否有该商品的数据有就更新。这种场景处理函数本身是幂等检查后再执行不会产生重复副作用。但另外有一些团队直接在回调里调用了外部短信服务发送用户通知结果20个实例全都发了一遍线上事故直接就是这个原因。发布订阅模型下要避免重复执行最靠谱的办法是在回调处理函数里做两层防护。第一层根据事件ID或者业务ID在本地内存里做一个去重集合如果最近已经处理过同一条事件则直接返回。第二层对于真正要执行外部动作的代码加上分布式锁保证同一时刻只有一台实例能执行。事件里自带的ID字段在这里就能派上大用场。5.4 频道数量爆炸式增长后的治理经验有一种情况在业务发展较快时会冒出来事件类型的数量越来越多发布端负责人在新业务上线时随手订了一个频道频道命名规范逐渐失控。Redis本身对频道数量没有硬性限制但在发布订阅模式下每个频道都要存储订阅关系频繁创建大量低频频道会增加Redis的内存碎片和连接管理的复杂度。治理经验上我比较推荐做三件事。第一件事在发布订阅SDK里维护一份频道注册表新事件类型必须在使用前登记登记内容包括事件类型名、频道名、payload结构说明、是否允许丢失、是否有补偿任务。这样既规范了命名也在出问题时能快速找到负责人。第二件事监控频道的活跃度定期扫描长期没有消息的频道推动业务方下线避免僵尸频道越积越多。第三件事如果发现订阅端和发布端的对应关系已经非常复杂事件总量已经稳定增长到每秒几千条量级就应该规划把这部分事件逐步迁移到专业消息队列中让发布订阅回到它该负责的“实时信号广播”角色上。我在这类架构演进上的体会是没有任何一个组件可以永远适应所有阶段。Redis发布订阅在业务初期提供了极低的接入门槛和可靠的实时性让团队可以快速跑通事件驱动的架构模式。等到业务规模增长到它的能力边界之外时再引入消息队列属于正常的架构演进两者共存也很常见——实时信号走发布订阅重要数据管道走消息队列各得其所。最后再分享一个实践中培养出来的习惯凡是接入发布订阅的上游系统发布事件时一定要打日志日志里包含频道名、事件ID、关键业务标识和发布时间戳。宁可日志多一点也不要在消息丢失排查时两眼一抹黑。这个习惯在多次线上故障排查中帮我快速定位了问题边界省下来的排查时间远比多写一行日志的成本值得。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑