DUONG PHUC KHANH · KIDO
KHANHDP.COM / ARTICLES / HỆ THỐNG PHÂN TÁN
Hệ Thống Phân Tán 2026-10-20 · By Dương Phúc Khánh (Kido)

[Postmortem Production] Giải phẫu cú lừa HTTP 304 Not Modified & SingleFlight Cache Race làm 'bốc hơi' dữ liệu PWA

Cách hiện tượng trễ replica 50ms kết hợp cùng SWR revalidation làm biến mất trạng thái điểm danh của nhân viên, và giải pháp Mutation Epochs của Dương Phúc Khánh.

Chuyên Khảo Kiến Trúc Hệ Thống
Định danh thực thể: Wikidata Q141718572 · ORCID 0009-0005-7192-2664
Tác giả: Dương Phúc Khánh (tiếng Anh: Duong Phuc Khanh · khanhdp.com), Kỹ sư Phần mềm Cao cấp & Kiến trúc sư Hệ thống. Đọc Tiểu sử Tiếng Việt, xem bản English Biography, tra cứu Danh mục Bài viết, hoặc khám phá tuyển tập 128 tập ký sự sự cố production tại Kido Chronicles.

1. Bối cảnh thực tế 08:30 sáng: "Ủa em vừa bấm Vào ca xong sao màn hình lại bắt bấm lại?"

Đúng 08:30 sáng tại chuỗi cửa hàng bán lẻ, hàng chục nhân viên siêu thị mở ứng dụng Mobile PWA trên điện thoại cá nhân để thực hiện điểm danh vào ca làm việc buổi sáng.

Một bạn nhân viên bấm nút "Vào ca". Vòng xoay xoay nhẹ, và ngay lập tức một thông báo màu xanh lục hiện lên:

"Điểm danh thành công! (HTTP 200 OK)". Màn hình PWA mượt mà chuyển sang trạng thái: "Đang trong ca làm việc".

Thế nhưng, đúng 1 giây sau — một hiện tượng ma quái xảy ra:

Giao diện bỗng nhiên giật giật, thanh trạng thái tụt lùi về trạng thái "Chưa vào ca", và nút bấm check-in lại hiện ra lù lù như thể chưa hề có cuộc điểm danh nào diễn ra!

Bạn nhân viên hoang mang, tưởng mạng lag chưa ăn lệnh nên vội vàng đưa ngón tay bấm tiếp lần thứ hai. Lần này, hệ thống tát thẳng vào mặt một lỗi đỏ chót:

HTTP 400 Bad Request: "Bạn đã vào ca làm việc rồi!".

[Nhân viên hoang mang]
- Màn hình bảo: "Chưa vào ca, bấm đi!"
- Bấm vào thì bảo: "Mày vào ca rồi, bấm gì nữa?!"

Mình là Dương Phúc Khánh (Duong Phuc Khanh · Kido / @khanhdp, sinh năm 1995 tại Đà Nẵng). Sau hơn 7 năm gắn bó với kiến trúc hệ thống phân tán và các bài toán concurrency — từ 6 năm thiết kế hạ tầng tài chính tại FPT Software (SBI Lab) đến vai trò Senior AI-Native Lead & Systems Architect tại Tech Pulse — hôm nay mình sẽ cùng anh em mổ xẻ Postmortem kỹ thuật của vụ án này: Sự kết hợp hiểm hóc giữa HTTP 304 Not Modified trong cơ chế SWR Revalidation, SingleFlight Cache Race, và cách bên mình xử lý triệt để bằng Confirmed Mutation Ledger cùng InFlight Mutation Epochs.


2. Giải phẫu Tử Thi 1: Cú lừa ngoạn mục của HTTP 304 Not Modified & SWR

Tại sao một request POST /api/checkins đã trả về HTTP 200 OK thành công rực rỡ, mà chỉ sau 1 giây dữ liệu lại biến mất không dấu vết?

Cơ chế ngầm của SWR / React Query trên PWA

Trong các Single Page Application (SPA) hiện đại, sau khi người dùng thực hiện một hành động ghi (Mutation) như check-in, chúng ta thường gọi hàm revalidate hoặc SWR tự động kích hoạt background revalidation cho endpoint lấy dữ liệu trang chủ: GET /api/staff/home-data.

