☰
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与Principal

WebSocket握手本质上是一个带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, Map<String, 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 -> Set<WebSocketSession>。当某个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重定向或401,WebSocket客户端可能无法正确处理。

我的做法是: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到连接的映射"这个数据结构设计好,它是整个方案的枢纽。映射设计得清晰,后面的拦截和清理都会顺很多。我在实际项目里最初就是没重视这个映射,结果连接关闭后没清理,跑了一天内存就涨上去了,这个坑希望大家别再踩。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询