Skip to content
DevRecall
← Roadmap
35

Scenarios: Concurrency, Idempotency & Reliability

drafted

Mock interview tình huống production — oversell, double charge, rate limit, retry/circuit breaker, graceful shutdown trên K8s, webhook, idempotent consumer, deadlock, distributed lock, cron chạy 2 lần, OTP/MFA, race condition trong Node.

0/58 rated · mastery 0%

Bài học

7 bài · ~106 phút đọc. Học theo thứ tự, rồi làm câu hỏi.
  1. 01~18 phút· 9 câuverify

    Oversell & hot row: race trên một resource đếm được

    Playbook cho oversell vé/tồn kho/voucher: nhận ra read-modify-write race (kể cả trong Node single-thread), fix nhỏ nhất bằng atomic conditional UPDATE, vì sao REPEATABLE READ cần retry, SKIP LOCKED cho pool, seat hold có hạn và payment về trễ, flash sale qua Redis Lua, hot row và sharded counter — đo thật trên PostgreSQL 17 + Redis 7.4 + Node 24.

  2. 02~14 phút· 7 câuverify

    Deadlock, distributed lock & job chạy hai lần

    Playbook cho các sự cố quanh lock: đọc deadlock log của Postgres, khoá theo thứ tự và giữ transaction ngắn, retry đúng lỗi, Redis lock có token và fencing, advisory lock cho cron, vì sao lock không chống được chạy lại tuần tự, cache rebuild lock, phản biện 'lock service chung' và cách bắt race trước production — chạy thật trên PostgreSQL 17 + Redis 7.4 + Node 24.

  3. 03~13 phút· 8 câuverify

    Double charge: idempotency key, timeout của provider & reconciliation

    Playbook chống trừ tiền hai lần: vì sao disable nút Pay không đủ, bốn lỗi của middleware idempotency 'chuẩn' trong note, fingerprint và scope, timeout là kết quả thứ ba, lease + recovery cho record kẹt 'processing', saga checkout với pivot transaction, reconciliation với settlement report và cách kể story STAR — chạy thật với Node 24, PostgreSQL 17 và một payment provider giả lập.

  4. 04~17 phút· 9 câuverify

    At-least-once: webhook, idempotent consumer, ordering & read-your-writes

    Webhook ack nhanh và verify chữ ký, idempotent consumer bằng ON CONFLICT trong cùng transaction, poison message và DLQ, ordering bị phá bởi Promise.all và retry, replica lag và read-your-writes — đo thật với Node 24, PostgreSQL 17 (primary + streaming replica) và Express.

  5. 05~15 phút· 11 câuverify

    Rate limiting, fail-open/fail-closed & OTP abuse

    Chọn thuật toán rate limit theo bài toán, tính fixed window và sliding window counter, tìm mọi bug của một token bucket Redis rồi viết lại bằng Lua, response 429 đúng, khi Redis chết thì fail-open hay fail-closed, OTP/MFA an toàn và SMS pumping — đo thật với Node 24 và Redis 7.4.

  6. 06~14 phút· 7 câuverify

    Retry storm, deadline, circuit breaker & load shedding

    Retry cái gì, full jitter, retry budget, khuếch đại 4^3 = 64, deadline propagation trong Node, circuit breaker cho downstream chậm, bulkhead/load shedding cho Black Friday và postmortem retry storm — đo thật với Node 24 và opossum 10.

  7. 07~15 phút· 7 câuverify

    Deploy an toàn: graceful shutdown, signal & probe trên Kubernetes

    502 khi rolling deploy, preStop và EndpointSlice, PID 1 và shell form, server.close với keep-alive, drain Kafka consumer, liveness vs readiness và restart loop.

Tóm tắt & cheat sheet của track

