Stream — log append-only, consumer group, PEL, trim, IDMP và failover
Stream là kiểu duy nhất của Redis được thiết kế làm log sự kiện: ghi nối đuôi, mỗi entry có ID tăng dần, nhiều nhóm consumer đọc độc lập, có xác nhận và giao lại. Nó lấp khoảng trống giữa Pub/Sub (mất tin khi không ai nghe) và list (không có ack, không đọc lại, một nhóm consumer). Ba bản gần đây thêm nhiều thứ quan trọng: trim biết consumer group (8.2), đọc kèm nhận lại entry treo (8.4), chống producer gửi trùng (8.6), trả entry về group ngay (8.8). Bài này đo từng cơ chế và chủ động phá: consumer chết giữa chừng, group chậm bị trim mất dữ liệu, producer gửi lại, master chết. Ví dụ: sự kiện đơn hàng → gửi email (e-commerce), sự kiện học tập → cập nhật tiến độ (LMS). So sánh Pub/Sub/List/Stream cơ bản đã có ở Redis — những thứ cần biết mục 1.8.
Thuật ngữ dùng trong bài
entry (một bản ghi trong stream: ID + các cặp field/value) · entry ID (<mili-giây>-<số thứ tự>, luôn tăng) · consumer group (một nhóm consumer chia nhau đọc stream, có con trỏlast-delivered-idriêng) · PEL (pending entries list — các entry đã giao nhưng chưaXACK, kèm consumer đang giữ, thời điểm giao, số lần giao) · claim (chuyển entry đang treo sang consumer khác) · poison message (entry lần nào xử lý cũng lỗi) · dead letter queue / DLQ (chỗ cất entry hỏng để người xem) · trim (cắt bớt đầu stream theo độ dàiMAXLENhoặc IDMINID) · lag (số entry group chưa đọc) · idempotent producer / IDMP (gửi lại cùng một thông điệp không tạo entry mới) · radix tree (cây tiền tố, ở đây chỉ mục các node theo ID).
Kiểm chứng: tài liệu Redis qua Context7 ngày 03/10/2026 (XADDgồmIDMP/IDMPAUTO,KEEPREF/DELREF/ACKED;XCFGSET,XAUTOCLAIM,XREADGROUPmục CLAIM,XNACK,XACKDEL,XTRIM,XINFO STREAM; release notes 8.2, 8.4, 8.6). Danh sách lệnh/tham số theo bản lấy từCOMMAND DOCScủa Redis 8.8.0. Lab chạy thật: Redis 8.8.0 (một node, và cặp master–replica tạm cho phần failover), PHP 8.3.33 + phpredis 6.3.0 + Laravel 12.64. Output:labs/redis/out/06*.txt.
Trả lời 30 giây
Stream = log append-only, ID tăng dần, nhiều consumer group, mỗi group có con trỏ riêng và PEL giữ entry đã giao chưa ack. Nó cho at-least-once: consumer chết thì entry nằm trong PEL tới khi người khácXAUTOCLAIM(hoặcXREADGROUP … CLAIMtừ 8.4); nên consumer phải idempotent (lab: chết sau khi gửi email, trước khi ack → không có bước chống trùng thì 1 đơn bị gửi 2 lần). Bốn bẫy lớn: (1)MAXLENtrim cắt cả entry group chậm chưa đọc — lab mất 8 900/9 900 entry màlagvẫn báo 1 000;ACKED(8.2) giữ lại nhưng stream phình theo group chậm nhất; (2)XDELkhông trả RAM; (3) producer gửi lại tạo bản trùng —IDMP(8.6) chặn được nhưng chỉ trong cửa sổ (mặc định 100 giây, 100 id/producer); (4) failover: mất entry đã trả OK (lab 20) và trạng thái group lùi lại nên giao lại entry đã xử lý (lab 50). Stream nằm trọn một key = một node: không chia partition như Kafka.
1. Bài toán gốc và bối cảnh ra đời
Trước Redis 5.0 (2018), muốn "phát sự kiện cho nhiều bên" có ba lựa chọn đều thiếu: Pub/Sub giao ngay rồi quên — ai không đang nghe là mất; list có lưu nhưng mỗi phần tử chỉ một người lấy, lấy là xoá, không ack, không đọc lại; Kafka làm đủ cả nhưng là một cụm riêng phải vận hành. antirez thiết kế stream mượn mô hình log + consumer group của Kafka nhưng nằm trong RAM của một Redis.
Tối ưu cho: luồng sự kiện vừa phải (nghìn – chục nghìn/giây), giữ ngắn hạn (phút – ngày), vài nhóm consumer độc lập, cần ack và giao lại, đọc lại theo khoảng ID/thời gian. Không tối ưu cho: giữ lâu dài khối lượng lớn (RAM), song song hoá bằng partition (một stream = một key = một node), exactly-once, thứ tự toàn cục qua nhiều stream.
2. Mô hình tư duy cốt lõi
Stream = một log chỉ ghi nối đuôi, ID tăng dần; consumer group = (con trỏ "đã giao tới đâu" + PEL "đã giao mà chưa xác nhận").
sequenceDiagram participant P as Producer participant S as Stream (log) participant G as Group "mail" participant C1 as Consumer c1 participant C2 as Consumer c2 P->>S: XADD * order o42 → 1791…-0 C1->>G: XREADGROUP … STREAMS events > G-->>C1: entry 1791…-0 (ghi vào PEL: c1, giờ giao, lần 1) Note over C1: gửi email … rồi chết trước XACK C2->>G: XAUTOCLAIM … min-idle 30000 (hoặc XREADGROUP … CLAIM) G-->>C2: entry 1791…-0 (PEL: c2, lần 2) C2->>C2: đã gửi chưa? (SET sent:id NX) → bỏ qua gửi trùng C2->>G: XACK → xoá khỏi PEL
Hệ quả trực tiếp: (a) entry không bị xoá khi đọc — đọc lại được theo ID, nhiều group đọc cùng log; (b) "đã giao" và "đã xong" là hai trạng thái khác nhau — khoảng giữa là nơi sinh ra trùng lặp; (c) log phải được cắt (trim) chủ động, và cắt là quyết định giữa RAM và group chậm; (d) mọi thứ — log, con trỏ, PEL — nằm trong một key, nên được replication bất đồng bộ như mọi key khác.
3. Internals và quyết định thiết kế
3.1 ID, và chuyện ID "kẹt" ở tương lai
### ID = <mili-giây>-<số thứ tự>, luôn tăng (06a-basics.txt)
XADD orders * type created order 1 → 1791015184467-0
XADD orders * … (cùng ms thì tăng seq) → 1791015184587-0 1791015184587-1 1791015184587-2
XADD orders 1-1 … (ID nhỏ hơn cuối) → ERR The ID specified in XADD is equal or smaller than the target stream top item
### producer tự đặt ID theo đồng hồ app chạy nhanh 1 giờ
XADD clock 1791018784725-0 (giờ app +1h)
XADD clock * → 1791018784725-1
XADD clock * → 1791018784725-2
XADD clock * → 1791018784725-3
→ ID tự sinh 'kẹt' ở mili-giây tương lai, chỉ tăng phần seq
XRANGE clock 1791015125128 1791015185128 (1 phút gần nhất theo giờ thật) → 0 entry, dù vừa ghi 3 entry
ID do Redis sinh từ đồng hồ của server, và luôn lớn hơn ID cuối: nếu đồng hồ lùi (hoặc ai đó đã ghi một ID tương lai), Redis giữ phần mili-giây cũ và tăng phần seq. Đảm bảo thứ tự
được giữ, đổi lại "ID ≈ thời điểm" không còn đúng. Bài học: để Redis sinh ID (*); đừng dùng ID làm giờ nghiệp vụ — cất giờ thật trong một field.
3.2 Lưu trữ: radix tree các node listpack, tên field lưu một lần
### 100 000 entry, 3 field — cùng tên field vs tên field đổi theo entry (06b-memory.txt)
cùng field: MEMORY USAGE=2774580 27.7 byte/entry
field đổi: MEMORY USAGE=5403061 54.0 byte/entry
### XDEL 90% entry (rải rác) vs XTRIM
trước: del XLEN=100000 MEMORY USAGE=1336227
XDEL 90 000 entry: XLEN=10000 MEMORY USAGE=1336227 ← entry chỉ bị đánh dấu xoá, node vẫn còn
XTRIM trm MINID 90001: xoá 90000 entry → XLEN=10000 MEMORY USAGE=137528 ← xoá nguyên node, trả RAM
XINFO STREAM del: length=10000 radix-tree-nodes=2394 max-deleted-entry-id=99999-0 entries-added=100000
Stream là radix tree chỉ mục theo ID đầu của mỗi node; mỗi node là một listpack tối đa 100 entry / 4 KB (stream-node-max-entries, stream-node-max-bytes).
Trong node, ID lưu dạng chênh lệch với ID đầu node, và entry nào có cùng bộ tên field với "master entry" của node thì chỉ lưu value — nên đổi tên field theo entry làm RAM gấp
đôi (27,7 → 54,0 byte). XDEL chỉ bật cờ "đã xoá" trong listpack: RAM chỉ được trả khi cả node rỗng — nên dọn bằng trim (MINID/MAXLEN), không bằng xoá lẻ.
3.3 Consumer group và PEL
### group 'mail', 10 entry; c1 đọc 5 rồi chết (không XACK); c2 đọc 5 và XACK (06c-groups.txt)
c1: XREADGROUP COUNT 5 > → 1-0 2-0 3-0 4-0 5-0
c2: XREADGROUP COUNT 5 > → 6-0 7-0 8-0 9-0 10-0
XPENDING orders mail → 5 1-0 5-0 c1 5
XPENDING orders mail - + 10 (id, consumer, idle ms, số lần giao):
1-0 c1 391 1
…
c2 đọc '>' lần nữa → 1 dòng (entry của c1 không được giao lại tự động)
1,2 giây sau: XAUTOCLAIM orders mail c2 1000 0-0 COUNT 10 →
0-0 1-0 order 1 2-0 order 2 3-0 order 3 4-0 order 4 5-0 order 5
XPENDING sau khi claim:
1-0 c2 95 2
…
XINFO CONSUMERS (c1 đã chết vẫn còn):
name c1 pending 0 idle 2168 inactive 2168
> chỉ trả entry mới; entry đã giao mà chưa ack nằm trong PEL của consumer cũ mãi mãi, cho tới khi ai đó claim. Docs gọi đây là at-least-once:
không mất, nhưng có thể xử lý hai lần. Consumer chết không tự biến mất khỏi group (XINFO CONSUMERS vẫn thấy) — phải XGROUP DELCONSUMER sau khi đã claim hết PEL của nó.3.4 Đọc kèm nhận lại (8.4) và trả entry về ngay (8.8)
### 8.4: c1 giữ 1-0..3-0 rồi treo; có thêm 4-0 mới. c2: XREADGROUP … COUNT 10 CLAIM 500 STREAMS jobs > (06d-claim.txt)
jobs 1-0 n 1 815 1 2-0 n 2 815 1 3-0 n 3 815 1 4-0 n 4 0 0
(entry claim kèm: số ms đã treo, số lần đã giao — đọc cùng lượt với entry mới 4-0)
### 8.8: XNACK — trả entry về group ngay, không chờ hết thời gian treo
XNACK jobs g SILENT IDS 1 5-0 → 1 (tắt máy: trả lại, không tính là lần giao hỏng)
XNACK jobs g FAIL IDS 1 6-0 → 1 (mình không xử lý được, người khác thử)
XNACK jobs g FATAL IDS 1 7-0 → 1 (độc: đánh dấu hỏng hẳn)
XPENDING jobs g - + 10:
5-0 -1 0
6-0 -1 1
7-0 -1 9223372036854775807
c4: XREADGROUP … COUNT 10 CLAIM 100000 STREAMS jobs > (ngưỡng treo rất lớn) →
jobs 5-0 n 5 1791015200017 0 6-0 n 6 1791015200017 1 7-0 n 7 1791015200017 9223372036854775807
← entry bị XNACK được giao ngay (thời điểm giao đặt về 0), không cần chờ 100 giây
CLAIM gộp vòng lặp "claim entry treo, rồi đọc entry mới" vào một lệnh — entry treo được trả trước, kèm thời gian treo và số lần giao để consumer tự quyết chuyển sang DLQ.
XNACK giải chuyện "consumer biết mình sắp dừng nhưng entry vẫn phải chờ hết min-idle": entry về "vùng NACK" ở đầu PEL, chủ để trống, được giao ngay. Hai chi tiết
lab cho thấy: FATAL đặt số lần giao = 2⁶³−1 (dùng làm dấu hiệu "độc"); và vì thời điểm giao bị đặt về 0, cột "đã treo" hiện ra là số mili-giây tính từ 1970 — code nào dùng
nó để tính thời gian xử lý sẽ nhận số vô nghĩa.
3.5 Trim khi có group chậm: KEEPREF, ACKED, DELREF (8.2)
### XADD … KEEPREF MAXLEN ~ 1000, 10 000 entry; group 'slow' chết sau khi đọc 100 entry đầu (chưa ack) (06e-trim.txt)
XLEN=1000 MEMORY USAGE=28205 | group slow: pending=100 last-delivered-id=1791015237245-99 entries-read=100 lag=1000
slow (consumer mới s2) đọc '>' → nhận 1000 entry (sinh ra sau 100 entry đầu: 9 900)
XAUTOCLAIM ev slow s2 0 0-0 COUNT 100 JUSTID → claim 0 id, 100 id trong danh sách "đã bị xoá khỏi stream"
### XADD … ACKED MAXLEN ~ 1000, 10 000 entry; group 'slow' chết sau khi đọc 100 entry đầu (chưa ack)
XLEN=10000 MEMORY USAGE=141257 | group slow: pending=100 last-delivered-id=1791015238861-83 entries-read=100 lag=9900
slow (consumer mới s2) đọc '>' → nhận 9900 entry (sinh ra sau 100 entry đầu: 9 900)
XAUTOCLAIM ev slow s2 0 0-0 COUNT 100 JUSTID → claim 100 id, 0 id trong danh sách "đã bị xoá khỏi stream"
### XADD … DELREF MAXLEN ~ 1000, 10 000 entry; group 'slow' chết sau khi đọc 100 entry đầu (chưa ack)
XLEN=1000 MEMORY USAGE=13907 | group slow: pending=0 last-delivered-id=1791015244629-99 entries-read=100 lag=1000
slow (consumer mới s2) đọc '>' → nhận 1000 entry (sinh ra sau 100 entry đầu: 9 900)
KEEPREF) cắt theo độ dài, không quan tâm group nào đã đọc: group chậm mất dữ liệu im lặng, và — điểm nguy hiểm nhất trong lab —
XINFO GROUPS báo lag=1000 chứ không phải 9 900, nên dashboard theo dõi lag không thấy mất mát. ACKED không mất nhưng đổi sang rủi ro RAM: một group chết là
stream không bao giờ được cắt. DELREF cắt và xoá luôn tham chiếu trong PEL (lab: pending=0) — gọn nhưng mất cả dấu vết.3.6 Producer gửi lại: IDMP (8.6)
### producer gửi lại sau timeout (không có IDMP) (06f-idmp.txt)
XADD pay * order 9 amount 100 → 1791015217480-0; gửi lại → 1791015217583-0; XLEN=2
### IDMP producer-id idempotent-id (8.6)
lần 1 → 1791015217825-0; gửi lại → 1791015217825-0; XLEN=1
cùng iid nhưng nội dung khác → 1791015217825-0 (vẫn trả ID cũ, nội dung mới bị bỏ)
XINFO STREAM pay2: idmp-duration=100 idmp-maxsize=100 pids-tracked=1 iids-tracked=1 iids-added=1 iids-duplicates=2
### cửa sổ chống trùng có hạn
IDMP-DURATION 1 giây, 2 giây sau gửi lại order-9 → 1791015220423-0; XLEN=2 ← trùng lọt
IDMP-MAXSIZE 3: gửi o-1…o-5, rồi gửi lại o-1 → XLEN trước 7, sau 8 ← o-1 đã bị đẩy khỏi bộ nhớ chống trùng
### IDMPAUTO: Redis tự băm nội dung
XADD pay3 IDMPAUTO p1 * order 9 → 1791015221461-0; lặp lại → 1791015221461-0; khác nội dung → 1791015221645-0
Timeout mạng sau khi XADD đã thành công là kịch bản kinh điển của "gửi lại tạo bản trùng". IDMP producer iid giữ một bảng (producer, iid) → entry ID, nên gửi lại trả
về entry cũ. Ba giới hạn đo được: chỉ nhớ IDMP-DURATION giây (mặc định 100, tối đa 86 400) và IDMP-MAXSIZE iid mới nhất mỗi producer (mặc định 100, tối đa 10 000);
cùng iid mà nội dung khác thì nội dung mới bị bỏ im lặng; đây là at-most-once phía producer trong cửa sổ, không phải exactly-once đầu–cuối.
3.7 Failover: mất entry đã OK, và giao lại entry đã xử lý
1. master: 150 entry; c1 đọc + ack 1-0…100-0; WAIT 1 1000 → 1 replica xác nhận (06g-failover.txt)
replica: XLEN=150, last-delivered-id=100-0
2. cắt mạng tới replica
3. master: c1 đọc + xử lý + ack 101-0…150-0 (đã gửi email cho 50 đơn)
4. master: producer XADD 151-0…170-0, nhận OK cho cả 20 → XLEN master=170
5. master chết; replica lên làm master: REPLICAOF NO ONE → OK
master mới: XLEN=150 (mất 20 entry đã trả OK), last-delivered-id=100-0
c1 đọc '>' trên master mới → nhận 50 entry: 101-0…150-0 GIAO LẠI (email gửi lần 2 nếu không idempotent)
WAIT để thu hẹp cửa sổ.4. Đảm bảo và giới hạn
| Khía cạnh | Đảm bảo | Giới hạn |
|---|---|---|
| Thứ tự | Trong một stream: tổng thứ tự theo ID, ID luôn tăng | Không có thứ tự giữa các stream; trong một group, nhiều consumer xử lý song song → thứ tự xử lý không bảo đảm (cách xử lý và trade-off: Thứ tự xử lý background job bằng Stream) |
| Giao nhận | Với group + ack: at-least-once (không mất khi consumer chết) | Trùng khi consumer chết sau xử lý trước ack, khi claim, khi failover; NOACK thì at-most-once |
| Producer | IDMP (8.6): không trùng trong cửa sổ | Cửa sổ 1–86 400 giây, 1–10 000 iid/producer; ngoài cửa sổ vẫn trùng |
| Bền vững | Theo RDB/AOF | Failover mất entry đã OK và lùi trạng thái group (lab) |
| Kích thước | Log ~19–28 byte/entry nhỏ (lab) | Toàn bộ trong RAM, một key = một node; trim là việc của bạn |
| Độ phức tạp | XADD O(1); XRANGE O(log N + M) | XRANGE - + 1 triệu entry: 470 ms (bài 1) |
Khung lý thuyết: exactly-once giữa hai hệ thống qua mạng không đạt được chỉ bằng giao thức gửi/ack — chỉ có "at-least-once + xử lý idempotent" (hoặc transaction bao cả xử lý lẫn ack, mà Redis và DB của bạn không chung transaction). Theo PACELC, stream kế thừa PA/EL của replication Redis (bài 1 mục 4).
5. Failure modes
| # | Tình huống | Hậu quả (đo) | Phát hiện | Chữa / phòng |
|---|---|---|---|---|
| 1 | Consumer chết giữa chừng, không ai claim | Entry treo trong PEL mãi; khách không nhận email | XPENDING tổng > 0 kéo dài; idle tối đa tăng | XAUTOCLAIM/CLAIM định kỳ; XNACK khi tắt máy có kiểm soát |
| 2 | Chết sau khi làm side effect, trước khi ack | Không idempotent: 1/199 đơn gửi 2 lần (lab Laravel) | Khách báo trùng | Khoá idempotency theo entry ID / ID nghiệp vụ (SET … NX, unique trong DB) |
| 3 | Entry độc, không ai ack | Được claim mãi, chiếm worker | Số lần giao tăng (XPENDING cột 4) | Ngưỡng số lần giao → DLQ + ack; XNACK FATAL |
| 4 | MAXLEN với group chậm/chết | Mất 8 900/9 900 entry chưa đọc, lag báo 1 000 | Rất khó: so entries-added với entries-read và ID đầu stream | ACKED + cảnh báo group chết; hoặc MINID theo thời gian giữ cam kết |
| 5 | ACKED với group chết | Không trim được: 10 000 thay vì 1 000 entry, RAM 5 lần | XLEN tăng mãi | Xoá group không còn dùng; alert XLEN |
| 6 | Dọn bằng XDEL | RAM không giảm (1 336 227 → 1 336 227) | MEMORY USAGE | Trim theo MINID |
| 7 | Producer gửi lại sau timeout | Entry trùng | Hai entry cùng ID nghiệp vụ | IDMP (8.6) + consumer idempotent cho phần ngoài cửa sổ |
| 8 | Failover | Mất 20 entry đã OK; giao lại 50 entry đã xử lý | Đối soát với nguồn | Outbox ở DB, consumer idempotent, WAIT cho sự kiện quan trọng |
| 9 | ID tự đặt theo giờ app lệch | ID "kẹt" ở tương lai; truy vấn theo thời gian trả rỗng | XRANGE theo giờ ra 0 | Để Redis sinh ID; giờ nghiệp vụ trong field |
| 10 | Tên consumer ngẫu nhiên mỗi lần restart (hostname + pid) | Consumer "ma" tích tụ, PEL của chúng không ai lấy | XINFO CONSUMERS dài dần | Tên ổn định theo slot/pod; claim rồi DELCONSUMER |
6. Vận hành trong production
- Metric bắt buộc:
XLEN,MEMORY USAGEcủa stream; mỗi group:pending,lag,entries-read; trong PEL: idle lớn nhất và số lần giao lớn nhất (XPENDING key group IDLE 60000 - + 10); số consumer (XINFO CONSUMERS); với IDMP:iids-duplicates. Đừng tinlagmột mình (mục 3.5). - Chính sách giữ: giữ theo thời gian (
MINID= giờ hiện tại − N giờ) dễ cam kết hơn theo số lượng; quyết định KEEPREF/ACKED/DELREF theo việc group chậm được phép mất hay không. - Capacity — ví dụ minh hoạ: 500 sự kiện đơn hàng/giây, giữ 24 giờ, mỗi entry ~200 byte payload + ~30 byte cấu trúc → 43,2 triệu entry ≈ 10 GB. Quá nhiều cho cache Redis — hoặc giảm thời gian giữ, hoặc chuyển dữ liệu lâu sang nơi rẻ (Kafka, object storage).
- Cấu hình:
stream-node-max-entries 100,stream-node-max-bytes 4096(node lớn hơn = gọn hơn nhưng trim/xoá thô hơn);IDMP-DURATION,IDMP-MAXSIZEquaXCFGSETtheo từng stream. - Laravel: không có queue driver chạy trên stream — consumer là một artisan command chạy lâu (supervisor), tự lo claim, DLQ, idempotency, tắt máy êm (
XNACK SILENTkhi nhận SIGTERM). phpredis 6.3: cóxReadGroup/xAck/xAutoClaim, chưa cóXNACK,XACKDEL,XDELEX,XCFGSET, tham số IDMP →executeRawvà tự thêm prefix (bài 2);xReadGroupmặc định chỉ đọc 1 entry mỗi lần và trả tên stream có prefix. - Debug lúc 3 giờ sáng: "email không gửi" →
XPENDINGcó entry treo của consumer đã chết?; "gửi trùng" → có failover/claim gần đây không, consumer có bước idempotent không; "Redis đầy RAM" →XLENstream nào lớn, group nào chết làmACKEDkhông cắt được.
6.1 Consumer Laravel (rút gọn từ labs/redis/laravel/06-consumer.php, đã chạy thật)
use Illuminate\Support\Facades\Redis;
// mỗi vòng: nhận lại entry treo > 30 giây, rồi mới đọc entry mới
[, $claimed] = array_pad(Redis::xautoclaim('events:order', 'mail', $me, 30000, '0-0', 50), 2, []);
$batch = $claimed ?: (Redis::xreadgroup('mail', $me, ['events:order' => '>'], 10, 2000)
[$prefix . 'events:order'] ?? []); // key trả về CÓ prefix; count mặc định của phpredis = 1
foreach ($batch as $id => $f) {
if (! handle($id, $f, $me)) { // lỗi → không ack, để claim lại lần sau
[$p] = Redis::xpending('events:order', 'mail', $id, $id, 1);
if ($p[3] >= 3) { // đã giao ≥ 3 lần → dead letter
Redis::xadd('events:order:dlq', '*', $f + ['src_id' => $id]);
Redis::xack('events:order', 'mail', [$id]);
}
continue;
}
Redis::xack('events:order', 'mail', [$id]);
}
function handle(string $id, array $f, string $me): bool
{
if ($f['kind'] === 'bad') return false; // lab: entry độc luôn lỗi
if (Redis::set("sent:$id", $me, 'EX', 86400, 'NX')) { // idempotent: entry này đã gửi chưa?
Redis::hincrby('emails', $f['order'], 1); // lab: thay cho Mail::send(...)
}
return true;
}
### consumer CÓ bước idempotent (SET sent:<id> NX) (06h-laravel.txt)
producer: 200 sự kiện đơn hàng (o77 là entry độc)
A: {"delivered":26,"sent":26,"skipped_dup":0,"dead":0,"claimed":0,"crashed_at":"1791015323203-17"} ← chết SAU khi gửi, TRƯỚC khi ack
XPENDING sau khi A chết: [5,"1791015323203-17","1791015323203-21",[["A","5"]]]
B: {"delivered":177,"sent":173,"skipped_dup":1,"dead":1,"claimed":7}
đơn được gửi email: 199 / 199 | đơn bị gửi >1 lần: 0 | dead letter: 1 | XPENDING cuối: [0,false,false,[]]
### consumer KHÔNG có bước idempotent
B: {"delivered":177,"sent":174,"skipped_dup":0,"dead":1,"claimed":7}
đơn được gửi email: 199 / 199 | đơn bị gửi >1 lần: 1 | dead letter: 1 | XPENDING cuối: [0,false,false,[]]
Bẫy của đoạn code: khoá sent:$id theo entry ID không chặn được trùng do producer gửi lại (entry ID khác) — chặn đầy đủ thì khoá theo ID nghiệp vụ (mã đơn);
SET NX rồi mới gửi mail mà mail lỗi thì đơn đó bị đánh dấu "đã gửi" — đặt khoá sau khi gửi thành công, hoặc dùng trạng thái 3 bước (đang gửi / đã gửi); khoá nằm trong
Redis cũng mất được khi failover (bài idempotency key).
7. So sánh và ma trận quyết định
| Trục | Pub/Sub | List queue | Stream | Kafka | RabbitMQ |
|---|---|---|---|---|---|
| Lưu khi không ai nghe | Không | Có | Có | Có | Có (queue) |
| Nhiều nhóm độc lập | Ai cũng nhận | Không | Có (group) | Có | Có (exchange) |
| Ack / giao lại | Không | Tự làm (bài 4) | Có (PEL, claim, NACK) | Offset | Có |
| Đọc lại lịch sử | Không | Không | Có (theo ID) | Có (lâu dài) | Hạn chế (streams plugin) |
| Song song hoá | — | Nhiều consumer | Nhiều consumer/group, một key | Partition | Nhiều queue/consumer |
| Giữ lâu / khối lượng | — | RAM | RAM | Đĩa, rất lớn | Đĩa/RAM |
| Vận hành | Có sẵn | Có sẵn | Có sẵn | Cụm riêng | Cụm riêng |
Dùng stream khi: đã có Redis, sự kiện vừa phải, giữ ngắn, cần vài group đọc độc lập có ack và đọc lại gần đây (sự kiện đơn hàng → email + thống kê + webhook). Tránh khi: cần giữ dài và khối lượng lớn, cần partition để song song hoá theo khoá, cần đảm bảo không mất khi failover (dùng Kafka/RabbitMQ bền, hoặc outbox trong DB làm nguồn), hoặc team không có người vận hành PEL/trim.
8. Góc nhìn tech lead: tác động tổ chức
- Stream là hạ tầng, không phải thư viện: cần thư viện consumer chung (claim, DLQ, idempotency, NACK khi tắt, metric) thay vì mỗi team tự viết vòng lặp
XREADGROUP. Không có cái đó thì mỗi bug ở mục 5 sẽ được viết lại nhiều lần. - ADR phải ghi: đảm bảo giao nhận (at-least-once), chính sách giữ (thời gian,
KEEPREF/ACKED), cách xử lý group chậm, nơi lưu nguồn sự thật (outbox), cách dựng lại khi mất stream. - Exit strategy: payload là JSON thuần với ID nghiệp vụ; consumer viết sau một interface "event source" — đổi sang Kafka là đổi adapter. Tính năng 8.2–8.8 (
ACKED,CLAIM,IDMP,XNACK) không có trên Valkey 9.1.2 (lab bài 1 chỉ thửXADD … IDMP: lỗi) — dùng thì đặt sau adapter. - Người trực cần biết: đọc
XINFO GROUPS/CONSUMERS,XPENDING, claim tay, chuyển DLQ, và hiểu vì saolagkhông nói hết sự thật.
9. Anti-patterns và bài học thực tế
- Ack trước khi xử lý "cho nhanh": biến at-least-once thành at-most-once.
- Không claim: entry của consumer chết nằm im vĩnh viễn.
MAXLENcố định cho mọi stream: group chậm mất dữ liệu vào giờ cao điểm — đúng lúc khó phát hiện nhất.- Dùng stream như queue một group nhưng mỗi worker một group: mọi worker cùng xử lý mọi entry (fan-out thay vì chia việc).
- Payload to: cả object đơn hàng 20 KB trong entry → RAM và băng thông; chỉ gửi ID + vài field, consumer tự đọc DB.
- Stream là nguồn sự thật duy nhất của sự kiện tài chính: failover mất entry đã OK (lab).
Nguồn công khai: tài liệu Redis về consumer group và mục "Recovering from permanent failures"; release notes 8.2/8.6 mô tả chính các vấn đề trim và gửi trùng mà
ACKED/IDMP giải. Chưa tìm được postmortem công khai cụ thể về stream bị trim mất — số liệu ở đây là lab tự dựng.
Điều docs không nói rõ: lag hiển thị sai sau khi trim cắt qua con trỏ group; idle của entry bị XNACK là số ms tính từ 1970; phpredis trả key
stream có prefix và đọc mặc định 1 entry.
10. Câu hỏi phụ interviewer hay hỏi tiếp
- Stream khác list và Pub/Sub thế nào?
- Pub/Sub không lưu; list lưu nhưng lấy là xoá, một nhóm, không ack; stream lưu như log, nhiều group, có PEL/ack/claim, đọc lại theo ID.
- PEL là gì, consumer chết thì sao?
- Danh sách entry đã giao chưa ack (consumer, giờ giao, số lần). Consumer chết → entry treo cho tới khi
XAUTOCLAIM/XREADGROUP … CLAIM; tắt máy có kiểm soát thìXNACK SILENT. - Stream có exactly-once không?
- Không. At-least-once + consumer idempotent (khoá theo ID nghiệp vụ).
IDMPchỉ chống trùng phía producer trong cửa sổ. - Trim thế nào cho an toàn?
- Theo thời gian (
MINID) với cam kết rõ; nếu group chậm không được mất thìACKED+ cảnh báo group chết; theo dõi cảentries-read, không chỉlag. - Xử lý poison message?
- Đếm số lần giao trong PEL; vượt ngưỡng → chuyển DLQ + ack (hoặc
XNACK FATALđể đánh dấu); có người xem DLQ. - Failover ảnh hưởng gì?
- Mất entry đã trả OK và con trỏ/ack lùi lại → giao lại (lab: mất 20, giao lại 50). Phòng: outbox làm nguồn, consumer idempotent,
WAIT. - Khi nào chọn Kafka thay vì Redis Stream?
- Khối lượng lớn, giữ lâu, cần partition, cần bền khi node chết, nhiều team dùng chung như backbone sự kiện.
Phụ lục: lab đã chạy
Môi trường: Redis 8.8.0 (redis:alpine); phần failover dùng 2 container tạm tmp-rdt-sm (master) và tmp-rdt-sr (replica) trên
tmp-rdt-net, cắt mạng bằng docker network disconnect, promote bằng tay; PHP 8.3.33 + phpredis 6.3.0 + Laravel 12.64 (project laravel-livewire mount read-only,
REDIS_PREFIX=lab:).
cd labs/redis
./06-stream.sh # chạy hết → out/06a … 06h
./06-stream.sh failover # 1 phần: basics | memory | groups | claim | trim | idmp | failover | laravel
Chưa verify: failover do Sentinel/Cluster tự làm (lab promote bằng tay); IDMP có được sao chép sang replica và còn hiệu lực sau failover không; hiệu năng
XADD/XREADGROUP ở tải cao; trên Valkey mới thử XADD … IDMP (lỗi), các lệnh/tham số 8.2+ khác chưa thử.