一.Redis的应用和解决方案
1.Redis的性能总结
2.Redis缓存的相关问题
3.数据库和缓存的一致性问题
1.Redis的性能总结
首先需要判断出Redis是否真的慢。可以通过基准性能测试得出Redis的平均响应延迟和最大响应延迟。如果当前的响应延迟大于基准性能测试结果的两倍以上则可认为Redis慢了。如果真的发现Redis慢了,则可以通过以下角度进行分析判断。
(1)CPU角度之使用复杂度过高的命令
原因:复杂度高的命令,执行时耗费CPU,一次返回客户端数据过多,网络传输耗时,阻塞后面请求。解决:不使用复杂度高的命令、集合中的聚合查询放在客户端做、一次查询数据尽量少、全量数据分批查、不要为了追求低内存而过度放宽压缩列表(O(N)~O(N^2))的使用条件。
(2)CPU角度之绑定CPU
原因:子进程继承父进程CPU使用偏好,子进程数据持久化期间,与父进程发生CPU争抢。解决:主进程、后台子线程、后台RDB进程、AOF重写进程,分别绑定固定的CPU核心。
(3)内存角度之操作bigkey
现象:slowlog出现set/del等简单命令,实例中存储了bigkey。原因:写入bigkey分配内存耗时久,删除bigkey释放内存耗时久。解决:不存储bigkey、使用unlink代替del可以把释放内存的操作交给后台线程执行、开启lazy-free机制,这样执行bigkey的del操作时也会把释放内存的操作交给后台线程。
(4)内存角度之集中过期
现象:整点发生延迟、间隔固定时间发生延迟,info中expired_keys短期突增。原因:清理过期key在主线程执行、key集中过期增加了清理负担,过期bigkey延迟更明显。解决:排查业务时是否使用expired/pexpired命令,过期增加一个随机时间、降低集中过期的压力,开启lazy-free机制。运维监控info中expired_keys指标,短期内突增及时报警。
(5)内存角度之实例内存达到上限
现象:实例内存超过mxmemory之后写请求变慢,info中evicted_keys短期突增。原因:淘汰数据在主线程中执行、LRU淘汰数据也会消耗时间、写OPS越大延迟越明显、淘汰bigkey延迟越明显。解决:避免存储bigkey,视业务更换淘汰策略(随机比LRU快),拆分实例分摊淘汰数据的压力,开启lazy-free机制,运维监控info中evicted_keys指标,短期内突增及时报警。
(6)内存角度之fork操作耗时严重
现象:延迟只发生在这3个时候:后台定时RDB、后台AOF重写、主从全量同步。原因:使用fork调用会创建子进程,fork拷贝内存页表耗费时间,完成前会阻塞主进程,虚拟机执行耗时更久。解决:实例内存10GB以下,优化RDB备份策略如低峰期执行RDB,视情况关闭AOF和AOF重写,Redis不部署在虚拟机上,调大repl-backlog-size降低全量同步的概率。
(7)内存角度之开启内存大页
现象:子进程持久化数据期间,主进程写请求变慢(写时复制)。原因:内存大页会以2MB为单位向操作系统申请内存,耗时久。内存大页机制是为了降低申请内存次数,Redis对性能和延迟敏感,希望申请内存的时候耗时短。解决:关闭内存大页。
(8)内存角度之使用swap
现象:所有请求变慢,延迟达到上百毫秒甚至秒级。原因:内存数据更换到磁盘,访问磁盘延迟高。解决:增加内存,重启实例释放swap,让实例重新使用内存,并进行swap监控报警。
(9)内存角度之碎片整理
现象:延迟只发生在自动碎片整理期间。原因:碎片整理在主线程执行。解决:关闭碎片自动整理,合理配置碎片整理参数。
(10)内存角度之从库加载大的RDB文件
主库实例大小控制在2~4GB,以免主从复制时,从库因加载大的RDB文件而阻塞。
(11)磁盘角度
现象:磁盘IO负载高,AOF重写期间延迟更明显。原因:开启了AOF,AOF刷盘always策略加重主线程写负担。AOF刷盘everysec策略时,如果磁盘负载高,write和fsync系统调用会发生阻塞。解决:升级SSD磁盘,不和高磁盘负载的服务部署在一起(如存储服务、消息队列服务),重写AOF时不做fsync操作,高流量写入场景不开启AOF。
(12)网络角度
现象:稳定运行的实例,突然开始变慢,现象持续。原因:网络层存在数据传输延迟、丢包。解决:若是正常的业务大流量访问,需及时扩容或迁移实例,否则排查流量大的实例。
2.Redis缓存的相关问题
(1)缓存穿透问题
缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求。解决方法如下:
方法一:缓存空对象。适用于数据频繁变化,实时性要求高的场景。缓存层要存更多空对象的键,这些键一般设置一个较短的过期时间。如果存储层添加了空对象的数据,为避免缓存层和存储层出现不一致,需要主动清除空对象。
方法二:布隆过滤器拦截。适用于数据相对固定,实时性要求低的场景。虽然代码维护比较复杂,但是缓存空间占用比较少。
三.请求入口检查合法性
(2)缓存击穿问题
缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期)。这时由于并发用户特别多,同时读缓存没读到数据,所以需要同时去数据库去取数据。从而引起数据库压力瞬间增大,造成过大压力。
缓存击穿的解决方法:问题的关键是产生了并发,因此需要限制并发访问数据库,可考虑加锁。由于Redis是单进程单实例,可利用setnx()的特性来实现简单的分布式锁。
(3)缓存雪崩问题
缓存雪崩是指缓存中数据大批量到过期时间,而查询量巨大,引起数据库压力过大甚至宕机。和缓存击穿不同的是,缓存击穿是并发查同一条数据,缓存雪崩是不同数据都过期,很多数据都查不到而查数据库。缓存雪崩的解决方法如下:
方法一:过期时间加上随机数,避免同时过期
方法二:服务降级、服务熔断、请求限流
方法三:主从集群保护缓存高可用
方法四:提前演练
(4)缓存无底洞问题
缓存无底洞问题是指客户端一次批量操作涉及多次网络,节点越多耗时越大,由于网络连接数变多,对节点性能有影响。所以缓存无底洞问题其实就是如何在更多节点的分布式缓存中进行高效的批量操作。缓存无底洞的解决方法如下:
方法一:串行命令,简单、少量的key,性能可以满足,但大量的key请求会导致延迟严重
方法二:串行IO,简单、少量节点,性能可以满足,但大量的节点也会导致延迟严重
方法三:并行IO,复杂,多线程维护成本高
方法四:hash_tag,性能最高、维护成本高、数据易倾斜
(5)布隆过滤器介绍
如何判断给定的元素是否存在给定的集合中?如果该集合是已经排序的,那么可以用二分查找来实现。但是,如果集合的数据量庞大到一定程度,大部分熟知的算法不再有什么用了。即使可以使用,但是机器内存也不允许,这时就可以使用布隆过滤器。
步骤一:首先需要有一个长度为n的比特数组,开始时将这个比特数组里所有的元素都初始化为0:00000000000000000000,比如这个比特数组的n为20。
步骤二:然后选取k个哈希函数,这k个哈希函数产生的结果的值的范围在0到n-1之间。接着对每个要添加进集合的对象进行哈希运算,然后将哈希计算结果作为数组的索引。将索引位置的比特位设置为1(不管该比特位原先为0还是为1)。
比如选取三个哈希函数,对象A计算出的哈希值分别为0、5、7,那么比特数组就为:10000101000000000000。对象B计算出的哈希值分别为2、8、13,那么添加B后的比特数组为:10100101100001000000。对象C计算出的哈希值分别为0、4、7,由于对象C的第一个和第三个哈希函数的值与对象A相同了,所以只需添加第二个哈希函数的值即可:10101101100001000000。
步骤三:现在Bloom Filter里已经有3个元素了,下面判断某元素X是否在该集合中:对元素X采用相同的三个哈希函数哈希,然后以这三个哈希值为索引去比特数组里找。如果三个索引位置的比特位都为1就认为该元素在集合中,否则不是。
特征一:如果该元素真的在集合中,那么Bloom Filter的exists方法肯定会返回true,这就是Bloom Filter不会漏报的特性。
特征二:如果该元素不在集合中,那么Bloom Filter的exists方法也有可能返回true。因为不同的元素经过哈希之后哈希值可能发生碰撞,所以Bloom Filter有可能误报,但是误报的几率不高,小于1%。
根据Bloom Filter的特征可以看到不是所有的场景都可以用的,只有在一些能容许少量的误报的情况下使用才行。该算法用很低的误报率却换来了大量的存储空间,实在是是一个很巧妙的算法。
3.数据库和缓存的一致性问题
(1)关于缓存和数据库一致性的相关问题
到底是更新缓存还是删缓存?到底选择先更新数据库再删除缓存,还是先删除缓存再更新数据库?为什么要引入消息队列保证一致性?延迟双删会有什么问题?到底要不要用?
(2)最简单直接的缓存方案
最简单直接的方案是全量数据刷到缓存中:首先,数据库的数据全量刷入缓存(不设置失效时间)。然后,写请求只更新数据库不更新缓存。最后,启动一个定时任务把数据库的数据更新到缓存。
这个方案的优点是:所有读请求都可以直接命中缓存,不需要再查数据库,性能非常高。但缺点也很明显,有2个问题:问题一.缓存利用率低:不经常访问的数据一直留在缓存。问题二.数据不一致:因为是定时刷新缓存,所以缓存和数据库存在不一致(取决于定时任务的执行频率)。
所以这种方案一般更适合业务体量小,且对数据一致性要求不高的业务场景。但是如果业务的体量很大,那么如何解决这两个问题呢?
(3)缓存利用率和一致性问题
问题一:如何提高缓存利用率
想要缓存利用率最大化,很容易想到的方案是缓存中只保留最近访问的热数据。
具体可以这样优化:首先,写请求依旧只写数据库。然后,读请求先读缓存,如果缓存不存在,则从数据库读取并重建缓存。接着,同时写入缓存中的数据都设置失效时间。
这样缓存中不经常访问的数据,随着时间的推移,都会逐渐过期淘汰掉。最终缓存中保留的都是经常被访问的热数据,缓存利用率得以最大化。
问题二:如何保证数据一致
要想保证缓存和数据库实时一致,那就不能再用定时任务刷新缓存了。所以当数据发生更新时,不仅要操作数据库,还要一并操作缓存。具体操作就是,修改一条数据时,不仅要更新数据库,也要连带缓存也一起更新。
但数据库和缓存都更新,又存在先后问题,那么对应的方案就有两个:
方案一:先更新缓存,后更新数据库
方案二:先更新数据库,后更新缓存
(4)异常引起的一致性问题
这两个方案哪个方案更好呢?先不考虑并发问题,正常情况下,无论谁先谁后,都可以让两者保持一致,现在重点考虑异常情况。因为操作分为两步,那么就很有可能发生第一步成功、第二步失败的情况。
情况一:先更新缓存成功,后更新数据库失败
如果缓存更新成功了,但数据库更新失败,那么此时缓存中是最新值,但数据库中是旧值。虽然此时读请求可以命中缓存,拿到正确的值,但一旦缓存失效,就会从数据库中读取到旧值,重建缓存也是这个旧值。这时用户会发现自己之前修改的数据又变回去了,对业务造成影响。
情况二:先更新数据库成功,后更新缓存失败
如果数据库更新成功了,但缓存更新失败,那么此时数据库中是最新值,缓存中是旧值。之后的读请求读到的都是旧数据,只有当缓存失效后,才能从数据库中得到正确的值。这时用户会发现,自己刚刚修改了数据,但却看不到变更,一段时间过后,数据才变更过来,对业务也会有影响。
可见,无论谁先谁后,但凡后者发生异常,就会对业务造成影响,那么应该怎么解决这个问题呢?一般会通过重试进行解决。其实除了操作失败的问题,还有并发的场景也会影响数据的一致性。
(5)并发引起的一致性问题
假设采用先更新数据库,再更新缓存的方案,并且两步都成功执行,如果存在并发,那么情况会是怎样?
有线程A和线程B两个线程,需要更新同一条数据,并发情况下会发生如下的场景:
步骤一:线程A更新数据库x = 1
步骤二:线程B更新数据库x = 2
步骤三:线程B更新缓存x = 2
步骤四:线程A更新缓存x = 1
最终x的值在缓存中是1,在数据库中是2,发生了不一致。也就是说,线程A虽然先于线程B发生,但线程B操作数据库和缓存的时间,却要比线程A的时间短,于是执行时序发生错乱,最终这条数据的结果是不符合预期的。
同样地,采用先更新缓存,再更新数据库的方案,也会发生类似的场景:
步骤一:线程A更新缓存x = 1
步骤二:线程B更新缓存x = 2
步骤三:线程B更新数据库x = 2
步骤四:线程A更新数据库x = 1
除此之外,如果从缓存利用率的角度来评估更新缓存的方案,也是不太推荐的。因为每次数据发生变更都更新缓存,但缓存中的数据不一定会被马上读取,这会导致缓存中存放了很多不常访问的数据,浪费缓存资源。而且很多情况下,写到缓存中的值,并不是与数据库中的值一一对应的。很有可能是先查询数据库,再经过一系列计算得出一个值,然后才把这个值才写到缓存中。
由此可见,这种更新数据库 + 更新缓存的方案,不仅缓存利用率不高,还会造成机器性能浪费。所以需要考虑另外一种方案:删除缓存。
(6)删除缓存可以保证一致性吗
删除缓存对应的方案也有两种:
方案一:先删除缓存,后更新数据库
方案二:先更新数据库,后删除缓存
经过前面的分析得知,但凡第二步操作失败,都会导致数据不一致。这里不再详述异常情况下的具体场景,而是重点来看并发情况下的问题。
如果采用方案一:先删除缓存,后更新数据库。有2个线程要并发读写数据,可能会发生如下情况:
步骤一:线程A要更新x = 2(原值x = 1)
步骤二:线程A先删除缓存
步骤三:线程B读缓存,发现不存在,从数据库中读取到旧值(x = 1)
步骤四:线程A将新值写入数据库(x = 2)
步骤五:线程B将旧值写入缓存(x = 1)
最终x的值在缓存中是1(旧值),在数据库中是2(新值),发生不一致。可见,先删除缓存,后更新数据库,当发生读+写并发时,还是会出现数据不一致。
如果采用方案二:先更新数据库,后删除缓存。有2个线程要并发读写数据,可能会发生以下情况:
步骤一:缓存中x不存在(数据库x = 1)
步骤二:线程A读取数据库,得到旧值(x = 1)
步骤三:线程B更新数据库(x = 2)
步骤四:线程B删除缓存
步骤五:线程A将旧值写入缓存(x = 1)
最终x的值在缓存中是1(旧值),在数据库中是2(新值),发生不一致。这种情况理论上是可能发生的,但实际发生的概率很低,因为必须满足3个条件:缓存刚好已失效、读请求 + 写请求并发、写比读的时间更短。
也就是更新数据库 + 删除缓存的时间(步骤三和步骤四),要比读数据库 + 写缓存时间(步骤二和步骤五)短。其实条件三发生的概率是非常低的,因为写数据库一般会先加锁,所以写数据库通常是要比读数据库的时间更长。
这么来看,先更新数据库 + 再删除缓存的方案,是可以更好地保证数据一致性的。所以,应该采用这种方案来操作数据库和缓存,来解决并发情况下的数据不一致问题。
(7)如何保证两步都执行成功
前面分析到,无论是更新缓存还是删除缓存,只要第二步发生失败,那么就会导致数据库和缓存不一致。所以保证第二步成功执行,就是解决问题的关键。
如果程序在执行过程中发生异常,那么最简单的解决办法就是:重试。无论是先操作缓存,还是先操作数据库,但凡后者执行失败了,就发起重试,尽可能地去做补偿。
那这是不是意味着,只要执行失败,立即重试就可以呢?答案是否定的,失败后立即重试的问题在于:立即重试很大概率还会失败、重试次数设置多少才合理、重试会一直占用这个线程资源,无法服务其它客户端请求。
所以,如果想通过重试的方式解决问题,这种同步重试的方案依旧不严谨。通常来说,更好的方案应该是异步重试。
异步重试就是把重试请求写到消息队列中,然后由专门的消费者来重试,直到成功。或者更直接的做法,为了避免第二步执行失败,可以把操作缓存这一步直接放到消息队列中,由消费者来操作缓存。
到这里可能会问,写消息队列也有可能会失败,而且引入消息队列又增加了更多的维护成本,这样做值得吗?这个问题很好,但思考这样一个问题:如果在执行失败的线程中一直重试,还没等执行成功,此时如果项目重启了,那么这次重试请求也就丢失了,那这条数据就一直不一致了。
所以,这里必须把重试或第二步操作放到另一个服务中,这个服务用消息队列最为合适。这是因为消息队列的特性,正好符合这里的需求。消息队列保证可靠性:写到队列中的消息,成功消费之前不会丢失(重启项目也不担心)。消息队列保证消息成功投递:下游从队列拉取消息,成功消费后才会删除消息,否则还会继续投递消息给消费者(符合重试的场景)。
如果确实不想在应用中去写消息队列,是否有更简单的方案,同时又可以保证一致性呢?方案还是有的,这就是近几年比较流行的解决方案:订阅数据库变更日志,再操作缓存。
具体来讲就是,业务应用在修改数据时,只需修改数据库,无需操作缓存。那什么时候操作缓存呢?这就和数据库的变更日志有关了。以MySQL为例,当一条数据发生修改时,MySQL就会产生一条变更日志binlog,我们可以订阅这个日志,拿到具体操作的数据,然后再根据这条数据,去删除对应的缓存。
订阅变更日志binlog,目前也有比较成熟的开源中间件,比如Canal,使用这种方案的优点在于:无需考虑写消息队列失败情况:只要写MySQL成功,binlog肯定会有。自动投递到下游队列:Canal会自动把数据库变更日志投递给下游的消息队列。
至此,可以得出结论:想要保证数据库和缓存一致性,推荐采用先更新数据库,再删除缓存方案,并配合消息队列或订阅变更日志的方式来做。
(8)主从库延迟和延迟双删问题
延迟双删其实是为了解决缓存中是旧值但数据库中是新值的问题。比如在先更新数据库再删除缓存方案中,遇到读写分离+主从库延迟时,特别容易发生缓存中是旧值但数据库中是新值的情况。
当两个线程要并发读写数据,可能会发生以下情况:
步骤一.线程A更新主库x = 2(原值x = 1)
步骤二.线程A删除缓存
步骤三.线程B查询缓存没命中,查询从库得旧值(从库x = 1)
步骤四.从库同步完成(主从库x = 2)
步骤五.线程B将旧值写入缓存(x = 1)
最终x的值在缓存中是1(旧值),在主从库中是2(新值),发生了不一致。由此可见,上述情况出现不一致问题的核心在于:缓存都被更新回了旧值。因此为了解决缓存中是旧值但数据库中是新值的问题,最有效的办法就是把缓存删掉。但是不能立即删,而是需要延迟删,这也是业界常用的缓存延迟双删策略。解决方案:线程A可以生成一条延时消息写到消息队列中,消费者延时删除缓存。
这个方案的目的是为了把缓存清掉,这样下次就可以从数据库读取到最新值写入缓存。但是,这个延迟删除缓存的延迟时间到底设置多久才合理呢?延迟时间要大于主从复制的延迟时间、延迟时间要大于读取数据库 + 写入缓存的时间。
这个延迟删除缓存的延迟时间在分布式和高并发场景下,其实是很难评估的。很多时候,都是凭借经验大致估算这个延迟时间,例如延迟为1-5s,只能尽可能地降低不一致的概率。所以采用这种方案,也只是尽可能保证一致性而已,极端情况下还是有可能发生不一致的。所以实际使用中,建议采用先更新数据库,再删除缓存的方案。同时要尽可能地保证主从复制不要有太大延迟,降低出问题的概率。
(9)可以做到强一致吗
可见上述这些方案还是不够完美,其实很难让缓存和数据库强一致。强一致最常见的方案是2PC、3PC、Paxos、Raft这类一致性协议,但它们的性能往往比较差,而且也比较复杂,还要考虑各种容错问题。
通常来说,引入缓存的目的其实是为了性能。一旦决定使用缓存,那必然要面临一致性问题。性能和一致性就像天平的两端,无法做到都满足要求。而且当操作数据库和缓存完成之前,只要有其它请求可以进来,都有可能查到中间状态的数据。所以如果非要追求强一致,那必须要求所有更新操作完成之前期间,不能有任何请求进来。虽然可以通过加分布锁的方式来实现,但要付出的代价,很可能会超过引入缓存带来的性能提升。
所以既然决定使用缓存,就必须容忍一致性问题,只能尽可能地去降低问题出现的概率。同时也要知道缓存都是有失效时间的,就算在这期间存在短期不一致,依旧有缓存失效时间来兜底,这样也能达到最终一致。
(10)总结
第一:为了提高应用的性能,会引入缓存
第二:引入缓存后需要考虑缓存和数据库一致性问题,可选的方案有:更新数据库 + 更新缓存、更新数据库 + 删除缓存
第三:更新数据库 + 更新缓存方案,在并发场景下无法保证缓存和数据一致性,且存在缓存资源浪费和机器性能浪费的情况
第四:更新数据库 + 删除缓存方案,在并发场景下依旧有数据不一致问题,解决方案是延迟双删,但这个延迟时间很难评估
第五:在先更新数据库再删除缓存方案下,为了保证两步都成功执行,需配合消息队列或订阅变更日志的方案来做,本质是通过重试的方式保证数据一致性
第六:在先更新数据库再删除缓存方案下,读写分离 + 主从库延迟也会导致缓存和数据库不一致,缓解此问题的方案是延迟双删,凭借经验发送延迟消息到队列中延迟删除缓存,同时也要控制主从库延迟,尽可能降低不一致发生的概率
二.Redis在用户数据里的应用
1.Redis缓存架构的典型生产问题
2.用户数据在读多写少场景下的缓存设计
3.热门用户数据的缓存自动延期机制
4.缓存惊群与穿透问题的解决方案
5.缓存和数据库双写不一致问题分析
6.基于分布式锁保证缓存和数据库双写一致性
7.缓存和数据库双写在分布式锁高并发下的优化
8.利用分布式锁自动超时消除串行等待锁的影响
9.写少读多的企业级缓存架构设计总结
1.Redis缓存架构的典型生产问题
Redis的典型生产问题如下:
问题一:热key问题
热key就是某个key形成了热点。比如某明星突然官宣离婚,那么就会出现大量用户瞬时涌入该明星微博进行围观的情况。从而出现瞬时百万级千万级请求去获取Redis某个key的数据,这就是热key问题。
对于社区电商APP来说,如果有一个比较好的帖子分享和团购活动,那么也有可能短时间内引发大量用户把这该帖子详情页分享到微信等社交应用。从而引发大量用户在短时间内查看该分享详情页,造成Redis热key问题。
问题二:大value问题
存储的key-value特别大,比如value多达10M。这个value如果被频繁读取,那么就有可能把Redis机器的网络带宽打满,阻塞别的请求。
问题三:缓存穿透击穿问题
缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求(布隆过滤器或设置空对象)。缓存击穿是指缓存中没有但数据库中有的数据(一般是缓存时间到期),这时由于并发请求特别多,同时读缓存没读到数据,又同时去数据库去取数据。
问题四:缓存失效和LRU被清理的问题
缓存数据设置了过期的时间,到期失效后应该如何来处理。Redis如果内存满了,LRU算法会自动淘汰一些数据,对于这些数据应该如何进行处理,如何才能实现自动加载和重建。
问题五:缓存雪崩问题
缓存雪崩是指缓存中数据大批量到过期时间,而查询量巨大,引起数据库压力过大甚至宕机。和缓存击穿不同的是:缓存击穿是指并发查同一条数据,缓存雪崩是不同数据都过期了。
如果Redis集群都崩掉了,只有数据库可以访问。那么首先就需要自动识别出缓存故障,然后马上进行限流对数据库进行保护,不让数据库崩溃,以及马上启动各个接口的降级机制。
各个接口的降级机制可以提前在JVM内存里,准备少量缓存作为降级备用数据。所以每个接口都需要有一个降级方案,一旦缓存故障,就可以自动限流避免数据库崩溃。
限流 -> 降级 -> 把JVM内存里缓存的默认数据给用户 或者 直接对用户进行提醒。
问题六:数据库的一致性问题
缓存数据和数据库之间的一致性的保障,双写、异步同步如何保证一致性。
Redis生产总结:
Redis上了生产以后,首先需要模拟出足量的数据写入Redis里,比如模拟出千万级数据量写入部署好的Redis集群中。然后进行高并发压测,并通过CacheCloud进行监控运维。监控出有多少个缓存节点、里面放了多少G数据、大压力下接口性能如何、QPS多少、Redis机器负载如何、缓存命中率如何、数据库回源比例是多少、演示Redis节点故障的主从切换、演示Redis集群扩容等。
2.用户数据在读多写少场景下的缓存设计
说明一:新增或更新用户时先获取分布式锁,避免短时间发生多次请求出现重复新增或更新
说明二:用户数据会先写数据库,再写Redis缓存
说明三:用户数据属于读多写少场景下的数据,适合用缓存支持高并发场景下的读取
说明四:由于大部分用户数据属于冷门数据,故其缓存的过期时间设置为2天加随机几小时
说明五:如果后面有请求需要频繁访问某条用户数据,那么可以不断延长(重置)其缓存的过期时间
3.热门用户数据的缓存自动延期机制
由于用户数据是属于读多写少场景下的数据,所以在新增或更新时对其进行缓存是很合适的。进行缓存后,在其他各种场景下,获取用户数据时就可以直接从缓存里进行读取。
为什么对用户数据进行缓存时,过期时间被设定为:履约周期天数加上随机几小时?因为只有少数的用户数据是热门数据,而大部分的用户数据都是冷门数据。因此没有必要让所有的用户数据都驻留在缓存里,占用缓存宝贵的内存空间。
如果缓存好的某用户数据后续没有被访问,那么就让它过期即可。当过一段时间后有请求需要访问该用户数据时,再重新回源数据库进行查询并写入缓存。
如果缓存好的一条用户数据后续被不断请求访问,成为了热门用户数据。那么每次访问该用户数据,可以延长(重置)其缓存的过期时间。这样就可以从热门数据一直都可以从缓存里读取,实现高并发读。
注意:从缓存里获取不到用户数据,需要回源数据库进行查询并写入缓存时,首先要加分布式锁。
4.缓存惊群与穿透问题的解决方案
(1)用户数据写入和查询时的缓存设计总结
写入用户数据时,会对数据库和缓存进行双写,缓存默认2天多随机时间过期。如果用户数据被频繁读取,那么其缓存就会不停地自动延期(重置)。如果用户数据没被读取,那么就按默认设定自动过期,避免占用缓存空间。当用户数据不能从缓存中读取到,则先从数据库里查出来,然后再放入缓存里。
(2)设置过期时间为2天加随机几小时的原因
随机几小时是为了避免缓存惊群的问题。如果缓存的一批数据的过期时间都设置一样,就会出现大量缓存同时过期。这会造成大量的瞬时请求去访问MySQL,对MySQL造成压力。为了避免该问题,可以设置缓存数据的过期时间都是随机的,不集中在某个时间点一起过期。
惊群效应是技术里的术语,指的是突然在某个时间点出了一个故障,导致一大片范围线程、进程、机器都同时被惊动了。
(3)缓存穿透问题的解决方案
缓存穿透是指缓存和数据库中都没有的数据,而用户不断发起请求(布隆过滤器或设置空对象)。如果大量请求的是同一批key(缓存和DB都没数据),则可以对这些key缓存一个空对象。如果大量请求的是不同的key(缓存和DB都没数据),则可以使用布隆过滤器过滤这些key。所以对从DB查出空数据的key缓存空对象,也不一定完全解决缓存穿透问题。
使用布隆过滤器过滤key时,应该是对DB里的所有数据进行添加,在查询缓存前过滤。但是如果在查询缓存前对所有key进行过滤(高风险方案),那么就存在很大风险。因为如果过滤所有key就发生了故障,那么可能会导致所有数据都被误判为没有数据。
所以可以考虑默认采用设置空对象来避免缓存穿透,然后统计这种缓存穿透对DB的请求数。如果请求数超出一定阈值,则说明有大量请求都是不同的key了,设置空对象无法避免,此时再升级为布隆过滤器来避免这种缓存穿透。
5.缓存和数据库双写不一致问题分析
(1)对缓存和数据库双写时发生不一致的场景
某用户数据在Redis的缓存已经过期了,此时刚好有两个线程分别并发去对给用户数据进行读取和更新。第一个线程在读取时,发现缓存没有数据,于是就去读库,读完库后会更新缓存。第二个线程在更新时,会先更新数据库,然后再更新缓存。以上两个线程也刚好对应了写缓存的两个场景。
由于这两个线程并发执行,那么就可能出现如下产生不一致的场景:第一个线程首先读库读到了旧值,还没来得及将读到的旧值写入缓存时。第二个线程的新值更新已经完成了写库和写缓存,此时缓存数据是最新的。接着才轮到第一个线程进行写缓存,但是这时候写的数据却是一开始读到的旧值。于是第一个线程写缓存时就把缓存里的最新数据给覆盖了,从而出现数据库里的是新数据,但缓存里的是旧数据,产生了不一致。
(2)缓存和数据库双写时不一致问题的解决方案
有一个简单易行的方案来解决这个问题,就是使用分布式锁让数据库读和写必须是串行化,所以接下来可以对数据库进行读和写时加同一个分布式锁。
6.基于分布式锁保证缓存和数据库双写一致性
用户数据是典型读多写少的数据,可能0.01%是写,99.99%是读,所以进行用户注册和信息更新的操作是极少的。社区平台平时对用户数据大部分都是进行查询,比如分享帖子的详情页、feed流页面就需要查询用户信息。基于用户数据读多写少的特点,在保证缓存和数据库双写一致性时,就需要注意以下几点。
注意一:不能在读缓存处加锁而在读库时加锁
在用户数据写的地方加锁,由于读多写少的特点,所以几乎不会影响性能。在用户数据读的地方加锁,就需要注意不能在读缓存处加锁,而应该在准备读库时加锁。
注意二:写库和读库加的锁是同一把锁
写数据库的加的锁和准备读数据库的地方加的锁,是同一把锁。
注意三:获取读库的锁后进行双重检查
如果一个线程在获得了读数据库的锁之后,需要进行双重检查,避免再去数据库查一次。因为如果出现大量的请求并发读取某个已过期的用户数据缓存时,此时只会有一个线程获取到锁去查库,然后其他大量的线程都只能在串行化排队。当获取到锁的线程完成读库 + 更新缓存并释放锁后,其他线程就没必要再查库了,否则影响性能。
7.缓存和数据库双写在分布式锁高并发下的优化
当某个冷门用户数据早已过期,但由于热搜等原因突然出现高并发读时,就会出现大量并发线程从缓存里都读不到数据,然后都会尝试进行读库 + 写缓存。也就是有大量并发线程在执行getUserInfoFromDB()方法,出现缓存击穿问题。
这些线程中只会有一个线程获取到锁,而其他并发的线程则产生严重的锁竞争问题。进行锁竞争的线程会串行化排队,第一个获取到锁的线程读库 + 写缓存。后续的线程获取到锁后,通过双重检查就可以直接读缓存了。但是即便有了双重检查,这些排队获取锁的线程还是需要一个个串行获取锁后才能执行。
然而其实只要第一个线程拿到锁,完成读库 + 写缓存后,Redis缓存里就已经有数据了。其他正在串行化排队获取锁的线程,就没必要继续排队去获取锁了。因此只要第一个线程成功完成读库 + 写缓存,其他线程就可以转为无锁情况下的串行读缓存。
为此,可以设置分布式锁的超时时间,超时时间可以参考一个线程完成读库 + 写缓存的时间。这样在超时时间内没有获得锁的线程会等待,超过超时时间内还获取不到锁就会返回false。当这些排队的线程获取分布式锁超时而返回false后,就可以尝试转为无锁串行读缓存了。
8.利用分布式锁自动超时消除串行等待锁的影响
进行加锁时设置锁的超时时间,让排队获取锁的线程自动超时。第一个拿到锁的线程在超时时间内处理完事情会释放锁,其他线程会继续竞争锁。而在这个超时时间里没有获得锁的线程会被挂起并进入队列进行串行等待。如果在这个超时时间外还获取不到锁,排队的线程就会被唤醒并返回false。而获取分布式锁超时失败的线程通过再次读缓存,从而实现无锁串行读缓存。
注意:设置的自动超时时间并不好控制。因此可以参考AQS的做法,获取不到锁的线程先挂起,第一个释放锁的线程就把这些线程全都唤醒执行并发读缓存。AQS是对排队中的线程一个个进行唤醒,需要改造成第一个锁释放后全部排队的线程都唤醒。
9.写少读多的企业级缓存架构设计总结
(1)读多写少场景引入Redis缓存
用户数据是典型读多写少的数据,可能0.01%写,99.99%读。读多写少的场景引入Redis缓存是非常有必要的,因为读可能是高并发的。而且读请求没必要都从数据库里读取数据,从缓存中读取数据即可。
(2)同步双写实现数据库和缓存强一致性
关于如何写库和写缓存才能保证数据库和缓存一致性,有两个方案:
方案一:通过异步先写数据库然后根据binlog写缓存实现最终一致性
方案二:通过同步使用分布式锁双写数据库和缓存实现强一致性
这里采用了同步双写,因为比较简单。当进行写时,会将数据库和缓存一起写,并且设置过期时间,以便让缓存留下热数据。当进行读时,每次读取缓存都会自动expire延期,让热数据一直保留在缓存里,冷数据自动过期。当需要读取冷数据时,发现没从缓存中读取到,那么再去读库 + 写缓存。
通过过期时间 -> 实现冷热分离 -> 让冷数据停留在MySQL + 让热数据停留在Redis
(3)读多写少场景下的数据库和缓存双写企业级方案
说明一:数据库和缓存同步双写
说明二:缓存实现冷热分离:设置过期时间 + 自动expireTime延期
说明三:缓存惊群解决方案:随机过期时间
说明四:缓存穿透解决方案:根据key查库发现不存在,可以缓存空数据
说明五:数据库缓存强一致性:写库和读库使用同一分布式锁 + 读库前进行双重检查
说明六:缓存击穿问题:分布式锁 + 消除串行等待锁(读操作的分布式锁设置自动超时)
三.Redis在列表数据里的应用
1.某类目下的商品列表数据按页缓存
2.某类目下的商品列表分页缓存的异步更新
3.数据库与缓存的分页数据一致性方案
4.热门商品列表的分页缓存失效时消除并发线程串行等待锁的影响
1.某类目下的商品列表数据按页缓存
由于不确定商品列表页的访问频率 + 缓存全部商品列表数据耗费内存,所以没有必要发布完商品就马上构建相关的商品列表缓存。一般会采用延迟构建缓存 + 分页列表惰性缓存的方案:即当有用户分页浏览商品列表时,才会构建该页的商品列表缓存。
可以针对每一页的商品列表数据精准设置过期时间。如果有的页列表一直没被访问,就让它自动过期即可。如果有的页列表频繁被访问,就自动去做过期时间延期。这样就实现了对页列表的缓存按照冷热数据进行精准过期控制。
2.某类目下的商品列表分页缓存的异步更新
(1)上述方案在上架商品时能很好运行
即运营不停上架一些商品写入数据库后,就假设不更新数据了。然后进行分页查询商品列表时,查第几页就构建第几页的缓存。并设置随机过期时间,让构建的分页缓存实现数据冷热分离。
(2)还要考虑运营下架或修改商品时对列表的影响
商品列表的分页缓存构建好之后,上下架或者修改一些商品。可能会导致之前构建的那些分页缓存都失效,此时就需要重建分页缓存。重建分页缓存会比较耗时,耗时的操作就必须采取异步进行处理了。于是进行如下改进:上下架或修改商品时,需要发送消息到MQ,然后异步消费该MQ的消息,找出该商品对应的分页缓存进行重建。
3.数据库与缓存的分页数据一致性方案
和用户数据的情况一样,有三个线程在几乎并发执行,都处理到同一条商品列表分页缓存数据。线程A读取不到某商品列表数据的分页缓存,需要读库 + 写缓存。线程B正在执行更新相关商品的数据,需要写库 + 发消息。线程C正在消费更新商品时发出的MQ消息,需要读库 + 写缓存。
那么就可能会出现如下情况:线程A先完成读库获得旧值,正准备写缓存。接着线程B马上完成写库和发消息,紧接着线程C又很快消费到该消息并完成读库获得新值 + 写缓存。之后才轮到线程A执行写缓存,但是写的却是旧值,覆盖了新值。从而造成不一致。
所以需要对读缓存失败时要读库和消费消息重建缓存时要读库加同一把锁。
4.热门商品列表的分页缓存失效时消除并发线程串行等待锁的影响
和用户数据一样,某个商品突然流量暴增成为爆款数据。一开始大量的并发线程读缓存失败,需要准备读库+写缓存,出现缓存击穿。这时就需要处理将并发线程的"串行等待锁+读缓存"转换成"串行读缓存",这可以通过简单的设定尝试获取分布式时的超时时间来实现。
也就是当并发进来串行排队的线程获取分布式锁超时返回失败后,就让这些线程重新读缓存(实现"串行等待锁+读缓存"转"串行读缓存"),从而消除串行等待锁带来的性能影响。
注意:等待锁释放的并发线程在超时时间内成功获取到锁之后要进行双重检查,这样可以避免出现大量并发进来的线程又串行地重复去查库。
四.Redis在购物车里的应用
1.购物车的读多写多场景分析
2.购物车的复杂缓存与异步落库(Sorted Set + Hash -> hPut + zadd)
3.购物车异步落库的消息丢失与不一致分析(缓存雪崩 + MQ异步出现问题)
4.购物车的阈值检查与重复加入逻辑(hGet + hLen + hFieldExists)
5.购物车加入商品多线程并发问题解决(分布式锁保证请求幂等)
6.购物车的查询、更新与选中功能(zrevrange +hGetAll + zremove+ hDel)
7.购物车的选中提交功能
8.总结
1.购物车的读多写多场景分析
(1)对用户数据和商品列表数据的处理
在新增或修改时,采用的是同步写库+写缓存或异步写库+写缓存的方案。其中用户数据缓存会进行同步更新,而商品列表数据的分页缓存则由于可能需要重建的分页缓存比较多,则会通过异步更新。并且在读取用户数据、商品列表数据时,是直接读缓存的。除非是读到了被淘汰掉的冷数据,才会重新读数据库 + 写缓存。
之所以这样设计,是因为用户数据和商品数据都是典型读多写少的数据,可能用户数据是0.01%写 + 99.99%读,而商品数据是1%写 + 99%读。由于写的情况很少,所以对应数据库的写压力也就很小。因此在用户数据写库时直接采用同步写库和写缓存,是没问题的。由于读的情况很多,所以通过先读缓存就可以用缓存抗下大量高并发的读。
(2)对购物车数据的处理
首先购物车功能包括:加入购物车、查看购物车、编辑购物车、发起结算等。然后当平台进行促销活动时,这时购物车的数据就会变成读多写多的数据,此时的购物车的数据可能会出现高并发的写。如果购物车数据的写也同步落库,那么可能就会导致数据库的压力很大。
因此对于购物车或者库存这种读多写多的数据,由于存在大量高并发的写、大量高并发的读,那么我们会把主要数据基于Redis来进行主存储,来实现高性能读写,同时通过异步把数据同步到MySQL进行持久化落库。
2.购物车的复杂缓存与异步落库(Sorted Set + Hash -> hPut + zadd)
由于购物车的数据是读多写多的数据,所以会使用缓存来存储主数据,以便能够抗住高并发的写和读,然后进行落库的时候再通过异步来落库。此外,商品系统一般也会使用缓存架构来提供商品数据的读接口。
更新购物车时,需要涉及如下操作:更新用户购物车的SKU数量缓存、更新加入到购物车的SKU扩展信息缓存、更新用户购物车的SKU加购时间排序缓存。
由于购物车包含很多种数据,所以会分成多个缓存key,这些key对应的缓存数据类型有:
Hash数据类型:购物车商品数量和信息的哈希{skuId -> count}和{skuId -> skuInfo}
ZSet数据类型:购物车加购排序的有序集合[skuId -> timestamp]
3.购物车异步落库的消息丢失与不一致分析(缓存雪崩 + MQ异步出现问题)
(1)异步落库时缓存崩了没有出现数据不一致(可以通过降级 + 缓存预热加载恢复)
此时其实就是缓存雪崩的情况。现已知用户在加购一个商品SKU到购物车时,会进行异步化落库磁盘。这时MySQL数据库有点像是备用存储,主要用在异步同步数据时备份数据。
一般来说购物车的主数据存储,是由Redis来实现的,并都优先从Redis中进行购物车的写和读,这时是不会有不一致的问题的。由于落库时通过异步化使用MySQL备用存储,那么万一Redis集群全都崩溃了,这时可能就会导致购物车的主数据都没了。
但即便Redis主数据全都没了,我们还是可以基于MySQL来进行降级,通过降级继续提供购物车的写和读。然后等缓存恢复后,再进行缓存预热加载,把数据库里的数据加载到缓存里。当然缓存集群崩了,其实就是缓存雪崩的问题了。
(2)异步落库时缓存写了但是MQ没有写成功从而出现数据不一致
此时其实就是MQ异步出现问题的情况,此时可能会对应下面两种异常情况:
情况一:刚刚写完缓存,还没来得及发送消息到MQ里,突然系统崩了。导致缓存写成功,但是异步消息没有发送出去。
情况二:刚刚写完缓存,系统正常运行,已经向MQ发出消息,但RocketMQ崩了。导致缓存写成功,但是异步消息也没发送成功。
这两种异常情况都会导致Redis里有数据,但RocketMQ里没消息。这时Redis中的数据自然也就没有异步落库到MySQL,从而造成Redis缓存和MySQL数据库之间的数据是不一致的。
这时候其实问题也不太大,即使出现了这样的情况,只要Redis里有数据就即可。因为用户后续一旦提交购物车生成订单,那么其数据就会从Redis里删除,这时MySQL就会跟Redis同步了。
(3)先出现不一致然后缓存崩了从而造成数据丢失
如果是Redis突然崩溃了,导致只有MySQL里有数据了。但是MySQL之前又因MQ崩了丢了一条数据,那么此时因为Redis崩了所以那条数据就丢失了。
这时其实问题也不大,因为这最多导致用户在购物车里找不到自己刚加入的商品。而且购物车只要没发起提交,Redis的本质还是临时性的数据存储空间。在购物车中找不到商品,那么重新加入购物车即可。
(4)为什么购物车的主数据存储要选用Redis
因为从业务上来说,购物车的数据其实是属于临时性的数据,用户仅仅是把一些商品在购物车里进行暂存。对用户来说,购物车里的商品会有三种情况:不发起购买,从购物车里直接删除这些商品、过了很长时间都没买,用户都已经把它给忘了、选择购物车里的商品发起购买。
所以对于这种比较偏临时的数据,使用Redis来当主数据的存储是没问题的。即便出现缓存雪崩或MQ异步出现问题,导致Redis和MySQL数据不一致,甚至购物车数据丢失,那么问题也不大。因为极端情况下,购物车少了一些商品,大不了让用户重新加购。
4.购物车的阈值检查与重复加入逻辑(hGet + hLen + hFieldExists)
主要是使用Redis的Hash数据类型的几个命令进行检查,比如hLen获取Hash数据类型某个key下的元素个数,比如hFieldExists判断在Hash数据类型中是否存在某个key。
(1)hLen命令获取当前购物车sku数量
(2)hFieldExists检查商品是否存在
(3)加购时候判断是否重复加入
5.购物车加入商品多线程并发问题解决(分布式锁保证请求幂等)
如果某个用户在加购时连续点了几次,那么由于网络等原因,就可能出现这几次请求并发到达服务器端来进行处理,导致重复加购问题。
可以通过在加入购物车的方法入口添加分布式锁来解决这个问题,也就是加分布式锁保证请求幂等性。
6.购物车的查询、更新与选中功能(zrevrange +hGetAll + zremove+ hDel)
(1)购物车的查询流程(基于有序集合 + 哈希来查)
(2)从缓存中获取购物车数据(基于zrevrange倒序 + hGetAll来查)
(3)通过分布式锁从数据库中获取购物车数据
(4)购物车的更新(基于zremove + hDel来删除)
(1)购物车的查询流程(基于有序集合 + 哈希来查)
如果缓存中存在,那么就从缓存中查询到后返回。如果缓存中不存在,那么就首先添加分布式锁,然后再查询MySQL,查询到数据后便将数据更新到缓存,最后返回。如果缓存和MySQL都不存在,那么就在查询MySQL后,缓存一个空值并设置随机过期时间。当下次再来查询购物车时,会先判断缓存中的空值是否存在,如果存在就不查数据库了。
(2)从缓存中获取购物车数据(基于zrevrange倒序 + hGetAll来查)
由于购物车缓存是使用Redis的Sorted Set + Hash来实现的,因此会先查询按时间排序的商品skuId集合,再查询每个skuId对应信息,通过zrevrange倒序和hGetAll查出购物车商品集合。
(3)通过分布式锁从数据库中获取购物车数据
加分布式锁,主要是为了保护数据库。
(4)购物车的更新(基于zremove + hDel来删除)
更新购物车其实主要是更新SKU数量,也要加分布式锁保证请求幂等性。更新购物车数量为0,即删除缓存时,就使用zremove和hDel。
7.购物车的选中提交功能
也要使用分布式锁保证请求的幂等性。
8.总结
一.读多写多,如果使用Redis作主存储,先写Redis再写MySQL,那么如果写多个key,Redis写一半key,系统就宕机,可利用lua保证事务性。如果Redis写完,系统宕机,MySQL没写,此时只有缓存有数据。可以考虑先顺序写磁盘或者先写操作系统Page Cache,然后再写Redis缓存。这样即便系统宕机,也可以在重启的时候从文件中恢复数据。
二.读多写少,如果使用MySQL作主存储,先写MySQL再写Redis。那么只要数据写到MySQL,就一定可以同步到Redis。比如通过Canal监听MySQL的binlog,只要写入MySQL,Canal就可以监听到binlog并发送消息到MQ。
五.Redis在库存里的应用
1.库存模块设计
2.库存缓存分片和渐进式同步方案
3.基于缓存分片的下单库存扣减方案
4.商品库存设置流程与异步落库的实现
5.库存入库时"缓存分片写入 + 渐进式写入 + 写入失败进行MQ补偿"的实现
6.库存扣减时"基于库存分片依次扣减 + 合并扣减 + 扣不了返还 + 异步落库"的实现
1.库存模块设计
(1)电商系统库存模块的设计要求
由于该库存模块可以支持高性能的并发读写,因此需要支持对商品库存进行多分片写入和读取处理(分片一般等于节点),需要提供单个分片库存不足以扣减时的合并库存功能,以及需要提供操作商品入库时的库存渐进性写入缓存的实现。也就是对于热点库存能够实现缓存分片。
进行库存分片后,如果遇到单个分片库存不足可以进行合并扣减库存。库存落库之后,库存数据以渐进式的方式写入到缓存里。
(2)社区电商系统库存模块功能分析
主要会有两个系统会操作库存的数据,即商品系统 + 订单系统。首先是商品系统会对商品库存进行入库和出库,然后是订单系统会对商品库存进行购买时的扣减和退款时的返还,所以商品系统和订单系统会影响库存数据变更。
一般而言,库存的数据都是要放到Redis里去的。因为这可以方便后面进行高并发活动如大促和秒杀,而大促和秒杀活动往往会对库存进行高并发读和写,所以库存数据是典型的读多写多数据。
(3)商品系统处理库存出⼊库时影响库存数据的设计
商品中⼼调⽤库存中⼼,添加商品库存信息时,一般会涉及到3个表的数据。第⼀个表是库存表,需要更新相关库存信息(第⼀次要新增)。第二个表是库存变更记录表,需要记录当次的库存变更记录。第三个表是库存变更明细表,需要记录当次的库存变更明细。
库存初始化到库存分⽚中的时候,采⽤渐进性同步的⽅式来进行同步。否则如果采用⼀次性同步的方式,假如过程中失败了就会造成库存不均匀。
例如每个库存分片(节点)需要写100个库存:
说明一:如果一次性同步,那么就是遍历一次节点,每个节点写100个库存。当遍历到某个节点却写入失败时,写入失败的库存数要重新遍历节点写入,这时候就会造成节点库存分配不均匀了。
说明二:如果渐进性同步,那么就是分多次遍历节点,已做好某次遍历节点写入库存就存在节点写入失败情况的准备了。比如每个节点写100个库存,那么就遍历节点10次,每次写10个库存,这样就可以尽量避免节点库存不均匀了。
说明三:当同步过程中出现异常导致同步中断,此时就发送⼀条消息给MQ做补偿。MQ补偿时,会扣减掉已同步缓存的数量,只同步剩余数量。补偿消息要避免重复消费,默认收到就只处理⼀次,异常则再次发送新的消息补偿缓存。
(4)订单系统扣减和返还库存时影响库存数据的设计
说明一:进⾏下单、缺货、取消、⻛控等业务场景时,会涉及对库存的操作变更。
说明二:每个商品SKU都会维护⼀个key,每次操作一个SKU库存时,这个key都会自增+1。通过这个key值对分⽚机器数取模,就可以选择其中⼀台机器进⾏库存扣减。
说明三:当被访问的分⽚库存不能完成此次扣减,则前往下⼀个分⽚继续尝试,直到所有分⽚都不⾜以扣减此次库存以后,则开始尝试合并库存扣减。
说明四:合并扣减⾸先会从每个分⽚尝试扣减,但默认扣减分⽚的最⼤剩余库存。当分⽚内的库存可购买数量⼩于用户需要购买数量时,那么就从lua脚本中返还本次分⽚的实际扣除数量,并记录起来。避免全部扣除后还是失败,或者中途扣除过程发⽣异常,可以进⾏回滚。
注意:Redis能执行lua脚本,一段lua脚本可以作为一个整体,这样将多条Redis命令写入lua,就可以实现事务的原子性。
(5)查看商品SKU库存的设计
每次查看商品SKU库存时,会去各个分⽚获取分⽚库存,然后合并才返回。
2.库存缓存分片和渐进式同步方案
(1)库存缓存分片方案避免瞬时流量倾斜
库存数据写入单节点缓存后:如果遇到大促活动如秒杀,需要瞬时高并发去操作一个商品SKU的库存时,就会导致对缓存集群里某个Redis节点造成过大压力,造成瞬时流量倾斜。
所以为了解决瞬时流量倾斜问题,往往采用缓存分片。比如商品SKU库存有100个,这时可以把这100个库存拆分为10个分片。假如Redis集群有5个节点,此时分10个分片,那么每个节点就有2个分片。不过库存分片的数量一般设置成与Redis节点数量一样(分片一般等于节点)。这样出现库存的瞬时高并发操作时,就可以将库存扣减请求分到多个节点上。这样高并发流量就能均匀负载到各个节点上去,避免对单个节点写压力过高。
(2)渐进性同步方案避免节点库存不均
在分配库存到分片缓存时,采用渐进性分配库存的方式。例如每个库存分片(分片一般等于节点)需要写100个库存。
如果一次性同步,那么就是遍历一次节点,每个节点写100个库存。当遍历到某个节点却写入失败时,写入失败的库存数要重新遍历节点写入,这时候就会造成节点库存分配不均匀了。
如果渐进性同步,那么就是分多次遍历节点,已做好某次遍历节点写入库存就存在节点写入失败情况的准备了。比如每个节点写100个库存,那么就遍历节点10次,每次写10个库存,这样就可以尽量避免节点库存不均匀了。
但是无论是一次性同步(刚性同步)或者是渐进性同步(柔性同步),都需要考虑将数据从数据库同步到缓存的过程中是有可能出现失败的。失败时就需要基于MQ来做补偿,把没同步成功的库存补偿回去。
3.基于缓存分片的下单库存扣减方案
(1)缓存分片下如何选择节点
假设一个商品SKU有10000个库存,拆分为10个库存分片,每个分片1000,这10个库存分片会分散在多个Redis节点里。那么用户下单需要扣减商品库存时,到底去哪个Redis节点进行库存扣减。
此时有两种选择Redis节点的方案:可以通过随机的方式选出一个Redis节点来进行库存扣减,也可以通过轮询的方式选出一个Redis节点来进行库存扣减,这里会通过轮询的方式来选择Redis节点去进行库存扣减。
(2)如何通过轮询选择Redis节点
首先商品SKU需要维护一个访问key,然后每次扣减库存时都对这个访问key进行自增。接着根据这个自增值对库存分片数量进行取模,通过取模确定一个库存分片。然后再根据这个库存分片,确定该分片是在哪个Redis节点里的。这样就可以将库存扣减请求发送到那个Redis节点里进行处理了。
(3)如何处理库存分片的库存不足问题
如果轮询出的某个库存分片没库存或者库存不够了,比如当前库存分片还有1个库存,但这次用户请求需要扣减3个库存。明显当前库存分片不足以扣减,此时就可以尝试下一个库存分片来进行扣减。如果下一个库存分片也不足以扣减,那么继续下一个库存分片来进行扣减。如果最后发现每个库存分片都无法单独进行扣减,那就合并库存再进行扣减。合并库存进行扣减时,会对多个库存分片里的库存逐一扣减。
4.商品库存设置流程与异步落库的实现
商品中心操作库存会分为3步:
第一步:对库存设置进行异步落库
第二步:落库的库存数据会被同步到缓存分片里,并且是渐进式写入的
第三步:如果同步到缓存分片过程出现问题,需要基于MQ进行补偿
比如采购系统发起商品采购,然后供应商把商品发到仓库里。接着仓库操作员对商品入库,商品进行入库时会发送商品入库事件消息。库存系统可以监听并消费该事件,然后异步触发商品库存的设置和初始化。
如果商品系统创建商品时就设置了商品库存,这时就可以同步调用库存系统的接口,去执行商品库存初始化设置操作。商品库存初始化时会更新库存,这时对DB的操作也是通过MQ异步进行。
也就是商品库存初始化、商品库存入库、购物车库存扣减,都是异步写库,但是写缓存是同步的。
5.库存入库时"缓存分片写入 + 渐进式写入 + 写入失败进行MQ补偿"的实现
(1)基于Redis多节点的库存缓存分片的实现细节
首先通过Jedis连接池的大小来获取Redis节点数量,然后获取要分配的商品SKU库存数量,接着通过要分配的库存数量除以Redis节点数量计算单节点要分配的总库存。
在计算渐进式写入库存时,每次遍历节点都要对各节点写入的库存数量满足:如果单节点分配的总库存数比较大,那么每次就写入十分之一的总库存数;如果单节点分配的总库存数比较小,那么每次就写入默认3个库存。
(2)对库存缓存分片进行渐进式写入的分析
假设有3个节点并且入库数量是900,这样每个节点会分配300个库存。然后开始遍历这3节点循环写入,每遍历到一个节点就直接写入300个库存。遍历完前两个节点都各写入300库存,但是遍历到第三个节点却写入失败,这时就会导致第三个节点完全没有任何库存。那么进行库存扣减时,所有压力都会集中到第一个和第二个节点上。
在如下代码实现中,当往某个节点写入库存时,不会关注是否会写入失败。不针对单节点进行写入重试,而是循环写所有节点,只关注写入的库存数量,所以才采用了渐进式写入的方法。如果每个节点要写入300个库存,那么就遍历节点10轮。执行每轮遍历时,遍历到某个节点就对该节点写入30个库存。这样的好处就是即便有节点写入失败了,也可以尽量保证节点库存数量均匀。只要各个节点的库存相差不大,就可以避免出现对某些节点长期压力。
6.库存扣减时"基于库存分片依次扣减 + 合并扣减 + 扣不了返还 + 异步落库"的实现
(1)库存扣减分三步
步骤一:维护一个商品SKU的消费购买次数Key,每次自增 + 1,用于分片取模。
步骤二:检测分片内的库存是否足够购买。如果不够,则依次选择新的分片进行扣减。如果所有分片都不够扣减,则进行合并后扣减,合并扣减不了那么再进行库存返还。
步骤三:库存的变化需要进行异步落库到DB,使用MQ来保证数据最终一致性。
对库存分片进行扣减库存时,首先会对多个分片依次扣减。如果一个分片扣减不成功就去下一个分片继续尝试扣减。如果所有分片都扣减不成功,那么就进行合并扣减。如果合并扣减也不成功,则进行库存返还。如果扣减成功,则还需要将扣减库存数转发到MQ异步落库到DB。
当然如果某个库存分片的库存已经为0,可以对该分片进行标记,避免下次路由到该分片尝试扣减库存。
(2)基于库存缓存分片的合并扣减逻辑和合并扣减失败的库存返还逻辑
合并扣减的时候,每当对一个节点扣减完该节点可以扣的库存后,也就是扣减该节点的缓存库存值和传入待扣减库存值的最小值之后,就返回还剩需要继续扣减的库存值。然后需要记录每个节点扣减成功的库存值,以便合并扣减失败时可进行返还。lua脚本传入的参数如果是负数就代表进行返还,也就是负负得正进行累加。
比如本来要扣10个库存,合并扣减时,第一个节点已经扣完它可以扣的2个库存后,就返回8给第二个节点去扣减。第二个节点已经扣完它可以扣的3个库存后,就返回5给第三个节点去扣减。第三个节点它的库存刚好经历入库有6个库存,那么扣减完5个库存就返回0。
(3)查询库存的实现
遍历每个缓存分片获取库存,然后累加进行返回。
六.Redis相关解决方案
1.数据库与缓存一致性方案
2.热key探测系统处理热key问题
3.缓存大value监控和切分处理方案
4.Redis内存不足强制回收监控告警方案
5.Redis集群缓存雪崩自动探测 + 限流降级方案
6.缓存击穿的解决方法之分级缓存
7.Redis热key的解决方式
8.大Value处理方案
1.数据库与缓存一致性方案
现有的业务场景下,都会涉及到数据库以及缓存双写的问题。⽆论是先删缓存再更新数据,或先更新数据再删缓存,都⽆法保证⼀致性。本身它们就不是⼀个数据源,⽆法通过代码上的谁先谁后去保证顺序。
(1)数据库与缓存同步双写强一致性方案
这是适合中小企业的方案:读数据时自动进行读延期,实现数据冷热分离。在保证数据库和缓存一致性时使用分布式锁,第一个获得分布式锁的线程双写数据库和缓存成功后才释放分布式锁。然后在高并发下,通过锁超时时间,实现"串行等待分布式锁 + 串行读缓存"转"串行读缓存"。
(2)数据库与缓存异步同步最终一致性方案
如果不想对数据库和缓存进行双写,可以通过监听数据库binlog,异步来进行复制同步,从而保证最终一致性。这个方案需要先写成功DB,之后才能读到缓存value。这个方案需要确保binlog不能丢失,并且需要使用Canal监听binlog。
具体的数据一致性方案设计:⾸先对于所有的DB操作都不去添加具体的删除缓存操作,⽽是待数据确认已提交到数据库后,通过Canal去监听binlog的变化。Canal会将binlog封装成消息发送到MQ,然后系统消费MQ的消息时,需要过滤出增删改类型的binlog消息。接着根据binlog消息 + 一致性相关的表和字段组装需要进行缓存删除的key,最后组装出key就可以对缓存进行删除了。
2.热key探测系统处理热key问题
为了解决热key问题,我们首先需要一个热key探测系统。热key探测系统会在服务系统(Redis客户端)进行接入统计,一旦热key探测系统在服务系统识别出某个key符合热key的条件,那么就会将这个热key的数据缓存到服务系统的JVM本地缓存里。
所以,热key探测系统具备的两大核心功能:自动探测热key、自动缓存热key数据到JVM本地缓存。
3.缓存大value监控和切分处理方案
大value,顾名思义,就是value值特别大,几M甚至几十M。如果在一次网络读取里面,频繁读取大value,会导致网络带宽被占用掉。value太大甚至会把带宽打满,导致其他数据读取请求异常。所以对于大value,要进行特殊的切分处理。
首先要能够对Redis里的大value进行监控。如果发现超过1MB的大value 的值,就监控和报警。然后进行自动处理,也就是把这个value的值,转换为拆分字符串来缓存。比如将一个大value拆分为10个串,把一个kv拆分为10个kv来存储:test_key => test_key_01、test_key_02...。接着进行读取的时候,则依次读取这拆分字符串对应的key,最后将读出来的拆分字符串进行重新拼接还原成原来的大value值。
4.Redis内存不足强制回收监控告警方案
(1)背景
当Redis内存中的数据达到maxmemory指定的数值时,将会触发执行Redis的回收策略(Eviction Policies)。如果执⾏的回收策略对key进⾏了回收(Eviction),那么被回收的key应该要通知到业务端,让业务端⾃⾏去判断处理。
(2)方案设计
根据Redis的发布订阅机制(pub/sub)与键空间通知特性,可通过订阅Redis的回收事件去捕捉被回收的key信息,然后通知到业务端。
键空间通知特性:每当从数据集中删除⼀个具有⼀定⽣存时间的键时,就会⽣成⼀个过期事件。每次执行回收策略要从数据集中回收⼀个键时,就会⽣成⼀个回收事件。
(3)通知方式
中间层去订阅Redis的回收事件,收到回收消息后就发消息给MQ。不同业务端只和MQ对接,通过tags过滤消费消息,⾃⾏处理被回收的key。
5.Redis集群缓存雪崩自动探测 + 限流降级方案
(1)限流降级背景
如果线上⽣产环境Redis集群崩溃了,只有数据库可以访问。此时所有的请求都会打到数据库,这样会导致整个系统都崩溃。
所以系统需要自动识别缓存是否崩溃:马上进⾏限流,对数据库进⾏保护和防护,让数据库不要崩溃掉。马上启动接⼝的降级机制,让少部分⽤户还能够继续使⽤系统。降级就是大量的请求没法请求到MySQL,只能通过JVM本地缓存,提供有限的一些数据缓存默认值。
缓存雪崩和缓存惊群的区别:缓存雪崩是Redis集群崩溃了,无法去读缓存了。缓存惊群是大量缓存集中过期,还可以去读缓存。
(2)通过hotkey实现自动探测缓存雪崩
为了防⽌Redis崩溃后,系统⽆法正常运转,所以需要做降级处理。
步骤一:通过AOP切面统计Redis连接异常
由于系统操作Redis的所有⽅法都是通过RedisCache和RedisLock来处理的,RedisCache提供对Redis缓存key的操作,RedisLock提供分布式锁操作。所以可以通过AOP切⾯的⽅式,对这两个类中的所有⽅法做⼀个切⾯。
当这两个类的方法在执⾏Redis操作时,发现Redis挂了就会抛出异常。所以在切⾯处理⽅法上,如果捕捉到异常就进行记录一下。
步骤二:通过JdHotKey来实现自动识别缓存故障
如果捕捉到Redis连接失败,那么就先不抛出异常,而是返回一个空值,继续用数据库提供服务,避免出现整个服务异常。
如果一分钟之内或者30秒之内出现了几次Redis连接失败,此时再设置一个hotkey表示Redis连接不上,并指定过期时间是1分钟左右。那么下次获取缓存时,就可以先根据hotkey来判断Redis是否异常。hotkey在1分钟之后会删除key,下次有请求过来会去看Redis能否连接,这样就可以简单实现Redis挂掉后直接查数据库的降级机制了。
如何判断Redis是挂掉了还是只是暂时的⽹络波动?可以在hotkey中配置规则,比如30秒内出现了3次Redis连接失败,那么就认为Redis是挂掉了,在本地缓存中设置一个hotkey。
如何⾃动恢复呢?可以设置hotkey的过期时间是60秒,缓存过期会重新尝试去操作Redis。如果此时Redis恢复了,那么由于hotkey已经失效了,所以就可以正常使⽤回Redis功能。如果Redis还是没有恢复,那么继续往本地缓存中的hotkey设置数据。
以上就实现了⾃动识别缓存故障。当识别出缓存故障后,需要再操作Redis时,就可以直接返回null或者返回false。从而实现在缓存故障时,绕过缓存,继续⽤数据库提供服务。
(3)探测缓存雪崩后的RateLimiter限流降级方案
首先添加拦截器对接口进行拦截,然后创建Limiter配置类LimiterProperties,最后在LimiterInterceptor拦截器中判断Redis连接是否正常。
(4)限流降级后操作数据库时也要通过本地缓存降级
以商品详情接⼝为例:⾸先通过redisCache.getCache()查询hotkey内存数据和Redis缓存数据。由于Redis连接失败时会返回null,于是就会从数据库中获取数据。
接下来看getCookbookFromDB()⽅法的上半部分的降级处理部分:此时会判断JdHotKeyStore.get(REDIS_CONNECTION_FAILED)是否存在。如果存在则表示Redis连接失败,需要做降级处理。
降级处理时会通过caffeineCache.getIfPresent()获取降级后的本地缓存数据,如果缓存中没有数据,那么就通过获取锁去查询数据库,然后将数据库的查询结果存放在caffeineCache中。
(5)缓存雪崩解决方案总结
首先,通过AOP切面 + JdHotKey实现自动化探测Redis故障,也就是判断出Redis是否故障,并设置hotkey标记Redis已经故障。然后,根据hotkey + RateLimiter令牌桶,可以对接口进行故障时的限流。接着,限流降级后操作数据库时也需要通过CaffeineCache本地缓存进行降级。
6.缓存击穿的解决方法之分级缓存
采用一级缓存L1和二级缓存L2,L1缓存失效时间短,L2缓存失效时间长。
请求优先从L1缓存获取数据,如果L1缓存未命中则加锁。只有1个线程获取到锁,这个线程会读库 + 更新L1缓存 + 更新L2缓存,其他线程则依旧从L2缓存获取数据并返回。
这种方式,主要是通过避免缓存同时失效并结合锁机制实现。所以当更新数据时,只能删除L1缓存,不能将L1和L2中的缓存同时删除。L2缓存中可能会存在脏数据,需要能够容忍这种短时间的不一致,而且这种方案可能会造成额外的缓存空间浪费。
7.Redis热key的解决方式
这种场景的解决方式比较百花齐放,比较常见的有:
(1)使用二级缓存
读取到Redis的key-value信息后,就直接写入到JVM缓存多一份。同时设置JVM缓存过期时间,设置淘汰策略譬如队列满时淘汰最先加入的。或者使用Guava Cache或Caffeine Cache进行单机本地缓存。但是这种做法普遍整体命中率偏低。
(2)改写Redis源码加入热点探测功能
当Redis服务端发现有热key时就推送到JVM,但这种方法主要是不通用,而且有一定难度。
(3)改写Jedis、Letture等Redis客户端的jar
通过本地计算来探测热点key,如果发现是热key那么就在本地缓存起来,然后通知集群内其他机器。
8.大Value处理方案
步骤一:首先需要配置一个crontab定时调度shell脚本,然后该脚本每天凌晨会通过rdbtools⼯具解析Redis的RDB⽂件,接着对解析出的内容进行过滤,把RDB⽂件中的⼤key导出到CSV⽂件。
步骤二:使⽤SQL导⼊CSV⽂件到MySQL数据库中,同时使⽤Canal监听MySQL的binlog⽇志。
步骤三:Canal会发送增量的大key数据消息到RocketMQ,RocketMQ的消费者系统会对增量的大key数据消息进⾏消费,消息中便会包含⼤key的详情信息。这样消费者就可以将⼤key的信息通过邮件等⽅式,通知开发⼈员。
为什么要把⼤key的CSV⽂件导⼊到MySQL存储?为什么不直接监听⼤key的CSV⽂件进⾏通知?
原因一:如果不导⼊MySQL,就⽆法使⽤Canal监听。这样就要开发⼀个程序,定时去扫描Redis节点下解析出来的CSV⽂件。如果Redis集群中有多个节点,那么每⼀个节点都要去扫描。⽽将CSV导⼊到MySQL后,只需要使⽤Canal去监听MySQL表的binlog,就可以把增量数据同步到RocketMQ中,由消费者统⼀进⾏处理。
原因二:解析CSV⽂件⽐直接从MySQL中查询复杂很多,尤其是需要进行信息过滤。导⼊到MySQL后可以通过SQL轻松的对⼤key的记录进⾏条件筛选,并且可以对每天产⽣的⼤key数据进⾏存储分析。