ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Session Hook实战:从会话生命周期管理到在线用户强制下线

Session Hook实战:从会话生命周期管理到在线用户强制下线

做权限类项目的时候,我接了一个听着很简单的需求:后台要能看到当前在线用户列表,要能查某个账号是不是同时在好几个终端登录,最后还要有一键踢人的按钮。当时我的第一反应是,把登录用户ID往Redis一存,退出或者超时的时候删掉,这不就完了吗?真做起来才发现,问题全卡在“退出或者超时的时候”这几个字上——你根本不知道用户的Session是几点创建的、几点销毁的、登录态是几点写进去的。要感知这些节点,就得在Servlet容器和Spring Boot之间搭一根“钩子”,这就是Session Hook技术要解决的事。

这篇文章我把做Session Hook的完整思路、核心代码、选型逻辑和踩坑记录都拆开写一遍。适合正在做登录鉴权、在线用户统计、重复登录控制、会话审计这类功能的后端同学参考。老实说,这技术本身不复杂,但它把“会话生命周期”这个平时完全黑盒的部分变成了可编程的事件流,很多运维和审计需求都依赖它。

1. Session Hook到底钩的是什么

1.1 先澄清一个容易混淆的理解

Session Hook并不是Spring Boot里的某个独立注解或者专用类,而是一组基于Servlet规范和Spring事件机制的组合玩法。它的目标是同一件事:在Session创建、销毁、属性变更、ID变化这些生命周期节点上,插入我们自己的逻辑。说白了,Session本身就是一个存放在服务端的KV容器,但谁什么时候往里放了值、什么时候整个容器被清空,业务代码如果不主动去记录,是完全无感知的。Hook就是用来补上这个感知能力的。

一个很直观的类比是快递驿站里的门口感应器。Session就是那个货架,你往里放包裹(存数据),往外取包裹(取数据),驿站关门(Session销毁)。感应器不会关心你放的包裹是什么,但它能在你进门和出门的时候响一声,甚至能统计今天多少人进出。Session Hook就是那个感应器,它不掺和业务数据,但能在关键节点上通知你。

Servlet规范为此提供了三个核心接口:监听Session整个生命周期的HttpSessionListener、监听Session中属性增删改的HttpSessionAttributeListener、以及监听Session ID变化的HttpSessionIdListener。Spring Boot里注册它们的方式是@WebListener加上@ServletComponentScan,或者用ServletListenerRegistrationBean手动注册。这就是最底层的Hook,任何上层业务都是在这个基础上叠加的。

1.2 没有Hook时,这些功能为什么做不出来

做一个后台登录功能只需要再简单不过的代码:登录校验通过后,把用户ID塞进session,然后在需要的地方session.getAttribute("userId")取出来。但一旦涉及以下需求,这套写法就完全失灵了。

在线用户统计。你想知道现在有多少用户在线,这不能遍历所有session——Tomcat的StandardManager根本不提供业务层面的遍历接口。就算你用JMX或者反射硬拿,在多实例部署下也是错的。Hook可以解决,因为sessionCreated和sessionDestroyed这两个节点就是在线人数的增减点,在这里登记和注销即可。

登录日志审计。公司要求每次登录都要记录IP、浏览器信息、登录时间,退出还要记录在线时长。你可以在登录接口里写日志,但“在线时长”你根本算不出来,因为你不知道session真正销毁的时刻。只有sessionDestroyed回调能告诉你,这个session什么时候结束的。

重复登录强制下线。同一账号最多只能有一个会话,新登录顶掉旧会话。这个逻辑看似简单,但要做到“顶掉旧会话”且不是仅靠Redis覆盖标记,就需要能定位到旧的HttpSession对象并把它标记失效。SessionRegistry就是为此设计的。

这些需求不约而同地指向同一个结论:只靠请求处理链路里的代码是远远不够的,必须把逻辑挂在Session生命周期的事件上。这是Session Hook存在的根本原因。

