MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

基于Redis的MySQL异步任务重试机制设计

2021-02-197.7k 阅读

基于Redis的MySQL异步任务重试机制设计原理

为什么需要异步任务重试机制

在使用 MySQL 进行数据处理的应用场景中,常常会遇到各种不可预见的错误。例如,网络波动可能导致数据库连接暂时中断,数据库服务器负载过高可能使得某些写入或读取操作超时。这些错误如果不加以处理,会导致任务失败,数据处理不完整,进而影响整个业务流程的正确性和稳定性。

异步任务重试机制的出现就是为了应对这类问题。当任务执行过程中遇到错误时,系统不会立即放弃,而是等待一段时间后重新尝试执行任务,给任务更多的机会成功执行,从而提高系统的容错能力和可靠性。

Redis 在异步任务重试机制中的角色

Redis 作为一款高性能的键值存储数据库,具有出色的读写性能和丰富的数据结构,在异步任务重试机制中扮演着至关重要的角色。

  1. 任务队列:Redis 的列表(List)数据结构非常适合用于构建任务队列。我们可以将需要执行的 MySQL 相关任务以某种序列化的形式(如 JSON 字符串)添加到 Redis 列表中。一个任务从产生到被处理的整个生命周期都可以在这个队列中进行管理。当任务执行失败需要重试时,我们可以将其重新放回队列中,等待下一次处理。
  2. 任务状态管理:Redis 的哈希(Hash)数据结构可以用来记录任务的状态信息。对于每个任务,我们可以用一个唯一标识(如任务 ID)作为哈希的键,在哈希值中记录任务的执行次数、上次执行时间、当前状态(如等待执行、正在执行、执行成功、执行失败等)等信息。这样,在任务重试过程中,我们可以方便地获取任务的历史信息,根据不同的情况进行相应的处理。
  3. 分布式锁:在分布式环境下,确保同一任务不会被多个进程同时处理是非常重要的。Redis 可以通过 SETNX(SET if Not eXists)命令实现简单的分布式锁。当一个进程尝试获取任务进行处理时,它首先尝试在 Redis 中设置一个与任务相关的锁。如果设置成功,说明该进程获得了处理该任务的权限;如果设置失败,说明其他进程已经在处理该任务,当前进程需要等待一段时间后再次尝试获取锁。

设计架构

任务生产者

任务生产者是整个异步任务重试机制的起点,它负责将需要执行的 MySQL 相关任务生成并发送到 Redis 任务队列中。任务的来源可以多种多样,例如用户的请求、定时任务触发、其他系统的消息通知等。

以一个简单的用户注册场景为例,当用户提交注册信息后,系统需要将用户信息插入到 MySQL 数据库中。此时,任务生产者可以将插入用户信息的 SQL 语句以及相关的参数封装成一个任务对象,然后将其序列化为 JSON 字符串,通过 Redis 的 RPUSH 命令添加到任务队列中。

任务消费者

任务消费者从 Redis 任务队列中取出任务,并尝试执行。在执行任务之前,任务消费者需要先获取任务的锁,以确保同一任务不会被多个消费者同时执行。如果获取锁成功,任务消费者开始执行任务,即根据任务中的 SQL 语句和参数在 MySQL 数据库中进行操作。

在执行任务过程中,任务消费者需要捕获可能出现的异常。如果任务执行成功,任务消费者需要在 Redis 中更新任务的状态为“执行成功”,并删除任务的锁;如果任务执行失败,任务消费者需要根据任务的重试策略决定是否重试。如果需要重试,任务消费者需要将任务重新放回 Redis 任务队列中,并更新任务的执行次数、上次执行时间等状态信息。

重试策略

重试策略决定了任务在执行失败后如何进行重试。常见的重试策略有以下几种:

  1. 固定间隔重试:任务执行失败后,每次等待固定的时间间隔后重试。例如,每次失败后等待 5 秒重试。这种策略简单直观,但在网络波动等情况下可能效率不高。
  2. 指数退避重试:任务执行失败后,等待的时间间隔按照指数级增长。例如,第一次失败后等待 1 秒,第二次失败后等待 2 秒,第三次失败后等待 4 秒,以此类推。这种策略可以避免在短时间内频繁重试,减轻系统负担,同时随着时间的推移增加重试的机会。
  3. 自定义重试:根据具体业务需求制定重试策略。例如,某些任务在第一次失败后等待 10 秒重试,第二次失败后等待 30 秒重试,第三次失败后不再重试,而是将任务标记为需要人工干预。

