<audio id="audio" title="40 | 缓存与风暴并存，谁说缓存风暴不可避免？" controls="" preload="none"><source id="mp3" src="https://static001.geekbang.org/resource/audio/b8/90/b8eec903993ce72ea8cb8b9423871790.mp3"></audio>

你好，我是温铭。

在前面缓存的那节课中，我为你介绍了，共享字典和 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 &quot;resty.lock&quot;
                            local lock, err = resty_lock:new(&quot;locks&quot;)
                            local elapsed, err = lock:lock(&quot;my_key&quot;)
                            -- query db and update cache
                            local ok, err = lock:unlock()
                            ngx.say(&quot;unlock: &quot;, ok)'

```

因为 `lua-resty-lock` 是基于共享字典来实现的，所以我们需要事先声明 shdict 的名字和大小；然后，再使用 `new` 方法来新建 lock 对象。你可以看到，这段代码中，我们只传了第一个参数 `shdict` 的名字。其实， `new` 方法还有第二个参数，可以用来指定锁的过期时间、等待锁的超时时间等多个参数。不过这里，我们使用的是默认值，它们就是用来避免死锁等各种异常问题的。

接着，我们就可以调用 `lock` 方法尝试获取锁。如果成功获取到锁的话，那就可以保证只有一个请求去数据源更新数据；而如果因为锁已经被抢占、超时等导致加锁失败，那就需要从陈旧的缓存中获取数据，返回给终端。这个过程是不是听起来很熟悉？没错，这里就正好用到了我们上节课介绍过的的 `get_stale` API：

```
local elapsed, err = lock:lock(&quot;my_key&quot;)
# elapsed 为 nil 表示加锁失败，err的返回值是 timeout、 locked 中的一个
if not elapsed and err then 
    dict:get_stale(&quot;my_key&quot;)
end

```

如果 `lock` 成功，那么就可以安全地去查询数据库，并把结果更新到缓存中。最后，我们再调用 `unlock` 接口，把锁释放掉就可以了。

结合 `lua-resty-lock` 和 `get_stale`，我们就完美地解决了缓存风暴的问题。在 `lua-resty-lock` 的文档中，给出了非常完整的处理代码，推荐你可以点击[链接](https://github.com/openresty/lua-resty-lock#for-cache-locks)查看。

不过，每当遇到一些有趣的实现，我们总是希望能够看看它的源码是如何实现的，这也是开源的好处之一。这里，我们再深入一步，看看 `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(&quot;shcache&quot;)

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',     -- &quot;named&quot; cache, useful for debug / report
        }
    )

    local my_table, from_cache = my_cache_table:load(key)

```

这段示例代码摘自官方的示例，不过，我已经把细节都隐藏了，方便你更好地把握重点。事实上，这个缓存封装的库并非是我们的最佳选择，但比较适合初学者去学习，所以我首先介绍的是它。在下一节课中，我会给你介绍其他的几个更好、更常用的封装，方便我们选择最合适的来使用。

### Nginx 配置指令

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

## 写在最后

这节课，我主要为你介绍了缓存风暴和相应的几种应对方式。不得不说，缓存风暴，和之前我们反复提到的 race 问题一样，通过 code review 和测试都很难被发现，最好的方法还是提升我们本身的编码水平，或者使用封装好的类库来解决这类问题。

最后，给你留一个作业题。在你熟悉的语言和平台中，都是如何处理缓存风暴之类问题的呢？是否有比 OpenResty 更好的解决思想和方法呢？欢迎留言和我讨论这个问题，也欢迎你把这篇文章分享给你的同事朋友，一起学习和进步。


