对 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

Mac 上 Skill CLI 无法执行的坑:最后其实一条命令就够了

我在做 Amazon skills 的过程中,逐步把本地 CLI 从 Python 脚本切到 Go 二进制。这样做的好处很明显:用户不用装 Python、不用配依赖,解压 skill 后直接运行。但在 macOS 上,我们反复遇到一个看起来很玄的问题:同一个二进制,在 Linux/Windows 上正常,在 Mac 上就是执行不了。 当时遇到的现象 常见报错大概有几类: * 双击或 agent 调用 CLI 时,系统提示文件来自未知开发者,无法打开。 * 终端里执行时提示 Permission denied。 * 已经 chmod +x 了,仍然被 macOS 拦截。 * Apple

By ladydd

当我把全世界人群的基因 PCA 跑出来后,看见了一个倒 L 型

最近我把之前学的一些分子人类学知识,终于真正落地了。 不是停留在看论文、看别人画图、看别人解释“人群结构”这些概念,而是自己把数据处理完,自己跑 PCA,自己把全世界不同人群放到一张图上。 然后那一刻,我真的被击中了。 图上出现了一个非常漂亮的倒 L 型。 一端是非洲,另一端逐渐拉向东亚,中间有中东、欧洲、南亚、欧亚大陆上的各种过渡人群。它不是那种随机散点图,而是有方向、有骨架、有历史感的结构。 我第一眼看到的时候,脑子里直接冒出一句话: 这不像是一张普通统计图,这像是人类迁徙史在二维空间里留下的影子。 当然,后来我也提醒自己,PCA 不能被过度浪漫化。它不是地图,也不是时间轴,更不是“谁从哪里走到哪里”的直接证据。PCA 本质上是把高维基因差异压缩到几个主成分上,用最大方差方向把样本摊开。它可以帮助我们观察人群结构、相似性、分化和混合,但不能单独承担全部历史解释。PCA 在群体遗传学里常用于观察 population structure

By ladydd

吞吐与延迟:一个厨房比喻讲透性能压测

写于 2026-06-26。背景: MCP 服务 跑在 3 台 ClickHouse(每台 16 核 / 64G,1 分片 3 副本)上。 我们花了一整轮做公网压测,把这套系统的极限、天花板和杠杆全摸清了。这篇把"吞吐 / 延迟 / 排队"这三个最容易混的概念讲透,配我们自己的真实实测数据。 一句话结论 我们这套系统的吞吐天花板 ≈ 76 req/s。 往里塞再多并发(100、200、300、500),每秒"做完"的还是大约 76 个,多出来的全在排队。系统不会崩,只会让每个人等得更久。 天花板能不能抬?

By ladydd

四卡 3090 本地模型部署复盘:Ollama 跑通 35B,以及 GPU0 掉卡问题

这次做的是一轮真实的本地模型部署摸底。 目标不是搭一个临时 Demo,而是把一台四卡 3090 GPU 机器接进自己的日常 AI 使用环境:本机跑 Open WebUI,负责账号、会话和前端配置;GPU 机器只负责模型推理。这样以后换模型、换推理框架、重启服务,都尽量不影响本机的使用入口。 最后结论比较清楚:qwen3.5:35b 的 GGUF Q4_K_M 量化版已经通过 Ollama 跑通,本机 Open WebUI 可以接入,热加载后的聊天速度也能用;但 GPU0 存在明显稳定性问题,重启后能短暂恢复,跑过负载后又会掉到 NVML 异常状态。 状态快照 当前能用的部分: * 本机 Open WebUI 已部署,

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