Redis Distributed Lock trong Microservices: Giải pháp chặn Race Condition khi xử lý đồng thời

Development tutorial - IT technology blog
Development tutorial - IT technology blog

Tuần trước team mình có một sự cố khá đau đầu: hệ thống order bị duplicate — cùng một đơn hàng nhưng bị trừ stock hai lần. Sau khi trace log, nguyên nhân là hai service instance xử lý cùng một request trong vòng chưa đến 50ms, cả hai đều đọc số tồn kho trước khi bên kia kịp ghi xuống DB. Classic race condition trong môi trường microservices.

Giải pháp mình chọn cuối cùng là Redis Distributed Lock — đơn giản, hiệu quả, và đã chạy ổn định từ đó đến nay. Bài này mình ghi lại cách implement để anh em trong team tham khảo.

Race Condition trong Microservices trông như thế nào?

Hãy hình dung bạn có API endpoint /api/orders/create và load balancer đang phân phối request lên 3 instance. Một khách hàng double-click vào nút “Đặt hàng” — hai request gần như đồng thời hit hai instance khác nhau. Cả hai bắt đầu chạy logic kiểm tra tồn kho trong cùng một khoảnh khắc:

# Instance A và Instance B đều chạy đoạn này gần như cùng lúc
stock = db.query("SELECT stock FROM products WHERE id = ?", product_id)
if stock > 0:
    db.execute("UPDATE products SET stock = stock - 1 WHERE id = ?", product_id)
    create_order(product_id, user_id)

Instance A đọc được stock = 1, Instance B cũng đọc được stock = 1. Cả hai thấy “còn hàng”, cả hai tạo order. Kết quả: stock = -1 và khách hàng nhận 2 xác nhận đơn hàng cho 1 sản phẩm duy nhất.

Single-server? Database transaction + row locking giải quyết được. Nhưng với nhiều service, nhiều instance, chạy trên nhiều server — bạn cần cơ chế khóa ở tầng cao hơn. Đó là Distributed Lock.

Redis Distributed Lock hoạt động như thế nào?

Trước khi xử lý critical operation, service phải “giành” được một lock key trên Redis. Giành được thì xử lý, xong thì release. Không giành được — vì instance khác đang giữ — thì chờ hoặc retry. Nghe đơn giản vậy thôi, nhưng cái hay là Redis đảm bảo bước giành lock này hoàn toàn atomic.

Redis cung cấp lệnh SET NX PX để thực hiện điều này:

# SET key value NX PX milliseconds
# NX = chỉ set nếu key chưa tồn tại
# PX = TTL tính bằng milliseconds

SET order:lock:product-123 "unique-token-abc" NX PX 5000
# Trả về OK nếu giành được lock
# Trả về nil nếu key đã tồn tại (service khác đang giữ)

Lệnh này atomic — dù có 100 instance gọi cùng lúc, chỉ đúng 1 instance SET thành công. Đây là điểm khác biệt then chốt so với cách check-then-set bằng hai lệnh riêng: khoảng trống giữa hai lệnh đó chính là nơi race condition chui vào.

TTL không phải tùy chọn — đây là thứ ngăn hệ thống bị deadlock vĩnh viễn. Nếu service crash ngay sau khi giành được lock mà chưa kịp release, Redis tự xóa lock sau khoảng thời gian đặt trước. Không có TTL, một service chết có thể làm tê liệt cả pipeline.

Implement Redis Distributed Lock với Python

Cài đặt

pip install redis

Class RedisLock

import redis
import uuid
import time

class RedisLock:
    def __init__(self, redis_client: redis.Redis, lock_key: str, ttl: int = 5000):
        self.redis = redis_client
        self.lock_key = lock_key
        self.ttl = ttl  # milliseconds
        self.token = str(uuid.uuid4())  # unique token mỗi instance lock

    def acquire(self, retry: int = 3, retry_delay: float = 0.1) -> bool:
        for attempt in range(retry):
            result = self.redis.set(
                self.lock_key,
                self.token,
                nx=True,
                px=self.ttl
            )
            if result:
                return True
            if attempt < retry - 1:
                time.sleep(retry_delay)
        return False

    def release(self):
        # Lua script đảm bảo chỉ release lock của chính mình
        lua_script = """
        if redis.call("get", KEYS[1]) == ARGV[1] then
            return redis.call("del", KEYS[1])
        else
            return 0
        end
        """
        self.redis.eval(lua_script, 1, self.lock_key, self.token)

    def __enter__(self):
        if not self.acquire():
            raise RuntimeError(f"Không thể lấy lock: {self.lock_key}")
        return self

    def __exit__(self, *args):
        self.release()

