分布式环境下缓存与数据库的一致性方案

一、常见模式解析

先来看看常用的缓存+数据库模式(旁路模式)

1.读请求:

先读取缓存,如果缓存中不存在数据,查询数据库,查到数据后,设置缓存,未查到返回空。

该模式缺点:

在请求量较大的情况下,如果缓存命中不高,会导致大量缓存穿透

改进:

先读取缓存,如果缓存中不存在数据或者为空标志位,查询数据库,查到数据后,设置缓存,未查到在缓存中设置空标志位。

 

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<String> 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<String> 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.写请求,两种方式:

2.1先删除/更新缓存,再写库

2.2先写库,再更新/删除缓存

二、并发情况下的一致性问题

上述的旁路模式存在的问题:

在单线程或者并发下的理想情况:最终数据数据库内数据和缓存保持一致,后续用户请求到的都是更新后的正确数据。

 

并发情况下存在的问题:

 

如上图,无论是先删除/更新缓存还是先更新数据库,只要3号步骤在写线程所有步骤之后执行,都会导致缓存和数据库数据不一致,后续请求到的数据都是a=1,而不是最新更新的a=2

三、一致性的方案:

1.使用分布式读写锁

网上有通过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<USER_LOCK_TRY_TIME;i++){
        //该方法封装了 redis SET key value [EX seconds] [PX milliseconds] [NX|XX]
        String result = redis.set(lockKey,"1","NX","EX",USER_WRITE_LOCK_EXPIRE_TIME);
        if(StringUtil.isNotBlank(result)){
            //返回不为空,说明获得锁
            return true;
        }
        try {
            Thread.sleep(INTERVAL_TIME_MILLI);
        } catch (InterruptedException e) {
            log.error("锁自旋发生异常",e);
        }
    }
    return false;
}

/**
 * 自旋检查用户读锁是否存在
 * @param userId
 * @return true 存在 false 不存在
 */
public boolean tryCheckUserDataReadLockExists(String userId){
    String lockKey = String.format(USER_DATA_READ_LOCK_KEY,userId);

    for(int i=0;i<USER_LOCK_TRY_TIME;i++){
        if(!redis.exists(lockKey)){
            //不存在,返回false
            return false;
        }

        try {
            Thread.sleep(INTERVAL_TIME_MILLI);
        } catch (InterruptedException e) {
            log.error("锁自旋发生异常",e);
        }
    }
    return true;
}

/**
 * 更新用户数据
 * @param userId
 * @param userData
 * @return  true 更新成功 false 更新失败
 */
public Boolean updateUserData(String userId,Set<String> 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<tryTime;i++){
        if(!redis.exists(lockKey)){
            //不存在,返回false
            return false;
        }

        try {
            Thread.sleep(INTERVAL_TIME_MILLI);
        } catch (InterruptedException e) {
            log.error("锁自旋发生异常",e);
        }
    }
    return true;
}

/**
 * 设置用户的读锁
 * @param userId
 * @return true 存在 false 不存在
 */
public boolean setUserDataReadLock(String userId){
    String lockKey = String.format(USER_DATA_READ_LOCK_KEY,userId);
    String result = redis.set(lockKey, "1", "XX", "PX", USER_READ_LOCK_EXPIRE_TIME_MILLI);
    return result != null;
}

/**
 *
 * @param userId
 * @return
 * @throws RuntimeException
 */
public Set<String> 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;
}

2.数据版本号-解决方案

3.事务-解决方案

4.有效期(对数据实时性要求不高的场景)
(2/3/4和其他解决方案具体实现待续)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值