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
Cần đọc trước
Vấn đề
Phần tiêu đề “Vấn đề”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á?
Chọn gì? (tóm tắt trước)
Phần tiêu đề “Chọn gì? (tóm tắt trước)”| 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.
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”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…
Cách hoạt động
Phần tiêu đề “Cách hoạt động”Optimistic: version column
Phần tiêu đề “Optimistic: version column”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 + 1WHERE id = $1 AND version = $2RETURNING 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.
An mở đơn 101: API trả dữ liệu kèm version = 1 (ETag "1"). Không có transaction nào mở.
- An và Bình cùng giữ ETag
"1"; database không giữ gì. - An lưu: row 101 sang
version = 2. - Bình lưu với
version = 1:UPDATE 0, row không đổi. - API đọc lại, row còn với
version = 2: trả412kèm ETag"2". - 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.
UPDATE 0: không tồn tại hay xung đột?
Phần tiêu đề “UPDATE 0: không tồn tại hay xung đột?”Đọ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 6585const tag = /^"(\d+)"$/.exec(ifMatch); // W/"2" không khớp strong comparisonif (!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.
Pessimistic: SELECT ... FOR UPDATE
Phần tiêu đề “Pessimistic: SELECT ... FOR UPDATE”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.
Atomic UPDATE: thường tốt nhất
Phần tiêu đề “Atomic UPDATE: thường tốt nhất”Trừ tồn không có think time:
UPDATE inventory SET stock = stock - 1WHERE 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.
Chi tiết vận hành
Phần tiêu đề “Chi tiết vận hành”- 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 0thì đọ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:
UPDATEchồng thời gian nhận40001thay vìUPDATE 0; coi cả hai là xung đột. lock_timeoutvàidle_in_transaction_session_timeoutđều mặc định0(tắt). Đặt cả hai khi dùngFOR UPDATE; xem Lock timeouts.xminlàm version? Được khi không thể thêm cột. Nhưngxminlà ID transaction tạo phiên bản row: đổi với mọiUPDATE, 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ộtversion.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”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).
Trade-offs
Phần tiêu đề “Trade-offs”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 UPDATEchỉ 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ì
Checklist production
Phần tiêu đề “Checklist production”- Chuyển đường đọc-sửa-ghi nào gói được trong một câu SQL sang atomic
UPDATE. - Bảng sửa qua form có
version; mọiUPDATEđềuversion = version + 1, kể cả batch job. - API trả
ETag, bắt buộcIf-MatchchoPATCH/PUT(thiếu thì428), sai thì412kèm bản hiện tại. UPDATE 0thì đọc lại để chọn404hay412; chỉ job nền mới retry (3–5 lần, có jitter).- Transaction có
FOR UPDATE: không I/O ngoài database, đặtlock_timeoutvàidle_in_transaction_session_timeout. - Theo dõi tỉ lệ
412và số retry; tăng cao thì cân nhắc atomic hoặc pessimistic.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”| 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 |
Tự kiểm tra
Phần tiêu đề “Tự kiểm tra”- 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? - Vì sao API trừ tồn kho không cần cột
version? - Vì sao
xmingây xung đột giả còn cộtversionthì không?
Tóm tắt
Phần tiêu đề “Tóm tắt”Tham khảo
Phần tiêu đề “Tham khảo”- PostgreSQL 17 — Row-Level Locks:
FOR UPDATEchặn ai. - PostgreSQL 17 — Deadlocks: không chờ người dùng trong transaction.
- PostgreSQL 17 — Read Committed: đánh giá lại
WHERE. - PostgreSQL 17 — System Columns:
xmin. - PostgreSQL 17 — Client Connection Defaults: các timeout.
- RFC 9110:
If-Match(§13.1.1),409(§15.5.10),412(§15.5.13). - RFC 6585 §3:
428 Precondition Required.