Release lock mình dùng Lua script thay vì gọi thẳng DEL vì một lý do cụ thể: nếu lock vừa expire đúng lúc bạn chuẩn bị xóa, và instance khác đã giành được lock mới, bạn không được phép xóa của người ta. Script kiểm tra token trước khi xóa — atomic, không có khoảng hở để race condition chen vào.

Áp dụng vào xử lý Order

import redis

redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True)

def create_order(product_id: str, user_id: str):
    lock_key = f"order:lock:{product_id}"

    with RedisLock(redis_client, lock_key, ttl=10000) as lock:
        # Chỉ một service được chạy đoạn này tại một thời điểm
        stock = db.query("SELECT stock FROM products WHERE id = ?", product_id)

        if stock <= 0:
            raise Exception("Hết hàng")

        db.execute(
            "UPDATE products SET stock = stock - 1 WHERE id = ?",
            product_id
        )
        order_id = db.insert("INSERT INTO orders ...")
        return order_id
    # Lock tự động được release khi ra khỏi with block

Xử lý khi không giành được lock

from fastapi import HTTPException

def create_order_endpoint(product_id: str, user_id: str):
    lock_key = f"order:lock:{product_id}"
    lock = RedisLock(redis_client, lock_key, ttl=10000)

    if not lock.acquire(retry=5, retry_delay=0.2):
        raise HTTPException(
            status_code=429,
            detail="Hệ thống đang xử lý request khác, vui lòng thử lại sau"
        )

    try:
        result = process_order(product_id, user_id)
        return result
    finally:
        lock.release()  # Luôn release dù có exception hay không

Những điểm cần chú ý khi dùng thực tế

1. Đặt TTL phù hợp

TTL quá ngắn → lock expire trước khi xử lý xong → race condition quay lại.
TTL quá dài → nếu service crash, các service khác phải ngồi chờ mòn mỏi.

Công thức mình đang dùng: TTL = thời gian xử lý ước tính × 3, tối thiểu 3 giây. Không chắc thời gian xử lý? Log duration trong 1–2 ngày, lấy p99 rồi nhân 3. Tránh đặt bằng tay theo cảm tính.

2. Chọn lock key đủ granular

Lock key quá rộng tạo bottleneck không cần thiết — 1000 sản phẩm khác nhau mà chỉ có 1 lock key global thì mỗi lúc chỉ xử lý được đúng 1 order:

# Quá rộng — block tất cả order
lock_key = "order:lock:global"

# Vừa đủ — chỉ block order cho product cụ thể
lock_key = f"order:lock:product:{product_id}"

# Granular hơn nếu business logic cho phép
lock_key = f"order:lock:product:{product_id}:warehouse:{warehouse_id}"

3. Kết hợp với Idempotency Key

Distributed lock chặn race condition, nhưng không chặn được duplicate khi client retry sau timeout. Hai cơ chế này bổ trợ nhau — dùng cả hai nếu hệ thống cần idempotency thực sự.

4. Monitoring lock trên Redis

# Xem tất cả lock key đang active
redis-cli KEYS "order:lock:*"

# Xem TTL còn lại của một lock
redis-cli TTL "order:lock:product-123"

# Xem giá trị (token) của lock
redis-cli GET "order:lock:product-123"

Khi debug, mình hay format và kiểm tra JSON payload trước khi set vào Redis tại toolcraft.app/vi/tools/developer/json-formatter — tiện hơn nhiều so với cài extension, không cần đăng ký, mở là dùng được ngay.

Kết luận

Sau vụ duplicate order hồi tuần trước, mình dùng Redis Distributed Lock và không thấy sự cố tương tự từ đó. Không phải vì nó là giải pháp hoàn hảo, mà vì nó đủ đơn giản để hiểu, đủ đáng tin để deploy production, và không cần thêm infrastructure nếu Redis đã có trong stack.

Bài học lớn nhất: đừng assume database transaction là đủ khi có nhiều service chạy song song. Concurrency trong môi trường distributed khác hẳn single-server — bạn phải nghĩ đến nó ở nhiều tầng. Redis Lock là cách nhanh nhất để bịt một trong những lỗ hổng phổ biến nhất.

Đang dùng Node.js? Xem thêm thư viện redlock. Go thì có go-redis với tính năng tương đương — cùng nguyên lý SET NX PX và Lua script release, chỉ khác syntax.

Share: