面试知识库

登录模块面经文档#

项目:黑马点评(类 Yelp 本地生活服务平台) 技术栈:Spring Boot · Redis · MySQL · Nginx


1. 模块概览#

登录模块解决两个核心问题:

  • 身份认证:通过手机号 + 验证码完成用户登录,首次登录自动注册
  • 会话保持:HTTP 无状态,借助 Redis + Token 实现跨服务器的登录状态维持
  • 安全防护:手机号/IP 黑名单,防止短信接口被恶意刷取

2. 整体流程图#

2.1 验证码登录流程#

2.2 双重拦截器会话保持流程#

2.3 黑名单防护流程#

请求发送验证码

    ├── 检查频率锁 (GET_CODE_LOCK:{phone})
    │       存在 → 返回"获取过快"

    ├── 检查手机号黑名单计数 (GET_CODE_BLACKLIST_PHONE:{phone})
    │       >= 400次 → 返回"手机号已被限制"

    ├── 检查 IP 黑名单计数 (GET_CODE_BLACKLIST_IP:{ip})
    │       >= 300次 → 返回"IP已被限制"

    └── 全部通过 → 更新计数 + 设置频率锁 + 发送验证码
plaintext

3. Redis 数据结构设计#

用途Key数据类型ValueTTL
验证码存储login:code:{phone}String6位验证码2 分钟
用户登录信息login:token:{uuid}HashuserId, nickName 等30 分钟(访问时刷新)
验证码频率锁login:lock:{phone}String”1”1 分钟
手机号黑名单计数login:blacklist:phone:{phone}String计数值24 小时
IP 黑名单计数login:blacklist:ip:{ip}String计数值24 小时

为什么用户信息用 Hash 而不是 String? Hash 支持对单个字段进行更新(如昵称修改),无需序列化/反序列化整个对象,更节省内存且操作灵活。


4. 核心实现细节#

4.1 Token 生成#

// 使用 UUID 生成不重复 Token
String token = UUID.randomUUID().toString(true); // true = 去除连字符

// 用户信息存为 Hash,所有字段转为 String
UserDTO userDTO = BeanUtil.copyProperties(user, UserDTO.class);
Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
    CopyOptions.create()
        .setIgnoreNullValue(true)
        .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));

String tokenKey = LOGIN_USER_KEY + token;
stringRedisTemplate.opsForHash().putAll(tokenKey, userMap);
stringRedisTemplate.expire(tokenKey, LOGIN_USER_TTL, TimeUnit.HOURS);
java

4.2 黑名单核心逻辑#


5. 技术原理深挖#

5.1 为什么 HTTP 是无状态的?会话保持的本质是什么?#

HTTP 协议设计上每次请求相互独立,服务器不保留上次请求的任何信息。会话保持的本质是在无状态协议上人为绑定请求与用户身份,做法是:客户端每次请求携带一个凭证(SessionId 或 Token),服务端根据凭证找到用户信息。

维度Cookie-SessionRedis + Token
存储位置服务器内存Redis(中间件)
分布式支持❌ 多台服务器 Session 不共享✅ 天然支持
安全性易受 CSRF 攻击Token 放 Header,避免 CSRF
扩展性差(内存有限)好(Redis 可集群)
实现复杂度
跨域支持差(Cookie 受同源策略限制)好(Header 不受限制)

5.3 JWT vs Redis+Token 对比#

维度JWTRedis + Token(本项目)
服务端存储无需存储(无状态)Redis 存用户信息
Token 吊销困难(需额外黑名单)直接删 Redis 的 key
Token 续签困难直接刷新 TTL
网络开销大(Header 较长,100B+)小(UUID 36字符)
安全性Payload 可被 Base64 解码(非加密)Redis 数据服务端保存,更安全

本项目选择 Redis+Token 的核心原因:Token 吊销(退出登录)和续签(活跃用户不掉线)这两个场景在 JWT 方案下实现复杂,Redis+Token 更简洁可控。

5.4 UUID 的唯一性原理#

UUID(128 位)组成:时间戳(60位)+ 时钟序列(14位)+ 节点标识/MAC地址(48位)。存入 Redis 后为 32 个十六进制字符(去连字符)。理论上碰撞概率极低(约 1/2^122),工程上视为唯一。

5.5 ThreadLocal 原理与内存泄漏#

ThreadLocal 为每个线程维护一份独立的变量副本,底层是 Thread 类中的 ThreadLocalMap,key 为 ThreadLocal 对象(弱引用),value 为存储的数据。

内存泄漏场景:在线程池中,线程不会被销毁而是复用。如果 ThreadLocal 对象被 GC 回收(key 变为 null),但 value 仍被 ThreadLocalMap 强引用,就会造成内存泄漏。

解决方案:请求处理完毕后,在拦截器的 afterCompletion 中调用 UserHolder.removeUser(),主动清除 ThreadLocal 中的用户信息。

5.6 双重拦截器的设计意图#

