高并发访问下避免对象缓存失效引发Dogpile效应
避免Redis/Memcached缓存失效引发Dogpile效应
Redis/Memcached高并发访问下的缓存失效时可能产生Dogpile效应(Cache Stampede效应).
避免Memcached缓存的Dogpile效应
Memcached的read-through cache流程:客户端读取缓存,没有的话就由客户端生成缓存.
Memcached缓存示例:
1 $mc = new Memcached(); 2 $mc->addServers(array( 3 array('127.0.0.1', 11211, 40), 4 array('127.0.0.1', 11212, 30), 5 array('127.0.0.1', 11213, 30) 6 )); 7 $data = $mc->get('cached_key'); 8 if ($mc->getResultCode() === Memcached::RES_NOTFOUND) { 9 $data = generateData(); // long-running process 10 $mc->set('cached_key', $data, time() + 30); 11 } 12 var_dump($data);
假如上面的generateData()是耗时3秒(或更长时间)的运算或数据库操作.当缓存服务器不可用(比如:缓存实例宕机,或网络原因)或是缓存失效瞬间,如果恰好有大量的访问请求,那就会出现机器CPU消耗或数据库操作次数短时间内急剧攀升,可能会引发数据库/Web服务器故障.
避免这样的Dogpile效应,通常有两种方法:
使用独立的更新进程
使用独立的进程(比如:cron job)去更新缓存,而不是让web服务器即时更新数据缓存.举个例子:一个数据统计需要每五分钟更新一次(但是每次计算过程耗时1分钟),那么可以使用cron job去计算这个数据,并更新缓存.这样的话,数据永远都会存在,即使不存在也不用担心产生dogpile效应,因为客户端没有更新缓存的操作.这种方法适合不需要即时运算的全局数据.但对用户对象,朋友列表,评论之类的就不太适用.
使用”锁”
除了使用独立的更新进程之外,我们也可以通过加”锁”,每次只允许一个客户端请求去更新缓存,以避免Dogpile效应.
处理过程大概是这样的:
A请求的缓存没命中
A请求”锁住”缓存key
B请求的缓存没命中
B请求需要等待直到”锁”释放
A请求完成,并且释放”锁”
B请求缓存命中(由于A的运算)
Memcached使用”锁”的示例:
1 function get($key) { 2 global $mc; 3 4 $data = $mc->get($key); 5 // check if cache exists 6 if ($mc->getResultCode() === Memcached::RES_SUCCESS) { 7 return $data; 8 } 9 10 // add locking 11 $mc->add('lock:' . $key, 'locked', 20); 12 if ($mc->getResultCode() === Memcached::RES_SUCCESS) { 13 $data = generateData(); 14 $mc->set($key, $data, 30); 15 } else { 16 while(1) { 17 usleep(500000); 18 $data = $mc->get($key); 19 if ($data !== false){ 20 break; 21 } 22 } 23 } 24 return $data; 25 } 26 27 $data = get('cached_key'); 28 29 var_dump($data);
上面的处理方法有个缺陷,就是缓存失效时,所有请求都需要等待某个请求完成缓存更新,那样无疑会增加服务器的压力.
如果能在数据失效之前的一段时间触发缓存更新,或者缓存失效时只返回相应状态让客户端根据返回状态自行处理,那样会相对比较好.
下面的get方法就是返回相应状态由客户端处理:
1 class Cache { 2 const RES_SUCCESS = 0; 3 const GenerateData = 1; 4 const NotFound = 2; 5 6 public function __construct($memcached) { 7 $this->mc = $memcached; 8 } 9 10 public function get($key) { 11 12 $data = $this->mc->get($key); 13 // check if cache exists 14 if ($this->mc->getResultCode() === Memcached::RES_SUCCESS) { 15 $this->_setResultCode(Cache::RES_SUCCESS); 16 return $data; 17 } 18 19 // add locking 20 $this->mc->add('lock:' . $key, 'locked', 20); 21 if ($this->mc->getResultCode() === Memcached::RES_SUCCESS) { 22 $this->_setResultCode(Cache::GenerateData); 23 return false; 24 } 25 $this->_setResultCode(Cache::NotFound); 26 return false; 27 } 28 29 private function _setResultCode($code){ 30 $this->code = $code; 31 } 32 33 public function getResultCode(){ 34 return $this->code; 35 } 36 37 public function set($key, $data, $expiry){ 38 $this->mc->set($key, $data, $expiry); 39 } 40 } 41 42 $cache = new Cache($mc); 43 $data = $cache->get('cached_key'); 44 45 switch($cache->getResultCode()){ 46 case Cache::RES_SUCCESS: 47 // ... 48 break; 49 case Cache::GenerateData: 50 // generate data ... 51 $cache->set('cached_key', generateData(), 30); 52 break; 53 case Cache::NotFound: 54 // not found ... 55 break; 56 }
上面的memcached缓存失效时,只有一个客户端请求会返回Cache::GenerateData状态,其它的都会返回Cache::NotFound.客户端可通过检测这些状态做相应的处理.
需要注意的是:”锁”的TTL值应该大于generateData()消耗时间,但应该小于实际缓存对象的TTL值.
避免Redis缓存的Dogpile效应
Redis正常的read-through cache示例:
1 $redis = new Redis(); 2 $redis->connect('127.0.0.1', 6379); 3 4 $data = $redis->get('hot_items'); 5 6 if ($data === false) { 7 // calculate hot items from mysql, Says: it takes 10 seconds for this process 8 $data = expensive_database_call(); 9 // store the data with a 10 minute expiration 10 $redis->setex("hot_items", 600, $data); 11 } 12 var_dump($data);
跟Memcached缓存一样,高并发情况下Redis缓存失效时也可能会引发Dogpile效应.
下面是Redis通过使用”锁”的方式来避免Dogpile效应示例:
1 $redis = new Redis(); 2 $redis->connect('127.0.0.1'); 3 4 $expiry = 600; // cached 600s 5 $recalculated_at = 100; // 100s left 6 $lock_length = 20; // lock-key expiry 20s 7 8 $data = $redis->get("hot_items"); 9 $ttl = $redis->get("hot_items"); 10 11 if ($ttl <= $recalculated_at && $redis->setnx('lock:hot_items', true)) { 12 $redis->expire('lock:hot_items', $lock_length); 13 $data = expensive_database_call(); 14 $redis->setex('hot_items', $expiry, $data); 15 } 16 var_dump($data);
上面的流程是这样的:
正常获取key为hot_items的缓存数据,同时也获取TTL(距离过期的剩余时间)
上面hot_items过期时间设置为600s,但当hot_items的TTL<=100s时,就触发缓存的更新过程
$redis->setnx('lock:hot_items', true)尝试创建一个key作为”锁”.若key已存在,setnx不会做任何动作且返回值为false,所以只有一个客户端会返回true值进入if语句更新缓存.
给作为”锁”的key设置20s的过期时间,以防PHP进程崩溃或处理过期时,在作为”锁”的key过期之后允许另外的进程去更新缓存.
if语句中调用expensive_database_call(),将最新的数据正常保存到hot_items.
原文地址:aHR0cDovL3d3dy56cndtLmNvbS8/cD03MjY1 (BASE64)
博主注:
这篇文章中的方法是一个很好的方法:
A请求的缓存没命中
A请求”锁住”缓存key
B请求的缓存没命中
B请求需要等待直到”锁”释放
A请求完成,并且释放”锁”
B请求缓存命中(由于A的运算)
但实现过程中有一个细节可以进行改进,就是当缓存命中时,不需要等过期再执行以上操作,只需在一定阈值范围内,直接在读取值之前,由当前请求将该命中的缓存更新掉就行。这样就不会碰到缓存一过期,有非常多请求加锁等待的情况了。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· winform 绘制太阳,地球,月球 运作规律
· 超详细:普通电脑也行Windows部署deepseek R1训练数据并当服务器共享给他人
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· AI 智能体引爆开源社区「GitHub 热点速览」
· 写一个简单的SQL生成工具