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_activity và pg_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.

