Vấn đề thứ tự khi xử lý background job bằng Redis Stream — solution và trade-off

Bài bổ sung cho Bài 6 · Stream (Redis 8.8.0). Mục tiêu: trả lời câu hỏi "dùng Redis Stream xử lý background job thì thứ tự xử lý có đúng không, nếu không thì sửa bằng cách gì và mỗi cách đổi lấy cái gì". Các kịch bản chính — bug gốc ở mục 2, S1, S4, S5 ở mục 3, producer gửi sai thứ tự ở mục 6, P1, P2 — đã chạy lab thật bằng labs/redis/08-thu-tu.sh, output dán nguyên văn ngay tại mục tương ứng; số liệu failover/redelivery lấy từ lab của Bài 6.

Kiểm chứng: tài liệu Redis qua Context7 ngày 21/10/2026: XREADGROUP (ID > = chỉ giao entry chưa từng giao cho consumer nào; hai consumer cùng group chia nhau các entry), XADD (ID tự sinh luôn tăng, entry có tổng thứ tự trong một stream), trang distributed locks (SET … NX PX để lấy lock; DELEX key IFEQ value, có từ 8.4, để chỉ nhả lock nếu vẫn là của mình). Lab chạy thật: Redis 8.8.0 (container tạm tmp-rdt-redis), consumer/producer/dispatcher viết bằng PHP 8.3.33 + phpredis 6.3.0, mỗi consumer là một process riêng (pcntl_fork); trạng thái đơn hàng và bảng outbox nằm trên PostgreSQL 18.4 (my-test-pgdb, bảng tmp_*). Output: labs/redis/out/08*.txt. Chưa verify bằng lab: S2, S3, P3, P4 (chi tiết ở Phụ lục).

Trả lời 30 giây

Redis Stream chỉ đảm bảo thứ tự ghi vào log (entry ID luôn tăng); không đảm bảo thứ tự xử lý khi consumer group dùng nhiều consumer song song, và còn bị đảo thêm bởi redelivery sau crash/claim và bởi failover (Bài 6: mất 20 entry đã OK, giao lại 50 entry đã xử lý). Năm hướng giải quyết, đánh đổi khác nhau: (1) một consumer duy nhất — đúng thứ tự tuyệt đối, throughput thấp nhất; (2) sharding theo khoá nghiệp vụ thành N stream — song song giữa khoá, tuần tự trong khoá, nhưng phải tự viết router và tự resharding; (3) đọc tuần tự rồi fan-out trong RAM theo khoá (worker-per-key) — không cần router ở Redis nhưng vẫn giới hạn bởi một tiến trình đọc; (4) lock theo khoá — chỉ chống ghi đè đồng thời, không tự chống đảo thứ tự nếu không kèm hàng đợi FIFO (lab: vẫn sai 3/20 lần, 11/20 khi hai consumer lệch nhịp 0–30 ms; TTL lock ngắn hơn thời gian job thì mất luôn cả loại trừ, sai 10/10); (5) ghi theo compare-and-swap với version — đúng trạng thái cuối cùng dù thứ tự lộn xộn, nhưng vô dụng với side-effect không thể rollback (đã gửi email, đã gọi webhook thanh toán). Thực tế nên kết hợp (2)/(3) ở tầng giao nhận với (5) ở tầng ghi dữ liệu làm lớp phòng thủ cuối, vì claim/failover vẫn đảo thứ tự dù tầng giao nhận đã làm đúng. Riêng trường hợp producer gửi sai thứ tự (retry của created bị paid vượt mặt) thì cả năm cách trên đều không tự cứu được — log sai từ gốc; phải chặn ở producer bằng outbox tuần tự theo khoá, và version dùng cho CAS phải gán lúc sự kiện sinh ra, không phải stream entry ID (mục 6).

1. Hai loại "thứ tự" khác nhau

Có ba cơ chế riêng biệt, độc lập với nhau, có thể làm lệch thứ tự xử lý so với thứ tự sinh sự kiện:

  1. Song song hoá bình thường: XREADGROUP chia entry cho nhiều consumer; không consumer nào biết entry khác đang được xử lý nhanh hay chậm — entry sinh sau có thể xử lý xong trước entry sinh trước.
  2. Redelivery sau crash/claim: consumer chết giữa chừng, entry nằm trong PEL tới khi bị XAUTOCLAIM. Nếu entry mới hơn của cùng khoá nghiệp vụ đã xử lý xong trong lúc đó, bản claim lại "chạy sau nhưng mang dữ liệu cũ hơn".
  3. Failover: replication bất đồng bộ nghĩa là con trỏ + PEL của consumer group có thể lùi lại khi master chết — entry đã xử lý và ack rồi bị giao lại (đo ở Bài 6: mất 20 entry đã nhận OK, giao lại 50 entry đã xử lý).

Không có cấu hình Redis Stream nào tắt được cả ba cơ chế này cùng lúc — xử lý thứ tự luôn là trách nhiệm của tầng ứng dụng, không phải của Redis.

2. Ví dụ cụ thể

Đơn hàng #42 sinh 3 sự kiện liên tiếp vào cùng một stream: 1000-0 order.created, 1000-1 order.paid, 1000-2 order.shipped. Consumer group order-processor có 2 consumer để đạt throughput cần (giả sử cần 500 event/giây, trong khi một job đôi khi gọi webhook mất 800 ms — một consumer đơn chỉ đạt tối đa ~1,25 event/giây trong trường hợp xấu đó, không đủ).

C1 nhận 1000-0 (created), gọi API ngoài mất 800 ms. C2 nhận 1000-1 (paid), chỉ ghi DB, mất 50 ms. Không có ràng buộc gì giữa hai consumer nên trình tự ghi DB thực tế là: t=50 ms — C2 ghi status=paid; t=800 ms — C1 ghi status=created, đè lên giá trị vừa ghi. Kết quả cuối: đơn #42 nằm ở trạng thái created dù paid đã xảy ra trước đó.

