2011年李彥宏在百度聯盟峰會上就提到過互聯網的讀圖時代已經到來1,圖片服務早已成為一個互聯網應用中占比很大的部分,對圖片的處理能力也相應地變成企業和開發者的一項基本技能。需要處理海量圖片的典型應用有:
1. 圖片類應用,如百度相冊。
2. 導購類應用,如Guang.com。
3. 電商類應用,如淘寶。
4. 雲存儲服務,如七牛雲存儲。
除此之外幾乎所有的網站都需要考慮自己圖片處理的解決方案,以免在流量變大之后顯得手足無措。
本文將從作者自己設計完成的圖片服務程序zimg的設計思路出發,探討高性能圖片服務器的特點、難點和應對辦法。
主要問題
要想處理好圖片,需要面對的三個主要問題是:大流量,高並發,海量存儲。下面將逐一進行討論。
大流量
除了那些擁有自己數據中心的大型企業,中小型企業都需要考慮到流量問題,因為流量就是成本,圖片相對於文本來說流量增加了一個數量級,省下的每一個字節都是白花花的銀子。我曾經在一篇博客2里看到,作者在業務邏輯中引入PHP的imagick模塊進行壓縮,短短幾行代碼就做到了每個月為公司節省2萬人民幣的效果,可見凡是涉及到圖片的互聯網應用,都應該統籌規划,降低流量節約開支。
高並發
高並發的問題在用戶量較低時幾乎不會出現,但是一旦用戶攀升,或者遇到熱點事件,比如淘寶的雙十一,或者網站被人上傳了一張爆炸性的新聞圖片,短時間內將會涌入大量的瀏覽請求,如果架構設計得不好,又沒有緊急應對方案,很可能導致大量的等待、更多的頁面刷新和更多請求的死循環。總的來說,就是要把圖片服務的性能做得足夠好。
海量存儲
在2012年的介紹Facebook圖片存儲的文章3里提到,當時Facebook用戶上傳圖片15億張,總容量超過了1.5PB,這樣的數量級是一般企業無法承受的。雖然我們很難做出一個可以跟Facebook比肩的應用,但是從架構設計的角度來說,良好的拓展方案還是要有的。我們需要提前設計出最合適的海量圖片數據存儲方案和操作方便的拓容方案,以應對將來不斷增長的業務需求。
以上三個問題,其實也是相互制約和鉗制的,比如要想降低流量,就需要大量的計算,導致請求處理時間延長,系統單位時間內的處理能力下降;再比如為了存儲更多的圖片,必然要在查找上消耗資源,同樣也會降低處理能力。所以,圖片服務雖然看起來業務簡單,實際做起來也不是一件小事。
設計方案
zimg是作者針對圖片處理服務器而設計開發的開源程序,它擁有很高的性能,也滿足了應用在圖片方面最基本的處理需求,下面將從架構設計、代碼邏輯和性能測試等方面進行介紹。
總體思路
想要在展現圖片這件事情上有最好的表現,首先需要從整體業務中將圖片服務部分分離出來。使用單獨的域名和建立獨立的圖片服務器有很多好處,比如:
1. CDN分流。如果你有注意的話,熱門網站的圖片地址都有特殊的域名,比如微博的是ww1.sinaimg.cn,人人的是fmn.xnpic.com等等,域名不同可以在CDN解析的層面就做到非常明顯的優化效果。
2. 瀏覽器並發連接數限制。一般來說,瀏覽器加載HTML資源時會建立很多的連接,並行地下載資源。不同的瀏覽器對同一主機的並發連接數限制是不同的,比如IE8是10個,Firefox是30個。如果把圖片服務器獨立出來,就不會占用掉對主站連接數的名額,一定程度上提升了網站的性能。
3. 瀏覽器緩存。現在的瀏覽器都具有緩存功能,但是由於cookie的存在,大部分瀏覽器不會緩存帶有cookie的請求,導致的結果是大量的圖片請求無法命中,只能重新下載。獨立域名的圖片服務器,可以很大程度上緩解此問題。
圖片服務器被獨立出來之后,會面臨兩個選擇,主流的方案是前端采用Nginx,中間是PHP或者自己開發的模塊,后端是物理存儲;比較特別一些的,比如Facebook,他們把圖片的請求處理和存儲合並成一體,叫做haystack,這樣做的好處是,haystack只會處理與圖片相關的請求,剝離了普通http服務器繁雜的功能,更加輕量高效,同時也使部署和運維難度降低。
zimg采用的是與Facebook相似的策略,將圖片處理的大權收歸自己所有,絕大部分事情都由自己處理,除非特別必要,最小程度地引入第三方模塊。
注:zimg的1.0版本,設計面向圖片量在TB級別的中小型服務,物理存儲暫時不支持分布式集群,分布式功能將在2.0版本中完成。
架構設計
為了極致的性能表現,zimg全部采用C語言開發,總體上分為三個層次,前端http處理層,中間圖片處理層和后端的存儲層。下圖為zimg架構設計圖:
http處理層引入基於libevent的libevhtp庫,libevhtp是一款專門處理基本http請求的庫,它太適合zimg的業務場景了,在性能和功能之間找到了很好的平衡點。圖片處理層采用imagemagick庫,imagemagick是現在公認功能最強,性能最好的圖片處理函數庫。存儲層采用memcached緩存加直接讀寫硬盤的方案,更加深入的優化將在后續進行,比如引入TFS4等。為了避免數據庫帶來的性能瓶頸,zimg不引入結構化數據庫,圖片的查找全部采用哈希來解決。
事實上圖片服務器的設計,是一個在I/O與CPU運算之間的博弈過程,最好的策略當然是繼續拆:CPU敏感的http和圖片處理層部署於運算能力更強的機器上,內存敏感的cache層部署於內存更大的機器上,I/O敏感的物理存儲層則放在配備SSD的機器上,但並不是所有人都能負擔得起這么奢侈的配置。zimg折中成本和業務需求,目前只需要部署在一台服務器上。由於不同服務器硬件不同,I/O和CPU運算速度差異很大,很難一棒子定死。zimg所選擇的思路是,盡量減少I/O,將壓力放在CPU上,事實證明這樣的思路基本沒錯,在硬盤性能很差的機器上效果更加明顯;即使以后SSD全面普及,CPU的運算能力也會相應提升,總體來說zimg的方案也不會太失衡。
代碼層面
雖然zimg在二進制實體上沒有分模塊,上面已經提到了原因,現階段面向中小型的服務,單機部署即可,但是代碼上是分離的,下面介紹主要部分的功能和實現,更詳細的內容可以從github上拉下來研究。熱烈歡迎大家fork和contribute。
main.c是程序的入口,主要功能是處理啟動參數,部分參數功能如下:
-p [port] 監聽端口號,默認4869
-t [thread_num] 線程數,默認4,請調整為具體服務器的CPU核心數
-k [max_keepalive_num] 最高保持連接數,默認1,不啟用長連接,0為啟用
-l 啟用log,會帶來很大的性能損失,自行斟酌是否開啟
-M [memcached_ip] 啟用緩存的連接IP
-m [memcached_port] 啟用緩存的連接端口
-b [backlog_num] 每個線程的最大連接數,默認1024,酌情設置
zhttpd.c是解析http請求的部分,分為GET和POST兩大部分,GET請求會根據請求的URL參數去尋找圖片並轉給圖片處理層處理,最后將結果返回給用戶;POST接收上傳請求然后將圖片存入計算好的路徑中。
為了實現zimg的總體設計願景,zhttpd承擔了很大部分的工作,也有一些關鍵點,下面撿重點的說一下:
在zimg中圖片的唯一Key值就是該圖片的MD5,這樣既可以隱藏路徑,又能減少前端(指zimg前面的部分,可能是你的應用服務器)和zimg本身的存儲壓力,是避免引入結構化存儲部分的關鍵,所以所有GET請求都是基於MD5拼接而成的。
大家設想一下,假如你的網站某個地方需要展示一張圖片,這個圖片原圖的大小是1000*1000,但是你想要展示的地方只有300*300,你會怎么做呢?一般還是依靠CSS來進行控制,但是這樣的話就會造成很多流量的浪費。為此,zimg提供了圖片裁剪功能,你所需要做的就是在圖片URL后面加上w=300&h=300(width和height)即可。
另一個情景是圖片灰白化,比如某天遇到重大自然災害,想要網站所有圖片變成灰白的,那么只需在圖片URL后面再加上g=1(gray)即可。
當然,依托於imagemagick所提供的完善的圖片處理函數,zimg將在后續版本中逐步增加功能,比如加水印等。
在圖片上傳部分,其實能玩的花樣很少,但是編寫代碼所消耗的時間最多。現在我們再假設一種情景,如果我們的圖片服務器前端采用Nginx,上傳功能用PHP實現,需要寫的代碼很少,但是性能如何呢,答案是很差。首先PHP接收到Nginx傳過來的請求后,會根據http協議(RFC1867)分離出其中的二進制文件,存儲在一個臨時目錄里,等我們在PHP代碼里使用$_FILES["upfile"][tmp_name]獲取到文件后計算MD5再存儲到指定目錄,在這個過程中有一次讀文件一次寫文件是多余的,其實最好的情況是我們拿到http請求中的二進制文件(最好在內存里),直接計算MD5然后存儲。
於是我去閱讀了PHP的源代碼,自己實現了POST文件的解析,讓http層直接和存儲層連在了一起,提高了上傳圖片的性能。關於RFC1867的內容和PHP是如何處理的,感興趣的讀者可以去搜索了解下,這里推薦@Laruence的文章《PHP文件上傳源碼分析(RFC1867) 》。
除了POST請求這個例子,zimg代碼中有多處都體現了這種“減少磁盤I/O,盡量在內存中讀寫”和“避免內存復制”的思想,一點點的積累,最終將會帶來優秀的表現。
zimg.c是調用imagemagick處理圖片的部分,這里先解釋一下在zimg中圖片存儲路徑的規划方案。
上文曾經提到,現階段zimg服務於存儲量在TB級別的單機圖片服務器,所以存儲路徑采用2級子目錄的方案。由於Linux同目錄下的子目錄數最好不要超過2000個,再加上MD5的值本身就是32位十六進制數,zimg就采取了一種非常取巧的方式:根據MD5的前六位進行哈希,1-3位轉換為十六進制數后除以4,范圍正好落在1024以內,以這個數作為第一級子目錄;4-6位同樣處理,作為第二級子目錄;二級子目錄下是以MD5命名的文件夾,每個MD5文件夾內存儲圖片的原圖和其他根據需要存儲的版本,假設一個圖片平均占用空間200KB,一台zimg服務器支持的總容量就可以計算出來了:
1024 * 1024 * 1024 * 200KB = 200TB
這樣的數量應該已經算很大了,在200TB的范圍內可以采用加硬盤的方式來拓容,當然如果有更大的需求,請期待zimg后續版本的分布式集群存儲支持。
除了路徑規划,zimg另一大功能就是壓縮圖片。從用戶角度來說,zimg返回來的圖片只要看起來跟原圖差不多就行了,如果確實需要原圖,也可以通過將所有參數置空的方式來獲得。基於這樣的條件,zimg.c對於所有轉換的圖片都進行了壓縮,壓縮之后肉眼幾乎無法分辨,但是體積將減少67.05%。具體的處理方式為:
圖片裁剪時使用LanczosFilter濾鏡;
以75%的壓縮率進行壓縮;
去除圖片的Exif信息;
轉換為JPEG格式。
經過這樣的處理之后可以很大程度的減少流量,實現設計目標。
zcache.c是引入memcached緩存的部分,引入緩存是很重要的,尤其是圖片量級上升之后。在zimg中緩存被作為一個很重要的功能,幾乎所有zimg.c中的查找部分都會先去檢查緩存是否存在。比如:
我想要a(代表某MD5)圖片裁剪為100*100之后再灰白化的版本,那么過程是先去找a&w=100&h=100&g=1的緩存是否存在,不存在的話去找這個文件是否存在(這個請求所對應的文件名為 a/100*100pg),還不存在就去找這個分辨率的彩色圖緩存是否存在,若依然不存在就去找彩色圖文件是否存在(對應的文件名為 a/100*100p),若還是沒有,那就去查詢原圖的緩,原圖緩存依然未命中的話,只能打開原圖文件了,然后開始裁剪,灰白化,然后返回給用戶並存入緩存中。
可以看出,上面過程中如果某個環節命中緩存,就會相應地減少I/O或圖片處理的運算次數。眾所周知內存和硬盤的讀寫速度差距是巨大的,那么這樣的設計對於熱點圖片抗壓將會十分重要。
除了上述核心代碼以外就是一些支持性的代碼了,比如log部分,md5計算部分,util部分等。
性能測試
為了橫向對比zimg的性能,我用PHP寫了一個功能一模一樣的后端,僅用時一下午,這充分證明了“PHP是世界上最好的語言”,也同時說明了用C語言來進行開發是多么的辛苦,不過,我喜歡性能測試結果出來之后的那份成就感,這樣的付出我覺得是值得的。
測試方案
采用Apache自帶的測試程序ab對指定請求進行測試,在特定並發數100的情況下進行10w個請求的測試,結果依據該並發下每秒處理請求數來定性,對比的方案是未啟用緩存的zimg,啟用緩存的zimg和Nginx+PHP,其中zimg端口為4868,Nginx端口為80。
測試命令分別為:
ab2 -c 100 -n 100000 http://127.0.0.1:4869/5f189d8ec57f5a5a0d3dcba47fa797e2
ab2 -c 100 -n 100000 http://127.0.0.1:80/zimg.php?md5=5f189d8ec57f5a5a0d3dcba47fa797e2
ab2 -c 100 -n 100000 http://127.0.0.1:4869/5f189d8ec57f5a5a0d3dcba47fa797e2?w=100&h=100&g=1
ab2 -c 100 -n 100000 http://127.0.0.1:80/zimg.php?md5=5f189d8ec57f5a5a0d3dcba47fa797e2&w=100&h=100&g=1
注:以下測試數據單位皆為rps(request per second)。
測試環境
操作系統:openSUSE 12.3
CPU:Intel Xeon E3-1230 V2
內存:8GB DDR3 1333MHz
硬盤:西部數據 1TB 7200轉
軟件版本
zimg:1.0.0
Nginx:1.2.9
PHP:5.3.17
測試結果
測試項目 | zimg | zimg+memcached | Nginx+PHP |
---|---|---|---|
靜態圖片 | 2857.80 | 4995.95 | 426.56 |
動態裁剪圖片 | 2799.34 | 4658.35 | 58.61 |
總的來說測試結果符合預期,純C寫成並且專門為圖片而做了大量優化的zimg表現遠遠優於采用PHP的方案,性能有6-79倍的提升。
高壓測試
在測試過程中由於php-fpm的性能瓶頸,導致並發壓力根本壓不上去,為了充分展現zimg面對超高並發的抗壓能力,我又做了另一項對比測試,即單純的echo測試。測試方法是在逐漸升高的並發壓力下完成20w個echo請求,記錄每種並發壓力下的處理能力。硬件環境不變,這次所要對比的是業界以性能著稱的Nginx,Nginx和zimg都是接收echo請求后返回簡單的“It works!”頁面,不做任何復雜的業務。
測試命令分別為:
ab2 -c 5000 -n 200000 http://127.0.0.1:4869/
ab2 -c 5000 -n 200000 http://127.0.0.1:80/
測試結果如下:
Concurrency | zimg | Nginx |
---|---|---|
100 | 32765.35 | 33412.12 |
300 | 32991.86 | 32063.05 |
500 | 31364.29 | 30599.07 |
1000 | 28936.67 | 28163.63 |
2000 | 27939.02 | 25124.51 |
3000 | 28168.56 | 22053.22 |
4000 | 28463.45 | 21464.88 |
5000 | 27947.37 | 13536.93 |
6000 | 27533.83 | 14430.21 |
7000 | 27502.03 | 14623.62 |
8000 | 26505.07 | 13389.28 |
9000 | 27124.89 | 13650.01 |
10000 | 27446.23 | 10901.13 |
11000 | 26335.22 | 10585.73 |
12000 | 27068.68 | 10461.54 |
13000 | 26798.55 | 8530.11 |
14000 | 26741.93 | 7628.09 |
15000 | 26556.54 | 9832.16 |
16000 | 26815.70 | 8018.44 |
17000 | 27811.33 | 7951.21 |
18000 | 25722.97 | 6246.00 |
19000 | 26730.02 | 8134.93 |
20000 | 27678.67 | 6106.95 |
這是一份有趣的數據,其實測試過程中,Nginx在並發1000開始已經出現了部分失敗,在並發9000以后就無法完成20w個請求,通過不斷降低請求數才勉強完成了測試。而強大的zimg毫無壓力地完成了20000並發以內的所有測試,沒有一個失敗返回。為了直觀地顯示測試結果請參考下圖:
由於去掉了不需要的復雜功能,zimg在http處理層面要遠比Nginx輕量,同時測試數據也說明了它的高並發抗壓能力。能有這樣的成績則完全要歸功於libevhtp項目,它比libevent自帶的http庫要優秀得多。在我設計zimg的早期版本時,選用了libevent自帶的evhttp庫,然后采用線程池的方式來實現多線程處理,結果發現在高壓力之下問題頻出,最后無奈放棄。該版本封存在github上的zimg_workqueue分支中,也算是一個紀念吧。