Làm thế nào để cho phép user tự lập lịch gửi email trong phần mềm — gửi hôm nay, tuần này, tháng này, hoặc lặp lại theo tần suất bất kỳ? Nghe đơn giản, nhưng khi có hàng trăm nghìn user cùng đặt lịch, bài toán “ai sẽ thức dậy đúng giờ để gửi email” không còn đơn giản nữa.

Vấn đề với cách làm cũ: cronjob chạy ở tầng worker

Trước đây, các cronjob được thiết kế để chạy trực tiếp ở tầng worker — mỗi lịch là một background service được cấu hình tay và cài sẵn trong code.

Cách này hoạt động ổn cho một số ít job cố định, nhưng có một vấn đề chí mạng: không mở rộng được khi user tự cấu hình. User muốn thêm lịch mới thì phải đợi dev thêm một background service, build lại, deploy lại. Hàng trăm nghìn user mỗi người vài lịch thì không thể nào “cấu hình tay” hết được.

Cần một cách để lịch trở thành dữ liệu: user tạo lịch → lịch được lưu lại → một worker duy nhất đọc dữ liệu đó và biết đúng giờ phải chạy gì, cho dù lịch là của ai.

Database master với 2 bảng: job_system và job_user

Giải pháp là tôi đưa mọi lịch về database master, chia làm 2 bảng theo đúng bản chất nguồn gốc của job:

job_system — cronjob của hệ thống

Đây là các job do tôi (dev) cấu hình, thay vì viết cứng trong code thì giờ lưu thành dòng dữ liệu. Mỗi dòng gồm:

  • Thời điểm chạy: biểu thức cron.
  • File assembly của C#: chỉ rõ worker biết cần nạp file DLL nào.
  • Namespace của background service: chỉ rõ class nào trong assembly đó cần chạy.
job_system.sql sql
 1CREATE SCHEMA IF NOT EXISTS td;
 2
 3CREATE TABLE IF NOT EXISTS td.job_system (
 4    id              UUID        DEFAULT gen_random_uuid() PRIMARY KEY,
 5    job_name        VARCHAR(255) NOT NULL,
 6    cron_expression TEXT         NOT NULL,          -- thời điểm chạy
 7    assembly_file   TEXT         NOT NULL,          -- vd: MyApp.Jobs.dll
 8    namespace_class TEXT         NOT NULL,          -- vd: MyApp.Jobs.SendSummaryEmail
 9    is_active       BOOLEAN      DEFAULT TRUE,
10    created_at      TIMESTAMP    DEFAULT NOW()
11);

Các job hệ thống này vẫn được chạy bằng Quartz.NET như trước, nhưng giờ Quartz chỉ là bộ máy chấp hành — nó đọc từ job_system xem giờ nào chạy job nào, chứ không còn viết tay từng trigger. Muốn thêm/sửa một job hệ thống, chỉ cần thêm một dòng trong DB.

job_user — job do user tạo

Đây là nơi chứa lịch mà user tự tạo trên UI (gửi email mỗi tháng ngày 30, mỗi tuần thứ 2…). Biểu thức cron được build từ lựa chọn của user, kèm database_id để worker biết dữ liệu của user đang nằm ở database nào, và job_type để biết loại nghiệp vụ cần xử lý:

job_user.sql sql
1CREATE TABLE IF NOT EXISTS td.job_user (
2    id              UUID        DEFAULT gen_random_uuid() PRIMARY KEY,
3    database_id     UUID        NOT NULL,   -- id database user, worker dùng để lấy connection string
4    cron_expression TEXT        NOT NULL,
5    job_type        VARCHAR(50) NOT NULL,   -- loại nghiệp vụ: vd 'send_email'
6    is_active       BOOLEAN     DEFAULT TRUE,
7    created_at      TIMESTAMP   DEFAULT NOW()
8);

database_id trỏ tới bảng danh mục database trong DB master — nhờ nó, khi worker cần truy vấn dữ liệu của user, nó tra ra connection string tương ứng mà không cần lưu thông tin user trực tiếp trong job.

Cron expression — biểu thức lịch tùy chỉnh

Mọi lịch (cả hệ thống lẫn user) đều được chuẩn hóa thành một biểu thức cron mở rộng 9 trường:

* * * * * * * * *
| | | | | | | | |
| | | | | | | | +-- End time   (vd 2026-12-31)
| | | | | | | +---- Start time (vd 2026-01-01)
| | | | | | +------ Year       (1900-3000)
| | | | | +-------- Day of week (1-7, 1 = Sunday)
| | | | +---------- Month      (1-12)
| | | +------------ Day of month (1-31)
| | +-------------- Hour       (0-23)
| +---------------- Minute     (0-59)
+------------------ Second     (0-59)

Ngoài chuẩn chung, tôi mở rộng thêm vài logic để bao phủ mọi nhu cầu thực tế:

Logic Ví dụ Ý nghĩa
Toàn bộ * mọi giá trị đều phù hợp
Giá trị cụ thể 12 đúng giá trị 12
Range 1-5 trong khoảng 1 đến 5
List 1,2,3 thuộc một trong các giá trị
Step */10 chia hết cho 10
Cuối tháng L ngày cuối cùng của tháng
Ngày cụ thể hoặc cuối tháng `30 L`

Logic 30|L là phần tôi custom thêm, vì các ngày 29, 30, 31 không phải tháng nào cũng có.

User không bao giờ gõ tay cron expression — backend dùng builder để dựng từ lựa chọn của user:

CronGeneratorTest.cs csharp
 1DateTime currentTime = DateTime.Now;
 2
 3CronExpressionModel cronExp = new CronExpressionBuilder()
 4    .SetStartTimeAndEndtime(currentTime.AddDays(-30), currentTime)
 5    .SetMonthly(30)          // mỗi tháng, ngày 30
 6    .BuildHour(6)            // lúc 6h sáng
 7    .GetResult();
 8
 9string cronExpression = cronExp.ToString();
10// "* * 6 30|L * * * 2026-01-01 2026-08-01"

Check “hôm nay có đúng lịch không” ngay trong database

Thay vì đọc toàn bộ job lên bộ nhớ rồi tự parse cron, tôi đưa logic check xuống PostgreSQL. Function td.is_cron_expression_satisfied bóc tách 9 trường của biểu thức, đối chiếu từng phần với thời điểm đích, và gọi td.is_cron_value_satisfied để xử lý từng logic *, range, list, step, L, 30|L:

02_is_cron_expression_satisfied.sql sql
  1DROP FUNCTION IF EXISTS td.is_cron_expression_satisfied(cron_expression VARCHAR, target_datetime TIMESTAMP WITHOUT TIME ZONE);
  2CREATE OR REPLACE FUNCTION td.is_cron_expression_satisfied(
  3    cron_expression VARCHAR,
  4    target_datetime TIMESTAMP WITHOUT TIME ZONE
  5)
  6RETURNS BOOLEAN
  7AS $$
  8DECLARE
  9    cron_parts VARCHAR[] = STRING_TO_ARRAY(cron_expression, ' ');
 10    seconds VARCHAR;
 11    minutes VARCHAR;
 12    hours VARCHAR;
 13    day_of_month VARCHAR;
 14    month VARCHAR;
 15    day_of_week VARCHAR;
 16    year VARCHAR;
 17    start_time VARCHAR;
 18    end_time VARCHAR;
 19    target_second INT = EXTRACT(SECOND FROM target_datetime);
 20    target_minute INT = EXTRACT(MINUTE FROM target_datetime);
 21    target_hour INT = EXTRACT(HOUR FROM target_datetime);
 22    target_day_of_month INT = EXTRACT(DAY FROM target_datetime);
 23    target_month INT = EXTRACT(MONTH FROM target_datetime);
 24    target_day_of_week INT = EXTRACT(DOW FROM target_datetime) + 1;
 25    target_year INT = EXTRACT(YEAR FROM target_datetime);
 26BEGIN
 27    seconds       := coalesce(cron_parts[1], '*');
 28    minutes       := coalesce(cron_parts[2], '*');
 29    hours         := coalesce(cron_parts[3], '*');
 30    day_of_month  := coalesce(cron_parts[4], '*');
 31    month         := coalesce(cron_parts[5], '*');
 32    day_of_week   := coalesce(cron_parts[6], '*');
 33    year          := coalesce(cron_parts[7], '*');
 34    start_time    := coalesce(cron_parts[8], '*');
 35    end_time      := coalesce(cron_parts[9], '*');
 36
 37    RETURN (
 38        (
 39            (start_time = '*' AND end_time = '*')
 40            OR (target_datetime BETWEEN start_time::date AND end_time::date)
 41        ) AND
 42        td.is_cron_value_satisfied(seconds, target_second, target_datetime) AND
 43        td.is_cron_value_satisfied(minutes, target_minute, target_datetime) AND
 44        td.is_cron_value_satisfied(hours, target_hour, target_datetime) AND
 45        td.is_cron_value_satisfied(day_of_month, target_day_of_month, target_datetime) AND
 46        td.is_cron_value_satisfied(month, target_month, target_datetime) AND
 47        td.is_cron_value_satisfied(day_of_week, target_day_of_week, target_datetime) AND
 48        td.is_cron_value_satisfied(year, target_year, target_datetime)
 49    );
 50END;
 51$$ LANGUAGE plpgsql;
 52
 53DROP FUNCTION IF EXISTS td.is_cron_value_satisfied(cron_value VARCHAR, target_value INT, target_datetime TIMESTAMP WITHOUT TIME ZONE);
 54CREATE OR REPLACE FUNCTION td.is_cron_value_satisfied(
 55    cron_value VARCHAR,
 56    target_value INT,
 57    target_datetime TIMESTAMP WITHOUT TIME ZONE
 58)
 59RETURNS BOOLEAN
 60AS $$
 61DECLARE
 62    cron_parts VARCHAR[] = REGEXP_SPLIT_TO_ARRAY(cron_value, ',');
 63    i INT;
 64BEGIN
 65    FOR i IN 1..ARRAY_LENGTH(cron_parts, 1) LOOP
 66        IF cron_parts[i] = '*' THEN
 67            -- mọi giá trị đều phù hợp
 68            RETURN TRUE;
 69        ELSIF cron_parts[i] LIKE '%-%' THEN
 70            -- giá trị nằm trong khoảng: vd 1-5
 71            DECLARE
 72                range_parts INT[] = ARRAY[
 73                    CAST(SPLIT_PART(cron_parts[i], '-', 1) AS INT),
 74                    CAST(SPLIT_PART(cron_parts[i], '-', 2) AS INT)
 75                ];
 76            BEGIN
 77                IF target_value BETWEEN range_parts[1] AND range_parts[2] THEN
 78                    RETURN TRUE;
 79                END IF;
 80            END;
 81        ELSIF cron_parts[i] LIKE '*/%' THEN
 82            -- giá trị chia hết cho bước: vd */10
 83            DECLARE
 84                step_value INT = CAST(SPLIT_PART(cron_parts[i], '*/', 2) AS INT);
 85            BEGIN
 86                IF target_value % step_value = 0 THEN
 87                    RETURN TRUE;
 88                END IF;
 89            END;
 90        ELSIF cron_parts[i] LIKE '%|L' THEN
 91            -- ngày cụ thể, nếu tháng không có thì lấy ngày cuối tháng: vd 30|L
 92            DECLARE
 93                day_test INT = CAST(SPLIT_PART(cron_parts[i], '|', 1) AS INT);
 94                last_day_of_month INT = EXTRACT(DAY FROM DATE_TRUNC('MONTH', target_datetime + INTERVAL '1 MONTH') - INTERVAL '1 DAY');
 95            BEGIN
 96                IF target_value = day_test THEN
 97                    RETURN TRUE;
 98                END IF;
 99                IF day_test > last_day_of_month AND target_value = last_day_of_month THEN
100                    RETURN TRUE;
101                END IF;
102            END;
103        ELSIF cron_parts[i] = 'L' THEN
104            -- ngày cuối cùng của tháng
105            DECLARE
106                last_day_of_month INT = EXTRACT(DAY FROM DATE_TRUNC('MONTH', target_datetime + INTERVAL '1 MONTH') - INTERVAL '1 DAY');
107            BEGIN
108                IF target_value = last_day_of_month THEN
109                    RETURN TRUE;
110                END IF;
111            END;
112        ELSIF target_value = CAST(cron_parts[i] AS INT) THEN
113            -- đúng giá trị cụ thể
114            RETURN TRUE;
115        END IF;
116    END LOOP;
117
118    RETURN FALSE;
119END;
120$$ LANGUAGE plpgsql;

Scheduler chỉ cần gọi đúng một câu lệnh để biết job có phải chạy ngay lúc này không:

check_job.sql sql
1SELECT td.is_cron_expression_satisfied(
2    '* * 6 30|L * * * 2026-01-01 2026-08-01',
3    NOW()
4);
5-- true nếu thời điểm hiện tại khớp lịch

Function lấy danh sách job cần chạy theo ngày giờ

Thay vì để scheduler tự query nhiều bước, tôi gói gọn vào một function: truyền vào ngày, giờ muốn checkloại nghiệp vụ cần xử lý, function sẽ chạy SELECT trên job_user — với WHERE lọc theo job_type và theo td.is_cron_expression_satisfied:

get_job_to_run.sql sql
 1DROP FUNCTION IF EXISTS td.get_job_to_run(
 2    target_datetime TIMESTAMP WITHOUT TIME ZONE,
 3    job_type VARCHAR
 4);
 5CREATE OR REPLACE FUNCTION td.get_job_to_run(
 6    target_datetime TIMESTAMP WITHOUT TIME ZONE,
 7    job_type VARCHAR
 8)
 9RETURNS SETOF td.job_user
10AS $$
11BEGIN
12    RETURN QUERY
13    SELECT *
14    FROM td.job_user j
15    WHERE j.job_type = job_type
16      AND j.is_active = TRUE
17      AND td.is_cron_expression_satisfied(j.cron_expression, target_datetime);
18END;
19$$ LANGUAGE plpgsql;

Scheduler gọi function này với giờ mình muốn check và loại nghiệp vụ cần chạy, nhận về đúng danh sách job phải xử lý:

query_job_to_run.sql sql
1SELECT *
2FROM td.get_job_to_run(
3    target_datetime => NOW(),       -- ngày giờ muốn check
4    job_type        => 'send_email' -- loại nghiệp vụ cần xử lý
5);

Worker điều phối: đọc job, check lịch, điều phối theo database_id

Không ai chạy timer cho từng job. Tại các mốc thời gian cố định (6h sáng, 5h chiều, 9h tối), một worker điều phối lo việc chọn job và điều phối. Trong môi trường Kubernetes/Docker, worker này là một instance duy nhất — đúng 1 pod cho toàn bộ phiên bản, không bao giờ chạy nhiều pod song song, để tránh hai pod cùng lúc đọc một job dẫn tới gửi trùng email:

flowchart TB
    QZ["Quartz.NET\nđọc job_system"]
    DB[("DB master\njob_system · job_user")]
    S["Worker điều phối\n1 pod duy nhất"]
    C{"User hoạt động\nn ngày gần nhất?"}
    Q[("ConcurrentQueue\njob không hoạt động")]
    W["Worker chung\nhàm dọn job"]
    G1["Kafka\nenv g1"]
    G2["Kafka\nenv g2"]
    GN["Kafka\nenv gn"]

    QZ --> S
    DB -->|"1. Đọc job + check cron"| S
    S -->|"2. Check hoạt động"| C
    C -->|"Không hoạt động"| Q
    Q -->|"Gọi hàm dọn"| W
    W -->|"UPDATE is_active = false"| DB
    C -->|"Hoạt động · g1"| G1
    C -->|"Hoạt động · g2"| G2
    C -->|"Hoạt động · gn"| GN
  1. Scheduler pod (1 pod duy nhất) wake lên đúng giờ quy định, gọi td.get_job_to_run với thời điểm cần check và loại nghiệp vụ để lấy danh sách job từ DB master.
  2. Function bên trong chạy một query check cron trong DB: chỉ trả về job nào thỏa mãn thời điểm hiện tại và đúng job_type. Nhờ check ngay trong SQL, cả trăm nghìn job được lọc xong chỉ trong vài query.
  3. Với mỗi job thỏa mãn, main task check trước khi publish: user có hoạt động trong n ngày gần nhất không:
    • Không hoạt động → không publish, đưa vào xử lý dọn job (bên dưới).
    • Có hoạt động → xác định env đích theo database_id (user nằm ở env g1, g2, … hay gn), rồi publish message vào Kafka của env đó.

Khi publish message vào queue, backend tự build động max_time_start cho từng message: hạn chót này được tính từ thời điểm chạy của lịch cộng thêm một khoảng chờ hợp lý, rồi đính kèm vào message. Nó không phải dữ liệu cố định lưu trong DB, mà là giá trị sinh ra lúc publish, vì mỗi lần chạy (chu kỳ này, chu kỳ sau) đều có mốc thời gian riêng. Đây là “giờ nhập viện” của message.

Check “user không hoạt động n ngày” trước khi publish

Job lịch gửi theo chu kỳ, nhưng không phải lúc nào user cũng có data mới. Nếu user không hoạt động trong n ngày gần nhất thì không có gì để gửi, nên main task lọc trước khi publish — không gửi message vô nghĩa:

MainTask.cs csharp
 1foreach (Job job in jobs)
 2{
 3    if (IsUserInactiveMoreThanDays(job, n: 7))
 4    {
 5        // không publish — đưa database id vào queue, gọi hàm dọn
 6        _concurrentQueueDBIds.Enqueue(job.DatabaseId);
 7        DoSlowMethod();
 8    }
 9    else
10    {
11        PublishToKafka(job);   // publish bình thường
12    }
13}

Worker chung dọn job không hoạt động

Việc dọn job được đưa về worker chung để main task không bị kéo theo việc nặng. Đây là class thật dùng chung cho toàn bộ phiên bản — thread main chạy vòng lặp bên trên (enqueue database_id rồi gọi DoSlowMethod), còn việc đọc ra và xử lý nghiệp vụ nằm ở thread khác:

TwoThreadUsingConcurrentQueue.cs csharp
 1using System.Collections.Concurrent;
 2
 3namespace TDProject.Core.Business;
 4
 5/// <summary>
 6/// Chạy song song 2 luồng bằng kiểu dữ liệu ConcurrentQueue:
 7/// 1 thread thêm dữ liệu vào biến lưu trữ global,
 8/// 1 thread đọc dữ liệu ra và handle nghiệp vụ.
 9/// </summary>
10public class TwoThreadUsingConcurrentQueue
11{
12    /// <summary>
13    /// danh sách database id đang chờ xử lý
14    /// </summary>
15    private ConcurrentQueue<Guid> _concurrentQueueDBIds = new ConcurrentQueue<Guid>();
16
17    /// <summary>
18    /// cờ nhận biết có đang chạy lệnh ở thread xử lý nghiệp vụ không
19    /// </summary>
20    private bool _isRunningBussinessThread = false;
21
22    private void DoSlowMethod()
23    {
24        // nếu đã có task run rồi thì cứ chạy tiếp vòng while trong task đó
25        if (_isRunningBussinessThread)
26        {
27            return;
28        }
29
30        _isRunningBussinessThread = true;
31
32        // chưa có thì run task mới
33        Task.Run(() =>
34        {
35            try
36            {
37                while (_concurrentQueueDBIds.Count > 0)
38                {
39                    Guid currentDB;
40                    _concurrentQueueDBIds.TryDequeue(out currentDB);
41
42                    // luôn phải có try catch khi làm việc đa luồng
43                    try
44                    {
45                        // UPDATE td.job_user SET is_active = false WHERE database_id = @currentDB
46                        ExecuteAsync(
47                            "UPDATE td.job_user SET is_active = false WHERE database_id = @currentDB",
48                            currentDB);
49                    }
50                    catch (Exception ex)
51                    {
52                        Console.WriteLine($"{this.GetType()} {nameof(DoSlowMethod)}" + ex);
53                    }
54                }
55            }
56            catch (Exception ex)
57            {
58                Console.WriteLine($"{this.GetType()} {nameof(DoSlowMethod)}" + ex);
59            }
60            finally
61            {
62                _isRunningBussinessThread = false;
63            }
64        });
65    }
66}

Logic của hàm dọn:

  • Nếu đã có task dọn đang chạy (_isRunningBussinessThread == true) thì return ngay — thread xử lý đang có sẽ tự rút hết phần tử trong while (_concurrentQueueDBIds.Count > 0).
  • Chưa có thì đặt cờ truechạy task dọn mới: rút lần lượt database_id ra khỏi ConcurrentQueue rồi UPDATE ... SET is_active = false — lần sau get_job_to_run sẽ không còn trả về job của những user đã “ngủ quên”, giúp giảm tải dần theo thời gian.
  • Mọi thao tác trong while đều nằm trong try/catch — bắt buộc khi làm việc đa luồng, để một job lỗi không làm chết cả thread đang xử lý các job còn lại.

Worker nghiệp vụ: chỉ xử lý message đúng giờ

Worker nghiệp vụ là các pod riêng, đọc message từ Kafka của từng env (g1, g2, …). Việc check “user có hoạt động hay không” đã được main task lo từ trước, nên worker nghiệp vụ chỉ còn một việc lọc duy nhất:

flowchart TB
    K["Kafka"]
    B["Worker nghiệp vụ\n(business pods)"]
    G{"NOW > max_time_start\nquá giờ?"}
    SKIP["Bỏ qua\nquá giờ — đổi ca"]
    U["Gửi email\ncho user"]

    K --> B --> G
    G -->|"Quá giờ"| SKIP
    G -->|"Đúng giờ"| U

Xử lý như bệnh viện: quá giờ ca sáng thì break

Mỗi message mang theo max_time_start — hạn chót phải bắt đầu xử lý. Worker business đối chiếu với thời điểm hiện tại:

  • Nếu message quá hạn (chưa được xử lý tới nơi, giờ đã qua max_time_start) thì không xử lý nữa.
  • Lý do giống như ở bệnh viện: khi hết giờ ca sáng, bác sĩ ca sáng phải dừng lại để ca chiều tiếp nhận bệnh nhân mới. Nếu cứ mãi xử lý đống hồ sơ cũ, bệnh nhân mới của ca chiều sẽ phải chờ — và với email, gửi muộn còn tệ hơn không gửi.

Nói cách khác: nếu NOW() > max_time_start thì break, nhường tài nguyên cho những message mới đúng giờ. Việc gửi bị bỏ qua đó sẽ được lên lịch lại ở chu kỳ sau.

Tổng kết

  • Cronjob không còn cấu hình tay ở tầng worker — tất cả chuyển thành dữ liệu trong DB master, chia 2 bảng:
    • job_system: job hệ thống do dev cấu hình (thời điểm chạy + file assembly + namespace C#), vẫn chạy bằng Quartz.NET.
    • job_user: job do user tự tạo trên UI.
  • Worker điều phối1 pod duy nhất cho toàn bộ phiên bản trong môi trường k8s/docker: đọc toàn bộ job, check cron bằng is_cron_expression_satisfied, lọc user không hoạt động n ngày trước khi publish, rồi điều phối vào env g1, g2, … theo database_id; max_time_start được backend build động lúc publish và đính kèm theo từng message.
  • Worker chung nhận job không hoạt động qua ConcurrentQueue và dọn bằng hàm dọn job — chỉ một task dọn chạy tại một thời điểm, UPDATE is_active = false để loại job khỏi các lần chạy sau.
  • Worker business chỉ còn lọc message quá max_time_start thì break như bệnh viện đổi ca, để ưu tiên việc mới.

Với thiết kế này, user thoải mái tự tạo lịch mà không cần dev can thiệp, và hàng trăm nghìn job vẫn được xử lý gọn trong vài mốc thời gian trong ngày.