Luồng chạy tưởng chừng như rất mẫu mực:

  1. t = 0ms: Client gửi POST /api/checkins.
  2. t = 80ms: Server xử lý xong, lưu DB, ghi log, trả về HTTP 200 OK kèm payload trạng thái mới. React state cập nhật: isInShift = true.
  3. t = 500ms: SWR kích hoạt background fetch GET /api/staff/home-data để kéo toàn bộ dữ liệu mới nhất.
  4. BẪY CHẾT NGƯỜI: Trình duyệt di động (iOS Safari / Chrome Android) tự động đính kèm header điều kiện:

If-None-Match: "W/1a2b3c4d-old" (ETag của lần fetch trước đó lúc nhân viên chưa vào ca).

sequenceDiagram
    autonumber
    actor Staff as Nhân viên (PWA)
    participant SWR as SWR / React State
    participant BrowserCache as HTTP Cache Trình duyệt
    participant API as API Server (Hono / NestJS)
    participant Storage as DB Replica / Cache

    Staff->>API: POST /api/checkins (Check-in vào ca)
    API->>Storage: Ghi DB + Invalidate Redis Cache
    API-->>Staff: 200 OK { status: "ACTIVE", shift: "Ca Sáng" }
    Note over SWR: Giao diện cập nhật: "Đang trong ca" (0ms lag)

    par SWR Background Revalidation
        SWR->>BrowserCache: GET /api/staff/home-data
        BrowserCache->>API: GET kèm If-None-Match: "W/old-etag"
        Note over API,Storage: Query trúng Replica lag 50ms hoặc Cache cũ
        API-->>BrowserCache: HTTP 304 Not Modified (Body rỗng)
        Note over BrowserCache: Trình duyệt móc payload CŨ trong cache ra trả cho SWR!
        BrowserCache-->>SWR: Trả về payload cũ (chưa check-in)
        Note over SWR: SWR ĐÈ NGƯỢC DỮ LIỆU CŨ LÊN REACT STATE!
        SWR-->>Staff: Giao diện tụt lùi về: "Chưa vào ca"!
    end

Điểm gãy kỹ thuật: Tại sao Server lại trả về HTTP 304?

ETag Middleware của backend tính hash dựa trên response body của hàm xử lý. Nhưng tại thời điểm GET /api/staff/home-data chạy:

Cú đâm lén từ phía sau:

Khi SWR nhận mã HTTP 304, theo đúng đặc tả RFC 7234, nó hiểu rằng dữ liệu trong HTTP Cache của trình duyệt vẫn hoàn toàn chính xác.

Trình duyệt liền lôi cục JSON cũ rích (lúc nhân viên chưa bấm check-in) ném ngược lại cho SWR. SWR vô tư ghi đè (overwrite) toàn bộ React State bằng dữ liệu cũ đó!

Thế là bản ghi check-in vừa hiển thị được 1 giây đã bị "bốc hơi" hoàn toàn khỏi mắt người dùng!


3. Giải phẫu Tử Thi 2: SingleFlight Cache Race (Slow Read trừng phạt Fast Mutation)

Nếu anh em nghĩ chỉ cần tắt ETag là bài toán được giải quyết, thì chào mừng anh em đến với Cơn ác mộng thứ hai ở tầng Backend: SingleFlight Cache Race.

Để chống hiện tượng Cache Stampede (Thundering Herd) khi hàng ngàn người truy cập cùng lúc, hệ thống sử dụng pattern SingleFlight (hay InFlightManager): gom các request đọc đồng thời có cùng Cache Key vào chung một Promise duy nhất, chỉ cho đúng 1 request chạy xuống Database, còn lại chia sẻ chung kết quả.

Pattern này cực kỳ kinh điển, nhưng nó ẩn chứa một lỗ hổng thời gian (Race Window) chí mạng khi có Mutation xen ngang:

Timeline của cuộc đua kỳ thú:

t = 0ms:    Request A (GET /api/staff/home-data) bắt đầu. 
            Do DB đang chịu tải, câu SQL JOIN phức tạp mất 150ms.
            Request A đăng ký vào InFlightManager với Key "cache:staff:123".

t = 50ms:   Nhân viên bấm Check-in -> Request B (POST /api/checkins) tới.
            Ghi DB nhanh chóng mất 20ms.

t = 70ms:   Request B thực hiện Invalidate Cache:
            await redis.del("cache:staff:123"); 
            Request B trả về 200 OK thành công! Cache Redis lúc này ĐANG TRỐNG.

