Cái bài toán xây dựng nên những job đồng bộ được lên lịch định kì là môt bài toán rất phổ biến, hầu như ở bất kì dự án nào cũng đều có nhu cầu sử dụng đến nó. Tuy nhiên để mà tạo job chạy trên những web server đơn lẻ chỉ 1 máy thì mọi thứ vẫn ổn, chỉ khi application chạy trên nhiều instances mà mỗi instance đều có những bộ lập lịch giống nhau, lúc này vấn đề mới xảy ra.

Hôm nay mình sẽ hướng dẫn mọi người cách tạo cron job chạy được trên nhiều instances với sự hỗ trợ của ShedLock.

Những trường hợp sử dụng cron job

Nhu cầu sử dụng cron job để giải quyết các bài toán trong doanh nghiệp rất nhiều và đa dạng, có thể nhắc đến một vài nhu cầu như là:

  • Tổng hợp dữ liệu báo cáo theo ngày, tuần, tháng
  • Định kì gửi mail nhắc nhở, notification tới khách hàng cho một nghiệp vụ nào đó
  • Hàng ngày chạy job crawl data kết quả sổ xố, kết quả bóng đá từ các trang mạng về một RDBMS
  • Gửi mail marketing sản phẩm tới tệp khách hàng có sẵn
  • ….

Và còn ti tỉ thứ, đủ loại nhu cầu có thể dùng cron job để giải quyết nữa, tùy tình hình thực tế sẽ nảy sinh ra các bài toán khác nhau.

Mọi thứ đều ổn và không xảy ra vấn đề gì khi application chạy trên 1 instance, tuy nhiên thì ứng dụng chúng ta build lên không phải lúc nào cũng chạy trên 1 instance mà chạy multiple instances và có thể scale lên nhiều node hơn nữa.

Lúc này vấn đề cần giải quyết là việc nhiều instance khi chạy job sẽ đồng thời cùng chạy và dẫn tới việc tranh chấp tài nguyên, xử lý sai lệch nghiệp vụ kinh doanh, xử lý trùng lặp dữ liệu.

Kiểu như bạn nhận được notification cộng tiền trên điện thoại hẳn 2, 3, 4 lần trong khi thực tế chỉ nhận 1 lần thì đã cảm thấy có gì đó sai sai rồi đúng không?

Và rồi cơ chế lock được sinh ra để giải quyết vấn đề này, có khá là nhiều cách để có thể xây dựng cron job đạt được tính nhất quán dữ liệu, tuy nhiên trong bài này mình sẽ đề cập đến ShedLock mà nhiều công ty hay sử dụng.

Cơ chế hoạt động của ShedLock như thế nào

ShedLock đơn giản chỉ là một khóa, nó đảm bảo cùng một nhiệm vụ chỉ chạy cùng lúc nhiều nhất một lần trên tất cả các instance, khi có một nhiệm vụ đang chạy trên 1 instance thì các nhiệm vụ cùng tên trên các instance khác sẽ bị bỏ qua.

Để đạt được việc chỉ chạy nhiều nhất 1 lần trên tất cả các instance thì ShedLock sử dụng một bảng cơ sở dữ liệu để quản lý các nhiệm vụ, nó bao gồm có 4 cột:

shedlock table
  • name: tên định danh duy nhất cho nhiệm vụ, nó phải là Unique
  • lock_until: bị khóa cho đến khi, tức là khóa tới thời điểm này thì nhả khóa
  • locked_at: thời điểm nhiệm vụ bắt đầu lấy được khóa và chạy
  • locked_by: bị khóa bởi ai

Khi một nhiệm vụ được lên lịch, một instance khi có được khóa sẽ tạo một bản ghi trong bảng shedlock, mỗi instance chỉ có thể lấy được khóa khi thời điểm của lock_until <=now() (điều này có thể dẫn đến vấn đề là thực tế job chạy trên instance A lâu và chưa hoàn thành xong thì instance B có thể lấy được khóa và chạy job, sẽ nói cuối bài)

