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

18 | worker间的通信法宝:最重要的数据结构之shared dict

18 | worker间的通信法宝:最重要的数据结构之shared dict-极客时间

18 | worker间的通信法宝:最重要的数据结构之shared dict

讲述:温铭

时长11:54大小10.89M

你好,我是温铭。
前面我们讲过,在 Lua 中, table 是唯一的数据结构。与之对应的一个事实是,共享内存字典 shared dict,是你在 OpenResty 编程中最为重要的数据结构。它不仅支持数据的存放和读取,还支持原子计数和队列操作。
基于 shared dict,你可以实现多个 worker 之间的缓存和通信,以及限流限速、流量统计等功能。你可以把 shared dict 当作简单的 Redis 来使用,只不过 shared dict 中的数据不能持久化,所以你存放在其中的数据,一定要考虑到丢失的情况。

数据共享的几种方式

在编写 OpenResty Lua 代码的过程中,你不可避免地会遇到,在一个请求的不同阶段、不同 worker 之间共享数据的情况,还可能需要在 Lua 和 C 代码之间共享数据。
所以,在正式介绍 shared dict 的 API 之前,先让我们了解一下,OpenResty 中常见的几种数据共享的方法;并学会根据实际情况,选择较为合适的数据共享方式。
第一种是 Nginx 中的变量。它可以在 Nginx C 模块之间共享数据,自然的,也可以在 C 模块和 OpenResty 提供的 lua-nginx-module 之间共享数据,比如下面这段代码:
location /foo {
set $my_var ''; # this line is required to create $my_var at config time
content_by_lua_block {
ngx.var.my_var = 123;
...
}
}
不过,使用 Nginx 变量这种方式来共享数据是比较慢的,因为它涉及到 hash 查找和内存分配。同时,这种方法有其局限性,只能用来存储字符串,不能支持复杂的 Lua 类型。
第二种是ngx.ctx,可以在同一个请求的不同阶段之间共享数据。它其实就是一个普通的 Lua 的 table,所以速度很快,还可以存储各种 Lua 的对象。它的生命周期是请求级别的,当一个请求结束的时候,ngx.ctx 也会跟着被销毁掉。
下面是一个典型的使用场景,我们用 ngx.ctx 来缓存 Nginx 变量 这种昂贵的调用,并在不同阶段都可以使用到它:
location /test {
rewrite_by_lua_block {
ngx.ctx.host = ngx.var.host
}
access_by_lua_block {
if (ngx.ctx.host == 'openresty.org') then
ngx.ctx.host = 'test.com'
end
}
content_by_lua_block {
ngx.say(ngx.ctx.host)
}
}
这时,如果你使用 curl 访问的话:
curl -i 127.0.0.1:8080/test -H 'host:openresty.org'
就会打印出 test.com,可以表明 ngx.ctx 的确是在不同阶段共享了数据。当然,你还可以自己动手修改上面的例子,保存 table 等更复杂的对象,而非简单的字符串,看看它是否满足你的预期。
不过,这里需要特别注意的是,正因为 ngx.ctx 的生命周期是请求级别的,所以它并不能在模块级别进行缓存。比如,我在 foo.lua 文件中这样使用就是错误的:
local ngx_ctx = ngx.ctx
local function bar()
ngx_ctx.host = 'test.com'
end
我们应该在函数级别进行调用和缓存:
local ngx = ngx
local function bar()
ngx_ctx.host = 'test.com'
end
ngx.ctx 还有很多的细节,后面的性能优化部分,我们再继续探讨。
接着往下看,第三种方法是使用模块级别的变量,在同一个 worker 内的所有请求之间共享数据。跟前面的 Nginx 变量和 ngx.ctx 不一样,这种方法有些不太好理解。不过别着急,概念抽象,代码先行,让我们先来看个例子,弄明白什么是 模块级别的变量
-- mydata.lua
local _M = {}
local data = {
dog = 3,
cat = 4,
pig = 5,
}
function _M.get_age(name)
return data[name]
end
return _M
在 nginx.conf 的配置如下:
location /lua {
content_by_lua_block {
local mydata = require "mydata"
ngx.say(mydata.get_age("dog"))
}
}
在这个示例中,mydata 就是一个模块,它只会被 worker 进程加载一次,之后,这个 worker 处理的所有请求,都会共享 mydata 模块的代码和数据。
自然,mydata 模块中的 data 这个变量,就是 模块级别的变量,它位于模块的 top level,也就是模块最开始的位置,所有函数都可以访问到它。
所以,你可以把需要在请求间共享的数据,放在模块的 top level 变量中。不过,需要特别注意的是,一般我们只用这种方式来保存只读的数据。如果涉及到写操作,你就要非常小心了,因为可能会有 race condition,这是非常难以定位的 bug
我们可以通过下面这个最简化的例子来体会下:
-- mydata.lua
local _M = {}
local data = {
dog = 3,
cat = 4,
pig = 5,
}
function _M.incr_age(name)
data[name] = data[name] + 1
return data[name]
end
return _M
在模块中,我们增加了 incr_age 这个函数,它会对 data 这个表的数据进行修改。
然后,在调用的代码中,我们增加了最关键的一行 ngx.sleep(5),这个 sleep 是一个 yield 操作:
location /lua {
content_by_lua_block {
local mydata = require "mydata"
ngx.say(mydata. incr_age("dog"))
ngx.sleep(5) -- yield API
ngx.say(mydata. incr_age("dog"))
}
}
如果没有这行 sleep 代码(也可以是其他的非阻塞 IO 操作,比如访问 Redis 等),就不会有 yield 操作,也就不会产生竞争,那么,最后输出的数字就是顺序的。
但当我们加了这行代码后,哪怕只是在 sleep 的 5 秒钟内,也很可能就有其他请求调用了mydata. incr_age 函数,修改了变量的值,从而导致最后输出的数字不连续。要知道,在实际的代码中,逻辑不会这么简单,bug 的定位也一定会困难得多。
所以,除非你很确定这中间没有 yield 操作,不会把控制权交给 Nginx 事件循环,否则,我建议你还是保持对模块级别变量的只读。
第四种,也是最后一种方法,用 shared dict 来共享数据,这些数据可以在多个 worker 之间共享。
这种方法是基于红黑树实现的,性能很好,但也有自己的局限性——你必须事先在 Nginx 的配置文件中,声明共享内存的大小,并且这不能在运行期更改:
lua_shared_dict dogs 10m;
shared dict 同样只能缓存字符串类型的数据,不支持复杂的 Lua 数据类型。这也就意味着,当我需要存放 table 等复杂的数据类型时,我将不得不使用 json 或者其他的方法,来序列化和反序列化,这自然会带来不小的性能损耗。
总之,还是那句话,这里并没有银弹,不存在一种完美的数据共享方式,你需要根据需求和场景,来组合多个方法来使用。

