工作中有关分布式缓存的使用和需要注意的问题梳理
目前工作中用到的分布式缓存技术有
redis
和memcached
两种,缓存的目的是为了在高并发系统中有效降低DB的压力,但是在使用的时候可能会因为缓存结构设计不当造成一些问题,这里会把可能遇到的坑整理出来,方便日后查找。
一、常用的两种缓存技术的服务端特点
1.1:Memcache服务端
Memcache
(下面简称mc
)服务端是没有集群概念的,所有的存储分发全部交由mc client去做,我这里使用的是xmemcached
,这个客户端支持多种哈希策略,默认使用key与实例数取模来进行简单的数据分片,这种分片方式会导致一个问题,那就是新增或者减少节点后会在一瞬间导致大量key失效,最终导致缓存雪崩的发生,给DB带来巨大压力,所以我们的mc client启用了xmemcached
的一致性哈希算法
来进行数据分片:
1 | XMemcachedClientBuilder builder = new XMemcachedClientBuilder(AddrUtil.getAddresses(servers)); |
根据一致性哈希算法
的特性,在新增或减少mc的节点只会影响较少一部分的数据。但这种模式下也意味着分配不均匀,新增的节点可能并不能及时达到均摊数据的效果,不过mc采用了虚拟节点
的方式来优化原始一致性哈希算法
(由ketama算法控制实现),实现了新增物理节点后也可以均摊数据的能力。
最后,mc服务端是多线程
处理模式,mc一个value最大只能存储1M
的数据,所有的k-v过期后不会自动移除,而是下次访问时与当前时间做对比,过期时间小于当前时间则删除,如果一个k-v产生后就没有再次访问了,那么数据将会一直存在在内存中,直到触发LRU
。
1.2:Redis服务端
redis服务端有集群模式,key的路由交由redis服务端做处理,除此之外redis有主从配置以达到服务高可用。
redis服务端是单线程
处理模式,这意味着如果有一个指令导致redis处理过慢,会阻塞其他指令的响应,所以redis禁止在生产环境使用重量级操作(例如keys,再例如缓存较大的值导致传输过慢)
redis服务端并没有采用一致性哈希来做数据分片,而是采用了哈希槽
的概念来做数据分片,一个redis cluster整体拥有16384
个哈希槽(slot
),这些哈希槽按照编号区间的不同,分布在不同节点上,然后一个key进来,通过内部哈希算法(CRC16(key)
)计算出槽位置,然后将数据存放进对应的哈希槽对应的空间,redis在新增或者减少节点时,其实就是对这些哈希槽进行重新分配,以新增节点为例,新增节点意味着原先节点上的哈希槽区间会相对缩小,被减去的那些哈希槽里的数据将会顺延至下一个对应节点,这个过程由redis服务端协调完成,过程如下:
迁移过程是以槽为单位,将槽内的key按批次进行迁移的(migrate
)。
有关哈希槽和一致性哈希算法的对比参考:一致性哈希和哈希槽对比
二、选型问题
2.1:缓存结构化选型
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会越来越多,越来越杂。
2.2:Redis的数据结构
常用操作和时间复杂度:Redis基础、常用类型介绍、时间复杂度
特殊结构BitMap:Redis中BitMap使用
BitMap适用于实现用户签到、在线状态等功能,空间占有量极低,除此之外还可以用它来实现一个BloomFilter。
在使用redis时,对于时间复杂度非O(1)
的操作都需要推算现有的数据量是否可能导致redis阻塞
(分页操作一般不会命中这个问题,因为单页个数有限,出问题较多的一般都是全量输出大列表导致的,生产环境应避免全量操作)。
2.3:Redis构造大索引回源问题
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被多个线程运行着,这个时候重复上述过程,就会存在一致性问题:
3.1:并发读写导致的一致性问题
- 运行着服务A的
线程1
首先修改数据,然后删除缓存 - 运行着服务B的
线程3
读缓存时发现缓存miss,开始读取DB中的源数据,需要注意的是这次读出来的数据是线程1
修改后的那份 - 这个时候运行着服务A的
线程2
上线,开始修改数据库,同样的,删除缓存,需要注意的是,这次删除的其实是一个空缓存,没有意义,因为本来线程3
那边还没有回源完成 - 运行着服务B的
线程3
将读到的由线程1
写的那份数据回写进Cache
上述过程完成后,最终结果就是DB里保存的最终数据是线程2
写进去的那份,而Cache经过线程3
的回源后保存的却是线程1
写的那份数据,不一致问题出现。
3.2:主从同步延时导致的一致性问题
这种情况要稍微修改下程序的流程图,多出一个从库:
现在读操作走从库,这个时候如果在主库写操作删除缓存后,由于主从同步有可能稍微慢于回源流程触发,回源时读取从库仍然会读到老数据。
3.3:缓存污染导致的一致性问题
每次做新需求时更新了原有的缓存结构,或去除几个属性,或新增几个属性,假如新需求是给某个缓存对象O
新增一个属性B
,如果新逻辑已经在预发
或者处于灰度
中,就会出现生产环境回源后的缓存数据没有B属性
的情况,而预发和灰度时,新逻辑需要使用B属性
,就会导致生产&预发缓存污染
。过程大致如下:
四、应对缓存一致性问题
4.1:binlog+消息队列+消费者del cache
上图是现在常用的清缓存策略,每次表发生变动,通过mysql产生的binlog
去给消息队列发送变动消息,这里监听DB变动的服务由canal
提供,canal可以简单理解成一个实现了mysql通信协议的从库,通过mysql主从配置完成binlog同步,且它只接收binlog,通过这种机制,就可以很自然的监听数据库表数据变动了,可以保证每次数据库发生的变动,都会被顺序发往消费者去清除对应的缓存key。
4.2:从库binlog+消息队列+消费者del cache
上面的过程能保证写库时清缓存的顺序问题,看似并没有什么问题,但是生产环境往往存在主从分离的情况,也就是说上面的图中如果回源时读的是从库(参考问题3.2
),那上面的过程仍然是存在一致性问题的:
从库延迟导致的脏读问题,如何解决这类问题呢?只需要将canal监听的数据库设置成从库即可,保证在canal推送过来消息时,所有的从库和主库完全一致,不过这只针对一主一从
的情况,如果一主多从
,且回源读取的从库有多个,那么上述也是存在一定的风险的(一主多从需要订阅每个从节点的binlog,找出最后发过来的那个节点,然后清缓存,确保所有的从节点全部和主节点一致)。
不过,正常情况下,从库binlog的同步速度都要比canal发消息快,因为canal要接收binlog,然后组装数据变动实体(这一步是有额外开销的),然后通过消息队列推送给各消费者(这一步也是有开销的),所以即便是订阅的master库的表变更,出问题的概率也极小。
ps:目前笔者公司内数据库都是一主一从,canal监听的都是从库的变动。
4.3:更新后key升级
针对上面的一致性问题3.3
(缓存污染),修改某个缓存结构可能导致在预发或者灰度中状态时和实际生产环境的缓存相互污染,这个时候建议每次更新结构时都进行一次key升级(比如在原有的key名称基础上加上_v2的后缀)。
并不是,比如上面的情况:
- 首先线程1走到服务A,写DB,发binlog删除缓存
- 然后线程3运行的服务B这时cache miss,然后读取DB回源(这时读到的数据是线程1写入的那份数据)
- 此时线程2再次触发服务A写DB,同样发送binlog删除缓存
- 最后线程3把读到的数据写入cache,最终导致DB里存储的是线程2写入的数据,但是cache里存储的却是线程1写入的数据,不一致达成
这种情况比较难以触发,因为极少会出现线程3那里写cache的动作会晚于第二次binlog发送的,除非在回源时做了别的带有阻塞性质的操作,所以根据现有的策略,针对问题3.1
,没有特别完美的解决方案,只能尽可能保证一致性,但由于实际生产环境就像问题3.1
里那样,处于多线程并发读写的环境,即便有binlog做最终的保证,也不能保证最后回源方法写缓存那里的顺序性。除非回源全部交由binlog消费者来做,不过这本就不太现实,这样等于说服务B没有回源方法了。
针对这个问题,出现概率最大的就是那种写并发概率很大的情况,这个时候伴随而来的还有命中率(参考五)
问题。
4.4:一致性问题解决总结
没有很好的避免问题3.1
的方法,从库binlog订阅+清缓存可以保证在清缓存后服务回源读到的一定是最新数据,但是这并不妨碍图11
中的问题发生,只是概率极小,在高并发写+读回源时没有什么办法是可以保证完全没有问题的,可以加分布式锁控制回源方法(这样出问题的概率就更加低了)。当然,只有在这种大量写操作的情况下,才会使问题3.1
出现的概率提升,一般这种大量写的还会有命中率低的问题,所以这类缓存的更新一般交给binlog消费者直接去做,在单线程处理模式下,一致性问题几乎不会出现。相比问题3.1
里的直接delete缓存,binlog模式可以保证清缓存后缓存回源时读到的内容一定是最新的,极大的降低了因为主从配置导致的问题3.1、3.2
出现的概率。
五、命中率问题
通过前面的流程,抛开特殊因素,已经解决了一致性的问题,但随着清缓存而来的另一个问题就是命中率问题,比如一个数据变更过于频繁,以至于产生过多的binlog消息,这个时候每次都会触发消费者的清缓存操作,这样的话缓存的命中率会瞬间下降,导致大部分用户访问直接访问DB,而且这种频繁变更的数据还会加大问题①出现的概率,所以针对这种频繁变更的数据,不再删除缓存key,而是直接在binlog消费者那里直接回源更新缓存,这样即便表频繁变更,用户访问时每次都是消费者更新好的那份缓存数据,只是这时候消费者要严格按照消息顺序来处理,否则也会有写脏的危险,比如开两个线程同时消费binlog消息,线程1接收到了第一次数据变更的binlog,而线程2接收到了第二次数据变更的binlog,这时线程1读出数据(旧数据),线程2读出数据(新数据)更新缓存,然后线程1再执行更新,这时缓存又会被写脏,所以为了保证消费顺序,必须是单线程处理,如果想要启用多线程均摊压力,可以利用key、id等标识性字段做任务分组,这样同一个id的binlog消息始终会被同一个线程执行。
六、缓存穿透
6.1:什么是缓存穿透?
正常情况下用户请求一个数据时会携带标记性的参数(比如id),而我们的缓存key则会以这些标记性的参数来划分不同的cache value,然后我们根据这些参数去查缓存,查到就返回,否则回源,然后写入cache服务后返回,详细过程参考上述第三部分。
这个过程看起来也没什么问题,但是某些情况下,根据带进来的参数,在数据库里并不能找到对应的信息,这个时候每次带有这种参数的请求,都会走到数据库回源,这种现象叫做缓存穿透,比较典型的出现这种问题的情况有:
①恶意攻击或者爬虫,携带数据库里本就不存在的数据做参数回源
②公司内部别的业务方调用我方的接口时,由于沟通不当或其他原因导致的参数大量误传
③客户端bug导致的参数大量误传
6.2:如何解决缓存穿透问题?
目前我们提倡的做法是回源查不到信息时直接缓存空数据(注意:空数据缓存的过期时间要尽可能小,防止无意义内容过多占用Cache内存),这样即便是有参数误传、恶意攻击等情况,也不会每次都打进DB。
但是目前这种做法仍然存在被攻击的风险,如果恶意攻击时携带少量参数还好,这样不存在的空数据缓存仅仅会占用少量内存,但是如果攻击者使用大量穿透攻击,携带的参数千奇百怪,这样就会产生大量无意义的空对象缓存,使得我们的缓存服务器内存暴增,这个时候就需要服务端来进行简单的控制:按照业务内自己的估算,合理的id大致在什么范围内,比如按照用户id做标记的缓存,就直接在获取缓存前判断所传用户id参数是否超过了某个阈值,超过直接返回空。(比如用户总量才几十万或者上百万,结果用户id传过来个几千万甚至几亿明显不合理的情况)
还可以通过布隆过滤器
的方式存储合理的key数据,一旦发现key未命中布隆过滤器就直接返回空对象。
七、缓存击穿
7.1:什么是缓存击穿?
缓存击穿是指在一个key失效后,大量请求打进回源方法,多线程并发回源的问题。
这种情况在少量访问时不能算作一个问题,但是当一个热点key失效后,就会发生回源时涌进过多流量,全部打在DB上,这样会导致DB在这一时刻压力剧增。
7.2:如何解决缓存击穿?
7.2.1:回源方法内追加互斥锁
这个可以避免多次回源,但是n台实例群模式下,仍然会存在实例并发回源的情况,但这个量级相比之前大量打进,已经降低不少了。
7.2.2:回源方法内追加分布式锁
这个可以完全避免上面多实例下并发回源的情况,但是缺点也很明显,那就是又引入了一个新的服务,这意味着系统复杂度和发生异常的风险会加大。
八、缓存雪崩
8.1:什么是缓存雪崩?
缓存雪崩是指缓存数据某一时刻出现大量失效的情况,所有请求全部打进DB,导致短期内DB负载暴增的问题,一般来说造成缓存雪崩有以下几种情况:
8.1.1:缓存服务扩缩容
这个是由缓存的数据分片策略的而导致的,如果采用简单的取模运算进行数据分片,那么服务端扩缩容就会导致雪崩的发生。
8.1.2:缓存服务宕机
某一时刻缓存服务器出现大量宕机的情况,导致缓存服务不可用,根据现有的实现,是直接打到DB上的。
8.2:如何避免雪崩的发生?
8.2.1:缓存服务端的高可用配置
上面mc和redis的分片策略已经说过,所以扩缩容带来的雪崩几率很小,其次redis服务实现了高可用配置:启用cluster模式,一主一从配置。由于对一致性哈希算法的优化,mc宕机、扩缩容对整体影响不大,所以缓存服务器服务端本身目前是可以保证良好的可用性的,尽可能的避免了雪崩的发生(除非大规模宕机,概率很小)。
8.2.2:数据分片策略调整
调整缓存服务器的分片策略,比如上面第一部分所讲的,给mc开启一致性哈希算法的分片策略,防止缓存服务端扩缩容后缓存数据大量不可用。
8.2.3:回源限流
如果缓存服务真的挂掉了,请求全打在DB上,以至于超出了DB所能承受之重,这个时候建议回源时进行整体限流,被限到的请求紫自动走降级逻辑。
九、热key问题
9.1:什么是热key问题?
了解了缓存服务端的实现,可以知道某一个确定的key始终会落到某一台服务器上,如果某个key在生产环境被大量访问,就导致了某个缓存服务节点流量暴增,等访问超出单节点负载,就可能会出现单点故障,单点故障后转移该key的数据到其他节点,单点问题依旧存在,则可能继续会让被转移到的节点也出现故障,最终影响整个缓存服务集群。
9.2:如何解决热key问题?
9.2.1:多缓存副本
预先感知到发生热点访问的key,生成多个副本key,这样可以保证热点key会被多个缓存服务器持有,然后回源方法公用一个,请求时按照一定的算法(哈希取模or随机)访问某个副本key:
9.2.2:本地缓存
针对热点key外面包一层短存活期的本地缓存,用于缓冲热点服务器的压力。