场景是这样的:FastAPI 项目,APScheduler 挂在应用里跑定时任务。
任务干的活就一件——往数据库写一条记录。本地跑,写一条,正常。
开开心心上线。为了扛并发,启动参数加上 --workers 2。
天塌了。同一秒钟,表里躺了两条。如果是放到真实的场景呢?发邮件、扣款等等。
复现代码
一个简单的表,用来写入数据
CREATE TABLE counter (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
用 APScheduler 的 BackgroundScheduler + CronTrigger 每 5 秒触发一次 tick();tick() 里做一件事:插入数据:
# app.py
from contextlib import asynccontextmanager
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger
from fastapi import FastAPI
import psycopg, os
DSN = os.environ["DATABASE_URL"]
def tick():
with psycopg.connect(DSN) as conn, conn.cursor() as cur:
cur.execute(
"INSERT INTO counter (created_at) VALUES (NOW());"
)
conn.commit()
@asynccontextmanager
async def lifespan(app: FastAPI):
scheduler = BackgroundScheduler(timezone="Asia/Shanghai")
scheduler.add_job(tick, CronTrigger(second="*/5"), id="tick_job")
scheduler.start()
yield
scheduler.shutdown(wait=False)
app = FastAPI(lifespan=lifespan)
uvicorn app:app 单进程启动,每 5 秒表里稳定多一条。看起来能上生产。
加一个 --workers 2,为什么每个 tick 都多出两条
同样一份代码,把命令改成 uvicorn app:app --workers 2。跑一会儿去表里 SELECT * FROM counter:
id|created_at |
--+-----------------------------+
1|2026-07-07 10:34:18.564 +0800|
2|2026-07-07 10:34:18.564 +0800|
3|2026-07-07 10:34:23.633 +0800|
4|2026-07-07 10:34:23.633 +0800|
5|2026-07-07 10:34:28.752 +0800|
6|2026-07-07 10:34:28.752 +0800|
看时间戳:id=1 与 id=2 属于同一个 tick(10:34:18);id=3 与 id=4 又是同一个 tick(10:34:23)。日志里能看到两个不同的 pid 在几乎同一时刻各喊了一声 "inserted"。本例跑出来的结果毫秒几乎不差,实际可能是有差异的。
日志:
[10:34:20.073] pid=35952, lock=False, inserted=True, total=2
[10:34:20.074] pid=24896, lock=False, inserted=True, total=2
[10:34:25.074] pid=35952, lock=False, inserted=True, total=4
[10:34:25.074] pid=24896, lock=False, inserted=True, total=4
很容易就能分析出来,存在两个调度器同时调度了任务。
那么问题来了,为什么会有两个调度器呢?
为什么 schedule 不知道自己被复制了
Uvicorn 的 --workers N 不是起 N 个线程,是起 N 个进程。Gunicorn 也一样。多进程模型通常靠 fork(或 spawn),主进程加载完代码,子进程各自拷贝一份运行时。我写的 lifespan 是应用生命周期钩子,每个 Worker 起来都会独立跑一遍。
结果就是:每个 Worker 都以为自己是唯一的。它们各自把 tick 注册进自己的调度器(scheduler),各自到点各自跑一遍。APScheduler 看不到别的进程的存在,也不打算看。数据库照单全收:两条记录、两个 pid、两个几乎重合的时间戳。
还有一层容易被忽略:重入(reentry)不是多 Worker 独有的。
哪怕只有一个 Worker,tick() 这次跑了 6 秒、调度周期是 5 秒,下一次到点它照样触发。
APScheduler 的 max_instances 默认是 1——旧实例还在跑,新一轮到点会被直接 skip,日志里打一句 "maximum number of running instances reached";但这道防线只在单进程内生效,跨进程它看不见。
"多 Worker" 只是把并发从"一份进程内"扩大到"N 份进程间",本质是同一个坑的不同规格。
我的方案:PostgreSQL 分布式锁
有个前提:我没有把 --workers 降到 1。
这里得先澄清一个常被混用的概念:Uvicorn 的多 Worker 不等于"多实例高可用"。
多实例高可用讲的是同一个应用跑在多个节点上(K8s 多副本、多机负载均衡),一台机器挂了流量切到别的机器。
Uvicorn 的多 Worker 是同一台机器上的多个子进程,主进程发现子进程挂了会再拉一个——这更像是"进程级自愈",跟应用高可用不是一码事。
多 Worker 真正解决的是并发吞吐:Python 有 GIL,单进程只能用一个核。砍成单进程,多核用不上、进程级自愈也没了。
所以问题不是"怎么让调度只在一个 Worker 起",而是"怎么让 N 个调度器到点时只有一个真的动手"。
项目里本来就跑着 PostgreSQL,那就用它自带的分布式锁(distributed lock)——pg_advisory_xact_lock。这是 PG 的应用级建议锁(advisory lock),拿一个 bigint 当锁 key,同一 key 全库串行;_xact_ 版本挂在事务上,COMMIT 或 ROLLBACK 时自动释放,不用担心忘了解锁。
关键动作只有两步:先拿锁,再在锁保护下重做一次 check-then-insert:
LOCK_KEY = 42
def tick():
with get_conn() as conn:
with conn.cursor() as cur:
cur.execute("SELECT pg_advisory_xact_lock(%s);", (lock_key,))
cur.execute(
"INSERT INTO counter (created_at) VALUES (NOW());"
)
conn.commit()
去 counter 表里看,每个 tick 只多出一条,created_at 变回一节拍一条。两个 Worker 每次到点都各自跑一遍 tick(),但只有一个真的动手写库。
为什么选 PG 而不是 Redis?
项目里已经有 PG,没有 Redis。分布式锁的第一优先级是"用你已经在用的东西"。为了给定时任务引入一个新组件,得不偿失。
不过用 pg_advisory_xact_lock 之前,有个前提得先确认清楚:这把锁是保存在单个 PostgreSQL 实例的共享内存里的,只对连到同一个实例的连接可见。也就是说,它只在"所有 Worker 都连同一个 PG 实例"的前提下才成立。一旦你的架构里有读写分离、多主复制、或者一层带自动切换的连接池——不同 Worker 可能落到不同的 PG 节点,各自拿到"自己那台机器"的锁,互相不知道对方存在,锁形同虚设,counter 表里那对双胞胎会原样回来。
用之前先回答一个问题:我所有 Worker 的这条 SQL,是不是都打到同一个 PG 实例? 不能确定就别用 advisory lock,改用其他方案。
总结
这个 bug 真正的问题不在 APScheduler,不在 Uvicorn,不在多 Worker——在于我一开始把"定时任务只有一份"当默认假设。
只要你把定时器嵌进 Web 进程、又开了多个 Worker,"任务只跑一次"就不是默认状态,是需要你自己保证的东西。你不给它加锁 + 重新检查,它就一定会跑 N 次,counter 表会替你诚实地记下每一次。
在多 Worker 环境下,任何写共享状态的定时任务,都先默认它会跑 N 次;用你项目里已经在用的存储(PG、Redis、MySQL 都行)加一把分布式锁 + 锁内二次检查,是最短路径。
下次看到有人往 FastAPI 里塞每几秒一次的 tick,先去数据库里看看每次 tick 到底多了几条记录——你就知道该问什么了。小经验,哈哈!