Đời Sống & Sự Cố • 10 phút đọc • 15/09/2026

Chuyện trực On-call lúc 3h sáng: Khi Database deadlock và bài học về Idempotency

Bài này có bản tiếng Anh. Read in English →

Nguyen Hai Trinh

Nguyen Hai Trinh • Senior Software Engineer @ SmileViet

Phòng điều khiển về đêm
Ảnh: UrusHyby · CC0

3h07 sáng, điện thoại rung liên hồi. Alert: payment-service p99 latency > 15s.

Hiện tượng: error rate từ 0.2% lên 18%

Giao dịch thanh toán timeout hàng loạt. Dashboard Grafana đỏ rực: error rate từ 0.2% nhảy lên 18% trong ba phút.

Tệ hơn, client mobile tự retry sau 3 giây nếu chưa nhận được response. Traffic nhân đôi ngay giữa lúc hệ thống đang yếu nhất.

Việc đầu tiên mình làm không phải sửa code. Là xác nhận blast radius: bao nhiêu user bị ảnh hưởng, tất cả region hay chỉ một khu vực, và quan trọng nhất — có đang mất tiền thật của khách không.

Ba câu đó phải trả lời trong 5 phút đầu. Trả lời được thì mới biết nên rollback ngay hay còn thời gian điều tra sâu.

Truy vết: hai transaction chờ nhau

Lướt log APM, mình thấy một pattern lạ: nhiều request cùng gắn vào connection đang chờ lock trong PostgreSQL, chờ rất lâu. Query pg_stat_activity ghép với pg_locks cho ra bức tranh rõ hơn:

SELECT
  blocked_locks.pid AS blocked_pid,
  blocking_locks.pid AS blocking_pid,
  blocked_activity.query AS blocked_query,
  blocking_activity.query AS blocking_query
FROM pg_catalog.pg_locks blocked_locks
JOIN pg_catalog.pg_locks blocking_locks
  ON blocking_locks.locktype = blocked_locks.locktype
  AND blocking_locks.database IS NOT DISTINCT FROM blocked_locks.database
  AND blocking_locks.relation IS NOT DISTINCT FROM blocked_locks.relation
  AND blocking_locks.pid != blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocked_activity ON blocked_activity.pid = blocked_locks.pid
JOIN pg_catalog.pg_stat_activity blocking_activity ON blocking_activity.pid = blocking_locks.pid
WHERE NOT blocked_locks.granted;

Kết quả: hai transaction chờ nhau, đúng kiểu kinh điển. Deadlock.

Vì sao deadlock: hai transaction lock ngược thứ tự

Database rơi vào deadlock giữa hai transaction cùng cập nhật số dư tài khoản, nhưng theo thứ tự khoá (lock) ngược nhau:

  • Transaction A: lock account_1 → chờ lock account_2
  • Transaction B: lock account_2 → chờ lock account_1

PostgreSQL thấy vòng chờ này và tự kill một trong hai transaction để phá deadlock. Hành vi đúng của database, không phải bug.

Chỗ sai nằm ở tầng ứng dụng. Transaction bị kill, response trả về lỗi. Client không nhận được ACK trong thời gian chờ, thế là retry request thanh toán y hệt. Request không mang định danh duy nhất, nên server xử lý nó như một giao dịch hoàn toàn mới. Double-charge cho một số người dùng.

Trình tự nó như thế này:

sequenceDiagram
  participant C as Client (mobile app)
  participant S as payment-service
  participant PG as PostgreSQL
  participant A as Transaction A
  participant B as Transaction B

  C->>S: request thanh toán
  S->>PG: mở Transaction A, lock account_1
  PG->>A: giữ lock account_1
  S->>PG: mở Transaction B, lock account_2
  PG->>B: giữ lock account_2
  A->>PG: xin lock account_2 (đang bị B giữ)
  B->>PG: xin lock account_1 (đang bị A giữ)
  Note over PG: vòng chờ lẫn nhau, deadlock
  PG->>A: kill một transaction để phá vòng
  PG-->>S: response lỗi
  S-->>C: không có ACK trong 3 giây
  C->>S: retry request thanh toán y hệt
  Note over C,S: request không mang định danh duy nhất
  S->>PG: xử lý như giao dịch hoàn toàn mới
  Note over PG: double-charge

