ngx_lua应用最佳实践

引子:node

如下文字,是UPYUN系统开发工程师timebug在SegmentFault D-Day南京站技术沙龙上所作分享的内容要义提炼,主题为UPYUN系统开发团队在进行业务逻辑由C模块到ngx_lua的迁移过程当中产生的心得体会,以及在NGINX上基于ngx_lua的方面的最佳实践方案。mysql

Upyun公众号:upaiyunnginx

---------------------------------------------------------------------git

ngx_lua 是一个NGINX的第三方扩展模块,它可以将Lua代码嵌入到NGINX中来执行。github

UPYUN的CDN大量使用了NGINX做为反向代理服务器,其中绝大部分的业务逻辑已经由Lua来驱动了。redis

关于这个主题,以前在 OSC源创会2014北京站 和 SegmentFault D-Day 2015南京站 有作过简单分享,Slide在【阅读原文】中能够看到。不过两次分享都因为我的时间安排上的不足,对Keynote后半部分偏实践的内容并无作过多地展开,未免有些遗憾,所以,本文做为一个补充将尝试以文字的形式来谈谈这块内容。sql

ngx_lua和Openresty数据库

Openresty 是一套基于NGINX核心的相对完整的Web应用开发框架,包含了ngx_lua在内的众多第三方优秀的NGINX C模块,同时也集成了一系列经常使用的lua-resty-*类库,例如redis, mysql等,特别地,Openresty依赖的NGINX核心和LuaJIT版本都是通过很是充分的测试的,也打了很多必要的补丁。后端

UPYUN CDN并无直接基于Openresty来开发,而是借鉴了Openresty的组织方式,把ngx_lua以及咱们须要用到的lua-resty-*类库直接集成进来本身维护。这样作的缘由是由于咱们自身也有很多C模块存在,同时对NGINX核心偶尔也会有一些二次开发的需求,反而直接用Openresty会以为有点不方便。除此以外,须要ngx_lua的地方,仍是强烈推荐直接用Openresty。api

Lua的性能

相比C模块,Lua模块在开发效率上有着自然的优点,语言表达能力也更强些,咱们目前除了一些业务无关的基础模块首选用C来实现外,其它能用Lua的基本上都用Lua了。这里你们可能比较关心的是脚本语言性能问题,关于这一点,从咱们的实践来看,其实没必要过于担忧的,咱们几个比较大的业务模块例如防盗链等用Lua重写后,在线下压测和线上运行过程当中,均没有发现任何明显的性能衰退迹象。固然,这里很大一部分功劳要归于 LuaJIT,相比Lua官方的VM,LuaJIT在性能上有着很是大的提高,另外,还能够利用LuaJIT FFI直接调用C级别的函数来优化Lua代码中可能存在的性能热点。

咱们目前线上用的就是LuaJIT最新的2.1开发版,性能相比稳定版又有很多提高,具体可参考LuaJIT这个 NYI 列表。特别地,这里推荐用Openresty维护的 Fork 版本,兼容性更加有保障。

如上图所示,LuaJIT在运行时会将热的Lua字节码直接翻译成机器码缓存起来执行。

另外,经过techempower 网站对Openresty的性能评测来看,相比node.js, cowboy, beego等,NGINX + ngx_lua + LuaJIT这个组合的性能表现仍是很是强劲的。

元数据同步与缓存

UPYUN CDN线上经过Redis主从复制的方式由中心节点向外围节点同步用户配置,另外,因为Redis自己不支持加密传输,咱们还在此基础上利用 stunnel 对传输通道进行了加密,保障数据传输的安全性。

1)缓存是万金油!

固然,不是说节点上有了Redis就能直接把它当作主要的缓存层来用了,要知道从NGINX到Redis获取数据是要消耗一次网络请求的,而这个毫秒级别的网络请求对于外围节点巨大的访问量来讲是不可接受的。因此,在这里Redis更多地承担着数据存储的角色,而主要的缓存层则是在NGINX的共享内存上。

根据业务特色,咱们的缓存内容与数据源是不须要严格保持一致的,既可以容忍必定程度的延迟,所以这里简单采用被动更新缓存的策略便可。ngx_lua提供了一系列共享内存相关的API (ngx.shared.DICT),能够很方便地经过设置过时时间来使得缓存被动过时,值得一提的是,当缓存的容量超过预先申请的内存池大小的时候,ngx.shared.DICT.set方法则会尝试以LRU的形式淘汰一部份内容。

