分布式定时任务的实现方式
文章目录
- 分布式定时任务的实现方式
- 可能带来的问题
- 一个简单的定时任务
- 解决方案
- 配置文件
- 数据库存储
- 配置指定 IP 决定实现
- 分布式锁
- 使用 redisson 实现分布式锁
- springboot sdk 方式
- 重新自定义 Client 方式
- 看门狗机制介绍
- 分布式任务调度框架
- XXL-Job 快速上手
- 下载官方代码
- 了解目录结构
- 更改配置文件
- 运行代码
- 打开面板
- 设定定时时间轮询方式等信息
- 其他问题
定时任务很常用,但是如果一份应用程序,部署在多台服务器上,就会出现"打鸣" 现象,如果说有些场景只是浪费资源罢了,但有些时候会影响到业务是否正常。
可能带来的问题
- 数据不一致性:如果多台服务器同时操作同一份数据,可能会导致数据不一致的情况。例如,一个服务器在读取数据的同时,另一个服务器可能已经修改了该数据,这样就会导致读取到的数据和修改后的数据不一致。
- 并发冲突:多台服务器同时操作数据可能会导致并发冲突。如果不同的服务器在相同的时间内修改了相同的数据,就会发生冲突。这可能导致其中一个操作被覆盖或者出现错误。
- 死锁:如果多台服务器之间在对同一组数据进行操作时,没有良好的并发控制机制,可能会导致死锁情况。死锁会导致服务器之间相互等待,无法继续执行。
- 性能问题:如果没有合适的并发控制策略,可能会导致多台服务器之间产生大量的竞争,从而影响整体性能。
一个简单的定时任务
- 注解开启定时任务
@EnableScheduling
- 写定时任务逻辑 , @Component 注册 Bean @Scheduled 设置定时执行逻辑,参数多种方式可以点进去看一下,一般想写啥逻辑现去百度。
package com.yidiansishiyi.aimodule.job.cycle;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.yidiansishiyi.aimodule.mapper.WmsensitiveMapper;
import com.yidiansishiyi.aimodule.model.entity.Wmsensitive;
import com.yidiansishiyi.aimodule.utils.SensitiveWordUtil;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.List;
import java.util.stream.Collectors;
@Component
@Slf4j
@Data
public class IncSyncSensitiveToMap {
@Resource
private WmsensitiveMapper wmsensitiveMapper;
@Scheduled(initialDelay = 1000, fixedRate = 60 * 1000 * 60 * 3)
public void run() {
List<Wmsensitive> wmSensitives = wmsensitiveMapper.selectList(Wrappers.<Wmsensitive>lambdaQuery()
.select(Wmsensitive::getSensitives)
.isNotNull(Wmsensitive::getSensitives)
.groupBy(Wmsensitive::getSensitives));
List<String> sensitiveList = wmSensitives.stream().map(Wmsensitive::getSensitives).collect(Collectors.toList());
// 初始化敏感词库
SensitiveWordUtil.initMap(sensitiveList);
log.info("同步了 {} 条敏感词", sensitiveList.size());
}
}
解决方案
- 配置文件
- 数据库存储
- 分布式锁
- 任务调度框架
配置文件
- 确定标记位置
@Value("${job.cycle.wmsensitive:false}") //这里默认关闭,只有加了配置文件打开才算是开启这个定时任务
private Boolean flag;
- if 包裹处理逻辑
if (flag){
List<Wmsensitive> wmSensitives = wmsensitiveMapper.selectList(Wrappers.<Wmsensitive>lambdaQuery()
.select(Wmsensitive::getSensitives)
.isNotNull(Wmsensitive::getSensitives)
.groupBy(Wmsensitive::getSensitives));
List<String> sensitiveList = wmSensitives.stream().map(Wmsensitive::getSensitives).collect(Collectors.toList());
// 初始化敏感词库
SensitiveWordUtil.initMap(sensitiveList);
log.info("同步了 {} 条敏感词", sensitiveList.size());
}
- 指定运行该定时任务的机子标记位为 true
job:
cycle:
wmsensitive: true
适用场景,小规模,就两三台机子,业务量真的不大,也没那必要用别的,标记位搞一搞最快
确定,没有做到高可用,万一挂了就定时任务就是真不执行了,谁知道这台机子挂了会不会影响太大,需要从真实业务出发判断是否使用该种方案,快但是可能有问题,但最简单依赖的东西也最少,最好实现。
数据库存储
就是将刚刚的标识位的思想稍微变得灵活一些,可以在不改变配置的情况下做到
- flag 换为 jobIp 从数据库字典里查询字段,嗯如果没有专门字典表,或者不了解的话完全可以从建立一张表
job_cycle 字段 id, hostIP,job_name 当然这是最简单的基本字段,根据工作名字查出来想要哪个 ip 来运行这个定时任务。 - 获取当前运行主机 ip ,注意这里直接抛了,也可以自己捕获打个日志啥的。
InetAddress localHost = InetAddress.getLocalHost();
String hostAddress = localHost.getHostAddress();
配置指定 IP 决定实现
@Value("${scheduled.taskIp}")
private String scheduledTaskIp;
// 保留原框架结构,涉及到的变量名改为新的命名
@Scheduled(cron = " */1 * * * ?")
public void uploadRealPolicy() {
try {
if (!InetAddress.getLocalHost().getHostAddress().equals(scheduledTaskIp)) {
log.info("非定时任务指定IP” + scheduledTaskIp + ",不执行实时转保定时任务");
return;
}
log.info("实时转保定时任务开始执行");
// 需要处理的PrpTempGeneratePolicy部分
// ...
} catch (Exception e) {
log.error("实时转保定时任务执行异常", e);
}
}
- 将 if 内的判断条件变为 hostAddress.equals(“查询回来的 ip”)
还是没有做到高可用,而且得多维护一个数据,当然做到的比配置更灵活,万一其中一个挂了,可以修改数据库让另一个执行,提供接口,在不考虑负载均衡等高可用特性的时候能简单的不通过重启来实现更换定执行定时任务的机子。适合小规模,不过如果 ip 经常变动,那就遭老罪了。
分布式锁
- 基于Redis的分布式锁:
使用Redis作为分布式锁的存储介质,利用Redis的单线程特性和原子性操作来实现锁的获取与释放。
- 优点:简单高效,可以避免死锁情况。
- 缺点:可能会存在锁失效的情况,需要合理设置过期时间。
- 基于ZooKeeper的分布式锁:
使用ZooKeeper的临时有序节点(EPHEMERAL_SEQUENTIAL)来实现分布式锁。
- 优点:ZooKeeper保证了强一致性,适用于一些需要强一致性的场景。
- 缺点:相对于Redis,实现和维护分布式锁的成本较高。
- 基于数据库的分布式锁:
利用数据库的事务特性,通过在数据库中创建一张锁表,通过事务来控制获取锁和释放锁的操作。
- 优点:可以保证数据的一致性,适用于对数据一致性要求较高的场景。
- 缺点:性能相对较低,可能会引起数据库的性能瓶颈。
- 基于分布式算法的锁:
使用一些分布式算法如Chubby、Paxos等来实现分布式锁。
- 优点:可以实现高度的分布式一致性。
- 缺点:实现复杂,不适用于所有场景。
- 基于Java实现的分布式锁:
使用Java并发库中的java.util.concurrent.locks
包提供的锁机制,结合分布式环境下的一些技术来实现。
- 优点:可以直接在代码中使用Java提供的锁机制,实现简单。
- 缺点:需要处理分布式环境下的一致性问题。
- 基于第三方组件的分布式锁:
使用一些第三方组件或者中间件来实现分布式锁,比如Curator、Spring Integration等。
- 优点:可以利用现有的组件,简化开发过程。
- 缺点:可能会受到第三方组件的限制和依赖。
使用 redisson 实现分布式锁
springboot sdk 方式
https://zhuanlan.zhihu.com/p/380530036
重新自定义 Client 方式
可以自定义一些配置会更灵活一些,可以读以下官方文档如果用到特性可以去看下相关配置,缺点就是全英文,优点就是全面。
https://github.com/redisson/redisson#quick-start
- 引入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.21.3</version>
</dependency>
- Config 配置类注册. RedissonClient 注册到 spring
package com.yidiansishiyi.aimodule.config;
import lombok.Data;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springfrclamework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "spring.redisson")
@Data
public class RedissonConfig {
private Integer database;
private String host;
private Integer port;
private String password;
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setDatabase(database)
.setAddress("redis://" + host + ":" + port)
.setPassword(password);
RedissonClient redisson = Redisson.create(config);
return redisson;
}
}
- 配置 Client 链接
spring:
redisson:
database: 1
host: x
port: 6379
password: x
- RLock 创建一个锁,锁名设置要求唯一,有辨识性,将定时任务实现逻辑放到锁内,抢到锁才能执行任务。在 finally 中进行锁的释放,防止异常情况下锁步释放锁死。
package com.yidiansishiyi.aimodule.job.cycle;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.yidiansishiyi.aimodule.mapper.WmsensitiveMapper;
import com.yidiansishiyi.aimodule.model.entity.Wmsensitive;
import com.yidiansishiyi.aimodule.utils.SensitiveWordUtil;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.List;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@Component
@Slf4j
public class IncSyncSensitiveToMap {
@Resource
private WmsensitiveMapper wmsensitiveMapper;
@Resource
private RedissonClient redissonClient;
@Scheduled(initialDelay = 1000, fixedRate = 60 * 1000 * 60 * 3)
public void run(){
RLock lock = redissonClient.getLock("aimodule:job:IncSyncSensitiveToMap:lock");
try {
if (lock.tryLock(0, -1, TimeUnit.MILLISECONDS)) {
List<Wmsensitive> wmSensitives = wmsensitiveMapper.selectList(Wrappers.<Wmsensitive>lambdaQuery()
.select(Wmsensitive::getSensitives)
.isNotNull(Wmsensitive::getSensitives)
.groupBy(Wmsensitive::getSensitives));
List<String> sensitiveList = wmSensitives.stream().map(Wmsensitive::getSensitives).collect(Collectors.toList());
// 初始化敏感词库
SensitiveWordUtil.initMap(sensitiveList);
log.info("同步了 {} 条敏感词", sensitiveList.size());
}
} catch (Exception e) {
log.error("IncSyncSensitiveToMap ", e);
} finally {
// 只能释放自己的锁
if (lock.isHeldByCurrentThread()) {
System.out.println("unLock: " + Thread.currentThread().getId());
lock.unlock();
}
}
}
}
有好多锁实现的,根据接口
- 尝试获取锁:这个方法用于尝试获取一个锁。在调用时,会先等待一段时间(
waitTime
)来获取锁。- 等待时间:
waitTime
参数是最长等待时间,如果在这个时间内无法获取锁,就会返回false
。- 租约时间:
leaseTime
参数是锁的租约时间。即,如果成功获取到锁,在指定时间后,锁会自动释放。- TimeUnit:
unit
参数是时间单位,可以是毫秒、秒、分钟等。
好不不得不承认这里有个小坑 , 直接点 trtLock 出现的接口并不是实际上锁逻辑走的路线,虽然逻辑上看着确实是正确处理我传入参数的代码,但是我不理解为什么会出现两个一样的现类没有明确决定选则了那种的情况,正确的逻辑走的是第一个圈出来的,找到其中的 tryLock 逻辑才是这段锁正确走的逻辑,多态的理念在这里出了一次小小的坑了我一把。RedissonLock 类里的 tryLock ;最后走到了 Lua 脚本;
下面的代码是一点点的追溯过程,具体看明白我也不太懂,不过大概逻辑看了下 , 有几个坑可以踩一踩
- 有没有默认指定实现,就是通过继承抽象类的方式去指定实现
- 看看上一步的实际类型,运行时类型, 编译时类型在编译后会被泛型擦除的(这个坑之前拷贝的时候踩过 多态再踩一脚.jpg)
- 先找本类,不要遇见就无脑点进去
以上三点是追源码时候挺重要的点,自己追了才知道哭死 ,以后有再加,背下来的知识点永远不如踩坑来的印象深刻
@Override
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
long time = unit.toMillis(waitTime);
long current = System.currentTimeMillis();
long threadId = Thread.currentThread().getId();
Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
return true;
}
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
current = System.currentTimeMillis();
CompletableFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
try {
subscribeFuture.get(time, TimeUnit.MILLISECONDS);
} catch (TimeoutException e) {
if (!subscribeFuture.completeExceptionally(new RedisTimeoutException(
"Unable to acquire subscription lock after " + time + "ms. " +
"Try to increase 'subscriptionsPerConnection' and/or 'subscriptionConnectionPoolSize' parameters."))) {
subscribeFuture.whenComplete((res, ex) -> {
if (ex == null) {
unsubscribe(res, threadId);
}
});
}
acquireFailed(waitTime, unit, threadId);
return false;
} catch (ExecutionException e) {
acquireFailed(waitTime, unit, threadId);
return false;
}
try {
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
while (true) {
long currentTime = System.currentTimeMillis();
ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
return true;
}
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
// waiting for message
currentTime = System.currentTimeMillis();
if (ttl >= 0 && ttl < time) {
commandExecutor.getNow(subscribeFuture).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
commandExecutor.getNow(subscribeFuture).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
}
} finally {
unsubscribe(commandExecutor.getNow(subscribeFuture), threadId);
}
// return get(tryLockAsync(waitTime, leaseTime, unit));
}
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
return commandExecutor.syncedEval(getRawName(), LongCodec.INSTANCE, command,
"if ((redis.call('exists', KEYS[1]) == 0) " +
"or (redis.call('hexists', KEYS[1], ARGV[2]) == 1)) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);",
Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
}
protected void scheduleExpirationRenewal(long threadId) {
ExpirationEntry entry = new ExpirationEntry();
ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
if (oldEntry != null) {
oldEntry.addThreadId(threadId);
} else {
entry.addThreadId(threadId);
try {
renewExpiration();
} finally {
if (Thread.currentThread().isInterrupted()) {
cancelExpirationRenewal(threadId);
}
}
}
}
这里使用了看门狗机制, scheduleExpirationRenewal 就是他的续期机制,当然后面还有具体的实现,不过触发很简单,就是把 leaseTime 改为 < 0 的参数。
因为这里使用了 等待时间 参数为 0 所以就是大家一起枪锁,抢不到别的的标示符就不成立不能执行,抢到的执行,如果执行不完成就走 30 续期机制,当然如果确定大概多少能执行完信息还是要设置成固定值就好了,也可以将等待时间改为某个固定值,在执行任务发生错误的时候捕获异常输出日志外,让下一台继续抢,或者定义一个守护线程检测状态, 规定时间内完成不了任务杀死线程,交由别的服务器进行操作,当然设计上会复杂很多。
看门狗机制介绍
使用标记位实现第一次枪锁,后续指定第一次抢到锁的机器执行当然每一次重启服务应该重新讲类变量标记位空.String lockValve = InetAddress.getLoopbackAddress().getHostAddress() + UUID.randomUUID()
boolean lock = false;
// 尝试从Redis获取数据
Object o = redisUtil.get(fxqLevelQuery.getKey());
// 如果数据为空
if (Objects.isNull(o)) {
// 尝试获取锁
Lock lock = redisUtil.setIfAbsent(fxqLevelQuery.getKey(), lockValve, time: -1);
// 如果成功获取锁
if (lockValve.equals(o)) {
lock = true;
}
// 如果未获取到锁
if (!lock) {
Log.info(fxgLeveLQuery.getDesc() + "未取得,本次不执行任务");
return;
}
Log.info("定时上传反洗钱风险等级划分及校验任务开始执行");
}
分布式任务调度框架
以上的解决方案都解决不了一个问题 , 这个任务就不是单机能完成的,或者说为了效率需要更多服务器去分片跑同一个业务,操作同一个数据,当然这种问题可以通过编码的形式解决,但是很复杂,已经有了很多优秀的分布式任务调度框架。在实现快速开发的同时保证了系统的高可用,而且提供的可视化操纵能更灵活的以面板中指令的方式来进行任务调度。
- Quartz:Quartz是一个开源的作业调度框架,可以在Java应用程序中用来调度任务。它提供了很多功能,例如分布式调度、持久化存储、错过作业重执行等。
- Elastic Job:Elastic Job是一个分布式调度解决方案,它由两个相互独立的子项目组成,分别是Elastic-Job-Lite和Elastic-Job-Cloud。它适用于在分布式环境下调度大规模的定时任务。
- XXL-Job:XXL-Job是一个轻量级、分布式任务调度框架,支持分布式调度和任务执行。它提供了可视化的任务管理界面,便于操作和监控。
- TBSchedule:TBSchedule是一个开源的分布式任务调度框架,可以用于大规模分布式环境下的任务调度。它具有强大的扩展性和高可用性。
- Spring Cloud Data Flow:Spring Cloud Data Flow是一个用于构建和执行数据处理管道的工具,它提供了任务调度、数据流管理等功能。
- Azkaban:Azkaban是一个用于任务调度和作业流程的批量工作流工具。它提供了直观的Web用户界面,可用于创建、监控和调度作业流。
- Airflow:Apache Airflow是一个以编程方式管理工作流的平台,它用于将各种任务连接起来,形成一个完整的工作流程。
- Celery:Celery是一个简单、灵活且可靠的分布式任务队列。它可以用于处理大量的任务,支持任务的异步执行。
当然,我也没咋用过就简单的上手了试了下 XXL-Job 评价: 简单易懂上手极快
XXL-Job 快速上手
好吧我就简单介绍以下大概步骤,成熟的使用文档和教学视频网上有很多,当然我也不多bb 多bb 我也不会,给我的感觉就是拉下来人家的代码,环境配置好跑以下,设定好定时模式,这些弄回来就算初步入门了,但是把它使用好也是们学问,不过优秀的框架的目的就是为了节省大家的时间,简单的就能用起来才是最好的,根据业务的程度决定如何学习这门框架。这块是坑请百度谢谢。
下载官方代码
了解目录结构
更改配置文件
运行代码
打开面板
设定定时时间轮询方式等信息
其他问题
整理追代码的思路
深度学习锁和线程有关的知识
填坑