hello,伙伴们好久不见,我是shigen。发现有两周没有更新我的文章了。也是因为最近比较忙,基本是993了。
缓存大家再熟悉不过了,几乎是现在任何系统的标配,并引申出来很多的问题:缓存穿透、缓存击穿、缓存雪崩…哎,作为天天敲业务代码的人,哪有时间天天考虑这么多的破事。直接封装一个东西,我们直接拿来就用岂不是美哉。看了项目组的代码,我也忍不住 diy 了,对于增删就算了,就是 get set 的 API 调用,修改?直接删了重新添加吧,哪有先查缓存再去修改保存的。难点就在于缓存的查询,要不缓存的穿透、击穿、雪崩会诞生对吧。
我们先看下缓存的逻辑:
是的,其实就是这么简单,剩下的就是考虑一下缓存穿透问题,最常见的处理方式就是加锁。这里我采用的是信号量 Semaphore。
好的,现在展示我的代码,代码结构如下:
.
├── CacheEnum.java -- 缓存枚举
├── CacheLoader.java -- 缓存加载接口
├── CacheService.java -- 缓存服务
└── CacheServiceImpl.java -- 缓存服务实现类
1 directory, 4 files
ok,现在我们一一讲解下:
CacheEnum
主要的代码:
public enum CacheEnum {
/** 用户token缓存 */
USER_TOKEN("USER_TOKEN", 60, "用户token"),
/** 用户信息缓存 */
USER_INFO("USER_INFO", 60, "用户信息"),;
/** 缓存前缀 */
private final String cacheName;
/** 缓存过期时间 */
private final Integer expire;
/** 缓存描述 */
private final String desc;
其他的就是 get/set 方法,这里不做展示。主要解决的痛点就是缓存过期时间的统一管理、缓存名称的统一管理。
CacheService
这里边就是定义了缓存操作的接口:
public interface CacheService {
/**
* 获取缓存
* @param cacheName 缓存名称
* @param key 缓存key
* @param type 缓存类型
* @return 缓存值
* @param <T> 缓存类型
*/
<T> T get(String cacheName, String key, Class<T> type);
/**
* 获取缓存
* @param cacheName 缓存名称
* @param key 缓存key
* @param type 缓存类型
* @param loader 缓存加载器
* @return 缓存值
* @param <T> 缓存类型
*/
<T> T get(String cacheName, String key, Class<T> type, CacheLoader<T> loader);
/**
* 删除缓存
* @param cacheName 缓存名称
* @param key 缓存key
*/
void delete(String cacheName, String key);
/**
* 设置缓存
* @param cacheName 缓存名称
* @param key 缓存key
* @param value 缓存值
*/
void set(String cacheName, String key, Object value);
}
其实就是一些增删查的方法。只不过这里我们更加关注的是:缓存的名称,缓存的 key,缓存对象,缓存对象的类型。
在 22 行这里用到了CacheLoader 接口,其实就是处理缓存对象在缓存中拿不到的问题,它的定义也很简单:
@FunctionalInterface
public interface CacheLoader<V> {
/**
* 加载缓存
* @param key 缓存key
* @return 缓存值
*/
V load(String key);
}
就一个方法,直接使用上 lambda 表达式,下边的测试类会讲到。
CacheServiceImpl
@Slf4j
@Service
public class CacheServiceImpl implements CacheService {
/** Semaphore */
private static final Semaphore CACHE_LOCK = new Semaphore(100);
/** 缓存操作 */
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 获取缓存key
* @param cacheName 缓存名称
* @param key 缓存key
* @return 缓存key
*/
private String getCacheKey(String cacheName, String key) {
Assert.isTrue(StrUtil.isNotBlank(cacheName), "cacheName不能为空");
Assert.isTrue(StrUtil.isNotBlank(key), "key不能为空");
Assert.isTrue(CacheEnum.getByCacheName(cacheName) != null, "需要使用CacheEnum枚举创建缓存");
return cacheName + ":" + key;
}
@Override
public <T> T get(String cacheName, String key, Class<T> type) {
Object value = redisTemplate.opsForValue().get(getCacheKey(cacheName, key));
if (value != null) {
return type.cast(value);
}
return null;
}
@Override
public <T> T get(String cacheName, String key, Class<T> type, CacheLoader<T> cacheLoader) {
try {
// 获取锁, 防止缓存击穿
CACHE_LOCK.acquire();
String cacheKey = getCacheKey(cacheName, key);
Object value = redisTemplate.opsForValue().get(cacheKey);
if (value != null) {
return type.cast(value);
}
value = cacheLoader.load(cacheKey);
if (value != null) {
this.set(cacheName, key, value);
return type.cast(value);
}
} catch (InterruptedException e) {
log.warn("获取锁失败");
} finally {
CACHE_LOCK.release();
}
return null;
}
@Override
public void delete(String cacheName, String key) {
redisTemplate.opsForValue().getOperations().delete(getCacheKey(cacheName, key));
}
@Override
public void set(String cacheName, String key, Object value) {
String cacheKey = getCacheKey(cacheName, key);
CacheEnum cacheEnum = CacheEnum.getByCacheName(cacheName);
Assert.isTrue(cacheEnum != null, "需要使用CacheEnum枚举创建缓存");
redisTemplate.opsForValue().set(cacheKey, value, cacheEnum.getExpire(), TimeUnit.SECONDS);
}
}
这里就是接口的具体实现。需要注意:
- 在获得完整的缓存 key 的时候,我们其实对于缓存的 cacheName 做了验证,参见上代码块 21 行,不允许自己定义缓存的 cacheName,统一在枚举类中定义。
- 因为 tair 的资源有点不好申请,这里使用的 redis 作为缓存的工具,结合 spring-boot-starter-data-redis 作为操作的 API。
- 应对缓存穿透问题,这里使用的是Semaphore 信号量。
别的就没什么好说的,现在我们来测试一下我们的封装是否管用。
测试代码
设置缓存
测试用定义的枚举类创建缓存:
@Test
void set() {
cacheService.set(CacheEnum.USER_INFO.getCacheName(), "10001", getFakeUser("10001"));
}
是没问题的,不用枚举类创建:
@Test
void testSetSelfDefinedCacheName() {
cacheService.set("user", "10001", getFakeUser("10001"));
}
直接异常出来了:
java.lang.IllegalArgumentException: 需要使用CacheEnum枚举创建缓存
读取缓存
读取缓存,我的 API 中分为两种情况:直接读取,没有就算了;读取缓存,没有的话再从 DB 中拿。对应的单测如下:
@Test
void testGet() {
UserEntity user = cacheService.get(CacheEnum.USER_INFO.getCacheName(), "10001",
UserEntity.class);
log.info("user: {}", user);
}
@Test
void testGetWithCacheLoader() {
UserEntity user = cacheService.get(CacheEnum.USER_INFO.getCacheName(), "10001",
UserEntity.class, new CacheLoader<UserEntity>() {
@Override
public UserEntity load(String key) {
return getFakeUser("10001");
}
});
log.info("user: {}", user);
}
@Test
void testGetWithSimpledCacheLoader() {
UserEntity user = cacheService.get(CacheEnum.USER_INFO.getCacheName(), "10001",
UserEntity.class, key -> getFakeUser(key));
log.info("user: {}", user);
}
第三种就是对于 lambda 接口的简化写法。
基于以上的方式,我们操作缓存就变得更加容易了。