在项目开发过程中,我们们经常会用到缓存来加速项目访问、减小数据库压力,但是在使用缓存过程会有一些常见的问题。今天我们就来聊聊关于缓存那些事。
说明
以下示例代码均基于 PHP 的 Laravel 框架完成
如何使用缓存
缓存处理流程
一般缓存会用在程序从数据库中查询数据之前,先从缓存中取数据,如果缓存中有相应数据,就直接返回缓存中的数据,否则从数据库中查询数据并存储到缓存,最后将查询到的结果返回。代码示例如下:
function cache_remember($cacheId, $second, Closure $callback){
if($second === null){
return $callback();
}
$data = Cache::get($cacheId);
if($data === null){
$data = $callback();
if($data !== null){
Cache::set($cacheId, $second, $data);
}
}
return $data;
}
缓存预热
缓存预热就是系统上线后,将相关的缓存数据直接加载到缓存系统。这样避免用户请求的时候再去加载相关的数据。可以通过定时任务,定时预热、刷新缓存
缓存常见问题和解决方案
缓存穿透
缓存穿透是指当用户反复查询一个数据库中不存在的数据,缓存系统自然也没有,这时候这样的无效请求就会压到数据库。如果系统有大量这样的请求,数据库压力自然也会增大
解决缓存穿透,主要有下面几个方法
- 添加拦截器,将一些明显无效的请求(比如 id 小于 0 或者 id 特别大)的请求拦截掉
- 如果查询为空,将 null(或者构造的 null 值) 写入缓存系统中,并设置一个比较短的缓存时间。毕竟没有结果也是一个结果
- 还有一个处理方案是采用布隆过滤器,使用一个足够大的bitmap,用于存储可能访问的key,不存在的 key 直接被过滤;
缓存击穿
缓存击穿是指当某个缓存数据过期时,如果此时突然有大量请求进来,直接访问到数据库,造成数据库的较大压力。
解决缓存击穿,主要有以下方法
- 加互斥锁
在获取缓存的时候,通过加互斥锁保证当某个缓存失效时,只有一个进程访问数据库获取数据,避免大量请求压到数据库
代码示例
function cache_remember($cacheId, $second, Closure $callback){
if($second === null){
return $callback();
}
$data = Cache::get($cacheId);
if($data !== null){
return null;
}
$lock = Cache::lock($cacheId.'_lock', 10);
try {
$lock->block(5);
$data = Cache::get($cacheId);
if($data === null){
$data = $callback();
if($data !== null){
Cache::set($cacheId, $second, $data);
}
}
// Lock acquired after waiting maximum of 5 seconds...
} catch (LockTimeoutException $e) {
// Unable to acquire lock...
} finally {
optional($lock)->release();
}
return $data;
}
- 加缓存标记
加互斥锁只是减轻了数据库的压力,并没有增加吞吐量。假设在高并发下,缓存重建期间key是锁着的,这时过来 1000 个请求 999 个都在阻塞的,同样会导致用户等待超时,这是个治标不治本的方法。
加缓存标记的思路是通过缓存标记在缓存将要过期之前,提前通知系统进行缓存更新,而在系统更新缓存期间,由于缓存还没有过期,仍然能够返回给用户
function cache_remember($cacheId, $second, Closure $callback){
if($second === null){
return $callback();
}
$signKey = $cacheId.'_sign';
$data = Cache::get($cacheId);
$sign = Cache::get($sign);
//缓存标记没有失效,返回缓存数据
if($sign !== null){
return $data;
}
Cache::set($signKey, 1, $second);
//缓存标记失效,通过子进程更新缓存
$cPid = pcntl_fork();
if($cPid == 0){
$data = $callback();
Cache::set($cacheId, $data, $second * 2);
exit();
}
//如果旧的缓存数据存在,暂时返回旧的数据
if($data !== null){
return $data;
}
//等待进程结束,获取新的缓存数据返回
pcntl_wait($status);
return Cache::get($cacheId);
}
如上面代码所示,我们通过缓存标记可以标记将要过期的缓存,然后通过子进程更新缓存,系统在此期间仍然可以获取之前的缓存数据返回。
说明
PHP 的多进程任务处理并不适合使用在 WEB 系统,这里只是展示缓存标记的实现思路,在实际的 WEB 系统中,可以通过将要过期的缓存信息写入 Redis 队列,通过常驻后台的 PHP 进程来监听队列更新这些缓存。
缓存雪崩
缓存雪崩是指由于缓存集中过期或者缓存服务器宕机导致大量缓存不可用从而请求直接压到数据库服务器上的现象。
如果缓存数据是通过预热加载进缓存系统的,并且在缓存过期之前,没有再次将新鲜的缓存加载到系统,缓存过期时,就有可能造成雪崩;或者如果短时间有大量数据更新,也会因为大量缓存失效造成雪崩。
对于缓存雪崩,有如下解决方案
- 缓存分级,根据数据的更新频次,访问频次,设置不同的有效期
- 对于统一预热的缓存数据,过期时间加上一个随机数,避免缓存集中大量过期
关于缓存的使用
缓存是系统开发中的重要内容,能够非常有效地降低数据库的压力,加速系统访问。因此有许多开发者将缓存当做万金油一样的存在,系统哪里访问慢,就通过加缓存的来解决。缓存的滥用同时也会给系统带来许多问题和隐患,先不说缓存更新不及时带来的数据陈旧问题,缓存的滥用会掩盖系统许多设计和编码的问题,而这些问题随着业务的发展随时会爆发。
那么我们用该如何使用缓存呢?
首选在业务发展的初期,系统最好不要大量使用缓存。因为业务发展初期,访问压力并不是特别大,业务也不会特别复杂,如果这时候系统在没有大量缓存的情况无法让用户正常使用,那系统设计和编码肯定有问题,这时候需要考虑的是如何优化设计和编码,而不是通过增加缓存来暂时掩盖设计和编码的问题。同时,不使用缓存也能够让一些隐藏的问题及时暴露,比例通过排查数据库慢查询日志发现某些查询性能低下的查询语句。
当业务发展到一定的规模,系统复杂度和访问压力逐渐增大,数据库查询逐渐成为性能瓶颈的时候,通过加缓存能够为我们赢来系统优化和升级的时间(因为给系统加缓存是很快的,并且能够在很长的一段时间内解决系统访问慢的问题)。而如果一开始系统就大量使用了缓存,这时候就很难快速优化系统,整体优化和升级也会手忙脚乱。