在计算机技术里,大家对于缓存一词肯定不陌生,CPU有缓存、数据库有缓存、静态资源缓存CDN、Redis等等;
在这里我们谈的主要是服务器缓存技术,服务端性能优化,最常用的手段就是缓存;
一般来说,缓存作用是把 热数据/结果数据 存放在读取速度更快的地方(内存),使程序可以节省大量读取时间,从而更快速地加载处理;
缓存主要分为本地缓存和远程缓存两种;
本地缓存:YAC(PHP)
远程缓存:Redis、memcache等
本地缓存:
本地缓存是应用程序在同一服务器内的缓存,优点是耗时极低,缺点是占用本地内存、多机冗余、数据不同步;
以PHP的YAC为例:
Yac 是为PHP实现的一个基于共享内存,无锁的内容Cache;
假设PHP以PHP-FPM运行,Yac和Pcache缓存的用户内容User Cache就像Opcache一样,保存在PHP-FPM占用的内存中,下一次脚本可以直接从PHP-FPM中读取数据;
httpd_mod-php同理,而Memcached/Redis需要通过网络(端口)才能访问数据;
使用本地缓存需要注意解决消息的一致性问题;
远程缓存:
Memcached、Redis都属于远程缓存,基于tcp传输数据,所以有一定的网络开销;
优点是可以实现分布式集群,有更强的一致性,可以用作大规模缓存的方案;
缓存使用场景:
二八定律中的 “二” ,热点数据;
经过复杂运算后得到的可以复用的数据;
某些需要频繁加载的配置信息;
等等…
本地缓存存放更新频率低,但请求量很高的数据,因为本地缓存速度更快,但储存空间有限;
对于更新频率很高的数据应该由远程分布式缓存来承担;
把一些不易改变且访问量巨大的数据缓存在本地,通过多级缓存的模式,从而提升系统性能;
当发生变更的时候,直接采取数据库和redis缓存双写的方案,让缓存时效性最高:
经典的缓存+数据库读写的模式:
- 读的时候,先读缓存,缓存没有的话,那么就读数据库,然后取出数据后放入缓存,同时返回响应
- 更新的时候,更新数据库,删除缓存
当发生变更之后,采取MQ异步通知的方式,通过数据生产服务来监听MQ消息,然后异步去拉取服务的数据更新本地缓存和远程缓存:
MQ更新模式:
- 通过mq的订阅模式(区别于队列模式),来解决多节点的分发;
- 节点进行监控,通过写入监控数据,然后统一采集分析线上缓存的使用情况,如:命中率,调用次数,缓存堆大小,存储层命中数等
类似做法:
MySQL binlog增量订阅消费+消息队列+处理并把数据更新到redis
缓存冷启动/预热
当系统第一次启动,大量请求涌入,此时的缓存为空,可能会导致数据库崩溃,进而让系统不可用,同样当redis所有缓存数据异常丢失,也会导致该问题;
因此,可以提前放入数据到redis避免上述冷启动的问题,当然也不可能是全量数据,可以统计出访问频率较高的热数据,这里热数据也比较多,需要多个服务并行的分布式去读写到redis中;
缓存穿透是指查询没有命中各级缓存,直接穿透到数据存储层进行查询,一般存储层无法直接应对高并发的查询,从而导致存储层负载异常;
在程序中可以通过统计总调用数、缓存层命中数、存储层命中数等数据,来监测缓存命中率及穿透情况;
解决方法:
1)缓存空对象
存储层不命中后,仍然将空对象保留到缓存层中,之后再访问这个数据将会从缓存中获取,保护了后端数据源
缓存空对象会有两个问题:
第一,空值做了缓存,意味着缓存层中存了更多无效的数据,需要更多的内存空间,比较有效的方法是针对这类数据设置一个较短的过期时间,让其自动剔除;
第二,如果存储层数据此时更新,缓存层和存储层的数据会有一段时间窗口的不一致,可能会对业务有一定影响,这个时候应考虑数据一致性问题;
2)布隆过滤器拦截
对一定不存在的key进行过滤,可以把所有存在的key放到一个大bitmap中,查询时通过该bitmap过滤;
这种方法适用于数据命中不高,数据相对固定、实时性低(通常是数据集较大)的应用场景,代码维护较为复杂
针对造成服务雪崩的不同原因, 可以使用不同的应对策略:
流量控制
网关限流(Nginx+Lua/OpenResty)
用户交互限流(1. 采用加载动画,提高用户的忍耐等待时间 2. 提交按钮添加强制等待时间机制)
关闭重试
改进缓存模式
缓存预加载
同步改为异步刷新
服务自动扩容
服务调用者降级服务
资源隔离(主要是对调用服务的线程池进行隔离,避免所有资源都等待)
对依赖服务进行分类(强依赖服务不可用会导致当前业务中止,而弱依赖服务的不可用不会导致当前业务的中止)
不可用服务的调用快速失败(超时机制,熔断器 和熔断后的 降级方法 )
资源降级(如:个性化推荐服务不可用,可以降级补充热点数据)
这里我们特指缓存穿透导致的雪崩:
由于缓存层承载着大量请求,有效的保护了存储层,但是如果缓存层由于某些原因整体不能提供服务,于是所有的请求都会达到存储层,存储层的调用量会暴增,造成存储层宕机的情况
解决方案:
1)保证缓存层服务高可用性
- 和飞机都有多个引擎一样,如果缓存层设计成高可用的,即使个别节点、个别机器、甚至是机房宕掉,依然可以提供服务;
如:高可用架构的redis cluster集群,主从架构、一主多从,一旦主节点宕机,从节点自动跟上,并且最好使用双机房部署集群;
- 远程缓存结合本地缓存使用,在redis全部失效的情况下依然能够靠本地缓存抗住部分压力;
2)依赖隔离组件为后端限流并降级
- 无论是缓存层还是存储层都会有出错的概率,可以将它们视同为资源;
作为并发量较大的系统,假如有一个资源不可用,可能会造成线程全部 hang 在这个资源上,造成整个系统不可用;
降级在高并发系统中是非常正常的:比如推荐服务中,如果个性化推荐服务不可用,可以降级补充热点数据,不至于造成前端页面是开天窗;
- 在实际项目中,我们需要对重要的资源 ( 例如 Redis、 MySQL、 Hbase、外部接口 ) 都进行隔离;
让每种资源都单独运行在自己的线程池中,即使个别资源出现了问题(单独资源的线程异常),对其他服务没有影响,避免所有资源都等待;
但是线程池如何管理,比如如何关闭资源池,开启资源池,资源池阀值管理,这些做起来还是相当复杂的,可以选择开源组件进行管理(如Hystrix)
3)提前演练
在项目上线前,演练缓存层宕掉后,应用以及后端的负载情况以及可能出现的问题,在此基础上做一些预案设定;
开发人员使用缓存 + 过期时间的策略既可以加速数据读写,又保证数据的定期更新,这种模式基本能够满足绝大部分需求;
但是有两个问题如果同时出现,可能就会对应用造成致命的危害:
- 当前 key 是一个热点 key( 例如一个热门的娱乐新闻),并发量非常大;
- 重建缓存不能在短时间完成,可能是一个复杂计算,例如复杂的 SQL、多次 IO、多个依赖等;
在缓存失效的瞬间,有大量线程来重建缓存,造成后端负载加大,甚至可能会让应用崩溃;
要解决这个问题也不是很复杂,但是不能为了解决这个问题给系统带来更多的麻烦,所以需要制定如下目标:
减少重建缓存的次数
数据尽可能一致
较少的潜在危险
1)互斥锁 (mutex key)
此方法只允许一个线程重建缓存,其他线程等待重建缓存的线程执行完,重新从缓存获取数据即可
(1) 从 Redis 获取数据,如果值不为空,则直接返回值,否则执行 (2.1) 和 (2.2)。
(2.1) 如果 set(nx 和 ex) 结果为 true,说明此时没有其他线程重建缓存,那么当前线程执行缓存构建逻辑。
(2.2) 如果 setnx(nx 和 ex) 结果为 false,说明此时已经有其他线程正在执行构建缓存的工作,那么当前线程将休息指定时间后,重新执行函数,直到获取到数据
2)永远不过期
“永远不过期”包含两层意思:
从缓存层面来看,确实没有设置过期时间,所以不会出现热点 key 过期后产生的问题,也就是“物理”不过期。
从功能层面来看,为每个 value 设置一个逻辑过期时间,当发现超过逻辑过期时间后,会使用单独的线程去构建缓存。
此方法有效杜绝了热点 key 产生的问题,但唯一不足的就是重构缓存期间,会出现数据不一致的情况
互斥锁 (mutex key):
这种方案思路比较简单,但是存在一定的隐患,如果构建缓存过程出现问题或者时间较长,可能会存在死锁和线程池阻塞的风险,但是这种方法能够较好的降低后端存储负载并在一致性上做的比较好;
” 永远不过期 “:
这种方案由于没有设置真正的过期时间,实际上已经不存在热点 key 产生的一系列危害,但是会存在数据不一致的情况,同时代码复杂度会增大,维护成本也会增大;
redis能用的的加锁命令分别是INCR、SETNX、SET
一、INCR
这种加锁的思路是, key 不存在,那么 key 的值会先被初始化为 0 ,然后再执行 INCR 操作进行加一。
然后其它用户在执行 INCR 操作进行加一时,如果返回的数大于 1 ,说明这个锁正在被使用当中。
1、 客户端A请求服务器获取key的值为1表示获取了锁
2、 客户端B也去请求服务器获取key的值为2表示获取锁失败
3、 客户端A执行代码完成,删除锁
4、 客户端B在等待一段时间后在去请求的时候获取key的值为1表示获取锁成功
5、 客户端B执行代码完成,删除锁
$redis->incr($key);
$redis->expire($key, $ttl); //设置生成时间为1秒
二、SETNX
这种加锁的思路是,如果 key 不存在,将 key 设置为 value
如果 key 已存在,则 SETNX 不做任何动作
1、 客户端A请求服务器设置key的值,如果设置成功就表示加锁成功
2、 客户端B也去请求服务器设置key的值,如果返回失败,那么就代表加锁失败
3、 客户端A执行代码完成,删除锁
4、 客户端B在等待一段时间后在去请求设置key的值,设置成功
5、 客户端B执行代码完成,删除锁
$redis->setNX($key, $value);
$redis->expire($key, $ttl);
三、SET
上面两种方法都有一个问题,会发现,都需要设置 key 过期。那么为什么要设置key过期呢?如果请求执行因为某些原因意外退出了,导致创建了锁但是没有删除锁,那么这个锁将一直存在,以至于以后缓存再也得不到更新。于是乎我们需要给锁加一个过期时间以防不测。
但是借助 Expire 来设置就不是原子性操作了。所以还可以通过事务来确保原子性,但是还是有些问题,所以官方就引用了另外一个,使用 SET 命令本身已经从版本 2.6.12 开始包含了设置过期时间的功能。
1、 客户端A请求服务器设置key的值,如果设置成功就表示加锁成功
2、 客户端B也去请求服务器设置key的值,如果返回失败,那么就代表加锁失败
3、 客户端A执行代码完成,删除锁
4、 客户端B在等待一段时间后在去请求设置key的值,设置成功
5、 客户端B执行代码完成,删除锁
$redis->set($key, $value, array('nx', 'ex' => $ttl)); //ex表示秒
注意问题:
问题1 redis发现锁失败了要怎么办?中断请求还是循环请求?
问题2 循环请求的话,如果有一个获取了锁,其它的在去获取锁的时候,是不是容易发生抢锁的可能?
问题3 锁提前过期后,客户端A还没执行完,然后客户端B获取到了锁,这时候客户端A执行完了,会不会在删锁的时候把B的锁给删掉?
解决办法:
针对问题1:使用循环请求,循环请求去获取锁
针对问题2:针对第二个问题,在循环请求获取锁的时候,加入睡眠功能,等待几毫秒在执行循环
针对问题3:在加锁的时候存入的key是随机的。这样的话,每次在删除key的时候判断下存入的key里的value和自己存的是否一样
do { //针对问题1,使用循环
$timeout = 10;
$roomid = 10001;
$key = 'room_lock';
$value = 'room_'.$roomid; //分配一个随机的值针对问题3
$isLock = Redis::set($key, $value, 'ex', $timeout, 'nx');//ex 秒
if ($isLock) {
if (Redis::get($key) == $value) { //防止提前过期,误删其它请求创建的锁
//执行内部代码
Redis::del($key);
continue;//执行成功删除key并跳出循环
}
} else {
usleep(5000); //睡眠,降低抢锁频率,缓解redis压力,针对问题2
}
} while(!$isLock);
以上的锁完全满足了需求,但是官方另外还提供了一套加锁的算法,这里以PHP为例
$servers = [
['127.0.0.1', 6379, 0.01],
['127.0.0.1', 6389, 0.01],
['127.0.0.1', 6399, 0.01],
];
$redLock = new RedLock($servers);
//加锁
$lock = $redLock->lock('my_resource_name', 1000);
//删除锁
$redLock->unlock($lock)
参考:
http://www.cocoachina.com/ios/20180309/22527.html
https://segmentfault.com/a/1190000008931971
https://mp.weixin.qq.com/s/TBCEwLVAXdsTszRVpXhVug
https://www.cnblogs.com/luyulong/p/5430803.html
https://segmentfault.com/a/1190000005988895
锁参考:https://blog.csdn.net/Dennis_ukagaka/article/details/78072274