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-

相關文章
相關標籤/搜索