1. 写在前头
大家好,我是方圆
。这篇我们将在本地缓存的基础上引入分布式缓存,开发分支对应increase_distributed_cache
,它是在increase_local_cache分支基础上进行开发和拓展的。在此处不再赘述分布式缓存的生效流程图,请以第五章开头整体流程图为准。
2. 分布式缓存的实现
分布式缓存使用的是Redis,它也是程序员中间件必须要掌握的工具之一,另在此推荐《Redeis设计与实现》这本书,很好读、很流畅。
分布式锁我借助了Redisson实现,之前实习的时候,导师也给我推荐过这个工具。我们需要在infrastructure层pom文件中引入它的依赖。
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.16.8</version>
</dependency>
在使用前需要安装Redis,并在yaml配置文件中添加如下Redis相关配置
spring:
application:
# 应用名称
name: flash-sale
# mysql
datasource:
type: org.apache.commons.dbcp2.BasicDataSource
url: jdbc:mysql://xxx:3306/flash_sale?useUnicode=true&characterEncoding=UTF-8&allowPublicKeyRetrieval=true
username: xxx
password: xxx
driver-class-name: com.mysql.cj.jdbc.Driver
# redis
redis:
host: 127.0.0.1
password: xxx
address: redis://127.0.0.1:6379
2.1 分析
- 在缓存服务实现上使用了
模板方法模式
,分布式缓存不过是在本地缓存的基础上加了一层而已,它的代码一定是能够通用的,也仅有查询数据库的操作需要具体的实现
,所以我们在AbstractCacheService抽象类上进行开发新增分布式缓存的代码即可。
2.2 RedissonConfig
- 为Redisson添加必要的配置信息
@Configuration
public class RedissonConfig {
@Value("${spring.redis.address}")
private String address;
@Value("${spring.redis.password}")
private String password;
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer().setAddress(address).setPassword(password);
return Redisson.create(config);
}
}
2.3 RedisConfig
- RedisConfig,缓存Key添加了StringRedisSerializer解析
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>();
redisTemplate.setConnectionFactory(factory);
// 缓存的key值用StringRedisSerializer解析
StringRedisSerializer keySerializer = new StringRedisSerializer();
redisTemplate.setKeySerializer(keySerializer);
redisTemplate.setHashKeySerializer(keySerializer);
return redisTemplate;
}
}
- 这里我没有对缓存value值指定解析器,而是使用了
默认的JdkSerializationRedisSerializer
。因为如果不使用它的话,而是使用网上大家说的比较多的JSON相关的解析器会导致EntityCache对象字段dataList内类型都为JSONObject,这样我们就不得不在具体的业务层代码中添加一层转换,将JSONObject转换成FlashActivity或FlashItem,以免发生转型失败的异常。如果看不懂这里的解释的话,大家可以写上如下代码试一试。
FastJsonRedisSerializer<Object> valueSerializer = new FastJsonRedisSerializer<>(Object.class);
redisTemplate.setValueSerializer(valueSerializer);
redisTemplate.setHashValueSerializer(valueSerializer);
2.4 封装RedisCacheService
- 使用了泛型T,代表缓存的对象类型。注入RedisTemplate,封装了一下get和set值的方法,方便之后调用
@Service
public class RedisCacheService<T> {
private static final Long ONE_WEEK_SECONDS = 607800L;
@Resource
private RedisTemplate<String, Object> redisTemplate;
@SuppressWarnings("unchecked")
public EntityCache<T> getValue(String key) {
return (EntityCache<T>) redisTemplate.opsForValue().get(key);
}
/**
* 保存长期的key,目前长期定的是1周
*/
public void setValue(String key, Object value) {
setValue(key, value, ONE_WEEK_SECONDS);
}
/**
* 保存有时间限制的key
*
* @param second 秒数
*/
public void setValue(String key, Object value, Long second) {
redisTemplate.opsForValue().set(key, value, second, TimeUnit.SECONDS);
}
}
2.5 AbstractCacheService
- 在AbstractCacheService注入如下Bean
@Resource
private RedissonClient redissonClient;
@Resource
private RedisCacheService<T> redisCacheService;
- 新增一些静态变量。其中在获取分布式锁时,为key定义了前缀,其中%s占位符表示的是缓存的key值,把分布式锁的key定义成动态的想法是:我期待能够实现并发的更新不同的缓存,对于同一条缓存大家争抢一把分布式锁,对于不同的缓存,那么就不让它争抢锁了吧
/**
* 分布式锁的key前缀, key: 前缀 + 查询条件toString
*/
private static final String UPDATE_LOCK_PREFIX = "UPDATE_LOCK_PREFIX_%s";
/**
* 分布式获取锁的等待时间
*/
private static final long WAIT_TIME = 1L;
/**
* 分布式锁最大的持有时间,超过自动释放锁
*/
private static final long LEASE_TIME = 5L;
/**
* 分布式缓存的持续时间
*/
private static final long DISTRIBUTED_CACHE_LIVE_TIME = 60;
- 代码主流程上的改动,以getCache方法举例说明,请关注下文中的代码注释,最终仍以分支代码为准
@Override
public T getCache(BaseQueryCondition queryCondition) {
String key = queryCondition.toString();
EntityCache<T> flashActivityCaches = flashLocalCache.getIfPresent(key);
if (flashActivityCaches != null) {
return hitLocalCache(flashActivityCaches).get(0);
} else {
// 再未命中本地缓存时,查询分布式缓存并更新本地缓存
return getDataFromDistributedCacheAndSaveLocalCache(queryCondition, key);
}
}
- 从分布式缓存中取单个对象,取出后进行本地缓存
private T getDataFromDistributedCacheAndSaveLocalCache(BaseQueryCondition queryCondition, String key) {
// 从分布式缓存中取单条数据
T data = getDataFromDistributedCache(queryCondition, key);
if (data == null) {
saveLocalCache(Collections.emptyList(), key);
} else {
saveLocalCache(Collections.singletonList(data), key);
}
return data;
}
- 从Redis分布式缓存获取单条数据,两种情况,命中缓存和未命中缓存
private T getDataFromDistributedCache(BaseQueryCondition queryCondition, String key) {
EntityCache<T> distributedCache = redisCacheService.getValue(key);
if (distributedCache != null) {
return hitDistributedCache(distributedCache, key).get(0);
} else {
return getDataFromDataBaseAndSaveDistributedCache(queryCondition, key);
}
}
- 命中缓存,在数据存在的情况下直接返回,注意在数据不存在时,这里也更新了一下本地缓存,否则该请求将重复的命中分布式缓存
private List<T> hitDistributedCache(EntityCache<T> distributedCache, String key) {
log.info("命中分布式缓存, {}", JSONObject.toJSONString(distributedCache));
if (distributedCache.isExist()) {
return distributedCache.getDataList();
} else {
saveLocalCache(Collections.emptyList(), key);
throw new RepositoryException(DATA_NOT_FOUND);
}
}
- 未命中缓存,这里涉及到了
分布式锁
,代码逻辑也很清晰,先尝试获取锁,获取到了查库,更新分布式缓存,返回数据,注意对于查询不到的数据,仍然进行了缓存,避免缓存击穿
private T getDataFromDataBaseAndSaveDistributedCache(BaseQueryCondition queryCondition, String key) {
RLock lock = redissonClient.getLock(String.format(UPDATE_LOCK_PREFIX, key));
T data = null;
try {
if (lock.tryLock(WAIT_TIME, LEASE_TIME, TimeUnit.SECONDS)) {
try {
data = getSingleDataFromDataBase(queryCondition);
saveDistributedCache(Collections.singletonList(data), key);
} catch (DomainException e) {
// 查询不存在的数据,同样加入缓存中,防止缓存穿透
saveDistributedCache(Collections.emptyList(), key);
} finally {
lock.unlock();
}
} else {
throw new RepositoryException(TRY_LATTER);
}
} catch (InterruptedException e) {
log.error("分布式锁获取失败", e);
}
return data;
}
getCache方法加入分布式锁的代码已经讲解完了,很简单,getCaches方法与之大同小异,参考源码即可。
3. 缓存的原则和系统的应对
《高并发秒杀的设计精要与实现》提到过一些缓存原则,对照这些原则,我想做一个总结,也是系统对这些原则的应对措施。
- 热点数据一律进缓存:系统中采用的是本地缓存和分布式缓存的解决方案,并为查询的操作做了缓存处理,其中对于数据库中不存在的数据也进行了缓存,避免缓存击穿
- 优先读取本地缓存,以本地缓存为主,远端分布式缓存为辅
- 所有缓存设置过期时间,本地缓存过期时间控制在秒级:本地缓存设置的是10s,分布式缓存为60s
- 本地缓存务必同时设置容量驱逐和时间驱逐两种方式:时间驱逐为10s,容量限制为33(暂定)
- 缓存KEY具有业务可读性,杜绝不同场景出现相同KEY:系统中的key采用的是查询条件的toString字符串,因不同业务可不同
- 缓存列表数据时,仅缓存第一页,缓存数量不超过20:缓存列表这里系统实现的不够好,而且之后更新列表缓存仍然是个问题,打算放弃列表的缓存更新,要么就要将条件查询方法重写或者改变列表缓存key在之后在更新缓存时能构造出key值
- 杜绝并发更新缓存,防止缓存击穿:在分布式缓存处增加了分布式锁
- 空数据进缓存,防止缓存击穿
- 读数据时,先读缓存,再读数据库
- 写数据时,先写数据库,再写缓存:这一点还未涉及,之后更新缓存时,将遵守!
加油儿