Skip to content

PostgreSQL — Locking & Concurrency

1. Row-level Locks

Có 4 loại, từ nhẹ đến nặng:

LockTypeORM modeÝ nghĩa
FOR KEY SHAREpessimistic_key_shareNhẹ nhất — chặn xóa/update primary key
FOR SHAREpessimistic_readChặn UPDATE/DELETE, cho phép đọc đồng thời
FOR NO KEY UPDATEpessimistic_partial_writeChặn UPDATE/DELETE, vẫn cho FOR KEY SHARE
FOR UPDATEpessimistic_writeNặng nhất — chặn tất cả trừ SELECT thường

Compatibility matrix

FOR KEY SHAREFOR SHAREFOR NO KEY UPDATEFOR UPDATE
FOR KEY SHARE
FOR SHARE
FOR NO KEY UPDATE
FOR UPDATE

✅ = 2 transaction cùng tồn tại được, ❌ = bị treo.

SELECT thường (không khai báo lock) không bị chặn bởi bất kỳ row lock nào, và cũng không tự sinh ra FOR KEY SHARE nào cả. FOR KEY SHARE chỉ phát sinh ngầm khi PostgreSQL cần check foreign key constraint (xem phần dưới).


FOR KEY SHARE

Chặn DELETE và UPDATE primary key, cho phép UPDATE column thường.

sql
SELECT * FROM users WHERE id = 1 FOR KEY SHARE;

PostgreSQL tự động apply khi INSERT/UPDATE có foreign key:

sql
-- Bạn viết:
INSERT INTO orders (user_id) VALUES (1);

-- PostgreSQL tự làm ngầm:
SELECT id FROM users WHERE id = 1 FOR KEY SHARE;
INSERT INTO orders (user_id) VALUES (1);

Dev ít khi tự dùng — chủ yếu PostgreSQL tự apply để bảo vệ foreign key integrity.


FOR SHARE

Cho phép nhiều transaction cùng đọc, chặn UPDATE/DELETE.

sql
SELECT * FROM users WHERE id = 1 FOR SHARE;

Dùng khi cần đọc data để tính toán, đảm bảo không ai thay đổi trong lúc tính. Dev tự dùng, PostgreSQL không tự apply.

Ví dụ thực tế — kiểm tra giá trước khi tạo order:

sql
-- Service A: đọc giá, KHÔNG có ý định sửa, chỉ cần đảm bảo giá không đổi
-- trong lúc đang tính tổng tiền order
BEGIN;
SELECT price, stock FROM products WHERE id = 10 FOR SHARE;
-- ... tính tổng tiền, validate, ghi order ...
COMMIT;
sql
-- Service A2 đọc cùng row, FOR SHARE → không bị chặn (đọc đồng thời OK)
BEGIN;
SELECT price FROM products WHERE id = 10 FOR SHARE;
COMMIT; -- ✓ chạy ngay

-- Service B (admin) muốn đổi giá → phải chờ A, A2 commit
BEGIN;
UPDATE products SET price = 200000 WHERE id = 10;
-- ⏳ chờ

FOR NO KEY UPDATE

Giống FOR UPDATE nhưng nhẹ hơn 1 bậc — vẫn cho FOR KEY SHARE chạy được.

sql
SELECT * FROM users WHERE id = 1 FOR NO KEY UPDATE;

Dùng khi update column thường nhưng vẫn muốn các INSERT vào bảng con (foreign key) chạy được song song.

FOR NO KEY UPDATE vs FOR UPDATE — khác nhau ở đâu?

Cả hai đều do dev chủ động gọi khi sắp update row, và cả hai đều mang nghĩa "tôi sắp sửa row này". Khác biệt chỉ lộ ra khi có table khác foreign-key reference tới table đang lock.

Trường hợp KHÔNG có FK liên quan: FOR NO KEY UPDATEFOR UPDATE hoạt động giống hệt nhau, không ai phân biệt được, vì không có ai cần FOR KEY SHARE để compatible.

Trường hợp CÓ FK liên quan — đây là lúc khác biệt xuất hiện:

sql
-- accounts(id) được transactions(account_id) reference (FK)

-- TH1: Session A dùng FOR UPDATE
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
-- chưa commit

-- Session B insert transaction mới
-- PostgreSQL tự FOR KEY SHARE accounts.id=1 để check FK còn tồn tại
INSERT INTO transactions (account_id, amount) VALUES (1, -50);
-- ⏳ BỊ CHẶN — FOR UPDATE conflict với FOR KEY SHARE
sql
-- TH2: Session A dùng FOR NO KEY UPDATE thay vì FOR UPDATE
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR NO KEY UPDATE;
-- chưa commit

-- Session B insert transaction mới
INSERT INTO transactions (account_id, amount) VALUES (1, -50);
-- ✓ KHÔNG BỊ CHẶN — FOR NO KEY UPDATE compatible với FOR KEY SHARE

Kết luận thực dụng:

Câu hỏiTrả lời
Table đang lock có bị table khác FK reference tới không?Không → dùng FOR UPDATE cho đơn giản, không có downside
Có → cân nhắc FOR NO KEY UPDATE nếu muốn các INSERT/FK-check khác chạy song song, không bị chặn bởi lock của bạn

Nói cách khác: FOR NO KEY UPDATE chỉ có ý nghĩa thực tiễn khi table đang lock là "cha" trong quan hệ FK và bạn muốn giảm tối đa việc block các transaction khác đang chỉ cần insert/check FK.


FOR UPDATE

Nặng nhất — chặn tất cả trừ SELECT thường.

sql
SELECT * FROM users WHERE id = 1 FOR UPDATE;

Dùng khi cần đọc rồi ghi, đảm bảo không ai chen vào giữa.

Ví dụ thực tế — pattern rút tiền điển hình:

sql
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR UPDATE;
-- giả sử check balance >= 100 trong app code
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
COMMIT;

Trong lúc đó, mọi session khác — kể cả chỉ muốn FOR SHARE để đọc — đều phải chờ:

sql
BEGIN;
SELECT balance FROM accounts WHERE id = 1 FOR SHARE;
-- ⏳ phải chờ transaction trên COMMIT/ROLLBACK

Dùng trong TypeORM

Bắt buộc phải nằm trong transaction, nếu không lock vô nghĩa vì release ngay sau SELECT.

typescript
// ✅ Đúng — lock nằm trong transaction
await dataSource.transaction(async (manager) => {
  const user = await manager.findOne(User, {
    where: { id: 1 },
    lock: { mode: "pessimistic_write" }, // FOR UPDATE
  });
  user.tier = "gold";
  await manager.save(user);
});

// ❌ Sai — transaction kết thúc ngay sau findOne, lock release luôn
const user = await userRepo.findOne({
  where: { id: 1 },
  lock: { mode: "pessimistic_write" },
});
user.tier = "gold";
await userRepo.save(user); // lock đã release từ lúc nãy

4 mode tương ứng:

typescript
// FOR KEY SHARE — PostgreSQL thường tự apply, ít khi dev gọi tay
await manager.findOne(Account, {
  where: { id },
  lock: { mode: "pessimistic_key_share" },
});

// FOR SHARE — đọc để tính toán, không sửa, cấm người khác sửa
await manager.findOne(Account, {
  where: { id },
  lock: { mode: "pessimistic_read" },
});

// FOR NO KEY UPDATE — sắp update, không đổi key, muốn giảm conflict với FK
await manager.findOne(Account, {
  where: { id },
  lock: { mode: "pessimistic_partial_write" },
});

// FOR UPDATE — sắp update/delete, lock chặt nhất
await manager.findOne(Account, {
  where: { id },
  lock: { mode: "pessimistic_write" },
});

2. Lock tự sinh bởi INSERT / UPDATE / DELETE

Không cần khai báo gì thêm — PostgreSQL tự sinh row lock khi thực thi các lệnh này.

INSERT

sql
INSERT INTO orders (user_id, total) VALUES (1, 500000);

