Elea Notes.

词条 · 系统与工程 · 入门

发布订阅

按名字收发,而不是按地址。收发双方因此不必知道彼此存在。

也称:发布订阅、发布/订阅、pub/sub、publish-subscribe、订阅

下面从”两个程序要交换消息”这个最简单的需求出发,看它为什么会从”互相打电话”演变成”按名字广播”,以及这个转变到底买到了什么。

先看一个麻烦:加第三个程序的时候,你要改前两个

你有两个程序。A 负责接收订单,B 负责发货。A 处理完一笔订单要告诉 B。

最直接的写法是 A 直接调 B:A 里存着 B 的地址,处理完就往那儿发一条消息。两个程序,一条连线,很清楚。

现在产品说:下单之后还要发一封确认邮件。于是你写了程序 C。

问题来了——你得改 A。A 原来只知道 B,现在要同时知道 B 和 C,处理完订单要发两次。

接着又要加:库存要扣减(程序 D)、风控要留痕(程序 E)、数据看板要计数(程序 F)。每加一个,A 都要改一次。A 现在存着五个地址,任何一个的地址变了 A 要跟着改,任何一个挂了 A 的发送代码要处理它的超时。

停下来看看卡在哪。A 是订单系统,却在承担「谁需要知道订单」这份知识。而这份知识和订单业务毫无关系,它属于系统的组织结构,会随组织的需求不断变化。变化频繁的东西被塞进了不该管它的地方。

朴素尝试为什么都不够

招数一:把地址列表挪到配置文件里。 A 的代码不用改了,改配置就行。缓解了一点,但 A 仍然要在运行时逐个发送、逐个处理失败、逐个等超时。如果 C 的邮件服务卡住三十秒,A 的订单处理就跟着卡三十秒。业务逻辑和投递逻辑还是缠在一起。

招数二:让 A 发完就不管,异步发送。 卡顿解决了。但可靠性变成了猜测:A 不知道 C 到底收到没有。而且 A 的代码里依然写着「有 C 这个东西」。

招数三:让 B、C、D 自己定期来问 A「有新订单吗」。 方向对了——A 不再需要知道谁在听。代价是延迟和浪费:轮询间隔设一秒,就有最多一秒的延迟,而且绝大多数次询问的答案是「没有」。要低延迟就得高频轮询,高频轮询的绝大部分是空转。

招数四:在中间放一个东西,A 把消息交给它,其余程序向它登记自己关心什么。 这一招成了。

四次失败给出的要求很清楚。要让「加一个接收方」不触碰发送方,中间那层必须:接收方按名字登记兴趣(而不是发送方记地址)、投递由中间层负责(发送方交出去就完事)、新接收方自行登记(不需要任何人配合)。

机制:名字取代地址

发布订阅把一次通信拆成三个角色,中间加了一层间接:

              ┌─────────────────────────────────────┐
              │            中间层                    │
   发布者      │   topic: "order.created"            │      订阅者
     A ───────>│      ├── 登记者:B                  ├────> B 发货
   发布到名字  │      ├── 登记者:C                  ├────> C 发邮件
   不知道有谁  │      └── 登记者:D                  ├────> D 扣库存
              │                                     │
              │   topic: "payment.failed"           │
              │      └── 登记者:E                  ├────> E 风控
              └─────────────────────────────────────┘

   A 只说:"这是一条 order.created"
   B/C/D 只说:"我要 order.created"
   双方都不知道对方存在

核心是那个名字(常叫 topic、channel 或 namespace)。发布者往名字里发,订阅者向名字登记。双方都只和名字打交道,不和彼此打交道。

这层间接带来三个直接后果:

接收方数量对发布方透明。 加第七个订阅者,A 的代码和配置都不动,因为 A 从来不知道有几个。这正是最初那个麻烦的解。

双方不必同时在线。 A 发布的时候 C 可以是关着的。至于 C 重启后能不能拿到期间的消息,取决于中间层是否保存——这是一个明确的设计选项,不同系统给的答案不同(丢掉 / 保留一段时间 / 永久保留)。

新增消费者是纯加法。 想加一个数据看板,写个程序登记同一个名字就行,现有系统完全不知道它出现了。

代价必须一起记住:

没人知道全貌。 A 不知道谁在听,B 不知道谁在发。调试一条消息为什么没到,你得同时查发布方、中间层、订阅方三处,而没有任何一处存着完整的连接图。这是发布订阅最真实的运维成本,直接调用则完全没有这个问题。

中间层成了单点。 它挂了所有通信都停。所以生产环境里它必然要做冗余,而这份复杂度是你为解耦付的价钱。

投递语义变得需要显式声明。 直接调用时”收到了吗”由返回值回答。经过中间层之后,恰好一次、至少一次、至多一次成了三种不同的保证,各有各的成本,而且你必须选一个。

为什么值得知道:它是「解耦」这个词的可操作版本

解耦常被说成一种美德,含义含糊。发布订阅把它变成了一个可检验的性质:加一个接收方,需不需要改发送方? 不需要,就是解耦的;需要,就不是。

几个能直接用的判断:

看到「发送方持有接收方列表」,就知道扩展成本在哪。 这个列表会随需求增长,每次增长都要改一处和业务无关的代码。这不是写得差,是拓扑选错了。

低延迟需求排除轮询。 轮询的延迟下限等于轮询间隔。要毫秒级就必须是推送,也就必须有一层能主动投递的东西。

解耦的账单是可观测性。 换来了自由增删,付出的是「没有一处能看到全貌」。所以采用发布订阅的系统,必须在监控上额外投入——每条消息带上可追踪的标识,否则排查故障时会无处下手。

收束

回到第二节那三条要求:接收方按名字登记、投递交给中间层、新增无需他人配合。把地址换成名字,三条同时满足。

订单系统重新只管订单。谁需要知道订单,这份知识搬到了它该在的地方——需要知道的人自己那儿。