Distributed Lock với Redis và Redisson: Ngăn ngừa Race Condition trong Hệ thống Multi-Node

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

Vấn đề Double-Spend trong Kiến trúc Multi-Node

Hãy hình dung một đợt flash sale vào nửa đêm với 10.000 người mua sắm đang hoạt động cùng lúc. Hai khách hàng bấm “Mua ngay” vào cùng một mili-giây cho chiếc PlayStation 5 cuối cùng còn lại trong kho. Phía trước, load balancer NGINX điều hướng Khách hàng A sang Node 1 và Khách hàng B sang Node 2.

Cả hai node đều truy vấn database: số lượng tồn kho còn 1. Cả hai cùng tiến hành trừ kho và trừ tiền trên thẻ thanh toán. Chỉ trong vài mili-giây, bạn đã bán ra hai chiếc máy chơi game trong khi kho chỉ còn đúng một chiếc. Khối synchronized tiêu chuẩn của Java hay mutex của Node.js chỉ bảo vệ được các thread dùng chung bộ nhớ trên một máy chủ duy nhất. Ngay khoảnh khắc ứng dụng mở rộng sang nhiều instance, cơ chế đồng bộ hóa in-process trở nên hoàn toàn vô hiệu.

Các hệ quản trị cơ sở dữ liệu như MySQL hay PostgreSQL xử lý transaction rất tốt, nhưng sử dụng chúng để điều phối ứng dụng ở tần suất cao lại phải trả giá rất đắt. Khi hàng chục node cùng tranh chấp các row lock trong những đợt traffic tăng đột biến, connection pool sẽ cạn kiệt chỉ trong vài giây và độ trễ database sẽ tăng vọt.

Đánh giá các Giải pháp Xử lý Concurrency Phân tán

Khi ứng dụng chạy trên nhiều node, có ba chiến lược phổ biến để tuần tự hóa các thao tác.

1. Khóa ở Tầng Database (Pessimistic vs. Optimistic)

Cơ sở dữ liệu quan hệ cho phép bạn khóa dòng tường minh bằng cú pháp SELECT ... FOR UPDATE hoặc theo dõi phiên bản dữ liệu bằng số version trong optimistic locking.

-- Khóa dòng theo cơ chế bi quan (Pessimistic row locking)
BEGIN;
SELECT stock FROM products WHERE id = 42 FOR UPDATE;
-- Kiểm tra tồn kho hợp lệ và giảm số lượng
UPDATE products SET stock = stock - 1 WHERE id = 42;
COMMIT;

2. Tự Xây dựng Redis Lock với SETNX

Redis hoạt động trên một event loop đơn luồng, hoàn thành các thao tác trên RAM với thời gian dưới một mili-giây. Các mô hình lock đời đầu thường dựa vào lệnh nguyên tử (atomic) SETNX:

# Chiếm lock nguyên tử với TTL 10 giây
SET lock:product:42 "client-uuid-123" NX PX 10000

3. Distributed Lock Chuẩn Production với Redisson

Redisson đóng vai trò cầu nối chuyển đổi các lệnh Redis thô thành các cấu trúc concurrency quen thuộc trong Java như RLock. Bên dưới hệ thống, thư viện này thực thi các Lua script đã qua kiểm chứng thực tế và tự động gia hạn thời gian giữ lock thông qua cơ chế watchdog.

RLock lock = redissonClient.getLock("lock:product:42");
lock.lock();
try {
    // Được tuần tự hóa trên toàn bộ các node trong cluster
    inventoryService.deductStock(42);
} finally {
    lock.unlock();
}

So sánh Đánh đổi: Lựa chọn Công cụ Phù hợp

Database Row Locking

  • Ưu điểm: Không yêu cầu thêm hạ tầng bổ sung; đảm bảo chuẩn ACID nghiêm ngặt ngay từ đầu.
  • Nhược điểm: Việc giữ lock chiếm dụng các database connection vốn khan hiếm. Dưới tải cao vượt mức 2.000+ request mỗi giây, tranh chấp dòng (row contention) dẫn đến tình trạng xếp hàng transaction và gây nghẽn timeout lan chuyền trên connection pool.

Tự Xây dựng với Redis SETNX

  • Ưu điểm: Cực kỳ nhẹ; thời gian chiếm lock nhanh dưới một mili-giây.
  • Nhược điểm: Nhìn có vẻ đơn giản nhưng rất khó viết an toàn. Nếu xảy ra hiện tượng dừng do garbage collection (GC pause) hoặc API bên thứ ba phản hồi chậm kéo dài quá TTL, Redis sẽ tự hủy lock sớm và một worker khác sẽ chiếm mất. Để nhả lock an toàn, cần phải kiểm tra quyền sở hữu bằng Lua script tùy biến nhằm tránh xóa nhầm token của process khác.

