+1

Vượt Qua Cron: Vì Sao Chúng Tôi Từ Bỏ Spring @Scheduled Và Redis Lock Để Đến Với Temporal

Vượt Qua Cron: Vì Sao Chúng Tôi Từ Bỏ Spring @Scheduled Và Redis Lock Để Đến Với Temporal

Hành trình di trú engine đối soát tài chính quan trọng từ kiến trúc polling database mong manh và khóa phân tán sang Temporal Scheduled Workflows bền bỉ.


Chuông Báo Động Lúc Nửa Đêm

Lúc 2:03 sáng, điện thoại tôi rung bần bật với cảnh báo sự cố P1: "Đối soát Voucher Thất bại — Phát hiện Sai lệch dữ liệu."

Với vai trò Principal Software Engineer, bị đánh thức bởi lỗi đồng bộ database là một cơn ác mộng quen thuộc. Trong hệ thống của chúng tôi, engine đối soát chịu trách nhiệm quét hàng ngàn bản ghi giao dịch, khớp dữ liệu bán hàng POS với mã voucher, và giải quyết các bất thường về trạng thái. Nếu job này không chạy hoặc chạy hai lần, chúng tôi hoặc khóa nhầm voucher hợp lệ hoặc mất tiền vì double-spending.

Khi kiểm tra log, tôi phát hiện một thảm họa hệ thống phân tán kinh điển. Chúng tôi đã scale microservice lên ba replica để đáp ứng chiến dịch marketing. Cả ba instance cùng thức dậy vào đúng một phút. Instance A giành được Redis lock và bắt đầu xử lý. Một đợt network partition ngắn khiến Instance A không thể gia hạn lease; TTL của ShedLock hết hạn, và Instance B vui vẻ nhặt lấy cùng job đó — trong khi Instance A vẫn đang chạy. Hai worker đối soát cùng một lô voucher song song, mỗi thằng bắn cùng downstream API calls, và sổ cái của chúng tôi kết thúc với các bút toán trùng lặp.

Chúng tôi đã rơi vào cái bẫy sử dụng annotation @Scheduled của Spring kết hợp với Redis distributed locks (ShedLock) để xử lý các cron job nghiệp vụ quan trọng, có trạng thái.

Trong bài viết này, tôi sẽ chia sẻ các anti-pattern của cách tiếp cận truyền thống này, sự chuyển đổi tư duy mà chúng tôi phải thực hiện, và cách chúng tôi cuối cùng đã di trú scheduler sang Temporal Durable Scheduled Workflows để đảm bảo rằng các cron job luôn được thực thi đáng tin cậy, bất kể điều gì xảy ra.


Anti-Pattern: Spring @Scheduled + Redis Locks

Trong nhiều năm, công thức chuẩn để chạy background tasks trong môi trường Java cluster trông như thế này:

  1. Gắn annotation @Scheduled(cron = "...") của Spring lên method.
  2. Bọc phần thực thi bằng thư viện distributed locking (như ShedLock dựa trên Redis) để ngăn nhiều replica chạy cùng job đồng thời.
  3. Query database bên trong method để tìm việc cần làm, xử lý nó, và cập nhật trạng thái database.

Đây là đoạn code legacy của chúng tôi:

@Component
public class LegacyVoucherScheduler {

    @Scheduled(cron = "0 */2 * * * *") // Chạy mỗi 2 phút
    // @SchedulerLock đóng vai distributed lock, backed bởi Redis (dùng thư viện ShedLock)
    @SchedulerLock(name = "voucherReconciliationLock", lockAtMostFor = "5m", lockAtLeastFor = "1m")
    public void reconcileVouchers() {
        // Query DB tìm các voucher redemption bị lỗi
        List<Voucher> unresolved = voucherRepo.findUnresolvedVouchers();
        for (Voucher voucher : unresolved) {
            // Xử lý phức tạp, gọi API bên ngoài, cập nhật DB
            reconcile(voucher);
        }
    }
}

Thoạt nhìn, code trông sạch sẽ, đơn giản và chuẩn mực. Nhưng khi throughput giao dịch tăng lên, mô hình này bắt đầu rách toạc.

1. Bẫy Lock Contention Và Network Glitch

Distributed locks phụ thuộc vào hệ thống bên ngoài (Redis/ZooKeeper) và thời gian lease. Nếu một worker instance bị garbage-collection (JVM GC pause) hoặc gặp nghẽn mạng sau khi đã giành được lock, lease có thể hết hạn trước khi job chạy xong. Một instance khác lập tức cho rằng lock đã trống, khởi động, và thực thi đúng cái job đó — gây ra race conditions nghiêm trọng. Các phiên bản ShedLock hiện đại hỗ trợ gia hạn lock (lockAtMostFor heartbeating) giúp giảm thiểu điều này, nhưng không loại bỏ hoàn toàn: một partition kéo dài hơn lease vẫn tạo ra overlap. Ngược lại, nếu một instance crash giữa chừng, lock có thể bị rò rỉ, chặn các lần chạy tiếp theo hoàn toàn cho đến khi TTL hết hạn.

Lưu ý về cú pháp cron. Ví dụ trên dùng cron 6 trường của Spring (giây đứng đầu). setCronSchedule() của Temporal, trình bày sau, dùng cron chuẩn 5 trường — copy-paste giữa hai cú pháp sẽ âm thầm gây lỗi.

2. Lỗi Âm Thầm Và Thiếu Khả Năng Quan Sát

Khi một @Scheduled job lỗi, nó lỗi âm thầm bên trong một background thread pool. Trừ khi bạn đã thiết lập các log parser phức tạp hoặc custom APM alerts, bạn sẽ không biết job đã crash. Hơn nữa, không có dashboard tập trung nào hiển thị lịch sử thực thi, trạng thái task, tham số đầu vào, hay stack traces của scheduled jobs xuyên suốt các replica.

3. Mất Lượt Chạy Khi Scale Down

Nếu node đang chạy cron task bị terminate trong quá trình rolling deployment, task bị kill giữa chừng. Lock ngăn các node khác tiếp quản, và lần thực thi đó biến mất vĩnh viễn cho đến chu kỳ cron tiếp theo. Đối với các thao tác sổ cái tài chính quan trọng, một lượt chạy bị mất đồng nghĩa trực tiếp với khiếu nại từ khách hàng.


Chuyển Đổi Tư Duy: Từ DB Polling Sang State Machine Hướng Sự Kiện

Để giải quyết các vấn đề này, chúng tôi nhận ra cần phải thay đổi góc nhìn. Một scheduled job không nên chỉ là một timer đơn lẻ polling database. Nó là một state machine phân tán, chạy dài hạn.

Thay vì để các application instance liên tục polling database để xem liệu có việc cần làm không, chúng tôi chuyển sang mô hình push — nơi trạng thái scheduling được quản lý bởi một orchestrator tập trung, bền bỉ: Temporal.

Temporal coi workflows như những đoạn code có khả năng phục hồi, có trạng thái, và nhất quán (deterministic). Trong kiến trúc này, scheduler không phải là một timer cục bộ trong JVM; nó là một thực thể bền bỉ được quản lý bởi Temporal Server, lên lịch tasks trên một distributed Queue bên ngoài. Các application instance cục bộ đơn giản đóng vai Workers không trạng thái, poll Queue này và chỉ thực thi khi được chỉ thị.

Với kiến trúc này, ngay cả khi Instance A crash giữa chừng quá trình đối soát, Temporal Server lập tức phát hiện mất heartbeat, đặt activity trở lại hàng đợi, và chuyển hướng nó sang Instance B — đảm bảo at-least-once execution guarantees (đảm bảo thực thi ít nhất một lần).

⚠️ At-least-once, không phải exactly-once. Temporal đảm bảo một workflow với Workflow ID cho trước sẽ không chạy đồng thời với chính nó, nhưng một activity có thể chạy nhiều hơn một lần (ví dụ: worker đã thực hiện side effect, rồi crash trước khi báo cáo thành công). Với bất cứ thứ gì liên quan đến tiền — trạng thái voucher, ghi sổ cái, API thanh toán bên ngoài — activities phải idempotent, thường thông qua idempotency key được tạo từ workflow run và business entity.


Triển Khai Durable Scheduled Workflows

Hãy xem cách chúng tôi thực hiện cuộc di trú này trong hệ thống bằng Java SDK.

Đầu tiên, chúng tôi định nghĩa Workflow Interface. Khác với scheduled methods cục bộ truyền thống, Temporal workflows được biểu diễn bằng interface với annotation @WorkflowInterface:

package com.example.transaction.infrastructure.temporal;

import io.temporal.workflow.WorkflowInterface;
import io.temporal.workflow.WorkflowMethod;

@WorkflowInterface
public interface VoucherReconciliationWorkflow {

    @WorkflowMethod(name = "VTransaction.VoucherReconciliationCron")
    void execute();
}

