分布式、多节点系统下定时任务重复执行问题解决方案

在分布式多节点系统下,或者是使用gunicorn等工具启用多个worker的情况下,如何保证后端的定时任务、初始化任务只执行一次呢?比如使用apscheduler或者flask-apscheduler实现的定时任务。

在这种情况下必须借助外部数据库才能实现,当然,不仅仅只能是Redis,你也可以利用当前系统下有的MySQL、或者MongoDB数据库,只需要自定义一张表,创建一个unique字段作为锁即可。

我这里将使用python语言,以MySQL为例,使用sqlalchemy+pymysql作为数据库操作方式,使用装饰器的方式对原有任务函数进行改造,以达到对分布式的支持。你可以将该方法扩散到其他语言、其他后端框架或者仅仅是定时任务后台的情况。

一、创建带唯一值字段的数据表

无论是redis、mysql、mongodb,要实现一个锁的功能,作为锁的字段必须为唯一值字段

我这里使用了sqlalchemy创建表、指定lock_key字段unique=True,并定义了add_lockdelete_lock方法,当然你也可以手动在数据库创建表,使用pymysql原生SQL语句实现锁方法。
![示例结构]

image.png

  • 若需要使用原生SQL语句定义表,可参照如下方式:
CREATE TABLE `task_lock` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`lock_key` varchar(20) COLLATE utf8mb4_bin NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `lock_key` (`lock_key`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin
  • SQLAlchemy代码示例
    sql_model.py
from sqlalchemy import MetaData, Table, Column, BigInteger, String, create_engine
from sqlalchemy import insert, delete
from sqlalchemy.exc import IntegrityError


metadata = MetaData()

engine = create_engine("mysql+pymysql://root:mypassword@127.0.0.1:3306/test?charset=utf8mb4")

# 定义唯一值字段的任务锁表
locks = Table("taskLock", metadata,
              Column("id", BigInteger(), primary_key=True, autoincrement=True),
              Column("lock_key", String(50), unique=True, nullable=False, comment="任务锁")
        )

# 创建表
metadata.create_all(engine)

# 加锁方法,bool值表示是否成功
def add_lock(lock_value):
    """添加唯一锁"""
    ins = insert(locks).values(lock_key=lock_value)
    # 若当前锁值不存在,则可以插入成功,返回True
    try:
        engine.connect().execute(ins)
        return True
    # 若当前插入锁值已存在,则会触发并捕获该异常,返回False
    except IntegrityError:
        return False

# 删除锁方法,只要成功添加了锁,任务执行后,无论成功还是失败都必须调用删除方法
def delete_lock(lock_value):
    """删除锁"""
    d = delete(locks).where(locks.c.lock_key == lock_value)
    engine.connect().execute(d)

二、定义单节点任务装饰器

为什么使用装饰器?使用装饰器的方式对原有函数进行改造,可保留原始函数代码不变,且复用性、可读性更高。建议大家多多使用装饰器哦,这里的装饰器需要传参,所以需要额外增加一层用来接收参数,关于装饰器的学习,可以参考其他文档。

decorators.py


from sql_models import add_lock, delete_lock


# 单节点任务装饰器,被装饰的任务在分布式多节点下同一时间只能运行一次
def single_task(task):
    def wrap(func):
        def inner(*args, **kwargs):
            add_result = add_lock(task)
            if add_result:
                print("当前节点获取任务:{}!".format(task))
                try:
                    result = func(*args, **kwargs)
                    return result
                except Exception as e:
                    raise e
                finally:
                    delete_lock(task)

            else:
                print("当前节点未获取任务:{}".format(task))
                return
        return inner
    return wrap

该装饰器的功能很简单,可接收一个任务名,该任务名将用作数据库中lock_key的唯一值写入。在执行原函数前,会先尝试加锁,即写入lock_key值,若写入成功,则获得锁,可以继续执行该任务函数;若加锁失败,即写入lock_key时数据库已存在当前值,说明其他节点正在执行该任务,则无法获得锁,不能执行该任务函数,只会打印提示信息。

三、装饰需单节点运行的任务

需要被装饰的任务一般是定时任务,或者是初始化时可能重复运行任务。

tasks.py
在这里定义了3个模拟任务task1、task2、task3,并只对task1与task3使用single_task装饰器进行单节点运行装饰:

import time
import random

from decorators import single_task
from apscheduler.schedulers.background import BlockingScheduler


@single_task("task1")
def task1(arg1, arg2):
    print("----------------------------------------------")
    print("开始执行task1")
    time.sleep(random.randint(1, 5))
    print("task1执行完成")
    print("----------------------------------------------" + "\n")
    return arg1 + arg2


def task2(arg1, arg2):
    print("----------------------------------------------")
    print("开始执行task2")
    time.sleep(random.randint(1, 5))
    print("task2执行完成")
    print("----------------------------------------------" + "\n")
    return arg1 * arg2


@single_task("task3")
def task3(arg1, arg2):
    print("----------------------------------------------")
    print("开始执行task2")
    time.sleep(random.randint(1, 5))
    print("task2执行完成")
    print("----------------------------------------------" + "\n")
    return arg1 / arg2

四、使用apscheduler开启定时任务

tasks.py

配置定时任务:

import time
import random

from decorators import single_task
from apscheduler.schedulers.background import BlockingScheduler


@single_task("task1")
def task1(arg1, arg2):
    print("----------------------------------------------")
    print("开始执行task1")
    time.sleep(random.randint(1, 5))
    print("task1执行完成")
    print("----------------------------------------------" + "\n")
    return arg1 + arg2


def task2(arg1, arg2):
    print("----------------------------------------------")
    print("开始执行task2")
    time.sleep(random.randint(1, 5))
    print("task2执行完成")
    print("----------------------------------------------" + "\n")
    return arg1 * arg2


@single_task("task3")
def task3(arg1, arg2):
    print("----------------------------------------------")
    print("开始执行task2")
    time.sleep(random.randint(1, 5))
    print("task2执行完成")
    print("----------------------------------------------" + "\n")
    return arg1 / arg2


if __name__ == '__main__':
    print("开始执行定时任务")
    scheduler = BlockingScheduler()
    scheduler.add_job(task1, args=(5, 5), trigger="interval", seconds=20)
    scheduler.add_job(task2, args=(5, 5), trigger="interval", seconds=20)
    scheduler.add_job(task3, args=(5, 5), trigger="interval", seconds=20)
    scheduler.start()


五、任务演示

此处简单模拟多个命令行窗口,几乎同时运行,模拟多节点的情况:


image.png

可以发现实现了我们的设计,在两个节点中,task1和task3只会运行一次,而未加装饰器的task2不受限制,每个节点中都会重复运行。

多节点,比如存在写入数据库或者文件操作的定时任务,则设置单节点运行是非常有必要的。

总结

同理,你可以使用相同的方式,利用MongDB或其他数据库实现单节点锁的装饰器,若有Redis则实现起来就更加容易和普遍了。

以上,希望文章能对你有所帮助。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,736评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,167评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,442评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,902评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,302评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,573评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,847评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,562评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,260评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,531评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,021评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,367评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,016评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,068评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,827评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,610评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,514评论 2 269