外观
登录模块面经文档
项目:黑马点评(类 Yelp 本地生活服务平台) 技术栈:Spring Boot · Redis · MySQL · Nginx
1. 模块概览
登录模块解决两个核心问题:
- 身份认证:通过手机号 + 验证码完成用户登录,首次登录自动注册
- 会话保持:HTTP 无状态,借助 Redis + Token 实现跨服务器的登录状态维持
- 安全防护:手机号/IP 黑名单,防止短信接口被恶意刷取
2. 整体流程图
2.1 验证码登录流程
客户端 后端服务 Redis DB
│ │ │ │
│── 发送验证码请求(手机号) ──>│ │ │
│ │── 检查频率锁 ────────────>│ │
│ │<─ 不存在,放行 ────────────│ │
│ │── 检查黑名单(手机号/IP) ──>│ │
│ │<─ 未超限,放行 ────────────│ │
│ │── 存验证码(TTL=2min) ─────>│ │
│ │── 设置频率锁(TTL=1min) ───>│ │
│<─── 返回成功 ────────────│ │ │
│ │ │ │
│── 提交登录(手机号+验证码) ─>│ │ │
│ │── 取验证码 ──────────────>│ │
│ │<─ 返回验证码 ──────────────│ │
│ │── 比对验证码,通过 ─────────│ │
│ │── 查询用户 ─────────────────────────────────>│
│ │<─ 不存在则创建新用户 ──────────────────────────│
│ │── 生成 UUID Token ────────────────────────── │
│ │── 存用户信息(Hash,TTL=30min)─>│ │
│<─── 返回 Token ──────────│ │ │2.2 双重拦截器会话保持流程
请求到来
│
▼
┌─────────────────────────────────────────┐
│ 拦截器1(拦截所有路径) │
│ 1. 读取 Header: Authorization │
│ 2. 若有 Token → 查 Redis 获取用户信息 │
│ → 写入 ThreadLocal │
│ → 刷新 Redis TTL(续期) │
│ 3. 无论有无 Token → 直接放行 │
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 拦截器2(只拦截需要登录权限的路径) │
│ 1. 检查 ThreadLocal 中是否有用户信息 │
│ 2. 有 → 放行 │
│ 3. 无 → 拦截,返回 401 Unauthorized │
└─────────────────────────────────────────┘
│
▼
Controller 处理2.3 黑名单防护流程
请求发送验证码
│
├── 检查频率锁 (GET_CODE_LOCK:{phone})
│ 存在 → 返回"获取过快"
│
├── 检查手机号黑名单计数 (GET_CODE_BLACKLIST_PHONE:{phone})
│ >= 400次 → 返回"手机号已被限制"
│
├── 检查 IP 黑名单计数 (GET_CODE_BLACKLIST_IP:{ip})
│ >= 300次 → 返回"IP已被限制"
│
└── 全部通过 → 更新计数 + 设置频率锁 + 发送验证码3. Redis 数据结构设计
| 用途 | Key | 数据类型 | Value | TTL |
|---|---|---|---|---|
| 验证码存储 | login:code:{phone} | String | 6位验证码 | 2 分钟 |
| 用户登录信息 | login:token:{uuid} | Hash | userId, 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 生成
java
// 使用 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);4.2 黑名单核心逻辑
java
// 检查频率锁
String phoneGetCodeLock = stringRedisTemplate.opsForValue().get(GET_CODE_LOCK + phone);
if (phoneGetCodeLock != null) {
return Result.fail("获取验证码过快,请稍后重试!");
}
// 检查手机号黑名单
String blacklistPhoneCount = stringRedisTemplate.opsForValue().get(GET_CODE_BLACKLIST_PHONE + phone);
int getCodePhoneCount = blacklistPhoneCount != null ? Integer.parseInt(blacklistPhoneCount) : 0;
if (getCodePhoneCount >= 400) {
return Result.fail("获取验证码次数过多,您的手机号已被限制!");
}
// 检查 IP 黑名单(同理,阈值 300)...
// 全部通过后,更新计数和锁
stringRedisTemplate.opsForValue().set(GET_CODE_BLACKLIST_PHONE + phone,
String.valueOf(getCodePhoneCount + 1), 24, TimeUnit.HOURS);
stringRedisTemplate.opsForValue().set(GET_CODE_LOCK + phone, "1", 1, TimeUnit.MINUTES);5. 技术原理深挖
5.1 为什么 HTTP 是无状态的?会话保持的本质是什么?
HTTP 协议设计上每次请求相互独立,服务器不保留上次请求的任何信息。会话保持的本质是在无状态协议上人为绑定请求与用户身份,做法是:客户端每次请求携带一个凭证(SessionId 或 Token),服务端根据凭证找到用户信息。
5.2 Cookie-Session vs Redis+Token 对比
| 维度 | Cookie-Session | Redis + Token |
|---|---|---|
| 存储位置 | 服务器内存 | Redis(中间件) |
| 分布式支持 | ❌ 多台服务器 Session 不共享 | ✅ 天然支持 |
| 安全性 | 易受 CSRF 攻击 | Token 放 Header,避免 CSRF |
| 扩展性 | 差(内存有限) | 好(Redis 可集群) |
| 实现复杂度 | 低 | 中 |
| 跨域支持 | 差(Cookie 受同源策略限制) | 好(Header 不受限制) |
5.3 JWT vs Redis+Token 对比
| 维度 | JWT | Redis + 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 + Token | Token 存 Redis | 分布式友好,可主动吊销 | 依赖 Redis 可用性 | 分布式 Web 应用(本项目) |
| JWT | Token 自包含用户信息 | 无状态,服务端无存储 | 吊销困难,续签复杂 | 微服务间鉴权 |
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 无法主动吊销,续签也比较复杂。