侧边栏壁纸
博主头像
Zeeland

The mixture of software dev+Iot+ml+anything

  • 累计撰写 60 篇文章
  • 累计创建 45 个标签
  • 累计收到 7 条评论

目 录CONTENT

文章目录

uwsgi部署多进程django apscheduler与问题排查

Zeeland
2023-08-31 / 0 评论 / 0 点赞 / 185 阅读 / 2,184 字

简介

之前写django的apscheduler一直采用decorator的方式构建,因为业务的定时任务是定死的,没有产生什么其他的问题。最近定时任务需要做动态增减,进行定时任务的动态设置,因此传统的decorator写法行不通了,需要用scheduler.add_job()来添加job。

最开始以为只是一个简单的函数替换,没想到替换了一下出现了很多不一样的问题,故笔者打算记录一下其中发生的问题。

本文将介绍如何使用uwsgi部署django+apscheduler的系统,为了聚焦重点,本文不细说的uwsgi.ini等文件的配置过程,只介绍如何在django中编写构建一个apscheduler系统,并且部署在uwsgi服务器上。

背景介绍

上面已经简单介绍过了,下面介绍一下笔者原有的apscheduler构建方式。在项目根目录下构建了一个jobs/core_job.py的文件,在里面编写核心的任务调度,大致代码如下所示:

from typing import List, Dict, Any

from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.interval import IntervalTrigger
from django_apscheduler.jobstores import DjangoJobStore, register_job

from app import models
from commons.log_util import get_logger, enable_log
from services.db import db_service
from services.huasi import huasi_storage

logger = get_logger()
enable_log()

# 创建后台调度器
scheduler = BackgroundScheduler()
scheduler.add_jobstore(DjangoJobStore(), "default")


@register_job(
    scheduler,
    "interval",
    seconds=60 * 120 * 1,
    id="store A device data",
    replace_existing=True,
)
def store_pingxiang_device_job():
    ...
    logger.info("[store A device job] finish")


@register_job(
    scheduler,
    "interval",
    seconds=60 * 60 * 1,
    id="store B device data",
    replace_existing=True,
)
def store_baiyun_device_job():
    ...
    logger.info("[store B device job] finish")


def start_scheduler():
    """获取需要存储任务调度的项目,开启任务调度"""
    scheduler.start()
    logger.info("[django scheduler] start scheduler")


def resume_scheduler():
    logger.info("[django scheduler] resume schedule")
    scheduler.resume()


def stop_scheduler():
    logger.info("[django scheduler] pause schedule")
    scheduler.pause()

一开始,我把它放到子模块的apps.py ready()里面,如下所示:

from django.apps import AppConfig


class AppConfig(AppConfig):
    default_auto_field = "django.db.models.BigAutoField"
    name = "app"
    verbose_name = "xxx管理系统"

    def ready(self):
        from jobs.core_job import start_scheduler

        start_scheduler()

后面我发现,这是一个很不明智的选择,因为在uwsgi中,如果你的processes不为1,即你要开启多进程模式时,ready()会被执行多次,而如果你的job是固定的,那么你就会出现下面这种问题,django apscheduler在初始化添加task到数据库时,发生pymysql.err.IntegrityError: (1062, "Duplicate entry 'store A device job' for key 'django_apscheduler_djangojob.PRIMARY'")这种问题。

根据提供的错误信息,问题是由于数据表 django_apscheduler_djangojob 中存在重复的条目导致的完整性错误(IntegrityError)。具体错误信息是:“Duplicate entry ‘store A device job’ for key ‘django_apscheduler_djangojob.PRIMARY’”,意思是在数据库中的 django_apscheduler_djangojob 表的主键上存在重复的条目,具体插入的条目是 ‘store A device job’。

要解决这个问题,你可以检查下你的代码中是否有重复插入相同数据的操作,或者手动清理数据库中的重复数据,确保每个条目在主键上是唯一的。你可以使用 SQL 命令来删除重复的条目,或者使用 Django ORM 提供的方法进行清理操作,具体取决于你的应用程序的需求和代码实现。

在数据库中,多次执行添加job会导致重复性问题,这也是为什么放到子模块的apps.py ready()不是一个明智的选择。

而事实上,如果你在uwsgi.ini中设置了多进程模式,那么放在项目目录下的settings.py中,也会产生同样的问题,因为两个进程的变量是独立的,而同时执行两个程序则会出现数据库字段重复的问题,从而产生报错。

解决多进程多次启动apscheduler的问题

方案1

要实现在多个worker中只运行一次apscheduler程序,可以使用分布式锁的概念。在这种方案中,只有一个worker能够获得锁并执行apscheduler程序,其他的worker会检测到锁已经被获取,并根据需要等待或跳过执行。