TL;DR

  • Mọi scenario trong track này quy về 3 câu hỏi: (1) hai actor cùng chạm một resource thì ai thắng? (concurrency), (2) cùng một việc bị làm hai lần thì sao? (idempotency), (3) dependency chậm/chết/đang deploy thì hệ thống cư xử thế nào? (reliability).
  • Sửa race condition = đẩy invariant xuống nơi có atomicity (một câu UPDATE ... WHERE, unique constraint, Lua script), không phải "check trong code rồi ghi".
  • At-least-once là thực tế của mạng: client retry, provider retry webhook, Kafka redeliver sau rebalance. Mọi handler có side effect phải idempotent — dedup bằng key + unique constraint trong cùng transaction với side effect.
  • Retry chỉ an toàn khi: lỗi transient, operation idempotent, có backoff + full jitter, có retry budget và deadline. Nếu không, retry biến một sự cố nhỏ thành retry storm.
  • Vận hành: graceful shutdown trên K8s là bài toán race giữa SIGTERM và việc gỡ endpoint → readiness fail + preStop sleep + đóng keep-alive + drain consumer. Liveness không bao giờ check DB.

Khái niệm cốt lõi

Khái niệmMột câuVí dụ ngắn
Read-modify-write raceĐọc → quyết định trong app → ghi; khoảng giữa là nơi request khác chen vào2 request cùng đọc stock=1, cùng trừ
Atomic conditional UPDATEĐiều kiện nằm trong WHERE, DB kiểm và ghi trong 1 lệnhUPDATE ... SET stock=stock-1 WHERE id=$1 AND stock>=1 → rowCount
Optimistic lockGhi kèm WHERE version=$old; 0 row → conflict → retryversion = version + 1
Pessimistic lockKhoá row khi đọc (FOR UPDATE), người khác chờNOWAIT = lỗi ngay, SKIP LOCKED = bỏ qua row đang bị khoá
Idempotency keyClient gửi 1 key/1 thao tác; server lưu key + fingerprint + responseIdempotency-Key: 7c3e...
Idempotent consumerDedup theo event_id bằng unique constraint trong cùng transaction với business writeINSERT INTO processed_events ... ON CONFLICT DO NOTHING
Token bucketBucket dung lượng B, nạp r token/s; request lấy 1 tokenB=20, r=100/60 → burst 20, trung bình 100/phút
Full jittersleep = random(0, min(cap, base·2^n))n=3, base=100ms → random 0..800ms
Retry budgetGiới hạn tỉ lệ retry / request (vd ≤10%)1000 rps → tối đa 100 retry/s
Circuit breakerClosed → Open (fail fast) → Half-open (thử)opossum errorThresholdPercentage: 50
Fencing tokenSố tăng dần cấp kèm lock; storage từ chối token cũWHERE last_token < $token
Reservation/holdGiữ tài nguyên có hạn (expires_at), hết hạn thì trả lạiGiữ ghế 10 phút khi checkout
Pivot transactionBước trong saga mà sau nó chỉ được đi tiếp (retry), không compensateCapture payment thành công
Read-your-writesUser luôn thấy chính thứ mình vừa ghiRoute read về primary 5s sau write

Cơ chế hoạt động

Chống double charge bằng idempotency key (có xử lý in-flight và crash)

Graceful shutdown của pod Node trên Kubernetes

Idempotent consumer + DLQ

Khung trả lời (dùng cho mọi scenario trong track)

  1. Clarify & đo: volume (QPS, số item), invariant nghiệp vụ ("không bao giờ bán quá 100 vé", "mỗi order tối đa 1 lần charge"), chấp nhận stale/lỗi đến đâu, hiện có metric/log gì.
  2. Gọi tên cơ chế: race (read-modify-write, check-then-act), duplicate (retry, redelivery), amplification (retry storm), race hạ tầng (SIGTERM vs endpoint removal).
  3. Fix theo lớp, rẻ nhất trước: constraint/atomic op ở storage → idempotency/dedup → lock (DB trước Redis) → thay đổi kiến trúc (queue, reservation, saga).
  4. Failure modes của chính cái fix: lock hết hạn, Redis chết, key "processing" kẹt, DLQ phá ordering, fail-open vs fail-closed.
  5. Chứng minh: test song song (k6/autocannon + assert invariant), metric (conflict rate, 409, DLQ size, retry ratio), reconciliation job định kỳ như lưới an toàn cuối.

