結論先講
Bcrypt 是故意設計成慢的。 這是一個 feature,不是 bug。它慢到每秒只能算 10-25 次 hash(rounds=10 的情況),這讓暴力破解不可行。但同樣的「慢」,在壓測場景下就變成了天花板——你的 API 不管多快,只要有一步需要 bcrypt,那一步就會拖住所有東西。
這一步怎麼來的:壓測平台蓋好了,9 個框架跑下去,結果全部卡在同一個地方。本來想看「哪個框架最快」,結果先看到的是「為什麼全部都這麼慢」。
先搞懂 Bcrypt 為什麼要慢
密碼 Hash 的目的
密碼 hash 不是為了加密(加密可以解密),是為了讓拿到資料庫的人也破解不了密碼。
如果 hash 很快(像 MD5,每秒幾十億次),攻擊者拿到 hash 後用暴力破解幾秒就搞定。所以密碼 hash 必須「刻意慢」——讓每次計算都要花 80-150ms,攻擊者要破解一個 8 位密碼就需要幾百年。
Rounds 的意義
Bcrypt 的 rounds(也叫 cost factor)決定了「要算幾次」。每加 1,計算時間翻倍:
| Rounds | 大約時間 | 用途 |
|---|---|---|
| 4 | ~5ms | 測試環境 |
| 8 | ~40ms | 低安全需求 |
| 10 | ~80-120ms | 業界標準(我們壓測用的) |
| 12 | ~300ms | 高安全需求 |
| 14 | ~1.2s | 金融等級 |
rounds=10 是 OWASP 建議的最低值。大部分框架的預設值就是 10。
壓測中的數學
理論上限
在 2 核 CPU 上,一個 bcrypt rounds=10 大約 100ms:
理論最大 RPS = CPU 核數 × (1000ms / hash_time)
= 2 × (1000 / 100)
= 20 RPS
但這只是「只做 hash,不做任何其他事」的理論值。加上 DB 查詢、JSON 序列化、網路傳輸,實際更低。
和壓測數據對照
| 框架 | 10 VU RPS | 理論上限 | 差異 |
|---|---|---|---|
| Go | 22 | ~25 | 接近 |
| Express-TS | 24 | ~25 | 接近 |
| FastAPI | 24 | ~20 | 超過(async 有幫助) |
| Spring Boot | 15 | ~20 | 低於(JVM overhead) |
| Django | 17 | ~20 | 接近 |
| .NET Core | 17 | ~13 | 接近(C# bcrypt 更慢) |
大部分框架的 10 VU RPS 都在 15-25 之間。不管框架自身有多快,bcrypt 把所有人拉到同一條起跑線。
為什麼 Bcrypt 是 CPU-bound
不是 I/O,是計算
大部分 Web 操作是 I/O-bound:等 DB 回應、等外部 API、等網路傳輸。這些等待時間可以用 async/await 或 goroutine 來「並行」——你等 DB 的時候可以處理別的 request。
但 bcrypt 是 CPU-bound:它需要 CPU 一直在算。不管你用 async 還是 goroutine,CPU 就那兩顆,同時只能算兩個 hash。
I/O-bound 操作:
Request 1: ──[CPU]──[等DB]──[CPU]──
Request 2: ──[CPU]──[等DB]──[CPU]──
→ 可以交錯,因為等 DB 時 CPU 閒著
CPU-bound 操作 (bcrypt):
Request 1: ──[CPU CPU CPU CPU CPU]──
Request 2: ──[CPU CPU CPU CPU CPU]──
→ 不能交錯,CPU 一直在忙
Node.js 的特別痛苦
Node.js 是 single-thread。一個 bcrypt hash 佔著 event loop 80ms,這 80ms 內所有其他 request 都在排隊——不只是 register/login,連 GET /posts 也被卡住。
這就是為什麼 Express-TS 在 50 VU 時 RPS 突然從 24 跳到 58(PM2 cluster 4 instances 終於發揮作用),但到 100 VU 又掉回 52——4 個 instance 的 CPU 都被 bcrypt 佔滿了。
Python 的 GIL 加倍痛苦
Python 的 GIL(Global Interpreter Lock)讓 multi-thread 的 CPU-bound 操作沒有加速效果。即使開了 4 個 uvicorn worker,每個 worker 的 bcrypt 還是在搶同一顆 CPU。
這解釋了為什麼 Django 在 CRUD 場景排最後——GIL + bcrypt 是雙重天花板。
Go 的相對優勢
Go 的 goroutine 在 CPU-bound 操作上不會神奇地變快——CPU 就那兩顆。但 Go 的 runtime scheduler 更有效率地利用 CPU 時間,加上 Go 的 bcrypt 實作(golang.org/x/crypto/bcrypt)是 native 的,沒有 VM/interpreter overhead。
這是 Go 在 CRUD 場景排第一的主要原因:不是 Go 讓 bcrypt 變快了,而是 Go 的 overhead 最小。
「Bcrypt 瓶頸」在 Grafana 上長什麼樣
Response Time 圖
你會看到一條很明顯的「膝蓋」——VU 從 10 到 50 時 response time 線性上升,到 100 VU 突然急劇上升。這個膝蓋就是 CPU 被佔滿的那一刻。
RPS 圖
RPS 在低 VU 時跟著 VU 上升,到某個點之後「平頂」——不管再加多少 VU,RPS 都不會增加。這個平頂就是 bcrypt 的 RPS 天花板。
擴展效率圖
RPS/VU 從一開始就在下降(不像 I/O-bound 操作會先維持平穩再下降)。這是 CPU-bound 的典型特徵——每加一個 VU 都在搶有限的 CPU。
在 CRUD 場景的影響
回顧一下 CRUD 測試流程:Register → Login → Get Profile → Update → Delete
其中 Register 和 Login 都需要 bcrypt:
- Register:
bcrypt.hash(password, 10)— 生成 hash - Login:
bcrypt.compare(password, hash)— 驗證密碼
5 步操作裡有 2 步需要 bcrypt。等於 40% 的操作是 CPU-bound。這就是為什麼 CRUD 場景所有框架的天花板都這麼低。
在混合場景(70% Read + 20% Write + 10% Auth)裡,bcrypt 只影響 10% 的操作。天花板被大幅提高——Spring Boot 從健康 VU 50 跳到 1,000。
同一個框架,減少 bcrypt 的比例,效能直接翻 20 倍。 這不是框架優化,是數學。
怎麼解決
不是「換一個框架」,而是「減少 bcrypt 被呼叫的次數」或「讓它不阻塞主執行緒」。
1. 加 Redis Session Cache
用戶登入一次(觸發 bcrypt),拿到 JWT 或 session。之後的請求帶 JWT/session,不再需要 bcrypt。
沒 cache: 每次請求都驗密碼 → 每次都 bcrypt
有 cache: 登入一次 → 之後帶 token → 只驗 JWT(< 1ms)
2. 用 async bcrypt(Node.js)
Node.js 有兩種 bcrypt:
bcryptjs:純 JavaScript 實作,在 event loop 上執行 → 阻塞bcrypt:C++ native binding,用 libuv thread pool → 不阻塞 event loop
用 native bcrypt 不會讓 hash 變快,但它不會阻塞其他非 bcrypt 的請求。
3. 降低 Rounds
如果你的威脅模型允許(例如內部系統),rounds 從 10 降到 8,hash 時間從 100ms 降到 25ms。RPS 天花板直接 4 倍。
4. 考慮 Argon2
Argon2 是 2015 年 Password Hashing Competition 的冠軍。它不只用 CPU,還用記憶體——讓 GPU 暴力破解也很慢。下一篇會詳細比較各語言的 bcrypt 實作差異,第 27 篇會比較 bcrypt vs argon2 vs scrypt。
下一篇
各語言 bcrypt 實作差異 — 同樣是 bcrypt rounds=10,為什麼 C# 的 BCrypt.Net 比 Go 的 x/crypto 慢快一倍?語言 runtime、native binding、memory allocation 都有影響。
本系列文章
完整 68 篇目錄見 系列首頁
← 上一篇:用 Grafana 讀懂壓測數據:哪些數字要看、哪些可以忽略 → 下一篇:各語言 bcrypt 實作差異:同一個算法,效能差一倍