对 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 应用场景最清晰的认识。

Read more

传统 SaaS 转向 AI 时代,我目前的一点理解:先把数据能力变成 Agent 可调用的基础设施

最近我一直在思考一个问题:传统 SaaS 到底应该怎么转向 AI? 一开始很容易想到的方向是:给原来的系统加一个 AI 助手。 比如在页面右下角放一个聊天框,让用户可以问数据、生成报告、总结内容、解释指标。这个当然有价值,但我现在越来越觉得,这只是比较表层的一种转型。 真正的变化,可能不是“在 SaaS 里面加 AI”,而是 SaaS 本身的能力形态发生变化。 过去的 SaaS,核心是给人使用。 人登录系统,看页面、点按钮、筛选数据、导出报表、判断问题,然后再去做决策。数据库是给 Web 页面供数的,后端 API 是给前端页面服务的,整个产品的中心是“人如何操作软件”。 但 AI 时代,尤其是 Agent 逐渐发展之后,

By ladydd

Go 和 Python 的并发模型对比:进程、线程、协程、并发和并行到底怎么理解?

最近我在写 worker 任务系统的时候,重新理解了一遍 Python 和 Go 的并发差异。 以前写 Python,多 worker 经常要考虑: 多进程怎么管理? 日志会不会串? 一个 worker 崩了怎么办? 怎么吃满多核心? 后来换成 Go,发现一个进程里开多个 goroutine worker 就很自然: go worker(1) go worker(2) go worker(3) go worker(4) 日志也好管,状态也好管,而且单进程还能利用多个 CPU 核心。 一开始很容易误会成: Python 不行,Go 行 但更准确的理解应该是: Python 和

By ladydd

Python 进程和 Go 进程的区别:为什么 Go 单进程多 worker 用起来更爽?

最近我在做 worker 任务系统的时候,突然意识到一个很关键的问题: 以前写 Python,多 worker 的时候经常要小心日志串、文件切割乱、时间不好管理。 但是换成 Go 以后,一个进程里开多个 goroutine worker,反而可以比较自然地写到同一个日志文件里。 一开始我以为这是“Python 和 Go 写日志能力不一样”,后来想明白了,核心不是日志本身,而是: Python 常见 worker 模型:多进程 Go 常见 worker 模型:单进程 + 多 goroutine 这背后其实是两个语言在并发模型上的巨大差异。 一、进程、线程、goroutine 先分清楚 先把几个概念捋一下。 进程:操作系统分配资源的单位 线程:CPU 调度执行的基本单位

By ladydd

从 Redis + Channel 到 Redis Stream:一次 Go 任务队列方案的重新理解

在讨论 PostgreSQL queue 方案之后,我又回头看了一下之前自己设想过的 Go + Redis 任务架构。 最早我脑子里的方案大概是: POST 创建任务 Redis 存状态 Go goroutine 直接处理 进程内 channel 控制并发 这个方案很直观,也很符合 Go 的写法。 用户请求进来后,API 生成一个 task_id,把任务状态写进 Redis,然后把任务塞进 Go 的 channel,由后台 goroutine 消费执行。并发控制就靠一个固定大小的 channel 或 worker pool,比如最多 20 个 goroutine 同时执行任务。 这个设计在单进程里确实很舒服。 但当我开始思考多进程、多容器、

By ladydd
陕公网安备61011302002223号 | 陕ICP备2025083092号