行业资讯
Shiro Session管理实战:从核心原理到集群部署与强制下线实现
1. 从一次登录失效的排查说起为什么需要手动操作Session最近在排查一个线上问题时遇到了一个挺典型的场景用户反馈登录后偶尔会莫名其妙地掉线需要重新登录。排查日志发现用户的Session在某个时间点被主动清除了但业务代码里并没有显式调用logout或invalidate。这让我重新审视了项目中Shiro的Session管理机制。我们通常依赖Shiro的自动管理比如登录成功后自动创建Session设置超时时间过期后自动清理。但在一些复杂的业务流中比如强制用户下线、踢人、会话数据迁移或者像我们遇到的这种“幽灵”失效问题仅仅依靠框架的默认行为是不够的。这时我们就需要主动、精确地去“操作”Session。Shiro的Session API提供了一套比Servlet原生HttpSession更强大、更统一的操作接口。它抽象了底层细节让你无论是在Web环境还是非Web环境比如单元测试、后台任务都能用同一套方式管理用户会话状态。理解并熟练使用这些API意味着你能更好地控制应用的安全状态实现更精细化的用户会话治理而不是被动地处理各种因Session引发的诡异问题。接下来我就结合实战拆解Shiro Session管理的核心操作。2. 理解Shiro Session的核心模型与关键接口在动手写代码之前我们必须先搞清楚Shiro Session的“世界观”。它并不是对HttpSession的简单包装而是一套自顶向下设计的、独立的安全会话模型。2.1 Session会话数据的统一抽象接口org.apache.shiro.session.Session接口是Shiro会话管理的基石。你可以把它理解为一个键值对存储专门用来存放与当前交互用户相关的数据。它与HttpSession最大的不同在于环境无关性。// 获取当前Subject的Session Session session SecurityUtils.getSubject().getSession(); // 存储数据 session.setAttribute(currentProjectId, 12345); // 获取数据 Integer projectId (Integer) session.getAttribute(currentProjectId); // 移除数据 session.removeAttribute(currentProjectId);这里有一个关键细节getSession()方法有一个重载版本getSession(boolean create)。当create为false时如果当前没有Session它会返回null。这在某些只读检查的场景下非常有用可以避免无意中创建一个不必要的Session。例如在统计在线用户数时你只需要检查是否存在有效的Session而不应该为每个访问者都创建一个。2.2 SessionManager会话生命周期的总指挥SessionManager负责Session的创建、维护和销毁。在Web应用中最常用的是DefaultWebSessionManager。它的配置决定了Session行为的方方面面。在Spring Boot的application.yml中典型的配置如下shiro: sessionManager: # Session全局超时时间毫秒默认30分钟 globalSessionTimeout: 1800000 # 是否开启会话验证调度定期清理过期Session sessionValidationSchedulerEnabled: true # 会话验证调度器执行间隔毫秒默认1小时 sessionValidationInterval: 3600000 # 是否在会话过期后删除无效的Session ID Cookie deleteInvalidSessions: true # Session ID Cookie配置 sessionIdCookie: name: SHRIOSESSIONID httpOnly: true maxAge: -1 # 浏览器关闭即失效DefaultWebSessionManager的一个强大之处在于其会话验证机制。它内部有一个SessionValidationScheduler默认使用一个单线程的ExecutorService定期比如每小时一次扫描所有活跃的Session将那些lastAccessTime加上timeout已经早于当前时间的Session标记为过期并清理。这就是为什么即使你不做任何操作闲置用户也会自动掉线的原因。注意在生产环境中如果应用重启内存中的Session会全部丢失。DefaultWebSessionManager默认将Session存储在内存中。对于需要持久化或集群部署的场景你需要配置SessionDAO例如使用EnterpriseCacheSessionDAO将会话数据存储到Redis中。这时SessionManager和SessionDAO的协作就至关重要了。2.3 Subject与Session的绑定关系这是容易混淆的一点。我们通过SecurityUtils.getSubject().getSession()获取的Session是与当前Subject即当前交互主体通常是用户绑定的。在Web环境下Shiro会通过Cookie默认名JSESSIONIDShiro可配置或URL参数找到Session ID然后从SessionManager中获取对应的Session对象再将其与当前线程的Subject绑定。这意味着一个有效的Session可以没有经过认证即用户未登录。例如用户访问网站首页可能就已经创建了一个匿名Session用于存放购物车信息或验证码。只有当调用subject.login(token)成功后这个Session才会与一个经过认证的身份Principal关联起来。理解这一点对于后续实现“强制下线”等功能很重要——你操作的是SessionSession失效会导致绑定它的Subject也变为未认证状态。3. 实战Session的增删改查与生命周期控制掌握了基本概念我们进入实战环节。下面这些操作是管理Session的日常。3.1 创建与获取不仅仅是getSession()大多数情况下Session的创建是隐式的。当第一次调用subject.getSession()或subject.getSession(true)时如果当前没有SessionSessionManager就会创建一个。但有些场景需要显式控制。Subject currentUser SecurityUtils.getSubject(); // 方式1获取现有Session不存在则创建最常用 Session session currentUser.getSession(); // 方式2仅获取不存在则返回null用于检查 Session existingSession currentUser.getSession(false); if (existingSession null) { log.info(当前用户没有活跃会话可能是个新访客。); // 可以在此处初始化一个匿名会话用于跟踪 session currentUser.getSession(); session.setAttribute(visitTime, new Date()); }创建Session时一个重要的属性是timeout超时时间毫秒。你可以在创建后单独设置session.setTimeout(30 * 60 * 1000); // 设置为30分钟但更常见的做法是在SessionManager级别配置globalSessionTimeout进行统一管理。个人经验是除非有非常特殊的、针对单个会话的超时需求比如付费用户会话时间更长否则尽量使用全局配置保持一致性减少维护复杂度。3.2 数据存取Attribute操作的最佳实践向Session中存取数据看似简单但有些细节能帮你避免坑。// 存数据 session.setAttribute(key, value); // 取数据 Object value session.getAttribute(key); // 删数据 session.removeAttribute(key); // 获取所有Attribute的键 CollectionObject keys session.getAttributeKeys();实操心得一序列化问题如果你的应用是集群部署并且使用了Redis等外部存储作为SessionDAO的后端那么存入Session的所有对象必须实现java.io.Serializable接口。否则在序列化存储时会抛出异常。这是一个非常常见的部署陷阱。建议在项目早期就建立规范规定所有可能放入Session的DTO或值对象都必须实现Serializable。实操心得二键的命名规范Session是全局的、基于字符串键的存储容易发生键名冲突。特别是当你引入第三方库或框架它们也可能向Session中存放数据。一个良好的实践是使用反向域名格式作为键的前缀例如com.yourcompany.project.module.key。这样能最大程度避免冲突。实操心得三控制数据量Session数据通常存储在服务器内存或外部缓存中不宜存放过大或过多的数据。避免将整个用户对象、大数据列表或文件流直接存入Session。只存放最小必要的标识符和状态信息比如用户ID、角色列表、当前租户ID等。大对象可以考虑存入数据库或分布式缓存在Session中只保留其引用ID。3.3 失效与登出理解invalidate()和logout()的区别这是两个紧密相关但作用范围不同的操作。session.invalidate()使当前Session立即失效。调用后该Session对象将被标记为无效并从SessionManager的存储中移除。后续任何尝试通过该Session ID访问的操作都会失败。但是它不会执行Shiro的登出逻辑比如清理与Subject关联的认证信息Principal和授权信息Roles/Permissions。在单纯的Web上下文中由于Session失效下次请求会因为没有Session ID而创建一个新的匿名Session用户自然就“掉线”了。但这是一种比较“粗暴”的方式。subject.logout()这是Shiro提供的标准登出操作。它会做一系列清理工作调用session.invalidate()使会话失效。清除Subject中绑定的身份Principal和凭证Credential。清除线程上下文中与当前Subject关联的所有状态。触发登出事件监听器LogoutListener。// 场景用户主动点击退出按钮 RequestMapping(/logout) public String logout() { SecurityUtils.getSubject().logout(); // 重定向到登录页或首页 return redirect:/login; } // 场景管理员在后台强制让某个用户下线已知其Session ID public void forceLogout(String sessionId) { try { // 1. 通过SessionManager获取到具体的Session对象 Session session sessionManager.retrieveSession(sessionId); if (session ! null) { // 2. 停止该会话 session.stop(); // 3. 显式失效stop方法通常也会触发失效但显式调用更明确 session.invalidate(); log.info(已强制下线会话: {}, sessionId); } } catch (Exception e) { log.error(强制下线会话失败: {}, sessionId, e); } }重要提示session.stop()方法来源于Session接口继承的Stoppable接口。它主要用于释放Session可能占用的资源然后通常会调用invalidate()。在强制下线时先stop()再invalidate()是一个更完整的流程。直接调用invalidate()在大多数情况下也够用但遵循接口设计能更好地处理边缘情况。那么在什么情况下用哪个用户主动退出永远使用subject.logout()。这是最规范、最安全的方式确保了所有安全状态被正确清理。会话过期交给SessionManager的验证调度器自动处理它会调用Session的expire()或invalidate()方法。管理员强制踢人如果你能拿到对应用户的Subject优先用subject.logout()。如果只能拿到SessionId则通过SessionManager获取Session后调用invalidate()。在这种情况下由于无法直接访问目标Subject的线程上下文logout()的某些清理动作可能无法执行但使Session失效足以达到踢人目的。3.4 监听会话事件让管理更智能Shiro提供了SessionListener接口允许你在Session生命周期的关键节点插入自定义逻辑。这对于实现监控、审计或特定业务联动非常有用。你需要实现这个接口并注册到SessionManager中。Component public class CustomSessionListener implements SessionListener { Override public void onStart(Session session) { // 会话创建时触发例如用户首次访问 String sessionId (String) session.getId(); log.info(会话启动: ID{}, 开始时间{}, sessionId, session.getStartTimestamp()); // 可以在这里初始化一些会话级别的跟踪信息 } Override public void onStop(Session session) { // 会话被显式stop()时触发 log.info(会话停止: ID{}, session.getId()); } Override public void onExpiration(Session session) { // 会话过期时触发超时 String username (String) session.getAttribute(username); log.warn(会话过期: ID{}, 用户{}, session.getId(), username); // 可以在这里触发清理关联资源的任务比如释放用户占用的临时锁 } }然后在Shiro的配置类中将其注入Bean public DefaultWebSessionManager sessionManager(CustomSessionListener customSessionListener) { DefaultWebSessionManager sessionManager new DefaultWebSessionManager(); // 设置其他配置... // 设置监听器集合 CollectionSessionListener listeners new ArrayList(); listeners.add(customSessionListener); sessionManager.setSessionListeners(listeners); return sessionManager; }一个实用的场景精准的在线用户统计。单纯统计Session数量是不准确的因为包含未登录的匿名会话。我们可以在用户登录成功的逻辑里向他的Session中存入一个标识如session.setAttribute(loggedIn, true)。然后在onExpiration和onStop监听器中检查这个标识如果是已登录用户的会话失效就更新在线用户计数。这样得到的数据远比简单的Session计数有价值。4. 进阶场景基于SessionManager的精细化管控当我们不满足于对当前用户Session的操作而是需要从系统层面查看和管理所有活跃会话时就需要直接与SessionManager打交道了。4.1 检索与遍历所有活跃会话SessionManager具体是其底层的SessionDAO提供了获取活跃会话的方法。这在实现“在线用户列表”或“会话管理”后台功能时是核心API。Autowired private SessionManager sessionManager; /** * 获取所有活跃会话注意性能敏感操作谨慎使用 */ public CollectionSession getActiveSessions() { // 需要将SessionManager转型为具体的实现类以调用getActiveSessions方法 // 注意DefaultWebSessionManager本身没有这个方法方法在其父类AbstractNativeSessionManager中 // 更通用的方式是注入SessionDAO if (sessionManager instanceof AbstractNativeSessionManager) { // 这种方式依赖于Shiro内部API可能在不同版本间有变化 return ((AbstractNativeSessionManager) sessionManager).getActiveSessions(); } // 更推荐的方式直接注入并使用SessionDAO return Collections.emptyList(); } // 更佳实践直接操作SessionDAO Autowired(required false) // requiredfalse防止没有配置SessionDAO时启动失败 private SessionDAO sessionDAO; public ListMapString, Object getActiveSessionList() { ListMapString, Object sessionList new ArrayList(); if (sessionDAO ! null) { // 获取所有活跃会话的键通常是Session ID CollectionSession sessions sessionDAO.getActiveSessions(); for (Session session : sessions) { MapString, Object sessionInfo new HashMap(); sessionInfo.put(sessionId, session.getId()); sessionInfo.put(startTime, session.getStartTimestamp()); sessionInfo.put(lastAccessTime, session.getLastAccessTime()); sessionInfo.put(timeout, session.getTimeout()); sessionInfo.put(host, session.getHost()); // 用户IP // 获取登录用户信息如果已登录 String username (String) session.getAttribute(username); sessionInfo.put(username, username ! null ? username : 匿名访客); sessionList.add(sessionInfo); } } return sessionList; }警告getActiveSessions()操作在会话数量很大时比如上万可能会对性能尤其是内存和CPU造成显著影响因为它可能需要从存储中加载大量数据。绝对不要在频繁调用的接口如每次页面请求中使用此方法。它只适用于管理员偶尔查看的后台功能。对于大规模应用应考虑分页查询或使用专门的监控系统。4.2 实现强制下线踢人功能这是后台管理系统的常见需求。原理就是通过目标用户的Session ID找到对应的Session并使其失效。Service public class SessionManagementService { Autowired private SessionDAO sessionDAO; /** * 根据用户名强制下线假设用户名已存入Session的username属性 * param username 要下线的用户名 * return 被下线的会话数量 */ public int forceLogoutByUsername(String username) { int count 0; CollectionSession sessions sessionDAO.getActiveSessions(); for (Session session : sessions) { // 遍历所有会话找到对应用户的会话 String sessionUser (String) session.getAttribute(username); if (username.equals(sessionUser)) { try { // 使会话失效 session.stop(); sessionDAO.delete(session); count; log.info(已强制下线用户[{}]的会话: {}, username, session.getId()); } catch (Exception e) { log.error(下线用户[{}]会话失败: {}, username, session.getId(), e); } } } return count; } /** * 根据Session ID强制下线 * param sessionId 会话ID * return 是否成功 */ public boolean forceLogoutBySessionId(String sessionId) { try { Session session sessionDAO.readSession(sessionId); if (session ! null) { session.stop(); sessionDAO.delete(session); log.info(已强制下线会话: {}, sessionId); return true; } } catch (Exception e) { log.error(下线会话失败: {}, sessionId, e); } return false; } }关键点与避坑指南并发问题在遍历getActiveSessions()并执行删除操作时如果会话集合非常大操作期间可能有新的会话创建或旧的会话过期。ConcurrentModificationException是潜在风险。一种更稳健的做法是先收集需要删除的Session ID列表然后再遍历这个列表逐个删除。通知客户端调用session.invalidate()或delete()只会使服务器端的Session失效。用户客户端的浏览器仍然持有旧的Session ID Cookie在下次请求前他可能感知不到自己已被踢出。对于追求实时体验的应用可以在踢人后通过WebSocket或Server-Sent Events (SSE) 主动通知客户端“账号已在别处登录”引导其刷新页面或跳转到登录页。资源清理确保SessionListener.onExpiration或onStop中的逻辑能正确处理这种强制失效的情况及时清理与该会话关联的临时文件、数据库锁等资源。4.3 自定义Session ID生成与Cookie管理默认情况下Shiro使用JavaUuidSessionIdGenerator生成随机的UUID作为Session ID。Cookie则通过SimpleCookie来管理。有时我们需要定制它们。场景一定制Session ID生成器。比如出于安全审计要求需要在Session ID中嵌入部分可读信息注意不能包含敏感信息。public class CustomSessionIdGenerator implements SessionIdGenerator { Override public Serializable generateId(Session session) { // 生成一个前缀UUID的组合ID例如 WEB_3f19c83f-... String prefix WEB_; return prefix java.util.UUID.randomUUID().toString(); } } // 在配置中设置 sessionManager.setSessionIdGenerator(new CustomSessionIdGenerator());场景二精细化Cookie配置。应对安全扫描设置更严格的Cookie属性。Bean public DefaultWebSessionManager sessionManager() { DefaultWebSessionManager sessionManager new DefaultWebSessionManager(); // 禁用URL重写传递Session ID更安全 sessionManager.setSessionIdUrlRewritingEnabled(false); SimpleCookie sessionIdCookie new SimpleCookie(SHRIOSESSIONID); sessionIdCookie.setHttpOnly(true); // 防止XSS读取Cookie sessionIdCookie.setSecure(true); // 仅HTTPS传输生产环境务必开启 sessionIdCookie.setMaxAge(-1); // 浏览器会话结束时过期 sessionIdCookie.setPath(/); // Cookie路径 // 设置SameSite属性以防范CSRF (需要Shiro 1.6或通过Servlet容器配置) // sessionIdCookie.setSameSite(Lax); sessionManager.setSessionIdCookie(sessionIdCookie); sessionManager.setSessionIdCookieEnabled(true); return sessionManager; }安全提醒Secure和HttpOnly是保护Session Cookie的关键标志。Secure确保Cookie只通过加密的HTTPS连接传输防止中间人窃听。HttpOnly阻止JavaScript通过document.cookie访问此Cookie能有效缓解XSS攻击窃取会话的风险。在生产环境中这两项应该始终启用。5. 集群环境下的Session管理从内存到Redis单机应用的内存Session管理很简单但一旦涉及多实例部署集群就必须解决Session共享问题。否则用户请求被负载均衡到不同服务器会因找不到之前的Session而导致登录状态丢失。Shiro通过SessionDAO抽象了Session的持久化层。默认的MemorySessionDAO只存内存。我们需要将其替换为支持分布式存储的DAO最常用的是基于Redis的实现。5.1 集成Redis作为Session存储首先引入Shiro Redis集成的依赖以Spring Boot为例dependency groupIdorg.crazycake/groupId artifactIdshiro-redis/artifactId version3.3.1/version !-- 注意版本与Shiro、Spring Boot兼容性 -- /dependency然后进行配置Configuration public class ShiroConfig { Bean public RedisManager redisManager() { RedisManager redisManager new RedisManager(); redisManager.setHost(localhost:6379); // 可设置密码、超时、数据库索引等 // redisManager.setPassword(yourpassword); // redisManager.setTimeout(2000); // redisManager.setDatabase(0); return redisManager; } Bean public RedisSessionDAO redisSessionDAO(RedisManager redisManager) { RedisSessionDAO sessionDAO new RedisSessionDAO(); sessionDAO.setRedisManager(redisManager); // 设置Session在Redis中的key前缀 sessionDAO.setKeyPrefix(shiro:session:); // 设置Session过期时间毫秒这里设置为与全局超时一致 sessionDAO.setExpire(1800000); return sessionDAO; } Bean public DefaultWebSessionManager sessionManager(RedisSessionDAO redisSessionDAO) { DefaultWebSessionManager sessionManager new DefaultWebSessionManager(); // 禁用Servlet容器的Session管理 sessionManager.setSessionIdUrlRewritingEnabled(false); sessionManager.setSessionValidationSchedulerEnabled(true); sessionManager.setGlobalSessionTimeout(1800000); // 使用RedisSessionDAO sessionManager.setSessionDAO(redisSessionDAO); return sessionManager; } }5.2 集群环境下的注意事项序列化再次强调所有存入Session的Attribute对象必须实现Serializable。shiro-redis默认使用JdkSerializationRedisSerializer要求很严格。你也可以配置为Jackson2JsonRedisSerializer等但要注意类路径一致性问题。Session过期同步Redis本身支持键过期。shiro-redis会在创建Session时设置一个Redis TTL生存时间。Shiro的SessionValidationScheduler会话验证调度器在集群环境下仍然会运行但它主要是为了调用SessionDAO的delete方法清理已过期的Session实体并触发SessionListener.onExpiration事件。Redis的自动过期是另一道保障。建议将Shiro的sessionValidationInterval设置得比Session超时时间短一些例如Session超时30分钟验证间隔20分钟以确保监听器逻辑能被及时触发。强制下线功能的调整在集群中forceLogoutByUsername的实现需要遍历所有Redis中的Session。shiro-redis的getActiveSessions()方法默认会返回Redis中所有前缀匹配的Session。在大规模集群中这个操作可能非常慢且消耗资源。生产环境应考虑其他方案方案A推荐在用户登录时将其Session ID与用户ID的映射关系额外存储到一个Redis Hash或Set中。踢人时先从这个映射中快速找到对应用户的所有Session ID再进行精准删除。这需要维护额外的数据结构。方案B使用Redis的Keyspace通知Key Events。配置Redis在键Session过期或被删除时发布通知应用监听这些通知来执行资源清理等后续操作而不是主动去遍历查询。网络与性能Session的每次读写都变成了网络IO对Redis的延迟和可用性要求很高。确保Redis是高可用的主从、集群模式并且应用服务器与Redis之间的网络延迟要低。可以考虑使用连接池shiro-redis已支持和合理的超时设置。6. 排查与调试Session不听话时的工具箱即使理解了所有原理实战中Session依然可能表现出各种“诡异”行为。下面分享几个排查思路和工具。6.1 常见问题与排查路径问题一登录后Session丢失频繁要求重新登录。排查点1Cookie路径与域名。检查浏览器开发者工具中的Application - Cookies。确认Session Cookie的Domain和Path是否正确。例如如果你的应用部署在https://app.example.com但Cookie的Domain是.example.com这是正确的子域名共享。如果Path是/admin那么只有/admin下的请求会携带Cookie其他路径的请求就会丢失Session。排查点2Secure标志。如果你的网站使用了HTTPS但Cookie没有设置Securetrue有些浏览器如Chrome新版本可能会拒绝发送这个Cookie。排查点3跨域问题。如果前端https://ui.example.com和后端APIhttps://api.example.com域名不同属于跨域。默认情况下Cookie不会自动携带。需要在服务端设置Cookie时指定SameSiteNone; Secure并且前端请求需要设置withCredentials: true。同时服务端响应头需要包含Access-Control-Allow-Credentials: true和正确的Access-Control-Allow-Origin不能是*。排查点4Session超时时间过短。检查globalSessionTimeout配置确认是否设置得太小比如几分钟。排查点5集群环境Session未同步。确认请求是否被负载均衡到了不同实例以及Redis等共享存储是否工作正常。检查Redis中是否存在对应的Session键。问题二getActiveSessions()返回空或数量不对。排查点1SessionDAO是否正确注入。在非Web环境或特定配置下SessionDAO可能没有被正确设置到SessionManager中。打印sessionManager.getSessionDAO()的类名确认。排查点2Redis连接与序列化。对于Redis存储检查Redis连接是否正常以及存储的键前缀keyPrefix是否匹配。使用Redis客户端工具如redis-cli直接查看keys shiro:session:*看数据是否存在以及序列化格式是否正确。排查点3权限问题。getActiveSessions()方法可能需要特定的权限才能调用检查调用者是否有相应授权。6.2 利用监听器和日志进行调试给SessionDAO和SessionManager增加详细的日志级别输出是追踪Session生命周期的有效手段。在logback-spring.xml中增加配置logger nameorg.apache.shiro.session.mgt levelDEBUG/ logger nameorg.apache.shiro.session.dao levelDEBUG/ !-- 如果用了shiro-redis -- logger nameorg.crazycake.shiro levelDEBUG/这样你就能在日志中看到Session的创建doCreate、读取doReadSession、更新doUpdate、删除doDelete以及过期验证等详细过程。结合之前编写的CustomSessionListener在onStart、onStop、onExpiration方法中加入详细的日志输出可以清晰地描绘出一个会话从生到死的完整轨迹对于定位“幽灵”失效问题尤其有帮助。6.3 一个真实的踩坑案例StopedSessionException有一次线上报警大量用户出现StopedSessionException。这个异常表示程序试图访问一个已经被标记为停止stopped的Session。排查发现问题出在一个自定义的Filter中。这个Filter的逻辑是检查某个请求参数如果参数不合法就直接重定向到错误页。问题在于它在重定向前先调用了一次subject.getSession()来记录日志。而在某些异常处理流程中Shiro可能会先使当前Session失效stop然后才走到这个Filter。此时getSession()会尝试获取一个已停止的Session从而抛出异常。修复方案在Filter中将subject.getSession()改为subject.getSession(false)如果返回null则说明Session已无效不再进行记录操作。或者将日志记录的逻辑移到更靠前的、Session肯定有效的Filter中。这个坑的教训是在异常处理或重定向的逻辑分支中要谨慎操作Session优先使用getSession(false)进行空值判断避免对无效Session进行操作。操作Shiro Session从基本的存取删改到进阶的全局管理和集群部署每一步都需要对框架机制有清晰的理解。它不仅仅是调用几个API那么简单更关乎应用的状态一致性、安全性和用户体验。尤其是在微服务和分布式架构成为主流的今天将会话状态无状态化如采用JWT是另一个趋势但理解传统的、有状态的Session管理仍然是构建稳健后台系统的重要基石。
郑州网站建设
网页设计
企业官网