做后端的朋友大概都遇到过一个挺尴尬的阶段项目最早是个单体应用登录逻辑写在 Controller 里一段 Session 就搞定了后来业务拆分用户中心、订单、库存、报表各自成了一个服务前端页面也从一套变成小程序、H5、后台管理三套登录这件事突然就变成了一个需要专门设计的东西。用户在你的小程序里登录了切到后台管理系统还得再登一次后台管理员改了权限用户前端页面刷新十次还是老样子更难受的是某次线上发版一个服务的 Session 配置写错全站用户被踢下线客服电话被打爆。Springboot 统一登录这件事本质是把“这个人是谁、他能干什么、这个身份多久失效”三个问题的答案从每个服务的私有内存里抽出来变成一套所有服务都认的公共契约。它不是什么高深的技术但设计得糙和设计得细维护成本差着量级。下面这些内容是我在几个中后台系统和一套多端产品上反复折腾之后沉淀下来的东西涵盖了从方案选型、令牌设计、过滤器实现、跨服务透传到单点登出、防爆破、压测容量估算的完整链路代码是基于 Spring Boot 3.x Spring Cloud Gateway 的写法Spring Boot 2.x 稍微改改也能直接跑。适合已经能写 CRUD、正准备给自己项目补齐登录体系的中级开发也适合正在做架构评审、想看看别人怎么权衡的同学。1. 先把需求拆明白统一登录到底统一了什么1.1 三个真实场景逼出来的需求很多人一上来就写代码结果写到一半发现需求没想清楚推翻重来。我习惯先把场景列出来逐个对照方案能不能兜住。第一个场景是多端共享登录态。某内部管理平台PC 后台和移动端审批小程序共用一个账号体系。用户在 PC 上登录后手机上扫码授权两边应该共用同一个身份。这就要求登录状态不能存在某一个 Web 容器的 HttpSession 里因为两个端根本不在同一个容器里。第二个场景是服务间的身份识别。用户请求打到网关网关转发给订单服务订单服务需要知道“当前操作人是谁”才能做数据权限过滤。如果每个服务都自己解析一遍 Cookie那就等于把认证逻辑复制了 N 份改一次密钥要发 N 次版。所以身份必须以某种可传递、可验证、无状态的形式挂在请求上。第三个场景是权限变更的即时生效。运营在后台把某个用户的角色从“普通员工”改成了“只读”理论上下一秒他就应该失去写权限。如果令牌是无状态的、有效期七天那这七天里他还是能写。这就需要引入一层“令牌版本号”或者黑名单机制来做补偿。把这三个场景摆出来你会发现统一登录其实不是“一个登录接口”而是四件事认证Authentication、凭证分发Token Issue、凭证校验Verification、凭证吊销Revocation。缺一个系统就是半残的。1.2 三种主流方案的取舍方案选型这块市面上比较常见的是三条路我在不同项目里都用过各有各的适用边界。方案核心做法优点短板适用规模集中式 Session登录态存 Redis各服务共享 SessionId改造量最小会话可随时踢人每次请求都要读 Redis跨域/跨端处理麻烦服务数少、以 Web 端为主纯 JWT 无状态令牌自带全部信息服务本地验签零存储、扩展性好、天然跨端无法即时吊销令牌体积大服务多、读多写少令牌中心Opaque 校验令牌是个随机串每次回源认证中心校验吊销即时、可精细控制认证中心成瓶颈需要缓存兜底安全要求高、金融类我最终选的是折中路线短期的 JWT 作为访问令牌加上一个存在 Redis 里的刷新令牌和一份用户级令牌版本号。访问令牌有效期压到 30 分钟短到即使被盗用窗口也有限刷新令牌有效期 7 天支持滑动续期用户权限变更时把版本号 1校验时比对令牌里的版本号不一致就强制重新登录。这么设计的原因很实在日志系统、报表服务这类服务一天几百万次请求如果每次都回源认证中心认证中心扛不住必须让它们本地验签但权限变更又必须能生效所以用版本号做兜底把“即时吊销”的粒度从“每个令牌”放宽到“每个用户”成本降了一个数量级。绝大多数业务场景用户级别的粒度已经够用了。提示不要一上来就追求“每个令牌都能单独吊销”。真要做就得把每个 jti 存进 Redis内存开销和 GC 压力都会上去除非有强合规要求否则没必要。1.3 我最终选定的链路整条链路我画不出来了懒得画图直接文字描述清楚你照着捋一遍就明白用户提交账号密码 → 网关转发到auth-service的/auth/login→ 认证服务查库、比密码、查角色 → 签发 accessToken 和 refreshToken把 refreshToken 的哈希存 Rediskey 设计成auth:rt:{userId}:{deviceId}→ 返回给前端。前端把 accessToken 放在Authorization: Bearer xxx头里refreshToken 存在更安全的地方小程序存 storageWeb 端放 HttpOnly Cookie。后续请求进网关网关的全局过滤器先看路径是否在白名单里不白名单就取 token、验签、检查过期、检查版本号、检查黑名单全部通过后把解析出来的用户信息写进请求头比如X-User-Id、X-User-Roles再转发给下游。下游服务用一个轻量的拦截器把这些头读出来塞进 ThreadLocal业务代码直接从上下文取人。刷新流程单独一个接口/auth/refresh走网关白名单绕过但内部会校验 refreshToken 的哈希是否和 Redis 里一致一致就签发新的 accessToken同时把 refreshToken 的过期时间往后滑。登出就是删 Redis 里那条 refreshToken同时把当前 accessToken 的 jti 塞进黑名单黑名单的 TTL 设成 accessToken 剩余有效期过期自动清理不用写定时任务。这套链路的好处是职责清晰网关只管校验和透传认证服务只管签发和吊销业务服务只管用上下文。每一层都能单独替换比如哪天要把 JWT 换成别的格式只动网关和认证服务两边业务代码一行不改。2. 令牌设计统一登录的地基2.1 双令牌结构与字段定义令牌设计是整个系统里最需要想清楚的部分因为一旦上线改字段就意味着所有端都要跟着改。我的原则是字段只留必要的语义要能被未来的自己看懂。accessToken 的 payload 我一般放这几个字段{ sub: 10086, uname: zhangsan, roles: [order:read, report:view], ver: 7, jti: b1f3c2a4-8e6d-4a1b-9c7f-2d5e8a0b3c11, iat: 1735660800, exp: 1735662600 }sub是用户主键不要放手机号或者邮箱那些会变roles放权限码而不是角色名权限码更稳定角色名改名是常事ver就是前面说的令牌版本号用户表里加一个token_version字段权限一变就 1jti是令牌唯一 ID用于登出时精确拉黑。这里有个细节值得说roles不要放太多。我见过有人把一个用户的上百个权限点全塞进 JWT结果 token 长度飙到 4KB每个请求的 Header 都超限某些网关直接返回 431。正确的做法是JWT 里只放粗粒度角色或者少量高频权限码细粒度权限交给下游服务查缓存。判断标准很简单如果 payload base64 之后超过 1KB就该考虑精简了。refreshToken 我不做成 JWT就是一个 32 字节随机数的 Base64 编码纯粹的不透明串。原因是不透明串没有任何信息可以泄露而且天然支持“服务端说了算”吊销就是删 key干净利落。2.2 有效期怎么算出来的有效期不是拍脑袋定的我一般按三个约束来倒推。约束一被盗用的风险窗口。accessToken 一旦泄露攻击者在有效期内可以随便用。行业里比较通行的做法是把窗口压到 15 到 30 分钟。我选 30 分钟是因为后台管理类系统用户经常连续操作一两个小时设太短会导致刷新太频繁前端要处理并发刷新容易出问题。约束二刷新请求的放大倍数。假设日活 10 万人平均在线 8 小时accessToken 30 分钟一过期理论上一天需要刷新100000 × 8 × 2 160 万次。这个量对认证服务来说不算大但如果 refresh 接口没做缓存和限流发版瞬间会有大量请求一起打过来这就是典型的惊群效应。所以我在前端做了两个处理刷新请求加 5 分钟的提前量避免卡在过期瞬间同一时刻只允许一个刷新请求在飞其余请求排队等结果。约束三Redis 的存储成本。refreshToken 存 Rediskey 是auth:rt:{userId}:{deviceId}value 存的是 token 的 SHA-256 哈希不存明文防止 Redis 被拖库后直接伪造。单条记录大概 200 字节左右10 万日活、人均 2 台设备也就是 20 万条内存占用约 40MB完全可以接受。加上黑名单和其他杂项一个 1GB 的 Redis 实例能撑很久。综合下来我的参数是accessToken 30 分钟、refreshToken 7 天、滑动续期上限 30 天超过 30 天必须重新登录一次防止一个 refreshToken 被无限续下去。2.3 密钥管理与签名算法签名算法我用 HS256也就是 HMAC-SHA256密钥 32 字节随机生成。选 HS256 而不是 RS256理由是这套系统里签发方和校验方都是自己的服务不存在“第三方需要验签但拿不到私钥”的场景对称加密的性能还更好。密钥绝对不能硬编码在代码里也不能提交到仓库。我的做法是放在配置中心或者环境变量里启动时注入。更进一步密钥要支持轮换配置里同时保留current和previous两把密钥签发只用 current验签时两把都试previous 保留一个 accessToken 有效期的时长之后就删掉。这样换密钥的时候老令牌在有效期内依然能验过不会造成全站掉线。下面是我用的一段密钥加载逻辑Configuration public class JwtKeyConfig { Bean public Key currentKey(Value(${auth.jwt.secret}) String secret) { byte[] bytes secret.getBytes(StandardCharsets.UTF_8); if (bytes.length 32) { throw new IllegalStateException(JWT 密钥长度不足 32 字节拒绝启动); } return new SecretKeySpec(bytes, HmacSHA256); } Bean public Key previousKey(Value(${auth.jwt.previous-secret:}) String secret) { if (secret null || secret.isBlank()) { return null; } return new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HmacSHA256); } }启动时直接 fail-fast密钥太短就拒绝启动比上线后才发现问题强得多。之前有个项目密钥是123456被安全扫描直接标红返工的时候全组一起加班这种坑一次就够了。2.4 单点登出与令牌吊销登出这件事很多实现都是“前端删掉 token 就算登出”这在安全评估里是过不了的因为 token 还在有效期内抓包拿到了照样能用。我的做法分两层第一层删掉 Redis 里的 refreshToken 记录。这样用户就没法再续期了30 分钟后 accessToken 自然失效。第二层把当前 accessToken 的jti写进黑名单key 是auth:bl:{jti}value 是1TTL 设置成令牌的剩余有效期。网关校验的时候多查一次黑名单命中就返回 401。黑名单的查询频率很高每个请求都要查所以必须走 Redis 的本地缓存或者布隆过滤器兜底。我的做法是在网关侧加一个 Caffeine 本地缓存缓存时间 60 秒key 是 jti。这样绝大多数请求根本不碰 Redis只有缓存未命中的时候才远程查一次。代价是登出最多延迟 60 秒生效这个延迟在绝大多数场景下是可以接受的如果业务要求更严格把时间调短就行。还有个容易忽略的点踢人。管理员在后台把某个用户强制下线思路是把他所有设备的 refreshToken 全删掉同时把token_version1。因为校验时会比对 version所以即使 accessToken 还没过期下一个请求也会被判定失效立刻踢下线。这就是版本号机制的价值它把“按令牌踢人”简化成了“按用户踢人”。3. 代码落地认证中心的三件套3.1 工程结构与依赖清单我把认证中心单独拆成了一个模块auth-service不塞进业务服务里。这样做的好处是权限边界清晰认证服务挂了不影响已经登录的用户他们的 token 还有效业务服务挂了认证服务还能正常签发。依赖方面核心就是这几个dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency /dependencies引spring-boot-starter-security主要是为了用它提供的BCryptPasswordEncoder不用自己造轮子。如果不想引入整个安全框架单独引spring-security-crypto也可以体积小很多。3.2 登录接口与密码校验登录接口要处理的细节比想象中多账号不存在和密码错误要返回同样的提示防止账号枚举、失败次数要计数、登录成功要清计数、密码要比对哈希不能比明文。RestController RequestMapping(/auth) RequiredArgsConstructor public class AuthController { private final AuthService authService; PostMapping(/login) public ResultLoginVO login(RequestBody Valid LoginDTO dto, HttpServletRequest request) { String ip IpUtil.getRealIp(request); return Result.ok(authService.login(dto, ip)); } }服务层的核心逻辑值得展开说public LoginVO login(LoginDTO dto, String ip) { // 1. 失败次数校验超过 5 次锁定 15 分钟 String failKey auth:fail: dto.getUsername(); String failStr redis.opsForValue().get(failKey); int failCount failStr null ? 0 : Integer.parseInt(failStr); if (failCount 5) { throw new BizException(账号已临时锁定请 15 分钟后再试); } // 2. 查用户查不到也走一遍密码比对防止时序攻击 UserDO user userMapper.selectByUsername(dto.getUsername()); String rawHash user null ? $2a$10$invalidinvalidinvalidinvalidinvalidinvalidinvalidinva : user.getPassword(); boolean matched encoder.matches(dto.getPassword(), rawHash); if (user null || !matched) { redis.opsForValue().increment(failKey); redis.expire(failKey, Duration.ofMinutes(15)); throw new BizException(账号或密码错误); } if (user.getStatus() ! 1) { throw new BizException(账号已被禁用); } // 3. 登录成功清空失败计数 redis.delete(failKey); // 4. 签发令牌 return tokenService.issue(user, dto.getDeviceId()); }这里有三个细节是我踩过坑才加的第一个是账号不存在时也要执行一次encoder.matches。BCrypt 的比对是刻意慢的如果账号不存在直接返回响应时间大概是 1 毫秒账号存在的话要 100 毫秒左右。攻击者通过响应时间差就能判断出哪些账号是真实存在的。用一个假的哈希跑一遍时间就对得上了。那个假哈希必须是合法的 BCrypt 格式长度和真实的一致。第二个是失败计数用 Redis 而不是内存。多实例部署时内存计数各算各的5 次变成 5×N 次防爆破形同虚设。第三个是密码哈希的 cost 值。BCrypt 的默认 cost 是 10单次比对大概 50 到 100 毫秒。有人为了安全调到 12单次到了 250 毫秒以上登录接口的 QPS 直接掉到个位数。我的经验是 cost 10 足够如果对安全有更高要求不如加二次验证而不是把 cost 往死里加。3.3 全局过滤器与用户上下文网关侧的过滤器是整个链路的守门人。基于 Spring Cloud Gateway写一个GlobalFilterComponent RequiredArgsConstructor public class AuthGlobalFilter implements GlobalFilter, Ordered { private final TokenVerifier verifier; private final AuthWhiteList whiteList; private static final ListString HEADERS List.of( X-User-Id, X-User-Name, X-User-Roles, X-Token-Version); Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request exchange.getRequest(); String path request.getURI().getPath(); // 1. 白名单直接放行 if (whiteList.match(path) || HttpMethod.OPTIONS.equals(request.getMethod())) { return chain.filter(exchange); } // 2. 清理客户端伪造的身份头防止越权 ServerHttpRequest.Builder builder request.mutate(); HEADERS.forEach(h - builder.headers(hs - hs.remove(h))); // 3. 取令牌 String token resolveToken(request); if (token null) { return unauthorized(exchange, 未登录); } // 4. 校验 VerifyResult result verifier.verify(token); if (!result.isValid()) { return unauthorized(exchange, result.getMessage()); } // 5. 注入身份头 builder.header(X-User-Id, result.getUserId()) .header(X-User-Name, result.getUserName()) .header(X-User-Roles, String.join(,, result.getRoles())) .header(X-Token-Version, String.valueOf(result.getVersion())); return chain.filter(exchange.mutate().request(builder.build()).build()); } private String resolveToken(ServerHttpRequest request) { String auth request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION); if (auth ! null auth.startsWith(Bearer )) { return auth.substring(7).trim(); } return request.getQueryParams().getFirst(access_token); } Override public int getOrder() { return -100; } }第 2 步的头部清理极其重要这是很多人会漏掉的安全漏洞。网关会信任X-User-Id这个头如果不清理攻击者自己构造一个X-User-Id: 1发过来网关不清理直接透传下游服务就以为他是管理员。必须在入口处把所有身份相关的头全部删掉再根据令牌重新注入。下游服务的上下文拦截器public final class UserContext { private static final ThreadLocalLoginUser HOLDER new ThreadLocal(); public static void set(LoginUser user) { HOLDER.set(user); } public static LoginUser get() { return HOLDER.get(); } public static Long userId() { LoginUser u HOLDER.get(); return u null ? null : u.getUserId(); } public static void clear() { HOLDER.remove(); } }Component public class UserContextInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { String userId request.getHeader(X-User-Id); if (userId ! null !userId.isBlank()) { UserContext.set(LoginUser.builder() .userId(Long.parseLong(userId)) .userName(request.getHeader(X-User-Name)) .roles(splitRoles(request.getHeader(X-User-Roles))) .build()); } return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserContext.clear(); } }afterCompletion里的clear()是必须的。线程池复用线程如果不清理下一个请求可能会读到上一个请求的用户信息这就是典型的越权事故。而且这种 bug 极其难复现因为它取决于线程池的调度测试环境很难撞上。3.4 跨服务调用时的身份透传服务 A 调服务 B用户身份怎么带过去如果 A 调 B 是通过 Feign 或者 RestTemplate默认是不会带Authorization头的B 会判定为未登录。我的做法是加一个RequestInterceptor把当前请求的Authorization头塞进下游请求Component public class FeignAuthInterceptor implements RequestInterceptor { Override public void apply(RequestTemplate template) { ServletRequestAttributes attrs (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attrs null) { return; } String auth attrs.getRequest().getHeader(HttpHeaders.AUTHORIZATION); if (auth ! null) { template.header(HttpHeaders.AUTHORIZATION, auth); } // 同时透传 traceId方便全链路排查 String traceId MDC.get(traceId); if (traceId ! null) { template.header(X-Trace-Id, traceId); } } }但这里有个坑异步调用拿不到 RequestContextHolder。因为RequestContextHolder是基于 ThreadLocal 的线程一换就空了。解决办法是把RequestContextHolder的继承策略设为可继承PostConstruct public void init() { RequestContextHolder.setRequestAttributes( RequestContextHolder.currentRequestAttributes(), true); }不过更彻底的办法是在提交异步任务的时候手动捕获身份信息用TaskDecorator装饰线程池public class ContextCopyingDecorator implements TaskDecorator { Override public Runnable decorate(Runnable runnable) { LoginUser user UserContext.get(); MapString, String mdc MDC.getCopyOfContextMap(); return () - { try { if (user ! null) UserContext.set(user); if (mdc ! null) MDC.setContextMap(mdc); runnable.run(); } finally { UserContext.clear(); MDC.clear(); } }; } }这个装饰器套到所有自定义线程池上异步日志和身份透传就都解决了。之前有个报表导出功能异步线程里取 userId 永远是 null查了半天才定位到这里后来统一加了装饰器再没出过问题。4. 网关层与多端接入的细节处理4.1 白名单与放行规则白名单这块看似简单实际是最容易配错的地方。我的经验是白名单用精确匹配 少量前缀匹配严禁使用通配符模糊匹配。auth: white-list: exact: - /auth/login - /auth/refresh - /auth/captcha - /actuator/health prefix: - /public/ - /static/绝对不要写/**/login这种也不要给整个/auth/**开白因为/auth/logout、/auth/me这些接口是需要身份的一旦开白登出接口就变成了任何人不带令牌都能调虽然危害不大但属于典型的配置疏忽。还有几个必须放行的场景新手容易忘CORS 预检请求。浏览器的OPTIONS请求不会带Authorization头如果被拦截前端所有跨域请求全部失败控制台报“预检失败”但后端日志里什么都看不到因为请求根本没进业务代码。所以过滤器里要判断HttpMethod.OPTIONS直接放行。健康检查端点。负载均衡器会定期探活这些请求没有令牌不放行会导致实例被判定为不健康反复摘除又挂回。静态资源和 Swagger。开发环境需要生产环境关掉。另外白名单我建议做成配置驱动 启动校验启动时把所有白名单路径打印一遍顺便检查有没有明显不合理的配置比如同时存在/auth/login和/auth/**有冲突就直接告警。这个成本很低但能避免上线后才发现少放了一个接口。4.2 多端登录与互踢策略多端登录的策略取决于业务没有标准答案但必须明确。我把常见策略列一下策略效果实现方式适合场景完全共享任意端登录都共用同一个令牌只存一份 refreshToken内部工具追求方便按端隔离PC 和 App 可以同时在线互不影响key 里带 deviceType大多数 C 端产品同端互踢手机上再登一次之前那台被踢key 里带 deviceId会员制、需要控制并发单点登录新登录把老的全局踢掉token_version 1强合规场景我的实现里refreshToken 的 key 设计成auth:rt:{userId}:{deviceType}:{deviceId}deviceType由前端传web、app、minideviceId可以是设备指纹或者前端生成的 UUID。这样按端隔离和同端互踢可以灵活组合想按端隔离就把同一 deviceType 下的旧 key 全删想全局单点就把auth:rt:{userId}:*全删。用SCAN命令按前缀扫不要用KEYSKEYS在百万级 key 的实例上会阻塞几秒钟足以让整个系统抖动。更好的做法是维护一个auth:devices:{userId}的 Set记录这个用户所有活跃的 deviceId登出时直接遍历这个 Set 删O(N) 但 N 很小。4.3 限流、防爆破与异常响应登录接口是最容易被撞库的接口必须做限流。我的组合拳是三层第一层IP 维度限流。同一个 IP 每分钟最多 20 次登录请求超过就返回 429。网关层用 Redis Lua 做令牌桶实现简单几行脚本搞定。第二层账号维度计数。就是前面说的连续失败 5 次锁 15 分钟。注意是“连续失败”成功一次要清零否则正常用户偶尔手滑几次就被锁了。第三层全局限速。认证服务整体的 QPS 上限超过就排队或者降级。这一层主要是防止被大面积压测打挂。异常响应这块有个细节值得强调登录失败的响应体里不要带任何能区分“账号不存在”和“密码错误”的信息包括提示文案、错误码、响应时长。文案统一成账号或密码错误错误码统一成一个响应时间通过前面说的假哈希比对来对齐。还有一个容易忘的日志里绝对不能打印密码和完整令牌。我见过日志里直接log.info(login params: {}, dto)的DTO 的toString里带密码日志平台上一搜一大片明文密码。要么给密码字段加ToString.Exclude要么打印的时候手动脱敏。令牌也一样最多打印前 8 位加后 4 位中间打星号。5. 常见问题与排查实录5.1 问题速查表下面这些是我在实际项目里真实遇到过的整理成表方便你对照排查。现象可能原因排查动作解决方式前端报 401但后端没日志请求被网关拦了没进业务服务看网关访问日志检查白名单和令牌头格式携带正确令牌仍 401令牌头缺少Bearer前缀抓包看 Header 原文前端统一加前缀偶发 401刷新页面又好了accessToken 刚好过期并发刷新冲突看失败请求的时间分布前端加刷新锁 提前量登出后还能访问只删了 refreshToken没拉黑 accessToken用登出前的 token 重放补黑名单逻辑用户 A 看到用户 B 的数据ThreadLocal 没清理看拦截器有没有 finally 清理补 afterCompletion令牌突然全部失效密钥轮换时直接替换没保留旧密钥看密钥配置变更时间双密钥过渡一个周期网关转发后下游拿不到身份头头名带下划线被某些服务器丢弃抓包看下游收到的头头名改成中划线移动端登录后 PC 端被踢互踢策略配置成了全局看 deviceId 生成逻辑按端隔离或者明确策略刷新接口 QPS 飙高前端多请求并发刷新看刷新接口的时间分布前端单飞 服务端合并5.2 几个让我熬夜的坑坑一网关的过滤器顺序。Spring Cloud Gateway 里GlobalFilter的getOrder()返回值越小优先级越高。我一开始把认证过滤器设成0结果 CORS 过滤器order 是Integer.MIN_VALUE 1在它之后执行预检请求进不来前端全线飘红。后来改成-100问题解决。如果你也遇到CORS 配置了但不生效先看过滤器顺序。坑二服务重启后用户集体掉线。早期版本我把签名密钥写在application.yml里用的是启动时随机生成的值。结果每次发版重启密钥变了所有令牌验签失败。改成固定密钥放配置中心后正常。这个坑的教训是任何跟令牌相关的配置都必须是跨实例、跨重启稳定的。坑三时区问题导致令牌提前过期。有一次容器基础镜像的时区是 UTC而 JWT 的exp是按服务器时间算的两边一差 8 小时令牌签发出来就是已过期状态。排查的时候看 payload 里的exp时间戳换算成日期一看差了 8 小时立刻定位。解决办法是所有服务统一用 UTC 时间戳System.currentTimeMillis() / 1000不要用LocalDateTime转。坑四异步线程里拿不到用户。前面提过RequestContextHolder是 ThreadLocalAsync一换线程就空了。我在一个导出任务里排查了两个小时最后发现是线程池的问题。现在我的原则是任何跨线程的地方都显式传递用户信息绝不依赖隐式上下文。坑五Redis key 过期时间没设。黑名单的 key 忘了设 TTL跑了一个月 Redis 内存打满触发淘汰策略把正常的会话数据也淘汰了用户莫名其妙掉线。现在我的习惯是凡是用opsForValue().set()一律用带Duration参数的重载方法或者包装一个setWithTtl()工具方法从源头杜绝。经验给 Redis 的所有写入操作加一层封装方法签名强制要求传过期时间传 null 直接抛异常。这个约束会让团队里所有人都不敢再写无 TTL 的 key。5.3 上线前的压测与容量估算登录链路的压测容易被忽略因为平时流量不大。但有两种情况会瞬间打满一是早晨集中打卡几万人在 10 分钟内陆续登录二是营销活动开抢几万人同时刷新页面令牌批量过期刷新请求一起涌进来。我做容量估算的方式比较简单粗暴先测单实例的极限 QPS再乘以实例数留 40% 余量。单实例压测的数据4C8G 容器MySQL 8 Redis 同机房大概是这样的接口单实例 QPS瓶颈点/auth/login含 BCrypt 比对40~60CPUBCrypt 是主要开销/auth/refresh800~1200Redis 读写网关验签HS2568000基本不是瓶颈登录接口的 QPS 低是正常的因为 BCrypt 就是故意慢的。40 QPS 意味着单实例一分钟能处理 2400 次登录早晨高峰如果 5 万人登录10 分钟窗口需要50000 / 2400 ≈ 21个实例。这个数字看着吓人但你算一下峰值每秒的登录数其实只有 83两个实例就够剩下的靠队列削峰。我的做法是登录接口前置一个轻量队列超过阈值的请求快速失败并提示当前登录人数较多请稍后再试而不是让它慢慢排队把线程池占满。这样即使被恶意压测也只影响登录不影响已经登录用户的正常业务。5.4 后续可以扩展的方向这套方案落地之后还有几个可以继续往下做的地方我按投入产出比排个序。第一个是设备指纹与异常登录检测。记录每次登录的 IP、UA、地域如果发现某次登录和上一次差异巨大比如半小时前在南方现在在北方就触发二次验证。这个不需要多复杂的算法一条简单的规则就能拦掉大部分盗号。第二个是权限缓存分层。目前权限是从令牌里解出来的粒度较粗。如果业务需要细到按钮级可以在下游服务里做一层 Caffeine Redis 的二级缓存第一次查库后面走本地缓存用户权限变更时通过消息广播失效。这样既不牺牲性能又能保证最终一致。第三个是令牌的无感续期。现在还是前端主动调刷新接口可以改成网关在检测到令牌剩余有效期不足 5 分钟时在响应头里带一个新的令牌前端下次请求自动换上。这样用户完全感知不到过期这回事体验会好很多。代价是网关要能拿到密钥权限收口要更谨慎。这三个方向我都试过第一个成本最低见效最快第二个适合权限复杂的中后台第三个体验最好但改动面最大建议按顺序来别一口气全上。最后分享一个我个人的配置习惯所有跟认证相关的参数有效期、白名单、限流阈值、密钥版本全部外置到配置中心并且在启动日志里完整打印一遍生效值。这么做的原因很朴素——线上出问题时第一件事永远是确认当前生效的配置到底是什么而不是去猜。我见过太多次因为某个环境的配置文件没同步导致测试环境好好的、生产环境一登录就 401 的情况。把这个成本前置到启动阶段比事后翻日志强太多。
阅读完成 · 觉得有帮助?