Optimistic Locking vs Pessimistic Locking: Xử lý Truy cập Database Đồng thời trong Ứng dụng Thực tế

Database tutorial - IT technology blog
Database tutorial - IT technology blog

Sáu Tháng Chiến đấu với Race Condition — Đây là Những Gì Tôi Học được

Nền tảng thương mại điện tử của chúng tôi có một bug tinh vi chỉ xuất hiện trong các đợt flash sale. Hai người dùng đồng thời lấy sản phẩm cuối cùng còn trong kho, cả hai giao dịch đều thành công, và chúng tôi kết thúc với số lượng tồn kho âm. Đây là bài toán ghi đồng thời kinh điển.

Sau sáu tháng liên tục cải tiến chiến lược locking — thay đổi cách tiếp cận, đo lường tác động, vấp phải các edge case — tôi muốn chia sẻ những gì thực sự hoạt động được trên production. Không phải chỉ những thứ trông đẹp trên giấy tờ.

Hai Triết lý Hoàn toàn Khác nhau

Cả hai chiến lược đều giải quyết cùng một vấn đề gốc rễ: dữ liệu bị hỏng khi nhiều transaction cùng lúc sửa đổi một row. Điểm khác biệt là thời điểm — khi nào cần đảm bảo tính độc quyền, và với chi phí như thế nào.

Pessimistic Locking

Pessimistic locking giả định rằng xung đột sẽ xảy ra. Vì vậy, nó ngăn chặn từ trước bằng cách giành lock trước khi đọc dữ liệu. Không có transaction nào khác có thể sửa đổi row đã bị lock cho đến khi bạn giải phóng nó.

-- PostgreSQL: Khóa row ngay khi đọc
BEGIN;
SELECT quantity FROM inventory
WHERE product_id = 42
FOR UPDATE;  -- Row đã bị khóa

-- Thực hiện business logic ở đây
UPDATE inventory SET quantity = quantity - 1
WHERE product_id = 42;
COMMIT;

Mệnh đề FOR UPDATE báo cho PostgreSQL giữ exclusive lock trên row đó trong suốt transaction. Bất kỳ transaction nào khác cố đọc với FOR UPDATE sẽ bị block và phải chờ đến lượt.

Optimistic Locking

Optimistic locking đặt cược ngược lại: xung đột là hiếm gặp, vì vậy không cần lock gì cả từ trước. Thay vào đó, phát hiện xung đột tại thời điểm ghi bằng số version hoặc timestamp.

-- Bảng cần có cột version
ALTER TABLE inventory ADD COLUMN version INTEGER DEFAULT 0;

-- Đọc mà không cần khóa
SELECT quantity, version FROM inventory WHERE product_id = 42;
-- Trả về: quantity=10, version=5

-- Chỉ cập nhật nếu version chưa thay đổi
UPDATE inventory
SET quantity = quantity - 1, version = version + 1
WHERE product_id = 42 AND version = 5;
-- Nếu transaction khác đã cập nhật trước, version hiện tại là 6
-- UPDATE này ảnh hưởng 0 row → phát hiện xung đột

Ứng dụng của bạn kiểm tra số row bị ảnh hưởng. Không có row nào được cập nhật có nghĩa là người khác đã sửa đổi dữ liệu giữa lúc bạn đọc và ghi — bạn thử lại thao tác.

Đánh đổi: Góc nhìn Thẳng thắn

Pessimistic Locking

Ưu điểm:

  • Xung đột không bao giờ đến được tầng ứng dụng — xử lý lỗi luôn đơn giản
  • Lý tưởng khi xung đột ghi xảy ra thường xuyên (flash sale, đặt chỗ)
  • Dễ lý luận về tính đúng đắn — lock tự đảm bảo invariant cho bạn

Nhược điểm khi lên production:

  • Lock contention làm giảm throughput khi concurrency cao — các transaction xếp hàng rất nhanh
  • Nguy cơ deadlock khi các transaction giành lock theo thứ tự khác nhau
  • Transaction chạy lâu giữ lock lâu hơn, chặn tất cả những thứ khác phía sau
  • Không hoạt động tốt với read replica — lock chỉ áp dụng cho primary

