Đồng bộ Dữ liệu giữa Redis và Cơ sở dữ liệu Quan hệ

Nguyên nhân phát sinh sự không nhất quán

Trong các hệ thống đọc-ghi tách biệt, dữ liệu thường được lưu trữ đồng thời ở CSDL (như MySQL) và bộ nhớ đệm (như Redis). Vấn đề cốt lõi xuất hiện khi có thao tác ghi: khoảng thời gian trễ giữa lúc CSDL cập nhật thành công và lúc bộ nhớ đệm được làm mới sẽ tạo ra một cửa sổ bất đồng bộ.

Ví dụ: Một giao dịch giảm số lượng tồn kho xuống 99 đã được ghi nhận vào MySQL, nhưng Redis vẫn đang giữ giá trị cũ là 100. Nếu ngay lúc này có một truy vấn đọc đến, hệ thống sẽ trả về dữ liệu lỗi thời (dirty data). Mục tiêu của đồng bộ bộ nhớ đệm là kiểm soát cửa sổ bất đồng bộ này ở mức chấp nhận được mà không làm suy giảm hiệu năng.

Chiến lược cơ bản: Ưu tiên ghi CSDL, sau đó vô hiệu hóa bộ nhớ đệm

Đây là phương pháp phổ biến nhất, trong đó luồng xử lý sẽ cập nhật cơ sở dữ liệu trước, rồi xóa key tương ứng trong Redis.

Tại sao không áp dụng "Xóa cache trước, ghi DB sau"?

Xét kịch bản cạnh tranh giữa thao tác đọc và ghi:

Luồng A (Ghi)              Luồng B (Đọc)
  │                           │
① Xóa cache thành công        │
  │                           │
② Chưa kịp ghi DB...         │
  │                        ③ Lỗi miss cache
  │                        ④ Truy vấn DB lấy giá trị cũ (100)
  │                        ⑤ Ghi ngược giá trị cũ vào cache
  │                           │
⑥ Ghi DB thành công (99)     │
  │                           │
Kết quả: DB=99, Cache=100 ❌

Cách này rất dễ khiến dữ liệu cũ bị nạp lại vào cache do điều kiện chạy đua, tạo ra trạng thái không nhất quán lâu dài.

Tại sao chọn "Xóa" thay vì "Cập nhật" cache?

  • Hiệu suất: Lệnh xóa có độ phức tạp O(1). Nếu giá trị cache là kết quả tổng hợp từ nhiều bảng, việc cập nhật đòi hỏi truy vấn phức tạp lại CSDL, tốn kém hơn nhiều so với việc chỉ xóa và để lần đọc sau tự nạp.
  • Tính đúng đắn: Ghi đè cache đòi hỏi cấu trúc dữ liệu đầy đủ, trong khi thao tác nghiệp vụ thường chỉ thay đổi vài trường. Xóa bỏ key sẽ buộc lần đọc tiếp theo phải lấy toàn bộ dữ liệu mới nhất từ CSDL, đảm bảo tính toàn vẹn.
// ❌ Cập nhật cache: Phức tạp và dễ sai lệch cấu trúc
cacheClient.set("item:1001", rebuildComplexJsonFromDb(id));

// ✅ Xóa cache: Đơn giản và an toàn
cacheClient.delete("item:1001");

Mô phỏng kịch bản đồng thời

Kịch bản 1: Hai thao tác ghi đồng thời

Cả hai luồng đều ghi DB và xóa cache. Vì thao tác xóa có tính lũy đẳng, kết quả cuối cùng trong DB sẽ được nạp lại vào cache ở lần đọc tiếp theo, đảm bảo tính nhất quán.

Kịch bản 2: Đọc và ghi đồng thời

Luồng ghi cập nhật DB xong và xóa cache. Trong khoảng thời gian cực ngắn trước khi xóa, luồng đọc có thể lấy được dữ liệu cũ từ cache. Tuy nhiên, ngay sau thao tác xóa, mọi truy vấn đọc tiếp theo sẽ buộc phải đi vào DB. Cửa sổ không nhất thống chỉ kéo dài vài mili-giây.

Rủi ro tiềm ẩn

Lỗ hổng duy nhất của phương án này là thao tác xóa cache có thể thất bại do lỗi mạng hoặc sự cố Redis. Khi đó, dữ liệu cũ vẫn nằm im trong cache, gây ra sự không nhất quán dài hạn. Do đó, cần các cơ chế dự phòng ở các phần sau.

Giải pháp tối ưu: Xóa kép có độ trễ

Nguyên lý hoạt động

Phương án này là một nỗ lực khắc phục cho nhược điểm của cách "Xóa cache trước, ghi DB sau". Các bước thực hiện: Xóa cache lần 1 → Cập nhật DB → Tạm dừng (Sleep) một khoảng thời gian → Xóa cache lần 2. Lần xóa thứ 2 nhằm dọn dẹp dữ liệu bẩn có thể bị luồng đọc ghi ngược vào trong khoảng thời gian CSDL chưa cập nhật xong.

