《Netty 高性能网络编程》10.3 会话表、心跳与广播

连接多了以后,回显要变成广播。本节用 ChannelGroup 管理会话,用 AttributeKey 绑定 userId,用 IdleStateHandler 加 ping/pong 清理死连接,给出 /ws/broadcast 的完整 WebSocketHub,并说明慢客户端过滤、广播风暴与按房间分组的扩展边界。

本节目标:把每条连接的回显升级成“谁在线、给谁发、谁已经死了”三件事,并让广播在慢客户端面前不拖垮整个进程。
适用版本:JDK 25 + Netty 4.2.19.Final(netty-transport、netty-handler、netty-codec-http 模块)。

承接的问题

10.2 的 /ws/echo 是“来一条回一条”,连接之间互不知道对方存在。/ws/broadcast 需要的是另一套结构:

  • 新连接完成握手后要登记,断开后要自动消失,否则广播会一直往已关闭的 Channel 写,日志里全是 ClosedChannelException。
  • 一条消息要发给所有在线连接,但某个客户端网络很差、出站缓冲已经堆满时,不能让它把广播线程卡住。
  • 客户端拔网线、NAT 表项过期时,TCP 可能几分钟都不通知服务端;连接对象和堆外缓冲一直占着,必须有应用层心跳。
  • 广播需要知道“是谁发的”“发给哪个房间”,至少要把 userId 和 Channel 绑定起来。

这一节用 ChannelGroup 做会话表,用 AttributeKey 存用户标识,用 IdleStateHandler 加 ping/pong 做存活检测,最后给出 /ws/broadcast 的完整 WebSocketHub 和广播边界。

核心机制一:ChannelGroup 就是会话表

io.netty.channel.group.ChannelGroup 是一个 Set<Channel>,额外提供 write、writeAndFlush、close 这类面向所有成员的批量操作。最常用的实现是:

private final ChannelGroup group =
        new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);

DefaultChannelGroup 的关键行为是自动移除:每个 Channel 加入时,它会在这个 channel 的 close future 上挂一个监听器,连接关闭后把 channel 从集合里删掉。业务代码不需要在 channelInactive 里手动 remove,也不会因为忘记删除而让集合无限增长。

GlobalEventExecutor 是 io.netty.util.concurrent 里的全局单线程执行器,专门用来跑这类轻量的通知任务。它不属于任何 I/O EventLoop,所以从任意业务线程调用 group.add、group.writeAndFlush 都是线程安全的;监听器也不会阻塞某个 worker 线程。

登记点仍然只能是 HANDSHAKE_COMPLETE,不能是 channelActive:

if (evt == WebSocketServerProtocolHandler.ServerHandshakeStateEvent.HANDSHAKE_COMPLETE) {
    group.add(ctx.channel());
}

用户标识放在 AttributeKey 上,而不是另开一个 Map<ChannelId, String>:

public static final AttributeKey<String> USER_ID = AttributeKey.valueOf("linkd.userId");

AttributeKey 挂在 Channel 上,连接关闭时随 channel 一起回收,不存在“会话表删了、用户表忘了删”的第二份状态。valueOf 同一个名字返回同一个 key 实例,跨 handler 读取时不需要传对象引用。

userId 在握手前从查询串取出来。10.1 讲过默认路径匹配会把查询串算进去,所以 /ws/broadcast 的协议 handler 要开 checkStartsWith;参数解析放在 WebSocketServerProtocolHandler 之前:

package com.plume.linkd.v2.server;

import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
import io.netty.handler.codec.http.FullHttpRequest;
import io.netty.handler.codec.http.QueryStringDecoder;
import io.netty.util.AttributeKey;

import java.util.List;

public final class UserIdExtractor extends ChannelInboundHandlerAdapter {
    public static final AttributeKey<String> USER_ID = AttributeKey.valueOf("linkd.userId");

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        if (msg instanceof FullHttpRequest request) {
            List<String> ids = new QueryStringDecoder(request.uri()).parameters().get("userId");
            if (ids != null && !ids.isEmpty()) {
                ctx.channel().attr(USER_ID).set(ids.get(0));
            }
        }
        ctx.fireChannelRead(msg);
    }
}

这个 handler 只读 uri(),不消费消息,也不改变引用计数:ChannelInboundHandlerAdapter 不自动释放入站对象,ctx.fireChannelRead(msg) 把所有权原样交给下一个 handler。放在 HttpObjectAggregator 之后,才能拿到 FullHttpRequest;放在 WebSocketServerProtocolHandler 之前,才能在 HTTP 请求被升级消费前读到查询串。

如果业务需要按 userId 反查 channel,再维护一个 ConcurrentHashMap<String, Channel>,但删除时要用 remove(key, value),避免误删同名的新连接。ChannelGroup 负责“所有连接”,map 负责“按用户找连接”,两者职责分开;不要指望一个 map 同时承担广播和自动清理。

核心机制二:广播与慢客户端

最简单的广播只有一行:

group.writeAndFlush(new TextWebSocketFrame(text));

ChannelGroup 会遍历所有成员,为每个 channel 准备一份 retained duplicate 再写出。DefaultChannelGroup.write 内部对 ByteBuf 或 ByteBufHolder 走 retainedDuplicate(),最后释放调用方传入的原始消息;所以把帧交给 writeAndFlush 之后,不要再 release 它,也不要拿同一个对象去做别的事。

这一行没有 isWritable 过滤。某个客户端出站缓冲超过高水位线时,writeAndFlush 仍然会把消息排进它的队列,队列继续涨,最后要么触发 OOM,要么在写失败时关闭连接。/ws/broadcast 用的是带过滤的版本:

public void broadcast(Channel sender, String text) {
    int slow = 0;
    for (Channel channel : group) {
        if (!channel.isActive()) {
            continue;
        }
        if (!channel.isWritable()) {
            slow++;
            continue;
        }
        channel.writeAndFlush(new TextWebSocketFrame(text));
    }
    if (slow > 0) {
        LOG.log(System.Logger.Level.WARNING,
                "broadcast skipped slow clients=" + slow + " sender=" + sender.remoteAddress());
    }
}

这里的 isWritable() 是 9.1 讲的水位线判断:出站缓冲低于低水位时为 true,高于高水位时为 false。广播跳过 false 的连接,语义是“这条消息你不收了”,而不是“服务端替你排队”。如果某个连接持续不可写,可以选择用 1013 关闭它,或者让它自己重连;继续为它累积消息只会把内存压力从一条连接扩散到整个进程。

ChannelGroup 的迭代是弱一致的:遍历期间加入的连接可能收不到这一条,刚断开的连接可能还会被访问一次(isActive 会挡住)。广播本来就是尽力而为,不承诺每个连接都收到;要可靠投递,需要消息 id、确认和重放,那是应用层协议,不是 ChannelGroup 的职责。

本机用两个 JDK java.net.http.WebSocket 客户端连 /ws/broadcast?userId=alice 和 ?userId=bob,由 alice 发一条消息,输出:

alice got=hello all
bob got=hello all

发送者自己也收到广播,因为循环没有排除 sender。要排除发送者,把第一行改成 if (channel == sender) continue;,但这样发送者就不知道广播是否成功,需要额外的确认消息。

核心机制三:心跳与死连接清理

WebSocket 的 ping/pong 是控制帧,WebSocketServerProtocolHandler 已经自动处理:收到 ping 回 pong,收到 pong 直接丢弃。业务 handler 看不到它们(10.2 已说明)。因此心跳检测要放在协议 handler 之前:

ch.pipeline().addLast(new IdleStateHandler(90, 30, 0, TimeUnit.SECONDS));
ch.pipeline().addLast(new WebSocketHeartbeatHandler());
ch.pipeline().addLast(new WebSocketServerProtocolHandler(...));

三个参数依次是读空闲、写空闲、全空闲,单位秒。这里的配置是“90 秒没读到任何东西就认为对端死了,30 秒没写东西就发一个 ping,全空闲不启用”。

