目前,在工做中用到的分布式缓存技术主要是redis和memcached两种
缓存的目的是为了在高并发系统中有效的下降DB数据库的压力mysql
memcache服务器是没有集群概念的。全部的存储分发所有交给memcache client去作,这里使用的是xmemcached,这个客户端支持多种哈希策略,默认使用key与实例取模来进行简单的数据分片。redis
这种分片方式会致使一个问题,那就是新增或者减小节点会在一瞬间致使大量的key失效,最终致使缓存雪崩的发生,给DB数据库带来巨大的压力算法
因此最好使用memcache client使用xmemcached的一致性哈希算法,来进行数据分片,配置文件以下:sql
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一致性哈希算法进行数据分片
根据一致性哈希算法的特性,在新增或减小memcache的节点只会影响较少一部分的数据。可是这种模式下也意味着分配不均匀,新增的节点可能并不能及时达到均摊数据的效果,不过,memcache采用了虚拟节点的方式来优化原始一致性哈希算法(由ketama算法控制实现),实现新增物理节点后,也能够均摊数据的能力,成功解决节点新增带来的问题数据库
最后,memcache服务器是多线程处理模式
memcache一个value最大只能存储1M的数据
key-value存在一个过时时间,也存在一个当前时间(当前缓存的访问时间与距离上一次访问时间),全部的key-value过时后不会自动移除,而是下次访问时与当前时间作对比,过时时间小于当前时间则删除,若是一个key-value产生后就没有再次访问了,那么该数据将会一直存在于内存中,直到触发LRU
缓存
redis服务器有集群模式,key的路由交给redis服务器作处理,除此以外,redis还有主从配置来达到服务器的高可用 redis服务器是单线程处理模式,这也就意味着若是有一个指令致使redis处理过慢,就会阻塞其余指令的响应,因此redis禁止在生产中使用重量级操做(例如:缓存较大的key-value值致使传输过慢) redis服务器并无采用一致性哈希来作数据分片,而是采用了哈希槽的概念来作数据分片,一个redis cluster集群拥有0-16383,一共16384个槽位(slot),这些哈希槽按照编号区间的不一样,分布在不一样的节点上 假如一个key进来,经过内部哈希算法(CRC16),计算出槽的位置,再把value存进去,存取过程相同 redis在新增节点时,其实就是对这些哈希槽进行从新平均分配,新增节点也就意味着原先节点上的哈希槽的数量会变少,这些减掉的哈希槽被转移到这个新增的节点上,以此来实现槽位的平均分配,过程以下:
memcache提供简单的key-value存储,value最大能够存储1M的数据,多线程处理模式,不会出现某个指令处理过慢,而致使其余请求排队的状况,适合存储数据的文本信息 redis提供丰富的数据结构,服务器是单线程处理模式,虽然处理速度很快,可是若是有一次查询出现瓶颈,那么后续的操做将会被阻塞,因此相比key-value这种由于数据过大而致使网络交互产生瓶颈的结构来讲,他更适合处理一些数据结构的查询、排序、分页等操做,而且这些操做每每复杂度不高,且耗时极短,所以不太可能会阻塞redis的处理 使用这两种缓存技术来构建咱们的缓存数据,目前提倡全部数据按照标志性字段(例如id)组成本身的信息缓存存储,这个通常由memcache的key-value结构来完成存储 而redis提供了不少好用的数据结构,通常构建结构化的缓存数据都使用redis来构建保存数据的基本结构,而后组装数据时根据redis里缓存的标志性字段去memcache里查询具体数据,例如一个排行榜接口的获取:
上图中redis提供排行榜的就够存储,排行榜里存储的是id和score,经过redis能够获取结构的id(与名字一一对应),而后利用得到的id能够从memcache中查出详细信息(score),而后再交给redis作最后的数据处理(排序) 上图是通常的缓存的作法,建议每条数据都要有结构存储服务器和数据存储服务器,这样便于数据的处理与维护,而不是把一个接口的大量数据直接缓存到memcache或者redis里,这样粗糙的划分,日积月累下来每一个数据都有一个缓存,最终致使key愈来愈多,愈来愈复杂,不便于维护
redis若是做缓存使用,key始终会有过时时间的存在,若是到了过时时间,使用redis构建的索引将会消失,这个时候回源的话,若是存在大批量的数据须要构建redis索引,就会存在回源方法过慢的问题,下面以某个评论系统为例: 评论系统采用有序集合做为评论列表的索引,存储的是评论id,用于排序的score值(点赞数),若是按照排序维度拆分,好比发布时间、点赞数等,那么一个资源下的评论列表根据排序维度的不一样,存在多个redis索引列表,而具体评论内容存在memcache,缓存结构以下:
上图能够看到,当咱们访问一个资源的评论区的时候,每次触发读缓存都会顺带延长一次缓存的过时时间,这样能够保证较热的缓存内容不会轻易的过时,可是若是一个评论区时间过长没人去访问,redis索引就会过时,若是一个评论区有上万条评论数据长时间没有人访问,忽然有人去考古,那么在回源构建redis索引的时候就会很慢,若是没有控制措施,还会形成下面缓存穿透的问题,从而致使这种重量级操做反复被多个线程执行,对DB形成巨大的压力
对于上面这种回源构建索引缓慢的问题,处理方式以下:
相比直接执行回源方法,这种经过消息队列构造redis索引的方法更加适合,首先仅构建单页或者前面几页的索引数据,而后经过队列通知job(这里能够理解为消费者),进行完整索引构造,固然,这只适合对缓存一致性要求不高的场景bash
通常状况下,缓存内的数据要和数据库保持一致性,这就涉及到更新DB后,缓存数据的主动失效策略(通俗的说法是清缓存),大部分会通过以下过程:
假如如今有两个服务,服务A和服务B,如今假设服务A会触发某个数据的写操做,而服务B则是只读程序,数据被缓存在一个cache服务内,如今假设服务A更新了一次数据库,那么结合上图得出如下流程: 1.服务A触发更新数据库的操做 2.更新操做完成后,删除数据对应的缓存key 3.只读服务B读取缓存时,发现这个缓存miss 4.服务B读取数据库源信息 5.写入服务B的缓存,并返回对应的信息 这个过程乍一看没什么问题,可是多线程运转的程序每每会致使意想不到的后果,如今想象一下服务A和服务B同时被多个线程运行着,这个时候重复上述过程的话,就会出现数据一致性的问题
1.运行着服务A的线程1首先修改数据,而后删除缓存
2.运行着服务B的线程3缓存时发现miss,开始读取DB中的源数据,须要注意的是此次读出来的数据是线程1修改后的那份
3.这个时候运行着服务A的线程2开始运行,开始修改数据库,一样的删除缓存,须要注意的是,此次删除的实际上是一个空缓存,没有意义,由于原本线程3那边尚未回源完成
4.运行着服务B的线程3将读到的由线程1写的那份数据写进cache服务器
上述过程完成后,最终的结果就是DB里保存的最终数据是线程2写进去的那份,而cache通过线程3的回源后,保存的倒是线程1写的那份数据,数据缓存不一致的问题出现
流程图以下:
如今数据库读操做走从库,这个时候若是在主库写操做删除缓存后,因为主从同步有可能稍微慢于回源流程,致使读取从库时仍然会读到老数据,并把该数据的缓存从新写入cache
数据修改更新了原有的缓存结构,或去除几个属性,或新增几个属性,假如新需求是给某个缓存对象O新增一个属性B,若是新逻辑已经在预发布或者处于灰度中,就会出现生产环境回源后的缓存数据没有B属性的状况,而预发布和灰度发布时,新逻辑须要使用B属性,就会致使生产环境和预发布环境的缓存污染问题,过程大体以下:
缓存一致性问题大体分为如下几个解决方案,下面一一介绍
上图是如今经常使用的清除缓存策略,每次表发生变更,经过mysql产生的binlog去给消息队列发送变更消息,这里监听DB变更的服务由cache提供,canal能够简单理解成一个实现了mysql通讯协议的从库,经过mysql主从配置完成binlog同步,切只接受binlog,经过这种机制,就能够很天然的监听数控的数据变更了,能够保证每次数据库发生的变更,都会被顺序发往消费者去清除对应的缓存key
上面的过程能保证写库时清缓存的顺序问题,看似并无什么问题,可是生产环境每每存在主从分离的状况,也就是说上图中若是回源时读的是从库,那上面的过程仍然是存在一致性问题的
从库延迟致使的脏读问题,如何解决这类问题呢? 只须要将canal监听的数据库设置成从库便可,保证在canal推送过来消息时,全部的从库和主库彻底一致,不过这只针对一主一从的状况,若是一主多从,且回源读取的从库有多个,那么上述也是存在必定的风险的(一主多从须要订阅每一个从节点的binlog,找出最后发过来的那个节点,而后清缓存,确保全部的从节点所有和主节点一致)。 不过,正常状况下,从库binlog的同步速度都要比canal发消息快,由于canal要接收binlog,而后组装数据变更实体(这一步是有额外开销的),而后经过消息队列推送给各消费者(这一步也是有开销的),因此即使是订阅的master库的表变动,出问题的几率也极小
针对上面的一致性问题(缓存污染),修改某个缓存结构可能致使在预发或者灰度中状态时和实际生产环境的缓存相互污染,这个时候建议每次更新结构时都进行一次key升级(好比在原有的key名称基础上加上_v2的后缀)。 binlog是否真的是准确无误的呢?
并非,好比上面的状况: 1.首先线程1走到服务A,写DB,发binlog删除缓存 2.而后线程3运行的服务B这时cache miss,而后读取DB回源(这时读到的数据是线程1写入的那份数据) 3.此时线程2再次触发服务A写DB,一样发送binlog删除缓存 4.最后线程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服务后返回。 这个过程看起来也没什么问题,可是某些状况下,根据带进来的参数,在数据库里并不能找到对应的信息,这个时候每次带有这种参数的请求,都会走到数据库回源,这种现象叫作缓存穿透,比较典型的出现这种问题的状况有: 1.恶意攻击或者爬虫,携带数据库里本就不存在的数据作参数回源 2.公司内部别的业务方调用我方的接口时,因为沟通不当或其余缘由致使的参数大量误传 3.客户端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