Tiếp theo, chúng tôi định nghĩa Activity Interface. Code workflow phải hoàn toàn deterministic (không được gọi database hoặc network I/O trực tiếp), vì vậy mọi side-effect — DB queries, downstream API calls — đều sống trong activities:

package com.example.transaction.infrastructure.temporal;

import io.temporal.activity.ActivityInterface;
import io.temporal.activity.ActivityMethod;

@ActivityInterface
public interface VoucherReconciliationActivity {

    @ActivityMethod
    void reconcileFailedVoucherStatus();
}

Implementation chỉ là một Spring bean bình thường inject repositories và HTTP clients — không có gì đặc biệt liên quan đến Temporal ngoài interface. Hãy đảm bảo mỗi lần ghi ra bên ngoài được gắn key ổn định (ví dụ: voucher.id + workflow run-id) để retries không gây double-spend.

Bây giờ đến phần workflow. Activity stub là một proxy được sinh tự động: gọi method trên nó sẽ ghi lại một lệnh ScheduleActivityTask mà Temporal biến thành một activity task trên queue.

package com.example.transaction.infrastructure.temporal;

import io.temporal.activity.ActivityOptions;
import io.temporal.common.RetryOptions;
import io.temporal.workflow.Workflow;
import java.time.Duration;

public class VoucherReconciliationWorkflowImpl implements VoucherReconciliationWorkflow {

    private final VoucherReconciliationActivity activity = Workflow.newActivityStub(
            VoucherReconciliationActivity.class,
            ActivityOptions.newBuilder()
                    .setStartToCloseTimeout(Duration.ofMinutes(5))
                    .setRetryOptions(RetryOptions.newBuilder()
                            .setInitialInterval(Duration.ofSeconds(2))
                            .setMaximumInterval(Duration.ofSeconds(30))
                            .setMaximumAttempts(5)
                            .build())
                    .build());

    @Override
    public void execute() {
        activity.reconcileFailedVoucherStatus();
    }
}

Một workflow và activity mà không ai hosting sẽ nằm mãi trên task queue. Chúng tôi đăng ký cả hai với một Worker gắn vào task queue v-transaction:

@Configuration
@RequiredArgsConstructor
public class TemporalWorkerConfig {

    private final WorkerFactory workerFactory;
    private final VoucherReconciliationActivity reconciliationActivity;

    @Bean
    public Worker voucherReconciliationWorker() {
        Worker worker = workerFactory.newWorker("v-transaction");
        worker.registerWorkflowImplementationTypes(VoucherReconciliationWorkflowImpl.class);
        worker.registerActivitiesImplementations(reconciliationActivity);
        return worker;
    }
}

Nếu bạn thích auto-discovery, temporal-spring-boot-starter sẽ tự động đăng ký mọi bean có annotation @WorkflowImpl / @ActivityImpl — lúc đó bean Worker tường minh ở trên trở thành không bắt buộc.

Cuối cùng, thay vì cấu hình local timers trong application properties, chúng tôi khởi tạo Temporal Workers và đăng ký cron schedule với Temporal Cluster khi ApplicationReadyEvent xảy ra.

Đây là cấu hình WorkerLifecycle chắc chắn của chúng tôi:

package com.example.transaction.config;

import io.temporal.api.enums.v1.WorkflowIdConflictPolicy;
import io.temporal.client.WorkflowClient;
import io.temporal.client.WorkflowOptions;
import io.temporal.worker.WorkerFactory;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.ContextClosedEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;

@Slf4j
@Component
@RequiredArgsConstructor
public class WorkerLifecycle {

    private final WorkerFactory workerFactory;
    private final WorkflowClient workflowClient;

    @Value("${temporal.worker.enabled:true}")
    private boolean workerEnabled;

    // Biểu thức cron chuẩn 5 trường (ví dụ: chạy hàng ngày lúc 2:00 AM)
    @Value("${app.voucher.scheduler.reconcile-cron:0 2 * * *}")
    private String reconcileCron;

    @EventListener(ApplicationReadyEvent.class)
    public void startWorkerFactory() {
        if (!workerEnabled) {
            log.info("Temporal worker bị tắt; bỏ qua scheduling.");
            return;
        }

        log.info("Khởi động Temporal WorkerFactory...");
        workerFactory.start();

        startVoucherReconciliationCron();
    }

