速賣通(aliexpress)活動頁麵優化第二輪,qps大幅提高
回顧
話說在以前文章中,我小小的吹了一個牛,要讓qps飛起來,但是在第一次的優化中,我們也隻是讓qps從170左右上升到了500左右,但是這並沒有讓qps並沒有“飛”起來。
而且我發現在我們的場景下gzip,已經成為勢要解決的最大問題,於是我花了一段時間,對我們活動頁麵的場景做了一係列降低壓縮級別的測試測試,測試結果如下:
通過一係列的計算,最終發現壓縮級別降低帶來的機器成本的下降,和帶寬帶來的提升帶來的成本上升相互抵消了。
把gzip去掉,或者降低壓縮級別會提高我們的帶寬消耗,經過詳細的壓測(對不同大小的頁麵,通過不斷的調整壓縮級別)和計算,降低壓縮級別帶來的qps的提高所節約的機器費用和帶寬提升所帶來的費用提升幾乎相互抵消(機器按照3年折舊來算),所以降低壓縮級別看上去很美,但是卻沒法操作。由於某些數字比較敏感,所以這裏就不給出詳細的計算公式了。
同時由於降低壓縮級別,導致壓縮之後的頁麵大出了幾KB,極有可能導致在網絡通信時導致RT中增加新的RTT出來,也許這在國內的網絡環境中影響不是特別大,但是對於世界範圍內的網絡環境,一個RTT有可能到達數百毫秒,對RT還是有一定的影響的,這一點,後麵還會闡述。
節約的機器的錢和帶寬增加帶來的成本投入是差不多的。而且降低壓縮級別導致的RT上升也是對國際友人的用戶體驗是不友好的,尤其是目前中俄局勢的大環境下,我們更要考慮俄羅斯妹子的體驗,所以降低壓縮級別在我們的場景裏也是不可靠的。
思路和問題
如果說壓縮是不可避免的,那麼我們怎麼做這個優化呢,我們能否將壓縮提前做掉呢,如果我提前壓縮好,放在內存中,用戶請求過的時候直接返回內存中的數據,豈不妙哉?直接把CPU密集型應用改造成了IO密集型應用,這個應該很有意思。
看起來很美的一個方案哦,但是對我這種php都沒有入門的同學來說,我還是得好好研究一番,現在,於是我用大腳拇指想了一下,這一想就想出了好幾個問題。
- php是進程模型,壓縮過的數據我應該放哪裏
- 如果是預先壓縮,打點怎麼搞
- 沒寫過php代碼,這段代碼應該怎麼寫
- 我們得告訴nginx無需再壓縮了
這些問題不解決,我們就無法繼續前進,那麼預先gzip這個方案看上去就無法在activity上實時了,顯然這也不是金牛座的風格,金牛座是一個省錢的星座是吧,所以節約機器是金牛座的一個天性。
問題和解決方案
在上一章中,我列舉了一些問題,這些問題應該如何解決呢,下麵我們來一個個分析一下這些問題
壓縮後數據放哪裏
-
放進程中?
由於php是進程模型,為了提高並發處理能力,我們都會開很多條進程,如果我們有一千個頁麵,每個頁麵消耗20KB的內存,然後我們開了40條進程,那麼總內存消耗數變成了20KB * 1000 * 40 = 800MB內存。每個進程都存儲了相同的頁麵數據。如果在java裏,我們隻需要20MB的消耗。所以一個20MB空間的需求活生生的弄成了800MB,這是廣大處女座程序員同誌們所不能接受的。 -
放tair中?
沒辦法,那怎麼解決這個問題呢,有人說了,我們放tair裏,耶,確實可以哦,看上去很美哦,但是使用tair來存儲也有幾個問題,我們來看看有哪些問題,這些問題都是已經預見過的:- tair網卡流量有可能跑滿,819大促某個應用即使隻放了2KB的數據,但是由於qps高,也將tair網卡流量跑滿了,不得不先擴容,因為tair的server一般會比web server少很多,而且基本都是公用的,跑滿還影響其他服務
- 每個請求20KB的數據,都要從tair返回,影響了RT
- 沒法遷移到海外CDN home集群,我們總不可能在CDN home集群裏部署tair集群吧,我靠,這個PE會跟我們著急的
所以放到tair中這事我是不敢指望了,比如說帶寬跑滿這事,不是說一定會發生,但是指不定哪天給你發生一下,君子不處危地,所以咱們還是別這樣玩
更何況後麵還要上海外CDN home集群呢,是吧。 -
放磁盤中?
那好像我們選擇的餘地也不是很多啊,想來想去,還有一個地方可以放,就是本機內存啊,或者本機磁盤啊,但是本機磁盤這事我不確定,後麵CDN home集群上是不是SSD,我們現有的activity是不是SSD,這些都是放磁盤的一些限製。 或者用內存做磁盤鏡像出來(tmpfs之流),這樣限製更少,但是帶來一個問題,就是容量控製和運維的難度增加了,我們的方案要優先節約各方的工作量,尤其是工程師和PE的工作量。所以放磁盤在我們的場景裏不是最優方案。 -
放共享內存中?
而放在共享內存中就簡單的多,但是我們需要一個工具,這個工具能夠讓php把數據放到係統的共享內存中。最好是現在的activity集群就支持,最好是現有的CDN home集群就支持,有這樣的東西嗎??????有的,就是APC,上文提過的APC ,上文中,我們講到APC可以將php代碼的字節碼緩存起來,但是我們沒有講的是APC其實還有一個功能,叫做user cache。何為user cache,既可以把用戶數據存儲在這裏的一種cache。而不隻是php的字節碼數據。
APC的user cache
其實在activity的第一輪優化中,我已經使用了user cache,我是怎麼使用的呢?這個我在上一篇文章中講過我走過的彎路(基於APC的片段緩存),當時直接使用APC的user cache來緩存 html片段,結果發現gzip上來之後,這個優化起到的作用很小,從另外一個側麵證明了在activity的場景下,gzip是影響qps的罪魁禍首。不過大家不要隨便亂套哈,我說是activity這個場景,其他的具體場景要具體分析的哦(如何分析?要看你的CPU TIME是用在哪裏了)。後來又把片段緩存去掉了,因為在gzip麵前,這個片段緩存起到的作用簡直太小兒科了(請看上文的測試數據)。
雖然走了這個“彎”路,但是讓我對APC的user cache和php代碼卻有了一定的了解,後來我實現了php中的pre-gzip也是根據之前我寫的apc片段緩存這段代碼改造的(而片段緩存改造自網上一個代碼片段,把片段保存在磁盤的PHP代碼片段)。所以在第一輪優化中的這個“彎”路其實也為第二輪優化做了準備,環環相扣啊,這個世界沒有無緣無故的愛呢。
不過使用user cache之前,我們還有些知識需要儲備一下,尤其是緩存的清空策略,我們常見的緩存清空策略有LFU,LRU,FIFO等,最最常見還是LRU,比如說memcached中使用就是LRU的清空策略,6年前我踩過一個“坑”。那麼APC中的緩存是使用什麼樣的清空策略呢,如果我們的數據量過大,那麼會不會導致服務發生問題呢?比如說,頁麵較多,導致剛剛放到緩存中的頁麵就被LRU掉了,那麼緩存基本失效。所以我得研究一下先。
- 文檔研究 首先研究的是APC的文檔,在連接中,有幾個參數是跟user cache相關的:
apc.shm_segments
編譯器緩存要分配的共享內存塊的數目。如果 APC 用光了共享內存但是已經將 apc.shm_size 設為了係統所能允許的最大值,可以嚐試增大此值。apc.shm_size
以 MB 為單位的每個共享內存塊的大小。默認時,有些係統(包括大多數 BSD 變種)的共享內存塊大小非常低。apc.user_ttl
緩存條目在緩衝區中允許逗留的秒數。0 表示永不超時。建議值為7200~86400 設為 0 意味著緩衝區有可能被舊的緩存條目填滿,從而導致無法緩存新條目。隻是針對每個用戶而言,建議值為7200~86400。 設為 0 意味著緩衝區有可能被舊的緩存條目填滿,從而導致無法緩存新條目。 如果大於0,APC將嚐試刪除過期條目。apc.gc_ttl
緩存條目在垃圾回收表中能夠存在的秒數。此值提供了一個安全措施,即在服務器進程在執行緩存的源文件時,如果該文件被修改則舊版本將不會被回收,直到達到此 TTL 為止。設為零將禁用此特性。
看上去也沒啥,完全沒有提到LRU相關的問題,而且user_ttl和gc_ttl我也沒有完全搞明白是怎麼回事,有點模煳。那我就不得不去看看代碼了了,這個問題還是搞清楚點比較好。
-
APC代碼研究
於是我找到APC的源代碼,值得注意的是,由於我們線上使用的是APC3.0.9,所以我看的是3.0.9的源代碼
代碼裏,我簡單的寫了一些中文注釋(英文注釋是代碼自帶)- APC中user cache的存儲結構
這個結構是一個典型的散列鏈表,和hashmap的實現是類似的道理,但是沒有hashmap這麼精致,我們來看一段代碼:
apc_cache_entry_t* apc_cache_user_find(apc_cache_t* cache, char *strkey, int keylen, time_t t) { slot_t** slot; LOCK(cache); /* cache裏有一個slot的數組,叫做slots,然後,然後取膜之後找到對應的slot */ slot = &cache->slots[string_nhash_8(strkey, keylen) % cache->num_slots]; /* 找到slot之後,拿到一個鏈表,開始遍曆這個鏈表,這個結構和HashMap是一樣的,但是取膜的問題上,HashMap有更巧妙的算法 */ while (*slot) { if (!memcmp((*slot)->key.data.user.identifier, strkey, keylen)) { /* Check to make sure this entry isn't expired by a hard TTL */ if((*slot)->value->data.user.ttl && ((*slot)->creation_time + (*slot)->value->data.user.ttl) < t) { remove_slot(cache, slot); break; } /* Otherwise we are fine, increase counters and return the cache entry */ (*slot)->num_hits++; (*slot)->value->ref_count++; (*slot)->access_time = t; /* 這種代碼看起來是不是很熟悉的咧 */ cache->header->num_hits++; UNLOCK(cache); return (*slot)->value; } slot = &(*slot)->next; } cache->header->num_misses++; UNLOCK(cache); return NULL; }
從上麵一段代碼中,我們基本得知了APC中user cache的結構,那麼下麵我們來看看APC如何插入新值的。
- APC中user cache的insert
- APC中user cache的存儲結構
int apc_cache_user_insert(apc_cache_t* cache, apc_cache_key_t key, apc_cache_entry_t* value, time_t t TSRMLS_DC)
{
slot_t** slot;
size_t* mem_size_ptr = NULL;
if (!value) {
return 0;
}
LOCK(cache);
process_pending_removals(cache);
slot = &cache->slots[string_nhash_8(key.data.user.identifier, key.data.user.identifier_len) % cache->num_slots];
if (APCG(mem_size_ptr) != NULL) {
mem_size_ptr = APCG(mem_size_ptr);
APCG(mem_size_ptr) = NULL;
}
while (*slot) {
if (!memcmp((*slot)->key.data.user.identifier, key.data.user.identifier, key.data.user.identifier_len)) {
/* If a slot with the same identifier already exists, remove it */
remove_slot(cache, slot);
break;
} else
/*
* This is a bit nasty. The idea here is to do runtime cleanup of the linked list of
* slot entries so we don't always have to skip past a bunch of stale entries. We check
* for staleness here and get rid of them by first checking to see if the cache has a global
* access ttl on it and removing entries that haven't been accessed for ttl seconds and secondly
* we see if the entry has a hard ttl on it and remove it if it has been around longer than its ttl
*/
if((cache->ttl && (*slot)->access_time < (t - cache->ttl)) ||
((*slot)->value->data.user.ttl && ((*slot)->creation_time + (*slot)->value->data.user.ttl) < t)) {
remove_slot(cache, slot);
continue;
}
slot = &(*slot)->next;
}
if (mem_size_ptr != NULL) {
APCG(mem_size_ptr) = mem_size_ptr;
}
/* 如果不能創建slot,那麼則返回0 */
if ((*slot = make_slot(key, value, *slot, t)) == NULL) {
UNLOCK(cache);
return 0;
}
if (APCG(mem_size_ptr) != NULL) {
value->mem_size = *APCG(mem_size_ptr);
}
UNLOCK(cache);
return 1;
}
代碼中寫道:如果不能創建slot,那麼就返回一個0告知用戶這次緩存沒有成功。同時在上麵代碼的第二段注釋中,我們可以看到,用戶在insert的時候,需要遍曆slot的鏈表,根據cache的ttl和cache裏的這個slot鏈表中所有元素的ttl找出可以被回收的空間。
而且這樣的操作,在find方法中也存在,所以我們可以看做APC在執行find和insert操作時,會在對應的slot鏈表上根據TTL來做緩存的清除動作。這是user_ttl所起的作用了。
當然APC在刪除slot鏈表時還有一些邏輯,根據源代碼中remove_slot方法所示,在remove時,如果ref_count小於等於0,那麼直接釋放這個slot,如果ref_count大於0,但是ttl相關的時間條件是滿足了,那麼就會將這個slot放到一個deleted_list中,供APC中的gc來回收這個slot對象。這就是gc_ttl這個參數的作用:控製slot在deleted_list中存活的時間。
-
APC中user cache的調研總結
- APC的緩存清空是跟TTL相關的,而不是LRU,所以先進緩存的,即使沒有人使用,不到時間不會被清除,這會導致先進緩存的數據在緩存過期之前一直在緩存中,**所以user_ttl時間不要設置為0,且gc_ttl也要大於0,這樣長時間不被訪問的頁麵會被請出緩存**,這樣其實也是不錯的選擇。我之所以要把APC代碼拿下來,看看它的緩存清空策略,其中一個非常重要的原因是我怕它是LRU,如果是LRU的清空策略,那麼我們就必須更加小心,因為共享內存不多的情況下,且訪問比較平均的情況下,有發生LRU命中率低的可能性。因為剛剛放進去的頁麵,有可能因為LRU被清掉。雖然是極端情況(我也告訴自己,不要想太多)但是819大促告訴我們,凡是有可能發生的,哪一天它就會冷不防的冒出來,不得不小心一點。
- 如果內存不足,APC會返回失敗告知php進程。也就是最差情況下,在共享內存不夠的情況下,我們的頁麵就得不到緩存,那麼就需要每次都做gzip,這個最差的結果和我們目前的情況是一樣的,也就是說最差也不過就是回到現狀,隻不過打點的工作需要PHP代碼實現了,這個還是可以接受的。所以我決定用APC來存儲壓縮後的html頁麵。
重複壓縮
如何避免重複壓縮
由於返回的html已經被php壓縮過了,那麼nginx或者apache再壓縮一遍其實是浪費了,而且不光是浪費,在firefox下,重複壓縮的數據還不能正常展示。我們不能簡單粗暴的關閉掉nginx壓縮,因為不使用這套方案的php頁麵或者nginx後麵的其他進程,比如nodejs之類,還是需要用到nginx的壓縮的,最好的方案就是我們在返回頭有一個標示,有了這個標示之後,nginx就不再壓縮返回數據,而且還不影響瀏覽器顯示。
-
gzip_types
我可以在gzip_types上做點手腳嗎,可以是可以,比如說隻要返回content-type=text/plain,那就不執行壓縮。普通的php頁麵沒有使用指定content-type,會默認使用text/html,而這個是默認會壓縮的,這也不失為一種方案。 -
gzip_min_length
比如說壓縮過的頁麵,都是小於50KB的,那麼我們可以設置大於50KB才壓縮,小於50KB不壓縮
打點
-
AE打點現狀
由於我們打點是依賴nginx,而現狀通過nginx時,數據已經被gzip過了,所以nginx不會再做ungzip,打點再gzip這種事情,那麼打點這個事情就需要交給程序來做了,這話說起來很輕巧(老大要求我們舉重若輕),但是實際上,這裏是最麻煩的地方,要找到解決方案,不得不先把情況了解清楚 -
預壓縮的打點方案一,cookie傳遞time
-
預壓縮的打點方案二,分段壓縮
說起分段壓縮,這裏還有個小故事,之前在網上看過一篇文章,是講分段壓縮的問題的,文章的結論是瀏覽器不支持分段壓縮,所以我的腦海裏一直有這個印象,後來在CDN群裏和同事聊天,同事建議我去看看varnish的ESI實現,同時給了我3個資料和提示,正是這個機緣巧合下,我才能找到替換用cookie傳time的打點方案,在這裏非常感謝同事
使用cookie傳遞打點需要的time屬性是滿足現狀的,但是後麵如果打點遷移到alilog(據說屬性很多,不隻是time一個動態的值),那麼需要存放在cookie裏的值會很多了,維護管理將會不太方便,所以使用分段壓縮是一個比較好的選擇。
也許你會想,那簡單啊,我直接把打點前的html壓縮成一個gzip流,然後打點數據再壓縮一下,最後再壓縮一下打點數據之後的html,這樣就可以壓縮成3個完整的gzip文件,返回給瀏覽器,這樣做是最簡單,最省力,最省事的。沒錯,我開始也是這麼想的,但是在不斷的查資料的過程中,發現HTTP規範中,明確指出,返回給瀏覽器的應該是整段的gzip文件
在我後續的文章也會說明,即使使用varnish中的ESI實現,對於PHP來說是行不通的,除非自己寫壓縮擴展。
所以這個優化,我們在PHP上隻能使用cookie傳遞time值的方案。
這段代碼應該怎麼實現呢
-
流程圖
要寫代碼,先定流程,所謂謀定而後動,所以我就整了一張流程圖:
實際上apc3.0.9有一個stats配置, 改變這個指令值要非常小心。 默認值 On 表示APC在每次請求腳本時都檢查腳本是否被更新, 如果被更新則自動重新編譯和緩存編譯後的內容。但這樣做對性能有不利影響。 如果設為 Off 則表示不進行檢查,從而使性能得到大幅提高。 但是為了使更新的內容生效,你必須重啟Web服務器(譯者注:如果采用cgi/fcgi類似的,需重啟cgi/fcgi進程)。 生產服務器上腳本文件很少更改, 可以通過禁用本選項獲得顯著的性能提升。**不過一般情況下,檢查文件是否最新並不是性能瓶頸所在,gzip才是。所以建議大家stats=on,這樣php文件更新時可以立馬自動重新編譯,並緩存編譯之後的內容。這一點非常重要。**那麼,如果頁麵改變了,但是緩存中的數據沒有改變,應該如何解決這個問題呢。直接修改緩存的key取值即可。這樣能保證最新的數據會生效。
如果不想修改緩存的key呢,那就需要我們將緩存時間設置的短一點了,比如說5分鍾,這樣5分鍾之後緩存中的數據失效,這樣5分鍾之後,新的PHP文件就生效了。為了性能,這點付出也是需要的。
-
代碼實現
<?php
ob_start();//緩衝區開始
//定義一個開始緩存的標示,這個函數將在後麵的代碼中被調用到
function cache_start_apc() {
//如果客戶端接受gzip數據
if (canBeGzip()) {
//根據URL生成一個user cache的key,並從apc user cache獲取對應的值
$key = sha1($_SERVER['REQUEST_URI']);
$content = apc_fetch($key);
// $content = none;
//如果緩存中該key對應的value不為空,那麼直接返回緩存中的數據,否則,退出函數,開始渲染頁麵
if (!empty($content)) {
header('Content-Encoding: gzip');
header("Vary: Accept-Encoding");
//清除緩衝區的任何內容
ob_clean();
//輸出數據到緩衝區,並flush之
echo $content;
ob_end_flush();
exit;
}
}
//如果客戶端不接受gzip數據,那麼直接往下執行,渲染頁麵
}
//如果瀏覽器接收壓縮數據,那麼使用zlib的庫進行壓縮,壓縮級別為6
function ob_beacon_and_gzip($content) {
//TODO 先打點,再壓縮,打點模板需要改一下,把time改成變量
return gzencode($content, 6);
}
function cache_end_apc() {
//客戶端接收壓縮數據的話,返回壓縮數據,不接受壓縮數據就無需壓縮並緩存了
if(canBeGzip()) {
//從緩衝區中拿到渲染好的html,並進行壓縮
$content = ob_beacon_and_gzip(ob_get_contents());
//壓縮完之後放到user cache,就算放失敗了,內存不夠了,也沒有關係,直接返回壓縮後的數據
$key = sha1($_SERVER['REQUEST_URI']);
apc_add($key, $content);
//清空緩衝區
ob_clean();
//設置壓縮頭
header('Content-Encoding: gzip');
header("Vary: Accept-Encoding");
//壓縮內容輸出
echo $content;
//flush緩衝區
ob_end_flush();
} else {
//如果客戶端不接受壓縮數據,則把緩衝區中的原始html直接返回
ob_end_flush();
}
}
function canBeGzip() {
return !headers_sent()&&extension_loaded("zlib")
&&strstr($_SERVER["HTTP_ACCEPT_ENCODING"],"gzip");
}
?>
-
一定要5分鍾之後生效嗎?
當然不一定,尤其現在的大促活動頁麵都是定製的情況下,如果有緊急發布,我們隻需要將修改時將緩存的key改一下即可,比如說原來APC user cache中存儲的gzip對應的key是123,那麼緊急發布時,新的頁麵的key是456即可,原來的壓縮數據在5分鍾之後會被放入GC隊列,然後等待被GC回收。 -
你很擔心php的壓縮效率是不是?
不用擔心,php的壓縮和nginx的壓縮是調用相同的庫,都是使用zlib庫,而且如果我們將也沒全部緩存的話,同一個頁麵幾分鍾才需要做一次壓縮,所以PHP的執行效率在這個場景下是不用擔心的。
測試
我嚐試在不同的壓縮級別和不同的頁麵大小的情況下做測試,並觀察CPU和Load的情況,測試腳本如下:
ab -n 10000 -c 10 -H 'Accept-Encoding:gzip' https://localhost:8888/xxx.php
這些頁麵都是來自於325大促的真實頁麵。
- 壓縮級別=6
原始頁麵大小 | 壓縮後的大小 | 優化後QPS | RT |
---|---|---|---|
92KB | 17KB | 2024 | 4.9ms |
138KB | 8.7KB | 1859 | 3.3ms |
182KB | 11.4KB | 2083 | 4.8ms |
248KB | 32KB | 1977 | 5.0ms |
295KB | 34.4KB | 1722 | 5.8ms |
整個測試沒有經過網卡,而且是在一台5年前的mac book pro上測試:雙核8G,三星的SSD。
在把壓縮級別調高之後,295KB的頁麵壓到了33.9KB,和34.4KB沒有太大區別,所以對gzip-level=9沒有進行更加深入的測試。
根據之前在4核虛擬機的對比來看,我預估:同樣的程序如果放到4核虛擬機上,刨去網卡帶寬限製不計,qps上到3000以上是沒有壓力的,原因是第一輪優化中的頁麵,在生產機器上qps是500左右,在我機器上是200左右,第二輪優化中,我機器上同樣的頁麵達到了2000左右,所以有理由相信生產機器上會達到3000以上,由於現在資源緊缺,所以這個優化方案並不會在速賣通雙11大促上線
從以上測試結果來看,有幾個結論比較搶眼:
- QPS飆的很高啊,高出現有的10倍左右。沒有緩存壓縮數據時qps大多在100-200
- RT下降的很厲害,相對值很高,但是絕對值不高,50ms以內的下降
- 壓縮級別調高,
- 帶寬消耗降低,但是不是特別明顯
- 由於包數量變少,所以假設MTU=1500,MSS=1460, 而我們親愛的俄國妹子的window size=16328(我連英文的amazon時,window size是16328,所以拿這個值舉例,server端的初始化擁塞窗口是10),誒,好了,如果我們的頁麵gzip level=9,那麼壓完之後,數據量小於1460*10,那麼俄羅斯妹子會很爽,因為我們會一下子發1460*10/1460=10個包過去,理想情況下一個RTT內,妹子就拿到了商品數據。如果gzip level=6,那麼壓完之後,數據有可能大於14600,那麼我們就隻會先發10個包過去,理想情況下等10個包的ACK最大seq的那個包返回,再接著發剩餘的包。這個時候,就不是一個RTT的問題了。在國際網絡環境下,RTT=300ms也是有的。當然這些都是估算,針對我們的場景,具體能出現什麼樣的優化效果,也是需要長期的測試的。而且一旦海外CDN Home集群上了之後,RTT有可能10ms,那麼就提升壓縮級別的效果就不明顯了。
能否使用swift來緩存壓縮之後的數據
是否可以其實取決於二個條件,如果這二個條件有一個不滿足,那麼就無法用swift來緩存壓縮之後的數據:
- 第一,需要在CDN home集群上部署swift,現在是沒有的,不過部署起來也不是難事
- 第二,對於php代碼中出現根據user-agent等header屬性決定顯示什麼樣的html來說
這個需求直接用代碼來壓縮的方案來實現就很方便了,根據user-agent中的部分核心屬性(為啥是部分核心屬性,因為user-agent太多了,每個user-agent都作為一個Key話,同一個頁麵也會產生大量的副本,對我們的819需求來說隻是為了分辨出是mobile還是pc,所以隻要為數不多的幾種key而已,而且每種key對應的html也是不一樣的,不存在同一個頁麵有不同副本的問題),渲染出不同的html,然後壓縮並通過不同的key緩存在共享內存。就好像這個需求和方案是天生一對一樣,如果用swift來緩存不同user-agent的頁麵,同一個php頁麵,將會產生很多份緩存,這樣熱點不明顯了,命中率也會受到影響。因為在web cache中間件上是根據完整的user-agent來做key的一部分的,所以user-agent越多,那麼副本越多。
後來在CDN群裏也提到了這個事情,確實是vary:user-agent會產生大量的副本,雖然CDN的同事說的是會影響命中率,但是我覺得是影響熱點的集中度和命中率都有,對於有多層cache的緩存中間件來說,熱點集中與否直接影響頁麵在哪一層cache上,從而影響到頁麵的響應速度。
總結
在這個優化中,研究了APC相關的實現,整理了整個流程,並且用代碼實現之,唯一不完美的地方,是打點,目前隻能把time參數放置在cookie中。通過這樣的優化,請求過來的時候,也沒啥PHP代碼要執行了,那些TMS天窗啥的都不用執行,隻需要執行文件頭的幾段緩存相關和user-agent相關的邏輯,然後直接拿了共享內存中的壓縮包就返回了。
下麵對比一下優化前後的兩種方案,我一般都會使用表格法,所以下麵簡單列一下優化前後的對比:
各維度 | 優化前 | 優化後 |
---|---|---|
TPS | 100-200左右 | 2000以上,在自己的老掉牙的筆記本上 |
RT | 40ms | 10ms |
php及時生效 | 及時生效 | 5分鍾之後生效,如果再TMS中可以隨機生成緩存的key,那麼也可以做到及時生效,老的緩存數據讓其自動過期 |
代碼侵入 | 無代碼侵入 | 少量代碼侵入,把埋點代碼向應用遷移 |
額外內存消耗 | 無額外內存消耗 | 有額外內存消耗(壓縮後20KB的頁麵有1000個,需要20MB的共享內存) |
壓縮級別 | 不能改變壓縮級別 | 可以增加壓縮級別,降低帶寬消耗和RT,提高用戶體驗,但在上了海外CDN home集群之後,gzip level調整的必要性不高 |
如果你隻有20台機器,那麼優化後隻需要3-5台,我們可以不在乎,疊機器不是問題。如果你有100台機器,保守可以優化到30台以內,極端點,15台也不是沒有可能。這個時候,少量的改造,帶來的就是大量機器的成本的降低,投入不大,但是產出是很大的。
再總結
雖然文章寫的差不多了,但是我還要多囉嗦幾句,這裏預先gzip並壓縮隻是一個優化思路,實際上我想看到這篇文章的人在工作中不會涉及到php,那麼這篇文章對和PHP無關的你有什麼助益呢,我簡單列一下:
- 知道在一些場景下,gzip可能是消耗CPU的大戶,大家可以觀察一下自己的應用
- 預先把瀏覽器需要的數據壓縮之後放緩存會帶來qps的極大提高(多高?我這個場景是10倍,取決於gzip在整個cpu time中的比重,你的場景未必有這麼多,也有可能更多)
- 雖然我是預壓縮的html,但是不代表你不能壓縮ajax返回的json,對應返回的json數據超過100KB的。每次都壓縮一下這個json和把json壓縮完放在內存的效率我就不說了,you know what i say.
- 100kB壓完之後,隻有17KB左右,內存占用少
- 長時間內,隻壓縮一次,CPU占用很少,提高QPS
- 沒有打點問題,操作起來非常簡單
文章最後,表示一下感謝,在整個優化的過程中,得到太多同學的幫助,不一一列舉,謝謝大家。
最後更新:2017-04-01 13:44:32