本文要感謝我職級評定過程中的一位評委,他建議把之前所做的各種性能優化的案例和方案加以提煉、總結,以文檔的形式沉澱下來,並在內部進行分享。力求達到如下效果:

  1. 形成可實踐、可借鑒、可參考的各種性能優化的方案以及選型考慮點,同時配合具體的真實案例,其他人遇到相似問題時,不用從零開始。

  2. 有助於開闊視野,除了性能優化之外,也能提供通用的常見思路以及方案選型的考慮點,幫助大家培養在方案選型時的意識、思維以及做各種權衡的能力。

  文章在內部分享後,引起強烈分享,得到了不少同事和朋友的認可和好評,覺得對日常的工作有很好的指導作用。考慮到這些經驗可能對業界同行也有幫助,所以在美團點評技術團隊博客公開。

  常見性能優化策略分類

  代碼

  之所以把代碼放到第一位,是因為這一點最容易引起技術人員的忽視。很多技術人員拿到一個性能優化的需求以後,言必稱緩存、異步、JVM等。實際上,第一步就應該是分析相關的代碼,找出相應的瓶頸,再來考慮具體的優化策略。有一些性能問題,完全是由於代碼寫的不合理,通過直接修改一下代碼就能解決問題的,比如for循環次數過多、作了很多無謂的條件判斷、相同邏輯重複多次等。

  數據庫

  數據庫的調優,總的來說分為以下三部分:

  SQL調優

  這是最常用、每一個技術人員都應該掌握基本的SQL調優手段(包括方法、工具、輔助係統等)。這裏以MySQL為例,最常見的方式是,由自帶的慢查詢日誌或者開源的慢查詢係統定位到具體的出問題的SQL,然後使用explain、profile等工具來逐步調優,最後經過測試達到效果後上線。這方麵的細節,可以參考MySQL索引原理及慢查詢優化。

  架構層麵的調優

  這一類調優包括讀寫分離、多從庫負載均衡、水平和垂直分庫分表等方麵,一般需要的改動較大,但是頻率沒有SQL調優高,而且一般需要DBA來配合參與。那麽什麽時候需要做這些事情?我們可以通過內部監控報警係統(比如Zabbix),定期跟蹤一些指標數據是否達到瓶頸,一旦達到瓶頸或者警戒值,就需要考慮這些事情。通常,DBA也會定期監控這些指標值。

  連接池調優

  我們的應用為了實現數據庫連接的高效獲取、對數據庫連接的限流等目的,通常會采用連接池類的方案,即每一個應用節點都管理了一個到各個數據庫的連接池。隨著業務訪問量或者數據量的增長,原有的連接池參數可能不能很好地滿足需求,這個時候就需要結合當前使用連接池的原理、具體的連接池監控數據和當前的業務量作一個綜合的判斷,通過反複的幾次調試得到最終的調優參數。

  緩存

  分類

  本地緩存(HashMap/ConcurrentHashMap、Ehcache、Guava Cache等),緩存服務(Redis/Tair/Memcache等)。

  使用場景

  什麽情況適合用緩存?考慮以下兩種場景:

短時間內相同數據重複查詢多次且數據更新不頻繁,這個時候可以選擇先從緩存查詢,查詢不到再從數據庫加載並回設到緩存的方式。此種場景較適合用單機緩存。

高並發查詢熱點數據,後端數據庫不堪重負,可以用緩存來扛。

  選型考慮

如果數據量小,並且不會頻繁地增長又清空(這會導致頻繁地垃圾回收),那麽可以選擇本地緩存。具體的話,如果需要一些策略的支持(比如緩存滿的逐出策略),可以考慮Ehcache;如不需要,可以考慮HashMap;如需要考慮多線程並發的場景,可以考慮ConcurentHashMap。

其他情況,可以考慮緩存服務。目前從資源的投入度、可運維性、是否能動態擴容以及配套設施來考慮,我們優先考慮Tair。除非目前Tair還不能支持的場合(比如分布式鎖、Hash類型的value),我們考慮用Redis。

  設計關鍵點

  什麽時候更新緩存?如何保障更新的可靠性和實時性?

  更新緩存的策略,需要具體問題具體分析。這裏以門店POI的緩存數據為例,來說明一下緩存服務型的緩存更新策略是怎樣的?目前約10萬個POI數據采用了Tair作為緩存服務,具體更新的策略有兩個:

