Redis高并发缓存实战
Redis高并发缓存实战
记录高并发场景下Redis部署、使用、存在的问题以及处理方案等
常见问题
在中小并发场景下,我们在使用缓存架构基本的业务流程是:
- 查询缓存,缓存存在则返回
- 缓存没有,查找数据库,更新缓存
/**
* 查询商品信息
*
* @param productId 商品ID
* @return 商品信息
*/
public Product getProductDetail(Long productId) {
Product product;
// step1: 查询缓存,缓存存在,直接返回
String productCacheKey = RedisConst.PRODUCT_CACHE_PREFIX + productId;
String productStr = redisCacheUtil.get(productCacheKey);
if (!StringUtils.isEmpty(productStr)) {
product = JSON.parseObject(productStr, Product.class);
return product;
}
// step2: 缓存不存在,尝试从数据库查询
product = productRepository.get(productId);
// step3: 数据库中存在,刷新到缓存中
if (null != product) {
redisCacheUtil.set(productCacheKey, JSON.toJSONString(product), getCacheTimeout(), TimeUnit.SECONDS);
}
return product;
}
上述代码实现了一个简单的缓存架构,当有请求获取商品信息时,先去缓存中查询,如果缓存中存在则直接返回缓存的商品信息;缓存中没有则请求数据库获取,如果数据库存在该商品信息,更新到缓存中,并返回商品信息。
缓存穿透
缓存穿透指查询一个不存在的数据。通常情况下,出于对容错以及数据一致的考虑,存储层不存在的数据并不会写入缓存层,而在调用查询接口时,缓存层以及存储层都不存在该数据。
缓存穿透的情况就会导致每一次请求都会到存储层查询数据,而缓冲层起不到任何作用,失去了保护的意义。
原因
- 自身业务代码或者数据出现问题
- 恶意攻击、爬虫等
解决方案
缓存空对象
/** * 查询商品信息 * * @param productId 商品ID * @return 商品信息 */ public Product getProductDetail(Long productId) { Product product; // step1: 查询缓存,缓存存在,直接返回 String productCacheKey = RedisConst.PRODUCT_CACHE_PREFIX + productId; String productStr = redisCacheUtil.get(productCacheKey); if (!StringUtils.isEmpty(productStr)) { // step1.1:判断是否为空对象 if (EMPTY_CACHE.equals(productStr)) { redisCacheUtil.expire(productCacheKey, getRandomEmptyCacheTimeout(), TimeUnit.SECONDS); return new Product(); } product = JSON.parseObject(productStr, Product.class); return product; } ... // step3: 数据库中存在,刷新到缓存中 if (null != product) { redisCacheUtil.set(productCacheKey, JSON.toJSONString(product), getCacheTimeout(), TimeUnit.SECONDS); } else { // step4: 数据库中不存在,刷新空对象到缓存中,并设置较短的过期时间,避免空对象占用过多内存 redisCacheUtil.set(productCacheKey, "{}", getEmptyCacheTimeout(), TimeUnit.SECONDS); } return product; }
上述代码中,当查询数据库也不存在数据时,缓存一个空对象来应对高并发下对同一个商品的查询请求;
同时加一个短暂的过期时间,以应对恶意请求不同商品时导致缓存过多空对象引起的内存过度消耗。使用布隆过滤器
布隆过滤器(Bloom Filter)由布隆(Burton Howard Bloom)在1970年提出的。它是由一个很长的二进制向量和**一系列随机映射函数
**组成,本质上由一个长度为m的位向量组成。它的优点是空间效率和查询时间都远远超过一般的算法,缺点是有一定的误识别率(存在的数据一定存在,不存在的数据可能存在)和
删除困难。详情查看 布隆过滤器简述 文章
缓存失效
缓存失效指存在大批量的缓存在同一时间过期(失效),导致大量的请求越过缓存层直接请求存储层,造成存储层压力过大甚至宕机不再提供服务。
解决方案
/**
* 查询商品信息
*
* @param productId 商品ID
* @return 商品信息
*/
public Product getProductDetail(Long productId) {
...
// step3: 数据库中存在,刷新到缓存中
if (null != product) {
redisCacheUtil.set(productCacheKey, JSON.toJSONString(product), getRandomCacheTimeout(), TimeUnit.SECONDS);
} else {
// step4: 数据库中不存在,刷新空对象到缓存中,并设置较短的过期时间,避免空对象占用过多内存
redisCacheUtil.set(productCacheKey, "{}", genEmptyRandomCacheTimeout(), TimeUnit.SECONDS);
}
return product;
}
/**
* 获取缓存超时时间
*
* @return 超时时间
*/
private Integer getRandomCacheTimeout() {
return PRODUCT_CACHE_TIMEOUT + new Random().nextInt(5) * 60 * 60;
}
/**
* 获取空缓存超时时间
* @return 超时时间
*/
private Integer getRandomEmptyCacheTimeout() {
return 60 + new Random().nextInt(30);
}
上述代码中,在查询数据库数据并更新缓存时,获取的超时时间添加随机数获取,错开超时时间,防止同一时间大批量缓存过期。
缓存雪崩
缓存雪崩指缓存层由于某些原因支撑不住宕机后,流量像洪流一样打到存储层,存储层调用量暴增,甚至导致存储层压力过大,最终造成存储层也宕机的情况。
原因
- 超大并发
- 缓存中存在大量的big key
- 缓存设计不佳
解决方案
- 使用Redis Sentinel或者Redis Cluster来保证缓存层服务高可用
- 引入Sentinel或者Hystrix等组件为后端服务进行限流、熔断、降级
- 提前模拟、演练后端负载情况下可能存在的问题,并在此基础上做一些预案设定
热点缓存重建
在使用缓存+过期时间的策略,可以加速数据读写,同时还保证数据能够定期更新,基本能够满足大部分的需求。
但是当这个key是一个热点key(高并发),并发量大。当这个key失效之后,又不能在短时间内再次缓存起来时,在缓存失效的这段时间内,大量请求同时越过缓存层尝试请求存储层获取数据并重建缓存,从而导致存储层压力增大甚至造成缓存雪崩。
解决方案
主要就是如何避免大量请求同时重建缓存。可以通过加互斥锁来控制只允许一个线程重建缓存,其他线程等待缓存重建完成后从缓存中获取即可。
/**
* 查询商品信息
*
* @param productId 商品ID
* @return 商品信息
*/
public Product getProductDetail(Long productId) {
...
// step1.2:获取分布式互斥锁
RLock hotLock = redisson.getLock(LOCK_PRODUCT_HOT_CACHE_PREFIX + productId);
// 加互斥锁
hotLock.lock();
try {
// step1.3: 再次尝试查询缓存,缓存存在,直接返回
productStr = redisCacheUtil.get(productCacheKey);
if (!StringUtils.isEmpty(productStr)) {
if (EMPTY_CACHE.equals(productStr)) {
redisCacheUtil.expire(productCacheKey, getRandomEmptyCacheTimeout(), TimeUnit.SECONDS);
return new Product();
}
product = JSON.parseObject(productStr, Product.class);
return product;
}
...
} finally {
// 解锁
hotLock.unlock();
}
return product;
}
上述代码使用Redisson实现了分布式互斥锁,单线程去存储层获取商品信息并重建缓存,采用双重检查的方式来处理等待线程去获取锁时,会再次判断缓存是否已经重建成功,成功直接返回。