Xây dựng dịch vụ Async trong Rust: Khi "đơn giản" hóa ra lại phức tạp hơn bạn nghĩ
Ban đầu nghĩ chỉ là một dự án cuối tuần nhỏ, nhưng việc xây dựng dịch vụ async trong Rust đã trở thành một bài học sâu sắc về tính chính xác của hệ thống. Bài viết phân tích những cạm bẫy bất ngờ từ việc xử lý idempotency, quản lý hàng đợi cho đến tắt dịch vụ một cách êm đẹp (graceful shutdown).

Tôi từng nghĩ dịch vụ async Rust này sẽ đơn giản
Tôi muốn xây dựng một dịch vụ async nhỏ bằng Rust. Nhận sự kiện, xử lý chúng và thử lại khi thất bại. Không có gì quá cầu kỳ.
Nó trông giống như một dự án cuối tuần.
Nhưng nó đã trở thành một bài học về việc các hệ thống "đơn giản" ngừng trở nên đơn giản nhanh chóng như thế nào một khi bạn bắt đầu quan tâm đến tính chính xác.
Toàn bộ dự án có sẵn tại đây: https://github.com/yourname/eventful
Phiên bản ban đầu ngây thơ
Thiết kế ban đầu trông giống như sau:
HTTP → queue → worker pool
- Handler nhận một sự kiện
- Đẩy nó vào một kênh (channel)
- các Worker kéo từ kênh và xử lý
Cách này hoạt động tốt — cho đến khi bạn thực sự cố gắng làm cho nó chính xác.
Ngay khi bạn giới thiệu tính năng thử lại (retry), tính nguyên đạm (idempotency) và xử lý lỗi, mọi thứ bắt đầu bị phá vỡ theo những cách không rõ ràng ngay từ đầu.
Bài toán 1: Tính nguyên đạm (Idempotency) không chỉ là "không chèn hai lần"
Tôi muốn quá trình nhập liệu (ingestion) phải nguyên đạm theo event_id.
Lúc đầu, điều đó có nghĩa là:
- Nếu ID tồn tại, trả về bản ghi hiện có
- Nếu không, hãy chèn nó
Nhưng điều đó để lại một lỗ hổng.
Nếu cùng một ID xuất hiện nhưng với một payload (dữ liệu tải) khác thì sao?
Đó không phải là trùng lặp — đó là xung đột (conflict).
Giải pháp là lưu trữ băm (hash) của payload và từ chối các trường hợp không khớp:
- Cùng ID + cùng payload → OK (đã loại bỏ trùng)
- Cùng ID + payload khác → 409 xung đột
Một thay đổi nhỏ, nhưng nó buộc tôi phải xử lý tính nguyên đạm như một ràng buộc thực sự thay vì một sự tiện lợi.
Bài toán 2: Bạn có thể mất công việc ngay cả khi đã "đặt hàng đợi"
Ban đầu, tôi giả định:
Nếu tôi đẩy một sự kiện vào hàng đợi, cuối cùng nó sẽ được xử lý.
Điều đó thực sự không đúng.
Hai thứ sẽ phá vỡ điều này:
- Hàng đợi đầy (
try_sendthất bại) - Hàng đợi bị hỏng (receiver bị drop)
Trong cả hai trường hợp, sự kiện tồn tại trong hệ thống, nhưng nó không bao giờ đến được một worker.
Giải pháp là tách biệt "tồn tại" và "được lập lịch".
Mỗi bản ghi theo dõi:
status(Đã nhận, Đang xử lý, v.v.)queued(cho dù chúng ta nghĩ nó đã được lập lịch hay chưa)
Nếu việc xếp hàng thất bại, bản ghi vẫn tồn tại, nhưng nó không còn được lập lịch đáng tin cậy nữa.
Điều này dẫn đến bài toán tiếp theo.
Bài toán 3: Bạn cần một trình quét (sweeper) ngay cả khi nó cảm thấy sai
Ban đầu tôi không muốn một tác vụ nền quét trạng thái. Nó cảm thấy như một giải pháp thay thế (workaround).
Nhưng mà không có nó, có quá nhiều cách để sự kiện bị mắc kẹt:
- xếp hàng thất bại
- worker bị sập giữa chừng khi đang xử lý
- thời điểm thử lại bị bỏ lỡ
Vì vậy, tôi đã thêm một trình quét (sweeper).
Nó chạy định kỳ và tìm kiếm:
- các sự kiện sẵn sàng để thử lại
- các sự kiện được đánh dấu là đã xếp hàng nhưng không được xử lý quá lâu
Sau đó, nó xếp hàng đợi lại chúng.
Nó không thanh lịch, nhưng nó mạnh mẽ. Nó mang lại cho bạn tính chính xác cuối cùng (eventual correctness) mà không yêu cầu mọi đường dẫn mã phải hoàn hảo.
Bài toán 4: "Độ sâu hàng đợi" không phải là một con số
Lúc đầu, tôi theo dõi độ sâu hàng đợi như một giá trị duy nhất.
Điều đó hóa ra là gây hiểu lầm.
Có ít nhất ba điều khác nhau đang xảy ra:
- Độ sâu kênh (Channel depth) — có bao nhiêu mục hiện có trong hàng đợi
- Các công việc tồn đọng (Backlog) — bao nhiêu sự kiện được đánh dấu
queued == true - Đang bay (Inflight) — bao nhiêu worker đang xử lý tích cực
Những thứ này không giống nhau.
Ví dụ:
- Độ sâu kênh có thể là 0 trong khi tồn đọng lại cao
- Đang bay có thể đạt tối đa trong khi hàng đợi vẫn trống
Vì vậy, tôi chia nhỏ chúng thành các chỉ số riêng biệt:
queue_channel_depthbacklog_queuedprocessing_inflight
Một khi tôi làm được điều đó, hệ thống trở nên dễ lý luận hơn nhiều.
Bài toán 5: Độ đồng thời (Concurrency) cần được giới hạn rõ ràng
Cách tiếp cận đơn giản nhất là tạo ra một tác vụ cho mỗi sự kiện.
Cách đó hoạt động cho đến khi nó không còn.
Tôi đã sử dụng Semaphore để giới hạn độ đồng thời:
- Mỗi tác vụ có được một phép (permit)
- Phép được giữ trong suốt quá trình xử lý
- Độ đồng thời tối đa được cố định
Thay vì một nhóm worker cố định, điều này cho phép tôi:
- giữ cho mã đơn giản
- tránh các worker nhàn rỗi
- vẫn thực thi các giới hạn
Nó cũng làm cho hành vi tắt dịch vụ dễ kiểm soát hơn nhiều.
Bài toán 6: Tắt dịch vụ êm đẹp (Graceful shutdown) là nơi mọi thứ trở nên lộn xộn
Dừng một hệ thống như thế này khó hơn là khởi động nó.
Bạn cần:
- Ngừng chấp nhận công việc mới
- Ngừng gửi các tác vụ mới
- Để công việc đang bay hoàn thành (trong phạm vi hợp lý)
- Không treo mãi mãi
Những gì tôi đã kết luận được:
- một kênh
watchđể báo hiệu tắt - một vòng lặp gửi đi thoát khi nhận tín hiệu
- một
JoinSettheo dõi các tác vụ worker - thời gian chờ để làm sạch (draining)
- buộc hủy bỏ sau thời gian chờ
Vì vậy, việc tắt dịch vụ trông như sau:
- Tín hiệu tắt
- Ngừng kéo từ hàng đợi
- Chờ tối đa N mili-giây cho các worker
- Hủy bỏ bất cứ thứ gì vẫn đang chạy
Nó không hoàn hảo, nhưng nó có thể dự đoán được.
Bài toán 7: Các chỉ số (metrics) sẽ nói dối với bạn nếu không cẩn thận
Tôi đã thêm chỉ số sớm, nhưng ban đầu chúng sai.
Vấn đề là cố gắng theo dõi số lượng bằng cách tăng và giảm ở nhiều nơi.
Điều đó rất dễ sai trong một hệ thống đồng thời.
Điều cuối cùng đã hoạt động:
- Bộ đếm (Counters) → chỉ tăng
- Số lượng trạng thái → chỉ cập nhật trên các chuyển đổi trạng thái thực
Ví dụ, queued_count chỉ thay đổi khi:
queuedchuyển từ false → truequeuedchuyển từ true → false
Bất cứ thứ gì khác sẽ dẫn đến sự lệch lạc (drift).
Mô hình kết quả
Hệ thống cuối cùng trông như sau:
HTTP → Ingest → Store → Channel → Dispatcher → Workers
↑
Sweeper
Với một máy trạng thái:
Received → Processing → Completed
↘ FailedRetry → Failed
Và các chỉ số phản ánh:
- ingress (dữ liệu đầu vào)
- deduplication (loại bỏ trùng)
- xử lý thành công/thất bại
- tồn đọng
- trạng thái hàng đợi
- độ đồng thời
- độ trễ
Những bài học rút ra được
Một vài điều nổi bật:
- "Hệ thống async đơn giản" thường không đơn giản một khi bạn quan tâm đến tính chính xác
- Máy trạng thái làm cho các bài toán đồng thời dễ lý luận hơn
- Áp suất ngược (backpressure) là đa chiều, không phải là một con số duy nhất
- Một trình quét thường là cách đơn giản nhất để đảm bảo tiến độ cuối cùng
- Việc tắt dịch vụ cần được thiết kế, không phải thêm vào sau này
- Khả năng quan sát (observability) thay đổi cách bạn thiết kế hệ thống
Những gì tôi đã không làm (cố ý)
Đây là một hệ thống trong bộ nhớ (in-memory).
Tôi chưa thêm:
- tính bền vững (persistence)
- xử lý phân tán
- hàng đợi bên ngoài
Đó sẽ là các bước tiếp theo, nhưng mục tiêu ở đây là làm đúng hành vi cốt lõi trước.
Lời kết
Điều này kết thúc là nhiều hơn về các trường hợp cạnh (edge cases) là các tính năng.
Hầu hết mã chỉ là đảm bảo hệ thống hoạt động chính xác khi mọi thứ không đi theo kế hoạch — mà hầu hết thời gian là như vậy trong các hệ thống thực.
Đó là phần thú vị.
Và trung thực mà nói, phần tôi không mong đợi khi bắt đầu.
Mã nguồn
Nếu bạn muốn xem triển khai đầy đủ:
Bài viết liên quan

Phần mềm
Anthropic ra mắt Claude Opus 4.7: Nâng cấp mạnh mẽ cho lập trình nhưng vẫn thua Mythos Preview
16 tháng 4, 2026

Công nghệ
Qwen3.6-35B-A3B: Quyền năng Lập trình Agentic, Nay Đã Mở Cửa Cho Tất Cả
16 tháng 4, 2026

Công nghệ
Spotify thắng kiện 322 triệu USD từ nhóm pirate Anna's Archive nhưng đối mặt với bài toán thu hồi
16 tháng 4, 2026