1.3 一条主线:从容器事件到业务感知

Spring Boot应用里,Servlet容器(Tomcat内嵌)管理着HttpSession的真实生命周期。监听器收到的是容器事件,比如HttpSessionEvent。但容器事件是孤立的,它只知道session创建了、销毁了,并不知道这个session属于哪个用户、对应哪条业务记录。要把容器事件翻译成业务事件,就需要一个桥接过程。

桥接分两步。第一步,在监听器里从session的属性(或外部存储)读取当前登录用户信息,然后发布一个Spring的ApplicationEvent。第二步,在项目里写@EventListener方法消费这个事件,做业务处理。这样做的好处是业务模块完全不依赖Servlet API,测试也方便。我在项目里就是按这条主线做的:监听器只负责采集原始信号,真正的业务逻辑全部放在事件监听方法里。

2. 四种方案选型,别一上来就写代码

2.1 四种Hook的定位对比

实现Session Hook这件事,至少有四种主流方案。它们不是替代关系,而是层级关系,选哪一个完全看你要钩什么。

方案钩取位置侵入性典型场景
Listener监听器Session创建、销毁、属性增删改低,不碰代码逻辑在线统计、登录审计、状态同步
Filter过滤器每次请求进入时,可包装Session对象中,需要覆写Request包装类全链路日志、权限统一校验
Interceptor拦截器Handler执行前后,可拿到方法信息中,与业务入口强相关登录态校验、接口级Session检查
AOP切面任意标注了注解或匹配规则的方法较高,需要切点设计精细化控制,比如@SessionHook注解

Listener的优势在于它被容器保证触发时机准确,比如session真正销毁时一定会回调。劣势是它拿不到完整的请求上下文,sessionCreated里能取到HttpServletRequest,但sessionDestroyed里拿不到当前请求。Filter恰好相反,每次请求都会经过,但正因为每次都经过,它更适合做“每次请求都检查一次”的逻辑,而不是做“某个时间点只发生一次”的逻辑。Interceptor是Spring MVC层面的,适合做业务拦截,但Session的销毁事件它永远感知不到。AOP适合做方法级别的钩子,它能在你调用某个方法的前后附加逻辑,但无法钩住容器内部行为。

2.2 我的选型经验

我的实践结论是:做全局生命周期类功能,比如在线统计、会话超时清理、登录审计,首选Listener;做请求级别的增强,比如给Session操作加日志、统一校验,选Filter;做业务接口级别的控制,比如UNAUTHORIZED跳转,选Interceptor;如果团队喜欢注解风格,想用一行@SessionHook标注某个方法需要自动注入当前用户和会话信息,那就上AOP。

一个比较容易犯的错误是什么都往Interceptor里放。因为Interceptor能在请求进来时拿到session做校验,很多同学就自然地把在线用户统计也放在Interceptor里。但这样有一个致命问题:用户session超时或被服务器清理时不会有任何请求经过Interceptor,你永远等不到“清理离线用户”这个动作。所以凡是涉及“用户已经不在线”的事件,必须依赖Listener,不能依赖请求链路。

3. HttpSessionListener实战:在线用户统计、登录日志与强制下线

3.1 创建与销毁两个钩子的基础实现

先来一个最直接的例子:统计在线用户。在Spring Boot中注册Listener很灵活,我用@WebListener加@Component双注解,Spring Boot会自动识别并注册。

