为什么我最终放弃了分布式 Pub/Sub,选择了房间粘滞(Affinity)
背景补充:在之前的 架构大修记 和 全栈 Monorepo 实践 中,我曾提到过为了可扩展性而追求“全量 Redis 化”和“无状态后端”。但随着重构的深入,我开始重新审视这一选型。
1. 序言
在构建一个支持多人实时协同的应用平台时,开发者通常会面临一个硬核的技术挑战:如何处理成千上万个长连接(WebSocket)在多台服务器间的同步?
在重构项目后端时,我陷入了两条路径的深度纠结:
- 方案 A:分布式长连接(Stateless Servers + Pub/Sub) —— 理论上的“终极可扩展架构”。
- 方案 B:房间亲和性(Room Affinity / Sticky Sessions) —— 务实派的“单机逻辑思维”。
经过对开发成本、运行效率以及心智负担的全面复盘,我最终选择了 方案 B。
2. 两个流派的深度对垒
流派一:分布式转发(Stateless + Pub/Sub)
核心逻辑:用户可以连接到集群中的任何一台机器。如果同一个协作空间内的用户分散在不同机器,通过 Redis 的发布/订阅(Pub/Sub)机制来跨机交换消息。
- 优势:极高的高可用性。任意机器宕机,用户重连到另一台机器,逻辑可以无缝延续。
- 代价:
- 性能损耗:每一次操作都要经历“序列化 -> 传输 -> 反序列化”的循环。
- 逻辑复杂化:为了防止多个进程同时修改同一个业务状态,必须引入分布式锁,开发量倍增。
流派二:房间亲和性(Room Affinity)
核心逻辑:通过网关调度,确保同一个协作空间内的所有用户都连接到同一台物理服务器上。
- 优势:业务逻辑处理极度简单,数据在内存中原地完成闭环。
- 代价:若单台服务器意外重启,该机器上正在进行的实时任务进度会丢失(除非引入额外的持久化快照)。
3. 维度对比:为什么“简单”才是最高级的生产力?
在选型过程中,我从以下三个核心维度进行了思考:
3.1 开发者的小组心智负担 (Cognitive Load)
| 维度 | 房间亲和性 (Affinity) | 分布式转发 (Pub/Sub) |
|---|---|---|
| 编程模型 | 像写单机程序。可以放心地使用局部变量、指针、Channel。 | 像写分布式数据库。每一步都要考虑数据在不在本地,是否需要远程同步。 |
| 状态一致性 | 天生一致。通过简单的局部互斥锁(Mutex)即可解决竞争,逻辑清晰。 | 极其沉重。在高频交互下,处理分布式锁冲突会消耗大量精力。 |
| 调试难度 | 低。看一台机器的本地日志就能还原完整的业务流。 | 高。必须汇总全集群日志才能拼凑出一个完整的操作现场。 |
3.2 运行效率与成本
- Affinity(亲和性):数据在内存中直接下发给同一个进程内的 Socket,延迟是微秒级的。对于高频互动的操作(如实时绘图、协同白板),这种低延迟是决定性的。
- Stateless(无状态):每一条消息都要走一次 Redis 转发。在高并发场景下,序列化开销和 Redis 的网络 I/O 往往先于业务逻辑达到性能瓶颈。
4. 最终选择:回归工程常识
我最终选择了 方案 B(房间亲和性),原因在于它完美平衡了“开发效率”与“系统鲁棒性”:
- 利用 Go 的并发威力:Go 语言的
Goroutine处理成千上万个局部连接非常轻松。在达到单机瓶颈之前,过度拆分架构只会大幅降低上线速度。 - “伪分布式”的容灾退路:虽然选择了亲和性,但我并没有完全抛弃 Redis。每当业务状态发生关键变更,系统会异步发送快照至 Redis。若某台服务器崩溃,用户重连至新节点后,新节点从 Redis 读回快照恢复现场。这既保留了内存操作的高效,又获得了分布式的数据安全感。
5. 结语:架构是为了解决问题,而非创造问题
在重构过程中,我意识到开发者很容易陷入“为了架构而架构”的陷阱。总觉得不写一段分布式逻辑就显得技术含量不足,但现实是:
- 开发效率(Time to Market)往往比架构完美度更重要。
- 低心智负担的系统往往更不容易出 Bug。
通过 “统一入口调度 + 内存状态机”,我只用了原计划 1/3 的开发时间就实现了稳定的实时同步。这种简洁带来的掌控感,是任何复杂的分布式中间件都无法比拟的。
最好的架构,是能让你在项目快速迭代的同时,依然能睡个好觉。
为什么我最终放弃了分布式 Pub/Sub,选择了房间粘滞(Affinity)
https://erdianzhang.cn/2026/03/06/affinity-over-pubsub/