先来看看常用的缓存+数据库模式(旁路模式)
先读取缓存,如果缓存中不存在数据,查询数据库,查到数据后,设置缓存,未查到返回空。
该模式缺点:
在请求量较大的情况下,如果缓存命中不高,会导致大量缓存穿透
改进:
先读取缓存,如果缓存中不存在数据或者为空标志位,查询数据库,查到数据后,设置缓存,未查到在缓存中设置空标志位。
tips:空标志位可以用特殊字符串设置,当缓存的数据结构不是字符串时,需要先判断类型
核心代码:
private static final String USER_DATA_KEY = "user_data_%s";
private static final Integer USER_DATA_EXPIRE_TIME = 3 * 24 * 60 * 60;//用户数据缓存时间 秒
private static final String USER_DATA_TYPE = "set";
private static final String EMPTY_KEY_TYPE = "none";
private static final String NULL_FLAG = "NULL-DATA";
private static final Integer NULL_FLAG_EXPIRE_TIME = 2;//空标志位缓存时间 秒
public Set queryUserData(String userId){
String key = String.format(USER_DATA_KEY,userId);
String redisType = redis.type(key);
//如果用户数据也是string,这里比较是否值相等
if(USER_DATA_TYPE.equals(redisType)){//有用户数据
return redis.smembers(key);
}
if(!EMPTY_KEY_TYPE.equals(redisType)){//说明此时是空数据标志位,
return null;
}
//说明此时缓存中没有数据,查询数据库,放入缓存。
Set dataInDb= userDaoByDB.queryUserData(userId);
if(!CollectionUtils.isEmpty(dataInDb)){
redis.sadd(key,dataInDb.toArray(new String[dataInDb.size()]));
redis.expire(key,USER_DATA_EXPIRE_TIME);
}else{
//数据库中没有查到数据,放置空标志位
redis.setex(key,NULL_FLAG_EXPIRE_TIME,NULL_FLAG);
}
return dataInDb;
}
2.1先删除/更新缓存,再写库
2.2先写库,再更新/删除缓存
上述的旁路模式存在的问题:
在单线程或者并发下的理想情况:最终数据数据库内数据和缓存保持一致,后续用户请求到的都是更新后的正确数据。
并发情况下存在的问题:
如上图,无论是先删除/更新缓存还是先更新数据库,只要3号步骤在写线程所有步骤之后执行,都会导致缓存和数据库数据不一致,后续请求到的数据都是a=1,而不是最新更新的a=2
网上有通过redis实现分布式读写锁的方式,基本都是通过lua脚本来利用redis的单线程来保证检查读写锁操作这两步的原子性。
本方案采用纯业务代码方式,较为繁琐,性能较差。但仅适合并发量不高、读多写少的系统的数据一致性。为了保证一致性,里面有部分步骤中直接采用快速失败,会导致读取或更新操作失败,需要客户端重试
这里采用redis的setnx和set来充当分布式锁。本方案
读操作:读的时候判断【写锁】是否存在(根据业务时长和性能要求可以定义自旋次数),如果没有【写锁】,设置【读锁】,然后再次检查【写锁】是否存在(这一步很重要,防止设置读锁与此步之间有写线程刚好设置完【写锁】),然后执行查缓存、查库、设置缓存的操作。
因为可能有读请求结束后立即释放读锁会导致下一个可能已经设置读锁的线程与写线程之间并发执行,因此读锁采用过期时间自动释放。
写操作:先尝试获取【写锁】,获取到后,检查是否存在【读锁】,然后执行写操作
核心代码:
部分变量:
private static final String USER_DATA_READ_LOCK_KEY = "user_data_read_lock_%s";
private static final String USER_DATA_WRITE_LOCK_KEY = "user_data_write_lock_%s";
private static final Integer USER_LOCK_TRY_TIME = 2;//锁自旋次数,根据业务决定
private static final Integer USER_WRITE_LOCK_EXPIRE_TIME = 3;//写锁最长过期时间,秒。根据业务决定
private static final Integer INTERVAL_TIME_MILLI = 200;//自旋间隔,根据业务决定
private static final Integer USER_READ_LOCK_EXPIRE_TIME_MILLI = 200;//读锁最长过期时间,秒。根据业务决定
写操作:
/**
* 获取写锁
* @param lockKey
* @return true 获得锁 false没有获得
*/
public boolean tryUserDataWriteLock(String lockKey){
for(int i=0;i userData){
String lockKey = String.format(USER_DATA_WRITE_LOCK_KEY,userId);
boolean writeLock = tryUserDataWriteLock(lockKey);
if(!writeLock){
return Boolean.FALSE;
}
try{
boolean readLockExists = tryCheckUserDataReadLockExists(userId);
if(readLockExists){
return Boolean.FALSE;
}
//执行数据库的更新
//执行缓存的 更新/删除
return true;
}catch (Exception e){
log.error("执行用户数据更新异常",e)
}finally {
//释放写锁
redis.del(lockKey);
}
return false;
}
读操作:
/**
* 自旋检查用户写锁是否存在
* @param userId
* @return true 存在 false 不存在
*/
public boolean tryCheckUserDataWriteLockExists(String userId,int tryTime){
String lockKey = String.format(USER_DATA_WRITE_LOCK_KEY,userId);
for(int i=0;i queryUserDataConcurrent(String userId) throws Exception{
boolean readLockExists = tryCheckUserDataWriteLockExists(userId, USER_LOCK_TRY_TIME);
if(readLockExists){
throw new Exception("error occured when try check write lock:write lock unreleased after try time "+USER_LOCK_TRY_TIME);
}
if(setUserDataReadLock(userId)){
readLockExists = tryCheckUserDataWriteLockExists(userId, 1);
if(readLockExists){
throw new Exception("error occured when try check write lock:write lock unreleased after try time "+USER_LOCK_TRY_TIME);
}
return queryUserData(userId);
}
return null;
}