常見效能優化策略總結

曉明發表於2016-12-06

常見效能優化策略分類

程式碼

之所以把程式碼放到第一位,是因為這一點最容易引起技術人員的忽視。很多技術人員拿到一個效能優化的需求以後,言必稱快取、非同步、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快取的方法。類似下面的程式碼:
  public String get(key) {
      String value = redis.get(key);
      if (value == null) { //代表快取值過期
          //設定3min的超時,防止del操作失敗的時候,下次快取過期一直不能load db
          if (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 {
              return value;      
          }
  }

非同步

使用場景

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

作用

  • 縮短介面響應時間,使使用者的請求快速返回,使用者體驗更好。
  • 避免執行緒長時間處於執行狀態,這樣會引起服務執行緒池的可用執行緒長時間不夠用,進而引起執行緒池任務佇列長度增大,從而阻塞更多請求任務,使得更多請求得不到技術處理。
  • 執行緒長時間處於執行狀態,可能還會引起系統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是否有問題,以及是否需要調優。

怎麼調?

  1. 如果發現高峰期CPU使用率與Load值偏大,這個時候可以觀察一些JVM的thread count以及gc count(可能主要是young gc count),如果這兩個值都比以往偏大(也可以和一個歷史經驗值作對比),基本上可以定位是young gc頻率過高導致,這個時候可以通過適當增大young區大小或者佔比的方式來解決。
  2. 如果發現關鍵介面響應時間很慢,可以結合gc time以及gc log中的stop the world的時間,看一下整個應用的stop the world的時間是不是比較多。如果是,可能需要減少總的gc time,具體可以從減小gc的次數和減小單次gc的時間這兩個維度來考慮,一般來說,這兩個因素是一對互斥因素,我們需要根據實際的監控資料來調整相應的引數(比如新生代與老生代比值、eden與survivor比值、MTT值、觸發cms回收的old區比率閾值等)來達到一個最優值。
  3. 如果發生full gc或者old cms gc非常頻繁,通常這種情況會誘發STW的時間相應加長,從而也會導致介面響應時間變慢。這種情況,大概率是出現了“記憶體洩露”,Java裡的記憶體洩露指的是一些應該釋放的物件沒有被釋放掉(還有引用拉著它)。那麼這些物件是如何產生的呢?為啥不會釋放呢?對應的程式碼是不是出問題了?問題的關鍵是搞明白這個,找到相應的程式碼,然後對症下藥。所以問題的關鍵是轉化成尋找這些物件。怎麼找?綜合使用jmap和MAT,基本就能定位到具體的程式碼。

多執行緒與分散式

使用場景

離線任務、非同步任務、大資料任務、耗時較長任務的執行**,適當地利用,可達到加速的效果。

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

常見做法

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

對於單機多執行緒,可以引入執行緒池的機制,作用有二:

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

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

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

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

關鍵流程

如果要設計這套系統,總體來說有哪些關鍵流程需要設計呢?
① 確定指標
② 採集資料
③ 計算資料,儲存結果
④ 展現和分析

需要監控和報警哪些指標資料?需要關注哪些?

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

  1. 介面效能相關,包括單個介面和全部的QPS、響應時間、呼叫量(統計時間維度越細越好;最好是,既能以節點為維度,也可以以服務叢集為維度,來檢視相關資料)。其中還涉及到服務依賴關係的管理,這個時候需要用到服務依賴管理系統
  2. 單個機器節點相關,包括CPU使用率、Load值、記憶體佔用率、網路卡流量等。如果節點是一些特殊型別的服務(比如MySQL、Redis、Tair),還可以監控這些服務特有的一些關鍵指標。

資料採集方式

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

資料計算

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

展現和分析

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

真實案例分析

案例一:商家與控制區關係的重新整理job

背景

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

業務需求

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

優化過程

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

  1. 拿到所有門店的配送範圍列表和控制區列表。
  2. 遍歷控制區列表,針對每一個控制區:
    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優化效果圖

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

案例二:POI快取設計與實現

背景

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

實現方案

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

詳細設計

第一版設計

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

第二版設計

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

  1. 某些情況下不能保證資料的實時一致(比如技術人員手動改動DB資料、利用MQ更新快取失敗),這個時候只能等待5分鐘的過期時間,有的業務是不允許的。
  2. 加入了過期時間導致另外一個問題:Tair在快取不命中的那一刻,會嘗試從硬碟中Load資料,如果硬碟沒有再去DB中Load資料。這無疑會進一步延長Tair的響應時間,這樣不僅使得業務的超時比率加大,而且會導致Tair的效能進一步變差。

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

poi快取設計圖

上線後效果

上線後,通過持續地監控資料發現,隨著呼叫量的上升,到DB的流量有了明顯地減少,極大地減輕了DB的壓力。同時這些資料介面的響應時間也有了明顯地減少。快取更新的雙重保障機制,也基本保證了快取資料的可用。見下圖:

poi快取優化效果圖_1

poi快取優化效果圖

案例三:業務運營後臺相關頁面的效能優化

背景

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

歡迎頁

  • 需求背景:歡迎頁是地推人員乃至總部各種角色人員進入外賣運營後臺的首頁,會顯示地推人員最想看到最關心的一些核心資料,其重要性不言而喻,所以該頁面的效能惡化會嚴重影響到使用者體驗。因此,首先需要優化的就是歡迎頁。通過相應定位和分析,發現導致效能惡化的主要原因有兩個:資料介面層和計算展現層。
  • 解決方案:對症下藥,分而治之。經過仔細排查、分析定位,資料介面層採用介面呼叫批量化、非同步RPC呼叫的方式來進行有效優化,計算展現層決定採用預先計算、再把計算好的結果快取的方式來提高查詢速度。其中,快取方案根據業務場景和技術特點,選用Redis。定好方案後,快速開發上線。
  • 上線效果:上線後效能對比圖,如下:
優化效果圖_1

組織架構頁

  • 需求背景:組織架構頁,採用了四層樹形結構圖,一起呈現載入,第一版上線後發現效能非常差。使用者迫切希望對這個頁面的效能進行調優。
  • 解決方案:經過分析程式碼,定位到一個比較經典的問題:裡面執行了太多次小資料量的SQL查詢。於是採用多個SQL合併成大SQL的方式,然後使用本地快取來快取這些資料,合理預估資料量和效能,充分測試後上線。
  • 上線效果:上線後效能對比圖,如下:
優化效果圖_2

訂單關聯樓宇頁

  • 需求背景:隨著訂單量日益增大,訂單表積累的資料日益增多,訂單關聯樓宇頁的效能也日益變差(響應時間線性上升)。而這個頁面和地推人員的業績息息相關,所以地推人員使用該頁面的頻率非常高,效能日益惡化極大地影響了地推人員的使用者體驗。
  • 解決方案:經過分析與設計,決定採用當時已有的訂單二級索引月分表來代替原始的訂單表來供前端的查詢請求;並且限制住篩選的時間條件,使得篩選的開始時間和結束時間不能跨月(事先和使用者溝通過,可以接受,能滿足使用者的基本需求),這樣就只需一個月分索引表即可,通過適當的功能限制來達到效能的調優。這樣從二級索引月分表中根據各種查詢條件查到最終的分頁的訂單ID集合,然後再根據訂單ID從訂單庫來查出相應的訂單資料集合。
  • 上線效果:上線後發現在呼叫量幾乎沒怎麼變的情況下,效能提升明顯,如下圖:
優化效果圖_3

其他

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

相關文章