后续演进:从单机到集群
本章目标:前面 15 章交付的是一个能跑通的单机版 im-go。这一章回答「接下来往哪走」——给出三阶段演进路线(多网关 + Redis 路由 → 有状态网关按 uid 分片 → 多机房就近接入),逐阶段说清要改什么、付出什么代价、什么信号出现时才值得做。
1. 先认清单机的边界
| 维度 | 单机的上限表现 | 首个瓶颈信号 |
|---|---|---|
| 连接数 | fd、每连接数十 KB 的读写缓冲内存、goroutine 数 | ss -s 与在线数上不去,建连成功率跌破 99%(第 13 章) |
| 消息吞吐 | 单进程 CPU 与 GC 开销 | 投递 P99 抬升,GC CPU 占比上涨(第 12 章) |
| 可用性 | 进程崩溃或机器故障 = 全部在线用户掉线 | 一次发布要「凌晨操作 + 提前公告」 |
| 数据层 | Redis、MySQL 都是单点 | 主从切换时路由短暂不可用 |
| 地域 | 单机房,异地用户 RTT 高 | 跨地区用户抱怨「消息发出去半天没反应」 |
判断标准只有一条:当「一次重启掉线几十万用户」变成业务不可接受的风险,或单机 CPU/带宽已经是天花板时,才进入阶段一。在线数只有几千的单机版本完全可以跑很久,提前集群化会把调试成本、部署成本、故障类型同时放大好几倍。
2. 阶段一:多网关 + Redis 路由(网关无状态)
┌── 网关 A ──┐
客户端 ─ L4 负载均衡 ─┼── 网关 B ──┼─ gRPC ─→ 逻辑层 × M ─→ Redis(路由表)/ MySQL
└── 网关 C ──┘ ↑
投递时逻辑层按路由表 gRPC 调回目标网关
| 要改的地方 | 具体动作 |
|---|---|
| 连接表定位 | 网关本地 map[connID]*Conn 只代表「本机连接」,权威映射写入 Redis route:uid:{uid}(第 06 章的键设计可直接用) |
| 投递路径 | 第 07 章的 d.reg.Push 从「只查本机连接表」扩成「本机命中直投、目标在别的网关就 gRPC 调用它的 Push 接口」,需要新增网关间推送接口、超时与熔断 |
| 实例注册 | 引入 Consul/Nacos 或直接用 K8s Service,网关启动注册、退出注销;拉起顺序与健康检查必须就绪 |
| 幽灵路由 | 网关启动时按 route:gw:{gwID} 批量清理;投递失败立即删字段并转离线兜底 |
| 负载均衡 | 网关无状态,任何一台都能接任何用户,所以四层轮询即可,不需要会话保持(session affinity) |
| 路由读优化 | 逻辑层加 1~3 秒的进程内缓存,把不一致窗口压到「一次失败投递」 |
| 存储读写分离 | 消息写入与历史查询拆开,MySQL 走主从读写分离,避免拉历史把写入拖慢(第 11 章) |
代价与收益很直白:收益是单台网关故障的掉线面从 100% 降到 1/N,且网关可以水平扩容;代价是投递多了一跳网关间 RPC,Redis 路由查询进入关键路径(建议哨兵或集群 + 本地缓存 + 查询超时熔断,超时就判离线而不是拖垮链路)。
3. 阶段二:有状态网关按 uid 分片
阶段一每投递一次就跨机 RPC 一次,群聊扩散时更明显——一个千人群的消息要在多台网关之间来回跳。阶段二让同一用户的连接固定落在某个分片,把「本分片内互发」变成纯内存操作,跨分片才走 RPC。
| 要改的地方 | 具体动作 |
|---|---|
| 分片算法 | 一致性哈希(consistent hashing)加虚拟节点(virtual node)打散热点;网关数变化时尽量只迁移 1/N 的会话 |
| 路由形态 | 从「查 Redis 得到网关 ID」变为「按 uid 直接算出分片号」,Redis 退化为归属表与兜底数据 |
| 连接迁移 | 扩缩容走「双写路由 → 按 uid 段渐进迁移 → 老连接自然更替或低峰期下发重连指令」,绝不在高峰期批量踢连接 |
| 故障预案 | 一台网关故障 = 一个分片的用户集体重连,必须准备客户端退避重连(backoff reconnect)+ 接入层限速,否则重连风暴会把鉴权与 Redis 一起打垮 |
| 本地队列 | 分片内投递可以直接写目标连接的发送队列,但仍要有界队列与慢客户端踢除(第 02 章) |
| 存储分片 | msg_00msg_15 按 conv_id % 16、offline_msg_0015 按 uid % 16 分表(第 09、11 章),网关分片尽量与用户分片对齐,避免一次投递跨多个库 |
不要过早选择有状态:会话(session)是很贵的资源——TLS、鉴权、未完成的 ACK 重发都挂在连接上,一次分片故障的恢复成本远高于无状态网关的「随便换一台」。只有当群扩散量与 Redis 路由查询确实成为热点时,这个阶段才划算。判据是:跨网关 RPC 的 QPS 已接近逻辑层总 QPS 的一半,或群扩散 P99 长时间高于单聊 P99。
4. 阶段三:多机房就近接入
用户 → GeoDNS / 就近负载均衡 → 就近机房接入 → 机房内闭环投递
↘ 跨机房消息总线(Kafka)↗ 其他机房
| 要改的地方 | 具体动作 |
|---|---|
| 接入路由 | GeoDNS 或就近负载均衡把用户解析到最近机房,客户端缓存解析结果并在失败时切换 |
| 用户归属 | 维护 uid → home region 归属表(MySQL + 本地缓存),让同一用户的多端尽量落同一机房,避免多端跨机房 |
| 消息同步 | 机房内闭环优先投递,跨机房走消息总线异步复制,接受最终一致(eventual consistency)、放弃强一致 |
| 数据层 | Redis 按机房部署(只存本机房在线连接),MySQL 主从跨机房(写主读从),路由表按 region 重建 |
| 故障切换 | 整机房故障时客户端重新解析并连接备用机房,切换过程要有退避与限速,且提前演练;数据合规要求高的场景还要考虑数据落地位置 |
这一阶段的代价是「一致性从强一致退化为最终一致」加跨机房带宽成本。只有当异地用户的 RTT 已经明显影响体感(实时消息对超过 100ms 的往返延迟很敏感)时,这个阶段才值得投入。
5. 每阶段的验收指标
| 阶段 | 关键指标 | 期望方向 |
|---|---|---|
| 单机 | 在线连接数、建连成功率、投递 P99、GC CPU 占比 | 得到一个明确的天花板数字,写进容量文档 |
| 一:多网关 | 单网关故障影响面、跨网关投递 P99、路由命中率 | 影响面 ≤ 1/N;路由命中率 > 99% |
| 二:分片 | 迁移时长、故障分片重连耗时、群扩散 P99 | 迁移可在低峰期完成,重连有退避与限速 |
| 三:多机房 | 就近接入 RTT、机房故障切换时长、跨机房同步滞后 | 同城/就近 RTT 明显下降,切换做到分钟级 |
常见坑
| 坑 | 现象 | 做法 |
|---|---|---|
| 只有几千在线就上集群 | 复杂度暴涨,故障类型变多,收益几乎为零 | 先按单机容量跑满,用指标驱动演进 |
| 网关做无缝有状态又不做迁移 | 一次扩容引发大面积掉线 | 迁移按 uid 段渐进,先双写后切换 |
| 把一致性哈希当银弹 | 会话全丢,用户体感是「消息全乱」 | 分片只做优化,权威路由仍留 Redis 兜底 |
| 跨机房追求强一致 | 写延迟被跨机房 RTT 拖住 | 接受最终一致,机房内闭环、跨机房异步 |
| 忽略重连风暴 | 故障恢复瞬间把鉴权、Redis 打挂 | 客户端指数退避 + 接入层限速 + 分批放行 |
| 路由表当持久存储 | Redis 切换丢数据后大量用户「查不到」 | 归属表落 MySQL,Redis 只作缓存与加速 |
| 监控只盯单机 | 集群化后看不出是哪个分片/机房异常 | 指标全部按 gateway_id、region 打标签 |
小结:演进顺序是「先无状态多网关,再有状态分片,最后多机房」——阶段一用 Redis 路由表换来故障影响面降到 1/N,阶段二用一致性哈希把群扩散的跨机开销压回机房内但必须配好迁移与重连退避预案,阶段三用就近接入换低延迟并接受最终一致;每一阶段都由明确的指标信号触发,而不是由技术新鲜感触发。