單日峰值 2T 發送量郵件營銷平臺實踐經驗
EDM簡介
EDM 是 Email DirectMarketing 的縮寫,即郵件營銷。是利用電子郵件(Email)與受眾客戶進行商業交流的一種直銷方式,郵件營銷的對于企業的價值主要體現在三個方面:開拓新客戶、維護老客戶,以及品牌建設。
在互聯網領域,大部分企業都有類似業務。總體來說,有歐美背景的公司往往比較重視EDM,所以EDM的效果也做得很不錯,而國內就相對做的少一點。
我們的EDM平臺應該是其中規模相對大一點的,每日發送量千萬級別,峰值數據2T(每日),如何能在峰值到來時保證系統的穩定,以及從大量的郵件發送任務中篩選出高優先級發送任務并及時發送是構建這個平臺的難點之一。
老平臺日益突出的問題
原有的老平臺已經是5年前的產物了,受限于當時研發人員對EDM的研發經驗和當時的技術棧,老平臺的幾個問題在日后的發展中越來越突出,慢慢的讓新架構變成了一件不可不做的事情。
老平臺存在的問題如下:
1. 技術棧、實現思路混亂:
-
受限于業務,老平臺分成了 生產郵件和促銷郵件 兩個子平臺。對于郵件任務的發送, 老促銷郵件平臺引入了Thrift框架 ,而老生產郵件平臺則通過搶占式更新數據庫的方式實現。
-
渲染郵件模板時, 老生產使用的是velocity ,而老促銷已經改為由上層(郵件模板裝修系統) 用handlebars提前渲染 好后直接發送。
2. SQL Server的改造需求:
-
老平臺的數據庫使用的是SQL Server,從降低公司成本的角度出發,有盡快遷移到MySQL的需求。
3. 歷史需求包袱很重:
-
很多業務已經下線,但是代碼還留在線上(包括各種關聯系統)。
新平臺方案
在這樣的情況下,我們決定拋棄老的平臺,構建新一代的EDM平臺。那其中第一步就是確認業務架構。 經過多次梳理和方案推導后,整個EDM平臺的業務架構如下:
-
新平臺將由生產平臺、發送平臺和統一管理平臺組成 :生產平臺負責生產郵件發送任務,發送平臺則部署在主流運營商的網絡上,冗余部署一定量的發送節點,保證發送成功率。因為郵箱服務提供商通常會對郵件的發送方按IP做流控限制,并且對各種網絡運營商投遞的郵件的接受程度也不一樣。同時,為了保證在主流的網絡運營商渠道上都有發送節點,避免因某個網段或某個運營商網絡故障引起郵件發送成功率的波動。所以將發送 和生產的邏輯區分開,有利于應用區分部署和擴容,平臺間的職責也更加清晰。
-
生產平臺和發送平臺間使用Redis隊列傳遞郵件任務 。設計期間考慮過MQ,kafka類組件,沒有使用的原因是以上組件對于傳遞郵件的任務都“過重”,引入新的組件意味著新的風險,新組件的穩定性也會影響平臺的穩定性。同時Redis對隊列有原生的支持,作為當前最常使用的組件,其簡單易用的特點正好符合新架構對這個組件的要求。再加上公司已經對Redis實現了集群及自動伸縮方案,可用率大大提高,學習零成本,都是Redis的優勢,也是這次架構升級選用Redis的理由。
-
建立統一管理平臺,實現對生成和發送情況的統一調度 。要求是實現對整個生產和發送平臺的調度管理,包含指定高優節點,屏蔽成功率較低節點,降級開關等一系列措施。
總結
-
生產平臺對接用戶組,訂單組,會員等系統的MQ消息或遠程調用生成郵件發送任務,并按類型劃分1-10個優先級,數字越大,優先級越高。
-
發送平臺聚焦在發送業務的處理上,包括優先級隊列、發送降頻、郵件模板渲染等事務。
-
管理平臺主要負責配置數據的維護、降級開關的推送和必要時人工對生產、發送平臺的調度。
-
Redis按優先級和目標郵箱兩個維度拆分成多個Redis隊列,負責將郵件任務傳遞到發送平臺。
注:老平臺也是生產和發送分成兩個平臺,只不過職責沒有劃分得非常清晰,比如各自的平臺上都集成了管理功能,發送節點只能調節自己的發送策略,不利于發送策略的批量調整。
方案細節
Redis優先級隊列
首先,業務上決定了郵件任務的緊急程度是不一樣的,例如用戶對賬戶密碼找回的郵件就比優惠券到賬的及時性敏感度更高,顯然密碼找回的郵件需要以最快速度投遞到用戶郵箱中。這和某些在極端場景才出現的“優先級”不一樣,是一直持續存在的高優任務,最簡單的辦法就是區別對待,按優先級設置隊列,從生產平臺開始到Redis隊列再到發送平臺都一直是一個或多個特殊的隊列,方便系統對這些高優發送任務做處理。
高優先級隊列長度的報警閥值比較小,一旦積壓研發同學會第一時間收到報警,必要時可以人工接入。而發送平臺總是最先拉取高優先級發送任務,保證其第一時間被處理。值得注意的是,從客觀規律上看高優的郵件往往量是比較小,這使得發送平臺總是優先處理高優的郵件并不會讓優先級低的發送任務沒有機會被拉取。
當業務量較大,發送較為頻繁時觸發郵件服務商的流控,或網絡不穩定,出口IP異常時都會引起部分發送平臺的郵件投遞成功率的下降,這時需要讓成功率將低的節點暫時甚至永久的不再向郵件服務商投遞郵件,解決方案之一是在隊列的拆分維度除了優先級以外再增加一個目標郵箱,一旦出現上述問題后,可以直接讓發送節點不再拉取該郵箱的所有隊列來實現故障隔離。
用優先級和目標郵箱拆分Redis隊列還有一個好處是,如果使用的是分布式的Redis,隊列的元素總是在一個分片中的,如果隊列過少,會導致有可能大量元素都集中在同一個分片中形成熱點分片。將Redis隊列拆分后可以讓分個分片的讀寫相對更均衡,分片的利用率更高。實際上,Redis隊列還設置了一個最大長度,防止隊列無限制的增長。
投遞降頻
投遞郵件時如果投遞被拒絕郵件服務提供商一般都會返回一個錯誤碼,發送平臺上有一個錯誤映射表,發送錯誤后將錯誤碼和錯誤映射表比較,如果觸發了流控則降低郵件發送任務的拉取頻率,直到投遞成功率恢復后再逐步提升發送能力。
Checker
Checker是生產平臺掃描郵件發送任務的定時任務的總稱,按職責不同,Checker被具體分為UnsuccessChecker、InQueueChecker、ExpireChecker等等。職責是將各個狀態的郵件發送任務更新為下一個流程需要的狀態,比如將入庫成功的狀態更新為Redis隊列中狀態,Redis隊列中狀態更新為發送平臺發送中狀態,發送錯誤的任務狀態更新為重新投遞狀態等等。
重新投遞是非常重要的一個功能,因為某個出口IP因為各種原因可能會常常被郵件服務商拒收,重試相當于有較大幾率更換出口IP再做發送嘗試,有利于投遞成功率的提高。
郵件發送任務常常被各種Checker更新,為了保證數據的一致性和狀態按正確流程流轉,郵件發送任務被加上了版本號,每次更新后自增,更新時使用樂觀鎖更新。
性能優化
初期平臺上線時的第一版架構如下:
上線后出現了一系列的性能問題,總結起來主要為兩類:
-
寫庫CPU 100%,影響遠程調用接口的性能,引發上游團隊關注;
-
代碼編寫不當,引發JVM假死和CPU 100%。
寫庫CPU 100%
1. 數據庫同一時間讀寫請求太多和索引利用率不高導致的
因此第一步想將數據庫的讀寫壓力分開,對數據庫做了讀寫分離,所有的讀請求全部調整到了從庫上去,以此降低主庫壓力。這里有一個前提是經過評估,從庫讀取臟數據并不會對業務產生困擾,因為郵件發送任務本身有版本號,即使數據庫主從同步有延遲引起從庫讀到“臟數據”使用樂觀鎖更新時也會失敗,不會引起業務錯誤。
第二步將查詢條件歸一再后建立索引,索引不是越多越好,歸一時可以現將查詢任務列出來,觀察哪些查詢條件是相似的,有沒有特殊的業務導致了不一樣的查詢條件,這些業務有沒有辦法從其他角度去支持,逐步歸納后再建索引。 上述步驟多次使用后,通常情況必須要建立的索引就不會太多了。目前郵件發送任務表(分表后單表千萬級)只有一個主鍵索引和一個組合索引(三個字段),所有查詢條件全部先利用索引查詢數據。
調整完索引后發現Checker的掃描還是過于頻繁,讀庫CPU利用率還是不夠理想(過高),梳理業務后發現整個平臺的發送能力取決于郵件服務商的接受量和一定程度的發送平臺量(出口IP量)。兩者不變的情況下,整個發送平臺的發送量不會提升,Redis隊列的吞吐能力也不變,Checker大部分時候運行的結果只是Push了個別元素,甚至沒有Push。Checker完全可以改成Redis隊列小于一定閥值,例如最大長度的1/2再做一次掃描,一次掃描盡量將隊列填滿。調整Checker策略和索引后數據庫的QPS大約降了2/3,load也穩定在5以下。
2. Checker引起數據庫死鎖
例如有Transaction1需要對ABC記錄加鎖,已經對A,B記錄加了X鎖,此刻正嘗試對C記錄加鎖。同時此前Transaction2已經對C記錄加了獨占鎖,此刻需要對B記錄加X鎖,就會產生數據庫的死鎖。
盡管Mysql做了優化,比如增加超時時間:innodb_lock_wait_timeout,超時后會自動釋放,釋放的結果是Transaction1和Transaction2全部Rollback(死鎖問題并沒有解決,如果不幸,下次執行還會重現)。
如果每個Transaction都是Update數萬、數十萬的記錄(我們的業務就是),那事務的回滾代價就非常高,還會引起數據庫的性能波動。
解決辦法很多,比如先查詢出數據后再做逐條做寫操作,或者寫操作加上一個limit限制每次的更新次數,同時避免兩個Transaction并發執行等等。
最終在調整了Checker的運行周期后選擇了逐條更新的方案,因為業務對于時間上要求并不高,更新不及時并不會引起業務上的錯誤。
經過以上優化后,壓測表明整個平臺3倍峰值流量下,數據庫CPU利用率10%以下,load5以下,95%的遠程調用只有一次數據庫的insert操作,遠程接口TP999在20ms內。
JVM假死和CPU 100%
出于減少遠程接口同步邏輯的需要,研發同學將大部分操作改為異步方式,比如郵件的推薦商品服務。
因為郵件發送任務在生產平臺到發送平臺間流轉需要一定的時間,將推薦商品服務異步化后,生產郵件發送任務的同步邏輯會減少,遠程接口調用或MQ消息消費線程可以更早返回,對推薦接口的性能波動容忍度也會變高,只需要保證在發送平臺渲染郵件模板前能夠拿到推薦商品的數據即可。
異步改造時,研發同學使用了線程池的無界隊列,并因為一個低級BUG導致上線后無界隊列的消費線程只有5個,生產和消費的速率嚴重不匹配,導致了短時間內JVM內存占用過高,JVM頻繁GC,JVM頻繁處于“stop the world”階段,呈現出“假死”狀態,最終再次影響到遠程接口的調用和MQ消息的消費。
這次的經驗說明,實例宕機或許并不是最難處理的,更難處理的是實例處于可以提供服務,但是沒有服務能力的狀態。
1. 使用 有界隊列
使用有界隊列,防止超長隊列的產生。設置隊列的拒絕策略,隊列無空閑位置時,放棄入隊操作。此時會導致部分郵件缺少推薦商品模塊,可視作推薦商品模塊的處理能力達到上限后的一種降級方式。
2. Redis元素不能太大
Redis隊列中元素的大小大于10K時,入隊和出隊的效率會嚴重下降,出于這個原因,Redis隊列中只存放有郵件發送任務的原始數據。
3. 緩存模板引擎解析結果
渲染工作是在發送節點上完成的。發送高峰期時,發送平臺的CPU利用率整體在80%甚至90%以上,發送能力無法再提升。經過一系列排查后發現CPU利用率較高的源頭來自于Handlebars模板渲染模塊。
抽樣查看部分線上機器的線程占用率時發現渲染線程大部分時間一直在做郵件模板的語法解析,參考相應文檔后發現語法解析是模板渲染中最耗時的流程,為了提高效率無論是Velocity還是Handlbars都會對模板語法解析的結果做緩存,下次渲染時直接使用解析結果渲染。
緩存是基于VelocityEngine或Handlebar實例的,如果JVM中存在多個VelocityEngine或Handlebar實例,緩存就無法有效利用,結果是每次渲染模板時都要做語法解析,如果并發解析的線程達到數十、數百個的話,就會引起實例的CPU 100%。
因此:
-
保證全局只有一個Handlebar實例,方便共享緩存結果;
-
容器啟動時,渲染線程依次啟動并等待一段時間后再啟動下一個渲染線程,避免并發啟動多個線程時出現并發解析模板的情況。
除了以上問題的解決,上線后研發團隊還做了幾次全流程的優化,優化包括 黑名單、退訂數據緩存化,Redis隊列Push方式異步化、批量化,發送平臺的拉取合并、郵件模板本地化 等。
優化后平臺的應用架構如下:
容災方案
容災方案中,優先考慮的就是多網絡運營商覆蓋的問題,防止某一網絡運營商網絡故障影響郵件發送的能力。目前的方案是單一機房配置單一網絡運營商的出口IP及反解析域名;每個機房部署的生產平臺、Redis隊列和發送平臺彼此之間相互獨立運行,但底層使用同一個數據庫,生產平臺提供的遠程接口為同一個別名服務,MQ消息也是消費的同一個Topic下的內容(比如兩個機房,每個機房1/2的消費),多個Redis之間存在少量數據的同步,比如去重數據。整體架構如下:
我們將緊急情況分為 內部接口、服務故障和外部服務故障 。(內部接口故障因深入到業務細節,暫時略過,只舉一個例子)
針對 內部故障 ,比如
-
Redis集群故障。如果是單分片故障,Redis集群提供了主從分片,可以通過切換分片的方式解決。如果是集群整體故障,可以啟用備用Redis集群,在這里不存在集群數據為空的問題,因為生產平臺有Checker存在,如果切換集群,Checker可以感知到Redis隊列數據量不夠,會重新將待發送的郵件任務Push到Redis隊列中。
針對 外部故障 ,比如
-
機房出口網絡故障。可以停止故障機房的發送平臺,因數據庫共享,數據入庫后對端機房的Checker會將數據重新Push到對端機房的Redis隊列中,從對端機房發送郵件任務。這里還有一種方案是修改故障機房的Redis集群配置,故障機房的生產平臺生成郵件發送任務后直接將數據Push到對端機房的Redis集群中,省略Checker掃描的這一步,會大大減少數據庫的讀壓力。
-
郵件服務提供商對部分出口IP降頻。發送節點上內置了降頻處理措施,可以解決該問題。
-
郵件服務商屏蔽部分出口IP。通過自研的配置推送與服務監控框架,可用管理平臺將被屏蔽的IP地址推送到發送平臺上,發送平臺通過比對如果發現自身已被屏蔽,將不再從Redis隊列中Pull相應的郵件發送任務。
總結
經過半年多的架構升級與持續優化,我有幾點經驗可以和大家共享的:
-
深入識別清楚業務特性對架構至關重要。EDM平臺上層對接了精準營銷平臺的促銷郵件和生產郵件業務。除開業務上少量的差別外,促銷郵件每日發送量較大,但對郵件的及時性要求不高,生產郵件每日發送量相對較小,但對郵件的及時性要求很高。原有的架構是把兩者當成了不同的事情在處理,所以有兩套不同的架構方式。但是總體來看,生產郵件大促期間發送量也很高,促銷郵件也有高優先級任務需要優先發送,對優先級也有要求。無論是生產還是促銷,本質上還是個郵件發送平臺,新的架構可以同時按照有對高優郵件的優先支持和發送量劇增時平臺吞吐量的穩定兩個原則去設計。
-
采用簡單有效的原則去設計架構。將郵件發送任務從生產平臺傳遞到發送平臺有很多種方式,甚至很多種框架可以選擇,比如kafka、MQ、Thrift等,最終選擇Redis主要原因還是Redis簡單,lpush和rpop兩個命令需要研發同學學習就可以完成該模板功能的開發。至于順序消費、事務性消費業務上都沒有要求,該模塊的職責僅僅是能夠將郵件發送任務傳遞到發送平臺即可,同時兼容區分優先級的方法,有一定的分區容錯能力即可。而Redis正好滿足了這兩個最強烈的要求。
-
堅定的圍繞業務特性做設計。郵件發送的業務特性是:數據總是單向流動的。數據總是從生產平臺到發送平臺,重新投遞時仍然是從生產平臺傳遞到發送平臺。因此checker的功能放在生產端以復用整個傳遞的邏輯,流控的策略可以加在生產端往Redis Push時和發送平臺從Redis Pull時,容災切換時考慮好了生產平臺怎么切換流量,發送平臺的切換方案也就隨之確定等等,圍繞業務特性,尊重客觀規律可以節省很多不必要的考慮。
如何用一套通用的解決方案解決兩個業務的不同需求是建設該平臺的難點,需要在兩種業務形態間找到共性并滿足各自業務對及時性,發送量方面的要求。在做方案時需要更多的關注到架構本身對性能、容災、業務和研發同學的友好性,架構越容易讓人接受,更簡單的解決現有問題,才有可能在以后的發展中不斷往好的方向進化,容納更多復雜的業務需求,支持業務的長久發展。
細節追蹤
1、優化上線后,出現了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次數減少了非常多,運行一段時間后加上了很多新功能,為了提高處理效率和減少業務之間的耦合,我們做了很多異步化的處理。更多的異步化意味著更多的線程和隊列,如上述經驗,很多元素被移到了年老代去,內存越用越小,如果正好在業務量不是特別大時,整個堆會呈現一個“穩步上升”的態勢,下一步就是內存閥值的持續報警了。
所以,無界隊列的使用是需要非常小心的。
直到兩周以前,我們又遇到了一個新問題:發送節點CPU 100%.
這個問題的表象為:CPU正常執行業務時保持在80%以上,高峰時超過95%數小時。監控圖標如下:
在說這個問題前,先看下發送節點的線程模型:
Redis中根據目標郵箱的域名有一到多個Redis隊列,每個發送節點有一個跟目標郵箱相對應的FetchThread用于從Redis Pull郵件發送任務到發送節點本地,然后通過一個BlockingQueue將任務傳遞給DeliveryThread,DeliveryThread連接具體郵件服務商的服務器發送郵件。考慮到每次連接郵件服務商的服務器是一個相對耗時的過程,因此同一個域名的DeliveryThread有多個,是多線程并發執行的。
既然表象是CPU 100%,根據這個線程模型,第一步懷疑是不是線程數太多,同一時間并發導致的。查看配置后發現線程數只有幾百個,同時一時間執行的只有十多個,是相對合理的,不應該是引起CPU 100%的根因。
但是在檢查代碼時發現有這么一個業務場景:
-
由于JIMDB的封裝,發送平臺采用的是輪詢的方式從Redis隊列中Pull郵件發送任務,Redis隊列為空時FetchThread會sleep一段時間,然后再檢查;
-
從業務上說網易+騰訊的郵件占到了整個郵件總量的70%以上,對非前者的FetchThread來說,Pull不到幾率非常高。
那就意味著發送節點上的很多FetchThread執行的是不必要的喚醒-->檢查-->sleep的流程,白白的浪費CPU資源。
于是我們利用事件驅動的思想將模型稍稍改變一下:
每次FetchThread對應的Redis隊列為空時,將該線程阻塞到Checker上,由Checker統一對多個Redis隊列的Pull條件做判斷,符合Pull條件后再喚醒FetchThread。
Pull條件為:
-
FetchThread的本地隊列長度小于初始長度的一半;
-
Redis隊列不為空。
同時滿足以上兩個條件,視為可以喚醒對應的FetchThread。
以上的改造本質上還是在降低線程上下文切換的次數,將簡單工作歸一化,并將多路并發改為阻塞+事件驅動和降低拉取頻率,進一步減少線程占用CPU的時間片的機會。
上線后,發送節點的CPU占用率有了20%左右的下降,但是并沒有直接將CPU的利用率優化為非常理想的情況(20%以下),我們懷疑并沒有找到真正的原因。
于是我們接著對郵件發送流程做了進一步的梳理,發現了一個非常奇怪的地方,代碼如下:
我們在發送節點上使用了Handlebars做郵件內容的渲染,在初始化時使用了Concurrent相關的Map做模板的緩存,但是每次渲染前卻要重新new一個HandlebarUtil,那每個HandlebarUtil豈不是用的都是不同的TemplateCache對象?既然如此,為什么要用ConcurrentMap (意味著線程安全) ?
進一步閱讀源碼后發現無論是Velocity還是Handlebars在渲染先都需要對模板做語法解析,構建抽象語法樹(AST),直至生成Template對象。構建的整個過程是相對消耗計算資源的,因此猜想Velocity或者Handlebars會對Template做緩存,只對同一個模板解析一次。
為了驗證猜想,可以把渲染的過程單獨運行下:
可以看到Handlebars的確可以對Template做了緩存,并且每次渲染前會優先去緩存中查找Template。而除了同樣執行5次,耗時開銷特別大以外,CPU的開銷也同樣特別大,上圖為使用了緩存CPU利用率,下圖為沒有使用到緩存的CPU利用率:
找到了原因,修改就比較簡單了保證handlebars對象是單例的,能夠盡量使用緩存即可。
上線后結果如下:
至此,整個性能優化工作已經基本完成了,從每個案例的優化方案來看,有以下幾點經驗想和大家分享:
-
性能優化首先應該定位到真正原因,從原因下手去想方案;
-
方案應該貼合業務本身,從客觀規律、業務規則的角度去分析問題往往更容易找到突破點;
-
一個細小的問題在業務量巨大的時候甚至可能壓垮服務的根因,開發過程中要注意每個細節點的處理;
-
平時多積累相關工具的使用經驗,遇到問題時能結合多個工具定位問題。
來自:http://mp.weixin.qq.com/s?__biz=MzIwODA4NjMwNA==&mid=2652897991&idx=1&sn=c26ebf0782817e44b6a2892c6ae04033&chksm=8cdcd688bbab5f9ebbea47157bdb727fed83be344f2aa0e1236e46e05ba70f99a7343777cba6&scene=0