登录这个功能,凡是做过业务系统的人都写过,但真正写得住的没几个。我带过几个不同规模的团队,每次新项目立项,登录模块永远是被排期压得最狠的那个——产品觉得它没工作量,测试觉得它没花样,结果上线第一个月工单里三成都是登录相关问题。这套以“路飞学成”为代号的在线学习系统,是给一家做职业技能培训的团队搭的学员端,名字取自“学员一路学、学有所成”的意思,核心场景是学员用手机在通勤路上刷课、用公司电脑做题、晚上回家接着看回放,学习进度强绑定账号,登录态一断,学员的怨气立刻就来。所以这里的“登录”两个字,背后其实是一整套账号体系、会话管理、风控策略和跨端体验的组合工程。下面我把这套东西从设计到落地完整拆一遍,包括参数怎么算、坑在哪里、为什么这么选。适合正在写登录模块的中初级后端、全栈,也适合做产品和技术负责人的朋友拿来对照自查。
1. 登录模块的整体设计与选型考量
1.1 先把需求边界划清楚,别急着写代码
很多人拿到“做个登录”这四个字,第一反应是打开 IDE 建 Controller,这是最容易返工的起点。我在动手前会先逼着产品把四件事说清楚,因为这四个答案直接决定后面所有技术选型。
第一,登录态要活多久。学习平台和电商不一样,电商要求短会话、强安全,学习平台的用户可能一周只上来两次,每次半小时。如果 token 只有 30 分钟,学员每次打开都要重新登录,课程看到一半跳出去回个消息,回来进度就没了,这种体验是致命的。所以我们的结论是:默认会话 2 小时,勾选“记住我”延长到 30 天,且只要在活跃期内有任意一次请求,就自动续期。
第二,能不能多端同时在线。这个必须问,因为涉及后面的并发踢人逻辑。培训业务的现实是:学员手机、平板、公司电脑同时登着很正常,你要是一刀切“后登录踢掉先登录”,学员在电脑上做题时手机弹个通知点进去,电脑就被踢了,骂声一片。我们的策略是允许 3 个端同时在线,超出时踢掉最久未活跃的那个,并且给被踢的端一个明确的提示,而不是白屏。
第三,账号从哪来。是学员自己注册、机构批量导入,还是走第三方授权。这家团队三种都有:自招学员自助注册,企业客户批量导入工号,还有一部分走第三方授权快捷登录。三种来源的账号在用户表里必须能区分,否则后面做数据统计、做权限、做客服排查全是糊涂账。
第四,出问题谁负责查。这句话听着像管理问题,其实是技术问题——如果登录日志只记了一条“登录成功”,客服接到“我登不上去”的工单,你根本没法判断是密码错了、账号被锁了、还是网络问题。所以日志字段的设计必须在第一版就定死,后面再补是补不回来的。
把这四个问题写成一张纸的需求卡片,贴在设计文档第一页,后面所有争论都回到这张纸上。这步花两小时,能省两周。
1.2 为什么最终没用纯 Session,也没用纯 JWT
这是每次架构评审必被拎出来讨论的点,我把当时的对比摆出来,你们可以直接抄这个框架去说服自己的团队。
| 维度 | 服务端 Session | 纯 JWT(无状态) | 我们的最终方案:JWT + Redis 混搭 |
|---|---|---|---|
| 存储位置 | 服务端内存或 Redis | 客户端持有 | 客户端持有 token,Redis 存状态索引 |
| 水平扩展 | 需共享存储,否则粘性会话 | 天然无状态,随便扩 | 无状态校验 + 一次 Redis 查询 |
| 主动失效 | 容易,删 key 即可 | 极难,只能等过期 | 容易,删索引即可 |
| 单点登录控制 | 容易 | 困难 | 容易 |
| 单次请求开销 | 一次存储查询 | 仅签名校验,最快 | 签名校验 + 一次 Redis 查询 |
| 移动端友好度 | 一般,依赖 Cookie | 好 | 好 |
纯 JWT 最大的问题不是性能,是“我改不了主意”。学员投诉账号被盗,你要立刻让这个 token 失效,纯 JWT 做不到——除非你改签名密钥,但那会把所有人都踢下线。而纯 Session 的问题在于我们有多端、有小程序、有第三方回调,Cookie 那套在非浏览器环境下处处别扭。
最后的折中是:JWT 里只放最小信息(用户 ID、端标识、签发时间、过期时间),签名用 HS256;同时在 Redis 里维护一份login:token:{userId}:{deviceId}的索引,值里存 token 的指纹(不是原文)和最后活跃时间。每次请求先验签,再查索引确认这个 token 没被吊销。多花一次 Redis 查询,换来强制下线、单点控制、活跃续期三个能力,这笔买卖很划算。实测下来 Redis 查询走本地连接池,P99 在 1 毫秒以内,对登录接口这种低频调用完全不是瓶颈。
注意:JWT 的 payload 是 Base64 编码不是加密,任何人都能解开看。所以里面绝对不要放手机号、身份证、角色权限这类信息,只放 ID 和端标识。
1.3 三种登录方式的取舍与组合
登录入口开几个,不是越多越好。每多一个入口,就多一条安全边界、多一套错误码、多一份客服话术。这家团队最终保留了三个:手机号 + 密码、手机号 + 短信验证码、第三方授权快捷登录。
密码登录是主力,适合老学员和电脑端;短信验证码是为了降低首次使用的门槛,很多人记不住自己注册时设的密码,尤其是企业批量导入的账号;第三方授权主要给移动端用,一键授权省掉输入。
这里有个细节值得说:短信验证码登录和密码登录必须是同一套账号体系下的两条通道。我见过有些项目把验证码登录做成了“免注册直接进”,结果同一个人用两种方式登录会生成两个账号,学习进度分裂,客服天天处理合并请求。正确做法是:验证码登录时先按手机号查用户,查到就发 token,查不到就走注册流程补一条用户记录,注册和登录共用同一个服务方法。
第三方授权也是同样的道理。授权回调拿到的是第三方平台的用户标识,我们要在绑定表里查有没有关联到本系统的用户 ID。有就发 token,没有就引导绑定手机号,绑定成功后再发 token。绝对不能跳过绑定直接建号,否则后面做学员归属、做课程权限、做发票,全都找不到人。
2. 数据表与核心字段设计细节
2.1 用户主表怎么切,别塞成一张宽表
第一版设计里最容易犯的错是把所有东西堆进user一张表:密码、手机号、第三方标识、登录时间、登录 IP、失败次数、锁定状态……字段冲到三四十个,后面每加一种登录方式就加两列,加到最后没人敢动这张表。
我的做法是拆成三张:user_base存身份主体(ID、昵称、头像、状态、注册来源、创建时间),user_credential存凭证(用户 ID、凭证类型、凭证值、盐版本、更新时间),user_binding存第三方关联(用户 ID、渠道标识、渠道用户 ID、绑定时间)。这样加一种新登录方式只是往user_credential插一行,主表完全不用动。
有一个坑必须提前说:手机号不能明文存储。合规要求是一方面,更现实的是数据泄露风险和内部越权查询。我们的方案是手机号密文存一列(AES 加密,密钥走配置中心),同时存一列手机号的 SHA256 哈希值用于精确查询和唯一索引。登录时前端传明文手机号,服务端算哈希去查,查到再用密文列解密出来做展示。加解密只发生在读写的瞬间,对性能影响可以忽略,单次 AES 加解密在微秒级。
索引设计上,user_credential表建(credential_type, credential_hash)联合唯一索引,登录查询走这个索引,能保证走索引且不重复。这里有个细节:联合索引的字段顺序不能反,登录时的查询条件永远是“类型 + 哈希值”,把类型放前面过滤性更好。
2.2 密码加密:BCrypt 的成本因子要算,不能拍脑袋
密码绝对不能存 MD5,也不能存 SHA256 加固定盐。前者是彩虹表一撞就穿,后者是撞库成本太低。我们现在统一用 BCrypt,它自带随机盐、自带慢哈希,天然抗暴力破解。
但成本因子(cost)不是越大越好,得算。BCrypt 的迭代次数是 2^cost,cost=10 时大约是 1024 轮,单次哈希在我这台测试机上耗时约 55 毫秒;cost=12 时翻四倍到 4096 轮,耗时约 210 毫秒。假设你的登录峰值是 50 QPS,全部走密码登录,那 cost=10 需要 50 × 0.055 ≈ 2.75 个核心专门做哈希计算,cost=12 就要 10 个核心,明显浪费。
// 注册与改密时生成 String hash = BCrypt.hashpw(rawPassword, BCrypt.gensalt(10)); // 登录时校验,注意要先查盐版本再校验 boolean ok = BCrypt.checkpw(rawPassword, storedHash);我们最终定的是 cost=10,同时预留了salt_version字段。以后算力上来了想升到 12,不需要让所有人重设密码,只要在读密码时判断版本号低于当前值,就用新 cost 重新哈希一次覆盖旧值——用户下次登录时无感升级。这个设计我很推荐,成本极低,但能让你在几年后不用做一次痛苦的强制改密。
还有一个很容易被忽略的点:用户不存在时也要跑一次假的哈希校验。否则攻击者可以通过响应时间差异判断手机号是否注册过,这就是典型的时序攻击。实现上很简单,硬编码一个假哈希串,用户查不到时拿它跑一遍checkpw,再统一返回“账号或密码不正确”。
2.3 登录日志与风控字段,第一版就要建
日志表我见过太多团队用“后面再加”的借口跳过,结果出事时只能干瞪眼。这张表我会在建表当天就建好,字段如下:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 自增主键 |
| user_id | bigint | 可为空,账号不存在时为空 |
| credential_hash | char(64) | 尝试登录的凭证哈希,便于反查撞库来源 |
| result | tinyint | 1 成功 / 2 密码错 / 3 锁定 / 4 验证码错 / 5 协议拒绝 |
| client_ip | varchar(45) | 兼容 IPv6 |
| device_id | varchar(64) | 端侧生成的稳定标识 |
| user_agent | varchar(255) | 截断存储 |
| risk_score | tinyint | 本次请求的风控评分 |
| created_at | datetime | 建索引,按天分区 |
created_at上和client_ip上都要有索引,因为排查时最常用的两个查询就是“某天某 IP 的所有尝试”和“某用户最近十次登录”。这张表增长很快,我们做了按天分区加 90 天归档的策略,查询只走热数据。
risk_score这个字段是我比较坚持要加的。当时没想好具体怎么用,就先留了坑,后来接风控策略时发现太有用了:新设备 20 分、异地 30 分、10 分钟内连续失败 25 分、非常用 UA 15 分,加起来超过 60 就触发二次验证。有了历史分数做基线,策略调起来有依据,不是拍脑袋。
3. 后端接口实现与关键代码拆解
3.1 登录接口的完整流程与幂等设计
先把流程串起来,这里我用伪代码加注释的方式,比纯文字清楚。整个登录接口我拆成了五个阶段,每个阶段失败都返回统一错误码,但日志里记录不同的 result。
public LoginResult login(LoginRequest req) { // 阶段一:前置风控,查 IP 和设备的失败计数 RiskContext risk = riskService.evaluate(req.getIp(), req.getDeviceId()); if (risk.isBlocked()) { logService.write(req, Result.LOCKED, risk); throw new BizException("操作过于频繁,请稍后再试"); } // 阶段二:图形验证码校验(仅在触发条件下要求) if (risk.needCaptcha() && !captchaService.verify(req.getCaptchaKey(), req.getCaptchaCode())) { logService.write(req, Result.CAPTCHA_WRONG, risk); throw new BizException("验证码错误"); } // 阶段三:查用户,查不到也要跑假校验,防时序攻击 UserCredential cred = credentialRepo.findByHash(type, sha256(req.getAccount())); if (cred == null) { passwordEncoder.fakeCheck(); // 固定耗时 rateLimiter.incrFail(req.getAccount()); logService.write(req, Result.PWD_WRONG, risk); throw new BizException("账号或密码不正确"); } // 阶段四:校验凭证与账号状态 if (cred.getStatus() != ACTIVE) { logService.write(req, Result.LOCKED, risk); throw new BizException("账号状态异常,请联系客服"); } if (!passwordEncoder.check(req.getPassword(), cred.getHash())) { int fail = rateLimiter.incrFail(req.getAccount()); if (fail >= LOCK_THRESHOLD) lockAccount(cred.getUserId()); logService.write(req, Result.PWD_WRONG, risk); throw new BizException("账号或密码不正确"); } // 阶段五:签发双 token,写登录索引,清理失败计数 rateLimiter.clearFail(req.getAccount()); TokenPair pair = tokenService.issue(cred.getUserId(), req.getDeviceId(), req.isRememberMe()); sessionIndexRepo.register(cred.getUserId(), req.getDeviceId(), pair); logService.write(req, Result.SUCCESS, risk); return buildResult(pair); }这里有几个设计意图要说清楚。第一,阶段三和阶段四分开,是为了让“账号不存在”和“密码错误”在响应上完全一致,但在日志里能区分——运营排查时非常有用。第二,失败计数用 Redis 的INCR加过期时间实现,key 是账号哈希,不是用户 ID,这样账号不存在时也能计数,防止攻击者用不存在的账号探测。
# 失败计数 key,10 分钟窗口 INCR login:fail:{accountHash} EXPIRE login:fail:{accountHash} 600锁定阈值我们定的是 5 次,锁定时长 15 分钟。但这里有个体验优化:锁定期间用户如果通过短信验证码登录成功,应该自动解锁。否则学员输错五次密码,等 15 分钟太难受了,他会直接打客服电话,而你其实可以通过另一条通道确认他就是本人。
3.2 图形验证码什么时候弹,这个时机决定体验
“一进登录页就要输验证码”是我最反感的交互之一。正确做法是动态触发,让正常用户永远看不到它。
判定逻辑我用了两条线:同账号 10 分钟内失败 2 次,或者同 IP 10 分钟内失败 5 次,满足任一条就要求验证码。这两条线分别对应“针对单账号的撞库”和“针对多账号的扫描”,覆盖面比较全。实现上用一个返回布尔值的服务方法:
public boolean needCaptcha(String accountHash, String ip) { long accountFail = redis.get(FAIL_ACCOUNT + accountHash); long ipFail = redis.get(FAIL_IP + ip); return accountFail >= 2 || ipFail >= 5; }前端拿到这个布尔值后,不要立刻显示验证码,而是等下一次点击“登录”时再展示,避免用户正在输入密码时表单突然跳动。这个小细节很多人不做,但实测下来能明显降低误操作率。
图形验证码本身我用的是生成图片 + Redis 存答案的方案,key 是随机 UUID,有效期 2 分钟,校验后立刻删除(不管对错),防止重放。这里踩过一个坑:校验后删除会导致用户输错一次就得刷新整张图,体验极差。后来改成校验失败时不删除,只在成功时删除,同时把允许的错误次数限制为 3 次,超过就作废。这个改动很小,但用户投诉量明显下降。
注意:验证码的答案在 Redis 里不要存明文,存手机号规范化后的值或者大小写无关的哈希都行。另外生成图片时避免使用容易混淆的字符(0 和 O、1 和 l),实际可用字符集控制在 32 个以内。
3.3 双 Token 刷新机制,并发刷新是最容易翻车的地方
Access Token 有效期 2 小时,Refresh Token 有效期 7 天(勾选记住我则 30 天)。刷新逻辑看着简单:拿 refresh 换新的 access,但实际项目里 90% 的登录 bug 都出在并发刷新上。
场景是这样的:学员打开课程页,页面同时发了 8 个接口请求,此时 access token 刚过期,8 个请求同时返回 401,8 个请求同时去调刷新接口。如果刷新接口是“用掉即废弃”的逻辑,第一个请求刷新成功,后面 7 个拿到的是失效的 refresh token,全部失败,用户被踢回登录页。
我的解法是给 refresh token 加一个短暂的重用宽限期。具体的做法是在 Redis 里存一个从旧 refresh token 哈希到新 token 的映射,有效期 10 秒。刷新时先查这个映射,如果命中就直��返回同一组新 token,不重复消费。
-- refresh.lua:原子化刷新,避免并发穿透 local oldHash = KEYS[1] local newToken = ARGV[1] local ttl = tonumber(ARGV[2]) local cached = redis.call('GET', 'refresh:reuse:' .. oldHash) if cached then return cached -- 宽限期内直接复用 end local valid = redis.call('GET', 'refresh:valid:' .. oldHash) if not valid then return nil -- 已失效,拒绝 end redis.call('DEL', 'refresh:valid:' .. oldHash) redis.call('SETEX', 'refresh:reuse:' .. oldHash, ttl, newToken) redis.call('SETEX', 'refresh:valid:' .. newToken, ttl * 1000, '1') return newToken这段脚本放在 Redis 里执行,保证原子性,不会出现两个请求同时判断“有效”然后都去消费的情况。宽限期我设的是 10 秒,足够覆盖绝大多数并发请求,同时也短到不会被滥用。
另一个坑是退出登录时要清理干净。很多人只删了前端的 token,服务端索引还在,结果 token 被中间人拿到后照样能用。正确做法是退出时把login:token:索引和refresh:valid:全部删掉,并且把这个 token 的 jti 加入一个短期黑名单,防止它在有效期内的残留请求继续通过。黑名单的 TTL 就是 access token 的剩余有效期,过期后自动清理,不会无限膨胀。
3.4 短信验证码登录的时序与防刷
短信接口是要花钱的,所以防刷比密码登录更严格。我们定的规则是:同一手机号 60 秒内只能发一次,1 小时内最多 5 次,同一 IP 1 小时内最多 20 次,三个条件任一触发就拒绝。
发送流程是:校验手机号格式 → 查频控 → 生成 6 位随机码 → 存 Redis(key 带手机号哈希和业务场景,TTL 5 分钟)→ 调短信通道 → 记录发送日志。这里有个细节,验证码存 Redis 时要用哈希后的手机号做 key,避免 Redis 里出现明文手机号,运维导出数据时也不会泄露。
校验流程是:查 Redis 取码 → 比对 → 成功后立刻删除。这里同样有并发问题,用户手快点了两次“验证”,第二次会失败。所以删除后要写一个短期的“已使用”标记,有效期 30 秒,第二次请求命中这个标记就理解为同一次成功登录,直接放行而不是报错。
还有一个很多人忽略的点:验证码校验失败 5 次也要作废这张码,否则攻击者可以在 5 分钟内暴力枚举 6 位数字。虽然 100 万种组合不容易撞,但配合大量并发还是有机会的,成本很低,做了更安心。
第三方授权的回调处理,核心是三步:校验回调参数签名、用授权码换用户标识、查绑定表决定是登录还是引导绑定。第二步的服务端调用一定要设超时(我设的 3 秒)并加重试(最多 2 次),因为外部接口不稳时会把你的线程池拖死。
4. 前端交互与体验打磨
4.1 表单校验要分层,别一股脑丢给后端
前端校验和前端提示是两件不同的事。校验是为了减少无效请求,提示是为了让用户知道错在哪。我习惯分三层:输入时就地校验格式(手机号位数、密码长度),失焦时校验业务规则(比如密码是否包含用户名),提交时统一交给后端并展示后端返回的文案。
这里最值得说的是错误提示的措辞。登录失败永远不要提示“该手机号未注册”或“密码错误”,统一用“账号或密码不正确”。原因很直白,前者等于免费给攻击者提供了账号枚举接口。同理,验证码错误就提示“验证码错误或已过期”,不要细分。这既是安全要求,也减少了用户对着屏幕琢磨“到底是哪错了”的焦虑。
另一个体验点是记住上次使用的登录方式。学员第二次打开时,默认选中他上次用的方式(密码还是验证码),手机号自动填上(从本地存储读,脱敏展示后四位)。这个功能做起来不到十行代码,但能让复访用户的登录步骤从四步降到一步。
4.2 401 拦截与自动续期,拦截器里必须排队
前端统一的请求拦截器里,最关键的就是 401 的处理。我见过最糟的实现是每个请求自己 catch 401 然后各自去刷新,结果就是前面说的并发问题。
正确的写法是维护一个刷新中的标志位和一个等待队列:
let refreshing = false let queue = [] axios.interceptors.response.use( res => res, async err => { const { config, response } = err if (response?.status !== 401 || config._retry) throw err if (refreshing) { // 已经在刷新,挂起等待结果 return new Promise(resolve => queue.push(t => resolve(axios(config)))) } refreshing = true config._retry = true try { await refreshToken() queue.forEach(fn => fn()) queue = [] return axios(config) } catch (e) { queue = [] redirectToLogin() throw e } finally { refreshing = false } } )_retry这个自定义标记是为了防止刷新失败后无限循环重试。这个逻辑看着短,但每个细节都是踩出来的:不标记 retry 会导致死循环,不排队会导致刷新接口被打爆,不出队会导致内存泄漏。
自动续期还有一个策略问题:什么时候续?我的做法是每次请求成功后,如果 access token 剩余有效期少于 30 分钟,就在后台静默刷新一次,用户完全无感。不要等到过期了再刷,因为那时用户可能正处在关键操作中,任何等待都是感知得到的。
4.3 多端一致性与被踢下线的提示
多端同时在线的规则前面说了,允许 3 个端。实现上是登录时检查该用户已有的活跃端数量,超过 3 个就按最后活跃时间排序,把最旧的移除。被移除的端请求时,Redis 索引查不到,返回一个专用状态码(我们用 4011),前端收到后弹一个温和的提示:“你的账号在其他设备登录,当前设备已退出”,并给一个“重新登录”的按钮。
这个提示文案很关键。如果直接白屏或者弹“未登录”,用户会以为系统坏了。给个明确的原因,用户就知道发生什么了,甚至会自己理解成“我确实在别处登了”。
还有一个跨端的细节:同一浏览器多标签页的登录态同步。用户在标签 A 退出登录,标签 B 还停留在登录状态,点任何按钮才提示要登录,很割裂。解法是用localStorage的storage事件做同步,A 退出时写一个标记,B 监听到就跳转登录页。这个成本极低,但能消除一类“我觉得这系统有 bug”的投诉。
5. 安全加固与异常场景处理
5.1 撞库、暴力破解与设备指纹
撞库和暴力破解的区别要搞清楚:暴力破解是拿一个账号死磕,撞库是拿一批账号密码挨个试。前者靠失败计数就能防住,后者必须靠 IP 和设备维度。
我们的三层防御是这样设计的。第一层是账号维度,失败 5 次锁定 15 分钟,前面说过。第二层是 IP 维度,用滑动窗口统计,10 分钟内同一 IP 失败超过 20 次就临时封禁,封禁时长指数退避(第一次 5 分钟,第二次 15 分钟,第三次 1 小时)。第三层是设备维度,同一设备 24 小时内尝试登录的账号数超过 5 个,就判定为可疑设备,强制要求短信验证码。
第三层是最有效的,因为正常用户不会在一天内登录五个不同账号。实现依赖设备标识,这个标识是前端首次访问时生成一个随机 UUID 存本地,请求时带上。它很容易被伪造,但对付自动化脚本足够了,因为脚本通常懒得处理这块。真要对抗高级攻击,还得上人机校验和行为分析,那是另一个量级的投入,一般业务没必要。
5.2 几个容易被忽略的绕过点
我把上线后陆续发现的问题整理成一张表,这些都是常规代码审计容易漏的:
| 问题 | 表现 | 修复方式 |
|---|---|---|
| 密码明文日志 | 调试日志把请求体打出来了 | 日志切面统一脱敏,字段名含 pwd/password 一律替换为 *** |
| 越权刷新 | 用自己的 refresh 换别人的 token | 刷新时必须校验 token 内的 userId 与索引中记录的一致 |
| 验证码复用 | 同一张验证码可用于多人登录 | 校验成功后立即删除,失败 3 次也删除 |
| 索引残留 | 用户删号后 token 仍有效 | 删号时级联清理所有会话索引,并加黑名单 |
| 时间戳信任 | 客户端传时间戳做重放判断 | 一律以服务端时间为准,客户端时间只用于展示 |
| 跨域宽松 | 允许任意 Origin 携带凭证 | 白名单精确匹配,不允许通配符配合凭证模式 |
其中越权刷新是最隐蔽也最危险的。攻击者如果拿到了 A 的 refresh token,伪造请求时把 userId 改成 B,如果你的刷新逻辑只校验 token 签名不校验归属,就能拿到 B 的会话。修复方法很简单,刷新时以 Redis 索引里存的 userId 为准,忽略请求里带的一切用户标识。
注意:登录接口一定要走 HTTPS,这不是建议是底线。前端对密码做一次 SHA256 再传,只能防住最原始的抓包,防不住中间人,真正的保护是传输层加密。
6. 常见问题排查实录
6.1 问题速查表,按现象找原因
这张表是我们团队内部 wiki 里被访问最多的一页,基本覆盖了 80% 的登录工单。
| 现象 | 高概率原因 | 排查动作 |
|---|---|---|
| 刚登录就掉线 | 服务端与网关的时钟不一致,JWT 提前过期 | 比对 NTP 同步状态,允许 60 秒时钟偏移 |
| 部分用户始终验证码错误 | Redis 键名用了明文手机号,与加密后的不一致 | 检查键生成逻辑是否统一走哈希函数 |
| 高峰期大量 401 | 刷新队列没做去重,刷新接口被打爆 | 看刷新接口 QPS,确认前端是否有排队逻辑 |
| 微信内打开无法登录 | 第三方回调域名未配置或路径末尾多了斜杠 | 核对回调地址逐字符比对 |
| 登录后权限全空 | 角色缓存未刷新,读到了注册时的初始值 | 登录成功后主动失效权限缓存 |
| 内存持续上涨 | 会话索引 key 没设过期时间 | 全量扫描login:token:*,补 TTL |
| 定时任务后全员重登 | 发版重启导致本地缓存的签名密钥重新生成 | 密钥走配置中心,不随机生成 |
时钟偏移那条特别值得展开说。JWT 里有exp和iat两个时间戳,如果签发服务器和校验服务器的时间差了一分钟以上,就会出现“刚签发的 token 立刻被判定为过期”或者“已过期的 token 还能用”。我们在校验时允许 60 秒的时钟偏移,同时要求所有节点接入统一的时间同步服务。这个数字不要太放宽,60 秒够容错又不至于让过期策略形同虚设。
6.2 几个印象比较深的实战坑
说两个我印象最深的。
第一个是移动端的 token 判断问题。早期我们在小程序端做了一个优化:拿到 token 后解析 payload 里的exp,提前判断要不要刷新。结果大量用户反馈“莫名其妙要重新登录”。排查后发现是部分手机系统时间不准,用户手动改过时间或者时区异常,导致客户端解析出来的过期时间和服务端完全不一致。最后的结论是:客户端永远不要自己解析 token 判断有效性,一律以服务端返回的 401 为准。这个原则后来写进了团队的接口规范,所有涉及时间有效性的判断都放服务端。
第二个是锁账号导致的连锁反应。我们最初的设计是,账号密码错误 5 次锁定 15 分钟,锁定期间任何登录方式都拒绝。上线后客服反馈,一批学员批量报“账号被锁”,原因是有个班的学员用了统一的初始密码,好几个人输错了同一个账号——其实是有人把工号填错了,几个人都在试同一个工号。这暴露了两个问题:初始密码设计不合理,以及锁定期间不应该堵死短信通道。后来改成初始密码必须首登强制修改,且锁定期间允许短信验证码登录并自动解锁,问题就消失了。
还有一个小技巧分享一下:登录日志里的user_agent字段,一开始我们存了完整字符串,结果发现同一个人每次请求的 UA 都会因为浏览器小版本更新而变化,做设备识别完全没法用。后来改成只取操作系统和浏览器两段,做粗粒度识别,稳定性好很多。
这套东西上线运行了一年多,登录相关的工单从最初的每月几十个降到了个位数。我个人最大的体会是,登录模块的复杂度不在代码量,在于你得提前想到所有“不正常”的情况:用户会在电梯里断网、会在两个设备间来回切、会把工号输成别人的、会用手表打开网页。代码写得再优雅,只要有一个场景没兜住,用户就会觉得整个系统不靠谱。所以这个模块值得多花几天,把边界想全,把日志记够,把提示写清楚——后面能省掉的是无数个加班的夜晚。