共享字典

上面数据共享的部分,我们花了很多的篇幅来学,有的人可能纳闷儿:它们看上去和 shared dict 没有直接关系,是不是有些文不对题呢?
事实并非如此,你可以自己想一下,为什么 OpenResty 中要有 shared dict 的存在呢?
回忆一下刚刚讲的几种方法,前面三种数据共享的范围都是在请求级别,或者单个 worker 级别。所以,在当前的 OpenResty 的实现中,只有 shared dict 可以完成 worker 间的数据共享,并借此实现 worker 之间的通信,这也是它存在的价值。
在我看来,明白一个技术为何存在,并弄清楚它和别的类似技术之间的差异和优势,远比你只会熟练调用它提供的 API 更为重要。这种技术视野,会给你带来一定程度的远见和洞察力,这也可以说是工程师和架构师的一个重要区别。
回到共享字典本身,它对外提供了 20 多个 Lua API,不过所有的这些 API 都是原子操作,你不用担心多个 worker 和高并发的情况下的竞争问题。
这些 API 都有官方详细的文档,我就不再一一赘述了。这里我想再强调一下,任何技术课程的学习,都不能代替对官方文档的仔细研读。这些耗时的笨功夫,每个人都省不掉的。
继续看 shared dict 的 API,这些 API 可以分为下面三个大类,也就是字典读写类、队列操作类和管理类这三种。