Không lock row vừa insert (chưa ai biết row đó tồn tại). Nhưng nếu có FK, PostgreSQL tự sinh FOR KEY SHARE lên row cha để check FK còn tồn tại:

sql
-- orders.user_id FK tới users.id
-- PostgreSQL tự làm ngầm:
SELECT id FROM users WHERE id = 1 FOR KEY SHARE;
INSERT INTO orders (user_id, total) VALUES (1, 500000);

UPDATE

sql
UPDATE accounts SET balance = balance - 100 WHERE id = 1;

PostgreSQL tự sinh FOR NO KEY UPDATE lên row bị update — đủ để chặn UPDATE/DELETE khác, nhưng vẫn cho FOR KEY SHARE (insert bảng con có FK) chạy được.

Nếu UPDATE đổi luôn primary key/unique key → tự nâng lên FOR UPDATE (vì key thay đổi, phải chặn cả FK-check).

DELETE

sql
DELETE FROM accounts WHERE id = 1;

PostgreSQL tự sinh FOR UPDATE — nặng nhất, vì xóa row thì key biến mất, phải chặn tất cả kể cả FK-check.

Tóm tắt lock tự sinh

LệnhLock tự sinhLý do
INSERTKhông lock row mới. FOR KEY SHARE lên row cha (nếu có FK)Row mới chưa ai thấy, chỉ cần verify FK
UPDATE (không đổi key)FOR NO KEY UPDATEChặn update/delete khác, vẫn cho FK-check
UPDATE (đổi primary/unique key)FOR UPDATEKey thay đổi, phải chặn cả FK-check
DELETEFOR UPDATEKey biến mất, phải chặn tất cả

Hành vi khi gặp conflict

Mặc định khi UPDATE/DELETE gặp row đang bị lock bởi transaction khác → chờ vô thời hạn cho tới khi transaction kia COMMIT hoặc ROLLBACK.

Có 2 cách thay đổi hành vi này:

NOWAIT — báo lỗi ngay lập tức thay vì chờ:

sql
-- SQL thuần
SELECT * FROM accounts WHERE id = 1 FOR UPDATE NOWAIT;
-- → ERROR 55P03: could not obtain lock on row in relation "accounts"

-- TypeORM
await manager.findOne(Account, {
  where: { id },
  lock: { mode: "pessimistic_write", onLocked: "nowait" },
});

Dùng khi: không muốn request bị treo, thà báo lỗi để retry ngay còn hơn chờ không biết bao lâu.

SKIP LOCKED — bỏ qua các row đang bị lock, chỉ lấy row available:

sql
-- SQL thuần
SELECT * FROM jobs WHERE status = 'pending'
ORDER BY created_at
LIMIT 1
FOR UPDATE SKIP LOCKED;

-- TypeORM
await manager.findOne(Job, {
  where: { status: "pending" },
  order: { createdAt: "ASC" },
  lock: { mode: "pessimistic_write", onLocked: "skip_locked" },
});

Dùng khi: queue processing với nhiều worker — mỗi worker lấy 1 job available, không tranh nhau cùng 1 row.

Worker 1: lấy job_id=1 (FOR UPDATE) → đang xử lý
Worker 2: SKIP LOCKED → bỏ qua job_id=1, lấy job_id=2 ngay
Worker 3: SKIP LOCKED → bỏ qua 1,2, lấy job_id=3 ngay
→ không worker nào phải chờ worker khác

So sánh 3 hành vi:

ModeHành vi khi gặp locked rowDùng khi
Mặc địnhChờ đến khi unlockCần đúng row đó, chấp nhận chờ
NOWAITBáo lỗi ngayKhông muốn chờ, tự retry trong app
SKIP LOCKEDBỏ qua, lấy row khácQueue processing, nhiều worker song song

3. Table-level Locks

PostgreSQL tự động apply, dev không cần tự lock thủ công.

3 mức quan trọng nhất

ACCESS SHARE — nhẹ nhất

sql
SELECT * FROM users;
-- tự động apply ACCESS SHARE
-- compatible với tất cả trừ ACCESS EXCLUSIVE

