转发自:JAVA葵花宝典 公众号文章
目录
前言:
目前工作中用到的分布式缓存技术有redis
和memcached
两种,缓存的目的是为了在高并发系统中有效降低DB
的压力,但是在使用的时候可能会因为缓存结构设计不当造成一些问题,这里会把可能遇到的坑整理出来,方便日后查找。
一. 常用的两种缓存技术的服务端特点
1. Memcache服务端
Memcache
(下面简称mc)服务端是没有集群概念的,所有的存储分发全部交由mc client
去做,我这里使用的是xmemcached
,这个客户端支持多种哈希策略,默认使用key
与实例数取模来进行简单的数据分片。
这种分片方式会导致一个问题,那就是新增或者减少节点后会在一瞬间导致大量key失效,最终导致缓存雪崩的发生,给DB带来巨大压力,所以我们的mc client
启用了xmemcached
的一致性哈希算法来进行数据分片
根据一致性哈希算法的特性,在新增或减少mc
的节点只会影响较少一部分的数据。但这种模式下也意味着分配不均匀,新增的节点可能并不能及时达到均摊数据的效果,不过mc采用了虚拟节点的方式来优化原始一致性哈希算法(由ketama
算法控制实现),实现了新增物理节点后也可以均摊数据的能力。增加虚拟节点到真实节点的映射过程。
最后,mc
服务端是多线程处理模式,mc
一个value
最大只能存储1M
的数据,所有的k-v
过期后不会自动移除,而是下次访问时与当前时间做对比,过期时间小于当前时间则删除,如果一个k-v
产生后就没有再次访问了,那么数据将会一直存在在内存中,直到触发LRU
。
2. Redis服务端
redis服务端有集群模式,key
的路由交由redis服务端做处理,除此之外,redis有主从配置以达到服务高可用。
redis服务端是单线程处理模式,这意味着如果有一个指令导致redis处理过慢,会阻塞其他指令的响应,所以redis禁止在生产环境使用重量级操作(例如keys *命令
,再例如缓存较大的值导致传输过慢)
redis服务端并没有采用一致性哈希来做数据分片,而是采用了哈希槽的概念来做数据分片,一个redis cluster
整体拥有16384
个哈希槽(slot
),这些哈希槽按照编号区间的不同,分布在不同节点上,然后一个key
进来,通过内部哈希算法(CRC16(key))计算出槽位置;
然后将数据存放进对应的哈希槽对应的空间,redis在新增或者减少节点时,其实就是对这些哈希槽进行重新分配,以新增节点为例,新增节点意味着原先节点上的哈希槽区间会相对缩小,被减去的那些哈希槽里的数据将会顺延至下一个对应节点,这个过程由redis服务端协调完成,过程如下:
图1
“迁移过程是以槽为单位,将槽内的
key
按批次进行迁移的(migrate)。
二、缓存一致性问题
一般情况下缓存内的数据要和数据库源数据保持一致性,这就涉及到更新DB后主动失效缓存策略(通俗叫法:清缓存),大部分会经过如下过程:
假如现在有两个服务,服务A
和服务B
,现在假设服务A会触发某个数据的写操作,而服务B
则是只读程序,数据被缓存在一个Cache
服务内,现在假如服务A
更新了一次数据库,那么结合上图得出以下流程:
-
服务A触发更新数据库的操作
-
更新完成后删除数据对应的缓存
key
-
只读服务(服务B)读取缓存时发现缓存
miss
-
服务
B
读取数据库源信息 -
写入缓存并返回对应信息
这个过程乍一看是没什么问题的,但是往往多线程运转的程序会导致意想不到的结果,现在来想象下服务A和服务B被多个线程运行着,这个时候重复上述过程,就会存在一致性问题。
存在问题为:
1. 并发读写导致的一致性问题
图6
-
运行着服务
A
的线程1首先修改数据,然后删除缓存 -
运行着服务
B
的线程3读缓存时发现缓存miss
,开始读取DB
中的源数据,需要注意的是这次读出来的数据是线程1修改后的那份 -
这个时候运行着服务
A
的线程2上线,开始修改数据库,同样的,删除缓存,需要注意的是,这次删除的其实是一个空缓存,没有意义,因为本来线程3那边还没有回源完成 -
运行着服务
B
的线程3将读到的由线程1写的那份数据回写进Cache
上述过程完成后,最终结果就是DB
里保存的最终数据是线程2写进去的那份,而Cache
经过线程3的回源后保存的却是线程1写的那份数据,不一致问题出现。
2. 主从同步延时导致的一致性问题
这种情况要稍微修改下程序的流程图,多出一个从库:
图7
现在读操作走从库,这个时候如果在主库写操作删除缓存后,由于主从同步有可能稍微慢于回源流程触发,回源时读取从库仍然会读到老数据。
3. 缓存污染导致的一致性问题
每次做新需求时更新了原有的缓存结构,或去除几个属性,或新增几个属性,假如新需求是给某个缓存对象O
新增一个属性B
,如果新逻辑已经在预发或者处于灰度中,就会出现生产环境回源后的缓存数据没有B
属性的情况,而预发和灰度时,新逻辑需要使用B
属性,就会导致生产&预发缓存污染。过程大致如下:
图8
三、缓存一致性问题解决方案
缓存一致性问题大致分为以下几个解决方案,下面一一介绍。
0.双删缓存key方案
先删除缓存key 再同步更新db数据,再将新db数据写到缓存
同步完数据后 再删除key(防止同步更新过程db中 有其他线程访问,读取数据库中的老脏数据,写到缓存)
1. binlog+消息队列+消费者del cache
图9
上图是现在常用的清缓存策略,每次表发生变动,通过mysql产生的binlog
去给消息队列发送变动消息,这里监听DB
变动的服务由canal提供,canal
可以简单理解成一个实现了mysql通信协议的从库,通过mysql主从配置完成binlog
同步,且它只接收binlog,通过这种机制,就可以很自然的监听数据库表数据变动了,可以保证每次数据库发生的变动,都会被顺序发往消费者去清除对应的缓存key
。
2. 从库binlog+消息队列+消费者del cache
上面的过程能保证写库时清缓存的顺序问题,看似并没有什么问题,但是生产环境往往存在主从分离的情况,也就是说上面的图中如果回源时读的是从库,那上面的过程仍然是存在一致性问题的:
图10
“从库延迟导致的脏读问题,如何解决这类问题呢?
只需要将canal
监听的数据库设置成从库即可,保证在canal
推送过来消息时,所有的从库和主库完全一致,不过这只针对一主一从的情况,如果一主多从,且回源读取的从库有多个,那么上述也是存在一定的风险的(一主多从需要订阅每个从节点的binlog
,找出最后发过来的那个节点,然后清缓存,确保所有的从节点全部和主节点一致)。
不过,正常情况下,从库binlog
的同步速度都要比canal
发消息快,因为canal
要接收binlog
,然后组装数据变动实体(这一步是有额外开销的),然后通过消息队列推送给各消费者(这一步也是有开销的),所以即便是订阅的master
库的表变更,出问题的概率也极小。
3. 更新后key升级
针对上面的一致性问题(缓存污染),修改某个缓存结构可能导致在预发或者灰度中状态时和实际生产环境的缓存相互污染,这个时候建议每次更新结构时都进行一次key升级(比如在原有的key名称基础上加上_v2的后缀)。
⚡⚡⚡binlog是否真的是准确无误的呢?⚡⚡⚡
图11
并不是,比如上面的情况:
-
首先
线程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
消息始终会被同一个线程执行。
五、缓存穿透/缓存击穿
场景略
解决方案:
1、缓存key的过期时间 尽量分散 防止大量key 失效。
2、增加 缓存一定短过期时间的空key,防止请求直接打到DB。
3、增加boomfiler 防止无效请求打到DB
4、请求增加分布式锁 防止大量请求同时访问
六、缓存雪崩
- 缓存服务端的高可用配置(搭建集群)
- 合理化数据分片策略调整
- 采用服务降级策略
七、热key问题
1. 什么是热key问题?
了解了缓存服务端的实现,可以知道某一个确定的key始终会落到某一台服务器上,如果某个key在生产环境被大量访问,就导致了某个缓存服务节点流量暴增,等访问超出单节点负载,就可能会出现单点故障,单点故障后转移该key的数据到其他节点,单点问题依旧存在,则可能继续会让被转移到的节点也出现故障,最终影响整个缓存服务集群。
2. 如何解决热key问题?
-
多缓存副本:预先感知到发生热点访问的
key
,生成多个副本key
,这样可以保证热点key会被多个缓存服务器持有,然后回源方法公用一个,请求时按照一定的算法随机访问某个副本key。
图12
2.本地缓存:针对热点key外面包一层短存活期的本地缓存,用于缓冲热点服务器的压力。