使用快取記憶體Serde加速Kafka反序列化效能 - Kaszuba

發表於2021-04-23

Kafka內部世界是在位元組級別上儲存狀態的,Serde負責在外部領域語言和Kafka世界之間進行翻譯,但會造成一定的效能損失,因為讀寫需要“始終”通過Serde,尤其是在使用諸如Avro之類的重型Serdes時。

Kafka峰會上,彭博社的Lei Chen很好地解釋了反序列化的效能問題。有幾種解決此問題的方法。彭博社採取的方法是建立自定義狀態儲存。這是一個非常好的解決方案,並且可以在所有情況下使用,但實際上我很困惑為什麼預設情況下框架沒有這樣的狀態儲存。如何執行此操作以及與此實現相關的問題可以在此處找到。但是還有一種更簡單的方法可能足以滿足您的用例,即快取Serde。

如果您的資料在對狀態儲存的讀取或寫入時都不經常更改,則在Serde級別上進行快取可能就足夠了。它實現起來很簡單,並且不需要對流api發生任何干擾。

我將只關注反序列化,但是相同的概念也適用於序列化。快取反序列化器通過維護記憶體中快取來工作。讀取時,首先搜尋快取以檢視該值是否已反序列化,如果已經返回,則返回該值;如果尚未返回,則呼叫內部反序列化器,並將反序列化的值儲存在快取中。如果達到快取記憶體的最大大小,它將開始清除最早的條目。任何型別的快取都可以用於此目的,下面介紹的一種使用LinkedHashMap,因為它的功能類似於堆疊,因此當達到一定大小的快取時,可以刪除最舊的條目。

package tkaszuba;

import org.apache.kafka.common.serialization.Deserializer;
import org.apache.kafka.common.utils.Bytes;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.util.LinkedHashMap;
import java.util.Map;

public class CachingDeserializer<T> implements Deserializer<T> {
    private static final Logger logger = LoggerFactory.getLogger(CachingDeserializer.class);

    private final LinkedHashMap<Bytes, T> cache = new LinkedHashMap<>();
    private final Deserializer<T> inner;
    private final int cacheSize;

    public CachingDeserializer(Deserializer<T> inner) {
        this(500, inner);
    }

    public CachingDeserializer(int cacheSize, Deserializer<T> inner) {
        this.cacheSize = cacheSize;
        this.inner = inner;
    }

    @Override
    public T deserialize(String s, byte[] bytes) {
        Bytes key = Bytes.wrap(bytes);
        if (cache.containsKey(key)) {
            logger.debug("Taking deserialized value from cache");
            return cache.get(key);
        }

        if (cache.size() == cacheSize)
            removeOldestEntryFromCache();

        T value = inner.deserialize(s, bytes);

        logger.debug("Adding deserialized value to cache");
        cache.put(key, value);

        return value;
    }

    public Map<Bytes, T> getCache() {
        return cache;
    }

    private void removeOldestEntryFromCache() {
        logger.debug("Removing oldest deserialized value from cache");
        cache.remove(cache.entrySet().iterator().next().getKey());
    }

    @Override
    public void configure(Map<String, ?> configs, boolean isKey) {
        inner.configure(configs, isKey);
    }

    @Override
    public void close() {
        inner.close();
    }
}

CachingDeserializer使用Byte物件包裝器作為鍵,而不是位元組陣列,因為它更易於使用。當達到快取大小時,將使用迭代器刪除列表中的第一項,因此無需進行昂貴的遍歷。

使用反序列化器的方法如下:

@Test
void testCachingSerde() {
   KeyValue<Integer, String> keyValue1 = new KeyValue<>(1, "test");
   KeyValue<Integer, String> keyValue2 = new KeyValue<>(2, "test");
   KeyValue<Integer, String> keyValue3 = new KeyValue<>(3, "test");

   KeyValueSerializer<Integer, String> innerSerializer = new KeyValueSerializer<>(Serdes.Integer(), Serdes.String());
   KeyValueDeserializer<Integer, String> innerDeserializer = new KeyValueDeserializer<>(Serdes.Integer(), Serdes.String());

   try(CachingDeserializer<KeyValue<Integer, String>> deserializer = new CachingDeserializer<>(2, innerDeserializer)) {
      assertDoesNotThrow(() -> deserializer.configure(Collections.emptyMap(), false));

      byte[] value1 = innerSerializer.serialize(TOPIC, keyValue1);
      byte[] value2= innerSerializer.serialize(TOPIC, keyValue2);
      byte[] value3 = innerSerializer.serialize(TOPIC, keyValue3);

      assertEquals(keyValue1, deserializer.deserialize(TOPIC, value1));
      assertEquals(1, deserializer.getCache().size(), "Should contain one item in the cache");

      assertEquals(keyValue1, deserializer.deserialize(TOPIC, value1));
      assertEquals(1, deserializer.getCache().size(), "Should contain one item in the cache");
      assertEquals(keyValue1, deserializer.getCache().entrySet().iterator().next().getValue(), "Should contain keyValue1");

      assertEquals(keyValue2, deserializer.deserialize(TOPIC, value2));
      assertEquals(2, deserializer.getCache().size(), "Should contain two items in the cache");

      Iterator<Map.Entry<Bytes, KeyValue<Integer, String>>> iterator = deserializer.getCache().entrySet().iterator();
      assertEquals(keyValue1, iterator.next().getValue(), "Should contain keyValue1");
      assertEquals(keyValue2, iterator.next().getValue(), "Should contain keyValue2");

      assertEquals(keyValue3, deserializer.deserialize(TOPIC, value3));
      assertEquals(2, deserializer.getCache().size(), "Should contain two items in the cache");

      iterator = deserializer.getCache().entrySet().iterator();
      assertEquals(keyValue2, iterator.next().getValue(), "Should contain keyValue2");
      assertEquals(keyValue3, iterator.next().getValue(), "Should contain keyValue3");

   }
}

注意:在快取中儲存複雜的可變物件(例如Avro)時,應克隆返回的物件,否則將修改快取中的原始物件。直接從狀態儲存讀取資料時,您無需擔心,因為在反序列化過程中會克隆物件。您可能已將其包含在Serde中,具體取決於如何使用Serde。

因此,自然而然的問題是,為什麼您不將所有內容都跳過而不直接將快取記憶體保留在記憶體中,而無需在後臺使用任何狀態儲存呢?我想這全都取決於您的用例,狀態儲存提供了容錯能力並很好地處理了多個分割槽,但是確實引入了很多複雜性,這些複雜性有時很難處理。希望在新版本中,API將會增長,並且與它們一起使用將變得更加容易。

 

相關文章