White-Box Auditing & Săn Bug Thực Chiến
~14 phútRace Conditions & Concurrency ExploitsMiễn phí

Khóa Cơ sở dữ liệu: Optimistic, Pessimistic và Postgres Advisory Locks

Kiến trúc phòng thủ concurrency toàn diện: SELECT FOR UPDATE, Idempotency keys phân tán, điều kiện versioning và ứng dụng Postgres Advisory Locks.

TL;DR: Rất nhiều đội ngũ kỹ sư cố gắng vá lỗi Race Condition bằng cách đặt cờ trong bộ nhớ ứng dụng (Application Mutex) hoặc tạo cache Redis tạm thời. Các giải pháp này thất bại ngay lập tức khi ứng dụng mở rộng ra nhiều container hoặc instance server khác nhau. Phòng thủ tận gốc đòi hỏi đưa cơ chế khóa về tầng lưu trữ dữ liệu tập trung: sử dụng Pessimistic Locking (SELECT ... FOR UPDATE), Optimistic Locking với cột version, các câu lệnh cập nhật có điều kiện nguyên tử (UPDATE accounts SET balance = balance - 100 WHERE id = 1 AND balance >= 100), hoặc sử dụng Postgres Advisory Locks để đồng bộ hóa các nghiệp vụ phức tạp.

1. Tại sao In-Memory Mutex thất bại trong thế giới Distributed System?

Khi phát hiện lỗi Race Condition trong code backend (ví dụ kiểm tra số dư và trừ tiền), phản xạ đầu tiên của nhiều lập trình viên là bọc đoạn code đó bằng synchronized (Java), sync.Mutex (Go), hoặc một biến boolean toàn cục (Node.js).

Cách này có thể vượt qua được bài test chạy trên máy tính cá nhân của lập trình viên (vốn chỉ có 1 tiến trình duy nhất). Nhưng trên môi trường production:

  • Hệ thống chạy đằng sau Load Balancer với 4 hoặc 8 bản sao container (Kubernetes Pods hoặc Docker replicas).
  • Khóa sync.Mutex của Pod A hoàn toàn vô hình trước Pod B và Pod C.
  • Khi một đợt tấn công đồng bộ như Single-Packet Attack ập tới, Load Balancer rải 30 requests sang các Pod khác nhau, và mỗi Pod lại hồn nhiên xử lý song song trên luồng riêng của nó.

Muốn khóa đồng bộ hiệu quả, bạn buộc phải dời cơ chế khóa về Single Source of Truth duy nhất: Cơ sở dữ liệu tập trung.

2. Khi nào nên dùng Pessimistic vs Optimistic Locking?

Mỗi chiến lược khóa giải quyết bài toán với mức đánh đổi khác nhau giữa tính nhất quán (Consistency) và hiệu năng (Throughput):

Ma trận Chiến lược Khóa Cơ sở dữ liệu

Chiến lược 1: Pessimistic Locking (SELECT ... FOR UPDATE)

Khi một transaction bắt đầu đọc dữ liệu, nó yêu cầu một Exclusive Lock trên hàng dữ liệu đó:

SQL
BEGIN;
-- Row is locked exclusively until COMMIT or ROLLBACK
SELECT balance FROM accounts WHERE user_id = 42 FOR UPDATE;

-- Application checks if balance >= 100 ...
UPDATE accounts SET balance = balance - 100 WHERE user_id = 42;
COMMIT;
  • Cơ chế: Bất kỳ transaction nào khác cố gắng đọc cùng hàng với FOR UPDATE hoặc cập nhật hàng này sẽ bị treo chờ (block) cho đến khi transaction đầu tiên hoàn tất.
  • Ưu điểm: Độ an toàn tuyệt đối, ngăn chặn 100% tình trạng dirty reads và lost updates.
  • Nhược điểm: Dễ gây tắc nghẽn hàng đợi (connection pool exhaustion) nếu transaction mất nhiều thời gian, hoặc dẫn tới Deadlock nếu hai transaction khóa các tài nguyên theo thứ tự ngược nhau (ví dụ User A chuyển tiền cho User B và User B cùng lúc chuyển tiền cho User A).