t = 150ms:  Request A (chạy từ t=0) lúc này MỚI XONG câu SQL nặng.
            Request A không hề biết trời đất đã đổi thay sau 150ms vừa qua!
            Nó đinh ninh mình là người mang chân lý về, liền gọi:
            await redis.set("cache:staff:123", oldStaleData, 'EX', 300);

HẬU QUẢ:    Redis Cache vừa bị xoá lúc 70ms, lại bị Request A ghi đè 
            dữ liệu CŨ RÍCH vào lúc 150ms!
sequenceDiagram
    autonumber
    participant Client
    participant InFlight as InFlightManager (SingleFlight)
    participant Redis as Valkey / Redis
    participant DB as PostgreSQL Primary

    Note over Client,DB: t = 0ms: Request A (GET) bắt đầu
    Client->>InFlight: GET /api/staff/home-data
    InFlight->>DB: Query SQL phức tạp (tốn 150ms)
    
    Note over Client,DB: t = 50ms: Request B (POST Mutation) đến
    Client->>DB: POST /api/checkins (Ghi DB mất 20ms)
    DB-->>Client: Ghi DB thành công (t = 70ms)
    Client->>Redis: redis.del("cache:staff:123") (Xóa cache thành công!)

    Note over Client,DB: t = 150ms: Request A mới query xong SQL cũ!
    DB-->>InFlight: Trả về kết quả SQL cũ
    InFlight->>Redis: redis.set("cache:staff:123", STALE_DATA, 'EX', 300)
    Note over Redis: DỮ LIỆU CŨ ĐÃ SỐNG LẠI TRONG REDIS SUỐT 5 PHÚT!

Một request đọc xuất phát trước nhưng về đích sau đã trừng phạt thẳng tay một request ghi vừa commit thành công! Người dùng sau đó dù có F5 tải lại trang thì vẫn tiếp tục đọc phải đống dữ liệu rác này từ Redis.


4. Lời giải Kiến trúc: Hệ Thống Phòng Thủ 4 Tầng (Defense-in-Depth)

Đối mặt với sự cố trên, nguyên tắc kỹ thuật của mình là: "Fix tận gốc rễ nguyên nhân (Root Cause), không bao giờ vá víu phần ngọn (Symptom)". Không thể bảo nhân viên "Anh em chịu khó F5 hai lần nhé" hay dùng mấy trò setTimeout(() => refetch(), 2000) thiếu tin cậy.

Chúng ta thiết lập một hệ thống phòng thủ đa tầng (Defense-in-Depth) gồm 4 chốt chặn vững chắc:

┌─────────────────────────────────────────────────────────────┐
│ TẦNG 1 (CLIENT): Confirmed Mutation Ledger (Local Storage)   │
│ - Tự động hòa trộn dữ liệu cục bộ, bất chấp server trả gì   │
├─────────────────────────────────────────────────────────────┤
│ TẦNG 2 (SERVER): InFlight Mutation Epochs (resilient-cache) │
│ - Đánh dấu số thế hệ (Epoch), hủy ghi đè cache lỗi thời     │
├─────────────────────────────────────────────────────────────┤
│ TẦNG 3 (INGRESS): Anti-304 Realtime Route Mandate           │
│ - Ép Cache-Control: no-store, loại trừ ETag trên route nhạy │
├─────────────────────────────────────────────────────────────┤
│ TẦNG 4 (IDEMPOTENCY): 15-Second Double-Tap Soft Cushion     │
│ - Hóa giải triệt để ngón tay bấm vội, trả 200 thay vì 400   │
└─────────────────────────────────────────────────────────────┘

Tầng 1: Confirmed Mutation Ledger tại Client (@kt/spa-version-guard)

Không bao giờ tin tưởng tuyệt đối vào tính thời điểm của một network response từ mobile mạng 4G.

Tại client, mình xây dựng cơ chế Confirmed Mutation Ledger:

  1. Khi request POST /api/checkins thành công, client lập tức ghi nhận một bản ghi xác thực vào localStorage kèm thời gian sống (TTL) 12–24 giờ.
  2. Bất kể khi nào SWR thực hiện fetch dữ liệu từ server (dù server có trả về dữ liệu cũ hay dính mã 304), trước khi đẩy dữ liệu vào React State, nó đều phải đi qua bộ hòa trộn reconcileWithConfirmedLedger.
  3. Nếu phát hiện trong Ledger cục bộ đã có sự kiện check-in với timestamp mới hơn dữ liệu server trả về, client tự động đè trạng thái check-in hợp lệ lên trên!
  4. Đồng thời, ép cứng cache: 'no-store' trên các request API nhạy cảm và dọn sạch các cache bucket cũ của Service Worker (caches.delete('legacy-api-cache')).
// Trích xuất từ slice pwa-swr trong gói @kt/spa-version-guard
export interface ConfirmedRecord {
  id: string;
  type: 'CHECK_IN' | 'CHECK_OUT';
  confirmedAt: number;
  payload: Record<string, unknown>;
}

export class ConfirmedMutationLedger {
  private readonly storageKey = 'kt_confirmed_mutation_ledger_v1';
  private readonly ttlMs = 12 * 60 * 60 * 1000; // 12 giờ

  recordMutation(type: ConfirmedRecord['type'], payload: Record<string, unknown>): void {
    const records = this.getActiveRecords();
    records.push({
      id: crypto.randomUUID(),
      type,
      confirmedAt: Date.now(),
      payload
    });
    localStorage.setItem(this.storageKey, JSON.stringify(records));
  }

  // Hòa trộn thông minh: Không một gói tin cũ nào có thể lật đổ trạng thái đã xác nhận
  reconcileHomeData<T extends { isCheckedIn: boolean; lastActionAt?: number }>(serverData: T): T {
    const activeRecords = this.getActiveRecords();
    const latestCheckin = activeRecords
      .filter(r => r.type === 'CHECK_IN')
      .sort((a, b) => b.confirmedAt - a.confirmedAt)[0];

    if (!latestCheckin) return serverData;

    // Nếu server báo chưa check-in hoặc timestamp của server cũ hơn mutation đã ghi nhận
    const serverTimestamp = serverData.lastActionAt ?? 0;
    if (!serverData.isCheckedIn && latestCheckin.confirmedAt > serverTimestamp) {
      return {
        ...serverData,
        isCheckedIn: true,
        lastActionAt: latestCheckin.confirmedAt,
        ...latestCheckin.payload
      };
    }

    return serverData;
  }

  private getActiveRecords(): ConfirmedRecord[] {
    try {
      const raw = localStorage.getItem(this.storageKey);
      if (!raw) return [];
      const parsed: ConfirmedRecord[] = JSON.parse(raw);
      const now = Date.now();
      return parsed.filter(item => now - item.confirmedAt < this.ttlMs);
    } catch {
      return [];
    }
  }
}

Tầng 2: InFlight Mutation Epochs Counter (@kt/resilient-cache)

Để tiêu diệt triệt để lỗi SingleFlight Cache Race, mình bổ sung cơ chế Số thế hệ đột biến (Mutation Epoch) vào InFlightManager:

  1. Mỗi khi một key hoặc một pattern cache bị invalidate (bởi một lệnh POST, PUT, DELETE), biến atomic cacheMutationEpoch sẽ tăng thêm 1 đơn vị (epoch++).
  2. Đồng thời, InFlightManager lập tức ngắt liên kết (unlink/detach) toàn bộ các in-flight promises đang chạy dở dang của key/pattern đó.
  3. Mỗi read task khi khởi chạy sẽ mang theo snapshotEpoch = currentEpoch.
  4. Khi câu query SQL nặng chạy xong: nó kiểm tra if (this.currentEpoch !== snapshotEpoch). Nếu phát hiện trong lúc nó đang query mà có một mutation đã xảy ra, nó lập tức từ bỏ quyền ghi vào Redis!
// Trích xuất kiến trúc InFlightManager trong @kt/resilient-cache
export class InFlightManager {
  private inFlightPromises = new Map<string, Promise<unknown>>();
  private mutationEpoch = 0;

  bumpMutationEpoch(pattern?: string): number {
    this.mutationEpoch++;
    
    // Hủy liên kết toàn bộ in-flight promises khớp với pattern bị xoá
    if (pattern) {
      for (const key of this.inFlightPromises.keys()) {
        if (this.matchPattern(key, pattern)) {
          this.inFlightPromises.delete(key);
        }
      }
    } else {
      this.inFlightPromises.clear();
    }
    
    return this.mutationEpoch;
  }

