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