Ước lượng thời gian trễ

Thời gian trễ phải đủ dài để bao quát toàn bộ thời gian một luồng đọc truy vấn DB và ghi ngược vào cache:

Thời gian chờ = Thời gian đọc DB + Thời gian ghi Cache + Biên an toàn

Giá trị thực tế thường rơi vào khoảng 500ms đến 1000ms.

Triển khai mã nguồn

public void modifyItem(Long itemId, ItemUpdateRequest req) {
    String cacheKey = "item:" + itemId;
    
    // Bước 1: Vô hiệu hóa cache lần đầu
    cacheClient.delete(cacheKey);

    // Bước 2: Cập nhật nguồn dữ liệu chính
    itemRepository.update(itemId, req);

    // Bước 3: Tạm ngưng luồng xử lý
    try {
        Thread.sleep(DELAY_INTERVAL_MS);
    } catch (InterruptedException ex) {
        Thread.currentThread().interrupt();
    }

    // Bước 4: Dọn dẹp cache lần hai
    cacheClient.delete(cacheKey);
}

Điểm yếu của phương pháp

  • Suy giảm吞吐量 (Throughput): Luồng bị block do sleep làm giảm đáng kể khả năng xử lý đồng thời.
  • Khó căn chỉnh thời gian: Ước lượng sai thời gian trễ sẽ khiến cơ chế vô hiệu hoặc gây chậm phản hồi không cần thiết.
  • Vẫn có thể thất bại: Lệnh xóa lần 2 vẫn có nguy cơ lỗi kết nối mạng.
  • Xâm phạm mã nguồn: Đưa logic sleep vào nghiệp vụ làm mã khó bảo trì.

Nhất quán dữ liệu cuối cùng: Đồng bộ hóa bất đồng bộ

Đây là kiến trúc được khuyến nghị cho các hệ thống sản xuất, giải quyết triệt để vấn đề xóa cache thất bại mà không làm ảnh hưởng đến luồng nghiệp vụ chính.

Theo dõi Binlog để xóa cache

Luồng nghiệp vụ chỉ tập trung vào việc ghi CSDL. Một dịch vụ độc lập sẽ đóng vai trò slave của MySQL, lắng nghe các thay đổi dữ liệu thông qua Binlog (UPDATE/DELETE) và tự động phát lệnh xóa key tương ứng trên Redis. Phương pháp này tách biệt hoàn toàn logic caching ra khỏi code nghiệp vụ.

Kiến trúc tích hợp Canal và Hàng đợi thông điệp

Để đảm bảo tin cậy, người ta thường chèn một lớp Message Queue (như Kafka hoặc RabbitMQ) giữa Canal và dịch vụ dọn dẹp cache:

MySQL (Binlog) → Canal (Phân tích) → MQ (Lưu trữ tạm & Đảm bảo giao hàng) → Dịch vụ vô hiệu hóa Cache → Redis

public class BinlogEventProcessor {
    public void monitorChanges() {
        CanalConnector conn = CanalConnectors.newSingleConnector(
            new InetSocketAddress("canal-node", 11111), "target", "", "");

        while (true) {
            conn.connect();
            conn.subscribe(".*\\..*");
            Message batch = conn.get(100);
            
            for (Entry record : batch.getEntries()) {
                if (record.getEntryType() == EntryType.ROWDATA) {
                    RowChange change = RowChange.parseFrom(record.getStoreValue());
                    if (change.getEventType() == EventType.UPDATE || change.getEventType() == EventType.DELETE) {
                        String tableName = record.getHeader().getTableName();
                        for (RowData row : change.getRowDatasList()) {
                            row.getBeforeColumnsList().stream()
                                .filter(Column::getIsKey)
                                .forEach(keyCol -> {
                                    String targetKey = tableName + ":" + keyCol.getValue();
                                    cacheClient.delete(targetKey);
                                });
                        }
                    }
                }
            }
        }
    }
}

Cơ chế thử lại và bù đắp lỗi

Khi dịch vụ consumer nhận sự kiện từ MQ nhưng không thể xóa cache, hệ thống cần thực hiện thử lại với khoảng thời gian lũy thừa (Exponential Backoff). Nếu vẫn thất bại, sự kiện sẽ được đẩy vào Dead Letter Queue để xử lý thủ công.

@RabbitListener(queues = "cache-invalidation-q")
public void onInvalidateEvent(InvalidateMessage payload) {
    int attempts = 0;
    while (attempts < MAX_RETRIES) {
        try {
            cacheClient.delete(payload.getTargetKey());
            return;
        } catch (Exception err) {
            attempts++;
            if (attempts >= MAX_RETRIES) {
                deadLetterProducer.publish(payload);
                return;
            }
            Thread.sleep((long) Math.pow(2, attempts) * 1000);
        }
    }
}

Thêm vào đó, một tác vụ định kỳ (Cron Job) nên được thiết lập để quét các bản ghi vừa cập nhật, so sánh dữ liệu giữa CSDL và Redis nhằm dọn dẹp những key bị sót.

