redis 应用问题——缓存穿透、击穿、雪崩以及分布式锁
redis 应用问题——缓存穿透、击穿、雪崩以及分布式锁
我在这里只记录点基本内容,不会写太深入的内容
先看基本的服务器流程
用户
通过浏览器
去请求webapi
,webapi
去访问redis缓存
,如果redis缓存
没有需要的数据,redis
就需要查询数据库
缓存穿透
现象
- 应用服务器压力变大了(webapi服务器)
- redis 命中率降低(redis 中没有数据,去查询数据库)
- 一直查询数据库
原因:
- redis 查询不到数据
- 出现很多非正常URL访问
一个一定不存在缓存及查询不到的数据,由于缓存是不命中时被动写的,并且出于容错考虑,如果存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义
解决方案
- 对空值缓存(临时应急方案):如果一个查询返回的数据为空(不管数据是否不存在),我们仍然把这个空结果(null)进行缓存,设置空结果的过期时间会很短,最长不超过五分钟。
- 设置可访问的名单(白名单):使用 bitmaps 类型定义一个可以访问的名单,名单 id 作为 bitmaps 的偏移量,每次访问和 bitmaps 里面的 id 进行比较,如果访问 id 不在 bitmaps 里面,进行拦截,不允许访问。效率不是很高。
- 采用布隆过滤器:布隆过滤器(Bloom Filter)是 1970 年由布隆提出的。它实际上是一个很长的二进制向量(位图)和一系列随机映射函数(哈希函数)。
- 进行实时监控:当发现 redis 的命中率开始急速降低,需要排查访问对象和访问的数据,和运维人员配合,可以设置黑名单限制服务。
布隆过滤器可以用于检索一个元素是否在一个集合中,他的优点是空间效率和查询时间都远远超过了一般的算法,缺点是有一定的错误识别率和删除困难。
将所有可能存在的数据哈希到一个足购大的 bitmaps 中,一个一定不存在的数据会被这个 bitmaps 拦截掉,不会去查询底层的数据库,从而避免了对底层存储系统的查询压力。
缓存击穿
现象
- 数据库访问压力瞬时增加
- redis 里面并没有出现大量的 key 过期
- redis 正常运行
原因:
- redis 的某个key过期了,大量访问使用这个key
key可能会在某些时间点被超高并发地访问,是一种非常“热点”的数据,这个时候,需要考虑一个问题:缓存被“击穿”的问题
解决方案
- 预先设置热门数据:在 redis 高峰访问之前,把一些热门数据提前存入到 redis 里面,加大这些热门数据key的时长
- 实时调整:现场监控哪些数据热门,实时调整key的过期时长
- 使用锁:(用锁会降低效率)
- 就是在缓存失效的时候(判断拿出来的值为空),不是立即去 load db
- 先使用缓存工具的某些带成功操作返回值的操作(比如 redis 的 setnx)去set一个 mutex key
- 当操作返回成功时,再进行 load db 的操作,并回设缓存,最后删除 mutex key
- 当操作返回失败,证码有线程在 load db ,当前线程睡眠一段时间再重试整个 get 缓存的方法
缓存雪崩
现象
- 数据库压力变大,服务器崩溃
原因:
- 在极少的时间内,查询大量key的集中过期情况
缓存失效时间的雪崩效应对底层系统的冲击非常可怕
解决方案
- 构建多级缓存架构:nginx 缓存 + redis 缓存 + 其它缓存(ehcache等)
- 使用锁或队列:(最有效,但是效率低)用加锁或队列的方式来保证不会有大量的线程对数据库一次性进行读写,从而避免失效时大量的并发请求落到底层存储系统上。不适用高并发情况
- 设置过期标志更新缓存:记录缓存数据是否过期(设置提前量),如果过期会触发通知另外的线程在后台去更新实际key的缓存
- 将缓存失效时间分散开:比如我们可以在原有的失效时间基础上增加一个随机值,比如 1-5 分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件
分布式锁
随着业务发展的需要,原单体单机部署的系统被演化成分布式集群系统后,由于分布式系统多线程、多进程,并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,单纯的编程语言API并不能提供分布式锁的能力,为了解决这个问题就需要一种跨系统/机器的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题
分布式锁主流的实现方案:
- 基于数据库实现分布式锁
- 基于缓存(redis 等)
- 基于 zookeeper
每一种分布式锁解决方案都有各自的优缺点:
- 性能:redis 最高
- 可靠性:zookeeper 最高
基于 redis 实现分布式锁
setnx <key> <value>
:会给 key 加锁,锁未释放就不能对这个 key 进行操作
del <key>
:删除 key,这样就可以释放锁
实际在代码中使用就是,先对这个锁进行写操作,写操作成功就可以对数据进行操作,使用完锁要记得释放
缺陷:如果当前锁释放前,服务器就宕机,那么这个锁就释放不了,其它用户也不能操作
解决方案一
设置过期时间,超时自动释放
先使用setnx <key> <value>
,再使用expire <key> <seconds>
,这样就给锁设置了过期时间
ttl <key>
:获取锁的剩余生存时间
缺陷:如果在上锁之后,服务器宕机,无法设置锁的过期时间,那么这个锁也释放不了
解决方案二
上锁的时候,同时设置过期时间
set <key> <value> nx ex <seconds>
:nx 表示上锁,ex 表示设置过期时间
上面的方案存在的问题:锁的误删问题,即我删了别人的锁,别人再删了我的锁
例子:
用户的操作是:上锁 --> 具体操作 --> 释放锁
比如有一个用户,在具体操作的时候,服务器卡顿,导致锁超时了,锁被自动释放,这时候,后边的用户就可以对锁进行操作。
如果后边的用户拿到锁之后,之前的用户使用的服务器又开始正常工作了,这个服务器又会继续之前用户的操作,最终去手动释放锁,这个手动释放的锁就是后边用户的
解决方案三
这里有多种处理方式,这里讲一下使用 UUID 防止误删,即,只能删除自己的锁,不能删除别人的锁
- 使用
set <key> <value> nx ex <seconds>
为每一个操作都生成一个唯一的随机 value ,这个值就是 uuid - 释放锁的时候,首先判断当前 uuid 和要释放的 uuid 是否一样,uuid 一致才能释放锁
当然,这里还有问题,删除操作缺乏原子性
例子:
用户通过了 uuid 的验证,操作到了释放锁的时候,还没完全删除时,同时锁也到了过期时间,自动释放了。
这个时候,后边的用户就可以拿到锁,但是前一个用户还在释放锁,就会把后边一个用户的锁给删除。
原因就是手动释放锁的操作缺乏原子性
解决方案四
Lua 支持原子操作,所以我们使用 Lua 脚本
使用 Lua 脚本去验证 uuid ,并释放锁,这样手动释放和自动释放就不会冲突了
分布式锁的条件
- 互斥性。在任意时刻,只有一个客户端能持有锁
- 不会发生死锁。即使只有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其它客户端能加锁
- 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了
- 加锁和解锁必须具有原子性