FastAPI多进程部署:定时任务重复执行?手把手教你用锁搞定

当你把FastAPI应用部署到生产环境,使用Uvicorn或Gunicorn启动多个工作进程(Worker)后,有没有发现你的定时任务像复制粘贴一样,在每个进程里都跑了一遍?🎯

有个案例,团队做了一个外卖聚合平台的后台。为了提高并发,他们用gunicorn -w 4启动了4个进程。结果,每天凌晨清理临时文件的定时任务,被执行了4次,差点把刚上传的商户logo给误删了。紧急排查了一晚上,才锁定是多进程"各自为政"惹的祸。

这绝不是个例。在多进程部署模式下,定时器多次触发、共享数据竞争、依赖项重复初始化是三大经典"坑"。今天,我们就把这几个问题彻底讲透,并给你可以直接复制粘贴的解决方案。

🔍 核心摘要

本文将解决你在FastAPI多进程部署中最常遇到的三个问题:

1)定时任务在多个Worker中重复执行;

2)多进程同时读写共享资源(如文件、缓存)导致的数据错乱;

3)数据库连接池等依赖被重复初始化。

核心解决方案是引入跨进程锁机制,我们将详细讲解基于文件锁的实现,并给出完整的代码示例。

📝 主要内容脉络

👉 一、问题根源:为什么多进程部署会带来这些麻烦?

👉 二、核心原理:锁是什么?进程锁与线程锁的天壤之别。

👉 三、实战演示:手写一个跨进程文件锁,并应用到定时任务和依赖初始化中。

👉 四、注意事项:锁的粒度和死锁风险,以及更优的替代方案探讨。

🚨 第一部分:问题与背景 - "复制"的进程,"捣乱"的任务

当你用uvicorn main:app --workers 4gunicorn -w 4启动应用时,操作系统会创建4个独立的Python进程。它们内存不共享,就像开了4家一模一样的餐厅分店。
1️⃣ 定时任务重复触发 :你在代码里用BackgroundSchedulerasyncio写了个定时器。对不起,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或数据库来实现。

🎯 更优雅的方案 :对于定时任务,更专业的做法是将任务"外置"。使用CeleryAPScheduler 配合Redis作为消息后端,或者使用Kubernetes CronJobRq-scheduler等专门的任务队列系统,从根本上避免多进程竞争问题。

💎 总结

多进程部署是提升FastAPI并发能力的标准操作,但随之而来的"任务重复执行"等问题,本质是缺乏进程间协调。通过实现一个简单的跨进程文件锁,我们可以在不引入复杂中间件的情况下,有效解决定时任务重复、初始化竞争等问题。

记住,文件锁是单机多进程场景下的实用利器。当你的架构扩展到多台机器时,就该请出Redis分布式锁等更强大的工具了。


希望这篇来自实战的总结,能让你下次部署时胸有成竹。如果你也曾被多进程坑过,或者有更巧妙的解决方案,欢迎在留言区分享你的故事

老规矩,觉得有用就点个收藏⭐,关注一下。说不定下一篇,我们就来聊聊怎么用Redis轻松实现那个更强大的分布式锁。

相关推荐
炸炸鱼.2 小时前
Python 操作 MySQL 数据库
android·数据库·python·adb
_深海凉_3 小时前
LeetCode热题100-颜色分类
python·算法·leetcode
AC赳赳老秦3 小时前
OpenClaw email技能:批量发送邮件、自动回复,高效处理工作邮件
运维·人工智能·python·django·自动化·deepseek·openclaw
zhaoshuzhaoshu3 小时前
Python 语法之数据结构详细解析
python
AI问答工程师4 小时前
Meta Muse Spark 的"思维压缩"到底是什么?我用 Python 复现了核心思路(附代码)
人工智能·python
zfan5205 小时前
python对Excel数据处理(1)
python·excel·pandas
小饕5 小时前
我从零搭建 RAG 学到的 10 件事
python
老歌老听老掉牙5 小时前
PyQt5+Qt Designer实战:可视化设计智能参数配置界面,告别手动布局时代!
python·qt
格鸰爱童话6 小时前
向AI学习项目技能(六)
java·人工智能·spring boot·python·学习
悟空爬虫-彪哥6 小时前
VRChat开发环境配置,零基础教程
python