Chiến lược 2: Optimistic Locking (Kiểm soát phiên bản)

Không khóa dữ liệu khi đọc, mà kiểm tra xem dữ liệu có bị thay đổi trong lúc xử lý hay không tại thời điểm ghi:

SQL
-- Read current state and version
SELECT balance, version FROM accounts WHERE user_id = 42;
-- version = 5, balance = 150

-- Update only if version hasn't changed
UPDATE accounts 
SET balance = balance - 100, version = version + 1 
WHERE user_id = 42 AND version = 5;
  • Cơ chế: Kiểm tra số hàng bị ảnh hưởng (rows affected). Nếu trả về 0, nghĩa là đã có một tiến trình khác chen ngang và sửa dữ liệu trước. Ứng dụng sẽ hủy bỏ thao tác hoặc chủ động thử lại (retry loop).
  • Ưu điểm: Không khóa hàng dữ liệu, đọc ghi tốc độ cao, không bao giờ bị Deadlock ở tầng database.
  • Nhược điểm: Phải viết thêm logic xử lý retry ở backend khi xảy ra xung đột.

Chiến lược 3: Atomic Conditional Updates (Tuyệt chiêu cho số dư & kho hàng)

Đối với các bài toán cộng trừ số lượng đơn giản, việc tách thành 2 bước SELECT rồi UPDATE chính là nguyên nhân gây ra race window. Ta có thể gộp cả hai thành một câu lệnh SQL duy nhất:

SQL
UPDATE accounts 
SET balance = balance - 100 
WHERE user_id = 42 AND balance >= 100;

Động cơ cơ sở dữ liệu sẽ khóa hàng dữ liệu nội bộ trong nano-giây khi thực thi lệnh này. Nếu số dư không đủ, rows affected sẽ bằng 0. Đoạn code này ngắn gọn hơn, không cần transaction dài dòng và an toàn tuyệt đối trước mọi kỹ thuật race condition.

3. Vũ khí đặc biệt: Postgres Advisory Locks

Có những tình huống nghiệp vụ không gắn liền với một hàng dữ liệu cụ thể trong cơ sở dữ liệu (ví dụ: cấp quyền làm việc với file trên S3, giới hạn mỗi người dùng chỉ được gọi API gửi email xác thực 1 lần trong 60 giây).

PostgreSQL cung cấp một tính năng cực mạnh gọi là Advisory Locks — cho phép ứng dụng mượn động cơ khóa của Postgres để tạo lock logic theo một số nguyên 64-bit tùy ý:

SQL
-- Acquire transaction-level advisory lock based on user_id
SELECT pg_advisory_xact_lock(42);

-- Perform critical business logic, external API calls, etc.
-- The lock is automatically released when the transaction ends (COMMIT or ROLLBACK)
  • pg_advisory_xact_lock(bigint): Tự động giải phóng khóa ngay khi transaction kết thúc (an toàn, không lo rò rỉ khóa nếu container ứng dụng bị crash đột ngột).
  • pg_try_advisory_xact_lock(bigint): Trả về true nếu lấy được khóa ngay, trả về false nếu đang bị tiến trình khác chiếm giữ. Phù hợp tuyệt vời cho logic: "Nếu đang có tiến trình chạy thì từ chối ngay lập tức thay vì bắt người dùng chờ".
Go
// Example Go implementation using pg_try_advisory_xact_lock
tx, err := db.BeginTx(ctx, nil)
if err != nil {
    return err
}
defer tx.Rollback()

var acquired bool
err = tx.QueryRowContext(ctx, "SELECT pg_try_advisory_xact_lock($1)", userID).Scan(&acquired)
if err != nil || !acquired {
    return errors.New("request in progress, please do not spam")
}

// Execute sensitive payment logic safely ...
return tx.Commit()
Thử đoán trước

