当你把FastAPI应用部署到生产环境,使用Uvicorn或Gunicorn启动多个工作进程(Worker)后,有没有发现你的定时任务像复制粘贴一样,在每个进程里都跑了一遍?🎯
有个案例,团队做了一个外卖聚合平台的后台。为了提高并发,他们用gunicorn -w 4启动了4个进程。结果,每天凌晨清理临时文件的定时任务,被执行了4次,差点把刚上传的商户logo给误删了。紧急排查了一晚上,才锁定是多进程"各自为政"惹的祸。
这绝不是个例。在多进程部署模式下,定时器多次触发、共享数据竞争、依赖项重复初始化是三大经典"坑"。今天,我们就把这几个问题彻底讲透,并给你可以直接复制粘贴的解决方案。
🔍 核心摘要
本文将解决你在FastAPI多进程部署中最常遇到的三个问题:
1)定时任务在多个Worker中重复执行;
2)多进程同时读写共享资源(如文件、缓存)导致的数据错乱;
3)数据库连接池等依赖被重复初始化。
核心解决方案是引入跨进程锁机制,我们将详细讲解基于文件锁的实现,并给出完整的代码示例。
📝 主要内容脉络
👉 一、问题根源:为什么多进程部署会带来这些麻烦?
👉 二、核心原理:锁是什么?进程锁与线程锁的天壤之别。
👉 三、实战演示:手写一个跨进程文件锁,并应用到定时任务和依赖初始化中。
👉 四、注意事项:锁的粒度和死锁风险,以及更优的替代方案探讨。
🚨 第一部分:问题与背景 - "复制"的进程,"捣乱"的任务
当你用uvicorn main:app --workers 4或gunicorn -w 4启动应用时,操作系统会创建4个独立的Python进程。它们内存不共享,就像开了4家一模一样的餐厅分店。
1️⃣ 定时任务重复触发 :你在代码里用BackgroundScheduler或asyncio写了个定时器。对不起,4家"分店"的"后厨"都会各自设置这个闹钟,到点就响,任务自然执行4次。
2️⃣ 共享资源竞争 :假设你用一个log.txt文件记录数据。进程A刚打开文件,进程B也打开了。A写入一行,B也写入一行,结果互相覆盖,日志丢了。
3️⃣ 依赖重复初始化:你在全局或依赖项中初始化了一个昂贵的数据库连接池。4个进程会分别初始化4次,不仅浪费资源,还可能很快耗光数据库的最大连接数。
核心矛盾 :多进程提升了并发能力,但也打破了单进程程序"唯一执行流"的假设。我们需要一个"协调员",告诉所有进程:"这件事,只能一个人干!"------这就是锁(Lock)。
🧠 第二部分:核心原理 - 锁:从"单店规矩"到"连锁店章程"
你可能用过Python的threading.Lock。但请注意,它只在同一个进程内的多个线程间 有效。我们的4个进程是4个独立王国,threading.Lock管不了别的王国的事。
我们需要的是跨进程锁(Inter-Process Lock) 。其原理是,利用一个所有进程都能访问的公共标记(比如文件系统的某个文件、Redis的一个键、数据库的一行记录)来充当"信号旗"。谁抢到了这个旗子,谁就有权执行关键代码。
今天,我们选用最简单、最普适的文件锁来实现。它不依赖外部服务,在单机多进程部署场景下非常可靠。你可以把它想象成,所有分店都盯着总部门口的一块公告板,谁先在上面贴上"我在打扫",其他人就得等着。
👨💻 第三部分:实战演示 - 打造你的跨进程锁并应用
第1步:实现一个可靠的文件锁
我们基于fcntl(Linux/Unix)或msvcrt(Windows)模块来实现。下面是一个兼容性较好的示例:
import os
import sys
import time
class FileLock:
"""简单的跨进程文件锁"""
def __init__(self, lock_file: str):
self.lock_file = lock_file
self._fd = None
def acquire(self, blocking: bool = True, timeout: float = 10):
"""获取锁。blocking=True时会等待,最多等待timeout秒"""
start = time.time()
while True:
try:
# 以读写方式打开文件
self._fd = os.open(self.lock_file, os.O_CREAT | os.O_RDWR)
# 尝试获取独占锁(非阻塞模式)
if sys.platform != 'win32':
import fcntl
fcntl.flock(self._fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
else:
import msvcrt
msvcrt.locking(self._fd, msvcrt.LK_NBLCK, 1)
# 成功获取锁
return True
except (BlockingIOError, PermissionError):
# 获取锁失败(文件已被其他进程锁定)
if self._fd:
os.close(self._fd)
self._fd = None
if not blocking:
return False
if time.time() - start > timeout:
raise TimeoutError(f"Acquire lock timeout after {timeout}s")
time.sleep(0.1) # 短暂等待后重试
def release(self):
"""释放锁"""
if self._fd:
try:
if sys.platform != 'win32':
import fcntl
fcntl.flock(self._fd, fcntl.LOCK_UN)
else:
import msvcrt
msvcrt.locking(self._fd, msvcrt.LK_UNLCK, 1)
finally:
os.close(self._fd)
self._fd = None
# 可选:删除锁文件,但通常保留以避免竞态条件
# try: os.remove(self.lock_file)
# except: pass
def __enter__(self):
self.acquire()
return self
def __exit__(self, exc_type, exc_val, exc_tb):
self.release()
第2步:改造定时任务 - 确保唯一执行
假设我们有一个每10分钟清理一次缓存的定时任务。使用锁进行改造:
import asyncio
from contextlib import asynccontextmanager
from fastapi import FastAPI
from your_file_lock import FileLock # 导入上面实现的锁
LOCK_FILE = "/tmp/cleanup_cache.lock" # 锁文件路径,确保所有进程能访问同一路径
async def cleanup_cache_task():
"""真正执行清理的任务函数"""
# 模拟耗时操作
print("开始清理缓存...")
await asyncio.sleep(5)
print("缓存清理完成。")
async def scheduled_cleanup_with_lock():
"""带锁的调度任务"""
lock = FileLock(LOCK_FILE)
try:
# 非阻塞尝试获取锁,获取不到说明其他进程已在执行
if lock.acquire(blocking=False):
await cleanup_cache_task()
else:
print("其他进程正在执行清理,本次跳过。")
finally:
lock.release()
@asynccontextmanager
async def lifespan(app: FastAPI):
# 启动时,启动一个后台任务
import asyncio
async def run_periodically():
while True:
await scheduled_cleanup_with_lock() # 使用带锁版本
await asyncio.sleep(600) # 等待10分钟
task = asyncio.create_task(run_periodically())
yield
# 关闭时,取消任务
task.cancel()
app = FastAPI(lifespan=lifespan)
# 你的其他路由...
@app.get("/")
def read_root():
return {"Hello": "World"}
关键点:现在,无论你启动多少个Worker,在同一个10分钟周期内,只有一个进程能成功获取锁并执行清理任务,其他进程会自动跳过。
第3步:保护依赖初始化 - 只初始化一次
对于数据库连接池、全局配置加载等,我们希望只在第一个起来的进程中初始化一次。
from fastapi import Depends
import pickle
import os
INIT_FLAG_FILE = "/tmp/app_initialized.flag"
INIT_LOCK_FILE = "/tmp/app_init.lock"
def global_init_dependency():
"""一个需要全局唯一初始化的依赖"""
# 首先检查标志文件是否存在
if os.path.exists(INIT_FLAG_FILE):
# 已初始化,直接返回数据
with open(INIT_FLAG_FILE, 'rb') as f:
cache = pickle.load(f)
print("从现有标志文件加载缓存。")
return cache
# 未初始化,需要竞争锁来进行初始化
with FileLock(INIT_LOCK_FILE) as lock:
# 获取锁后,再次检查,防止多个进程同时通过第一层检查后排队等待,第一个初始化完后,后面的应直接使用结果
if os.path.exists(INIT_FLAG_FILE):
with open(INIT_FLAG_FILE, 'rb') as f:
return pickle.load(f)
print("执行全局唯一的初始化操作...")
# 模拟昂贵的初始化,比如创建连接池、加载大模型等
expensive_cache = {"model": "loaded", "connections": 10}
# 将初始化结果写入标志文件
with open(INIT_FLAG_FILE, 'wb') as f:
pickle.dump(expensive_cache, f)
return expensive_cache
@app.get("/data")
def get_data(cache: dict = Depends(global_init_dependency)):
return {"data_from_cache": cache}
⚠️ 第四部分:注意事项与进阶思考
警告与最佳实践:
🔒 锁的粒度要细 :锁住的范围越小越好(比如只锁住cleanup_cache_task函数内部),而不是锁住整个循环。持有锁的时间越长,其他进程等待越久,并发性能越差。
💀 严防死锁 :确保任何异常路径下锁都能被释放。务必使用try...finally或在with语句中使用锁。
📍 锁文件路径必须一致 :所有进程必须能访问同一个锁文件路径 。不要用相对路径,最好使用绝对路径(如/tmp/或/var/run/下的文件)。
📈 分布式环境升级 :文件锁只在单台服务器的多进程间有效。如果你有多台服务器(分布式部署),需要分布式锁,如使用Redis(Redlock算法)、ZooKeeper或数据库来实现。
🎯 更优雅的方案 :对于定时任务,更专业的做法是将任务"外置"。使用Celery 、APScheduler 配合Redis作为消息后端,或者使用Kubernetes CronJob 、Rq-scheduler等专门的任务队列系统,从根本上避免多进程竞争问题。
💎 总结
多进程部署是提升FastAPI并发能力的标准操作,但随之而来的"任务重复执行"等问题,本质是缺乏进程间协调。通过实现一个简单的跨进程文件锁,我们可以在不引入复杂中间件的情况下,有效解决定时任务重复、初始化竞争等问题。
记住,文件锁是单机多进程场景下的实用利器。当你的架构扩展到多台机器时,就该请出Redis分布式锁等更强大的工具了。
希望这篇来自实战的总结,能让你下次部署时胸有成竹。如果你也曾被多进程坑过,或者有更巧妙的解决方案,欢迎在留言区分享你的故事。
老规矩,觉得有用就点个收藏⭐,关注一下。说不定下一篇,我们就来聊聊怎么用Redis轻松实现那个更强大的分布式锁。