概述:
要将Caffeine本地缓存和Redisson分布式缓存结合起来使用,可以创建一个工具类,它首先尝试从本地Caffeine缓存中获取数据,如果本地缓存中没有找到,则从Redisson分布式缓存中获取,并在获取后将数据回填到本地缓存中。
注意点:
- 并发处理:Caffeine 已经是线程安全的,所以本地缓存的并发访问不是问题。对于 Redisson,客户端本身也是线程安全的,但是在处理写回策略和缓存穿透时,可能需要额外的并发控制。
- 写回策略(Write-Back / Write-Behind):在这种策略下,数据首先写入本地缓存,然后异步地写入后端存储(例如 Redis)。这可以通过一个队列和后台线程来实现,该线程定期将更改写入后端存储。
- 缓存穿透保护:缓存穿透是指查询不存在的数据,导致请求直接打到数据库上。为了防止缓存穿透,可以使用空对象模式或布隆过滤器。空对象模式是指即使值不存在也在缓存中存储一个特殊的空对象,而布隆过滤器可以在请求到达缓存之前过滤掉不存在的键。
- 同步写入Redis和本地缓存:当本地缓存被写入时,同时将数据同步写入Redis。这样可以确保两者的数据一致性。但这种方法会增加每次写入操作的延迟。
- 使用锁或同步机制:在更新本地缓存的同时,使用锁或其他同步机制来确保数据也被写入Redis。如果本地缓存失效,可以通过锁来保证在读取Redis之前数据已经被写入。
- 设置合理的过期时间:在Redis中为缓存数据设置一个比本地缓存更长的过期时间,这样即使本地缓存失效,数据仍然可以从Redis中获取。
- 延迟本地缓存的过期时间:可以在本地缓存的基础上添加一个短暂的延迟时间,以确保Redis中的数据在本地缓存失效前已经更新。
- 使用缓存刷新策略:定期或在本地缓存即将失效时,异步刷新本地缓存的数据。这样可以确保本地缓存中的数据在大多数时间都是最新的。
工具类:
以下是这样一个工具类的简单示例:
1 | java复制代码import com.github.benmanes.caffeine.cache.Cache; |
重要功能:
getAll
:批量从缓存获取数据。首先从本地缓存获取,如果本地缓存中缺失,则从Redis中获取,并回填到本地缓存。putAll
:批量写入数据到本地和Redis缓存。invalidateAll
:批量从本地和Redis缓存中移除数据。stats
:获取Caffeine缓存的统计信息。
Note:
添加了一个ConcurrentHashMap
来存储锁对象,并在获取数据时使用了一个双重检查锁定模式。当本地缓存中没有数据时,首先获取一个锁,然后再次检查本地缓存以确保数据在获取锁的过程中没有被其他线程填充。如果本地缓存仍然没有数据,会从Redis获取数据,如果Redis也没有数据,则从数据源加载数据并更新Redis和本地缓存。这样的策略可以减少缓存击穿的风险。
请注意,这种锁的使用会增加系统的复杂性,并可能导致性能开销,特别是在高并发场景下。因此,在实现这种机制时,需要仔细衡量其潜在的性能影响。
加了一个单线程的 ExecutorService
用于处理写回策略。当本地缓存中的条目因为驱逐策略被移除时,会将这个条目异步地写入 Redis。还添加了一个 shutdown
方法来关闭线程池。
对于缓存穿透保护,可以在 get
方法中加入逻辑来返回空对象或者使用布隆过滤器来预先检查键是否可能存在,一个特殊的空对象 NULL_PLACEHOLDER
存储到本地缓存和 Redis 中,这样下次查询相同的键时就能直接从缓存中获取到空对象,从而防止缓存穿透。
测试类:
使用示例:
1 | java复制代码 |
测试说明:
在这个示例中,首先配置了 Redisson 客户端并连接到本地运行的 Redis 服务器。然后,创建了一个 HybridCache
实例,设置了本地缓存的大小和过期时间,以及 Redis 缓存的过期时间。
定义了一个 dataLoader
函数,它模拟了从数据库或其他数据源加载数据的过程。接着,使用 hybridCache.get
方法尝试从缓存中获取数据。如果本地缓存和 Redis 缓存中都没有找到数据,将调用 dataLoader
函数加载数据并存入缓存。
然后,更新了缓存中的数据,并再次从缓存中获取更新后的数据。之后,使用 refresh
方法来手动刷新缓存中的数据。最后,使用 invalidate
方法使缓存中的数据失效,并关闭 Redisson 客户端和混合缓存。
Note:
请注意,在实际应用中,可能需要根据实际业务逻辑和数据源来实现数据加载函数。此外,还需要确保 Redis 服务器正在运行且可访问。
优化点1: 关于老铁提出问题再次优化
在使用 Caffeine 缓存时,确实可能需要设置总大小上限或键的个数上限,并且有时候需要为每个键设置不同的过期时间。
以下是如何在配置 Caffeine 缓存时实现这两个优化:
1. 设置 Caffeine 缓存的总大小上限或键的个数上限
Caffeine 提供了两种方式来限制缓存的大小:基于权重的限制和基于最大条目数的限制。权重可以通过实现 Weigher
接口来定义,这里是一个基于键和值的大小设置权重的示例:
1 | java复制代码Caffeine.newBuilder() |
如果只需要限制缓存的最大条目数,可以使用 maximumSize
方法:
1 | java复制代码Caffeine.newBuilder() |
2. 为每个键设置不同的过期时间
Caffeine 允许你通过实现 Expiry
接口来为每个键定义不同的过期策略。
下面是一个示例,展示如何为不同的键设置不同的过期时间:
1 | java复制代码Caffeine.newBuilder() |
在这个 Expiry
实现中,expireAfterCreate
方法定义了每个键在创建时的过期时间。expireAfterUpdate
和 expireAfterRead
方法允许你在键被更新或读取后调整它们的过期时间
优化点2 分布式环境本地缓存和Redis一致性问题:
在分布式环境中,确保本地缓存(如 Caffeine)与分布式缓存(如 Redis)之间的一致性是一个常见的挑战。
为了减少中间件的引入并使用 Redis 自身的消息推送功能,我们可以依赖于 Redis 的发布/订阅机制来通知各个应用实例缓存的变动。
解决方案:
- 本地缓存一致性: 当一个应用实例更新了 Redis 中的一个键值对时,它可以通过 Redis 的发布/订阅系统发布一个消息。其他应用实例订阅了这个消息,可以据此来清除或更新本地 Caffeine 缓存中相应的键。
- 键值变更处理: 如果一个键的值在 Redis 中发生变更,我们不直接在本地缓存中更新这个值,而是删除本地缓存中的这个键。下次访问这个键时,如果本地缓存中没有找到,就会从 Redis 中加载并重新放入 Caffeine 缓存。
实现步骤:
- 发布消息: 当一个键在 Redis 中被更新或删除时,发布一个消息到特定的频道。
1 | java复制代码public void publishKeyInvalidation(String key) { |
- 订阅消息: 在应用启动时,订阅 Redis 频道,监听键失效的消息。
1 | java复制代码public void subscribeToCacheInvalidationChannel() { |
- 处理键值变更: 当需要更新键值时,先在 Redis 中进行更新,然后发布消息。
1 | java复制代码public void updateValueInCache(String key, ValueType value) { |
- 处理本地缓存: 当本地缓存尝试访问一个键时,如果缓存中没有,则从 Redis 加载。
1 | java复制代码public ValueType getValueFromCache(String key) { |
注意事项:
- 这种方案假设 Redis 的读写操作比本地缓存的操作要慢,因此在 Redis 缓存中更新数据后,我们只是删除本地缓存中的数据,而不是更新它。
- 在高并发环境中,可能会出现短暂的不一致,因为消息传递和处理需要时间。
- 为了避免不必要的 Redis 访问,可以在本地缓存中设置一个短暂的过期时间,这样即使不立即从 Redis 加载数据,本地缓存中的数据也会很快过期并被刷新。
通过以上步骤,可以构建一个既利用了本地缓存速度优势,又保持了与分布式缓存一致性的混合缓存策略。
优化点3:如果每个KEY的VALUE非常大,该如何处理
当每个键的值非常大时,仅仅通过设置 maximumSize
来限制键的数量可能是不够的,因为这样做不能有效控制占用的总内存大小。
在这种情况下,需要考虑整体的内存使用情况,并使用基于权重的缓存大小限制,
这可以通过 Caffeine 的 maximumWeight
和 weigher
配置来实现。这里是如何使用 maximumWeight
和 weigher
来限制缓存的总内存使用的:
1 | java复制代码Caffeine.newBuilder() |
使用 maximumWeight
和 weigher
的组合,你可以更好地控制缓存的内存占用,而不仅仅是缓存项的数量。这样可以保证即使
本文转载自: 掘金