如下代码片断给出了一个简陋的实现,固然咱们下面会提到这个实现其实存在很多问题,但基本结构大体上是同样的,能够看到下面区分了4种状态,分别是:HIT和MISS, HIT_NEGATIVE和NO_DATA,前二者是对于有数据的状况而言的,后二者则是对于数据不存在的状况而言的,通常来讲,对于NO_DATA咱们会给一个相对更短的过时时间,由于数据不存在这种状况是没有一个固定的边界的,容易把容量撑满。

local metadata = ngx.shared.metadata

-- local key, bucket = ...

local value = metadata:get(key)

if value ~= nil then

if value == "404" then

return -- HIT_NEGATIVE

else

return value -- HIT

end

end

local rds = redis:new()

local ok, err = rds:connect("127.0.0.1", 6379)

if not ok then

metadata:set(key, "404", 120) -- expires 2 minutes

return -- NO_DATA

end

res, err = rds:hget("upyun:" .. bucket, ":something")

if not res or res == ngx.null then

metadata:set(key, "404", 120)

return -- NO_DATA

end

metadata:set(key, res, 300) -- expires 5 minutes

rds:set_keepalive()

return res -- MISS

2)什么是Dog-Pile效应?

在缓存系统中,当缓存过时失效的时候,若是此时正好有大量并发请求进来,那么这些请求将会同时落到后端数据库上,可能形成服务器卡顿甚至宕机。

很明显上面的代码也存在这个问题,当大量请求进来查询同一个key的缓存返回nil的时候,全部请求就会去链接Redis,直到其中有一个请求再次将这个key的值缓存起来为止,而这两个操做之间是存在时间窗口的,没法确保原子性:

local value = metadata:get(key)

if value ~= nil then

-- HIT or HIT_NEGATIVE

end

-- Fetch from Redis

避免Dog-Pile效应一种经常使用的方法是采用主动更新缓存的策略,用一个定时任务主动去更新须要变动的缓存值,这样就不会出现某个缓存过时的状况了,数据会永远存在,不过,这个不适合咱们这里的场景;另外一种经常使用的方法就是加锁了,每次只容许一个请求去更新缓存,其它请求在更新完以前都会等待在锁上,这样一来就确保了查询和更新缓存这两个操做的原子性,没有时间窗口也就不会产生该效应了。

lua-resty-lock -基于共享内存的非阻塞锁实现。

首先,咱们先来消除下你们对锁的抗拒,事实上这把共享内存锁很是轻量。第一,它是非阻塞的,也就是说锁的等待并不会致使NGINX Worker进程阻塞;第二,因为锁的实现是基于共享内存的,且建立时总会设置一个过时时间,所以这里不用担忧会发生死锁,哪怕是持有这把锁的NGINX Worker Crash了。

那么,接下来咱们只要利用这把锁按以下步骤来更新缓存便可:

一、检查某个Key的缓存是否命中,若是MISS,则进入步骤2。

二、初始化resty.lock对象,调用lock方法将对应的Key锁住,检查第一个返回值(即等待锁的时间),若是返回nil,按相应错误处理;反之则进入步骤3。

三、再次检查这个Key的缓存是否命中,若是依然MISS,则进入步骤4;反之,则经过调用unlock方法释放掉这把锁。

四、经过数据源(这里特是Redis)查询数据,把查询到的结果缓存起来,最后经过调用unlock方法释放当前Hold住的这把锁。

具体代码实现请参考:https://github.com/openresty/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实现了一个完整的缓存状态机,并提供了适配接口

恩,这个库几乎解决了咱们上面提到的全部问题:1.内置缓存锁实现2.故障时使用陈旧的拷贝- STALE

因此,不想折腾的话,直接用它就是的。另外,它还提供了序列化、反序列化的接口,以UPYUN为例,咱们的元数据原始格式是JSON,为了减小内存大小,咱们又引入了 MessagePack,因此最终缓存在NGINX共享内存上是被MessagePack进一步压缩过的二进制字节流。

固然,咱们在这基础上还增长了一些东西,例如shcache没法区分数据源中数据不存在和数据源链接不上两种状态,所以咱们额外新增了一个NET_ERR状态来标记链接不上这种状况。

序列化、反序列化太耗时?!

因为ngx.shared.DICT只能存放字符串形式的值(Lua里面字符串和字节流是一回事),因此即便缓存命中,那么在使用前,仍是须要将其反序列化为Lua Table才行。而不管是JSON仍是MessagePack,序列化、反序列操做都是须要消耗一些CPU的。

若是你的业务场景没法忍受这种程度的消耗,那么不妨能够尝试下这个库:https://github.com/openresty/lua-resty-lrucache 。它直接基于LuaJIT FFI实现,能直接将Lua Table缓存起来,这样就不须要额外的序列化反序列化过程了。固然,咱们目前还没尝试这么作,若是要作的话,建议在shcache共享内存缓存层之上再加一层lrucache,也就是多一级缓存层出来,且这层缓存层是Worker独立的,固然缓存过时时间也应该设置得更短些。