Optimistic Locking

Ưu điểm:

  • Không có overhead lock khi đọc — scale tốt hơn nhiều cho workload đọc nhiều
  • Deadlock về cơ bản là không thể xảy ra (không có gì bị lock)
  • Hoạt động được trên hệ thống phân tán và read replica mà không cần xử lý đặc biệt
  • Throughput cao hơn khi xung đột thực sự hiếm (dưới ~5% request)

Nhược điểm khi lên production:

  • Logic retry tăng độ phức tạp đáng kể — bạn cần xử lý StaleDataError ở khắp nơi có thể xảy ra
  • Khi contention cao, retry dồn lại và throughput sụp đổ — thường còn tệ hơn pessimistic
  • Yêu cầu thay đổi schema (cột version) trên bảng hiện có, tức là cần migration
  • Người dùng thấy thao tác thất bại thay vì chờ ngắn — cần xử lý UX cẩn thận

Chọn Chiến lược Phù hợp với Mức Contention

Sau khi chạy cả hai trên production qua một vài service, đây là nguyên tắc tôi đã đúc kết được:

  • Ghi có contention cao (tồn kho, đặt chỗ, tài khoản tài chính) → pessimistic locking
  • Ghi có contention thấp (cập nhật hồ sơ người dùng, chỉnh sửa tài liệu, thay đổi cấu hình) → optimistic locking
  • Workload hỗn hợp → mặc định dùng optimistic, pessimistic cho các tài nguyên nóng cụ thể

Với bài toán tồn kho flash sale của chúng tôi, pessimistic locking là lựa chọn đúng. Cửa sổ contention rất hẹp — có thể chỉ 50–200ms trong quá trình thanh toán. Việc block là chấp nhận được. Còn bán vượt kho thì không.

Mẹo Riêng cho PostgreSQL: SKIP LOCKED

Với các mô hình dạng queue, FOR UPDATE SKIP LOCKED là một thay đổi cuộc chơi. Thay vì block, các transaction bỏ qua các row đang bị lock và xử lý những row có sẵn tiếp theo:

-- Xử lý đơn hàng đang chờ mà không block
SELECT id, customer_id, total
FROM orders
WHERE status = 'pending'
ORDER BY created_at
LIMIT 10
FOR UPDATE SKIP LOCKED;

Pattern này rất hữu ích cho job queue và batch processor. Bạn chỉ cần bất kỳ row nào có sẵn — không phải một row cụ thể — vì vậy bỏ qua các row đang bị lock giúp tất cả worker chạy song song.

Triển khai: Python + SQLAlchemy

Đây là code thực tế tôi dùng trong một service FastAPI sử dụng PostgreSQL — có thể copy-paste ngay.

Optimistic Locking với SQLAlchemy

from sqlalchemy import Column, Integer, String
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.exc import StaleDataError

class Base(DeclarativeBase):
    pass

class Inventory(Base):
    __tablename__ = "inventory"
    
    id = Column(Integer, primary_key=True)
    product_id = Column(Integer, nullable=False)
    quantity = Column(Integer, nullable=False)
    # SQLAlchemy tự động dùng cột này cho optimistic locking
    version = Column(Integer, nullable=False, default=0)
    
    __mapper_args__ = {
        "version_id_col": version  # Bật optimistic locking
    }


def deduct_inventory(session, product_id: int, qty: int) -> bool:
    """Trả về True nếu thành công, False nếu xung đột (caller nên retry)."""
    try:
        item = session.query(Inventory).filter_by(
            product_id=product_id
        ).first()
        
        if item.quantity < qty:
            return False  # Không đủ hàng trong kho
        
        item.quantity -= qty
        session.commit()  # Raise StaleDataError nếu version không khớp
        return True
        
    except StaleDataError:
        session.rollback()
        return False  # Caller thực hiện retry

