10.22 程序員怎麼能不知道OpenResty?——搭建高性能Web應用的利器

為什麼要做高性能服務

首先給高性能 Web 服務一個簡單定義:QPS 過萬的服務是高性能 Web 服務。我認為一個好服務絕對不是優化出來的,架構決定一個服務的基準,過早優化是萬惡之源。

大家都知道如果做 Web,Web 只需要水平伸縮、擴展就行,那麼為什麼還要高性能呢?事實上有些服務是不適合水平伸縮的,比如有狀態的服務。我盤點了過去幾年使用過的服務,發現有狀態的數據庫服務的確很多:

  • 統一集中式的緩存 Redis
  • 高性能的隊列 Kafka
  • 傳統的關係型數據庫 MySQL、Postgres
  • 新興的非關係型數據庫 MongoDB、Elasticsearch
  • Embedded 數據庫,屬於內嵌數據庫,跟服務配置在一起的 SQLite3、H2、Boltdb
  • 最新的時序數據庫 InfluxDB、Prometheus 等

上面這些服務實際上都是有狀態的,它的擴容、伸縮不是那麼簡單,一般是以 Sharding 的方式通過人工操作手動做伸縮。

而基礎服務和平臺服務是整個公司的服務所依賴的,一家公司的服務可能有成千上百臺機器,但是基礎服務應該只佔很小的比例,所以我們對基礎的通用服務有高性能需求,比如 Gateway 網關、Logging、Tracing等監控系統,以及公司級別的用戶的 API、Session/Token校驗,這類服務對性能有一定要求。

此外,水平擴展是有限度的,隨著機器的增多,機器提供的容量、QPS 不是一個線性增長的過程。

高性能的好處,我認為有以下幾個方面:

  • 成本低,運維簡單:伸縮容不敏感,一臺機器可以扛多臺機器的量;
  • 便於流量分發:機器少可以便於流量分發,部署快意味著回滾快;面對完全不兼容,或者對極度的流量平滑遷移有需求的情況會用到紅綠部署;
  • 簡化設計:應用內的緩存會更高效,按機器緯度可以做一些簡單的 ABTest;
  • “程序員的自我修養”。

如何做高性能服務

絕大部分Web 應用實際上都是 IO 密集型服務,而非 CPU 的密集型服務。對 CPU 的性能,大家可能沒有一個直觀感受,這裡給大家舉個例子:π 秒約等於一個納世紀,說的是人在觀察世界的時候是以秒級的尺度。而 CPU 的是納秒級別的尺度。對於 CPU 來說,3.14 秒相當於人類的一個世紀這麼漫長,1 秒就相當於 CPU 的“33 年”。如果是 1-2個毫秒,我們覺得很快,但對於 CPU 實際上是它的“20 天左右”。

而這僅僅是單核,實際上還有多核的加持。面對這麼強的 CPU 性能,怎麼充分利用呢?這就有了 2000 年以來的新型編程模式:異步模型,也叫事件驅動模型。異步編程、事件驅動,是把阻塞的、慢的 IO 操作轉化成了快的 CPU 操作,用通過 CPU 完全無阻塞的事件通知機制來驅動整個應用。

我自2012 年在手機搜狐網接觸 Python Tornado 異步編程以來,用過多個語言和框架做異步編程。我認為同步模型就是“線程池+頻繁的上下文切換+線程之間數據同步的大鎖”,這意味著如果是同步模型,需要根據當前的 loading 去做系統的調優,根據目前的各個狀況,一點一滴去做,實際上調優難度是非常大的。

而異步模型相當於一個高併發的狀態,因為它是EventLoop,是在不停循環的。同時有兩個請求過來,它可能同時會觸發,如果其中一個請求操作 CPU 沒有及時讓出,就會影響另一個請求。它用潛在的時延換來更高的併發。高性能就相當於“異步+緩存”,即異步解決 IO 密集、緩存解決 CPU 密集的問題。