为什么不启用全空闲?因为服务端自己每 30 秒发一次 ping,写操作会重置全空闲计时器;即使客户端一个字节都不回,全空闲也永远不会触发。真正能证明对端还活着的是入站数据:pong、文本、二进制都算。读空闲才是死亡信号。

心跳 handler 把两类事件分开处理:

package com.plume.linkd.v2.server;

import io.netty.channel.ChannelDuplexHandler;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.handler.codec.http.websocketx.CloseWebSocketFrame;
import io.netty.handler.codec.http.websocketx.PingWebSocketFrame;
import io.netty.handler.timeout.IdleStateEvent;

public final class WebSocketHeartbeatHandler extends ChannelDuplexHandler {

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
        if (evt instanceof IdleStateEvent) {
            boolean writerIdle = evt == IdleStateEvent.WRITER_IDLE_STATE_EVENT
                    || evt == IdleStateEvent.FIRST_WRITER_IDLE_STATE_EVENT;
            if (writerIdle) {
                ctx.channel().writeAndFlush(new PingWebSocketFrame());
            } else {
                ctx.channel().writeAndFlush(new CloseWebSocketFrame(1001, "heartbeat timeout"))
                        .addListener(ChannelFutureListener.CLOSE);
            }
            return;
        }
        ctx.fireUserEventTriggered(evt);
    }
}

两个细节。第一,用 ctx.channel().writeAndFlush 而不是 ctx.writeAndFlush:从 channel 的 tail 开始走完整出站链,close 帧会经过 WebSocketServerProtocolHandler,由它记录关闭状态并启动关闭流程;从当前 handler 的 ctx 写出会跳过协议 handler 的这段逻辑。第二,ping 不带载荷,控制帧最多 125 字节;如果要用载荷算 RTT,放 8 字节时间戳就够了,不要把业务 JSON 塞进 ping。

本机把空闲时间临时改成 3 秒读空闲、1 秒写空闲,用一个只完成握手、之后不回任何帧的原始客户端观察,输出:

t=1.00s opcode=0x9 len=0 payload=
t=2.00s opcode=0x9 len=0 payload=
t=3.01s opcode=0x8 len=19 payload=03e96865617274626561742074696d656f7574

前三行是每 1 秒一个 ping(opcode 0x9);3.01 秒时服务端发 close(opcode 0x8),载荷 03e9 是状态码 1001,后面的 UTF-8 字节是 heartbeat timeout。生产配置用 30 秒 ping、90 秒超时,这两个值不是协议规定,要按 RTT 和故障发现时间调;macOS 的 TCP keepalive 默认空闲两小时才开始探测,不能替代应用层心跳。

在 linkd-v2 里落地:/ws/broadcast 的完整 WebSocketHub

把前面的片段合成一个类。它只接受文本帧,二进制和聚合错误由 10.2 的 handler 处理;ChannelGroup 负责会话表,AttributeKey 负责用户标识:

package com.plume.linkd.v2.server;

import io.netty.channel.Channel;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.channel.group.ChannelGroup;
import io.netty.channel.group.DefaultChannelGroup;
import io.netty.handler.codec.TooLongFrameException;
import io.netty.handler.codec.http.websocketx.CloseWebSocketFrame;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler;
import io.netty.util.concurrent.GlobalEventExecutor;

@ChannelHandler.Sharable
public final class WebSocketHub extends SimpleChannelInboundHandler<TextWebSocketFrame> {
    private static final System.Logger LOG = System.getLogger(WebSocketHub.class.getName());
    private final ChannelGroup group = new DefaultChannelGroup(GlobalEventExecutor.INSTANCE);

