极客时间已完结课程限时免费阅读

40 | 缓存与风暴并存,谁说缓存风暴不可避免?

40 | 缓存与风暴并存,谁说缓存风暴不可避免?-极客时间

40 | 缓存与风暴并存,谁说缓存风暴不可避免?

讲述:温铭

时长08:19大小7.61M

你好,我是温铭。
在前面缓存的那节课中,我为你介绍了,共享字典和 lru 缓存在高性能方面的一些优化技巧。其实,我们还遗留了一个非常重要的问题,也值得我们今天用单独的一节课来介绍,那就是“缓存风暴”。

什么是缓存风暴?

什么是缓存风暴呢?让我们先来设想下面这么一个场景。
数据源在 MySQL 数据库中,缓存的数据放在共享字典中,超时时间为 60 秒。在这 60 秒内的时间里,所有的请求都从缓存中获取数据,MySQL 没有任何的压力。但是,一旦到达 60 秒,也就是缓存数据失效的那一刻,如果正好有大量的并发请求进来,在缓存中没有查询到结果,就要触发查询数据源的函数,那么这些请求全部都将去查询 MySQL 数据库,直接造成数据库服务器卡顿,甚至卡死。
这种现象就叫做“缓存风暴”,它也有一个对应的英文名字Dog-Pile。很明显,我们之前出现的缓存相关的代码,都没有做过对应的处理。比如下面这段代码,就是有缓存风暴隐患的伪代码:
local value = get_from_cache(key)
if not value then
value = query_db(sql)
set_to_cache(value, timeout = 60)
end
return value
这段伪代码看上去逻辑都是正常的,你使用单元测试或者端对端测试,都不会触发缓存风暴。只有长时间的压力测试才会发现这个问题,每隔 60 秒的时间,数据库就会出现一次查询的峰值,非常有规律。不过,如果你这里的缓存失效时间设置得比较长,那么缓存风暴问题被发现的几率就会降低。

如何避免缓存风暴?

现在明白了什么是缓存风暴,我们的下一步就是要搞清楚如何避免它了。下面,让我们分为几个不同的情况来讨论一下。

主动更新缓存

在上面的伪代码中,缓存是被动更新的。只有在终端请求发现缓存失效时,它才会去数据库查询新的数据。那么,如果我们把缓存的更新,从被动改为主动,也就可以直接绕开缓存风暴的问题了。
在 OpenResty 中,我们可以这样来实现。首先,使用 ngx.timer.every 来创建一个定时任务,每分钟运行一次,去 MySQL 数据库中获取最新的数据,并放入共享字典中:
local function query_db(premature, sql)
local value = query_db(sql)
set_to_cache(value, timeout = 60)
end
local ok, err = ngx.timer.every(60, query_db, sql)
然后,在终端请求的代码逻辑中,去掉查询 MySQL 的部分,只保留获取共享字典缓存的代码:
local value = get_from_cache(key)
return value
通过这样两段伪码的操作,缓存风暴的问题就被绕过去了。但这种方式也并非完美,因为这样的每一个缓存都要对应一个周期性的任务(OpenResty 中 timer 是有上限的,不能太多);而且缓存过期时间和计划任务的周期时间还要对应好,如果这中间出现了什么纰漏,终端就可能一直获取到的都是空数据。
所以,在实际的项目中,我们一般都是使用加锁的方式来解决缓存风暴问题。接下来,我将为你讲解几种不同的加锁方式,你可以根据需要自行选择。

lua-resty-lock

