对 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

三台机器部署 ClickHouse 高可用集群实战记录

本文是一份可发布版部署记录。真实 IP、域名、账号、密码、下载链接、业务目录名、机器唯一标识等敏感信息已经替换为占位符。命令中的 <...> 需要按自己的环境替换。 目标与拓扑 这次目标是用三台数据节点部署一套 ClickHouse 高可用集群,拓扑采用: 1 shard x 3 replicas 含义是:集群只有一个逻辑分片,三台机器都保存同一份数据的完整副本。任意一台数据节点宕机时,只要 ClickHouse Keeper 仍然有多数派,剩余节点仍可继续提供读写服务。 规划节点如下: 主机名示例地址角色ch-01<ch-01-ip>ClickHouse Server + ClickHouse Keeperch-02<ch-02-ip>ClickHouse Server + ClickHouse Keeperch-03<ch-03-ip&

By ladydd

折腾记(二):接入火山引擎实时语音 API,家庭语音助手体验直接拉满

接上篇 上一篇用全开源组件(Whisper + Hermes + Edge-TTS)搭了个语音助手,能跑,但体验就是"能用"二字: * 中文识别只有 70 分,方言基本歇菜 * 英文唤醒词"Alexa"喊着别扭 * 说完到回复要等 4-8 秒 * 它说话的时候你插不了嘴 这些问题靠堆开源组件很难根治。于是我去试了火山引擎(字节跳动)的语音服务,结果直接换了条路。 这篇分两段:先讲怎么用火山引擎的 ASR/TTS 替换掉开源组件(小改),再讲怎么上端到端实时语音模型(大改)。 第一段:先把 ASR 和 TTS 换成火山引擎 为什么换 我用豆包输入法的时候发现它语音识别准得离谱。一查,豆包用的就是字节自家的火山引擎 Seed-ASR。开通后有免费额度(

By ladydd

折腾记(一):用全开源组件给家里搭一个语音助手,对接自己的 Hermes Agent

起因 事情是从一块 ESP32-S3 开发板开始的。 我手上有一块 Seeed Studio XIAO ESP32-S3 Sense,带摄像头和麦克风。最初的想法很美好:用这块板子做一个无线语音终端,对着它说话,连到我服务器上跑的 Hermes Agent(一个自托管的 AI agent),让它回答我。 但折腾到一半我突然意识到一件事:我的麦克风、音响、服务器全在家里,为什么要绕一圈用 ESP32?直接把麦克风和音响插到服务器上不就行了? ESP32 那条路(做无线拾音终端)当然也有价值,但那是"为了学嵌入式而学",不是解决问题的最短路径。于是这个项目就从"嵌入式项目"变成了"在服务器上拼一个语音助手"。这篇就记录后者。 教训零:先想清楚你要解决的是什么问题。很多时候最优解比你最初设想的简单得多。 目标

By ladydd

Kiro 的三种代理设置方法:本地、服务端、Remote

作为kiro的骨灰级用户,这篇是我自己折腾 Kiro / Kiro Remote / Ubuntu Server 代理问题后的复盘。 核心不是“怎么配一个代理”,而是先判断:到底是谁在访问外网? 谁访问外网,代理就要配给谁。 0. 先说结论 Kiro 相关代理大概分三类: 场景真正访问外网的进程在哪里代理应该配在哪里本地 KiroWindows / Mac 本机本机 Clash / Proxifier / 系统代理服务端 Kiro / CLIUbuntu Server 上的 shell、CLI、node、kiro 进程Ubuntu 的环境变量,比如 HTTP_PROXY / HTTPS_PROXYKiro Remote远程 Ubuntu 上的 ~/.kiro-server 和 extensionHost远程 Ubuntu 的 Kiro Server

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