分布式缓存问题
# 分布式缓存问题
分布式系统的特点是高性能、高并发和高可用。分布式缓存往往在高并发的场景下遇到如下的一些问题。
# 缓存穿透
一般的缓存系统,都是按照key去缓存查询,如果不存在对应的value,就会去后台数据库(MySQL等)查找。
缓存穿透是指在高并发下查询key不存在的数据,会穿过缓存查询数据库,导致数据库压力过大而宕机。简单来说就是客户端查询的数据在我们业务库中根本就不存在。
解决方案:
对查询结果为空的情况也进行缓存,缓存时间(ttl)设置短一点。但是这个方案有个问题:缓存太多空值占用了更多的空间。
对于第一点的问题可以使用布隆过滤器。在缓存之前在加一层布隆过滤器,在查询的时候先去布隆过滤器查询 key 是否存在,如果不存在就直接返回,存在再查缓存和DB。注意,布隆过滤器可以在应用程序中集成,也可以在RedisClient通过模块加载,但布隆过滤器是不能删除数据,这个时候可以考察使用布谷鸟过滤器。
布隆过滤器(Bloom Filter)是1970年由布隆提出的。它实际上是一个很长的二进制向量和一系列随机hash映射函数。
布隆过滤器可以用于检索一个元素是否在一个集合中。它的优点是空间效率和查询时间都远远超过一般的算法。
布隆过滤器的原理是,当一个元素被加入集合时,通过K个Hash函数将这个元素映射成一个数组中的K个点,把它们置为1。检索时,我们只要看看这些点是不是都是1就(大约)知道集合中有没有它了:如果这些点有任何一个0,则被检元素一定不在;如果都是1,则被检元素很可能在。这就是布隆过滤器的基本思想。
# 缓存击穿
缓存击穿是指一些设置了过期时间的热点key过期然后被超高并发地访问,造成并发访问数据库。
解决方案:
用分布式锁控制访问的线程。分布式锁主要有redis实现和zookeeper实现,这里仅讨论redis实现的分布式锁。
一般使用redis的setnx互斥锁先进行判断,这样其他线程就处于等待状态,保证不会有大并发操作去操作数据库。
分布式锁的实现通常需要考虑两个问题:
- 如果第一个获取锁的失败了怎么处理?设置锁的过期时间
- 锁超时了怎么办?使用多线程解决,一个线程取DB,一个线程监控是否取回来并更新锁时间。
这些问题在分布式锁的实现方案都有考虑到,这里简单做拓展。
不设超时时间,使用
volatile-lru
回收策略,但会造成写一致问题。当数据库数据发生更新时,缓存中的数据不会及时更新,这样会造成数据库中的数据与缓存中的数据的不一致,应用会从缓存中读取到脏数据。可采用延时双删策略处理。
# 缓存雪崩
当缓存服务器重启或者大量缓存集中在某一个时间段失效,这样在失效的时候,也会给我们的数据库带来很大压力,甚至有可能瞬间就把DB给压垮!
其实,缓存雪崩背后是缓存击穿。缓存击穿通常针对某一key缓存,而缓存雪崩针对大片的key。无论如何,当这种突然间大量的热点key失效了或redis重启,大量访问数据库,数据库就有可能崩溃!
解决方案:
- 参考缓存击穿解决方案。
- 随机设置过期时间。目的是将key的失效期分散开,减少雪崩的概率。
- 如果某些业务一定要求零点更新缓存的话,考虑在业务层进行零点延时。
- 设置二级缓存(数据不一定一致)
# 缓存与数据库一致性
缓存和DB的数据不一致的根源是缓存更新策略与CAP定理。
# 缓存更新策略
- 利用Redis的缓存淘汰策略被动更新 LRU 、LFU
- 利用TTL被动更新
- 在更新数据库时主动更新 (延时双删)
- 异步更新,定时任务,数据不保证实时一致
策略 | 一致性 | 维护成本 |
---|---|---|
Redis的缓存淘汰策略被动更新 | 最差 | 最低 |
Redis TTL被动更新 | 较差 | 较低 |
在更新数据库时主动更新 | 较强 | 最高 |
强一致性很难,追求最终一致性。互联网业务数据处理的特点是高吞吐量、低延迟,服务追求高可用,数据敏感性低于金融业。
# 延时双删
无论是先更新数据库再更新缓存或者先更新缓存再更新数据库,本质上不是一个原子操作,所以时序控制不可行。
通常采用延时双删的方式,达到最终一致性。
先更新数据库同时删除缓存项(key),等读的时候再填充缓存
2秒后再删除一次缓存项(key)。
如果删除失败,可以考虑设置缓存过期时间(ttl)或将缓存删除失败记录到日志中,利用脚本提取失败记录再次删除。
升级方案:通过数据库的binlog来异步淘汰key,利用工具(canal)将binlog日志采集发送到MQ中,然后通过ACK机制确认处理删除缓存。
# 数据并发竞争
这里的并发竞争指的是多个redis的client同时set同一个key引起的并发问题。
例如,多客户端(Jedis)同时并发写一个key,一个key的值是1,本来按顺序修改为2,3,4,最后是4,但是顺序变成了4,3,2,最后变成了2。
# 方案一:分布式锁+时间戳
针对并发竞争的场景,主要是准备一个分布式锁,大家去抢锁,抢到锁就做set操作。加锁的目的实际上就是把并行读写改成串行读写的方式,从而来避免资源竞争。整体方案如下:
Redis分布式锁主要原理是redis的
setnx()
函数。
此外,由于要求执行的“有序性”,还要结合时间戳,避免执行的覆盖。例如:
系统A key 1 {ValueA 15:00}
系统B key 1 {ValueB 15:05}
假设系统B先抢到锁,将key1设置为{ValueB 15:05},接下来系统A抢到锁,发现自己的key1的时间戳早于缓存中的时间戳(15:00 < 15:05),那就不做set操作了。
# 方案二:消息队列
在并发量过大的情况下,可以通过消息中间件进行处理,把并行读写进行串行化。把Redis的set操作放在队列中使其串行化,必须一个一个执行。
# Hot Key
Hot Key指突然有大量的请求(几十万)访问某个Redis某个key时,由于流量集中达到网络上限,从而导致这个redis的服务器宕机,造成缓存击穿。接下来对这个key的访问将直接访问数据库造成数据库崩溃,或者访问数据库回填Redis,再访问Redis,继续崩溃。
像微博热搜、百度热搜之类的。
# 如何发现热key
- 预估热key,比如秒杀的商品、火爆的新闻等
- 在客户端进行统计,实现简单,加一行代码即可
- 如果是Proxy,比如Codis,可以在Proxy端收集
- 利用Redis自带的命令,monitor、hotkeys。但是执行缓慢(不要用)
- 利用基于大数据领域的流式计算技术来进行实时数据访问次数的统计,比如 Storm、SparkStreaming、Flink,这些技术都是可以的,发现热点数据后可以写到zookeeper中。
# 如何处理热Key
变分布式缓存为本地缓存:发现热key后,把缓存数据取出后,直接加载到本地缓存中。可以采用Ehcache、Guava Cache,这样系统在访问热key数据时就可以直接访问自己的缓存了(数据不要求实时一致)。
在每个Redis主节点上备份热key数据,这样在读取时可以采用随机读取的方式,将访问压力负载到每个Redis上。
利用对热点数据访问的限流熔断保护措施。例如,每个系统实例每秒最多请求缓存集群读操作不超过 400 次,一超过就可以熔断,不让请求缓存集群。直接返回一个空白信息,然后用户稍后会自行再次重新刷新页面之类的(首页不行,系统不友好)。
通过系统层自己直接加限流熔断保护措施,可以很好的保护后面的缓存集群。
# Big Key
Big key指的是存储的值(Value)非常大,常见场景:
- 热门话题下的讨论
- 大V的粉丝列表
- 序列化后的图片
- 没有及时处理的垃圾数据
- .....
# Big Key的影响
- Big key会大量占用内存,在集群中无法均衡
- Redis的性能下降,主从复制异常
- 在主动删除或过期删除时会操作时间过长而引起服务阻塞
# 如何发现Big Key
redis-cli --bigkeys
命令。可以找到某个实例5种数据类型(String、hash、list、set、zset)的最大key。但如果Redis 的key比较多,执行该命令会比较慢获取生产Redis的RDB文件,通过
rdbtools
分析rdb生成csv文件,再导入MySQL或其他数据库中进行分析统计,根据size_in_bytes
统计big key。
# 如何处理Big Key
优化big key的原则就是string减少字符串长度,list、hash、set、zset等减少成员数
string类型的big key,尽量不要存入Redis中,可以使用文档型数据库MongoDB或缓存到CDN上。如果必须用Redis存储,最好单独存储,不要和其他的key一起存储,采用一主一从或多从。
单个简单的key存储的value很大,可以尝试将对象分拆成几个key-value, 使用mget获取值,这样分拆的意义在于分拆单次操作的压力,将操作压力平摊到多次操作中,降低对redis的IO影响。
hash, set,zset,list 中存储过多的元素,可以将这些元素分拆(常见)。以hash类型为例,对于field过多的场景,可以根据field进行hash取模,生成一个新的key。例如原来的hashkey如下:
hash_key:{filed1:value, filed2:value, filed3:value ...}
可以hash取模拆分,取模后便将原先单个key分成多个key,每个key filed个数为原先的1/N。拆分后key:value形式:
hash_key:1:{filed1:value} hash_key:2:{filed2:value} hash_key:3:{filed3:value} ...
删除大key时不要使用del,因为del是阻塞命令,删除时会影响性能。建议使用
lazy delete k1 k2 ...
(unlink命令)删除指定的key(s),若key不存在则该key被跳过。相比会产生阻塞的DEL,该命令会在另一个线程中回收内存,因此它是非阻塞的。 这也是该命令名字的由来:仅将keys从key空间中删除,真正的数据删除会在后续异步操作。
阿里Redis也有删除bigkey的说明,可以参考。Hash删除: hscan + hdel、List删除: ltrim、Set删除: sscan + srem、SortedSet删除: zscan + zrem。