Trade-offs

Kỹ thuậtThroughput khi contention caoĐộ phức tạpKhi nào dùngRủi ro chính
Atomic conditional UPDATECao nhưng serialize trên 1 rowThấpCounter/tồn kho đơn giảnHot row: mọi request xếp hàng trên 1 row lock
Optimistic (version)Kém khi conflict nhiều (retry liên tục)Trung bìnhLogic phức tạp, conflict hiếmRetry storm, starvation
SELECT ... FOR UPDATETrung bình, giữ lock suốt transactionTrung bìnhNhiều bước đọc/ghi cần nhất quánDeadlock, pool cạn nếu transaction dài
FOR UPDATE SKIP LOCKEDCaoTrung bìnhPhân phát item bất kỳ (ghế trống bất kỳ, job queue)Không dùng được khi cần đúng 1 row cụ thể
Redis Lua pre-decrementRất cao (~100k ops/s một node, verify)CaoFlash saleLệch với DB → cần reconciliation
Sharded counterCaoTrung bìnhCounter nóng (view, stock pool)Đọc tổng tốn hơn; check "≥0" khó hơn
Redis lockCaoTrung bìnhTránh làm trùng việc tốn kém (efficiency)Không an toàn cho correctness nếu thiếu fencing
Postgres advisory lockTrung bìnhThấpJob/cron singleton, đã có PostgresSession lock + PgBouncer transaction mode = hỏng
Rate limit fail modeHành vi khi Redis chếtHợp cho
Fail-openCho qua hết (có thể kèm local limiter)API đọc thông thường, ưu tiên availability
Fail-closedChặn (429/503)Login, OTP, gửi SMS, endpoint tốn tiền
Degraded localMỗi pod dùng limit/N in-memoryHầu hết trường hợp — trung dung

Pitfalls

  • Kiểm tra trong code (if (stock > 0)) rồi mới UPDATE — dù dùng transaction ở READ COMMITTED vẫn race.
  • Idempotency key không có fingerprint (cùng key, body khác) và không có lease → key kẹt processing vĩnh viễn sau crash.
  • Không truyền idempotency key xuống payment provider → timeout ở giữa vẫn double charge.
  • Retry ở mọi layer (client + gateway + service + SDK) → amplification theo cấp số nhân.
  • server.close() rồi nghĩ là xong: keep-alive connection giữ process sống đến SIGKILL.
  • CMD ["npm", "start"] hoặc shell form → SIGTERM không tới được Node.
  • Liveness probe gọi DB → DB chậm 30s = restart toàn bộ fleet.
  • Verify HMAC trên JSON.stringify(req.body) thay vì raw body; so sánh bằng ===; không kiểm timestamp → replay.
  • Check exists(event_id) ngoài transaction → hai consumer cùng pass check.
  • Redis lock release bằng DEL trơn; lock hết hạn giữa chừng mà không có fencing token.
  • OTP tạo bằng Math.random(), lưu plaintext, không giới hạn số lần thử, không throttle resend.

