在缓存系统中,缓存总有失效的时候,比如我们经常使用的 Memcache 和 Redis ,都会设置超时时间;而一旦缓存到了超时时间失效之后,如果此时再有大量的并发向数据库发起请求,就会造成服务器卡顿甚至是系统当机。这就是 Dog Pile Effect 。
避免这样的 Dog Pile 效应,通常有两种方法:
使用独立的更新进程
使用独立的进程(比如 cron job)去更新缓存,而不是让 web 服务器即时更新数据缓存。举个例子:一个数据统计需要每五分钟更新一次(但是每次计算过程耗时1分钟),那么可以使用 cron job 去计算这个数据,并更新缓存。这样的话,数据永远都会存在,即使不存在也不用担心产生 Dog Pile 效应,因为客户端没有更新缓存的操作。这种方法适合不需要即时运算的全局数据。但对用户对象、朋友列表、评论之类的就不太适用。
使用“锁”
除了使用独立的更新进程之外,我们也可以通过加“锁”,每次只允许一个客户端请求去更新缓存,以避免 Dog Pile 效应。
处理过程大概是这样的:
A 请求的缓存没命中
A 请求“锁住”缓存 key
B 请求的缓存没命中
B 请求需要等待直到“锁”释放
A 请求完成,并且释放“锁”
B 请求缓存命中(由于 A 的运算)
lua-resty-lock - 基于共享内存的非阻塞锁实现。
首先,我们先来消除下大家对锁的抗拒,事实上这把共享内存锁非常轻量。第一,它是非阻塞的,也就是说锁的等待并不会导致 NGINX Worker 进程阻塞;第二,由于锁的实现是基于共享内存的,且创建时总会设置一个过期时间,因此这里不用担心会发生死锁,哪怕是持有这把锁的 NGINX Worker Crash 了。
那么,接下来我们只要利用这把锁按如下步骤来更新缓存即可:
- 检查某个 Key 的缓存是否命中,如果 MISS,则进入步骤 2。
- 初始化 resty.lock 对象,调用 lock 方法将对应的 Key 锁住,检查第一个返回值(即等待锁的时间),如果返回 nil,按相应错误处理;反之则进入步骤 3。
- 再次检查这个 Key 的缓存是否命中,如果依然 MISS,则进入步骤 4;反之,则通过调用 unlock 方法释放掉这把锁。
- 通过数据源(这里特是 Redis)查询数据,把查询到的结果缓存起来,最后通过调用 unlock 方法释放当前 Hold 住的这把锁。
具体代码实现请参考:lua-resty-lock#for-cache-locks
当数据源故障的时候怎么办?NO_DATA?
同样,我们以上面的代码片段为例,当 Redis 返回出现 err 的时候,此时的状态即不是 MISS 也不是 NO_DATA,而这里统一把它归类到 NO_DATA 了,这就可能会引发一个严重的问题,假设线上这么一台 Redis 挂了,此时,所有更新缓存的操作都会被标记为 NO_DATA 状态,原本旧的拷贝可能还能用的,只是可能不是最新的罢了,而现在却都变成空数据缓存起来了。
那么如果我们能在这种情况下让缓存不过期是不是就能解决问题了?答案是 yes。
lua-resty-shcache - 基于 ngx.shared.DICT 实现了一个完整的缓存状态机,并提供了适配接口
恩,这个库几乎解决了我们上面提到的所有问题:
- 内置缓存锁实现
- 故障时使用陈旧的拷贝 - STALE
所以,不想折腾的话,直接用它就是的。
参考:
[1] 如何处理 Dog Pile Effect
[2] 浅谈 ngx_lua 在 UPYUN 的应用