持久化数据结构与配置版本管理

109 阅读4分钟

前言

上一篇文章中,我写了一个简单的配置管理类,实现了COW类似的功能,每次获取的配置基于最新版本,底层使用不可变Map保存当前配置,每次写操作都需要复制Map,其时间复杂度为o(n),如果将底层实现改成可持久化数据结构,比如PMap(底层使用HMAT)不仅保留了不可变、线程安全等能力,还能获得等效常量时间复杂度。

此外,我给的例子使用了Map<String, String>,实际上,配置通常需要解析成不同的类型,本文也给出一个实现,使用到了Caffeine 缓存,你可以理解成使用了固定大小的ConcurrentLinkedHashMap,通过大小限制实现驱逐,避免内存泄露。这里主要考虑了系统中多个版本共存,导致配置存在多版本。

实现细节

新实现类如下,后文有详细分析


class ConfigManager {

    private volatile PMap<String, String> config = HashPMap.empty(IntTreePMap.empty());
    private final ReentrantLock updateLock = new ReentrantLock();

    /**
     * 全量更新配置(原子操作)
     * @param newConfig 新的配置映射
     */
    public void updateAll(Map<String, String> newConfig) {
        updateLock.lock();
        try {
            // 创建新的不可变配置快照
            config = config.plusAll(newConfig);
        } finally {
            updateLock.unlock();
        }
    }

    /**
     * 增量更新单个配置项
     */
    public void update(String key, String value) {
        updateLock.lock();
        try {
            config = config.plus(key, value);
        } finally {
            updateLock.unlock();
        }
    }

    /**
     * 获取当前配置值(无锁读取)
     * @param key 配置键
     * @return 配置值(不存在时返回 null)
     */
    public String get(String key) {
        return config.get(key);
    }

    /**
     * 获取配置快照(线程安全的不可变视图)
     * @return 当前配置的不可变副本
     */
    public PMap<String, String> getSnapshot() {
        return config; // 直接返回不可变对象引用
    }

    record ConfigKey(String key, String value) {}

    final Cache<ConfigKey, Object> configCache = Caffeine.newBuilder().maximumSize(1000).build();

    @SuppressWarnings("unchecked")
    public <T> T getConfig(String key, Function<String, T> parser) {
        String value = config.get(key);
        if (value == null) {
            return null;
        }
        return (T) configCache.get(new ConfigKey(key, value), k -> parser.apply(value));
    }
}

1. 线程安全和并发控制

FeatureFlagManager 使用 ReentrantLock 来确保配置映射的线程安全更新。此锁对于在多个线程尝试同时更新配置时保持数据一致性至关重要。

private final ReentrantLock updateLock = new ReentrantLock();

全量配置更新

updateAll 方法允许对整个配置映射进行原子更新。通过锁定更新过程,确保在更新操作期间没有其他线程干扰。

public void updateAll(Map<String, String> newConfig) {
    updateLock.lock();
    try {
        config = config.plusAll(newConfig);
    } finally {
        updateLock.unlock();
    }
}

增量配置更新

对于更新单个配置项,使用 update 方法。它遵循相同的锁定机制以确保原子性。

public void update(String key, String value) {
    updateLock.lock();
    try {
        config = config.plus(key, value);
    } finally {
        updateLock.unlock();
    }
}

2. 使用持久化数据结构实现不可变性

配置存储在一个持久化映射 (PMap) 中,该映射提供配置的不可变快照。这种方法确保任何读取操作都是线程安全的,无需锁定。PMap实现了Map接口,同时提供了函数式”修改“方法(对象不可变的,返回结果为新对象)。

private volatile PMap<String, String> config = HashPMap.empty(IntTreePMap.empty());

3. 高效的配置检索

get 方法提供了无锁的方式来检索配置值,利用了 PMap 的不可变性。

public String get(String key) {
    return config.get(key);
}

实现配置解析功能

为了提高性能,ConfigManager 使用 Caffeine 缓存来存储解析后的配置值。此缓存减少了重复解析配置值的开销。

final Cache<ConfigKey, Object> configCache = Caffeine.newBuilder().maximumSize(1000).build();

Cache 中的key选择 record ConfigKey(String key, String value) {}, 尽量不要使用 String 类型,因为组合String(比如使用格式为"keykey-value")每次查询都需要创建临时String并计算hashcode,性能开销大一些,同时可读性比较差;使用 record 可以避免以上问题。

getConfig 方法检索并解析配置值,将结果缓存以供将来访问。这里的 get 等效于 ComputeIfAbsent。

public <T> T getConfig(String key, Function<String, T> parser) {
    String value = config.get(key);
    if (value == null) {
        return null;
    }
    return (T) configCache.get(new ConfigKey(key, value), k -> parser.apply(value));
}

结论与总结

  • 通过结合持久化数据结构和COW思想,可以实现动态配置管理,实现配置高效且安全地更新和访问。
  • 通过缓存可以避免重复计算,提高系统性能。
  • 使用缓存需要注意内存泄露的风险和缓存驱逐的策略。你也可以使用不同的驱逐策略。
  • 配置版本管理常常涉及 feature toggle、AB测试、权限、灰度等内容,需要考虑到配置具有不同的时效性和可变性。
  • 配置版本管理利于排查问题,特别是涉及微服务场景时,可以通过版本号关联trxId,方便排查问题。本文中可以改视图为 record ConfigView(long configId, PMap<String, String> config) {}实现。
  • 很多实现没有考虑请求中配置的不变性,导致业务可能出现异常并且难以排查。使用本文实现结合TTL可以实现请求级配置管理,避免业务异常。