網易雲商·七魚智慧客服自適應 ProtoStuff 資料庫快取實踐

網易智企發表於2023-05-17

需求背景

目前,網易雲商·七魚智慧客服資料庫快取使用了 spring-data-redis 框架,並由自研的快取元件進行管理。該元件使用 Jackson 框架對快取資料進行序列化和反序列化,並將其以明文 JSON 的形式儲存在 Redis 中。

這種方式存在兩個問題:

  • 速度慢,CPU佔用高

在應用服務中,讀寫快取資料時需要進行字串的反序列化和序列化操作,即將物件轉換為 JSON 格式再轉換為位元組陣列,但是使用 Jackson 序列化方式的效能並不是最優的。此外,線上上服務分析中發現,對於快取命中率較高的應用,在併發稍微高一點的情況下,Jackson 序列化會佔用較多的 CPU 資源。

  • 儲存空間大,資源浪費

對於 Redis 叢集來說,JSON 資料佔用的儲存空間較大,會浪費 Redis 儲存資源。

在對同類序列化框架進行調研後,我們決定使用 ProtoStuff 代替 Jackson 框架。本文將簡要介紹 ProtoStuff 的儲存原理,並討論在替換過程中遇到的一些問題。

關於 ProtoStuff

什麼是 ProtoStuff?

ProtoStuff 是一種基於 Google Protocol Buffers(protobuf)協議的序列化和反序列化庫,它可以將 Java 物件序列化為二進位制資料並進行網路傳輸或儲存,也可以將二進位制資料反序列化為 Java 物件。與其他序列化庫相比,ProtoStuff 具有更高的效能和更小的序列化大小,因為它使用了基於標記的二進位制編碼格式,同時避免了 Java 序列化的一些缺點,例如序列化後的資料過大和序列化效能較慢等問題。因此,ProtoStuff 被廣泛應用於高效能的分散式系統和大規模資料儲存系統中。

Protostuff 的序列化編碼演算法與 Protobuf 基本相同,都採用基於 Varint 編碼的變長序列化方式,以實現對編碼後的位元組陣列的壓縮。此外,Protostuff 還引入了 LinkedBuffer 這種資料結構,透過連結串列的方式將不連續記憶體組合起來,從而實現資料的動態擴張,提高儲存效率。

Varint 編碼是一種可變長度的整數編碼方式,用於壓縮數字資料,使其更加緊湊。它使用 1 個或多個位元組來表示一個整數,其中每個位元組的高位都用於指示下一個位元組是否屬於同一個數。較小的數字使用較少的位元組編碼,而較大的數字則需要更多的位元組編碼。這種編碼方式被廣泛應用於網路傳輸和儲存領域。

LinkedBuffer

簡單看一下 LinkedBuffer 的原始碼:

public final class LinkedBuffer{
    /**
     * The minimum buffer size for a {@link LinkedBuffer}.
     */
    public static final int MIN_BUFFER_SIZE = 256;

    /**
     * The default buffer size for a {@link LinkedBuffer}.
     */
    public static final int DEFAULT_BUFFER_SIZE = 512;

    final byte[] buffer;

    final int start;

    int offset;

    LinkedBuffer next;       
}

byte[] buffer 是用來儲存序列化過程中的位元組陣列的,預設的大小是 512,最低可以設定成 256。LinkedBuffer next 指向的是下一個節點。start 是開始位置,offset 是偏移量。

連結串列大概長這樣,這樣就可以把幾塊連續的記憶體塊給連結到一起了。

Schema 介面

除了 LinkedBuffer 這個類,還有一個關鍵的介面:Schema,這是一個類似於資料庫 DDL 結構的東西,它定義了序列化物件的類的結構資訊,有哪些欄位,欄位的順序是怎麼樣的,怎樣序列化,怎樣反序列化。

在使用的時候一般用的是 RuntimeSchema 這個實現類。

public final class RuntimeSchema<T> implements Schema<T>, FieldMap<T>
{
    private final FieldMap<T> fieldMap;