Thời điểm khi một instance lấy được khóa thì dữ liệu sẽ được cập nhật như sau:

  • name: tên của nhiệm vụ chạy dữ liệu
  • lock_until: thời gian nhả khóa, sẽ tính từ thời điểm now()+lockAtMostFor (đây là một tham số trong phần config)
  • locked_at: thời điểm lấy được khóa
  • locked_by: đối tượng nắm giữ khóa

Sau khi nhiệm vụ hoàn thành thì lock_until sẽ được đặt về thời điểm mới nhất, tuy nhiên nó sẽ rơi vào hai trường hợp sau:

  • Khi thời gian xử lý của job nhanh hơn thời gian của lockAtLeastFor thì ShedLock sẽ luôn đặt lock_until = locked_at + lockAtLeastFor khi mở khóa
  • Khi thời gian xử lý của job lâu hơn thời gian của lockAtLeastFor, nhưng không vượt qua lockAtMostFor thì lúc này lock_until = now()

Triển khai Schedule Job ShedLock với MySQL

Hiện tại ShedLock đã có triển khai với nhiều provider khác nhau: Hazelcast, ElasticSearch, Cassandra, PostgreSQL, MySQL… ở bài này mình đi vào triển khai với MySQL.

Cấu hình dự án như ảnh bên dưới:

struct project
package com.cafeincode.shedlock.config;

import org.springframework.boot.autoconfigure.jdbc.DataSourceProperties;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.jdbc.core.JdbcTemplate;

import javax.sql.DataSource;

@Configuration
public class DatasourceConfiguration {

    @Bean
    @ConfigurationProperties("spring.datasource")
    public DataSourceProperties dataSourceProperties() {
        return new DataSourceProperties();
    }

    @Bean("dataSource")
    @Primary
    public DataSource dataSource() {
        return dataSourceProperties().initializeDataSourceBuilder().build();
    }

    @Bean("jdbcTemplate")
    public JdbcTemplate jdbcTemplate() {
        return new JdbcTemplate(dataSource());
    }

}
package com.cafeincode.shedlock.config;


import net.javacrumbs.shedlock.core.LockProvider;
import net.javacrumbs.shedlock.provider.jdbctemplate.JdbcTemplateLockProvider;
import net.javacrumbs.shedlock.spring.annotation.EnableSchedulerLock;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;

import javax.sql.DataSource;

@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "15m")
public class ShedLockConfiguration {

    @Bean
    public LockProvider lockProvider(DataSource dataSource) {
        return new JdbcTemplateLockProvider(dataSource);
    }
}

ở annotation @EnableSchedulerLock có một tham số defaultLockAtMostFor chỉ ra rằng nếu những schedule job thực tế mà không khai báo lockAtMostFor thì nó sẽ sử dụng tham số mặc định này.

package com.cafeincode.shedlock.job;

import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import net.javacrumbs.shedlock.spring.annotation.SchedulerLock;
import org.apache.commons.lang3.time.StopWatch;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;

@EnableAsync
@Component
@Slf4j
public class ShedLockJobSchedule {

    private static final String TASK_NAME = "JOB_SCHEDULE_AGGREGATE";

    @Scheduled(cron = "${shedlock.job.cron}")
    @SchedulerLock(name = TASK_NAME, lockAtLeastFor = "${shedlock.job.lockAtLeastFor}", lockAtMostFor = "${shedlock.job.lockAtMostFor}")
    @SneakyThrows
    public void run() {
        StopWatch watch = StopWatch.createStarted();
        writeLog();
        watch.stop();
        log.info("[Job] end schedule job total executed time :[{}] s", watch.getTime(TimeUnit.SECONDS));
    }

    private void writeLog() throws InterruptedException {
        log.info("[Job] started schedule job with shedlock by cafeincode");
        Thread.sleep(10000);
    }
}
spring.datasource.url=jdbc:mysql://localhost:3307/cafeincode_schema?useEncoding=true&characterEncoding=UTF-8&zeroDateTimeBehavior=convertToNull&useLegacyDatetimeCode=false&serverTimezone=Asia/Ho_Chi_Minh&useSSL=false&allowPublicKeyRetrieval=true
spring.datasource.username=root
spring.datasource.password=123456789

