架构设计:接入层、逻辑层、路由层与存储层
IM 服务端的架构难点不在"拆成几个服务",而在两个约束:连接是有状态的,必须知道某个用户在哪个进程上;消息要按会话有序,不能被随便的并发打散。本章给出四层架构的职责边界、依赖方向、接口定义与扩容单位,并说清一条消息在层与层之间的超时预算怎么分配。
分层原则
四条硬规则,违反任何一条都会在半年后变成事故:
- 依赖单向:接入层 → 逻辑层 → 路由层 / 存储层,禁止跨层反向调用,禁止接入层直接读业务库;
- 只有接入层有连接:逻辑层永远不持有 socket,需要下发消息时通过路由层定位接入机,再走内网 RPC;
- 逻辑层必须无状态:任何需要保存的东西(路由、消息、会话)都外置,这样发布只是滚动重启;
- 跨层通信走内网 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 五件事,超时预算逐层递减并显式设置;扩容单位为接入层加机器、逻辑层加副本、路由层加分片、存储层做迁移,故障处置顺序按修复成本从低到高推进。