    public static <T> RuntimeSchema<T> createFrom(Class<T> typeClass, Set<String> exclusions, IdStrategy strategy) {
        // 省略部分程式碼
        final Map<String, java.lang.reflect.Field> fieldMap = findInstanceFields(typeClass);
        final ArrayList<Field<T>> fields = new ArrayList<Field<T>>(fieldMap.size());
        int i = 0;
        boolean annotated = false;
        for (java.lang.reflect.Field f : fieldMap.values()) {
            if (!exclusions.contains(f.getName())) {
                if (f.getAnnotation(Deprecated.class) != null) {
                    i++;
                    continue;
                }
                final Tag tag = f.getAnnotation(Tag.class);
                final int fieldMapping;
                final String name;
                if (tag == null) {
                    // 省略部分程式碼
                    fieldMapping = ++i;
                    name = f.getName();
                }
                else {
                    // 省略部分程式碼
                    annotated = true;
                    fieldMapping = tag.value();
                    // 省略部分程式碼
                    name = tag.alias().isEmpty() ? f.getName() : tag.alias();
                }

                final Field<T> field = RuntimeFieldFactory.getFieldFactory(f.getType(), strategy).create(fieldMapping, name, f,                        strategy);
                fields.add(field);
            }
        }
        return new RuntimeSchema<T>(typeClass, fields, RuntimeEnv.newInstantiator(typeClass));
    }

    static void fill(Map<String, java.lang.reflect.Field> fieldMap, Class<?> typeClass) {
        if (Object.class != typeClass.getSuperclass())
            fill(fieldMap, typeClass.getSuperclass());

        for (java.lang.reflect.Field f : typeClass.getDeclaredFields()) {
            int mod = f.getModifiers();
            if (!Modifier.isStatic(mod) && !Modifier.isTransient(mod) && f.getAnnotation(Exclude.class) == null)
                fieldMap.put(f.getName(), f);
        }
    }

    @Override
    public List<Field<T>> getFields()    {
        return fieldMap.getFields();
    }

    @Override
    public final void writeTo(Output output, T message) throws IOException {
        for (Field<T> f : getFields())
            f.writeTo(output, message);
    }

}

根據 fill 方法的實現,我們可以得知 fieldMap 是透過呼叫當前類及其父類的 getDeclaredFields 方法所獲取的所有欄位。接著,在 createFrom 方法中,我們遍歷所有欄位,獲取每個欄位的序列化序號 fieldMapping。在序列化過程中,我們呼叫 writeTo 方法,將每個欄位按照 fieldMapping 的順序寫入位元組陣列中。

眾所周知,Java 的 getDeclaredFields 方法返回的欄位陣列不是按照特定的順序排列的。欄位的順序取決於具體的 JVM 實現以及編譯器等因素。因此,在不使用 Tag 註解的時候,序列化的欄位順序是不固定的。如果在原有的欄位中間隨意插入一個欄位,或者在合併程式碼的時候調換了欄位的順序,反序列化的資料不僅會錯亂,很大機率還會報錯。

在 ProtoStuff 的官方文件裡,推薦使用 @Tag 註解來顯式的宣告欄位序列化的順序。Tag 註解對於小專案或者固定不會變的類物件確實是挺好用的,但是對於老專案序列化框架遷移來說,多個程式碼倉庫超過 400 個物件需要加 Tag 註解,程式碼改動量和影響範圍將會非常龐大。而且一旦有欄位加了 Tag 註解,那麼後續新增的所有欄位都需要新增註解,並且需要保證新增欄位的順序是遞增的,會有一定的維護成本和風險。

自適應 ProtoStuff 的改造方案

為了減少序列化框架遷移過程的程式碼改動範圍和風險,降低後期編碼維護成本,我們需要一個可以在序列化與反序列化時自動適配欄位的改造方案。

主要思路

序列化

  • 將 getDeclaredFields 方法獲取到的當前類及其父類所有的欄位,根據欄位名稱進行排序。
  • 遍歷排序後的欄位列表,將欄位轉換成 ProtoStuff 需要的 Field 列表,再呼叫 RuntimeSchema 的構造方法新建一個物件。透過 RuntimeSchema 物件完成序列化操作,生成位元組陣列。
  • 由於 ProtoStuff 的編碼是 T-L-V 格式的,只存了物件欄位的下標和具體的值,沒有存完整的類路徑,而且 spring-data-redis 反序列化的時候不知道目標物件的型別,因此還需要一個包裝類來儲存額外的資訊。
  • 對統一包裝物件進行序列化,返回生成的位元組陣列。
  • 將快取物件的類結構資訊快取到 Redis 中,以便反序列化時使用。

