在昨天的研究中,發(fā)現(xiàn)BCrypt最大的隱患在于性能。BCrypt的安全性是通過犧牲性能來獲取的。BCrypt比傳統(tǒng)hash+salt要安全一萬倍,但是代價是使用BCrypt做認證對比密碼和密文時候,性能也比hash+salt要慢一萬倍。
所以,我得出一個結論。如果使用傳統(tǒng)hash+salt需要N臺認證服務器的話,那用BCrypt就需要添加10000*N臺服務器才能達到同樣的性能。比如一個郵件系統(tǒng)使用了2臺服務器來專門作認證,那使用BCrypt的話就需要再購買2萬臺。當然,小的應用,如果使用一臺服務器1%的性能就可以做完認證的話,使用BCrypt只需要100臺服務器。
這個聽起來比較嚇人,但是我通過另外一種計算方法,也能得出同樣的結果。比如說163的郵件系統(tǒng),峰值的時候每秒鐘可能需要處理10000個登陸請求。比如每天早上9點到9點半之間,很多人都會登陸郵件系統(tǒng)。按照BCrypt提到的,每個請求需要花費0.3秒,那么處理10000個請求需要3000秒。這顯然是不能接受的。如果要讓客戶都能在1秒鐘登陸,那就需要添加服務器,把登陸請求均分到各個服務器上。對于單CPU的服務器,就一共需要3000臺。
我把上面的想法和網(wǎng)友討論后,得到了不同的意見。有人覺得0.3秒的處理時間其實不多阿,因為通常一個web請求,內(nèi)存,磁盤數(shù)據(jù)庫轉(zhuǎn)一圈下來,離0.3秒也差不多了吧,所以BCrypt的0.3秒的開銷是一個正常值,不會帶來什么問題。有人提到普通web服務器每秒處理1000個請求是很常見的。如果按照我上面的計算方法,這就表示每個請求在1ms內(nèi)完成了,這聽起來有點不可能嘛。
根據(jù)上面的意見,我又仔細思考了一下。我想借這個話題,先討論一下Web服務器的性能,再看看BCrypt帶來的問題應該如何解決。
注意,下面所敘述的,是根據(jù)我現(xiàn)有知識和經(jīng)驗做的邏輯推理。我沒有寫半行web程序的code來驗證,我也沒有任何大規(guī)模web項目的經(jīng)驗。但是我非常喜歡先做這樣“紙上談兵”的推理,因為這樣可以讓我先仔細想清楚后再行動。
網(wǎng)頁的加載時間,和單個request的處理時間是不一樣的。單個網(wǎng)頁中往往包含多個子元素,比如圖片和AJAX調(diào)用。這些子元素的加載都會在額外的request里面完成。另外網(wǎng)頁加載的時間除了web服務器的運算處理時間外,還有數(shù)據(jù)在網(wǎng)絡上的傳輸時間,腳本運行時間,瀏覽器繪制時間,當然如果是第一次訪問,還包括DNS查詢和TCP握手時間等等,F(xiàn)在主流網(wǎng)站的頁面加載時間都在2秒以內(nèi),每個頁面大概包含10-50個子元素。客戶端腳本和瀏覽器的處理占用了大概一半時間,所以跟服務器相關的時間大概就是1秒。由于部分子元素可能在瀏覽器中有cache,所以這1秒鐘對應的大概就是5-25個請求。這樣算下來每個請求大概在200-40毫秒之間。這里面大概web服務器的運算開銷可能只有25%,那大概就是50ms到10ms之間。注意,50ms-10ms這個估算是指平均時間。
由于網(wǎng)絡傳輸和客戶端導致的開銷不是今天的討論范圍,所以下面就只討論web服務器運算一個request需要怎樣的開銷。處理一個web request,通常牽涉到下面一些工作。輸出流添加,數(shù)據(jù)庫讀寫,文件讀寫,復雜計算。輸出流添加是最常見的,比如服務器會把Response.Write里面的內(nèi)容加入到Response里面。輸出流添加是非常快的。速度和string.format或者StringBuilder.Append應該在一個數(shù)量級?隙ū群撩胍於鄠數(shù)量級。復雜計算是指需要CPU密集超過50ms的運算,比如正則表達式運算和BCrypt操作。數(shù)據(jù)庫讀寫和文件讀寫所需要的時間往往也超過50ms,但是這兩者不一定需要占用web服務器的CPU資源。
由此,在考量web服務器運算一個request所需的開銷的時候,先看這個request是不是需要復雜運算。如果需要,那么這個復雜運算肯定是一個瓶頸。
那么數(shù)據(jù)庫和文件操作是不是瓶頸呢。從處理request所需的時間上來說,這兩者肯定也是瓶頸。因為再快也得先等到這兩者返回。但是和復雜運算相比,復雜運算損害的不單單是request所需的時間,另外還損害了服務器的可伸縮性。
由于數(shù)據(jù)庫和文件操作不占用web服務器的CPU,所以web服務器在等待數(shù)據(jù)庫和文件操作結果的時候,可以把CPU資源用來處理新的request。比如來了100個請求,每個請求都需要2秒鐘的數(shù)據(jù)庫操作,不等于說服務器需要200秒才能處理完所有請求的。因為服務器可以讓100個請求都進來,然后發(fā)送者100個請求的數(shù)據(jù)庫操作,然后讓這100個請求同時等待。2秒鐘以后,這100個數(shù)據(jù)庫操作的結果都返回回來后,服務器可以把這100個請求的結果都返回了。
但如果請求中牽涉到了復雜運算呢,服務器就沒辦法并發(fā)了。如果來了100個請求,每個請求都需要運算2秒鐘,而服務器如果只有一個CPU的話,那就一定需要200秒。
由此可見,請求的平均處理時間和服務器的吞吐量不是簡單的比例關系。這牽涉到這個請求具體是如何處理的。如果服務器的吞吐量是1秒鐘1000個請求,不能簡單地用1000毫秒除以1000,得到每個請求只需要1毫秒來處理。這個運算法則只在沒有任何并發(fā)可能性的情況下才有效。這也在此證明了,合理使用異步調(diào)用,對服務器性能會有多大的提高。
接下來反過來想,對于一個不涉及數(shù)據(jù)庫,不涉及文件,不涉及復雜運算的請求,合理的處理時間應該是多少呢。這個處理時間其實等同于同等復雜程度的函數(shù)調(diào)用時間。換句話說,如果只是計算出一個幾十k的頁面,花費的時間肯定在1ms的數(shù)量級。你肯能覺得這里沒有考慮web服務器的“儀式性”開銷,比如說處理TCP報文,分析HTTP頭等等。其實,主流的web服務器在這方面已經(jīng)足夠優(yōu)化,比如從IIS6開始,對于HTTP協(xié)議的處理都直接在內(nèi)核里面做了,所以就算把這些開銷都算上,也能做到1ms的數(shù)量級。對于計算機來說,1ms其實很長的,1ms表示一秒內(nèi)調(diào)用函數(shù)1000次而已。不信你可以估算一下下面的代碼,循環(huán)了10000次,大概需要多少時間:
StringBuilder dummy = new StringBuilder(); for (int i = 0; i < 10000; i++) { dummy.AppendLine((string.Format("this is {0}", i))); } var len = dummy.Length; |
另外,上訴的討論還沒有考慮一個非常重要的武器:Cache。如果使用cache, 整個頁面可能根本不需要任何運算,都不需要跑一行處理代碼,web服務器就把結果給返回了。如果大量使用cache,別說一千次,一秒鐘處理上十萬次都可以。大多數(shù)的網(wǎng)站已經(jīng)廣泛使用cache了,圖片是cache的,blog文章的內(nèi)容是cache的,甚至評論都是cache的。正因為如此,把包含復雜運算,數(shù)據(jù)庫和文件處理的慢操作跟帶cache的塊操作一平均,結果落到前面估算的50ms-10ms是很自然的事情。
有了上面的分析后,再來看文章前面的兩個問題。第一個問題是,BCrypt的300ms開銷,和數(shù)據(jù)庫,磁盤文件兜一圈下來的開銷也差不多阿,為什么說BCrypt就更嚴重呢。原因是BCrypt的300ms都壓在了CPU上面,而人家的是可以并發(fā)的。當然,動不動就要去兜數(shù)據(jù)庫和磁盤文件的web應用,設計上也有問題的。第二個問題是,我的服務器可以一秒鐘處理1000個請求,按照原來的計算方法,就是1ms處理1個了,這個不可能吧。這個問題就要區(qū)分情況來回答了。首先,1ms處理1個請求是可能的,比如使用了cache,絕對能。其次,如果考慮并發(fā)的話,的確不能簡單做除法。但問題關鍵是,BCrypt是不支持并發(fā)的阿。
所以,BCrypt最大的隱患的確就是在性能上。我所做的下列判斷是正確的:“如果使用傳統(tǒng)hash+salt需要N臺認證服務器的話,那用BCrypt就需要添加10000*N臺服務器才能達到同樣的性能”。
接下來的問題是,BCrypt性能問題有解決辦法么。在前面的邏輯推理中,我想到了一個最常見的辦法:用空間換時間。我可以在內(nèi)存中建立cache,把密碼原文和用BCrypt計算后的hash緩存起來。這樣每次處理認證請求的時候,就不需要計算了。當然,這里其實帶來了新的安全隱患。如果有人盜竊了服務器的內(nèi)存轉(zhuǎn)儲(memory dump), 那么就能看到cache里面的東西,那可就是密碼的原文明文阿。這有何去何從呢?