    @Override
    public void userEventTriggered(ChannelHandlerContext ctx, Object evt) {
        if (evt == WebSocketServerProtocolHandler.ServerHandshakeStateEvent.HANDSHAKE_COMPLETE) {
            String userId = ctx.channel().attr(UserIdExtractor.USER_ID).get();
            if (userId == null) {
                userId = "anonymous";
                ctx.channel().attr(UserIdExtractor.USER_ID).set(userId);
            }
            group.add(ctx.channel());
            LOG.log(System.Logger.Level.INFO,
                    "ws join user=" + userId + " size=" + group.size());
            return;
        }
        ctx.fireUserEventTriggered(evt);
    }

    @Override
    protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) {
        broadcast(ctx.channel(), frame.text());
    }

    public void broadcast(Channel sender, String text) {
        int slow = 0;
        for (Channel channel : group) {
            if (!channel.isActive()) {
                continue;
            }
            if (!channel.isWritable()) {
                slow++;
                continue;
            }
            channel.writeAndFlush(new TextWebSocketFrame(text));
        }
        if (slow > 0) {
            LOG.log(System.Logger.Level.WARNING,
                    "broadcast skipped slow clients=" + slow + " sender=" + sender.remoteAddress());
        }
    }

    public int size() {
        return group.size();
    }

    public void closeAll(int code, String reason) {
        for (Channel channel : group) {
            channel.writeAndFlush(new CloseWebSocketFrame(code, reason))
                    .addListener(ChannelFutureListener.CLOSE);
        }
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        if (cause instanceof TooLongFrameException) {
            ctx.writeAndFlush(new CloseWebSocketFrame(1009, "message too big"))
                    .addListener(ChannelFutureListener.CLOSE);
            return;
        }
        LOG.log(System.Logger.Level.WARNING,
                "ws error remote=" + ctx.channel().remoteAddress(), cause);
        ctx.close();
    }
}

WebSocketHub 本身标 @Sharable,因为它的全部状态是 ChannelGroup,没有按连接变化的字段。SimpleChannelInboundHandler<TextWebSocketFrame> 仍然只接受文本;二进制回显、聚合器、会话表三者可以共存,靠类型过滤分流。

/ws/broadcast 的 pipeline 装配如下,注意前四行的顺序:

ch.pipeline().addLast(new HttpServerCodec());
ch.pipeline().addLast(new HttpObjectAggregator(64 * 1024));
ch.pipeline().addLast(new UserIdExtractor());
ch.pipeline().addLast(new IdleStateHandler(90, 30, 0, TimeUnit.SECONDS));
ch.pipeline().addLast(new WebSocketHeartbeatHandler());
ch.pipeline().addLast(new WebSocketServerProtocolHandler(
        "/ws/broadcast", null, true, 64 * 1024, false, true));
ch.pipeline().addLast(new WebSocketFrameAggregator(64 * 1024));
ch.pipeline().addLast(hub);

UserIdExtractor 在协议 handler 之前,才能读到查询串;IdleStateHandler 和心跳 handler 也在协议 handler 之前,才能看到 pong 和原始入站字节;WebSocketFrameAggregator 在协议 handler 之后,才能消费已经解出来的帧;hub 在最后,负责业务。六参数构造器的最后一个 true 是 checkStartsWith,没有它,/ws/broadcast?userId=alice 不会匹配。

关闭服务器时先关会话,再关监听,最后停 EventLoop:

public void close() {
    hub.closeAll(1001, "server shutdown");
    if (serverChannel != null) {
        serverChannel.close().syncUninterruptibly();
    }
    boss.shutdownGracefully();
    worker.shutdownGracefully();
}

顺序反了会让 closeAll 往已经被 EventLoop 丢弃的连接写 close 帧;serverChannel.close() 先执行能挡住新连接,但已有连接还在 worker 上,shutdownGracefully 会等它们把关闭流程走完。

广播风暴与按房间分组

/ws/broadcast 的广播是 O(N) 写,但 N 个客户端各发一条就是 O(N²)。1000 个连接同时发言,每人一条,服务端要写 100 万次;isWritable 只能保护单条连接,保护不了总 CPU 和总内存。三个缓解方向:

  • 按房间分组:Map<String, ChannelGroup>,每个房间一个 group,广播只遍历本房间。房间数多时,map 本身要清理,空 group 不能无限留着。
  • 限流与去重:同一用户 1 秒内只允许发一条,或者对相同内容做合并;这属于业务层,ChannelGroup 不提供。
  • 换成发布订阅:进程内广播用 group 足够;跨节点广播需要 Redis pub/sub、NATS 或消息队列,每个节点只向本机连接扇出。

