在日常业务中,缓存是提升系统性能的重要手段。
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 上实现了按前缀批量失效功能。