分布式缓存相关问题
数据本来应当存放在数据库,抽象来说的话,应当存放在数据源。但是我们在程序中不免有一些频繁使用的数据,这时候我们得考虑是否需要将数据存放在里服务器比较近的位置,就是缓存。但是使用缓存随之而来会有很多问题
以下说的缓存,都是服务端缓存,和客户端缓存无关
# 缓存属性
我们在系统中引入缓存一般需要考虑以下几个属性
# 吞吐量
吞吐量是指这个缓存在单位时间内读写的操作次数,反应了缓存进行并发读写的性能。如果不是并发,普通的 HashMap 的读取效率也是 O(1),但是在并发场景下,读取操作收到了诸多影响(需要记录缓存读取的访问时间与访问状态等等数据,为过期等功能服务),更别提写入操作了
既然吞吐量受限是有原因的,那么我们就可以优化了。缓存中最主要的数据竞争是读取操作的同时进行对数据状态的写入操作,这些写入操作主要做数据维护
此时有两种可以选择的处理思路。一种是以 guava cache 为代表的同步处理机制,在访问数据的时候一并完成缓存淘汰、统计等状态变更操作,通过分段加锁的方式来减少竞争
另外一种是以 caffeine 为代表的异步日志提交机制,参考了数据库的日志设计,将数据的读写看成日志的提交过程。并且提交后有专门的环形缓冲区来记录日志,我们已经在 MySQL 的 redolog 中用到了这个缓冲区。设计成环形的好处是可以用有限的空间来存放无限的数据,前提是这些数据应当过一段时间后失效
# 命中率与淘汰策略
缓存需要在消耗空间与节约时间中做取舍,我们应当尽可能让缓存淘汰掉一些低价值数据,根据空间局部性与时间局部性,刚刚使用过的数据肯定是需要留下来的,此时如何定义低价值成了提升命中率的重点
我们已经学习了 FIFO、LRU 等等基础的缓存淘汰策略,这些算法也在不断的升级改进,以 LFU(淘汰最不经常使用的数据)为例,已经有更好的 TinyLFU 版本了。该版本做了一定的优化,比如没过一段时间,就将计时器的数值减半,以此解决旧热点难以清除的问题(滑动时间窗口)
# 扩展功能
缓存往往提供以下的基础功能:
- 淘汰策略:数据太多怎么处理
- 失效策略:比如 redis 的过期字典,一段时间后缓存中的数据自动失效
- 支持并发:为保证缓存数据的准确性,在并发访问的时候需要做同步控制
- 统计信息:提供比如命中率、自动回收计数等等数据
- 持久化:缓存挂了之后会从磁盘中读取数据,分布式缓存通常会考虑持久化功能
# 分布式缓存
分布式缓存是指使用缓存集群来保存数据,此时我们需要额外考虑数据在集群中的同步操作,对于分布式缓存来说,处理同步操作(数据在集群中的网络传递)比吞吐量更加重要
从访问的角度来说:
- 频繁更新但是读取较少的数据,一般是不会被做成缓存的
- 对于更新非常少,而读取又非常多的数据,更适合做成复制式缓存。复制式缓存的意思是缓存中的所有数据在分布式集群的每个节点中都有一份副本,因为这个特性,更新缓存的代价十分高昂
- 对于更新与读取都十分频繁的数据,理论上应当做成集中式缓存。即缓存中的数据不会遍布集群中的所有机器,每个节点只缓存部分数据,当请求的数据在某个节点中没有的时候,机器的拓扑结构会让他找到存放该数据的机器。redis 的去中心化集群,就是集中式缓存的设计
同时,分布式缓存也不可避免的需要在 AP 与 CP 中做取舍,redis 就是典型的 AP 式,高性能高可用,但是不保证强一致性,有可能这个节点写入数据了,过一会再另外一个节点还是访问不到该数据,以此该设计更适合做缓存。zk 集群就是 CP 型,强一致性的实现,让他更适合当分布式锁与注册中心
# 缓存经典问题
使用缓存大大提高了系统性能,但是不可避免的也提高了系统的复杂度,引发的问题也随之而来
# 缓存穿透
指执行了大量不存在在 redis 里的查询(高并发情况下缓存命中率降低),或者出现了大量恶意查询(黑客攻击使数据库压力增大),或者缓存中数据已经过期了,一群人访问一些奇奇怪怪的请求,等等情况
这些情况会导致数据库的压力激增,并且通常出现缓存穿透的时候,数据库中一般也查不出数据,导致没有意义的 db 查询
解决方法:
1,缓存无效数据,让无效访问命中缓存,但是一般而言,无效请求无穷无尽,用个字典存放所有的恶意请求,一般来说不现实 2,使用 bitmaps 存放 url 白名单,只有登录的用户才可以访问该接口,如果一个用户执行大量的恶意请求就把他踢出白名单,但是如果有人想用爬虫搞你,一般来说攻击者会有很多的 IP 地址,在某些情况下这种方法不适用 3,布隆过滤器(一个判断 key 是否合法的数据结构),比较优秀的解法 4,业务层限流或者熔断
总结一下,防止穿透的主要思路就是请求在打到 db 之前对其进行过滤,减少数据库压力。我们推荐使用布隆过滤器这些方法进行算法过滤
# 布隆过滤器的原理
维持布隆过滤器的是一个位数组(byte[]),和一群 hash 函数组成的
在一个元素加入布隆过滤器中的时,会使用这些 hash 函数映射出多个地址,将位数组的对应地址标记为1
判断一个元素是否存在于布隆过滤器的时,会使用这些 hash 函数映射出多个地址,如果映射出的地址中有0,说明这个元素不在过滤器中
除了寻找数据是否有效以外,过滤器还可以对两组大量数据进行模糊比较以寻找相同的数据,这种查询虽然消耗了一些准确性,但时间复杂度和空间复杂度都大大优化了,之后可以对选出的数进行二次操作
从原理中我们可以推断出,过滤器的时间复杂度与空间复杂度都为 O(1),非常的节省资源,并且该过滤器是保证准确性的。他在企业生产中可以使用的原因就是,他判定在过滤器里的数据不一定真的存在,但是他判定不在过滤器里的数据一定不存在
# 如何增加布隆过滤器的准确性
布隆过滤器的准确性主要依靠于 hash 碰撞的次数,只要减少了 hash 碰撞的次数就能增加准确性,因此我们有以下几个思路
- 1,将位数组增大
- 2,增加多个 hash 函数(过多的 hash 函数会填满位数组,导致准确性降低)
- 3,优化 hash 函数减少 hash 碰撞
# guava 提供的布隆过滤器
guava 为我们提供了一个不稳定的过滤器,这个 API 还挺好用的,我们可以模仿这个思路实现一个布隆过滤器。这个 API 在业务中也可以使用
// 创建布隆过滤器,设置存储的数据类型,预期数据量,误判率 (必须大于0,小于1)
int insertions = 10000000;
double fpp = 0.0001;
BloomFilter<String> bloomFilter = BloomFilter.create(Funnels.stringFunnel(Charset.defaultCharset()), insertions, fpp);
// 随机生成数据,并添加到布隆过滤器中(将预期数据量全部塞满)
// 同时也创建一个 List 集合,将布隆过滤器中预期数据的十分之一存储到该 List 中
List<String> lists_1 = new ArrayList<String>();
for (int i = 0; i < insertions; i++) {
String uid = UUID.randomUUID().toString();
bloomFilter.put(uid);
if (i < insertions / 10) {
lists_1.add(uid);
}
}
// 再创建一个 List 集合,用来存储另外五分之一不存在布隆过滤器中的数据
List<String> lists_2 = new ArrayList<String>();
for (int i = 0; i < insertions / 5; i++) {
String uid = UUID.randomUUID().toString();
lists_2.add(uid);
}
// 对已存在布隆过滤器中的 lists_1 中的数据进行判断,看是否在布隆过滤器中
int result_1 = 0;
for (String s : lists_1) {
if (bloomFilter.mightContain(s)) result_1++;
}
System.out.println("在 <已存在> 布隆过滤器中的" + lists_1.size() + "条数据中,布隆过滤器认为存在的数量为:" + result_1);
// 对不存在布隆过滤器中的 lists_2 中的数据进行判断,看是否在布隆过滤器中
int result_2 = 0;
for (String s : lists_2) {
if (bloomFilter.mightContain(s)) result_2++;
}
System.out.println("在 <不存在> 布隆过滤器中的" + lists_2.size() + "条数据中,布隆过滤器认为存在的数量为:" + result_2);
// 对数据进行整除,求出百分率
NumberFormat percentFormat = NumberFormat.getPercentInstance();
percentFormat.setMaximumFractionDigits(2);
float percent = (float) result_1 / lists_1.size();
float bingo = (float) result_2 / lists_2.size();
System.out.println("命中率为:" + percentFormat.format(percent) + ",误判率为:" + percentFormat.format(bingo));
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
# 缓存击穿
在高并发场景下,大量请求同时访问同一个缓存键,而该缓存键在缓存中不存在(即缓存失效),导致这些请求直接穿透到后端数据库,造成数据库压力剧增,甚至可能使数据库崩溃,和缓存雪崩有些类似,本质上都是数据过期导致数据库压力增大
解决方法:
- 1,延长热点数据过期时间,或者永不过期
- 2,监控告警层面:实时监控完善告警
- 3,业务层限流或者熔断,减少流量
- 4,定时任务将 db 数据刷进数据
- 5,加锁:当缓存中某个键不存在时,可以通过互斥锁来控制只有一个请求去加载数据并写入缓存,其他请求则等待这个请求完成后再从缓存中读取数据
- 6,多级缓存:可以用 guava 提供的本地缓存或者分布式系统中多加几层缓存
# 缓存雪崩
多个缓存在同一时间大面积过期,导致数据库接受大量请求,常见的出现原因有两种,第一种可能是 Redis 宕机,第二种可能是大量数据采用了相同的过期时间
解决方法:
- 1,限流,避免同时处理大量请求。在缓存能力不足的时候,高可用架构是解决的唯一方式
- 2,使用集群
- 3,错开缓存过期时间,设置随机时间戳
- 4,定时任务刷数据
# 缓存污染
缓存污染是指操作系统将不常用的数据从内存移到缓存,降低了缓存效率的现象。缓存污染会随着数据的持续增加而逐渐显露,随着服务的不断运行,缓存中会存在大量的永远不会再次被访问的数据
缓存污染主要使用内存淘汰策略处理,根据淘汰策略去选择要淘汰的数据(比如 LRU,或者淘汰即将要过期的数据,或者淘汰最近最少使用的数据),然后进行删除操作
# 三级缓存
为什么需要/解决的痛点:
- 1,针对 qps 过高的业务才会设置三级缓存,平时来说只设置分布式缓存足够了,但是分布式缓存集群也有 qps 上限
- 2,redis 访问可能需要1ms 左右,如果一个业务需要访问100个则需要100ms,导致请求延迟,要求微秒级响应(如金融交易、实时推荐)
是什么/怎么使用:三级缓存是分布式缓存,本地缓存,持久化一起构成的,redis 可以缓存次热数据,本地可以访问过热数据
引入造成的问题:需要额外考虑缓存维护的事情,可能出现三大缓存问题,缓存一致性问题,高频写操作时的问题
# 多级缓存如何保证缓存一致性
项目中有时会遇到这种情况:由于流量过大,多个系统不得不设置多个缓存,但是这样会出现问题,比如二级缓存读取一级缓存的数据时,如果一级缓存中的数据已经过期或不准确,会导致二级缓存中的数据也是陈旧的
我们可以使用事件驱动的方式,在数据源发生变化时,触发事件来更新(或者直接删除)各级缓存。这可以通过消息队列来实现。如果在高并发的情况下,建议删除各级缓存而非更新
如果说这个更新事件是下游提供的,我们无法感知到下游数据的变动,可以改成定时任务去访问下游,来更新多级缓存
此外,我们还可以设置不同的过期时间,这是最基础的兜底方案。给本地缓存设置一个较短的过期时间(例如 30秒 ~ 5分钟)。给分布式缓存设置一个较长的过期时间。利用 TTL(Time To Live)自动过期机制。即使数据更新了,本地缓存最多也就是在 TTL 时间内脏读,TTL 过期后自动回源 Redis 拉取最新数据
# 分布式锁超时自动解锁
假如用 redis 实现分布式锁,也就是 setnx 同时设置超时时间,假设某业务耗时较长或网络原因,超过锁的超时时间了会导致两个线程都操作资源,这块如何解决?超时时间值如何设置?
用 set nx ex 加锁,del 解锁。业务上是先加锁(ex 参数用于设置过期时间,需要加 ex 参数是因为如果 A 上锁后崩溃其他线程就永远拿不到锁了,nx 功能是只有当数据不存在的时候才会进行操作,需要加 nx 参数因为如果先读后写会有原子性问题)-》try 执行业务代码(如果加锁代码在 try 内部,会发生 a 锁 b 删问题)-》 finally 解锁
这些参数也可以保持 redis 的并发安全性,在设置分布式锁或者秒杀等场景中用的较多。如果设置成功了,会返回 SUCCESS,如果失败了则返回 null。redis 内部是单线程执行的,因此不会出现线程安全问题
在 redis 内部,像这个问题,会造成分布式锁的 A 锁 B 删问题,如果按照正常的上锁-try-finally-解锁这种顺序写代码,如果 A 业务超时了,B 就可以造成加锁,A 执行完业务后会删掉 B 的锁。这里说一个可以防止 A 锁 B 删的解法,就是每个机器带着自己机器的唯一标识去访问 redis,分布式锁中的 value 填入自己的唯一的客户端 ID,或者用 UUID 这种随机数。如果这么做则需要先读 redis 中数据,判断后再写入,因此需要引入 lua 脚本或者事务保证原子性(EVAL 和 EVALSHA 是 Redis 提供的 Lua 脚本执行命令,我们可以用这两个保证原子性)
但是我们实际遇到的问题是超时自动解锁,也就是说,虽然 A 不能删除 B 的锁,但是锁也超时了,业务上是同时有两个线程在执行该任务,分布式锁没有起到该有的效果,因此我们还是需要处理问题:
1,一般来说将超时时间往大了设置不会出现业务问题,但是从性能方面考虑可能会出现多个线程等待一个锁的问题。往小了设置可能会出现业务还没有执行完,其他的线程就加锁了
为了处理以上问题,我们有以下策略:
2,让超时的这个机器去检查自己的锁是否被别人抢了:这个超时的机器执行完业务后,去 redis 或者 db 中检查,是不是有别的机器在执行这个业务,如果有,自己做一些补偿策略,将自己所做的业务回滚掉
3,让其他的机器检查超时的机器是否执行完毕:在每台机器执行该业务前,我们先检查一下 db 中的状态和分布式锁,如果 db 中某个数据的状态是一个中间态,并且没有分布式锁,什么有机器超时了,我们上分布式锁然后直接退出。如果说是正常情况,我们上锁执行业务,上完锁之后要立刻把 db 中的数据状态修改为中间态,执行完毕将数据状态改为最终态
4,续租机制:如果有必要,写个定时任务,起定时任务检测机器的业务是否执行完毕,如果到了时间任务还没有执行完,我们可以则调用 redis 的 expire 指令进行锁数据的延期操作,这个解法被称做看门狗机制
但是,这些解法都不是最优解,最优解是 lua 脚本,而且 redisson 已经为我们贴心的提供了封装
# 分布式锁最优实践(引入 RedissonLock)
我平时用上面的写法是足够的,当然如果在银行转账那种涉及金额的业务,这个最优实践还是有必要的。上面的实现依赖 jedis,我们可以引入 redisson 来实现分布式锁,Redisson 和 Jedis 都是 Java 客户端框架,用于访问 Redis 数据库。Redisson 是一个 高阶、分布式、非阻塞 的 Redis 客户端,基于 Netty 实现异步和响应式编程。提供了大量分布式对象(如 RLock, RMap)和分布式服务(如分布式锁、限流器),适合构建复杂分布式系统
Redisson 普通的锁实现源码主要是 RedissonLock 这个类,源码中加锁释放锁操作都是用 lua 脚本完成的,封装的非常完善,开箱即用。加锁用 lua 是因为他自己实现了可重入锁。RedissonLock 的解锁实现就是上文中我们讨论的最后结论,解锁用 del + lua 脚本,并且考虑到了 a 锁 b 删问题
当你调用 lock() 不指定时间时,Redisson 自动启用看门狗,来处理锁过期问题
# 异步复制造成的主从不一致问题(红锁)
redis 是 AP 架构,去中心化集群部署是异步复制的,当然,这里读数据读的都是主库,因此不存在读写分离下的主从不一致问题。超极端情况下,数据写入主库,还没有异步复制到从库,主库崩了,这时候就需要处理问题了
- 1,修改 redis 配置,改成全同步或者半同步,改成 CP 架构。当然全同步一般是不可能改的,半同步在 redis 高版本可以实现。可以修改 redis 配置为 x 台从机确认写入数据后,主库才返回成功
- 2,使用 RedLock。redLock 的中文是直译过来的,就叫红锁。红锁并非是一个工具,而是 redis 官方提出的一种分布式锁的算法。就在刚刚介绍完的 redisson 中,就实现了 redLock 版本的锁 getRedLock 方法
它的核心思想是基于多个独立的 Redis 实例(通常为奇数个,如 5 个)进行多数派投票(Quorum),从而降低单点故障的影响。这个半数选举一看就很分布式,符合 paxos 提供的思路
红锁算法流程为:
1,顺序向五个节点请求加锁 2,根据一定的超时时间来推断是不是跳过该节点 3,三个节点加锁成功并且花费时间小于锁的有效期 4,认定加锁成功
假设锁30秒过期,三个节点加锁花了31秒,自然是加锁失败了。假设有效期是10秒,那么单个 redis 实例操作超时时间,应该比10s短
这个性能开销相当之大,需要与多个 Redis 节点交互,增加了网络延迟和计算成本,并且如果 Redis 节点崩溃后未持久化锁数据,重启后可能导致锁状态不一致
同时读的时候也需要集群中一半节点有数据,那极端场景下5台集群写锁的时候只写3台,读的时候有一台挂了,我只读了2个,那我认为没有上锁操作业务了,因此还是有问题
最终解法:如果必须保证这么上锁,不如改用 zk
# 缓存常用的读写策略
# 旁路缓存模式
数据库作主,缓存为辅。这也是最正常的缓存使用方案。我们知道使用缓存时最需要注意的就是缓存数据库不一致问题,旁路缓存模式给了我们很好的解决方案。旁路缓存的特点在于缓存只做新增和删除,不做更新
- 查询:先查缓存,查不到再查数据库,更新缓存然后返回结果,这里是由缓存组件负责从数据库中同步加载数据
- 修改:先删除缓存,修改数据库,然后删除缓存
落实到代码里,大概是这样的:
// 更新 db
crmUserInfoRouteService.updateStatus(userName, status, operator);
// 删除 redis 数据
deleteQueryByUserNameCache(userName);
// 删除本地 ThreadLocal 数据
RequestContextCache.clear();
2
3
4
5
6
旁路缓存模式有以下几个要点:
# 为什么不能先删除缓存,后修改数据库?
如果先删除缓存,后修改数据库
在进行写操作后如果有另外一个线程进行读操作,并且这个线程在缓存中没有找到,将未修改的数据放到 cache 中,脏数据只能自己过期或者下一次写操作时才可以去除,脏数据时间范围时间范围不确定性很大,比如
如果下一次对该数据的更新马上就到来,那么会失效缓存,脏数据的时间就很短
如果下一次对该数据的更新要很久才到来,那这期间缓存保存的一直是脏数据,时间范围很长
# 为什么不能修改数据库后直接更新缓存?
不更新缓存主要是为了防止并发
多并发写操作时,可能数据库中的数据和缓存中数据不是一个线程的
比如有如下情况:A 线程写入数据库-》B 线程写入数据库-》B 线程修改缓存-》A 线程修改缓存
直接删除缓存不会出现这个问题
上面两个问题,可以统称为缓存与数据库的一致性问题,即如何保证缓存和数据库的一致性,只需要遵从上面的流程就可以保证一致性了
# 更新失败应该怎么办?
如果更新数据库成功,而删除缓存这一步失败的情况的话应该怎么做:
1,缩短过期时间:如果删除失败它大概率也会自己过期 2,增加 cache 更新重试机制:自己定一个合适的重试次数,每隔一段时间后进行重试。如果多次重试失败,把当前更新失败的 key 存入队列中,等到时机合适后将队列中的 key 全部删除
如果更新数据库失败该怎么办:
先将更新失败的数据放到一个安全的地方,比如消息队列,然后再不停的去执行写入 DB 操作;或者报警告知用户
# 缓存一致性
上述的方案是实战中最优秀的方案,即先改 DB 在删除缓存,当然我们还有其他的满足数据一致性的方案:
1,延迟双删:
先来说延迟的必要性,如果按下图的流程执行代码是有问题的,如图所示:

为了处理这种问题,我们可以引入延迟双删的处理:

不过这种情况还是有极端情况,读线程可能会将老数据放进 redis 中,所以延时双删还是不一定能保证缓存及数据一致
2,binlog、MQ 异步回写:更新 DB 后发一条 MQ 消息,消费者负责更新/删除缓存;或者 binlog 订阅:通过 Canal 订阅 MySQL binlog,异步更新 Redis。此种方案适合允许短暂不一致,适合高并发或者实时性要求没那么高的业务,比如其他业务线同学想同步数据可以这么做
# 读写穿透(Read/Write through)
缓存作为数据库的前置代理,所有读写都经过缓存。从 cache 中读取数据并将数据写入 DB。这里主要是写数据时有些区别。这么处理的好处是大大提升了读取效率,一般在流量高的时候会这么处理
读取流程:
- 读缓存,命中则返回
- 未命中 → 缓存组件自己从数据库加载 → 更新缓存 → 返回
更新流程:
- 更新缓存
- 缓存组件同步更新数据库,注意,这块对应用层来说是透明的
# 异步回写
查询流程:同读写穿透
更新流程:
- 只更新缓存,立即返回
- 缓存异步批量更新到数据库
异步回写主要用于需要保证性能、写多的场景,比如秒杀场景或者高并发的计数器都可以使用该操作,数据允许短暂丢失或延迟,Linux 系统的页缓存和 MySQL InnoDB 引擎的 Cache Pool 其实就是使用的 WriteBack 策略