Dễ hiểu nhưng không đơn giản

Memory Caching có lẽ là một trong những phần hứng thú nhất đối với các anh em lập trình, đặc biệt với các anh em lập trình hệ thống và đam mê việc cải thiện performance. Memory Caching về cơ bản là tương đối dễ hiểu, nó chỉ đơn giản là việc tổ chức và lấy dữ liệu trên bộ nhớ chính (RAM) vì tốc độ của bộ nhớ này rất nhanh, dữ liệu không có trên bộ nhớ sẽ được lấy từ cơ sở dữ liệu và dữ liệu trên bộ nhớ khi thay đổi cũng sẽ được đồng bộ lại với cơ sở dữ liệu.

Tổ chức dữ liệu

Dữ liệu được lưu trên cache thường ở dạng key-value, cả key và value thường sẽ ở dạng byte array, khai báo trong java sẽ kiểu thế này:

Map<ByteArray, byte[]> map

Tại sao chúng ta lại không lưu ở dạng đối tượng mà lại lưu ở dạng byte array? Đó là vì một số nguyên nhân sau:

  1. Lưu dữ liệu ở dạng đối tượng sẽ tốn bộ nhớ: một con trỏ trong java cũng tốn 8 bytes rồi vậy nếu chúng ta chuyển từ byte array sang đối tượng, có thể chúng ta sẽ phải sử dụng rất nhiều con trỏ. Thêm vào nữa khi lưu dữ liệu ở dạng byte array chúng ta sẽ có thể tận được sức mạnh của các giao thức như Protobuf hay MsgPack để giảm được kích thước của dữ liệu, từ đó tiết kiệm được rất nhiều khi lưu ở bộ nhớ
  2. Chúng ta thường sẽ không biết được kiểu đối tượng được lưu sẽ là gì: Bạn hãy nhớ lại việc mình sử dụng Redis thế nào nhé, bạn có thể thấy bạn chỉ cần cài đặt Redis server và chạy, bạn không can thiệp bất cứ thứ gì hay viết bất cứ thứ gì để can thiệp vào phần core của server cả, chính vì vậy bạn sẽ không có cách nào để biết dữ liệu sẽ được lưu trên bộ nhớ là gì ngoài byte array. Muốn lưu được đối tượng, bạn sẽ phải cho phép người dùng viết plugin, nhưng điều đó quá phức tạp với họ
  3. Lưu đối tượng sẽ làm giảm hiệu năng của cache server: Muốn lưu được dạng đối tượng, chúng ta sẽ phải trải qua các bước serialize và deserialize điều này sẽ rất tốn thời gian xử lý cho server, tốt nhất việc đó hãy để client lo.

Đọc dữ liệu

Việc đọc dữ liệu tương đối đơn giản.

Bước 1. Client sẽ serialize key thành byte array Bước 2. Client sẽ gọi đến cache với key byte array này để lấy giá trị (value) Bước 3. Cache sẽ sẽ kiểm tra trong map trên bộ có nhớ có giá trị không, nếu không có, nó sẽ lục tìm ở trong database và ném dữ liệu vào bộ nhớ, lần kế tiếp nó sẽ không cần phải truy cập vào db nữa. Bước 4. Cache sẽ trả lại dữ liệu cho client, tất cả ở dạng byte array Bước 5. Client sẽ deserialize byte array thành đối tượng tương ứng cho dev

Quá trình đọc dữ liệu sẽ sử dụng chung một luồng để tránh tình trạng không đồng bộ (inconsistent), code sẽ kiểu thế này:

public byte[] get(ByteArray key) {
    byte[] value = null;
    synchronized (map) {
        value = map.get(key);
    }

    if(value == null)
        value = load(key);
    return value;

}

protected byte[] load(ByteArray key) {
    Lock keyLock = lockProvider.provideLock(key);
    byte[] unloadValue = null;
    keyLock.lock();
    try {
        byte[] value = map.get(key);
        if(value != null)
            return value;
        unloadValue = mapPersistExecutor.load(mapSetting, key);
    }
    finally {
        keyLock.unlock();
    }
    if(unloadValue != null) {
        synchronized (map) {
            map.putIfAbsent(key, unloadValue);
        }
    }
    return unloadValue;
}

Bạn có thể tham khảo file này để xem chi tiết hơn

Ghi dữ liệu

Việc đọc dữ liệu có vẻ đơn giản, nhưng việc ghi dữ liệu lại phức tạp hơn rất nhiều:

Bước 1. Client sẽ serialize cả key và value thành dạng byte array và gửi đến cache Bước 2. Cache sẽ lưu chính xác các byte array này vào bộ nhớ, sau đó nó sẽ ném các byte array này vào một queue để cho luồng khác thực hiện việc ghi vào database, điều này cho phép cache nhanh chóng được giải phóng để trả về kết quả cho client Bước 3. Cache sẽ trả lại kết quả cho client Bước 4. Client kết thúc việc gọi cache và làm việc khác. Bước 5. Song song với bước 3 và 4 tầng persistent cũng sẽ gom lại các câu lệnh update thành một bó và lưu xuống database để làm tăng hiệu năng.

Code sẽ kiểu thế này:

public byte[] put(ByteArray key, byte[] value) {
    byte[] v = null;
    synchronized (map) {
        v = map.put(key, value);
        mapPersistExecutor.persist(mapSetting, key, value);
    }
    return v;
}

Bạn có thể tham khảo file này để xem chi tiết hơn

Tổng kết

Bài viết đến đây tương đối dài rồi nên mình sẽ tổng kết lại một chút. Về cơ bản memory cache rất đơn giản, nó chỉ lưu trữ dữ liệu ở dạng byte array chính vì thế nó rất nhanh, không giống như dữ liệu được lưu trữ ở database, phải là ở dạng đối tượng có thể so sánh được để lưu trữ vào BTree. Nếu dự án của bạn đang cần một giải pháp để nâng cao hiệu năng, hay cân nhắc việc sử dụng cache nhé.