对 Python 应用场景的一次重新思考:FastAPI、协程、线程、数据库与任务系统边界
最近在重新设计一个任务系统时,我顺便把自己对 Python,尤其是 CPython 应用场景的理解重新梳理了一遍。
这次讨论的背景是一个典型的异步任务服务:
上游提交任务
API 立即返回 task_id
后台 worker 慢慢执行
用户通过 task_id 查询任务状态
任务主要是 LLM 调用、图片下载、外部 HTTP 请求这类 I/O 型工作。
一开始关注的是队列、Redis、PostgreSQL、worker 并发控制这些问题。但聊到后面,其实更核心的问题变成了:
Python 到底应该放在什么位置?
哪些并发适合 Python?
哪些并发不要硬塞给 Python?
FastAPI、协程、线程、数据库之间应该怎么分工?
这篇文章就是这次思考的整理。
一、我不想抛弃 Python,但也不想陷入 Python 多进程
Python 的开发效率很高,尤其是在业务编排、API、数据处理、调用外部服务这些场景里,仍然非常舒服。
我并不想简单地说:
以后全部换 Go
这不现实,也没必要。
但我也越来越清楚地意识到,在 CPython 里,如果一开始就把系统设计成多进程、多 worker、多层并发叠加,很容易让心智负担变重。
比如:
uvicorn --workers 4
每个 worker 内部又有 asyncio.Semaphore
每个进程还有自己的内存队列
后台又 create_task
这种设计一旦上了多进程,就会出现一个很麻烦的问题:
你以为自己限制了并发,其实只限制了某一个进程里的并发。
比如每个进程限制 20:
4 个进程 × 每个 20 = 实际 80
这不是 Python 独有的问题,但在 FastAPI + CPython 的场景里很容易发生。
所以我现在更倾向于一个收紧后的原则:
只要还在 CPython 里:
API 层用 FastAPI
并发尽量在单进程里处理
具体选择协程还是线程
不要优先碰 Python 多进程
二、FastAPI 适合做什么?
FastAPI 非常适合作为 API 层。
它适合做:
参数校验
权限校验
请求路由
写数据库
查数据库
调用轻量外部接口
返回响应
也就是说,FastAPI 最适合承担:
请求入口层
它不应该承担太多重任务。
尤其是下面这种任务,不应该直接压在 API 进程里:
几十秒的 LLM 调用
长时间图片下载
视频生成
复杂后台处理
需要失败恢复的任务
对于这类任务,更合理的方式是:
POST /tasks 只负责创建任务
后台 worker 负责执行任务
GET /tasks/{id} 负责查询任务状态
API 层应该尽量短、快、无状态。
这也是我现在对 FastAPI 的定位:
FastAPI 负责接单,不负责干重活。
三、请求内并发:适合用协程
如果一个接口本身需要同时调用多个外部服务,比如:
GET /analyze
↓
同时调用 model A
同时调用 model B
同时调用 model C
↓
聚合结果返回
这种就是典型的请求内 I/O 并发。
它非常适合用:
asyncio
asyncio.gather
httpx.AsyncClient
asyncpg
例如:
results = await asyncio.gather(
call_model_a(),
call_model_b(),
call_model_c(),
)
这类场景里,Python 协程是舒服的。
因为任务的大部分时间不是在占用 CPU,而是在等网络、等数据库、等外部 API 返回。
这正是 asyncio 擅长的场景。
四、长任务不要直接塞进 API 进程
另一类场景是后台任务。
比如:
POST /tasks
↓
立即返回 task_id
↓
后台几十秒后完成
这种任务就不适合在 FastAPI 里直接:
asyncio.create_task(process_task(...))
这么写看起来简单,但问题很多:
任务藏在 API 进程内存里
进程挂了任务可能丢
多进程后并发倍增
任务状态和执行状态容易分裂
无法优雅做超时回收
所以我的原则是:
长任务不在 API 进程里直接执行。
更稳的结构是:
FastAPI:
创建任务,写入 PostgreSQL / Redis
Worker:
独立单进程,消费任务,执行任务
PostgreSQL / Redis:
保存任务状态,控制全局并发,支持恢复
也就是说,即使坚持 Python 单进程,也应该让 API 和 worker 分层,而不是全塞进同一个 FastAPI 进程。
五、Python worker:单进程优先
对于 worker,我现在的倾向是:
Python worker 保持单进程
然后在单进程内部选择:
协程
或者:
线程
不要一上来就用 Python 多进程。
一个比较清晰的 worker 模型是:
1 个 Python worker 进程
↓
1 个 asyncio event loop
↓
N 个 coroutine worker
↓
每个 coroutine 循环领取任务、执行任务、写结果
比如:
WORKER_CONCURRENCY=5
表示当前这个 Python worker 进程内部最多同时跑 5 个任务。
如果需要更高吞吐,可以启动多个容器,但不要在一个 Python 容器里再搞多进程套多协程。
更推荐保持结构简单:
一个 worker 容器 = 一个 Python 进程
一个 Python 进程 = N 个协程
全局并发则不要交给 Python 进程内机制,而交给 PostgreSQL 或 Redis。
六、协程和线程怎么选?
这是这次讨论里非常重要的结论。
我现在会用一个简单规则判断:
能 await 的,用协程。
不能 await 但会阻塞的,用线程。
CPU 重计算,不适合 CPython 单进程并发。
1. 能 await 的,用协程
适合:
HTTP 请求
LLM API 调用
图片下载
文件上传下载
数据库异步驱动
等待外部服务返回
常用技术栈:
httpx.AsyncClient
asyncpg
aiofiles
asyncio.gather
asyncio.Semaphore
asyncio.Queue
这类任务大部分时间在等待 I/O,不需要一直占用 CPU。
所以协程非常合适。
2. 同步阻塞库,用线程兜底
有些库没有 async 版本,比如:
requests
某些云厂商 SDK
某些同步 LLM SDK
阻塞式文件操作
轻量图片处理
如果这些函数会阻塞 event loop,就应该放到线程里:
result = await asyncio.to_thread(sync_func, arg1, arg2)
或者:
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(executor, sync_func)
线程在这里的作用不是让 Python 做 CPU 并行,而是:
别让同步阻塞函数卡住整个 event loop。
这是线程在 Python async 系统里的一个很实用的位置。
3. CPU 密集型任务,不要硬塞进 CPython 单进程
如果是:
大量图像计算
视频编码
本地模型推理
复杂压缩/解压
大规模 CPU 计算
那就不要指望:
FastAPI 单进程 + 线程
能优雅解决。
CPython 有 GIL,线程很难真正发挥多核 CPU 的并行能力。
这类任务应该考虑:
拆成独立服务
用 Go / Rust 重写
调用外部命令
交给 GPU 服务
单独拆 CPU worker
核心原则是:
不要让 CPython 承担它不擅长的位置。
七、爬虫:非常适合 Python 单进程多协程
爬虫是一个很典型的例子。
很多爬虫任务本质上是 I/O 密集型:
请求网页 -> 等网络
下载图片 -> 等网络
调用代理 -> 等网络
写数据库 -> 等 I/O
这些等待时间很长,而 CPU 实际工作时间不一定多。
所以 Python 单进程多协程非常适合爬虫。
一个典型结构是:
1 个 Python 进程
1 个 event loop
N 个抓取协程
每个协程负责领取 URL、请求、解析、写回结果
例如:
async def crawl_worker():
while True:
url = await get_next_url()
html = await fetch(url)
await save_result(url, html)
如果使用:
httpx.AsyncClient
aiohttp
asyncpg
asyncio.Semaphore
就可以在一个进程里同时处理大量等待网络返回的任务。
比如:
全局抓取并发 100
单域名并发 5
请求超时 10 秒
失败最多重试 3 次
结果写 PostgreSQL
这种模型对 Python 来说非常自然。
但这里也有边界。
如果爬虫后面有大量 CPU 工作,比如:
复杂文本抽取
大量图片处理
PDF 解析
OCR
视频处理
本地模型推理
那它就不再是单纯的 I/O 密集型任务了。
更合理的方式是拆开:
async 爬虫负责下载
CPU worker 负责解析
数据库 / 队列负责连接两段流程
不要把下载、解析、计算、存储全部混在一个 Python event loop 里硬扛。
八、单进程多协程不等于完整任务系统
Python 单进程多协程解决的是:
怎么同时等待很多 I/O。
但它不解决:
任务状态怎么管理?
任务失败怎么重试?
哪些任务已经处理过?
哪些任务正在处理?
进程挂了怎么恢复?
系统级并发怎么控制?
这些问题不能只靠 asyncio。
比如爬虫场景里,真正做稳以后会遇到:
哪些 URL 已经抓过?
哪些 URL 正在抓?
哪些 URL 抓取失败?
失败后要不要重试?
同一个域名最多同时抓几个?
全局最多同时抓几个?
抓到的数据保存在哪里?
进程挂了以后怎么恢复?
这些问题不适合只放在 Python 内存里。
更成熟的做法是引入 PostgreSQL 或 Redis 这类外部状态组件。
PostgreSQL 可以负责:
url 队列表
任务状态
去重
失败次数
抓取结果
下次重试时间
全局并发协调
Redis 可以负责:
高速队列
短期去重
限速计数器
分布式锁
域名级并发控制
这样 Python 协程就不用承担所有职责。
Python 只负责:
领取任务
发请求
解析结果
写回状态
数据库或 Redis 负责:
任务事实来源
状态持久化
并发协调
失败恢复
这点非常重要。
成熟的 Python 异步架构不是单纯靠 asyncio,而是:
asyncio + 外部状态管理
一句话概括就是:
协程负责并发执行,数据库负责秩序。
九、适当引入数据库,反而能让 Python 更简单
以前可能会觉得,引入 PostgreSQL / Redis 是增加复杂度。
但在任务系统里,适当引入数据库,反而是在降低 Python 代码的复杂度。
因为很多东西如果不用数据库托管,就会落到 Python 进程内存里:
内存队列
内存 set 去重
内存 semaphore
内存 retry 记录
内存 running 状态
这在单进程短任务里可以,但一旦服务重启、任务失败、worker 扩容,就很容易乱。
如果把这些状态交给数据库,Python 代码反而更专注:
我只负责从数据库领取一个任务
执行它
把结果写回去
比如 PostgreSQL 任务表可以设计成:
tasks
- task_id
- status
- request
- result
- error
- attempts
- locked_until
- created_at
- updated_at
worker 领取时用:
FOR UPDATE SKIP LOCKED
locked_until
attempts
Redis 方案也可以类似:
Redis Stream 负责任务队列
Redis Hash 负责任务状态
Redis ZSET 负责 running 任务
Lua 负责原子并发控制
XAUTOCLAIM 负责崩溃恢复
这些组件不是为了炫技,而是为了让 Python 不必自己承担“系统秩序”。
这就是我现在很认同的一个方向:
Python 适合做执行者和编排者。
数据库适合做状态中心和秩序中心。
十、本进程并发和全局并发要分清
这是最容易混的地方。
Python 里的:
asyncio.Semaphore
asyncio.Queue
threading.Lock
concurrent.futures.ThreadPoolExecutor
这些都是本进程内的工具。
它们控制的是:
当前 Python 进程里发生什么。
它们控制不了:
整个系统里所有 worker 一共跑了多少任务。
所以如果系统中有多个 worker 容器,或者未来可能扩展到多个实例,就必须把全局并发控制放到外部组件里。
比如 PostgreSQL:
tasks 表
FOR UPDATE SKIP LOCKED
pg_advisory_xact_lock
locked_until
attempts
或者 Redis:
Redis Stream
Redis Hash
Redis ZSET
Lua
XAUTOCLAIM
一句话:
Python 进程内工具负责局部并发。
PostgreSQL / Redis 负责全局并发。
这两个层次一定不能混。
十一、我现在对 Python 的定位
经过这次思考,我对 Python 的定位反而更清楚了。
Python 适合:
业务编排
API 服务
I/O 型任务
LLM 调用
外部 HTTP 集成
爬虫
数据清洗
胶水层
快速验证
Python 不适合:
复杂多进程调度
极致低内存 worker
CPU 密集型并行
高性能网关
大规模连接管理
这不是说 Python 差,而是说它应该放在合适的位置。
Python 的强项是:
表达力
开发效率
生态
业务组合能力
Go 的强项是:
低内存
高并发
单二进制部署
goroutine
服务端长期运行
所以更合理的路线不是二选一,而是:
Python 负责业务表达。
Go 负责更底层、更高性能、更轻量的执行层。
十二、我的 CPython 默认策略
以后只要是 CPython 项目,我会默认采用这个策略:
FastAPI:
单进程优先
async endpoint
做 API 层,不做重任务
请求内 I/O 并发:
asyncio.gather
httpx.AsyncClient
asyncpg
同步阻塞库:
asyncio.to_thread
ThreadPoolExecutor
长任务:
不在 API 里直接 create_task
交给独立 worker
Python worker:
单进程
内部 asyncio 并发
任务状态:
放 PostgreSQL / Redis
不放 Python 内存
全局并发:
不靠 Python 进程内机制
靠 PostgreSQL / Redis
爬虫 / LLM / 外部 API:
非常适合单进程多协程
CPU 密集任务:
拆服务
Go / Rust / 外部命令 / GPU 服务
这个策略可以让我继续使用 Python,但不把 Python 推到它不舒服的位置。
十三、最终理解
这次思考之后,我觉得最重要的不是“Python 行不行”,而是:
Python 应该放在哪一层?
如果把 Python 放在:
API
业务编排
I/O 调用
爬虫下载
LLM 请求
胶水层
它依然非常强。
如果把 Python 放在:
复杂多进程任务调度
极致低内存 worker
CPU 并行计算
它就会开始别扭。
所以我现在不会简单地说“抛弃 Python”,而是会更精确地使用它。
对于 CPython,我的最终原则是:
FastAPI 做 API。
单进程优先。
I/O 并发用协程。
同步阻塞用线程。
长任务交给独立 worker。
任务状态交给 PostgreSQL / Redis。
全局并发交给 PostgreSQL / Redis。
CPU 密集任务不要硬塞给 Python。
这套边界清楚以后,Python 仍然是一个非常好用的生产力工具。
只是不能再用它硬扛所有问题。
真正成熟的理解不是“Python 能不能并发”,而是:
Python 协程负责并发执行。
数据库负责状态和秩序。
线程负责兜底阻塞库。
CPU 密集任务交给更合适的执行层。
这才是我现在对 Python 应用场景最清晰的认识。
陕公网安备61011302002223号