资讯动态

ZMQ三大通信模式详解:REQ/REP、PUB/SUB与PUSH/PULL

发布时间:2026/10/18 3:34:41 来源:尧图企业网站定制
我是做后端和分布式系统出身的第一次接触ZMQ是因为一个内部任务调度模块要把几千个小任务分摊到几台机器上执行再把结果捞回来。刚开始图省事直接用原生Socket写了一套TCP通信报文格式自己定粘包拆包自己处理断线重连自己写连个心跳都要自己造。折腾了一周我发现我想要的只是把任务发出去、把结果收回来这么个简单语义却被迫在重复造一整套不完整的RPC轮子。后来换成ZMQ核心逻辑一个下午就改完了还顺带把之前那套代码里隐藏的乱序、半包、连接泄漏问题全治好了。ZMQ全称ZeroMQ本质上不是一个消息队列服务而是一组封装好的通信模式库。它把网络编程里最常见的几种通信语义固定成了标准Socket类型REQ、REP、PUB、SUB、PUSH、PULL……每个类型都有明确的收发规则和适用场景。这篇文章是ZMQ通信模式系列的第一部分我把最基础也最常用的三个模式讲透请求-应答REQ/REP、发布-订阅PUB/SUB、管道PUSH/PULL。这三个模式基本覆盖了日常开发里80%的消息通信需求适合刚开始接触分布式通信的人也适合那些已经在用ZMQ但还停留在能跑就行阶段的朋友。文章里的代码都用Python的pyzmq写环境简单、逻辑直白照着敲一遍就能跑起来。1. ZMQ是什么先解决传统Socket开发的三个老大难1.1 传统Socket开发到底痛在哪里做过原生TCP通信的朋友应该都有共鸣看起来API很简单bind、listen、accept、recv、send但真正要把一套通信写得可靠坑是全方位的。第一个痛点是消息边界。TCP是字节流没有一条消息的概念你send一个结构体对端可能一次性收到好几个也可能收到半个。所以必须自己在应用层定义帧格式长度前缀也好、特殊分隔符也好都得自己设计、自己解析。第二个痛点是连接管理。多客户端并发接入你要维护一堆fd要处理半关闭、对端异常断开、重连风暴还要考虑粘包导致的缓冲区溢出。第三个痛点是阻塞与超时。一旦对端不响应你的recv就卡在那里要么靠系统信号要么靠自己封装非阻塞IO越写越复杂。这些痛点不是不能解决市面上成熟的框架也都解决了但它们通常有个共同特点重。你引入一个重量级框架往往要同步引入它的协议规范、部署方式、运维体系对一个小项目来说这是很大的成本。ZMQ走的是另一条路——它把可靠通信中最通用的那部分抽象成了几个固定的模式做成一个轻量库直接嵌入你的进程里不依赖独立的消息服务进程。1.2 ZMQ给出的答案可复用的通信模式ZMQ的核心思想可以概括成一句话网络通信的五种基本语义不需要你每次重新发明直接用对应类型的Socket就行。整个模型围绕三个基础概念展开。第一个是上下文Context它是ZMQ运作的总管家负责管理线程资源、IO线程和Socket生命周期一个进程里通常只需要一个。第二个是Socket注意它和原生Socket完全是两码事ZMQ的Socket从创建那一刻起就自带类型语义比如REQ类型的Socket只能先send再recvPUB类型的Socket只能send不能recv。第三个是消息ZMQ中的消息是长度明确、边界完整的字节帧库内部帮你处理了分帧、缓存和重组你send出去的多大对端recv收到的就是多大永远不会出现半条消息或两条消息粘在一起的情况。还有一个关键点ZMQ是去中心化的没有消息中间件。服务端和客户端的概念被弱化成了bind和connect两个动作bind是等待连接的一方connect是主动连接的一方。connect是懒惰式的——即使对端还没起来调用connect也不会报错ZMQ会在后台不断重试一旦对端bind了端口连接自动建立。这点在工程上非常爽它意味着进程启动顺序不再敏感。注意ZMQ的Socket不是线程安全的一个Socket只能在一个线程内使用但Context是线程安全的可以多线程共享每线程各自创建自己的Socket去收发。这是最容易踩的并发坑后文会再提到。1.3 环境准备与第一个最小示例在动手之前先把环境准备好。Python下直接装pyzmqpip install pyzmq装完验证一下import zmq print(zmq.__version__)然后就可以跑一个最经典的请求-应答示例了。先起服务端import zmq context zmq.Context() socket context.socket(zmq.REP) socket.bind(tcp://*:5555) while True: message socket.recv() print(f收到请求: {message}) socket.send(bworld)再起客户端import zmq context zmq.Context() socket context.socket(zmq.REQ) socket.connect(tcp://localhost:5555) for i in range(10): socket.send(bhello) message socket.recv() print(f收到回复: {message})这里有个小细节要解释一下地址里的tcp://是传输协议除了TCPZMQ还支持进程内通信inproc://和同一台机器上的进程间通信ipc://。inproc的实际用法主要是多线程之间传递消息后面讲分布式章节时我们再展开现在先用tcp把三套核心模式跑熟。2. 请求-应答模式REQ/REP最常用的客户端/服务端骨架2.1 模式原理与严格交替规则REQ/REP是ZMQ里最基础的模式它实现的是同步的客户端-服务端调用你可以把它理解成一个极简版的RPC框架的通信底层。REQ端客户端的语义是send一条请求然后必须recv一条响应不能连续send两次。REP端服务端的语义正好反过来recv一条请求处理完然后必须send一条响应也不能连续recv两次。这个一发一收、严格交替的规则是硬性的违反它会直接抛异常ZMQ内部的术语叫EFSM意思是Socket状态机出错。为什么要有这么严格的规则因为它保证了一条完整的请求-响应链路不会被破坏。想想看如果允许客户端连发两条请求服务端就分不清哪条响应对应哪条请求了。REQ/REP通过这种交替约束天然把同步语义固化了下来你根本不需要在应用层维护消息ID和配对逻辑。2.2 完整的请求应答示例下面是一个稍微完整一点的例子客户端请求一段文本处理任务服务端返回处理结果。服务端代码import zmq import time context zmq.Context() socket context.socket(zmq.REP) socket.bind(tcp://*:5555) while True: request socket.recv_string() print(f服务端收到: {request}) time.sleep(0.1) # 模拟处理耗时 socket.send_string(f已处理: {request})客户端代码import zmq context zmq.Context() socket context.socket(zmq.REQ) socket.connect(tcp://localhost:5555) for task_id in range(5): socket.send_string(f任务-{task_id}) reply socket.recv_string() print(f客户端收到: {reply})运行起来你会看到客户端发一条服务端回一条消息一一对应顺序完全一致。这里顺便说一句REQ/REP模式下多个客户端可以同时连接同一个REP服务端服务端会轮流处理各个客户端的请求。但要注意REP端一次只能处理一条请求其余请求会在各自的内部队列里等着所以它天然是串行处理的如果你需要并发处理要么自己在服务端开多线程、每线程一个Socket要么用下篇要讲的ROUTER/DEALER做异步分发。2.3 超时、阻塞与优雅退出使用REQ/REP时大部分人遇到的第一道坎就是阻塞。默认情况下send和recv都是阻塞的客户端send之后会一直卡在recv等待响应服务端也会一直卡在recv等待请求。如果是正常的你来我往这没问题但一旦对端宕机或者网络异常你的线程就可能永久挂起。解决阻塞的标准做法是使用轮询器Poll给IO操作设定超时时间。pyzmq里的做法是为Socket创建一个Poll对象登记感兴趣的事件类型然后调用poll指定超时毫秒数。例如import zmq context zmq.Context() socket context.socket(zmq.REQ) socket.connect(tcp://localhost:5555) poll zmq.Poller() poll.register(socket, zmq.POLLIN) socket.send(bhello) events poll.poll(timeout3000) # 3秒超时 if events: reply socket.recv() print(f收到回复: {reply}) else: print(请求超时稍后重试) # 注意超时后当前请求已经被队列占用需要重置Socket状态 socket.setsockopt(zmq.LINGER, 0) socket.close()这里有个经验之谈如果请求超时了别直接在同一REQ Socket上再send一次因为Socket可能仍处于等待响应的中间状态直接复用会触发EFSM。稳妥的做法是把旧Socket关掉、重新创建。虽然多了一点代码量但避免了状态机错乱这种看着莫名奇妙的异常。REQ/REP还有一个工程细节值得提ZMQ对断线重连是自动处理的。当服务端进程挂了客户端发送的消息会进入Socket内部队列等服务端重新启动并bind同一个端口客户端的连接会自动恢复队列里的消息会被重新发送。这个特性是ZMQ底层自动完成的不需要你写任何重连逻辑这也是我当初从原生Socket切过来时最爽的一点。3. 发布-订阅模式PUB/SUB一对多分发与慢连接者陷阱3.1 发布订阅的语义如果说REQ/REP是一对一问答那PUB/SUB就是一对多广播。发布者只负责往管道里扔消息订阅者按自己的兴趣去接收两者完全解耦——发布者不知道有多少订阅者订阅者也不知道消息是哪个发布者来的。PUB类型的Socket只能send不能recvSUB类型的Socket只能recv不能send。SUB还有一个独特动作订阅过滤。ZMQ的过滤是前缀匹配机制你需要告诉Socket你关心哪些主题收到不匹配的消息直接丢弃。如果不做任何订阅设置SUB会收不到任何消息这一点和很多消息中间件的默认全部接收不同属于最容易踩的坑之一。下面是一个天气推送的例子发布者持续按主题发送消息import zmq import time import random context zmq.Context() socket context.socket(zmq.PUB) socket.bind(tcp://*:5556) topics [bA, bB, bC] while True: topic random.choice(topics) message f城市-{random.randint(1, 100)} 温度 {random.randint(-5, 35)} socket.send_multipart([topic, message.encode()]) time.sleep(0.5)订阅者的代码import zmq context zmq.Context() socket context.socket(zmq.SUB) socket.connect(tcp://localhost:5556) socket.setsockopt(zmq.SUBSCRIBE, bA) # 只订阅主题A while True: topic, message socket.recv_multipart() print(f收到主题[{topic.decode()}]的消息: {message.decode()})注意send_multipart和recv_multipart这对方法它发的是多段消息。在ZMQ里一条消息可以由多个消息帧组成PUB/SUB的约定是第一个帧放主题、后面的帧放业务数据。用多帧的好处是订阅过滤只需要扫描第一帧效率高语义也清晰。3.2 慢连接者问题PUB/SUB模式里最经典的坑就是慢连接者问题你启动订阅者之后立刻开始接收消息却什么都收不到。不是代码写错了而是连接建立需要时间。ZMQ的connect是异步的。SUB调用connect只是把去连接这个请求交给了后台IO线程真正完成TCP握手、通知给PUB端需要几毫秒甚至几十毫秒。在这段时间里PUB发出去的消息并没有确定的接收者按ZMQ的语义这些消息被直接丢弃了。这就是为什么很多人第一次跑PUB/SUB会发现开头几条消息总是丢。这个问题的本质是PUB/SUB本身是面向持续流的协议它不保证订阅者从连接那一刻开始就能完整接收只保证连接建立成功后的消息能按订阅规则送达。要解决慢连接者问题工程上有几种常见思路。第一种最简单粗暴在订阅程序启动后sleep一两秒再开始处理消息。开发调试够用生产环境就不太体面了因为延时不好估。第二种是同步握手。订阅者启动后先通过另一个REQ/REP通道告诉发布者我已经ready了发布者收到确认后才开始发业务数据。这个方案可靠但要额外维护一套同步通道。第三种是使用ZMQ的扩展类型XPUB/XSUB在代理层面感知订阅关系变化。这个属于进阶玩法需要配合代理结构使用我计划在系列下篇里详细讲。现阶段记住一点只要发布者是独立进程且持续在发订阅者晚到一会儿最坏的结果就是丢了开头几条消息对监控指标采集、日志流订阅这类场景通常无伤大雅但如果是想做可靠的事件分发那就需要用别的模式或框架了。3.3 实际工程中的注意事项用PUB/SUB做项目有两条心得我认为比任何API细节都重要。第一SUB的订阅过滤一定要尽早设置。在pyzmq里setsockopt(zmq.SUBSCRIBE, ...)应该在connect之后、正式开始接收之前就完成。虽然理论上在接收循环里随时可以调整订阅主题但频繁变更订阅会导致内部状态重建还可能吞掉正在路上的消息。如果确实需要动态调整建议通过订阅者自己维护一份主题清单需要变化时统一批量更新不要逐条来。第二订阅者处理能力跟不上发布速度时别指望ZMQ帮你缓冲。PUB端向订阅者推送本质上是在每个SUB的接收队列里塞消息一直到高水位标记HWM为止。一旦订阅者消费太慢队列满了ZMQ默认的策略是静默丢弃新消息。所以要么让发布者放慢节奏要么在订阅者侧做批量聚合消费要么干脆为每个订阅者单独规划容量。消息不丢这个承诺在标准PUB/SUB里是不存在的提前想清楚比上线后排查要省心得多。4. 管道模式PUSH/PULL并行任务分发的利器4.1 PUSH/PULL的负载均衡原理请求-应答是同步问答发布-订阅是单向广播那把一堆任务分给一堆工人并行处理这种需求ZMQ用什么模式答案是PUSH/PULL官方手册里它叫管道模式也叫扇入扇出模式。PUSH端把任务消息逐条发给所有已连接的PULL端分发的策略是轮询Round-Robin第一节发给第一个PULL第二个发给第二个PULL……依次循环。这种轮询是负载均衡的朴素实现它不关心PULL端当前忙不忙只管一人一条平均派发。你可以把它想象成工地上的工头派活工头手里一把任务单按顺序挨个塞给每个工人发完一圈从头再来。PUSH/PULL和PUB/SUB的本质区别在于PUB/SUB是每个订阅者都收到一份完全相同的拷贝适合广播PUSH/PULL是每个工人收到不同的任务适合分工。前者是数据复制后者是任务切分。4.2 一个经典的生产者-工人-收集器示例ZMQ官方文档里有一个非常经典的三段式例子任务发生器Ventilator、工人Worker、结果收集器Sink。任务发生器用PUSH把任务派发给多个工人工人处理完以后再用另一个PUSH把结果发给收集器。下面我给一个简化但完整的可运行版本。任务发生器import zmq context zmq.Context() ventilator context.socket(zmq.PUSH) ventilator.bind(tcp://*:5557) for task_id in range(100): ventilator.send_json({ task_id: task_id, payload: fdata-{task_id}, duration: (task_id % 10) 1 })启动两个工人import zmq import time context zmq.Context() receiver context.socket(zmq.PULL) receiver.connect(tcp://localhost:5557) sender context.socket(zmq.PUSH) sender.connect(tcp://localhost:5558) while True: task receiver.recv_json() time.sleep(task[duration] * 0.05) sender.send_json({ task_id: task[task_id], worker: worker-1, result: task[payload].upper() })结果收集器import zmq context zmq.Context() sink context.socket(zmq.PULL) sink.bind(tcp://*:5558) received 0 while received 100: result sink.recv_json() received 1 print(f已收集 {received} 条结果最近一条: {result})注意几个关键点。第一任务发生器的bind和工人的connect方向是任务往下游推而结果是反过来的工人connect到收集器的bind端口。方向不同但都是PUSH/PULL逻辑上完全成立这正好体现了ZMQ去中心化的特点——谁bind谁是服务端只是约定不代表谁更高级。第二工人的处理循环是持续运行的因为任务会源源不断进来。第三send_json/recv_json是pyzmq提供的便捷方法能把Python字典自动序列化成JSON消息开发效率很高但生产上如果对性能敏感建议换成pickle或者更紧凑的自定义编码毕竟JSON的序列化开销不小。4.3 背压、高水位标记与消息丢弃管道模式下我最想强调的概念是背压和高水位标记HWMHigh Water Mark。HWM是ZMQ给每个Socket内部队列设置的消息数上限。发送方向有SNDHWM接收方向有RCVHWM默认值通常是1000。当队列积压超过HWMZMQ的处理策略因Socket类型而异PUB端超了HWM直接丢弃新消息PUSH端超了HWMsend会阻塞直到队列有空位。这个区别非常关键——PUSH/PULL管道天然有背压机制工人处理得慢任务发生器就会停下来等着不会无限往内存里塞任务而PUB/SUB则是宁可丢也不等。理解了这个机制你就知道为什么PUSH/PULL适合做任务分发它自带限速阀门不用你额外设计流控。实际项目中如果任务产生速度远高于消费能力你会看到PUSH端的send渐渐变慢这是正常的背压传导系统在做自我保护。遇到这种情况合理的方案不是拼命提高PUSH端性能而是增加工人数量或者在不改变任务定义的前提下做批量合并。另外提示一下HWM的默认值1000对你很多场景可能不够尤其是大消息、高吞吐时。可以在Socket上显式设置socket.setsockopt(zmq.SNDHWM, 10000) socket.setsockopt(zmq.RCVHWM, 10000)数值给多大没有银弹建议根据单条消息大小和可接受的内存占用反推。举个例子如果单条消息平均1KB队列最多接受5万个那RCVHWM设50000对应的内存峰值大约是50MB。这样算出来的值才是合理的别随手填个百万级导致内存被悄悄吃满。5. 三个模式怎么选选型对比与踩坑速查5.1 模式选型对比写了这么多最后把三个模式放在一起对比一下方便你以后遇到具体问题时快速对号入座。模式方向语义典型场景主要限制REQ/REP双向一发一收同步请求-响应RPC调用、任务查询、账号鉴权严格交替无法连发多路复用需要ROUTER/DEALERPUB/SUB单向广播一对多分发行情推送、日志订阅、监控指标无消息确认慢订阅者会丢消息落后于连接建立的消息会丢PUSH/PULL单向管道任务分工与聚合并行任务分发、日志采集汇聚轮询分发不等工人状态部分靠背压保护配置不当也会堆积选型时可以按这个顺序问自己三个问题这次的通信需要不需要问一句答一句需要就用REQ/REP。需要不需要把同一条消息复制给多个接收方需要就用PUB/SUB。需要的是把不同的任务分给不同的人并行处理需要就用PUSH/PULL。很多实际系统都是多个模式混用的——比如任务调度模块里分发任务用PUSH/PULL查询执行状态用REQ/REP状态变更通知用PUB/SUB。模式之间不冲突关键是每一段通信都找到最匹配的语义。5.2 高频问题速查以下是我在实际项目中反复遇到、也经常在社区里看到的问题清单整理成速查表供收藏现象可能原因处理建议REQ/REP客户端卡在recv不返回服务端没启动服务端处理逻辑异常退出用Poller加超时检查服务端是否正常bind程序正式运行前先准备好日志REQ/REP客户端连发两条报EFSM违反了一发一收交替规则检查代码里是否嵌套了多次send确保每条send后有对应recv服务端重启后客户端消息丢了重启期间客户端Socket已排队但连接中断确认LINGER设置必要时在应用层做重试/补偿SUB端什么都收不到忘记设置SUBSCRIBE订阅主题不匹配检查订阅前缀用一个空字节set接口订阅全部主题做初步排查SUB端丢前几条消息慢连接者问题启动延迟、同步握手、或升级到XPUB做订阅感知PUSH端send越来越慢某个PULL工人消费太慢背压传导增加工人检查工人处理逻辑是否有瓶颈适当调高HWM多线程共用同一个Socket导致异常Socket非线程安全每个线程独立创建SocketContext保持全局共享最后补充一个排查技巧遇到任何消息没按预期收发的问题不要先怀疑ZMQ的可靠性先检查Socket类型和收发方向是否匹配。PUB去recv、REP去连发、PULL去send这些模式错位问题占了新手报错的八成以上。把模式语义背下来排查定位会快很多。5.3 从核心基础到进阶路线到这里ZMQ三个最核心的通信模式算讲完了。REQ/REP解决同步调用PUB/SUB解决广播分发PUSH/PULL解决并行分工这三板斧已经能支撑起不少分布式系统的消息骨架。但说实话ZMQ的价值远不止这三个模式我上手越久越觉得最能发挥它威力的其实是那些看起来更复杂、也更灵活的组合玩法——比如REQ/REP一旦遇到一个客户端需要同时向多个服务端发请求的场景标准REQ/REP就玩不转了这时候需要ROUTER/DEALER才能做异步多路复用再比如广播需要过滤转发、订阅关系需要动态感知的时候XPUB/XSUB配合代理结构才有完整的解法还有inproc进程内通信做线程间消息传递、多线程模型下的Socket生命周期管理、以及如何根据业务场景合理设置各种Socket选项做性能调优这些都属于进阶范畴。我个人的习惯是在使用ZMQ之前先想清楚这条链路的业务语义到底是什么是问答是广播还是分工想清楚了再对着模式表选Socket类型几乎不会选错。有些朋友一开始就想着用ROUTER/DEALER把所有场景都装下结果代码复杂度直线上升反而不如从基础模式组合开始来得清晰。ZMQ这套模式体系最让人舒服的地方就是概念收敛每一个模式就是一个明确的通信契约组装的复杂度都在你的掌控之中而不是藏在框架的暗坑里。下篇我会重点展开ROUTER/DEALER、代理机制、inproc线程模型和性能调优那也是ZMQ在超大规模分布式场景里真正发光发热的部分。这篇文章写到的所有示例你都应该自己动手敲一遍因为我踩过最大的坑就是看懂了但没跑过——ZMQ的模式语义看着简单实际跑起来遇到的状态机错误和丢消息问题光靠看是看不出感觉的。

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

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

免费获取报价 →
↑