0

PostgreSQL Bài 31: Hệ thống khóa chuyên sâu: Table Locks, Row Locks, Advisory Locks

Mặc dù kiến trúc MVCC (đã học ở Bài 29) đảm bảo "đọc không chặn ghi, ghi không chặn đọc", PostgreSQL vẫn duy trì một hệ thống khóa (Locking System) đồ sộ. Hệ thống này là lớp khiên bảo vệ cấu trúc vật lý của cơ sở dữ liệu khi chạy các lệnh DDL (thêm cột, xóa bảng, tạo index) và cung cấp công cụ khóa bi quan (Pessimistic Locking) cho các nghiệp vụ tài chính, kho hàng đụng độ cực cao.

Đặc biệt với các hệ thống backend chịu tải lớn (viết bằng Golang hay Laravel), việc nắm vững cơ chế SKIP LOCKED hoặc Advisory Locks sẽ giúp bạn thiết kế hàng đợi (Message Queue) hoặc khóa phân tán (Distributed Lock) trực tiếp trên PostgreSQL mà không cần dựng thêm Redis.

1. Table-Level Locks (Khóa cấp độ Bảng)

Mọi câu lệnh truy vấn khi chạm vào một bảng đều tự động xin một loại khóa cấp độ bảng để đảm bảo không ai có thể xóa hoặc sửa cấu trúc bảng đó trong lúc nó đang được đọc/ghi.

PostgreSQL định nghĩa 8 cấp độ khóa bảng. Dưới đây là 4 cấp độ quan trọng nhất bạn thường xuyên gặp phải trên Production:

Tên khóa (Lock Mode) Lệnh kích hoạt tiêu biểu Xung đột với (Block) Tác động thực tế
AccessShareLock SELECT AccessExclusive Rất nhẹ. Cho phép tất cả các lệnh đọc/ghi khác diễn ra song song. Chỉ chặn lệnh xóa/sửa cấu trúc bảng.
RowExclusiveLock INSERT, UPDATE, DELETE Share, AccessExclusive Cho phép đọc song song. Chặn các lệnh tạo Index thông thường (CREATE INDEX không có cờ CONCURRENTLY).
ShareUpdateExclusiveLock VACUUM (không FULL), CREATE INDEX CONCURRENTLY Lệnh DDL, VACUUM khác Rất an toàn trên Production. Không chặn SELECT, INSERT, UPDATE, DELETE.
AccessExclusiveLock DROP TABLE, ALTER TABLE, VACUUM FULL TẤT CẢ MỌI KHÓA Cơn ác mộng Production! Chặn đứng hoàn toàn mọi câu lệnh đọc và ghi vào bảng.

Nguyên tắc "Xếp hàng chết chùm" (Lock Queue Starvation): Giả sử bảng orders đang có hàng ngàn câu lệnh SELECT chạy liên tục. Bạn tung ra một lệnh ALTER TABLE orders ADD COLUMN note TEXT;. Lệnh ALTER này xin khóa AccessExclusiveLock.

  • Nó không thể chạy ngay vì phải chờ các lệnh SELECT cũ nhả khóa AccessShareLock.

  • Tệ hơn, một khi lệnh ALTER đã bước vào hàng đợi xin khóa, toàn bộ các lệnh SELECT hoặc INSERT đến sau nó đều bị chặn lại chờ lệnh ALTER chạy xong. Hệ thống sập cục bộ chỉ trong vài giây.

2. Row-Level Locks (Khóa cấp độ Dòng) và Pessimistic Locking

Khi xử lý các nghiệp vụ nhạy cảm như trừ tiền ví điện tử hoặc trừ tồn kho (Inventory Deduction), bạn không thể để hai request đồng thời đọc cùng một số dư rồi cùng trừ tiền (race condition). Bạn cần khóa chủ động (Pessimistic Locking).

Cú pháp: SELECT ... FOR [MODE]

2.1. Bốn chế độ Khóa dòng

  1. FOR UPDATE:

    • Khóa mạnh nhất. Báo hiệu: "Tôi chuẩn bị UPDATE hoặc DELETE dòng này".

    • Chặn toàn bộ các giao dịch khác muốn FOR UPDATE, FOR SHARE hoặc UPDATE/DELETE cùng dòng đó.

  2. FOR NO KEY UPDATE (Khuyên dùng cho phần lớn trường hợp):

    • Báo hiệu: "Tôi sẽ UPDATE dòng này, nhưng KHÔNG sửa giá trị của Khóa chính/Khóa unique".

    • Nó nhẹ hơn FOR UPDATE vì vẫn cho phép các bảng khác kiểm tra khóa ngoại (Foreign Key) trỏ tới dòng này.

  3. FOR SHARE:

    • Báo hiệu: "Tôi cần đọc dòng này và đảm bảo không ai được phép sửa/xóa nó cho đến khi tôi xong việc".

    • Chặn UPDATE/DELETE, nhưng cho phép các transaction khác cùng FOR SHARE.

  4. KEY SHARE:

    • Yếu nhất, tự động được PostgreSQL sử dụng dưới nền khi nó cần kiểm tra Foreign Key.

2.2. Vũ khí tối thượng: NOWAIT và SKIP LOCKED

Nếu dòng dữ liệu đã bị khóa bởi Transaction A, khi Transaction B gọi SELECT ... FOR UPDATE, mặc định B sẽ bị treo (block) vô thời hạn chờ A nhả khóa.

Để tránh việc các Goroutine hoặc Worker bị treo nghẽn, hãy dùng các cờ phụ trợ:

  • NOWAIT: Nếu dòng đang bị khóa, PostgreSQL lập tức ném lỗi ngay không chờ đợi (ERROR: could not obtain lock on row). Backend có thể catch lỗi này và báo cho user: "Đơn hàng đang được xử lý, thử lại sau".

    SQL

    SELECT balance FROM wallets WHERE user_id = 1 FOR UPDATE NOWAIT;
    
    
  • SKIP LOCKED (Trái tim của Database Queue): Bỏ qua hoàn toàn các dòng đang bị khóa và chỉ trả về các dòng đang rảnh rỗi.

    SQL

    -- Đây chính là câu query cốt lõi đằng sau driver 'database' của Laravel Queue!
    SELECT * FROM jobs 
    WHERE status = 'PENDING' 
    ORDER BY created_at ASC 
    FOR UPDATE SKIP LOCKED 
    LIMIT 1;
    
    

    Nhờ SKIP LOCKED, 100 worker có thể cùng query vào bảng jobs mà không bị đụng độ nhau (mỗi worker sẽ tự động lướt qua job mà worker khác đã khóa và lấy job tiếp theo).

3. Advisory Locks (Khóa tư vấn / Distributed Locks)

Hầu hết lập trình viên khi cần làm Distributed Lock (đảm bảo 1 tiến trình cronjob chạy duy nhất trên toàn cụm server, hoặc giới hạn rate limit) đều vội vã cài đặt Redis (Redlock).

Thực tế, PostgreSQL cung cấp Advisory Locks — hệ thống khóa ở tầng ứng dụng do PostgreSQL quản lý.

  • Nó không gắn với bất kỳ bảng hay dòng nào.

  • Chỉ đơn giản là một "cái tên" (hoặc ID 64-bit) được lưu trên RAM của PostgreSQL. Nó cực kỳ nhẹ và nhanh.

3.1. Phân loại Advisory Locks

  • Session-level (Cấp độ kết nối): Khóa được giữ cho đến khi bạn chủ động nhả ra (pg_advisory_unlock) hoặc kết nối database bị đứt.

  • Transaction-level (Cấp độ giao dịch): Tự động nhả khóa khi Transaction COMMIT hoặc ROLLBACK. Không cần lệnh unlock thủ công (hậu tố _xact_).

3.2. Kịch bản ứng dụng: Chống chạy trùng Cronjob ở kiến trúc Microservices

Giả sử bạn có 3 pod backend (Go) cùng chạy. Cả 3 pod đều kích hoạt một cronjob quét dữ liệu vào lúc 00:00. Để đảm bảo chỉ đúng 1 pod được chạy job này:

Mã giả SQL điều phối bằng Advisory Lock:

SQL

-- 1. Định nghĩa một ID ngẫu nhiên đại diện cho job "DailyReport" (ví dụ: 123456789)
-- 2. Pod gọi hàm try_lock (Không block, trả về boolean ngay lập tức)

SELECT pg_try_advisory_lock(123456789);

  • Pod A gọi trước: Trả về TRUE (Được cấp khóa) ──► Bắt đầu chạy logic quét dữ liệu.

  • Pod B và Pod C gọi chậm hơn 1 mili-giây: Trả về FALSE (Khóa đã có chủ) ──► Thoát ngay, không chạy cronjob nữa.

  • Khi Pod A chạy xong, gọi lệnh giải phóng:

    SQL

    SELECT pg_advisory_unlock(123456789);
    
    

    (Nếu Pod A bị crash chết đột ngột, kết nối TCP đứt, PostgreSQL cũng tự động nhả khóa này ngay lập tức, hoàn toàn không bị kịch bản "dead lock" như khóa file hệ thống).

4. Tóm tắt & Bài tiếp theo

  • Table Locks: Bảo vệ cấu trúc DDL. Lệnh ALTER TABLE là hung thần với AccessExclusiveLock, cần thận trọng khi chạy trên production.

  • Row Locks: FOR UPDATE chặn cập nhật, kết hợp SKIP LOCKED để xây dựng hàng đợi tốc độ cao (Queue/Outbox pattern) triệt tiêu tranh chấp.

  • Advisory Locks: Giải pháp Distributed Lock hoàn hảo, nhẹ gọn, lưu trực tiếp trên RAM của Postgres, thay thế hoàn toàn nhu cầu dựng Redis cho các bài toán khóa ứng dụng phân tán đơn giản.

Bài 32 xem tiếp: Deadlock (Khóa chéo) và Công cụ chẩn đoán (Lock Monitoring) — khóa dữ liệu là con dao hai lưỡi. Chúng ta sẽ tìm hiểu nguyên nhân sinh ra Deadlock (SQLSTATE 40P01), cách PostgreSQL tự động phát hiện và triệt tiêu Deadlock (Deadlock Detector), cùng bộ truy vấn SQL thực chiến để soi xem kết nối nào đang block kết nối nào qua bảng pg_locks và pg_stat_activity.


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí