同一秒插了两条 counter:一个改一行部署命令就能复现的 FastAPI 竞态

11 阅读6分钟

场景是这样的: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 到底多了几条记录——你就知道该问什么了。小经验,哈哈!