Xử lý xung đột dữ liệu giao dịch bằng Distributed Lock trên Redis và Khám phá hiệu năng nội bộ

Bối cảnh kỹ thuật

Khi hệ thống giao dịch đạt đến quy mô nhất định, áp lực truy cập gia tăng dẫn đến nhiều vấn đề về tính toàn vẹn dữ liệu. Một trường hợp điển hình là hiện tượng đơn hàng bị tạo lặp. Dù số lượng tiền và sản phẩm giống hệt nhau nhưng lại tồn tại hai bản ghi với thời gian sinh ra chênh lệch chỉ vài chục mili giây.

Nếu nguyên nhân không xuất phát từ thao tác người dùng nhấn sai nhiều lần mà do sự chia tải chưa hợp lý giữa các nút máy chủ, thì việc xác nhận một trạng thái duy nhất là bắt buộc để đảm bảo tính đúng đắn của nghiệp vụ.

Hướng tiếp cận giải quyết

Có nhiều phương án xử lý vấn đề này, tuy nhiên mỗi cái đều có những đánh đổi nhất định:

  • Tính duy nhất ở mức Database: Thêm ràng buộc unique key cho ID giỏ hàng. Tuy đảm bảo tính đúng, nhưng khi áp lực truy cập cao, khóa bảng hoặc row-level locking sẽ tạo thành nút thắt cổ chai, làm giảm hiệu năng tổng thể.
  • Kiểm tra trước khi Insert: Quét xem đã có đơn hàng chưa rồi mới insert. Phương pháp này dễ gặp lỗi Race Condition trong môi trường đồng thời vì khoảng cách giữa bước query và insert đủ dài để yêu cầu thứ hai đi qua cửa sổ kiểm tra đó.
  • Sử dụng Cache làm điểm chốt: Đưa logic chặn trùng lặp lên Redis. Do Redis hoạt động dựa trên RAM, tốc độ phản hồi cực nhanh, giúp giảm tải đáng kể cho hạ tầng database chính.

Yếu tố cốt lõi cần đạt được là tính độc quyền (mutual exclusion) cùng khả năng tự hủy sau một khoảng thời gian để tránh mất mát tài nguyên nếu quá trình xử lý bị treo (deadlock).

Thiết kế kiến trúc

Để triển khai một phiên bản khóa phân tán ổn định, ba nguyên tắc sau cần được tuân thủ:

  1. Độc quyền: Chỉ một tiến trình/thời gian luồng duy nhất có quyền sở hữu khóa tại một thời điểm.
  2. Thời hạn sống (TTL): Khóa phải tự động hết hạn nếu không được giữ bởi chủ sở hữu, ngăn ngừa tình trạng khóa mãi không thả ra.
  3. Nguyên tử (Atomic): Các thao tác set và get phải thực hiện liên tục không bị ngắt quãng để đảm bảo dữ liệu luôn ở trạng thái tin cậy.

Cài đặt ứng dụng

Chúng ta sẽ xây dựng một lớp bao bọc (wrapper) sử dụng thư viện Redisson chuẩn hóa cho Java. Dưới đây là ví dụ mã nguồn đã được tái cấu trúc:

import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;

@Slf4j
@RequiredArgsConstructor
public class OrderDistributionController {

    private final RedissonClient redissonClient;

    /**
     * Thực hiện giao diện khóa với thời gian chờ cố định
     */
    public void acquireOrderLock(String cartId, long ttlSeconds) {
        RLock rLock = redissonClient.getLock("order_lock_prefix_" + cartId);
        try {
            // Chờ tối đa 10 giây để lấy được khóa, giữ khóa 10 giây
            boolean acquired = rLock.tryLock(10L, ttlSeconds, TimeUnit.SECONDS);
            
            if (acquired) {
                executeOrderLogic(cartId);
            } else {
                log.warn("Không thể lấy khóa cho giỏ hàng: {}", cartId);
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            log.error("Quá trình lấy khóa bị gián đoạn", e);
            throw new RuntimeException(e);
        } finally {
            releaseLock(rLock);
        }
    }

    /**
     * Nghiệp vụ tạo đơn hàng mẫu
     */
    private void executeOrderLogic(String cartId) {
        // Giả lập logic nghiệp vụ
        System.out.println("Đang xử lý đơn hàng cho: " + cartId);
    }

    /**
     * Giải phóng khóa một cách an toàn
     */
    private void releaseLock(RLock lock) {
        if (lock.isHeldByCurrentThread()) {
            lock.unlock();
            log.debug("Khóa đã được gỡ bỏ.");
        }
    }
}

Trong ví dụ trên, chúng ta thấy việc kiểm tra isHeldByCurrentThread() rất quan trọng. Khi sử dụng Redisson, cơ chế WatchDog có thể tự động gia hạn khóa nếu thời gian chạy business logic kéo dài hơn thời gian thiết lập TTL. Tuy nhiên, trong môi trường production, việc mở rộng tự động này nên được cân nhắc kỹ lưỡng vì nó tốn tài nguyên nền tảng.

Toàn bộ logic vận hành dựa trên sự kết hợp giữa Redis Keys và Lua Script. Script Lua đóng vai trò như một giao thức bất khả xâm phạm, đảm bảo rằng chuỗi lệnh SET (khi khóa chưa tồn tại) và NX (không tồn tại sẵn) được thực thi song song tuyệt đối.

Phân tích hiệu năng của Redis

Lý do Redis được lựa chọn thay thế cho các giải pháp dựa trên đĩa cứng truyền thống nằm ở 4 yếu tố vật lý và kiến trúc:

  1. Tốc độ truy cập RAM: Băng thông của RAM vượt trội so với HDD hay SSD thường, loại bỏ độ trễ tìm kiếm cơ học của ổ đĩa quay.
  2. Mô hình Single-threaded xử lý mạng: Phiên bản Redis cũ thường chạy trên một luồng đơn để xử lý tất cả yêu cầu mạng, loại bỏ overhead do chuyển đổi ngữ cảnh (context switch) của CPU. Tuy nhiên, các phiên bản mới đã bổ sung multithreading cho các thao tác xóa dữ liệu lớn (UNLINK, FLUSHALL ASYNC) để tránh nghẽn mạch khi thu hồi bộ nhớ.
  3. I/O Đa nhiệm (Multiplexing): Sử dụng cơ chế Select/Epoll/Kqueue cho phép một luồng giám sát nhiều kết nối socket, phục vụ đồng thời hàng nghìn client mà không cần khởi tạo luồng riêng biệt.
  4. Optimization về cấu trúc dữ liệu: Redis không lưu trữ raw string hay list, mà có các encoding cụ thể để tối ưu không gian và tốc độ truy xuất.

Cấu trúc dữ liệu bên dưới (Internals)

1. String và SDS (Secure Dynamic String)

Redis không dùng char* của C mà dùng cấu trúc SDS. Trong SDS, ngoài phần dữ liệu buf[], còn có các thuộc tính len (độ dài) và free (bộ đệm thừa). Điều này giúp Redis biết ngay độ dài chuỗi O(1) mà không cần duyệt ký tự. Để tối ưu cache CPU dòng 64-byte, nếu chuỗi ngắn (< 44 bytes), Redis dùng kiểu embstr chiếm ít vùng nhớ hơn.

2. Hash và Ziplist

Khi số lượng cặp khóa-giá trị nhỏ, Redis sử dụng ziplist (danh sách nối compact) để tiết kiệm bộ nhớ so với bảng băm truyền thống. Tuy nhiên, khi dữ liệu tăng lên, nó chuyển sang hashtable để duy trì tốc độ tìm kiếm.

3. List và QuickList

Lựa chọn giữa hai hướng: Ziplist (tiết kiệm nhưng chậm khi chèn/xóa) và Linked List (nhanh nhưng tốn bộ nhớ). QuickList là giải pháp trung gian, sử dụng danh sách đôi (linked list) các node chứa dữ liệu nén dưới dạng ziplist, cân bằng giữa chi phí bộ nhớ và tốc độ truy cập.

4. Set và ZSet

Đối với tập hợp (Set), Redis dùng intset nếu toàn bộ là số nguyên 16-bit hoặc 32-bit nhỏ, giúp nén dữ liệu cực tốt. Ngược lại là hashtable.

Với Set Sắp xếp (ZSet), Redis kết hợp hai cấu trúc: ziplist (cho dữ liệu ít) và splist (Jump List / Skip List) cho dữ liệu lớn. Skip List hoạt động dựa trên nguyên lý tìm kiếm nhị phân nhưng cấu trúc链表, cho phép nhảy cóc qua các node lớn nhằm đạt độ phức tạp thời gian O(log N).

Ứng dụng BitMap (Biểu đồ Bit)

Kể từ phiên bản Redis 2.2.0, thêm vào hỗ trợ dữ liệu BitMap. Đây không hẳn là một cấu trúc dữ liệu mới mà là biến thể của String, nơi mỗi giá trị (value) là một chuỗi bit.

Ứng dụng thực tế:

Giả sử cần theo dõi trạng thái đăng nhập của 100 triệu người dùng mỗi ngày.

  • Sử dụng key là ngày tháng (vd: "2023-10-25").
  • Sử dụng offset là User ID.
  • Nếu offset i bằng 1 nghĩa là người dùng i đã login, 0 nghĩa là chưa.

Ưu điểm:

Dung lượng tiêu tốn cực thấp. Ví dụ lưu 100 triệu bit chỉ tốn khoảng 12MB RAM.

Các lệnh thao tác phổ biến:


# Kiểm tra status
GETBIT key offset

# Thiết lập status
SETBIT key offset 1

# Đếm số lượng người đã đăng nhập (O(N))
BITCOUNT key

# Thống kê tổng hợp nhiều ngày (Bitwise AND/OR/XOR)
BITOP OR dest_key date_1 date_2 date_3

Kỹ thuật này phù hợp cho bài toán đếm số lượng, lưu trạng thái flags đơn giản, hoặc lọc dữ liệu quy mô lớn với chi phí bộ nhớ tối thiểu.

Thẻ: Redis distributed-lock system-design Concurrency Java

Đăng vào ngày 31 tháng 8 lúc 19:38