qinghuan
← 黑马点评 / 全部笔记

单元 94 / 黑马点评

P0 · 必会主线L2 · 进阶

黑马点评:短信登录与 Redis 用户态 DTO

分清验证码、会话令牌和 UserDTO,沿登录保存、每次请求读取、到请求结束清理走完流程。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能说明登录态的完整写入、读取、续期和清理链路,并检查匿名接口的行为。

学习理由、准备要求与知识点

为什么学它

登录贯穿令牌生成、用户 DTO、过期与线程清理,任何一段类型或生命周期不一致都会出错。

先读这些内容

知识点清单

  • 验证码与限流
  • 随机登录令牌
  • UserDTO
  • Hash 序列化
  • 整体 TTL
  • 字段类型转换
  • 刷新与鉴权拦截
  • 上下文清理

运行前先确认点评应用通用教学流程,不声称采用实际项目源码。Redis 7.4、Java 17;ThreadLocal 源码固定 OpenJDK 17.0.16+8。算法只模拟验证码状态,不发送短信,不生成生产令牌。

01先弄懂它是什么

短信登录里有三个东西,不要混在一起:验证码完成这一次身份核验,令牌把后续请求关联到会话,用户 DTO 保存后续业务需要的少量身份信息。手机号不能直接充当难以猜测的会话令牌,完整用户实体也不宜直接当会话内容。分清三者,才能分别规定有效期、保存字段和读取类型。

完整流程从规范手机号、限制发送频率和设置验证码有效期开始。核验成功后消费验证码,再查找或创建用户,并生成不可预测的会话令牌。手机号唯一约束能防止并发建出重复用户,却不能替代验证码只能用一次的要求。若验证码已消费、后续创建失败,应明确重新验证或受控恢复流程,不能让同一验证码无限重用。

Redis 会话解决多实例共享身份状态,ThreadLocal 解决当前线程访问身份的便利性。前者可存活几十分钟,后者应局限在一次请求处理范围内。服务器线程会复用,请求结束未清理时,下一个匿名请求可能看到上个用户。即使 Redis 中的令牌已经删除,线程里残留的 DTO 也不会自动消失。

文中这几个词,先认一认

UserDTO
请求身份所需的少量字段组成的对象,例如 id、nickName、icon;它不是数据库 User 实体。
单次消费
把验证码核验成功和作废作为一次原子状态变化,重复提交时不能再验证成功。
滑动有效期
会话有效访问时延长闲置有效期;通常仍应另设从登录起计算的最长有效时间。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

写入类型必须沿整条链保持一致:User 实体先显式转换为 UserDTO,再编码为 Redis Hash 的字符串字段;读取时验证 id 格式和范围,再构造新的 UserDTO。User 不是 UserDTO 的实例时,强制类型转换不会帮你转换属性。也不能假定一个 Map 中的 id 已经是 Long,Redis 序列化器和客户端会决定实际表示。

保存会话时,身份字段和有效期都要写成功,不能只保存身份却漏掉期限。Hash 场景可以把 HSET 和 EXPIRE 放进 MULTI/EXEC,并逐项检查返回值;其中一项运行失败时,Redis 不会自动撤销另一项,所以仍要处理只完成一部分的情况。有效的已登录访问,包括相关公开页面上的访问,可以按规则延长会话期限;能否进入受保护接口,再由单独的权限检查决定。无效令牌不能因为被反复提交就不断续期。

请求开始时要放入本次正确的身份,结束时在 finally 或可靠的请求完成钩子里调用 remove。使用异步 Servlet、线程池或 CompletableFuture 后,后续代码可能换到另一个线程;普通 ThreadLocal 不会自动把身份带过去。

需要时应显式传递一份不可变的身份信息,或使用框架支持的传递方式,并让工作线程自己完成清理。下面先用同步方法演示验证码的检查与消费不可分开,再考虑怎样用 Redis Lua 或其他共享存储实现同样规则。

正在加载流程图…

共享会话与线程临时身份是两个生命周期,必须分别过期和清理。
查看流程图源码(Mermaid)
flowchart TD
A["手机号与验证码"] --> B["原子校验 消费"]
B --> C["查找或创建 User"]
C --> D["显式构造 UserDTO"]
D --> E["Redis 字符串字段及期限"]
E --> F["请求读取 解析 UserDTO"]
F --> G["对象授权与业务处理"]
G --> H["请求结束 remove"]