字典读写类

首先来看字典读写类。在最初的版本中,只有字典读写类的 API,它们也是共享字典最常用的功能。下面是一个最简单的示例:
$ resty --shdict='dogs 1m' -e 'local dict = ngx.shared.dogs
dict:set("Tom", 56)
print(dict:get("Tom"))'
除了 set 外,OpenResty 还提供了 safe_setaddsafe_addreplace 这四种写入的方法。这里safe 前缀的含义是,在内存占满的情况下,不根据 LRU 淘汰旧的数据,而是写入失败并返回 no memory 的错误信息。
除了 get 外,OpenResty 还提供了 get_stale 的读取数据的方法,相比 get 方法,它多了一个过期数据的返回值:
value, flags, stale = ngx.shared.DICT:get_stale(key)
你还可以调用 delete 方法来删除指定的 key,它和 set(key, nil) 是等价的。

队列操作类

再来看队列操作,它是 OpenResty 后续新增的功能,提供了和 Redis 类似的接口。队列中的每一个元素,都用 ngx_http_lua_shdict_list_node_t 来描述:
typedef struct {
ngx_queue_t queue;
uint32_t value_len;
uint8_t value_type;
u_char data[1];
} ngx_http_lua_shdict_list_node_t;
我把这些队列操作 API 的 PR 贴在了文章中,如果你对此感兴趣,可以跟着文档、测试案例和源码,来分析具体的实现。
不过,下面这 5 个队列 API,在文档中并没有对应的代码示例,这里我简单介绍一下:
lpush/rpush,表示在队列两端增加元素;
lpop/rpop,表示在队列两端弹出元素;
llen,表示返回队列的元素数量。
别忘了我们上节课讲过的另一个利器——测试案例。如果文档中没有,我们通常可以在测试案例中找到对应的代码。队列相关的测试,正是在 145-shdict-list.t 这个文件中:
=== TEST 1: lpush & lpop
--- http_config
lua_shared_dict dogs 1m;
--- config
location = /test {
content_by_lua_block {
local dogs = ngx.shared.dogs
local len, err = dogs:lpush("foo", "bar")
if len then
ngx.say("push success")
else
ngx.say("push err: ", err)
end
local val, err = dogs:llen("foo")
ngx.say(val, " ", err)
local val, err = dogs:lpop("foo")
ngx.say(val, " ", err)
local val, err = dogs:llen("foo")
ngx.say(val, " ", err)
local val, err = dogs:lpop("foo")
ngx.say(val, " ", err)
}
}
--- request
GET /test
--- response_body
push success
1 nil
bar nil
0 nil
nil nil
--- no_error_log
[error]

管理类

最后要说的管理类 API 也是后续新增的,属于社区呼声比较高的需求。其中,共享内存的使用情况就是最典型的例子。比如,用户申请了 100M 的空间作为 shared dict,那么这 100M 是否够用呢?里面存放了多少 key?具体是哪些 key 呢?这几个都是非常现实的问题。
对于这类问题,OpenResty 的官方态度,是希望用户使用火焰图来解决,即非侵入式,保持代码基的高效和整洁,而不是提供侵入式的 API 来直接返回结果。
但站在使用者友好角度来考虑,这些管理类 API 还是非常有必要的。毕竟开源项目是用来解决产品需求的,并不是展示技术本身的。所以,下面我们就来了解一下,这几个后续增加的管理类 API。
首先是 get_keys(max_count?),它默认也只返回前 1024 个 key;如果你把 max_count 设置为 0,那就返回所有 key。
然后是 capacityfree_space,这两个 API 都属于 lua-resty-core 仓库,所以需要你 require 后才能使用:
require "resty.core.shdict"
local cats = ngx.shared.cats
local capacity_bytes = cats:capacity()
local free_page_bytes = cats:free_space()
它们分别返回的,是共享内存的大小(也就是 lua_shared_dict 中配置的大小)和空闲页的字节数。因为 shared dict 是按照页来分配的,即使 free_space 返回为 0,在已经分配的页面中也可能存在空间,所以它的返回值并不能代表共享内存实际被占用的情况。