以下是一种可能的解决方案:

  1. 使用一个共享的可靠存储系统,如数据库或分布式缓存(例如Redis)来存储锁的状态。

  2. 在你的Django应用中,使用一个装饰器或中间件来实现锁的逻辑。这个装饰器/中间件的作用是在apscheduler程序执行之前检查锁的状态,并根据情况决定是否执行程序。

下面是一个简单的实现示例,使用Redis作为存储系统:

import redis
from functools import wraps

def apscheduler_lock(func):
    @wraps(func)
    def wrapper(*args, **kwargs):
        lock_key = "apscheduler_lock"
        lock_value = "locked"
        # 连接到Redis
        redis_client = redis.Redis(host='localhost', port=6379)
        # 尝试获取锁
        acquired_lock = redis_client.set(lock_key, lock_value, nx=True, ex=60)
        if acquired_lock:
            # 如果成功获得了锁,执行apscheduler程序
            result = func(*args, **kwargs)
            # 执行完毕后释放锁
            redis_client.delete(lock_key)
            return result
        else:
            # 没有获得锁,跳过执行apscheduler程序
            return None  # 可以根据需要返回其他信息

    return wrapper

在你的apscheduler任务函数上应用这个装饰器,例如:

from apscheduler.schedulers.background import BackgroundScheduler

@schedudler_lock
def my_apscheduler_task():
    # 执行你的apscheduler任务
    pass

# 创建一个定时任务
scheduler = BackgroundScheduler()
scheduler.add_job(my_apscheduler_task, 'interval', minutes=10)
scheduler.start()

这样,无论你启动多少个worker(也可以使用多个uwsgi进程),只有一个worker能够获取到锁并执行apscheduler程序,其他的worker会跳过执行。

请根据你的实际情况适配这个示例,并确保你的共享存储系统的配置和连接代码是正确的。

方案2

如果你觉得分布式锁比较麻烦,也可以参考django-apscheduler作者提供的方案,使用manage.py操作命令行的方式启动的apscheduler,这种方式比较自由,在多进程的django启动时候,再执行命令启动apscheduler,可以避免上述阐述的重复添加job的问题。

Add a custom Django management command to your project that schedules the APScheduler jobs and starts the scheduler:

# runapscheduler.py
import logging

from django.conf import settings

from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.triggers.cron import CronTrigger
from django.core.management.base import BaseCommand
from django_apscheduler.jobstores import DjangoJobStore
from django_apscheduler.models import DjangoJobExecution
from django_apscheduler import util

logger = logging.getLogger(__name__)


def my_job():
  # Your job processing logic here...
  pass


# The `close_old_connections` decorator ensures that database connections, that have become
# unusable or are obsolete, are closed before and after your job has run. You should use it
# to wrap any jobs that you schedule that access the Django database in any way. 
@util.close_old_connections
def delete_old_job_executions(max_age=604_800):
  """
  This job deletes APScheduler job execution entries older than `max_age` from the database.
  It helps to prevent the database from filling up with old historical records that are no
  longer useful.
  
  :param max_age: The maximum length of time to retain historical job execution records.
                  Defaults to 7 days.
  """
  DjangoJobExecution.objects.delete_old_job_executions(max_age)


class Command(BaseCommand):
  help = "Runs APScheduler."

  def handle(self, *args, **options):
    scheduler = BlockingScheduler(timezone=settings.TIME_ZONE)
    scheduler.add_jobstore(DjangoJobStore(), "default")

    scheduler.add_job(
      my_job,
      trigger=CronTrigger(second="*/10"),  # Every 10 seconds
      id="my_job",  # The `id` assigned to each job MUST be unique
      max_instances=1,
      replace_existing=True,
    )
    logger.info("Added job 'my_job'.")

    scheduler.add_job(
      delete_old_job_executions,
      trigger=CronTrigger(
        day_of_week="mon", hour="00", minute="00"
      ),  # Midnight on Monday, before start of the next work week.
      id="delete_old_job_executions",
      max_instances=1,
      replace_existing=True,
    )
    logger.info(
      "Added weekly job: 'delete_old_job_executions'."
    )

    try:
      logger.info("Starting scheduler...")
      scheduler.start()
    except KeyboardInterrupt:
      logger.info("Stopping scheduler...")
      scheduler.shutdown()
      logger.info("Scheduler shut down successfully!")

The management command defined above should be invoked via ./manage.py runapscheduler whenever the webserver serving your Django application is started. The details of how and where this should be done is implementation specific, and depends on which webserver you are using and how you are deploying your application to production. For most people this should involve configuring a supervisor process of sorts.

Register any APScheduler jobs as you would normally. Note that if you haven’t set DjangoJobStore as the ‘default’ job store, then you will need to include jobstore=‘djangojobstore’ in your scheduler.add_job() calls.

总结

本文总结了使用uwsgi部署多进程的django apscheduler可能会产生的问题,并提供了两种解决方案,如果你有更好的想法,欢迎在评论区一起讨论。

0

评论区