简介
layering-cache是一个支持分布式环境的多级缓存框架,使用方式和spring-cache类似。一级缓存使用Caffeine作为本地缓存,二级缓存使用redis作为集中式缓存。一级缓存和二级缓存的数据一致性是通过推和拉两种模式相结合的方式来实现的。推主要是基于redis的pub/sub机制,拉主要是基于消息队列和记录消费消息的偏移量来实现的。
支持
- 支持缓存命中率的监控统计,统计数据上报支持自定义扩展
- 内置dashboard,支持对缓存的管理和缓存命中率的查看
- 支持缓存过期时间在注解上直接配置
- 支持缓存的自动刷新(当缓存命中并发现二级缓存将要过期时,会开启一个异步线程刷新缓存)
- 缓存Key支持SpEL表达式
- Redis支持Kryo、FastJson、Jackson、Jdk和Protostuff序列化,默认使用Protostuff序列化,并支持自定义的序列化
- 支持同一个缓存名称设置不同的过期时间
- 支持禁用一级缓存,只使用二级缓存
优势
- 提供缓存命中率的监控统计,统计数据上报支持自定义扩展
- 支持本地缓存和集中式两级缓存
- 接入成本和使用成本都非常低
- 无缝集成Spring、Spring boot
- 内置dashboard使得缓存具备可运维性
- 通过缓存空值来解决缓存穿透问题、通过异步加载缓存的方式来解决缓存击穿和雪崩问题
快速开始
集成 Spring Boot
- 引入layering-cache
<dependency>
<groupId>com.github.xiaolyuh</groupId>
<artifactId>layering-cache-starter</artifactId>
<version>${layering.version}</version>
</dependency>
- 添加配置
#layering-cache 配置
layering-cache.stats=true
# 缓存命名空间,如果不配置取 "spring.application.name"
layering-cache.namespace=layering-cache-web
# redis单机
layering-cache.redis.database=0
layering-cache.redis.host=127.0.0.1
layering-cache.redis.port=6379
layering-cache.redis.password=
# redis集群
#layering-cache.redis.password=
#layering-cache.redis.cluster=127.0.0.1:6379,127.0.0.1:6378
# 设置redis值的序列化方式,默认是Protostuff
#com.github.xiaolyuh.redis.serializer.KryoRedisSerializer
#com.github.xiaolyuh.redis.serializer.FastJsonRedisSerializer
#com.github.xiaolyuh.redis.serializer.JacksonRedisSerializer
#com.github.xiaolyuh.redis.serializer.JdkRedisSerializer
#com.github.xiaolyuh.redis.serializer.ProtostuffRedisSerializer
layering-cache.redis.serializer=com.github.xiaolyuh.redis.serializer.JacksonRedisSerializer
- 配置类中添加注解
@EnableLayeringCache
启用layering-cache
@SpringBootApplication
@EnableLayeringCache
public class LayeringCacheStartDemoApplication {
public static void main(String[] args) {
SpringApplication.run(LayeringCacheStartDemoApplication.class, args);
}
}
集成 Spring
- 引入layering-cache
<dependency>
<groupId>com.github.xiaolyuh</groupId>
<artifactId>layering-cache-aspectj</artifactId>
<version>${layering.version}</version>
</dependency>
compile 'com.github.xiaolyuh:layering-cache:${layering.version}'
- RedisClient(可以参考layering-cache-aspectj的Test配置)
@Configuration
@PropertySource({"classpath:application.properties"})
public class RedisConfig {
@Value("${spring.redis.database:0}")
private int database;
@Value("${spring.redis.host:192.168.83.128}")
private String host;
@Value("${spring.redis.password:}")
private String password;
@Value("${spring.redis.port:6378}")
private int port;
@Bean
public RedisClient layeringCacheRedisClient() {
RedisProperties redisProperties = new RedisProperties();
redisProperties.setDatabase(database);
redisProperties.setHost(host);
redisProperties.setPassword(StringUtils.isBlank(password) ? null : password);
redisProperties.setPort(port);
KryoRedisSerializer<Object> kryoRedisSerializer = new KryoRedisSerializer<>(Object.class);
StringRedisSerializer keyRedisSerializer = new StringRedisSerializer();
SingleRedisClient redisClient = new SingleRedisClient(redisProperties);
redisClient.setKeySerializer(keyRedisSerializer);
redisClient.setValueSerializer(kryoRedisSerializer);
return redisClient;
}
}
- 声明CacheManager和LayeringAspect
/**
* 多级缓存配置
*
* @author yuhao.wang3
*/
@Configuration
@Import({RedisConfig.class})
@EnableAspectJAutoProxy
public class CacheConfig {
@Bean
public CacheManager layeringCacheManager(RedisClient layeringCacheRedisClient, CacheStatsReportService cacheStatsReportService, LayeringCacheProperties layeringCacheProperties) {
LayeringCacheManager layeringCacheManager = new LayeringCacheManager(layeringCacheRedisClient);
// 默认开启统计功能
layeringCacheManager.setStats(layeringCacheProperties.isStats());
// 上报缓存统计信息
layeringCacheManager.setCacheStatsReportService(cacheStatsReportService);
// 设置缓存命名空间
GlobalConfig.setNamespace(StringUtils.isBlank(layeringCacheProperties.getNamespace()) ? applicationName : layeringCacheProperties.getNamespace());
return layeringCacheManager;
}
@Bean
@ConditionalOnMissingBean(CacheStatsReportService.class)
public CacheStatsReportService cacheStatsReportService() {
return new DefaultCacheStatsReportServiceImpl();
}
}
使用
注解形式
直接在需要缓存的方法上加上Cacheable、CacheEvict、CachePut注解。
@Cacheable(value = "user:info", depict = "用户信息缓存", enableFirstCache = true,
firstCache = @FirstCache(expireTime = 4, timeUnit = TimeUnit.SECONDS),
secondaryCache = @SecondaryCache(expireTime = 10, preloadTime = 3, forceRefresh = true, timeUnit = TimeUnit.SECONDS))
public User getUser(User user) {
logger.debug("调用方法获取用户名称");
return user;
}
@CachePut(value = "user:info", key = "#userId", depict = "用户信息缓存", enableFirstCache = true,
firstCache = @FirstCache(expireTime = 4, timeUnit = TimeUnit.SECONDS),
secondaryCache = @SecondaryCache(expireTime = 10, preloadTime = 3, forceRefresh = true, timeUnit = TimeUnit.SECONDS))
public User putUser(long userId) {
User user = new User();
user.setUserId(userId);
user.setAge(31);
user.setLastName(new String[]{"w", "y", "h"});
return user;
}
@CacheEvict(value = "user:info", key = "#userId")
public void evictUser(long userId) {
}
@CacheEvict(value = "user:info", allEntries = true)
public void evictAllUser() {
}
直接使用API
@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = {CacheConfig.class})
public class CacheCoreTest {
private Logger logger = LoggerFactory.getLogger(CacheCoreTest.class);
@Autowired
private CacheManager cacheManager;
@Test
public void testCacheExpiration() {
FirstCacheSetting firstCacheSetting = new FirstCacheSetting(10, 1000, 4, TimeUnit.SECONDS, ExpireMode.WRITE);
SecondaryCacheSetting secondaryCacheSetting = new SecondaryCacheSetting(10, 4, TimeUnit.SECONDS, true);
LayeringCacheSetting layeringCacheSetting = new LayeringCacheSetting(firstCacheSetting, secondaryCacheSetting);
String cacheName = "cache:name";
String cacheKey = "cache:key1";
LayeringCache cache = (LayeringCache) cacheManager.getCache(cacheName, layeringCacheSetting);
cache.get(cacheKey, () -> initCache(String.class));
cache.put(cacheKey, "test");
cache.evict(cacheKey);
cache.clear();
}
private <T> T initCache(Class<T> t) {
logger.debug("加载缓存");
return (T) "test";
}
}
注解说明
@Cacheable
表示用的方法的结果是可以被缓存的,当该方法被调用时先检查缓存是否命中,如果没有命中再调用被缓存的方法,并将其返回值放到缓存中。
名称
| 默认值
| 说明
|
value
| 空字符串数组
| 缓存名称,cacheNames的别名
|
cacheNames
| 空字符串数组
| 缓存名称
|
key
| 空字符串
| 缓存key,支持SpEL表达式
|
depict
| 空字符串
| 缓存描述(在缓存统计页面会用到)
|
enableFirstCache
| true
| 是否启用一级缓存
|
firstCache
| | 一级缓存配置
|
secondaryCache
| | 二级缓存配置
|
@FirstCache
一级缓存配置项
名称
| 默认值
| 说明
|
initialCapacity
| 10
| 缓存初始Size
|
maximumSize
| 5000
| 缓存最大Size
|
expireTime
| 9
| 缓存有效时间
|
timeUnit
| TimeUnit.MINUTES
| 时间单位,默认分钟
|
expireMode
| ExpireMode.WRITE
| 缓存失效模式,ExpireMode.WRITE:最后一次写入后到期失效,ExpireMode.ACCESS:最后一次访问后到期失效
|
@SecondaryCache
二级缓存配置项
名称
| 默认值
| 说明
|
expireTime
| 5
| 缓存有效时间
|
preloadTime
| 1
| 缓存主动在失效前强制刷新缓存的时间,建议是 expireTime * 0.2
|
timeUnit
| TimeUnit.HOURS
| 时间单位,默认小时
|
forceRefresh
| false
| 是否强制刷新(直接执行被缓存方法)
|
magnification
| 1
| 非空值和null值之间的时间倍率,默认是1。如expireTime=60秒,magnification=10,那么当缓存空值时,空值的缓存过期时间是60/10=6秒。
|
@CachePut
将数据放到缓存中
名称
| 默认值
| 说明
|
value
| 空字符串数组
| 缓存名称,cacheNames的别名
|
cacheNames
| 空字符串数组
| 缓存名称
|
key
| 空字符串
| 缓存key,支持SpEL表达式
|
depict
| 空字符串
| 缓存描述(在缓存统计页面会用到)
|
enableFirstCache
| true
| 是否启用一级缓存
|
firstCache
| | 一级缓存配置
|
secondaryCache
| | 二级缓存配置
|
@CacheEvict
删除缓存
名称
| 默认值
| 说明
|
value
| 空字符串数组
| 缓存名称,cacheNames的别名
|
cacheNames
| 空字符串数组
| 缓存名称
|
key
| 空字符串
| 缓存key,支持SpEL表达式
|
allEntries
| false
| 是否删除缓存中所有数据,默认情况下是只删除关联key的缓存数据,当该参数设置成 true 时 key 参数将无效
|
监控统计功能
Layering Cache 的监控统计功能默认是开启的
Spring
直接在声明CacheManager Bean的时候将stats设置成true。
/**
* 多级缓存配置
*
* @author yuhao.wang3
*/
@Configuration
@EnableAspectJAutoProxy
public class CacheConfig {
@Bean
public CacheManager cacheManager(RedisTemplate<String, Object> redisTemplate) {
LayeringCacheManager layeringCacheManager = new LayeringCacheManager(redisTemplate);
// 默认开启统计功能
layeringCacheManager.setStats(true);
return layeringCacheManager;
}
...
}
Spring Boot
在application.properties文件中添加以下配置即可
layering-cache.stats=true
缓存监控数据上报扩展
缓存监控数据上报扩展
去实现CacheStatsReportService
接口,然后将实现类注入到CacheManager
中,如下是上报到CAT的示例:
/**
* 多级缓存配置
*
* @author yuhao.wang3
*/
@Configuration
@Import({RedisConfig.class})
@EnableAspectJAutoProxy
public class CacheConfig {
@Bean
public CacheManager layeringCacheManager(RedisClient layeringCacheRedisClient, CacheStatsReportService cacheStatsReportService, LayeringCacheProperties layeringCacheProperties) {
LayeringCacheManager layeringCacheManager = new LayeringCacheManager(layeringCacheRedisClient);
// 默认开启统计功能
layeringCacheManager.setStats(layeringCacheProperties.isStats());
// 上报缓存统计信息
layeringCacheManager.setCacheStatsReportService(cacheStatsReportService);
// 设置缓存命名空间
GlobalConfig.setNamespace(StringUtils.isBlank(layeringCacheProperties.getNamespace()) ? applicationName : layeringCacheProperties.getNamespace());
return layeringCacheManager;
}
// 上报CAT示例
@Bean
public CacheStatsReportService cacheStatsReportService() {
return cacheStatsInfos -> {
for (CacheStatsInfo cacheStatsInfo : cacheStatsInfos) {
Transaction transaction = Cat.newTransaction("layering-cache", cacheStatsInfo.getCacheName());
try {
// 记录一个事件
Cat.logEvent("layering-cache", cacheStatsInfo.getCacheName(), Message.SUCCESS, JSON.toJSONString(cacheStatsInfo));
// 记录一个业务指标
String name = StringUtils.isBlank(cacheStatsInfo.getDepict()) ? cacheStatsInfo.getCacheName() : cacheStatsInfo.getDepict();
Cat.logMetricForSum(name + "-hitRate", cacheStatsInfo.getHitRate());
Cat.logMetricForCount(name + "-firstSize", (int) cacheStatsInfo.getFirstCacheSize());
Cat.logMetricForCount(name + "-requestCount", (int) cacheStatsInfo.getRequestCount());
transaction.setDurationInMillis(cacheStatsInfo.getTotalLoadTime());
transaction.setStatus(Message.SUCCESS);
} catch (Exception e) {
transaction.setStatus(e);
} finally {
transaction.complete();
}
}
};
}
}
监控页面
直接单独部署layering-cache-web
服务即可。
日志格式:
Layering Cache 统计信息:{"cacheName":"people1","depict":"查询用户信息1","firstCacheMissCount":3,"firstCacheRequestCount":4575,"hitRate":99.9344262295082,"internalKey":"4000-15000-8000","layeringCacheSetting":{"depict":"查询用户信息1","firstCacheSetting":{"allowNullValues":true,"expireMode":"WRITE","expireTime":4,"initialCapacity":10,"maximumSize":5000,"timeUnit":"SECONDS"},"internalKey":"4000-15000-8000","secondaryCacheSetting":{"allowNullValues":true,"expiration":15,"forceRefresh":true,"preloadTime":8,"timeUnit":"SECONDS","usePrefix":true},"useFirstCache":true},"missCount":3,"requestCount":4575,"secondCacheMissCount":3,"secondCacheRequestCount":100,"totalLoadTime":142}
- 如果项目集成了ELK之类的日志框架,那我们可以直接基于以上日志做监控和告警。
- 统计数据每隔一分钟采集一次
- 添加redis配置(需要查看缓存统计的redis服务器)
- 查询缓存命中率
- 缓存详细
实现原理
总体架构
layering-cache
总体架构分为两层,第一层是本地缓存L1,第二层是集中式缓存L2,如下图:
缓存的选择
- 一级缓存:Caffeine是一个一个高性能的 Java 缓存库;使用 Window TinyLfu 回收策略,提供了一个近乎最佳的命中率(Caffeine 缓存详解)。优点数据就在应用内存所以速度快。缺点受应用内存的限制,所以容量有限;没有持久化,重启服务后缓存数据会丢失;在分布式环境下缓存数据数据无法同步;
- 二级缓存:redis是一高性能、高可用的key-value数据库,支持多种数据类型,支持集群,和应用服务器分开部署易于横向扩展。优点支持多种数据类型,扩容方便;有持久化,重启应用服务器缓存数据不会丢失;他是一个集中式缓存,不存在在应用服务器之间同步数据的问题。缺点每次都需要访问redis存在IO浪费的情况。
我们可以发现Caffeine和Redis的优缺点正好相反,所以他们可以有效的互补。
数据读取
![image.png](https://img-blog.csdnimg.cn/img_convert/c9e1055c8218035ee9d4611ebd506693.png
- 数据读取会先读L1,当L1未命中会获取本地锁;
- 获取到本地锁过后去读L2,如果L2未命中,则获取redis分布式锁;
- 获取到分布式锁过后去读DB,然后将数据放到L1和L2中。
- 获取到本地锁过后去读L2,如果L2命中,则将数据放入到L1中,并判断是否需要刷新缓存;
数据删除/更新
缓存的数据更新需要保证多机器下一级缓存和二级缓存的数据一致性。保证多机数据一致性的方式一般有两种,一种是推模式,这种方式实时性好,但是推的消息有可能会丢;另一种是拉模式,但是这种方式,实时性不好。
layering-cache结合了推和拉两种模式来保证多机数据的一致性。推主要是基于redis的pub/sub机制,拉主要是基于消息偏移量的方式,架构如下:
可用性设计
借助redis的list结构维护一个删除缓存的消息队列,所有应用服务器内存中保存一个偏移量(offset
)。offset
表示该服务处理缓存消息的位置,每次处理消息后就更新offset的位置,这样就能保证消息不会丢失。最后在每天凌晨3点会去清空这个消息队列。
pub/sub断线重连设计
layering-cache
会记录两个参数:最后一次处理推消息的时间A和最后一次处理拉消息的时间B。如如果B - A >= 10s
则认为断线,然后发起重连尝试。
推模式数据同步
在数据删除或更新时,首先更新DB,保证DB数据的准确性;再更新或删除redis缓存,然后向redis推送一条消息,并将这条消息保存到redis的消息队列中;最后再发送一条pub/sub
消息。应用服务器收到pub/sub
消息后,将会根据本地offset
去redis消息队列中拉取需要处理的消息,然后根据拉取到的消息删除本地缓存。这里允许消息的重复消费,因为本地缓存即使删除,也会根据二级缓存重建。
基于redis pub/sub 实现一级缓存的更新同步。主要原因有两点:
- 使用缓存本来就允许脏读,所以有一定的延迟是允许的 。
- redis本身是一个高可用的数据库,并且删除动作不是一个非常频繁的动作所以使用redis原生的发布订阅在性能上是没有问题的。
拉模式数据同步
这里分几种情况:
- 服务刚启动的时候,需要同步最新偏移量(offset)到本地。
- 每隔30秒会检查一下本地偏移量和远程偏移量是否一致,以此来解决redis
pub/sub
消息丢失或者断线问题。 - 每天凌晨3点会执行一个定时任务来清空消息队列。
Cache和CacheManager接口
该框架最核心的接口有两个,一个是Cache接口:主要负责具体的缓存操作,如对缓存的增删改查;一个是CacheManager接口:主要负责对Cache的管理,最常用的方法是通过缓存名称获取对应的Cache。
Cache接口:
public interface Cache {
String getName();
Object getNativeCache();
Object get(Object key);
<T> T get(Object key, Class<T> type);
<T> T get(Object key, Callable<T> valueLoader);
void put(Object key, Object value);
Object putIfAbsent(Object key, Object value);
void evict(Object key);
void clear();
CacheStats getCacheStats();
}
CacheManager接口:
public interface CacheManager {
Collection<Cache> getCache(String name);
Cache getCache(String name, LayeringCacheSetting layeringCacheSetting);
Collection<String> getCacheNames();
List<CacheStatsInfo> listCacheStats(String cacheName);
void resetCacheStat();
}
在CacheManager里面Cache容器默认使用ConcurrentMap<String, ConcurrentMap<String, Cache>> 数据结构,以此来满足同一个缓存名称可以支持不同的缓存过期时间配置。外层key就是缓存名称,内层key是"一级缓存有效时间-二级缓存有效时间-二级缓存自动刷新时间"缓存时间全部转换成毫秒值,如"1111-2222-3333"。
缓存的监控和统计
简单思路就是缓存的命中和未命中使用LongAdder先暂存到内存,在通过定时任务同步到redis,并重置LongAdder,集中计算缓存的命中率等。监控统计API直接获取redis中的统计数据做展示分析。
因为可能是集群环境,为了保证数据准确性在同步数据到redis的时候需要加一个分布式锁。
重要提示
- layering-cache支持同一个缓存名称设置不同的过期时间,但是一定要保证key唯一,否则会出现缓存过期时间错乱的情况
- 删除缓存的时候会将同一个缓存名称的不同的过期时间的缓存都删掉
- 在集成layering-cache之前还需要添加以下的依赖,主要是为了减少jar包冲突。
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-core</artifactId>
<version>4.3.18.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-aop</artifactId>
<version>4.3.18.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>4.3.18.RELEASE</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.31</version>
</dependency>
<dependency>
<groupId>com.esotericsoftware</groupId>
<artifactId>kryo-shaded</artifactId>
<version>3.0.3</version>
</dependency>
<dependency>
<groupId>org.aspectj</groupId>
<artifactId>aspectjweaver</artifactId>
<version>1.8.10</version>
</dependency>