模块四:Push 流程
Push 是「在线投递」:消息先落 MongoDB,再由 turms-service 经 RPC 把通知推到目标客户端所在的 gateway 节点、再下发到 session。本模块讲清整条链路、零拷贝设计、以及离线回收。
前置:session 模型与
UserStatusService(模块一、二);Message 实体(模块三)。
1. 总览:在线投递主链路
四个关键性质:
- at-most-once / fire-and-forget:推送无 per-message ack、无重传、无离线队列。
- 异步与响应解耦 :通知在
dispatch0的 detached 路径上订阅,客户端响应不等推送完成。 - 单 buffer 零拷贝全链路 :编码一次,沿途
composite/readRetainedSlice/duplicate/retain,不复制。 - 离线回收而非排队 :每个 RPC 返回该节点的离线 recipient IDs,并集后交
afterNotify插件(APNs/FCM 钩子);turms 本身不存离线消息,靠 pull 恢复(模块六)。
2. 触发:ServiceRequestDispatcher
客户端请求经 gateway RPC 到达 ServiceRequestDispatcher.dispatch0(ServiceRequestDispatcher.java:237):
- 解码
TurmsRequest(ProtoDecoder+mergeFrom,buffer 全读不阻塞)。 - 插件
ClientRequestTransformer(顺序转换)。 - 按
KindCase查ClientRequestHandler(@ServiceRequestMapping映射,启动时反射建立),插件ClientRequestHandler先于默认 handler(first-wins)。 - handler 返回
RequestHandlerResult。
RequestHandlerResult:handler 与 dispatcher 的契约
RequestHandlerResult record(RequestHandlerResult.java:45):
| 字段 | 含义 |
|---|---|
code / reason |
响应状态码与原因(回请求方) |
response |
TurmsNotification.Data,回请求方的业务数据(如 messageId) |
notifications |
List<Notification>,要推送给相关用户的通知列表 |
Notification(内嵌 record,:329)= (forwardToRequesterOtherOnlineSessions, recipients Set<Long>, notification TurmsRequest)。一个结果可含多条 Notification(如「推给接收方」+「推给请求方其它在线设备」两条)。
异步:响应不等待推送
dispatch0 在 doOnEach(:347)里:成功(code==OK)时 turmsRequestBuffer.retain() 并订阅 notifyRelatedUsersOfAction(...) 到一条 detached 路径,完成时 release。而 ServiceResponse(:385 map)在另一条路径上独立返回。所以客户端拿到响应不等推送。
注:
turmsRequestBuffer在 handler 后仍retain,因为NioByteString字段直接引用该 buffer(性能),需保活到工作流结束(注释:308-314)。
3. 构建通知与转发
notifyRelatedUsersOfAction0(ServiceRequestDispatcher.java:447):
-
构建
TurmsNotification(通知形态,非响应形态):javaTurmsNotification notificationForRecipients = ClientMessagePool.getTurmsNotificationBuilder() .setTimestamp(System.currentTimeMillis()) .setRelayedRequest(notification) // 转发的原始 TurmsRequest .setRequesterId(requesterId) .build(); -
编码一次 为堆外
ByteBuf:ProtoEncoder.getDirectByteBuffer(notificationForRecipients)。 -
forwardToRequesterOtherOnlineSessions:若为 true,额外把通知推给请求方的其它在线设备 (用excludedDeviceType排除发起设备)。此时notificationByteBuf.retain(2),分别转发给请求方与接收方,Flux.mergeDelayError合并、doFinally释放。 -
调
outboundMessageManager.forwardNotification(...),返回离线 recipient IDs。 -
afterNotify插件 :把离线 IDs 传给RequestHandlerResultHandler.afterNotify--这是离线推送(APNs/FCM)的扩展点。
插件扩展点时序:beforeNotify -> 转发 -> afterNotify(offlineRecipientIds)。
4. 路由与扇出:OutboundMessageManager
OutboundMessageManager(@Component,OutboundMessageManager.java:67)负责「把 buffer 发给任意服务器上的任意用户」。类注释明确:所有发往外服务的操作须保证 buffer release 1 次;不支持取消(取消会内存泄漏)。
4.1 按 gateway 节点分组
forwardClientMessageByRecipientIds(:207):
- 对每个 recipient 调
userStatusService.getUserSessionsStatus(recipientId)(模块二的 Redis 状态);离线则跳过。 - 在线者取
sessionsStatus.getActiveNodeIds()(用户在线设备所在的所有 gateway 节点)。 - 把 recipients 按 nodeId 聚合成
Map<String, Set<Long>> nodeIdToUserIds。 - 短路 :若
node.getDiscoveryService().getActiveSortedGatewayMembers().size() == 0(无 gateway 节点),直接返回全部 recipients 为离线。
4.2 每节点一个 RPC(并行)
forwardClientMessageToNodes(:301):
- 1 个节点:单 RPC。
- N 个节点:
messageData.retain(nodeCount),每节点一个SendNotificationRequestRPC 并行发出 ,collectOfflineRecipientIds(Flux.merge+ 并集)汇总离线 IDs,doFinally(messageData.release())。
关键:按节点而非按 recipient 发 RPC。一个 gateway 节点上可能有多个 recipient,一次 RPC 携带全部 recipient IDs,摊薄 RPC 开销。
forwardClientMessageToNode(:370):把 messageData + recipients + excludedUserSessionIds + excludedDeviceType 包成 SendNotificationRequest,调 node.getRpcService().requestResponse(nodeId, request),返回 Mono<Set<Long>>(该节点的离线 IDs)。
4.3 buffer 引用计数(易错点)
整条链路的 ByteBuf 引用计数显式管理:
OutboundMessageManager入口retain(nodeCount)(多节点)或单次传递。- 每节点 RPC 内部再按需
retain(如forwardNotification(notification, buffer, requesterId, excludedDeviceType)路径:357)。 doFinally/ 出错路径release。- 类注释警告:不支持取消 ,取消会泄漏(
:57-62)。
单 recipient 与多 recipient 走不同方法(forwardClientMessageByRecipientId vs ...ByRecipientIds),1 个 recipient 时直接取 getActiveNodeIds(),避免分组开销。
5. RPC 载荷:SendNotificationRequest + 零拷贝编解码
SendNotificationRequest extends RpcRequest<Set<Long>>(SendNotificationRequest.java:40):
| 字段 | 含义 |
|---|---|
notificationBuffer |
ByteBuf(TurmsNotification 的堆外 buffer) |
recipientIds |
该节点上的接收者 |
excludedUserSessionIds |
排除的 session(如请求方发起设备) |
excludedDeviceType |
排除的设备类型 |
nodeTypeToRequest() = SERVICE,nodeTypeToRespond() = GATEWAY,isAsync() = true。- 构造时
setBoundBuffer(notificationBuffer)把 buffer 绑定到请求生命周期。 - 在 gateway 侧
callAsync()委托notificationService.sendNotificationToLocalClients(...)。
自定义紧凑编解码(非 protobuf)
SendNotificationRequestCodec(SendNotificationRequestCodec.java:38)是 turms 节点间 RPC 的自定义 codec:
writeRequestData:只写元数据(recipientIds 写为 longs、excludedUserSessionIds 写为 userId+deviceType 字节对、excludedDeviceType 一字节)。byteBufToComposite(:114):直接返回原始notificationBuffer--RPC 编解码器把它组合(composite)进 RPC 帧,零拷贝。readRequestData(:70):in.readRetainedSlice(in.readableBytes())把剩余字节作为 retained slice 读出,零拷贝还原 notificationBuffer。initialCapacityForRequest:精确预分配元数据容量,避免扩容。
节点间 RPC 用自定义紧凑编码而非 protobuf(模块一
CodecService)。通知 buffer 作为「裸字节」组合进帧,全程不复制。
6. 网关投递:NotificationService
NotificationService implements RpcNotificationService(NotificationService.java:59)。sendNotificationToLocalClients(:106):
- 对每个 recipientId:
sessionService.getUserSessionsManager(recipientId)--本地内存查 (模块二的userIdToSessionsManager)。null -> 离线。 - 否则遍历
getDeviceTypeToSession().values():- 跳过
excludedDeviceType与excludedUserSessionIds中的 session。 notificationData.retain()(下游 send 完成或失败时释放)。userSession.sendNotification(notificationData, tracingContext);onErrorResume时把 recipient 加入offlineRecipientIds。userSession.getConnection().tryNotifyClientToRecover():若该 session 处于 UDP 休眠态(连接断但有 UDP 地址),发OPEN_CONNECTION信号唤醒客户端重建 TCP/WS(模块九)。
- 跳过
Mono.whenDelayError(monos)等全部发送完成,返回offlineRecipientIds;doFinally(notificationData.release())。- 插件
NotificationHandler.handle(notification, recipientIds, offlineRecipientIds);通知日志(按relayedRequestType过滤)。
网关侧只查本地内存 session,不查 Redis--RPC 路由已经把 recipients 分发到「拥有其 session 的节点」。所以
getUserSessionsManager返回 null 即该用户在本节点无在线 session = 离线。
7. session 与 Netty 写出
-
UserSession.sendNotification(ByteBuf, TracingContext)(模块一UserSession.java:192)->notificationConsumer.apply(buf, ctx)。 -
notificationConsumer在UserSessionAssembler.bindConnectionWithSessionWrapper中设置(模块一:80-98):javaturmsNotificationBuffer = turmsNotificationBuffer.duplicate(); // 独立 readerIndex,零拷贝 return netConnection.send(turmsNotificationBuffer) .doOnError(t -> handleConnectionError(...));duplicate()共享内容但独立 reader index--同一 buffer 可发给多个客户端而不互相干扰,且不复制内容。 -
NetConnection.send:TcpConnection.send(TcpConnection.java:61):connection.sendObject(buffer).then()。WebSocketConnection.send(WebSocketConnection.java:67):out.sendObject(new BinaryWebSocketFrame(buffer)).then()。- reactor-netty 的
sendObject无论成功失败都会释放 buffer。
8. 零拷贝全链路(重点)
一条通知 buffer 从编码到客户端,全程零拷贝(SSL 关闭时直达 socket):
要点:
- 编码一次 :
ProtoEncoder.getDirectByteBuffer只调一次,后续全程是同一个堆外 buffer。 - 跨节点 :
retain(nodeCount),同一 buffer 发往 N 个节点不复制。 - 跨客户端 :
duplicate(),同一 buffer 发给一个节点上 N 个客户端不复制(仅独立 reader index)。 - 跨 RPC :
composite+readRetainedSlice,节点间传输不复制。 - 代价:引用计数必须精确 ,任何路径漏
release即泄漏;故类注释强调不支持取消。
9. 响应 vs 通知:TurmsNotification 的双重角色
TurmsNotification 同一个 proto 承担两种用途:
| 用途 | 用的字段 | 路径 |
|---|---|---|
| 响应(给请求方) | request_id / code / reason / data |
handler 结果 -> ServiceResponse -> 经请求 RPC 回 gateway -> 客户端 |
| 通知(给相关用户) | requester_id / relayed_request / timestamp |
notifyRelatedUsersOfAction0 构建 -> OutboundMessageManager -> 目标客户端 |
例:发消息时,请求方收到响应 (含 messageId),接收方收到通知 (含转发来的 CreateMessageRequest)。两者是同一 proto 的不同字段组合。
10. 离线回收
turms 不存离线消息、不存储转发:
- 每个
SendNotificationRequest返回该 gateway 节点的离线 recipient IDs。 OutboundMessageManager.collectOfflineRecipientIds取并集。ServiceRequestDispatcher.notifyRelatedUsersOfAction0把离线 IDs 传给RequestHandlerResultHandler.afterNotify插件--APNs/FCM 等离线推送在此接入。- 离线用户重新上线后,通过
QUERY_MESSAGES_REQUEST主动拉取漏掉的消息(模块六)--这是可靠性主手段,不是重传。
因此 push 是 at-most-once:在线尽力推,离线不排队,靠 pull 兜底。可靠性模型在模块七总结。
11. 关键设计要点(Takeaways)
- 异步解耦:通知在 detached 路径订阅,客户端响应不等推送。
- 按节点扇出:recipients 按目标 gateway 节点分组,每节点一个 RPC(非每 recipient),摊薄 RPC 开销。
- 单 buffer 零拷贝全链路 :编码一次,
composite/readRetainedSlice/duplicate/retain,SSL 关时直达 socket。 - 显式引用计数 :
retain(nodeCount)、doFinally release,不支持取消(否则泄漏)。 - TurmsNotification 双重角色 :响应用
requestId/code/data,通知用requesterId/relayedRequest/timestamp。 - at-most-once + 离线回收:无 ack/重传/离线队列;离线 IDs 交插件(APNs/FCM),pull 兜底(模块六/七)。
- 网关只查本地内存 :RPC 已路由到归属节点,
getUserSessionsManager返回 null 即离线。
12. 关键文件索引
| 关注点 | 文件 |
|---|---|
| service 端分发/触发 | turms-service/src/main/java/im/turms/service/access/servicerequest/dispatcher/ServiceRequestDispatcher.java |
| handler 结果契约 | turms-service/src/main/java/im/turms/service/access/servicerequest/dto/RequestHandlerResult.java |
| 扇出/路由 | turms-server-common/src/main/java/im/turms/server/common/infra/message/OutboundMessageManager.java |
| RPC 载荷 | turms-server-common/src/main/java/im/turms/server/common/domain/notification/rpc/dto/SendNotificationRequest.java |
| RPC 零拷贝编解码 | turms-server-common/src/main/java/im/turms/server/common/domain/notification/rpc/dto/SendNotificationRequestCodec.java |
| 网关投递 | turms-gateway/src/main/java/im/turms/gateway/domain/notification/service/NotificationService.java |
| session 推送 | turms-gateway/src/main/java/im/turms/gateway/access/client/common/UserSession.java、UserSessionAssembler.java |
| 连接写出 | turms-gateway/src/main/java/im/turms/gateway/access/client/tcp/TcpConnection.java、.../websocket/WebSocketConnection.java |
| 在线状态查询 | turms-server-common/src/main/java/im/turms/server/common/domain/session/service/UserStatusService.java(模块二) |
下一模块:05-group-message-push.md 群消息推送(成员查询 + 扇出到多接收方 + 群通知)。