為了提供序列化的效率,還可以將 RuntimeSchema 物件快取到本地。

反序列化

將位元組陣列反序列化成通用的包裝類。

從包裝類中獲取到源資料的類路徑,版本號,欄位雜湊值。先判斷源資料類是否是集合或者基本資料型別,如果是基本資料型別,直接返回 source 欄位的內容。如果是集合類,判斷本地版本號是否與包裝類獲取到的版本號一致,一致的時候返回 source 欄位的內容。

源資料型別既不是集合也不是基本資料型別,獲取本地物件的版本號,如果本地物件版本號大於快取版本號,則將快取資料淘汰掉。

如果本地物件的版本號和快取中的版本號一致,就直接使用本地類進行轉換,獲取到 RuntimeSchema 進行反序列化。

如果本地物件的版本號小於快取中的版本號,則需要根據類路徑 + 快取版本號 從 Redis 中獲取到對應的類結構資訊,將本地的欄位進行重新排序,獲取到和快取資料對應的欄位順序值,再生成相應的 RuntimeSchema 進行反序列化。

程式碼實現

ProtoStuff 的入門使用是很簡單的,只需要引入 ProtoStuff 的依賴,然後在需要使用序列化的類欄位上加上 Tag 註解即可使用。也可以不使用註解,ProtoStuff 會根據欄位順序來確定快取中的順序。

增加 Maven 依賴

<!--        protostuff        -->
        <dependency>
            <groupId>io.protostuff</groupId>
            <artifactId>protostuff-core</artifactId>
            <version>1.7.4</version>
        </dependency>

        <dependency>
            <groupId>io.protostuff</groupId>
            <artifactId>protostuff-runtime</artifactId>
            <version>1.7.4</version>
        </dependency>

統一包裝類

public class ProtoStuffWrapper implements Serializable {
    private static final long serialVersionUID = 6310017353904821602L;
    // 版本號
    @Tag(1)
    private int version;
    // 包裝型別的完整路徑名
    @Tag(2)
    private String className;
    // 包裝物件序列化後的位元組陣列
    @Tag(3)
    private byte[] data;
    // 是否是沒有包裝的型別
    @Tag(4)
    private boolean noWrapperObject;
    // 用於儲存集合物件
    @Tag(5)
    private Object source;
    // 類欄位hash
    @Tag(6)
    private int classHash;
    // 省略 get set 和 構造方法
}

對於基本資料型別和一些 Java 的基礎物件,以及集合,Map 類物件,會直接將資料放在 source 中。

重寫序列化方法

實現 org.springframework.data.redis.serializer.RedisSerializer 介面, 重寫序列化方法。

流程圖

程式碼

public class ProtostuffRedisSerializer implements RedisSerializer<Object> {
    private static final Map<String, ProtoSchema> SCHEMA_CACHE = new ConcurrentHashMap<>(200);
    private static final Map<String, Schema> REMOTE_CLASS_SCHEMA_CACHE = new ConcurrentHashMap<>(200);
    private static final Delegate<Timestamp> TIMESTAMP_DELEGATE = new TimestampDelegate();
    private static final DefaultIdStrategy ID_STRATEGY = (DefaultIdStrategy) RuntimeEnv.ID_STRATEGY;
    private static final ThreadLocal<LinkedBuffer> BUFFER = ThreadLocal.withInitial(() -> LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE));
    private static final Schema WRAPPER_SCHEMA = RuntimeSchema.getSchema(ProtoStuffWrapper.class);
    private static final int SECONDS_OF_THIRTY_DAYS = 30 * 60 * 60 * 24;
    private static final long MILLISECOND_OF_THIRTY_DAYS = SECONDS_OF_THIRTY_DAYS * 1000L;
    private final StringRedisTemplate redisTemplate;

    static {
        ID_STRATEGY.registerDelegate(TIMESTAMP_DELEGATE);
    }

    public ProtostuffRedisSerializer(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Override
    public byte[] serialize(Object o) throws SerializationException {
        if (source == null) {
            return EMPTY_ARRAY;
        }
        LinkedBuffer buffer = BUFFER.get();
        byte[] data = new byte[0];
        try {
            String className = getClassName(source);
            Class<?> typeClass = source.getClass();
            Object serializeObj;

            if (isBasicType(source, className) || isArrayType(typeClass)) {
                int classVersion = 0;
                if (isArrayType(typeClass)) {
                    classVersion = readVersion(source);
                }
                serializeObj = new ProtoStuffWrapper(className, classVersion, source);
            } else {
                ProtoSchema protoSchema = getCachedProtoSchema(className, source);
                try {
                    data = ProtostuffIOUtil.toByteArray(source, protoSchema.getSchema(), buffer);
                } finally {
                    buffer.clear();
                }
                serializeObj = new ProtoStuffWrapper(className, data, protoSchema);
            }
            data = ProtostuffIOUtil.toByteArray(serializeObj, WRAPPER_SCHEMA, buffer);
        } catch (Exception e) {
            logger.error("protostuff serialize fail", e);
        } finally {
            buffer.clear();
        }
        return data;
    }

    @Override
    public Object deserialize(byte[] bytes) throws SerializationException {
        return deserialize(source, Object.class);
    }
}