    private void startVoucherReconciliationCron() {
        VoucherReconciliationWorkflow workflow = workflowClient.newWorkflowStub(
                VoucherReconciliationWorkflow.class,
                WorkflowOptions.newBuilder()
                        .setWorkflowId("voucher-reconciliation-cron")
                        .setTaskQueue("v-transaction")
                        .setCronSchedule(reconcileCron)
                        // Chạy lại mỗi lần app boot là an toàn:
                        // USE_EXISTING trả về execution đang chạy thay vì throw
                        // WorkflowExecutionAlreadyStarted.
                        .setWorkflowIdConflictPolicy(WorkflowIdConflictPolicy.USE_EXISTING)
                        .build());

        WorkflowClient.start(workflow::execute);
        log.info("Đã đăng ký VoucherReconciliationWorkflow với cron: {}", reconcileCron);
        // Chúng tôi CỐ Ý KHÔNG nuốt exception ở đây: đăng ký schedule thất bại
        // phải làm fail quá trình boot để liveness/readiness phản ánh đúng thực tế.
    }

    @EventListener(ContextClosedEvent.class)
    public void shutdownWorkerFactory() throws InterruptedException {
        if (!workerEnabled) {
            return;
        }
        log.info("Đang tắt Temporal WorkerFactory một cách nhẹ nhàng...");
        workerFactory.shutdown();
        // Cho phép các activity đang chạy hoàn thành (hoặc chạm heartbeat timeout
        // và được reschedule trên worker còn sống) trước khi JVM thoát.
        workerFactory.awaitTermination(30, TimeUnit.SECONDS);
    }
}

Ghi chú về setCronSchedule vs. Schedules API hiện đại

Ví dụ trên dùng WorkflowOptions.setCronSchedule() vì nó là phép tương đương trực tiếp, một dòng với @Scheduled và giúp việc di trú dễ hiểu. Cho code mới, Temporal khuyến nghị Schedules API (ScheduleClient), cung cấp pause/unpause, trigger() thủ công, backfill, jitter, và overlap policies tường minh (SKIP, BUFFER_ONE, CANCEL_OTHER) — tất cả được hiển thị như first-class operations trong Web UI:

ScheduleClient scheduleClient = ScheduleClient.newInstance(service);

scheduleClient.createSchedule(
    "voucher-reconciliation",
    Schedule.newBuilder()
        .setAction(ScheduleActionStartWorkflow.newBuilder()
            .setWorkflowType(VoucherReconciliationWorkflow.class)
            .setOptions(WorkflowOptions.newBuilder()
                .setWorkflowId("voucher-reconciliation")
                .setTaskQueue("v-transaction")
                .build())
            .build())
        .setSpec(ScheduleSpec.newBuilder()
            .setCronExpressions(List.of(reconcileCron))
            .build())
        .setPolicy(SchedulePolicy.newBuilder()
            .setOverlap(ScheduleOverlapPolicy.SCHEDULE_OVERLAP_POLICY_SKIP)
            .build())
        .build(),
    ScheduleOptions.newBuilder().build());

Nếu bạn đang xây hệ thống từ đầu, hãy bắt đầu từ đây.


Đánh Đổi: Liệu Có Xứng Đáng?

Chuyển sang Temporal đã giải quyết mọi sự cố production mà chúng tôi gặp phải với hệ thống cron dùng Redis lock trước đó. Tuy nhiên, trong kỹ nghệ phần mềm, không có bữa trưa miễn phí. Đây là các đánh đổi chúng tôi đã đánh giá:

🟢 Những Chiến Thắng:

  • Không Có Tự Chạy Đồng Thời: Temporal Server là nguồn sự thật duy nhất cho scheduling và bắt buộc tính duy nhất của Workflow ID — chỉ một execution đang chạy của một cron workflow tồn tại tại bất kỳ thời điểm nào. Kết hợp với idempotent activities (xem phần Chi phí), lớp bug double-spend biến mất.
  • Khả Năng Quan Sát Tập Trung: Mọi lần chạy đều có thể kiểm tra trong Temporal Web UI — thời lượng, inputs, outputs, số lần retry, stack traces, và worker nào đã thực thi mỗi activity. Không cần grep ba pods để tìm xem ai đã chạy job lúc 2 giờ sáng nữa.
  • Chịu Lỗi Nguyên Bản: Nếu một Spring Boot replica crash giữa chừng, Temporal reschedule activity đang chạy sang một worker khỏe mạnh. Trạng thái workflow được lưu trữ bền bỉ trong cluster, vì vậy lần chạy không bao giờ bị mất — chỉ bị trễ bởi retry policy.

