1. 从三套账号到一套凭证:统一登录到底在解决什么
前两年接手一个内部平台整合的活儿,用户侧最刺耳的反馈不是功能不好用,而是“我在 A 系统刚登录完,切到 B 系统又要输一遍密码,账号还是另外一套”。这类问题在系统数量超过三个之后几乎必然出现:有的系统用手机号登录,有的用员工号,有的还留着最早那套 admin/admin 的默认账号。等到要做权限审计、要看某个人到底能进哪些系统、要在他离职当天把所有入口关掉,麻烦才真正开始。所以后来我们把 Spring Boot 统一登录这件事重新做了一遍,目标很明确——所有业务系统不再自己管密码,认证只在一处发生,凭证能在系统之间被信任。
这里说的Spring Boot 统一登录,指的是把“用户身份确认”这件事从各个业务系统里抽出来,独立成一个认证中心,业务系统只负责“拿到凭证之后判断这个人能不能干这件事”。它和单点登录不完全是一回事:单点登录强调的是“登录一次,多处通行”的体验,统一登录强调的是“账号体系、认证逻辑、登录态只有一份”的治理能力。前者是结果,后者是手段。很多团队一上来就奔着 SSO 的体验去做,做完了发现账号还是分散的,密码策略还是各管各的,等于没解决根本问题。
这篇文章面向的是后端开发、架构设计,以及那些正在被“第 N 个系统要接登录”折磨的技术负责人。不管你是要做一个全新的认证中心,还是想把现有的几套账号收编到一处,下面这些设计取舍、代码结构、上线后踩到的坑,都是我实际趟过的。阅读门槛不高,会写 Spring Boot 的 Controller 就能看懂主体逻辑,但里面关于会话模型和续期策略的取舍,值得有经验的人多停一会儿。
1.1 一个典型的多系统账号割裂现场
先说清楚割裂是怎么长出来的。最开始只有一个系统,登录逻辑写在UserController里,密码用 MD5 存,登录成功往 Session 里塞个userId,完事。后来要做第二个系统,工期紧,直接把第一个系统的用户表复制过去,登录接口复制过去,改个包名就上线了。第三个系统是外采的,人家有自己的用户体系,只好做一次数据同步。第四个系统开始要做单点,才发现前三个系统的密码加密方式都不一样,同一个人的密码居然有三个版本。
我们盘过一次,一个不到 200 人的组织,用户表有 4 张,字段能对上的只有手机号和姓名,其中两个系统的手机号还有 60% 是空的。这种状态下,任何一次“某员工离职了,把他所有权限关掉”的诉求,都变成人工逐个系统点一遍的操作。更别说做登录审计——你连“谁在什么时候登录了哪个系统”都拿不到完整数据。
统一登录要收编的正是这些:账号唯一来源、密码策略统一、登录动作集中记录、登录态统一管理。这四件事只要有一件还留在业务系统里,统一登录就是半成品。我见过不少项目只做了 Token 校验,密码还是各系统自己存、自己改,结果改密码的接口有四个,用户改完一个另外三个还能用旧密码登,等于白做。
1.2 统一登录、单点登录、统一认证的边界划分
这三个词经常被混着用,实际落地时的边界得先划清楚,否则需求会一直漂。
| 概念 | 关注点 | 落地产物 | 谁负责 |
|---|---|---|---|
| 统一登录 | 账号与密码只有一份,登录动作集中发生 | 认证中心 + 统一用户表 | 认证中心 |
| 单点登录 | 登录一次,多个系统共享登录态 | 跨域凭证传递机制 | 认证中心 + 各业务系统 |
| 统一认证 | 凭证的签发、校验、失效统一口径 | Token 规范 + 校验 SDK | 认证中心 |
我的建议是:先把统一登录做扎实,再谈单点体验。顺序反过来的项目,通常会在“要不要允许各系统保留本地账号做兜底”这个问题上纠缠三个月。我的答案是不留兜底,认证中心挂了就整体挂,这听起来激进,但恰恰逼着你把认证中心的可用性做上去——认证中心背后只有一个 Redis 和一个用户库,它的稳定性本来就该高于任何一个业务系统。
1.3 先把目标写死:登录态的三个硬指标
在动手写第一行代码之前,我们把验收标准定成了三条,后面所有设计都是围绕这三条倒推的。
第一条是凭证的无状态校验比例。业务系统每次请求都要判断“这个人登录了吗”,如果每次都回调认证中心,认证中心就会成为全站流量的单点,一次抖动全站 502。所以我们的目标是:80% 以上的校验走本地验签,只有需要判断“是否已被踢下线”这类强一致场景才回源。
第二条是登录态失效的传播延迟。用户点了登出,或者在后台被管理员禁用,其他系统多久能感知?我们的底线是 30 秒,靠短 TTL 加一轮主动通知达成。
第三条是单点故障的爆炸半径。认证中心的 Redis 挂了会怎样?我们的选择是降级为“已有 Token 仍可验签通过,新登录全部失败”,而不是全站瘫痪。这条直接决定了 Token 里必须带足够的自描述信息。
这三条定完之后,很多争论自动消失了。比如“Token 要不要存数据库”,因为第一条和第三条的存在,答案必然是存 Redis 且只存必要索引,不做全量落库。
2. 认证中心的形态选型:Session、Token 还是标准协议
选型这件事最怕的是“别人用什么我用什么”。我见过一个内部管理系统照抄了某开放平台的 JWT 方案,结果因为没法主动踢人,被安全部门打回来重做。形态选型必须回到你的实际场景:客户端是什么、有多少个系统、要不要支持移动端、能不能接受短暂的不一致。
2.1 三种会话模型的真实差异
把常见的三种做法摊开对比,差异其实很直观。
| 维度 | 服务端 Session | 纯 JWT 无状态 | Redis 索引 + 短期 JWT |
|---|---|---|---|
| 校验开销 | 每次请求查存储 | 本地验签,零 IO | 本地验签为主,敏感操作回源 |
| 主动失效 | 立即生效 | 基本做不到 | 秒级生效 |
| 跨域支持 | 需要 Cookie 域配置,跨主域困难 | 天然友好 | 天然友好 |
| 移动端/第三方 | 不友好 | 友好 | 友好 |
| 水平扩展 | 需要会话共享 | 无压力 | 需要 Redis 高可用 |
| 典型痛点 | 多域名下 Cookie 传不过去 | 无法踢人、无法改权限即时生效 | 多一层 Redis 依赖 |
服务端 Session 的问题不在技术,在浏览器。一旦你的系统分布在多个二级域名下,Cookie 的Domain属性能覆盖的范围有限,跨主域就得走 URL 回传或者跳板页,体验和安全性都打折。纯 JWT 的问题在于它把“信任”固化在了签发那一刻,签出去之后你在服务端说什么它都不听,除非引入黑名单——而一旦引入黑名单,它就不再是无状态的了。
所以真实项目里最后落在第三种:短期 JWT 承担高频校验,Redis 承担状态索引。这个组合不是妥协,是把两种模型的优点各自用在它擅长的地方。JWT 的 15 分钟有效期意味着即使 Redis 完全不可用,最坏情况下的风险窗口也就是 15 分钟;而 Redis 里存的只是一个jti -> userId的映射,内存占用极小,一个十万用户的系统,索引数据撑死几十兆。
2.2 为什么最终落在“Redis 索引 + 短期 JWT”
有一个细节值得说清楚:很多人以为用 JWT 就是为了不查库,其实不完全对。真正的收益是把校验动作从“网络往返”变成“本地计算”。业务系统拿到 Token 之后,验签、检查过期时间、解析出用户 ID,全程不需要任何外部调用,P99 耗时在微秒级。这对那些 QPS 上万的接口来说是刚需。
但我们同时保留了一个 Redis 索引,键是auth:jti:{jti},值是用户 ID 和签发时间,TTL 略大于 Token 有效期。业务系统在两类场景下会回源查这个索引:一是涉及资金、权限变更的敏感接口,二是用户在客户端主动触发的“检查登录态”请求。这样做的好处是,日常读接口不碰 Redis,写接口和敏感接口保证强一致,两头都照顾到了。
提示:索引的 TTL 一定要比 Token 有效期长一点,比如 Token 15 分钟、索引 20 分钟。否则会出现 Token 还没过期、索引先没了的情况,回源校验直接判失败,用户看到的是“莫名其妙被登出”。
2.3 协议层选型:自研接口还是走标准协议
这一层经常被过度设计。我的判断标准很简单:如果你的系统只服务于自家前端和自家兄弟系统,自研一套登录 + 刷新接口完全够用,别急着上 OAuth2。标准协议的价值在于“第三方接入”和“用户授权”,当你的场景里出现“让用户授权某个外部应用访问他的数据”时,才真正需要它。
我们当时的做法是折中:对外提供一套标准的授权码流程给合作方,对内则走自研的账号密码登录 + Token 刷新。两套接口共用同一个用户表、同一套密码校验逻辑、同一个 Token 签发器,只是入口不同。这样既没有把内部系统复杂化,也留好了对外的口子。
如果确实要走标准协议,Spring 生态里有现成的授权服务器实现可以直接用,但要注意版本兼容问题——Spring Boot 3.x 之后安全框架的主版本号变了,配置写法和 2.x 差别不小,网上大量教程还是老版本,照抄会浪费不少时间。
2.4 一个可落地的模块拆分
把认证中心拆成三个模块,是我试过最舒服的结构:
auth-core:用户、密码、Token 签发与校验的核心逻辑,不依赖 Web 层,可以被任何入口复用。auth-api:对外的 HTTP 接口,登录、刷新、登出、校验,只做参数校验和编排。auth-client:给业务系统用的 SDK,包含过滤器、用户上下文、Feign 透传,打成 starter 引入。
拆开的好处是auth-core可以被单元测试完整覆盖,Token 签发这种逻辑一旦有 bug 就是安全事故,必须能脱离容器跑测试。而auth-client单独发布版本,业务系统升级 SDK 不影响认证中心,认证中心改接口也不强制所有系统同时升级——只要保持向后兼容。
3. 认证中心服务端的关键实现:从登录接口到凭证签发
服务端的代码量其实不大,难点全在细节。密码怎么存、Token 怎么签、刷新怎么做、并发刷新怎么办,这四件事每一件都有能让人返工的坑。
3.1 密码存储与登录校验的正确姿势
密码存储只有一个正确答案:慢哈希 + 随机盐。BCrypt 或 Argon2 都行,别用 MD5,也别用 SHA-256 加固定盐。BCrypt 的强度参数(cost)设置在 10 到 12 之间是常见选择,10 在普通服务器上单次校验约 50 到 100 毫秒,这个延迟对登录接口来说完全可接受,但对暴力破解来说成本极高。
// 注册/改密时 String hash = passwordEncoder.encode(rawPassword); // 登录校验时,注意 isMatch 内部是常量时间比较 boolean ok = passwordEncoder.matches(rawPassword, storedHash);有个容易忽略的点:BCrypt 每次 encode 的结果都不一样(盐是随机的),所以不能用哈希值做等值查询,只能先按用户名或手机号查出用户,再比对哈希。这意味着登录接口必然有一次按用户名的索引查询,这个字段一定要建唯一索引,否则高并发下会退化成全表扫。
配套的还有防爆破。我们的做法是三层:同一账号连续失败 5 次锁定 5 分钟;同一 IP 每分钟登录请求不超过 20 次;登录接口本身加一层滑动窗口限流。锁定的计数存在 Redis 里,键是auth:fail:{userId},用INCR加过期时间实现,不用考虑分布式锁,因为INCR本身是原子的。
注意:锁定策略要能通过后台手动解除,否则测试环境天天锁人,运维会来找你。我们后来加了一个管理接口,把失败计数的键直接删掉。
3.2 双 Token 设计与签发逻辑
我们用的是 accessToken + refreshToken 的组合。accessToken 是短期 JWT,15 分钟;refreshToken 是一个随机串,7 天,服务端存 Redis。这样设计的原因很直接:JWT 一旦签发就无法撤销,有效期必须短;但让用户每 15 分钟重新输一次密码是不可能的,所以需要一个长期的、可撤销的凭证来换新的 accessToken。
public TokenPair issue(Long userId, String deviceId) { String jti = UUID.randomUUID().toString().replace("-", ""); Instant now = Instant.now(); String access = Jwts.builder() .setId(jti) .setSubject(String.valueOf(userId)) .claim("dev", deviceId) .setIssuedAt(Date.from(now)) .setExpiration(Date.from(now.plusSeconds(900))) .signWith(secretKey, SignatureAlgorithm.HS256) .compact(); String refresh = RandomStringUtils.randomAlphanumeric(48); String rtKey = "auth:rt:" + refresh; redis.opsForValue().set(rtKey, userId + ":" + deviceId, Duration.ofDays(7)); // 反向索引,用于踢人和单点登出时批量清理 redis.opsForSet().add("auth:user:" + userId, refresh); redis.expire("auth:user:" + userId, Duration.ofDays(7)); // 写入 jti 索引,供敏感接口回源校验 redis.opsForValue().set("auth:jti:" + jti, String.valueOf(userId), Duration.ofMinutes(20)); return new TokenPair(access, refresh); }deviceId这个字段是我后来加的,用来区分同一个用户的不同客户端。没有它的时候,用户手机上登一次、电脑上登一次,服务端只存一个 refreshToken,结果就是后登的把先登的顶掉,用户抱怨“我在电脑上好好的,手机一登就被踢了”。加上设备标识之后,每个设备一个 refreshToken,踢人也可以精确到某一台设备。
3.3 Redis 里的登录态结构设计
键的设计值得单独说,因为它决定了你后续能不能做批量操作。我们最终用了三组键:
| 键模式 | 值 | TTL | 用途 |
|---|---|---|---|
auth:rt:{refreshToken} | userId:deviceId | 7 天 | refreshToken 换取新 accessToken |
auth:user:{userId} | Set of refreshToken | 7 天 | 踢人、登出全部设备 |
auth:jti:{jti} | userId | 20 分钟 | 敏感接口回源校验、黑名单 |
第一组是核心映射,第二组是反向索引。第二组的存在是为了避免用KEYS auth:rt:*去扫全库找某个用户的所有 refreshToken——那是灾难性操作。用 Set 存起来之后,踢人就是一次SMEMBERS加若干个DEL,O(N) 但 N 是设备数,通常不超过 5。
还有一个隐藏需求:管理员要能看到“这个用户现在在几个设备上登录”。有这个 Set 之后,SCARD一行就出来了。上线之后这个能力被运营用得比我想的多,用来判断异常登录很有帮助。
3.4 续期、并发刷新与刷新风暴的处理
刷新接口是全系统最容易被写出并发 bug 的地方。场景是这样的:前端同时发起 5 个请求,发现 accessToken 过期了,5 个请求同时调刷新接口,每个都拿同一个 refreshToken 去换,服务端要是每次都给新 Token 并把旧 refreshToken 删掉,那 4 个请求就失败了——这就是典型的刷新风暴。
我们的解法是刷新操作幂等化:换发新 accessToken 时,refreshToken 不立即作废,而是保留一个短暂的宽限期,比如 60 秒。在宽限期内,同一个 refreshToken 可以重复换取新 accessToken,每次签发的新 accessToken 用同一个 jti 覆盖写(SET覆盖而非新增)。60 秒之后旧 refreshToken 才真正删除。
public TokenPair refresh(String refreshToken) { String key = "auth:rt:" + refreshToken; String val = redis.opsForValue().get(key); if (val == null) throw new AuthException("凭证已失效,请重新登录"); String[] parts = val.split(":"); Long userId = Long.valueOf(parts[0]); String deviceId = parts[1]; // 滑动续期:每次刷新把 TTL 重置回 7 天 redis.expire(key, Duration.ofDays(7)); // 签发新 accessToken,旧 refreshToken 保留宽限期 return buildAccessOnly(userId, deviceId, refreshToken); }另一个思路是前端加锁,用一个 Promise 保证同一时刻只有一个刷新请求在飞。这个方案在前端做是对的,但不能只靠前端,因为移动端、第三方调用方你控制不了。服务端幂等才是兜底。
还有一个细节:如果用户在 A 设备刷新了 refreshToken 的 TTL,B 设备的 TTL 是不是也要跟着续?我们的选择是不续,各设备独立计算 7 天。理由是如果做成统一的“活跃即续期”,一个用户只要有一台设备在用,所有设备就永不过期,安全性上说不过去。
4. 业务系统的接入姿势:过滤器、拦截器与网关
认证中心做得再漂亮,业务系统接入体验差,推广就会被抵制。我们的目标是“引入一个依赖、加一行配置、搞定”,实际上也确实做到了,但过程中的取舍值得摊开讲。
4.1 接入层的三种位置与各自的适用面
业务系统在哪儿拦截请求做校验,有三个位置可选:Servlet 过滤器、Spring MVC 拦截器、API 网关。三者的差别不在于能不能做,而在于能拿到什么上下文、能覆盖多大范围。
过滤器的位置最靠前,在 Spring MVC 的 HandlerMapping 之前,能拿到原始的HttpServletRequest,也能拦截静态资源请求。适合做“所有请求都必须带 Token”这种全局约束。缺点是拿不到 Controller 的方法和注解,没法做细粒度的方法级放行。
拦截器的位置在 HandlerMapping 之后,能拿到HandlerMethod,可以读注解。适合做@Anonymous这种“这个方法允许匿名访问”的标记。缺点是它已经进入了 MVC 流程,一些早期异常(比如请求体解析失败)它拦不到。
网关则是另一层考虑:如果你的系统已经统一走网关,那在网关做一次校验、把用户 ID 通过请求头透传给下游,下游系统连 SDK 都不用引。这个方案最省事,但要求所有流量必须经过网关,内网直连的调用绕不过去。
我们的实际做法是网关做第一道,SDK 做第二道。网关负责验签并把X-User-Id、X-Token-Jti写进请求头,同时剥掉外部请求里自带的这两个头(防止伪造)。业务系统里的 SDK 负责二次校验并构建用户上下文。听起来冗余,但内网调用、定时任务触发、消息消费这些场景不经过网关,必须有 SDK 兜底。
4.2 一个可复用的 starter 封装
auth-client这个 starter 里核心就三样东西:
@Component public class AuthFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest req, HttpServletResponse resp, FilterChain chain) throws IOException, ServletException { String token = resolveToken(req); // Header 优先,其次 Cookie if (token != null) { Claims claims = tokenVerifier.verify(token); // 本地验签 if (claims != null) { UserContext.set(new LoginUser( Long.valueOf(claims.getSubject()), claims.getId(), claims.get("dev", String.class))); } } try { chain.doFilter(req, resp); } finally { UserContext.clear(); // 必须清理,见后文踩坑 } } }resolveToken的取值顺序要讲究:先看Authorization: Bearer xxx,其次看自定义头,最后才看 Cookie。为什么 Cookie 放最后?因为如果前端同时带了 Cookie 和 Header,Header 应该是用户当前操作的那个身份,Cookie 可能是更早留下的。这个顺序搞反过,导致用户在切换账号之后仍然被识别成旧账号。
UserContext用ThreadLocal实现,但要注意:Web 容器是线程池,线程会被复用。如果不在finally里清理,下一个请求进来就会读到上一个请求的用户信息,这是最典型也最危险的一类 bug,后文会专门讲我踩的那次。
4.3 跨服务调用时的用户上下文透传
微服务拆分之后,A 服务调 B 服务,B 服务也得知道“当前是谁在操作”。做法是在 HTTP 客户端加一个拦截器,把当前线程的用户信息塞进请求头。
public class FeignAuthInterceptor implements RequestInterceptor { @Override public void apply(RequestTemplate template) { LoginUser user = UserContext.get(); if (user != null) { template.header("X-User-Id", String.valueOf(user.getUserId())); template.header("X-Token-Jti", user.getJti()); template.header("X-Internal-Call", "1"); } } }这里有个安全考量:内部调用头X-Internal-Call必须在网关层被无条件剥掉,否则外部请求伪造这个头就能绕过鉴权直接调到内网服务。我们在网关上做了一个请求头白名单,只保留必要的那几个,其余全部丢弃。
如果用线程池做异步编排,ThreadLocal会丢,得换成支持父子线程传递的实现,并且在提交任务时做一次快照拷贝。这个问题在引入异步之后一定会遇到,建议一开始就封装好工具方法,别让每个业务方自己new ThreadLocal。
4.4 登出与踢人:反向通知怎么做
登出分两种:用户主动点退出,和管理员在后台踢人。主动退出好办,删掉 refreshToken、删掉 jti 索引就行。难的是“已经签发出去的 accessToken 怎么立刻失效”。
最直接的办法是每次校验都回源查 jti 索引,查到不存在就拒绝。这能实现秒级失效,但和前面“80% 走本地验签”的目标冲突。我们的折中是:accessToken 有效期压到 15 分钟,登出时只删 refreshToken,accessToken 自然过期。用户点退出之后,前端立即清掉本地存储,所有后续请求都不会带 Token,实际上已经登出了。真正需要“立刻”的场景是安全事件,比如账号被盗,这时候走一个特殊接口,把用户的 jti 全部拉黑,而敏感接口本来就回源校验,能立刻拦住。
如果想做得更彻底,可以用一个广播机制通知所有在线节点刷新本地黑名单。我们试过一轮,最终放弃了——因为多节点部署时消息到达的顺序无法保证,反而容易出现“A 节点认为拉黑了、B 节点认为没有”的混乱状态。有这个精力,不如把 accessToken 的有效期再压短一点。
5. 上线之后才暴露的问题:踩坑与排查链路
前面讲的都是设计。真实项目里最耗时间的永远是“上线之后才发现”的那部分。下面这几个坑,有的是我亲手挖的,有的是帮别人排查的,过程完整放出来,方便你按图索骥。
5.1 时钟漂移导致 Token 莫名过期
现象是:少数用户反馈“刚登录就提示登录过期”,而且集中在某一批机器上。日志里看,exp时间明明还没到。
排查路径是这样的。第一步,把出问题的 Token 解出来,看iat和exp,发现exp - iat确实是 900 秒,令牌本身没问题。第二步,对比签发节点的系统时间和校验节点的系统时间,发现两者差了将近 4 分钟——签发节点跑在容器里,容器宿主机的时钟没开同步,长期运行之后漂移累积。第三步,加日志打出now和exp的差值,确认就是时钟问题。
修复方案有两个层面。短期是加大校验侧的容忍窗口,也就是验签时允许exp有 60 到 120 秒的偏移,很多 JWT 库都支持这个配置。长期是把所有节点的时钟同步打开,并且在签发时把iat往前推一点点,宁可让 Token 早一点生效,也别让它一签出来就在别人眼里过期了。
注意:容忍窗口不要设太大,超过 5 分钟就失去了意义,反而给了被撤销的 Token 更长的有效时间。
5.2 跨域请求里 Cookie 传不过去的连环坑
现象是:单个系统访问正常,从 A 系统跳转到 B 系统之后,B 系统拿不到登录态,用户被迫重新登录。打开浏览器开发者工具,请求头里确实没有 Cookie。
这个问题有好几层原因,得逐层剥。第一层是SameSite属性,浏览器默认策略下,跨站请求不会带上 Cookie,必须显式设为SameSite=None; Secure。第二层是Domain属性,要设置成共同的父域,比如两个子域都设成Domain=.example.internal,注意前面的点。第三层是Path,默认是当前路径,得设成/。第四层才是 CORS 预检,Access-Control-Allow-Credentials必须为true,同时Access-Control-Allow-Origin不能是通配符,必须是具体来源。
我们当时卡在第二层和第三层的组合上,Domain设对了但Path没设,导致只在根路径下能读到 Cookie。定位方法是直接在浏览器里看 Cookie 的实际属性,比看服务端配置快得多。
对于完全不同的主域,Cookie 方案基本上走不通,这时候只能退回到 URL 参数传递一次性授权码,由目标系统拿授权码去换 Token。这条路径要严格限制授权码的有效期(我们的做法是 60 秒)和一次性使用,否则就是明显的安全漏洞。
5.3 多端登录互斥:产品需求和技术实现的错位
上线两周之后产品提了个需求:“同一个账号最多允许两台设备同时在线,第三台登录要把最早的那台踢掉。”听起来简单,做起来发现“设备”这个概念在 Web 端根本不存在——浏览器没有可靠的设备标识,清缓存就变了。
我们试过用浏览器指纹(User-Agent + 屏幕分辨率 + 时区),结果发现同一台电脑换浏览器就被识别成新设备,用户在公司用 Chrome、回家用 Edge,两下就把名额占满了。最后改成“按 refreshToken 计数”,每次登录生成一个新 refreshToken,超过上限就按签发时间删掉最老的,被踢的那台设备下次刷新时会失败,提示“您的账号在其他设备登录”。
这个方案不完美——用户在同一台电脑上开两个浏览器窗口,在服务端看来就是两台设备。但产品接受了,因为它的实际诉求是“防止账号被大量共享”,按 Token 计数已经能覆盖 90% 的场景。技术方案和产品目标之间总有这种微调,关键是别为了追求“真正的设备识别”去引入一堆指纹库,那会把简单问题复杂化。
5.4 一次登录态串号的完整排查过程
这是我最不愿意回忆的一次事故。现象是:极少数用户反馈“看到别人的数据了”。听到这个描述,第一反应是数据权限的 SQL 写错了,但查下来 SQL 没问题,是用户上下文里拿到的userId不对。
排查链路是这样的。第一步,确认现象可复现,在压测环境用两个账号交替高频请求,大约几万次之后能复现一次。第二步,怀疑缓存,把用户上下文的缓存关掉,仍然复现,排除。第三步,怀疑连接池或者请求转发,在过滤器入口打出当前线程名和userId,发现同一个线程名先后处理了两个不同用户的请求,第二次请求读到的userId是第一次的。
到这里原因就清楚了:ThreadLocal没有清理。代码里finally块的UserContext.clear()是有的,但有一类请求不走那个过滤器——内部的健康检查接口和静态资源,它们被更靠前的一个 Filter 直接短路返回了,chain.doFilter根本没执行,finally也没执行。而这些短路路径在处理过程中,线程被别的请求复用了。
修复很简单,把清理动作提到过滤器的入口处,也就是进入时先clear()一次再set(),这样即使上一次没清理干净,这一次也会覆盖掉。同时把短路的那个 Filter 也加上清理。改完之后压测跑了 50 万次,没再复现。
这个坑给我的教训是:任何依赖线程复用的上下文,清理动作要放在“进入时”和“退出时”两个位置,只靠finally是不够的,因为你永远不知道有没有哪个分支把doFilter短路了。这个经验后来被我用在了所有类似场景里,包括 MDC 日志上下文、事务上下文。
关于限流和压测,这里补一组我们线上的实际数据作参考:认证中心的登录接口单实例 QPS 在 800 左右,瓶颈在 BCrypt 的哈希计算,CPU 打满;Token 校验接口因为是纯本地验签,单实例能跑到 2 万以上。这个比例说明一件事——把计算密集的密码校验和 IO 密集的校验接口混在同一个实例上是不划算的,我们后来把登录接口单独拆了一组实例,校验接口用更小的规格但更多的副本,资源利用率明显好转。
最后分享一个我一直在用的小习惯:认证相关的所有接口,日志里必须打jti,不要只打userId。因为一个用户可能同时有多个 Token 在飞,出问题的时候只看userId根本分不清是哪一次登录、哪一台设备。有了jti,从网关日志到业务日志能一路串起来,排查效率差一个数量级。这个字段的存储成本几乎为零,但真出事的时候,它会是你最快的那条线索。