Lock & concurrent transaction trong PostgreSQL

4 câu hỏi~30 phútTừ khóa: row lock, FOR UPDATE, FOR NO KEY UPDATE, FOR SHARE, SKIP LOCKED, NOWAIT, table lock, ACCESS EXCLUSIVE, deadlock, lock_timeout, statement_timeout, advisory lock, pg_locks, pg_stat_activity, pg_blocking_pids

Tiếp đi: lock trong PostgreSQL. Ngoài FOR UPDATE ra còn những loại lock nào, và khi bị treo thì xem ở đâu?

Phần "vì sao cần lock" và @Lock/@Version trong JPA đã có ở bài 3.5 và bài 4.3.10. Bài này nhìn từ phía PostgreSQL.

1. Bài toán

shop-app có 3 việc chạy đồng thời:

2. Vấn đề

3. Giải pháp: các loại lock

Row lock (khóa từng row, lưu ngay trong tuple, không giới hạn số lượng):

ModeLấy bởiChặn
FOR UPDATESắp UPDATE/DELETE, hoặc đổi keyMọi row lock khác trên row đó
FOR NO KEY UPDATEUPDATE thường (không đổi PK/unique)FOR UPDATE, FOR NO KEY UPDATE, FOR SHARE
FOR SHAREMuốn row không bị sửa trong lúc mình dùngMọi kiểu sửa
FOR KEY SHARETự động khi INSERT row con có FKDELETE / đổi key của row cha

SELECT thường không lấy row lock (MVCC, bài 6.3).

Tùy chọn khi row đang bị khóa: mặc định chờ; NOWAIT lỗi ngay; SKIP LOCKED bỏ qua row đó.

Table lock (8 mode). Những cái hay gặp:

ModeLấy bởiXung đột với
ACCESS SHARESELECTChỉ ACCESS EXCLUSIVE
ROW EXCLUSIVEINSERT/UPDATE/DELETESHARE trở lên (ví dụ CREATE INDEX thường)
SHARE UPDATE EXCLUSIVEVACUUM, CREATE INDEX CONCURRENTLYChính nó và các mode mạnh hơn
ACCESS EXCLUSIVEPhần lớn ALTER TABLE, DROP, VACUUM FULLTất cả, kể cả SELECT

4. Code từng bước

Bước 1: Hàng đợi job bằng SKIP LOCKED

public interface OrderRepository extends JpaRepository<Order, Long> {
    @Query(value = """
        SELECT * FROM orders
        WHERE status = 'PENDING'
        ORDER BY created_at
        LIMIT :size
        FOR UPDATE SKIP LOCKED
        """, nativeQuery = true)
    List<Order> lockNextBatch(@Param("size") int size);
}

@Service
@RequiredArgsConstructor
public class PaymentDispatcher {
    private final OrderRepository orders;

    @Transactional
    public int dispatchBatch() {
        List<Order> batch = orders.lockNextBatch(50);   // mỗi pod lấy 50 order KHÁC nhau
        batch.forEach(o -> o.setStatus(OrderStatus.DISPATCHING));
        return batch.size();
    }   // commit → nhả lock; gọi payment ở bước sau, ngoài transaction
}

Bước 2: Timeout cho lock

spring:
  datasource:
    hikari:
      connection-init-sql: SET lock_timeout = '3s'; SET statement_timeout = '10s'
-- Trong migration: không để ALTER chờ vô hạn rồi chặn mọi người phía sau
SET lock_timeout = '2s';
ALTER TABLE orders ADD COLUMN note text;    -- lỗi nhanh thì retry lúc vắng

Bước 3: Tránh deadlock khi chuyển tiền

@Transactional
public void transfer(long fromUser, long toUser, BigDecimal amount) {
    long first = Math.min(fromUser, toUser), second = Math.max(fromUser, toUser);
    walletRepository.lockByUserId(first);    // SELECT ... FOR UPDATE, luôn theo thứ tự id tăng
    walletRepository.lockByUserId(second);
    walletRepository.debit(fromUser, amount);
    walletRepository.credit(toUser, amount);
}

Bước 4: Advisory lock: lock theo một số tùy ý, không gắn với row. Ví dụ chỉ cho một pod chạy job tính thưởng mỗi đêm:

