社区作业提问:敲鸭
引言
xhy:思考,如果为了验证码相关的场景就引入 redis(中间件) 是否是必要的?给你两个作业: 1.假设只是为了验证码的场景就引入一个中间件是否是必要的? 2.如果 1 是没必要的则说明为什么?那这里的技术实现方案又有哪几种?如果 1 是有必要的 则也说明必要性
思考方向:从验证码的业务特性出发,结合存储和定时销毁的需求,判断在不同规模下的技术方案
xhy:
- jdk 的延迟队列的数据结构是什么?
- 是否还有其他的单机的解决办法?
- 如果有,不同方案的差别是什么?
- 如果有最好的方案,自己实现的话如何实现?
验证码特性分析:存储+定时销毁
- 验证码通常是短期且频繁访问的数据,存在几十秒或几分钟,不需要持久化;
- 一致性要求不高,只要生成的验证码与验证时对比一致即可。即便偶尔有部分缓存失效,用户也可以重新请求;
- 存储性:
- 数据量小;
- 低频写入高频读取:用户请求验证码时才生成,但每次提交验证码都要进行读取;
- 时效性:精准过期/自动清理
业务场景
单机
JDK 原生的延迟队列:
- 存储验证码:可以将验证码存储在内存中,并设置一个过期时间。
- 定时销毁:通过 JDK 提供的!!!#fff2cc DelayQueue!!! 或定时任务(ScheduledExecutorService)来在验证码过期后自动删除。
public class VerifyCodeManager {
// 延迟队列实现
private DelayQueue<DelayedCode> queue = new DelayQueue<>();
// 验证码存储
public void storeCode(String phone, String code) {
// 5分钟后过期
queue.offer(new DelayedCode(phone, code, 5, TimeUnit.MINUTES));
}
// 验证码校验
public boolean validateCode(String phone, String inputCode) {
// 清理过期验证码
cleanExpiredCodes();
// 遍历队列检查
return queue.stream()
.anyMatch(item -> item.getPhone().equals(phone) && item.getCode().equals(inputCode));
}
// 清理过期验证码
private void cleanExpiredCodes() {
long currentTime = System.currentTimeMillis();
queue.forEach(item -> {
if (item.getExpireTime() <= currentTime) {
// 删除过期验证码,队列中自动移除
queue.remove(item);
}
});
}
// 自定义延迟元素
class DelayedCode implements Delayed {
private String phone;
private String code;
private long expireTime;
public DelayedCode(String phone, String code, long delay, TimeUnit timeUnit) {
this.phone = phone;
this.code = code;
this.expireTime = System.currentTimeMillis() + timeUnit.toMillis(delay);
}
public String getPhone() {
return phone;
}
public String getCode() {
return code;
}
public long getExpireTime() {
return expireTime;
}
@Override
public long getDelay(TimeUnit unit) {
long diff = expireTime - System.currentTimeMillis();
return unit.convert(diff, TimeUnit.MILLISECONDS);
}
@Override
public int compareTo(Delayed o) {
return Long.compare(this.expireTime, ((DelayedCode) o).expireTime);
}
}
}
JDK延迟队列的数据结构:DelayQueue
源码
public class DelayQueue<E extends Delayed> extends AbstractQueue<E>
implements BlockingQueue<E> {
private final transient ReentrantLock lock = new ReentrantLock();
private final PriorityQueue<E> q = new PriorityQueue<E>();
}
- 承自
AbstractQueue
,实现了BlockingQueue
接口 PriorityQueue
是一个基于堆的数据结构,保证最小延迟的元素总是优先出队- 由泛型定义看出每个放入
DelayQueue
的元素都需要实现Delayed
接口
public interface Delayed extends Comparable<Delayed> {
long getDelay(TimeUnit unit); // 获取任务的剩余延迟时间
int compareTo(Delayed o); // 比较任务的优先级
}
DelayQueue
就像一个 倒计时沙漏系统。你把任务放入队列,它们会根据自己的“到期时间”排队,沙子流完了任务才会被取出,避免了频繁轮询。缺点在于DelayQueue
是一个 无界队列,你可以一直往队列里丢任务,可能会导致内存溢出。而在多线程中,如果不小心管理任务的“唯一性”或者线程间没有良好的协作,可能会导致任务重复处理或者错乱。就像几个办公室员工同时从桌子上拿文件,如果没有规则,可能会拿错文件或者重复工作。
在验证码场景中,-验证码的存储和过期管理由 DelayQueue
来负责。DelayQueue
保证最早到期的验证码最先被移除,从而使得系统能够自动清理过期的验证码。
单机场景下的其他解决方案
ScheduledExecutorService
基于线程池设计的定时任务类,在java的JUC包中,每个调度任务都会分配到线程池中的一个线程去执行,并发不受影响,各自执行各自的。
它有三个方法:
- schedule:只执行一次调度
- scheduleAtFixedRate:类似固定发车时间表,间隔时间是固定的,到点就发车。如果任务超过间隔时间,直接开始下一个任务
- scheduleWithFixedDelay:按照固定延迟时间调度任务。无论任务本身执行了多长时间,都会等待它完成之后再开始计时间隔,间隔完了再执行下一个。
结合验证码:
- 自动过期:
ScheduledExecutorService
能方便地为验证码设置过期时间,并在时间到达后自动清除,无需手动检查。 - 线程池管理:调度任务使用线程池来管理,这样即使有多个用户请求验证码,每个任务也可以独立并发地执行,互不干扰,保证了高效的执行。
import java.util.concurrent.*;
public class VerifyCodeManager {
// 线程池用于管理调度任务
private ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
// 存储验证码
private ConcurrentMap<String, String> codes = new ConcurrentHashMap<>();
// 存储验证码并设置过期时间
public void storeCode(String phone, String code) {
codes.put(phone, code);
// 设置5分钟后自动清除验证码
scheduler.schedule(() -> {
codes.remove(phone);
System.out.println("验证码过期清除: " + phone);
}, 5, TimeUnit.MINUTES);
}
// 校验验证码
public boolean validateCode(String phone, String inputCode) {
String storedCode = codes.get(phone);
return storedCode != null && storedCode.equals(inputCode);
}
// 定时清理过期验证码的任务(可选功能)
public void periodicCleanup() {
scheduler.scheduleWithFixedDelay(() -> {
System.out.println("每分钟检查验证码有效性...");
// 可以根据业务逻辑定期检查验证码有效性
}, 0, 1, TimeUnit.MINUTES);
}
public static void main(String[] args) {
VerifyCodeManager manager = new VerifyCodeManager();
manager.storeCode("1234567890", "1234");
manager.periodicCleanup();
}
}
Timer 和 TimerTask
Timer
:是一个容器,管理所有的定时任务。TimerTask
是放进去的“清理任务”,每个任务都定义了“什么时候清理,清理什么”。
结合验证码:
- 每个验证码都有它的生命周期,比如存储后 5 分钟过期。
- 通过
Timer
来管理这些生命周期,每个验证码都有一个“定时清理的提醒” TimerTask
就是具体的清理任务,把失效的验证码从系统中移除- 如果你想定期检查验证码是否已经过期,可以设置一个定时任务,每 10 分钟执行一次,去清理已经过期的验证码
import java.util.*;
import java.util.concurrent.*;
public class VerifyCodeManager {
// 存储验证码的Map
private Map<String, String> codes = new HashMap<>();
// 存储定时任务的Timer实例
private Timer timer = new Timer();
// 存储验证码并设置过期时间
public void storeCode(String phone, String code) {
codes.put(phone, code);
// 创建一个TimerTask用于删除过期验证码
TimerTask task = new TimerTask() {
@Override
public void run() {
codes.remove(phone);
System.out.println("验证码已过期,手机号:" + phone);
}
};
// 设置任务在5分钟后执行
timer.schedule(task, 5 * 60 * 1000);
}
// 校验验证码
public boolean validateCode(String phone, String inputCode) {
String storedCode = codes.get(phone);
return storedCode != null && storedCode.equals(inputCode);
}
// 可选:定时清理过期验证码的任务
public void periodicCleanup() {
TimerTask cleanupTask = new TimerTask() {
@Override
public void run() {
System.out.println("定期检查验证码...");
// 可以实现遍历验证码并清除过期的验证码
}
};
// 设置定时任务,每10分钟检查一次
timer.scheduleAtFixedRate(cleanupTask, 0, 10 * 60 * 1000);
}
public static void main(String[] args) {
VerifyCodeManager manager = new VerifyCodeManager();
manager.storeCode("1234567890", "1234");
manager.periodicCleanup();
}
}
传统方案比较
DelayQueue
:延迟+优先队列,适合任务到期后自动被移除的场景。但它是阻塞式的,无法定时检查集合的状态,只能“等到”队列中的元素被消费。ScheduledExecutorService
:线程池调度器,适用于定时任务的执行,可以更精确地控制任务执行的时间。但基于线程池的,适合较复杂的并发任务,配置上可能略复杂。Timer
和TimerTask
:早期的定时任务调度方式,适合简单的场景。然而,Timer
的线程是单线程的,如果一个任务执行时间过长,其他任务会被阻塞,且不支持并发任务执行。
自定义方案设计
- 一个独立线程:设计一个专门的线程负责定时检查过期数据。与
ScheduledExecutorService
或Timer
相比,创建一个单独的检查线程开销小,而且无需考虑线程池的管理、队列容量等复杂配置。 - 自定义检查周期:可以根据需求调整检查的频率,例如每秒检查一次,每 10 秒检查一次,或者更灵活的时间间隔。
- 逻辑清晰:直接操作
ConcurrentHashMap
,在一个专门的线程中进行检查和清理,避免多线程竞态问题,减少并发管理的复杂度。
- 主要组件
ConcurrentHashMap<String, CodeItem> codeMap
:- 存储所有的验证码的,键是手机号,值是验证码及其创建时间的封装类
CodeItem
。
- 存储所有的验证码的,键是手机号,值是验证码及其创建时间的封装类
ExpirationChecker
线程:- 自定义的线程,定期检查验证码是否过期。每隔 1 秒检查一次
codeMap
中存储的验证码,如果某个验证码的创建时间超过了 5 分钟,它就会被移除。
- 自定义的线程,定期检查验证码是否过期。每隔 1 秒检查一次
CodeItem
类:- 封装验证码的内容+创建时间。每个
CodeItem
对象包含一个验证码字符串和创建时间(毫秒级的时间戳)。
- 封装验证码的内容+创建时间。每个
- 流程:存储--->过期检查---->校验验证码
- 代码
public class VerifyCodeManager {
// 线程安全map
private ConcurrentHashMap<String, CodeItem> codeMap = new ConcurrentHashMap<>();
// 自定义过期检查线程
public class ExpirationChecker extends Thread {
@Override
public void run() {
while (true) {
long now = System.currentTimeMillis();
codeMap.entrySet().removeIf(entry ->
now - entry.getValue().createTime > 5 * 60 * 1000
);
try {
Thread.sleep(1000); // 每秒检查一次
} catch (InterruptedException e) {
break;
}
}
}
}
// 验证码存储项
private class CodeItem {
String code;
long createTime;
CodeItem(String code) {
this.code = code;
this.createTime = System.currentTimeMillis();
}
}
public VerifyCodeManager() {
// 启动过期检查线程
new ExpirationChecker().start();
}
public void storeCode(String phone, String code) {
codeMap.put(phone, new CodeItem(code));
}
public boolean validateCode(String phone, String inputCode) {
CodeItem item = codeMap.get(phone);
return item != null && item.code.equals(inputCode);
}
}
对比
特性/方案 | 自定义过期检查线程 |
|
|
|
基本原理 | 单独的线程定期检查过期数据 | 线程池执行定时任务 | 基于优先队列,延迟任务按时间顺序消费 | 单线程定时任务调度,基于时间点执行 |
适用场景 | 简单的过期管理、缓存清理、验证码过期等 | 并发任务调度、复杂的定时任务处理 | 延迟任务(按时间顺序执行),适合单任务延迟 | 简单的定时任务调度,适用于单线程环境 |
并发处理能力 | 适合单一线程处理,无法处理高并发任务 | 支持并发任务,能够并行执行多个任务 | 仅支持队列中的任务按顺序消费,单线程处理 | 单线程,不能并行处理多个任务 |
任务调度精度 | 可自定义周期和执行频率,但需要手动控制 | 精确控制任务执行时间,支持周期性调度 | 延迟任务严格按时间顺序执行 | 精度较高,但如果任务执行时间过长,会阻塞后续任务 |
内存消耗 | 低,资源消耗较少,适合小规模场景 | 较高,尤其是大量任务时,线程池可能占用较多资源 | 内存消耗较低,适合存储少量任务 | 低,但由于是单线程,任务数量过多时可能会导致瓶颈 |
易用性 | 实现简单,灵活,但需要手动管理线程生命周期和异常 | 使用简单,且有较为丰富的接口支持 | 使用复杂,较适合单任务延迟,不适合多任务场景 | 简单易用,但容易因任务过长时间阻塞后续任务 |
线程安全 | 线程安全,由于使用 | 内部实现线程池,线程安全 | 线程安全,基于 | 单线程,不存在并发问题 |
任务移除方式 | 手动检查任务是否过期并删除 | 任务执行完毕后自动移除 | 任务到期后自动移除 | 任务执行完成后自动移除 |
适用的任务类型 | 适用于需要定期检查、定时清理的简单任务 | 适用于定期或周期性任务调度,多任务并发处理 | 适用于按时间延迟执行任务,单个任务延迟 | 适用于简单的定时任务,不需要复杂调度 |
系统复杂度 | 低,简单的线程管理 | 中等,需要配置线程池和管理任务 | 高,需要处理队列管理、元素延迟管理等 | 中等,较为简单但需要处理单线程调度问题 |
任务失败的处理 | 需要手动管理失败重试机制 | 可以指定失败重试策略 | 通过队列管理自动处理失败任务 | 如果任务超时或执行失败,可能会影响后续任务的执行 |
分布式
如果是 分布式系统,每个 JVM 实例的内存是隔离的,使用本地缓存(如 HashMap
)就无法保证验证码数据在不同节点间共享。这时,必须引入 集中式缓存系统,比如 Redis 来实现分布式存储和定时销毁。
- 集中存储:可以跨多台服务器共享验证码数据,避免因不同 JVM 实例而无法访问的情况。
- 定时销毁:Redis 提供了设置过期时间的功能,验证码可以在存储时设置 TTL(过期时间),当过期时间到达时,Redis 会自动销毁该键值对。
@Service
public class VerifyCodeService {
@Autowired
private RedisTemplate redisTemplate;
// 存储验证码
public void storeCode(String phone, String code) {
// 设置5分钟过期
redisTemplate.opsForValue().set(
"verify:code:" + phone,
code,
5,
TimeUnit.MINUTES
);
}
// 验证码校验
public boolean validateCode(String phone, String inputCode) {
String redisCode = (String) redisTemplate.opsForValue()
.get("verify:code:" + phone);
return inputCode.equals(redisCode);
}
}
/ | 单机 | 分布式 |
优点 | 轻量级/无额外依赖 | 持久化/自动国企 |
缺点 | 无法集群/重启丢失 | 引入中间件复杂度/额外运维成本和网络开销 |
over