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)
_run_daily 靠进程内的 sleep 位置避重,不是 DB 级;--workers 4 ⇒ 4 个进程各跑一遍 ⇒ notify_subscription_expiring 对每个用户重复发 4 封站内信。⚠️ 而 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,不在进程内存 ⇒ 多进程/多容器下幂等依然有效。
必须先做两件事,否则会出线上事故:
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,也防"任务失败重跑"和"人工触发"。
用 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) | 独立,可先做 |
| 4 | pg_stat_statements | 需重启 PG,你拍板 |
⚠️ 2 和 3 不依赖任何前置,风险极低 ⇒ 建议先上这两个。
1 必须等0a/0b 做完。
如果我不查就让你上 --workers 4:
「失败重跑安全」这句注释是对的,但它指的是"串行重跑",不含"并发多进程"。