Hai consumer song song làm đảo thứ tự ghi DB Stream có 3 entry theo thứ tự created, paid, shipped. Consumer C1 nhận entry created và xử lý mất 800ms vì gọi API ngoài. Consumer C2 nhận entry paid và xử lý mất 50ms. C2 ghi DB trước ở mốc 50ms, C1 ghi DB sau ở mốc 800ms và đè giá trị paid bằng created, khiến trạng thái cuối cùng của đơn hàng sai. Stream "orders" — đơn #42 1000-0 created 1000-1 paid 1000-2 shipped consumer C1 xử lý created — 800ms (gọi API) consumer C2 xử lý paid — 50ms DB: orders.status (đơn #42) t=50ms: ghi status=paid t=800ms: ghi status=created (đè lên) ✕ Kết quả cuối: status = "created" dù "paid" đã xảy ra trước đó — sai trạng thái nghiệp vụ. Nguyên nhân: đơn vị song song hoá là entry, không phải khoá nghiệp vụ (order id).
Hình 1 — Mỗi job riêng lẻ hoàn toàn đúng; bug sinh ra từ việc hai entry của cùng một đơn hàng được hai consumer độc lập xử lý song song, không ai biết thứ tự tương đối của nhau.

Lab tái hiện đúng ví dụ này: một stream orders, group order-processor, hai consumer là hai process PHP riêng, cùng đang chờ ở XREADGROUP … COUNT 1 BLOCK; producer XADD created rồi paid sát nhau; handler created ngủ 800 ms (thay cho gọi API ngoài), paid ngủ 50 ms, rồi ghi trạng thái xuống PostgreSQL bằng UPDATE thường (không kiểm version). Chạy 10 lần, in diễn biến lần 1:

### BUG: 1 stream, 1 group, 2 consumer song song (2 process); created 800 ms, paid 50 ms; UPDATE thường   (08a-baseline.txt)
lần 1 — stream: 1792591449272-0 created (v=1), 1792591449272-1 paid (v=2)
  t=   2 ms  C1 nhận 1792591449272-0 created (v=1)
  t=   2 ms  C2 nhận 1792591449272-1 paid (v=2)
  t= 111 ms  C2 ghi status=paid → UPDATE 1
  t= 816 ms  C1 ghi status=created → UPDATE 1
  status cuối từng lần: created✕ created✕ created✕ created✕ created✕ created✕ created✕ created✕ created✕ created✕
  → SAI trạng thái cuối (không phải paid): 10/10 lần

10/10 lần đơn #42 kết thúc ở created. Không có yếu tố may rủi: hai consumer đều đang chờ, Redis giao entry đầu cho một consumer và entry sau cho consumer còn lại gần như cùng một mili-giây (t=2 ms); từ đó thứ tự ghi DB chỉ còn phụ thuộc thời gian xử lý — job nhanh luôn ghi trước, job chậm luôn ghi đè sau.

3. Các solution — xếp theo độ phức tạp tăng dần

#SolutionĐảm bảo thứ tựTrade-off chính
S1Một consumer duy nhất cho streamTuyệt đối (không gì chạy song song)Throughput = tốc độ 1 consumer; một entry chậm chặn toàn bộ entry sau nó, kể cả của khoá khác (head-of-line blocking toàn cục). Chỉ hợp khối lượng thấp. Lab: sai 0/10.
S2Sharding theo khoá nghiệp vụ: N stream, route bằng hash(key) % N, mỗi shard 1 consumer đang hoạt độngĐúng trong phạm vi 1 khoá Phải tự viết router ở producer (không có partition + rebalance tự động như Kafka); N cố định từ đầu, thêm shard sau phải tự di trú khoá; một consumer chết chỉ làm nghẽn 1/N nhưng vẫn là nghẽn; chi phí vận hành tăng tuyến tính theo N. Chưa verify bằng lab.
S3Đọc tuần tự bằng 1 consumer, fan-out trong RAM theo khoá (worker-per-key, kiểu actor model)Đúng trong phạm vi 1 khoáKhông cần router ở Redis như S2, nhưng vẫn chỉ 1 tiến trình đọc — giới hạn bởi CPU/băng thông 1 máy; ack phải theo đúng tiến độ worker (không ack ngay sau đọc) nếu không mất at-least-once khi tiến trình chết giữa chừng; cần backpressure để tránh OOM khi một khoá backlog dài. Chưa verify bằng lab.
S4Lock theo khoá (distributed lock, kiểu "withoutOverlapping" của Laravel queue)Chỉ loại trừ ghi đồng thời (và chỉ khi TTL lock dài hơn job), không tự đảm bảo thứ tự Nếu không kèm hàng đợi FIFO riêng theo khoá, ai giành được lock trước (lúc đầu cũng như lúc lock nhả ra) không chắc là người cầm sự kiện sinh trước — muốn đúng thứ tự thật phải quay lại gần S3. TTL lock ngắn hơn job → chạy đè thật (lab); TTL dài → khoá treo lâu khi consumer chết thật. Lab: sai 3/20, 11/20 khi lệch nhịp, 10/10 khi TTL < job.
S5Ghi theo compare-and-swap với version (last-writer-wins, bỏ qua bản cũ)Không ép thứ tự xử lý — chỉ đảm bảo trạng thái cuối cùng đúng (convergence) Chỉ dùng được cho state kiểu "bản mới nhất thắng" (một field trạng thái); vô dụng với side-effect không thể rollback (đã gửi email, đã gọi webhook thanh toán); phải enforce CAS ở mọi code path ghi, một đường ghi tắt (sửa tay DB) phá quy tắc là mất tác dụng ngay. Lab: sai 0/10, kể cả khi hai UPDATE chạy chồng nhau.
Cách nói khi phỏng vấn: "Với S5, mỗi sự kiện mang theo một số phiên bản tăng dần cho từng đơn hàng. Khi ghi xuống database, em chỉ cập nhật nếu phiên bản đang lưu nhỏ hơn phiên bản của sự kiện tới. Sự kiện cũ tới muộn sẽ thấy dòng đã có phiên bản cao hơn nên bị bỏ qua, không ghi đè được. Nhờ vậy trạng thái cuối luôn đúng dù thứ tự xử lý lộn xộn, nhưng cách này chỉ hợp với việc cập nhật trạng thái, không dùng được cho việc như gửi email."

Kết quả lab cho S1, S4, S5 — cùng cặp sự kiện như mục 2 (created 800 ms, paid 50 ms, ghi xuống PostgreSQL); "sai" = trạng thái cuối khác paid:

