目前工做中用到的分布式缓存技术有redis
和memcached
两种,缓存的目的是为了在高并发系统中有效下降DB
的压力,可是在使用的时候可能会由于缓存结构设计不当形成一些问题,这里会把可能遇到的坑整理出来,方便往后查找。java
Memcache
(下面简称mc)服务端是没有集群概念的,全部的存储分发所有交由mc client
去作,我这里使用的是xmemcached
,这个客户端支持多种哈希策略,默认使用key
与实例数取模来进行简单的数据分片。mysql
这种分片方式会致使一个问题,那就是新增或者减小节点后会在一瞬间致使大量key失效,最终致使缓存雪崩的发生,给DB带来巨大压力,因此咱们的mc client
启用了xmemcached
的一致性哈希算法来进行数据分片:redis
XMemcachedClientBuilder builder = new XMemcachedClientBuilder(AddrUtil.getAddresses(servers));
builder.setOpTimeout(opTimeout);
builder.setConnectTimeout(connectTimeout);
builder.setTranscoder(transcoder);
builder.setConnectionPoolSize(connectPoolSize);
builder.setKeyProvider(keyProvider);
builder.setSessionLocator(new KetamaMemcachedSessionLocator()); //启用ketama一致性哈希算法进行数据分片
复制代码
根据一致性哈希算法的特性,在新增或减小mc
的节点只会影响较少一部分的数据。但这种模式下也意味着分配不均匀,新增的节点可能并不能及时达到均摊数据的效果,不过mc采用了虚拟节点的方式来优化原始一致性哈希算法(由ketama
算法控制实现),实现了新增物理节点后也能够均摊数据的能力。算法
最后,mc
服务端是多线程处理模式,mc
一个value
最大只能存储1M
的数据,全部的k-v
过时后不会自动移除,而是下次访问时与当前时间作对比,过时时间小于当前时间则删除,若是一个k-v
产生后就没有再次访问了,那么数据将会一直存在在内存中,直到触发LRU
。sql
redis服务端有集群模式,key
的路由交由redis服务端作处理,除此以外redis有主从配置以达到服务高可用。数据库
redis服务端是单线程处理模式,这意味着若是有一个指令致使redis处理过慢,会阻塞其余指令的响应,因此redis禁止在生产环境使用重量级操做(例如keys
,再例如缓存较大的值致使传输过慢)缓存
redis服务端并无采用一致性哈希来作数据分片,而是采用了哈希槽的概念来作数据分片,一个redis cluster
总体拥有16384
个哈希槽(slot
),这些哈希槽按照编号区间的不一样,分布在不一样节点上,而后一个key
进来,经过内部哈希算法(CRC16(key))计算出槽位置;服务器
而后将数据存放进对应的哈希槽对应的空间,redis在新增或者减小节点时,其实就是对这些哈希槽进行从新分配,以新增节点为例,新增节点意味着原先节点上的哈希槽区间会相对缩小,被减去的那些哈希槽里的数据将会顺延至下一个对应节点,这个过程由redis服务端协调完成,过程以下:markdown
迁移过程是以槽为单位,将槽内的
key
按批次进行迁移的(migrate)。网络
mc提供简单的k-v
存储,value最大能够存储1M的数据,多线程处理模式,不会出现由于某次处理慢而致使其余请求排队等待的状况,适合存储数据的文本信息。
redis提供丰富的数据结构,服务端是单线程处理模式,虽然处理速度很快,可是若是有一次查询出现瓶颈,那么后续的操做将被阻塞,因此相比k-v
这种可能由于数据过大而致使网络交互产生瓶颈的结构来讲,它更适合处理一些数据结构的查询、排序、分页等操做,这些操做每每复杂度不高,且耗时极短,所以不太可能会阻塞redis的处理。
使用这两种缓存服务来构建咱们的缓存数据,目前提倡全部数据按照标志性字段(例如id)组成本身的信息缓存存储,这个通常由mc的k-v结构来完成存储。
而redis提供了不少好用的数据结构,通常构建结构化的缓存数据都使用redis的数据结构来保存数据的基本结构,而后组装数据时根据redis里缓存的标志性字段去mc里查询具体数据,例如一个排行榜接口的获取:
上图redis提供排行榜的结构存储,排行榜里存储的是id
和score
,经过redis
能够获取到结构内全部信息的id
,而后利用得到的id
能够从mc
中查出详细信息,redis
在这个过程负责分页、排序,mc
则负责存储详细信息。
上面是比较合适的缓存作法,建议每条数据都有一个本身的基本缓存数据,这样便于管理,而不是把一个接口的巨大结构彻底缓存到mc或者redis里,这样划分太粗,日积月累下来每一个接口或者巨大方法都有一个缓存,key会愈来愈多,愈来愈杂。
Redis若是作缓存使用,始终会有过时时间存在,若是到了过时时间,使用redis构建的索引将会消失,这个时候回源,若是存在大批量的数据须要构建redis索引,就会存在回源方法过慢的问题,这里以某个评论系统为例;
评论系统采用有序集合做为评论列表的索引,存储的是评论id
,用于排序的score
值则按照排序维度拆分,好比发布时间、点赞数等,这也意味着一个资源下的评论列表根据排序维度不一样存在着多个redis索引列表,而具体评论内容存mc,正常状况下结构以下:
上面是正常触发一个资源的评论区,每次触发读缓存,都会顺带延长一次缓存的过时时间,这样能够保证较热的内容不会轻易过时,可是若是一个评论区时间过长没人访问过,redis索引就会过时,若是一个评论区有数万条评论数据,长时间没人访问,忽然有人过去考古,那么在回源构建redis索引时会很缓慢,若是没有控制措施,还会形成下面缓存穿透的问题,从而致使这种重量级操做反复被多个线程执行,对DB形成巨大压力。
对于上面这种回源构建索引缓慢的问题,处理方式能够是下面这样:
相比直接执行回源方法,这种经过消息队列构造redis索引的方法更加适合,首先仅构建单页或者前面几页的索引数据,而后经过队列通知job(这里能够理解为消费者)进行完整索引构造,固然,这只适合对一致性要求不高的场景。
通常状况下缓存内的数据要和数据库源数据保持一致性,这就涉及到更新DB后主动失效缓存策略(通俗叫法:清缓存),大部分会通过以下过程:
假如如今有两个服务,服务A
和服务B
,如今假设服务A会触发某个数据的写操做,而服务B
则是只读程序,数据被缓存在一个Cache
服务内,如今假如服务A
更新了一次数据库,那么结合上图得出如下流程:
服务A触发更新数据库的操做
更新完成后删除数据对应的缓存key
只读服务(服务B)读取缓存时发现缓存miss
服务B
读取数据库源信息
写入缓存并返回对应信息
这个过程乍一看是没什么问题的,可是每每多线程运转的程序会致使意想不到的结果,如今来想象下服务A和服务B被多个线程运行着,这个时候重复上述过程,就会存在一致性问题。
运行着服务A
的线程1首先修改数据,而后删除缓存
运行着服务B
的线程3读缓存时发现缓存miss
,开始读取DB
中的源数据,须要注意的是此次读出来的数据是线程1修改后的那份
这个时候运行着服务A
的线程2上线,开始修改数据库,一样的,删除缓存,须要注意的是,此次删除的实际上是一个空缓存,没有意义,由于原本线程3那边尚未回源完成
运行着服务B
的线程3将读到的由线程1写的那份数据回写进Cache
上述过程完成后,最终结果就是DB
里保存的最终数据是线程2写进去的那份,而Cache
通过线程3的回源后保存的倒是线程1写的那份数据,不一致问题出现。
这种状况要稍微修改下程序的流程图,多出一个从库:
如今读操做走从库,这个时候若是在主库写操做删除缓存后,因为主从同步有可能稍微慢于回源流程触发,回源时读取从库仍然会读到老数据。
每次作新需求时更新了原有的缓存结构,或去除几个属性,或新增几个属性,假如新需求是给某个缓存对象O
新增一个属性B
,若是新逻辑已经在预发或者处于灰度中,就会出现生产环境回源后的缓存数据没有B
属性的状况,而预发和灰度时,新逻辑须要使用B
属性,就会致使生产&预发缓存污染。过程大体以下:
缓存一致性问题大体分为如下几个解决方案,下面一一介绍。
上图是如今经常使用的清缓存策略,每次表发生变更,经过mysql产生的binlog
去给消息队列发送变更消息,这里监听DB
变更的服务由canal提供,canal
能够简单理解成一个实现了mysql通讯协议的从库,经过mysql主从配置完成binlog
同步,且它只接收binlog,经过这种机制,就能够很天然的监听数据库表数据变更了,能够保证每次数据库发生的变更,都会被顺序发往消费者去清除对应的缓存key
。
上面的过程能保证写库时清缓存的顺序问题,看似并无什么问题,可是生产环境每每存在主从分离的状况,也就是说上面的图中若是回源时读的是从库,那上面的过程仍然是存在一致性问题的:
从库延迟致使的脏读问题,如何解决这类问题呢?
只须要将canal
监听的数据库设置成从库便可,保证在canal
推送过来消息时,全部的从库和主库彻底一致,不过这只针对一主一从的状况,若是一主多从,且回源读取的从库有多个,那么上述也是存在必定的风险的(一主多从须要订阅每一个从节点的binlog
,找出最后发过来的那个节点,而后清缓存,确保全部的从节点所有和主节点一致)。
不过,正常状况下,从库binlog
的同步速度都要比canal
发消息快,由于canal
要接收binlog
,而后组装数据变更实体(这一步是有额外开销的),而后经过消息队列推送给各消费者(这一步也是有开销的),因此即使是订阅的master
库的表变动,出问题的几率也极小。
针对上面的一致性问题(缓存污染),修改某个缓存结构可能致使在预发或者灰度中状态时和实际生产环境的缓存相互污染,这个时候建议每次更新结构时都进行一次key升级(好比在原有的key名称基础上加上_v2的后缀)。
⚡⚡⚡binlog是否真的是准确无误的呢?⚡⚡⚡
并非,好比上面的状况:
首先线程1
走到服务A
,写DB
,发binlog
删除缓存
而后线程3
运行的服务B这时cache miss
,而后读取DB
回源(这时读到的数据是线程1写入的那份数据)
此时线程2
再次触发服务A
写DB
,一样发送binlog
删除缓存
最后线程3
把读到的数据写入cache
,最终致使DB里存储的是线程2
写入的数据,可是cache
里存储的倒是线程1
写入的数据,不一致达成
这种状况比较难以触发,由于极少会出现线程3
那里写cache
的动做会晚于第二次binlog
发送的,除非在回源时作了别的带有阻塞性质的操做;
因此根据现有的策略,没有特别完美的解决方案,只能尽量保证一致性,但因为实际生产环境,处于多线程并发读写的环境,即使有binlog
作最终的保证,也不能保证最后回源方法写缓存那里的顺序性。除非回源所有交由binlog
消费者来作,不过这本就不太现实,这样等于说服务B没有回源方法了。
针对这个问题,出现几率最大的就是那种写并发几率很大的状况,这个时候伴随而来的还有命中率问题。
经过前面的流程,抛开特殊因素,已经解决了一致性的问题,但随着清缓存而来的另外一个问题就是命中率问题。
好比一个数据变动过于频繁,以致于产生过多的binlog
消息,这个时候每次都会触发消费者的清缓存操做,这样的话缓存的命中率会瞬间降低,致使大部分用户访问直接访问DB;
并且这种频繁变动的数据还会加大问题①
出现的几率,因此针对这种频繁变动的数据,再也不删除缓存key
,而是直接在binlog
消费者那里直接回源更新缓存,这样即使表频繁变动,用户访问时每次都是消费者更新好的那份缓存数据,只是这时候消费者要严格按照消息顺序来处理;
不然也会有写脏的危险,好比开两个线程同时消费binlog消息,线程1接收到了第一次数据变动的binlog,而线程2接收到了第二次数据变动的binlog,这时线程1读出数据(旧数据),线程2读出数据(新数据)更新缓存,而后线程1再执行更新,这时缓存又会被写脏;
因此为了保证消费顺序,必须是单线程处理,若是想要启用多线程均摊压力,能够利用key
、id
等标识性字段作任务分组,这样同一个id
的binlog
消息始终会被同一个线程执行。
正常状况下用户请求一个数据时会携带标记性的参数(好比id),而咱们的缓存key则会以这些标记性的参数来划分不一样的cache value,而后咱们根据这些参数去查缓存,查到就返回,不然回源,而后写入cache服务后返回。
这个过程看起来也没什么问题,可是某些状况下,根据带进来的参数,在数据库里并不能找到对应的信息,这个时候每次带有这种参数的请求,都会走到数据库回源,这种现象叫作缓存穿透,比较典型的出现这种问题的状况有:
恶意攻击或者爬虫,携带数据库里本就不存在的数据作参数回源
公司内部别的业务方调用我方的接口时,因为沟通不当或其余缘由致使的参数大量误传
客户端bug致使的参数大量误传
目前咱们提倡的作法是回源查不到信息时直接缓存空数据(注意:空数据缓存的过时时间要尽量小,防止无心义内容过多占用Cache内存),这样即使是有参数误传、恶意攻击等状况,也不会每次都打进DB。
可是目前这种作法仍然存在被攻击的风险,若是恶意攻击时携带少许参数还好,这样不存在的空数据缓存仅仅会占用少许内存,可是若是攻击者使用大量穿透攻击,携带的参数千奇百怪,这样就会产生大量无心义的空对象缓存,使得咱们的缓存服务器内存暴增。
这个时候就须要服务端来进行简单的控制:按照业务内本身的估算,合理的id
大体在什么范围内,好比按照用户id作标记的缓存,就直接在获取缓存前判断所传用户id参数是否超过了某个阈值,超过直接返回空。(好比用户总量才几十万或者上百万,结果用户id传过来个几千万甚至几亿明显不合理的状况)
缓存击穿是指在一个key
失效后,大量请求打进回源方法,多线程并发回源的问题。
这种状况在少许访问时不能算做一个问题,可是当一个热点key
失效后,就会发生回源时涌进过多流量,所有打在DB上,这样会致使DB在这一时刻压力剧增。
回源方法内追加互斥锁:这个能够避免屡次回源,可是n台实例群模式下,仍然会存在实例并发回源的状况,这个量级相比以前大量打进,已经大量下降了。
回源方法内追加分布式锁:这个能够彻底避免上面多实例下并发回源的状况,可是缺点也很明显,那就是又引入了一个新的服务,这意味着发生异常的风险会加大。
缓存雪崩是指缓存数据某一时刻出现大量失效的状况,全部请求所有打进DB,致使短时间内DB负载暴增的问题,通常来讲形成缓存雪崩有如下几种状况:
缓存服务扩缩容:这个是由缓存的数据分片策略的而致使的,若是采用简单的取模运算进行数据分片,那么服务端扩缩容就会致使雪崩的发生。
缓存服务宕机:某一时刻缓存服务器出现大量宕机的状况,致使缓存服务不可用,根据现有的实现,是直接打到DB上的。
缓存服务端的高可用配置:上面mc
和redis
的分片策略已经说过,因此扩缩容带来的雪崩概率很小,其次redis服务实现了高可用配置:启用cluster
模式,一主一从配置。因为对一致性哈希算法的优化,mc
宕机、扩缩容对总体影响不大,因此缓存服务器服务端自己目前是能够保证良好的可用性的,尽量的避免了雪崩的发生(除非大规模宕机,几率很小)。
数据分片策略调整:调整缓存服务器的分片策略,好比上面第一部分所讲的,给mc开启一致性哈希算法的分片策略,防止缓存服务端扩缩容后缓存数据大量不可用。
回源限流:若是缓存服务真的挂掉了,请求全打在DB
上,以致于超出了DB
所能承受之重,这个时候建议回源时进行总体限流,被限到的请求紫自动走降级逻辑,或者直接报错。
了解了缓存服务端的实现,能够知道某一个肯定的key始终会落到某一台服务器上,若是某个key在生产环境被大量访问,就致使了某个缓存服务节点流量暴增,等访问超出单节点负载,就可能会出现单点故障,单点故障后转移该key的数据到其余节点,单点问题依旧存在,则可能继续会让被转移到的节点也出现故障,最终影响整个缓存服务集群。
key
,生成多个副本key
,这样能够保证热点key会被多个缓存服务器持有,而后回源方法公用一个,请求时按照必定的算法随机访问某个副本key。若是以为文章不错,点个赞分享转发下,谢谢支持!!!