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.
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ý:
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:
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:
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:
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ịchFunction 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 check và loạ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:
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ý:
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
- Scheduler pod (1 pod duy nhất) wake lên đúng giờ quy định, gọi
td.get_job_to_runvới thời điểm cần check và loại nghiệp vụ để lấy danh sách job từ DB master. - 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. - 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:
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:
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ử trongwhile (_concurrentQueueDBIds.Count > 0). - Chưa có thì đặt cờ
truevà chạy task dọn mới: rút lần lượtdatabase_idra khỏiConcurrentQueuerồiUPDATE ... SET is_active = false— lần sauget_job_to_runsẽ 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ối là 1 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, … theodatabase_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
ConcurrentQueuevà 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_startthì 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.