Cheat sheet

  • Oversell: UPDATE ... WHERE stock >= $qty + check rowCount; ghế cụ thể → unique (show_id, seat_id); flash sale → Redis Lua + queue + reconcile; hot row → sharded counter.
  • Postgres errors: 40001 serialization_failure, 40P01 deadlock_detected, 55P03 lock_not_available (NOWAIT), 23505 unique_violation. SQL Server: deadlock = error 1205; WITH (UPDLOCK, ROWLOCK), READPAST ≈ SKIP LOCKED.
  • Idempotency record: key, scope(user/tenant), fingerprint(sha256 body), status, locked_until, response_code, response_body, created_at + TTL ~24h (Stripe giữ 24h, verify).
  • Rate limit: 429 + Retry-After; IETF RateLimit-Policy / RateLimit headers (draft, verify). Tính toán trong Lua để atomic.
  • Retry: chỉ transient (timeout, 502/503/504, 429 có Retry-After, ECONNRESET) + idempotent; full jitter; tối đa 3 attempt; retry ở một layer; budget 10%.
  • Shutdown: preStop sleep 5–10s → SIGTERM → readiness 503 → server.close() + closeIdleConnections() → drain → commit offset → exit. terminationGracePeriodSeconds mặc định 30s và bao gồm preStop.
  • Webhook: raw body HMAC-SHA256 + timestamp ±5 phút + timingSafeEqual → insert event (unique event_id) → 200 nhanh → worker → reconcile theo API.
  • Lock: SET k token NX PX ttl + Lua compare-and-delete + fencing token cho correctness; hoặc pg_try_advisory_xact_lock.
  • Cron: K8s concurrencyPolicy: Forbid + job idempotent (CronJob có thể tạo 2 job hoặc 0 job trong vài trường hợp — docs K8s).
  • OTP: crypto.randomInt, hash + TTL 5 phút, ≤5 lần thử, resend cooldown 30–60s, single-use, bind user + purpose, timingSafeEqual; ưu tiên TOTP/WebAuthn.

References

Interview Questions

Showing 58 / 58

const { rowCount } = await pool.query(
  `UPDATE shows SET remaining = remaining - $2
   WHERE id = $1 AND remaining >= $2`,
  [showId, qty],
);
if (rowCount === 0) throw new SoldOutError(); // MySQL: affectedRows; SQL Server: @@ROWCOUNT
CREATE UNIQUE INDEX uq_active_seat ON seat_holds (show_id, seat_id)
  WHERE status IN ('held', 'booked');
-- SQL Server: filtered index cũng hỗ trợ WHERE; MySQL không có partial index → dùng generated column
async function withSerializableRetry<T>(fn: () => Promise<T>, max = 5): Promise<T> {
  for (let attempt = 1; ; attempt++) {
    try { return await fn(); }
    catch (e: any) {
      if (!['40001', '40P01'].includes(e.code) || attempt === max) throw e;
      await sleep(Math.random() * 20 * 2 ** attempt); // full jitter
    }
  }
}
UPDATE promo_codes SET user_id = $1, claimed_at = now()
WHERE id = (
  SELECT id FROM promo_codes
  WHERE user_id IS NULL AND campaign_id = $2
  ORDER BY id
  FOR UPDATE SKIP LOCKED
  LIMIT 1
)
RETURNING code;
window [00:00–01:00)  count=100  (all at 00:59.5)
window [01:00–02:00)  count=100  (all at 01:00.2)
=> 200 requests within ~0.7 s
prev window  [00:00-01:00) = 80
curr window  [01:00-02:00) = 30, now = 01:15
weight = 1 - 15/60 = 0.75
estimate = 80*0.75 + 30 = 90  -> allow
async function callWithRetry<T>(fn: () => Promise<T>, max = 4): Promise<T> {
  for (let i = 0; i < max; i++) {
    try { return await fn(); }
    catch (e) {
      if (!isRetryable(e) || i === max - 1) throw e;
      const base = 2 ** i * 100;          // 100, 200, 400, 800ms
      const jitter = Math.random() * 100;
      await sleep(base + jitter);
    }
  }
  throw new Error('unreachable');
}
import CircuitBreaker from 'opossum';
const breaker = new CircuitBreaker(fetchPrice, {
  timeout: 1200, errorThresholdPercentage: 50,
  rollingCountTimeout: 10_000, volumeThreshold: 20, resetTimeout: 30_000,
});
breaker.fallback((sku: string) => priceCache.getStale(sku));
breaker.on('open', () => metrics.increment('cb.pricing.open'));
spec:
  terminationGracePeriodSeconds: 45
  containers:
    - name: api
      lifecycle:
        preStop:
          sleep: { seconds: 10 }   # older clusters: exec: { command: ["sleep", "10"] }