按房间分组的最小结构是:

private final Map<String, ChannelGroup> rooms = new ConcurrentHashMap<>();

ChannelGroup room(String name) {
    return rooms.computeIfAbsent(name, key ->
            new DefaultChannelGroup(GlobalEventExecutor.INSTANCE));
}

DefaultChannelGroup 会在连接关闭时自动移除成员,但不会把空 group 从 rooms 里删掉;房间很多且生命周期短时,需要在成员数归零后清理,或者定期扫描 isEmpty()。并发下 size()==0 之后可能又有新连接加入,清理要用 rooms.remove(name, group) 的带值删除,避免误删刚被复用的房间。

常见错误与边界

错误一:在 channelInactive 里手动 group.remove,又依赖自动移除。 两个动作本身不冲突,但手动删除后再遍历可能触发弱一致迭代的边界情况;更重要的是,如果只手动删而忘了处理异常关闭路径,就会漏。交给 DefaultChannelGroup 的 close future 监听器,业务只负责 add。

错误二:广播时对每个连接 writeAndFlush 却不看 isWritable。 慢客户端会把出站队列越堆越长,最后拖垮整个进程。广播要么跳过不可写连接,要么用 1013 关闭它;不要用无界队列“先存着”。

错误三:把心跳定时器放在业务线程里直接写 channel。 定时线程和 EventLoop 线程同时写同一条连接,帧可能交错。心跳只应该发一个 PingWebSocketFrame,由所属 EventLoop 串行写出;Netty 的 writeAndFlush 本身是线程安全的,但不要在定时线程里做阻塞等待。

错误四:只启用 allIdle。 服务端自己发 ping 会重置全空闲计时器,客户端死了也检测不到。用读空闲判断死亡,写空闲触发 ping。

边界:广播没有投递保证。 遍历期间加入的收不到,跳过的慢客户端收不到,写失败的也收不到;没有消息 id、没有重放。需要可靠广播时,在应用层做序列号和补发。

边界:ChannelGroup 不是分布式会话表。 它只包含本 JVM 的 channel。多节点部署时,每个节点各有一份 group,跨节点广播要经过外部消息系统;不要假设 group.size() 是集群在线数。

小结

  • DefaultChannelGroup(GlobalEventExecutor.INSTANCE) 在连接关闭时自动移除成员,登记点放在 HANDSHAKE_COMPLETE。
  • 用户标识用 AttributeKey 挂在 Channel 上,随连接回收;需要反查时另建 map,并用 remove(key, value) 删除。
  • 广播优先用 group.writeAndFlush;需要过滤慢客户端时遍历 group,按 isActive() 和 isWritable() 跳过。
  • 心跳把 IdleStateHandler 放在协议 handler 之前,读空闲关闭、写空闲发 ping;全空闲会被自己的 ping 重置,不能用来判死。
  • close 帧的状态码用 1001、1009、1013 这类线上值,1005、1006、1015 不能发送。
  • 广播是 O(N),N 个客户端同时发言就是 O(N²);按房间分组、限流和外部 pub/sub 是三个扩展方向。

WebSocket 这条线到这里闭环:握手、帧、会话、心跳、广播。下一节进入 HTTP/2,看 Http2FrameCodec 如何把一条 TCP 连接拆成多条独立的多路复用流。

阅读导航:上一节:10.2 帧处理、分片与聚合 · 下一节:11.1 Http2FrameCodec 与多路复用 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「java」更多文章

  1. 《Netty 高性能网络编程》15.3 linkd-v2 验收与复盘
  2. 《Netty 高性能网络编程》15.2 限流、超时与降级
  3. 《Netty 高性能网络编程》15.1 网关路由与转发