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
atomicNguyên tử — lệnh chạy trọn vẹn, không ai chen được vào giữa
NX / XXTuỳ chọn của lệnh SET: chỉ ghi khi key chưa có (NX) / chỉ ghi khi key đã có (XX)
GETTuỳ chọn của SET: trả về giá trị cũ của key trong cùng một lệnh
KEEPTTLGhi đè 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-policyLuật chọn key nào bị đuổi: allkeys-lru, volatile-lru, noeviction…
RDB / AOFHai 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 tagPhầ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 KEEPTTL hay EX? 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ùng KEEPTTL. Nếu ghi EX 86400 lầ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ùng EX 86400 để kéo hạn ra. Dùng KEEPTTL ở đâ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_progress khi process chết. Lệnh SET ... XX EX 86400 vẫn ghi đè giá trị và đặt lại hạn trong một lệnh, không cần EXPIRE riê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) khi NX thành công, không phải null. Viết if ($old !== null) là sai — nhánh replay sẽ chạy cho mọi request. Phải so sánh === false. Predis trả về null trong 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ả:

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ảnhVì 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êngKhông nên
v1Khi đổ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ềnKhô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 keyKhông có (khuyên dùng)
Client vô tình dùng lại 1 key cho POST /orders và POST /refunds2 key khác nhau → cả hai đều chạy1 key → 422 hash mismatch → lộ bug của client
Key dài, log khó đọcCóKhông
Hai endpoint cố tình dùng chung keyChạy đượcBị 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ácLatencyThroughput (1 client)
Redis SET ... NX GET EX 60p50 0.047 ms (avg 0.112)7.924 ops/s
PG INSERT ... ON CONFLICT DO NOTHING
(synchronous_commit=on, mặc định)
10.47 ms95 tps
PG cùng câu lệnh, synchronous_commit=off0.43 ms2.310 tps
PG SELECT 1 (đo round-trip nền)0.124 ms8.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ó fsync mỗi commit. Tắt synchronous_commit thì 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ự độngEX 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 treoTTL 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 haiSET 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ìnhMấ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 everysec1 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 định0

Đú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 Accepted ngay, 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

RedisBảng idempotency_keys
Latency (đo thật, mục 3.1)0.047 ms10.5 ms (0.43 ms nếu tắt synchronous_commit)
Mất dữ liệu khi crashCó — 1–60 giây tuỳ cấu hìnhKhông
Bị evictCó, nếu chung instance với cacheKhông
Mất key khi failoverCó (replication async)Không (replication sync được)
Dọn rácTTL, miễn phíCron + VACUUM
Nhả key in_progress treoTTL ngắn, miễn phíCron
Cùng transaction với business logicKhông thểĐược — nhưng bài gốc đã nói: cố ý để ngoài transaction
Debug lúc 3h sángSCAN; không query được theo user hay thời gianSELECT ... 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ấuCòn dòng, join được với orders
Hạ tầng phải nuôiThêm 1 instance Redis noeviction + persistenceKhông thêm gì

Tiêu chí chọn

ChọnKhi nào
RedisAPI 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 PostgresDí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ả haiRedis đứ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 RAM192 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ộcAlert 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ángKhá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ườiJunior 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:

  1. redis-cli INFO stats | grep evicted_keys — khác 0 là tìm ra thủ phạm. Đối chiếu redis-cli INFO memory với đồ thị traffic theo giờ.
  2. Chặn máu ngay: thêm tầng 4 nếu chưa có — CREATE UNIQUE INDEX CONCURRENTLY ON orders (idem_key); (dùng CONCURRENTLY để 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.
  3. Tách instance hoặc ít nhất tách DB với maxmemory-policy noeviction.
  4. Alert evicted_keys > 0 trên instance đó — bình thường phải luôn bằng 0.
  5. 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ý:

  1. Đổi ngay thành idem:v2:{user_id}:{client_key}. Bump v1→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.
  2. Ngay cả khi đã scope, lưu user_id và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.
  3. Client là bên thứ ba không tin được thì đừng trả body cũ: trả 409 kèm Location: /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ý:

  1. Bật AOF: --appendonly yes --appendfsync everysec — thu hẹp cửa sổ mất từ 60 giây xuống 1 giây.
  2. Restart có kế hoạch thì redis-cli BGSAVE trước khi stop, và dùng SHUTDOWN SAVE thay vì docker kill.
  3. 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ỏiTrả 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

  1. 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.
  2. Key: idem:v1:{user_id}:{client_key}. user_id là bắt buộc vì bảo mật. request_hash nằm trong value, không nằm trong key.
  3. Bẫy chết người: Redis chung với cache + allkeys-lru sẽ đuổi key idempotency trước hạn (đã chứng minh ở mục 4.1). Tách instance noeviction.
  4. 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ỏ.