只用一个拦截器的问题:

  • 若拦截所有路径做鉴权 → 未登录用户连浏览商品都做不到
  • 若只拦截需要登录的路径 → 用户长时间只浏览公开页面,Token 会过期,下次操作需要重新登录

双重拦截器分工:第一个拦截器负责「刷新 TTL」,第二个拦截器负责「鉴权」,互不干扰,职责清晰。


6. 方案对比与选型理由#

6.1 会话保持方案横向对比#

方案原理优势劣势适用场景
Cookie-Session服务器内存存 Session实现简单,成熟不支持分布式,占用内存单机小型应用
Nginx IP Hash同一 IP 路由到同一服务器零代码改动Session 无备份,服务器宕机丢失服务器性能均等场景
Redis + TokenToken 存 Redis分布式友好,可主动吊销依赖 Redis 可用性分布式 Web 应用(本项目)
JWTToken 自包含用户信息无状态,服务端无存储吊销困难,续签复杂微服务间鉴权

6.2 黑名单存储方案对比#

方案优势劣势
Redis(本项目)速度快,支持 TTL 自动过期,分布式共享依赖 Redis 可用性
MySQL持久化,可查询历史慢,不适合高频访问
本地 Map速度最快不支持分布式,重启丢失

7. 极端场景与容灾#

场景一:Redis 宕机时用户无法登录怎么办?#

影响:验证码无法存储,Token 无法验证,登录功能完全不可用。

应对策略

  • 降级方案:使用本地缓存(如 Caffeine)临时承接少量验证码存储,但不支持分布式
  • 限流熔断:打开断路器,返回”服务暂时不可用”
  • 根本解决:Redis 高可用部署(哨兵/集群)

场景二:Token 在 Redis 中过期,但用户还在操作#

第一个拦截器的作用就是解决这个问题——每次任意请求到达时都会刷新 TTL。只要用户在操作(包括浏览不需要登录的页面),Token 就不会过期。

场景三:同一用户短时间内并发多次获取验证码#

频率锁(TTL=1分钟)使用 Redis SET key value EX 60 NX 语义保证原子性。两个并发请求同时检查锁时,由于 Redis 单线程模型,只有一个会成功设置,另一个会检测到锁存在而返回失败,天然防止并发问题。

场景四:用户退出登录后 Token 仍然有效怎么办?#

本项目中退出登录直接删除 Redis 中对应的 Token key,Token 立即失效。这也是本项目选择 Redis+Token 而不用 JWT 的核心原因之一——JWT 无法主动吊销。


8. 面试题清单#

8.1 基础实现层#

题目考察点
介绍一下你做的登录模块整体设计能力,表达清晰度
为什么使用 Redis 存储 Token,不放数据库?Redis vs MySQL 特性对比
用户信息为什么用 Hash 结构存储?Redis 数据结构选型
UUID 如何保证唯一性?有多长?UUID 原理
为什么用双重拦截器,而不是一个?设计思维
什么是 ThreadLocal?为什么要在请求结束后 remove?内存泄漏,线程池场景

8.2 方案对比层#

题目考察点
Cookie-Session 和 Redis+Token 有什么区别?分布式 Session 解决方案
为什么不用 JWT?JWT 有哪些缺点?JWT 深度理解
Nginx IP Hash 实现会话保持有什么问题?方案取舍
拦截器和过滤器有什么区别?为什么选拦截器?Spring MVC 工作原理

8.3 原理深挖层#

题目考察点
HTTP 为什么是无状态的?会话保持的本质是什么?HTTP 协议理解
JWT 的三段结构是什么?Payload 是加密的吗?JWT 结构原理
ThreadLocal 底层数据结构是什么?key 用弱引用的原因?JVM 内存模型
Redis 的 Token 过期时间如何续签?为什么在第一个拦截器里续签?TTL 刷新时机

8.4 极端场景层#

题目考察点
Redis 宕机了,登录模块怎么降级?容灾能力
用户退出登录后,Token 立即失效了吗?如何实现的?Token 主动吊销
黑名单用 Redis 存,Redis 重启了黑名单丢失怎么办?数据持久化
有人用代理 IP 不断换 IP 请求,你的 IP 黑名单还有效吗?安全防护局限性
频率锁的设置是原子操作吗?并发时会不会两个请求都通过检查?Redis 原子性

9. 一句话总结(面试开场白模板)#

登录模块主要分三部分:验证码登录使用 Redis 存储验证码(TTL=2分钟),用户通过校验后生成 UUID 作为 Token,将用户信息以 Hash 结构存入 Redis 并返回 Token;会话保持通过双重拦截器实现,第一个拦截器拦截所有路径,有 Token 就刷新 TTL 并写入 ThreadLocal,第二个拦截器只拦截需要登录权限的路径做鉴权;安全防护通过手机号和 IP 双维度黑名单,防止短信接口被恶意刷取。选择 Redis+Token 而不用 JWT,主要是因为 JWT 无法主动吊销,续签也比较复杂。