從上面的 deserialize 方法的定義中可以看到,入參只有一個位元組陣列,出參是一個 Object,沒有 Class 類的引數,因此必須要有一個統一的包裝類來儲存目標類的定義資訊。

Timestamp 序列化代理

對於 Timestamp 型別的欄位需要自己寫一個序列化代理去處理,不然會有解析失敗的問題。

public class TimestampDelegate implements Delegate<Timestamp> {
    @Override
    public WireFormat.FieldType getFieldType() {
        return WireFormat.FieldType.FIXED64;
    }

    @Override
    public Timestamp readFrom(Input input) throws IOException {
        return new Timestamp(input.readFixed64());
    }

    @Override
    public void writeTo(Output output, int number, Timestamp timestamp, boolean repeated) throws IOException {
        output.writeFixed64(number, timestamp.getTime(), repeated);
    }

    @Override
    public void transfer(Pipe pipe, Input input, Output output, int number, boolean repeated) throws IOException {
        output.writeFixed64(number, input.readFixed64(), repeated);
    }

    @Override
    public Class<?> typeClass() {
        return Timestamp.class;
    }
}

ProtoSchema

本地快取物件,用來快取序列化物件的 RuntimeSchema 和類的相關資訊。

public class ProtoSchema {
    // 版本號
    private int version;
    // 類欄位hash
    private int hash;
    // 序列化物件的RuntimeSchema
    private Schema schema;
    // 本地快取生效開始時間
    private long createTime;
    // 省略 get set 和 構造方法
}

getCachedProtoSchema

獲取序列化物件的 RuntimeSchema 和類的相關資訊。本地快取中存在則直接使用快取中的資料,不存在時,解析類物件,根據排序後的欄位構建 RuntimeSchema 來進行序列化。

