Bỏ qua để đến nội dung
Knowledge Hub

Optimistic hay pessimistic locking: chọn theo xung đột và think time Trung cấp

Thời gian đọc ước tính: 9 phút

Nhân viên CSKH An mở đơn 101 trên màn hình admin để đổi địa chỉ. Một phút sau, Bình mở cùng đơn để huỷ. Cả hai sửa vài phút rồi bấm Lưu: người lưu sau ghi đè người lưu trước mà không biết.

Đây là lost update; lịch chạy chi tiết ở Transaction anomalies.

Cùng hệ thống, API trừ tồn kho nhận hàng trăm request mỗi giây vào vài SKU hot. Hai bài toán chống ghi đè này có nên dùng chung một cách khoá?

Tình huống Chọn Điều kiện
Trừ tồn: điều kiện viết được trong một câu SQL Atomic UPDATE Kiểm tra rowCount; 0 là hết hàng, không retry
Đọc, chạy logic app vài ms, rồi ghi; xung đột cao SELECT ... FOR UPDATE Transaction ngắn, không I/O ngoài, có lock_timeout
Người dùng sửa form, think time giây đến phút Version column + If-Match Không giữ transaction qua tương tác người dùng
Job nền đọc-tính-ghi, xung đột thấp Version column + retry Tối đa 3–5 lần, đọc lại mỗi lần

Think time là khoảng giữa đọc và ghi mà ứng dụng chờ thứ ngoài database, thường là người dùng.

Optimistic phát hiện xung đột lúc ghi (UPDATE 0). Pessimistic chặn từ lúc đọc: request sau chờ. Atomic gộp kiểm tra vào chính câu ghi.

Tỉ lệ xung đột quyết định chi phí của optimistic: mỗi xung đột là một lần làm lại. Ở Transaction patterns, 50 request tranh một hot row tốn 189 lần retry.

Đang tải diagram…

Bảng orders có cột version integer NOT NULL DEFAULT 1. API trả version qua ETag khi mở form; khi lưu, một câu vừa kiểm tra vừa ghi:

UPDATE orders SET shipping_address = $3, version = version + 1
WHERE id = $1 AND version = $2
RETURNING version;

Giữa đọc và ghi không có lock nào, nên An và Bình suy nghĩ bao lâu cũng không chặn ai.

Bình lưu với version cũ nhận UPDATE 0 và 412; địa chỉ An vừa sửa không bị ghi đè
Bình lưu với version cũ nhận UPDATE 0 và 412; địa chỉ An vừa sửa không bị ghi đèBước 1/6: An mở đơn 101: API trả dữ liệu kèm version = 1 (ETag "1"). Không có transaction nào mở.thời gianthink time, không lock …GET /orders/101ETag "1"An (CSKH)sửa địa chỉorders 101PostgreSQLBình (CSKH)huỷ đơn
Bước 1/6

An mở đơn 101: API trả dữ liệu kèm version = 1 (ETag "1"). Không có transaction nào mở.

  1. An và Bình cùng giữ ETag "1"; database không giữ gì.
  2. An lưu: row 101 sang version = 2.
  3. Bình lưu với version = 1: UPDATE 0, row không đổi.
  4. API đọc lại, row còn với version = 2: trả 412 kèm ETag "2".
  5. Bình xem bản mới, lưu với If-Match: "2": version = 3.

Hai UPDATE chồng thời gian vẫn đúng. Ở Read Committed, câu sau chờ transaction đầu commit rồi đánh giá lại WHERE trên bản mới. Optimistic vẫn có row lock, nhưng chỉ trong một câu.

Đọc lại một lần để chọn mã:

Đọc lại Nghĩa HTTP
Không có row Không tồn tại 404
Version khác Xung đột 412 nếu dùng If-Match; 409 nếu version gửi trong body

RFC 9110 §13.1.1: khi If-Match sai, server không được thực hiện method và có thể trả 412. So khớp dùng strong comparison, nên ETag là "2", không phải W/"2". 409 (§15.5.10) dành cho xung đột người dùng có thể tự giải quyết.

Lưu đơn hàng từ màn hình admin

Hay gặp

const { rows } = await pool.query('SELECT version FROM orders WHERE id = $1', [id]);
if (rows[0].version !== expected) return res.status(409).end();
await pool.query('UPDATE orders SET shipping_address = $2 WHERE id = $1', [id, address]);

Nên viết

const ifMatch = req.get('If-Match');
if (!ifMatch) return res.status(428).end(); // RFC 6585
const tag = /^"(\d+)"$/.exec(ifMatch); // W/"2" không khớp strong comparison
if (!tag) return res.status(412).end();
const upd = await pool.query(
`UPDATE orders SET shipping_address = $3, version = version + 1
WHERE id = $1 AND version = $2 RETURNING version`,
[id, Number(tag[1]), address],
);
if (upd.rowCount === 1) return res.set('ETag', `"${upd.rows[0].version}"`).end();
const cur = await pool.query('SELECT version FROM orders WHERE id = $1', [id]);
if (cur.rowCount === 0) return res.status(404).end();
return res.set('ETag', `"${cur.rows[0].version}"`).status(412).end();

Vì sao

Bản sai kiểm tra rồi ghi bằng hai câu (check-then-act) và không tăng version. Bản đúng gộp kiểm tra vào WHERE, chỉ đọc lại khi thất bại.

Body 412 nên theo Problem Details, kèm bản hiện tại.

FOR UPDATE khoá row đến cuối transaction: UPDATE, DELETE, FOR UPDATE khác phải chờ, SELECT thường vẫn đọc được (xem Row-level locks). Hợp khi logic giữa đọc và ghi chỉ vài mili giây trong app.

Trừ tồn không có think time:

UPDATE inventory SET stock = stock - 1
WHERE sku = $1 AND stock >= 1 RETURNING stock;

Không version, không retry: UPDATE 0 là hết hàng. Request đồng thời chỉ xếp hàng trong một câu.

  • Mọi đường ghi phải tăng version: API, batch job, script sửa tay; có thể ép bằng trigger BEFORE UPDATE.
  • Retry chỉ cho máy: job nền gặp UPDATE 0 thì đọc lại, tính lại, tối đa 3–5 lần với backoff và jitter. Thay đổi của người dùng không tự retry: đó chính là ghi đè.
  • Repeatable Read: UPDATE chồng thời gian nhận 40001 thay vì UPDATE 0; coi cả hai là xung đột.
  • lock_timeout và idle_in_transaction_session_timeout đều mặc định 0 (tắt). Đặt cả hai khi dùng FOR UPDATE; xem Lock timeouts.
  • xmin làm version? Được khi không thể thêm cột. Nhưng xmin là ID transaction tạo phiên bản row: đổi với mọi UPDATE, kể cả câu không đổi dữ liệu (xung đột giả), và là số 32-bit có thể quay vòng. Ưu tiên cột version.

Demo trên PostgreSQL 17.11 (schema demo_optimistic_locking), mỗi session một process psql; đơn 101 bắt đầu version = 1.

1. Optimistic. An và Bình cùng đọc version = 1; An lưu trước:

-- An: ... WHERE id = 101 AND version = 1 RETURNING version;
version
---------
2
(1 row)
UPDATE 1
-- Bình: ... WHERE id = 101 AND version = 1 RETURNING version;
version
---------
(0 rows)
UPDATE 0

Đọc lại id 101 trả version = 2 (412); cùng câu với id 999 cũng UPDATE 0 nhưng đọc lại 0 rows (404). Khi An chưa commit, UPDATE của Bình chờ khoảng 1,97 s rồi vẫn nhận UPDATE 0.

2. Pessimistic. An BEGIN; SELECT ... FOR UPDATE; rồi để client ngồi yên 4 s; Bình chạy cùng câu. Session thứ ba thấy:

application_name | state | wait_event_type | wait_event | blocked_by
------------------+---------------------+-----------------+---------------+------------
session_1_an | idle in transaction | Client | ClientRead | {}
session_2_binh | active | Lock | transactionid | {16803}

Bình xong sau 2984.470 ms, đúng lúc An commit; SELECT thường trả ngay. Session giữ lock không làm gì: giá của think time trong transaction.

3. Atomic và xmin. SKU-7 còn 1: UPDATE trừ tồn lần đầu UPDATE 1, lần hai UPDATE 0. Câu SET note = note trên đơn 101 đổi xmin từ 3126 sang 3139, version giữ 2 (giá trị xmin khác mỗi lần chạy).

Version column được gì

  • Không giữ lock hay connection trong think time.

  • Ánh xạ thẳng sang HTTP: ETag, If-Match, 412.

  • Rẻ khi xung đột hiếm; các form không chờ nhau.

  • < slot="cons">
  • Hot row: retry bùng nổ, trong khi FOR UPDATE chỉ xếp hàng.

  • Quên tăng version ở một đường ghi là quay lại lost update.

  • Người dùng phải xem bản mới và sửa lại.

Version column mất gì

  1. Chuyển đường đọc-sửa-ghi nào gói được trong một câu SQL sang atomic UPDATE.
  2. Bảng sửa qua form có version; mọi UPDATE đều version = version + 1, kể cả batch job.
  3. API trả ETag, bắt buộc If-Match cho PATCH/PUT (thiếu thì 428), sai thì 412 kèm bản hiện tại.
  4. UPDATE 0 thì đọc lại để chọn 404 hay 412; chỉ job nền mới retry (3–5 lần, có jitter).
  5. Transaction có FOR UPDATE: không I/O ngoài database, đặt lock_timeout và idle_in_transaction_session_timeout.
  6. Theo dõi tỉ lệ 412 và số retry; tăng cao thì cân nhắc atomic hoặc pessimistic.
Cách hiểu sai Hệ quả Cần làm
Optimistic là không có lock nào Bất ngờ khi UPDATE chờ nhau Lock vẫn có, chỉ trong một câu
UPDATE 0 luôn là xung đột Trả 412 cho đơn đã xoá Đọc lại, phân biệt 404
Gặp 412 thì server tự retry Ghi đè đúng thứ cần chặn Trả cho người dùng quyết định
  1. An chưa commit thì Bình gửi UPDATE ... WHERE version = 1. Vì sao Bình chờ, và vì sao kết quả là UPDATE 0?
  2. Vì sao API trừ tồn kho không cần cột version?
  3. Vì sao xmin gây xung đột giả còn cột version thì không?