一般性原則
性能優化的層次
一般性方法
緩存
并發
惰性
批量,合并
更高效的實作
縮小解空間
性能優化與代碼品質
總結
作為一個程式員,性能優化是常有的事情,不管是桌面應用還是web應用,不管是前端還是後端,不管是單點應用還是分布式系統。本文從以下幾個方面來思考這個問題:性能優化的一般性原則,性能優化的層次,性能優化的通用方法。而且不限于任何語言、架構,不過可能會用Python語言來舉例。
依據資料而不是憑空猜測
這是性能優化的第一原則,當我們懷疑性能有問題的時候,應該通過測試、日志、profillig來分析出哪裡有問題,有的放矢,而不是憑感覺、撞運氣。
一個系統有了性能問題,瓶頸有可能是CPU,有可能是記憶體,有可能是IO(磁盤IO,網絡IO),大方向的定位可以使用top以及stat系列來定位(vmstat,iostat,netstat…),針對單個程序,可以使用pidstat來分析。
在本文中,主要讨論的是CPU相關的性能問題。按照80/20定律,絕大多數的時間都耗費在少量的代碼片段裡面,找出這些代碼唯一可靠的辦法就是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,固定的輸入有固定的輸出,緩存是不會失效的。但如果計算受到中間狀态、環境變量的影響,那麼緩存的結果就可能失效。
一個人幹不完的活,那就找兩個人幹。并發既增加了系統的吞吐,又減少了使用者的平均等待時間。
這裡的并發是指廣義的并發,粒度包括多機器(叢集)、多程序、多線程。
對于無狀态(狀态是指需要維護的上下文環境,使用者請求依賴于這些上下文環境)的服務,采用叢集就能很好的伸縮,增加系統的吞吐,比如挂載nginx之後的web server。
對于有狀态的服務,也有兩種形式,每個節點提供同樣的資料,如mysql的讀寫分離;每個節點隻提供部分資料,如mongodb中的sharding。
分布式存儲系統中,partition(sharding)和replication(backup)都有助于并發。
絕大多數web server,要麼使用多程序,要麼使用多線程來處理使用者的請求,以充分利用多核CPU,再有IO阻塞的地方,也是适合使用多線程的。比較新的協程(Python greenle、goroutine)也是一種并發。
将計算推遲到必需的時刻,這樣很可能避免了多餘的計算,甚至根本不用計算。
在有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》。
衡量代碼品質的标準是可讀性、可維護性、可擴充性,但性能優化有可能會違背這些特性,比如為了屏蔽實作細節與使用方式,可能會加入接口層(虛拟層),這樣可讀性、可維護性、可擴充性會好很多,但是額外增加了一層函數調用,如果被頻繁調用,也是一筆開銷。
這種有損代碼品質的優化,應該放到最後,不得已而為之,同時寫清楚注釋與文檔。
為了追求可擴充性,經常會引入一些設計模式,如狀态模式、政策模式、模闆方法、裝飾器模式等,但這些模式不一定是性能友好的。
為了性能,可能需要寫出一些反模式的、定制化的、不那麼優雅的代碼,這些代碼其實是脆弱的,需求的一點點變動,對代碼邏輯可能有至關重要的影響,是以還是回到前面所說,不要過早優化,不要過度優化。