@Transactional
public void nightlyRewards() {
    Boolean got = jdbc.queryForObject(
        "SELECT pg_try_advisory_xact_lock(?)", Boolean.class, 20261014L);
    if (!Boolean.TRUE.equals(got)) return;     // pod khác đang chạy
    // ... tính thưởng; lock tự nhả khi transaction kết thúc
}

Bước 5: Khi bị treo: ai chặn ai

SELECT pid, state, wait_event_type, now() - xact_start AS tx_age,
       pg_blocking_pids(pid) AS blocked_by, left(query, 80)
FROM pg_stat_activity
WHERE wait_event_type = 'Lock';

SELECT locktype, relation::regclass, mode, granted, pid
FROM pg_locks WHERE NOT granted;

SELECT pg_cancel_backend(12345);     -- hủy câu đang chạy
SELECT pg_terminate_backend(12345);  -- đóng hẳn connection (cẩn thận)

Bước 6: Test

for i in $(seq 1 20); do
  curl -s -X POST localhost:8080/api/transfers -H 'Content-Type: application/json' \
    -d '{"from":1,"to":2,"amount":10}' &
  curl -s -X POST localhost:8080/api/transfers -H 'Content-Type: application/json' \
    -d '{"from":2,"to":1,"amount":10}' &
done; wait
# log không có "deadlock detected"; tổng balance hai ví không đổi

5. Cách hoạt động

Deadlock:
  T1: lock wallet 1 ──────── chờ wallet 2
  T2: lock wallet 2 ──────── chờ wallet 1
  Sau deadlock_timeout (1s) PostgreSQL kiểm tra đồ thị chờ, thấy vòng,
  hủy 1 transaction: ERROR 40P01 deadlock detected → app retry

Hàng đợi lock bảng (vì sao migration làm treo cả API):
  T_report (SELECT dài)     giữ ACCESS SHARE
  ALTER TABLE               xin ACCESS EXCLUSIVE → CHỜ
  SELECT của API #1..#500   xin ACCESS SHARE → xếp SAU ALTER → CHỜ
  ⇒ đặt lock_timeout cho migration

SKIP LOCKED:
  pod A: PENDING 1..50 (lock)   pod B: bỏ qua 1..50, lấy 51..100   pod C: 101..150

6. So sánh

Chờ (mặc định)NOWAITSKIP LOCKED
Row đang khóaĐợi đến khi nhả / lock_timeoutLỗi 55P03 ngayBỏ qua, trả các row còn lại
Dùng khiPhải xử lý đúng row đó (ví của user)Muốn báo "đang bận, thử lại" ngayHàng đợi job, phát voucher
Row lockAdvisory lockRedis lock (chương 7)
Gắn vớiRow thậtMột số tự chọnKey trong Redis
Nhả khiCommit/rollbackTransaction hoặc session kết thúcTTL / unlock
Phạm viMột DBMột DBNhiều hệ thống

7. Khi nào nên dùng / không nên

UPDATE thường thì có khóa gì không, hay chỉ khi viết FOR UPDATE mới khóa?

Có khóa. Mọi UPDATE/DELETE đều tự lấy row lock trên các row nó sửa (UPDATE thường là FOR NO KEY UPDATE) và giữ đến hết transaction. FOR UPDATE chỉ là cách khóa sớm hơn, từ lúc SELECT, để giữa lúc đọc và lúc ghi không ai chen vào.

BEGIN;
UPDATE products SET stock = stock - 1 WHERE id = 42;   -- row 42 bị khóa từ đây
-- ... làm thêm 5 giây ...
COMMIT;                                               -- đến đây mới nhả
-- trong 5 giây đó, mọi UPDATE khác vào row 42 phải chờ

Đó là lý do câu atomic UPDATE nên đặt cuối transaction và transaction phải ngắn: sản phẩm hot sẽ bị nghẽn ở đúng row đó.

Gặp deadlock thì có phải là bug không? Có tránh 100% được không?

Bạn nghĩ đúng một nửa: deadlock thường xuyên là dấu hiệu thứ tự lock lộn xộn, nên sửa. Nhưng tránh 100% thì khó: FK lấy FOR KEY SHARE ngầm, UPDATE nhiều row theo thứ tự index khác nhau, trigger... đều có thể tạo vòng chờ. Nên làm cả hai:

  1. Giảm xác suất: khóa theo thứ tự id, transaction ngắn, UPDATE nhiều row thì ORDER BY id khi SELECT FOR UPDATE.
  2. Chịu được: bắt 40P01 (Spring: CannotAcquireLockException/DeadlockLoserDataAccessException) và retry cả transaction vài lần, kèm idempotency.