代码示例

Python 实现任务生产者

以下是使用 Python 和 Redis - Py 库实现任务生产者的示例代码:

import redis
import json


def produce_task():
    r = redis.Redis(host='localhost', port=6379, db=0)
    task = {
        "sql": "INSERT INTO users (username, password) VALUES (%s, %s)",
        "params": ("test_user", "test_password")
    }
    task_json = json.dumps(task)
    r.rpush('mysql_task_queue', task_json)
    print("Task produced successfully.")


if __name__ == "__main__":
    produce_task()

在上述代码中,我们创建了一个简单的任务,该任务是向名为 users 的表中插入一条用户数据。然后将任务序列化为 JSON 字符串,并使用 Redis 的 rpush 方法将其添加到名为 mysql_task_queue 的任务队列中。

Python 实现任务消费者

import redis
import json
import mysql.connector
import time


def consume_task():
    r = redis.Redis(host='localhost', port=6379, db=0)
    while True:
        task_json = r.lpop('mysql_task_queue')
        if task_json is None:
            time.sleep(1)
            continue
        task = json.loads(task_json)
        lock_key = f"task_lock:{task['sql']}:{task['params']}"
        lock_acquired = r.setnx(lock_key, 1)
        if not lock_acquired:
            r.rpush('mysql_task_queue', task_json)
            continue
        try:
            cnx = mysql.connector.connect(user='root', password='password', host='127.0.0.1', database='test')
            cursor = cnx.cursor()
            cursor.execute(task['sql'], task['params'])
            cnx.commit()
            cursor.close()
            cnx.close()
            r.delete(lock_key)
            print("Task executed successfully.")
        except mysql.connector.Error as err:
            print(f"Error: {err}")
            # 简单的固定间隔重试策略
            retry_delay = 5
            time.sleep(retry_delay)
            r.rpush('mysql_task_queue', task_json)
        except Exception as e:
            print(f"Unexpected error: {e}")
            r.rpush('mysql_task_queue', task_json)


if __name__ == "__main__":
    consume_task()

在上述代码中,任务消费者不断从 Redis 任务队列中取出任务。在执行任务前,先尝试获取任务的锁。如果获取锁成功,则连接 MySQL 数据库并执行任务中的 SQL 语句。如果执行成功,删除任务锁并打印成功信息;如果执行失败,根据简单的固定间隔重试策略,等待 5 秒后将任务重新放回队列。

实现重试策略类

为了使重试策略更加灵活和可扩展,我们可以创建一个重试策略类。以下是使用 Python 实现指数退避重试策略类的示例代码:

import time


class ExponentialBackoffRetry:
    def __init__(self, base_delay=1, max_delay=60):
        self.base_delay = base_delay
        self.max_delay = max_delay
        self.current_delay = base_delay

    def get_delay(self, retry_count):
        delay = self.base_delay * (2 ** retry_count)
        return min(delay, self.max_delay)


我们可以在任务消费者代码中引入这个重试策略类,如下:

import redis
import json
import mysql.connector
import time


class ExponentialBackoffRetry:
    def __init__(self, base_delay=1, max_delay=60):
        self.base_delay = base_delay
        self.max_delay = max_delay
        self.current_delay = base_delay

    def get_delay(self, retry_count):
        delay = self.base_delay * (2 ** retry_count)
        return min(delay, self.max_delay)


def consume_task():
    r = redis.Redis(host='localhost', port=6379, db=0)
    retry_policy = ExponentialBackoffRetry()
    while True:
        task_json = r.lpop('mysql_task_queue')
        if task_json is None:
            time.sleep(1)
            continue
        task = json.loads(task_json)
        lock_key = f"task_lock:{task['sql']}:{task['params']}"
        lock_acquired = r.setnx(lock_key, 1)
        if not lock_acquired:
            r.rpush('mysql_task_queue', task_json)
            continue
        retry_count = 0
        while True:
            try:
                cnx = mysql.connector.connect(user='root', password='password', host='127.0.0.1', database='test')
                cursor = cnx.cursor()
                cursor.execute(task['sql'], task['params'])
                cnx.commit()
                cursor.close()
                cnx.close()
                r.delete(lock_key)
                print("Task executed successfully.")
                break
            except mysql.connector.Error as err:
                print(f"Error: {err}")
                retry_delay = retry_policy.get_delay(retry_count)
                time.sleep(retry_delay)
                retry_count += 1
            except Exception as e:
                print(f"Unexpected error: {e}")
                retry_delay = retry_policy.get_delay(retry_count)
                time.sleep(retry_delay)
                retry_count += 1


