引子:
以下文字,是UPYUN系统开发工程师timebug在SegmentFault D-Day南京站技术沙龙上所做分享的内容要义提炼,主题为UPYUN系统开发团队在进行业务逻辑由C模块到ngx_lua的迁移过程中产生的心得体会,以及在NGINX上基于ngx_lua的方面的最佳实践方案。
Upyun公众号:upaiyun
---------------------------------------------------------------------
ngx_lua是一个NGINX的第三方扩展模块,它能够将Lua代码嵌入到NGINX中来执行。
UPYUN的CDN大量使用了NGINX作为反向代理服务器,其中绝大部分的业务逻辑已经由Lua来驱动了。
关于这个主题,之前在OSC源创会2014北京站和SegmentFault D-Day 2015南京站有做过简单分享,Slide在【阅读原文】中可以看到。不过两次分享都由于个人时间安排上的不足,对Keynote后半部分偏实践的内容并没有做过多地展开,未免有些遗憾,因此,本文作为一个补充将尝试以文字的形式来谈谈这块内容。
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。
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了。
那么,接下来我们只要利用这把锁按如下步骤来更新缓存即可:
1、检查某个Key的缓存是否命中,如果MISS,则进入步骤2。
2、初始化resty.lock对象,调用lock方法将对应的Key锁住,检查第一个返回值(即等待锁的时间),如果返回nil,按相应错误处理;反之则进入步骤3。
3、再次检查这个Key的缓存是否命中,如果依然MISS,则进入步骤4;反之,则通过调用unlock方法释放掉这把锁。
4、通过数据源(这里特是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-