凌晨3点的闹钟:分布式定时任务设计实战
凌晨3点,服务器警报声
凌晨3点,你被一阵刺耳的警报声从梦中惊醒。监控系统显示,服务器负载异常,应用程序的日志文件中充满了重复执行的任务记录。显然,你的定时任务系统出现了问题,导致任务在多个节点上重复执行。如果你是一位运维人员或开发人员,面对这样的场景,一定心急如焚。今天,我们就来解决这个问题,通过设计一个可靠的分布式定时任务系统,确保任务在集群中只执行一次,同时具备高可用性和扩展性。
业务场景与问题
假设你正在为一家电商平台设计一个定时任务系统,用于在每天凌晨1点清理过期的购物车数据。由于业务规模不断扩大,单个服务器已经无法满足需求,你需要将任务分布到多个服务器上。然而,分布式环境中最大的挑战之一就是如何确保同一个任务不会在多个节点上重复执行。你可能想到用数据库或者消息队列来解决,但这里我们使用一个更加轻量级、易于维护的方案------Redis。
分布式定时任务的核心思路
在分布式环境中,定时任务的执行需要一个中心化的调度机制,确保任务在集群中只执行一次。Redis 的分布式锁机制非常适合用来解决这个问题。通过 Redis 的 SETNX(Set if Not Exists)命令可以实现一个简单的锁机制,但它不够安全,容易出现死锁。因此,我们将使用 Redis 的 SET 命令,结合过期时间来实现一个更可靠的分布式锁。
实现步骤
1. 引入 Redis 依赖
如果你使用的是 Java 项目,首先需要在 pom.xml 中引入 Redis 依赖:
xml
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>4.0.1</version>
</dependency>
2. 创建分布式锁类
接下来,我们创建一个 DistributedLock 类,用于管理 Redis 分布式锁:
java
import redis.clients.jedis.Jedis;
public class DistributedLock {
private Jedis jedis;
private String lockKey;
private long acquireTimeout;
private long lockTimeout;
public DistributedLock(Jedis jedis, String lockKey, long acquireTimeout, long lockTimeout) {
this.jedis = jedis;
this.lockKey = lockKey;
this.acquireTimeout = acquireTimeout;
this.lockTimeout = lockTimeout;
}
public boolean lock() {
long end = System.currentTimeMillis() + acquireTimeout;
while (System.currentTimeMillis() < end) {
// 尝试获取锁,设置过期时间
if (jedis.set(lockKey, "locked", "NX", "EX", lockTimeout) != null) {
return true;
}
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
return false;
}
public void unlock() {
// 释放锁
jedis.del(lockKey);
}
}
3. 创建定时任务类
接下来,我们创建一个 ShoppingCartCleaner 类,用于执行定时清理过期购物车的任务:
java
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
public class ShoppingCartCleaner {
private JedisPool jedisPool;
private static final String LOCK_KEY = "shopping_cart_cleaner_lock";
private static final long ACQUIRE_TIMEOUT = 10000; // 获取锁的超时时间,单位毫秒
private static final long LOCK_TIMEOUT = 30000; // 锁的超时时间,单位毫秒
public ShoppingCartCleaner() {
JedisPoolConfig poolConfig = new JedisPoolConfig();
poolConfig.setMaxTotal(128);
poolConfig.setMaxIdle(128);
poolConfig.setMinIdle(16);
poolConfig.setTestOnBorrow(true);
poolConfig.setTestOnReturn(true);
poolConfig.setTestWhileIdle(true);
poolConfig.setMinEvictableIdleTimeMillis(60000);
poolConfig.setTimeBetweenEvictionRunsMillis(30000);
poolConfig.setNumTestsPerEvictionRun(-1);
this.jedisPool = new JedisPool(poolConfig, "localhost", 6379);
}
public void cleanShoppingCarts() {
Jedis jedis = null;
DistributedLock lock = new DistributedLock(jedis, LOCK_KEY, ACQUIRE_TIMEOUT, LOCK_TIMEOUT);
try {
jedis = jedisPool.getResource();
if (lock.lock()) {
// 执行清理任务
System.out.println("开始清理过期购物车数据...");
// 模拟清理操作
Thread.sleep(5000);
System.out.println("过期购物车数据清理完成!");
} else {
System.out.println("未能获取锁,任务已在其他节点执行...");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (lock != null) {
lock.unlock();
}
if (jedis != null) {
jedis.close();
}
}
}
}
4. 配置定时任务
为了确保任务每天凌晨1点执行,我们需要使用 Cron 表达式。这里我们使用 Spring 的 @Scheduled 注解来配置定时任务:
java
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class TaskScheduler {
private final ShoppingCartCleaner shoppingCartCleaner;
public TaskScheduler(ShoppingCartCleaner shoppingCartCleaner) {
this.shoppingCartCleaner = shoppingCartCleaner;
}
@Scheduled(cron = "0 0 1 * * ?")
public void scheduleShoppingCartCleaner() {
shoppingCartCleaner.cleanShoppingCarts();
}
}
验证与测试
现在,我们来验证一下这个方案是否有效。假设你有两台服务器,每台服务器上都部署了相同的代码。我们可以模拟服务器的启动过程,确保任务只在一台服务器上执行。
5. 启动多个实例
为了模拟多节点环境,我们可以在本地启动多个 JVM 实例。编辑 application.properties 文件,配置不同的端口:
properties
# application.properties - 实例1
server.port=8081
# application.properties - 实例2
server.port=8082
然后分别启动两个实例:
sh
# 启动实例1
java -jar -Dspring.profiles.active=instance1 target/your-application.jar
# 启动实例2
java -jar -Dspring.profiles.active=instance2 target/your-application.jar
6. 检查任务执行日志
等待一段时间,查看日志文件,确保任务只在一台服务器上执行:
sh
# 查看实例1的日志
tail -f logs/instance1.log
# 查看实例2的日志
tail -f logs/instance2.log
如果一切正常,你将看到一个实例成功获取锁并执行任务,另一个实例未能获取锁并输出相应的日志。
扩展与优化
虽然上述方案已经能够解决大部分问题,但在实际生产环境中,我们还需要考虑一些扩展和优化的点。
7. 锁的可靠性
在分布式锁的设计中,锁的可靠性非常重要。如果一个任务在执行过程中突然中断,锁应该能够自动释放。我们在 DistributedLock 类中已经设置了锁的超时时间,确保锁不会永久持有。此外,我们还可以使用 Redis 的 Lua 脚本来进一步提升锁的可靠性:
java
import redis.clients.jedis.Jedis;
public class DistributedLock {
private Jedis jedis;
private String lockKey;
private long acquireTimeout;
private long lockTimeout;
private static final String SCRIPT = "if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then " +
"redis.call('pexpire', KEYS[1], ARGV[2]) " +
"return 1 " +
"else " +
"local currentValue = redis.call('get', KEYS[1]) " +
"if tonumber(currentValue) + tonumber(ARGV[2]) < tonumber(redis.call('time')[1] * 1000) then " +
"redis.call('del', KEYS[1]) " +
"if redis.call('setnx', KEYS[1], ARGV[1]) == 1 then " +
"redis.call('pexpire', KEYS[1], ARGV[2]) " +
"return 1 " +
"end " +
"end " +
"return 0 " +
"end";
public DistributedLock(Jedis jedis, String lockKey, long acquireTimeout, long lockTimeout) {
this.jedis = jedis;
this.lockKey = lockKey;
this.acquireTimeout = acquireTimeout;
this.lockTimeout = lockTimeout;
}
public boolean lock() {
long end = System.currentTimeMillis() + acquireTimeout;
String identifier = UUID.randomUUID().toString();
while (System.currentTimeMillis() < end) {
// 使用 Lua 脚本确保锁的可靠性
if (jedis.eval(SCRIPT, 1, lockKey, identifier, String.valueOf(lockTimeout * 1000)).equals("1")) {
return true;
}
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
return false;
}
public void unlock(String identifier) {
// 使用 Lua 脚本确保只有持有锁的客户端能够解锁
String unlockScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " +
"redis.call('del', KEYS[1]) " +
"return 1 " +
"else " +
"return 0 " +
"end";
jedis.eval(unlockScript, 1, lockKey, identifier);
}
}
8. 实例代码修改
接下来,我们需要在 ShoppingCartCleaner 类中使用改进的 DistributedLock:
java
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;
public class ShoppingCartCleaner {
private JedisPool jedisPool;
private static final String LOCK_KEY = "shopping_cart_cleaner_lock";
private static final long ACQUIRE_TIMEOUT = 10000; // 获取锁的超时时间,单位毫秒
private static final long LOCK_TIMEOUT = 30000; // 锁的超时时间,单位毫秒
public ShoppingCartCleaner() {
JedisPoolConfig poolConfig = new JedisPoolConfig();
poolConfig.setMaxTotal(128);
poolConfig.setMaxIdle(128);
poolConfig.setMinIdle(16);
poolConfig.setTestOnBorrow(true);
poolConfig.setTestOnReturn(true);
poolConfig.setTestWhileIdle(true);
poolConfig.setMinEvictableIdleTimeMillis(60000);
poolConfig.setTimeBetweenEvictionRunsMillis(30000);
poolConfig.setNumTestsPerEvictionRun(-1);
this.jedisPool = new JedisPool(poolConfig, "localhost", 6379);
}
public void cleanShoppingCarts() {
Jedis jedis = null;
DistributedLock lock = new DistributedLock(jedis, LOCK_KEY, ACQUIRE_TIMEOUT, LOCK_TIMEOUT);
String identifier = UUID.randomUUID().toString();
try {
jedis = jedisPool.getResource();
if (lock.lock()) {
// 执行清理任务
System.out.println("开始清理过期购物车数据...");
// 模拟清理操作
Thread.sleep(5000);
System.out.println("过期购物车数据清理完成!");
} else {
System.out.println("未能获取锁,任务已在其他节点执行...");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (lock != null) {
lock.unlock(identifier);
}
if (jedis != null) {
jedis.close();
}
}
}
}
进阶思考
在实际应用中,分布式定时任务可能会涉及到更多的复杂场景,例如任务失败重试、任务状态监控等。你可以考虑以下几点:
- 任务重试机制:在任务失败时,记录失败状态并定时重试。
- 任务状态监控:使用监控工具(如 Prometheus)来监控任务的执行状态,及时发现和解决问题。
- 分布式调度框架:考虑使用成熟的分布式调度框架(如 Apache Dubbo、Quartz 集群等)来简化任务调度逻辑。
结语
设计一个可靠的分布式定时任务系统并不是一件轻松的任务,但通过使用 Redis 分布式锁,我们可以有效地解决任务在多个节点上重复执行的问题。如果你需要进一步优化锁的实现或者生成复杂的 Cron 表达式,可以试试 Hey Cron,这是一个免费的在线工具网站,提供 Cron 表达式生成器、正则表达式生成器、中英互译、JSON 格式化、Base64 编码解码和时间戳转换等功能。希望这些工具能为你的开发工作带来便利。