定时任务在智慧停车业务中的必要性
定时任务可以在指定的时间或间隔内周期性地执行一些任务,可以实现很多有用的功能。在智慧停车业务中,定时任务可以用来:
- 停车场车位信息的更新:定时任务可以周期性地从停车场管理系统获取车位信息,然后更新到智慧停车系统中,确保智慧停车系统中的车位信息与实际情况一致。
- 费用结算:定时任务可以根据停车场收费标准,结算停车费用,并将费用信息存入数据库。(这块的功能就很像滴滴打车订单完成后自动完成扣费)
- 数据统计分析:定时任务可以定期对停车场数据进行分析和统计,生成各种报表,帮助停车场管理者更好地了解停车场的使用情况。
- 车辆预约和通知:定时任务可以在预约时间到达时自动发送预约通知,以提醒用户前来停车,并保证车位的预留。
因此在我的系统中,将其独立为一个模块,便于后期将各种功能集成进来。
定时任务的选型方案
常见的定时任务的解决方案有以下几种:
右半部分基于 Java 或 Spring 框架即可支持定时任务的开发运行,左侧部分需要引入第三方框架支持。不同的方案有不同的要求和特点:
- XXL-JOB 是一个分布式任务调度平台,其核心设计目标是开发迅速、学习简单、轻量级、易扩展。任务调度与任务执行分离,功能很丰富,在多家公司商业产品中已有应用。
- Elastic-Job 是一个分布式调度解决方案,由两个相互独立的子项目 Elastic-Job-Lite 和 Elastic-Job-Cloud 组成。Elastic-Job-Lite 定位为轻量级无中心化解决方案,依赖 Zookeeper ,使用 jar 包的形式提供分布式任务的协调服务,之前是当当网 Java 应用框架 ddframe 框架中的一部分,后分离出来独立发展。
- Quartz 算是定时任务领域的老牌框架了,出自 OpenSymphony 开源组织,完全由 Java 编写,提供内存作业存储和数据库作业存储两种方式。在分布式任务调度时,数据库作业存储在服务器关闭或重启时,任务信息都不会丢失,在集群环境有很好的可用性。
- 淘宝出品的 TBSchedule 是一个简洁的分布式任务调度引擎,基于 Zookeeper 纯 Java 实现,调度与执行同样是分离的,调度端可以控制、监控任务执行状态,可以让任务能够被动态的分配到多个主机的 JVM 中的不同线程组中并行执行,保证任务能够不重复、不遗漏的执行。
- Timer 和 TimerTask 是 Java 基础组件库的两个类,简单的任务尚可应用,但涉及到的复杂任务时,建议选择其它方案。
- ScheduledExecutorService 在 ExecutorService 提供的功能之上再增加了延迟和定期执行任务的功能。虽然有定时执行的功能,但往往大家不选择它作为定时任务的选型方案。
- [@EnableScheduling] 以注解的形式开启定时任务,依赖 Spring 框架,使用简单,无须 xml 配置。特别是使用 Spring Boot 框架时,更加方便。
引入第三方分布式框架会增加项目复杂度,Timer、TimerTask 比较简单无法符合复杂的分布式定时任务,而基于注解的 @EnableScheduling 定时任务方案操作简单使用方面,功能方面也能完全胜任应用的需求。综上智慧停车场管理系统将采用基于注解的 @EnableScheduling 定时任务方案
微服务架构环境下定时任务存在的问题
一个简单的定时任务项目实现起来非常简单,但我们创建一个模块拥有一个启动类,在启动类加上 @EnableScheduling 注解就代表定时任务功能已经启用:
1
2
3
4
5
6
7
8
9
@SpringBootApplication
@EnableScheduling
public class ParkingScheduleJobApplication {
public static void main(String[] args) {
SpringApplication.run(ParkingScheduleJobApplication.class, args);
}
}
然后专注设计自己的定时任务执行类就可以了,只需要用 @Scheduled 加上 cron 表达式就能定制自己的定时任务,并被系统自动识别,并按要求执行:
1
2
3
4
5
6
7
8
9
10
11
@Component
@Slf4j
public class UserBirthdayBasedPushTask {
//每隔 5s 输出一次日志
@Scheduled(cron = " 0/5 * * * * ?")
public void scheduledTask() {
log.info("Task running at = " + LocalDateTime.now());
}
}
比如每隔 5s 输出一次日志。但是这会存在一种情况,我我们的应用进行了多实例部署,那么这样一段定时任务执行代码就会被重复执行,而重复执行会导致数据的混乱或糟糕的用户体验,比如本次基于会员生日推送营销短信/邮件时,用户会被短信/邮件轰炸,这肯定不是我们想看到的。即使部署了多代码实例,任务在同一时刻应当执行一次才是符合正常逻辑的,而不能因为实例的增多,导致执行次数增多。那么该如何解决呢?
分布式定时任务
保证任务在同一时刻只有执行,就需要每个实例执行前拿到一个令牌,谁拥有令牌谁有执行任务,其它没有令牌的不能执行任务,通过数据库记录就可以达到这个目的。
上面两种方案是一个演化的关系,A 相比于 B 方案存在漏洞:
当 select 指定记录后,再去 update 时,存在时间间隙,因为 select 操作并不会加行锁,因此会导致多个实例同时执行任务,而 B 方案通过 update 更新操作的返回值 1 或者 0 能避免多个实例同时执行任务。返回 1 则当前任务执行,而其他实例的定时任务则不会执行。
实际上这套实现逻辑并不需要自己实现,有现成的方案可以使用:ShedLock,可以使我们的定时任务在同一时刻,最多执行一次。
ShedLock 使用方法:
- 引入 ShedLock 的 jar 包:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-core</artifactId>
<version>4.5.0</version>
</dependency>
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-spring</artifactId>
<version>4.5.0</version>
</dependency>
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-jdbc-template</artifactId>
<version>4.5.0</version>
</dependency>
- 启动类新增 @EnableSchedulerLock 注解,以及打开 ShedLock 获取锁的支持。这里需要引入 spring-jdbc 的 jar 包。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@SpringBootApplication
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "30s")
public class ParkingScheduleJobApplication {
public static void main(String[] args) {
SpringApplication.run(ParkingScheduleJobApplication.class, args);
}
@Bean
//基于 Jdbc 的方式提供的锁机制
public LockProvider lockProvider(DataSource dataSource) {
return new JdbcTemplateLockProvider(dataSource);
}
}
- 任务执行类的方法上,同样增加 @SchedulerLock 注解,并声明定时任务锁的名称,如果有多个定时任务,要确保名称的唯一性。
1
2
3
4
5
6
7
8
9
10
11
12
@Component
@Slf4j
public class UserBirthdayBasedPushTask {
//每隔 5s 输出一次日志
@Scheduled(cron = " 0/5 * * * * ?")
@SchedulerLock(name = "scheduledTask")
public void scheduledTask() {
log.info("Task running at = " + LocalDateTime.now());
}
}
- 新增名为 shedlock 的数据库,并新建 shedlock 数据表,表结构如下:
做完这些步骤,定时任务的初步框架构建完成。
定时任务实现根据用户的生日推送营销短信/邮件的功能
考虑到发送短信的实现方案较为复杂,在应用中用发送邮件代替。
首先需要获取用户的生日信息,而它们放在 parking-member 模块当中,因此需要采用 Feign 的方式远程调用对应的方法,编写对应的 MemberClient 接口:
1
2
3
4
5
6
7
8
@FeignClient("parking-member")
public interface MemberClient {
/**
* 获取会员信息列表
*/
@GetMapping("/api/member/list")
CommonResult<List<MemberDTO>> getList();
}
在对应的执行任务类中编写需要的业务逻辑,这将会用到会员信息,因为需要根据用户在注册账号时填写的出生年月判断当天是否会员的生日。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Scheduled(cron = " 0/5 * * * * ?")
@SchedulerLock(name = "UserBirthdayBasedPushTask")
public void scheduledTask() {
CommonResult<List<MemberDTO>> res = memberClient.getList();
if (res.getCode() == 200) {
List<MemberDTO> members = res.getData();
for (MemberDTO m : members) {
//已认证邮箱
if (!StringUtils.isEmpty(m.getEmail())) {
//获取当前日期
DateTimeFormatter df = DateTimeFormatter.ofPattern("MM-dd");
String curDate = df.format(LocalDate.now());
//用户填写了生日信息,并且当天生日
if (!StringUtils.isEmpty(m.getBirth()) && m.getBirth().substring(5).equals(curDate)){
//发送生日祝福邮箱
Context context = new Context();
context.setVariable("username", m.getFullName());
String content = templateEngine.process("/email/birth", context);
mailUtil.sendMail(m.getEmail(), "生日祝福", content);
log.info("已向会员[{}]发送生日祝福邮箱", m.getFullName());
}
}
}
}
}
这样功能即完成了。: