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-id riêng) · PEL (pending entries list — các entry đã giao nhưng chưa XACK, 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ài MAXLEN hoặc ID MINID) · 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 (XADD gồm IDMP/IDMPAUTO, KEEPREF/DELREF/ACKED; XCFGSET, XAUTOCLAIM, XREADGROUP mụ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 DOCS củ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ác XAUTOCLAIM (hoặc XREADGROUP … CLAIM từ 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) MAXLEN trim cắt cả entry group chậm chưa đọc — lab mất 8 900/9 900 entry mà lag vẫn báo 1 000; ACKED (8.2) giữ lại nhưng stream phình theo group chậm nhất; (2) XDEL khô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
Consumer group: giao, PEL, consumer chết, XAUTOCLAIM Sáu entry được thêm vào stream. Con trỏ của group tiến dần. Entry 1 và 2 giao cho c1, entry 3 và 4 giao cho c2; tất cả nằm trong PEL. c2 ack 3 và 4 nên chúng rời PEL. c1 chết; entry 1 và 2 treo trong PEL với thời gian chờ tăng dần. c2 dùng XAUTOCLAIM nhận 1 và 2 với số lần giao bằng 2, rồi ack. Stream "events" + group "mail" 1 2 3 4 5 6 last-delivered consumer c1 ✕ consumer c2 1, 2 3, 4 PEL của group (id · consumer · số lần giao) 1 c1 lần 12 c1 lần 1 3 c2 lần 14 c2 lần 1 1 c2 lần 22 c2 lần 2 c2: XACK 3 4 → rời PEL 1, 2 treo: idle 5s … 30s … không ai giao lại c2: XAUTOCLAIM min-idle 30s → nhận 1, 2 (lần giao 2) c2: XACK 1 2 → PEL rỗng c1 có thể đã gửi email cho đơn 1 trước khi chết → c2 phải kiểm "đã gửi chưa" (idempotent)
Hình 1 — Đọc bằng > 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)
Trim khi có group chậm Hai thanh biểu diễn stream 10 000 entry. Hàng trên KEEPREF: đầu ghi chạy sang phải, cửa sổ 1 000 entry cuối được giữ, phần phía trước bị cắt dần, trong đó có 8 900 entry group slow chưa đọc. Hàng dưới ACKED: không cắt được vì group slow chưa ack, stream dài ra hết 10 000 entry. 10 000 lần XADD … MAXLEN ~ 1000; group "slow" dừng ở entry 100 KEEPREF (mặc định) slow dừng ở 100 8 900 entry slow chưa đọc đã bị cắt — XINFO lag vẫn báo 1 000 ACKED (8.2) slow dừng ở 100 không cắt được entry chưa ack: XLEN 10 000, RAM 141 KB thay vì 28 KB — group chết là stream phình mãi
Hình 2 — Trim mặc định (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)
Failover làm mất entry và lùi con trỏ group Master có log 1 tới 170 và con trỏ group ở 150; replica có log 1 tới 150 và con trỏ ở 100 vì mạng bị cắt. Master chết, replica lên làm master: entry 151 tới 170 mất dù producer đã nhận OK; con trỏ lùi về 100 nên entry 101 tới 150 được giao lại. Replication bất đồng bộ: log và trạng thái group cùng lag master 1 … 150151…170 group: 150 ✕ chết replica 1 … 150 group: 100 ← đã sao chép tới WAIT ✂ mạng tới replica bị cắt ack 101–150, XADD 151–170 chỉ ở master replica lên master: 151–170 mất (producer đã nhận OK) con trỏ lùi về 100 → 101–150 giao lại cho consumer (lab: 50 entry) ack cũng là một lệnh ghi được sao chép bất đồng bộ — mất ack = xử lý lại
Hình 3 — Con trỏ group và PEL là dữ liệu trong key stream, sao chép như mọi lệnh ghi. Lab: sau failover, 20 entry producer đã nhận OK biến mất và 50 entry đã xử lý + ack bị giao lại. Không có cấu hình nào của stream chặn được cả hai — chỉ có: consumer idempotent, producer có thể gửi lại từ nguồn (outbox), và WAIT để thu hẹp cửa sổ.

