在日常业务中,缓存是提升系统性能的重要手段。
Caffeine 作为 Java 中性能最好的本地缓存库之一,被广泛使用。但在实际使用过程中,我们经常会遇到一个需求:

如何让某个前缀下的所有 key 批量失效?

比如缓存中存了如下数据:

user:1 -> Alice
user:2 -> Bob
order:1 -> OrderA

如果我们想让 user:* 的缓存全部失效,该怎么做?


1. 为什么 Caffeine 不支持前缀过期?

Caffeine 的设计理念是:

  • 过期策略基于 时间(expireAfterWrite / expireAfterAccess / refreshAfterWrite)
  • 淘汰策略基于 大小(maximumSize / maximumWeight)
  • 不会解析 key,也不会额外维护前缀索引

因此,Caffeine 原生不支持前缀批量过期。要实现这一功能,我们只能在应用层做扩展。


2. 常见的几种方案

方案一:遍历删除

当需要让 user:* 全部失效时,直接遍历:

cache.asMap().keySet().removeIf(key -> key.startsWith("user:"));
  • ✅ 简单直接
  • ❌ O(n) 遍历,数据量大时性能差

方案二:版本号 + 逻辑过期

给每个前缀维护一个版本号,真实缓存 key 变成:

user:1:123   // user 前缀的版本号是 123
user:2:123

当需要批量过期时,只需把 user 的版本号 +1,老版本数据自动失效,不会再命中。

  • ✅ O(1),高性能
  • ❌ 老数据还会占用空间,直到被 Caffeine 自然回收

方案三:前缀索引 Map

维护一个额外的索引:

"user" -> { "user:1", "user:2" }
"order" -> { "order:1" }

当需要过期时,直接找到该前缀的 key 集合,一次性失效:

Set<K> keys = prefixIndex.remove("user");
cache.invalidateAll(keys);
  • ✅ O(1) 定位前缀,O(m) 删除 m 个 key
  • ✅ 不会残留老数据
  • ❌ 需要额外内存维护索引

3. 通用封装实现

下面是一个可直接使用的封装类,支持:

  • put/get 自动维护前缀索引
  • 批量过期 O(1)
  • 并发安全(基于 ConcurrentHashMap
import com.github.benmanes.caffeine.cache.*;
import java.util.*;
import java.util.concurrent.*;
import java.util.function.Function;

public class PrefixCaffeineCache<K, V> {

    private final Cache<K, V> cache;
    private final ConcurrentMap<String, Set<K>> prefixIndex = new ConcurrentHashMap<>();
    private final Function<K, String> prefixExtractor;

    public PrefixCaffeineCache(Cache<K, V> cache, Function<K, String> prefixExtractor) {
        this.cache = cache;
        this.prefixExtractor = prefixExtractor;
    }

    public void put(K key, V value) {
        cache.put(key, value);
        String prefix = prefixExtractor.apply(key);
        prefixIndex.computeIfAbsent(prefix, p -> ConcurrentHashMap.newKeySet()).add(key);
    }

    public V get(K key, Function<K, V> mappingFunction) {
        return cache.get(key, k -> {
            V v = mappingFunction.apply(k);
            String prefix = prefixExtractor.apply(k);
            prefixIndex.computeIfAbsent(prefix, p -> ConcurrentHashMap.newKeySet()).add(k);
            return v;
        });
    }

    public V getIfPresent(K key) {
        return cache.getIfPresent(key);
    }

    public void expireByPrefix(String prefix) {
        Set<K> keys = prefixIndex.remove(prefix);
        if (keys != null && !keys.isEmpty()) {
            cache.invalidateAll(keys);
        }
    }

    public void invalidate(K key) {
        cache.invalidate(key);
        String prefix = prefixExtractor.apply(key);
        Set<K> set = prefixIndex.get(prefix);
        if (set != null) {
            set.remove(key);
            if (set.isEmpty()) {
                prefixIndex.remove(prefix);
            }
        }
    }

    public void clear() {
        cache.invalidateAll();
        prefixIndex.clear();
    }
}

4. 使用示例

public class Demo {
    public static void main(String[] args) {
        Cache<String, String> caffeineCache = Caffeine.newBuilder()
                .maximumSize(1000)
                .expireAfterWrite(10, TimeUnit.MINUTES)
                .build();

        PrefixCaffeineCache<String, String> cache =
                new PrefixCaffeineCache<>(caffeineCache, key -> key.split(":", 2)[0]);

        cache.put("user:1", "Alice");
        cache.put("user:2", "Bob");
        cache.put("order:1", "OrderA");

        System.out.println("Before expire: " + cache.getIfPresent("user:1")); // Alice

        cache.expireByPrefix("user");

        System.out.println("After expire: " + cache.getIfPresent("user:1")); // null
        System.out.println("Still has: " + cache.getIfPresent("order:1"));  // OrderA
    }
}

5. 总结

  • 小数据量:遍历删除即可
  • 大数据量,高并发:推荐版本号法(逻辑过期)
  • 既要准确删除,又要高性能:前缀索引 Map 最合适

实际项目中,版本号法 + 前缀索引法混合使用是最佳实践:

  • 版本号保证 O(1) 性能和逻辑隔离
  • 索引 Map 确保旧数据能及时被清理,不占空间

👉 这样,我们就能在 Caffeine 上实现了按前缀批量失效功能。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注