ROW EXCLUSIVE — hay gặp nhất

sql
INSERT / UPDATE / DELETE
-- tự động apply ROW EXCLUSIVE

ACCESS EXCLUSIVE — nặng nhất

sql
ALTER TABLE / DROP TABLE / TRUNCATE
-- conflict với TẤT CẢ lock khác kể cả ACCESS SHARE (SELECT thường)

Lock queuing — nguy hiểm nhất

Session A: SELECT (ACCESS SHARE)          → đang chạy
Session B: ALTER TABLE (ACCESS EXCLUSIVE) → chờ A
Session C: SELECT (ACCESS SHARE)          → chờ B  ← dù compatible với A!
Session D: SELECT                         → chờ B
... tất cả xếp hàng sau B

1 ALTER TABLE trên production có thể:

chờ long-running transaction
→ block toàn bộ request mới
→ connection pool đầy
→ hệ thống đứng

Cách phòng tránh:

sql
SET lock_timeout = '2s';
ALTER TABLE users ADD COLUMN phone2 text;
-- tự hủy nếu chờ quá 2s, không block tiếp

4. Deadlock

Xảy ra khi 2 transaction chờ nhau mãi mãi:

Transaction A giữ lock row 1, chờ row 2
Transaction B giữ lock row 2, chờ row 1
→ cả 2 chờ nhau → không ai tiến được

PostgreSQL tự phát hiện và xử lý

Tự chọn 1 transaction làm victim → rollback → transaction kia tiếp tục.

ERROR 40P01: deadlock detected
Detail: Process 1234 waits for ShareLock on transaction 5678
        Process 5678 waits for ShareLock on transaction 1234

Cách phòng tránh

Luôn lock theo cùng thứ tự:

typescript
// ❌ Nguy hiểm — 2 transaction lock ngược thứ tự nhau
// Transaction A: lock id=1 trước, rồi id=2
// Transaction B: lock id=2 trước, rồi id=1

// ✅ An toàn — luôn lock theo thứ tự id tăng dần
const ids = [1, 2].sort((a, b) => a - b);
for (const id of ids) {
  await manager.findOne(User, {
    where: { id },
    lock: { mode: "pessimistic_write" },
  });
}

Lock tất cả row cần dùng ngay từ đầu transaction:

sql
BEGIN;
SELECT * FROM users WHERE id IN (1, 2) ORDER BY id FOR UPDATE;
-- ORDER BY id đảm bảo luôn lock theo cùng thứ tự
UPDATE users SET tier = 'gold' WHERE id = 1;
UPDATE users SET tier = 'silver' WHERE id = 2;
COMMIT;

5. Monitoring

sql
-- Xem các lock đang active
SELECT pid, locktype, relation::regclass, mode, granted
FROM pg_locks
WHERE relation IS NOT NULL;

-- Xem ai đang block ai
SELECT blocked.pid, blocked.query,
       blocking.pid AS blocking_pid,
       blocking.query AS blocking_query
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
  ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE cardinality(pg_blocking_pids(blocked.pid)) > 0;

-- Kill connection bị treo
SELECT pg_terminate_backend(pid)
FROM pg_stat_activity
WHERE state = 'idle in transaction'
AND now() - query_start > interval '5 minutes';

6. Tóm tắt — khi nào dùng gì

Tình huốngNên dùng
Đọc rồi ghi, không muốn ai chen vàoFOR UPDATE
Đọc để tính toán, không muốn data thay đổiFOR SHARE
Update column thường, vẫn cho insert bảng conFOR NO KEY UPDATE
Bảo vệ foreign keyPostgreSQL tự apply FOR KEY SHARE
Không muốn chờ lock, thà retry ngayFOR UPDATE NOWAIT
Queue processing, nhiều worker song songFOR UPDATE SKIP LOCKED
ALTER TABLE trên productionSET lock_timeout trước
DeadlockLock theo cùng thứ tự, FOR UPDATE sớm ngay đầu transaction

Personal notes by thanhlt