资讯动态

WebSocket登录超时处理:Shiro Session过期与长连接关闭方案

发布时间:2026/10/15 7:12:32 来源:尧图企业网站定制
1. 登录超时这件事为什么在WebSocket场景下变得棘手做过后台管理系统的朋友大概率都处理过登录超时用户一段时间不操作Session过期再点任何按钮就跳回登录页。这套逻辑在传统的HTTP请求-响应模型里非常成熟Spring Security或者Shiro的Session机制天然就能覆盖。但一旦系统里引入了WebSocket事情就变得微妙起来了。WebSocket是一条全双工的长连接通道它和HTTP的Session生命周期并不完全同步。浏览器里的HTTP Session过期了可WebSocket连接可能还挂在那里服务端依然认为这个通道是活的。更麻烦的是很多系统用WebSocket来推送通知、实时消息、在线状态如果登录已经超时了这条通道还在源源不断地推数据既浪费资源又存在信息泄露的隐患。这个系列我打算分几篇把这件事讲透。第一篇先聚焦在问题建模和整体方案设计上为什么Shiro的Session过期不会自动关掉WebSocket我们到底要在哪一层做拦截超时判定应该以什么为准这些想不清楚后面写多少代码都是白搭。文章会涉及Shiro的Session管理机制、WebSocket握手阶段的身份校验、心跳与超时的关系以及一个可落地的整体架构思路。适合已经用过SpringBoot和Shiro、正在或准备引入WebSocket的开发者也适合想搞清楚长连接场景下会话管理这个通用问题的朋友。我先把结论抛出来登录超时和WebSocket连接关闭是两个必须显式关联的动作框架不会帮你自动完成。下面我们一步步拆开看。2. Shiro的Session过期机制到底管了什么2.1 Shiro Session的存储与过期判定Shiro自己维护了一套Session体系和Servlet容器的HttpSession是两回事。默认情况下Shiro的DefaultWebSessionManager会创建一个SimpleSession把它存在SessionDAO里。这个Session有一个核心字段叫timeout单位是毫秒默认值通常是30分钟1800000毫秒。过期是怎么判定的Shiro并不是靠一个后台线程定时去扫描而是在每次访问Session的时候做惰性检查。具体逻辑在AbstractValidatingSessionManager.doGetSession里拿到Session后调用validate如果isTimedOut()返回true就抛ExpiredSessionException然后触发onExpiration回调最终把Session从SessionDAO里删掉。这里有个关键点惰性检查意味着如果没有任何人访问这个Session它就不会被判定为过期也不会被清理。对于纯HTTP系统用户每次点页面都会带上Cookie自然会触发检查。但WebSocket连接建立之后如果客户端不发消息、服务端也不主动去读Session那么这个Session可能一直躺在DAO里直到某个HTTP请求把它唤醒。2.2 为什么WebSocket连接不受Session过期影响WebSocket握手阶段确实会走一次HTTP升级请求这时候能拿到Session。但握手成功之后连接就脱离了HTTP的请求-响应周期变成一个独立的TCP长连接。Shiro的过滤器链Filter Chain只在HTTP请求经过时生效WebSocket后续的帧收发根本不经过Shiro的过滤器。所以会出现这样的现象用户登录后打开页面WebSocket连上了。然后用户去开会一小时后回来HTTP Session早就过期了但WebSocket连接还在服务端还能往这个连接推消息。如果此时用户点击页面上的按钮HTTP请求会因为Session过期被Shiro拦截跳转登录页但WebSocket那条通道除非我们主动关否则它一直开着。注意这不是Shiro的bug而是设计边界。Shiro管的是HTTP会话WebSocket是另一套生命周期两者需要我们自己桥接。2.3 超时判定应该以什么为准这里有个设计决策WebSocket的关闭时机应该以Shiro Session的过期时间为准还是以WebSocket自己的空闲时间为准我的建议是以Shiro Session为准。原因很简单登录状态是业务层面的概念WebSocket只是这个登录状态下的一个通信通道。用户登录超时了通道就应该关闭不管这个通道最近有没有数据传输。如果以WebSocket空闲时间为准会出现用户明明已经超时但因为服务端一直在推心跳或通知连接一直不断的荒谬情况。当然实际实现时我们没法实时感知Session过期因为是惰性检查所以需要配合定时任务或者拦截器来主动触发检查。这部分在后续篇章会展开。3. WebSocket握手阶段能拿到哪些身份信息3.1 握手请求里的Session与PrincipalWebSocket握手本质上是一个带Upgrade: websocket头的HTTP GET请求。既然是HTTP请求它就会经过Shiro的过滤器链前提是你的过滤器链配置覆盖了这个路径。也就是说在握手阶段我们可以通过SecurityUtils.getSubject()拿到当前登录用户。在Spring的HandshakeInterceptor里beforeHandshake方法能拿到ServerHttpRequest和ServerHttpResponse。通过request.getPrincipal()或者直接调SecurityUtils.getSubject().getPrincipal()就能把用户标识取出来塞进WebSocketSession的attributes里供后续消息处理时使用。Override public boolean beforeHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, MapString, Object attributes) { Subject subject SecurityUtils.getSubject(); if (subject null || !subject.isAuthenticated()) { return false; // 拒绝握手 } attributes.put(userId, subject.getPrincipal()); attributes.put(sessionId, subject.getSession().getId()); return true; }这段代码的价值在于把HTTP会话的身份快照到WebSocket会话里。后续WebSocket收到消息时不用再去查Shiro直接用attributes里的userId即可。但要注意这个快照是静态的Session过期后它不会自动失效所以还需要额外的过期检查机制。3.2 握手被拒绝时客户端会看到什么如果beforeHandshake返回false握手会失败客户端收到的是HTTP 403或者连接直接关闭。不同浏览器的表现略有差异有的会在控制台报WebSocket connection failed有的会触发onerror回调。这里有个实操经验不要指望客户端能优雅地区分握手被拒和网络断开。所以如果是因为未登录导致握手失败最好在握手前就让前端知道。常见做法是前端在建立WebSocket之前先发一个轻量的HTTP请求检查登录状态确认有效再连。这样用户体验更可控。3.3 把sessionId存进WebSocketSession的用途把sessionId存下来是为了后续做Session过期联动关闭。我们可以维护一个映射sessionId - SetWebSocketSession。当某个Session过期时通过这个映射找到所有关联的WebSocket连接逐个关闭。这个映射的维护要注意线程安全因为WebSocket的握手、消息处理、关闭可能发生在不同线程。用ConcurrentHashMap加ConcurrentHashMap.newKeySet()是比较稳妥的选择。4. 整体方案设计三层拦截与一个定时器4.1 方案全景我把整个方案拆成三个拦截点加一个兜底定时器层级触发时机作用握手拦截WebSocket连接建立时校验登录状态拒绝未登录握手记录sessionId映射消息拦截收到WebSocket消息时检查Session是否过期过期则关闭连接关闭回调连接关闭时清理sessionId映射避免内存泄漏定时扫描固定间隔主动检查所有活跃连接对应的Session过期则关闭三层拦截覆盖了连接建立、数据传输、连接结束的完整生命周期定时器则解决了用户不操作也不发消息的静默过期场景。4.2 为什么需要定时器兜底前面说过Shiro的Session过期是惰性检查。如果用户登录后连上WebSocket然后既不操作页面也不发消息那么握手拦截不会触发连接已经建立了消息拦截不会触发没有消息关闭回调不会触发连接还开着结果就是Session在DAO里假活WebSocket也一直挂着。定时器的作用就是每隔一段时间比如1分钟遍历活跃连接主动调用sessionDAO.readSession(sessionId)并检查isTimedOut()把过期的连接关掉。定时器的间隔怎么定太短浪费资源太长则超时后连接还会多存活一段时间。我的经验值是30秒到1分钟。考虑到Session超时通常是30分钟级别1分钟的延迟完全可以接受。4.3 关闭连接时要不要通知客户端要。直接session.close()客户端只会看到连接断开不知道原因。更好的做法是先发一条自定义的关闭消息比如{type: SESSION_EXPIRED, message: 登录已超时请重新登录}客户端收到这条消息后主动跳转登录页同时关闭连接。这样用户体验是被友好地请出去而不是莫名其妙断线。需要注意的是WebSocket协议本身有Close帧可以携带状态码和原因。但状态码是有限枚举自定义原因字符串长度也有限制。所以用业务消息先行通知再关闭连接是更灵活的做法。4.4 并发关闭的幂等处理定时器扫描和消息拦截可能同时发现同一个连接过期都去调close()。虽然WebSocketSession.close()本身对已关闭的连接是幂等的不会抛异常但发通知消息可能会重复。所以最好给每个连接加一个AtomicBoolean标记确保通知关闭这个组合动作只执行一次。private final AtomicBoolean closing new AtomicBoolean(false); public void closeWithReason(WebSocketSession session, String reason) { if (closing.compareAndSet(false, true)) { // 发送通知消息 session.sendMessage(new TextMessage(buildExpireMessage(reason))); session.close(CloseStatus.NORMAL); } }5. 几个容易踩坑的细节5.1 Shiro过滤器链要放行WebSocket路径吗要但要注意放行的含义。WebSocket握手路径比如/ws/**需要在Shiro的filterChainDefinition里配置成anon吗不一定。如果你希望握手时校验登录那就不应该配anon而应该配authc或者自定义过滤器。这样未登录的握手会被Shiro直接拦截连beforeHandshake都进不去。但这样做的副作用是Shiro拦截后返回的是HTTP重定向或401WebSocket客户端可能无法正确处理。我的做法是Shiro层面配anon放行把登录校验逻辑放到beforeHandshake里自己做。这样握手失败时返回的是干净的握手拒绝客户端处理起来更明确。当然这要求beforeHandshake里必须严格校验不能漏。5.2 SessionDAO的readSession可能返回null定时器里调sessionDAO.readSession(sessionId)时如果Session已经被其他途径清理掉了会返回null。这时候不能直接NPE而应该视为Session已失效同样关闭连接。Session session sessionDAO.readSession(sessionId); if (session null || session.isTimedOut()) { closeWithReason(wsSession, SESSION_EXPIRED); }5.3 集群环境下sessionId映射不共享如果应用部署了多个实例用户A的WebSocket连接可能落在实例1而他的HTTP请求可能落到实例2。这时候实例1内存里的sessionId - WebSocketSession映射实例2是看不到的。解决思路有两种一是用Redis等共享存储维护映射关系二是用消息广播让所有实例都知道Session过期事件。第一种更直接但要注意WebSocketSession本身不能序列化只能存哪个实例持有哪个sessionId这样的路由信息。这部分内容比较独立我打算在后续篇章单独展开。5.4 心跳消息不要用来续期Session有些实现会用WebSocket心跳ping/pong来保活Session即收到心跳就刷新Session的最后访问时间。这是个危险的设计。心跳是连接层的行为不应该等同于用户操作。如果心跳能续期那用户开着页面去睡觉Session永远不会过期登录超时机制就形同虚设了。正确的做法是只有真实的用户操作HTTP请求或明确的业务消息才续期Session心跳不续期。这样超时判定才符合业务预期。6. 本篇小结与后续规划把上面的内容串起来核心逻辑就是Shiro的Session过期是惰性的、HTTP层面的WebSocket是独立的长连接不会自动感知Session过期。我们要做的是在握手时快照身份、在消息和定时任务中主动检查Session状态、在过期时友好地通知并关闭连接。这一篇重点在方案设计和原理分析代码层面只给了关键片段。下一篇我会把完整的可运行代码搭出来包括Shiro的配置、WebSocket的Handler和Interceptor、定时任务的具体实现以及前端如何配合处理超时消息。再往后会讲集群环境下的映射共享和性能优化。如果你现在正在做类似的功能建议先把sessionId到连接的映射这个数据结构设计好它是整个方案的枢纽。映射设计得清晰后面的拦截和清理都会顺很多。我在实际项目里最初就是没重视这个映射结果连接关闭后没清理跑了一天内存就涨上去了这个坑希望大家别再踩。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