Vấn đề flag kỳ lạ trong Memcache giữa .NET và Java

Bối cảnh

Gần đây khi làm việc với các dự án .NET Core, chúng tôi sử dụng Memcache làm server cache. Quá trình tích hợp Memcache vào Spring Boot được thực hiện thông qua thư viện spymemcached. Việc cấu hình khá đơn giản: thêm dependency, viết helper class và inject thông qua @Configuration.

Tuy nhiên, câu chuyện thú vị mới chỉ bắt đầu từ đây...

Vấn đề gặp phải

Sau khi cấu hình xong, hệ thống bắt đầu đọc các cache đã có sẵn. Kết quả trả về là null và xuất hiện cảnh báo: Failed to decompress data. Điều đặc biệt là dữ liệu vẫn đọc được bình thường từ command line. Tất cả dữ liệu cache đều là string, loại trừ khả năng vấn đề serialization (ban đầu cũng nghi ngờ về sự khác biệt trong string serialization giữa Java và .NET).

Do cảnh báo ban đầu chỉ là warn nên bị bỏ qua, tiếp theo là quá trình debug kéo dài:

  1. Java cache, Java đọc - OK
  2. Java cache, .NET đọc - OK
  3. Thêm trực tiếp từ console, Java đọc - OK
  4. Đổi sang thư viện xmemcached cho Java
  5. Triển khai thêm nhiều môi trường Memcache khác nhau

Kết luận cuối cùng rất bất ngờ: .NET cache (sử dụng Enyim.Caching), Java không thể đọc được bình thường.

Đây là kết luận rất kỳ lạ vì khi hỏi ai cũng nói rằng "Memcache không phụ thuộc ngôn ngữ"!

Giải pháp tạm thời

Sau nhiều lần thử nghiệm thất bại, cuối cùng cũng tìm ra manh mối từ kỹ thuật của nhà cung cấp: cần sử dụng các interface bắt đầu bằng string để đọc. Điều này cho thấy không phải đọc không được mà là giải mã bị lỗi, trả về null.

Tiếp tục điều tra, phát hiện tham số flag đóng vai trò quan trọng. Cụ thể, các client khác nhau sử dụng các giá trị flag khác nhau để đánh dấu dữ liệu: Java dùng flag 32, .NET dùng giá trị khác. Giải pháp đề xuất là sửa .NET client thành flag 32.

Tiếp theo, clone source code của Enyim.Caching và tìm đến vị trí tạo flag trong file DefaultTranscoder.cs (khoảng dòng 74):

public static uint TypeCodeToFlag(TypeCode code)
{
    return 32;

    //return (uint)((int)code | 0x0100);  //Trước khi sửa
}

Trong đó, TypeCode là enum định nghĩa các kiểu dữ liệu của hệ thống. Đối với String, giá trị là 18:

namespace System
{
    [ComVisible(true)]
    public enum TypeCode
    {
        Empty = 0,
        Object = 1,
        DBNull = 2,
        Boolean = 3,
        Char = 4,
        SByte = 5,
        Byte = 6,
        Int16 = 7,
        UInt16 = 8,
        Int32 = 9,
        UInt32 = 10,
        Int64 = 11,
        UInt64 = 12,
        Single = 13,
        Double = 14,
        Decimal = 15,
        DateTime = 16,
        String = 18
    }
}

Theo kết quả nghiên cứu, cần đặt flag của .NET client thành 32. Kết quả sau khi sửa: Java đọc cache bình thường!

Tuy nhiên, giải pháp này có nhược điểm: tất cả các project phải reference phiên bản tự build này, và mỗi khi Enyim.Caching nâng cấp, lại phải tải về, build lại và cập nhật cho toàn bộ các project.

Giải pháp tối ưu hơn

Quyết tâm tìm giải pháp triệt để hơn, tôi phân tích sâu hơn và phát hiện trong source code của spymemcached. Cụ thể, class SerializingTranscoder.java xử lý giải mã như sau:

public Object decode(CachedData d) {
    byte[] data = d.getData();
    Object rv = null;
    if ((d.getFlags() & COMPRESSED) != 0) {
      data = decompress(d.getData());
    }
    int flags = d.getFlags() & SPECIAL_MASK;
    if ((d.getFlags() & SERIALIZED) != 0 && data != null) {
      rv = deserialize(data);
    } else if (flags != 0 && data != null) {
      switch (flags) {
      case SPECIAL_BOOLEAN:
        rv = Boolean.valueOf(tu.decodeBoolean(data));
        break;
      case SPECIAL_INT:
        rv = Integer.valueOf(tu.decodeInt(data));
        break;
      // ... các case khác
      default:
        getLogger().warn("Undecodeable with flags %x", flags);
      }
    } else {
      rv = decodeString(data);
    }
    return rv;
}

Method decodeString không có xử lý đặc biệt:

protected String decodeString(byte[] data) {
    String rv = null;
    try {
      if (data != null) {
        rv = new String(data, charset);
      }
    } catch (UnsupportedEncodingException e) {
      throw new RuntimeException(e);
    }
    return rv;
}

Quan trọng nhất, xem logic giải nén trong BaseSerializingTranscoder.java, có đoạn catch xuất ra lỗi "Failed to decompress data" - chính là lỗi đã gặp.

Vậy tại sao đặt flag = 32 lại hoạt động? Xem logic kiểm tra nén:

static final int COMPRESSED = 2;
if ((d.getFlags() & COMPRESSED) != 0) {
    data = decompress(d.getData());
}

.NET mặc định sử dụng: 18 | 0x0100 = 274

274 & 2 = 2  // Khác 0, sẽ giải nén -> lỗi
32 & 2 = 0   // Bằng 0, không giải nén -> bình thường

Điều này xác nhận rằng flag không phụ thuộc vào ngôn ngữ mà liên quan đến cờ nén và kiểu dữ liệu.

Vấn đề đã rõ: miễn là không thực hiện giải nén thì sẽ hoạt động. Các tham số này là trạng thái nội bộ của class, không thể sửa từ bên ngoài. Tuy nhiên có thể mở rộng bằng cách tự tạo Transcoder riêng.

Method get của memcachedClient hỗ trợ tham số Transcoder tùy chỉnh: memcachedClient.get(String key, Transcoder<T> tc)

Giải pháp: Tạo custom Transcoder kế thừa BaseSerializingTranscoder, override không cho giải nén:

HMSerializingTranscoder transcoder = new HMSerializingTranscoder();
return memcachedClient.get(key, transcoder);

Kết luận

Vấn đề đã được phân tích rõ ràng và giải pháp đã xác định. Bằng cách mở rộng thư viện có sẵn thay vì sửa source code bên thứ ba, không cần lo lắng về việc nâng cấp thư viện trong tương lai.

Bài học kinh nghiệm: Đọc source code và đi sâu vào vấn đề mang lại kết quả đáng giá - giải quyết triệt để mà không phụ thuộc vào các phiên bản thư viện.

Đăng vào ngày 27 tháng 6 lúc 22:02