高并发缓存框架
常规缓存使用:
public class ProductService {
@Resource
private ProductDao productDao;
private static final Integer PRODUCT_CACHE_TIMEOUT = 60*60*24;
@Transactional
public Product create(Product product){
Product result = productDao.create(product);
RedisUtils.set(RedisKeyPrefixConst.PRODUCT_CACHE+result.getId(), JSON.toJSONString(result));
return result;
}
@Transactional
public Product update(Product product){
Product result = productDao.update(product);
RedisUtils.set(RedisKeyPrefixConst.PRODUCT_CACHE+result.getId(), JSON.toJSONString(result));
return result;
}
@Transactional
public Product get(Long productId){
String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE +productId;
String productStr = RedisUtils.get(productCacheKey,String.class);
if (StringUtils.isNotEmpty(productStr)){
Product product = JSON.parseObject(productStr, Product.class);
return product;
}
Product result = productDao.get(productId);
if (result!=null){
RedisUtils.set(RedisKeyPrefixConst.PRODUCT_CACHE+result.getId(), JSON.toJSONString(result));
}
return result;
}
}
场景一
如京东、淘宝这种大型互联网公司,商品数量有几十亿甚至几百亿商品,那么这个时候把商品信息存在redis里面显然不可取,该如何解决?
1. 问题解析
向这种大型互联网公司的海量数据,实际只有百分1的数据需要经常访问,也就是说99%的数据是不会访问的,那么这个时候把所有数据都放入redis就显得不合理了
2. 解决思路
把数据做一下隔离,只把1%的数据放入redis缓存,进行冷热分离。只需要在上面代码加上PRODUCT_CACHE_TIMEOUT
缓存的超时时间,如下:
public class ProductService {
@Resource
private ProductDao productDao;
private static final long PRODUCT_CACHE_TIMEOUT = 60*60*24;
@Transactional
public Product create(Product product){
Product result = productDao.create(product);
RedisUtils.set(RedisKeyPrefixConst.PRODUCT_CACHE+result.getId(), JSON.toJSONString(result),
PRODUCT_CACHE_TIMEOUT);
return result;
}
@Transactional
public Product update(Product product){
Product result = productDao.update(product);
RedisUtils.set(RedisKeyPrefixConst.PRODUCT_CACHE+result.getId(), JSON.toJSONString(result),
PRODUCT_CACHE_TIMEOUT);
return result;
}
@Transactional
public Product get(Long productId){
String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE +productId;
String productStr = RedisUtils.get(productCacheKey,String.class);
if (StringUtils.isNotEmpty(productStr)){
Product product = JSON.parseObject(productStr, Product.class);
return product;
}
Product result = productDao.get(productId);
if (result!=null){
RedisUtils.set(RedisKeyPrefixConst.PRODUCT_CACHE+result.getId(), JSON.toJSONString(result),
PRODUCT_CACHE_TIMEOUT);
}
return result;
}
}
场景二
缓存穿透、缓存雪崩、缓存击穿、缓存数据库双写不一致
1、缓存击穿
由于大批量缓存在同一时间失效,导致大量的请求击穿了缓存数据库,直接进行了数据库查询,导致数据库瞬间压力过大,严重的甚至导致数据库挂掉。
解决思路
可以将大批量导入的数据存入缓存的时候加上随机时间,错开商品的过期时间,这样就可以防止大量数据缓存在同一时间过期。
在场景一代码基础上添加过期时间+随机时间的方法:
private long genProductCacheTimeout(){
return PRODUCT_CACHE_TIMEOUT + new Random().nextInt(30)*60;
}
修改原有设置缓存代码:
修改前:RedisUtils.set(RedisKeyPrefixConst.PRODUCT_CACHE+result.getId(), JSON.toJSONString(result),
PRODUCT_CACHE_TIMEOUT);
修改后:RedisUtils.set(RedisKeyPrefixConst.PRODUCT_CACHE+result.getId(), JSON.toJSONString(result),
genProductCacheTimeout());
2、缓存穿透
用户访问的数据既不在缓存当中,也不在数据库中。出于容错的考虑,如果从底层数据库查询不到数据,则不写入缓存。这就导致每次请求都会到底层数据库进行查询,缓存也失去了意义。当高并发或有人利用不存在的Key频繁攻击时,数据库的压力骤增,甚至崩溃,这就是缓存穿透问题。
缓存穿透发生的场景一般有两类:
- 来数据是存在的,但由于某些原因(误删除、主动清理等)在缓存和数据库层面被删除了,但前端或前置的应用程序依旧保有这些数据;
- 恶意攻击行为,利用不存在的Key或者恶意尝试导致产生大量不存在的业务数据请求。
解决思路
1、缓存空值在redis,代码如下
//添加空置常量
private static final String EMPTY_CACHE="{}";
//修改原有get方法,如下:
@Transactional
public Product get(Long productId){
String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE +productId;
String productStr = RedisUtils.get(productCacheKey,String.class);
if (StringUtils.isNotEmpty(productStr)){
//判断当缓存值为空串的时候,直接返回null
if (EMPTY_CACHE.equals(productStr)){
return null;
}
Product product = JSON.parseObject(productStr, Product.class);
return product;
}
Product result = productDao.get(productId);
if (result!=null){
RedisUtils.set(productCacheKey, JSON.toJSONString(result),
genProductCacheTimeout());
}else {
//当不存在的时候存储空串到缓存
RedisUtils.set(productCacheKey, EMPTY_CACHE);
}
return result;
}
3、缓存雪崩
(1)一次大V直播带货导致线上商品系统崩溃
问题解析
大V带货,带的大部分都是冷门的商品,这些商品都是存储在数据库中,并没有在缓存中,当大v带货3、2、1上链接的时候,同时会有几万的并发击穿缓存,直达数据库,且几万并发都可以查询到商品,那么在解决完缓存穿透的代码上,系统会重复重建无数次的缓存,从而浪费性能,甚至宕机,从而形成一系列的连锁反应,造成系统崩溃等情况,这就是缓存雪崩(Cache Avalanche)。
解决方法
1、DCL,双层检测锁,加锁,代码如下:
@Transactional
public Product get(Long productId){
Product product =null;
String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE +productId;
String productStr = RedisUtils.get(productCacheKey,String.class);
if (StringUtils.isNotEmpty(productStr)){
if (EMPTY_CACHE.equals(productStr)){
return null;
}
product = JSON.parseObject(productStr, Product.class);
return product;
}
synchronized (this){
productStr = RedisUtils.get(productCacheKey,String.class);
if (StringUtils.isNotEmpty(productStr)){
if (EMPTY_CACHE.equals(productStr)){
return null;
}
product = JSON.parseObject(productStr, Product.class);
return product;
}
product = productDao.get(productId);
if (product!=null){
RedisUtils.set(productCacheKey, JSON.toJSONString(product),
genProductCacheTimeout());
}else {
RedisUtils.set(productCacheKey, EMPTY_CACHE);
}
}
return product;
}
(2) 突发性热点缓存重建导致系统压力暴增。
存在的问题, synchronized (this)中锁的对象,有问题,比如
李佳奇上链接的商品是1088号商品,同时另一个大V罗永浩上链接的商品是10888,那么由于商品不一样,synchronized (this)锁的对象是一样的,谁先上链接获得锁,另一个大V上商品就得排队,导致所有i的商品都得排队。
1、解决思路
使用分布式锁,redisson,方法如下:
- 导入maven包:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.6.5</version>
</dependency>
- 创建分布式锁的前缀常量
private static final String LOCK_PRODUCT_HOT_CACHE_CREATE_PREFIX="lock:product:hot_cache_crete_prefix";
- 修改get方法加锁方式
@Transactional
public Product get(Long productId){
Product product =null;
String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE +productId;
product = getProductFormCache(productCacheKey);
if (product!=null){
return product;
}
RLock hotCacheLock = redisson.getLock(LOCK_PRODUCT_HOT_CACHE_CREATE_PREFIX + productId);
hotCacheLock.lock();
try {
product = getProductFormCache(productCacheKey);
if (product!=null){
return product;
}
product = productDao.get(productId);
if (product!=null){
RedisUtils.set(productCacheKey, JSON.toJSONString(product),
genProductCacheTimeout());
}else {
RedisUtils.set(productCacheKey, EMPTY_CACHE);
}
}finally {
hotCacheLock.unlock();
}
return product;
}
2、分布式锁优化
使用hotCacheLock.tryLock(3, TimeUnit.SECONDS);
代替hotCacheLock.lock();
hotCacheLock.tryLock(3, TimeUnit.SECONDS);
源码解析:
/**
* Acquires the lock if it is free within the given waiting time and the
* current thread has not been {@linkplain Thread#interrupt interrupted}.
*
* <p>If the lock is available this method returns immediately
* with the value {@code true}.
* If the lock is not available then
* the current thread becomes disabled for thread scheduling
* purposes and lies dormant until one of three things happens:
* <ul>
* <li>The lock is acquired by the current thread; or
* <li>Some other thread {@linkplain Thread#interrupt interrupts} the
* current thread, and interruption of lock acquisition is supported; or
* <li>The specified waiting time elapses
* </ul>
*
* <p>If the lock is acquired then the value {@code true} is returned.
*
* <p>If the current thread:
* <ul>
* <li>has its interrupted status set on entry to this method; or
* <li>is {@linkplain Thread#interrupt interrupted} while acquiring
* the lock, and interruption of lock acquisition is supported,
* </ul>
* then {@link InterruptedException} is thrown and the current thread's
* interrupted status is cleared.
*
* <p>If the specified waiting time elapses then the value {@code false}
* is returned.
* If the time is
* less than or equal to zero, the method will not wait at all.
*
* <p><b>Implementation Considerations</b>
*
* <p>The ability to interrupt a lock acquisition in some implementations
* may not be possible, and if possible may
* be an expensive operation.
* The programmer should be aware that this may be the case. An
* implementation should document when this is the case.
*
* <p>An implementation can favor responding to an interrupt over normal
* method return, or reporting a timeout.
*
* <p>A {@code Lock} implementation may be able to detect
* erroneous use of the lock, such as an invocation that would cause
* deadlock, and may throw an (unchecked) exception in such circumstances.
* The circumstances and the exception type must be documented by that
* {@code Lock} implementation.
*
* @param time the maximum time to wait for the lock
* @param unit the time unit of the {@code time} argument
* @return {@code true} if the lock was acquired and {@code false}
* if the waiting time elapsed before the lock was acquired
*
* @throws InterruptedException if the current thread is interrupted
* while acquiring the lock (and interruption of lock
* acquisition is supported)
*/
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
从参数描述可以看出,第一个参数设置了最大等待时间,第二个参数是单位:
hotCacheLock.tryLock(3, TimeUnit.SECONDS);
意思就是等待3秒,3秒后串转并,直接向下执行
4、缓存数据库双写不一致问题
线程3在从第二步到第三步的时候卡了一下,此时线程2全部执行完,此时就导致缓存数据库双写不一致问题。
解决方法:
使用分布式锁去解决缓存数据库双写不一致的问题,因为分布式锁的原理是吧并行转换成串行,因此分布式锁与高并发系统设计是相违背的,因此需要极力去优化分布式锁。
1、解决不一致问题
- 创建商品更新key
private static final String LOCK_PRODUCT_UPDATE_PREFIX = "lock:product:update";
- 修改get方法,在查询数据库数据前加上更新锁,代码如下
@Transactional
public Product get(Long productId) {
Product product = null;
String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE + productId;
product = getProductFormCache(productCacheKey);
if (product != null) {
return product;
}
//解决热点缓存问题
RLock hotCacheLock = redisson.getLock(LOCK_PRODUCT_HOT_CACHE_CREATE_PREFIX + productId);
hotCacheLock.lock();
try {
product = getProductFormCache(productCacheKey);
if (product != null) {
return product;
}
//解决缓存数据库双写不一致问题
RLock updateLock = redisson.getLock(LOCK_PRODUCT_UPDATE_PREFIX + productId);
updateLock.lock();
try {
product = productDao.get(productId);
if (product != null) {
RedisUtils.set(productCacheKey, JSON.toJSONString(product),
genProductCacheTimeout());
} else {
RedisUtils.set(productCacheKey, EMPTY_CACHE, genProductCacheTimeout());
}
} finally {
updateLock.unlock();
}
} finally {
hotCacheLock.unlock();
}
return product;
}
- 在更新方法前添加和查询方法中 一样的更新锁,代码如下:
@Transactional
public Product update(Product product) {
//解决缓存数据库双写不一致问题
RLock updateLock = redisson.getLock(LOCK_PRODUCT_UPDATE_PREFIX + product.getId());
updateLock.lock();
Product result = null;
try {
result = productDao.update(product);
RedisUtils.set(RedisKeyPrefixConst.PRODUCT_CACHE + result.getId(), JSON.toJSONString(result),
genProductCacheTimeout());
}finally {
updateLock.unlock();
}
return result;
}
2、分布式锁性能优化
(1)读写锁
使用读写锁代替分布式锁:代码如下:
1、get方法中锁的替换
@Transactional
public Product get(Long productId) {
Product product = null;
String productCacheKey = RedisKeyPrefixConst.PRODUCT_CACHE + productId;
product = getProductFormCache(productCacheKey);
if (product != null) {
return product;
}
//解决热点缓存问题
RLock hotCacheLock = redisson.getLock(LOCK_PRODUCT_HOT_CACHE_CREATE_PREFIX + productId);
hotCacheLock.lock();
try {
product = getProductFormCache(productCacheKey);
if (product != null) {
return product;
}
//解决缓存数据库双写不一致问题
//RLock updateLock = redisson.getLock(LOCK_PRODUCT_UPDATE_PREFIX + productId);
//updateLock.lock();
//使用读写锁中读锁加锁
RReadWriteLock readWriteLock = redisson.getReadWriteLock(LOCK_PRODUCT_UPDATE_PREFIX + productId);
RLock rLock = readWriteLock.readLock();
rLock.lock();
try {
product = productDao.get(productId);
if (product != null) {
RedisUtils.set(productCacheKey, JSON.toJSONString(product),
genProductCacheTimeout());
} else {
RedisUtils.set(productCacheKey, EMPTY_CACHE, genProductCacheTimeout());
}
} finally {
//updateLock.unlock();
rLock.unlock();
}
} finally {
hotCacheLock.unlock();
}
return product;
}
2、update方法中锁的替换
@Transactional
public Product update(Product product) {
//解决缓存数据库双写不一致问题
//RLock updateLock = redisson.getLock(LOCK_PRODUCT_UPDATE_PREFIX + product.getId());
//updateLock.lock();
//使用读写锁中的写锁加锁
RReadWriteLock readWriteLock = redisson.getReadWriteLock(LOCK_PRODUCT_UPDATE_PREFIX + product.getId());
RLock wLock = readWriteLock.writeLock();
wLock.lock();
Product result = null;
try {
result = productDao.update(product);
RedisUtils.set(RedisKeyPrefixConst.PRODUCT_CACHE + result.getId(), JSON.toJSONString(result),
genProductCacheTimeout());
}finally {
//updateLock.unlock();
wLock.unlock();
}
return result;
}
读写锁源码解析
差看源码
RLock rLock = readWriteLock.readLock();
核心代码如下:
@Override
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
internalLockLeaseTime = unit.toMillis(leaseTime);
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
"local mode = redis.call('hget', KEYS[1], 'mode'); " +
"if (mode == false) then " +
"redis.call('hset', KEYS[1], 'mode', 'write'); " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (mode == 'write') then " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"local currentExpire = redis.call('pttl', KEYS[1]); " +
"redis.call('pexpire', KEYS[1], currentExpire + ARGV[1]); " +
"return nil; " +
"end; " +
"end;" +
"return redis.call('pttl', KEYS[1]);",
Arrays.<Object>asList(getName()),
internalLockLeaseTime, getLockName(threadId));
}
实际上也是执行了一段lua脚本,核心脚本如下:
redis.call('hset', KEYS[1], 'mode', 'write');
在原有的基础上增加了模式mode;底层逻辑实际上是setnx命令。
- 当新请求进来的时候,如果是写入数据,先查询是否有锁,在判断模式是否是write,当有write锁存在的时候,需要等待前一个写锁执行完,写锁与写锁也是互斥的。
- 如果是查询数据,也会先检查锁是否存在,在检查是否有写的模式存在,如果存在则需要排队等待,没有则直接执行
- 当全部是查询请求,redis会先写入一个分布式锁,模式是读read,当又有新请求进来的时候,判断锁存不存在,然后判断模式有没有写模式,没有写模式,加锁次数+1,并行执行查询。
- 当释放锁的时候加锁次数-1,当减到0的时候,锁完全释放。
场景三(超高并发)
超高并发场景中,在上面代码的基础上,可能导致每秒几十万甚至上百万的请求到达redis,导致缓存数据库崩掉,从而导致各个web系统接连崩掉,产生了缓存雪崩。
解决方法,使用多级缓存架构
- 创建JVM二级缓存
private static Map<String,Product> productMap = new ConcurrentHashMap<>();
- 修改get、update方法,在set缓存的时候,将缓存数据也放一份在map中。
//JVM二级缓存,解决超高并发问题
productMap.put(productCacheKey,product);