接收門店變更的消息,準實時更新。

給每一個POI緩存數據設置5分鍾的過期時間,過期後從DB加載再回設到DB。這個策略是對第一個策略的有力補充,解決了手動變更DB不發消息、接消息更新程序臨時出錯等問題導致的第一個策略失效的問題。通過這種雙保險機製,有效地保證了POI緩存數據的可靠性和實時性。

  緩存是否會滿,緩存滿了怎麽辦?

  對於一個緩存服務,理論上來說,隨著緩存數據的日益增多,在容量有限的情況下,緩存肯定有一天會滿的。如何應對?

  ① 給緩存服務,選擇合適的緩存逐出算法,比如最常見的LRU。

  ② 針對當前設置的容量,設置適當的警戒值,比如10G的緩存,當緩存數據達到8G的時候,就開始發出報警,提前排查問題或者擴容。

  ③ 給一些沒有必要長期保存的key,盡量設置過期時間。

  緩存是否允許丟失?丟失了怎麽辦?

  根據業務場景判斷,是否允許丟失。如果不允許,就需要帶持久化功能的緩存服務來支持,比如Redis或者Tair。更細節的話,可以根據業務對丟失時間的容忍度,還可以選擇更具體的持久化策略,比如Redis的RDB或者AOF。

  緩存被“擊穿”問題

  對於一些設置了過期時間的key,如果這些key可能會在某些時間點被超高並發地訪問,是一種非常“熱點”的數據。這個時候,需要考慮另外一個問題:緩存被“擊穿”的問題。

概念:緩存在某個時間點過期的時候,恰好在這個時間點對這個Key有大量的並發請求過來,這些請求發現緩存過期一般都會從後端DB加載數據並回設到緩存,這個時候大並發的請求可能會瞬間把後端DB壓垮。

如何解決:業界比較常用的做法,是使用mutex。簡單地來說,就是在緩存失效的時候(判斷拿出來的值為空),不是立即去load db,而是先使用緩存工具的某些帶成功操作返回值的操作(比如Redis的SETNX或者Memcache的ADD)去set一個mutex key,當操作返回成功時,再進行load db的操作並回設緩存;否則,就重試整個get緩存的方法。類似下麵的代碼:

  publicString get(key){ String value = redis.get(key); if(value == null) { //代表緩存值過期//設置3min的超時,防止del操作失敗的時候,下次緩存過期一直不能load dbif(redis.setnx(key_mutex, 1, 3* 60) == 1) { //代表設置成功value = db.get(key); redis.set(key, value, expire_secs); redis.del(key_mutex); } else{ //這個時候代表同時候的其他線程已經load db並回設到緩存了,這時候重試獲取緩存值即可sleep(50); get(key); //重試} } else{ returnvalue; } }

  異步

  使用場景

  針對某些客戶端的請求,在服務端可能需要針對這些請求做一些附屬的事情,這些事情其實用戶並不關心或者用戶不需要立即拿到這些事情的處理結果,這種情況就比較適合用異步的方式處理這些事情。

  作用

縮短接口響應時間,使用戶的請求快速返回,用戶體驗更好。

避免線程長時間處於運行狀態,這樣會引起服務線程池的可用線程長時間不夠用,進而引起線程池任務隊列長度增大,從而阻塞更多請求任務,使得更多請求得不到技術處理。

線程長時間處於運行狀態,可能還會引起係統Load、CPU使用率、機器整體性能下降等一係列問題,甚至引發雪崩。異步的思路可以在不增加機器數和CPU數的情況下,有效解決這個問題。

  常見做法

  一種做法,是額外開辟線程,這裏可以采用額外開辟一個線程或者使用線程池的做法,在IO線程(處理請求響應)之外的線程來處理相應的任務,在IO線程中讓response先返回。

  如果異步線程處理的任務設計的數據量非常巨大,那麽可以引入阻塞隊列BlockingQueue作進一步的優化。具體做法是讓一批異步線程不斷地往阻塞隊列裏扔數據,然後額外起一個處理線程,循環批量從隊列裏拿預設大小的一批數據,來進行批處理(比如發一個批量的遠程服務請求),這樣進一步提高了性能。

  另一種做法,是使用消息隊列(MQ)中間件服務,MQ天生就是異步的。一些額外的任務,可能不需要我這個係統來處理,但是需要其他係統來處理。這個時候可以先把它封裝成一個消息,扔到消息隊列裏麵,通過消息中間件的可靠性保證把消息投遞到關心它的係統,然後讓這個係統來做相應的處理。

  比如C端在完成一個提單動作以後,可能需要其它端做一係列的事情,但是這些事情的結果不會立刻對C端用戶產生影響,那麽就可以先把C端下單的請求響應先返回給用戶,返回之前往MQ中發一個消息即可。而且這些事情理應不是C端的負責範圍,所以這個時候用MQ的方式,來解決這個問題最合適。

  NoSQL

  和緩存的區別

  先說明一下,這裏介紹的和緩存那一節不一樣,雖然可能會使用一樣的數據存儲方案(比如Redis或者Tair),但是使用的方式不一樣,這一節介紹的是把它作為DB來用。如果當作DB來用,需要有效保證數據存儲方案的可用性、可靠性。

  使用場景

  需要結合具體的業務場景,看這塊業務涉及的數據是否適合用NoSQL來存儲,對數據的操作方式是否適合用NoSQL的方式來操作,或者是否需要用到NoSQL的一些額外特性(比如原子加減等)。

  如果業務數據不需要和其他數據作關聯,不需要事務或者外鍵之類的支持,而且有可能寫入會異常頻繁,這個時候就比較適合用NoSQL(比如HBase)。

  比如,美團點評內部有一個對exception做的監控係統,如果在應用係統發生嚴重故障的時候,可能會短時間產生大量exception數據,這個時候如果選用MySQL,會造成MySQL的瞬間寫壓力飆升,容易導致MySQL服務器的性能急劇惡化以及主從同步延遲之類的問題,這種場景就比較適合用Hbase類似的NoSQL來存儲。

  JVM調優

  什麽時候調?

  通過監控係統(如沒有現成的係統,自己做一個簡單的上報監控的係統也很容易)上對一些機器關鍵指標(gc time、gc count、各個分代的內存大小變化、機器的Load值與CPU使用率、JVM的線程數等)的監控報警,也可以看gc log和jstat等命令的輸出,再結合線上JVM進程服務的一些關鍵接口的性能數據和請求體驗,基本上就能定位出當前的JVM是否有問題,以及是否需要調優。

  怎麽調?

如果發現高峰期CPU使用率與Load值偏大,這個時候可以觀察一些JVM的thread count以及gc count(可能主要是young gc count),如果這兩個值都比以往偏大(也可以和一個曆史經驗值作對比),基本上可以定位是young gc頻率過高導致,這個時候可以通過適當增大young區大小或者占比的方式來解決。

如果發現關鍵接口響應時間很慢,可以結合gc time以及gc log中的stop the world的時間,看一下整個應用的stop the world的時間是不是比較多。如果是,可能需要減少總的gc time,具體可以從減小gc的次數和減小單次gc的時間這兩個維度來考慮,一般來說,這兩個因素是一對互斥因素,我們需要根據實際的監控數據來調整相應的參數(比如新生代與老生代比值、eden與survivor比值、MTT值、觸發cms回收的old區比率閾值等)來達到一個最優值。

如果發生full gc或者old cms gc非常頻繁,通常這種情況會誘發STW的時間相應加長,從而也會導致接口響應時間變慢。這種情況,大概率是出現了“內存泄露”,Java裏的內存泄露指的是一些應該釋放的對象沒有被釋放掉(還有引用拉著它)。那麽這些對象是如何產生的呢?為啥不會釋放呢?對應的代碼是不是出問題了?問題的關鍵是搞明白這個,找到相應的代碼,然後對症下藥。所以問題的關鍵是轉化成尋找這些對象。怎麽找?綜合使用jmap和MAT,基本就能定位到具體的代碼。

  多線程與分布式

  使用場景

  離線任務、異步任務、大數據任務、耗時較長任務的運行**,適當地利用,可達到加速的效果。

  注意:線上對響應時間要求較高的場合,盡量少用多線程,尤其是服務線程需要等待任務線程的場合(很多重大事故就是和這個息息相關),如果一定要用,可以對服務線程設置一個最大等待時間。

  常見做法

  如果單機的處理能力可以滿足實際業務的需求,那麽盡可能地使用單機多線程的處理方式,減少複雜性;反之,則需要使用多機多線程的方式。

  對於單機多線程,可以引入線程池的機製,作用有二:

提高性能,節省線程創建和銷毀的開銷

限流,給線程池一個固定的容量,達到這個容量值後再有任務進來,就進入隊列進行排隊,保障機器極限壓力下的穩定處理能力在使用JDK自帶的線程池時,一定要仔細理解構造方法的各個參數的含義,如core pool size、max pool size、keepAliveTime、worker queue等,在理解的基礎上通過不斷地測試調整這些參數值達到最優效果。

合並機加單機(合並機改純後級好嗎)

  如果單機的處理能力不能滿足需求,這個時候需要使用多機多線程的方式。這個時候就需要一些分布式係統的知識了。首先就必須引入一個單獨的節點,作為調度器,其他的機器節點都作為執行器節點。調度器來負責拆分任務,和分發任務到合適的執行器節點;執行器節點按照多線程的方式(也可能是單線程)來執行任務。這個時候,我們整個任務係統就由單擊演變成一個集群的係統,而且不同的機器節點有不同的角色,各司其職,各個節點之間還有交互。這個時候除了有多線程、線程池等機製,像RPC、心跳等網絡通信調用的機製也不可少。後續我會出一個簡單的分布式調度運行的框架。

  度量係統(監控、報警、服務依賴管理)

  嚴格來說,度量係統不屬於性能優化的範疇,但是這方麵和性能優化息息相關,可以說為性能優化提供一個強有力的數據參考和支撐。沒有度量係統,基本上就沒有辦法定位到係統的問題,也沒有辦法有效衡量優化後的效果。很多人不重視這方麵,但我認為它是係統穩定性和性能保障的基石。

  關鍵流程

  如果要設計這套係統,總體來說有哪些關鍵流程需要設計呢?

  ① 確定指標

  ② 采集數據

  ③ 計算數據,存儲結果

  ④ 展現和分析

  需要監控和報警哪些指標數據?需要關注哪些?

  按照需求出發,主要需要二方麵的指標:

接口性能相關,包括單個接口和全部的QPS、響應時間、調用量(統計時間維度越細越好;最好是,既能以節點為維度,也可以以服務集群為維度,來查看相關數據)。其中還涉及到服務依賴關係的管理,這個時候需要用到服務依賴管理係統

單個機器節點相關,包括CPU使用率、Load值、內存占用率、網卡流量等。如果節點是一些特殊類型的服務(比如MySQL、Redis、Tair),還可以監控這些服務特有的一些關鍵指標。

  數據采集方式

  通常采用異步上報的方式,具體做法有兩種:第一種,發到本地的Flume端口,由Flume進程收集到遠程的Hadoop集群或者Storm集群來進行運算;第二種,直接在本地運算好以後,使用異步和本地隊列的方式,發送到監控服務器。

  數據計算

  可以采用離線運算(MapReduce/Hive)或者實時/準實時運算(Storm/Spark)的方式,運算後的結果存入MySQL或者HBase;某些情況,也可以不計算,直接采集發往監控服務器。

  展現和分析

  提供統一的展現分析平台,需要帶報表(列表/圖表)監控和報警的功能。

  真實案例分析

  案例一:商家與控製區關係的刷新job

  背景

  這是一個每小時定期運行一次的job,作用是用來刷新商家與控製區的關係。具體規則就是根據商家的配送範圍(多個)與控製區是否有交集,如果有交集,就把這個商家劃到這個控製區的範圍內。

  業務需求

  需要這個過程越短越好,最好保持在20分鍾內。

  優化過程

  原有代碼的主要處理流程是:

拿到所有門店的配送範圍列表和控製區列表。

遍曆控製區列表,針對每一個控製區:

  a. 遍曆商家的配送範圍列表,找到和這個控製區相交的配送範圍列表。

  b. 遍曆上述商家配送範圍列表,對裏麵的商家ID去重,保存到一個集合裏。

  c. 批量根據上述商家ID集合,取到對應的商家集合。

  d. 遍曆上述商家集合,從中拿到每一個商家對象,進行相應的處理(根據是否已是熱門商家、自營、在線支付等條件來判斷是否需要插入或者更新之前的商家和控製區的關係)。

  e. 刪除這個控製區當前已有的,但是不應該存在的商家關係列表。

  分析代碼,發現第2步的a步驟和b步驟,找出和某控製區相交的配送範圍集合並對商家ID去重,可以采用R樹空間索引的方式來優化。具體做法是:

任務開始先更新R樹,然後利用R樹的結構和匹配算法來拿到和控製區相交的配送範圍ID列表。

再批量根據配送範圍ID列表,拿到配送範圍列表。

然後針對這一批配送範圍列表(數量很小),用原始多邊形相交匹配的方法做進一步過濾,並且對過濾後的商家ID去重。

  這個優化已經在第一期優化中上線,整個過程耗時由40多分鍾縮短到20分鍾以內。

  第一期優化改為R樹以後,運行了一段時間,隨著數據量增大,性能又開始逐漸惡化,一個月後已經惡化到50多分鍾。於是繼續深入代碼分析,尋找了兩個優化點,安排第二期優化並上線。

  這兩個優化點是:

第2步的c步驟,原來是根據門店ID列表從DB批量獲取門店,現在可以改成mget的方式從緩存批量獲取(此時商家數據已被緩存);

第2步的d步驟,根據是否已是熱門商家、自營、在線支付等條件來判斷是否需要插入或者更新之前的商家和控製區的關係。

  上線後效果

  通過日誌觀察,執行時間由50多分鍾縮短到15分鍾以內,下圖是截取了一天的4台機器的日誌時間(單位:毫秒):

  

  可以看到,效果還是非常明顯的。

  案例二:POI緩存設計與實現

  背景

  2014年Q4,數據庫中關於POI(這裏可以簡單理解為外賣的門店)相關的數據的讀流量急劇上升,雖然說加入從庫節點可以解決一部分問題,但是畢竟節點的增加是會達到極限的,達到極限後主從複製會達到瓶頸,可能會造成數據不一致。所以此時,急需引入一種新的技術方案來分擔數據庫的壓力,降低數據庫POI相關數據的讀流量。另外,任何場景都考慮加DB從庫的做法,會對資源造成一定的浪費。

  實現方案

  基於已有的經過考驗的技術方案,我選擇Tair來作為緩存的存儲方案,來幫DB分擔來自於各應用端的POI數據的讀流量的壓力。理由主要是從可用性、高性能、可擴展性、是否經過線上大規模數據和高並發流量的考驗、是否有專業運維團隊、是否有成熟工具等幾個方麵綜合考量決定。

  詳細設計

  第一版設計

  緩存的更新策略,根據業務的特點、已有的技術方案和實現成本,選擇了用MQ來接收POI改變的消息來觸發緩存的更新,但是這個過程有可能失敗;同時啟用了key的過期策略,並且調用端會先判斷是否過期,如過期,會從後端DB加載數據並回設到緩存,再返回。通過兩個方麵雙保險確保了緩存數據的可用。

  第二版設計

  第一版設計運行到一段時間以後,我們發現了兩個問題:

某些情況下不能保證數據的實時一致(比如技術人員手動改動DB數據、利用MQ更新緩存失敗),這個時候隻能等待5分鍾的過期時間,有的業務是不允許的。

加入了過期時間導致另外一個問題:Tair在緩存不命中的那一刻,會嚐試從硬盤中Load數據,如果硬盤沒有再去DB中Load數據。這無疑會進一步延長Tair的響應時間,這樣不僅使得業務的超時比率加大,而且會導致Tair的性能進一步變差。

  為了解決上述問題,我們從美團點評負責基礎架構的同事那裏了解到Databus可以解決緩存數據在某些情況下不一致的問題,並且可以去掉過期時間機製,從而提高查詢效率,避免tair在內存不命中時查詢硬盤。而且為了防止DataBus單點出現故障影響我們的業務,我們保留了之前接MQ消息更新緩存的方案,作了切換開關,利用這個方案作容錯,整體架構如下:

  

  上線後效果

  上線後,通過持續地監控數據發現,隨著調用量的上升,到DB的流量有了明顯地減少,極大地減輕了DB的壓力。同時這些數據接口的響應時間也有了明顯地減少。緩存更新的雙重保障機製,也基本保證了緩存數據的可用。見下圖:

  

  

  案例三:業務運營後台相關頁麵的性能優化

  背景

  隨著業務的快速發展,帶來的訪問量和數據量的急劇上升,通過我們相應的監控係統可以發現,係統的某些頁麵的性能開始出現惡化。 從用戶方的反饋,也證明了這點。此時此刻,有必要迅速排期,敏捷開發,對這些頁麵進行調優。

  歡迎頁

需求背景:歡迎頁是地推人員乃至總部各種角色人員進入外賣運營後台的首頁,會顯示地推人員最想看到最關心的一些核心數據,其重要性不言而喻,所以該頁麵的性能惡化會嚴重影響到用戶體驗。因此,首先需要優化的就是歡迎頁。通過相應定位和分析,發現導致性能惡化的主要原因有兩個:數據接口層和計算展現層。

解決方案:對症下藥,分而治之。經過仔細排查、分析定位,數據接口層采用接口調用批量化、異步RPC調用的方式來進行有效優化,計算展現層決定采用預先計算、再把計算好的結果緩存的方式來提高查詢速度。其中,緩存方案根據業務場景和技術特點,選用Redis。定好方案後,快速開發上線。

上線效果:上線後性能對比圖,如下:

  

組織架構頁

需求背景:組織架構頁,采用了四層樹形結構圖,一起呈現加載,第一版上線後發現性能非常差。用戶迫切希望對這個頁麵的性能進行調優。

解決方案:經過分析代碼,定位到一個比較經典的問題:裏麵執行了太多次小數據量的SQL查詢。於是采用多個SQL合並成大SQL的方式,然後使用本地緩存來緩存這些數據,合理預估數據量和性能,充分測試後上線。

上線效果:上線後性能對比圖,如下:

訂單關聯樓宇頁

需求背景:隨著訂單量日益增大,訂單表積累的數據日益增多,訂單關聯樓宇頁的性能也日益變差(響應時間線性上升)。而這個頁麵和地推人員的業績息息相關,所以地推人員使用該頁麵的頻率非常高,性能日益惡化極大地影響了地推人員的用戶體驗。

解決方案:經過分析與設計,決定采用當時已有的訂單二級索引月分表來代替原始的訂單表來供前端的查詢請求;並且限製住篩選的時間條件,使得篩選的開始時間和結束時間不能跨月(事先和用戶溝通過,可以接受,能滿足用戶的基本需求),這樣就隻需一個月分索引表即可,通過適當的功能限製來達到性能的調優。這樣從二級索引月分表中根據各種查詢條件查到最終的分頁的訂單ID集合,然後再根據訂單ID從訂單庫來查出相應的訂單數據集合。

上線效果:上線後發現在調用量幾乎沒怎麽變的情況下,性能提升明顯,如下圖:

  其他

合並機加單機(合並機改純後級好嗎)

  除了上麵介紹的之外,優化還涉及前端、分布式文件係統、CDN、全文索引、空間索引等幾方麵。限於篇幅,我們留到未來再做介紹。

  圖片來自 RoadandTrack

  查看文章原網址可點擊“閱讀原文”。

  更多技術博客:美團點評技術博客。

  PS:正文中標綠的名詞均為參考鏈接,可點擊查詢。

  美團點評

  技術團隊

  https://tech.meituan.com

  長按二維碼關注我們