Optimistic Locking: tránh lost update với version column — hoatq.dev

cat blog/.md

Optimistic Locking: tránh lost update với version column

date: tags: backend, postgresql, concurrency, design-pattern, sqlalchemy

Có một bug khó chịu mà tôi đã debug mất gần một ngày: trong hệ thống quản lý đơn hàng, sales A mở đơn để cập nhật địa chỉ giao, sales B cũng mở đúng đơn đó để sửa số lượng. A save lúc 10:00:05, B save lúc 10:00:08. Cuối cùng địa chỉ giao là cái mới, nhưng số lượng quay về giá trị cũ — đúng cái B đã đổi bị mất.

Cả hai đều save thành công, không có lỗi gì cả. Đây là lost update — một dạng race condition kinh điển, và là lý do tại sao chỉ có transaction thôi không đủ.

Bài này đi qua optimistic locking — cách rẻ và đơn giản để chống lost update mà không cần lock row, kèm code thật với PostgreSQL và SQLAlchemy.

Lost update xảy ra thế nào

Flow của bug ở trên:

  1. A đọc đơn hàng vào form (gọi tắt là v1)
  2. B đọc cùng đơn hàng (cũng là v1)
  3. A sửa địa chỉ trên form, bấm Save → backend UPDATE orders SET ... ghi tất cả field, đơn thành v2
  4. B sửa số lượng trên form (đang dựa trên v1 cũ), bấm Save → backend UPDATE orders SET ... ghi tất cả field, đè lên v2 thành v3

Kết quả: thay đổi của A bị mất. Vấn đề không phải ở SQL — mỗi UPDATE đều atomic. Vấn đề là B đang ghi dựa trên snapshot cũ, không biết A đã sửa giữa chừng.

Database transaction READ COMMITTED không cứu được, vì hai transaction này không chạy đồng thời — chúng cách nhau nhiều giây, mỗi cái mở/đóng riêng.

Pessimistic lock: cách trực tiếp nhưng tốn

Cách đầu tiên ai cũng nghĩ đến là: khi A mở đơn để sửa, lock luôn row đó, B muốn sửa thì phải chờ.

SELECT * FROM orders WHERE id = 123 FOR UPDATE;

FOR UPDATE giữ lock cho tới khi transaction kết thúc. B chạy cùng query sẽ block.

Vấn đề: transaction phải còn mở suốt thời gian user điền form. Người dùng đi pha cà phê 10 phút, lock vẫn giữ. Pool connection cạn rất nhanh. Trong web app stateless điển hình, ta không giữ transaction qua nhiều request — pessimistic lock chỉ hợp với critical section ngắn (vài chục ms), không hợp với form do người dùng nhập.

Optimistic lock đi theo hướng ngược lại: không lock gì cả, mà giả định va chạm hiếm khi xảy ra, chỉ kiểm tra lúc save. Nếu phát hiện ai đã sửa trước đó, từ chối write.

Version column

Thêm một cột version (hoặc updated_at, hoặc cả etag hash) vào bảng:

ALTER TABLE orders ADD COLUMN version INT NOT NULL DEFAULT 1;

Mỗi lần đọc, trả version về client. Mỗi lần update, ràng buộc WHERE id = ? AND version = ?SET version = version + 1:

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

Nếu version còn khớp → update 1 row, OK. Nếu ai đó đã update trước → version đã tăng, WHERE không match, update 0 row → conflict.

Quan trọng: kiểm tra số row affected, không phải có exception hay không. SQL không ném lỗi, nó chỉ update 0 row.

Triển khai với SQLAlchemy

SQLAlchemy có support sẵn qua version_id_col:

from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import declarative_base

Base = declarative_base()

class Order(Base):
    __tablename__ = "orders"

    id = Column(Integer, primary_key=True)
    shipping_address = Column(String)
    quantity = Column(Integer)
    version = Column(Integer, nullable=False)

    __mapper_args__ = {
        "version_id_col": version,
    }

Khi flush, SQLAlchemy tự thêm WHERE version = NSET version = N + 1. Nếu mismatch, nó raise StaleDataError.

from sqlalchemy.orm.exc import StaleDataError

def update_order(session, order_id: int, expected_version: int, payload: dict):
    order = session.get(Order, order_id)
    if order is None:
        raise OrderNotFound(order_id)

    if order.version != expected_version:
        raise OrderConflict(order_id, server_version=order.version)

    for k, v in payload.items():
        setattr(order, k, v)

    try:
        session.commit()
    except StaleDataError:
        session.rollback()
        raise OrderConflict(order_id, server_version=None)

Hai check: một check ngay khi load (early reject), một check qua StaleDataError (race ở mức rất ngắn). Cái thứ hai ít gặp hơn nhưng vẫn cần để đóng cửa sổ TOCTOU.

API contract: trả version cho client

Endpoint GET trả về kèm version:

{
  "id": 123,
  "shipping_address": "...",
  "quantity": 5,
  "version": 7
}

