0

[Case Study Production] Giải phẫu cơn bão Race Condition lúc 6h00 sáng & cách gánh 1.100+ WAU trên VPS 8GB RAM bằng Valkey Distributed Lock

1. Bối cảnh thực tế: Cơn bão Check-in lúc 06:00 sáng

Đúng 06:00 sáng mỗi ngày tại Đà Nẵng, hàng trăm vận động viên trên nền tảng Winter Arc PWA đồng loạt mở ứng dụng để bấm nút "Hoàn thành thử thách" nhằm giữ chuỗi kỷ luật (Streak) 100 ngày.

Không hề có cụm AWS EKS tiêu tốn $5.000/tháng ở phía sau. Toàn bộ hệ sinh thái production — phục vụ 1.100+ người dùng hoạt động hàng tuần (WAU), 712 người dùng cày nhiệm vụ mỗi ngày, thời gian phiên trung bình 8 phút 04 giây, và P75 LCP đạt 1.12s — được vận hành hoàn toàn trên một máy chủ Linux VPS 8GB RAM duy nhất.

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 làm việc với các hệ thống phân tán và giao dịch tải cao — từ 6 năm làm Tech Lead & PM cho các nền tảng tài chính Nhật Bản tại FPT Software (SBI Lab) đến vai trò Senior AI-Native Lead & Systems Architect tại Tech Pulse — bài viết này chia sẻ lại cách mình giải quyết triệt để bài toán TOCTOU Race Condition ở ngưỡng tải cao mà không làm nghẽn Database.


2. Giải phẫu lỗi: Khi mạng 4G chập chờn gặp "Ngón tay liên thanh"

Trong phòng tập dưới tầng hầm, mạng 4G rất dễ rớt gói tin (packet loss). Khi người dùng bấm nút Check-in và thấy vòng xoay loading hơi khựng lại, phản xạ tự nhiên là bấm liên tiếp 3–4 lần, cộng thêm cơ chế auto-retry của client.

Nếu viết theo luồng kiểm tra ngây thơ (Naïve Check-Then-Act):

// ❌ BẪY KINH ĐIỂN: TOCTOU (Time-of-Check to Time-of-Use)
const existingLog = await this.prisma.dailyCheckIn.findFirst({
  where: { userId, date: today }
});

if (!existingLog) {
  // Khi 4 request cùng vượt qua câu if ở trên trong cùng 1 mili-giây:
  await this.prisma.dailyCheckIn.create({ data: { userId, date: today } });
  await this.prisma.user.update({
    where: { id: userId },
    data: { streak: { increment: 1 }, points: { increment: 50 } }
  });
}

Hậu quả:

  1. Nhân bản điểm thưởng & Streak: 4 request song song cùng đọc thấy existingLog === null trước khi bất kỳ request nào kịp commit.
  2. Nghẽn cổ chai Connection Pool: Nếu đẩy mức cô lập lên SERIALIZABLE trong PostgreSQL, các worker sẽ bị treo chờ khoá dòng (Row Lock contention), kéo theo hiệu ứng domino làm cạn kiệt pool kết nối.

3. Lời giải Kiến trúc: Phòng thủ đa tầng (Defense-in-Depth) dưới 5ms

Thay vì ép PostgreSQL gánh toàn bộ áp lực đồng thời, mình áp dụng triết lý 7-Rung Ladder (tận dụng tối đa nguyên lý phần cứng và cấu trúc dữ liệu nguyên thuỷ) qua 3 lớp:

Lớp 1: Atomic Distributed Mutex tại tầng Valkey 8 (O(1))

Chặn đứng các request trùng lặp ngay tại bộ nhớ RAM trước khi chạm vào tầng ORM:

// ✅ Tầng gác cổng nguyên tử với Valkey 8 (SET NX PX)
const lockKey = `lock:checkin:${userId}:${workoutDate}`;
const acquired = await this.valkey.set(lockKey, requestId, 'PX', 5000, 'NX');

if (!acquired) {
  // Phát hiện retry đồng thời -> Trả về ngay trạng thái Idempotent trong < 2ms
  return this.getCachedCheckInStatus(userId, workoutDate);
}

Lớp 2: Ràng buộc bất biến tại Database (UNIQUE Constraint + ON CONFLICT DO NOTHING)

Không bao giờ tin tưởng tuyệt đối vào tầng ứng dụng. Dưới PostgreSQL, bảng daily_check_ins luôn có khoá duy nhất UNIQUE (user_id, workout_date) kết hợp câu lệnh INSERT ... ON CONFLICT DO NOTHING RETURNING id. Chỉ khi thực sự có dòng mới được sinh ra, câu lệnh cộng điểm và tăng Streak mới được kích hoạt trong cùng một Transaction ngắn.

Lớp 3: Redis Bitmap (SETBIT / BITCOUNT) thay cho bảng nối khổng lồ

Để tính toán chuỗi ngày hoạt động và bảng xếp hạng cho hàng nghìn vận động viên:

  • Thay vì quét hàng triệu dòng lịch sử bằng GROUP BY, mỗi năm hoạt động của 1 user chỉ tốn đúng 365 bits (~46 Bytes) trong Valkey Bitmap.
  • Kiểm tra ngày tập luyện bằng GETBIT (O(1)) và đếm tổng số ngày hoàn thành bằng chỉ thị phần cứng POPCNT qua BITCOUNT trong < 0.1ms.

4. Kết quả thực tế trên Production

Chỉ số đo lường Trước khi tối ưu Sau khi áp dụng Valkey Mutex + Bitmap
Độ trễ Check-in p99 820ms < 5ms
Lỗi trùng lặp điểm / Streak Xảy ra mỗi sáng cao điểm 0% (Tuyệt đối Idempotent)
Dung lượng RAM lưu chuỗi 365 ngày ~32 KB / user 46 Bytes / user (Giảm 99.8%)
Tải CPU VPS lúc 06:00 sáng Spike 85%+ < 12%

5. Tổng kết & Tài liệu tham khảo

Khi thiết kế các tính năng tương tác tần suất cao (Check-in, Flash Sale, Điểm danh, Claim Voucher), việc kết hợp Valkey Atomic Lock (SET NX PX) ở tầng bộ nhớ với Ràng buộc bất biến (UNIQUE + ON CONFLICT DO NOTHING) dưới PostgreSQL giúp bảo vệ toàn vẹn dữ liệu mà vẫn giữ độ trễ ở mức vài mili-giây.


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.