if __name__ == "__main__":
    consume_task()

在这个改进后的代码中,我们创建了 ExponentialBackoffRetry 类来实现指数退避重试策略。在任务执行失败时,根据重试次数获取相应的延迟时间,等待后再次尝试执行任务。

分布式环境下的考虑

分布式任务调度

在分布式环境中,多个节点可能同时运行任务消费者。为了避免任务被重复执行,除了使用 Redis 分布式锁外,还可以采用分布式任务调度框架。例如,Celery 是一个广泛使用的分布式任务队列框架,它可以与 Redis 集成,实现任务的分布式调度。

Celery 通过消息代理(如 Redis)来接收和分发任务。每个 Celery 工作节点(worker)从消息代理中获取任务并执行。在任务重试方面,Celery 提供了丰富的重试机制配置选项,可以方便地实现各种重试策略。

数据一致性

在分布式环境下,由于网络延迟、节点故障等原因,可能会出现数据一致性问题。例如,在任务重试过程中,可能会出现部分数据已经成功写入 MySQL,但由于后续重试失败导致数据不一致。

为了保证数据一致性,可以采用以下几种方法:

  1. 事务机制:在执行 MySQL 操作时,将相关的操作放在一个事务中。如果任务执行失败,回滚事务,确保数据的一致性。
  2. 幂等性设计:设计任务时,使其具有幂等性。即多次执行任务对系统的影响是相同的,不会产生额外的副作用。例如,对于插入操作,可以先查询数据是否已经存在,如果存在则不进行插入;对于更新操作,可以根据版本号等信息进行条件更新,确保不会重复更新。
  3. 分布式事务:如果涉及多个 MySQL 数据库或其他分布式系统的操作,可以使用分布式事务框架,如 Seata 等。Seata 提供了 AT、TCC 等多种分布式事务模式,可以有效地解决分布式环境下的数据一致性问题。

性能优化

批量处理任务

为了提高任务处理效率,可以采用批量处理任务的方式。任务消费者从 Redis 任务队列中一次取出多个任务,然后批量执行这些任务。在 MySQL 中,可以使用 executemany 方法来批量执行 SQL 语句。

以下是修改后的任务消费者代码,展示如何批量处理任务:

import redis
import json
import mysql.connector
import time


class ExponentialBackoffRetry:
    def __init__(self, base_delay=1, max_delay=60):
        self.base_delay = base_delay
        self.max_delay = max_delay
        self.current_delay = base_delay

    def get_delay(self, retry_count):
        delay = self.base_delay * (2 ** retry_count)
        return min(delay, self.max_delay)


def consume_task():
    r = redis.Redis(host='localhost', port=6379, db=0)
    retry_policy = ExponentialBackoffRetry()
    batch_size = 10
    while True:
        tasks_json = r.lrange('mysql_task_queue', 0, batch_size - 1)
        if not tasks_json:
            time.sleep(1)
            continue
        tasks = [json.loads(task_json) for task_json in tasks_json]
        lock_keys = []
        for task in tasks:
            lock_key = f"task_lock:{task['sql']}:{task['params']}"
            lock_acquired = r.setnx(lock_key, 1)
            if not lock_acquired:
                for key in lock_keys:
                    r.delete(key)
                r.lpush('mysql_task_queue', *tasks_json)
                break
            lock_keys.append(lock_key)
        else:
            try:
                cnx = mysql.connector.connect(user='root', password='password', host='127.0.0.1', database='test')
                cursor = cnx.cursor()
                for task in tasks:
                    cursor.execute(task['sql'], task['params'])
                cnx.commit()
                cursor.close()
                cnx.close()
                for key in lock_keys:
                    r.delete(key)
                r.ltrim('mysql_task_queue', batch_size, -1)
                print(f"{batch_size} tasks executed successfully.")
            except mysql.connector.Error as err:
                print(f"Error: {err}")
                for key in lock_keys:
                    r.delete(key)
                r.lpush('mysql_task_queue', *tasks_json)
                retry_delay = retry_policy.get_delay(0)
                time.sleep(retry_delay)
            except Exception as e:
                print(f"Unexpected error: {e}")
                for key in lock_keys:
                    r.delete(key)
                r.lpush('mysql_task_queue', *tasks_json)
                retry_delay = retry_policy.get_delay(0)
                time.sleep(retry_delay)


