news 2026/10/31 8:00:12

Spring Boot微服务统一登录:JWT双令牌与网关鉴权实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot微服务统一登录:JWT双令牌与网关鉴权实战

做后端的朋友大概都遇到过一个挺尴尬的阶段:项目最早是个单体应用,登录逻辑写在 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 的哈希存 Redis(key 设计成auth:rt:{userId}:{deviceId})→ 返回给前端。前端把 accessToken 放在Authorization: Bearer xxx头里,refreshToken 存在更安全的地方(小程序存 storage,Web 端放 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字段,权限一变就 +1;jti是令牌唯一 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 存 Redis,key 是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 是1,TTL 设置成令牌的剩余有效期。网关校验的时候多查一次黑名单,命中就返回 401。

黑名单的查询频率很高,每个请求都要查,所以必须走 Redis 的本地缓存或者布隆过滤器兜底。我的做法是在网关侧加一个 Caffeine 本地缓存,缓存时间 60 秒,key 是 jti。这样绝大多数请求根本不碰 Redis,只有缓存未命中的时候才远程查一次。代价是登出最多延迟 60 秒生效,这个延迟在绝大多数场景下是可以接受的,如果业务要求更严格,把时间调短就行。

还有个容易忽略的点:踢人。管理员在后台把某个用户强制下线,思路是把他所有设备的 refreshToken 全删掉,同时把token_version+1。因为校验时会比对 version,所以即使 accessToken 还没过期,下一个请求也会被判定失效,立刻踢下线。这就是版本号机制的价值,它把“按令牌踢人”简化成了“按用户踢人”。

3. 代码落地:认证中心的三件套

3.1 工程结构与依赖清单

我把认证中心单独拆成了一个模块auth-service,不塞进业务服务里。这样做的好处是权限边界清晰,认证服务挂了不影响已经登录的用户(他们的 token 还有效),业务服务挂了认证服务还能正常签发。

依赖方面,核心就是这几个:

<dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-impl</artifactId> <version>0.11.5</version> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-jackson</artifactId> <version>0.11.5</version> <scope>runtime</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 Result<LoginVO> 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,写一个GlobalFilter:

@Component @RequiredArgsConstructor public class AuthGlobalFilter implements GlobalFilter, Ordered { private final TokenVerifier verifier; private final AuthWhiteList whiteList; private static final List<String> HEADERS = List.of( "X-User-Id", "X-User-Name", "X-User-Roles", "X-Token-Version"); @Override public Mono<Void> 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 ThreadLocal<LoginUser> 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(); Map<String, 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、mini),deviceId可以是设备指纹或者前端生成的 UUID。这样"按端隔离"和"同端互踢"可以灵活组合:

  • 想按端隔离,就把同一 deviceType 下的旧 key 全删;
  • 想全局单点,就把auth:rt:{userId}:*全删。

用SCAN命令按前缀扫,不要用KEYS,KEYS在百万级 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是 ThreadLocal,@Async一换线程就空了。我在一个导出任务里排查了两个小时,最后发现是线程池的问题。现在我的原则是:任何跨线程的地方,都显式传递用户信息,绝不依赖隐式上下文。

坑五: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~60CPU,BCrypt 是主要开销
/auth/refresh800~1200Redis 读写
网关验签(HS256)8000+基本不是瓶颈

登录接口的 QPS 低是正常的,因为 BCrypt 就是故意慢的。40 QPS 意味着单实例一分钟能处理 2400 次登录,早晨高峰如果 5 万人登录,10 分钟窗口,需要50000 / 2400 ≈ 21个实例。这个数字看着吓人,但你算一下峰值每秒的登录数其实只有 83,两个实例就够,剩下的靠队列削峰。

我的做法是登录接口前置一个轻量队列,超过阈值的请求快速失败并提示"当前登录人数较多,请稍后再试",而不是让它慢慢排队把线程池占满。这样即使被恶意压测,也只影响登录,不影响已经登录用户的正常业务。

5.4 后续可以扩展的方向

这套方案落地之后,还有几个可以继续往下做的地方,我按投入产出比排个序。

第一个是设备指纹与异常登录检测。记录每次登录的 IP、UA、地域,如果发现某次登录和上一次差异巨大(比如半小时前在南方,现在在北方),就触发二次验证。这个不需要多复杂的算法,一条简单的规则就能拦掉大部分盗号。

第二个是权限缓存分层。目前权限是从令牌里解出来的,粒度较粗。如果业务需要细到按钮级,可以在下游服务里做一层 Caffeine + Redis 的二级缓存,第一次查库,后面走本地缓存,用户权限变更时通过消息广播失效。这样既不牺牲性能,又能保证最终一致。

第三个是令牌的无感续期。现在还是前端主动调刷新接口,可以改成网关在检测到令牌剩余有效期不足 5 分钟时,在响应头里带一个新的令牌,前端下次请求自动换上。这样用户完全感知不到过期这回事,体验会好很多。代价是网关要能拿到密钥,权限收口要更谨慎。

这三个方向我都试过,第一个成本最低见效最快,第二个适合权限复杂的中后台,第三个体验最好但改动面最大,建议按顺序来,别一口气全上。


最后分享一个我个人的配置习惯:所有跟认证相关的参数(有效期、白名单、限流阈值、密钥版本)全部外置到配置中心,并且在启动日志里完整打印一遍生效值。这么做的原因很朴素——线上出问题时,第一件事永远是确认"当前生效的配置到底是什么",而不是去猜。我见过太多次因为某个环境的配置文件没同步,导致测试环境好好的、生产环境一登录就 401 的情况。把这个成本前置到启动阶段,比事后翻日志强太多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/31 7:59:38

深度神经网络DNN:原理、训练调优与工程落地实践

1. 从线性模型的死胡同说起&#xff1a;DNN到底补上了哪块缺口 我见过太多人第一次接触深度神经网络&#xff08;DNN&#xff09;时的两种极端反应&#xff1a;一种是觉得它神秘得不行&#xff0c;仿佛只要堆几层全连接就能自动变聪明&#xff1b;另一种是觉得它就是"调包…

作者头像 李华
网站建设 2026/10/31 7:59:34

MAT 1.11.0 专版:精准解析 JDK8 HPROF 内存泄漏

简介&#xff1a;本资源为 Eclipse Memory Analyzer Tool&#xff08;MAT&#xff09;1.11.0 独立可执行版&#xff0c;专为 Java 开发者与性能调优工程师设计&#xff0c;用于深度分析 JDK8 环境下的堆内存 dump 文件&#xff0c;快速定位内存泄漏、对象膨胀及 GC Roots 引用链…

作者头像 李华
网站建设 2026/10/31 7:57:44

APK修改工具链配置指南:从反编译到重签名避坑

简介&#xff1a;这份APK修改工具资料面向Android应用开发者、逆向工程爱好者与希望进行个性化定制的进阶用户&#xff0c;系统梳理了APK文件结构、修改流程与常用工具链。内容涵盖AndroidManifest.xml配置解析、res资源目录、classes.dex字节码、lib库文件与META-INF签名信息等…

作者头像 李华
网站建设 2026/10/31 7:56:46

Cocos Creator麻将棋牌项目:牌局逻辑、状态机与断线重连

简介&#xff1a;面向棋牌游戏开发学习者与独立开发者的《cocos creator达达麻将棋牌游戏》项目包&#xff0c;以Cocos Creator为前端引擎&#xff0c;串联JavaScript游戏逻辑、Node.js后端服务与MySQL数据存储&#xff0c;可用于理解麻将规则实现、玩家匹配、登录与数据同步等…

作者头像 李华
网站建设 2026/10/31 7:56:11

MySQL事务原理与实践:从ACID特性到高并发优化

1. MySQL事务基础概念 事务(Transaction)是数据库管理系统执行过程中的一个逻辑单位&#xff0c;由一个有限的数据库操作序列构成。在MySQL中&#xff0c;事务是最小的不可再分的工作单元&#xff0c;它要么全部执行成功&#xff0c;要么全部不执行。 1.1 事务的四大特性(ACI…

作者头像 李华
网站建设 2026/10/31 7:56:10

MySQL高可用集群实战:Group Replication与InnoDB Cluster

1. MySQL高可用集群的核心价值与挑战 在互联网业务爆发式增长的今天&#xff0c;数据库作为业务系统的核心组件&#xff0c;其稳定性直接决定了服务的SLA水平。我经历过多次因数据库单点故障导致的线上事故&#xff0c;深刻体会到高可用架构不是"锦上添花"而是"…

作者头像 李华