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 updateHai người ghi đè nhau, thay đổi của người trước biến mất không dấu vết.
optimistic lockingKhoá 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 lockingKhoá bi quan — khoá row ngay lúc đọc, ai đến sau phải nằm chờ.
affected rowsSố dòng một câu UPDATE thật sự sửa. Đây là tín hiệu phát hiện conflict.
READ COMMITTEDIsolation 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
OptimisticPessimistic (FOR UPDATE)
Lúc đọckhông khoákhoá row
Đụng độphát hiện sau, phải retryngăn trước, phải chờ
Giao dịch có bước người dùng suy nghĩdùng đượckhông — giữ lock qua nhiều HTTP request là thảm hoạ
Tranh chấp cao trên một rowretry bão, có thể starvation (một request thua mãi)xếp hàng, chậm nhưng chắc
Deadlockgần như khôngcó, nếu khoá nhiều row không theo thứ tự cố định
Chi phí khi không có đụng độgần bằng 0vẫ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ự set updated_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:

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 0stock = 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ẫyChi tiếtTrạ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:

  1. Cột timestamptz, không đặt precision (mặc định microsecond).
  2. Trên model: protected $dateFormat = 'Y-m-d H:i:s.u';
  3. Để 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

5. Tác hại / hạn chế

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ốngChọnVì sao
Sửa form dài, có bước người dùng suy nghĩOptimisticKhông ai giữ lock qua HTTP request
Trừ tồn kho lúc flash sale, một SKU hotCâu UPDATE nguyên tử, hoặc pessimisticOptimistic 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ảnPessimistic, khoá theo thứ tự id tăng dầnTránh deadlock; tiền không được retry mù
Sửa hồ sơ / bài viết / cấu hìnhOptimisticĐụng độ hiếm, retry rẻ
Ghi hàng loạt từ queue workerOptimistic + retry, hoặc guard bằng chính trạng tháiWorker retry là chuyện thường, cơ chế đã có sẵn
Ràng buộc trải trên nhiều rowConstraint / unique indexVersion 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 >= 1 giả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ột version, 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, SERIALIZABLE chỉ 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 row10 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 shopcả 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_locksSIReadLock | 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 standby
HINT: 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).

Kết luận chọn cái nào

Tình huốngDùng gì
Form web: đọc ở request này, ghi ở request sauCột version — SERIALIZABLE không cứu được
Đọc – tính – ghi gọn trong một transaction, trải nhiều row/bảngSERIALIZABLE, 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 counterMột câu UPDATE nguyên tử — không cần cả hai
Tranh chấp cao trên một row nóngPessimistic (FOR UPDATE); SERIALIZABLE ở đây abort tới 50%

Cách nhớ: SERIALIZABLE bảo vệ bên trong một transaction. Cột version bả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ần version dù đã bật SERIALIZABLE.

7. Khó khăn khi vận hành thực tế

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:

  1. Thêm version bigint NOT NULL DEFAULT 0 + trigger BEFORE UPDATE.
  2. Form Livewire giữ public int $version, nạp lúc mount().
  3. Lúc lưu, $affected === 0 thì 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.
  4. Log sự kiện conflict kèm user_id cả 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ý:

  1. 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 = 0 nghĩ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.
  2. Không đặt retry cho loại conflict này — kết quả mong muốn đã đạt, retry chỉ đốt tài nguyên.
  3. 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ỏiTrả lời
Cơ chế cốt lõiUPDATE ... 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ôngChỉ 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 ở đâuNgoài DB::transaction(), có đọc lại row, có jitter, có giới hạn số lần
SERIALIZABLE thay được khôngKhô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ùngTranh 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 productionMetric đế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