按顺序看,每一步做了什么

  1. 对验证码发送和验证建立频率、失败次数与有效期规则。
  2. 原子完成匹配检查与成功消费,再创建会话。
  3. 显式处理 User、DTO、Redis 字段之间的类型转换。
  4. 在每条完成、异常和异步路径验证身份传播与清理。

03对照实现代码

ThreadLocal.remove 从当前线程的映射移除当前键 (新标签页)↗

OpenJDK jdk17u jdk-17.0.16+8 · ThreadLocal#set;ThreadLocal#remove;ThreadLocal.ThreadLocalMap#remove

以下是原创同步请求边界示例,展示 DTO 解码与 finally 清理;不是项目 UserHolder 或 JDK 源码节选,不适用于未经适配的异步请求生命周期。

核心机制代码 · java
import java.util.Map;
import java.util.concurrent.Callable;

class SourceModel {
  record UserDTO(long id, String nickName, String icon) {}
  private static final ThreadLocal<UserDTO> CURRENT = new ThreadLocal<>();
  static UserDTO decode(Map<String,String> fields) {
    String raw = fields.get("id");
    if (raw == null || !raw.matches("[0-9]+")) throw new IllegalArgumentException("id");
    long id = Long.parseLong(raw); // 显式解析,溢出和非法字段不形成已登录身份
    if (id <= 0) throw new IllegalArgumentException("id");
    return new UserDTO(id, fields.getOrDefault("nickName",""), fields.getOrDefault("icon",""));
  }
  static <T> T request(UserDTO user, Callable<T> business) throws Exception {
    CURRENT.remove(); // 同步顶层请求入口先清除可能残留的旧身份
    try {
      if (user != null) CURRENT.set(user);
      return business.call(); // 业务仍应执行对象归属和权限检查
    } finally {
      CURRENT.remove(); // 正常返回和异常离开都清理当前线程
    }
  }
}

真实 set 和 remove 都从 Thread.currentThread() 获取 ThreadLocalMap,因此操作的是调用线程的身份副本,而不是整个进程中的所有请求。

remove 找到当前 ThreadLocal 对应条目后清除并整理映射;它不向 Redis 发送删除命令,也不会撤销其他线程已取得的 DTO。

ThreadLocalMap 的键用了弱引用,也不表示请求一结束,里面保存的用户对象就会马上清空。服务器线程可能长期存活;漏掉 remove,仍可能让旧对象保存太久,甚至让后来的请求读到上次身份。弱引用不能代替明确的请求结束清理。

教学 request 只处理同步的最外层请求,所以结束时直接清理,不恢复旧值。若还有嵌套身份切换或异步分派,就要另外规定进入、退出和传递方式,不能原样套用。

代码先调用谁,再调用谁

  1. Redis 字段解码为 UserDTO
  2. 当前请求线程 set
  3. 业务读取并授权
  4. 正常或异常结束
  5. 当前请求线程 remove

04动手写一个例子

验证码有效期、失败预算与单次消费

以主体与用途组成验证码键。服务端发行新的六位教学验证码时替换旧值;验证在到期前成功一次后删除,失败最多消耗三次机会。所有状态变化在同一个进程内原子执行;真实多实例需等价共享协议。

输入 1
有效码123456;先错误一次,再正确一次,再重放
输出 1
false、true、false
输入 2
到期点100,验证时刻100
输出 2
false

用 Key(subject,purpose) 隔离同一主体的登录与其他用途,避免只按手机号存一个通用验证码。

校验和删除放在一个同步方法内;匹配成功时立即删除,两个并发提交只有一个能观察到可消费状态。

失败则减少剩余次数,耗尽和过期都移除记录。对外统一失败结果,内部监控再分类,避免接口泄露过多账号状态。

为什么这样写是对的同一已发行挑战最多返回一次成功;已过期或耗尽预算的挑战不再通过;不同用途互不消费。

时间 平均单次 O(1)额外空间 O(n)

六位比较长度固定,哈希表更新平均常数;n 为仍保存的挑战数。真实系统还需定期清理无人访问的过期记录。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 60 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.*;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.concurrent.*;

public class Main {
  record Key(String subject, String purpose) {}
  record Challenge(String code, long expiresAt, int attemptsLeft) {}
  static final class OtpStore {
    private final Map<Key,Challenge> records = new HashMap<>();
    synchronized void issue(Key key, String code, long expiresAt) {
      if (key.subject()==null || key.subject().isBlank() || key.purpose()==null ||
          key.purpose().isBlank() || code==null || !code.matches("[0-9]{6}") || expiresAt<=0)
        throw new IllegalArgumentException("challenge");
      records.put(key,new Challenge(code,expiresAt,3)); // 新发行使同用途旧码失效
    }
    synchronized boolean verify(Key key, String supplied, long now) {
      if (now<0) throw new IllegalArgumentException("time");
      Challenge c=records.get(key);
      if (c==null) return false;
      if (now>=c.expiresAt()) { records.remove(key); return false; } // 到期边界不再有效
      boolean format=supplied!=null && supplied.matches("[0-9]{6}");
      boolean equal=format && MessageDigest.isEqual(
          c.code().getBytes(StandardCharsets.US_ASCII),
          supplied.getBytes(StandardCharsets.US_ASCII));
      if (equal) { records.remove(key); return true; } // 匹配与消费在同一原子边界内
      if (c.attemptsLeft()==1) records.remove(key);
      else records.put(key,new Challenge(c.code(),c.expiresAt(),c.attemptsLeft()-1));
      return false; // 格式错误也消耗现存挑战的验证预算
    }
  }
  static void check(boolean ok) { if(!ok) throw new AssertionError(); }
  public static void main(String[] args) throws Exception {
    OtpStore s=new OtpStore();
    Key login=new Key("account-1","LOGIN"), reset=new Key("account-1","RESET");
    s.issue(login,"123456",100);
    check(!s.verify(login,"000000",1) && s.verify(login,"123456",2));
    check(!s.verify(login,"123456",3));
    s.issue(login,"123456",100);
    check(!s.verify(login,"123456",100));
    s.issue(login,"123456",200);
    check(!s.verify(login,"x",101) && !s.verify(login,"000000",102) &&
          !s.verify(login,"111111",103) && !s.verify(login,"123456",104));
    s.issue(login,"123456",200); s.issue(login,"654321",200);
    check(!s.verify(login,"123456",110) && s.verify(login,"654321",111));
    s.issue(login,"123456",200); s.issue(reset,"654321",200);
    check(s.verify(reset,"654321",112) && s.verify(login,"123456",112));
    check(!s.verify(new Key("missing","LOGIN"),"123456",113));
    s.issue(login,"123456",200);
    ExecutorService pool=Executors.newFixedThreadPool(2);
    CountDownLatch start=new CountDownLatch(1);
    try {
      Callable<Boolean> call=() -> { start.await(); return s.verify(login,"123456",120); };
      Future<Boolean> a=pool.submit(call), b=pool.submit(call);
      start.countDown();
      check((a.get()?1:0)+(b.get()?1:0)==1); // 并发重放只能消费成功一次
    } finally { pool.shutdownNow(); }
    System.out.println("8 OTP lifecycle checks passed");
  }
}

这些特殊情况也要试一试

  • 验证码为固定教学输入,生产发行必须采用合适安全随机源及发送限制。
  • 本模型明文保存在内存,实际存储需结合凭证威胁模型、访问控制与保留期设计。
  • 验证码单次通过不表示会话已经建立,后续失败需要明确用户流程。
进阶选读 · 生产拓展:跨实例消费与会话发行的失败恢复

场景多个应用实例验证同一验证码,且验证成功后到创建会话之间可能崩溃。

做法用共享存储的原子脚本实现校验、失败预算与消费;对后续流程设置短期、用途受限的认证事务标识,或明确重新验证规则。不要把已消费验证码重新放回。会话发行和恢复接口都应防重复与越权。

复杂度每次挑战状态转换可以保持固定次数存储操作;恢复记录空间随保留窗口增长。

好处与代价更平滑恢复会增加凭证种类与状态机复杂度,简单业务可以采用清晰的重新验证体验。

05项目里怎么用

验证码保护要覆盖发送和验证两个入口。发送应按主体、来源和业务额度限制,重新发送不能成为无限重置猜测预算的通道;验证需要总请求预算与风险控制。错误响应和日志不得回显真实验证码或完整会话令牌。短信只能证明当时对该通信渠道的控制,重要操作还要考虑进一步验证策略。