Endpoint PUT/PATCH yêu cầu client gửi lại version đó:

PATCH /orders/123
{
  "quantity": 6,
  "version": 7
}

Hoặc theo chuẩn HTTP, dùng header If-Match với ETag:

GET /orders/123 → ETag: "7"

PATCH /orders/123
If-Match: "7"

Server so sánh, mismatch trả 409 Conflict (hoặc 412 Precondition Failed nếu dùng If-Match):

@router.patch("/orders/{order_id}")
def patch_order(order_id: int, body: OrderPatchIn, session: WriteDbDep):
    try:
        order = update_order(session, order_id, body.version, body.dict(exclude={"version"}))
    except OrderConflict as e:
        raise HTTPException(
            status_code=409,
            detail={
                "code": "ORDER_CONFLICT",
                "message": "Đơn hàng đã được người khác cập nhật, vui lòng tải lại.",
                "server_version": e.server_version,
            },
        )
    return order

Đừng trả 500 cho conflict — đây không phải lỗi server, đây là tình huống bình thường mà client cần xử lý.

Phía UI: cho người dùng quyết định

Khi nhận 409, đừng silent retry. Người dùng cần biết dữ liệu đã thay đổi và chính họ phải chọn giữ thay đổi của ai.

Pattern tôi hay dùng:

  1. Fetch lại bản mới nhất
  2. So sánh field-by-field với form hiện tại của user
  3. Hiện modal: “Đơn này đã được cập nhật bởi người khác. Field X đã đổi từ A → B. Bạn muốn ghi đè hay huỷ thay đổi của mình?”

Với những field “an toàn” (ví dụ comment, không xung đột về business), có thể merge tự động: gửi lại request với version mới, chỉ những field user thực sự đổi.

Đừng làm cái việc gọi là “last write wins” và pretend không có conflict — đó chính là cái bạn đang cố tránh.

Khi nào nên retry tự động

Có những loại update mà retry tự động hợp lý: counter tăng, append vào list, tăng quota… những thay đổi mang tính “additive” và không phụ thuộc giá trị cũ:

def increment_view_count(session, post_id: int, max_retries: int = 3):
    for attempt in range(max_retries):
        post = session.get(Post, post_id)
        post.view_count += 1
        try:
            session.commit()
            return
        except StaleDataError:
            session.rollback()
            if attempt == max_retries - 1:
                raise

Nhưng với những update do người dùng nhập vào form, không nên retry tự động — phải hỏi người dùng.

Ngoài ra với counter, dùng UPDATE ... SET view_count = view_count + 1 thuần SQL còn an toàn hơn, không cần version column:

UPDATE posts SET view_count = view_count + 1 WHERE id = $1;

Optimistic locking dành cho trường hợp giá trị mới phụ thuộc vào snapshot user nhìn thấy.

Mấy cái dễ vấp

1. Quên check số row affected. Nếu dùng SQL trần (không qua ORM), phải đọc cursor.rowcount hoặc RETURNING id. Update 0 row mà không kiểm tra → bug âm thầm.

2. Version không nằm trong cùng transaction với update. Tách check version ra một SELECT riêng rồi UPDATE riêng (không có WHERE version = ?) là sai — vẫn có khe TOCTOU. Phải nhét version vào WHERE của UPDATE.

3. Dùng updated_at làm version. Hoạt động được, nhưng timestamp resolution có thể trùng (ms hoặc thậm chí μs trên một số DB). Số nguyên đơn điệu tăng an toàn hơn.

4. Frontend cache version cũ. Sau khi 409 và user chọn “ghi đè”, phải dùng version mới nhất, không phải version đầu user load. Tôi từng thấy app gửi đúng cái version đã conflict lúc trước → 409 vô tận.

5. Bulk update. Với UPDATE ... WHERE status = 'pending' ảnh hưởng nhiều row, version-per-row không giúp được. Lúc đó cần thiết kế khác — chia batch, hoặc giả lập optimistic ở mức tập hợp.

Tóm lại

  • Lost update không phải bug hiếm, nó là default behavior của hầu hết web app nếu bạn không phòng.
  • Pessimistic lock đơn giản nhưng tốn tài nguyên, không hợp form do người dùng nhập.
  • Optimistic lock với version column rẻ, hoạt động tốt khi conflict hiếm, và thân thiện với REST.
  • Trả version cho client, yêu cầu client gửi lại, server check trong WHERE clause của UPDATE.
  • Conflict trả 409, để UI quyết định ghi đè hay huỷ — đừng tự retry với form.

Cái chốt là: bạn đang đánh cược conflict hiếm xảy ra. Nếu hai user thường xuyên đụng cùng record, optimistic lock sẽ gây nhiều 409 và user khó chịu — lúc đó cân nhắc chia nhỏ entity, hoặc dùng pessimistic lock với UI “đang được người khác sửa”.

// reactions


cat comments.log


hoatq@dev : ~/blog $