Kịch bảnCấu hìnhSai trạng thái cuốiOutput
Bug gốc2 consumer song song, UPDATE thường10/1008a-baseline.txt
S11 consumer, UPDATE thường0/1008b-s1.txt
S4a2 consumer + lock SET NX PX 5000, chờ 20 ms thử lại3/2008c-lock.txt
S4bnhư S4a + 0–30 ms ngẫu nhiên trước khi lấy lock11/20
S4cnhư S4a nhưng TTL lock 500 ms < job 800 ms10/10
S52 consumer song song + CAS theo version nghiệp vụ0/1008d-cas.txt

Số lần sai của S4a/S4b dao động giữa các lần chạy script (S4a: 4/20, 8/20, 3/20; S4b: 10/20, 11/20, 11/20 — file output giữ lần chạy cuối) vì nó phụ thuộc lịch CPU của máy lab. Chỉ nên đọc là "khác 0", không phải một tỉ lệ dùng được cho production.

S1 — một consumer: job paid chỉ được nhận sau khi job created xong (t=814 ms), nên thứ tự ghi luôn đúng — đổi lại, đơn khác cũng phải xếp hàng sau 800 ms đó.

### S1: 1 consumer duy nhất, cùng 2 sự kiện, UPDATE thường                                          (08b-s1.txt)
lần 1 — stream: 1792591461863-0 created (v=1), 1792591461865-0 paid (v=2)
  t=   1 ms  C1 nhận 1792591461863-0 created (v=1)
  t= 812 ms  C1 ghi status=created → UPDATE 1
  t= 814 ms  C1 nhận 1792591461865-0 paid (v=2)
  t= 873 ms  C1 ghi status=paid → UPDATE 1
  status cuối từng lần: paid paid paid paid paid paid paid paid paid paid
  → SAI trạng thái cuối (không phải paid): 0/10 lần

S4 — lock theo khoá: đoạn code consumer trong lab:

// S4: lấy lock theo khoá trước khi xử lý (labs/redis/laravel/08-thu-tu.php, rút gọn)
$lock  = "lock:order:{$f['order']}";
$token = "$me:$id";                                        // giá trị riêng của người giữ lock
while (!$r->set($lock, $token, ['NX', 'PX' => $ttl])) {    // đã có người giữ → chờ 20 ms rồi thử lại
    usleep(20000);
}
usleep(COST_MS[$type] * 1000);                             // "xử lý job": created 800 ms, paid 50 ms
write_state($db, 'plain', (int) $f['order'], $type, 0);    // UPDATE thường, không version
$r->rawCommand('DELEX', $lock, 'IFEQ', $token);            // 8.4+: chỉ xoá nếu lock vẫn là của mình
Cách nói khi phỏng vấn: "Trước khi xử lý sự kiện của đơn nào, consumer giành một khoá riêng cho đơn đó trong Redis, khoá có thời hạn để lỡ consumer chết thì khoá tự nhả. Ai giành được thì làm, người kia chờ một chút rồi thử lại, làm xong thì chỉ xoá khoá nếu nó vẫn là của mình. Khoá này chỉ bảo đảm hai bên không làm cùng lúc, chứ không bảo đảm ai làm trước — người giành được trước là người nhanh tay hơn, không phải người cầm sự kiện sinh ra trước. Còn nếu thời hạn khoá ngắn hơn thời gian xử lý thì khoá hết hạn giữa chừng và người kia vào làm luôn."
### S4a: 2 consumer + lock theo khoá (SET lock:order:42 <token> NX PX 5000, chờ 20 ms rồi thử lại), nhả bằng DELEX IFEQ   (08c-lock.txt)
lần 1 — stream: 1792591473321-0 created (v=1), 1792591473321-1 paid (v=2)
  t=   1 ms  C1 nhận 1792591473321-0 created (v=1)
  t=   1 ms  C2 nhận 1792591473321-1 paid (v=2)
  t=   1 ms  C1 lấy được lock:order:42 (SET NX PX 5000) sau 1 lần thử
  t= 814 ms  C1 ghi status=created → UPDATE 1
  t= 820 ms  C1 nhả lock: DELEX IFEQ → 1
  t= 827 ms  C2 lấy được lock:order:42 (SET NX PX 5000) sau 41 lần thử
  t= 897 ms  C2 ghi status=paid → UPDATE 1
  t= 898 ms  C2 nhả lock: DELEX IFEQ → 1
lần 7 — stream: 1792591480566-0 created (v=1), 1792591480566-1 paid (v=2)
  t=   1 ms  C2 nhận 1792591480566-0 created (v=1)
  t=   2 ms  C1 nhận 1792591480566-1 paid (v=2)
  t=   3 ms  C1 lấy được lock:order:42 (SET NX PX 5000) sau 1 lần thử
  t=  58 ms  C1 ghi status=paid → UPDATE 1
  t=  60 ms  C1 nhả lock: DELEX IFEQ → 1
  t=  65 ms  C2 lấy được lock:order:42 (SET NX PX 5000) sau 4 lần thử
  t= 870 ms  C2 ghi status=created → UPDATE 1
  t= 871 ms  C2 nhả lock: DELEX IFEQ → 1
  status cuối từng lần: paid paid paid paid paid paid created✕ created✕ paid paid paid paid paid paid created✕ paid paid paid paid paid
  → SAI trạng thái cuối (không phải paid): 3/20 lần

### S4b: như S4a, thêm 0–30 ms ngẫu nhiên trước khi lấy lock (đọc DB, validate… — 2 consumer không bao giờ khớp từng ms)
lần 1 — stream: 1792591497950-0 created (v=1), 1792591497950-1 paid (v=2)
  t=   1 ms  C2 nhận 1792591497950-0 created (v=1)
  t=   1 ms  C1 nhận 1792591497950-1 paid (v=2)
  t=  18 ms  C1 lấy được lock:order:42 (SET NX PX 5000) sau 1 lần thử
  t=  74 ms  C1 ghi status=paid → UPDATE 1
  t=  74 ms  C1 nhả lock: DELEX IFEQ → 1
  t=  88 ms  C2 lấy được lock:order:42 (SET NX PX 5000) sau 4 lần thử
  t= 897 ms  C2 ghi status=created → UPDATE 1
  t= 898 ms  C2 nhả lock: DELEX IFEQ → 1
