引入 Redis 或中间件的问题 | jdk延迟队列及其他方案

社区作业提问:敲鸭

引言

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:线程池调度器,适用于定时任务的执行,可以更精确地控制任务执行的时间。但基于线程池的,适合较复杂的并发任务,配置上可能略复杂。
  • TimerTimerTask:早期的定时任务调度方式,适合简单的场景。然而,Timer 的线程是单线程的,如果一个任务执行时间过长,其他任务会被阻塞,且不支持并发任务执行。

自定义方案设计

  • 一个独立线程:设计一个专门的线程负责定时检查过期数据。与 ScheduledExecutorServiceTimer 相比,创建一个单独的检查线程开销小,而且无需考虑线程池的管理、队列容量等复杂配置。
  • 自定义检查周期:可以根据需求调整检查的频率,例如每秒检查一次,每 10 秒检查一次,或者更灵活的时间间隔。
  • 逻辑清晰:直接操作 ConcurrentHashMap,在一个专门的线程中进行检查和清理,避免多线程竞态问题,减少并发管理的复杂度。
  1. 主要组件
  • ConcurrentHashMap<String, CodeItem> codeMap
    • 存储所有的验证码的,键是手机号,值是验证码及其创建时间的封装类 CodeItem
  • ExpirationChecker 线程
    • 自定义的线程,定期检查验证码是否过期。每隔 1 秒检查一次 codeMap 中存储的验证码,如果某个验证码的创建时间超过了 5 分钟,它就会被移除。
  • CodeItem
    • 封装验证码的内容+创建时间。每个 CodeItem 对象包含一个验证码字符串和创建时间(毫秒级的时间戳)。
  1. 流程:存储--->过期检查---->校验验证码
  2. 代码
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);  
    }  
}

对比

特性/方案

自定义过期检查线程

ScheduledExecutorService

DelayQueue

Timer

基本原理

单独的线程定期检查过期数据

线程池执行定时任务

基于优先队列,延迟任务按时间顺序消费

单线程定时任务调度,基于时间点执行

适用场景

简单的过期管理、缓存清理、验证码过期等

并发任务调度、复杂的定时任务处理

延迟任务(按时间顺序执行),适合单任务延迟

简单的定时任务调度,适用于单线程环境

并发处理能力

适合单一线程处理,无法处理高并发任务

支持并发任务,能够并行执行多个任务

仅支持队列中的任务按顺序消费,单线程处理

单线程,不能并行处理多个任务

任务调度精度

可自定义周期和执行频率,但需要手动控制

精确控制任务执行时间,支持周期性调度

延迟任务严格按时间顺序执行

精度较高,但如果任务执行时间过长,会阻塞后续任务

内存消耗

低,资源消耗较少,适合小规模场景

较高,尤其是大量任务时,线程池可能占用较多资源

内存消耗较低,适合存储少量任务

低,但由于是单线程,任务数量过多时可能会导致瓶颈

易用性

实现简单,灵活,但需要手动管理线程生命周期和异常

使用简单,且有较为丰富的接口支持

使用复杂,较适合单任务延迟,不适合多任务场景

简单易用,但容易因任务过长时间阻塞后续任务

线程安全

线程安全,由于使用 ConcurrentHashMap 存储数据

内部实现线程池,线程安全

线程安全,基于 PriorityQueue 实现

单线程,不存在并发问题

任务移除方式

手动检查任务是否过期并删除

任务执行完毕后自动移除

任务到期后自动移除

任务执行完成后自动移除

适用的任务类型

适用于需要定期检查、定时清理的简单任务

适用于定期或周期性任务调度,多任务并发处理

适用于按时间延迟执行任务,单个任务延迟

适用于简单的定时任务,不需要复杂调度

系统复杂度

低,简单的线程管理

中等,需要配置线程池和管理任务

高,需要处理队列管理、元素延迟管理等

中等,较为简单但需要处理单线程调度问题

任务失败的处理

需要手动管理失败重试机制

可以指定失败重试策略

通过队列管理自动处理失败任务

如果任务超时或执行失败,可能会影响后续任务的执行

分布式

如果是 分布式系统,每个 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

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值