多 worker 改造:前置检查结论

检查① 进程内状态 —— 🔴 发现真隐患

定位

app/main.py 的 lifespan 里启动了 4 个后台定时任务:

async def lifespan(app: FastAPI):
    await start_weather_cron()      # → asyncio.create_task(cron_scheduler())
    await start_distribution_cron()  # → 3 个 create_task

其中包括 🔴 run_settle_push —— 佣金结算推进(涉及钱)。

逐个查幂等性(注释不可信,实际看代码)

任务防重机制多进程是否安全
run_settle_push(佣金结算)✅ UPDATE ... WHERE status='accrued' RETURNING id✅ 安全 —— 原子条件更新,第二个进程拿不到行
run_cooldown_scan(冷静期扫描)待查—
run_subscription_expiry(订阅到期标记)同上模式✅ 安全
D7 到期提醒(站内信)🔴 无 DB 级去重❌ 不安全

🔴 隐患的具体机制

async def _run_daily(fn, name, at):
    while True:
        ...
        await asyncio.sleep(wait)
        await fn()                # ← 多进程 ⇒ 每个进程都会跑到这里
        await asyncio.sleep(60)

⚠️ 而 app_messages 表没有唯一约束兜底(实测索引只有主键/user_id/type/destination,无"每日一次"唯一键)⇒ 重复站内信会真的落库。


检查② 幂等中间件 —— ✅ 安全

# app/services/idempotency.py:106
INSERT INTO idempotency_keys (scope, idem_key, body_hash) ...
except asyncpg.UniqueViolationError:   # ← 靠 DB 唯一约束

✅ 幂等键存在 PG,不在进程内存 ⇒ 多进程/多容器下幂等依然有效。


🔴 结论:多 worker 不能直接上

必须先做两件事,否则会出线上事故:

前置 A:给站内信加 DB 级去重(🔴 必须)

ALTER TABLE app_messages
  ADD COLUMN IF NOT EXISTS dedup_key text;
CREATE UNIQUE INDEX IF NOT EXISTS uq_app_messages_dedup
  ON app_messages(dedup_key) WHERE dedup_key IS NOT NULL;

调用侧传 dedup_key = f"sub-expire:{user_id}:{date}:{remind_days}"。

⚠️ 收益:不光防多 worker,也防"任务失败重跑"和"人工触发"。

前置 B:定时任务的跨进程互斥(🔴 强烈建议)

用 PG 咨询锁,保证同类任务全局只跑一次:

async def _cron_once(conn, name: str, fn):
    got = await conn.fetchval("SELECT pg_try_advisory_lock(hashtext($1))", name)
    if not got:
        logger.info("[cron] %s 已在其他进程执行,跳过", name)
        return
    try:
        await fn()
    finally:
        await conn.execute("SELECT pg_advisory_unlock(hashtext($1))", name)

⇒ 即使将来扩到多个容器,也不会重复执行。


改什么(第一阶段,修正版)

#改动状态
0a站内信加 dedup_key 唯一索引🔴 新前置
0b定时任务加 advisory lock🔴 新前置
1--workers 4依赖 0a/0b
2连接池环境变量化 + command_timeout=15独立,可先做
3慢请求日志(>500ms)独立,可先做
4pg_stat_statements需重启 PG,你拍板

⚠️ 2 和 3 不依赖任何前置,风险极低 ⇒ 建议先上这两个。

1 必须等0a/0b 做完。


🔴 这次检查的价值

如果我不查就让你上 --workers 4:

「失败重跑安全」这句注释是对的,但它指的是"串行重跑",不含"并发多进程"。