從今年一月份開始,團隊陸續完成了郵件服務的架構升級。新平臺上線運行的過程當中也發生了一系列的性能問題,即便不少看起來微不足道的點也會讓整個系統運行得不是那麼平穩,今天就將這段時間的問題以及解決方案統一整理下,但願能起到拋磚的做用,讓讀者在遇到相似問題的時候能多一個解決方案。sql
新平臺上線後初版架構以下:數據庫
這版架構上線後,咱們遇到的第一個問題:數據庫讀寫壓力過大後影響總體服務穩定。緩存
表現爲:安全
一、數據庫主庫壓力高,同時伴有大量的讀,寫操做。性能優化
二、遠程服務接口性能不穩定,業務繁忙時數據庫的插入操做延遲升高,接口響應變慢,接口監控頻繁報警,影響業務方。服務器
通過分析後,咱們作了以下優化:多線程
一、數據庫作讀寫分離,將Checker的掃表操做放到從庫上去(主從庫中的同步延遲不影響咱們發送,此次掃描不到的下次掃表到便可,由於每條郵件任務上有版本號控制,因此也不擔憂會掃描到「舊記錄」的問題)。架構
二、將Push到Redis的操做變成批量+異步的方式,減小接口同步執行邏輯中的操做,主庫只作最簡單的單條數據的Insert和Update,提升數據庫的吞吐量,儘可能避免由於大量的讀庫請求引發數據庫的性能波動。併發
這麼作還有一個緣由是通過測試,對於Redis的lpush命令來講每次Push1K大小的元素和每次Push20K的元素耗時沒有明顯增長。異步
所以,咱們使用了EventDrieven模型將Push操做改爲了定時+批量+異步的方式往Redis Push郵件任務,這版優化上線後數據庫主庫CPU利用率基本在5%如下。
總結:此次優化的經驗能夠總結爲:用異步縮短住業務流程 +用批量提升執行效率+數據庫讀寫分離分散讀寫壓力。
優化後的架構圖:
優化上線後,咱們又遇到了第二個問題:JVM假死。
表現爲:
一、單位時間內JVM Full GC次數明顯升高,GC後內存居高不下,每次GC能回收的內存很是有限。
二、接口性能降低,處理延遲升高到幾十秒。
三、應用基本不處理業務。
四、JVM進程還在,能響應jmap,jstack等命令。
五、jstack命令看到絕大多數線程處於block狀態。
堆信息大體以下(注意紅色標註的點):
如上兩圖,能夠看到RecommendGoodsService 類佔用了60%以上的內存空間,持有了34W個 「郵件任務對象」,很是可疑。
分析後發現製造平臺在生成「郵件任務對象」後使用了異步隊列的方式處理對象中的推薦商品業務,由於某個低級的BUG致使處理隊列的線程數只有5個,遠低於預期數量, 所以隊列長度劇增致使的堆內存不夠用,觸發JVM的頻繁GC,致使整個JVM大量時間停留在」stop the world 」 狀態,JVM響應變得很是慢,最終表現爲JVM假死,接口處理延遲劇增。
總結:
一、咱們要儘可能讓代碼對GC友好,絕大部分時候讓GC線程「短,平,快」的運行並減小Full GC的觸發機率。
二、咱們線上的容器都是多實例部署的,部署前一般也會考慮吞吐量問題,因此JVM直接掛掉一兩臺並不可怕,對於業務的影響也有限,但JVM的假死則是很是影響系統穩定性的,與其奈活,不如快死!
相信不少團隊在使用線程池異步處理的時候都是使用的無界隊列存放Runnable任務的,此時必定要很是當心,無界意味着一旦生產線程快於消費線程,隊列將快速變長,這會帶來兩個很是很差的問題:
一、從線程池到無界隊列到無界隊列中的元素全是強引用,GC沒法釋放。
二、隊列中的元素由於等不到消費線程處理,會在Young GC幾回後被移到年老代,年老代的回收則是靠Full GC才能回收,回收成本很是高。
通過一段時間的運行,咱們將JVM內存從2G調到了3G,因而咱們又遇到了第三個問題:內存變大的煩惱。
JVM內存調大後,咱們的JVM的GC次數減小了很是多,運行一段時間後加上了不少新功能,爲了提升處理效率和減小業務之間的耦合,咱們作了不少異步化的處理。更多的異步化意味着更多的線程和隊列,如上述經驗,不少元素被移到了年老代去,內存越用越小,若是正好在業務量不是特別大時,整個堆會呈現一個「穩步上升」的態勢,下一步就是內存閥值的持續報警了。
因此,無界隊列的使用是須要很是當心的。
咱們把郵件服務分爲生產郵件和促銷郵件兩部分,代碼90%是複用的,但獨立部署,獨立的數據庫,促銷郵件上線後,咱們又遇到了老問題:數據庫主庫壓力再次CPU100%
在通過生產郵件3個月的運行及優化後,咱們對代碼作了少量的改動用於支持促銷郵件的發送,促銷的業務能夠歸納爲:瞬間大量數據寫入,Checker每次須要掃描上百萬的數據,整個系統須要在大量待發送數據中維持一個較穩定的發送速率。上線後,數據庫又再次報出異常:
一、主庫的寫有大量的死鎖異常(原來的生產郵件就有,不過再促銷郵件的業務形態中影響更明顯)。
二、從庫有大量的全表掃描,讀壓力很是高。
死鎖的問題,緣由是這樣的:
條件1:若是有Transaction1須要對ABC記錄加鎖,已經對A,B記錄加了X鎖,此刻在嘗試對C記錄枷鎖。
條件2:若是此前Transaction2已經對C記錄加了獨佔鎖,此刻須要對B記錄加X鎖。
就會產生dead lock。實際狀況是:若是兩條update語句同一時刻既須要掃描ABC又須要掃描DCB,那麼死鎖就出現了。
儘管Mysql作了優化,好比增長超時時間:innodb_lock_wait_timeout,超時後會自動釋放,釋放的結果是Transaction1和Transaction2所有Rollback(死鎖問題並無解決,若是不幸,下次執行還會重現)。再若是每一個Transaction都是update數萬,數十萬的記錄(咱們的業務就是),那事務的回滾代價就很是高了。
解決辦法不少,好比先select出來再作逐條作update,或者update加上一個limit限制每次的更新次數,同時避免兩個Transaction併發執行等等。咱們選擇了第一種,由於咱們的業務對於時間上要求並不高,能夠「慢慢作」。
全表掃表的問題發生在Checker上,咱們封裝了不少操做郵件任務的邏輯在不一樣的Checker中,好比:過時Checker,重置Checker,Redis Push Checker等等。他們負責將郵件任務更新爲業務須要的各類狀態,大部分時候他們是並行執行的,會產生不少select請求。嚴重時,讀庫壓力基本維持在95%上長達數小時。
全表掃描99%的緣由是由於select沒有使用索引,因此每每開發同窗的第一反應是加索引,而後讓數據庫「死扛」讀壓力 ,但索引是有成本的,佔用硬盤空間不說,insert/delete操做都須要維護索引,其實咱們還有另外好幾種方案能夠選擇,好比:是否是須要這麼頻繁的執行select? 是否是每次都要select這麼多數據?是否是須要同一時間併發執行?
咱們的解決辦法是:合理利用索引+下降掃描頻率+掃描適量記錄。
首先,將Checker裏的SQL統一化,每一個Checker產生的SQL只有條件不一樣,使用的字段基本同樣,這樣能夠很好的使用索引。
其次,咱們發現發送端的消費能力是整個郵件發送流程的制約點,消費能力決定了某個時間內須要多少郵件發送任務,Checker掃描的量只要剛剛夠發送端滿負荷發送就能夠了,
所以,Checker再也不每一個幾分鐘掃表一次,只在隊列長度低於某個下限值時才掃描,
而且一次掃描到隊列的上限值,多一個都不掃。
通過以上優化後,促銷的庫也沒有再報警了。
直到兩週之前,咱們又遇到了一個新問題:發送節點CPU100%.
這個問題的表象爲:CPU正常執行業務時保持在80%以上,高峯時超過95%數小時。監控圖標以下:
在說這個問題前,先看下發送節點的線程模型:
Redis中根據目標郵箱的域名有一到多個Redis隊列,每一個發送節點有一個跟目標郵箱相對應的FetchThread用於從Redis Pull郵件發送任務到發送節點本地,而後經過一個BlockingQueue將任務傳遞給DeliveryThread,DeliveryThread鏈接具體郵件服務商的服務器發送郵件。考慮到每次鏈接郵件服務商的服務器是一個相對耗時的過程,所以同一個域名的DeliveryThread有多個,是多線程併發執行的。
既然表象是CPU100%,根據這個線程模型,第一步懷疑是否是線程數太多,同一時間併發致使的。查看配置後發現線程數只有幾百個,同時一時間執行的只有十多個,是相對合理的,不該該是引發CPU100%的根因。
可是在檢查代碼時發現有這麼一個業務場景:
一、因爲JIMDB的封裝,發送平臺採用的是輪詢的方式從Redis隊列中Pull郵件發送任務,Redis隊列爲空時FetchThread會sleep一段時間,而後再檢查。
二、從業務上說網易+騰訊的郵件佔到了整個郵件總量的70%以上,對非前者的FetchThread來講,Pull不到概率很是高。
那就意味着發送節點上的不少FetchThread執行的是沒必要要的喚醒->檢查->sleep的流程,白白的浪費CPU資源。
因而咱們利用事件驅動的思想將模型稍稍改變一下:
每次FetchThread對應的Redis隊列爲空時,將該線程阻塞到Checker上,由Checker統一對多個Redis隊列的Pull條件作判斷,符合Pull條件後再喚醒FetchThread。
Pull條件爲:
1.FetchThread的本地隊列長度小於初始長度的一半。
2.Redis隊列不爲空。
同時知足以上兩個條件,視爲能夠喚醒對應的FetchThread。
以上的改造本質上仍是在下降線程上下文切換的次數,將簡單工做歸一化,並將多路併發改成阻塞+事件驅動和下降拉取頻率,進一步減小線程佔用CPU時間片的機會。
上線後,發送節點的CPU佔用率有了20%左右的降低,可是並無直接將CPU的利用率優化爲很是理想的狀況(20%如下),咱們懷疑並無找到真正的緣由。
因而咱們接着對郵件發送流程作了進一步的梳理,發現了一個很是奇怪的地方,代碼以下:
咱們在發送節點上使用了Handlebars作郵件內容的渲染,在初始化時使用了Concurrent相關的Map作模板的緩存,可是每次渲染前卻要從新new一個HandlebarUtil,那每一個HandlebarUtil豈不是用的都是不一樣的TemplateCache對象?既然如此,爲何要用Concurrent(意味着線程安全)的Map?
進一步閱讀源碼後發現不管是Velocity仍是Handlebars在渲染先都須要對模板作語法解析,構建抽象語法樹(AST),直至生成Template對象。構建的整個過程是相對消耗計算資源的,所以猜測Velocity或者Handlebars會對Template作緩存,只對同一個模板解析一次。
爲了驗證猜測,能夠把渲染的過程單獨運行下:
能夠看到Handlebars的確能夠對Template作了緩存,而且每次渲染前會優先去緩存中查找Template。而除了一樣執行5次,耗時開銷特別大之外,CPU的開銷也一樣特別大,上圖爲使用了緩存CPU利用率,下圖爲沒有使用到緩存的CPU利用率:
找到了緣由,修改就比較簡單了保證handlebars對象是單例的,可以儘可能使用緩存便可。
上線後結果以下:
至此,整個性能優化工做已經基本完成了,從每一個案例的優化方案來看,有如下幾點經驗想和你們分享:
一、性能優化首先應該定位到真正緣由,從緣由下手去想方案。
二、方案應該貼合業務自己,從客觀規律、業務規則的角度去分析問題每每更容易找到突破點。
三、一個細小的問題在業務量巨大的時候甚至可能壓垮服務的根因,開發過程當中要注意每一個細節點的處理。
四、平時多積累相關工具的使用經驗,遇到問題時能結合多個工具定位問題。