4. Đảm bảo và giới hạn

Khía cạnhĐảm bảoGiới hạn
Thứ tựTrong một stream: tổng thứ tự theo ID, ID luôn tăngKhô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ậnVớ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
ProducerIDMP (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ữngTheo RDB/AOFFailover mất entry đã OK và lùi trạng thái group (lab)
Kích thướcLog ~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ạpXADD 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ốngHậu quả (đo)Phát hiệnChữa / phòng
1Consumer chết giữa chừng, không ai claimEntry treo trong PEL mãi; khách không nhận emailXPENDING tổng > 0 kéo dài; idle tối đa tăngXAUTOCLAIM/CLAIM định kỳ; XNACK khi tắt máy có kiểm soát
2Chết sau khi làm side effect, trước khi ackKhông idempotent: 1/199 đơn gửi 2 lần (lab Laravel)Khách báo trùngKhoá idempotency theo entry ID / ID nghiệp vụ (SET … NX, unique trong DB)
3Entry độc, không ai ackĐược claim mãi, chiếm workerSố lần giao tăng (XPENDING cột 4)Ngưỡng số lần giao → DLQ + ack; XNACK FATAL
4MAXLEN với group chậm/chếtMất 8 900/9 900 entry chưa đọc, lag báo 1 000Rất khó: so entries-added với entries-read và ID đầu streamACKED + cảnh báo group chết; hoặc MINID theo thời gian giữ cam kết
5ACKED với group chếtKhông trim được: 10 000 thay vì 1 000 entry, RAM 5 lầnXLEN tăng mãiXoá group không còn dùng; alert XLEN
6Dọn bằng XDELRAM không giảm (1 336 227 → 1 336 227)MEMORY USAGETrim theo MINID
7Producer gửi lại sau timeoutEntry trùngHai entry cùng ID nghiệp vụIDMP (8.6) + consumer idempotent cho phần ngoài cửa sổ
8FailoverMất 20 entry đã OK; giao lại 50 entry đã xử lýĐối soát với nguồnOutbox ở DB, consumer idempotent, WAIT cho sự kiện quan trọng
9ID tự đặt theo giờ app lệchID "kẹt" ở tương lai; truy vấn theo thời gian trả rỗngXRANGE theo giờ ra 0Để Redis sinh ID; giờ nghiệp vụ trong field
10Tê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ấyXINFO CONSUMERS dài dầnTên ổn định theo slot/pod; claim rồi DELCONSUMER

6. Vận hành trong production

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ụcPub/SubList queueStreamKafkaRabbitMQ
Lưu khi không ai ngheKhôngCóCóCóCó (queue)
Nhiều nhóm độc lậpAi cũng nhậnKhôngCó (group)CóCó (exchange)
Ack / giao lạiKhôngTự làm (bài 4)Có (PEL, claim, NACK)OffsetCó
Đọc lại lịch sửKhôngKhôngCó (theo ID)Có (lâu dài)Hạn chế (streams plugin)
Song song hoá—Nhiều consumerNhiều consumer/group, một keyPartitionNhiều queue/consumer
Giữ lâu / khối lượng—RAMRAMĐĩa, rất lớnĐĩa/RAM
Vận hànhCó sẵnCó sẵnCó sẵnCụm riêngCụ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

9. Anti-patterns và bài học thực tế

  1. Ack trước khi xử lý "cho nhanh": biến at-least-once thành at-most-once.
  2. Không claim: entry của consumer chết nằm im vĩnh viễn.
  3. MAXLEN cố đị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.
  4. 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).
  5. 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.
  6. 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ụ). IDMP chỉ 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ử.