gitbook/OpenResty从入门到实战/docs/126872.md
2022-09-03 22:05:03 +08:00

8.9 KiB
Raw Blame History

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

你好,我是温铭。

在前面缓存的那节课中,我为你介绍了,共享字典和 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 更好的解决思想和方法呢?欢迎留言和我讨论这个问题,也欢迎你把这篇文章分享给你的同事朋友,一起学习和进步。