Bật log_lock_waits = on để log ghi lại câu nào chờ lâu và chi tiết deadlock.

Phỏng vấn hỏi lock trong PostgreSQL thì trả lời sao?

Q1. PostgreSQL có những loại lock nào?

🇻🇳 Tiếng Việt:

Có row lock và table lock. Row lock gồm FOR UPDATE, FOR NO KEY UPDATE, FOR SHARE, FOR KEY SHARE; UPDATE và DELETE tự lấy row lock, SELECT thường thì không nhờ MVCC. Table lock có 8 mode, ví dụ SELECT lấy ACCESS SHARE, DML lấy ROW EXCLUSIVE, còn phần lớn ALTER TABLE lấy ACCESS EXCLUSIVE chặn cả đọc. Ngoài ra có advisory lock để khóa theo một khóa ứng dụng tự định nghĩa.

🇬🇧 English:

There are row locks and table locks. Row locks are FOR UPDATE, FOR NO KEY UPDATE, FOR SHARE and FOR KEY SHARE; UPDATE and DELETE take row locks automatically, while plain SELECT does not because of MVCC. Table locks have eight modes: SELECT takes ACCESS SHARE, DML takes ROW EXCLUSIVE, and most ALTER TABLE statements take ACCESS EXCLUSIVE, which blocks even reads. There are also advisory locks keyed by an application-defined number.

Q2. Nhiều worker cùng lấy job từ một bảng, làm sao không trùng và không chờ nhau?

🇻🇳 Tiếng Việt:

Em dùng SELECT ... FOR UPDATE SKIP LOCKED kèm LIMIT. Mỗi worker khóa một batch row, các worker khác bỏ qua những row đang bị khóa và lấy row tiếp theo. Trong cùng transaction em đổi trạng thái sang đang xử lý rồi commit ngay; việc gọi hệ thống ngoài làm sau khi commit để không giữ lock lâu.

🇬🇧 English:

I use SELECT ... FOR UPDATE SKIP LOCKED with a LIMIT. Each worker locks a batch of rows, and the other workers skip the locked rows and take the next ones. In the same transaction I mark them as processing and commit right away; external calls happen after the commit so locks are not held for long.

Q3. Xử lý deadlock và lock chờ lâu trong database thế nào?

🇻🇳 Tiếng Việt:

PostgreSQL tự phát hiện deadlock sau deadlock_timeout và hủy một transaction với lỗi 40P01. Em giảm deadlock bằng cách khóa theo thứ tự cố định và giữ transaction ngắn, đồng thời retry transaction bị hủy. Để không treo, em đặt lock_timeout và statement_timeout, đặc biệt cho migration. Khi có sự cố em xem pg_stat_activity với pg_blocking_pids và pg_locks để tìm session đang chặn.

🇬🇧 English:

PostgreSQL detects deadlocks after deadlock_timeout and aborts one transaction with error 40P01. I reduce deadlocks by locking in a consistent order and keeping transactions short, and I retry the aborted transaction. To avoid hanging I set lock_timeout and statement_timeout, especially for migrations. During an incident I check pg_stat_activity with pg_blocking_pids and pg_locks to find the blocking session.

Cách nhớ ngắn gọn


Tự kiểm tra

SELECT thường có chặn UPDATE không?

Không chặn ở mức row (MVCC). Nó chỉ giữ ACCESS SHARE trên bảng, xung đột với ACCESS EXCLUSIVE.

NOWAIT và SKIP LOCKED khác nhau thế nào?

NOWAIT lỗi ngay khi gặp row bị khóa; SKIP LOCKED bỏ qua row đó và trả các row còn lại.

Vì sao một ALTER TABLE có thể làm treo mọi SELECT?

Nó chờ ACCESS EXCLUSIVE; các SELECT đến sau xếp hàng sau nó trong hàng đợi lock.

Mã lỗi deadlock trong PostgreSQL?

40P01 (deadlock_detected). Lỗi lock_timeout là 55P03.

Vì sao nên dùng pg_try_advisory_xact_lock thay vì bản session?

Bản xact tự nhả khi transaction kết thúc; bản session có thể bị bỏ quên trên connection trả về pool.