lần 2 — stream: 1792591499233-0 created (v=1), 1792591499233-1 paid (v=2)
  t=   1 ms  C2 nhận 1792591499233-0 created (v=1)
  t=   1 ms  C1 nhận 1792591499233-1 paid (v=2)
  t=   3 ms  C2 lấy được lock:order:42 (SET NX PX 5000) sau 1 lần thử
  t= 816 ms  C2 ghi status=created → UPDATE 1
  t= 820 ms  C2 nhả lock: DELEX IFEQ → 1
  t= 845 ms  C1 lấy được lock:order:42 (SET NX PX 5000) sau 41 lần thử
  t= 902 ms  C1 ghi status=paid → UPDATE 1
  t= 908 ms  C1 nhả lock: DELEX IFEQ → 1
  status cuối từng lần: created✕ paid created✕ created✕ paid created✕ created✕ paid paid paid created✕ paid paid created✕ created✕ created✕ paid created✕ created✕ paid
  → SAI trạng thái cuối (không phải paid): 11/20 lần

### S4c: như S4a nhưng TTL lock 500 ms < thời gian job created 800 ms
lần 1 — stream: 1792591525929-0 created (v=1), 1792591525929-1 paid (v=2)
  t=   1 ms  C2 nhận 1792591525929-1 paid (v=2)
  t=   1 ms  C1 nhận 1792591525929-0 created (v=1)
  t=   3 ms  C1 lấy được lock:order:42 (SET NX PX 500) sau 1 lần thử
  t= 510 ms  C2 lấy được lock:order:42 (SET NX PX 500) sau 21 lần thử
  t= 567 ms  C2 ghi status=paid → UPDATE 1
  t= 568 ms  C2 nhả lock: DELEX IFEQ → 1
  t= 810 ms  C1 ghi status=created → UPDATE 1
  t= 810 ms  C1 nhả lock: DELEX IFEQ → 0 (lock đã hết hạn, không còn là của mình)
  status cuối từng lần: created✕ created✕ created✕ created✕ created✕ created✕ created✕ created✕ created✕ created✕
  → SAI trạng thái cuối (không phải paid): 10/10 lần

Kết luận khớp với bảng: lock là mutual exclusion (loại trừ lẫn nhau), không phải ordering (sắp thứ tự). Muốn đúng thứ tự bằng lock thì phải thêm hàng đợi FIFO theo khoá phía sau lock — tức là quay về S3.

S5 — CAS theo version: consumer giữ nguyên như bug gốc (2 process song song, job chậm vẫn ghi sau), chỉ đổi câu UPDATE thành bản có điều kiện version. Phần sau của output chạy tay đúng câu đó trên PostgreSQL, gồm cả trường hợp hai UPDATE chạy chồng nhau (mở 2 session bằng pgtest.parallel, A giữ khoá dòng 1 giây):

### S5: 2 consumer song song + CAS theo version nghiệp vụ (created v=1, paid v=2)                   (08d-cas.txt)
lần 1 — stream: 1792591747440-0 created (v=1), 1792591747441-0 paid (v=2)
  t=   2 ms  C2 nhận 1792591747440-0 created (v=1)
  t=   2 ms  C1 nhận 1792591747441-0 paid (v=2)
  t=  62 ms  C1 ghi status=paid version=2 → UPDATE 1
  t= 807 ms  C2 ghi status=created version=1 → UPDATE 0 (bỏ qua: bản cũ)
  status cuối từng lần: paid paid paid paid paid paid paid paid paid paid
  → SAI trạng thái cuối (không phải paid): 0/10 lần
### Câu CAS chạy tay trên PostgreSQL 18.4: paid (v=2) tới trước, created (v=1) tới muộn
UPDATE tmp_orders SET status = 'paid',    version = 2 WHERE id = 42 AND version < 2;
UPDATE 1
UPDATE tmp_orders SET status = 'created', version = 1 WHERE id = 42 AND version < 1;
UPDATE 0
SELECT id, status, version FROM tmp_orders;
 id | status | version
----+--------+---------
 42 | paid   |       2
### CAS khi 2 UPDATE chạy CHỒNG nhau: A giữ row lock 1 giây, B phải chờ (READ COMMITTED mặc định: chờ xong kiểm lại WHERE trên bản mới)
--- A = paid v=2 (commit sau 1 s), B = created v=1 tới trong lúc A chưa commit
----- A -----
UPDATE 1
----- B -----
Timing is on.
UPDATE 0
Time: 696.906 ms
 id | status | version
----+--------+---------
 42 | paid   |       2
--- A = created v=1 (commit sau 1 s), B = paid v=2 tới trong lúc A chưa commit
----- A -----
UPDATE 1
----- B -----
Timing is on.
UPDATE 1
Time: 716.985 ms
 id | status | version
----+--------+---------
 42 | paid   |       2
--- như trường hợp đầu nhưng B chạy ở REPEATABLE READ
----- A -----
UPDATE 1
----- B -----
Timing is on.
Time: 703.011 ms
ROLLBACK
Time: 0.200 ms
ERROR:  could not serialize access due to concurrent update
 id | status | version
----+--------+---------
 42 | paid   |       2
Cách nói khi phỏng vấn: "Em chạy thử đúng câu cập nhật có điều kiện phiên bản trên PostgreSQL: sự kiện thanh toán mang phiên bản 2 tới trước thì cập nhật được một dòng, sự kiện tạo đơn mang phiên bản 1 tới sau thì không còn dòng nào thoả điều kiện nên không ghi gì. Hai câu chạy chồng nhau cũng an toàn ở mức cô lập mặc định: câu tới sau phải chờ khoá dòng của câu trước, chờ xong PostgreSQL kiểm lại điều kiện trên dữ liệu mới nhất rồi mới quyết định ghi hay bỏ. Nếu chạy ở mức repeatable read thì câu tới sau bị lỗi xung đột và ứng dụng phải thử lại, thử lại thì nó sẽ thấy phiên bản 2 và bỏ qua."

