高并发下的缓存一致性,并发,穿透问题

缓存在高并发场景下的常见问题

缓存一致性问题

当数据时效性要求很高的时候,需要保证缓存中的数据与数据库中的保持一致,而且需要保证缓存节点和副本中的数据也要保持一致,不能出现差异现象。这样就比较依赖缓存的过期和更新策略。一般会在数据库发生更改的时候,主动更新缓存中的数据或者移除对应的缓存。

  1. 更新数据库成功—>更新缓存失败—数据不一致
  2. 更新缓存成功—>更新数据库失败—数据不一致
  3. 更新数据库成功—>淘汰缓存失败—数据不一致
  4. 淘汰缓存成功—>更新数据库失败—查询缓存丢失

缓存并发问题

缓存过期后将尝试从数据库获取数据,在单线程情况下是合理而又稳固的流程,但是在高并发情况下,有可能多个请求并发的从数据库中获取数据,对后端数据库造成极大的压力,甚至导致数据库崩溃。另外,当某个缓存的key被更新时,同时也有可能在被大量的请求获取,也同样导致数据一致性的问题。如何解决?一般我们会想到类似“锁”的机制,在缓存更新或者过期的情况下,先获取锁,在进行更新或者从数据库中获取数据后,再释放锁,需要一定的时间等待,就可以从缓存中继续获取数据

  1. 使用互斥锁(mutex key)
    只让一个线程构建缓存,其他线程构建缓存的线程执行完,重新从缓存获取数据就ojbk了
    单机直接用synchronized或者lock,分布式就用分布式锁(可以用memcache的add,redis的setnx,zookeeper的节点添加监听等等等…..)

    memcache伪代码如下

if (memcache.get(key) == null) {  
    // 3 min timeout to avoid mutex holder crash  
    if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {  
        value = db.get(key);  
        memcache.set(key, value);  
        memcache.delete(key_mutex);  
    } else {  
        sleep(50);  
        retry();  
    }  
}  

redis伪代码

String get(String key){
    String value = redis.get(key);
    if(value == null){
        if(redis.setnx(key_Mutex),"1"){
            redis.expire(key_mutex,3*60);//防止死锁
            value = db.get(key);
            redis.set(key,value);
            resdis.delete(key_Mutex);
        }else{
            Thread.sleep(50);
            get(key);
        }
    }
}
  1. 提前使用互斥锁
    在value内部设置1个超时值(timeout1), timeout1比实际的memcache timeout(timeout2)小。当从cache读取到timeout1发现它已经过期时候,马上延长timeout1并重新设置到cache。然后再从数据库加载数据并设置到cache中。伪代码如下
v = memcache.get(key);
if (v == null) {
    if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {
        value = db.get(key);
        memcache.set(key, value);
        memcache.delete(key_mutex);
    } else {
        sleep(50);
        retry();
    }
} else {
    if (v.timeout <= now()) {
        if (memcache.add(key_mutex, 3 * 60 * 1000) == true) {
            // extend the timeout for other threads
            v.timeout += 3 * 60 * 1000;
            memcache.set(key, v, KEY_TIMEOUT * 2);

            // load the latest value from db
            v = db.get(key);
            v.timeout = KEY_TIMEOUT;
            memcache.set(key, value, KEY_TIMEOUT * 2);
            memcache.delete(key_mutex);
        } else {
            sleep(50);
            retry();
        }
    }
}

上面两种方案
优点:避免cache失效时刻大量请求获取不到mutex并进行sleep
缺点:代码复杂性增大,会出现死锁和线程池阻塞等问题,因此一般场合用方案一也已经足够

  1. 永远不过期
    这里的“永远不过期”包含两层意思:
    (1) 从redis上看,没有设置过期时间,就不会出现热点key过期问题,也就是“物理”不过期。
    (2) 从功能上看,如果不过期,那不就成静态的了吗?所以我们把过期时间存在key对应的value里,如果发现要过期了,通过一个后台的异步线程进行缓存的构建,也就是“逻辑”过期,有一个问题是在异步构建缓存完成之前其他线程访问的是旧的数据
String get(final String key) {  
        V v = redis.get(key);  
        String value = v.getValue();  
        long timeout = v.getTimeout();  
        if (v.timeout <= System.currentTimeMillis()) {  
            // 异步更新后台异常执行  
            threadPool.execute(new Runnable() {  
                public void run() {  
                    String keyMutex = "mutex:" + key;  
                    if (redis.setnx(keyMutex, "1")) {  
                        // 3 min timeout to avoid mutex holder crash  
                        redis.expire(keyMutex, 3 * 60);  
                        String dbValue = db.get(key);  
                        redis.set(key, dbValue);  
                        redis.delete(keyMutex);  
                    }  
                }  
            });  
        }  
        return value;  
    }  
  1. 资源保护(尚未了解)

缓存穿透问题

场景:在高并发场景下,如果一个key被高并发访问,没有被命中,处于对容错性的考虑,会尝试去从后端数据库中获取,从而导致了大量请求到达数据库,而当该key对应的数据本身就是空的情况下,就导致数据库中并发地去执行很多不必要的查询操作,从而导致巨大冲击和压力
可以通过下面的几种常用方式来避免缓存问题

  1. 缓存空对象
    对查询结果为空的对象也进行缓存,如果是集合,可以缓存一个空的集合(非null),如果是缓存单个对象,可以通过字段标识来区分。这样避免请求穿透到后端数据库,同时,也需要保证缓存数据的时效性。适合命中不高,但可能被频繁更新的数据
    这里写图片描述
  2. 单独过滤处理
    对所有可能对应数据为空的key进行统一的存放,并在请求前做拦截,这样避免请求穿透到后端数据库。这种方式实现起来相对复杂,比较适合命中不高,但是更新不频繁的数据

总结:作为一个并发量较大的互联网应用,我们的目标有3个:

  1. 加快用户访问速度,提高用户体验。

  2. 降低后端负载,保证系统平稳。

  3. 保证数据“尽可能”及时更新(要不要完全一致,取决于业务,而不是技术。)

---接下来一篇将对 缓存雪崩 做个简单的总结

猜你喜欢

转载自blog.csdn.net/weixin_36708538/article/details/80338643