持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第2天,点击查看活动详情
背景
公司最近有个web应用在线上跑了几天,最近突然有异常日志提醒,显示ClickHouse连接异常超时。查看ClickHouse的状态发现两个节点在不同天数的凌晨2点都因为OOM重启了。查询应用日志发现凌晨2点有一个定时任务在执行。
问题分析
找到每天2点的定时任务,发现是一个基于ClickHouse的聚合统计SQL。
我们在ClickHouse相关的配置中已经设置了max_memory_usage,正常SQL会报Memory limit,而不是节点重启。那么为什么ClickHouse的节点还会重启呢?
于是看了官方介绍后发现当前参数并不能限制所有查询:
You can use SHOW PROCESSLIST to see the current memory consumption for each query.
Memory usage is not monitored for the states of certain aggregate functions.
Memory usage is not fully tracked for states of the aggregate functions min, max, any, anyLast, argMin, argMax from String and Array arguments.
上面就提到了有些聚合查询并不能限制,而我们的SQL就是group by的聚合查询。并且也提示了我们可以使用SHOW PROCESSLIST查看每个查询的当前内存消耗。于是我们单独拿出SQL去ClickHouse的命令行执行了几次,发现是可以跑出结果的。那为什么在应用里面就不行了呢?
这就和开发时的偷懒有关系了,因为在线上这个应用启动了两个实例,我们直接使用了Spring的Schedule,并没有做任何处理,也就导致当前这种情况在同一时间当前SQL会执行两次,当连接请求都在一个ClickHouse节点上面时,就会出现内存不够用的情况,导致OOM。
问题解决
关于ClickHouse方面的优化有专门的人去做,这里我们主要介绍一下如何优化多实例下如何解决定时任务重复执行的问题。
因为这个应用并不是核心业务,也不想做过多的修改,和引入过多的组件,于是直接使用了shedlock来解决应用侧的问题,在之前的文章中也简单介绍过shedlock和一些其他的分布式调度组件,有兴趣的可以了解下。
下面我们主要介绍一下shedlock的使用和其简单的原理。
shedlock的使用非常简单,这也是我们选择他的原因,他可以直接无缝对接spring schedule,只需在原有方法上面加一个注解即可。
shedlock的使用
1-首先引入相关依赖<shedlock.spring>4.28.0</shedlock.spring>
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-spring</artifactId>
<version>${shedlock.spring}</version>
</dependency>
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-jdbc-template</artifactId>
<version>${shedlock.spring}</version>
</dependency>
2-执行SQL,我们选择基于jdbc的lock,因此需要创建一个表
CREATE TABLE IF NOT EXISTS shedlock
(
name VARCHAR(64) NOT NULL,
lock_until TIMESTAMP(3) NOT NULL,
locked_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
locked_by VARCHAR(255) NOT NULL,
PRIMARY KEY (name)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COMMENT = '分布式调度锁';
3-新增配置类,配置LockProvider
/**
* defaultLockAtMostFor 默认锁的最大时间
*/
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "PT30S")
@Configuration
public class SchedulerConfiguration {
@Resource
private DataSource dataSource;
@Bean
public LockProvider lockProvider() {
return new JdbcTemplateLockProvider(
JdbcTemplateLockProvider.Configuration.builder()
.withJdbcTemplate(new JdbcTemplate(dataSource))
.usingDbTime()
.build()
);
}
}
4-修改原有Spring的定时任务,这里只需要在原有Spring Schedule任务上面新增一个注解,指定任务名称和其他参数
@SchedulerLock(name = "TestTaskScheduler", lockAtLeastFor = "3s")
/**
* 最少持有时间, unlock的时候会更新。开始的时候是配置的最大持有时间:defaultLockAtMostFor = "PT30S"
* */
@SchedulerLock(name = "TestTaskScheduler", lockAtLeastFor = "3s")
@Scheduled(cron = "10 * * * * ?")
public void testSchedule() {
log.info("test task");
try {
} catch (InterruptedException interruptedException) {
interruptedException.printStackTrace();
}
log.info("test task end");
}
到此我们可以进行验证,启动两个应用,使用相同的库和表,会发现每分钟的第10秒中日志只会出现在一个应用日志里面。
shedlock的原理
1-切面
通过上面的入门我们可以发现只需要一个注解就可以帮我们解决问题,那么很容易想到注解+AOP的组合。
于是翻找源码发现了SchedulerLockConfigurationSelector
@Override
@NonNull
public String[] selectImports(@NonNull AnnotationMetadata metadata) {
AnnotationAttributes attributes = AnnotationAttributes.fromMap(metadata.getAnnotationAttributes(EnableSchedulerLock.class.getName(), false));
InterceptMode mode = attributes.getEnum("interceptMode");
if (mode == PROXY_METHOD) {
//默认PROXY_METHOD
return new String[]{AutoProxyRegistrar.class.getName(), LockConfigurationExtractorConfiguration.class.getName(), MethodProxyLockConfiguration.class.getName()};
} else if (mode == PROXY_SCHEDULER) {
return new String[]{AutoProxyRegistrar.class.getName(), LockConfigurationExtractorConfiguration.class.getName(), SchedulerProxyLockConfiguration.class.getName(), RegisterDefaultTaskSchedulerPostProcessor.class.getName()};
} else {
throw new UnsupportedOperationException("Unknown mode " + mode);
}
}
在默认的mode下面我们可以看到MethodProxyLockConfiguration,在这个配置中定义了MethodProxyScheduledLockAdvisor。
@Bean
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
MethodProxyScheduledLockAdvisor proxyScheduledLockAopBeanPostProcessor(
@Lazy LockProvider lockProvider,
@Lazy ExtendedLockConfigurationExtractor lockConfigurationExtractor
) {
MethodProxyScheduledLockAdvisor advisor = new MethodProxyScheduledLockAdvisor(
lockConfigurationExtractor,
new DefaultLockingTaskExecutor(lockProvider)
);
advisor.setOrder(getOrder());
return advisor;
}
MethodProxyScheduledLockAdvisor中定义了切面的逻辑:
//继承了AbstractPointcutAdvisor来定义切面逻辑class MethodProxyScheduledLockAdvisor extends AbstractPointcutAdvisor
private final Pointcut pointcut = new ComposablePointcut(methodPointcutFor(net.javacrumbs.shedlock.core.SchedulerLock.class))
.union(methodPointcutFor(SchedulerLock.class));
private final Advice advice;
MethodProxyScheduledLockAdvisor(ExtendedLockConfigurationExtractor lockConfigurationExtractor, LockingTaskExecutor lockingTaskExecutor) {
this.advice = new LockingInterceptor(lockConfigurationExtractor, lockingTaskExecutor);
}
这里我们基本就知道SchedulerLock注解的作用了,其实就是切入点。而切面的逻辑都在LockingInterceptor的invoke方法中。
public Object invoke(MethodInvocation invocation) throws Throwable {
Class<?> returnType = invocation.getMethod().getReturnType();
if (returnType.isPrimitive() && !void.class.equals(returnType)) {
throw new LockingNotSupportedException("Can not lock method returning primitive value");
}
LockConfiguration lockConfiguration = lockConfigurationExtractor.getLockConfiguration(invocation.getThis(), invocation.getMethod()).get();
TaskResult<Object> result = lockingTaskExecutor.executeWithLock(invocation::proceed, lockConfiguration);
if (Optional.class.equals(returnType)) {
return toOptional(result);
} else {
return result.getResult();
}
}
到此我们知道,引入了shedlock后,我们加了注解SchedulerLock的定时任务每次执行都会先进入LockingInterceptor的invoke方法中。
2-加锁以及锁原理
利用AOP其实已经解决了代码改动大的问题,这也是为什么说shedlock使用简单的一个原因。
那么它是如何解决重复执行任务的问题呢?
我们直接看invoke方法中的executeWithLock方法,第一步就是加锁,获取到锁后才能执行任务。
Optional<SimpleLock> lock = lockProvider.lock(lockConfig);
//获取到锁后执行任务
return TaskResult.result(task.call());
最后在finally中释放锁
SimpleLock activeLock = LockExtender.endLock();
if (activeLock != null) {
activeLock.unlock();
} else {
// This should never happen, but I do not know any better way to handle the null case.
logger.warn("No active lock, please report this as a bug.");
lock.get().unlock();
}
lockProvider.lock锁逻辑的具体实现都在JdbcTemplateLockProvider extends StorageBasedLockProvider 中逻辑如下:
//Sets lockUntil according to LockConfiguration if current lockUntil <= now
protected boolean doLock(LockConfiguration lockConfiguration) {
String name = lockConfiguration.getName();
if (!lockRecordRegistry.lockRecordRecentlyCreated(name)) {
// create record in case it does not exist yet
if (storageAccessor.insertRecord(lockConfiguration)) {
lockRecordRegistry.addLockRecord(name);
// we were able to create the record, we have the lock
return true;
}
// we were not able to create the record, it already exists, let's put it to the cache so we do not try again
lockRecordRegistry.addLockRecord(name);
}
// let's try to update the record, if successful, we have the lock
return storageAccessor.updateRecord(lockConfiguration);
}
insertRecord:
第一次执行任务也就是缓存中没有当前任务名称的记录时候会执行insertRecord,主要是像shedlock表中插入一条数据内容如下:
执行的SQL:
INSERT INTO shedlock(name, lock_until, locked_at, locked_by) VALUES(:name, TIMESTAMPADD(MICROSECOND, :lockAtMostForMicros, UTC_TIMESTAMP(3)), UTC_TIMESTAMP(3), :lockedBy)
demo数据: INSERT INTO `shedlock`(`name`, `lock_until`, `locked_at`, `locked_by`) VALUES ('TestTaskScheduler', '2022-10-15 13:59:40.146', '2022-10-15 13:59:10.146', 'MacBook-Pro.local');
updateRecord:
当前任务如果已经存在记录,则更新锁。更新成功则获取锁成功。条件是current lockUntil <= now。根据任务的名称来查看lock_until是否已经比当前时间小,也就说明上一个任务的锁时间已经到了。
UPDATE shedlock SET lock_until = TIMESTAMPADD(MICROSECOND, :lockAtMostForMicros, UTC_TIMESTAMP(3)), locked_at = UTC_TIMESTAMP(3), locked_by = :lockedBy WHERE name = :name AND lock_until <= UTC_TIMESTAMP(3)
最后来看看释放锁的逻辑:
@Override
public void unlock(@NonNull LockConfiguration lockConfiguration) {
try {
doUnlock(lockConfiguration);
} catch (TransactionSystemException e) {
logger.info("Unlock failed due to TransactionSystemException - retrying");
doUnlock(lockConfiguration);
}
}
doUnlock:
释放锁,基于IF条件来 取lockAtLeastForMicros或者当前时间的最大值。
UPDATE shedlock SET lock_until = IF (TIMESTAMPADD(MICROSECOND, :lockAtLeastForMicros, locked_at) > UTC_TIMESTAMP(3) , TIMESTAMPADD(MICROSECOND, :lockAtLeastForMicros, locked_at), UTC_TIMESTAMP(3)) WHERE name = :name AND locked_by = :lockedBy
以上所有的SQL都可以在源码中找到,我们使用的是Mysql,因此可以找到具体实现类MySqlServerTimeStatementsSource。
最后来看一下数据库中数据的变化:
我们这里配置了lockAtLeast是3s,默认是0。配置了defaultLockAtMostFor 是30s。
假如两个节点执行在第一次数据插入之后,lock_util和lock_at直接会差30s。
'2022-10-15 13:59:40.146', '2022-10-15 13:59:10.146'。
如果另一个节点也在这个时间点内去获取锁,是获取不到的,那么就会执行
return TaskResult.notExecuted();
如果任务瞬间执行完毕,进行unlock后,因为我们配置了lockAtLeast,那么lock_util则会被更新为2022-10-15 13:59:13.146
总结
shedlock可以在基本不修改代码逻辑的情况快速下解决我们的问题,在一些具有少量定时任务的应用中,大家可以尝试使用一下。