ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot + Netty 实现多设备登录控制与超限踢人下线实践

Spring Boot + Netty 实现多设备登录控制与超限踢人下线实践 Spring Boot 里跑 Netty这活儿我其实干过不少次。多客户端登录、设备数限制、超限踢人下线听起来像是个挺具体的小需求但真做起来里面涉及的东西远比“写个 Handler 判断一下”要多得多。尤其是“超设备数下线”这个动作做得好不好直接影响用户的体感——是“被顶掉”还是“登录失败”是“悄悄断线”还是“明确收到提示”这些细节都得靠设计去兜底。我写这篇文章就是想把这套东西彻底捋一遍。从方案选型、会话管理、强制下线的实现方式到心跳清理、粘包处理、并发边界再到实际踩过的坑和排查思路尽可能完整地还原一个可落地的实现路径。适合正在用 Spring Boot 做 IoT、长连接推送、IM 或在线业务并且被“多端登录控制”卡住的同学参考。1. 需求拆解与方案选型到底在解决什么问题1.1 “超设备下线”的业务本质先把需求聊透。所谓的“多客户端登录超出设备数下线”在实际业务里通常长这样用户在一个 App 或门户网站登录后后端建立一条 TCP 长连接设备主动连上来或者服务端推送消息下去。产品规定同一账号最多同时在线 N 台设备比如 N3。当第 4 台设备尝试接入时你不能让它无限增长必须处理掉一条旧连接或者拒绝这条新连接。这个问题的本质是一个业务账号对应多条物理连接你需要在这两者之间建立清晰的映射关系并且把“连接数”转成“设备数”来做控制。很多新人容易犯的第一个错就是只在 Netty 的ChannelHandler里数连接。效果是什么一台设备断线重连、闪断重连、或者同一个物理设备开多个页面全都会被算成“多台设备”很快用户就被踢下线了。所以做这个需求之前先要界定“设备”的粒度。一般来说客户端需要在上报登录信息时携带deviceId设备唯一标识可以是 UUID、MAC、IMEI、或者客户端生成的机器码。服务端以userId deviceId作为一条会话的唯一标识以userId维度统计设备数量。同一个deviceId重复登录算同一台设备只更新连接引用不新增设备数。这一步想清楚后面所有逻辑才不会跑偏。1.2 为什么选 Netty 而不是 WebSocket 或普通 HTTP这个场景有人会问用 WebSocket 不也行吗对如果业务只是“浏览器页面聊天推送”WebSocket 完全够用。但一旦落到“多客户端”“多设备”“长连接维护”“服务端主动推送”这些词上Netty 的优势就很明显了连接承载能力更强Netty 基于 NIO单线程可以管理上千上万个连接而传统的 BIO 一个线程挂一个连接到了几千并发资源就吃紧。协议定制灵活你可以自定义帧格式可以二进制定制可以用长度字段解决粘包也可以用HttpServerCodec兼容 WebSocket甚至像很多物联网充电桩的实战方案一样在 Netty 之上跑 MQTT。精细控制连接生命周期IdleStateHandler做心跳超时、ChannelGroup做批量通知、ChannelFutureListener做断线回调这些都是现成的。Java 后端技术栈里Spring Boot Netty 的组合非常成熟。Spring Boot 负责业务层、配置管理、和数据库/Redis 交互Netty 专注于网络 IO各管一摊边界清晰。1.3 现有旧连接怎么定位接口下 ARP、设备 IP、上下线时间的类比热词里有一条“接口下 ARP 地址怎么查 IP 地址上线时间和下线时间”这其实是网络设备场景里的需求。但把它类比到业务系统里思路是完全一样的你需要一张“在线设备表”记录每台设备的上线时间、下线时间、IP、设备标识、以及对应的 Channel 引用。网络工程师查 ARP 表找设备你查的则是自己的 Session 表找 Channel。所以与其说这是一个 Netty 编程问题不如说这是一个“在线状态管理 连接控制”的工程问题。最核心的产出物就是那个ChannelManager会话管理器。2. 整体架构与会话模型设计2.1 Spring Boot Netty 的职责划分我不会把 Netty 的业务处理逻辑堆在 Handler 里写了上万行。合理的做法是Spring Boot 层提供鉴权接口、查询在线设备列表接口、手动踢人接口、推送消息接口。Netty 层只负责接收字节、解析协议、维护连接、心跳检测、把业务事件抛给 Spring 容器管理的 Service。共享存储层如果项目只有单机部署用本地ConcurrentHashMap就够如果未来要扩展多节点就把在线会话信息同步到 Redis用 Redis 的 Hash 结构存userId - MapdeviceId, sessionInfo。这里有一个非常重要的设计决策不要把 Channel 对象直接放进 Redis。Channel 是 JVM 内存里的对象跨节点无法序列化。Redis 里存的是“逻辑会话信息”userId、deviceId、ip、上线时间、节点标识定位到节点后再从该节点本地的ChannelManager里取出真正的 Channel。这是多节点扩展的前提。2.2 会话数据结构Channel 的 attr 到底该存什么Netty 的每个Channel自带一个AttributeMap你可以把会话信息挂上去。我会定义一个Session对象专门存这些元数据public class Session { private Long userId; private String deviceId; private String token; // 登录票据可用于鉴权 private String ip; private Integer deviceType; // 设备类型APP/PC/小程序等 private String nodeId; // 服务节点标识分布式时用 private Long loginTime; // 上线时间 private Long lastHeartbeatTime;// 最后心跳时间 // getter/setter 省略 }挂在 Channel 上时用一个常量 keypublic class Attributes { public static final AttributeKeySession SESSION AttributeKey.valueOf(session); }为什么推荐挂 attr 而不是用一个ChannelId - Session的全局 Map 来存因为通道关闭时ChannelHandlerContext.channel().attr(Attributes.SESSION)可以直接取到绑定信息方便在清理逻辑里知道“这条连接属于哪个用户、哪台设备”不用再做二次查找。当然反向索引userId - Channel还是需要的这就是下面要说的ChannelManager的活儿。2.3 核心组件ChannelManager 的设计我会把ChannelManager设计成一个单例的 Spring 组件内部维护三张表Component public class ChannelManager { // 1. 全局连接组用于服务关闭时批量关连接 private final ChannelGroup channelGroup new DefaultChannelGroup(GlobalEventExecutor.INSTANCE); // 2. 用户维度 - 设备Map // userId - (deviceId - Channel) private final ConcurrentHashMapLong, ConcurrentHashMapString, Channel userChannels new ConcurrentHashMap(); // 3. 反向映射ChannelId - userId方便快速查归属 private final ConcurrentHashMapChannelId, Long channelOwner new ConcurrentHashMap(); }注意ChannelGroup的GlobalEventExecutor.INSTANCE它是一个全局的事件执行器不用额外引入线程池对连接进行批量操作时会自动用这个 executor 来执行。核心方法至少要保证这几个// 添加或更新会话 public void addSession(Channel channel, Session session) // 按用户获取所有设备连接 public ListChannel getChannelsByUserId(Long userId) // 按用户设备获取连接 public Channel getChannel(Long userId, String deviceId) // 按用户获取设备数量 public int getDeviceCount(Long userId) // 移除会话注意区分是主动下线还是异常断开 public boolean removeSession(Channel channel, Session session)每个方法都要考虑并发安全。比如addSession时userChannels.computeIfAbsent(userId, k - new ConcurrentHashMap())这段逻辑在高并发下要确保不会重复创建子 Map。ConcurrentHashMap的computeIfAbsent本身已经做了并发处理但使用时要留意“函数内不要递归操作同一个 Map”这种死循环风险。2.4 为什么一定需要一个“踢人”的幂等接口实际运营场景里管理员手动下线某个用户、或者在 PC 端点击“退出其他设备”都会调用同一个下线接口。所以“下线”这个动作一定要做幂等不管调用一次还是十次最终效果都是“该设备连接被断开且资源被清理干净”。我会提供一个统一的方法public void kickDevice(Long userId, String deviceId, String reason) { Channel channel getChannel(userId, deviceId); if (channel ! null channel.isActive()) { // 发送一条“下线通知”消息让客户端知道是被踢下线的 sendKickNotice(channel, reason); // 延迟或立即关闭连接 channel.close(); } // 即使 channel 已经不存在也要清理可能残留的映射 removeSessionByUserAndDevice(userId, deviceId); }这个方法既可以被 Netty 内部 Handler 调用比如超限时踢掉最旧设备也可以被 Controller 调用管理员后台操作。3. 超限登录的核心逻辑什么时候踢、踢谁、怎么踢3.1 触发时机不是登录接口里判断而是在通道建立并鉴权后判断多客户端登录的控制有两种实现位置在登录鉴权接口HTTP里判断登录时查出在线设备数如果达到上限直接返回“设备数已满”客户端连接都不一定能建立成功。这种方式适合“登录即创建连接”的强绑定场景但对长连接来说不够灵活因为 HTTP 请求和 TCP 连接不是一一对应的。在 Netty 的鉴权 Handler 里判断推荐客户端先建立 TCP 连接然后发送登录帧带上 userId、deviceId、token服务端在 Handler 里做鉴权。鉴权通过后再执行业务规则判断设备数是否超限我推荐第二种。理由很简单长连接场景下连接建立是第一步业务鉴权是第二步。如果你让客户端先去请求 HTTP 登录接口、再拿 token 去建 TCP那连接建立、登录、鉴权分散在两条链路上出错时不好排查。让 TCP 连接自己承载登录逻辑一次握手就能确认“你是谁、你用的哪台设备”后续的心跳帧、业务帧都可以直接复用这个会话不用每次都传 token。3.2 判断规则设备总数 vs 去重设备数在 Handler 里登录帧解析后的处理流程大概是这样的// 登录鉴权 Handler 的核心伪代码 if (session null) { // 1. 解析 userId, deviceId, token // 2. 校验 token查库/查 Redis // 3. 如果校验失败回错误帧并关闭连接 // 4. 设备去重同一 deviceId 再次登录替换旧 Channel Channel oldChannel channelManager.getChannel(userId, deviceId); if (oldChannel ! null oldChannel.isActive()) { // 同设备重连直接踢旧的不算新增设备 kickChannel(oldChannel, REPLACE_BY_NEW_CONNECTION); } // 5. 超限判断 int deviceCount channelManager.getDeviceCount(userId); if (deviceCount maxDeviceCount) { // 选择策略优先踢最早登录的设备还是拒绝当前设备 Channel eldest channelManager.getOldestChannel(userId); kickChannel(eldest, DEVICE_LIMIT_EXCEEDED); } // 6. 添加新会话 channelManager.addSession(channel, newSession); // 7. 回登录成功帧 }这里第 5 步有两种策略我分别说下踢最旧设备最常见用户在一台设备上登录后再用另一台设备登录第 4 台设备会把最早登录的那台顶掉。适合 IM 工具、音频播放 APP 这类“随处可用”的产品。实现时需要能查“最早登录的 Channel”所以Session里loginTime字段要存准或者给ChannelManager加一个有序结构比如TreeMapLong, Channel以登录时间排序。拒绝新设备适合安全要求较高的场景比如网银、内部 OA不允许悄悄冒出来一台新设备。那种逻辑就简单了超限直接回错误帧不踢老连接。我的经验和建议是策略做成配置项通过 Spring 的ConfigurationProperties读取不要写死在代码里。因为产品经常会改口今天说“踢最旧”明天说“还是别踢了直接拒绝吧”做成配置后改一行 yml 就行。3.3 怎么通知旧设备“你被下线了”这是个容易忽略的细节。粗暴地channel.close()没问题但客户端那边“哇的一下就断了”不知道是自己网络出问题还是被服务端踢了很影响体验。折衷做法下线前先发一条协议帧比如{ type: KICK_OUT, code: 4001, message: 您的账号在其他设备登录您已被下线, timestamp: 1712345678901 }客户端收到这个帧之后可以弹一个“您的账号已在其他设备登录”的提示然后再主动关闭本地连接。服务端则是发完帧之后通过channel.writeAndFlush(frame).addListener(ChannelFutureListener.CLOSE)关闭连接。注意writeAndFlush之后直接 close 可能会让客户端来不及读取数据就被 RST最好给一个极短的延迟或者用addListener在写操作完成后再 close。实际上 Netty 的ChannelFutureListener.CLOSE就是官方推荐的做法。3.4 登录成功后Session 的存储位置Session 挂到 Channel 的 attr 上是这样写的channel.attr(Attributes.SESSION).set(session);后续所有 Handler心跳、业务消息、异常关闭都能通过ctx.channel().attr(Attributes.SESSION).get()拿到对应的会话非常方便。但注意Netty 的 Handler 是单例的多个 Channel 共享同一个 Handler 实例。所以 Handler 里绝对不能持有“只属于某一个连接”的状态例如this.session xxx。一旦这么做多连接进来后 session 就被互串了。所有连接相关的状态必须挂在 Channel 的 attr 上或者用ChannelManager这类外部容器来管理。4. 完整代码实现从 Server 初始化到强制下线4.1 依赖与基础版本选择我是用的依赖组合dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter/artifactId /dependency dependency groupIdio.netty/groupId artifactIdnetty-all/artifactId version4.1.100.Final/version /dependencyNetty 版本方面4.x 一路用下来很稳。5.x 当年没有正式发布就搁置了不用纠结直接选 4.1 最新版就行。注意 Spring Boot 3.x 使用的是 Java 17Netty 对 Java 17 的支持没问题但要确认依赖版本别和 Boot 自带版本冲突。如果你用的是 Spring Boot 3.x其实 Boot 内部已经有 Netty 依赖WebFlux、gRPC 等场景会用可以统一版本管理。4.2 Netty Server 启动类用 Spring Boot 的方式启动 Netty关键是别让 Netty 的线程组被 Spring 管理也不要在主线程里阻塞启动。我会写一个NettyServerRunner实现ApplicationRunner在 Spring 容器启动完成后拉起 NettyComponent public class NettyServerRunner implements ApplicationRunner { private final NettyServer nettyServer; public NettyServerRunner(NettyServer nettyServer) { this.nettyServer nettyServer; } Override public void run(ApplicationArguments args) { nettyServer.start(); } }Server 启动的核心部分public void start() { EventLoopGroup bossGroup new NioEventLoopGroup(1); EventLoopGroup workerGroup new NioEventLoopGroup(); try { ServerBootstrap bootstrap new ServerBootstrap(); bootstrap.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .option(ChannelOption.SO_BACKLOG, 1024) .childOption(ChannelOption.SO_KEEPALIVE, true) .childOption(ChannelOption.TCP_NODELAY, true) .childHandler(new ChannelInitializerSocketChannel() { Override protected void initChannel(SocketChannel ch) { ChannelPipeline pipeline ch.pipeline(); // 拆包粘包处理 pipeline.addLast(new LengthFieldBasedFrameDecoder( 1024 * 1024, 0, 4, 0, 4)); pipeline.addLast(new LengthFieldPrepender(4)); // 自定义编解码器处理字节与消息对象 pipeline.addLast(new JsonMessageDecoder()); pipeline.addLast(new JsonMessageEncoder()); // 心跳检测读空闲 90 秒触发 pipeline.addLast(new IdleStateHandler(90, 0, 0)); // 业务 Handler pipeline.addLast(new AuthHandler()); pipeline.addLast(new HeartbeatHandler()); pipeline.addLast(new BusinessHandler()); pipeline.addLast(new ExceptionHandler()); } }); ChannelFuture future bootstrap.bind(port).sync(); future.channel().closeFuture().sync(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); } }这里面有几个点我单独说明粘包/半包处理我选用的是LengthFieldBasedFrameDecoder协议里前 4 字节表示整个数据包的长度。这个方案最通用业务字段里不用再塞结束符。TCP 是流协议没有消息边界客户端一次性发多条消息或者一条消息被拆成多个 TCP 段都需要依赖长度字段重新组包。不加这一步粘包问题一定会在压测或极端网络下爆出来。SO_BACKLOG 1024这是等待 accept 的连接队列长度。如果短时间有大量客户端涌入这个值太大会浪费内存太小会导致连接失败。1024 对于大多数业务场景足够。TCP_NODELAY 设置为 true关闭 Nagle 算法。如果业务需要低延迟的小包传输比如即时聊天、推送必须开启。否则在交互式场景下会明显感觉到“卡顿”。4.3 解码器把字节流转成消息对象我用一个简单的JsonMessageDecoder做示范。实际业务里我建议定义更严格的协议格式比如一个Message类包含type和data字段这里为了把原理讲清楚直接以 JSON 为例public class JsonMessageDecoder extends ByteToMessageDecoder { Override protected void decode(ChannelHandlerContext ctx, ByteBuf in, ListObject out) { // 因为前面已经经过 LengthFieldBasedFrameDecoder这里一个 ByteBuf 就是一个完整包 byte[] bytes new byte[in.readableBytes()]; in.readBytes(bytes); // 转成统一的 RequestMessage RequestMessage msg JSON.parseObject(bytes, RequestMessage.class); out.add(msg); } }注意解码器不要缓存状态。每个连接的数据流是独立的Handler 是单例共享的但每个连接对应独立的ByteToMessageDecoder实例这是由ChannelInitializer在为每个连接创建 pipeline 时生成的。所以解码器可以安全地持有自己的cumulationBuffer。4.4 AuthHandler设备登录与控制的核心战场AuthHandler是整个需求里最核心的 Handler。它只处理登录请求登录完成后会把自己从 pipeline 里移除后续的业务消息不走这一层。这样的好处是鉴权逻辑不需要每次消息都判断一次“这个连接有没有登录过”。这个设计有个好处减少判断分支提高执行效率。ChannelHandler.Sharable public class AuthHandler extends ChannelInboundHandlerAdapter { // 注意Sharable 修饰时Handler 是单例的里面不能有实例级别的连接状态 private final ChannelManager channelManager; private final DeviceLimitProperties deviceLimitProperties; Override public void channelRead(ChannelHandlerContext ctx, Object msg) throws Exception { if (msg instanceof RequestMessage) { RequestMessage req (RequestMessage) msg; if (req.getType() MessageType.LOGIN) { Channel channel ctx.channel(); // 1. 解析登录数据 LoginPacket loginPacket JSON.parseObject(req.getData(), LoginPacket.class); Long userId loginPacket.getUserId(); String deviceId loginPacket.getDeviceId(); String token loginPacket.getToken(); // 2. 校验 token boolean authPassed authService.validateToken(userId, token); if (!authPassed) { sendErrorAndClose(ctx, AUTH_FAILED, 登录凭证无效); return; } // 3. 设备去重 Channel oldChannel channelManager.getChannel(userId, deviceId); if (oldChannel ! null oldChannel.isActive()) { // 同一台设备重新登录替代旧连接 sendKick(oldChannel, 重复登录旧连接将被关闭); oldChannel.close(); } // 4. 设备数判断 int deviceCount channelManager.getDeviceCount(userId); if (deviceCount deviceLimitProperties.getMaxDevice()) { if (deviceLimitProperties.isKickOld()) { Channel eldest channelManager.getOldestChannel(userId); if (eldest ! null) { sendKick(eldest, 设备数量超出限制您已被下线); eldest.close(); } } else { sendErrorAndClose(ctx, DEVICE_LIMIT_EXCEEDED, 设备数量已达上限); return; } } // 5. 建立会话 Session session new Session(); session.setUserId(userId); session.setDeviceId(deviceId); session.setIp(getRemoteIp(channel)); session.setLoginTime(System.currentTimeMillis()); channel.attr(Attributes.SESSION).set(session); channelManager.addSession(channel, session); // 6. 移除鉴权 Handler后续消息不再判定登录 ctx.pipeline().remove(this); // 7. 返回登录成功 sendSuccess(ctx, LOGIN_OK, 登录成功); } else { // 未登录状态下发送非登录消息直接关闭 ctx.close(); } } else { ctx.fireChannelRead(msg); } } }这里有几个“为什么”要说明为什么Sharable注解加不加我在代码里加了因为AuthHandler可能被多个连接共享如果通过 Spring 注入构造的话。但使用了Sharable就要求 Handler 内部不能保存任何连接相关的状态。我上面代码里只有channelManager和deviceLimitProperties这两个无状态依赖所以是安全的。如果写法是new AuthHandler()每次给每个 pipeline 新建一个实例那不加Sharable也行但用 Spring 管理统一 Bean 会更规范。为什么要在鉴权成功之后移除 Handler因为鉴权只需要执行一次。留着它在 pipeline 里每来一条消息都要经过if (msg instanceof RequestMessage) { if (req.getType() LOGIN) ... }纯属浪费。移除它是标准做法。4.5 强制下线如何让“最旧设备”优雅退出我在前面用了sendKick方法。它的实现需要特别注意“先通知后关闭”的顺序private void sendKick(Channel channel, String reason) { if (channel null || !channel.isActive()) { return; } ResponseMessage response new ResponseMessage(); response.setType(MessageType.KICK_OUT); response.setCode(4001); response.setData(reason); channel.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); }addListener(ChannelFutureListener.CLOSE)是 Netty 官方推荐的做法等消息真正写入网络缓冲区后再关闭连接。如果你直接channel.close()可能把刚写入的数据一起丢掉客户端啥也没收到连接倒是断了。等客户端重连过来问“为什么掉线了”你也解释不清楚。另外超限的时候如果“最旧设备”刚好是一个已经断开的连接踢它等于白踢。所以getOldestChannel的实现要过滤掉!isActive()的连接public Channel getOldestChannel(Long userId) { MapString, Channel deviceMap userChannels.get(userId); if (deviceMap null || deviceMap.isEmpty()) { return null; } return deviceMap.values().stream() .filter(Channel::isActive) .sorted(Comparator.comparingLong( ch - ch.attr(Attributes.SESSION).get().getLoginTime())) .findFirst() .orElse(null); }4.6 心跳检测与僵尸连接清理踢人下线这个动作如果只靠“登录时判断”那必然会有漏洞。比如用户直接拔掉网线、App 被杀后台TCP 连接不会立刻关闭服务端如果把这种僵尸连接还算作“在线设备”那用户明明没在用了却一直占着设备名额。所以要配合心跳超时清理。Netty 的IdleStateHandler在 pipeline 里会自动检测读空闲假设 90 秒没有收到客户端任何数据包触发userEventTriggered事件ChannelHandler.Sharable public class HeartbeatHandler extends ChannelInboundHandlerAdapter { Override public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { if (evt instanceof IdleStateEvent) { IdleStateEvent event (IdleStateEvent) evt; if (event.state() IdleState.READER_IDLE) { // 读空闲超时判定连接已不可用主动关闭 Session session ctx.channel().attr(Attributes.SESSION).get(); if (session null) { // 还没登录就超时直接关 ctx.close(); } else { log.info(设备心跳超时, userId{}, deviceId{}, ip{}, session.getUserId(), session.getDeviceId(), session.getIp()); // 清理会话映射 channelManager.removeSession(ctx.channel(), session); ctx.close(); } } } super.userEventTriggered(ctx, evt); } }但这里会出现前面说的“误杀”情况如果客户端只是临时切到后台 5 秒或者网络抖动90 秒内没发心跳就被踢了所以时间配置要根据业务来我一般和设备端约定“客户端每 30 秒发一次心跳”服务端IdleStateHandler的超时时间设为 90 秒3 个心跳周期留出足够余量。而且超时后不要立刻删会话可以先给客户端发一个“PING”探活如果再过 10 秒还没 PONG 响应再下线。这样误杀率会低很多。热词里有一条“netty粘包处理”这个在实际项目中一定要提前解决不要等压测出了问题再改协议。上面我已经用LengthFieldBasedFrameDecoder做了长度帧拆包客户端写数据时也必须按同样的协议来否则两边各说各话连登录都过不去。5. 与 Spring Boot 业务层的交互5.1 推送消息从 Controller 到 Channel长连接最大的价值是服务端主动推送。比如管理员在后台发一条通知系统需要在 Controller 里根据userId找到对应的所有 Channel然后逐个推送。RestController RequestMapping(/api/push) public class PushController { private final ChannelManager channelManager; private final PushService pushService; PostMapping(/toUser) public ResultVoid pushToUser(RequestBody PushRequest request) { // 业务校验、落库、记录推送日志等 pushService.savePushRecord(request); // 找到该用户所有在线设备 ListChannel channels channelManager.getChannelsByUserId(request.getUserId()); for (Channel channel : channels) { if (channel ! null channel.isActive()) { ResponseMessage response new ResponseMessage(); response.setType(MessageType.NOTIFY); response.setData(request.getContent()); channel.writeAndFlush(response); } } return Result.success(); } }注意channelManager.getChannelsByUserId返回的是一个设备列表你可以在推送之前再过滤一下设备类型比如后台推送只需要推送给手机 App不推给 PC 客户端。这要求Session里把deviceType存好。5.2 分布式扩展Redis 里放什么不放什么如果服务是部署多个节点的本地ConcurrentHashMap存 Channel 就不够了。我建议的扩展方案是Redis 的 Hash 结构key 为online:device:{userId}field 为deviceIdvalue 为sessionInfoJSON包含 nodeId、ip、loginTime。每个 Netty 节点启动时向 Redis 注册自己的节点信息。推送或踢人时先查 Redis 拿到nodeId然后通过内部 RPC或者接口调用转发到目标节点由目标节点在本地ChannelManager里查到真正的 Channel 再执行操作。这个方案唯一要注意的是状态一致性设备下线后要同时删 Redis 里的记录和本地 Map 里的 Channel最好用 TTL 加主动删除兜底防止宕机时 Redis 残留脏数据。5.3 Spring Boot 生命周期管理Netty Server 启动后如果 Spring 容器被关闭或者重启Netty 的线程组也必须优雅释放。这需要监听 Spring 的关闭事件PreDestroy public void destroy() { // 通知所有连接关闭 channelManager.closeAllChannels(); bossGroup.shutdownGracefully(); workerGroup.shutdownGracefully(); }如果这里不做清理在本地重复重启应用时会遇到“端口被占用”的问题甚至在极端情况下连接资源耗尽。6. 常见问题与排查技巧实录6.1 连接反复建立、旧连接没有真正关闭现象用户设备 A 登录后设备 B 再登录结果设备 A 的 Channel 没有从ChannelManager里移除导致设备数量只增不减最终把新登录的设备也挤下线。原因channel.close()只说明了连接关闭但ChannelManager里的映射是手动维护的没有人去删。如果只调close()而不调用removeSessionMap 里就会残留已关闭的 Channel。修正统一在ChannelInboundHandlerAdapter#channelInactive回调里做清理不要在不同位置重复删。在channelInactive里根据Session信息调用removeSession并且用ChannelId做判断防止删除的不是当前连接存储的引用。注意channelInactive在连接关闭时一定会被触发不管是因为心跳超时、踢下线还是网络异常。所以这应该是唯一清理映射的地方除了主动踢人接口的幂等兜底外。6.2 超限踢人时误踢新连接现象用户本来在线 3 台设备第 4 台设备登录逻辑触发超限结果踢掉的不是设备 A而是当前刚登录的这台设备。原因getOldestChannel排序错误或者loginTime没有正确赋值。如果登录后忘了把Session挂到 attr 上排序时拿到 null 就会抛 NPE 或者排序结果混乱。排查建议在登录成功帧返回时打印 session 的完整信息在getOldestChannel里过滤掉channel.attr(Attributes.SESSION).get() null的连接。一定要先过滤后排序再加try-catch兜底日志。6.3 心跳超时误杀用户“隐身”但连接还活着现象用户只是切了后台App 的 TCP 连接还活着但因为客户端在后台暂停了心跳发送服务端误判为离线把设备踢下线。用户回到前台发现需要重新登录体验很差。修正服务端不要把“读空闲”作为唯一判断依据。可以增加一个“半关闭”状态心跳超时后服务端主动发送PING帧并启动一个短定时器等待PONG如果 PONG 回来连接继续保留没有 PONG 再清理。这个过程对所有客户端是透明的只要客户端实现 PING/PONG 逻辑就行。6.4 粘包导致消息解析异常现象客户端同时发送多条消息时服务端解析 JSON 报错或者一条消息里拼了两条数据。原因没有做长度帧拆包。TCP 是流式的你发“A”和“B”两个包底层可能合并成一个包传过来也可能把 A 拆成两半。如果没有帧边界解析服务端一定会错乱。修正使用LengthFieldBasedFrameDecoder配合客户端的长度头部这是最干净的做法。如果你用 JSON 做协议千万别去“读一行”或者“按 \n 分隔”——二进制流里不一定有换行。6.5 Channel 泄漏连接关闭了但没有释放资源现象服务端长时间运行后内存暴涨或者文件描述符FD到达上限出现Too many open files错误。原因连接没有正常关闭或者EventLoop里的任务堆积。尤其是踢人逻辑里如果直接channel.close()而不是操作完之后调用close()并移除监听器就可能导致重复关闭。修正所有关闭操作放到 finally 或ChannelFutureListener里执行定期用channelGroup.size()查看当前连接总数配合监控告警一旦连接数异常增长及时处理。6.6 登录态校验不过却被允许建立连接现象客户端绕过 HTTP 登录直接连 TCP 端口可能发送任意数据被服务端当作登录帧解析。修正鉴权失败后不仅要关闭连接还要记录安全日志。同时建议 Netty Server 的端口只对内网/安全网关开放不直接暴露公网。如果必须暴露公网可以加一层 AES 或 TLS 加密防止恶意扫描和协议攻击。6.7 高并发下同一 userId 同时登录现象同一个账号的两台设备几乎同时发起登录两个请求都通过了“设备数判断”导致设备数短暂超限。原因没有做并发控制。getDeviceCount和addSession是两步操作中间可能有其他请求插入。修正对同一个userId加锁。最简单的方式是使用ConcurrentHashMapLong, Object作为用户维度的锁池private final ConcurrentHashMapLong, Object userLocks new ConcurrentHashMap(); private Object getUserLock(Long userId) { return userLocks.computeIfAbsent(userId, k - new Object()); }在addSession整个流程中对userLocks里对应的锁对象加synchronized块包住“判断设备数 - 踢人 - 添加会话”这段逻辑。这样同一用户的并发登录会被串行化其他用户不受影响。6.8 热词里那条“springboot版本太高”的画面顺便提到一个 Spring Boot 相关的常见坑Netty 4.1 老版本在某些高版本 Spring Boot 环境下可能出现 jar 包冲突尤其是 Spring Boot 3.x 自带 Netty 后如果你的依赖里再手动引入一个低版本 Netty会看到NoSuchMethodError之类的异常。解决方式是统一 Netty 版本号或者直接使用 Spring Boot 内置的 Netty 版本不要重复引。如果对版本协调没底就用mvn dependency:tree先看冲突再决定哪个依赖要 exclude。7. 压测与上线前的检查清单写到最后我把上线前需要确认的点列一下每个都踩过坑协议兼容性客户端和服务端的长度帧格式、消息类型枚举、错误码表和版本号是否有约定。线上改协议必须带版本号否则老客户端会解析失败。心跳周期客户端 30 秒一次心跳服务端 90 秒读空闲超时是不是合理。如果设备网络环境差可以放宽到 120 秒甚至 180 秒但要能接受故障发现延迟。最大连接数Netty 线程模型里单机支撑几万连接没问题但要注意SO_BACKLOG、文件描述符上限、堆内存和堆外内存配置。用-XX:MaxDirectMemorySize限制堆外内存防止 ByteBuf 泄漏。消息体大小限制LengthFieldBasedFrameDecoder的第一个参数是最大帧长度不要设得过大按业务最大需求来就行超限的帧直接丢弃并记录日志防止恶意客户端把内存打爆。日志采样:上线初期给关键流程登录成功、超限踢人、心跳超时下线加日志但要注意别log.info打满磁盘。建议抽样记录或者使用按用户 ID 哈希抽样的方式。优雅停机:K8s 滚动发布时旧的 Pod 在关闭前必须把已连接客户端平滑迁移到新节点。这一步如果没做每次发布都会导致全网设备集体掉线重连那场面非常酸爽。我个人在实际操作中的体会是这类需求最大的难点不是“写代码实现功能”而是把边界情况想清楚重复登录、并发登录、心跳超时、连接泄漏、跨节点会话一致性……每一条都要推演一遍。如果你是想在项目里直接落地照着上面的ChannelManager和AuthHandler结构先把单机版本跑通再加 Redis 和跨节点推送会顺畅很多。最后再分享一个小技巧给ChannelManager的removeSession方法加上synchronized或者用ConcurrentHashMap#compute原子操作看起来是小事但它在并发场景下的作用非常大尤其是在高复用、高频建连断连的推送系统里能帮你挡掉不少偶发的 NPE 和数据不一致问题。多端登录控制这个功能做一次需求可能是两天的事但耐心把陷阱填平线上省下的排查时间会远超这两天。
返回列表