import javax.servlet.annotation.WebListener; import javax.servlet.http.HttpSession; import javax.servlet.http.HttpSessionEvent; import javax.servlet.http.HttpSessionListener; import org.springframework.stereotype.Component; @Component @WebListener public class SessionLifecycleListener implements HttpSessionListener { @Override public void sessionCreated(HttpSessionEvent se) { HttpSession session = se.getSession(); // 设置会话超时时间,单位秒,这里配的30分钟 session.setMaxInactiveInterval(30 * 60); System.out.println("[sessionHook] 新会话创建: " + session.getId()); } @Override public void sessionDestroyed(HttpSessionEvent se) { HttpSession session = se.getSession(); System.out.println("[sessionHook] 会话销毁: " + session.getId()); // 这里你getAttribute("LOGIN_USER_ID")大概率拿不到值 // 原因后面单独说 } }

这里有个知识点:sessionCreated回调里的session,如果要保证不同应用的超时策略一致,最好在监听器里统一调用setMaxInactiveInterval,而不是依赖web.xml或yml配置。这样做的好处是,如果有人改了配置文件导致超时时间漂移,监听器这里还有个底线兜住。sessionDestroyed是垃圾回收和主动登出都会触发的,它是清理在线用户记录的唯一可靠时机。

3.2 用AttributeListener补上“谁登录了”的关键拼图

在线统计如果只统计“有多少活跃session”,根本不算在线用户统计,顶多叫活跃会话统计。真正的需求是按用户维度统计,这就要知道每个session对应哪个用户。答案很自然:用户登录成功后,登录接口里执行session.setAttribute("LOGIN_USER_ID", userId),然后我们在Session的属性监听器里感知这个写入动作,把它登记为一次正式登录。

import javax.servlet.annotation.WebListener; import javax.servlet.http.HttpSessionAttributeListener; import javax.servlet.http.HttpSessionBindingEvent; import org.springframework.stereotype.Component; @Component @WebListener public class LoginAttributeListener implements HttpSessionAttributeListener { public static final String LOGIN_USER_KEY = "LOGIN_USER_ID"; @Override public void attributeAdded(HttpSessionBindingEvent event) { if (LOGIN_USER_KEY.equals(event.getName())) { String sessionId = event.getSession().getId(); Object userId = event.getValue(); // 在这里登记:当前sessionId对应的在线用户是userId System.out.println("[sessionHook] 用户登录: sessionId=" + sessionId + ", userId=" + userId); } } @Override public void attributeRemoved(HttpSessionBindingEvent event) { if (LOGIN_USER_KEY.equals(event.getName())) { System.out.println("[sessionHook] 用户登出: sessionId=" + event.getSession().getId()); } } @Override public void attributeReplaced(HttpSessionBindingEvent event) { if (LOGIN_USER_KEY.equals(event.getName())) { Object oldValue = event.getValue(); Object newValue = event.getSession().getAttribute(LOGIN_USER_KEY); System.out.println("[sessionHook] 用户会话被重新绑定: old=" + oldValue + ", new=" + newValue); } } }

attributeReplaced有一个非常值得注意的细节:HttpSessionBindingEvent.getValue()返回的是旧值。为什么?因为事件是在属性替换完成之前生成的,所以它携带的是旧值。如果你在业务里想拿到新值,只能再调一次getAttribute(LOGIN_USER_KEY)。这个细节我一开始没注意,结果在“用户被踢下线后重新登录”的场景里,拿到的userId始终是旧设备的用户ID,排查了半天。

3.3 强制下线功能是怎么挂上钩子的

踢人下线本质上是让目标session失效。最直接的手段是调用HttpSession.invalidate(),但你要从后台管理端拿到目标session的对象。在单体、单节点部署下,可以借助SessionRegistry来定位session并标记过期。Spring Security里有一个现成的SessionRegistry实现,先注册一个HttpSessionEventPublisher,把容器的session事件转发给Spring容器。

import org.springframework.boot.web.servlet.ServletListenerRegistrationBean; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.core.session.SessionRegistry; import org.springframework.security.core.session.SessionRegistryImpl; import org.springframework.security.web.session.HttpSessionEventPublisher; @Configuration public class SessionRegistryConfig { @Bean public SessionRegistry sessionRegistry() { return new SessionRegistryImpl(); } @Bean public ServletListenerRegistrationBean<HttpSessionEventPublisher> httpSessionEventPublisher() { return new ServletListenerRegistrationBean<>(new HttpSessionEventPublisher()); } }

有了SessionRegistry,踢人逻辑就很简单了:

import java.util.List; import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.session.SessionRegistry; import org.springframework.stereotype.Service; @Service public class SessionKickService { private final SessionRegistry sessionRegistry; public SessionKickService(SessionRegistry sessionRegistry) { this.sessionRegistry = sessionRegistry; } public void kickUser(String username) { List<SessionInformation> sessions = sessionRegistry.getAllSessions(username, false); for (SessionInformation info : sessions) { info.expireNow(); // 会触发session后续请求被拒绝,同时容器的session被标记失效 } } }

这里有个前提:你的项目必须通过Spring Security做登录,因为SessionRegistry的会话注册依赖Security的会话管理机制。如果你的权限系统是自己写Filter实现的,那建议走另一条路:在Redis里维护一个“已踢下线用户ID”的黑名单,然后在统一的Session校验Filter里,发现用户ID在黑名单就直接拒绝并销毁session。两种方案没有对错,取决于你的登录体系是否基于Spring Security。

4. 用Filter和Wrapper给Session做一次“全景日志”

4.1 为什么需要Filter级别的钩子

Listener能感知生命周期,但它感知不到“某次请求访问了session里的哪个数据”。有一次我排查两个会话之间数据串了的问题,非常希望有一个日志能记录“哪个request调用了getAttribute、setAttribute”。这种情况下Listener帮不了太多,就需要一个Filter级别的钩子:在请求到达时,用一个包装类把原始的HttpServletRequest包起来,然后覆写getSession(boolean)方法,返回值也是包装过的Session对象,这样所有对Session的调用都经过我们的代码。

先说一个注意点:Filter本身不会自动生效,需要在Spring Boot配置里注册,或者直接在Filter类上加@Component,再设置@Order。

import java.io.IOException; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequestWrapper; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import org.springframework.core.annotation.Order; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @Component @Order(1) public class SessionTraceFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { HttpServletRequestWrapper wrapper = new HttpServletRequestWrapper(request) { @Override public HttpSession getSession(boolean create) { HttpSession session = super.getSession(create); if (session != null) { String currentUser = (String) session.getAttribute("LOGIN_USER_ID"); System.out.println("[sessionHook] 请求访问Session, URI=" + request.getRequestURI() + ", user=" + currentUser + ", sessionId=" + session.getId()); } return session; } @Override public HttpSession getSession() { return this.getSession(true); } }; filterChain.doFilter(wrapper, response); } }

4.2 覆写getSession的精髓与注意事项

上面的代码里,getSession()和getSession(boolean)都覆写了,这是为了拦截所有获取session的入口。如果只覆写getSession(boolean),有部分代码直接调用不带参数的getSession()时就会绕过我们的日志。

这个包装器模式还有一个很实用的衍生用法:强制会话统一刷新过期时间、在写Session前附加操作、以及阻止某些接口误用session。但要注意,getSession(false)在会话不存在时必须返回null,千万不要在create参数为false时也去强制创建一个session,否则会造成大量空session产生。我见过线上被这个bug打爆过,OOM频繁发生,就是因为包装类里没判断create参数,导致每个请求都创建了新session。

另一个坑是:这个Filter适合做日志,不适合做业务判断。因为getSession()可能被同一个请求调用多次,你的打印也会跟着出现多次,如果还要在里面做在线状态变更,就会重复执行。所以Filter级别的Hook,我建议定位在“观测”而不是“修改”。

5. Spring Security场景下的Session Hook

5.1 并发登录控制的钩子配置

Spring Security天然把Session管理做成了安全策略的一部分。你可以让同一用户最多只允许一个有效会话,或者只允许指定数量,这是通过SessionManagement来配置的。

import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.core.session.SessionRegistry; @Configuration @EnableWebSecurity public class SecurityConfig { private final SessionRegistry sessionRegistry; public SecurityConfig(SessionRegistry sessionRegistry) { this.sessionRegistry = sessionRegistry; } @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.sessionManagement(session -> session .maximumSessions(1) .maxSessionsPreventsLogin(false) .expiredSessionStrategy(event -> { System.out.println("[sessionHook] 会话过期,通知客户端重新登录"); }) .sessionRegistry(sessionRegistry) ); return http.build(); } }

这里的maximumSessions(1)表示同一账号最多在线一个会话。maxSessionsPreventsLogin(false)表示当新登录发生时,旧的会话会被踢下线,而不是阻止新登录;如果设为true,则用户在新设备登录时会收到“该账号已在别处登录”的提示。这个选择没有标准答案,大多数产品选false,因为用户换设备时希望新设备能立刻登录。

5.2 登录后换SessionId防固定会话攻击

固定会话攻击是一个安全风险:攻击者先获取一个有效SessionId,然后诱导用户用它登录,登录成功后攻击者依然可以通过这个SessionId冒充用户。解决方案是在登录成功后更换SessionId,让攻击者握着的旧ID失效。Spring Security默认就开启了会话固定保护,但如果你在自定义登录接口里手动session.setAttribute,可能会绕过安全策略。一个稳妥的写法是在登录成功处理器里主动更换SessionId。

import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.AuthenticationSuccessHandler; public class SessionFixationSuccessHandler implements AuthenticationSuccessHandler { @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { // 强制更换SessionId,防止会话固定攻击 request.changeSessionId(); // 后续再写入用户信息到session request.getSession().setAttribute("LOGIN_USER_ID", authentication.getName()); response.sendRedirect("/"); } }

5.3 用SessionRegistry实现踢人下线

上文已经展示了SessionRegistry的用法。实际项目里,踢人下线还要考虑一个问题:SessionRegistry保存的会话信息是注册在内存里的,expireNow()只是把该session标记为过期,如果用户当前正在请求,请求结束前session可能仍然可用,但下一次请求会被Security拦截掉。如果需要立即彻底销毁,还要拿到原始的HttpSession对象并调用invalidate。

一个更偏业务的实现是把“被踢状态”同步到Redis,然后在下一次请求进来时,由Filter先判断该账号是否处于被踢状态,如果是就直接重定向到登录页。这种方案与Spring Security解耦,适用范围更广。

6. 分布式会话下,Hook要在Spring Session Redis里换一套打法

6.1 单机Listener到集群为什么会失效

单体应用部署时,所有Session都存活在同一个Tomcat容器里,Listener注册在哪个容器就监听哪个容器的Session,一切正常。但一旦部署多副本,比如两个实例,用户的Session可能落在实例A上,而第二次请求被负载均衡转发到了实例B,实例B发现本地没有这个Session,就会重新创建一个。如果不做Session共享,用户就掉线了。

解决Session共享的主流方案是Spring Session加Redis。所有Session数据集中存到Redis,任意实例都能从Redis读取同一个Session。但问题来了:Redis里Session的创建和过期,并不会自动触发我们之前在Tomcat里注册的HttpSessionListener回调。因为Session数据已经不属于某个具体容器的内存了,容器无法感知它的创建和销毁。此时要Hook“Session创建/销毁”事件,就得走Spring Session自己提供的事件机制。

6.2 用Spring事件监听Session生命周期

Spring Session为Redis实现了几个漂亮的事件对象:SessionCreatedEvent、SessionDeletedEvent、SessionExpiredEvent。要在Spring Boot中感知它们,直接写事件监听方法即可。

import org.springframework.context.event.EventListener; import org.springframework.session.events.SessionCreatedEvent; import org.springframework.session.events.SessionDeletedEvent; import org.springframework.session.events.SessionExpiredEvent; import org.springframework.stereotype.Component; @Component public class RedisSessionEventListener { @EventListener public void handleSessionCreated(SessionCreatedEvent event) { System.out.println("[sessionHook] Redis会话创建: " + event.getSessionId()); } @EventListener public void handleSessionDeleted(SessionDeletedEvent event) { System.out.println("[sessionHook] Redis会话删除: " + event.getSessionId()); } @EventListener public void handleSessionExpired(SessionExpiredEvent event) { System.out.println("[sessionHook] Redis会话过期: " + event.getSessionId()); } }

这套机制的原理是:Spring Session在往Redis写入或删除Session数据时,会同步发布对应的ApplicationEvent。所以只要是你的应用主动调用了sessionRepository.save()或deleteById(),事件一定会发。但“过期”事件比较特殊,它不是应用主动触发的,而是Redis通过Key过期通知发送给应用的。

6.3 Redis过期通知这个坑,部署前一定要确认

Spring Session Redis要正常感知Session过期,必须在Redis服务端配置过期事件通知。具体的配置项是notify-keyspace-events,至少要包含Ex。如果你的Redis是云厂商提供的,通常可以在控制台改参数;如果是自建Redis,在redis.conf里改:

notify-keyspace-events Ex

改完重启Redis或者执行CONFIG SET notify-keyspace-events Ex。如果没有这个配置,SessionExpiredEvent就永远不会触发。我记得第一次在测试环境模拟session超时,发现事件一直不来,最后查了很久才发现是这台自建Redis根本没开过期通知。另一个要提前预期的情况是:Redis的Key过期事件不是精确实时的,它是惰性策略,key过期后只有被再次访问,或到了后台周期性扫描才会真正清理并发送通知,所以事件可能有一丁点延迟。

还有一个与业务相关的坑:一旦Session数据放到了Redis,不管它是不是被成功消费,只要Redis里的Key过期,事件就会发出来。如果你的SessionExpiredEvent处理逻辑里依赖业务数据表,就要防止重复处理和幂等问题。我在项目里就遇到过重试机制把用户登出日志重复写了两遍。

7. 避坑手册:Session Hook实战常见问题速查

现象原因解决方案
监听器完全不生效没扫描到listener组件检查类上是否有@Component或@WebListener,确保启动类上有@ServletComponentScan
事件触发了两次同一个Listener被注册了两遍排查是不是同时加了@WebListener和手动ServletListenerRegistrationBean,二选一
sessionDestroyed里getAttribute拿不到值销毁回调阶段属性已被清理提前在AttributeListener里记录userId,或在销毁前主动保存
集群下Listener只在一台机器生效Session分布式后容器事件缺失改用Spring Session的事件监听
SessionExpiredEvent一直不触发Redis未开启过期通知设置notify-keyspace-events为Ex
登录后旧session还能用未正确调用changeSessionId登录成功处理器中调用changeSessionId
踢人下线后用户还是能访问接口只提交了expireNow,当前请求未结束在下一次请求拦截里再校验session状态并重定向

这里再补两个独家经验。第一,所有Listener的处理逻辑都要做成幂等的。Session销毁事件和Session过期的业务处理可能会被多次调用,一定要用Redis记录处理状态或者保证操作的可重入性。第二,Session Hook的代码不要太重。它就是挂一个旁路监控,如果Handler里要做网络请求或者复杂计算,会拖慢甚至阻塞容器线程,建议把事件发布到消息队列异步处理。

我在实际项目里最终沉淀了一套相对完整的流程:用AttributeListener标记登录和登出,用SessionLifecycleListener配合Redis记录在线状态,用Spring Session事件处理分布式下的会话过期,用SessionRegistry做并发控制和踢人。这套组合下来,在线用户模块的准确率很高,运维那边的反馈也比较满意。

最后再分享一个小技巧。调试Session Hook的时候,千万别依赖日志系统异步输出,先用同步的System.out.println配合打印sessionId,确认回调顺序。我之前遇到过日志组件本身做了异步批处理,导致事件回调顺序和日志输出顺序对不上,排错排了很久。Hook技术最忌讳的就是“回调时机不明确”,先保证回调触发顺序是对的,再优化日志策略。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进