架构设计:接入层、逻辑层、路由层与存储层

IM 服务端的架构难点不在"拆成几个服务",而在两个约束:连接是有状态的,必须知道某个用户在哪个进程上;消息要按会话有序,不能被随便的并发打散。本章给出四层架构的职责边界、依赖方向、接口定义与扩容单位,并说清一条消息在层与层之间的超时预算怎么分配。

分层原则

四条硬规则,违反任何一条都会在半年后变成事故:

  1. 依赖单向:接入层 → 逻辑层 → 路由层 / 存储层,禁止跨层反向调用,禁止接入层直接读业务库;
  2. 只有接入层有连接:逻辑层永远不持有 socket,需要下发消息时通过路由层定位接入机,再走内网 RPC;
  3. 逻辑层必须无状态:任何需要保存的东西(路由、消息、会话)都外置,这样发布只是滚动重启;
  4. 跨层通信走内网 RPC(如 gRPC):不共享数据库连接、不共享本地缓存,否则扩容时会互相拖死。

四层职责与接口

客户端 ──WSS/TCP──▶ 接入层 gateway
                     │  持有连接、心跳、编解码、限流、TLS 卸载
                     ▼ gRPC
                   逻辑层 logic(无状态)
                     │  鉴权、会话、去重、msgID、投递编排
        ┌────────────┼─────────────────┐
        ▼            ▼                 ▼
   路由层 route   消息队列 MQ       存储层 storage
   Redis 路由表   削峰/异步落库      MySQL + Redis + OSS

接入层与逻辑层的接口只有三个方法,越少越稳定:

// gateway 依赖的 logic 接口:接入层只做"搬运",不做业务判断
type LogicClient interface {
    // Auth 校验令牌与设备,返回本次连接绑定的用户与设备信息
    Auth(ctx context.Context, req *AuthReq) (*AuthResp, error)
    // OnMessage 处理一条上行消息,返回 ACK 与可选的下行指令
    OnMessage(ctx context.Context, req *UpstreamReq) (*UpstreamResp, error)
    // OnDisconnect 通知连接断开,用于清理路由与触发离线逻辑
    OnDisconnect(ctx context.Context, req *DisconnectReq) error
}

type AuthReq struct {
    Token       string // 由登录接口下发,见第 05 章
    DeviceID    string // 设备指纹,同一账号多端登录时用于区分
    DeviceType  int32  // 1=iOS 2=Android 3=Web 4=Desktop
    AppVersion  string // 用于灰度与协议兼容判断
    GatewayID   string // 由接入层填入,逻辑层据此写路由表
    ConnID      int64
}

路由层的接口同样只有两个操作,它是整个系统的"通讯录":

type RouteStore interface {
    // Bind 连接建立后写入:uid+deviceType → gatewayID+connID
    Bind(ctx context.Context, uid int64, deviceType int32, node RouteNode, ttl time.Duration) error
    // Lookup 投递前查询:拿到该用户全部在线端,返回空表示全部离线
    Lookup(ctx context.Context, uid int64) ([]RouteNode, error)
}

type RouteNode struct {
    GatewayID string `json:"gateway_id"`
    ConnID    int64  `json:"conn_id"`
    DeviceID  string `json:"device_id"`
}

路由表的具体键设计与快速查找在 06 章展开,本章只约定接口与语义:Bind 必须带 TTL(建议 90s,与心跳超时同源),Lookup 必须容忍短暂的多余节点(转发失败由接入层兜底),这样路由层挂掉时也只是"找不到人"而不是"发错人"。

扩容单位与失败影响

四层的扩容方式完全不同,选择前先看清代价:

层状态扩容动作扩不动时的表现失败影响
接入层有连接加机器,客户端按灰度重连单机连接数到顶、内存告急该机用户掉线,重连即恢复
逻辑层无状态直接加副本,改注册中心权重CPU 打满、P99 上涨请求失败可重试,用户基本无感
路由层有路由按 uid 分片加节点,需要迁移单分片 QPS 到顶新消息投不出去,重连后自愈
存储层有数据分库分表 + 迁移写放大、磁盘 IOPS 打满影响面最大,只能降级为只读或延迟发送

判断顺序建议固定为:先看接入层连接数与内存 → 再看逻辑层 CPU 与 RPC 排队 → 再看路由层分片 QPS → 最后看存储层写入延迟。这个顺序是按"修起来最便宜"排的。

超时预算

端到端 200ms 的预算必须逐层分配,否则任何一个环节的默认超时(gRPC 无超时、HTTP 默认 30s)都会把故障放大成雪崩:

客户端 → 接入层        10ms   (网络 + 解码)
接入层 → 逻辑层        15ms   (gRPC 超时 30ms,留一次重试)
逻辑层 → 路由层 Lookup 10ms   (Redis 超时 20ms,失败即降级为离线)
逻辑层 → 存储落库      60ms   (MySQL 超时 80ms,写入用批量提交)
逻辑层 → MQ 投递       10ms   (异步,超时只记日志不影响 ACK)
下行投递(异机)       30ms   (gRPC 到目标接入机,失败转离线)
                       ─────
合计                  135ms,余量 65ms 留给抖动与重试

配套三条纪律:每层超时必须显式设置且逐层递减;重试只允许在最外层做一次(内层重试会叠加放大);落库超时不得阻塞 ACK 之外的路径——ACK 前必须落库成功,这是"消息不丢"的底线。

接入层内部结构

接入层是本架构唯一有连接的层,进程内结构决定了单机能扛多少连接:

┌─ gateway 进程 ──────────────────────────────────────────┐
│  acceptor(1 个 goroutine)                              │
│    └─ 接收新连接 → 交给 worker 池做鉴权与注册             │
│  reader × N(每连接 1 个)                                │
│    └─ 读帧 → 心跳本地处理 → 业务帧投递给 logic             │
│  writer × N(每连接 1 个)                                │
│    └─ 从有界 channel 取下行帧 → 写 socket                 │
│  connManager                                            │
│    └─ map[connID]*Conn(分片锁)+ 定时器扫描超时连接       │
│  limiter                                                │
│    └─ 按 uid/设备 限速,超限直接断连而不是排队             │
└─────────────────────────────────────────────────────────┘

每连接两个 goroutine 是常见做法,代价是 8 万连接约 16 万 goroutine:Go 可以承受(初始栈 2KB),但发送队列必须有界,队列满时选择丢弃非关键帧或踢连接,绝不阻塞 reader。

工程注意点

  • 接入层不要做业务:出现过"接入层顺手拼了一下在线态"的实现,后来多端登录上线,接入层要跟着改,代价是全网重连。
  • 发布必须能优雅下线:接收集群收到下线信号后先摘除路由、向客户端发重连指令(带上备用节点),再等待存量连接 drain(建议 30s),最后才退出进程。直接 kill 会让 8 万用户同时重连。
  • 重连风暴要有退避:客户端退避区间建议 1s~60s 指数增长并加 ±20% 抖动,服务端在下线指令里带 retry_after,否则一次发布就是一次自我 DDoS。
  • 跨机房不跨层:接入层与逻辑层尽量同机房,路由表用多机房副本异步同步,宁可短暂查不到也不让每跳跨机房。

小结:IM 服务端按"有状态接入、无状态逻辑、有状态路由、可分片存储"四层划分,依赖单向且只有接入层持有连接;层间接口收敛到 Auth、OnMessage、OnDisconnect 与 Bind、Lookup 五件事,超时预算逐层递减并显式设置;扩容单位为接入层加机器、逻辑层加副本、路由层加分片、存储层做迁移,故障处置顺序按修复成本从低到高推进。

笔记加载中…