Optimistic locking — cơ chế, cách implement, và bẫy khi dùng updated_at làm row version
Hai người cùng sửa một bản ghi. Người lưu sau đè mất thay đổi của người lưu trước, không ai biết. Đó là lost update (mất cập nhật) — bài toán mà optimistic locking sinh ra để giải.
Bài này trả lời ba câu: cơ chế thật chạy thế nào, implement ra sao trong PostgreSQL +
Laravel, và câu hay bị hỏi nhất — dùng luôn cột updated_at có sẵn làm
row version được không?
Trả lời ngắn cho câu thứ ba: về lý thuyết được, nhưng với Laravel mặc định thì không an toàn. Đã test trên PostgreSQL 18.4: hai update song song đều báo thành công, một cái bị mất im lặng. Chi tiết ở mục 3.
Mọi output SQL trong bài đều chạy thật trên PostgreSQL 18.4
(container my-test-pgdb của repo postgresql-learning), không phải chép
từ trí nhớ. Chỗ nào chưa verify được sẽ ghi rõ.
Thuật ngữ dùng trong bài
| Thuật ngữ | Nghĩa ngắn |
|---|---|
| lost update | Hai người ghi đè nhau, thay đổi của người trước biến mất không dấu vết. |
| optimistic locking | Khoá lạc quan — không khoá gì lúc đọc, chỉ kiểm tra lúc ghi xem row còn nguyên không. |
| pessimistic locking | Khoá bi quan — khoá row ngay lúc đọc, ai đến sau phải nằm chờ. |
| affected rows | Số dòng một câu UPDATE thật sự sửa. Đây là tín hiệu phát hiện conflict. |
| READ COMMITTED | Isolation level mặc định của PostgreSQL — mỗi câu lệnh nhìn thấy dữ liệu đã commit tại thời điểm câu lệnh đó bắt đầu. |
| EvalPlanQual (EPQ) | Cơ chế PostgreSQL đánh giá lại điều kiện WHERE trên phiên bản row mới sau khi phải chờ transaction khác commit. |
| clock skew | Đồng hồ các máy chủ lệch nhau vài chục tới vài trăm mili giây. |
1. Nó là gì / chạy thế nào
Optimistic locking đặt cược rằng đụng độ hiếm. Không khoá gì lúc đọc. Lúc ghi mới hỏi database một câu duy nhất: "row này còn nguyên như lúc tôi đọc không?"
Toàn bộ cơ chế nằm gọn trong một câu UPDATE ... WHERE, và thứ mang thông tin
về là số dòng bị ảnh hưởng:
UPDATE products
SET stock = 90, version = version + 1
WHERE id = 1 AND version = 0; -- version đọc được lúc nãy
-- affected = 1 → ghi thành công
-- affected = 0 → có người sửa trước, đây là conflict
sequenceDiagram participant A as Session A participant DB as PostgreSQL participant B as Session B A->>DB: SELECT stock, version - nhan 100, v=0 B->>DB: SELECT stock, version - nhan 100, v=0 Note over A,B: Ca hai cung cam v=0 A->>DB: UPDATE ... WHERE version = 0 DB-->>A: UPDATE 1 - version thanh 1 A->>DB: COMMIT B->>DB: UPDATE ... WHERE version = 0 Note over DB,B: Row dang bi A khoa nen B phai CHO DB-->>B: UPDATE 0 - doc lai thay version = 1 B->>B: Conflict nen doc lai row roi retry
Vì sao B chắc chắn nhận UPDATE 0
Đây là chỗ hay bị trả lời hời hợt trong phỏng vấn. Ở READ COMMITTED, khi B chạm phải row
đang bị A khoá, B không bỏ qua và cũng không đọc bản cũ. B chờ
A commit, rồi đọc lại row ở phiên bản mới nhất và kiểm tra lại điều kiện
WHERE một lần nữa — cơ chế này gọi là EvalPlanQual (EPQ).
Lúc đó version đã là 1, điều kiện version = 0 trượt, nên B sửa 0 dòng.
Hệ quả thực tế ít người để ý: conflict không hề rẻ. B vẫn phải trả tiền chờ A commit xong mới biết mình thua. Dưới tranh chấp cao, optimistic locking không nhanh hơn pessimistic bao nhiêu — xem mục 6.
Test thật
Bảng tmp_ol4, cột version int, hai session song song cùng đọc
version = 0, một bên trừ 10 một bên trừ 30:
----- A: -10 guard version=0 ----- ----- B: -30 guard version=0 -----
BEGIN BEGIN
UPDATE 1 UPDATE 0
COMMIT COMMIT
id | stock | version
----+-------+---------
1 | 90 | 1 ← đúng: chỉ 1 trong 2 lệnh thắng
So với pessimistic locking
sequenceDiagram participant A as Session A participant DB as PostgreSQL participant B as Session B A->>DB: SELECT ... FOR UPDATE DB-->>A: Tra row va khoa row luon B->>DB: SELECT ... FOR UPDATE Note over DB,B: B nam cho ngay tu luc DOC A->>DB: UPDATE roi COMMIT DB-->>B: Tra row ban moi - da tru B->>DB: UPDATE roi COMMIT Note over A,B: Khong conflict, khong retry - doi lai la cho
| Optimistic | Pessimistic (FOR UPDATE) | |
|---|---|---|
| Lúc đọc | không khoá | khoá row |
| Đụng độ | phát hiện sau, phải retry | ngăn trước, phải chờ |
| Giao dịch có bước người dùng suy nghĩ | dùng được | không — giữ lock qua nhiều HTTP request là thảm hoạ |
| Tranh chấp cao trên một row | retry bão, có thể starvation (một request thua mãi) | xếp hàng, chậm nhưng chắc |
| Deadlock | gần như không | có, nếu khoá nhiều row không theo thứ tự cố định |
| Chi phí khi không có đụng độ | gần bằng 0 | vẫn phải xin và giữ lock |
2. Cách implement
2.1. SQL thuần
ALTER TABLE products ADD COLUMN version bigint NOT NULL DEFAULT 0;
-- đọc
SELECT id, stock, version FROM products WHERE id = 1;
-- ghi, kèm guard
UPDATE products
SET stock = 90, version = version + 1
WHERE id = 1 AND version = 0;
Không cần index riêng cho version: WHERE id = ? AND version = ? đã
đi vào primary key, cột version chỉ là filter thêm trên đúng một row.
2.2. Laravel
Đã verify trên Laravel 12.64.0: framework không có optimistic
locking dựng sẵn. Tài liệu chính thức chỉ có phần pessimistic locking
(lockForUpdate(), sharedLock(), và refreshForUpdate() để
nạp lại model kèm khoá bên trong transaction). Phần optimistic phải tự viết — may là chỉ vài dòng,
vì ->update() của query builder đã trả về số dòng đã đổi.
class StaleModelException extends \RuntimeException {}
function saveWithVersion(Product $p, array $data): void
{
$affected = Product::whereKey($p->id)
->where('version', $p->version)
->update($data + [
'version' => $p->version + 1,
'updated_at' => now(), // xem ghi chú ngay dưới
]);
if ($affected === 0) {
throw new StaleModelException("Product {$p->id} đã bị người khác sửa");
}
}
Bẫy nhỏ nhưng hay dính:
Model::where(...)->update()đi qua query builder, không qua Eloquent event, nên không tự setupdated_at. Muốn giữ cột đó đúng thì phải thêm tay như trên.
2.3. Retry — phần hay bị quên
flowchart TD
R["Đọc LẠI row (lấy version mới)"] --> U["UPDATE ... WHERE version = ?"]
U --> C{"affected = 1?"}
C -->|"có"| DONE["Xong"]
C -->|"không"| N{"Còn lượt retry?"}
N -->|"còn"| S["Ngủ ngắn có jitter"] --> R
N -->|"hết"| F["Trả lỗi cho người dùng / đẩy sang hàng đợi"]
foreach (range(1, 3) as $attempt) {
$p = Product::find($id); // đọc LẠI, bắt buộc
try {
saveWithVersion($p, ['stock' => $p->stock - $qty]);
return;
} catch (StaleModelException) {
usleep(random_int(5, 20) * 1000 * $attempt);
}
}
throw new \RuntimeException('Hệ thống đang bận, thử lại sau');
Ba lỗi retry hay gặp:
- Không đọc lại row → retry mãi mãi với version cũ, lần nào cũng thua.
- Retry bên trong transaction → transaction có thể đã abort, hoặc vẫn nhìn
snapshot cũ. Vòng retry phải bọc ngoài
DB::transaction(). - Retry đều nhau, không jitter (không thêm độ trễ ngẫu nhiên) → nhiều request thua đồng loạt rồi đụng lại đúng cùng lúc, thành sóng.
2.4. Chống "quên tăng version" — để database tự làm
Guard chỉ đáng tin nếu mọi đường ghi đều tăng version. App quên một chỗ, một script import, một lần sửa tay bằng psql — là cơ chế nói dối. Cách chắc nhất là đẩy việc tăng version xuống trigger:
CREATE FUNCTION bump_version() RETURNS trigger AS $$
BEGIN NEW.version = OLD.version + 1; RETURN NEW; END $$ LANGUAGE plpgsql;
CREATE TRIGGER products_bump BEFORE UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION bump_version();
Output thật — câu UPDATE dưới đây không hề nhắc tới cột version:
--- app quên tăng version, trigger vẫn tăng ---
UPDATE 1
id | stock | version
----+-------+---------
1 | 90 | 1
--- guard bằng version cũ -> phải UPDATE 0 ---
UPDATE 0
--- guard bằng version mới -> UPDATE 1 ---
UPDATE 1
id | stock | version
----+-------+---------
1 | 80 | 2
Cái giá: thêm một trigger phải bảo trì, và mọi UPDATE đều tốn thêm một lần
gọi hàm plpgsql. Với bảng ghi cực nóng (hàng chục nghìn update/giây) thì cân nhắc; với bảng
nghiệp vụ bình thường thì không đáng lo.
2.5. Version phải đi hết một vòng
flowchart LR DB1["DB: version=7"] --> SRV["Server đọc row"] SRV --> FORM["Form / API response
mang theo version=7"] FORM --> USER["Người dùng sửa"] USER --> POST["POST kèm version=7
(hidden input hoặc If-Match)"] POST --> GUARD["UPDATE ... WHERE version = 7"] GUARD --> DB2["DB: version=8"]
Client quên gửi lại version là toàn bộ cơ chế tắt lặng lẽ. Xử lý đúng:
thiếu version thì trả 400, tuyệt đối không mặc định
version = 0 và cũng không bỏ qua guard.
3. Dùng updated_at làm row version — phân tích đầy đủ
Ý tưởng rất hấp dẫn: cột đã có sẵn, không cần migration, không phải đụng code cũ.
UPDATE products SET price = 120000, updated_at = now()
WHERE id = 1 AND updated_at = '2026-09-08 09:07:44';
3.1. Vì sao Laravel mặc định thì hỏng
Hai mảnh ghép lại thành lỗ hổng:
(a) Eloquent ghi thời gian ở độ phân giải GIÂY. Trong
Illuminate/Database/Grammar.php:283 (Laravel 12.64.0):
public function getDateFormat() { return 'Y-m-d H:i:s'; } // không có .u
fromDateTime() format updated_at bằng đúng chuỗi này trước khi gửi
xuống DB. Cột trong PostgreSQL là timestamp (chứa được microsecond) nhưng
giá trị Laravel ghi vào chỉ tới giây. Đây là cấp framework, không phải cấu
hình của bạn.
(b) Trong cùng một giây, guard mù hoàn toàn. Test thật, cột
timestamp(0), hai session cùng đọc updated_at = 2026-09-08 09:07:44,
một bên trừ 10 một bên trừ 30 từ mức 100:
----- A: stock -10 ----- ----- B: stock -30 -----
BEGIN BEGIN
UPDATE 1 UPDATE 1 ← lẽ ra phải là 0
COMMIT COMMIT
id | stock | updated_at
----+-------+---------------------
1 | 60 | 2026-09-08 09:07:44 ← đúng phải là 90
Cả hai câu đều báo thành công. Mất một update, không lỗi, không log, không ai biết.
Cùng kịch bản đó với cột timestamptz đầy đủ microsecond và
clock_timestamp() thì B trả UPDATE 0 và stock = 90 — đúng.
sequenceDiagram participant A as Session A participant DB as products - cot timestamp 0 participant B as Session B A->>DB: doc updated_at = 09:07:44 B->>DB: doc updated_at = 09:07:44 A->>DB: UPDATE ... WHERE updated_at = 09:07:44 DB-->>A: UPDATE 1 - ghi lai updated_at = 09:07:44 Note over DB: Cung giay nen gia tri KHONG doi B->>DB: UPDATE ... WHERE updated_at = 09:07:44 DB-->>B: UPDATE 1 - guard khong thay gi bat thuong Note over A,B: Thay doi cua A bien mat
3.2. Bảng bẫy đầy đủ
| Bẫy | Chi tiết | Trạng thái |
|---|---|---|
| Laravel ghi chỉ tới giây | Grammar::getDateFormat() trả 'Y-m-d H:i:s', dùng cho mọi model |
✅ verify từ source Laravel 12.64.0 |
| Hai ghi trong cùng giây không bị phát hiện | Test ở trên: stock = 60 thay vì 90 |
✅ verify PostgreSQL 18.4 |
now() KHÔNG đổi trong một transaction |
Hai UPDATE trong cùng transaction đều ra 09:08:18.598513+00.
now() là thời điểm transaction bắt đầu. Muốn giá trị khác nhau phải
dùng clock_timestamp() |
✅ verify PostgreSQL 18.4 |
timestamp(0) LÀM TRÒN, không cắt |
09:07:02.759537 lưu thành 09:07:03 — timestamp nằm ở
"tương lai" so với thời điểm ghi thật |
✅ verify PostgreSQL 18.4 |
$model->save() không dirty thì không sinh câu UPDATE nào |
Model.php:1315: if (count($dirty) > 0). Không đổi gì thì
updated_at cũng không nhích |
✅ verify từ source |
DB::table()->update() không đụng updated_at |
Mọi raw update, script import, job cũ đều làm guard mù | ✅ verify từ source |
| Clock skew giữa nhiều app server | Laravel sinh thời gian ở PHP chứ không phải ở DB. Hai server lệch nhau vài trăm ms
thì updated_at mới có thể nhỏ hơn giá trị cũ |
⚠️ chưa verify — cần hai máy lệch giờ thật |
| Mất phần lẻ khi timestamp đi vòng qua API | timestamp vs timestamptz, serialize ra JSON rồi parse lại,
timezone khác nhau — dễ lệch đúng ở phần microsecond mà guard đang dựa vào |
⚠️ chưa verify |
3.3. Nếu vẫn muốn dùng updated_at
Cần đủ cả ba, thiếu một là hỏng:
- Cột
timestamptz, không đặt precision (mặc định microsecond). - Trên model:
protected $dateFormat = 'Y-m-d H:i:s.u'; - Để database sinh thời gian bằng
clock_timestamp()ngay trong câu UPDATE, không để PHP sinh — như vậy mới bỏ được clock skew.
Tới bước này bạn đã viết nhiều code và nhớ nhiều thứ hơn là chỉ thêm một cột
version bigint.
Khuyến nghị: dùng
version bigint+ trigger. Rẻ hơn, không dính bẫy nào ở trên, và khi debug thì log đọc ra ngay ("version 47 → 48") thay vì phải soi microsecond.Ngoại lệ hợp lý duy nhất của
updated_at: conflict giữa hai người dùng sửa cùng một form — khoảng cách tính bằng giây tới phút, độ phân giải giây là đủ, và bạn còn hiển thị được cho người dùng "bản ghi đã bị sửa lúc 10:32". Không dùng cho luồng máy-với-máy (job, API, webhook) — đó đúng là chỗ hai lệnh va nhau trong cùng mili giây.
4. Lợi ích
- Không giữ lock qua thời gian người dùng suy nghĩ. Đây là lợi ích lớn nhất.
Form sửa hồ sơ mở 10 phút mà giữ
FOR UPDATEthì cả hệ thống chờ theo. - Gần như miễn phí khi không có đụng độ. Chỉ thêm một điều kiện vào
WHEREtrên câu update vốn đã chạy. - Không deadlock. Không giữ lock lâu thì không có vòng chờ vòng tròn.
- Hoạt động xuyên request, xuyên service. Version là dữ liệu, gửi qua HTTP được — lock thì không.
- Cho phép hiển thị conflict tử tế. Biết chính xác row đã đổi nên UI có thể hiện diff và để người dùng chọn, thay vì âm thầm đè.
5. Tác hại / hạn chế
- Conflict là lỗi im lặng.
UPDATE 0không ném exception. Quên kiểm$affectedthì code chạy "thành công" mà dữ liệu không đổi — tệ hơn cả lost update, vì bạn tưởng đã bảo vệ. - Công việc đã làm bị vứt đi. Retry nghĩa là tính lại từ đầu. Nếu giữa đó có gọi API bên thứ ba thì retry có thể gọi hai lần.
- Tranh chấp cao thì thoái hoá. Một SKU hot trong flash sale: mỗi request đều đọc, thua, đọc lại, thua tiếp. CPU tăng, throughput giảm, có request thua mãi (starvation).
- Conflict vẫn phải trả tiền chờ. Như mục 1: B chờ A commit rồi mới biết mình thua.
- Chỉ bảo vệ trong phạm vi một row. Ràng buộc kiểu "tổng số ghế đã đặt không vượt sức chứa" trải trên nhiều row thì version từng row không cứu được — cần constraint, hoặc khoá ở mức cao hơn.
- Phải sửa cả UI/API. Không phải chuyện chỉ nằm trong database.
6. Trade-off — chọn cái nào
flowchart TD
START["Cần chống ghi đè lên cùng một row"] --> ATOMIC{"Update viết lại được
thành 1 câu nguyên tử?
ví dụ SET stock = stock - 1"}
ATOMIC -->|"được"| A1["Dùng luôn câu UPDATE đó
+ điều kiện WHERE stock >= 1
Không cần lock, không cần version"]
ATOMIC -->|"không"| USER{"Giữa đọc và ghi có
bước người dùng suy nghĩ?"}
USER -->|"có"| OPT["Optimistic locking
(version bigint)"]
USER -->|"không"| HOT{"Nhiều request cùng đánh
đúng một row?"}
HOT -->|"hiếm khi"| OPT
HOT -->|"thường xuyên"| PES["Pessimistic locking
SELECT ... FOR UPDATE"]
| Tình huống | Chọn | Vì sao |
|---|---|---|
| Sửa form dài, có bước người dùng suy nghĩ | Optimistic | Không ai giữ lock qua HTTP request |
| Trừ tồn kho lúc flash sale, một SKU hot | Câu UPDATE nguyên tử, hoặc pessimistic | Optimistic sẽ retry bão; SET stock = stock - 1 WHERE stock >= 1 còn không cần lock nào |
| Chuyển tiền giữa hai tài khoản | Pessimistic, khoá theo thứ tự id tăng dần | Tránh deadlock; tiền không được retry mù |
| Sửa hồ sơ / bài viết / cấu hình | Optimistic | Đụng độ hiếm, retry rẻ |
| Ghi hàng loạt từ queue worker | Optimistic + retry, hoặc guard bằng chính trạng thái | Worker retry là chuyện thường, cơ chế đã có sẵn |
| Ràng buộc trải trên nhiều row | Constraint / unique index | Version từng row không diễn tả được ràng buộc tổng |
Tiêu chí quyết định gọn: so P(đụng độ) × chi phí retry với chi phí giữ lock. Đụng độ dưới khoảng 5% thì optimistic gần như luôn thắng. Trên khoảng 30% trên cùng một row thì optimistic biến thành máy đốt CPU. (Hai con số này là mốc kinh nghiệm để ước lượng, không phải kết quả đo — hãy đo trên hệ thống của bạn.)
Cửa thứ ba hay bị bỏ qua: viết lại query cho nguyên tử thì không cần lock nào cả.
UPDATE products SET stock = stock - 1 WHERE id = ? AND stock >= 1giải quyết trọn bài toán tồn kho — một câu, không retry, không version, không trigger. Trước khi thêm cộtversion, luôn hỏi trước: update này có viết được dạng đó không?
Cửa thứ tư: SERIALIZABLE có thay được cột version không?
Câu hỏi hay gặp: PostgreSQL đã có isolation level SERIALIZABLE tự bắt lost
update rồi, sao còn phải thêm cột? Trả lời ngắn: được, nhưng chỉ khi việc ĐỌC và việc
GHI nằm trong cùng một transaction. Luồng web bình thường không như vậy.
sequenceDiagram participant A as User A participant DB as PostgreSQL participant B as User B A->>DB: BEGIN SERIALIZABLE - SELECT - COMMIT Note over A,DB: GET form, transaction dong ngay B->>DB: BEGIN SERIALIZABLE - SELECT - COMMIT Note over B,DB: B cung doc ban cu B->>DB: BEGIN SERIALIZABLE - UPDATE - COMMIT A->>DB: BEGIN SERIALIZABLE - UPDATE - COMMIT Note over A,B: Bon transaction, khong cai nao chong nhau
PostgreSQL khong thay conflict - ban cua B mat im lang
SSI (Serializable Snapshot Isolation — cơ chế đằng sau SERIALIZABLE của
PostgreSQL) chỉ so các transaction đang sống chồng thời gian với nhau, qua
predicate lock (SIReadLock — khoá ghi nhớ "transaction này đã đọc phạm vi nào").
Transaction đọc đã commit xong từ 5 phút trước thì không còn gì để so. Cột version
thì khác: nó là "cái tôi đã đọc" được mang theo trong form, nên sống xuyên qua
nhiều HTTP request.
thời gian ───────────────────────────────────►
SERIALIZABLE bảo vệ: [ BEGIN ... đọc ... ghi ... COMMIT ]
└────── chỉ trong khung này ──────┘
version bảo vệ: GET(v=7) ····· user gõ 3 phút ····· POST(v=7)
└────────── xuyên suốt cả khung này ──────────┘
Test 1 — hai transaction chồng nhau: SERIALIZABLE bắt được
----- Tx1 ----- ----- Tx2 -----
BEGIN ISOLATION LEVEL SERIALIZABLE (sleep 0.2s)
SELECT body FROM tmp_docs WHERE id=1 BEGIN ISOLATION LEVEL SERIALIZABLE
(sleep 1s) SELECT body FROM tmp_docs WHERE id=1
UPDATE tmp_docs SET body='Tx1 sua' UPDATE 1
ROLLBACK COMMIT
ERROR: could not serialize access due to concurrent update
Chú ý lỗi ở đây là concurrent update — đó là luật first-updater-wins của
REPEATABLE READ,SERIALIZABLEchỉ thừa hưởng. Chưa cần tới SSI. Nghĩa là với một row đơn thìREPEATABLE READđã đủ, không cần trả giá của SSI.
Test 2 — luồng web thật (đọc ở request này, ghi ở request sau): SERIALIZABLE thua
User A: BEGIN SER; SELECT; COMMIT; [user gõ 1 giây] BEGIN SER; UPDATE; COMMIT;
User B: BEGIN SER; SELECT; COMMIT; BEGIN SER; UPDATE; COMMIT;
----- User A ----- BEGIN UPDATE 1 COMMIT ← không lỗi
----- User B ----- BEGIN UPDATE 1 COMMIT ← không lỗi
id | body | version
----+----------------+---------
1 | A viet lai het | 3 ← bản của B bị ghi đè im lặng: LOST UPDATE
Test 3 — đúng luồng đó, dùng cột version, chỉ READ COMMITTED, không cần transaction
UPDATE tmp_docs SET body = '...', version = version + 1 WHERE id = 1 AND version = 1;
----- User A ----- UPDATE 0 ← app biết mình thua, hiện màn hình conflict
----- User B ----- UPDATE 1
Vậy SERIALIZABLE được việc gì?
Nó bắt loại lỗi mà cột version không bắt được: write
skew (hai transaction đọc cùng một điều kiện rồi ghi hai row khác nhau, mỗi
cái đúng riêng lẻ nhưng sai khi ghép lại). Ví dụ hai người cùng đặt một phòng trùng giờ — cả
hai đều thấy "chưa ai đặt" rồi cùng INSERT:
----- A ----- count 0 → INSERT 0 1 COMMIT
----- B ----- count 0 → ERROR: could not serialize access due to read/write dependencies
DETAIL: Reason code: Canceled on identification as a pivot, during write.
Cột version vô dụng ở đây vì hai người ghi hai row mới, chẳng đụng row nào có
sẵn. Nhưng cùng bài toán đó, một EXCLUDE constraint (ràng buộc loại trừ — chặn hai
row có khoảng thời gian đè lên nhau) làm được ở READ COMMITTED, rẻ hơn nhiều:
CREATE EXTENSION IF NOT EXISTS btree_gist;
CREATE TABLE bookings (
id bigserial PRIMARY KEY,
room int,
span tstzrange,
EXCLUDE USING gist (room WITH =, span WITH &&)
);
----- A ----- INSERT 0 1 COMMIT
----- B ----- ERROR: conflicting key value violates exclusion constraint
"bookings_room_span_excl"
Cái giá của SERIALIZABLE (số đo thật trên PostgreSQL 18.4)
| Vấn đề | Đo được | Ý nghĩa |
|---|---|---|
| Tỉ lệ abort khi tranh chấp | 20 transaction song song trên cùng một row → 10 bị 40001 (50%) | Retry bão. Optimistic trên cùng cảnh đó chỉ trả UPDATE 0, không tốn transaction nào |
| False positive theo query plan | Hai transaction của hai shop khác nhau, không đụng nhau: seq scan → cả hai xung đột 40001; thêm index trên shop → cả hai commit bình thường |
Bẫy nặng nhất. ANALYZE làm đổi plan lúc 2h sáng → tỉ lệ 40001 tăng vọt mà code không đổi một dòng |
| Predicate lock leo cấp | pg_locks → SIReadLock | relation | 1 (khoá cả bảng, không phải từng row) |
Mặc định max_pred_locks_per_page = 2, max_pred_locks_per_transaction = 64. Vượt ngưỡng là leo từ row lên page rồi lên relation → càng nhiều false positive |
| Không chạy được trên replica | ERROR: cannot use serializable mode in a hot standbyHINT: You can use REPEATABLE READ instead. |
App tách read/write connection (đọc từ standby) thì phần đọc đó không được bảo vệ |
| Thủng nếu lẫn isolation level | — | Bảo đảm chỉ đúng khi mọi transaction đụng vào dữ liệu đó đều SERIALIZABLE. Một job cron chạy READ COMMITTED là hỏng cả cơ chế |
| PgBouncer transaction pooling | chưa verify | SET default_transaction_isolation ở mức session không dính khi connection bị dùng chung. Phải set trong từng transaction |
Phía Laravel
Laravel không có API cho isolation level. Phải tự chạy câu SQL, và nó phải là câu đầu tiên trong transaction:
DB::transaction(function () {
DB::statement('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE'); // phải là câu ĐẦU TIÊN
$doc = Document::find($id);
$doc->update([...]);
}, attempts: 5);
PostgreSQL bắt buộc câu này chạy trước query đầu tiên, nếu không:
SET TRANSACTION ISOLATION LEVEL must be called before any query (nguồn:
check_transaction_isolation() trong src/backend/commands/variable.c).
attempts:có retry 40001 —ConcurrencyErrorDetectorkiểm tra$e->getCode() === '40001'trước khi so chuỗi lỗi, vàQueryExceptioncopy code từPDOException. (Đọc trên Laravel 12.64.0.)- Bẫy: retry chạy lại cả closure. Có
Mail::send()hay gọi API bên ngoài trong đó là gửi hai lần. Đẩy side-effect raDB::afterCommit()hoặc dispatch job sau commit.
Kết luận chọn cái nào
| Tình huống | Dùng gì |
|---|---|
| Form web: đọc ở request này, ghi ở request sau | Cột version — SERIALIZABLE không cứu được |
| Đọc – tính – ghi gọn trong một transaction, trải nhiều row/bảng | SERIALIZABLE, hoặc FOR UPDATE khoá theo thứ tự id |
| Trùng lịch / trùng khoảng (write skew) | EXCLUDE constraint trước, SERIALIZABLE sau |
| Trừ tồn kho, tăng counter | Một câu UPDATE nguyên tử — không cần cả hai |
| Tranh chấp cao trên một row nóng | Pessimistic (FOR UPDATE); SERIALIZABLE ở đây abort tới 50% |
Cách nhớ:
SERIALIZABLEbảo vệ bên trong một transaction. Cộtversionbảo vệ xuyên qua nhiều request. Hai cái giải hai bài toán khác nhau, không thay thế nhau. Hệ thống có form dài thì vẫn cầnversiondù đã bật SERIALIZABLE.
7. Khó khăn khi vận hành thực tế
- Cần metric ngay từ đầu. Đếm
optimistic_conflict_totaltheo bảng vàretry_exhausted_total. Không có nó thì 3h sáng bạn chỉ nhận được "người dùng bảo bấm Lưu mà không ăn" — không có gì để lần. - Retry giấu tải. Tỉ lệ conflict tăng dần thì latency p99 tăng trước, error rate tăng sau. Cảnh báo theo error rate là muộn — alert theo tỉ lệ conflict.
- Test phải có nhánh conflict. Test bình thường luôn xanh vì không có
đụng độ. Cần một test chạy hai session song song (dùng
parallel()trongpgtest.py) khẳng định bên thứ hai nhậnUPDATE 0. - UI phải xử được conflict. Ném 409 rồi để người dùng tự xoay xở là thiết kế tồi. Tối thiểu: nạp lại bản mới, hiện chỗ khác nhau, cho chọn. Tốt hơn: tự merge những field không đụng nhau.
- Đường ghi vòng ngoài. Admin sửa tay bằng psql, script import, job cũ — bất kỳ đường nào không tăng version đều làm guard nói dối. Đây là lý do chính chọn trigger thay vì tin app.
- Migration thêm cột version trên bảng lớn.
ADD COLUMN ... DEFAULT 0 NOT NULLtrên PostgreSQL 11+ không rewrite bảng, nhưng vẫn cầnACCESS EXCLUSIVEtrong chốc lát — xem Migration PostgreSQL zero-downtime.
8. Case thực tế và cách xử lý
Case 1 — Hai admin cùng sửa giá sản phẩm
Admin A mở form lúc 10:00, admin B mở lúc 10:01. B lưu lúc 10:05, A lưu lúc 10:06. Giá của B bị đè im lặng. Một tuần sau kế toán hỏi vì sao giá sai, không ai truy được.
sequenceDiagram participant A as Admin A participant SYS as He thong participant B as Admin B A->>SYS: 10:00 mo form, version = 7 B->>SYS: 10:01 mo form, version = 7 B->>SYS: 10:05 luu gia 120k, WHERE version = 7 SYS-->>B: OK - version thanh 8 A->>SYS: 10:06 luu gia 110k, WHERE version = 7 SYS-->>A: UPDATE 0 - ban ghi da doi SYS->>A: Hien diff 100k thanh 120k boi Admin B luc 10:05 A->>SYS: Chon giu ban cua toi hay lay ban moi
Xử lý từng bước:
- Thêm
version bigint NOT NULL DEFAULT 0+ triggerBEFORE UPDATE. - Form Livewire giữ
public int $version, nạp lúcmount(). - Lúc lưu,
$affected === 0thì không ném 500 mà nạp lại bản mới và hiện diff kèm tên người sửa và thời điểm. - Log sự kiện conflict kèm
user_idcả hai bên. Sau một tuần bạn biết ngay quy trình duyệt giá đang chồng chéo ở khâu nào — dữ liệu để sửa quy trình, không chỉ sửa code.
Case 2 — Queue worker chạy trùng job
Hệ thống hàng đợi là at-least-once (ít nhất một lần — job có thể được giao
lại). Job cập nhật trạng thái đơn hàng bị giao hai lần, hai worker chạy song song, cả hai đọc
được status = 'pending'.
Xử lý:
- Guard bằng chính trạng thái, không cần cột version:
UPDATE orders SET status = 'paid' WHERE id = ? AND status = 'pending';affected = 0nghĩa là job kia đã làm rồi → return, không throw. Job trùng ở đây là chuyện bình thường, không phải lỗi. - Không đặt retry cho loại conflict này — kết quả mong muốn đã đạt, retry chỉ đốt tài nguyên.
- Nếu cần idempotent xuyên nhiều bảng thì dùng idempotency key + unique index — xem Chống double-submit và request trùng, mục "layer 2".
Case 2 cho thấy một điều quan trọng: không phải conflict nào cũng cần retry. Conflict do người dùng (case 1) cần hỏi lại người dùng. Conflict do job trùng (case 2) chỉ cần im lặng bỏ qua. Trộn hai loại vào chung một handler là nguồn của rất nhiều bug khó tả.
9. Câu hỏi phụ interviewer hay hỏi tiếp
"Optimistic locking có cần transaction không?"
Một câu UPDATE đơn thì không — nó đã nguyên tử sẵn. Cần transaction khi guard một
row nhưng ghi nhiều bảng.
"Nếu dùng SERIALIZABLE thì còn cần version không?"
Vẫn cần, nếu là luồng web. SERIALIZABLE chỉ bảo vệ bên trong một
transaction: hai transaction phải chồng thời gian thì PostgreSQL mới thấy conflict. Form
web đọc ở request GET rồi ghi ở request POST là hai transaction rời
nhau — đã test, cả hai cùng COMMIT không lỗi và bản ghi của người lưu trước mất im
lặng. Chỉ khi đọc và ghi nằm gọn trong một transaction thì SERIALIZABLE mới thay được version,
và lúc đó bạn trả giá: abort tới 50% khi tranh chấp cao, false positive đổi theo query plan,
không dùng được trên hot standby. Phân tích đầy đủ kèm output test ở
mục 6, phần "Cửa thứ tư".
"Version có tràn số không?"
bigint = 9.2 × 1018. Không. Dùng int (2.1 tỉ) thì về lý
thuyết có, với bảng ghi cực nóng trong nhiều năm.
"Vì sao thằng thua phải chờ commit rồi mới trả UPDATE 0, thay vì trả
ngay?"
Vì row đang bị khoá bởi transaction kia. PostgreSQL chờ, rồi đánh giá lại WHERE
trên phiên bản mới (EvalPlanQual). Hệ quả: conflict không rẻ — dưới tranh chấp cao, optimistic
không nhanh hơn pessimistic bao nhiêu.
"Client không gửi version lên thì sao?"
Coi là request không hợp lệ, trả 400. Đừng mặc định version = 0 và đừng bỏ qua
guard — bỏ qua là tắt lặng lẽ cả cơ chế.
"Dùng xmin (system column của PostgreSQL) làm version được không?"
Được — Hibernate có chế độ này, khỏi thêm cột. Nhược điểm: xmin là 32-bit và bị
wraparound (quay vòng khi cạn số), không portable sang DB khác, và ORM ngoài Hibernate phải tự
viết. Với Laravel thì không đáng.
"Optimistic locking khác gì idempotency key?"
Khác mục tiêu. Optimistic locking chống hai người sửa cùng lúc. Idempotency key chống
một request bị gửi hai lần. Hệ thống thật thường cần cả hai — xem
bài chống double-submit.
"Vì sao không dùng luôn updated_at?"
Câu trả lời đầy đủ ở mục 3. Nói ngắn trong phỏng vấn: "Dùng
được nếu cột đủ độ phân giải và thời gian do database sinh. Nhưng Laravel format
updated_at bằng 'Y-m-d H:i:s' — chỉ tới giây — nên hai ghi trong cùng
một giây sẽ không bị phát hiện. Tôi đã test và thấy mất update im lặng. Một cột
version bigint rẻ hơn và không có bẫy đó."
Tóm tắt
| Câu hỏi | Trả lời |
|---|---|
| Cơ chế cốt lõi | UPDATE ... WHERE id = ? AND version = ?, kiểm số dòng bị ảnh hưởng |
| Guard nên dùng cột gì | version bigint + trigger BEFORE UPDATE |
Dùng updated_at được không | Chỉ cho conflict người-với-người, và chỉ khi cột đủ microsecond + thời gian do DB sinh. Laravel mặc định thì không |
| Retry đặt ở đâu | Ngoài DB::transaction(), có đọc lại row, có jitter, có giới hạn số lần |
| SERIALIZABLE thay được không | Không, với luồng web — nó chỉ bảo vệ bên trong một transaction. Chỉ thay được khi đọc và ghi nằm cùng transaction |
| Khi nào đừng dùng | Tranh chấp cao trên một row → pessimistic; update viết được nguyên tử → không cần lock nào |
| Phải có gì trước khi lên production | Metric đếm conflict, test hai session song song, UI xử lý conflict |
Đọc tiếp: Chống double-submit và request trùng · Migration PostgreSQL zero-downtime