Async Patterns: Message Queue, Job Queue, Batch Processing và Outbox – Kiến Trúc Bất Đồng Bộ Cho Hệ Thống Ngân Hàng
Async Patterns: Message Queue, Job Queue, Batch Processing và Outbox – Kiến Trúc Bất Đồng Bộ Cho Hệ Thống Ngân Hàng
Xử lý bất đồng bộ (async) không còn là thứ “có thì tốt” mà là yêu cầu sống còn trong các hệ thống ngân hàng hiện đại. Từ việc đối chiếu giao dịch cuối ngày, gửi SMS thông báo, cho đến sinh báo cáo hàng tháng – tất cả đều cần một kiến trúc vừa nhanh, vừa tin cậy, vừa có khả năng chịu lỗi. Bài viết này sẽ đi sâu vào bốn mẫu hình (pattern) cốt lõi: Message Queue, Job Queue, Batch Processing và Outbox, thông qua một case study thực tế tại X Bank. Bạn sẽ không chỉ hiểu lý thuyết mà còn thấy được cách triển khai production–grade cùng những anti‑pattern cần tránh.
1. Bài toán thực tế tại X Bank
Ba vấn đề nhức nhối
Vấn đề 1: Đối chiếu cuối ngày (End‑of‑Day Reconciliation)
- 10 triệu giao dịch cần được so khớp giữa hệ thống X Bank và mạng ACH.
- Công việc chạy lúc 11 giờ đêm, phải xong trước 6 giờ sáng (cửa sổ 7 tiếng).
- Hiện tại mất 6 tiếng, full table scan, khoá database (DB), kế toán phải chờ đợi. Nếu chạy sớm hơn (3 giờ chiều) thì sẽ chặn người dùng nghiệp vụ.
Vấn đề 2: Gửi SMS thông báo
- Mỗi giao dịch thành công cần gửi một SMS qua cổng của bên thứ ba.
- Độ trễ của cổng SMS: 200–500 ms, trong khi API giao dịch phải phản hồi dưới 100 ms để đảm bảo trải nghiệm người dùng.
- Nếu gọi SMS đồng bộ (sync), API sẽ bị kéo chậm. Nếu cổng SMS sập, toàn bộ API giao dịch cũng thất bại (cascade failure).
Vấn đề 3: Sinh báo cáo hàng tháng
- Vào ngày đầu tháng, 500.000 tài khoản cần tạo báo cáo PDF, sau đó đẩy vào hàng đợi báo cáo.
- Quá trình này mất 2–3 giờ, làm nghẽn các dịch vụ khác vào giờ cao điểm. Database chậm khiến nhiều service timeout.
Năm câu hỏi thăm dò
- Batch processing: Nếu chia 10 triệu bản ghi thành 6 phân vùng (mỗi phân vùng ~1.67M) và xử lý song song, tốc độ có tăng gấp 6 lần không? Vì sao?
- Async notification: Nếu gửi SMS theo kiểu fire‑and‑forget, làm sao biết được SMS đã gửi thành công hay thất bại? Chiến lược retry ra sao?
- Job queue vs Message queue: Đối chiếu là tác vụ định kỳ (scheduled), còn gửi SMS là tác vụ phát sinh theo request. Có nên dùng chung một loại queue?
- Idempotency trong batch: Nếu batch job chết ở bản ghi 5 triệu / 10 triệu, khởi động lại từ đầu sẽ xử lý trùng 5 triệu record. Làm sao để resume từ vị trí 5 triệu?
- Outbox pattern: Giao dịch và bản ghi SMS được insert chung transaction vào DB, consumer đọc từ Outbox để gửi SMS. Nếu consumer sập 30 phút, 100.000 SMS tồn đọng – làm cách nào để bắt kịp mà không làm quá tải cổng SMS?
Tất cả những câu hỏi này sẽ được trả lời xuyên suốt bài viết.
2. Mental Model – Mô hình tư duy
2.1 Message Queue vs Job Queue
Nhiều người hay nhầm lẫn hai khái niệm này, nhưng chúng phục vụ các mục đích rất khác nhau.
| Tiêu chí | Message Queue (Kafka, RabbitMQ) | Job Queue (Quartz, SQS + Lambda) |
|---|---|---|
| Bản chất | Giao tiếp giữa các service theo sự kiện | Thực thi tác vụ nền với lịch trình & retry |
| Mô hình | Push: producer gửi → consumer xử lý | Pull: scheduler lấy task từ queue và thực thi |
| Ngữ nghĩa đảm bảo | At‑most‑once, at‑least‑once, exactly‑once (tuỳ cấu hình) | Exactly‑once (idempotent) hoặc at‑least‑once với dedup |
| Độ bền vững | Broker lưu trữ (vài ngày đến vài tuần) | Database‑backed (trạng thái lưu persistent) |
| Use case | Liên lạc liên service, event streaming, audit log | Scheduled tasks, batch processing, report generation |
| Ví dụ | PaymentService → SMS Notification service | DailyReconciliation scheduled 11 PM, ReportGeneration mùng 1 |
Thông thường, trong kiến trúc ngân hàng, bạn sẽ dùng cả hai: Message Queue để liên kết các microservice (như gửi SMS), còn Job Queue (thường là Spring Batch kết hợp Quartz) để xử lý các batch lớn định kỳ.
2.2 Luồng xử lý bất đồng bộ (Async Request Flow)
Mô hình cũ đồng bộ hoá tất cả:
Client → API → ProcessPayment (chậm) → Trả kết quả (block 3 giây)
Mô hình bất đồng bộ hiện đại:
Client → API → Enqueue JobId → Trả ngay (100 ms)
Client poll hoặc nhận webhook:
GET /job/job-id-123 → {"status": "PROCESSING", "progress": "50%"}
hoặc webhook: POST /callback {"jobId": "...", "status": "COMPLETE", "result": {...}}
Background worker → Poll queue → ProcessPayment → Lưu kết quả vào Redis
Như vậy API không bao giờ bị khoá lâu, trải nghiệm người dùng được cải thiện rõ rệt.
2.3 Spring Batch Architecture
Spring Batch tổ chức công việc theo cấu trúc phân cấp:
JOB → STEPS → ITEM READER / PROCESSOR / WRITER
├─ Job: container ngoài cùng (chứa transaction‑log, job parameters, execution history)
├─ Step: chuỗi các thao tác
│ ├─ ItemReader: đọc dữ liệu từ nguồn (theo chunk, ví dụ 100 items)
│ ├─ ItemProcessor: biến đổi/xác thực (business logic)
│ └─ ItemWriter: ghi ra đích (batch insert)
└─ JobRepository: lưu trạng thái thực thi để restart/resume
Chunk‑oriented processing (xử lý theo khối) là cơ chế mặc định:
for chunk in chunks_of_100:
items = read(chunk)
processed = process(items)
write(processed)
commit_transaction()
Mỗi chunk là một transaction riêng. Nhờ đó:
- Không phải ghi log cho từng record riêng lẻ → nhanh hơn.
- Nếu crash ở chunk 500, khi restart sẽ bắt đầu từ chunk 501 (bỏ qua các chunk đã commit).
2.4 @Async và Thread Pool
@Async của Spring rất tiện, nhưng cấu hình mặc định là một cái bẫy.
- SimpleAsyncTaskExecutor (mặc định): Tạo một thread mới cho mỗi lần gọi. Dưới tải cao (1000 req/s) → tạo 1000 thread, mỗi thread tiêu tốn ~1 MB bộ nhớ → 1 GB bộ nhớ mỗi giây, context switching điên cuồng, OOM sau 30–60 giây.
- Custom ThreadPoolTaskExecutor: Giới hạn pool (ví dụ core 5, max 20), hàng đợi chờ (capacity 1000), chính sách từ chối (CallerRunsPolicy: nếu queue đầy thì chính caller thực thi để tạo áp lực ngược). Phù hợp cho các tác vụ fire‑and‑forget như gửi SMS.
2.5 Scheduled Tasks: @Scheduled vs Quartz
- @Scheduled: Đơn giản, chạy trong process, nhưng nếu triển khai nhiều node thì tất cả đều chạy dẫn đến trùng lặp. Cần manual lock (ví dụ Redis lock) để tránh.
- Quartz: Lịch trình được lưu trong database, hỗ trợ clustering: chỉ một node thực thi, các node khác chờ. Phù hợp với những job quan trọng, không được chạy trùng (reconciliation, reporting). Đánh đổi: thêm một chút overhead truy vấn DB, nhưng không đáng kể.
2.6 Outbox Pattern
Mục tiêu: Đảm bảo transactional inbox + gửi message với ngữ nghĩa exactly‑once.
Vấn đề: Nếu bạn insert transaction vào DB và gửi message sang Kafka trong hai thao tác riêng biệt, rủi ro split‑brain: DB insert thành công, message không gửi được → mất message.
Giải pháp:
- Atomic operation: Trong cùng một giao dịch, vừa insert bản ghi nghiệp vụ vừa insert vào bảng
outbox.BEGIN TRANSACTION INSERT INTO transactions (...) VALUES (...); INSERT INTO outbox (event_type, aggregate_id, payload, sent_flag) VALUES (..., ..., ..., false); COMMIT - Async consumer: Định kỳ (mỗi 5 giây) poll
SELECT * FROM outbox WHERE sent_flag = false AND created_at < NOW() - 1 phút(trừ đi 1 phút để tránh race condition). Gửi event sang Kafka, sau đóUPDATE outbox SET sent_flag = true. - Phục hồi thảm hoạ: Dù consumer sập 30 phút, 100.000 event vẫn nằm yên trong outbox. Khi consumer sống lại, nó đọc theo thứ tự FIFO. Cần đảm bảo downstream có khả năng nhận trùng (idempotency key).
2.7 Batch Partitioning & Song song
Bài toán 10 triệu giao dịch:
- Tuần tự (1 thread): 6 giờ.
- Phân vùng (6 partition, 6 thread): Mỗi thread xử lý 1.67M record. Về lý thuyết thời gian giảm còn ~1 giờ (gấp 6 lần). Thực tế, do giới hạn I/O, tranh chấp DB, có thể chỉ đạt 2–3x, vì tất cả thread cùng đọc DB, gọi API ACH. Cần tối ưu chunk size, connection pool…
- Phân vùng + Chunk: Mỗi partition xử lý theo chunk (ví dụ chunk=1000). Nếu lỗi chỉ mất dữ liệu trong chunk đó, có thể restart từ chunk gần nhất.
2.8 Idempotency trong Batch Restart
Idempotency nghĩa là “thực hiện một hành động nhiều lần nhưng kết quả chỉ thay đổi một lần”. Với batch:
- Không có idempotency: Chạy từ đầu sau khi crash → 5 triệu record đầu bị đếm trùng hai lần. Kết quả sai.
- Có checkpoint: Duy trì bảng
reconciliation_sessionvới cộtlast_processed_offset. Mỗi lần hoàn thành chunk, cập nhật offset mới. Khi restart, đọc offset và tiếp tục từ đó. Vậy là “resume” chứ không “re‑run”.
3. Production‑Grade Implementation
Dưới đây là các đoạn code minh hoạ được tuyển chọn, giải thích cặn kẽ các chi tiết quan trọng.
3.1 Spring Batch cho End‑of‑Day Reconciliation
Cấu hình Step với fault‑tolerant:
@Bean
public Step reconciliationStep(...) {
return new StepBuilder("reconciliationStep", jobRepository)
.<TransactionRecord, ReconciliationResult>chunk(1000, txManager)
.reader(itemReader)
.processor(itemProcessor)
.writer(itemWriter)
.faultTolerant()
.skipLimit(10) // bỏ qua tối đa 10 record lỗi
.skip(ReconciliationException.class)
.retry(3) // thử lại tối đa 3 lần với lỗi tạm thời
.retryLimit(3)
.build();
}
Điểm mấu chốt:
chunk(1000): Mỗi transaction chứa 1000 record, giảm số commit.faultTolerant(): Cho phép skip/retry có kiểm soát.skip(ReconciliationException.class): Nếu một record chứa lỗi nghiệp vụ không thể sửa (ví dụ dữ liệu không hợp lệ), ta bỏ qua và ghi log, tránh cho cả job bị dừng.
ItemReader đọc theo ngày và sắp xếp để đảm bảo thứ tự:
reader.setSql("SELECT * FROM transactions WHERE date(created_at) = CURRENT_DATE ORDER BY id");
ItemProcessor gọi ACH gateway, nếu lỗi mạng (tạm thời) thì ném ngoại lệ để retry; nếu lỗi logic nghiệp vụ thì trả về null để skip.
ItemWriter ghi batch:
resultRepository.saveAll(items); // batch insert 1000 record, hiệu quả hơn 1000 insert đơn lẻ
3.2 Partitioned Step – Chia để trị 10 triệu bản ghi
Chúng ta tạo một Partitioner chia dữ liệu thành 6 partition dựa trên ID:
@Bean
public Partitioner partitioner() {
return gridSize -> {
long recordsPerPartition = 10_000_000 / gridSize;
for (int i = 0; i < gridSize; i++) {
ExecutionContext context = new ExecutionContext();
context.putLong("minId", i * recordsPerPartition);
context.putLong("maxId", (i + 1) * recordsPerPartition);
partitions.put("partition-" + i, context);
}
return partitions;
};
}
Bước chính (partitionedReconciliationStep) sử dụng gridSize=6 và taskExecutor với 6 thread. Mỗi worker step sẽ nhận minId, maxId qua @StepScope để đọc đúng dải dữ liệu của mình.
Như vậy toàn bộ tiến trình chỉ còn khoảng 1 giờ (nếu tối ưu đủ tốt). Đây là đáp án cho câu hỏi thăm dò số 1: tăng tốc có thể gần 6x nhưng thường chỉ 2–3x do bottleneck DB.
3.3 @Async cho SMS – Fire‑and‑Forget có kiểm soát
Service gửi SMS bất đồng bộ:
@Async("notificationThreadPool")
public void sendSmsAsync(String phone, String message) {
SmsEvent event = new SmsEvent(phone, message, LocalDateTime.now(), 1);
kafkaTemplate.send("sms-notifications", phone, event);
// Bắt lỗi gửi Kafka nhưng không throw ra ngoài (fire-and-forget)
}
Cấu hình notificationThreadPool:
@Bean(name = "notificationThreadPool")
public ThreadPoolTaskExecutor notificationThreadPool() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(20);
executor.setQueueCapacity(1000);
executor.setRejectedExecutionHandler(new CallerRunsPolicy());
return executor;
}
CallerRunsPolicy nghĩa là khi pool và queue đầy, chính thread gọi API sẽ thực thi tác vụ, tạo back‑pressure tự nhiên, ngăn hệ thống sụp đổ vì quá tải.
Consumer Kafka xử lý SMS:
- Kiểm tra
attempt(tối đa 3 lần), nếu thất bại vẫn ghi nhận để xem xét thủ công. - Nếu gateway lỗi tạm thời, ném ngoại lệ để không commit offset, message sẽ được gửi lại.
- Nếu gửi thành công, đánh dấu và ghi nhận.
3.4 Job Status Tracking với Redis
Để ops có thể theo dõi tiến độ theo thời gian thực, chúng ta lưu % hoàn thành và trạng thái vào Redis:
redisTemplate.opsForValue().set("job:status:" + jobId, "PROCESSING", Duration.ofHours(24));
redisTemplate.opsForValue().set("job:progress:" + jobId, String.valueOf(percentage));
API GET /job/{jobId} sẽ lấy dữ liệu này để hiển thị cho client hoặc dashboard.
3.5 Quartz cho tác vụ lập lịch phân tán
Cấu hình Quartz lưu trong database PostgreSQL:
props.setProperty("org.quartz.jobStore.class", "org.quartz.impl.jdbcjobstore.JobStoreTX");
props.setProperty("org.quartz.jobStore.driverDelegateClass", "org.quartz.impl.jdbcjobstore.PostgreSQLDelegate");
Job DailyReconciliationJob implements Job, bên trong gọi jobLauncher.run(...) để khởi chạy Spring Batch job (đã phân vùng). Như vậy chỉ cần 1 instance Quartz khởi động, Spring Batch lo phần song song.
3.6 Outbox Pattern Hoàn chỉnh
Bảng outbox có các cột: id, event_type, aggregate_id, payload (JSON), sent, created_at, sent_at.
Trong cùng một transaction, vừa lưu Transaction vừa lưu OutboxEvent. Consumer định kỳ:
@Scheduled(fixedRate = 5000)
public void pollAndPublishOutboxEvents() {
List<OutboxEvent> unsent = outboxRepository.findUnsentBefore(
LocalDateTime.now().minusMinutes(1), PageRequest.of(0, 100));
for (OutboxEvent event : unsent) {
try {
kafkaTemplate.send(event.getEventType(), event.getAggregateId(), event.getPayload());
event.setSent(true);
event.setSentAt(LocalDateTime.now());
outboxRepository.save(event);
} catch (Exception e) {
// giữ sent=false, sẽ retry lần sau
}
}
}
Để tránh quá tải cổng SMS khi consumer catch‑up sau downtime, ta áp dụng rate limiting ở phía SMS consumer: dùng Semaphore hoặc RateLimiter để giới hạn số lần gọi đồng thời (ví dụ max 50 requests/giây). Kafka consumer có thể tạm dừng poll nếu số message tồn đọng quá lớn. Đây là đáp án cho câu hỏi thăm dò số 5.
4. Trade‑offs & Anti‑Patterns
Anti‑Pattern #1: @Async trên private method
// ❌ Sai
@Service
public class PaymentService {
@Async // AOP proxy không bắt được private method
private void sendNotification(String msg) { ... }
}
// ✅ Đúng
@Service
public class NotificationService {
@Async("notificationThreadPool")
public void sendNotificationAsync(String msg) { ... }
}
Proxy của Spring chỉ hoạt động khi gọi public method từ bên ngoài bean. Đặt @Async trên private method khiến method chạy đồng bộ, mất tính async.
Anti‑Pattern #2: Dùng SimpleAsyncTaskExecutor mặc định
Không định nghĩa thread pool riêng dẫn đến thread explosion, OOM, CPU thrashing. Luôn dùng ThreadPoolTaskExecutor với core/max pool và queue xác định.
Anti‑Pattern #3: Batch Job không có idempotency
Job chạy lại từ đầu sau khi lỗi → xử lý lại dữ liệu đã xử lý → sai kết quả đối chiếu. Cần có last_processed_id checkpoint (trong DB hoặc Spring Batch JobRepository cũng có sẵn nếu dùng đúng restart).
Anti‑Pattern #4: @Scheduled trên multi‑node không khoá phân tán
Mỗi node cùng chạy job report vào giờ quy định → dữ liệu ghi trùng, báo cáo sai. Giải pháp: dùng Quartz clustering, hoặc Redis lock (@SchedulerLock của ShedLock).
Anti‑Pattern #5: Thiếu giám sát (monitoring) cho batch job
Chỉ log lỗi đơn thuần, không alert → sáng hôm sau kế toán mới phát hiện job đối chiếu thất bại lúc 2 giờ sáng, mất nhiều giờ điều tra. Cần:
- Metric
durationvàsuccess/failuregửi về Prometheus/Grafana. - Alert ngay khi job thất bại (qua Slack, PagerDuty).
- Progress tracker (Redis) để ops biết job đang chạy hay đã chết.
5. Interview Framework – Trả lời phỏng vấn
Khung này giúp bạn tự luyện tập ba tầng kiến thức.
Tầng 1 – Surface Knowledge
Q1.1: API cần 100 ms, SMS gateway mất 500 ms, làm thế nào?
- Trả lời: Chuyển SMS sang async (API trả về ngay). SMS được đẩy vào Kafka và consumer xử lý.
- Thất bại: retry queue, nếu vượt ngưỡng thì vào Dead Letter Queue (DLT).
- Monitoring: theo dõi tỉ lệ gửi thành công.
- Đánh đổi: eventual consistency – có thể SMS chưa tới ngay, nhưng API không bị chậm.
Q1.2: Khi nào dùng Spring Batch, khi nào dùng Quartz?
- Quartz: lập lịch cron phức tạp, cần clustering, job bền vững trong DB.
- Spring Batch: chunk processing, restart/resume, partitioning.
- Thực tế: Quartz trigger job, Spring Batch thực thi.
Tầng 2 – Deep Technical
Q2.1: Thiết kế hệ thống đối chiếu 10 triệu giao dịch, hoàn thành trước 6 AM.
- Dùng Spring Batch partitioning 6 phần, mỗi worker đọc 1.67M record, chunk 1000.
- Song song gọi ACH API với
ThreadPoolTaskExecutor, rate limit nếu cần. - Fault‑tolerant: skip/retry, DLT cho các lỗi không sửa được.
- Checkpoint: JobRepository của Spring Batch cho phép restart sau lỗi.
- Quartz khởi động lúc 11 PM.
Q2.2: Outbox pattern, consumer sập 30 phút, 100k SMS tồn. Catch‑up an toàn?
- Consumer poll dần với batch size nhỏ (100) và interval 5s.
- Rate limiter ở SMS gateway: không quá 50 req/s.
- Back‑pressure: nếu lag > 10.000, tạm dừng poll để tránh quá tải.
- Idempotency: mỗi SMS có key duy nhất để tránh gửi trùng khi retry.
Tầng 3 – System Design
Q3: Hệ thống sinh báo cáo hàng tháng cho 500K tài khoản.
- Trigger 2 AM ngày 1, Quartz đảm bảo single instance.
- Partition 500K accounts → 10 threads.
- Mỗi thread: đọc giao dịch 30 ngày, generate PDF (dùng template engine iText), lưu S3.
- ItemWriter: cập nhật trạng thái report.
- Notification: async job gửi email với rate limit (1000/min).
- Monitoring: Redis lưu progress %, alert khi lỗi.
- Bảo đảm chi phí: S3 rẻ hơn lưu DB, nén PDF, dùng SendGrid cho email.
6. Checklist Thành thạo
Năm điểm bạn phải giải thích được mà không cần nhìn note:
- Spring Batch Chunk Processing – Đọc → Xử lý → Ghi theo block, transaction an toàn, restart từ chunk lỗi.
- @Async Thread Pool Sizing – Luôn dùng bounded pool, queue capacity và rejection policy để tránh OOM.
- Quartz vs @Scheduled – @Scheduled chạy đơn giản, dễ bị trùng khi multi‑node; Quartz có clustering, lưu trong DB.
- Outbox Pattern Mechanics – Atomic insert, polling định kỳ, idempotency, cleanup định kỳ.
- Idempotency trong Batch Restart – Checkpoint last_processed_id, resume từ điểm dừng, tránh xử lý trùng.
Production Checklist bổ sung:
- Chunk size đảm bảo mỗi chunk < 10 giây.
- Thread pool bounded, có tên rõ ràng.
- Scheduled tasks được bảo vệ bằng distributed lock.
- Outbox có consumer, có rate limit, có DLT.
- Metrics + alert đầy đủ.
- Test chaos: giết job giữa chừng, đứt mạng khi poll.
Kết luận
Async pattern không đơn thuần là thêm @Async hay dùng một cái queue bất kỳ. Nó đòi hỏi bạn phải suy nghĩ thấu đáo về độ bền dữ liệu, khả năng phục hồi, tính tương tranh và giám sát. Trong lĩnh vực ngân hàng, nơi sai sót dù nhỏ cũng ảnh hưởng đến tiền bạc và niềm tin của khách hàng, việc áp dụng đúng các mẫu hình: Message Queue để giao tiếp bất đồng bộ, Job Queue cho tác vụ lập lịch, Batch processing cho xử lý khối lượng lớn, và Outbox để đảm bảo giao dịch tin cậy – chính là nền tảng của một hệ thống vững chắc.
Hy vọng qua bài viết, bạn đã có một cái nhìn toàn diện và tự tin để ứng dụng vào thực tế cũng như vượt qua các buổi phỏng vấn khó nhằn.