我知道,一提到加锁,很多人可能会眉头一皱,觉得这是一个比较重的操作。而且,如果发生死锁了该怎么办呢?这显然还要处理不少异常情况。
但是,使用 OpenResty 中的 lua-resty-lock 这个库来加锁,这样的担心就大可不必了。lua-resty-lock 是 OpenResty 自带的 resty 库,它底层是基于共享字典,提供非阻塞的 lock API。我们先来看一个简单的示例:
resty --shdict='locks 1m' -e 'local resty_lock = require "resty.lock"
local lock, err = resty_lock:new("locks")
local elapsed, err = lock:lock("my_key")
-- query db and update cache
local ok, err = lock:unlock()
ngx.say("unlock: ", ok)'
因为 lua-resty-lock 是基于共享字典来实现的,所以我们需要事先声明 shdict 的名字和大小;然后,再使用 new 方法来新建 lock 对象。你可以看到,这段代码中,我们只传了第一个参数 shdict 的名字。其实, new 方法还有第二个参数,可以用来指定锁的过期时间、等待锁的超时时间等多个参数。不过这里,我们使用的是默认值,它们就是用来避免死锁等各种异常问题的。
接着,我们就可以调用 lock 方法尝试获取锁。如果成功获取到锁的话,那就可以保证只有一个请求去数据源更新数据;而如果因为锁已经被抢占、超时等导致加锁失败,那就需要从陈旧的缓存中获取数据,返回给终端。这个过程是不是听起来很熟悉?没错,这里就正好用到了我们上节课介绍过的的 get_stale API:
local elapsed, err = lock:lock("my_key")
# elapsed 为 nil 表示加锁失败,err的返回值是 timeout、 locked 中的一个
if not elapsed and err then
dict:get_stale("my_key")
end
如果 lock 成功,那么就可以安全地去查询数据库,并把结果更新到缓存中。最后,我们再调用 unlock 接口,把锁释放掉就可以了。
结合 lua-resty-lockget_stale,我们就完美地解决了缓存风暴的问题。在 lua-resty-lock 的文档中,给出了非常完整的处理代码,推荐你可以点击链接查看。
不过,每当遇到一些有趣的实现,我们总是希望能够看看它的源码是如何实现的,这也是开源的好处之一。这里,我们再深入一步,看看 lock 这个接口是如何加锁的,下面便是它的源码:
local ok, err = dict:add(key, true, exptime)
if ok then
cdata.key_id = ref_obj(key)
self.key = key
return 0
end
在共享字典章节中,我曾经提到过,shared dict 的所有 API 都是原子操作,不用担心出现竞争,所以用 shared dict 来标记锁的状态是个不错的主意。
这里 lock 接口的实现,便使用了 dict:add 接口来尝试设置 key。如果 key 在共享内存中不存在,add 接口就会返回成功,表示加锁成功;其他并发的请求走到 dict:add 这一行的代码逻辑时,就会返回失败,然后根据返回的 err 信息,选择是直接返回,还是多次重试。

lua-resty-shcache

不过,在上面 lua-resty-lock 的实现中,你需要自己来处理加锁、解锁、获取过期数据、重试、异常处理等各种问题,还是相当繁琐的。所以,这里我再给你介绍一个简单的封装:lua-resty-shcache
lua-resty-shcache是 CloudFlare 开源的一个 lua-resty 库,它在共享字典和外部存储之上,做了一层封装;并且额外提供了序列化和反序列化的接口,让你不用去关心上述的各种细节:
local shcache = require("shcache")
local my_cache_table = shcache:new(
ngx.shared.cache_dict
{ external_lookup = lookup,
encode = cmsgpack.pack,
decode = cmsgpack.decode,
},
{ positive_ttl = 10, -- cache good data for 10s
negative_ttl = 3, -- cache failed lookup for 3s
name = 'my_cache', -- "named" cache, useful for debug / report
}
)
local my_table, from_cache = my_cache_table:load(key)
这段示例代码摘自官方的示例,不过,我已经把细节都隐藏了,方便你更好地把握重点。事实上,这个缓存封装的库并非是我们的最佳选择,但比较适合初学者去学习,所以我首先介绍的是它。在下一节课中,我会给你介绍其他的几个更好、更常用的封装,方便我们选择最合适的来使用。

Nginx 配置指令

