面试知识库

06 - 认证鉴权#

知识图谱#

核心代码走读#

双拦截器配置#

文件: src/main/java/com/hmdp/config/MvcConfig.java

@Override
public void addInterceptors(InterceptorRegistry registry) {
    // 第一层: Token 刷新 (所有路径)
    registry.addInterceptor(new RefreshTokenInterceptor(stringRedisTemplate))
            .addPathPatterns("/**")
            .order(0);  // 最先执行

    // 第二层: 登录校验 (排除公开路径)
    registry.addInterceptor(new LoginInterceptor())
            .excludePathPatterns(
                "/shop/**", "/voucher/**", "/shop-type/**",
                "/upload/**", "/blog/hot", "/user/code", "/user/login"
            ).order(1);  // 后执行
}
java

RefreshTokenInterceptor#

文件: src/main/java/com/hmdp/utils/RefreshTokenInterceptor.java

登录注册流程#

文件: src/main/java/com/hmdp/service/impl/UserServiceImpl.java

Bitmap 签到#


面试 Q&A#

Q1: 为什么用双拦截器?一个不行吗?#

单拦截器的问题:如果 LoginInterceptor 排除了 /shop/**,那么用户访问商铺页面时不会执行拦截器,Token TTL 不会刷新。用户可能在浏览商铺时 Token 过期。

双拦截器设计

  • RefreshTokenInterceptor(order=0):匹配所有路径,负责 Token 刷新和 UserHolder 填充
  • LoginInterceptor(order=1):排除公开路径,只负责鉴权

这样用户访问任何页面(包括公开页面)都会刷新 Token,不会因为”只看不操作”而被登出。

追问:如果把刷新逻辑放在 LoginInterceptor 里,排除路径设为空呢?#

不行。排除路径为空意味着所有请求都需要登录,未登录用户连商铺列表都看不了。

双拦截器的精妙在于:Token 刷新和权限校验解耦。第一层”尽力刷新”(有 Token 就刷新,没有也放行),第二层”严格校验”(保护路径必须登录)。

Q2: Token TTL 有个不一致的地方,你注意到了吗?#

是的,这是一个隐蔽的 Bug:

位置常量单位实际时长
login() 创建 TokenLOGIN_USER_TTL = 36000TimeUnit.HOURS36000小时 ≈ 4年
RefreshTokenInterceptor 刷新LOGIN_USER_TTL = 36000TimeUnit.MINUTES36000分钟 ≈ 25天

同一个常量在两处用了不同的 TimeUnit!创建时 Token 能活 4 年,但每次刷新后 TTL 被缩短为 25 天。

更合理的做法:统一为 TimeUnit.MINUTES 配合一个合理的值(如 30 分钟或 7 天)。

Q3: 为什么用 Redis Hash 存 Token 而不是 String?#

Hash 优势

  1. 部分字段读取:可以 HGET 单个字段,不需要反序列化整个对象
  2. 部分字段更新:可以 HSET 更新单个字段
  3. 内存效率:小 Hash(< 512 字段)使用 ziplist 编码,比 JSON String 更省内存

String 优势

  1. 整体读写更简单(序列化/反序列化一步到位)
  2. 不需要处理类型转换(Hash 的 value 都是 String)

项目选 Hash 是合理的——UserDTO 只有 3 个字段(id, phone, nickName),很适合 Hash。

追问:BeanUtil.beanToMap 那段有什么作用?#

Map<String, Object> userMap = BeanUtil.beanToMap(userDTO, new HashMap<>(),
        CopyOptions.create()
                .setIgnoreNullValue(true)
                .setFieldValueEditor((fieldName, fieldValue) -> fieldValue.toString()));
java

关键是 setFieldValueEditor:把所有字段值转为 String。因为 StringRedisTemplate 的 Hash 只接受 <String, String>,UserDTO 的 id 是 Long 类型,不转会报 ClassCastException。

Q4: ThreadLocal 有什么需要注意的?#

内存泄漏风险:Tomcat 使用线程池,线程被复用。如果不在请求结束时清理 ThreadLocal,前一个用户的数据可能”泄漏”给下一个请求。

项目中通过 afterCompletion 调用 UserHolder.removeUser() 来清理。但有个风险:如果 preHandle 中抛异常,afterCompletion 仍然会执行(Spring MVC 保证),所以不会泄漏。

追问:如果用 @Async 异步方法,能拿到 UserHolder 的数据吗?#

不能@Async 会在新线程中执行,而 ThreadLocal 是线程隔离的。

解决方案:

  1. 在调用异步方法前取出 userId 作为参数传入
  2. 使用 InheritableThreadLocal(但线程池场景下仍有问题)
  3. 使用 TransmittableThreadLocal(阿里开源,专门解决线程池传递问题)

Q5: Bitmap 签到的空间效率怎么样?#

极致高效

  • 每天 1 bit,一个月 31 bits ≈ 4 bytes
  • 一个用户一年:12 keys × 4 bytes = 48 bytes
  • 100 万用户一年:48 MB

对比数据库方案:每天一行记录,一个用户一年 365 行,100 万用户 3.65 亿行。

追问:连续签到天数怎么算的?#

BITFIELD GET u{n} 0 取出本月前 n 天的签到位图(作为十进制数),然后从最低位(今天)开始逐位与1做”与运算”:

假设签到位图: 1110110 (第1,2,3,5,6天签到, 第4天未签到)
反转后从低位算: 0110111

0110111 & 1 = 1 → count=1, 右移
011011  & 1 = 1 → count=2, 右移
01101   & 1 = 1 → count=3, 右移
0110    & 1 = 0 → 中断! 返回 count=3
plaintext

踩坑点#

踩坑点说明面试官考察意图
Token TTL 单位不一致login() 用 HOURS, interceptor 用 MINUTES细节观察力
验证码硬编码code = "123123" 开发遗留安全意识
限流计数器非原子GET + 判断 + SET 不是原子操作并发安全
ThreadLocal 清理必须在 afterCompletion 中 removeUser内存泄漏
IP 获取方式request.getRemoteAddr() 在反向代理后获取的是代理IP运维知识

加分回答#

  • 提到 request.getRemoteAddr() 应改为 request.getHeader("X-Forwarded-For")(Nginx 场景)
  • 分析限流计数器的并发问题:两个请求同时 GET 到 count=399,都判断 < 400,都 SET 为 400 → 实际发了 401 次。可用 Redis INCR 原子操作替代
  • 提到可以用 Redis + Lua 实现滑动窗口限流(比固定窗口更精确)
  • 讨论 Token 方案 vs JWT:Token 需要存储但可以服务端主动失效,JWT 无状态但无法主动踢人

关联文档#