目录
背景
目前,在工作中用到的分布式缓存技术主要是redis和memcached两种
缓存的目的是为了在高并发系统中有效的降低DB数据库的压力
缓存服务器的选型和特点
1.memcache服务器
memcache服务器是没有集群概念的。所有的存储分发全部交给memcache client去做,这里使用的是xmemcached,这个客户端支持多种哈希策略,默认使用key与实例取模来进行简单的数据分片。
这种分片方式会导致一个问题,那就是新增或者减少节点会在一瞬间导致大量的key失效,最终导致缓存雪崩的发生,给DB数据库带来巨大的压力
所以最好使用memcache client使用xmemcached的一致性哈希算法,来进行数据分片,配置文件如下:
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
2.redis服务器
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构造大索引的回源问题
redis如果作缓存使用,key始终会有过期时间的存在,如果到了过期时间,使用redis构建的索引将会消失,这个时候回源的话,如果存在大批量的数据需要构建redis索引,就会存在回源方法过慢的问题,下面以某个评论系统为例:
评论系统采用有序集合作为评论列表的索引,存储的是评论id,用于排序的score值(点赞数),如果按照排序维度拆分,比如发布时间、点赞数等,那么一个资源下的评论列表根据排序维度的不同,存在多个redis索引列表,而具体评论内容存在memcache,缓存结构如下:
上图可以看到,当我们访问一个资源的评论区的时候,每次触发读缓存都会顺带延长一次缓存的过期时间,这样可以保证较热的缓存内容不会轻易的过期,但是如果一个评论区时间过长没人去访问,redis索引就会过期,如果一个评论区有上万条评论数据长时间没有人访问,突然有人去考古,那么在回源构建redis索引的时候就会很慢,如果没有控制措施,还会造成下面缓存穿透的问题,从而导致这种重量级操作反复被多个线程执行,对DB造成巨大的压力
对于上面这种回源构建索引缓慢的问题,处理方式如下:
相比直接执行回源方法,这种通过消息队列构造redis索引的方法更加适合,首先仅构建单页或者前面几页的索引数据,然后通过队列通知job(这里可以理解为消费者),进行完整索引构造,当然,这只适合对缓存一致性要求不高的场景
缓存一致性问题
一般情况下,缓存内的数据要和数据库保持一致性,这就涉及到更新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写的那份数据,数据缓存不一致的问题出现
2.主从同步延时导致的一致性问题
流程图如下:
现在数据库读操作走从库,这个时候如果在主库写操作删除缓存后,由于主从同步有可能稍微慢于回源流程,导致读取从库时仍然会读到老数据,并把该数据的缓存重新写入cache
3.缓存污染导致的一致性问题
数据修改更新了原有的缓存结构,或去除几个属性,或新增几个属性,假如新需求是给某个缓存对象O新增一个属性B,如果新逻辑已经在预发布或者处于灰度中,就会出现生产环境回源后的缓存数据没有B属性的情况,而预发布和灰度发布时,新逻辑需要使用B属性,就会导致生产环境和预发布环境的缓存污染问题,过程大致如下:
如何解决缓存一致性问题
缓存一致性问题大致分为以下几个解决方案,下面一一介绍
1.binlog+纤细队列+消费者del cache
上图是现在常用的清除缓存策略,每次表发生变动,通过mysql产生的binlog去给消息队列发送变动消息,这里监听DB变动的服务由cache提供,canal可以简单理解成一个实现了mysql通信协议的从库,通过mysql主从配置完成binlog同步,切只接受binlog,通过这种机制,就可以很自然的监听数控的数据变动了,可以保证每次数据库发生的变动,都会被顺序发往消费者去清除对应的缓存key
2.从库binlog+消息队列+消费者del cache
上面的过程能保证写库时清缓存的顺序问题,看似并没有什么问题,但是生产环境往往存在主从分离的情况,也就是说上图中如果回源时读的是从库,那上面的过程仍然是存在一致性问题的
从库延迟导致的脏读问题,如何解决这类问题呢?
只需要将canal监听的数据库设置成从库即可,保证在canal推送过来消息时,所有的从库和主库完全一致,不过这只针对一主一从的情况,如果一主多从,且回源读取的从库有多个,那么上述也是存在一定的风险的(一主多从需要订阅每个从节点的binlog,找出最后发过来的那个节点,然后清缓存,确保所有的从节点全部和主节点一致)。
不过,正常情况下,从库binlog的同步速度都要比canal发消息快,因为canal要接收binlog,然后组装数据变动实体(这一步是有额外开销的),然后通过消息队列推送给各消费者(这一步也是有开销的),所以即便是订阅的master库的表变更,出问题的概率也极小
3.更新后,key升级
针对上面的一致性问题(缓存污染),修改某个缓存结构可能导致在预发或者灰度中状态时和实际生产环境的缓存相互污染,这个时候建议每次更新结构时都进行一次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消息始终会被同一个线程执行。
缓存穿透
1.什么是缓存穿透
正常情况下用户请求一个数据时会携带标记性的参数(比如id),而我们的缓存key则会以这些标记性的参数来划分不同的cache value,然后我们根据这些参数去查缓存,查到就返回,否则回源,然后写入cache服务后返回。
这个过程看起来也没什么问题,但是某些情况下,根据带进来的参数,在数据库里并不能找到对应的信息,这个时候每次带有这种参数的请求,都会走到数据库回源,这种现象叫做缓存穿透,比较典型的出现这种问题的情况有:
1.恶意攻击或者爬虫,携带数据库里本就不存在的数据做参数回源
2.公司内部别的业务方调用我方的接口时,由于沟通不当或其他原因导致的参数大量误传
3.客户端bug导致的参数大量误传
2. 如何解决缓存穿透问题?
目前我们提倡的做法是回源查不到信息时直接缓存空数据(注意:空数据缓存的过期时间要尽可能小,防止无意义内容过多占用Cache内存),这样即便是有参数误传、恶意攻击等情况,也不会每次都打进DB。
但是目前这种做法仍然存在被攻击的风险,如果恶意攻击时携带少量参数还好,这样不存在的空数据缓存仅仅会占用少量内存,但是如果攻击者使用大量穿透攻击,携带的参数千奇百怪,这样就会产生大量无意义的空对象缓存,使得我们的缓存服务器内存暴增。
这个时候就需要服务端来进行简单的控制:按照业务内自己的估算,合理的id大致在什么范围内,比如按照用户id做标记的缓存,就直接在获取缓存前判断所传用户id参数是否超过了某个阈值,超过直接返回空。(比如用户总量才几十万或者上百万,结果用户id传过来个几千万甚至几亿明显不合理的情况)
缓存击穿
1. 什么是缓存击穿?
缓存击穿是指在一个key失效后,大量请求打进回源方法,多线程并发回源的问题。
这种情况在少量访问时不能算作一个问题,但是当一个热点key失效后,就会发生回源时涌进过多流量,全部打在DB上,这样会导致DB在这一时刻压力剧增。
2. 如何解决缓存击穿?
回源方法内追加互斥锁:这个可以避免多次回源,但是n台实例群模式下,仍然会存在实例并发回源的情况,这个量级相比之前大量打进,已经大量降低了。
回源方法内追加分布式锁:这个可以完全避免上面多实例下并发回源的情况,但是缺点也很明显,那就是又引入了一个新的服务,这意味着发生异常的风险会加大。
缓存雪崩
1. 什么是缓存雪崩?
缓存雪崩是指缓存数据某一时刻出现大量失效的情况,所有请求全部打进DB,导致短期内DB负载暴增的问题,一般来说造成缓存雪崩有以下几种情况:
缓存服务扩缩容:这个是由缓存的数据分片策略的而导致的,如果采用简单的取模运算进行数据分片,那么服务端扩缩容就会导致雪崩的发生。
缓存服务宕机:某一时刻缓存服务器出现大量宕机的情况,导致缓存服务不可用,根据现有的实现,是直接打到DB上的。
2. 如何避免雪崩的发生?
缓存服务端的高可用配置:上面mc和redis的分片策略已经说过,所以扩缩容带来的雪崩几率很小,其次redis服务实现了高可用配置:启用cluster模式,一主一从配置。由于对一致性哈希算法的优化,mc宕机、扩缩容对整体影响不大,所以缓存服务器服务端本身目前是可以保证良好的可用性的,尽可能的避免了雪崩的发生(除非大规模宕机,概率很小)。
数据分片策略调整:调整缓存服务器的分片策略,比如上面第一部分所讲的,给mc开启一致性哈希算法的分片策略,防止缓存服务端扩缩容后缓存数据大量不可用。
回源限流:如果缓存服务真的挂掉了,请求全打在DB上,以至于超出了DB所能承受之重,这个时候建议回源时进行整体限流,被限到的请求紫自动走降级逻辑,或者直接报错。
热key问题
1. 什么是热key问题?
了解了缓存服务端的实现,可以知道某一个确定的key始终会落到某一台服务器上,如果某个key在生产环境被大量访问,就导致了某个缓存服务节点流量暴增,等访问超出单节点负载,就可能会出现单点故障,单点故障后转移该key的数据到其他节点,单点问题依旧存在,则可能继续会让被转移到的节点也出现故障,最终影响整个缓存服务集群。
2. 如何解决热key问题?
多缓存副本:预先感知到发生热点访问的key,生成多个副本key,这样可以保证热点key会被多个缓存服务器持有,然后回源方法公用一个,请求时按照一定的算法随机访问某个副本key