另外,即使你没有使用 OpenResty 的 lua-resty 库,你也可以用 Nginx 的配置指令,来实现加锁和获取过期数据——即proxy_cache_lockproxy_cache_use_stale。不过,这里我并不推荐使用 Nginx 指令这种方式,它显然不够灵活,性能也比不上 Lua 代码。

写在最后

这节课,我主要为你介绍了缓存风暴和相应的几种应对方式。不得不说,缓存风暴,和之前我们反复提到的 race 问题一样,通过 code review 和测试都很难被发现,最好的方法还是提升我们本身的编码水平,或者使用封装好的类库来解决这类问题。
最后,给你留一个作业题。在你熟悉的语言和平台中,都是如何处理缓存风暴之类问题的呢?是否有比 OpenResty 更好的解决思想和方法呢?欢迎留言和我讨论这个问题,也欢迎你把这篇文章分享给你的同事朋友,一起学习和进步。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 1

提建议

上一篇
39 | 高性能的关键:shared dict 缓存和 lru 缓存
下一篇
41 | lua-resty-* 封装,让你远离多级缓存之痛
unpreview
 写留言

精选留言(9)

  • Geek_30e248
    2020-12-16
    解决缓存风暴:给ttl加一个随机的范围值,这样可以极大降低缓存集中失效的概率,从而避免缓存风暴
    1
  • helloworld
    2019-08-27
    老师,有没有写restful api的后端框架推荐?

    作者回复: 这里有很多 https://github.com/bungle/awesome-resty#web-frameworks,但都不流行😢

    1
  • helloworld
    2019-08-27
    老师,为什么很多or的web框架只利用content_by_lua阶段?

    作者回复: 因为这样最简单直接,但并不推荐。一般我们把鉴权的放在 access 阶段,改写的放在 rewrite 阶段,这样逻辑上更清晰,后面也容易做拆分。

    1
  • Charles
    2019-08-26
    项目比较小,一般碰不到老师说的缓存风暴问题,但是能想到的就是主动缓存更新和前端取到缓存空值就加一个队列排队取
    1
  • 向文超
    2022-09-07 来自上海
    缓存风暴 说的就是缓存血崩,血崩的经典解决方法好像还挺多的,除了加锁,还有比如设置缓存过期时间的随机抖动、对db请求限流等
  • witt
    2019-09-13
    接触到两种处理缓存的方式 1. ehcache中有参数可以设置缓存最后一次访问后失效的时间,也就是说如果这个缓存一直在被访问就不会失效,直到这个缓存闲置时间超过设置的值,才会失效 2. 设置二级缓存,读取数据首先从一级缓存读取,一级缓存不存在再用二级缓存里读取,二级缓存比一级缓存失效时间有个30s的延迟,也就是说一级缓存失效后30s二级缓存才失效,更新数据也是先更新一级缓存再更新二级缓存,但更新是没有30s这么长的延迟 当然,这两种缓存都可以主动清理,第二种清理缓存的方式是先清理二级缓存再清理一级缓存
    展开
  • 李春恒
    2019-09-06
  • manatee
    2019-08-26
    想请问下老师,定时更新缓存,和缓存失效时被动查询数据库有什么区别呢

    作者回复: 定时更新需要处理各种异常,如果失败了是否要重试,重试多少次,逻辑会比较复杂;而被动更新就很简单了,失败了就使用过期数据,等着下一个请求再去更新就行了。

    共 2 条评论
  • 许童童
    2019-08-26
    如果不能接受过期数据,一个请求去查询数据库,其它请求没获取到锁,则持续等待,此时这个查询请求很耗时,会不会因为大流量把openResty压垮?

    作者回复: 终端用户的等待时间就变边长,服务端会维护很多并发连接,压垮 OpenResty 倒不至于。 不能接受过期数据,这个其实是个伪命题,本来就是缓存,是允许和数据库的数据不同的。

    共 2 条评论