Redis分布式锁解决多实例同时执行定时任务的问题以及扫描延时任务时对桶的获取问题
创建定时任务,删除回收站和保存未上传完文件的临时目录。同时引入 Redis 分布式锁解决多实例同时执行定时任务的问题以及扫描延时任务时对桶的获取问题。
一、创建定时任务,删除回收站和保存未上传完文件的临时目录
1. 背景
- 回收站中文件是“软删除”的,实际仍占据磁盘空间,需定期清理。
- 用户上传过程中未完成的文件片段(保存在临时目录)也会长期占据磁盘资源。
2. 实现方式
-
使用 定时任务调度器(如 Spring
@Scheduled
、xxl-job、Quartz):- 定期扫描回收站中的“过期文件”并从磁盘删除;
- 检查临时目录中超过一定时间未完成上传的文件片,并将其清除。
二、引入 Redis 分布式锁解决多实例同时执行定时任务的问题
1. 问题:多实例环境下的并发调度问题
- 假如服务部署了多个节点(多实例),每个节点都运行定时任务。
- 如果不加限制,同一批数据可能会被多个节点重复处理或冲突操作(比如多次删除、重复扫描)。
2. 解决方案:引入 Redis 分布式锁
- 使用 Redis 提供的
SETNX
+ TTL 机制或 Redisson 分布式锁。 - 例如:
if (redis.setnx("cron:clear_trash_lock", "1", 30000)) {// 当前节点获得锁,执行清理逻辑clearTrash();redis.del("cron:clear_trash_lock");
}
- 保证任一时刻只有一个实例能执行定时清理任务,避免重复操作。
三、解决扫描延时任务时对“桶”的获取问题
1. 背景:Zset 分桶分片
- 延迟任务通过 Redis Zset 存储,按时间排序。
- 为提高调度精度与性能,Zset 被分成多个桶(bucket),每个桶维护不同时间段或哈希范围的任务。
2. 问题:多实例并发扫描时对桶冲突
- 多个实例可能同时去扫描同一个桶,从而导致重复调度或资源竞争。
3. 解决方案:Redis 锁 + 分布式桶调度
- 对每个桶加锁,如:
lockKey = "delay:bucket:scan:" + bucketId
- 某个实例拿到锁后,才可以扫描这个桶中的任务。
- 可以采用 Redis 锁或使用 Redis
SETNX
实现轻量级互斥。
✍️ 总结归纳:
我们通过定时任务定期清理回收站中的过期文件及未完成上传的临时目录,避免磁盘空间被长期占用。在多实例部署场景中,我们引入 Redis 分布式锁,确保同一任务在任一时刻只被一个节点执行。同时,为了精确调度 Redis Zset 分桶中的延时任务,我们也在桶级别加锁,避免多个实例重复获取和调度同一个桶中的任务,从而保障系统的调度准确性与性能稳定性。
111
🔐 一、Redis 分布式锁解决多实例同时执行定时任务的问题
✅ 背景问题
在分布式部署中,应用通常部署多个实例(节点),每个节点都有自己的定时任务调度线程。如果不做限制,就会导致:
- 同一批定时任务被多个节点重复执行;
- 扫描回收站、清理临时目录等任务重复操作,产生资源浪费,甚至并发冲突(例如重复删除、重复写数据库)。
✅ 技术方案:Redis 分布式锁
目标: 保证某个定时任务在某一时间点 只能由一个实例执行一次。
✅ 实现细节
- 使用 Redis 的原子指令
SET key value NX PX
实现锁机制。 - 例如使用 Redisson,或者自己用底层 Redis API 实现:
Boolean isLock = redisTemplate.opsForValue().setIfAbsent("task:clear_trash_lock", "1", 30, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(isLock)) {try {// 执行定时任务cleanTrashDir();} finally {redisTemplate.delete("task:clear_trash_lock");}
}
关键点:
点 | 说明 |
---|---|
SETNX | 保证只有一个线程能设置成功(抢到锁) |
PX 设置过期时间 | 避免宕机后锁永久阻塞 |
最终释放锁 | 避免死锁 |
唯一标识 + Lua 脚本 | 防止释放别人加的锁,可用 UUID +Lua实现原子检查+删除 |
🪣 二、扫描延时任务时对桶的获取问题
✅ 背景问题
-
延时任务被存储在多个 Redis Zset 桶中(
bucket_0
~bucket_n
),每个桶存放一批按照时间排序的任务。 -
多实例并发扫描时,可能出现多个实例同时扫描同一个桶,导致:
- 延时任务重复调度、重复消费
- 系统资源浪费,消费幂等性难保障
✅ 技术方案:桶级分布式锁(Redis 分片 Zset 锁)
目标: 在同一时间点,每个桶只由一个实例进行扫描和任务派发。
✅ 实现细节:
- 每个实例轮询一部分桶(如 hash 分区 + 轮询)
- 在扫描桶前,尝试加锁,例如:
String lockKey = "delay:bucket:lock:" + bucketId;
Boolean acquired = redisTemplate.opsForValue().setIfAbsent(lockKey, instanceId, 5, TimeUnit.SECONDS);if (Boolean.TRUE.equals(acquired)) {try {// 安全地扫描该桶中的任务scanAndPushDelayedTasks(bucketId);} finally {redisTemplate.delete(lockKey); // 释放锁}
}
关键点:
点 | 说明 |
---|---|
桶级锁(bucket:lock:{id} ) | 限定某个 Zset 桶只被一个实例扫描 |
锁有效时间 | 避免故障节点卡死,建议设置 3~5 秒 |
实例分桶策略 | 例如 mod 分桶(bucketId % instanceCount == instanceIndex )或按哈希分配 |
锁释放安全 | 和上面一样,可以用 UUID + Lua 脚本确保释放的是自己加的锁 |
✅ 实战效果
- 提高调度精度:保证 Zset 桶中任务只被单点准确调度。
- 提升系统性能与稳定性:避免多个实例争抢同一个任务或重复调度。
- 增强系统容错性:即使某个实例宕机,其他实例会因锁过期自动接管任务处理。
🧠 补充建议
- 推荐使用 Redisson 框架,它支持可靠的分布式锁、WatchDog 自动续租等功能,简化开发。
- 桶调度最好加监控(如扫描频率、任务分布、扫描耗时等),提升可观察性。
- Zset 中任务执行后应及时清除,避免任务堆积影响调度性能。
🚀 高可用延时任务调度:如何用 Redis 分布式锁与分桶机制精准调度任务
在一个分布式系统中,尤其是处理上传任务、清理临时文件、调度延时操作时,如何保障定时任务只被执行一次,并确保任务调度的准确性与高性能,是一个常见却又容易踩坑的技术挑战。
本文结合实际案例,分享我们在文件上传场景下,如何通过 Redis + 分布式锁 + 分片分桶机制实现高可用的延时任务调度。
📌 背景场景
我们设计了一个网盘系统,用户上传文件时采用分片方式,为了防止恶意攻击或长时间未完成上传而占用磁盘,我们为每个上传任务设置了一个“延时清理机制”。
这个机制的执行核心依赖两个定时任务:
- 定期清理过期的文件临时目录
- 定期扫描 Redis Zset 中的延时任务并发送到 Kafka
⚠️ 由于是分布式部署,这些定时任务若无控制,就会在多个服务实例中重复执行,引发多种问题:
- 重复清理、重复调度、磁盘 I/O 浪费
- Kafka 消息重复发送,甚至导致数据不一致
🚧 技术挑战
- 多个实例同时触发定时任务,如何只执行一次?
- 延时任务存储在 Redis Zset 中,多个实例并发扫描,如何避免重复调度?
- 如何提升任务调度精度,避免任务延迟或“扎堆”?
🧩 解决方案:Redis 分布式锁 + 分桶调度机制
🔐 一、使用 Redis 分布式锁保障定时任务单实例执行
为了保证清理任务(如清理回收站、临时文件夹)在同一时间点只由一个实例执行,我们引入 Redis 分布式锁。
✅ 实现方式:
Boolean isLock = redisTemplate.opsForValue().setIfAbsent("lock:clear_trash", "instance1", 30, TimeUnit.SECONDS
);
if (Boolean.TRUE.equals(isLock)) {try {// 执行清理任务} finally {redisTemplate.delete("lock:clear_trash");}
}
setIfAbsent
保证只有一个实例能拿到锁- 设置过期时间避免宕机后锁悬空
- 也可用 UUID + Lua 脚本实现「安全释放锁」
🪣 二、Redis Zset 分片分桶调度机制
我们将所有延时任务分布在多个 Redis Zset 中,形成逻辑桶:
delay:bucket:0
delay:bucket:1
- …
delay:bucket:n
每个桶按任务到期时间排序存储任务,定时任务扫描每个桶并将到期任务投递到 Kafka。
✅ 分桶+锁调度机制
为了避免多个实例同时扫描同一个桶,导致任务重复调度,我们引入桶级分布式锁:
String lockKey = "lock:bucket:" + bucketId;
Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, instanceId, 5, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(locked)) {try {scanAndPush(bucketId);} finally {redisTemplate.delete(lockKey);}
}
- 每个桶在每次扫描前必须加锁,防止并发调度
- 每个任务有唯一 ID,确保下游 Kafka 消费时幂等(如:文件片
userId+md5+index
)
🎯 技术优势
技术组件 | 作用 | 优势 |
---|---|---|
Redis 分布式锁 | 控制任务唯一执行 | 简洁、轻量、高性能 |
分片 Zset 桶 | 存储延时任务 | 提高并发调度精度 |
桶级锁 | 控制每个桶的调度权限 | 避免重复投递 |
Kafka | 解耦任务派发和执行 | 可异步扩展、持久消费 |
唯一任务 ID | 保证幂等 | 防止重复调度执行 |
🔍 示例:文件片延时清理
-
用户上传某个大文件(20个分片)
-
每上传一个分片,向
delay:bucket:{n}
加入一条延时任务(5秒后过期) -
定时任务每秒扫描所有桶,发现到期任务就发送到 Kafka
-
Kafka 消费后执行:
- 更新
user_unfinished_space
- 清理无效文件片
- 更新
-
上传完成时,再反向扣除
unfinished
空间值,并更新到数据库
🧠 总结
在这个延时任务场景中:
- Redis 分布式锁+桶锁机制让定时任务更安全、稳定
- Zset 分桶机制实现了任务的分布式、高精度调度
- Kafka 作为下游异步处理组件,为系统解耦和扩展性提供保障
这一方案不仅适用于网盘系统上传任务的清理场景,也可以广泛应用于订单超时处理、自动取消支付、延迟消息等领域。
多实例同时执行定时任务的问题 在单体应用中会不会遇到,还是说必须要在微服务情境下
这个问题在单体应用中一般不会遇到,而在微服务或多实例部署场景下则是必须解决的问题。:
✅ 一、单体应用中为什么不会遇到这个问题?
单体应用的特征:
- 只有一个应用实例在运行
- 所有定时任务由当前进程调度执行
意味着:
- 所有定时任务只有一个“调度者”
- 不存在任务被多个实例同时触发的并发冲突
- 不需要加锁控制并发执行
👉 所以:在单体应用中无需担心定时任务重复执行的问题
🚨 二、微服务 / 多实例部署中为什么会出现这个问题?
微服务 or 集群部署的特征:
- 同一个服务有多个实例(水平扩展)
- 每个实例都有定时任务调度器(比如 Spring 的
@Scheduled
)
问题就出现了:
- 如果你在多个实例中都开启了定时任务
- 就会出现“每个实例都在执行一遍同样的任务”的情况
后果包括:
- 重复清理、重复发消息、重复写库等副作用
- 引发数据不一致、任务重复执行、资源浪费,甚至服务崩溃
✅ 三、典型解决方案(仅在分布式架构中用)
为了避免这种情况,必须做“任务抢占”或者“主节点执行”机制:
方案 | 核心思想 | 使用场景 |
---|---|---|
Redis 分布式锁 | 哪个实例抢到锁就执行任务,抢不到就跳过 | 简单、轻量 |
数据库乐观锁 | 用 DB 的版本号或行锁控制执行唯一性 | 数据量不大时可用 |
专门的调度中心(如 xxl-job) | 由调度平台控制任务分发 | 大型调度系统 |
Leader 选举(如 Zookeeper、etcd) | 选一个主节点执行所有任务 | 状态机一致性场景 |
🧠 总结
应用模式 | 是否会遇到多实例定时任务冲突? | 是否需要分布式锁? |
---|---|---|
单体应用 | ❌ 不会(只有一个调度器) | ❌ 不需要 |
微服务 / 多实例 | ✅ 会(每个实例都有调度器) | ✅ 需要使用锁或主控机制 |
所以:
“多实例定时任务并发执行冲突”是微服务架构下的天然问题,单体应用则天生规避这个问题。
单体应用可以进行实例部署吗
✅ 一、什么是单体应用?
单体应用(Monolithic Application) 是指:
- 整个系统打包成一个整体(通常是一个大 jar 包或 war 包)
- 所有模块(用户、订单、商品、支付等)都在一个进程中运行
- 通常部署为 一个实例
✅ 二、什么是实例部署?
实例部署 指的是:
- 将同一个应用部署成多个运行实例(多个进程),例如部署到多个服务器或容器中
- 多个实例对外提供相同的服务,通过负载均衡器分发请求
❓那么问题来了:单体应用可以进行实例部署吗?
✅ 答案是:可以的!
虽然“单体应用”本质上是一个整体,但它仍然可以部署多份,即:
单体应用 × 多实例部署 是完全成立的。
你可以将同一个单体应用复制 2 个或多个实例部署在不同的机器或容器中,让它们同时运行,实现 横向扩展,提升系统吞吐能力。
🔁 例子:
假设你有一个基于 Spring Boot 构建的单体系统 MyApp.jar
,你可以这样做:
- 部署在两台服务器 A、B 上
- 启动两个进程:
java -jar MyApp.jar
on A 和 on B - 通过 Nginx 或其他负载均衡器将请求路由到两个实例
⚠️ 三、需要注意的点
1. 多实例部署后的问题
- 数据一致性(多个实例访问同一个数据库)
- 定时任务重复执行(每个实例都执行一次,参考你前面问的“Redis 分布式锁”问题)
- 状态保持问题(如果你把会话存在内存中,用户登录信息无法共享)
2. 解决方案
- 使用 分布式缓存(如 Redis)共享状态
- 使用 分布式锁 控制任务唯一执行
- 使用 负载均衡+会话粘性 或 token 登录机制 解决登录态问题
🧠 总结
问题 | 答案 |
---|---|
单体应用可以多实例部署吗? | ✅ 可以,尤其是为了提高并发处理能力 |
是否等同于微服务? | ❌ 不等同,微服务是逻辑拆分,单体是逻辑集中 |
部署多个单体实例时要注意什么? | 状态共享、定时任务重复、分布式锁、负载均衡等问题 |
Redisson 分布式锁的使用
✅ 一、Redisson 分布式锁的原理
Redisson 是一个基于 Redis 的 Java 客户端,提供了丰富的分布式数据结构和分布式锁实现。
核心机制:
- 利用 Redis 的
SET key value NX PX timeout
命令实现加锁(原子性) - 默认锁过期时间,防止死锁
- 自动续期机制(Watchdog),防止任务时间过长导致锁被误释放
- 支持公平锁、可重入锁、读写锁等模式
✅ 二、项目中引入 Redisson(Maven)
<dependency><groupId>org.redisson</groupId><artifactId>redisson-spring-boot-starter</artifactId><version>3.23.2</version> <!-- 可替换为最新版本 -->
</dependency>
✅ 三、配置 Redisson 客户端
示例:单节点 Redis 配置(application.yml)
spring:redis:host: 127.0.0.1port: 6379redisson:config: classpath:redisson-single.yaml
redisson-single.yaml 配置内容:
singleServerConfig:address: "redis://127.0.0.1:6379"database: 0
✅ 注意:支持集群、哨兵、云托管等模式,这里以单节点为例。
✅ 四、代码示例:定时任务使用 Redisson 分布式锁
1. 配置 Redisson 客户端 Bean(如未使用 starter 自动配置)
@Configuration
public class RedissonConfig {@Beanpublic RedissonClient redissonClient() {Config config = new Config();config.useSingleServer().setAddress("redis://127.0.0.1:6379");return Redisson.create(config);}
}
2. 使用分布式锁保护定时任务
@Component
public class CleanupScheduler {@Autowiredprivate RedissonClient redissonClient;private static final String LOCK_KEY = "task:cleanup:lock";@Scheduled(cron = "0 0 * * * ?") // 每小时执行一次public void cleanTempFiles() {RLock lock = redissonClient.getLock(LOCK_KEY);boolean isLocked = false;try {// 尝试获取锁,最多等待5秒,锁的自动释放时间为30秒isLocked = lock.tryLock(5, 30, TimeUnit.SECONDS);if (isLocked) {System.out.println("成功获得分布式锁,开始清理任务...");// 执行定时任务核心逻辑doCleanup();} else {System.out.println("未获得分布式锁,本实例跳过执行。");}} catch (InterruptedException e) {Thread.currentThread().interrupt();e.printStackTrace();} finally {if (isLocked) {lock.unlock(); // 手动释放锁System.out.println("释放分布式锁");}}}private void doCleanup() {// 删除未上传完的临时文件夹// 清理过期的 Zset 延时任务// 记录日志等操作}
}
✅ 五、补充细节
技术细节 | 说明 |
---|---|
tryLock(timeout, leaseTime) | timeout 是最大等待时间,leaseTime 是锁的自动过期时间 |
Watchdog 自动续期 | 如果你只调用 lock() 而非 tryLock,Redisson 会自动每隔 10 秒续期,默认锁自动续期时间为 30 秒 |
unlock 必须放 finally 中 | 保证任务执行异常时也能释放锁 |
锁粒度控制 | key 中可加入具体业务维度(如任务名、机器名)提升可扩展性 |
✅ 六、总结
Redisson 是一个高可用、高性能的分布式锁实现方案,尤其适用于微服务多实例部署场景。它封装了复杂的 Redis 锁逻辑,使用简单、安全,能有效防止定时任务重复执行的问题。
如果你需要进一步讲解 集群 Redis + Redisson 的高可用锁机制 或 公平锁 / 读写锁 使用方式,也可以继续告诉我。