Distributed Lock của Redisson

  • Ưu điểm: Hỗ trợ reentrant hoàn chỉnh; hỗ trợ cơ chế retry backoff không chặn (non-blocking); tích hợp watchdog tự động gia hạn lease giúp lock không bị hết hạn ngoài ý muốn trong các đợt GC pause đột ngột hoặc sự cố mạng chập chờn.
  • Nhược điểm: Thêm một thư viện client vào dự án; kiến trúc Redis master-replica thông thường vẫn có nguy cơ mất lock do hiện tượng split-brain nếu primary node gặp sự cố trước khi kịp replicate dữ liệu ghi sang replica.

Kiến trúc Đề xuất: Mô hình Fast-Path

Trong các hệ thống microservice thông lượng cao, việc tách tải điều phối ra khỏi database cốt lõi sẽ mang lại độ trễ thấp nhất. Mô hình phân chia trách nhiệm rõ ràng đã được kiểm chứng:

  1. Redis Cluster / Sentinel: Đóng vai trò tầng điều phối trung tâm, lưu giữ các token đồng bộ hóa ngắn hạn trên RAM.
  2. Redisson: Quản lý việc chiếm lock của thread, cơ chế retry và tự động gia hạn lease.
  3. Cơ sở dữ liệu Quan hệ (PostgreSQL / MySQL): Thực thi các business transaction an toàn sau khi Redisson cho phép đúng một node xử lý tại một thời điểm.

Triển khai Thực tế: Cấu hình Redisson trong Spring Boot

Bước 1: Khởi động Redis

Khởi động một container Redis cục bộ bằng Docker:

docker run -d --name redis-lock -p 6379:6379 redis:7-alpine

Bước 2: Thêm Maven Dependency

Thêm Redisson starter vào file pom.xml:

<dependency>
    <groupId>org.redisson</groupId>
    <artifactId>redisson-spring-boot-starter</artifactId>
    <version>3.34.0</version>
</dependency>

Bước 3: Cấu hình Bean Redisson Client

Định nghĩa một class cấu hình để tinh chỉnh các ngưỡng connection pool:

import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class RedissonConfig {

    @Bean
    public RedissonClient redissonClient() {
        Config config = new Config();
        config.useSingleServer()
              .setAddress("redis://127.0.0.1:6379")
              .setConnectionPoolSize(64)
              .setConnectionMinimumIdleSize(24);
        return Redisson.create(config);
    }
}

Bước 4: Bảo vệ Logic Nghiệp vụ Trọng yếu

Sử dụng tryLock kèm thời gian chờ rõ ràng để caller thread có thể fail-fast thay vì bị treo vô thời hạn khi có tranh chấp lớn:

import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;

@Service
public class OrderProcessingService {

    private final RedissonClient redissonClient;

    public OrderProcessingService(RedissonClient redissonClient) {
        this.redissonClient = redissonClient;
    }

    public boolean processOrder(Long productId, int quantity) {
        String lockKey = "lock:order:product:" + productId;
        RLock lock = redissonClient.getLock(lockKey);

        try {
            // Chờ tối đa 3 giây; truyền -1 để watchdog tự động gia hạn lease theo thời gian thực
            boolean acquired = lock.tryLock(3, -1, TimeUnit.SECONDS);
            if (!acquired) {
                // Worker khác đang giữ lock; từ chối hoặc đẩy request vào hàng đợi
                return false;
            }

            try {
                return executeOrderDeduction(productId, quantity);
            } finally {
                if (lock.isHeldByCurrentThread()) {
                    lock.unlock();
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return false;
        }
    }

    private boolean executeOrderDeduction(Long productId, int quantity) {
        // Logic kiểm tra tồn kho trong database và tạo đơn hàng được thực hiện tại đây
        return true;
    }
}

Cách Watchdog Ngăn chặn Tình trạng Hết hạn Sớm

Thiết lập thời gian lease về -1 sẽ kích hoạt watchdog nội bộ của Redisson. Cứ mỗi 10 giây—tương đương một phần ba timeout mặc định 30 giây của watchdog—một tác vụ nền sẽ kiểm tra xem thread có còn đang thực thi hay không. Nếu cổng thanh toán phản hồi chậm mất tới 15 giây, watchdog sẽ tự động làm mới TTL trở lại mức 30 giây.

Điều gì xảy ra nếu node bị crash giữa chừng? Watchdog sẽ dừng hoạt động cùng với JVM. Lock trong Redis khi đó sẽ tự động hết hạn sau 30 giây, giải phóng key để các node đang khỏe mạnh có thể tiếp tục xử lý mà không gây ra hiện tượng deadlock vĩnh viễn.

Share: