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 小时前
AI 手势识别系统:踩坑与实现全记录 (PyTorch + MediaPipe)
人工智能·pytorch·python
忧郁的橙子.3 小时前
26期_01_Pyhton文件的操作
开发语言·python
wWYy.3 小时前
详解redis(15):缓存雪崩
数据库·redis·缓存
这周也會开心3 小时前
Redis相关知识点
数据库·redis·缓存
小CC吃豆子4 小时前
Python爬虫
开发语言·python
June bug4 小时前
(#字符串处理)字符串中第一个不重复的字母
python·leetcode·面试·职场和发展·跳槽
lixzest5 小时前
PyTorch基础知识简述
人工智能·pytorch·python
Anastasiozzzz5 小时前
Redis的键过期是如何删除的?【面试高频】
java·数据库·redis·缓存·面试
飞Link5 小时前
深度学习里程碑:ResNet(残差网络)从理论到实战全解析
人工智能·python·深度学习