Lưu idempotency key ở Redis
Bài Chống double-click và duplicate request mô tả bốn
tầng phòng thủ, trong đó tầng 2 — idempotency key được cài bằng một bảng
PostgreSQL tên idempotency_keys. Ngoài thực tế rất nhiều đội lưu key đó ở
Redis thay vì bảng. Bài này trả lời hai câu: cơ chế chạy thế nào và
đặt tên key Redis ra sao — cùng với chỗ nó gãy.
Đọc bài kia trước. Bài này chỉ thay chỗ lưu của tầng 2. Mọi thứ khác — vì sao cần key, client sinh key lúc nào, vì sao tầng 4 (unique index) không bao giờ được bỏ — nằm ở bài gốc và vẫn đúng nguyên vẹn.
Thuật ngữ
| Từ | Nghĩa |
|---|---|
| atomic | Nguyên tử — lệnh chạy trọn vẹn, không ai chen được vào giữa |
NX / XX | Tuỳ chọn của lệnh SET: chỉ ghi khi key chưa có (NX) / chỉ ghi khi key đã có (XX) |
GET | Tuỳ chọn của SET: trả về giá trị cũ của key trong cùng một lệnh |
KEEPTTL | Ghi đè giá trị nhưng giữ nguyên hạn sống cũ, không gia hạn |
| eviction | Đuổi key — Redis đầy RAM thì tự xoá bớt key để lấy chỗ, kể cả key chưa hết hạn |
maxmemory-policy | Luật chọn key nào bị đuổi: allkeys-lru, volatile-lru, noeviction… |
| RDB / AOF | Hai cách Redis ghi dữ liệu xuống đĩa: snapshot theo chu kỳ (RDB) và ghi nối từng lệnh (AOF) |
| hash tag | Phần trong {} của tên key — Redis Cluster chỉ băm phần này để chọn node |
Môi trường kiểm chứng. Mọi output trong bài chạy thật trên Redis 8.8.0 (container
chiennn_redis), phpredis 6.3.0, và PostgreSQL 18.4 (my-test-pgdb). Chỗ nào chưa verify được sẽ ghi rõ.
1. Nó là gì / chạy thế nào
Bản chất giống hệt bảng idempotency_keys: một máy trạng thái 2 pha
cho mỗi key. Khác biệt duy nhất là ở Redis, cả hai pha gói gọn trong một lệnh mỗi pha.
flowchart TD R["Request toi
Idempotency-Key: 8f3a"] --> S["SET idem:v1:7:8f3a
status=in_progress
NX GET EX 60"] S -->|"tra ve nil
= TOI la nguoi dau tien"| W["Chay business logic"] S -->|"tra ve gia tri cu
= da co nguoi khac"| CHK{"Doc value cu"} CHK -->|"hash khac"| E422["422
Key da dung cho payload khac"] CHK -->|"status = in_progress"| E409["409
Dang xu ly, thu lai sau"] CHK -->|"status = done"| REPLAY["Tra lai y nguyen
response cu"] W --> OK["SET idem:v1:7:8f3a
status=done + code + body
XX EX 86400"] W -->|"exception"| DEL["DEL key
de client retry duoc"]
Toàn bộ mẹo nằm ở một lệnh SET ... NX GET: vừa chiếm chỗ vừa đọc
giá trị cũ, atomic. Không có GET thì phải làm hai round-trip và dính race.
GET/EXAT/PXAT có từ Redis 6.2.0,
KEEPTTL từ 6.0.0 (tra Context7, kho redis/docs, file
content/commands/set.md). Redis cũ hơn phải dùng Lua — xem mục 1.3.
1.1. Kiểm chứng: hai request tranh nhau một key
$ redis-cli SET idem:demo:k1 '{"status":"in_progress"}' NX GET EX 60
A: (nil) # request 1: khong co gia tri cu -> TOI la nguoi dau tien
B: {"status":"in_progress"} # request 2: doc duoc state cua thang kia
$ redis-cli TTL idem:demo:k1
59 # request 2 KHONG reset TTL, nho NX
Sau khi business logic xong, ghi kết quả. Ví dụ với XX KEEPTTL (giữ nguyên hạn cũ):
$ redis-cli SET idem:demo:k1 '{"status":"done","code":201,"body":"{\"order_id\":9}"}' XX KEEPTTL
OK
$ redis-cli TTL idem:demo:k1
59 # KEEPTTL giu nguyen han cu, khong gia han them
$ redis-cli GET idem:demo:k1
{"status":"done","code":201,"body":"{\"order_id\":9}"}
Pha 2 dùng
KEEPTTLhayEX? Tuỳ bạn chọn TTL một mức hay hai mức:
- Một mức (pha 1 đã đặt
EX 86400) → pha 2 phải dùngKEEPTTL. Nếu ghiEX 86400lần nữa thì mỗi lần replay lại đẩy hạn ra thêm 24 giờ; client nào retry đều đặn sẽ giữ key sống mãi — rò rỉ RAM chậm và khó thấy.- Hai mức (pha 1
EX 60, xem mục 4.4) → pha 2 phải dùngEX 86400để kéo hạn ra. DùngKEEPTTLở đây là key chết sau 60 giây, mất luôn khả năng replay.Bài này chọn hai mức, vì nó tự dọn được key kẹt
in_progresskhi process chết. LệnhSET ... XX EX 86400vẫn ghi đè giá trị và đặt lại hạn trong một lệnh, không cầnEXPIREriêng.
1.2. Code Laravel tối thiểu
// app/Http/Middleware/IdempotentRedis.php
use Illuminate\Support\Facades\Redis;
public function handle(Request $request, Closure $next)
{
$key = $request->header('Idempotency-Key');
if (! $key) {
return $next($request); // hoac abort(400) neu API bat buoc
}
$redis = Redis::connection('idempotency'); // connection RIENG - xem muc 4.1
$hash = hash('xxh128', $request->getContent());
$k = sprintf('idem:v1:%d:%s', $request->user()->id, $key);
// 1 lenh: chiem cho + doc state cu.
// TTL ngan o pha 1 (60s) de process chet thi key tu nha - xem muc 4.4
$old = $redis->set($k, json_encode(['status' => 'in_progress', 'hash' => $hash]),
['NX', 'GET', 'EX' => 60]);
if ($old !== false) { // phpredis tra ve false khi NX thanh cong
$prev = json_decode($old, true);
// Cung key nhung payload khac = client dung sai. Phai bao loi, khong duoc im lang.
abort_if($prev['hash'] !== $hash, 422, 'Idempotency-Key da dung cho request khac');
abort_if($prev['status'] === 'in_progress', 409, 'Dang xu ly, thu lai sau');
return response($prev['body'], $prev['code']);
}
try {
$response = $next($request);
} catch (\Throwable $e) {
$redis->del($k); // loi -> nha key de client retry duoc
throw $e;
}
// Xong viec moi keo TTL ra 24h
$redis->set($k, json_encode([
'status' => 'done',
'hash' => $hash,
'code' => $response->getStatusCode(),
'body' => $response->getContent(),
]), ['XX', 'EX' => 86400]);
return $response;
}
Kiểm chứng thật giá trị trả về của phpredis 6.3.0 (chạy trong container laravel-app):
$r->set($k, '{"status":"in_progress"}', ["NX","GET","EX"=>60]);
// lan 1 -> bool(false) <- NX thanh cong, khong co gia tri cu
// lan 2 -> string(24) "{"status":"in_progress"}"
$r->ttl($k); // int(60)
$r->set($k, '{"status":"done"}', ["XX","KEEPTTL"]); // bool(true)
$r->get($k); // string(17) "{"status":"done"}"
$r->ttl($k); // int(60) <- KEEPTTL giu nguyen
Bẫy kiểu dữ liệu: phpredis trả về
bool(false)khiNXthành công, không phảinull. Viếtif ($old !== null)là sai — nhánh replay sẽ chạy cho mọi request. Phải so sánh=== false. Predis trả vềnulltrong trường hợp này, nên đừng copy code giữa hai driver mà không thử.
1.3. Vì sao không dùng Cache::add()
Đọc source Laravel 12, Illuminate/Cache/LuaScripts.php:19:
return redis.call('exists',KEYS[1])<1 and redis.call('setex',KEYS[1],ARGV[2],ARGV[1])
Nó atomic thật, nhưng chỉ trả về true/false, không trả giá trị cũ. Hậu quả:
- Phải gọi thêm
Cache::get()→ hai round-trip thay vì một. - Có khe cửa hẹp:
add()trảfalse, rồi key hết hạn ngay trướcget()→ nhậnnullvà không biết xử lý sao. Hiếm, nhưng đúng loại bug chỉ nổ lúc traffic cao.
SET NX GET gộp cả hai làm một, hết chuyện.
1.4. Bản Lua — khi Redis < 6.2 hoặc driver không hỗ trợ GET
Lua trong Redis chạy đơn luồng và atomic, nên GET rồi SET bên trong
script vẫn an toàn:
-- KEYS[1] = idem key, ARGV[1] = payload in_progress, ARGV[2] = ttl giay
local old = redis.call('GET', KEYS[1])
if old then return old end
redis.call('SET', KEYS[1], ARGV[1], 'EX', ARGV[2])
return false
$ redis-cli EVAL "$LUA" 1 idem:lua:k1 '{"status":"in_progress"}' 60
A: [] # rong = toi la nguoi dau tien
B: [{"status":"in_progress"}] # gia tri cu
$ redis-cli TTL idem:lua:k1
60
Cùng kết quả, chạy được trên mọi phiên bản Redis và mọi driver. Đổi lại: script phải nạp vào server (Redis tự cache theo SHA), và log chậm khó đọc hơn.
2. Design key Redis ra sao
Đây là phần dễ làm sai nhất, vì key sai không gây lỗi — nó chỉ âm thầm cho request thứ hai chạy tiếp.
idem:v1:{user_id}:{client_key}
│ │ │ └─ UUID do client sinh
│ │ └─ scope theo chu the
│ └─ version cua FORMAT VALUE
└─ namespace
| Mảnh | Vì sao có | Bỏ được không |
|---|---|---|
idem: | Tách namespace khỏi cache. Cho phép SCAN MATCH idem:* khi debug, và tách connection/DB riêng | Không nên |
v1 | Khi đổi cấu trúc JSON trong value (thêm field, đổi tên), bump thành v2 là toàn bộ key cũ tự chết theo TTL — không migration, không FLUSHDB | Được, nếu chấp nhận deploy phải đọc được cả 2 format trong 24h |
{user_id} | Bảo mật. Không có nó, key là global: ai đoán được key của người khác thì đọc được response của họ — chứa order_id, số tiền | Không bao giờ |
{client_key} | Ý định của client | — |
Với API dùng token của bên thứ ba (không có user_id theo nghĩa người dùng), thay
bằng api_key_id hoặc tenant_id. Nguyên tắc không đổi: key phải
được scope theo chủ thể gọi, đúng như Stripe scope theo account.
2.1. Ba thứ KHÔNG được nhét vào key
a) Đừng nhét request_hash
❌ idem:v1:7:8f3a:{hash_cua_payload}
Rất hay gặp, và nó phá đúng thứ mình đang xây. Cùng key + payload khác = tên key khác
hẳn → NX thành công → business logic chạy lần hai. Bạn vừa biến một
lỗi 422 ồn ào thành một đơn hàng trùng im lặng.
hash phải nằm trong value, để so sánh sau khi đã chiếm
được chỗ. Đó là cách duy nhất bắt được "client dùng lại key cho payload khác".
b) Đừng nhét endpoint — trừ khi cố ý
Có endpoint trong key | Không có (khuyên dùng) | |
|---|---|---|
Client vô tình dùng lại 1 key cho POST /orders và POST /refunds | 2 key khác nhau → cả hai đều chạy | 1 key → 422 hash mismatch → lộ bug của client |
| Key dài, log khó đọc | Có | Không |
| Hai endpoint cố tình dùng chung key | Chạy được | Bị chặn — nhưng đây gần như luôn là bug |
c) Đừng nhét timestamp hay random do server sinh
Key phải tái tạo được từ chính request. Nhét now() vào là mỗi lần
retry ra một key mới — tầng 2 biến mất mà không ai biết.
2.2. Value đựng gì
{"status":"done","hash":"a1b2c3d4e5f60718","code":201,"body":"{\"order_id\":90210}"}
Đo thật một key như trên, bằng MEMORY USAGE:
$ redis-cli MEMORY USAGE 'idem:v1:7:8f3a1c92-4d5e-4f1a-9b2c-3d4e5f6a7b8c'
192
192 byte/key. Nhân lên: 10.000 đơn/ngày, TTL 24h → khoảng 2MB. Rẻ. Nhưng
nếu ai đó lỡ nhét cả response HTML 200KB vào body thì con số thành 2GB
và Redis bắt đầu đuổi key. Body lớn thì chỉ lưu order_id, lúc replay query lại DB.
2.3. Redis Cluster: hash tag
Cluster chia key theo 16384 slot dựa trên băm tên key. Nếu bạn cần idempotency key và lock của cùng một user nằm cùng node (để chạy chung một Lua script), thêm hash tag:
idem:v1:{user:7}:8f3a # chi phan trong {} duoc bam de chon slot
lock:v1:{user:7}:place-order
Không cần multi-key thì đừng thêm hash tag. Nó ép mọi key của một user vào một slot; user hot (shop lớn, bot) sẽ dồn hết tải vào một node trong khi các node khác rảnh.
3. Lợi ích
3.1. Số đo thật
Cùng một máy, cùng một client, đo thao tác "chiếm chỗ" của tầng 2:
| Thao tác | Latency | Throughput (1 client) |
|---|---|---|
Redis SET ... NX GET EX 60 | p50 0.047 ms (avg 0.112) | 7.924 ops/s |
PG INSERT ... ON CONFLICT DO NOTHING( synchronous_commit=on, mặc định) | 10.47 ms | 95 tps |
PG cùng câu lệnh, synchronous_commit=off | 0.43 ms | 2.310 tps |
PG SELECT 1 (đo round-trip nền) | 0.124 ms | 8.085 tps |
Đo bằng redis-benchmark -n 20000 -c 1 và pgbench -c 1 -T 5, chạy
trong chính container của từng server. Máy dev, đĩa chậm — đọc theo tỉ lệ, đừng đọc
số tuyệt đối.
Đọc kỹ dòng thứ ba. Postgres chậm 200 lần không phải vì Postgres kém, mà vì nó
fsyncmỗi commit. Tắtsynchronous_committhì khoảng cách còn 10 lần — và bạn vừa đánh đổi đúng cái Redis đang đánh đổi: chấp nhận mất vài trăm mili-giây dữ liệu cuối khi máy chết. Đây mới là bản chất của trade-off, không phải "Redis nhanh hơn".
3.2. Những cái được khác
| Được gì | Cụ thể |
|---|---|
| TTL tự động | EX 86400 là xong. Bảng Postgres cần cron DELETE ... WHERE created_at < now() - interval '24 hours', và cái DELETE đó sinh dead tuple, phải để VACUUM dọn |
Nhả key in_progress treo | TTL ngắn ở pha 1 là miễn phí. Ở Postgres phải viết thêm cron dọn dòng treo — đây là chỗ Redis hơn bảng, không phải kém |
| Không đụng DB chính | Đường ghi checkout vốn đã có transaction dài; bớt 2 query khỏi connection pool là bớt áp lực đúng chỗ nghẽn |
| Một lệnh thay hai | SET NX GET gộp claim + read. Bảng Postgres cần INSERT ... ON CONFLICT rồi SELECT |
4. Tác hại / hạn chế
4.1. Key bị đuổi TRƯỚC khi hết TTL — bẫy số một
Cấu hình Redis của chính repo này (lấy từ docker inspect chiennn_redis):
["redis-server","--requirepass","...","--maxmemory","96mb",
"--maxmemory-policy","allkeys-lru","--save","60","1","--appendonly","no"]
allkeys-lru = khi đầy RAM, Redis đuổi key bất kỳ, kể cả key có TTL
24 giờ. Dựng một container riêng (maxmemory 3mb, cùng policy) để chứng minh:
$ redis-cli SET 'idem:u7:POST:/orders:8f3a' '{"status":"done"}' EX 86400
$ redis-cli EXISTS 'idem:u7:POST:/orders:8f3a'
1
$ redis-cli TTL 'idem:u7:POST:/orders:8f3a'
86399 # con 24 gio
# do rac vao cache chung (session, cache view, cache query...)
$ redis-cli EXISTS 'idem:u7:POST:/orders:8f3a'
0 # BIEN MAT
$ redis-cli INFO stats | grep evicted
evicted_keys:72232
Hậu quả thật: client retry sau 5 giây, key đã bị đuổi → SET NX thành công
→ tạo đơn thứ hai. Không exception, không log, chỉ có một đơn trùng.
Idempotency key không phải cache — nó là state. Cache mất thì tính lại. State mất thì tạo đơn trùng. Hai thứ này không được ở chung một instance có
allkeys-lru.
Cách chữa — tách hẳn ra:
# redis rieng cho idempotency
redis-server --maxmemory-policy noeviction --appendonly yes --appendfsync everysec
// config/database.php
'redis' => [
'cache' => [...], // allkeys-lru, mat cung khong sao
'idempotency' => ['host' => env('REDIS_IDEM_HOST'), 'database' => 3], // noeviction
],
Không tách được instance thì tối thiểu đổi sang volatile-lru và
đảm bảo mọi key cache đều có TTL. Nhưng key idem cũng có TTL nên nó vẫn nằm trong tầm
ngắm — đây chỉ là giảm rủi ro, không phải diệt. Vẫn nên tách.
4.2. Redis mất dữ liệu là hành vi bình thường, không phải sự cố
| Cấu hình | Mất tối đa |
|---|---|
--save 60 1 --appendonly no (đúng cấu hình repo này) | 60 giây key gần nhất |
--appendonly yes --appendfsync everysec | 1 giây |
--appendfsync always | ~0, nhưng latency về gần bằng Postgres — mất luôn lý do dùng Redis |
Postgres COMMIT mặc định | 0 |
Đúng 60 giây đó là lúc client đang retry.
4.3. Failover mất key
Redis replication là bất đồng bộ (async). Master nhận SET, trả OK
cho bạn, rồi chết trước khi kịp gửi cho replica → replica lên làm master, key không tồn tại.
sequenceDiagram participant A as App participant M as Redis master participant R as Redis replica A->>M: SET idem:v1:7:8f3a NX M-->>A: OK (nil - toi la nguoi dau tien) Note over M,R: chua kip replicate M--xM: master chet Note over R: sentinel bau replica len master A->>R: retry - SET idem:v1:7:8f3a NX R-->>A: OK (nil - "nguoi dau tien" LAN THU HAI) Note over A: chay business logic lan 2 -> don trung
Đây đúng bằng dòng "Redis failover" trong bảng 3.2 của bài
gốc, chỉ khác là lần này nó ăn vào idempotency chứ không phải lock. Lệnh WAIT 1 100
giảm được xác suất nhưng không diệt được — Redis không có consensus cho việc này — và
nó cộng latency vào mọi request ghi.
4.4. Key kẹt ở in_progress khi process chết
Process bị OOM-kill ngay sau SET NX → key kẹt in_progress đủ 24
giờ → client retry nhận 409 mãi mãi.
Bảng Postgres cũng dính y hệt. Khác là ở Postgres bạn
SELECT ... WHERE status='in_progress' AND created_at < now() - interval '5 minutes'
để soi và dọn; ở Redis phải SCAN toàn bộ rồi đọc từng value.
Cách chữa — TTL hai mức (đã áp dụng trong code mục 1.2):
// pha 1: TTL ngan, bang dung timeout cua request
$redis->set($k, $inProgress, ['NX', 'GET', 'EX' => 60]);
// pha 2: xong viec moi keo ra 24h
$redis->set($k, $done, ['XX', 'EX' => 86400]);
Chọn TTL pha 1 lớn hơn thời gian xử lý chậm nhất (p99 + biên), nhưng đủ nhỏ để client không phải chờ lâu. 60 giây hợp với đa số API HTTP.
Đánh đổi kèm theo: TTL pha 1 ngắn nghĩa là nếu request thật sự chạy quá 60 giây (gọi cổng thanh toán chậm), key hết hạn giữa chừng và request retry sẽ chạy lại từ đầu. Việc nào có thể vượt 60 giây thì phải đẩy sang queue và trả
202 Acceptedngay, chứ không kéo dài TTL.
4.5. WRONGTYPE — key trùng tên với cấu trúc khác
SET ... GET chỉ làm việc với string. Nếu tên key đã bị ai đó dùng cho hash/list:
$ redis-cli HSET tmp_h a b
$ redis-cli SET tmp_h 'x' NX GET EX 60
WRONGTYPE Operation against a key holding the wrong kind of value
$ redis-cli TYPE tmp_h
hash # key cu KHONG bi ghi de - lenh huy toan bo
Tin tốt: lệnh huỷ hoàn toàn, không phá dữ liệu cũ. Tin xấu: middleware của bạn ném exception
5xx cho mọi request mang key đó. Đây chính là lý do prefix idem: ở mục 2 tồn
tại — để không ai vô tình đụng phải namespace này.
5. Trade-off: Redis hay bảng Postgres
| Redis | Bảng idempotency_keys | |
|---|---|---|
| Latency (đo thật, mục 3.1) | 0.047 ms | 10.5 ms (0.43 ms nếu tắt synchronous_commit) |
| Mất dữ liệu khi crash | Có — 1–60 giây tuỳ cấu hình | Không |
| Bị evict | Có, nếu chung instance với cache | Không |
| Mất key khi failover | Có (replication async) | Không (replication sync được) |
| Dọn rác | TTL, miễn phí | Cron + VACUUM |
Nhả key in_progress treo | TTL ngắn, miễn phí | Cron |
| Cùng transaction với business logic | Không thể | Được — nhưng bài gốc đã nói: cố ý để ngoài transaction |
| Debug lúc 3h sáng | SCAN; không query được theo user hay thời gian | SELECT ... WHERE user_id=? ORDER BY created_at |
| Trả lời khách "sao tôi bị trừ 2 lần" | Key hết hạn = mất dấu | Còn dòng, join được với orders |
| Hạ tầng phải nuôi | Thêm 1 instance Redis noeviction + persistence | Không thêm gì |
Tiêu chí chọn
| Chọn | Khi nào |
|---|---|
| Redis | API traffic cao, mỗi ms đều tính; hậu quả trùng lặp nhẹ (gửi lại email, tạo bản ghi có thể merge); và đã có unique index tầng 4 chặn hậu |
| Bảng Postgres | Dính tiền (payment, refund, sổ cái); cần audit trail; traffic vừa phải. Vài ms không ai chết, một đơn trùng thì có |
| Cả hai | Redis đứng trước lọc 99% retry (nhanh, rẻ), Postgres unique index chặn 1% Redis để lọt. Redis không thay tầng 4, nó chỉ làm tầng 4 ít bị gọi tới |
Điểm mấu chốt của cả bài: Redis là lớp tối ưu, không phải lớp đảm bảo. Mọi hạn chế ở mục 4 đều dẫn tới cùng một kết luận — tầng 4 (unique index trên
idem_key) không bao giờ được bỏ. Nếu bạn đang định bỏ nó vì "đã có Redis rồi", bạn vừa xây một hệ thống đúng 99% và không biết 1% kia rơi vào đâu.
6. Khó khăn khi vận hành thực tế
| Vấn đề | Thực tế |
|---|---|
| Ước lượng RAM | 192 byte/key (đo thật). 10k đơn/ngày × TTL 24h ≈ 2MB. Nhưng lỡ lưu response 200KB thì thành 2GB. Phải cắt body và đặt trần |
| Monitoring bắt buộc | Alert evicted_keys > 0 trên instance idempotency. Số này khác 0 nghĩa là bạn đang tạo đơn trùng ngay lúc này mà không biết. Thêm used_memory / maxmemory và rdb_last_bgsave_status |
| Debug 3h sáng | Khách báo bị trừ 2 lần. Ở Postgres: một câu SELECT. Ở Redis: key hết hạn 24h trước, chỉ còn log. → Bắt buộc log idem_key vào application log và lưu vào cột orders.idem_key. Không làm thì Redis là hộp đen |
| Deploy đổi format value | Đang deploy dở: pod cũ ghi format A, pod mới đọc format B → json_decode trả null → 500. Prefix v1/v2 giải quyết, nhưng phải nhớ bump |
| Chi phí | Managed Redis có persistence + noeviction đắt hơn instance cache thường. Đội nhỏ hay tiếc rồi dùng chung → quay lại bẫy 4.1 |
| Con người | Junior sẽ thấy Cache::add() ngắn hơn và đổi sang nó (mục 1.3). Cần comment giải thích ngay tại chỗ vì sao dùng SET NX GET thô |
7. Case thực tế và cách xử lý
Case 1 — "Đơn trùng chỉ xuất hiện lúc 20h mỗi ngày"
Triệu chứng: ngày thường sạch. Khoảng 20h (giờ cao điểm) có 5–10 đơn trùng. Không có exception, không có log lỗi.
Nguyên nhân: 20h là lúc traffic cao → cache view/session phình → Redis
chạm maxmemory → allkeys-lru đuổi key idempotency (bẫy 4.1). Client
mạng 4G chậm nên retry → key đã mất → đơn thứ hai.
Xử lý từng bước:
redis-cli INFO stats | grep evicted_keys— khác 0 là tìm ra thủ phạm. Đối chiếuredis-cli INFO memoryvới đồ thị traffic theo giờ.- Chặn máu ngay: thêm tầng 4 nếu chưa có —
CREATE UNIQUE INDEX CONCURRENTLY ON orders (idem_key);(dùngCONCURRENTLYđể không khoá bảng). Bảng đang có sẵn dòng trùng thì index sẽ fail — phải dọn trùng trước. - Tách instance hoặc ít nhất tách DB với
maxmemory-policy noeviction. - Alert
evicted_keys > 0trên instance đó — bình thường phải luôn bằng 0. - Dọn hậu quả:
SELECT idem_key, count(*) FROM orders GROUP BY 1 HAVING count(*) > 1;
Case 2 — "Client đọc được đơn hàng của người khác"
Triệu chứng: pentest báo — đổi Idempotency-Key thành giá trị
đoán được (order-2026-09-09-001) thì API trả về response của user khác, lộ
order_id và số tiền.
Nguyên nhân: key Redis đặt là idem:{client_key}, thiếu
user_id. Client sinh key nghĩa là kẻ tấn công điều khiển tên key → nó đọc
được value của bất kỳ ai trùng key.
Xử lý:
- Đổi ngay thành
idem:v2:{user_id}:{client_key}. Bumpv1→v2để key cũ chết theo TTL, không cần xoá tay và không có khoảng thời gian đọc nhầm format. - Ngay cả khi đã scope, lưu
user_idvào value và kiểm tra lại trước khi trả response — phòng chỗ khác trong code build tên key sai. - Client là bên thứ ba không tin được thì đừng trả
bodycũ: trả409kèmLocation: /orders/{id}và bắt nó gọi endpoint đọc bình thường — nơi có authorization đầy đủ.
Đây chính là Case 5 trong bài gốc, chỉ khác là lỗ hổng lần này nằm ở cách đặt tên key Redis chứ không phải ở logic.
Case 3 — "Sau khi restart Redis, một loạt webhook chạy lại"
Triệu chứng: deploy xong, restart container Redis. 15 phút sau đối tác phàn nàn nhận trùng ~200 webhook.
Nguyên nhân: --appendonly no --save 60 1 — restart làm mất
tối đa 60 giây key gần nhất (bẫy 4.2). Đối tác retry theo lịch của họ, key không còn, webhook gửi
lại.
Xử lý:
- Bật AOF:
--appendonly yes --appendfsync everysec— thu hẹp cửa sổ mất từ 60 giây xuống 1 giây. - Restart có kế hoạch thì
redis-cli BGSAVEtrước khi stop, và dùngSHUTDOWN SAVEthay vìdocker kill. - Việc gửi ra ngoài (webhook, email, gọi API đối tác) thì đừng chỉ dựa vào Redis — ghi trạng thái "đã gửi" vào bảng cùng transaction với business logic. Đây đúng là loại việc mục 5 khuyên dùng Postgres.
8. Câu hỏi phụ interviewer hay hỏi tiếp
| Hỏi | Trả lời ngắn |
|---|---|
Sao không SETNX rồi GET? | Hai round-trip, và key có thể hết hạn ở giữa → GET trả nil, không biết xử lý. SET NX GET (Redis 6.2+) gộp làm một |
SET NX GET khác gì Cache::lock()? | Lock có owner token, tự release, sống vài giây, mục đích là loại trừ đồng thời. Idem key sống 24h, lưu cả response, mục đích là chống retry theo thời gian. Hai trục khác nhau — xem mục 1.2 bài gốc |
| Redis đơn hay Redlock? | Redlock giải bài toán lock. Với idempotency, vấn đề là độ bền dữ liệu chứ không phải mutual exclusion — Redlock không giúp gì. Cần bền thì dùng Postgres |
| TTL bao lâu? | 24h cho done (bằng Stripe), 60s cho in_progress. Dài hơn 24h chỉ tốn RAM — sau đó client không còn retry nữa |
| Redis chết thì API làm gì? | Chọn fail open (bỏ qua idempotency, cho chạy) nếu đã có unique index tầng 4. Chưa có tầng 4 thì phải fail closed (503), vì cho chạy = chấp nhận đơn trùng |
| Trả lại response cũ có luôn đúng không? | Không. Response chứa dữ liệu động (số dư hiện tại, thời gian còn lại) thì replay ra dữ liệu cũ. Chỉ lưu phần bất biến: order_id, status lúc tạo |
Vì sao hash nằm trong value chứ không trong key? | Nhét vào key thì payload khác = key khác = NX thành công = chạy lần hai. Mất luôn khả năng phát hiện client dùng sai key (mục 2.1a) |
| Redis Cluster có ảnh hưởng gì không? | Không, vì mọi thao tác chỉ đụng một key. Chỉ cần hash tag khi muốn gộp idem key và lock vào một Lua script — và hash tag lại gây lệch tải (mục 2.3) |
Tóm tắt
- Cơ chế:
SET key value NX GET EX 60(chiếm chỗ + đọc state cũ trong một lệnh) → chạy business logic →SET key value XX EX 86400. Lỗi thìDELđể client retry được. - Key:
idem:v1:{user_id}:{client_key}.user_idlà bắt buộc vì bảo mật.request_hashnằm trong value, không nằm trong key. - Bẫy chết người: Redis chung với cache +
allkeys-lrusẽ đuổi key idempotency trước hạn (đã chứng minh ở mục 4.1). Tách instancenoeviction. - Không thương lượng: Redis mất dữ liệu, evict và mất key khi failover đều là hành vi bình thường — nên unique index tầng 4 không bao giờ được bỏ.