缓存穿透 缓存击穿 缓存雪崩

Scroll Down

缓存DB的使用姿势 Cache Aside Pattern(旁路缓存)

应用程序先从cache取数据,没有得到,则从数据库中取数据,成功后,放到缓存中。
命中应用程序从cache中取数据,取到后返回。
更新缓存先把数据存到数据库中,成功后,再让缓存失效。
WX20210120-122524@2x

分布式锁

锁的目的是确保在几个可能尝试做相同工作的节点中,只有一个请求真正做了这件事(至少一次只有一个请求过来)。获取锁之后去把数据写入共享存储系统,执行一些计算,调用一些外部API等等,在高分布式系统中上,使用锁无非就是有两个原因:为了效率或正确性
效率:使用锁可以避免不必要的重复工作(例如一些昂贵的计算)。
如果锁失败和两个请求做同样的的工作,结果是一个成本的增加(比如你最终支付二倍的钱甚至更多)或者不是设计金钱交易的通知之流的处理会多次通知
正确性:使用锁可以防止并发进程互相踩到对方的脚,从而破坏系统的状态。
如果锁失败,两个请求并发地处理同一块数据,那么结果将是文件损坏、数据丢失、永久不一致、这就好比新来的实习生给病人的药物剂量错误,直接导致病人变身超级撒亚人。

function writeData(filename, data) {
    var lock = lockService.acquireLock(filename);
    if (!lock) {
        throw 'Failed to acquire lock';
    }
    try {
        var file = storage.readFile(filename);
        var updated = updateContents(file, data);
        storage.writeFile(filename, updated);
    } finally {
        lock.release(lock);
    }
}

如何设计以一个分布式锁,首先一个分布式锁必须要保证能够超时自动释放,没有自动释放机制(锁所有者将无限期地持有它)的分布式锁基本上是无用的。如果持有锁的客户端崩溃,并且没有在短时间内恢复到完整状态,那么就会创建死锁。

private static final String LOCK_SUCCESS = "OK";
private static final Long RELEASE_SUCCESS = 1L;
private static final String SET_IF_NOT_EXIST = "NX";
private static final String SET_WITH_EXPIRE_TIME = "PX";

@Override
public String acquire() {
    try {
        // 获取锁的超时时间,超过这个时间则放弃获取锁
        long end = System.currentTimeMillis() + acquireTimeout;
        // 随机生成一个 value
        String requireToken = UUID.randomUUID().toString();
        while (System.currentTimeMillis() < end) {
            String result = jedis
                .set(lockKey, requireToken, SET_IF_NOT_EXIST, SET_WITH_EXPIRE_TIME, expireTime);
            if (LOCK_SUCCESS.equals(result)) {
                return requireToken;
            }
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }
    } catch (Exception e) {
        log.error("acquire lock due to error", e);
    }

    return null;
}

@Override
public boolean release(String identify) {
    if (identify == null) {
        return false;
    }

    String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
    Object result = new Object();
    try {
        result = jedis.eval(script, Collections.singletonList(lockKey),
            Collections.singletonList(identify));
        if (RELEASE_SUCCESS.equals(result)) {
            log.info("release lock success, requestToken:{}", identify);
            return true;
        }
    } catch (Exception e) {
        log.error("release lock due to error", e);
    } finally {
        if (jedis != null) {
            jedis.close();
        }
    }

    log.info("release lock failed, requestToken:{}, result:{}", identify, result);
    return false;
}

还有就是使用的场景内部 当拿到锁的逻辑的执行时间长超过了锁的过期时间,这样另一个请求也会去拿到锁去执行,这样引来的问题还是数据未严格的串行执行。对加锁超时场景要有合理的预估。否则只好后期人工干预数据了。还有就是在解锁的使用lua脚本去处理让value的比对和删除作为原子指令来处理。
还有就是当一种情况当A请求拿到数据执行此时发生了fullGC,stw之后 锁超市 释放此时恢复A继续工作执行物理逻辑,B请求拿到锁之后开始执行,执行完毕之后释放锁,然后A继续执行 次数数据就会被更改这也是分布式由于GC导致的问题。
unsafe-lock
分布式系统大师Martin Kleppmann和Redis之父antirez关于分布式锁的讨论
fencing-tokens

Martin给出的解决方案为:fencing token一个单调递增的数字(如图)

antirez反驳认为,如果你能保证分布式锁的递增token,那么便不需要锁了,直接使用CAS乐观锁便可解决资源共享问题;如果增加了fencingToken机制,由于原子性和锁的复杂性则更加难处理。

针对当个Redis Server宕机的情况发生的,提供了一种RedLock的当时来实现 具体的实现呢则是多台redis实例上去获取锁,只有超过半数+1的获取到锁就执行临界区域的代码,解锁则是一个一个去解锁。

缓存穿透

所谓缓存穿透一般来将是缓存中未命中的值,而起这些无效的请求一直 请求,不同的访问缓存和数据库,这其实就好比是恶意攻击了,所以在缓存使用设计上就要去避免这种行为,当缓存和数据库均不存在数据的时候,在会写缓存的时候设置短暂过期时间一个null的值来规避这种恶意请求。不过这些恶意的校验需要先挡在上游也就是在拦截器或者过滤器阶段 排除一些恶意数据,

缓存击穿

缓存击穿的含义顾名思义,就是没有用了缓存的的便利,反而还直接走数据库,还增加了更新缓存的消耗。一般来讲是key的过期时间设置的太短。

缓存雪崩

对与缓存雪崩来讲,雪崩有可能是缓存大量的过期,导致所有的请求全部走到数据库导致 数据库链接被打满,然后拖慢了应用的接口响应速度,导致调用方只能降级,短时间的数据库访问增高,导致内存使用量增高,CPU使用率身高,磁盘堵塞严重,最后垮掉。正所谓冰冻三尺非一日之寒,应用雪崩宕机在各个环节都有责任,设置随机过期时间,将热点数据打散 megt多个key

缓存DB一致性解决方案

在旁路缓存使用方式中,当缓存和数据库的数据出现不一致我们到底是更新缓存还是淘汰缓存呢,
更新缓存:数据不但写入数据库,还会写入缓存;优点:缓存不会增加一次miss,命中率高

淘汰缓存:数据只会写入数据库,不会写入缓存,只会把数据淘汰掉;优点:简单,缺点会增加一次miss。

比如简单设置值的我认为更新缓存即可,如果是复杂计算的那种结果,淘汰缓存更为合适。

还有就是当更新数据的时候先更新数据库还是先淘汰缓存,有点先有鸡先有蛋的味道了。

  1. 先淘汰缓存再更新数据库
    先淘汰缓存,再写数据库:第一步淘汰缓存成功,第二步写数据库失败,则会增加一次miss。
  2. 先更新数据库再删除缓存
    先写数据库,再淘汰缓存:第一步写数据库操作成功,第二步淘汰缓存失败,则会出现DB中是新数据,Cache中是旧数据,数据不一致。

先淘汰缓存在更新数据库,所以数据的最终一致性是可以得到有效的保证的 但是针对并行读写,还是需要引入分布式锁来将并行转为串行执行。
并行更新场景中,在淘汰缓存的时候先去获取分布式锁,然后再去更新数据库 最后释放锁,在并行读取的场景中,发现缓存不存在时,就去获取分布式锁,去访问数据库但后去更新缓存,然后释放锁。