目前市面上主流的異步語言和框架包含:

  • C 和 C++,一般不會拿來寫 Web 應用;
  • PHP(swoole),這個生態相對來說沒有那麼好;
  • Java,近些年藉助 Spring Cloud、Gateway 又火了一把,但還是一個比較初級的狀態,還是在那種 then/onError 結合子的狀況下面去編程;
  • NodeJS,談異步肯定離不開 JS 和 NodeJS,JS 生態是異步的生態,它沒有同步的阻塞。從開始設計到現在,經過這麼多年發展,從最開始回調,逐漸演化成為 Promise 類庫的加持,再到 NodeJS 的 async/await、IO 這類方便用 generate 寫異步代碼的狀態,async/await 的模型也是當前整個業界比較認可和推崇的寫異步的方式;
  • Python,Python 經歷了從最開始的 yield 做 generate,到 yield+send 做成協程的狀態,再到 yield+from 加 generate 做流式的處理,到現在 3.x 版本的 asyncio 納入官方庫的過程;
  • Rust,Rust 是種新興語言,它基於內存模型的特殊,選擇了 pull 模型做異步的執行。它是在 1.39.0 的時候即今年 9 月份,async/await 這種模式才是一個 stable 的狀態,一個async/await 的 MVP 最小可用的產品,而 tokio 作為官方 de-facto 的 runtime 是在 3-6 個月穩定,所以 2020 年上半年可能可以看到一波用 Rust 去寫 Web 程序的浪潮;
  • Golang,是用 Goroutine 去做切換,不同於其他,它看起來是一個同步代碼,實際上是在後面的 runtime 做異步調度的形式。

那為什麼還要學 OpenResty 呢?這裡首先是限定在 2015 年,今天要講的實踐大約是在 2015-2016 年做的,可能時間上沒有這麼新了。但在 2015 年,上述這些異步除了 JS,其他的成熟度沒有那麼高,所以 在當時 OpenResty 的確是很好的做異步的框架。

什麼是 OpenResty

Nginx

Nginx 是一流的反向代理服務器,它有完善的異步開源生態,且在一線互聯網公司已經成為標配,是已經引入的技術棧。因此在 OpenResty 基礎上再引入一些東西是風險極低的事情,只是引進來一個 Module,就可以複用已有的學習成本。

此外,Nginx有天然的架構設計:

  • Event Driving
  • Master-Worker Model
  • URL Router,不用再選路由庫,本身在 config 裡就很高效
  • Processing Phases,關於對請求的處理流程這一點只有在使用 OpenResty 後才能感受到

Lua

Lua 是一個小巧靈活的編程語言,天生和 C 親和,支持 Coroutine。除此之外它還有一個很高效的 LuaJIT 實現,提供了一個性能很好、很高的 FFI 接口,根據 Benchmark 這種方式比手寫C 代碼調 C 函數還要快。

OpenResty

OpenResty 是Nginx+Lua(JIT),它是兩者完美的有機結合,把異步的Nginx 生態用 Lua 去驅動。Lua 本身不是一個異步生態,這裡提一下春哥為什麼要去做 OPM 和包管理,其實是因為 luarocks 是同步的生態,所以 OpenResty 很多包都是 lua-resty 開頭的,表明它是異步的,能在 OpenResty 上使用。

OpenResty 應用實踐:WebBeacon

WebBeacon 概述

Beacon 是一個埋點的服務,記錄 http 請求,做後續的數據分析,從而計算出會話數量、訪問時長、跳出率等一系列的數據。

dig.lianjia.com 是WebBeacon 服務的前端,它負責支持數據的蒐集,不負責數據的處理和計算。它還負責接收 http 請求,返回 1×1 的 gif 圖片;接收到了 http 請求後,會有一個內部的格式稱作為 UAL(Universal Access Logging),這是統一全局的訪問日誌格式,把http 請求相關的信息落地推到 Kafka,就可以做實時或者離線的數據統計,這就是整個服務的概況。

我們接手時已經有一個版本,第一個版本是用 PHP 實現的,性能不是很好。它是 FastCGI + PHP Logging to file 直接接收到請求,PHP 寫到文件裡,再由 rsyslog imfile,file 的 input module 去讀取日誌文件,通過 Kafka 的 output module 去推到 Kafka 的一個狀況。

舉個例子,為什麼 PHP 性能不高呢?根據 PHP 的請求模型,為了防止內存洩漏,你是不需要關注資源釋放的。每個請求開始時打開日誌文件,然後寫入日誌,請求結束時自動關閉,只要不是在 Extension 裡初始化都會重複這個過程。在這個項目中,每次打開和關閉只是為了寫一條日誌進去,這就會使性能變得很差。

面臨的挑戰

  • 重要程度很高,很長一段時間,整個鏈家網的大數據部門唯一生產資料就是源於此服務,如果沒有這個服務,就沒有後面所有數據統計計算和報表的輸出,對於下游的依賴方,這個服務重要度相當高。
  • 高吞吐狀態,所有的 PC 站、M 站、安卓、iOS、小程序甚至內網服務都可以往這個服務上打,所以對吞吐有一個非常高的要求。
  • 這個服務有業務,它要去到不同的地方取信息,再做一些轉換,最後落地,肯定需要有一個靈活的編程的東西可以搞定它。
  • 資源隔離性差,當時鏈家網的整個服務是一個混合部署的狀態,只靠操作系統的隔離性在維持,所以會出現多個服務跑在同一臺機器上的情況,對 CPU、內存、磁盤等有爭用的狀態。

堅持的原則

  • 性能 Max,性能越高越好;
  • 避免對磁盤的(硬)依賴,因為混合部署的原因,希望它能夠在關鍵路徑上甚至從頭到尾能夠避免對磁盤的依賴;
  • 即刻響應用戶,一個 WebBeacon 的服務,實際上核心是落數據,用戶可以直接響應,不需要等,落數據可以放在後臺做,不需要卡著用戶的請求。我們希望有一個機制,能夠拿到請求,直接返回一個請求,後面再做業務重的部分;
  • 重構成本儘可能低,這個項目已經有第一版了,雖然是重構,但相當於重寫了一版。我們希望它能在這個過程中耗時越短越好,在原有的基礎上能夠繼承下來所有的功能點,同時有自己新的特點。

主體邏輯

程序員怎麼能不知道OpenResty?——搭建高性能Web應用的利器

上圖是OpenResty 的階段圖,寫過 OpenResty 程序的人都會知道它是非常重要的東西,是 OpenResty 的核心流程。通常大家在 Content generatedby?階段會用 upstream 去做 balancer,但實際在做 Web 應用時,直接寫 content_by_lua 就可以,因為是你直接來輸出,同時 access 和 header 兩個階段做一些數據的蒐集,通過 log_by_lua* 這個階段把數據落地,達到我們即刻響應用戶的需求。content_by_luacontent_by_lua實際上很簡單,就是無腦地吐固定的內容:

程序員怎麼能不知道OpenResty?——搭建高性能Web應用的利器

如上圖,聲明 Content-Length=43,如果不聲明則默認是Chunked 模式在我們的場景裡是沒有意義的;z 是 Lua5.2 Multiline String 的一種寫法,它會把當前的換行和後面的空白字符全部截取掉,然後拼回去。

access_by_lua

我們在 access_by_lua 的過程中做了幾件事情:

首先是解析 cookie,要去記錄和下發一些 cookie,我們使用 cloudflare/lua-resty-cookie 的包去解析 cookie。

然後生成uuid 去標識設備 ID 或者請求 ID 等一系列這種隨機的狀態,我們用了 openssl 的 C.RAND_bytes,隨機生成了 16 個 bytes、128 bit,然後用 C.ngx_hex_dump 轉化,再一點一點切成 uuid 的狀態。因為我們內部大量使用 uuid 的生成,所以這塊希望它的性能越高越好。

除了uuid 還有 ssid,ssid 是 session ID,session 是記錄會話的數量。在 WebBeacon 裡,會話是指用戶在 30 分鐘內連續地訪問。如果一個用戶在 30 分鐘內持續訪問我們的服務,則認為它是一個會話的狀態;超 30 分鐘 cookie 過期了,會重新生成一個新的 cookie,即是一個新的會話。此外,這裡的統計是不跨自然天的,比如晚上 11:40 分,一個用戶進來,會只給他20 分鐘的 cookie,從而保證不跨自然天。

除了以上這些,我們還有一個最重的業務邏輯。

手機端瀏覽器每次發請求都會有成本,我們做了設定,將手機端蒐集的日誌打包一起上傳,把多條的埋點日誌彙總,當用戶按 home 鍵退出或放到後臺時觸發上報流程,以 POST 的形式去上報,POST 時同時做編碼,加上 GZIP,它的流量損耗會很低。這意味著我們要去解析 body,去把一條上報上來的 POST 請求拆成 N 條不一樣的埋點日誌再落地。為此我們做了以下的事情:

  • 限定 Max body,因為不希望落磁盤,所以設置了 64K 的大小。考慮到我們有 GZip,可能之前有 400k-500k 的狀態,彙總蒐集後應該是足夠使用了;
  • 客戶端編碼,服務端自然要去解碼。通過 zlib 去解 Gzip,然後 decode URL,再做 json_decode,終於把一個請求拆成了 N 個,一個 list 下面包含 N 個請求的狀況,把每一個 list item 重新編碼成埋點日誌再落回去;
  • 用 table new,table.new(#list,0)->table.new(0,30)/table.clear,然後做 json_encode、ngx.escape_url 等操作,最後形成單條的日誌;
  • 假設在這個過程中出現任何問題就會做降級,用 log_escape 把一個 raw body 落到了日誌裡面,這樣後期還是有能力去做找回。

header(body)_filter_by_lua

還有一個邏輯是蒐集、彙總字段的過程。

為什麼不在 access_by_lua 的時候一起做了?原因是我們在壓測時發現在高併發壓力的情況下,某個操作在 access_by_lua 階段會發生 coredump。也有可能是我們用的方式不對,本身就不應該用在 access_by_lua 段,這塊兒沒有深究。

所以我們把一部分過濾階段與當前請求沒有太大關係的業務挪到了header_filter_by_lua 的過程中,我們有解X-Forwarded-For 去落 IP,有解lianjia_token 去落 ucid,鏈家裡的 ucid是一個長串的數字。Lua 裡面的數字有 51 位的精度,這意味著這塊數字沒辦法落下來,於是我們使用 FFI 去 new 一個 64 位的 URL 的 number,並做一系列的變換,再通過打印截取的方式落出來想要的 20 多位的 ucid 的長度。

log_by_lua 備選方案

下面介紹最核心的一環即 log_by_lua 落日誌的過程。

  • access_log 是 Nginx 原生內置的方式,這種方式不適用我們的場景,因為我們有 TB 級別的日誌量,並且是混合部署,所以磁盤是爭用的狀態;最核心的一點是 Read 和 Write 是阻塞的操作系統調用,這會讓它的性能急劇下降;
  • 張德江寫的 doujiang24/lua-resty-kafka 是另一個方式,這個其實我們並沒有做調研,因為 kafka 的協議和特性太多太繁雜,這個方案當前特性和後續發展可能沒法滿足我們的需求。
  • 我們最後選定了 cloudflare/lua-resty-looger-socket 的庫,可以遠程非阻塞地落日誌。

rsyslog

程序員怎麼能不知道OpenResty?——搭建高性能Web應用的利器

落日誌的工具我們選擇 rsyslog,實際上並沒有做太多的技術選型,直接在原來的技術選型上做了定製和優化:

  • 單條日誌最大長度是 8k,超長的單條日誌它會截斷,這是 rsyslog 裡面的規範,但是在它裡面也可以做定製;
  • rsyslog 內部通過 Queue 傳遞消息,內部 Queue 用得非常重。我們選取了 Disk-Assisted Memory,它能在 Memory 爆掉時落地到磁盤做降級,保證它的可靠程度;
  • Parser 沒有用新版本的 RFC,而是選用老版本的 RFC3164,是很簡單的一個協議,如上圖, local msg="<142>"..timestamp..""..topic..":"..msg 就是 RFC3164 的規範日誌;
  • 我們有一條日誌落多條的需求,日誌和日誌之間要做切割,這裡其實開啟了 SupportOctetCountedFraming 的參數做切割,實際上就是在當前的 message 的頭上再加上 message 的長度,通過一個基礎的 RFC,加上額外的功能特性完成整個日誌的落地;
  • 用 imptcp 去建立 unix socket 的文件句柄,沒有用 TCP、UDP,因為 unix socket 的損耗會更低。值得注意的是,cloudflare/lua-resty-looger-socket 的庫不支持 dgram unix socket,目前 cloudflare 已經停止維護這個庫了;
  • 輸出用了 omkafka 的 output module,開啟了兩個特殊的操作 :開啟 dynaTopic 可以定製錄入到制定 topic;開啟 snappy 的壓縮可以減輕傳輸流量;
  • 額外開啟兩個 module:一個是 omfile,它接到 imptcp 傳過來請求,在推 kafka 的同時落地到本地,這麼做的原因是之前下游推送程序宕機導致 kafka 裡的消息沒有及時被拿走而造成數據的丟失,因此需要做重要數據的備份,再加上磁盤的依賴;另一個是 impstats,它是做監控,輸出 Queue 容量、當前的消費狀況等一系列數據。

部署方案

前面提到我們是混合部署的狀況,線上會有準入的標準:tar 包+run.sh。我們預先編譯好 OpenResty,把所有依賴靜態地編譯進去,在發佈時把 OpenResty 的二進制文件拉到本地和 Lua 腳本混在一起,rsync 到固定的位置上再跑起來,使用 supervisord 或者 systemctl 做線上的daemon管理。

測試環境是自我維護的,這個項目可以分成兩塊,一塊是 OpenResty,另一塊是 rsyslog。可能你會把 OpenResty 的所有代碼落到代碼倉庫,而忽略了實際上 rsyslog 的配置也是相當重要,所以其實應該把所有的項目相關的東西都落到代碼倉庫裡。我們通過 Ansible 去做剩下的所有事情來管理測試環境。

性能數據

最終的性能數據如下圖:

程序員怎麼能不知道OpenResty?——搭建高性能Web應用的利器

2018 年初我做了統計,QPS 峰值大概是 26000QPS,單機壓測的峰值在 30000QPS,所以其實一臺 OpenResty 的機器可以抗住整個埋點的流量。日誌傳輸壓縮之後大概有 30M,每天的日誌落起來大概在 10 億條左右。為了保證服務的可靠性,當時線上的服務器是三臺 EC2 C3.2xlarg。

總結

  • 總的來說,架構是第一位的,我們使用 OpenResty+rsyslog+Kafka 三款久經考驗的組件構建出一個高性能的服務;
  • 要保持邊界,守住底線,比如不落磁盤,就要想盡一切辦法不落磁盤,保證有極致的性能;
  • 有時候你看起來很高性能,但是代碼寫起來還是會有很多坑,你可以通過火焰圖等很多方式來避免。重要的是,你需要有意識地去把握性能的問題,從一點一滴的小事情做起。比如 NYI 的 not yet implement 方法要避免去使用;比如 table.new/table clear一次性預分配已知大小 table array 的操作,都要避免重複的、多次的動態擴充;ngx.now 的實現性能非常高,它是有緩存的,基本滿足對時間精度的要求;uuid 的生成,從 libuuid 轉換成一次性生成 16 字節數據;通過 shared dict 之類的減少 CPU 的操作等等,最後我們可以構建出一個高性能的 Web 服務。

分享自:https://segmentfault.com/a/1190000020728562


分享到:


相關文章: