效能最佳化指南:效能最佳化的一般性原則與方法

dbasdk發表於2018-05-30

【本文轉自部落格園 作者:xybaby 原文連結:https://www.cnblogs.com/xybaby/p/9055734.html】
作為一個程式設計師,效能最佳化是常有的事情,不管是桌面應用還是web應用,不管是前端還是後端,不管是單點應用還是分散式系統。本文從以下幾個方面來思考這個問題:效能最佳化的一般性原則,效能最佳化的層次,效能最佳化的通用方法。本文不限於任何語言、框架,不過可能會用Python語言來舉例。

  不過囿於個人經驗,可能更多的是從Linux服務端的角度來思考這些問題。

  一般性原則

  依據資料而不是憑空猜測

  這是效能最佳化的第一原則,當我們懷疑效能有問題的時候,應該透過測試、日誌、profillig來分析出哪裡有問題,有的放矢,而不是憑感覺、撞運氣。一個系統有了效能問題,瓶頸有可能是CPU,有可能是記憶體,有可能是IO(磁碟IO,網路IO),大方向的定位可以使用top以及stat系列來定位(vmstat,iostat,netstat...),針對單個程式,可以使用pidstat來分析。

  在本文中,主要討論的是相關的效能問題。按照80/20定律,絕大多數的時間都耗費在少量的程式碼片段裡面,找出這些程式碼唯一可靠的辦法就是profile,我所知的程式語言,都有相關的profile工具,熟練使用這些profile工具是效能最佳化的第一步。

  忌過早最佳化

  The real problem is that programmers have spent far too much time worrying about efficiency in the wrong places and at the wrong times; premature optimization is the root of all evil (or at least most of it) in programming.

  我並不十分清楚Donald Knuth說出這句名言的上下文環境,但我自己是十分認同這個觀念的。在我的工作環境(以及典型的網際網路應用開發)與程式設計模式下,追求的是快速的迭代與試錯,過早的最佳化往往是無用功。而且,過早的最佳化很容易拍腦袋,最佳化的點往往不是真正的效能瓶頸。

  忌過度最佳化

  As performance is part of the specification of a program – a program that is unusably slow is not fit for purpose

  效能最佳化的目標是追求合適的價效比。

  在不同的階段,我們對系統的效能會有一定的要求,比如吞吐量要達到多少多少。如果達不到這個指標,就需要去最佳化。如果能滿足預期,那麼就無需花費時間精力去最佳化,比如只有幾十個人使用的內部系統,就不用按照十萬線上的目標去最佳化。

  而且,後面也會提到,一些最佳化方法是“有損”的,可能會對程式碼的可讀性、可維護性有副作用。這個時候,就更不能過度最佳化。

  深入理解業務

  程式碼是服務於業務的,也許是服務於終端使用者,也許是服務於其他程式設計師。不瞭解業務,很難理解系統的流程,很難找出系統設計的不足之處。後面還會提及對業務理解的重要性。

  效能最佳化是持久戰

  當核心業務方向明確之後,就應該開始關注效能問題,當專案上線之後,更應該持續的進行效能檢測與最佳化。

  現在的網際網路產品,不再是一錘子買賣,在上線之後還需要持續的開發,使用者的湧入也會帶來效能問題。因此需要自動化的檢測效能問題,保持穩定的測試環境,持續的發現並解決效能問題,而不是被動地等到使用者的投訴。

  選擇合適的衡量指標、測試用例、測試環境

  正因為效能最佳化是一個長期的行為,所以需要固定衡量指標、測試用例、測試環境,這樣才能客觀反映效能的實際情況,也能展現出最佳化的效果。

  衡量效能有很多指標,比如系統響應時間、系統吞吐量、系統併發量。不同的系統核心指標是不一樣的,首先要明確本系統的核心效能訴求,固定測試用例;其次也要兼顧其他指標,不能顧此失彼。

  測試環境也很重要,有一次突然發現我們的QPS高了許多,但是程式壓根兒沒最佳化,查了半天,才發現是換了一個更牛逼的物理機做測試伺服器

  效能最佳化的層次

  按照我的理解可以分為需求階段,設計階段,實現階段;越上層的階段最佳化效果越明顯,同時也更需要對業務、需求的深入理解。

  需求階段

  不戰而屈人之兵,善之善者也

  程式設計師的需求可能來自PM、UI的業務需求(或者說是功能性需求),也可能來自Team Leader的需求。當我們拿到一個需求的時候,首先需要的是思考、討論需求的合理性,而不是立刻去設計、去編碼。

  需求是為了解決某個問題,問題是本質,需求是解決問題的手段。那麼需求是否能否真正的解決問題,程式設計師也得自己去思考,在之前的文章也提到過,產品經理(特別是知道一點技術的產品經理)的某個需求可能只是某個問題的解決方案,他認為這個方法可以解決他的問題,於是把解決方案當成了需求,而不是真正的問題。

  需求討論的前提對業務的深入瞭解,如果不瞭解業務,根本沒法討論。即使需求已經實現了,當我們發現有效能問題的時候,首先也可以從需求出發。

  需求分析對效能最佳化有什麼幫助呢,第一,為了達到同樣的目的,解決同樣問題,也許可以有效能更優(消耗更小)的辦法。這種最佳化是無損的,即不改變需求本質的同時,又能達到效能最佳化的效果;第二種情況,有損的最佳化,即在不明顯影響使用者的體驗,稍微修改需求、放寬條件,就能大大解決效能問題。PM退步一小步,程式前進一大步。

  需求討論也有助於設計時更具擴充套件性,應對未來的需求變化,這裡按下不表。

  設計階段

  高手都是花80%時間思考,20%時間實現;新手寫起程式碼來很快,但後面是無窮無盡的修bug

  設計的概念很寬泛,包括架構設計、技術選型、介面設計等等。架構設計約束了系統的擴充套件、技術選型決定了程式碼實現。程式語言、框架都是工具,不同的系統、業務需要選擇適當的工具集。如果設計的時候做的不夠好,那麼後面就很難最佳化,甚至需要推到重來。

  實現階段

  實現是把功能翻譯成程式碼的過程,這個層面的最佳化,主要是針對一個呼叫流程,一個函式,一段程式碼的最佳化。各種profile工具也主要是在這個階段生效。除了靜態的程式碼的最佳化,還有編譯時最佳化,執行時最佳化。後二者要求就很高了,程式設計師可控性較弱。

  程式碼層面,造成效能瓶頸的原因通常是高頻呼叫的函式、或者單次消耗非常高的函式、或者二者的結合。

  下面介紹針對設計階段與實現階段的最佳化手段。

  一般性方法

  快取

  沒有什麼效能問題是快取解決不了的,如果有,那就再加一級快取

  a cache /k??/ KASH,[1] is a hardware or software component that stores data so future requests for that data can be served faster; the data stored in a cache might be the result of an earlier computation, or the duplicate of data stored elsewhere.

  快取的本質是加速訪問,訪問的資料要麼是其他資料的副本 -- 讓資料離使用者更近;要麼是之前的計算結果 -- 避免重複計算.

  快取需要用空間換時間,在快取空間有限的情況下,需要優秀的置換換算來保證快取有較高的命中率。

  資料的快取

  這是我們最常見的快取形式,將資料快取在離使用者更近的地方。比如作業系統中的CPU cache、disk cache。對於一個web應用,前端會有瀏覽器快取,有CDN,有反向代理提供的靜態內容快取;後端則有本地快取、分散式快取。

  資料的快取,很多時候是設計層面的考慮。

  對於資料快取,需要考慮的是快取一致性問題。對於分散式系統中有強一致性要求的場景,可行的解決辦法有lease,版本號。

  計算結果的快取

  對於消耗較大的計算,可以將計算結果快取起來,下次直接使用。

  我們知道,對遞迴程式碼的一個有效最佳化手段就是快取中間結果,lookup table,避免了重複計算。python中的method cache就是這種思想.

  對於可能重複建立、銷燬,且建立銷燬代價很大的物件,比如程式、執行緒,也可以快取,對應的快取形式如單例、資源池(連線池、執行緒池)。

  對於計算結果的快取,也需要考慮快取失效的情況,對於pure function,固定的輸入有固定的輸出,快取是不會失效的。但如果計算受到中間狀態、環境變數的影響,那麼快取的結果就可能失效,比如我在前面提到的python method cache

  併發

  一個人幹不完的活,那就找兩個人幹。併發既增加了系統的吞吐,又減少了使用者的平均等待時間。

  這裡的併發是指廣義的併發,粒度包括多機器(叢集)、多程式、多執行緒。

  對於無狀態(狀態是指需要維護的上下文環境,使用者請求依賴於這些上下文環境)的服務,採用叢集就能很好的伸縮,增加系統的吞吐,比如掛載nginx之後的web server

  對於有狀態的服務,也有兩種形式,每個節點提供同樣的資料,如mysql的讀寫分離;每個節點只提供部分資料,如mongodb中的sharding

  分散式儲存系統中,partition(sharding)和replication(backup)都有助於併發。

  絕大多數web server,要麼使用多程式,要麼使用多執行緒來處理使用者的請求,以充分利用多核CPU,再有IO阻塞的地方,也是適合使用多執行緒的。比較新的協程(Python greenle、goroutine)也是一種併發。

  惰性

  將計算推遲到必需的時刻,這樣很可能避免了多餘的計算,甚至根本不用計算,這個在之前的《lazy ideas in programming》一文中舉了許多例子。

  CopyOnWrite這個思想真牛逼

  批次,合併

  在有IO(網路IO,磁碟IO)的時候,合併操作、批次操作往往能提升吞吐,提高效能。

  我們最常見的是批次讀:每次讀取資料的時候多讀取一些,以備不時之需。如GFS client會從GFS master多讀取一些chunk資訊;如分散式系統中,如果集中式節點複雜全域性ID生成,俺麼應用就可以一次請求一批id。

  特別是系統中有單點存在的時候,快取和批次本質上來說減少了與單點的互動,是減輕單點壓力的經濟有效的方法

  在前端開發中,經常會有資源的壓縮和合並,也是這種思想。

  當涉及到網路請求的時候,網路傳輸的時間可能遠大於請求的處理時間,因此合併網路請求就很有必要,比如mognodb的bulk operation,redis 的pipeline。寫檔案的時候也可以批次寫,以減少IO開銷,GFS中就是這麼幹的

  更高效的實現

  同一個演算法,肯定會有不同的實現,那麼就會有不同的效能;有的實現可能是時間換空間,有的實現可能是空間換時間,那麼就需要根據自己的實際情況權衡。

  程式設計師都喜歡早輪子,用於練手無可厚非,但在專案中,使用成熟的、經過驗證的輪子往往比自己造的輪子效能更好。當然不管使用別人的輪子,還是自己的工具,當出現效能的問題的時候,要麼最佳化它,要麼替換掉他。

  比如,我們有一個場景,有大量複雜的巢狀物件的序列化、反序列化,開始的時候是使用python(Cpython)自帶的json模組,即使發現有效能問題也沒法最佳化,網上一查,替換成了ujson,效能好了不少。

  上面這個例子是無損的,但一些更高效的實現也可能是有損的,比如對於python,如果發現效能有問題,那麼很可能會考慮C擴充套件,但也會帶來維護性與靈活性的喪失,面臨crash的風險。

  縮小解空間

  縮小解空間的意思是說,在一個更小的資料範圍內進行計算,而不是遍歷全部資料。最常見的就是索引,透過索引,能夠很快定位資料,對資料庫的最佳化絕大多數時候都是對索引的最佳化。

  如果有本地快取,那麼使用索引也會大大加快訪問速度。不過,索引比較適合讀多寫少的情況,畢竟索引的構建也是需有消耗的。

  另外在遊戲服務端,使用的分線和AOI(格子演算法)也都是縮小解空間的方法。

  效能最佳化與程式碼質量

  很多時候,好的程式碼也是高效的程式碼,各種語言都會有一本類似的書《effective xx》。比如對於python,pythonic的程式碼通常效率都不錯,如使用迭代器而不是列表(python2.7 dict的iteritems(), 而不是items())。

  衡量程式碼質量的標準是可讀性、可維護性、可擴充套件性,但效能最佳化有可能會違背這些特性,比如為了遮蔽實現細節與使用方式,我們會可能會加入介面層(虛擬層),這樣可讀性、可維護性、可擴充套件性會好很多,但是額外增加了一層函式呼叫,如果這個地方呼叫頻繁,那麼也是一筆開銷;又如前面提到的C擴充套件,也是會降低可維護性、

  這種有損程式碼質量的最佳化,應該放到最後,不得已而為之,同時寫清楚註釋與文件。

  為了追求可擴充套件性,我們經常會引入一些設計模式,如狀態模式、策略模式、模板方法、裝飾器模式等,但這些模式不一定是效能友好的。所以,為了效能,我們可能寫出一些反模式的、定製化的、不那麼優雅的程式碼,這些程式碼其實是脆弱的,需求的一點點變動,對程式碼邏輯可能有至關重要的影響,所以還是回到前面所說,不要過早最佳化,不要過度最佳化。

  總結

  來張腦圖總結一下

  本文版權歸作者xybaby(博文地址:http://www.cnblogs.com/xybaby/)所有。

來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/29734436/viewspace-2155353/,如需轉載,請註明出處,否則將追究法律責任。

相關文章