一、缓存惊群
1、场景描述
用户数据写入和查询,缓存设计,写入的时候,库+缓存,双写,缓存默认 2 天多随机时间
的过期,读的时候,读延期,频繁读,缓存会不停的延期,没有人查呢,过期,避免占用缓
存的空间,缓存没查到,从数据库里提出来,放到缓存里去
每次写入缓存的时候,为什么一定要设置 2 天+随机几个小时的时间呢?
答案,缓存惊群的问题,缓存一批数据,突然之间一起都没了,过期时间设置的都是一样的,
缓存集群都惊了,数据库也惊了,大量缓存同时过期->惊群 ->瞬时大量请求走到 mysql 去
造成压力
大量的缓存数据,过期时间都是随机,不要集中在某个时间点一起过期
惊群,技术里典型的术语,突然在某个时间点,出了一个故障,一大片范围线程/进程/机器,
都同时被惊动了,惊群效应
2、解决方案
每次写入缓存的时候,设置 2 天+随机几个小时的时间,使得缓存数据不会一起失效
代码实现
redisCache.set(RedisKeyConstants.USER_INFO_PREFIX + cookbookUserDO.getId(),
JsonUtil.object2Json(cookbookUserDTO), CacheSupport.generateCacheExpireSecond());
/**
* 生成缓存过期时间
* 2天加上随机几小时
*
* @return
*/
static Integer generateCacheExpireSecond() {
return TWO_DAYS_SECONDS + RandomUtil.genRandomInt(0, 10) * 60 * 60;
}
二、缓存穿透
1、场景描述
缓存穿透的一个问题,穿透->读取缓存没读到->从 db 里读->也没读到->bug->高并发的读一个数据,缓存和 db 都没有->每次高并发读取,缓存都会被穿透过去,每次都要读一下db,导致高并发空请求都针对 db 再走,压力
2、解决方案
先从缓存中获取数据,如果缓存中不存在,则从数据库中查出来,如果数据库中查出来的数据为空,则赋值为"{}",并且设置失效时间。
@Override
public CookbookUserDTO getUserInfo(CookbookUserQueryRequest request) {
Long userId = request.getUserId();
CookbookUserDTO user = getUserFromCache(userId);
if(user != null) {
return user;
}
return getUserInfoFromDB(userId);
}
private CookbookUserDTO getUserFromCache(Long userId) {
String userInfoKey = RedisKeyConstants.USER_INFO_PREFIX + userId;
String userInfoJsonString = redisCache.get(userInfoKey);
log.info("从缓存中获取作者信息,userId:{},value:{}", userId, userInfoJsonString);
if (StringUtils.hasLength(userInfoJsonString)){
// 防止缓存穿透
if (Objects.equals(CacheSupport.EMPTY_CACHE, userInfoJsonString)) {
return new CookbookUserDTO();
}
redisCache.expire(RedisKeyConstants.USER_INFO_PREFIX + userId,
CacheSupport.generateCacheExpireSecond());
CookbookUserDTO dto = JsonUtil.json2Object(userInfoJsonString, CookbookUserDTO.class);
return dto;
}
return null;
}
private CookbookUserDTO getUserInfoFromDB(Long userId) {
// 有两个选择,load from db + write redis,加两把锁,user_lock,user_update_lock
// 基于redisson,加多锁,multi lock
// 共用一把锁,multi lock加锁,不同的锁应对的是不同的并发场景
// String userLockKey = RedisKeyConstants.USER_LOCK_PREFIX + userId;
// 有大量的线程突然读一个冷门的用户数据,都囤积在这里,在上面大家都没读到
// 都在这个地方在排队等待获取锁,然后去尝试load db + write redis
// 非常严重的锁竞争的问题,线程,串行化,一个一个的排队,一个人先拿锁,load一次db,写缓存
// 下一个人拿到锁了,通过double check,直接读缓存,下一个人,短时间内突然有一个严重串行化,虽然每次读缓存,时间不多
// 其实只要有第一个人,能够拿到锁,进去,laod db + wreite redis,redis里就已经有数据了
// 后续的线程就不需要通过锁排队,串行化,一个一个load redis里的数据
// 只要有一个人能够成功,其他的人,其实可以突然之间全部转换为上面的操作,无锁的情况下,大量的一起并发的读redis就可以了
String userLockKey = RedisKeyConstants.USER_UPDATE_LOCK_PREFIX + userId;
boolean lock = false;
try {
lock = redisLock.tryLock(userLockKey, USER_UPDATE_LOCK_TIMEOUT);
} catch(InterruptedException e) {
CookbookUserDTO user = getUserFromCache(userId);
if(user != null) {
return user;
}
log.error(e.getMessage(), e);
throw new BaseBizException("查询失败");
}
if (!lock) {
CookbookUserDTO user = getUserFromCache(userId);
if(user != null) {
return user;
}
log.info("缓存数据为空,从数据库查询作者信息时获取锁失败,userId:{}", userId);
throw new BaseBizException("查询失败");
}
try {
CookbookUserDTO user = getUserFromCache(userId);
if(user != null) {
return user;
}
log.info("缓存数据为空,从数据库中获取数据,userId:{}", userId);
String userInfoKey = RedisKeyConstants.USER_INFO_PREFIX + userId;
// 在这里先读到了db里的用户信息的旧数据
// 这个线程刚刚读到,还没有来得及把旧数据写入缓存里去
CookbookUserDO cookbookUserDO = cookbookUserDAO.getById(userId);
if (Objects.isNull(cookbookUserDO)) {
redisCache.set(userInfoKey, CacheSupport.EMPTY_CACHE, CacheSupport.generateCachePenetrationExpireSecond());
return null;
}
CookbookUserDTO dto = cookbookUserConverter.convertCookbookUserDTO(cookbookUserDO);
// 此时这个线程,在上面的那个线程都已经把新数据写入缓存里去了,缓存里已经是最新数据了
// 把旧数据库,写入了缓存做了一个覆盖操作,典型的,数据库+缓存双写的时候,写和读,并发的时候
// db里是新数据,缓存里是旧数据,旧数据是覆盖了新数据的
// db和缓存,数据是不一致的
redisCache.set(userInfoKey, JsonUtil.object2Json(dto), CacheSupport.generateCacheExpireSecond());
return dto;
} finally {
redisLock.unlock(userLockKey);
}
}
三、缓存一致性
1、场景描述
如果数据库中的某条数据,放入缓存之后,又立马被更新了,那么该如何更新缓存呢?不更新缓存行不行?
答:当然不行,如果不更新缓存,在很长的一段时间内(决定于缓存的过期时间),用户请求从缓存中获取到的都可能是旧值,而非数据库的最新值。这不是有数据不一致的问题?
网友评论