🔴 Chi Phí:

  • Overhead Hạ Tầng: Bạn phải host và quản lý một Temporal Cluster (với persistence store như PostgreSQL hoặc Cassandra) hoặc trả phí cho Temporal Cloud. Bề mặt phụ thuộc tăng lên.
  • Đường Cong Học Tập Kiến Trúc: Developers phải nội hóa ranh giới determinism giữa workflows và activities, và mô hình replay bắt buộc nó. Dự kiến 1–2 sprint ramp-up trước khi team thành thạo.
  • Idempotency Activity Là Trách Nhiệm Của Bạn: Temporal xử lý delivery; nó không xử lý exactly-once side effects. Mọi activity ghi vào DB hoặc gọi external API đều cần idempotency key. Đây là điều không thể thương lượng cho workloads tài chính.
  • Thay Đổi Tư Duy Testing: Unit tests giờ dùng TestWorkflowEnvironment với time-skipping; integration tests cần dev server. Mocking cron tick thì dễ, nhưng scaffolding test không quen thuộc với hầu hết Spring teams.

Cutover Di Trú: Cách Chúng Tôi Bật Công Tắc Mà Không Chạy Đúp

Phần rủi ro nhất không phải viết code mới — mà là không chạy cả hai scheduler cùng lúc. Playbook của chúng tôi:

  1. Ship Temporal workflow ở trạng thái tắt. Feature flag (app.voucher.scheduler.engine=legacy|temporal) mặc định là legacy. Workers đăng ký nhưng không tạo schedule.
  2. Shadow-run. Bật flag sang temporal ở staging; giữ method @Scheduled phía sau if (engine == LEGACY). Đường đi mới ghi vào một trường shadow status trong một tuần. Diff kết quả shadow vs. legacy trong báo cáo đêm.
  3. Cutover từng môi trường. Production nhận flag flip cuối cùng, với cửa sổ soak 24 giờ trong thời điểm traffic thấp. Bean @Scheduled legacy vẫn nằm trong codebase — bị tắt — cho một release cycle đầy đủ làm đường rollback.
  4. Xóa code legacy. Chỉ sau hai chu kỳ cron với zero discrepancies. ShedLock dependency được loại bỏ trong cùng MR.

Bỏ qua shadow-run chính là cách các team vô tình double-process một ngày voucher.


Observability: Đừng Dừng Lại Ở "Nó Đã Có Trên UI"

Temporal Web UI tuyệt vời cho việc kiểm tra, nhưng alerting production vẫn thuộc về stack hiện tại của bạn:

  • Metrics: Kết nối MicrometerClientStatsReporter vào WorkflowServiceStubs để SDK metrics của Temporal (temporal_workflow_*, temporal_activity_*) chảy vào Prometheus cùng với mọi thứ khác.
  • Tracing: Thêm OpenTelemetry workflow/activity interceptors để span từ upstream HTTP request tiếp tục xuyên qua activity và vào downstream DB call.
  • Business SLO: Alert quan trọng nhất không phải "Temporal is up" — mà là "voucher-reconciliation workflow chưa hoàn thành thành công trong 2 × cron interval gần nhất." Hiển thị nó dưới dạng Prometheus rule trên sự kiện CompletedExecution của workflow.

Đúc Kết

Nếu bạn đang dựa vào @Scheduled và distributed locks để xử lý các thao tác ảnh hưởng trực tiếp đến dòng tiền của công ty, bạn đang đùa với lửa. Đây là những gì chúng tôi học được từ cuộc di trú:

  1. Coi Scheduled Tasks Như State Machines. Khi tasks mang tầm quan trọng nghiệp vụ, hãy coi scheduling không phải là một JVM thread bất đồng bộ mà là một process bền bỉ, có thể kiểm tra được.
  2. Chuyển Từ Local Clocks Sang Server-Managed Schedules. Hãy để orchestrator quản lý trigger; để các replica của bạn là stateless, scalable consumers của task queue.
  3. Thiết Kế Cho At-Least-Once. Temporal đảm bảo delivery, không phải exactly-once side effects. Idempotent activities là hợp đồng mà bạn phải tuân thủ.
  4. Cutover Phía Sau Feature Flag. Shadow-run scheduler mới song song với cái cũ trước khi xóa legacy code. Sai lệch tìm thấy trong diff thì rẻ; sai lệch tìm thấy trong ticket khách hàng thì không.

Bạn đã từng chuyển đổi khỏi distributed locks trong microservices của mình chưa? Những sự cố scheduling production tệ nhất mà bạn phải debug lúc 2 giờ sáng là gì? Hãy thảo luận ở phần bình luận bên dưới!



All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí