Daniel Nguyen
← Tất cả dự án

Background job trên SQS và ECS, không bao giờ chạy hai lần

Tôi xây nền tảng job trên AWS SQS và ECS với atomic claim, lease, heartbeat, retry và idempotency key, để job ERP dài không chạy hai lần hay biến mất.

TTMI
Không chạy trùngViệc nặng của ERP đã rời khỏi request
Mảng
ERP bán lẻ
Thời gian
2025–2026
Vai trò
Backend developer, nền tảng worker
  • Python
  • Django
  • PostgreSQL
  • AWS SQS
  • AWS ECS
  • Docker
  • pytest

Các phần ghép với nhau thế nào

Background job trên SQS và ECS, không bao giờ chạy hai lầnThe job table decides ownership: one worker wins the conditional claim, duplicates skip, and a heartbeat keeps the lease and message visibility alive.API servicedịch vụJob queue (SQS)hàng đợiWorker AworkerWorker BworkerJob tablekho dữ liệuHeartbeat threaddịch vụjob + idempotency keypublish after commitdeliver (at-least-once)duplicate deliveryclaim: 1 row, winsclaim: 0 rows, skipsstart heartbeatrenew leaseextend visibility
The job table decides ownership: one worker wins the conditional claim, duplicates skip, and a heartbeat keeps the lease and message visibility alive.
  • Dịch vụ
  • Hàng đợi
  • Worker
  • Kho dữ liệu

Bấm vào một worker để dừng nó giữa chừng. Lease hết hạn, job quay lại hàng đợi, còn bản giao trùng bị bỏ qua nhờ idempotency key.

  • đang chờ
  • đang xử lý
  • đã ghi
  • chạy lại rồi mới ghi
  • bản trùng, bỏ qua
  • đang bị worker dừng giữ
0đã xử lý
0giao lại
0bản trùng bị bỏ qua
0bị mất

    Vấn đề

    ERP của TTMI phục vụ 50 cửa hàng, 500 nhân viên và 4 thương hiệu. Một số việc của nó chạy rất lâu: tính giá vốn kho, ghi sổ một lô chứng từ lớn, các job migrate dữ liệu. Tất cả từng chạy ngay trong HTTP request và giữ request thread cho tới khi xong.

    Chuyển những việc này sang queue nghe thì đơn giản. Cái khó là những gì có thể hỏng ở giữa:

    • AWS SQS giao message theo kiểu at-least-once. Cùng một message có thể tới hai lần, đôi khi tới hai worker cùng lúc.
    • Worker có thể chết giữa chừng. Phải có ai đó phát hiện và nhặt job lên lại.
    • Một request có thể enqueue job rồi rollback transaction của chính nó. Khi đó job sẽ chạy trên dữ liệu chưa từng tồn tại.

    Với ERP, đây là rủi ro thật. Một job tính giá vốn chạy hai lần, hay một job ghi sổ chạy được một nửa, đều để lại số sai trong sổ sách.

    Tôi đã làm gì

    Tôi viết nền tảng worker và triển khai nó thành một worker service riêng trên ECS. Tôi chọn tự viết phần claim và lease thay vì dùng một task framework đầy đủ, vì tôi cần kiểm soát chính xác ai đang sở hữu một job ở mọi thời điểm. Đổi lại, tôi cũng phải tự lo phần poison message và dead-letter.

    Bảng job là nguồn sự thật

    Mỗi job có một dòng trong PostgreSQL. Worker claim job bằng một lệnh conditional update duy nhất: chuyển sang running chỉ khi job vẫn đang pending. Nếu lệnh update chạm đúng một dòng, worker đó thắng. Nếu chạm không dòng nào, worker khác đã tới trước và worker này bỏ message. Không có khoảng hở giữa đọc và ghi để race chen vào.

    Cái giá là queue chỉ còn là kênh giao hàng. Trạng thái, số lần thử và quyền sở hữu nằm trong database.

    Lease, reclaim và heartbeat

    Mỗi lần claim đi kèm một lease có hạn. Nếu worker chết, lease hết hạn và worker khác có thể reclaim job, đồng thời tăng bộ đếm attempt.

    Trong lúc job chạy, một heartbeat thread làm hai việc: kéo dài visibility của message SQS để queue không giao nó cho worker khác, và gia hạn lease trong database. Nếu heartbeat phát hiện lease đã mất, job dừng lại thay vì chạy song song với một chủ mới. Lúc khởi động, worker kiểm tra rằng chu kỳ heartbeat ngắn hơn cả lease lẫn visibility timeout, vì cấu hình sai chỗ này sẽ âm thầm phá hết mọi bảo đảm ở trên.

    Idempotency key bằng unique constraint

    Mỗi job đang hoạt động có một fingerprint được bảo vệ bởi unique constraint. Nếu hai request cùng tạo một job, lệnh insert thứ hai vấp constraint, và code coi đó là kết quả bình thường của một cuộc race thay vì một lỗi.

    Chỉ enqueue sau khi commit

    Message chỉ được publish sau khi transaction tạo job đã commit. Rollback thì không còn job lẫn message, nên không có job mồ côi.

    Retry, và tách image

    Job lỗi được retry có backoff, tới một giới hạn số lần giao. API và worker build từ hai container image riêng, nên các dependency nặng mà worker cần không lọt vào API service.

    Kết quả

    Tính giá vốn và vài luồng chứng từ nặng giờ chạy trên worker service riêng thay vì trong request. Unit test phủ thẳng các tình huống lỗi: race khi claim, reclaim sau khi worker chết, hành vi heartbeat, mất lease và cleanup idempotent. Message giao trùng hay worker chết đều không dẫn tới chạy hai lần.

    Mô phỏng queue ở trang chủ là một mô hình đồ chơi của thiết kế này. Nó cho thấy worker claim job, lease hết hạn và message trùng bị bỏ qua, với thời gian giả lập.

    Nếu làm lại

    “Exactly once” trên thực tế nghĩa là giao at-least-once cộng với thực thi idempotent có lease canh giữ, và tôi sẽ giải thích với team theo đúng cách đó ngay từ đầu. Vì tự xây lớp queue, tôi sẽ thiết kế đường dead-letter và cảnh báo cho nó ngay từ ngày đầu, thay vì để làm sau.