节点健康检查

被动健康检查与主动健康检查

咱们先来看下NGINX基本的被动健康检查机制:

upstream api.com {

server 127.0.0.1:12354 max_fails=15 fail_timeout=30s;

server 127.0.0.1:12355 max_fails=15 fail_timeout=30s;

server 127.0.0.1:12356 max_fails=15 fail_timeout=30s;

proxy_next_upstream error timeout http_500;

proxy_next_upstream_tries 2;

}

主要由max_failes和fail_timeout两个配置项来控制,表示在fail_timeout时间内若是该server异常次数累计达到max_failes次,那么在下一个fail_timeout时间内,咱们就认为这台server宕机了,即在这段时间内不会再将请求转发给它。

其中判断某次转发到后端的请求是否异常是由proxy_next_upstream这个指令决定的,默认只有error和timeout,这里新增了http_500这种状况,即当后端响应500的时候咱们也认为异常。

proxy_next_upstream_tries是NGINX 1.7.5版本后才引入的指令,能够容许自定义重试次数,本来默认重试次数等于upstream内配置的server个数(固然标记为down的除外)。

但只有被动健康检查的话,咱们始终没法回避一个问题,即咱们始终要将真实的线上请求转发到可能已经宕机的后端去,不然咱们就没法及时感知到这台宕机的机器当前是否是已经恢复了。固然,NGINX PLUS商业版是有主动监控检查功能的,它经过 health_check 这个指令来实现,固然咱们这里就不展开说了,说多了都是泪。另外Taobao开源的 Tengine 也支持这个特性,建议你们也能够尝试下。

lua-resty-checkups -纯Lua实现的节点健康检查模块

这个模块目前是根据咱们自身业务特色高度定制化的,所以暂时就没有开源出来了。agentzh维护的 lua-resty-upstream-healthcheck (https://github.com/openresty/lua-resty-upstream-healthcheck)模块跟咱们这个很像但不少地方使用习惯都不太同样,固然,若是当初就有这样一个模块的话,说不定就不会重造轮子了:-)

-- app/etc/config.lua

_M.global = {

checkup_timer_interval = 5,

checkup_timer_overtime = 60,

}

_M.api = {

timeout = 2,

typ = "general", -- http, redis, mysql etc.

cluster = {

{ -- level 1

try = 2,

servers = {

{ host = "127.0.0.1", port = 12354 },

{ host = "127.0.0.1", port = 12355 },

{ host = "127.0.0.1", port = 12356 },

}

},

{ -- level 2

servers = {

{ host = "127.0.0.1", port = 12360 },

{ host = "127.0.0.1", port = 12361 },

}

},

},

}

上面简单给出了这个模块的一个配置示例,checkups同时包括了主动和被动健康检查两种机制,咱们看到上面checkup_timer_interval的配置项,就是用来设置主动健康检查间隔时间的。

特别地,咱们会在NGINX Worker初始阶段建立一个全局惟一的timer定时器,它会根据设置的间隔时间进行轮询,对所监控的后端节点进行心跳检查,若是发现异常就会主动将此节点暂时从可用列表中剔除掉;反之,就会从新加入进来。checkup_timer_overtime配置项,跟咱们使用了共享内存锁有关,它用来确保即便timer所在的Worker因为某种异常Crash了,其它Worker也能在这个时间过时后新起一个新的timer,固然存活的timer会始终去更新这个共享内存锁的状态。

其它被动健康检查方面,跟NGINX核心提供的机制差很少,咱们也是仿照他们设计的,惟一区别比较大的是,咱们提供了多级server的配置策略,例如上面就配置了两个server层级,默认始终使用level 1,当且仅当level 1的节点所有宕机的时候,此时才会切换使用level 2,特别地,每层level多个节点默认都是轮询的,固然咱们也提供配置项能够特殊设置为一致性哈希的均衡策略。这样一来,同时知足了负载均衡和主备切换两种模式。

另外,基于 lua-upstream-nginx-module 模块,checkups还能直接访问nginx.conf中的upstream配置,也能够修改某个server的状态,这样主动健康检查就能使用在NGINX核心的upstream模块上了。

其它

固然,ngx_lua在UPYUN还有不少方面的应用,例如流式上传、跨多个NGINX实例的访问速率控制等,这里就不一一展开了,此次Keynote中也没有提到,之后有机会咱们再谈谈。

-The End-

相关文章
相关标签/搜索