FROM node:22-alpine
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
CMD npm start
ERROR:  deadlock detected
DETAIL:  Process 4211 waits for ShareLock on transaction 918273; blocked by process 4302.
         Process 4302 waits for ShareLock on transaction 918270; blocked by process 4211.
         Process 4211: UPDATE inventory SET qty = qty - 1 WHERE sku = 'B-2'
         Process 4302: UPDATE inventory SET qty = qty - 1 WHERE sku = 'A-1'
HINT:  See server log for query details.
CONTEXT:  while updating tuple (12,7) in relation "inventory"
apiVersion: batch/v1
kind: CronJob
spec:
  schedule: "0 2 * * *"
  timeZone: "Asia/Ho_Chi_Minh"
  concurrencyPolicy: Forbid
  startingDeadlineSeconds: 600
  jobTemplate:
    spec:
      backoffLimit: 2
app.post('/vouchers/:code/redeem', async (req, res) => {
  const v = await db.voucher.findUnique({ where: { code: req.params.code } });
  if (!v || v.redeemedAt) return res.status(409).end();
  await paymentService.applyCredit(req.user.id, v.amount); // await → yields
  await db.voucher.update({ where: { code: v.code }, data: { redeemedAt: new Date() } });
  res.json({ ok: true });
});
async function allow(userId: string): Promise<boolean> {
  const now = Date.now();
  const key = `rl:${userId}`;
  const { tokens, ts } = await redis.hgetall(key);
  const refill = Math.floor((now - ts) / 1000);
  const current = Math.min(10, (tokens ?? 10) + refill);
  if (current < 1) return false;
  await redis.hset(key, { tokens: current - 1, ts: now });
  return true;
}
-- KEYS[1]=bucket  ARGV: capacity, refill_per_ms, cost
local cap, rate, cost = tonumber(ARGV[1]), tonumber(ARGV[2]), tonumber(ARGV[3])
local t = redis.call('TIME'); local now = t[1] * 1000 + math.floor(t[2] / 1000)
local h = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(h[1]) or cap
local ts = tonumber(h[2]) or now
tokens = math.min(cap, tokens + (now - ts) * rate)
local allowed = 0
if tokens >= cost then tokens = tokens - cost; allowed = 1 end
redis.call('HSET', KEYS[1], 'tokens', tokens, 'ts', now)
redis.call('PEXPIRE', KEYS[1], math.ceil(cap / rate))
local retry = allowed == 1 and 0 or math.ceil((cost - tokens) / rate)
return { allowed, math.floor(tokens), retry }
async function pay(req: Request) {
  const key = req.headers['idempotency-key'] as string;
  const existing = await db.idempotency.findByKey(key);
  if (existing) return existing.response;            // replay

  const inserted = await db.idempotency.insertIfAbsent(key, 'processing');
  if (!inserted) throw new ConflictError('in progress');

  const result = await chargeGateway(req.body);
  await db.idempotency.update(key, 'done', result);
  return result;
}
UPDATE idempotency_keys
SET locked_until = now() + interval '60 seconds', attempt = attempt + 1
WHERE scope = $1 AND key = $2 AND status = 'processing' AND locked_until < now()
RETURNING recovery_point, provider_ref;
-- KEYS[1]=stock KEYS[2]=buyers  ARGV[1]=user ARGV[2]=qty
if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then return -1 end
local s = tonumber(redis.call('GET', KEYS[1]) or '0')
if s < tonumber(ARGV[2]) then return 0 end
redis.call('DECRBY', KEYS[1], ARGV[2])
redis.call('SADD', KEYS[2], ARGV[1])
return 1
-- 16 shards
UPDATE campaign_budget_shards
SET spent = spent + $2
WHERE campaign_id = $1 AND shard = floor(random() * 16)::int
  AND spent + $2 <= shard_cap;