if __name__ == "__main__":
    consume_task()

在上述代码中,任务消费者一次从 Redis 任务队列中取出 batch_size 个任务,获取每个任务的锁。如果所有任务的锁都获取成功,则批量执行这些任务。如果有任何一个任务的锁获取失败,则将所有任务重新放回队列。

优化 Redis 操作

  1. 减少 Redis 命令次数:尽量合并 Redis 操作,减少与 Redis 的交互次数。例如,在获取任务和更新任务状态时,可以使用 Redis 的管道(Pipeline)功能,将多个命令一次性发送到 Redis 服务器,提高操作效率。
  2. 合理设置 Redis 数据结构:根据实际需求选择合适的 Redis 数据结构。例如,如果任务队列中的任务需要按照优先级执行,可以使用 Redis 的有序集合(Sorted Set)数据结构,通过设置不同的分数来表示任务的优先级。

监控与调优

  1. 监控任务执行情况:可以使用 Redis 的 INFO 命令获取 Redis 的运行状态信息,包括连接数、内存使用情况等。同时,在任务消费者代码中添加日志记录功能,记录任务的执行时间、执行结果、重试次数等信息。通过分析这些日志和监控数据,可以及时发现性能瓶颈和潜在问题。
  2. 调优 MySQL 配置:根据任务的特点和系统的负载情况,合理调整 MySQL 的配置参数。例如,调整 innodb_buffer_pool_size 参数可以优化 InnoDB 存储引擎的性能,提高数据读写速度。

错误处理与日志记录

详细的错误信息记录

在任务执行过程中,捕获到异常时应该记录详细的错误信息。这不仅有助于定位问题,还可以为后续的故障分析提供依据。在 Python 中,可以使用 Python 内置的 logging 模块来记录日志。

以下是在任务消费者代码中添加日志记录功能的示例:

import redis
import json
import mysql.connector
import time
import logging


logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')


class ExponentialBackoffRetry:
    def __init__(self, base_delay=1, max_delay=60):
        self.base_delay = base_delay
        self.max_delay = max_delay
        self.current_delay = base_delay

    def get_delay(self, retry_count):
        delay = self.base_delay * (2 ** retry_count)
        return min(delay, self.max_delay)


def consume_task():
    r = redis.Redis(host='localhost', port=6379, db=0)
    retry_policy = ExponentialBackoffRetry()
    batch_size = 10
    while True:
        tasks_json = r.lrange('mysql_task_queue', 0, batch_size - 1)
        if not tasks_json:
            time.sleep(1)
            continue
        tasks = [json.loads(task_json) for task_json in tasks_json]
        lock_keys = []
        for task in tasks:
            lock_key = f"task_lock:{task['sql']}:{task['params']}"
            lock_acquired = r.setnx(lock_key, 1)
            if not lock_acquired:
                for key in lock_keys:
                    r.delete(key)
                r.lpush('mysql_task_queue', *tasks_json)
                break
            lock_keys.append(lock_key)
        else:
            try:
                cnx = mysql.connector.connect(user='root', password='password', host='127.0.0.1', database='test')
                cursor = cnx.cursor()
                for task in tasks:
                    cursor.execute(task['sql'], task['params'])
                cnx.commit()
                cursor.close()
                cnx.close()
                for key in lock_keys:
                    r.delete(key)
                r.ltrim('mysql_task_queue', batch_size, -1)
                logging.info(f"{batch_size} tasks executed successfully.")
            except mysql.connector.Error as err:
                logging.error(f"MySQL error: {err}, tasks: {tasks}")
                for key in lock_keys:
                    r.delete(key)
                r.lpush('mysql_task_queue', *tasks_json)
                retry_delay = retry_policy.get_delay(0)
                time.sleep(retry_delay)
            except Exception as e:
                logging.error(f"Unexpected error: {e}, tasks: {tasks}")
                for key in lock_keys:
                    r.delete(key)
                r.lpush('mysql_task_queue', *tasks_json)
                retry_delay = retry_policy.get_delay(0)
                time.sleep(retry_delay)