Ca này khó phát hiện vì deadlock chỉ xảy ra khi hai giao dịch chuyển tiền ngược chiều nhau, giữa đúng hai tài khoản cụ thể, gần như đồng thời. Xác suất thấp. Nhưng không bằng 0, và với traffic đủ lớn thì vài lần mỗi ngày vào giờ cao điểm.

Sửa: Idempotency-Key cho mọi request có thể retry

Mọi request có khả năng retry — thanh toán, tạo đơn hàng — giờ bắt buộc mang một Idempotency-Key duy nhất do client sinh ra. Thường là UUID v4 sinh ngay lúc user bấm nút thanh toán, giữ nguyên qua mọi lần retry. Server lưu kết quả xử lý theo key đó:

func ProcessPayment(ctx context.Context, db *sql.DB, key string, req PaymentRequest) (*PaymentResult, error) {
	tx, err := db.BeginTx(ctx, nil)
	if err != nil {
		return nil, err
	}
	defer tx.Rollback()

	var existing PaymentResult
	err = tx.QueryRowContext(ctx,
		`SELECT result FROM idempotency_records WHERE key = $1 FOR UPDATE`, key,
	).Scan(&existing.Result)

	if err == nil {
		// Key đã tồn tại và đã xử lý xong → trả lại response cũ, không xử lý lại
		return &existing, nil
	}
	if err != sql.ErrNoRows {
		return nil, err
	}

	result, err := executeTransfer(ctx, tx, req)
	if err != nil {
		return nil, err
	}

	if _, err := tx.ExecContext(ctx,
		`INSERT INTO idempotency_records (key, result, created_at) VALUES ($1, $2, now())`,
		key, result,
	); err != nil {
		return nil, err
	}

	return result, tx.Commit()
}

Ba bước:

  1. Request đến, server check xem key đã có trong bảng idempotency_records chưa, dùng FOR UPDATE để tránh race condition khi hai request mang cùng key đến gần như cùng lúc.
  2. Key đã tồn tại và đã xử lý xong thì trả lại response đã lưu, không chạy lại business logic.
  3. Key chưa có thì xử lý transaction, rồi lưu kết quả gắn với key đó trong cùng một DB transaction với nghiệp vụ chính. Chỗ này dễ sai nhất: insert idempotency record ở một transaction riêng thì một lỗi giữa chừng có thể khiến transfer thành công mà record không được lưu.

Chữa luôn nguyên nhân: sắp thứ tự lock

Idempotency-Key mới chỉ chặn hậu quả. Deadlock vẫn còn nguyên, phải xử lý riêng.

Cách làm đơn giản: sắp thứ tự lock theo account_id tăng dần trước khi mở transaction, bất kể tiền thực tế chuyển theo chiều nào:

func lockOrder(accountA, accountB string) (first, second string) {
	if accountA < accountB {
		return accountA, accountB
	}
	return accountB, accountA
}

Mọi transaction lock theo cùng một thứ tự cố định thì không còn vòng chờ lẫn nhau giữa hai transaction đối xứng. Deadlock bị loại bằng thiết kế, thay vì xử lý mỗi lần nó nổ.

Sau đó thì sao

Sau khi lên cả hai thay đổi: tỷ lệ double-charge về 0 trong hơn 6 tháng vận hành. p99 latency ổn định quanh 200ms, kể cả lúc traffic giờ cao điểm tăng đột biến.

Deadlock alert từ database cũng về 0. Trước đó là vài lần mỗi tuần — không phải lần nào cũng thành double-charge, nhưng rủi ro nằm đó suốt.

Còn một việc dễ quên: rà lại danh sách API có side-effect quan trọng nhất trong hệ thống — thanh toán, tạo đơn, gửi email xác nhận — xem cái nào chưa có Idempotency-Key. Danh sách đó thường dài hơn mình tưởng.

Cần người cho dự án contract hoặc freelance?

Mình nhận dự án fullstack — backend Go, frontend React/Next.js, hoặc cả hai đầu. Gửi vài dòng về phạm vi công việc và thời gian, mình phản hồi trong 48 giờ.