Phương Pháp Xử Lý Sự Cố Phá Vỡ Bộ Nhớ Đệm Redis

Phân Biệt Các Vấn Đề Hiệu Năng Trong Caching

Khi làm việc với hệ thống phân phối, việc tối ưu hóa truy xuất dữ liệu là ưu tiên hàng đầu. Tuy nhiên, hai hiện tượng thường gặp gây ảnh hưởng lớn đến database là Xâm nhập bộ nhớ đệm (Cache Penetration)Phá vỡ bộ nhớ đệm (Cache Breakdown).

1. Xâm Nhập Và Phá Vỡ Bộ Nhớ Đệm

  • Xâm nhập bộ nhớ đệm: Xảy ra khi ứng dụng yêu cầu truy vấn một bản ghi không tồn tại trong cả cache lẫn cơ sở dữ liệu. Mỗi lần gọi sẽ đi thẳng xuống database, tiêu tốn tài nguyên. Cách khắc phục phổ biến bao gồm lưu trữ giá trị rỗng (null object) hoặc sử dụng thuật toán bộ lọc Bloom.
  • Phá vỡ bộ nhớ đệm: Tình trạng xảy ra khi một dữ liệu nóng (hot key) hết hạn và bị xóa khỏi cache. Trong khoảng thời gian ngắn trước khi dữ liệu được tải lại, lượng lớn request đồng bộ sẽ cùng lúc truy cập trực tiếp vào database, gây quá tải.

2. Giải Pháp Ngăn Chặn Phá Vỡ Sử Dụng Kiểm Tra Khóa Kép

Để ngăn chặn tình trạng nhiều luồng thực hiện truy vấn database cùng lúc khi cache trống, kỹ thuật Double-Checked Locking thường được áp dụng. Dưới đây là ví dụ triển khai bằng Spring Boot và Redis, nơi chúng ta sử dụng đối tượng khóa riêng biệt thay vì `synchronized` thông thường để tăng tính linh hoạt.

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;

@Component
public class ProductCacheManager {

    @Autowired
    private RedisTemplate<String, Object> redisClient;
    
    private final ObjectMapper mapper = new ObjectMapper();
    private final ReentrantLock cacheLock = new ReentrantLock();
    private static final String CACHE_PREFIX = "product:info:";

    /**
     * Khởi tạo dữ liệu mẫu để kiểm tra
     */
    public void resetTestData() {
        HashOperations ops = redisClient.opsForHash();
        if (ops.hasKey("system_keys", "demo_id")) {
            ops.delete("system_keys", "demo_id");
        }
    }

    public void saveToCache(Long id, String productName) throws Exception {
        // Giả lập việc lưu vào cache từ DB
        String json = mapper.writeValueAsString(new Product(id, productName));
        redisClient.opsForHash().put(CACHE_PREFIX, String.valueOf(id), json);
    }

    public String fetchProductDetail(String productId) throws InterruptedException {
        String key = CACHE_PREFIX + productId;
        Object cachedData = redisClient.opsForHash().get(CACHE_PREFIX, productId);

        if (cachedData == null) {
            // Lần đầu kiểm tra thấy null, cần kiểm tra khóa
            cacheLock.lock();
            try {
                // Kiểm tra lại sau khi có quyền
                cachedData = redisClient.opsForHash().get(CACHE_PREFIX, productId);
                if (cachedData == null) {
                    System.out.println("[Thread " + Thread.currentThread().getName() + "] Cache trống, truy xuất DB");
                    // Load lại dữ liệu giả lập
                    long id = Long.parseLong(productId);
                    saveToCache(id, "SanPham_" + id);
                    cachedData = redisClient.opsForHash().get(CACHE_PREFIX, productId);
                } else {
                    System.out.println("[Thread " + Thread.currentThread().getName() + "] Dữ liệu đã được tải bởi luồng khác");
                }
            } finally {
                cacheLock.unlock();
            }
        } else {
            System.out.println("[Thread " + Thread.currentThread().getName() + "] Lấy dữ liệu từ Cache ngay lập tức");
        }
        return cachedData != null ? cachedData.toString() : null;
    }
}

Đoạn mã trên sử dụng ReentrantLock để đảm bảo chỉ có một luồng thực thi nạp dữ liệu từ database khi cache bị thiếu. Luồng thứ hai chờ đợi và lấy kết quả từ cache ngay khi luồng đầu tiên hoàn tất.

3. Mô Phỏng Đồng Thời Để Thử Nghiệm

Chúng ta có thể dùng ThreadPoolExecutor để mô phỏng hành vi của nhiều người dùng cùng lúc truy cập vào cùng một ID sản phẩm chưa được cache.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.CountDownLatch;

public class ConcurrencyTest {
    public static void main(String[] args) throws InterruptedException {
        ProductCacheManager manager = new ProductCacheManager();
        
        ExecutorService pool = Executors.newFixedThreadPool(5);
        CountDownLatch latch = new CountDownLatch(10);

        for (int i = 0; i < 10; i++) {
            pool.submit(() -> {
                try {
                    // Giả lập delay nhẹ để tăng khả năng tranh chấp khóa
                    Thread.sleep(10); 
                    manager.fetchProductDetail("100");
                } catch (Exception e) {
                    e.printStackTrace();
                } finally {
                    latch.countDown();
                }
            });
        }
        
        latch.await();
        pool.shutdown();
    }
}

Kết quả chạy thử cho thấy chỉ có đúng một luồng thực hiện việc lấy dữ liệu từ nguồn gốc, các luồng còn lại đều nhận kết quả từ bước chuyển đổi vừa diễn ra, chứng tỏ hiệu quả của cơ chế khóa đồng bộ.

Thẻ: Redis cache-breakdown java-concurrency spring-boot backend-optimization

Đăng vào ngày 21 tháng 5 lúc 18:41