最近准备把近期学到的一些东西应用到公司里,先来改进下缓存系统吧。
为什么使用缓存
缓存主要用来解决两种类型的场景
CPU占用过高的场景
数据库IO过高的场景
如果访问量很小,根本没必要折腾这个。在技术带来好处的同时一定会增加其原本的复杂度。
如何选择缓存
对于缓存系统的选择,对PHP而言好像也没几个可以选的,一般也就 Memcached 和 Redis了(我找了一圈就发现这2个…不像Java可以有很多种选择方案)
有下面几个维度的选择参考
比较项 | Memcached | Redis |
---|---|---|
读写性能 | 很高 | 高 |
淘汰算法 | LRU | LRU |
是否持久化 | 否 | 是 |
是否支持多线程 | 是 | 否 |
是否支持集群 | 是 | 是 |
支持广泛的编程语言 | 是 | 是 |
支持广泛的数据结构 | 否 | 是 |
支持发布/订阅 | 否 | 是 |
支持事务 | 否 | 是 |
好吧我选择redis,其实对于我们这种小公司选择啥都一样,根本没有瓶颈啊。选择redis是因为折腾多吧,坑也多,就喜欢干这种事。
缓存清空策略
缓存命中率:等于缓存请求成功数 / 请求缓存总数。这是一个衡量缓存有效性的重要指标,命中率越高 说明缓存使用率越高。
缓存可以使用的存储空间是有限的,当缓存空间占用满了以后,就需要清除掉一些缓存 以便给新的缓存数据使用。要删除哪些缓存,是由缓存的清空策略来决定的。常见的缓存清空策略有下面几种。
FIFO(first in first out)
- 先进先出策略。最先进入缓存的数据在缓存空间不足的时候,会优先被清楚掉。该策略主要通过比较元素的创建时间,以保证最新的数据可用。
LFU(less frequently used)
- 最少使用策略。根据缓存的使用次数,无论缓存是否过期,清除使用次数较少的元素来释放空间。该策略主要通过比较元素的命中次数,以保证高频数据可用。
LRU(least recently used)
- 最近最少使用策略。无论缓存是否过期,都根据缓存最后一次被使用的时间戳,然后清楚最远使用的时间戳来清楚缓存释放空间。该策略主要通过比较元素最近一次被查询使用的时间,以保证热点数据可用。
Memcached 和 Redis清空策略有何不同
Memcached在删除失效缓存的时候采用的消极的方法,内部并不会监视缓存是否失效,而是在Get缓存的时候判断是否失效,然后做上标记并回收失效的缓存所占用的空间。当有新的缓存数据进入的时候,Memcached会优先使用这些空间。当失效的缓存空间用完了后,所有缓存可用空间占完后,Memcached采用LRU的策略来释放空间。
Redis采用的是监视内存,通过周期性行为来删除失效缓存。删除方式有好几种可选模式,不过大多也是使用的LRU策略来释放空间。
缓存分类
在应用服务框架中,根据和应用的耦合度划分为:
本地缓存:指的是在应用中的缓存组件,其最大的优点是应用和cache是在同一个进程内部,请求缓存非常快速,没有过多的网络开销等,在单应用不需要集群支持或者集群情况下各节点无需互相通知的场景下使用本地缓存较合适。同时,它的缺点是缓存跟应用程序耦合在一起,多个应用程序无法直接的共享缓存,各应用或集群的各节点都需要维护自己的单独缓存,对内存是一种浪费。 比如
cookie
,session
分布式缓存:指的是与应用分离的缓存组件或服务,其最大的优点是自身就是一个独立的应用,与本地应用隔离,多个应用可直接的共享缓存。
缓存常见问题
使用缓存中常见的挖坑有3个,也就是这次要改善的代码
缓存穿透
缓存穿透:假如查询数据,发现缓存中不存在,然后去查询数据库也不存在。这个时候就相当于缓存是一种摆设一样,等于所有的请求都是直接打到数据库上,这样访问量一大,数据库有挂掉的风险。
解决方案:
① 对于为空的查询结果 设置默认 Null
进行缓存(异常的操作不要进行缓存)。
- 这种方法会缓存太多空值,占用太多空间,解决办法 给空值设置一个较短的过期时间。
- 复杂了业务逻辑,需要在插入缓存的时候删除这个空缓存
② 使用过滤器
需要事先定义一些过滤规则,对于无效的请求直接过滤掉返回,连缓存都不用去查询了。
针对以上改进 最终的执行流程如下图
Laravel 下改进伪代码如下:
过滤器的话,可以使用中间件来实现。新建一个中间件,在其中指定过滤规则,然后在执行的方法中增加中间件过滤即可。
缓存击穿
缓存击穿:假如某些Key设置了过期时间,在过期后有大量请求进来(比如热点数据),导致直接查询数据库,访问量剧增,数据库有挂掉的风险。
解决方案:
① 使用分布式锁:
加载数据的时候可以利用分布式锁锁住这个数据的Key,在Redis中直接使用
setnx
操作即可,对于获取到这个锁的线程,查询数据库更新缓存,其他线程采取重试策略,这样数据库不会同时受到很多线程访问同一条数据
② 缓存到期自动刷新
对于过期的数据可以采取到期后自动刷新数据的方式。比如redis中数据过期后的广播+laravel command的形式,实现在过期后重新加载数据
Redis 分布式锁的实现
要实现redis的分布式锁,需要用到redis的 setnx
方法。
setnx
方法根据 官方文档 介绍:
如果Key不存在,可以设置一个String类型的值,他的功能等于
set
方法。当一个key拥有一个值的后,setnx
什么都不会操作。setnx 是 【set if not exists】 的简写 也就是不存在才会设置的意思返回值
- 1 当Key的值被设置的时候
- 0 当Key的值没有被设置的时候
|
|
因为同时只能有一个设置成功,所以用他来实现类似锁的功能。
大概实现:
1.如果进程执行
setnx
结果为1,说明该进程获取到了锁,同时setnx
可以设置值,可以给key设置一个值代表过期时间(不能锁住太久 可以使用当前时间+锁的有效时间)
2.后面的进程进来执行setnx
结果都会为0,说明别的进程已经获取到了锁。没有获得所的进程可以不断尝试setnx
操作,以获得锁在进行相应操作
但是这个实现过程中有2个问题需要解决
1.死锁的问题
如果一个进程获取到了锁,但是进程挂掉了,导致他一直持有锁,别的进程又获取不到。所以要确定有效的释放锁规则和合理的过期时间,一般考虑当前业务需要执行的时间然后设置就差不多了
2.锁失效的问题
锁失效其实就是多个进程获取到锁的问题。举个例子:假设锁的 Key=lockDelay
- A第一个获取到了lockDelay的锁,然后它挂掉了
- B和C正在不断的检测锁是否已经被释放或者过期
- B和C同时发现lockDelay的锁已经超时了
- B 执行
del lockDelay
删除 key - B 执行
setnx lockDelay
然后返回1,成功获取锁 - C 执行
del lockDelay
删除 key,注意这个时候C删除的key其实是B设置的,为什么C会执行这个操作?因为B和C同时都判断初始的key超时了 - 然后C执行
setnx lockDelay
也会返回1,成功获取锁(因为上一步C把B设置的key给删除了)。 - 这个时候B和C同时都获取到了锁,和锁失效是一样的
关于上面的问题解决办法:
- 假设A第一个获取到了lockDelay的锁,然后它挂掉了
- B执行
setnx lockDelay
来尝试获取锁,由于A已经获取到了锁,所以此时B执行的结果返回是0,也就是获取锁失败 - 然后B 执行
get lockDelay
来检测锁是否超时,如果没有超时则等一段时间后再次执行检测 - 如果B发现lockDelay已经超时了,也就是当前时间大于lockDelay存储的过期时间,然后B执行
getset lockDelay <当前时间戳 + 锁定时间 + 1>
- 这里是利用
getset
命令的特性,他会给key设置新值并返回旧的值,比如这里执行后,就相当于给lockDelay设置了一个新的过期时间,然后返回了上一次的过期时间 - 然后B比较上一次的过期时间,发现比当前时间小,他就获得了锁
- 再假设C和B同时发现lockDelay超时了,并在B操作
getset
之前先执行了getset
命令,这也没有关系。此时C获得了锁,而B执行的getset
设置了一个新的过期时间(注意这里:C获得锁设置的新过期时间肯定是大于当前的),而B执行后又增大了过期时间,但是B返回的过期时间其实是C设置的也就是大于当前的时间,而B通过比较会发现他并没有获得锁也就不会执行业务逻辑了,会继续选择等待以尝试获得锁。
画图流程如下:
改进伪代码示例如下:
缓存雪崩
缓存雪崩:是指大量的缓存在同一个时刻失效,从而导致请求大量打到数据库上,数据库压力巨大引起系统雪崩。
解决办法:
1.随机值:给缓存设置不同的过期时间,比如在原本的过期时间上在随机加上一些时间,尽量让不同的key过期时间不同
2.采用多级缓存:不同级别缓存设置的超时时间不同,及时某个级别缓存都过期,也有其他级别缓存兜底。
3.增加缓存系统监控,根据业务适当扩容缓存
缓存污染
缓存污染:是指在本地缓存中获取了某些结果,然后接下来你修改了缓存,但是却没有更新数据库,这个时候就造成了数据库和缓存不一致的情况,也就是缓存污染
解决办法:一般是在开发人员代码层面解决,代码要严格的review。