Bảo đảm an toàn với TTL

Dù áp dụng cơ chế nào, việc thiết lập thời gian sống (TTL) cho key vẫn là lưới an toàn cuối cùng. Nếu mọi cơ chế đồng bộ đều sụp đổ, dữ liệu cũng sẽ tự động bị xóa sau khi hết hạn.

cacheClient.setex("item:" + id, 1800, serializedData);

Xử lý các kịch bản đọc bất thường

Thủng bộ nhớ đệm (Cache Penetration)

Xảy ra khi client liên tục truy vấn một dữ liệu không tồn tại (ví dụ id = -1), làm quá tải CSDL. Giải pháp:

  • Lưu trữ giá trị rỗng: Cache một đối tượng rỗng với thời gian sống ngắn.
  • Bộ lọc Bloom: Sử dụng Bloom Filter làm lớp bảo vệ phía trước Redis. Nếu Bloom Filter khẳng định phần tử không tồn tại, chặn request ngay lập tức.
BloomFilter<Long> idValidator = BloomFilter.create(Funnels.longFunnel(), expectedSize, fpp);

if (!idValidator.mightContain(targetId)) {
    return null;
}

Phá vỡ bộ nhớ đệm (Cache Breakdown)

Xảy ra khi một "hot key" đột ngột hết hạn, hàng loạt request đồng thời đổ thẳng xuống CSDL. Giải pháp:

  • Khóa phân tán (Mutex): Chỉ cấp phép cho một request đi xuống DB, các request khác sẽ chờ và đọc lại cache.
  • Hết hạn logic: Key không gán TTL vật lý, thay vào đó lưu thêm một timestamp hết hạn trong value. Khi phát hiện hết hạn logic, một luồng nền sẽ bất đồng bộ cập nhật lại cache, trong khi request hiện tại vẫn được phục vụ bằng dữ liệu cũ.
public Item fetchItemWithLock(Long targetId) {
    String key = "item:" + targetId;
    String cached = cacheClient.get(key);
    if (cached != null) return deserialize(cached);

    RLock mutex = redisson.getLock("lock:item:" + targetId);
    if (mutex.tryLock(5, 15, TimeUnit.SECONDS)) {
        try {
            cached = cacheClient.get(key); // Kiểm tra lại
            if (cached != null) return deserialize(cached);
            Item dbItem = itemRepository.findById(targetId);
            cacheClient.setex(key, 1800, serialize(dbItem));
            return dbItem;
        } finally {
            mutex.unlock();
        }
    } else {
        Thread.sleep(100);
        return fetchItemWithLock(targetId);
    }
}

Sập bộ nhớ đệm (Cache Avalanche)

Xảy ra khi hàng loạt key cùng expire một lúc hoặc toàn bộ Redis cluster ngừng hoạt động. Giải pháp:

  • Làm lệch thời gian TTL: Cộng thêm một giá trị ngẫu nhiên vào TTL cơ bản để tránh việc key expire đồng loạt.
  • Bộ nhớ đệm đa tầng: Kết hợp cache cục bộ (Caffeine) + Redis + DB.
  • Hạn lưu & Giảm tải: Sử dụng Sentinel để giới hạn lưu lượng truy cập xuống DB khi có sự cố.
int jitter = new Random().nextInt(300);
int adjustedTtl = BASE_EXPIRE_SECONDS + jitter;
cacheClient.setex(key, adjustedTtl, value);

Đánh giá và khuyến nghị lựa chọn

Chiến lượcMức độ nhất quánHiệu suấtĐộ phức tạpPhù hợp với
Ghi DB trước, Xóa cache sauKhông nhất quán ngắn (ms)CaoThấpĐa số các nghiệp vụ thông thường
Xóa kép có độ trễCửa sổ bất đồng bộ nhỏTrung bìnhTrung bìnhHệ thống cần giảm thiểu lỗi đọc cũ, không thích dùng MQ
Canal + MQ bất đồng bộNhất quán cuối cùng (giây)CaoCaoHệ thống quy mô lớn, khối lượng dữ liệu lớn

Khuyến nghị:

  • Nếu hệ thống chấp nhận độ trễ vài giây: Ghi DB trước, xóa cache sau, kết hợp TTL.
  • Nếu yêu cầu khắt khe hơn (chấp nhận trễ mili-giây): Áp dụng xóa kép có độ trễ.
  • Nếu cần cơ chế bù đắp và khả năng truy vết: Lựa chọn Canal + MQ kết hợp quét bù đắp định kỳ.
  • Nếu yêu cầu bắt buộc nhất quán tuyệt đối (như tài chính): Tránh dùng cache, truy vấn trực tiếp DB hoặc sử dụng các giải pháp cache hỗ trợ giao dịch.

Thẻ: Redis mysql canal CacheConsistency DistributedSystems

Đăng vào ngày 17 tháng 9 lúc 12:23