Pessimistic Locking với SQLAlchemy

from sqlalchemy import select
from sqlalchemy.orm import Session

def deduct_inventory_pessimistic(
    session: Session, 
    product_id: int, 
    qty: int
) -> bool:
    """Dùng SELECT FOR UPDATE — block cho đến khi giành được lock."""
    # with_for_update() thêm FOR UPDATE vào câu SQL
    item = session.execute(
        select(Inventory)
        .filter_by(product_id=product_id)
        .with_for_update()
    ).scalar_one_or_none()
    
    if item is None or item.quantity < qty:
        return False
    
    item.quantity -= qty
    session.commit()  # Lock được giải phóng ở đây
    return True


# Biến thể skip-locked (mô hình job queue):
def claim_pending_orders(session: Session, batch_size: int = 10):
    return session.execute(
        select(Order)
        .filter_by(status="pending")
        .order_by(Order.created_at)
        .limit(batch_size)
        .with_for_update(skip_locked=True)
    ).scalars().all()

Retry Decorator cho Optimistic Locking

Tập trung logic retry vào một chỗ. Rải rác nó khắp các method trong service là cơn ác mộng bảo trì:

import functools
import time
from sqlalchemy.exc import StaleDataError

def retry_on_conflict(max_retries: int = 3, delay: float = 0.1):
    """Decorator tự động retry khi xảy ra xung đột optimistic locking."""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_retries):
                result = func(*args, **kwargs)
                if result is not False:  # Điều chỉnh theo quy ước giá trị trả về của bạn
                    return result
                if attempt < max_retries - 1:
                    time.sleep(delay * (attempt + 1))  # Exponential backoff
            raise RuntimeError(
                f"Thao tác thất bại sau {max_retries} lần thử (xung đột)"
            )
        return wrapper
    return decorator


@retry_on_conflict(max_retries=3)
def update_user_profile(session, user_id, data):
    # logic optimistic locking ở đây
    pass

Xử lý Deadlock (Pessimistic Locking)

Luôn xử lý deadlock một cách tường minh — PostgreSQL sẽ phát hiện và trả về chúng dưới dạng lỗi:

from psycopg2 import errors as pg_errors
from sqlalchemy.exc import OperationalError

def safe_checkout(session, order_id):
    try:
        # Logic pessimistic locking của bạn
        process_order(session, order_id)
    except OperationalError as e:
        if "deadlock detected" in str(e).lower():
            session.rollback()
            # Ghi log và retry hoặc thông báo cho người dùng
            raise RetryableError("Vui lòng thử lại")
        raise

Thêm Một Mẹo Thực tế

Khi debug một vấn đề locking phức tạp, tôi cần đối chiếu file CSV xuất từ database với một số file cấu hình JSON. Tôi dùng toolcraft.app/vi/tools/data/csv-to-json để chuyển đổi trực tiếp trên trình duyệt — không cần cài gì, không upload lên server. Tiết kiệm cho tôi 20 phút viết script tạm chỉ để kiểm tra xem giá trị cột version có nhất quán giữa các môi trường không.

Kết luận

Không có chiến lược nào vượt trội hoàn toàn. Pessimistic locking cho bạn đảm bảo mạnh mẽ với cái giá là giảm concurrency. Optimistic locking cho bạn khả năng mở rộng với cái giá là độ phức tạp của logic retry.

Kỹ năng thực sự là xác định được phần nào trong hệ thống là điểm nóng contention thực sự. Hãy đo lường các slow query và lock wait trước — đừng đoán mò. Bắt đầu với optimistic locking cho hầu hết các thao tác cập nhật, sau đó thêm pessimistic locking một cách có chọn lọc cho một số ít tài nguyên có contention cao thực sự cần đến nó. Theo dõi lock wait trên production (pg_stat_activitypg_locks là công cụ đắc lực của bạn), và xem xét lại chiến lược khi traffic pattern thay đổi.

Share: