从慢订阅者到发布者:LiteBus 如何用 Unix Socket 背压保护可靠 Topic
"消息不能静默丢失",听起来像是给队列足够大的内存就行。实际不是。
在发布订阅系统里,如果某个订阅者处理不过来,而发布者仍然无限快速地产生消息,任何有限队列最终都会满。此时系统必须做选择:
丢消息;
无限占内存;
让慢订阅者拖垮整个 Broker;
或把压力沿链路传回发布者。
LiteBus 阶段 4A 的 RELIABLE Topic 选择最后一种:背压。
本文解释它如何在单线程 Broker、Unix Domain Socket 和非阻塞 epoll 模型中工作,以及它保证什么、不保证什么。
问题:慢订阅者会把消息堆在哪里
假设有一条可靠 Topic:/robot/critical_event,三个角色:
Publisher:持续发布关键事件。
Broker: 收到发布消息后,路由给订阅者。
Subscriber:因业务处理慢,读取速度低于发布速度。
如果 Broker 对 Subscriber 持续 send(),但 Subscriber 不及时读取,Broker 端 Socket 最终会暂时不可写,send() 返回 EAGAIN。
这时已经不能继续把数据交给内核了。若 Broker 仍在自己的内存里不断排队,outbound_frames 最终也会达到上限。
LiteBus 的每个连接都有独立输出队列:
outbound_frames:待发送的完整 LiteBus 帧。
outbound_offset:队首帧已发送的字节数。
queued_bytes: 队列累计字节数。
当前上限为:256 帧 / 1 MiB。
这些上限不是吞吐指标,而是内存保护线。
三种 Topic 策略的分歧点
当 Subscriber 输出队列已满时,三种策略的选择不同:
| 策略 | 行为 |
|---|---|
| BEST_EFFORT | 丢弃刚到的新完整 Event |
| LATEST | 删除同 Topic、尚未开始发送的旧状态,尝试保留最新状态 |
| RELIABLE | 不丢 Event;不继续接受这个 Publisher 的更多发布数据;等 Subscriber 恢复 |
这里的 RELIABLE 不是"无限缓存",而是**"拒绝无限积压"**。
背压不是停止连接,而是暂停 EPOLLIN
LiteBus Broker 使用 epoll 监听每个 Client fd。正常情况下:
EPOLLIN: Socket 有数据可读。
EPOLLOUT: Socket 可继续发送待发帧。
EPOLLERR/HUP/RDHUP:连接错误或关闭。
当某个可靠订阅者队列满、Broker 发现不能再把当前 Event 放入该 Subscriber 队列时,会把这条消息记录为待路由发布:
deferred_publications:
key: publisher_fd
value: 按发布顺序保存的未完成路由消息
每条记录:Event + 还没有成功入队的 RELIABLE subscriber_fd 集合
随后 Broker 更新 Publisher fd 的 epoll 事件:
移除 EPOLLIN;
保留 EPOLLERR、EPOLLHUP、EPOLLRDHUP。
这不是关闭连接,也不是清空缓存。 它只是告诉内核:
这个 Publisher 后续即使有数据可读,也暂时不要因为可读事件唤醒 Broker。
因此 Broker 不会继续调用 recv(),也不会把 Publisher 的后续字节继续搬到自身用户态内存。
数据会不会丢
正常情况下不会。数据会沿着链路逐层停住:
Publisher 应用层
│ send()
▼
Publisher 内核发送缓冲区
│
▼
Broker 内核接收缓冲区
│ Broker 已暂停 EPOLLIN,不再 recv()
▼
Broker 用户态 receive_buffer_
当 Broker 内核接收缓冲区满后,Publisher 再继续发送:
- 非阻塞 Socket :
send()返回EAGAIN/EWOULDBLOCK - 阻塞 Socket :
send()阻塞,直到 Broker 恢复读取
LiteBus Node 使用非阻塞 Socket。因此 Publisher Node 的发送逻辑会等待 EPOLLOUT;若长期不可写,最终按自身发送超时返回失败。
这就是背压的含义:
慢 Subscriber
↓
Subscriber 输出队列满
↓
Broker 暂停读 Publisher
↓
Broker 接收缓冲区满
↓
Publisher 最终无法继续高速发送
压力没有消失,而是从最慢的位置向上游传播。
Broker 如何恢复
当 Subscriber 再次读取数据,Broker 收到该连接的 EPOLLOUT:
- 从
outbound_frames队首继续发送。 - 已部分发送帧从
outbound_offset继续,不会重新发送头部,也不会跳到下一帧。 - 队列腾出空间后,Broker 重试
deferred_publications。 - 一条待路由 Event 对所有 RELIABLE Subscriber 都已成功入队后,删除该待路由记录。
- Publisher 没有更多待路由 Event 后,Broker 重新为该 Publisher 添加
EPOLLIN。
一旦重新监听 EPOLLIN,若 Broker 内核接收缓冲区中已有积压数据,epoll 会很快再次报告可读,Broker 继续按原顺序读取、解析和路由。
为什么已发送一半的帧绝不能丢
LiteBus 使用 SOCK_STREAM。Socket 传输的是连续字节,不保留应用消息边界。
每个 LiteBus 帧的格式是:
[4 字节大端长度][Protobuf Envelope]
假设一帧只发送了前 300 字节,随后 send() 返回 EAGAIN。若 Broker 删除剩余部分并直接发送下一帧,接收端看到的是:
[旧帧的前 300 字节][新帧的长度字段][新帧内容...]
接收端无法再判断下一个长度字段从哪里开始,后续全部字节都可能无法解析。
因此 LiteBus 的硬规则是:
已开始发送的队首帧:继续发送到完整,或连接关闭。
只有尚未开始发送的完整帧:才可能因 Best Effort 或 Latest 策略被丢弃/替换。
这也是 RELIABLE、LATEST 和 BEST_EFFORT 共用输出队列时最重要的安全边界。
RELIABLE 保证什么
阶段 4A 的可靠 Topic 语义是:
Broker 正常运行期间:
不因 Subscriber 输出队列满而静默丢弃可靠消息。
队列压力:
通过暂停 Publisher 读取向上游传播。
帧完整性:
已开始发送的帧不会被截断或错位。
它不保证:
Broker 崩溃后恢复未投递消息;
设备断电后恢复未投递消息;
Subscriber 断开后永久保存历史;
跨重连自动重放;
业务处理成功确认。
后面这些需要磁盘持久化、确认协议、重放、幂等和去重机制,属于持久化与运行管理阶段------不应该被"可靠 Topic"这个名字掩盖。
什么时候该用 RELIABLE
适合:
关键告警;
不可静默遗漏的状态变化;
需要上游减速而不是丢失的控制事件。
不适合:
高频图像;
激光点云;
高频原始传感器数据;
只关心最新值的位置、电量等状态流。
建议的选择:
| 数据类型 | 推荐策略 |
|---|---|
| 图像、雷达 | BEST_EFFORT |
| 姿态、位置、电量 | LATEST + retain_last + transient |
| 关键事件 | RELIABLE |
写在最后
可靠不是"总是更好"。
它的代价是:一个慢 Subscriber 可能降低 Publisher 的实际发布速度。
选择 QoS 时,应先明确业务真正不能丢失的是什么------不是所有数据都值得用背压保护,也不是所有数据都能承受静默丢失。
背压的本质,是用"上游变慢"换"不丢数据 + 不 OOM"。它把选择权交回给了系统设计者:你愿意为可靠性付出多少延迟代价?
本文基于 LiteBus 阶段 4A 的 RELIABLE Topic 实现。如果你在构建类似的发布订阅系统,希望这篇能帮你理解背压的完整链路和边界。