缓存的问题以及双写不一致分析
缓存的问题以及双写不一致分析
缓存模型以及带来的问题
为了保障数据库的高可用和系统的高性能,我们一般会在程序接口与数据库中间在加一层缓存。
程序请求流程如下:
请求达到redis分为两种情况:
- 如果redis中有对应的数据存在,则直接从redis返回数据。
- 如果redis中对应的数据不存在,那么就会再去DB中去查,将查出来的结果返回给用户同时将结果缓存到redis中。
那么针对这样的架构设计会带来一些问题,例如缓存穿透、缓存击穿、缓存雪崩,针对这些问题业界也有一些解决方案。
缓存穿透
缓存穿透,即请求在缓存中查不到数据,在数据库中也查不到。可见如果都是这样的查询请求情况会造成数据库大查询压力过大。
解决方案:
- 对参数进行合法校验,例如是userid = -1的请求在程序层面直接过滤掉,这可以有效的拦截大部分不合法的请求。
- 将数据库中没有查询到结果的数据也写入到缓存,即key=-1,value=null。这时要注意为了防止redis被无效的key占满,这一类缓存的有效期要设置得短一点。
- 引入布隆过滤器
- 先将数据库中的key存储在布隆过滤器的bitmap中
- 然后每次客户端查询数据时先访问redis,判断数据在redis中是否存在。
- 如果Redis中不存在该数据,则通过布隆过滤器判断数据是否在数据库中。
- 如果布隆过滤器告诉我们不存在,则直接返回null给客户端即可,避免了查询数据库的操作。
- 如果布隆过滤器告诉我们该Key极有可能存在,那么将查询下推到数据库。
- 要注意布隆过滤器存在一定的误判率,并且布隆过滤器只能加数据,不能减数据。虽然不能完全避免数据穿透的现象,但已经可以将99.99%的穿透查询给屏蔽在Redis层了,极大的降低了底层数据库的压力,减少了资源浪费。
布隆过滤器简介
布隆过滤器实现原理
布隆过滤器的核心实现是一个超大的位数组(固定大小的位图bitmap)和一系列的哈希映射函数。
布隆过滤器首先对数组里的位进行初始化,将里面每个位置都置为0。
存入数据
存入数据时对数据进行hash后将其hash值与bitmap进行映射,如果hash数中有位数为1的话就将bitmap相应位数置1,如果bitmap位数值本身就为1的话就不管。
查询数据
查询时间hash后的hash数与bitmap进行比较,如果hash数中为1的位置在bitmap中不为1则证明该数不存在。
布隆过滤器的应用
- 数据库防止穿库,Google Bigtable、HBase 和 Cassandra 以及 Postgresql 使用BloomFilter来减少不存在的行或列的磁盘查找。避免代价高昂的磁盘查找会大大提高数据库查询操作的性能。
- 业务场景中判断用户是否阅读过某视频或文章,比如抖音或头条,当然会导致一定的误判,但不会让用户看到重复的内容。
- 缓存宕机、缓存击穿场景,一般判断用户是否在缓存中,如果在则直接返回结果,不在则查询db,如果来一波冷数据,会导致缓存大量击穿,造成雪崩效应,这时候可以用布隆过滤器当缓存的索引,只有在布隆过滤器中,才去查询缓存,如果没查询到,则穿透到db。如果不在布隆器中,则直接返回。
- WEB拦截器,如果相同请求则拦截,防止重复被攻击。用户第一次请求,将请求参数放入布隆过滤器中,当第二次请求时,先判断请求参数是否被布隆过滤器命中。可以提高缓存命中率。Squid 网页代理缓存服务器在 cache digests 中就使用了布隆过滤器。Google Chrome浏览器使用了布隆过滤器加速安全浏览服务
- Venti 文档存储系统也采用布隆过滤器来检测先前存储的数据。
- SPIN 模型检测器也使用布隆过滤器在大规模验证问题时跟踪可达状态空间。
缓存击穿
缓存击穿,即缓存中没有,但是数据库中有。一般是出现在缓存数据初始化和key过期了的情况。当热点数据key从缓存内失效时,大量访问同时请求这个数据,就会将查询下沉到DB层,此时DB层的负载压力会骤增。
解决方案:
设置热点缓存用不过期或者延长过期时间。这时要注意在value当中包含一个逻辑上的过期时间,然后另起一个线程定期重建这些缓存。
在查询DB的时候要防止并发,设置一个互斥锁,只让一个请求通过,只有一个请求去数据库中拉取数据,取完数据后将数据缓存到redis,避免其他大量请求同时穿过Redis访问底层数据库。
在使用互斥锁时要避免出现死锁或者锁过期的情况:
- lua脚本或事务将获取锁和设置锁过期时间作为一个原子性操作,以避免出现某个客户端获取锁之后宕机导致的死锁问题。
- 另起一个线程监控获取锁的线程的查询状态,快到说过期时间还没查询结束则延长锁的过期时间,避免多次查询锁过期造成计算资源的浪费。
缓存雪崩
缓存雪崩就是缓存大面积过期,导致请求都被转发到DB。
解决方案
- 把缓存的失效时间分散开了,例如在原本同一失效时间的基础上增加一个随机值。
- 延长热点key的过期时间或者设置永不过期
- 对于一定要在固定时间让key失效的场景(例如每日12点准时更新所有最新排名),可以在固定的失效时间时在接口服务端设置随机延时,将请求的时间打散,让一部分查询先将数据缓存起来
如何保证Redis与数据库的数据一致
当我们对数据进行修改的时候有个问题,到底是先删缓存(先删了缓存之后下次访问再从数据库中捞数据然后在写到缓存里面)还是先写数据库(先删数据库然后将缓存更新)
先删缓存再写数据库
存在问题:
在高并发场景下,当第一个线程删除了缓存,还没来得及写数据,第二个线程来读取数据,会发现缓存中的数据为空,那就会去读数据库中的数据(读出来的是旧值),读完之后,把读到的结果写入缓存(此时,第一个线程已经把新的值写到缓存里面了),这样缓存中的值就会被覆盖为旧值脏数据。
产生问题的根本原因:这两个数据修改的操作不是原子性的。
解决方案:
- 先操作缓存,但是不删除缓存,将缓存修改为一个特殊值(-999),客户端读缓存时,发现是默认值就休眠一小会再去查redis。(特殊值对业务有侵入;在高并发的场景下,如果同一个数据一瞬间改动很频繁的话,休眠时间可能会多次重复,对性能有影响)
- 延时双删。先删除缓存,然后在写数据库,休眠一段时间,再次删除缓存。(如果数据写操作比较频繁同样会导致存在脏数据的问题)
结论:
所以在先删除缓存再写数据库这种设计要求写操作不能太频繁,允许高并发的读操作。
先写数据库再删缓存
存在问题:
如果数据库写完了之后,缓存删除失败,数据就会不一致。
解决方案:
- 给缓存设置一个过期时间,当一个缓存过期了,就会去数据库中重读数据(过期时间内缓存就不会更新)
- 引入MQ保证原子操作,有两个消费者,一个操作redis,一个操作MQ,当删除失败就利用mq的重试机制保证最终一致性。(在mq重试机制内缓存还是没有更新)
- 将热点数据缓存设置为不过期,但是在value中写入一个逻辑上的过期时间,另外起一个后台线程扫描这些key,对于逻辑上已过期的缓存,进行删除。
结论:
所以先写数据库再删除缓存方案只能保证redis与mysql在一定时间内的最终一致性。