private ProtoSchema getCachedProtoSchema(String className, Object source) {
        ProtoSchema protoSchema = SCHEMA_CACHE.get(className);
        if (protoSchema != null) {
            if (protoSchema.getVersion() == 0) {
                // 基本型別包裝類直接返回
                return protoSchema;
            }
            if (System.currentTimeMillis() - protoSchema.getCreateTime() < MILLISECOND_OF_THIRTY_DAYS) {
                // 本地快取在有效期內直接返回,不在有效期的重新載入類結構資訊
                return protoSchema;
            }
        }
        Class<?> typeClass = source.getClass();
        List<Field<?>> fields = new ArrayList<>();

        LinkedHashMap<String, java.lang.reflect.Field> fieldMap = new LinkedHashMap<>();
        fill(fieldMap, typeClass);
        java.lang.reflect.Field[] declaredFields = fieldMap.values().toArray(new java.lang.reflect.Field[0]);
        // 按欄位名進行排序
        Arrays.sort(declaredFields, Comparator.comparing(java.lang.reflect.Field::getName));
        int length = declaredFields.length;
        List<ProtoFieldDescription> fieldDescriptionList = new ArrayList<>(length);
        java.lang.reflect.Field f;
        Class<?> type;
        io.protostuff.runtime.Field<?> field;
        ProtoFieldDescription d;
        int index = 0;
        for (java.lang.reflect.Field declaredField : declaredFields) {
            f = declaredField;
            type = f.getType();
            d = new ProtoFieldDescription(f.getName(), ++index, type.getCanonicalName());
            fieldDescriptionList.add(d);

            field = RuntimeFieldFactory.getFieldFactory(type, ID_STRATEGY).create(d.getIndex(), d.getFieldName(), f, ID_STRATEGY);
            fields.add(field);
        }
        RuntimeSchema schema = new RuntimeSchema(typeClass, fields, RuntimeEnv.newInstantiator(typeClass));

        String[] fieldNames = fieldDescriptionList.stream().map(ProtoFieldDescription::getFieldName).toArray(String[]::new);
        protoSchema = new ProtoSchema(readVersion(source), Arrays.hashCode(fieldNames), schema);

        // 本地快取ProtoStuffSchema
        SCHEMA_CACHE.putIfAbsent(className, protoSchema);
        // 快取類結構資訊到Redis
        cacheFieldDescription(getCacheKey(className, protoSchema.getVersion()), JSON.toJSONString(fieldDescriptionList));

        return protoSchema;
    }

    static void fill(Map<String, java.lang.reflect.Field> fieldMap, Class<?> typeClass) {
        if (Object.class != typeClass.getSuperclass()) {
            fill(fieldMap, typeClass.getSuperclass());
        }
        for (java.lang.reflect.Field f : typeClass.getDeclaredFields()) {
            int mod = f.getModifiers();
            if (!Modifier.isStatic(mod) && !Modifier.isTransient(mod) && f.getAnnotation(Exclude.class) == null) {
                fieldMap.put(f.getName(), f);
            }

將 ProtoStuffSchema 快取在本地,可以避免每次都重複解析類的結構,最佳化效能。本地快取增加了有效期,可以儲存 Redis 中的類結構資訊和本地快取中的一致,從而避免出現 Redis 中的資料過期導致老版本應用沒法讀取到對應版本類結構資訊的情況。

RuntimeSchema(java.lang.Class, java.util.Collection<io.protostuff.runtime.field>, io.protostuff.runtime.RuntimeEnv.Instantiator) 這個構造方法是自適應的關鍵,正是因為有了這個構造方法,我們才能自己構建欄位的順序。

重寫反序列化方法

流程圖

首先,需要對位元組陣列進行解析,以得到相應的統一包裝類。隨後,需要根據快取版本號和本地類版本號進行比較,以確定是否需要使用快取中的資料。

生成版本號的邏輯是:基礎版本號加上類的欄位數量。如果版本號相同,我們還需要檢查類的欄位雜湊值,然後根據欄位雜湊值獲取排序後的欄位名的雜湊值。

程式碼

public <T> T deserialize(byte[] source, Class<T> type) throws SerializationException {
        if (isEmpty(source)) {
            return null;
        }
        try {
            ProtoStuffWrapper wrapper = new ProtoStuffWrapper();
            ProtostuffIOUtil.mergeFrom(source, wrapper, WRAPPER_SCHEMA);

            int cacheVersion = wrapper.getVersion();
            if (wrapper.isNoWrapperObject()) {
                // 集合陣列,基本型別包裝類 快取物件,快取與本地版本不一致,直接淘汰掉
                if (cacheVersion == 0 || cacheVersion == inferVersion(wrapper.getSource())) {
                    return (T) wrapper.getSource();
                }
                return null;
            }
            String className = wrapper.getClassName();
            if (StringUtils.isNotEmpty(className)) {
                Class<?> typeClass = Class.forName(className);
                ProtoSchema protoSchema = getProtoSchema(className, typeClass);

                int localVersion = protoSchema.getVersion();
                if (cacheVersion >= localVersion) {
                    Schema cachedSchema = getCachedSchema(wrapper, typeClass, protoSchema);

                    if (cachedSchema != null) {
                        Object newMessage = cachedSchema.newMessage();
                        ProtostuffIOUtil.mergeFrom(wrapper.getData(), newMessage, cachedSchema);
                        return (T) newMessage;
                    }
                }
            }
        } catch (Exception e) {
            // 快取,本地結構不一致, 列印一個錯誤日誌
        }
        return null;
    }

    private ProtoSchema getProtoSchema(String className, Class<?> typeClass) throws InstantiationException, IllegalAccessException {
        ProtoSchema protoSchema = SCHEMA_CACHE.get(className);
        if (protoSchema != null) {
            return protoSchema;
        }
        return getCachedProtoSchema(className, typeClass.newInstance());
    }

    private Schema getCachedSchema(ProtoStuffWrapper wrapper, Class<?> typeClass, ProtoSchema protoSchema) {
        if (wrapper.getVersion() == protoSchema.getVersion()) {
            if (protoSchema.getHash() == wrapper.getClassHash()) {
                return protoSchema.getSchema();
            } else {
                // 快取,本地結構不一致, 列印一個錯誤日誌
                // logger.error("警告,本地與快取中的版本號一致,但是欄位順序不一致,應用存在異常。請重新部署, className:{}", wrapper.getClassName());
            }
        }
        // 快取中為新版本,本地為老版本
        return getSchemaFromCache(typeClass, wrapper);
    }

getCachedSchema

本地版本為老版本,快取版本為新版本時,反序列化的時候需要先獲取到 Redis 中新版本的類描述資訊。為了避免重複請求 Redis,類描述資訊也會在本地快取一份資料。

 private <T> Schema<T> getSchemaFromCache(Class<?> typeClass, ProtoStuffWrapper wrapper) {
        String cacheKey = getCacheKey(wrapper.getClassName(), wrapper.getVersion());
        Schema schema = REMOTE_CLASS_SCHEMA_CACHE.get(cacheKey);
        if (schema != null) {
            return schema;
        }
        Map<String, ProtoFieldDescription> fieldDescriptionMap = getProtoFieldDescriptionMap(cacheKey);
        if (MapUtils.isEmpty(fieldDescriptionMap)) {
            return null;
        }
        java.lang.reflect.Field[] declaredFields = typeClass.getDeclaredFields();
        final ArrayList<io.protostuff.runtime.Field<T>> fields = new ArrayList<>(declaredFields.length);
        ProtoFieldDescription d;
        for (java.lang.reflect.Field field : declaredFields) {
            d = fieldDescriptionMap.get(field.getName());
            if (d != null) {
                Class<?> type = field.getType();
                if (Objects.equals(d.getType(), type.getCanonicalName())) {
                    // 欄位型別一致
                    io.protostuff.runtime.Field<T> pField = RuntimeFieldFactory.getFieldFactory(type, ID_STRATEGY).create(d.getIndex(), d.getFieldName(), field, ID_STRATEGY);
                    fields.add(pField);
                }
            }
        }
        schema = new RuntimeSchema(typeClass, fields, RuntimeEnv.newInstantiator(typeClass));
        REMOTE_CLASS_SCHEMA_CACHE.putIfAbsent(cacheKey, schema);
        return schema;
    }

    private Map<String, ProtoFieldDescription> getProtoFieldDescriptionMap(String key) {
        String cache = getStringFromRedis(key);
        if (StringUtils.isEmpty(cache)) {
            return new ConcurrentHashMap<>();
        }
        List<ProtoFieldDescription> fieldDescriptionList = JSON.parseArray(cache, ProtoFieldDescription.class);
        if (fieldDescriptionList == null) {
            return new ConcurrentHashMap<>();
        }
        return fieldDescriptionList.stream().collect(Collectors.toMap(ProtoFieldDescription::getFieldName, Function.identity(), (a, b) -> b));
    }

總結

ProtoStuff 是一個非常優秀的 Java 序列化框架,具有高效性、空間佔用小、易用性和可擴充套件性等優點。

本方案在設計之初,考慮到資料庫快取序列化框架作為快取元件的一部分,需要更多地為使用的業務方考慮。因此,改造方案花費了大量精力將框架做成自適應的。此舉的目的在於,讓接入方在使用過程中無需擔心新增欄位可能會引發的反序列化順序問題,也無需額外維護 Tag 標籤的順序,更不需要對歷史程式碼進行相容改造。只要簡單的升級一下依賴的二方包,就可以實現元件的升級。

附上官網文件地址:

https://protostuff.github.io/docs/protostuff-runtime/

相關文章