多级缓存架构当中每一层的意义
ngx_lua
属于 nginx
的一部分,它的执行指令都包含在 nginx
的11个步骤之中了,相应的处理阶段可以做插入式处理,即可插拔式架构,不过 ngx_lua
并不是所有阶段都会运行的;另外指令可以在 http、server、server if、location、location if
几个范围进行配置
1. nginx本地缓存
nginx本地缓存,抗的是热数据的高并发访问,根据场景设置缓存时间商品的购买总是有热点的,比如每天购买的iphone,nike海尔等知名品牌的东西的人,总是比较多的,这些热数据,利用nginx本地缓存,由于经常被访问,所以可以锁定在nginx的本地缓存内大量的热数据的访问,就是经常会访问的那些数据,就会被保留在nginx本地缓存内,那么对这些热数据的大量访问,就直接走nginx就可以了,不需要走后续的各种网络开销了.
2. redis分布式缓存
如果nginx本地缓存不命中,则nginx会访问redis集群服务,redis集群服务,抗的是很高的离散访问,因为redis的性能问题,能够支撑海量的数据,高并发的访问,提供高可用的服务.
nginx本地内存有限,也就能cache住部分热数据,除了热点数据,其他相对不那么热的数据,可能流量会经常走到redis那里,利用redis cluster的多master写入,横向扩容,1T+以上海量数据支撑,几十万的读写并发,99.99%高可用性,那么就可以抗住大量的离散访问请求.
3.多级缓存架构设计需要解决的问题
这里列了一些
- 数据库+缓存双写一致性解决方案
面临难题:高并发场景下,如何解决数据库与缓存双写的时候数据不一致的情况?
2.缓存维度化拆分解决方案
面临难题:如何解决大value缓存的全量更新效率低下问题?
3.缓存命中率提升解决方案
面临难题:如何提升缓存命中率?
4.缓存并发重建冲突解决方案
面临难题:如何解决高并发场景下,缓存重建时的分布式并发重建的冲突问题?
5.缓存预热解决方案
面临难题:如何解决高并发场景下,缓存冷启动导致MySQL负载过高,甚至瞬间被打死的问题?
6.缓存雪崩解决方案
面临难题:如何解决恐怖的缓存雪崩问题?避免给公司带来巨大的经济损失?
7.缓存穿透解决方案
面临难题:如何解决高并发场景下的缓存穿透问题?避免给Mysql带来过大的压力?
8缓存失效解决方案
面临难题:如何解决高并发场景下的缓存失效问题?避免给redis集群带来过大的压力?
多级缓存、缓存维度划分、数据聚合、动态渲染
1.多级缓存实现
一般来说,缓存有两个原则.
- 越靠近用户的请求越好.比如,能用本地缓存的就不要发送HTTP请求,能用CDN缓存的就不要打到源站,能用OpenResty缓存的就不要打到数据库.
- 尽量使用本进程和本机的缓存解决.因为跨了进程和机器甚至机房,缓存的网络开销就会非常大,这一点在高并发的时候会非常明显.
自然,在OpenResty中, 缓存的设计和使用也遵循这两个原则,OpenResty中有两个缓存的组件:shared dict缓存和lru缓存. 前者只能缓存字符串对象,缓存的数据有且仅有一份,每一个worker都可以进行访问,所以常用于worker之间的数据通信,后者则可以缓存所有的lua对象,但只能在单个worker进程内访问,有多少个worker,就会有多少份缓存数据.
shared dict 和 lru 缓存的区别
先查看本地是否存在,如果存在直接返回,如果不存在再去查询redis,如果redis当中还不存在,就请求到源服务器,这就是跨机器,跨网络的多级请求,本地实际上也可以利用字典及lru实现多级缓存
2.缓存击穿问题
缓存击穿,是指某个极度热点
数据在某个时间点过期时,恰好在这个时间点对这个KEY有大量的并发请求过来,这些请求发现缓存过期一般都会从DB加载数据并回设到缓存,但是这个时候大并发的请求可能会瞬间压垮DB.
先来设想下面一个场景.数据源在MySQL
数据库中,缓存的数据放在共享字典中,超时时间为1分钟.在这1分钟内的时间里,所有的请求都从缓存中获取数据,MySQL没有任何的压力.但是,一旦到达一分钟,也就是缓存数据失效的那一刻,如果正好有大量的并发请求进来,在缓存中没有查询到结果,就要出发查询数据源的函数,那么这些请求全部都将去查询Mysql数据库,直接造成数据库服务卡顿,甚至卡死.
对于一些设置了过期时间的KEY,如果这些KEY可能会在某些时间点被超高并发地访问,是一种非常热点
的数据,这个时候,需要考虑这个问题.
如何避免缓存击穿?
- 主动更新缓存:默认缓存是被动更新的.只有在中端请求发现缓存失效时,它才会去数据库查询新的数据.那么,如果我们把缓存的更新,从被动改为主动,也就可以直接绕开缓存风暴的问题了,在
OpenResty
中,我们可以使用ngx.timer.every
来创建一个定时器去定时更新.
缺点:每一个缓存都要对应一个周期性的任务:而且缓存过期时间和计划任务的周期时间还要对应好,如果这中间出现了什么纰漏,终端就可能一直获取到的都是空数据. - 使用互斥锁:请求发现缓存不存在后,去查询DB前,使用锁,保证有且仅有一个请求去查询DB,并更新到缓存.流程如下:
- 获取锁,直到成功或超时.如果超时,则抛出异常,返回.如果成功,则继续向下执行.
- 再去缓存中.如果存在值,则直接返回;如果不存在,则继续往下执行,如果成功获取到锁的话,就可以保证只有一个请求去数据源更新数据,并更新到缓存中了.
- 查询DB,并更新到缓存中,返回值.
OpenResty
可以利用lua-resty-lock
加锁,利用的是OpenResty
自带的resty
库,它底层是基于共享字典
,提供非阻塞的lock API
不过,在上面lua-resty-lock的实现中,你需要自己来处理加锁,解锁,获取过期数据,重试,异常处理等各种问题,还是相当繁琐的,我们可以使用lua-resty-mlcache,看下图
Redis缓存穿透
缓存穿透是指查询一个一定不存在的数据,由于缓存不命中,并且处于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次都请求到要到存储层去查询,失去了缓存的意义.
- 危害:对底层数据源(mysql,hbase,http接口,rpc调用等等)压力过大,有些底层数据源不具备高并发性.
- 原因: 可能是代码本身或者数据存在的问题造成的,也很有可能是一些恶意攻击,爬虫等等(因为http读接口都是开放的)
- 如何发现:可以分别记录cache命中数,以及总调用量,如果发现空命中(cache都没有命中)较多,则可能就会是缓存穿透问题.
解决思路:
1.缓存空对象
如果一个查询返回的数据为空,(不管是数据不存在,还是系统故障),我们仍然把这个空结果进行缓存(有一个比较巧妙的做法是,可以将这个不存在的key预先设定一个特定值.)但它的过期时间会很短,最长不超过5分钟.
- 适用场景: 数据命中不高,数据频繁变化实时性高
- 维护成本: 代码比较简单,但是有两个问题:
第一:空值做了缓存,意味着缓存层中存了更多的键,需要更多的内存空间(如果是攻击,问题更严重),比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除.
第二:缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响.例如过期时间设置为5分钟,如果此时存储层添加了这个数据,那此段时间就会出现缓存层和存储数据的不一致,此时可以利用消息系统或者其他方式清除掉缓存层中的空对象.
- bloomfilter提前拦截
通常如果想判断一个元素是不是一个集合里,一般想到的是将集合中所有元素保存起来,然后通过比较确定.链表,树,散列表(又叫哈希表,Hash table)等等数据结构都是这种思路.但是随着集合中元素的增加,我们需要的存储空间越来越大.同时检索速度也越来越慢.
布隆过滤器原理
布隆过滤器的原理是,当一个元素被加入集合时,通过K个Hash函数将这个元素映射成一个位数组中的K个点,把它们置为1.检索时,我们只要看看这些点是不是都是1就(大约)知道集合中有没有它了:如果这些点有任何一个0,则被检元素一定不在,如果都是1,则被检元素很可能在.这就是布隆过滤器的基本思想.
数组的容量即使再大,也是有限的.那么随着元素的增加,插入的元素就会越来越多,位数组中被置为1的位置因此也越来越多,这就会造成一种情况:当一个不在布隆过滤器中的元素,经过同样规则的哈希计算之后,得到的值在位数组中查询,有可能这些位置因为之前其它元素的操作先被置为1了,所以,有可能一个不存在布隆过滤器中的会被误判断成在布隆过滤器中,这就是布隆过滤器的一个缺陷,但是,如果布隆过滤器判断某个元素不在布隆过滤器中,那么这个值就一定不在布隆过滤器中.
总结就是:
布隆过滤器说某个元素在,可能会被误判, 布隆过滤器说某个元素不在,那么一定不在
对所有可能查询的参数以hash形式存储,在控制层先进行校验,不符合则丢弃.它的优点是空间效率和查询时间都比较一般的算法要好的多,缺点是有一定的误判问题.
适用场景:数据命中不高,数据相对固定实时性低(通常是数据集较大),比如爬虫URL去重,邮箱系统的垃圾邮件过滤,消息推送系统的用户推送过滤
Redis中的布隆过滤器:
https://github.com/RedisBloom/RedisBloom
Redis官方提供的布隆过滤器到了Redis4.0提供了插件功能之后才正式登场.布隆过滤器作为一个插件加载到Redis Server中,给Redis提供了强大的布隆去重功能.
布隆过滤器有两个基本指令,bf.add添加元素,bf.exists查询元素是否存在,它的用法和set集合的sadd和sismember差不多.注意bf.add只能一次添加一个元素,如果想要一次添加多个,就需要用到bf.madd指令.同样如果需要一次查询多个元素是否存在,就需要用到bf.mexists指令.
Redis还提供了自定义参数的布隆过滤器,需要在add之前使用bf.reserve指令显式创建,否则会使用默认配置
bf.reserve过滤器名error_rate initial_size
布隆过滤器存在误判的情况,在Redis中有两个值决定布隆过滤器的准确率:
- error_rate: 允许布隆过滤器的错误率,这个值越低过滤器的位数组的大小越大,占用空间也就越大
- initial_size: 布隆过滤器可以储存的元素个数,当实际存储的元素个数超过这个值之后,过滤器的准确率会下降
Redis集群当中使用hash Tag让key分配在某个节点
Hash Tag原理是:当一个key包含{}的时候,不对整个key做hash,而仅对{}包括的字符串做hash.hash Tag可以让不同的key拥有相同的hash值,从而分配在同一个槽里;这样针对不同key的批量操作(mget/mset等);以及事务,Lua脚本等都可以支持.
分布式重建缓存的并发冲突问题
现在我们的逻辑是Lua代码里会先去worker内存中取数据,再到redis中取数据,如果redis取不到,就会去源站获取,如果还是取不到,就需要重建缓存了.但是重建缓存有一个问题,因为我们的服务可能是多实例的,虽然在nginx层我们通过流量分发将请求通过id分发到了不同的nginx应用层上.
那么到了接口服务层,可能多次请求访问的是不同的实例,那么可能会导致多个机器去重建读取相同的数据,然后写入缓存中,这就有了分布式重建缓存的并发冲突问题.
问题:可能2个实例获取到的数据快照不一样,但是新数据先写入缓存,如果这个时候另外一个实例的缓存后写入,就有问题了.
解决方案:(使用分布式锁)
- 变更缓存重建更新redis之前,都需要先对获取对应商品id的分布式锁
- 拿到分布式锁之后,需要根据时间版本去比较一下,如果自己的版本新于redis中的版本,那么就更新,否则就不更新
- 如果拿不到分布式锁,那么就等待,不断轮询等待,直到自己获取到分布式锁
分布式锁的基本条件
首先, 为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下几个条件:
- 1.互斥性.在任意时刻,只有一个客户端能持有锁.
-
- 不会发生死锁.即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁.
-
- 解铃还需系铃人.加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了,即不能误解锁.
缓存维度化拆分解决方案
面临难题:如何解决大value缓存的全量更新效率低下问题?
为什么需要维度化?
一般的业务场景,会对一些页面的数据全量打包为一个kv,比如说商品详情页面,会把商品的基本信息,店家商铺信息,商品分类信息等这些信息组成的json全部塞到一个kv中,然后,当我们对其中的某项数据进行修改的时候(比如说修改了商品的分类),需要把这个kv取出来,修改里面的数据,然后才是放到redis中,很明显这样操作是对redis造成很大的性能影响的,每一次小更新,都需要去操作这个比较大的kv值,对redis造成了一定的压力,就会出现以下问题
- 网络耗费的资源大,每次需要把真个缓存获取,修改,网络传输耗时为主要消耗
-
redis的性能和吞吐量能够支撑到多大,基本跟数据本身的大小有很大关系
下图时redis在大value下的表现
前端展示可以分为这么几个维度: 商品维度(标题、图片、属性等)、 主商品维度(商品介绍、规格参数)、 分类维度、 商家维度、 店铺维度等; 另外还有一
些实时性要求比较高的如实时价格、实时促销、广告词、配送至、预售等是通过异步加载。
Redis缓存和DB的一致性问题
1)产生原因
主要有两种情况,会导致缓存和 DB 的一致性问题:
缓存和 DB 的操作,不在一个事务中,可能只有一个操作成功,而另一个操作失败,导致不一致。
我们讨论二种更新策略:
- 先更新数据库,再更新缓存
- 先删除缓存,再更新数据库
1)先更新数据库,再更新缓存 这套方案,我们不考虑
问题:同时有请求A和请求B进行更新操作,那么会出现
(1)A更新了数据库
(2)B更新了数据库
(3)B更新了缓存
(4)A更新了缓存
这就出现请求A更新缓存应该比请求B更新缓存早才对,但是因为网络等原因,B却比A更早更新了缓存。这就导致了脏数据
- 先删缓存,再更新数据库
我们会基于这个方案去实现缓存更新,但是不代表这个方案在并发情况下没问题
该方案会导致不一致的原因是。同时有一个请求A进行更新操作,另一个请求B进行查询操作。那么会出现如下情形:
(1)请求A进行写操作,删除缓存
(2)请求B查询发现缓存不存在
(3)请求B去数据库查询得到旧值
(4)请求B将旧值写入缓存
(5)请求A将新值写入数据库
上述情况就会导致不一致的情形出现
- 解决方案(数据库与缓存更新与读取操作进行异步串行化)
更新数据的时候,将操作任务,发送到一个队列中。读取数据的时候,如果发现数据不在缓存中,那么将重新读取数据+更新缓存的操作任务,也发送同一个队列中。 每个个队列可以对应多个消费者,每个队列拿到对应的消费者,然后一条一条的执行。