令牌使用安全随机源并通过 TLS 传输。Cookie 或请求头的存放方案分别需要评估浏览器脚本访问、CSRF 与泄露风险。退出应使服务端会话失效;强制下线和账号禁用需要说明何时作用于已恢复的请求身份。长时间滑动续期还应结合绝对期限,避免凭证在不断访问中无限存活。

测试沿写入和读取两端建立闭环:实体转 DTO,DTO 编码为 Hash,Hash 解码并校验,再在请求结束清理。至少模拟成功、过期、损坏 id、匿名请求紧随登录请求,以及业务抛异常。异步场景应在实际任务线程验证上下文,而不能因为主请求线程 remove 了就认为全部工作线程已清理。

容易踩的坑

  • 登录存 User,读取时直接强转 UserDTO。
  • 校验验证码与删除分成两次无协调请求,允许并发重复通过。
  • 把 ThreadLocal 弱键机制当作可靠的请求清理。
  • 每次访问都续期,包括无效令牌或无限延长的旧会话。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
最小 DTO 会话类型和暴露字段边界清楚资料变化可能需要更新或重新加载共享登录态与请求身份
滑动期限加绝对期限兼顾活跃体验和凭证最长寿命需要保存发行时间并处理续期长期使用的浏览器会话
显式传递异步身份跨线程行为可追踪需要维护方法参数和上下文范围线程池或异步任务

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01验证码、令牌、DTO分别有什么职责?

展开参考答案与推导

验证码完成一次验证,令牌关联后续会话,DTO提供业务需要的身份字段。

02Redis会话过期会自动清空ThreadLocal吗?

展开参考答案与推导

不会。二者位置与生命周期不同,请求线程必须自行清理。

03为什么不能把User强转为UserDTO?

展开参考答案与推导

强转只检查对象的运行时类型,不复制属性;应构造目标对象并显式转换字段。

换个情况,还会处理吗

01线程A处理登录用户后又处理匿名请求,如何避免串身份?

展开参考答案与推导

可靠地在请求结束remove,并在入口建立明确上下文;匿名请求不能继承上一次线程值。

02验证码已消费但会话写入失败,能重新塞回验证码吗?

展开参考答案与推导

不宜。可能重新启用已使用凭证,应采用明确重新验证或受控认证事务恢复。

03异步任务读取不到UserHolder,应该怎么做?

展开参考答案与推导

识别线程边界,显式传递最小身份快照或使用框架上下文机制,并在工作线程结束清理。

写代码试一试

01到期点100,在100验证正确码是否成功?

输入 / 条件
expiresAt=100,now=100
输出 / 预期
false

提示 有效区间右端不包含到期点。

展开参考答案与推导

使用now>=expiresAt判定失效,并清除挑战。

02两个并发正确提交如何只成功一个?

输入 / 条件
同一个挑战并发两次验证
输出 / 预期
一true一false

提示 检查和消费同一原子边界。

展开参考答案与推导

第一个成功删除记录,第二个无法再取得有效挑战;跨实例需要共享原子存储协议。

03Redis字段id为abc时能构造已登录DTO吗?

输入 / 条件
id=abc
输出 / 预期
拒绝身份解码

提示 不能用默认0伪装成功。

展开参考答案与推导

验证数字格式、Long范围和业务正数条件;失败按无效会话处理并记录脱敏诊断。

读完之后,独立完成

课后作业:自己做一遍

本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能说明登录态的完整写入、读取、续期和清理链路,并检查匿名接口的行为。

本篇重点加练

会话寿命与刷新、鉴权次序共同决定持续在线和及时失效能否同时成立。

  • 整体 TTL
  • 刷新与鉴权拦截

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01面试场景

    短信发送被同一设备切换手机号连续调用,而登录请求重复到达。设计发送与验证两阶段的限制口径,并界定验证码和登录令牌的可记录信息。

    提交内容
    接口规则与滥用测试表
    怎样算完成
    • 分别覆盖发送和验证
    • 包含重复提交及敏感信息检查

    练到的知识点验证码与限流 · 随机登录令牌

  2. 作业 02代码推演

    为 UserDTO 定义仅含 id、nickName、icon 的 Hash 协议。给出字段缺失、额外字段、空字符串三组输入,逐字段分析恢复结果与拒绝规则。

    提交内容
    序列化契约与逐项推演
    怎样算完成
    • 区分可选字段与必需字段
    • 禁止凭 Java 类型名猜测转换行为

    练到的知识点UserDTO · Hash 序列化

  3. 作业 03设计评审

    登录用户访问公开页、受保护页和不存在的路径时分别经过哪些拦截步骤?把异常、提前返回及异步派发列入上下文生命周期评审。

    提交内容
    路径矩阵与时序图
    怎样算完成
    • 写清刷新触发条件
    • 逐个标出线程上下文的退出责任

    练到的知识点刷新与鉴权拦截 · 上下文清理

  4. 作业 04代码推演

    有效会话还剩很短寿命,两个请求先后完成读取与刷新,其中一个请求到达受保护接口较晚。定义每步使用的时间及失效判据,画出可能的观察窗口。

    提交内容
    会话时间线
    怎样算完成
    • 读取、刷新、授权时点分别记录
    • 包含过期边界相等的情况

    练到的知识点整体 TTL · 刷新与鉴权拦截