SELECT sum(spent) FROM campaign_budget_shards WHERE campaign_id = $1;
async function getWithLock(key: string): Promise<string> {
  const val = await redis.get(key);
  if (val) return val;
  const lock = await redis.set(`lock:${key}`, '1', 'NX', 'EX', 10);
  if (!lock) { await sleep(50); return getWithLock(key); }
  const fresh = await queryDB(key);
  await redis.set(key, fresh, 'EX', 300);
  await redis.del(`lock:${key}`);
  return fresh;
}
const deadline = Date.now() + 1900;
const signal = AbortSignal.any([AbortSignal.timeout(1900), clientAbort.signal]);
const left = () => Math.max(0, deadline - Date.now());
const a = await callA({ signal, timeoutMs: Math.min(800, left()) });
const [b, c] = await Promise.all([
  callB(a, { signal: AbortSignal.any([signal, AbortSignal.timeout(Math.min(900, left()))]) }),
  callC(a, { signal: AbortSignal.any([signal, AbortSignal.timeout(Math.min(900, left()))]) }),
]);
process.on('SIGTERM', async () => {
  server.close();                 // stop accepting
  await drainInflightRequests(30_000);
  await db.end();
  await consumer.disconnect();
  process.exit(0);
});
livenessProbe:
  httpGet: { path: /health, port: 3000 }   # checks DB + Redis
  periodSeconds: 5
  timeoutSeconds: 1
  failureThreshold: 2
readinessProbe:
  httpGet: { path: /health, port: 3000 }
app.use(express.json());
app.post('/webhooks/payments', async (req, res) => {
  const sig = req.header('x-signature');
  const expected = crypto.createHmac('sha256', SECRET)
    .update(JSON.stringify(req.body)).digest('hex');
  if (sig !== expected) return res.status(401).end();
  await handleEvent(req.body);       // 5–15 s of work
  res.sendStatus(200);
});
async function handle(message: KafkaMessage) {
  const eventId = message.headers!.eventId!.toString();
  const seen = await db.processedEvents.exists(eventId);   // check outside tx
  if (seen) return;
  await db.transaction(async (tx) => {
    await applyBusinessLogic(tx, message);
    await tx.processedEvents.insert(eventId);
  });
  await consumer.commitOffsets([...]);
}
async function transfer(from: number, to: number, amount: number) {
  return retry(() => db.tx(async (tx) => {
    await tx.query('SELECT * FROM accounts WHERE id = $1 FOR UPDATE', [from]);
    await fraudService.check(from, to, amount);          // HTTP, 200-2000 ms
    await tx.query('SELECT * FROM accounts WHERE id = $1 FOR UPDATE', [to]);
    await tx.query('UPDATE accounts SET balance = balance - $2 WHERE id = $1', [from, amount]);
    await tx.query('UPDATE accounts SET balance = balance + $2 WHERE id = $1', [to, amount]);
  }), { retries: 5, retryOn: () => true });
}
-- on primary, after the write commits
SELECT pg_current_wal_lsn();            -- e.g. 3/A1B2C3D0
-- on a replica, before serving the read
SELECT pg_last_wal_replay_lsn() >= '3/A1B2C3D0'::pg_lsn AS caught_up;
async function sendOtp(userId: string, phone: string) {
  const code = Math.floor(100000 + Math.random() * 900000).toString();
  await redis.set(`otp:${phone}`, code);
  await sms.send(phone, `Your code is ${code}`);
}
async function verifyOtp(phone: string, code: string) {
  return (await redis.get(`otp:${phone}`)) === code;
}