
1. 为什么2025年还在用Cookie与Session实现用户登录先说个场景。我最近接手一个老项目的改造SpringBoot单体应用用户量不大内部系统加对外开放的轻量级站点。需求很朴素用户能登录、登录状态别动不动就掉、退出登录后Session别残留。技术选型阶段团队里有人提议直接上JWT加Redis理由是新项目就该用新方案。我倒是先问了三个问题目前有没有Redis等中间件在跑前段是不是纯静态分离Token失效后用户被踢下线能不能接受问完这三点结论很清楚Cookie Session这套经典方案完全够用而且比JWT在某些场景下更省心。这不是什么技术倒退而是选型要匹配实际需求。SpringBoot对原生Servlet容器内的HttpSession做了完整支持只需要引入spring-boot-starter-web甚至不需要额外加Redis依赖就能跑通整套登录链路。开发一个内部管理系统、中小型Web站点、或者毕业设计级别的项目用Session做登录态保持代码量少、逻辑直观、调试方便出了问题直接在服务端查Session状态就行。这个方案能解决的问题也很明确用户输入用户名密码登录后服务端生成一个会话标识通过响应头Set-Cookie下发给浏览器浏览器后续每次请求自动带上这个Cookie服务端通过Session中的用户信息判断你是谁、你是否已登录。整个过程不涉及复杂的签名算法、不需要额外的基础设施、也不依赖前端配合做Token存储。对小白开发者来说这是理解HTTP无状态协议、会话管理、登录态保持的最佳切入点对有经验的开发者来说理清这套机制也有助于后面理解Spring Security、Shiro等安全框架的底层设计。真正的核心问题不只是怎么实现登录而是登录之后的状态是怎么跨请求保持的。这个机制没搞清楚哪怕代码抄对了一旦遇到Cookie丢失、Session失效、前后端联调对不上照样一脸懵。2. Cookie与Session的底层协作机制登录态跨请求保持的原理2.1 HTTP是无状态的但业务是有状态的HTTP协议本身是无状态的每个请求都是独立的。你在浏览器里输入账号密码点击登录请求到达服务器服务器校验通过返回一个登录成功的响应。但如果下一个请求不带任何额外的会话信息服务器根本不知道这个请求是从刚才那个登录用户那边发来的。为了解决这个问题就引入了会话跟踪技术。Cookie和Session是其中最经典的一对组合。简单来说Session存在服务器端Cookie存在客户端浏览器。Session里保存用户的登录状态和用户信息Cookie里保存Session的ID用于告诉服务器我是哪个会话的请求。用一个不太恰当但很好理解的类比Session是酒店房间里的住宿档案记录了住客的一切信息Cookie是房卡上面只写了房间号。你每次走到酒店前台发起请求递上房卡Cookie前台凭房号查出住宿档案Session就知道你是谁、住哪个房、住了几天。房卡丢了前台就无法定位你的档案你就算不上了。2.2 Cookie在请求中到底是怎么传递的很多初学者问Cookie是在请求头里吗。答案是Cookie主要通过请求头传递但它的源头在响应头。整个流程是这样的服务端在响应时通过Set-Cookie响应头把Cookie信息下发到浏览器。比如响应头Set-Cookie: SESSIONabc123; Path/; HttpOnly; Max-Age2592000浏览器收到这个响应头后会把Cookie存储在本地。只要Cookie未过期、未被人为清除、且满足Domain和Path匹配规则浏览器就会在后续对同域名发起的请求中自动带上它。请求头中携带Cookie格式类似Cookie: SESSIONabc123; USER_NAMEzhangsan需要注意的是浏览器对Cookie的处理遵循同源策略和路径匹配规则。Domain不匹配的域名不会带Path不符合的路径也不会带。比如Cookie的Path设置/admin那访问/user的请求就不会携带这个Cookie。在一个典型的登录场景中用户提交账号密码后服务端把用户ID存入Session然后通过Set-Cookie把SessionID下发。后续的所有请求浏览器自动带上这个SessionID服务端就能识别出当前请求属于哪个已登录用户。2.3 Session是怎么在服务端存活的Session本质上是一个存储在服务端的Map结构以SessionID为Key以会话数据为Value。在SpringBoot的默认配置下HttpSession存储在内存中也就是一个ConcurrentHashMap。默认实现类是StandardSessionManagerTomcat容器自带。Session的生命周期有几个关键节点创建时点第一次调用request.getSession(true)或request.getSession()时创建。数据保存Session中以属性Attribute为单位保存数据比如session.setAttribute(userId, 10001)。销毁时点调用session.invalidate()时立即销毁超过超时时间无请求时自动失效服务器重启时内存中的Session丢失。这就是内存Session的一个天然短板服务重启后所有用户被迫下线。所以稍微上规模的项目都会把Session往Redis里放用spring-session-data-redis做分布式会话管理。但对中小项目来说内存Session完全够用重启就重启用户重新登录一下而已。2.4 Cookie的属性和登录态保持的关系Cookie不是简单地存一个值就行它上面挂的每个属性都在影响登录态的保持Max-Age / Expires决定Cookie能在浏览器存多久。不设置的话就是临时Cookie浏览器关闭即失效。对记住我功能来说必须设置足够的存活时间。Path决定Cookie在哪些路径下会发送。一般设置为Path/让整个域名下的请求都能带上。Domain决定Cookie属于哪个域名。默认是当前域名设置为.example.com可以实现跨子域名共享。HttpOnly设置为true后JavaScript的document.cookie无法读取该Cookie能有效降低XSS攻击导致Cookie泄露的风险。Secure要求只在HTTPS连接下传输避免明文传输被窃取。SameSite控制第三方请求中是否携带Cookie。SameSiteLax是主流浏览器的默认行为在跨站场景下能有效防CSRF。说白了登录态保持不是单靠Session一个东西实现的而是Session的存活时间、Cookie的Max-Age、浏览器的清理策略三者共同作用的结果。任何一个环节不匹配都会导致为什么过一会儿就掉线这类问题。3. SpringBoot登录模块实操从项目搭建到完整代码实现3.1 项目结构与依赖引入新建一个SpringBoot项目我这边用的是SpringBoot 2.7版本JDK 8Maven构建。核心依赖只需要一个spring-boot-starter-web如果你要连数据库做用户查询再引入MySQL驱动、MyBatis或JPA。为了把你从完整的代码堆砌里解放出来这里用一个内存用户列表模拟用户表后面替换成数据库查询逻辑即可。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency注意千万不要加spring-boot-starter-security除非你想在它的登录表单基础上做二次开发。大多数人第一次写登录功能就是想看自己手写的流程跑通Security的过滤链会绕开你写的控制器逻辑很多新手在这上面卡了两三天最后把依赖删了才跑通。项目结构建议按模块分包不要全塞在Controller里controllerLoginController、UserControllerserviceUserServicemodelUser、LoginRequest、LoginResponseinterceptorLoginInterceptor登录拦截器configWebConfig注册拦截器3.2 登录接口的具体实现登录接口的核心任务校验用户名密码校验通过后创建Session并把用户信息放进Session。RestController RequestMapping(/api/user) public class LoginController { Resource private UserService userService; PostMapping(/login) public Result login(RequestBody LoginRequest loginRequest, HttpServletRequest request, HttpServletResponse response) { // 1. 参数校验 if (StringUtils.isBlank(loginRequest.getUsername()) || StringUtils.isBlank(loginRequest.getPassword())) { return Result.error(用户名和密码不能为空); } // 2. 用户认证 User user userService.authenticate(loginRequest.getUsername(), loginRequest.getPassword()); if (user null) { return Result.error(用户名或密码错误); } // 3. 判断是否勾选记住我 boolean rememberMe loginRequest.getRememberMe() ! null loginRequest.getRememberMe(); // 4. 创建或获取Session存放用户信息 HttpSession session request.getSession(true); session.setAttribute(Constants.SESSION_USER, user); session.setAttribute(Constants.SESSION_LOGIN_TIME, System.currentTimeMillis()); // 5. 如果勾选记住我延长会话的最大存活时间 if (rememberMe) { session.setMaxInactiveInterval(7 * 24 * 3600); // 7天 // 同时需要让JSESSIONID持久化见下文记住我部分 } // 6. 直接设置一个自定义Cookie做客户端标记可选 Cookie cookie new Cookie(Constants.COOKIE_USER_NAME, URLEncoder.encode(user.getUsername(), UTF-8)); cookie.setPath(/); cookie.setHttpOnly(true); cookie.setMaxAge(rememberMe ? 7 * 24 * 3600 : -1); response.addCookie(cookie); return Result.success(登录成功, userService.toView(user)); } }注意几个容易踩的细节request.getSession(true)的参数true表示如果没有Session则创建。如果用getSession(false)在未登录的情况下永远拿不到Session容易在后续逻辑里抛NPE。用户密码绝对不能明文存储。数据库里存的是BCrypt加密后的密文UserService里用BCryptPasswordEncoder.matches(明文, 密文)做比对。Session里只放用户ID、用户名、角色这类必要的认证信息不要把整个密码对象往Session里塞。Session ID的刷新和创建由容器管理不需要手动操作JSESSIONID。3.3 登录拦截器的完整实现与注册登录接口写完后需要对受保护的接口做拦截。最经典的实现是写一个HandlerInterceptor在preHandle中检查Session里有没有用户信息。public class LoginInterceptor implements HandlerInterceptor { private static final SetString EXCLUDE_URIS new HashSet(Arrays.asList( /api/user/login, /api/user/register, /api/captcha, /error )); Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 如果是OPTIONS预检请求直接放行跨域场景需要 if (OPTIONS.equalsIgnoreCase(request.getMethod())) { response.setStatus(HttpServletResponse.SC_OK); return true; } // 放行登录注册等开放接口 String requestURI request.getRequestURI(); if (EXCLUDE_URIS.contains(requestURI)) { return true; } HttpSession session request.getSession(false); User user session null ? null : (User) session.getAttribute(Constants.SESSION_USER); if (user null) { // 未登录返回401状态码或重定向到登录页 if (isAjaxRequest(request)) { response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\未登录或登录已过期\}); } else { response.sendRedirect(/login.html); } return false; } // 已登录放行并把用户信息传给Controller request.setAttribute(Constants.REQUEST_USER, user); return true; } private boolean isAjaxRequest(HttpServletRequest request) { String requestedWith request.getHeader(X-Requested-With); return XMLHttpRequest.equals(requestedWith); } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { // 可以在这里记录访问日志 } }拦截器注册的代码一定要写对否则拦截不生效Configuration public class WebConfig implements WebMvcConfigurer { Resource private LoginInterceptor loginInterceptor; Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(loginInterceptor) .addPathPatterns(/api/**) .excludePathPatterns(/api/user/login, /api/user/register, /api/captcha); } }这一步有个血泪教训excludePathPatterns里的路径必须和addPathPatterns的匹配规则完全对得上。比如拦截的是/api/**排除的是/user/login那这个排除就不生效——因为/user/login根本不在拦截范围内。新手很容易把路径写岔最后排查半天发现是路径前缀不一致。3.4 退出登录与Session销毁的正确姿势退出登录看似简单就一行代码但实际上有几件事要做PostMapping(/logout) public Result logout(HttpServletRequest request, HttpServletResponse response) { HttpSession session request.getSession(false); if (session ! null) { // 1. 清空Session中的用户信息 session.removeAttribute(Constants.SESSION_USER); // 2. 使Session失效服务端彻底销毁该会话 session.invalidate(); } // 3. 删除浏览器端的相关Cookie Cookie[] cookies request.getCookies(); if (cookies ! null) { for (Cookie cookie : cookies) { cookie.setMaxAge(0); cookie.setValue(null); cookie.setPath(/); response.addCookie(cookie); } } return Result.success(退出成功); }退出时不能只删Cookie不管Session也不能只销毁Session不清理Cookie。只删Cookie服务端的Session对象如果没超时还是会驻留在内存里大量用户反复登录退出会积累很多无效Session只销毁Session浏览器端的JSESSIONID还有残留但下次请求时服务端找不到对应Session会新建一个问题倒不大脏Cookie留在浏览器里始终不干净。如果有自定义的记住我Cookie上面for循环已经删了所有Cookie。但要注意删除Cookie时setPath(/)必须和当初设置时保持一致Path不一致的话删除操作是无效的。3.5 获取当前登录用户的接口登录后前端需要拿当前用户信息来渲染页面这个接口务必要从会话中获取而不是让前端传来一个用户ID那样任何人都能伪造GetMapping(/currentUser) public Result currentUser(HttpServletRequest request) { User user (User) request.getAttribute(Constants.REQUEST_USER); if (user null) { return Result.error(获取用户信息失败); } return Result.success(userService.toView(user)); }这里的request.getAttribute拿到的值就是在LoginInterceptor里放进去的。因为拦截器已经确保了这个接口必须有登录态所以这里不会出现user为null的情况但稳妥起见还是判断一下。4. 登录状态保持的进阶处理会话超时、记住我与并发控制4.1 Session超时时间到底在哪配SpringBoot中Session超时时间有三种配置方式优先级不同很容易搞混SpringBoot配置文件application.ymlserver: servlet: session: timeout: 30m # 默认30分钟单位支持秒s、分钟m、小时h也可以直接写数字表示秒这是最推荐的方式配置直观、全局生效。Servlet容器层面如果是SpringBoot内嵌的Tomcat可以在配置里加server.tomcat.*相关参数。但通常不需要SpringBoot的配置已足够。代码层面动态设置session.setMaxInactiveInterval(1800); // 单位秒这个值的优先级最高会覆盖前两种配置。因为它是针对某个具体Session实例的而不是全局配置。需要特别强调的是每次请求访问Session之后超时时间会顺延这是Servlet规范定义的行为。也就是说只要用户持续操作Session就不会过期用户超过设定时间没有请求Session才会被容器回收。理解这一点就不会遇到明明没关浏览器过一会儿就掉线的困惑了——那不是Bug是超时机制生效了。4.2 实现记住我功能的两层含义记住我在很多项目里其实是两个不同的需求第一层浏览器关闭后Session不丢。默认情况下JSESSIONID是临时CookieChrome关闭后它就被清掉了下次打开浏览器需要重新登录。这就是为什么很多站点功能明明没坏用户每次打开都要重新登录的原因。要让Session跨浏览器会话存活必须在创建Cookie时设置一个足够的Max-AgeSpringBoot默认使用容器管理的Session和Cookie如果你只是想让记住我生效推荐的姿势是使用Spring Boot 2.x的server.servlet.session.cookie.max-age配置或者干脆下发给前端的JSESSIONID由容器设置。这里有个细节SpringBoot内嵌Tomcat会在每次request.getSession()时从ServletContext中读取默认的会话Cookie配置再返回Set-Cookie因此全局配置能影响它。如果不用全局配置而是按用户勾选动态控制就得偏门一点。绝大部分项目直接采用全局max-age配置为7天、1个月或者Session超时时间本身配长一点。这比动态修改JSESSIONID简单可靠得多。第二层服务端Session尽量持久。即便JSESSIONID存住了如果服务端Session因为超时被销毁无状态后端照样只能让用户重新登录。所以记住我的本质是把session.setMaxInactiveInterval调大同时让JSESSIONID的Max-Age与之匹配。实操中要注意这两者的时间如果不匹配就会出现一种很尴尬的现场浏览器里Cookie还在7天但服务端Session已经没了两小时。用户点一下跳回登录页。排查时浏览器开发者工具里Cookie明明在很容易误判成服务端问题。4.3 并发登录控制同账号多端登录的取舍要不要做同账号多端同时登录取决于业务需要。内部管理系统一般允许同一账号多地登录方便用户同时用电脑和手机。但涉及金融、支付、内容审核类系统通常要求一个账号只允许一处登录新登录会顶掉旧登录。用Session做单端登录的实现思路服务端维护一个MapuserId, sessionId映射。每次登录成功把userId - 新sessionId写入map。在拦截器里检查当前请求的SessionID是否等于map中该userId对应的sessionId如果不等于说明被顶号了返回账号在其他设备登录的提示。public class SessionRegistry { private final ConcurrentHashMapInteger, String userSessionMap new ConcurrentHashMap(); public void register(Integer userId, String sessionId) { userSessionMap.put(userId, sessionId); } public boolean isActiveSession(Integer userId, String sessionId) { String current userSessionMap.get(userId); return sessionId.equals(current); } }顶号逻辑还有一点要注意被顶掉的那一侧最好能把旧Session直接invalidate掉防止它继续占用服务端内存。但旧Session此刻没法自己在服务端销毁因为顶号操作发生在别的请求中。所以更稳的做法是给每个用户的Session里保存loginTokenmap里存userId - loginToken每次拦截器校验的时候对比loginToken。被顶号后旧请求即使带着旧Session也会因为token不匹配而直接终止再配合定期清理内存可以被及时回收。4.4 多环境下的Session配置本地、测试、生产不同环境登录态的配置应当不同配置项本地开发测试环境生产环境Session超时30m30m2hCookie SameSiteLaxLaxLax或StrictCookie Securefalsefalsetrue强制HTTPS域名localhosttest.xxx.comwww.xxx.com建议把Sesssion相关配置抽到application-{env}.yml中用spring.profiles.active切换。生产环境务必把server.servlet.session.cookie.secure设为true这样JSESSIONID只会在HTTPS链路中传输。如果你的服务还没上HTTPS那Secure别急着开否则Cookie根本下发不了用户永远登不上。5. 踩坑实录Cookie丢失、Session失效、前端联调对不上5.1 坑一Chrome开发者工具Application里能看到Cookie但请求头里没有这是一个特别经典的问题。明明登录成功响应里也看到了Set-Cookie浏览器Application面板里也显示了这个Cookie但Network里发出的请求没有Cookie头。常见原因有两个Path不匹配。Cookie的Path被设置成了/api而后续请求的URL是/api/user/currentUser按说匹配但如果请求路径前缀不对就丢。看一下Cookie的Path设置和实际请求路径是否一致。SameSite导致跨站不携带。浏览器请求了另一个域名的资源比如前端在http://localhost:3000后端在http://localhost:8080这属于跨站不同站点Chrome的SameSite默认Lax不会在跨站请求中携带Cookie。如果你部署时前端和后端是不同域名比如前端app.example.com、后端api.example.com这算跨站。解决方案是把后端接口做反向代理让请求路径保持在同域内或者将Cookie的SameSite设为None但前提是同时设置Securetrue且必须HTTPS。5.2 坑二改了代码重启服务用户全掉线开发阶段无所谓生产环境如果用的内存Session发布服务就是一次全员踢下线。这是内存Session的固有缺陷只有引入spring-session-data-redis、spring-session-jdbc这类外部会话存储方案才能解决。项目中引入Spring Session非常轻量dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.session/groupId artifactIdspring-session-data-redis/artifactId /dependency加完依赖后SpringBoot自动配置会让HttpSession存放在Redis中。业务代码完全不用改session.setAttribute、session.getAttribute照常使用。注意Redis本身的持久化策略也要处理好Redis重启不掉数据才算是真正解决了重启即掉线的问题。5.3 坑三后端一切正常前端却拿到了401拦截器里用了response.setStatus(401)但SpringBoot对/error路径有一个默认的BasicErrorController它可能会把你的401响应给拦截并重写最终前端拿到的可能是200加HTML错误页。解决方案一在过滤链处理。解决方案二在拦截器里用response.getWriter().write(JSON)并flush缓存然后return false。实测下来如果接口是前后端分离建议返回200再加业务状态码比如{code:401}前端只要判断code就可以省去和HTTP状态码较劲的过程response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\msg\:\未登录\}); response.getWriter().flush();这种方式在Ajax请求中非常稳。如果是页面跳转形式直接sendRedirect到登录页就行别混用两套逻辑否则容易有漏网之鱼。5.4 坑四跨域配置与Session连不上前端用Vue后端SpringBoot做了CORS跨域配置前端发请求带withCredentials: trueaxios的withCredentialsfetch的credentials: include结果Session还是对不上。关键点在于CORS跨域携带Cookie有两个硬性要求服务端Access-Control-Allow-Origin不能是*必须是具体的前端域名。服务端必须显式设置Access-Control-Allow-Credentials: true。Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(http://localhost:3000) .allowedMethods(GET, POST, PUT, DELETE, OPTIONS) .allowedHeaders(*) .allowCredentials(true) .maxAge(3600); } }这里有个坑SpringBoot的allowedOriginPatterns和allowCredentials(true)同时使用才有效如果用了allowedOrigins(*)加allowCredentials(true)直接报错。另外同一个Cookie在浏览器里是有域名归属的跨域名访问时即使服务端返回了Set-Cookie某些浏览器也可能直接拒绝写入或者后续请求带不上。5.5 坑五页面刷新后登录态丢失这个坑的根源往往不是后端而是前端把登录态信息存错了地方。新手常见做法是把用户信息存在sessionStorage里关掉标签页就没——这不是后端会话的问题纯粹是前端存储位置的问题。判断登录态是否有效的唯一标准应该是带着Cookie去请求/api/user/currentUser返回200说明登录态有效而不是看前端本地有没有存userInfo。建议前端在页面刷新后先调一次这个接口再初始化路由和页面而不是从存储里赌一把。这也是早期前后端分离项目最常见的假掉线问题来源。6. 安全与性能加固登录模块上线前必做的几件事6.1 防会话固定攻击Session Fixation用户登录前服务端可能已经下发了一个匿名SessionID比如在访问登录页时。如果攻击者诱导用户用攻击者已知的SessionID去登录登录成功后服务端没有生成新的SessionID那么这个会话就被攻击者劫持了。防御方式很简单登录成功后服务端应该让旧的Session失效创建新的Session。Spring Security默认就做了这件事如果你纯手写登录千万别漏// 登录成功后的安全处理 HttpSession oldSession request.getSession(false); if (oldSession ! null) { oldSession.invalidate(); } HttpSession newSession request.getSession(true); newSession.setAttribute(Constants.SESSION_USER, user);注意顺序先失效旧Session再创建新Session最后把用户数据放进新Session。这样才能确保浏览器拿到的是全新的SessionID。6.2 Cookie的HttpOnly与Secure必须配置如果你不显式设置Cookie属性SpringBoot内嵌Tomcat默认创建的JSESSIONID是HttpOnly的这是个好消息。但你自己创建的业务Cookie比如存放用户名的那个必须手动加HttpOnly。cookie.setHttpOnly(true);HttpOnly的作用是禁止JavaScript读取该Cookie。如果没有它站点一旦存在XSS漏洞攻击者可以通过document.cookie直接把SessionID偷走。这是Web安全中最常见的一条攻击路径代价极小收益极大上线前务必检查每个Cookie。6.3 验证码与登录频率限制Session的实际使用中验证码、防暴力破解也依赖Session。图形验证码/短信验证码存Session时要注意// 生成验证码后存入Session Integer randomCode generateCode(); session.setAttribute(captchaCode, randomCode); session.setAttribute(captchaTime, System.currentTimeMillis()); // 校验时 Object code session.getAttribute(captchaCode); if (code null || !code.toString().equalsIgnoreCase(inputCode)) { return Result.error(验证码错误); } // 校验通过后立即清除防止复用 session.removeAttribute(captchaCode);验证码存Session要比存Redis简单得多但有两个关键验证码设置超时时间比如2分钟校验通过后用移除以防止复用。千万别只存邮箱/手机号验证码不设过期那可是安全隐患。登录接口一定要做频率限制。最简单的做法是记录请求IP加用户名的失败次数连续失败5次锁定一小时。这个计数器可以放在Session里或某个本地缓存中。暴力破解的成本就这么被拦住了。6.4 Session的性能与内存管理内存Session看似简单但高并发下容易产生两个问题Session泄漏用户退出时没有调用invalidate()Session对象一直驻留在内存里。大量用户访问过系统但从不退出内存逐渐被填满。Session数据放大往Session里塞大对象比如把整个用户列表放进去导致内存膨胀。实操建议登录成功时只存必要字段比如userID、用户名、角色列表不要塞整个Entity。定时监控Session数量Tomcat自带的管理面板或JVM监控里都能看到。如果你用的是内嵌Tomcat默认最大Session数量没有明确上限完全受堆内存约束。一旦发现OOM优先排查是不是Session没清理。登录功能在SpringBoot里写得顺手后你会发现Spring Security的很多设计理念其实和这套手写流程一脉相承——只不过它会强制你遵循安全最佳实践。从小项目起步时手写这套登录逻辑会让人对Session机制印象非常深后面再看任何权限框架都会顺畅很多。