Tại sao dùng Postgres Advisory Locks lại an toàn hơn việc tự cài đặt Distributed Lock bằng Redis (Redlock) cho những giao dịch tài chính quan trọng? Bởi vì Postgres Advisory Locks nằm cùng transaction context với dữ liệu tài chính. Nếu database transaction bị rollback do lỗi hoặc timeout, lock cũng lập tức bị hủy bỏ hoặc duy trì đúng theo ACID. Với Redis, nếu worker bị crash giữa chừng hoặc gặp sự cố phân mảnh mạng (network partition), khóa có thể bị giữ quá hạn (deadlock) hoặc hết hạn trước khi database kịp commit (split-brain).

4. Idempotency Keys trong kiến trúc thanh toán

Trong thiết kế REST API chuẩn cho thanh toán (như Stripe, PayPal), client bắt buộc phải gửi một Header duy nhất cho mỗi giao dịch: Idempotency-Key: <UUID>.

Quy trình xử lý chuẩn ở backend:

  1. Ghi nhận Idempotency-Key vào bảng idempotency_records với trạng thái PENDING và khóa Unique Constraint trên cột này.
  2. Nếu request thứ 2 gửi cùng key trong khi request 1 đang chạy: Cơ sở dữ liệu ném lỗi UniqueViolation ngay lập tức, ngăn chặn hoàn toàn việc thực thi kép.
  3. Sau khi xử lý thanh toán xong, cập nhật kết quả và response payload vào bản ghi. Mọi request gửi lại key này sau đó sẽ được trả thẳng kết quả cũ mà không chạy lại logic nghiệp vụ.

5. AI Copilot Slot: Prompt thiết kế kiến trúc khóa chống Race Condition

AI Copilot Prompt Slot
Role:Principal Backend & Database Architect
Task:Rà soát schema và mã nguồn để thiết kế cơ chế khóa chống Race Condition tối ưu
Context:Hệ thống e-commerce xử lý đơn hàng Flash Sale với lượng truy cập cao trên PostgreSQL
Output:Đánh giá rủi ro TOCTOU, so sánh các giải pháp khóa và mã SQL mẫu áp dụng Atomic Update + Advisory Locks
Bạn là kiến trúc sư cơ sở dữ liệu và bảo mật backend cao cấp.
Tôi có đoạn code xử lý áp dụng voucher giảm giá sau:
1. Đọc voucher: SELECT usage_count, max_usage FROM vouchers WHERE code = $1;
2. Kiểm tra: if (usage_count >= max_usage) throw Error('Hết lượt');
3. Cập nhật: UPDATE vouchers SET usage_count = usage_count + 1 WHERE code = $1;

Hãy phân tích:
1. Tại sao đoạn code trên bị hớ hếnh trước Single-Packet Attack?
2. Hãy viết lại câu lệnh SQL để biến thao tác trên thành Atomic Conditional Update mà không cần dùng Pessimistic Lock dài dòng.
3. Trong trường hợp voucher gắn liền với từng User (mỗi user chỉ dùng 1 lần trên toàn hệ thống), hãy thiết kế schema và cơ chế Unique Constraint hoặc Postgres Advisory Lock để đảm bảo tính bất biến.

6. Liên hệ các bài khác

Tự kiểm tra
0/5 câu đã trả lời
  1. Q1
    Tại sao việc sử dụng sync.Mutex hoặc synchronized trong code backend không giải quyết được Race Condition trên môi trường production?
  2. Q2
    Điểm khác biệt cơ bản giữa Pessimistic Locking và Optimistic Locking là gì?
  3. Q3
    Tại sao câu lệnh UPDATE accounts SET balance = balance - 100 WHERE id = 1 AND balance >= 100 lại an toàn trước race condition?
  4. Q4
    Postgres Advisory Lock khác gì so với Table Lock hay Row Lock thông thường?
  5. Q5
    Header Idempotency-Key giúp ngăn chặn Race Condition trong giao dịch như thế nào?

Bài này có giúp bạn hiểu bản chất không?

Bài này đáng gửi cho bạn học cùng?

Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.

Thảo luận & góp ý

Hỏi đáp về bài này

Bài tiếp theo

Mini-challenge: Khai thác Limit Overrun trên Mock API Thanh toán