写在最后

在实际的开发中,我们经常会用到多级缓存,OpenResty 的官方项目中也有对缓存的封装。你能找出来是哪几个项目吗?或者你知道一些其他缓存封装的 lua-resty 库吗?
欢迎留言和我分享,也欢迎你把这篇文章分享给你的同事、朋友,我们一起交流,一起进步。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 6

提建议

上一篇
17 | 为什么能成为更好的Web服务器?动态处理请求和响应是关键
下一篇
19 | OpenResty 的核心和精髓:cosocket
unpreview
 写留言

精选留言(16)

  • helloworld
    2019-07-24
    老师有两个问题请教: 1. ngx.var API是否是lua代码和Nginx C模块之间共享数据的唯一方法?有其他方法吗 2. 关于ngx.ctx,官方文档也提到说这个API 的查询也相对有点昂贵:"The ngx.ctx lookup requires relatively expensive metamethod calls and it is much slower than explicitly passing per-request data along by your own function arguments.",如果这样是否还能说ng.ctx速度很快呢 另外补充一个文中未提到的,ngx.var可以在子请求中有效,也就是说可以跨location使用,而ngx.ctx不可以。
    展开

    作者回复: 其他的方式就是借助外部的储存了,比如 memcached 扥。 ngx.ctx 的快是相对于 ngx.var 而言的。 多谢补充,ngx.ctx 确实不能跨 location,有一个库 lua-resty-ctxdump 可以解决这个问题。

    2
  • HelloBug
    2019-07-07
    温铭老师,你好~有以下一些问题想请教一下~ 1.ngx.var变量的作用域在nginx C模块之间、nginx C和lua-nginx-module模块之间。这个不太理解,从请求的角度来看,是一个工作进程中的单个请求吗? 2.文中有描述ngx.ctx是一种昂贵的调用,是虽然ngx.ctx访问速度快,但ngx.ctx在每一个请求中都会占据内存空间的昂贵吗?还有一个问题,ngx.ctx占据的内存空间的大小是动态增长的还是有大小限制的呢? 3.操作模块内的变量时,如果两个操作之间有阻塞操作,可能出现竞争。如果两个操作之间没有阻塞操作,恰好CPU时间到,当前进程进入就绪队列,也可能产生竞争的对吧?
    展开

    作者回复: 1. 是的,ngx.var 的生命周期和请求一致,请求结束它也就消失了。它的优势是数据可以在 C 模块和 Lua 代码中传递。 2. 原文中没有提到 ngx.ctx 是昂贵的操作吧?可能是我没有表达清楚,是我们会用 ngx.ctx 来替代 ngx.var 这种昂贵的操作,后者才是昂贵的。 3. 两个操作之间有 `yield 操作`,可能出现竞争,而不是`阻塞操作`,有阻塞操作是不会出现竞争的。只要不把主动权交给 Nginx 的事件循环,就不会有竞争。

    共 2 条评论
    2
  • manatee
    2019-07-05
    想请问老师现在很多基于插件来实现的网关里面的插件配置是怎么保存在or里的呢?是sharedict吗

    作者回复: 有些是共享字典,有些是 lrucache

    1
  • zyonline
    2019-07-05
    local ngx = ngx local function bar() ngx_ctx.host = 'test.com' end 中 ngx_ctx.host = 'test.com' 是不是应该是ngx.ctx.host = 'test.com'
    展开

    作者回复: 是的,本来想写的是: local ngx_ctx = ngx.ctx

    共 4 条评论
    1
  • 阳光梦
    2019-07-05
    local ngx_ctx = ngx.ctx local function bar() ngx_ctx.host = 'test.com' end 上面内容放在access.lua中,比如access阶段包含此文件。为何不能执行第一句?
    展开

    作者回复: 有什么报错吗?ngx.ctx 是可以在 access 阶段使用的

    共 3 条评论
    2
  • Geek_2d276a
    2021-08-04
    这段话“如果没有这行 sleep 代码(也可以是其他的非阻塞 IO 操作,比如访问 Redis 等),就不会有 yield 操作,也就不会产生竞争,那么,最后输出的数字就是顺序的。”中的“非阻塞IO操作”在当前语境中含义是会产生竞争,还是不会产生竞争?
  • 黑化肥发灰会灰花
    2021-08-02
    请问一下有没办法在http{lua_shared_dict dogs 10m;} 定义的,在stream中获取到值,共享内容跨子系统?
  • Geek_xiaoer
    2021-05-14
    老师您好,`yield`有可能导致race。哪些API(比如ngx.sleep)或哪些操作(比如访问redis)会yield?请问这个有参考文档,或者整理吗,谢谢!
    1
  • 阿海
    2021-03-10
    请问老师,openresty有没有提供像redis-cli 这样的命令行工具从 终端来查看shared_dict的实际存储内容吗?
  • 姚坤
    2019-09-21
    对于模块级别变量的测试,没有能够模拟出老师说的 race condition 状况。 请老师解答 同时在浏览器打开两个页面请求 localhost/testmodule 多次刷新,每个页面都是稳定输出 4,5 模块文件如下 m.lua: local _M = {} local color = { red = 1, blue = 2, green = 3, } _M.incGreen = function() color["green"] = color["green"] + 1 return color["green"] end _M.getGreen = function() return color["green"] end return _M ---------------- config 配置如下 location /testmodule { content_by_lua_block { local m = require "m" ngx.say(m.incGreen()) ngx.sleep(5) ngx.say(m.incGreen()) } }
    展开
    共 1 条评论
  • helloworld
    2019-07-24
    老师,还有一个问题,local ngx_ctx = ngx.ctx 这个API缓存语句不能用于模块级别,要用于函数级别,那么local ngx_var = ngx.var 这个是不是也是同样的?

    作者回复: 是的

    1
  • HelloBug
    2019-07-07
    关于ngx.ctx不要做模块级别的缓存,我做了以下的测试。 myctx.lua文件: local _M = {} local ngx_ctx = ngx.ctx function _M.bar() ngx_ctx.host = 'test.com' end return _M nginx.conf文件: worker_processes 1; server { listen 9999; location ~/(?<myurl>.*) { content_by_lua_block { local myctx = require "myctx" if ngx.var.myurl == "1" then ngx.say(ngx.ctx.host) ngx.ctx.host = "who" ngx.say(ngx.ctx.host) myctx.bar() ngx.say(ngx.ctx.host) else ngx.say(ngx.ctx.host) end } } } 测试结果如下: [root@localhost nginx]# curl localhost:9999/1 nil who test.com [root@localhost nginx]# curl localhost:9999/1 nil who who [root@localhost nginx]# curl localhost:9999/1 nil who who [root@localhost nginx]# curl localhost:9999/2 nil [root@localhost nginx]# curl localhost:9999/2 nil 第一个请求可以理解,第二、第三个请求证明不要做模块级别的缓存。但是为什么这样呢?即使此时ngx_ctx保留的是第一个请求的ngx.ctx,为什么再次设置变量的值ngx_ctx.host就不管用了呢?
    展开
    共 1 条评论
    1
  • J.Smile
    2019-07-05
    坚持跟读
    共 1 条评论
  • Rye
    2019-07-05
    请教老师个问题,我想在请求返回给各户端前 拿到上游服务器的IP和端口,也就是upstream的具体哪个节点负责处理的请求,有什么办法么?我在 ngx.balancer 里看大部分是set的方法。

    作者回复: 你在 ngx.balancer 的 set_current_peer 时,保存下上游的节点信息?

    共 2 条评论
  • zhang
    2019-07-05
    老师,luajit最大可用内存是2g,在openrety中如何将它提高?

    作者回复: 1.15.8 这个最新版本中,默认已经提高了,支持了 64 位

    共 2 条评论
  • Seven
    2019-07-05
    如果用Dict做一个pub/sub, 由于数据不能持久化,那为了在teload或重启时保证数据完整,是不是必须自己做ACK机制证实每个客户端都收到了每一条数据?
    共 1 条评论