Skip to content

登录模块面经文档

项目:黑马点评(类 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数据类型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 生成

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),服务端根据凭证找到用户信息。

维度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 无法主动吊销,续签也比较复杂。