spring.jpa.properties.hibernate.format_sql=true
spring.jackson.time-zone=Asia/Ho_Chi_Minh
spring.jackson.date-format=dd-MM-yyyy HH:mm:ss
spring.datasource.hikari.maxLifeTime=600000
spring.jpa.show-sql=true

shedlock.job.cron= 0/30 * * * * *
shedlock.job.lockAtLeastFor= 30s
shedlock.job.lockAtMostFor= 10m

server.port=8888
version: '3.0'
services:
  mysql:
    container_name: cafeincode-mysql
    image: mysql:5.7
    ports:
      - "3307:3306"
    environment:
      MYSQL_DATABASE: "cafeincode_schema"
      MYSQL_ROOT_PASSWORD: "123456789"
      MYSQL_ALLOW_EMPTY_PASSWORD: "true"
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.7.7</version>
        <relativePath/> <!-- lookup parent from repository -->
    </parent>
    <groupId>com.cafeincode</groupId>
    <artifactId>schedule-shedlock</artifactId>
    <version>0.0.1</version>
    <name>schedule-job-shedlock-spring</name>
    <description>Building schedule job with shedlock and spring</description>
    <properties>
        <java.version>1.8</java.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>net.javacrumbs.shedlock</groupId>
            <artifactId>shedlock-spring</artifactId>
            <version>4.37.0</version>
        </dependency>
        <dependency>
            <groupId>net.javacrumbs.shedlock</groupId>
            <artifactId>shedlock-provider-jdbc-template</artifactId>
            <version>4.37.0</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.apache.commons</groupId>
            <artifactId>commons-lang3</artifactId>
            <version>3.12.0</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.26</version>
            <scope>provided</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
            </plugin>
        </plugins>
    </build>

</project>
CREATE TABLE shedlock
(
    name       VARCHAR(64),
    lock_until TIMESTAMP NULL,
    locked_at  TIMESTAMP NULL,
    locked_by  VARCHAR(255),
    PRIMARY KEY (name)
);

Demo cron job shedlock

Trước tiên chạy docker-compose up -d để khởi tạo môi trường cho mysql, sau đó chạy script để tạo bảng shedlock, mình có đính kèm câu sql phía trên, cái này rất đơn giản.

Sau đó run project lên và theo dõi

khi instance bắt đầu chạy job
lúc này là khi lấy được khóa
lúc này job đã chạy xong

Thời điểm lockAtLeastFor mình để là 30s, còn thời gian lockAtMostFor mình để 10 phút, theo như hình các bạn có thể thấy:

  • khi job bắt đầu chạy, trong bảng shedlock sinh ra một bản ghi thời điểm lấy khóa (locked_at) là 2023-03-18 16:15:30, lúc này lock_until = locked_at (cũng chính là now()) + lockAtMostFor thành 2023-03-18 16:25:30
  • Nghĩa là job sẽ chạy tối đa tới thời điểm 2023-03-18 16:25:30, tuy nhiên ảnh thứ 3 thì thấy job chỉ chạy 10s, ít hơn cả thời điểm lockAtLeastFor (30s) nên sau khi job chạy xong thì lock_until được đặt thành 2023-03-18 16:16:00 (lúc này mình chụp thiếu 1 hình trong DB thôi nhưng các bạn biết thế là được, vì job hoàn thành trước thời gian lockAtLeastFor)

Một vài lưu ý cơ bản

  • Về bản chất ShedLock chỉ là triển khai của 1 khóa, trong những trường hợp job chạy quá lâu và lâu hơn cả thời gian cấu hình của lockAtMostFor thì sẽ nảy sinh vấn đề về việc nhả khóa, lấy khóa từ các instance khác. Cách giải quyết lúc này là hãy điều chỉnh tham số lockAtMostFor dài hơn nhiều so với thời gian mà job thực hiện.
  • Trường hợp nhiều instance bị chênh lệch đồng hồ, và thời gian chênh lệch nhiều hơn so với lockAtLeastFor, khi đó nhiệm vụ sẽ bị thực hiện nhiều lần, lúc này cách xử lý là theo dõi quá trình thực hiện job và tinh chỉnh lại tham số lockAtLeastFor cho phù hợp.

Xem triển khai chi tiết tại đây: cafeincode.com/schedule-job-shedlock

Một số bài viết liên quan: