FastAPI 高负载登录如何处理 bcrypt/argon2 阻塞与扩展瓶颈?
可以使用线程池执行 bcrypt/argon2,主要是为了避免事件循环被阻塞。吞吐量仍取决于哈希成本和可用计算资源。通常应先限制哈希并发和请求积压,再根据压测结果配置 worker、CPU 和内存。
增加进程后有效、增加线程后无效,这一现象不足以单独证明瓶颈来自 GIL。缺少实际版本、CPU 配额和性能数据时,也无法断定必须改用进程池。
为什么使用 to_thread 后仍然遇到瓶颈?
问题中的代码:
await asyncio.to_thread(bcrypt.hashpw, password, salt)
这行代码把同步计算交给工作线程,让事件循环可以继续处理其他协程,但不会减少单次哈希的计算量。并发请求超过机器的处理能力后,请求会排队,尾延迟也会升高。
对于 CPU 密集型操作,线程能否利用多个核心,取决于扩展库是否在计算期间释放 GIL。Python 官方文档说明,释放 GIL 的扩展可以通过 asyncio.to_thread() 执行 CPU 密集型任务。Python 文档
不能仅凭扩展使用 C 实现,就推断它能在线程中并行,因为实现语言本身不能保证释放 GIL。另外,当前 pyca/bcrypt 的核心实现使用 Rust,其源码在主要哈希计算期间调用 py.detach(...),允许计算脱离 GIL 执行。实际部署时仍需核对安装版本及调用链。bcrypt 源码
当有效 CPU 资源已经用满时,继续增加线程通常只会加剧调度竞争。Argon2 还可能先受到内存容量或内存带宽限制。
怎样判断该增加线程还是 worker?
先分别测量密码哈希和完整 HTTP 请求:
- 固定密码、哈希参数和依赖版本,测量单次生成和验证的耗时。
- 在相同 CPU 和内存配额下,逐步提高线程并发,记录吞吐量、CPU 使用率和延迟。
- 再比较不同 worker 数量,并保持总哈希并发接近,避免将并发额度增加带来的改善误判为进程本身的优势。
- 最后压测完整登录链路,分别记录数据库查询、等待哈希执行、实际计算和响应生成的耗时。
重点检查以下情况:
- 所有可用核心都已繁忙:考虑增加计算资源、减少无效登录请求,或在安全要求允许的范围内评估哈希参数。
- 只有一个核心繁忙:检查 Python 层处理、锁和扩展库行为,以及压测客户端是否先达到上限。
- 宿主机有空闲但应用吞吐量不变:检查容器 CPU quota、CPU affinity 和 CPU throttling。
- 增加 worker 后改善明显:可能是多个事件循环分担了 Python 层工作,也可能是每个进程各自增加了线程池或连接池容量,需要进一步区分。
部署多个 worker 是正常的选择,但同一台机器的 CPU 已饱和时,增加进程也无法持续提高吞吐量。如果哈希库已经能释放 GIL,也不必仅因操作是 CPU 密集型就改用 ProcessPoolExecutor。
FastAPI 中如何限制哈希并发?
可以继续使用独立的 ThreadPoolExecutor。在 FastAPI 项目中,也可以使用 AnyIO 的 to_thread.run_sync(),配合专用的 CapacityLimiter 为密码计算设置单独的并发预算。
下面只展示密码计算组件,省略数据库查询、限流和完整登录路由:
from contextlib import asynccontextmanager
import anyio
import bcrypt
from fastapi import FastAPI, Request
@asynccontextmanager
async def lifespan(app: FastAPI):
# 示例值,必须根据每个进程的资源预算压测调整。
app.state.password_limiter = anyio.CapacityLimiter(2)
yield
app = FastAPI(lifespan=lifespan)
def hash_password_sync(password: bytes, cost: int) -> bytes:
return bcrypt.hashpw(password, bcrypt.gensalt(rounds=cost))
async def hash_password(
request: Request, password: bytes, cost: int
) -> bytes:
return await anyio.to_thread.run_sync(
hash_password_sync,
password,
cost,
limiter=request.app.state.password_limiter,
)
async def verify_password(
request: Request, password: bytes, stored_hash: bytes
) -> bool:
return await anyio.to_thread.run_sync(
bcrypt.checkpw,
password,
stored_hash,
limiter=request.app.state.password_limiter,
)
使用时要注意:
2是演示并发控制的示例值,不能直接视为生产配置。- limiter 在每个 worker 进程中独立存在,需要跨进程计算总并发。
- 自定义 limiter 提供独立的并发预算,但不保证使用独占的 OS 线程池。
- limiter 只限制正在执行的哈希数量,请求仍可能在等待时不断积压,因此还需要请求准入控制。
- AnyIO 的线程限制和 asyncio 默认 executor 是两套机制,修改其中一个不会自动调整另一个。AnyIO 文档
登录时使用 bcrypt.checkpw(password, stored_hash) 验证已有哈希。注册或修改密码时,才生成随机 salt 并创建新哈希。验证仍需执行昂贵计算,使用 checkpw 并不会让它变成低成本操作。
如何避免高负载把服务拖垮?
应同时控制请求进入速度、正在计算的数量和允许等待的数量。
- 在执行昂贵哈希之前,按账户、来源及全局容量实施限流。
- 设置有限的等待队列或直接拒绝超出容量的请求,避免无限排队。
- 区分限流和服务过载:前者可返回
429,后者可返回503,并根据实际策略决定是否提供Retry-After。 - 多实例部署时,进程内计数只对本进程有效;全局限制需要共享状态或网关协调。
- 同时覆盖登录、注册、修改密码和密码重置等会触发哈希计算的入口。
还要注意取消行为:请求超时或协程取消通常不能终止已经在线程中运行的哈希。如果请求一结束就假定 CPU 已释放,容量统计会偏低,后续请求仍会不断进入。AnyIO 默认在等待工作线程完成期间屏蔽其取消作用域的取消;启用 abandon_on_cancel=True 后,底层线程仍会继续运行。AnyIO 取消说明
独立认证服务和后台 worker 是否有帮助?
独立认证服务适合需要隔离资源、集中实施认证策略,或者让认证容量独立扩展的系统。它可以防止登录计算挤占其他业务资源,但密码计算成本依然存在,还需处理网络调用、超时及服务可用性。
后台任务队列适合处理不影响当前认证结果的后续工作。登录时必须先完成密码验证,再签发会话或令牌。如果请求将验证交给后台 worker 后仍要等待结果,整体容量问题依然存在,还会增加排队和通信成本。
对于常规应用,可以先在 Web worker 内受控地将计算卸载到线程,并在入口限流。只有压测表明资源隔离或独立扩展确有收益时,再拆分认证服务。
改用 Argon2 会更快吗?
Argon2id 可以作为密码存储方案评估,但不能保证比 bcrypt 更快。密码哈希的目标包括提高离线猜测成本,选择算法和设置参数时,需要同时考虑安全要求、延迟、内存和吞吐量。OWASP 推荐优先使用 Argon2id,并将 bcrypt 主要用于现有系统。OWASP 密码存储指南
Argon2 的主要参数包括:
| 参数 | 含义 | 高并发时的影响 |
|---|---|---|
time_cost | 计算迭代成本 | 增大后通常需要更多计算时间 |
memory_cost | 每次计算的内存预算,argon2-cffi 使用 KiB | 决定可同时承载多少次计算 |
parallelism | 单次计算内部的并行度 | 需要与外层请求并发共同评估 |
这些参数的含义见 argon2-cffi API 文档。不要把 parallelism 理解为允许同时登录的用户数。
例如,假设每次 Argon2 计算配置为 64 MiB,同时执行 16 次,仅算法工作内存就约需 1 GiB,还未计入应用、数据库连接及其他开销。多 worker 部署时,还需要累加所有进程的并发预算。
调参时,应在实际部署环境中测量目标并发下的 P95/P99 延迟、CPU、内存和吞吐量。单次计算最快的配置不一定能提供最高的整体吞吐量。argon2-cffi 参数选择指南
如果从 bcrypt 迁移到 Argon2id,可以在用户成功验证旧密码后生成新哈希并更新记录,同时把这次额外计算计入容量预算。不要直接改用 SHA-256 等快速哈希来提高登录吞吐量,它们不适合直接存储密码。OWASP 密码存储指南
备注:内容仅供参考。