Ở READ COMMITTED (mặc định), B chờ ~0,7 giây tới khi A commit, sau đó đánh giá lại điều kiện version < :v trên bản dòng mới: trường hợp 1 ra UPDATE 0, trường hợp 2 ra UPDATE 1 — cả hai đều kết thúc ở paid, version 2. Ở REPEATABLE READ, B nhận could not serialize access due to concurrent update (dòng ERROR in sau cùng vì helper in stderr sau stdout) — consumer chạy ở mức này phải bắt lỗi và retry. Lưu ý: dòng tmp_orders được tạo sẵn (version 0) trước khi có sự kiện; nếu sự kiện đầu tiên phải tự INSERT dòng thì cần thêm upsert có điều kiện version — phần đó chưa lab.

4. Kết hợp thực tế — không có một "chuẩn" duy nhất

Hầu hết hệ thống production dùng hai lớp chồng lên nhau thay vì chọn một giải pháp: lớp giao nhận (S2 hoặc S3) để vừa đạt throughput vừa giữ thứ tự ở điều kiện bình thường, và lớp ghi dữ liệu (S5) làm phòng thủ cuối — vì claim/redelivery và failover vẫn có thể đảo thứ tự ngay cả khi tầng giao nhận đã làm đúng việc của nó.

Sharding theo khoá + worker-per-key + CAS làm phòng thủ cuối Producer băm order id để chọn stream shard A hoặc B. Mỗi shard có một consumer đọc tuần tự và phân việc cho worker riêng theo khoá trong RAM, giữ đúng thứ tự trong một khoá. Lớp ghi DB cuối cùng dùng compare-and-swap theo version để tự vệ thêm cho trường hợp failover hoặc claim làm giao lại entry cũ sau entry mới. Lớp giao nhận (S2+S3) + lớp ghi dữ liệu (S5) làm phòng thủ cuối Producer: hash(order_id) Stream shard A (#42, #44…) Stream shard B (#43, #45…) 1 consumer đọc tuần tự → worker/khoá (#42: created→paid→shipped đúng thứ tự) 1 consumer đọc tuần tự → worker/khoá (khoá khác #42 chạy song song) UPDATE orders SET status=:s, version=:v WHERE id=:id AND version < :v Vì sao vẫn cần CAS dù đã sharding + xử lý tuần tự? XAUTOCLAIM / failover có thể giao lại entry cũ sau khi entry mới đã xử lý xong (đo được ở Bài 6) — CAS là lớp chặn cuối cho đúng cả trường hợp này.
Hình 2 — Sharding + worker-per-key giải quyết thứ tự ở điều kiện bình thường; CAS theo version giải quyết luôn cả trường hợp bất thường (crash, claim, failover) mà không giải pháp nào ở tầng giao nhận chặn được hoàn toàn.

5. Chọn theo bối cảnh

6. Khi chính producer gửi sai thứ tự

Mọi solution ở mục 3 đều dựa trên một giả định: thứ tự entry trong stream đúng bằng thứ tự nghiệp vụ — Redis chỉ không đảm bảo thứ tự xử lý. Có một tình huống phá vỡ chính giả định đó: producer publish order.created bị lỗi, đưa vào retry bất đồng bộ, và trong lúc chờ retry thì order.paid được publish thành công. Khi ấy bản thân log đã sai: paid có entry ID nhỏ hơn created, dù về nghiệp vụ created xảy ra trước.

6.1 Ví dụ cụ thể

  1. t=0 ms: publish created (seq nghiệp vụ = 1) lỗi do network blip; producer lên lịch retry async, backoff 500 ms rồi nhân đôi.
  2. t=300 ms: thanh toán xong, publish paid (seq = 2) thành công ngay → vào stream ở entry 5000-0.
  3. t=500 ms: retry lần 1 của created vẫn lỗi.
  4. t=1000 ms: retry lần 2 thành công → created vào stream ở entry 5000-7, tức là sau paid.

Dù dùng S1 (một consumer, đọc tuần tự tuyệt đối), consumer vẫn xử lý paid trước rồi created đè lên sau — nó chỉ trung thực làm theo thứ tự log, mà log sai từ gốc. Gốc vấn đề đã chuyển từ tầng xử lý (consumer) sang tầng sinh và gửi sự kiện (producer). IDMP (8.6) cũng không giúp gì ở đây: nó chỉ chặn cùng một (producer, iid) bị ghi hai lần trong cửa sổ, không sắp lại thứ tự hay chặn entry tới sai thứ tự.

Lab: producer giả lập đúng dòng thời gian trên (publish created lỗi ở 0 ms và 500 ms, thành công ở lần retry 1000 ms; paid publish OK ở 300 ms), một consumer duy nhất (S1), UPDATE thường. ID 5000-0/5000-7 ở danh sách trên là minh hoạ; ID thật nằm trong output:

### Producer gửi sai thứ tự: publish created lỗi, retry async (500 ms, rồi 1000 ms); paid publish OK ở 300 ms   (08e-reorder.txt)
### Consumer S1 (1 consumer, đọc tuần tự), UPDATE thường
lần 1 — XRANGE orders - + : 1792591555249-0 paid(v=2)  1792591555952-0 created(v=1)
  t=   1 ms  producer: publish created (v=1) LỖI (giả lập network blip) → hẹn retry sau 500 ms
  t= 301 ms  producer: publish paid (v=2) OK → 1792591555249-0
  t= 301 ms  C1 nhận 1792591555249-0 paid (v=2)
  t= 363 ms  C1 ghi status=paid → UPDATE 1
  t= 500 ms  producer: retry #1 created LỖI → hẹn retry sau 500 ms nữa (backoff x2)
  t=1005 ms  producer: retry #2 created OK → 1792591555952-0
  t=1005 ms  C1 nhận 1792591555952-0 created (v=1)
  t=1812 ms  C1 ghi status=created → UPDATE 1
  status cuối từng lần: created✕ created✕ created✕ created✕ created✕
  → SAI trạng thái cuối (không phải paid): 5/5 lần

5/5 lần sai. XRANGE cho thấy chính log đã đảo: entry paid có ID nhỏ hơn entry created ~700 ms. Consumer S1 làm đúng việc của nó — đọc tuần tự — nên ghi paid ở 363 ms rồi created đè lên ở 1812 ms. Cách sửa phía consumer ở mục 3 (S1, cũng như S2/S3 dựa trên cùng giả định) không cứu được trường hợp này.

Retry ở producer làm event cũ vào stream sau event mới, và cách chặn Trục thời gian: ở 0ms publish created lỗi; ở 300ms paid publish thành công và vào stream ở entry 5000-0; ở 500ms retry created lỗi; ở 1000ms retry created thành công và vào stream ở entry 5000-7, sau paid. Phần dưới: thay đổi trạng thái và outbox ghi chung một transaction, dispatcher đọc outbox theo thứ tự từng khoá và không cho paid vượt created; consumer dùng CAS với version nghiệp vụ gán lúc tạo event. Vấn đề: retry ở producer làm "created" vào log sau "paid" 0ms300ms500ms1000ms created (seq=1) → lỗi paid (seq=2) → OK retry #1 → lỗi retry #2 → OK (muộn) 5000-0 paid 5000-7 created ✕ Log đã sai từ gốc: kể cả một consumer đọc tuần tự (S1) cũng ghi paid rồi created đè lên. Cách chặn: outbox tuần tự theo khoá + version nghiệp vụ State change + outboxcùng 1 transaction, seq=1,2 Dispatcher theo từng khoácreated lỗi → paid #42 phải đợi Stream#42: created → paid (đúng) UPDATE orders SET status=:s, version=:v WHERE id=:id AND version < :v :v = seq nghiệp vụ gán lúc TẠO event — không phải stream entry ID (created có ID lớn hơn paid) Khoá khác (#43, #44…) vẫn được dispatch song song — chỉ chặn trong phạm vi một khoá.
Hình 3 — Retry bất đồng bộ cho phép sự kiện seq=2 vượt mặt seq=1 trước khi vào log. Outbox + dispatcher theo khoá chặn chuyện này xảy ra; CAS với version nghiệp vụ là lưới an toàn cuối cho các đường không đi qua outbox (producer cũ, nhiều producer cùng ghi một thực thể).

6.2 Các solution

#SolutionGiải quyết đượcTrade-off chính
P1Outbox + dispatcher tuần tự theo khoá: ghi event vào bảng outbox trong cùng transaction với thay đổi trạng thái; dispatcher đọc theo thứ tự từng khoá, entry lỗi thì giữ nguyên vị trí (retry hoặc DLQ), không cho entry sau của cùng khoá vượt lênChặn tận gốc: thứ tự trong stream = thứ tự nghiệp vụThêm bảng outbox + tiến trình dispatcher, thêm vài ms trễ; một entry lỗi dai dẳng chặn cả khoá đó (head-of-line blocking ở producer) tới khi retry được hoặc chuyển DLQ — dispatcher phải song song theo khoá để không chặn khoá khác. Lab: stream ra đúng thứ tự, khoá khác không bị chặn.
P2Version nghiệp vụ + CAS ở consumer (chính S5, triển khai đúng)Trạng thái cuối đúng dù log sai thứ tự: paid (v=2) ghi trước, created (v=1) tới sau: điều kiện 2 < 1 sai nên bị bỏ qua (lab: 0/5 sai; dùng entry ID làm version: 5/5 sai)Version phải gán lúc sự kiện sinh ra trong hệ nguồn (cột tăng dần trong DB), độc lập với lúc publish/retry. Dùng stream entry ID làm version là sai hoàn toàn — created retry xong có ID lớn hơn nên CAS chọn nhầm nó là bản mới nhất. Vẫn vô dụng với side-effect không bỏ qua được.
P3Reorder buffer ở consumer: mỗi event mang seq theo khoá, consumer giữ "seq kế tiếp mong đợi" và buffer các event tới sớm cho tới khi đủ seq liền trướcSide-effect chạy đúng thứ tự nghiệp vụ, không chỉ state cuốiThêm trạng thái buffer theo khoá (RAM), cần timeout + DLQ khi seq bị thiếu mãi; mọi event phía sau một seq bị trễ đều phải đợi — đánh đổi độ trễ lấy thứ tự, giống reorder buffer của TCP. Seq phải liên tục (không có lỗ), nếu không buffer chờ vô hạn. Chưa verify bằng lab.
P4Đổi thiết kế event: state-carrying thay vì delta — phát order_state_changed {id, status, version} mang toàn bộ trạng thái thay vì created/paid/shipped rời rạcThứ tự tới không còn quan trọng: áp bản version cao nhất đã thấyMất khả năng dùng stream làm log chi tiết cho audit/event sourcing (phải log riêng nếu cần); payload lớn hơn; consumer cần phản ứng theo chuyển trạng thái (ví dụ "vừa chuyển sang paid thì gửi email") phải tự so trạng thái cũ/mới. Chưa verify bằng lab.
Cách nói khi phỏng vấn: "Câu lệnh cập nhật ở đây giống hệt phần trước, chỉ khác ở chỗ lấy số phiên bản từ đâu. Số phiên bản phải được đánh ngay lúc sự kiện sinh ra trong hệ thống gốc, ví dụ tạo đơn là 1, thanh toán là 2. Nếu lấy mã entry của Redis làm phiên bản thì sai, vì sự kiện tạo đơn phải gửi lại nên vào Redis muộn hơn và được mã lớn hơn, hệ thống sẽ tưởng nó là bản mới nhất rồi ghi đè lên trạng thái đã thanh toán."

Lab P2 — cùng log sai thứ tự như 6.1, một consumer, câu CAS giống hệt mục 3; chỉ khác nguồn của :v: version nghiệp vụ mang trong event, hoặc stream entry ID đổi ra số (ms * 100000 + seq):

### P2 đúng: cùng log sai thứ tự, 1 consumer, CAS với version NGHIỆP VỤ (gán lúc tạo event: created=1, paid=2)   (08f-p2.txt)
lần 1 — XRANGE orders - + : 1792591566859-0 paid(v=2)  1792591567558-0 created(v=1)
  t=   0 ms  producer: publish created (v=1) LỖI (giả lập network blip) → hẹn retry sau 500 ms
  t= 301 ms  producer: publish paid (v=2) OK → 1792591566859-0
  t= 301 ms  C1 nhận 1792591566859-0 paid (v=2)
  t= 360 ms  C1 ghi status=paid version=2 → UPDATE 1
  t= 501 ms  producer: retry #1 created LỖI → hẹn retry sau 500 ms nữa (backoff x2)
  t=1000 ms  producer: retry #2 created OK → 1792591567558-0
  t=1000 ms  C1 nhận 1792591567558-0 created (v=1)
  t=1802 ms  C1 ghi status=created version=1 → UPDATE 0 (bỏ qua: bản cũ)
  status cuối từng lần: paid paid paid paid paid
  → SAI trạng thái cuối (không phải paid): 0/5 lần

### P2 sai: CAS với version = stream entry ID đổi ra số (ms * 100000 + seq)
lần 1 — XRANGE orders - + : 1792591578172-0 paid(v=2)  1792591578870-0 created(v=1)
  t=   1 ms  producer: publish created (v=1) LỖI (giả lập network blip) → hẹn retry sau 500 ms
  t= 303 ms  C1 nhận 1792591578172-0 paid (v=2)
  t= 303 ms  producer: publish paid (v=2) OK → 1792591578172-0
  t= 360 ms  C1 ghi status=paid version=179259157817200000 → UPDATE 1
  t= 501 ms  producer: retry #1 created LỖI → hẹn retry sau 500 ms nữa (backoff x2)
  t=1003 ms  C1 nhận 1792591578870-0 created (v=1)
  t=1003 ms  producer: retry #2 created OK → 1792591578870-0
  t=1809 ms  C1 ghi status=created version=179259157887000000 → UPDATE 1
  status cuối từng lần: created✕ created✕ created✕ created✕ created✕
  → SAI trạng thái cuối (không phải paid): 5/5 lần

Version nghiệp vụ: 0/5 sai — created v=1 tới sau bị UPDATE 0. Version = entry ID: 5/5 sai — entry created retry muộn có ID lớn hơn nên điều kiện "version đang lưu nhỏ hơn" đúng, CAS cho nó ghi đè. Đúng như bảng: CAS chỉ đúng khi version sinh ra cùng sự kiện ở hệ nguồn.

Lab P1 — hai đơn #42 và #43, mỗi đơn created rồi paid, xen kẽ nhau trong outbox; publish created #42 bị giả lập lỗi 2 lần đầu. Code phía hệ nguồn và dispatcher:

// Hệ nguồn: đổi trạng thái + ghi outbox trong CÙNG một transaction (labs/redis/laravel/08-thu-tu.php, rút gọn)
$db->beginTransaction();
$db->prepare('UPDATE tmp_src_orders SET status = :s, version = :v WHERE id = :id')->execute($row);
$db->prepare('INSERT INTO tmp_outbox (aggregate_id, type, version) VALUES (:id, :s, :v)')->execute($row);
$db->commit();

// Dispatcher tuần tự theo khoá: mỗi vòng (200 ms) đọc các dòng chưa publish, theo khoá rồi theo id
$rows = $db->query('SELECT id, aggregate_id, type, version FROM tmp_outbox
                    WHERE published_at IS NULL ORDER BY aggregate_id, id')->fetchAll();
$blocked = [];
foreach ($rows as $o) {
    $k = $o['aggregate_id'];
    if (isset($blocked[$k])) continue;                    // entry trước của khoá này chưa đi được → chờ
    $sid = $publish($o);                                  // XADD orders * order … type … version …
    if ($sid === null) {                                  // lỗi: giữ nguyên trong outbox, chỉ chặn khoá này
        $db->exec("UPDATE tmp_outbox SET attempts = attempts + 1 WHERE id = {$o['id']}");
        $blocked[$k] = true;
        continue;
    }
    $db->prepare('UPDATE tmp_outbox SET published_at = now(), stream_id = :sid WHERE id = :id')
       ->execute(['sid' => $sid, 'id' => $o['id']]);
}
// Bản "ngây thơ" để so sánh: ORDER BY id và không có $blocked — entry lỗi bị bỏ qua, entry sau đi trước
Cách nói khi phỏng vấn: "Mỗi lần đổi trạng thái đơn, em ghi luôn một dòng sự kiện vào bảng outbox trong cùng giao dịch, nên hoặc cả hai cùng có, hoặc cả hai cùng không. Một tiến trình riêng đọc outbox theo từng đơn, sự kiện cũ trước, rồi đẩy lên Redis. Sự kiện nào đẩy lỗi thì giữ nguyên chỗ đó, và các sự kiện sau của chính đơn đó phải chờ nó đi được đã; đơn khác thì vẫn đẩy bình thường. Nhờ vậy thứ tự trong stream luôn đúng thứ tự nghiệp vụ của từng đơn, cái giá là một đơn bị kẹt thì chỉ đơn đó chậm."
### Outbox: UPDATE trạng thái + INSERT outbox trong 1 transaction — ROLLBACK thì mất cả hai         (08g-outbox.txt)
BEGIN;
UPDATE tmp_src_orders SET status = 'shipped', version = 3 WHERE id = 42;
INSERT INTO tmp_outbox (aggregate_id, type, version) VALUES (42, 'shipped', 3);
ROLLBACK;
SELECT (SELECT status FROM tmp_src_orders WHERE id = 42) AS status, (SELECT count(*) FROM tmp_outbox) AS outbox_rows;
 status | outbox_rows
--------+-------------
 paid   |           0
### P1 sai — dispatcher NGÂY THƠ: đọc outbox theo id, entry lỗi thì bỏ qua, đẩy entry sau (publish created #42 lỗi 2 lần đầu)
outbox sau 4 transaction của app:
  outbox.id=1  order=42  created v=1
  outbox.id=2  order=43  created v=1
  outbox.id=3  order=42  paid    v=2
  outbox.id=4  order=43  paid    v=2
dispatcher naive: SELECT id, aggregate_id, type, version FROM tmp_outbox WHERE published_at IS NULL ORDER BY id
  vòng 1 t=  17 ms  outbox.id=1 #42 created → publish LỖI, giữ nguyên trong outbox
  vòng 1 t=  22 ms  outbox.id=2 #43 created → XADD 1792591588799-0
  vòng 1 t=  24 ms  outbox.id=3 #42 paid    → XADD 1792591588804-0
  vòng 1 t=  28 ms  outbox.id=4 #43 paid    → XADD 1792591588807-0
  vòng 2 t= 235 ms  outbox.id=1 #42 created → publish LỖI, giữ nguyên trong outbox
  vòng 3 t= 440 ms  outbox.id=1 #42 created → XADD 1792591589219-0
XRANGE orders - + (thứ tự consumer sẽ thấy):
  1792591588799-0  #43 created v=1
  1792591588804-0  #42 paid    v=2
  1792591588807-0  #43 paid    v=2
  1792591589219-0  #42 created v=1
consumer S1 (UPDATE thường) áp theo thứ tự log → #42=created✕  #43=paid
### P1 đúng — dispatcher TUẦN TỰ THEO KHOÁ: entry lỗi giữ vị trí, entry sau CÙNG khoá phải chờ, khoá khác chạy tiếp
outbox sau 4 transaction của app:
  outbox.id=1  order=42  created v=1
  outbox.id=2  order=43  created v=1
  outbox.id=3  order=42  paid    v=2
  outbox.id=4  order=43  paid    v=2
dispatcher perkey: SELECT id, aggregate_id, type, version FROM tmp_outbox WHERE published_at IS NULL ORDER BY aggregate_id, id
  vòng 1 t=  28 ms  outbox.id=1 #42 created → publish LỖI, giữ nguyên trong outbox
  vòng 1 t=  29 ms  outbox.id=3 #42 paid    → CHỜ (entry trước của #42 chưa publish được)
  vòng 1 t=  33 ms  outbox.id=2 #43 created → XADD 1792591590012-0
  vòng 1 t=  35 ms  outbox.id=4 #43 paid    → XADD 1792591590017-0
  vòng 2 t= 242 ms  outbox.id=1 #42 created → publish LỖI, giữ nguyên trong outbox
  vòng 2 t= 244 ms  outbox.id=3 #42 paid    → CHỜ (entry trước của #42 chưa publish được)
  vòng 3 t= 451 ms  outbox.id=1 #42 created → XADD 1792591590433-0
  vòng 3 t= 453 ms  outbox.id=3 #42 paid    → XADD 1792591590435-0
XRANGE orders - + (thứ tự consumer sẽ thấy):
  1792591590012-0  #43 created v=1
  1792591590017-0  #43 paid    v=2
  1792591590433-0  #42 created v=1
  1792591590435-0  #42 paid    v=2
consumer S1 (UPDATE thường) áp theo thứ tự log → #42=paid  #43=paid
Cách nói khi phỏng vấn: "Em kiểm tra hai việc. Một là tính nguyên tử: mở giao dịch, đổi trạng thái sang đã giao hàng và ghi dòng outbox, rồi huỷ giao dịch — đọc lại thì trạng thái vẫn là đã thanh toán và outbox không có dòng nào, nghĩa là không bao giờ có chuyện đổi trạng thái mà mất sự kiện hay ngược lại. Hai là thứ tự: dispatcher ngây thơ bỏ qua sự kiện lỗi nên thanh toán của đơn 42 lên stream trước tạo đơn, consumer đọc tuần tự vẫn ra trạng thái sai; dispatcher theo từng đơn bắt thanh toán của đơn 42 chờ, stream ra đúng thứ tự, còn đơn 43 thì đã đẩy xong ngay từ vòng đầu."

6.3 Kết hợp

Tổ hợp hợp lý vẫn giống mục 4, chỉ sửa đúng một chỗ: P1 chặn ở producer để vấn đề không xảy ra, P2 làm lưới an toàn với version lấy từ thời điểm sự kiện sinh ra — không phải thời điểm hay thứ tự nó được đẩy lên Redis. P3 chỉ thêm khi có side-effect bắt buộc đúng trình tự mà P1 không bao phủ được hết (ví dụ nhiều producer khác nhau cùng phát sự kiện cho một thực thể). P4 đáng cân nhắc ngay từ lúc thiết kế nếu consumer chủ yếu chỉ cần "trạng thái mới nhất" chứ không cần từng bước chuyển.

Nguồn: bug gốc (mục 2), S1, S4, S5 (mục 3), producer gửi sai thứ tự (6.1), P1 và P2 (6.2) đo bằng lab riêng của bài này — labs/redis/08-thu-tu.sh, output labs/redis/out/08a…08g. Số liệu failover (mất 20 entry đã OK, giao lại 50), hành vi IDMP và bảng đảm bảo/giới hạn của Stream lấy từ lab ở Bài 6 · Stream. S2, S3, P3, P4 và phần kết hợp (mục 4, 6.3) là phân tích, chưa verify bằng lab.

Phụ lục: lab đã chạy

Môi trường: Redis 8.8.0 (redis:alpine, container tạm tmp-rdt-redis trên network tmp-rdt-net, dựng bởi labs/redis/lib.sh); consumer, producer, dispatcher: PHP 8.3.33 + phpredis 6.3.0 + pdo_pgsql (image laravel-app, dùng phpredis gốc thay cho facade Laravel vì mỗi consumer phải pcntl_fork thành một process và tự mở kết nối riêng); PostgreSQL 18.4 my-test-pgdb (repo postgresql-learning), bảng tmp_orders (phía consumer), tmp_src_orders + tmp_outbox (hệ nguồn) tạo đầu script và DROP khi xong. Process PHP dùng chung network namespace với container Redis lab, nên script gắn tạm tmp-rdt-redis vào network của my-test-pgdb và gỡ ra khi kết thúc. Phần CAS chạy chồng dùng helper pgtest.parallel của repo postgresql-learning. Thời gian job là usleep (800 ms / 50 ms), lỗi publish là giả lập trong code.

cd labs/redis
./08-thu-tu.sh             # chạy hết → out/08a … 08g (~2,5 phút)
./08-thu-tu.sh lock        # 1 phần: baseline | s1 | lock | cas | reorder | p2 | outbox
# dọn container lab khi xong mọi lab redis/
docker rm -f $(docker ps -aq -f name=tmp-rdt-); docker network rm tmp-rdt-net

Chưa verify: S2 (sharding N stream theo hash(key) % N), S3 (worker-per-key trong RAM), P3 (reorder buffer), P4 (event mang trạng thái); đảo thứ tự do redelivery sau crash/XAUTOCLAIM và do failover (cơ chế 2, 3 ở mục 1) không dựng lại ở đây — số liệu lấy từ Bài 6; nhiều dispatcher outbox chạy song song; dispatcher chết giữa XADD và đánh dấu đã publish; upsert có điều kiện version khi dòng chưa tồn tại; mọi kịch bản chỉ chạy với 1–2 đơn hàng, chưa đo ở tải cao.