综合应用

  1. 作业 05排错实验

    在独立本地 Redis 中写入含超大 id、前导空格 id 和正常 id 的会话 Hash,再执行一次字段更新;观察 DTO 恢复与整个键 TTL 的变化。

    提交内容
    最小程序、命令与观测表
    怎样算完成
    • 记录类型转换失败的处理
    • 记录更新前后 TTL 和测量时刻

    练到的知识点整体 TTL · 字段类型转换

  2. 作业 06编程实践

    实现仅绑定回环地址的会话恢复演示,使用单工作线程交替处理有效、损坏、过期会话和匿名请求;用可控时钟或短 TTL 记录行为。

    提交内容
    Java 源码与可重放请求集
    怎样算完成
    • 响应不泄露会话令牌
    • 所有分支记录身份来源和资源释放证据

    练到的知识点UserDTO · Hash 序列化 · 整体 TTL · 字段类型转换 · 刷新与鉴权拦截 · 上下文清理

  3. 作业 07设计评审

    客户端长期后台轮询公开资源,产品却要求用户无主动操作一小时后退出。评审滑动有效期如何识别活动,并定义绝对寿命与空闲寿命的口径。

    提交内容
    有效期契约
    怎样算完成
    • 区分后台流量和用户活动
    • 说明服务端采用的可信时间来源

    练到的知识点整体 TTL

  4. 作业 08面试场景

    刷新拦截器依赖 Redis,鉴权拦截器读取恢复出的 DTO。Redis 响应迟到而请求已被取消时,评审哪些后续动作仍可能发生及其可观测性。

    提交内容
    取消后的动作清单
    怎样算完成
    • 区分响应生命周期和服务端执行
    • 指出可确认与未知状态

    练到的知识点刷新与鉴权拦截

  5. 作业 09排错实验

    在独立本地 Redis 与最小拦截器桩中,分别安排只访问公开路径、连续受保护请求及请求取消,记录会话最后访问时间和真实到期时间。

    提交内容
    本地程序与计时记录
    怎样算完成
    • 固定测试时间范围
    • 对测量精度及桩未验证的 Web 行为作说明

    练到的知识点整体 TTL · 刷新与鉴权拦截

进阶挑战 · 掌握前置后选做

  1. 作业 10面试场景

    产品提出“用户最多同时登录两台设备,退出单台不影响另一台”。说明现有单令牌模型需要哪些状态,评估续期、并发登录和退出请求乱序。

    提交内容
    状态图与故障测试计划
    怎样算完成
    • 覆盖第三台登录的竞争
    • 声明撤销的生效时间范围,以及过期数据的清理规则

    练到的知识点验证码与限流 · 随机登录令牌 · 整体 TTL · 刷新与鉴权拦截

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 11思维拓展 · 条件变化

    将会话改为“绝对寿命 8 小时且管理员撤销须在 10 秒内生效”,同时允许 Redis 暂时不可用。建立需求冲突表并提出可验证的服务承诺。

    提交内容
    约束表与验收提案
    怎样算完成
    • 明确不可用期间的业务决策
    • 不能同时默认所有目标无条件满足

    练到的知识点整体 TTL · 刷新与鉴权拦截

  2. 作业 12思维拓展 · 开放探究

    研究“收到有效令牌的请求就应自动延长登录”这一规则是否适用于共享终端与无人值守设备,为两类场景提出不同的可检验假设。

    提交内容
    研究问题与对照方案
    怎样算完成
    • 至少包含一个安全与便利性的冲突
    • 不用真实账户实验

    练到的知识点整体 TTL · 刷新与鉴权拦截

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