if __name__ == "__main__":
    consume_task()

在上述代码中,我们使用 logging.basicConfig 配置了日志记录的基本设置,包括日志级别为 INFO,日志格式包含时间、日志级别和消息。在任务执行成功或失败时,记录相应的日志信息,其中失败时记录详细的错误信息和任务内容。

错误分类与处理

根据错误的类型和严重程度进行分类处理。例如,对于数据库连接错误,可以尝试重新建立连接后重试;对于数据完整性错误(如违反唯一约束),可以根据业务逻辑进行相应的处理,如跳过该任务或进行数据修正后重试。

以下是根据错误类型进行不同处理的示例代码:

import redis
import json
import mysql.connector
import time
import logging


logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')


class ExponentialBackoffRetry:
    def __init__(self, base_delay=1, max_delay=60):
        self.base_delay = base_delay
        self.max_delay = max_delay
        self.current_delay = base_delay

    def get_delay(self, retry_count):
        delay = self.base_delay * (2 ** retry_count)
        return min(delay, self.max_delay)


def consume_task():
    r = redis.Redis(host='localhost', port=6379, db=0)
    retry_policy = ExponentialBackoffRetry()
    batch_size = 10
    while True:
        tasks_json = r.lrange('mysql_task_queue', 0, batch_size - 1)
        if not tasks_json:
            time.sleep(1)
            continue
        tasks = [json.loads(task_json) for task_json in tasks_json]
        lock_keys = []
        for task in tasks:
            lock_key = f"task_lock:{task['sql']}:{task['params']}"
            lock_acquired = r.setnx(lock_key, 1)
            if not lock_acquired:
                for key in lock_keys:
                    r.delete(key)
                r.lpush('mysql_task_queue', *tasks_json)
                break
            lock_keys.append(lock_key)
        else:
            try:
                cnx = mysql.connector.connect(user='root', password='password', host='127.0.0.1', database='test')
                cursor = cnx.cursor()
                for task in tasks:
                    cursor.execute(task['sql'], task['params'])
                cnx.commit()
                cursor.close()
                cnx.close()
                for key in lock_keys:
                    r.delete(key)
                r.ltrim('mysql_task_queue', batch_size, -1)
                logging.info(f"{batch_size} tasks executed successfully.")
            except mysql.connector.Error as err:
                if err.errno == mysql.connector.errorcode.CR_SERVER_LOST:
                    logging.warning("Database connection lost, retrying...")
                    time.sleep(5)
                    r.lpush('mysql_task_queue', *tasks_json)
                elif err.errno == mysql.connector.errorcode.ER_DUP_ENTRY:
                    logging.warning("Duplicate entry error, skipping task...")
                    for key in lock_keys:
                        r.delete(key)
                    r.ltrim('mysql_task_queue', batch_size, -1)
                else:
                    logging.error(f"MySQL error: {err}, tasks: {tasks}")
                    for key in lock_keys:
                        r.delete(key)
                    r.lpush('mysql_task_queue', *tasks_json)
                    retry_delay = retry_policy.get_delay(0)
                    time.sleep(retry_delay)
            except Exception as e:
                logging.error(f"Unexpected error: {e}, tasks: {tasks}")
                for key in lock_keys:
                    r.delete(key)
                r.lpush('mysql_task_queue', *tasks_json)
                retry_delay = retry_policy.get_delay(0)
                time.sleep(retry_delay)


if __name__ == "__main__":
    consume_task()

在上述代码中,我们针对 MySQL 连接丢失错误(CR_SERVER_LOST),等待 5 秒后重新将任务放回队列重试;对于重复插入错误(ER_DUP_ENTRY),跳过该任务并继续处理其他任务。对于其他类型的 MySQL 错误和意外错误,按照常规的重试策略处理。

通过详细的错误记录和分类处理,可以使异步任务重试机制更加健壮和可靠,提高系统的稳定性和可维护性。