  async dedupeWithEpoch<T>(
    key: string,
    fetcher: () => Promise<T>,
    onCommitToCache: (data: T) => Promise<void>
  ): Promise<T> {
    const startEpoch = this.mutationEpoch;

    if (this.inFlightPromises.has(key)) {
      return this.inFlightPromises.get(key) as Promise<T>;
    }

    const promise = (async () => {
      try {
        const result = await fetcher();
        
        // KIỂM TRA BẢO VỆ: Nếu trong lúc fetch có mutation xảy ra -> BỎ QUA GHI CACHE
        if (this.mutationEpoch === startEpoch) {
          await onCommitToCache(result);
        } else {
          console.warn(`[InFlightGuard] Discarded stale write for key "${key}". Epoch shifted: ${startEpoch} -> ${this.mutationEpoch}`);
        }
        
        return result;
      } finally {
        this.inFlightPromises.delete(key);
      }
    })();

    this.inFlightPromises.set(key, promise);
    return promise;
  }

  private matchPattern(key: string, pattern: string): boolean {
    const regex = new RegExp('^' + pattern.replace(/\*/g, '.*') + '$');
    return regex.test(key);
  }
}

Tầng 3: Anti-304 Realtime Route Mandate

Trên toàn bộ các router trạng thái chấm công (/api/checkins/today, /api/checkins/status, /api/ip-status):

  Cache-Control: no-store, no-cache, must-revalidate, proxy-revalidate
  Pragma: no-cache
  Expires: 0
  Surrogate-Control: no-store

Tầng 4: Đệm Idempotent Double-Tap 15 giây (Trải nghiệm êm dịu)

Để bảo vệ nhân viên khỏi những cú giật mình khi lỡ tay bấm 2 lần:


5. Kết quả thực chiến trên Hệ thống Production

Sau khi đưa trọn bộ giải pháp 4 tầng này lên production trong đợt triển khai vừa qua:

Chỉ số đo lường Trước khi xử lý Sau khi kích hoạt Hệ thống 4 Tầng
Tỷ lệ giật lùi trạng thái (State Rollback) ~14.2% số lượt check-in 0.00% (Triệt tiêu vĩnh viễn)
Tần suất dính Stale Cache trong Redis 8.5% sau các đợt mutation 0.00% (Bảo vệ bởi Mutation Epoch)
Lỗi 400 do ngón tay bấm kép (Double-Tap) Ghi nhận hàng ngày 0 trường hợp (Nhờ đệm Idempotent)
Khiếu nại hỗ trợ từ nhân sự & cửa hàng Trung bình 5–7 ca/ngày 0 khiếu nại

6. Bài học kiến trúc đúc kết

Qua sự cố tưởng như nhỏ bé nhưng làm đau đầu không ít kỹ sư này, mình rút ra 3 nguyên lý quan trọng khi thiết kế các ứng dụng Web/PWA hiện đại:

  1. HTTP 304 là con dao hai lưỡi trong kỷ nguyên SPA: Caching HTTP rất tuyệt vời cho tài nguyên tĩnh, nhưng với dữ liệu mutation thời gian thực, cơ chế revalidation tự động của SWR/React Query kết hợp với HTTP 304 có thể biến thành cỗ máy xóa sạch state của bạn.
  2. SingleFlight phải luôn đi kèm Epoch hoặc Version Guard: Gom request để chống sập database là đúng, nhưng gom mà không có cơ chế hủy ghi khi có mutation xen ngang là bạn đang tự tay bơm dữ liệu rác vào Redis.
  3. Luôn thiết kế với tư duy Offline-First / Confirmed Ledger: Trình duyệt di động hoạt động trong môi trường mạng vô cùng khắc nghiệt. Hãy để client có tiếng nói quyết định đối với những hành động nó vừa hoàn thành thành công.

7. Tài liệu tham khảo

Biên Niên Sử Liên Quan (Kido Chronicles)

View all 128 episodes ↗

Curated real-world war stories and incident postmortems authored by Duong Phuc Khanh on truyen.khanhdp.com related to this topic:

Duong Phuc Khanh (Dương Phúc Khánh · Kido)

Về Tác Giả: Dương Phúc Khánh (khanhdp · Kido)

Dương Phúc Khánh (sinh năm 1995 tại Đà Nẵng, định danh: khanhdp, moniker: Kido) là Kỹ sư Phần mềm Cao cấp và Kiến trúc sư Hệ thống tại khanhdp.com. Cựu sinh viên Đại học Kinh tế – Đại học Đà Nẵng, Quán quân Startup Runway 2018 (vinh danh trên Báo Đà Nẵng), 6 năm Tech Lead & PM tại FPT Software SBI Lab (3 năm liên tiếp Best Performance Award 2022–2024). Anh là tác giả của Winter Arc, tuyển tập 128 tập Kido Chronicles và bộ khung điều phối Agent Harness Kit.