为了账号安全,请及时绑定邮箱和手机立即绑定

构建一个可伸缩、可靠且成本效益高的异步任务调度系统

介绍

欢迎再次光临我的博客!😁 这里是我谈论我在工作中遇到的工程问题的地方。我之所以这样做主要是因为解决这些问题让我感到兴奋。通过识别低效、瓶颈和挑战,我遇到了一个在软件工程中虽然常见但却非常关键的问题。

那个问题是需要异步执行动作——通常需要精确的定时,有时还需要周期性执行。遵循我解决问题的核心策略(跨越空间和时间),我决定构建一个既能处理单一动作又能扩展到各种用例的解决方案。无论是发送通知、处理交易、触发系统工作流等,许多任务都需要计划执行。如果没有强大的调度机制,高效处理这些任务很快就会变得复杂、不可靠且成本高昂。

为了应对这个问题,我着手创建一个可扩展、可靠且经济实惠的事件安排器——一个能无缝处理延迟、即时和重复任务的调度器。

在这篇文章中,我将带你一步步看:

  • 导致需要事件安排器的问题所在
  • 理想解决方案所需的功能和非功能需求
  • 实现时所考虑的系统设计和架构决策

到最后,你将清楚地了解如何构建一个无服务器定时任务系统,确保准确性、耐久性和可扩展性的同时,控制成本支出。让我们开始吧!

GIF
这是一张动图的链接。

管理订阅变化跨越一个日历周期

订阅管理伴随着独特的挑战,特别是在处理取消或降级请求时 😭。用户可以在他们的账单周期中的任何时间提出取消或降级请求,但由于 预付订阅的性质,此类修改只能在一个周期结束时生效。这种延迟需要 异步处理 —一个可以立即记录这些请求,但再在适当的时间执行的系统。

解决办法:一个合理的调度安排

没有适当的调度机制,高效管理这些延迟动作会变得很困难。系统必须确保每个请求在正确的时间执行,同时避免遗漏或重复执行。此外,频繁的任务,比如批量处理多个预定变更——必须处理而不会压垮系统。为了解决这些问题,我们需要一个可靠、可扩展且成本效益高的调度程序,能够无缝处理延迟和重复任务

图片说明

……

功能要求:定义核心功能

一个健壮且可扩展的定时任务系统必须能够高效地安排、执行、更新、监控和重试任务,确保其可靠和灵活。

1. 创建和安排任务

系统必须允许用户安排所要完成的动作:

  • 操作类型、执行时间、执行的数据和所需的元数据(如适用)。
  • 可选字段如重复执行、执行频率和剩余执行次数。
  • 早期验证以确保操作的执行符合要求。

2. 更新或删除一个动作

用户可以在动作执行前2分钟内更新或删除该动作。一旦锁定,就无法再进行任何修改。

3. 行动状态的管理

每个操作都必须具备一个内部管理的状态,来反映其执行进度。状态的变化和结果必须记录在元数据中以便追踪。

4. 行动落实映射

每个动作必须对应于一个负责其执行的具体执行服务。没有对应执行服务的动作必须被标出以避免执行错误。

5. 重试失败

失败的操作必须使用指数退避重试以处理暂时性失败。如果重试次数超过上限,则需要人工介入处理。

关于即时动作、延期动作和重复行为

系统需要区分即时行动和延迟行动,以确保及时执行,以保证操作的时效性。

  • 需要在2分钟内执行的动作必须实时处理,不能有任何调度延迟。
  • 需要在2分钟后执行的动作必须在正确的时间安排处理。
  • 需要重复执行的动作必须按照要求的次数和频率重复执行。

……

非功能需求(NFR):确保系统可靠且可扩展

一个预定动作系统需要满足关键的NFRs,以保证其可靠性、可扩展性、安全性和可维护性。

1. 可靠性和持久性

  • 操作必须正确且准时(±2分钟内)执行。
  • 重复的操作必须按照计划准确执行
  • 失败的操作必须采用指数回退策略重试,非重复的操作必须只执行一次

2. 可伸缩性

  • 系统必须能够根据需求自动扩展以处理高请求负载。
  • 无服务器架构能够确保成本效益和灵活性。
  • 必须使用基于队列的方式(例如 AWS SQS)来控制执行频率,以避免下游服务因负荷过大而崩溃。

3. 可用性

  • 系统必须始终保持在线,没有任何冷启动时间,确保在需要时可以立即运行。无服务器架构可以以合理成本支持这一点。

4. 安全:

  • 签名验证必须保护请求的安全性,并防止未授权执行行为。

5. 维护性

  • 系统必须是模块化、封装化且有组织的,并置于一个单一仓库中。
  • 基础设施和数据库索引规则必须被明确界定。
  • 必须使用类型化语言来提高可靠性。
  • 必须启用本地测试,并使用加密的环境变量。
  • 启动脚本可以自动化包的安装和环境的设置。
  • 全面的测试必须确保安全的更改和集成。

6.可观察性

  • API端点必须暴露:

  • 所有预定的操作

  • 按状态筛选的操作

  • 失败操作的重试选项

  • 必须有一个集中日志系统来一致追踪执行问题。

此处为空

工具:驱动计划动作系统

架构图如下。架构图

AWS Lambda:无服务器计算

  • 支持无需管理服务器的事件驱动执行。
  • 负责动作调度和验证。
  • 通过实时事件流处理即时动作。
  • 在预定的执行时间执行延迟动作。
  • 根据动作类型管理相关任务。

Amazon EventBridge 服务:管理定时任务

  • 充当延迟操作的调度器。
  • 每5分钟检查一次到期的待处理操作,并将它们加入处理队列。
  • 确保操作在预定时间前后2分钟内执行。

亚马逊 sqs:用于扩展的队列任务

  • 解耦执行任务,通过异步处理预定的动作。
  • 控制请求频率,以防止系统过载情况。
  • 使用 FIFO(先进先出)机制来保持执行顺序并避免重复执行。

Amazon DynamoDB:存储预定行动

  • 作为存储预定操作的主要数据库。
  • 提供快速读写操作以处理高负载。
  • 存储元数据以跟踪执行情况、重试和结果。
  • 使用DynamoDB Streams来触发即时执行。

亚马逊API Gateway:管理端点的公开与配置

  • 提供用于创建、更新和删除计划操作的 HTTP 端点。
  • 提供监控端点以按状态检索操作并重试失败的请求。
  • 通过认证和授权机制确保这些端点的安全访问。

系统设计:用于计划任务的数据库结构

字段 描述
id 每个计划操作的唯一标识符。
data 存储执行特定详情信息。
action 定义要执行的操作类型。
executionTime 指定操作的执行时间。
repeat 指示操作是否需要重复执行。
frequency 定义重复操作的时间间隔周期。
executionRemainder 跟踪剩余执行次数。
status 执行状态("PENDING","IN_PROGRESS","COMPLETED","FAILED")。
createdAt 创建操作的时间戳。
updatedAt 最后修改的时间戳。
retryCount 失败执行重试的次数。
metadata 存储日志和额外的执行详情。

示例:计划通知动作

{
    "data": {
        "mobile": "60123456789",
        "subject": "测试主题",
        "name": "Joojo",
        "templateType": "用户逾期付款提醒",
        "notificationType": "SMS"
    },
    "repeat": true,
    "frequency": "每日",
    "executionRemainder": 5,
    "action": "发送通知",
    "executionTime": 1736930117120
}

点击进入全屏模式 点击退出全屏模式

项目组织

我采用了一种分层模块化的方法来提高可维护性、可扩展性和方便更改。很多时候,不同的团队需要在不影响其他部分的情况下扩展服务,引入他们自己的更改。我通过将组件拆解并组织成独立的模块来实现这一点。接下来我们详细探讨一下。

1. 具有模块化设计的单一应用程序

整个系统被设计为一个单一的应用程序,但采用了模块化结构,以便分离关注点。每个模块都负责系统的一个特定方面,从而使代码库更易于导航和修改,更便于维护。

    ./src
    ├── app.ts
    ├── clients(客户端)
    ├── config(配置)
    ├── controllers(控制器)
    ├── handlers(处理器)
    ├── helpers(辅助工具)
    ├── middleware(中间件)
    ├── models(模型)
    ├── routes(路由)
    ├── service(服务)
    ├── types(类型)
    └── utils(工具)

全屏模式退出全屏

2. 无服务器处理程序的分布式处理:

该项目基于AWS Lambda设计,导出不同的处理程序并进行结构化设计,以实现计划动作的无缝执行和独立处理。这些处理程序确保各种任务独立处理,提高了容错性和扩展性。

  • 操作处理器:管理创建、调度、检索、更新、删除和处理计划操作。这将所有与操作相关的逻辑集中在一起,便于修改而不影响系统其他部分。
  • 延迟操作处理器:专门处理需要在稍后的时间启动的操作。这种分离确保延迟操作能够高效地调度和处理,而不干扰实时执行。
  • 即时操作处理器:触发必须在在2分钟内开始的操作执行,利用DynamoDB流检测变更并立即启动执行。这确保了紧急任务能够及时处理。
  • 履行处理器:确保计划操作通过与适当的履行服务交互来正确执行。这种设计使得履行逻辑能够独立于操作调度进行发展。
    ├── handlers
    │   ├── fulfillment.ts
    │   ├── initiate-scheduled-actions.ts
    │   ├── initiate-stream-actions.ts
    │   └── process.ts
    │   └── http-apis.ts

全屏模式 退出全屏

3. 通过分离关注点提高可维护性

每个项目模块都是独立的,这意味着修改一个组件不会直接影响其他组件。这减少了破坏现有功能的可能性,同时也简化了调试。

  • 控制层 处理请求路由和执行逻辑。
  • 业务服务 管理业务逻辑和数据交互。
  • 客户端应用 与外部服务如数据库、队列和 API 进行交互。
  • 数据模型 定义系统中使用的数据结构。
  • 中间件层 确保请求通过验证和认证层。
  • 工具函数 提供可重用的辅助函数,如日志记录、错误处理和重试。
文件夹结构如下:
./src
├── clients (客户端代码)
├── controllers
├── middleware
├── models
├── service
└── utils

点击进入全屏模式 点击退出全屏

4. 易于扩展性

采用模块化设计,无需改动核心组件就可以添加新功能。比如:

  • 可以通过在履行服务模块中添加新的操作来引入一种新的计划类型的操作,而无需修改现有的调度或队列逻辑。
  • 可以通过在客户端模块中扩展来实现新的外部服务整合,确保与第三方系统的无缝对接。

图片说明(点击此处查看图片)

……

延迟任务:确保按时完成

系统通过定期运行高效地处理定时任务,确保所有待处理的任务都能准时执行,不会出现延迟。

定期执行的计划任务

  • Lambda 函数 定期扫描数据库,查找具有 等待中 状态且 执行时间 到期的动作。
  • Amazon EventBridge5 分钟 触发一次该 Lambda 函数,以确保动作能够及时被处理。
  • 该函数将这些 等待中的动作 入队到 Amazon SQS,确保了可靠和可扩展的执行流程。

为什么这种方法管用?

  • 高效的批处理 确保可以一次处理多个操作。
  • 可扩展性 通过将执行与 SQS 解耦来维持,队列对于处理下游系统的负载非常关键。
  • 状态管理:操作遵循生命周期 (PENDING → IN_PROGRESS → COMPLETED/FAILED/NO_ACTION),每个状态都会持久化到数据库以便于跟踪和恢复。
  • 执行管理:成功的执行会被标记为 COMPLETED,失败的执行会被标记为 FAILED,重复的操作在更新其剩余执行次数后重置为 PENDING
  • 自动重试:失败的操作使用 指数退避 进行重试。如果重试次数超过限制,操作将保持 失败 状态直到手动重置。
  • 幂等性和数据完整性:执行计数器防止重复执行,并阻止无效操作(例如:负的执行计数器)。
  • 可观察性:元数据存储日志、执行时间戳、API响应和失败原因等信息,便于调试。

实时处理:使用DynamoDB Streams处理实时操作

某些计划的操作如果它们创建后2分钟内需要执行,则需要进行立即处理。为了高效地处理这些操作,系统利用了DynamoDB Streams和AWS Lambda进行实时处理。

……

1. 立即执行是怎么运作的

  • DynamoDB Streams 检测数据库中的更改,当有新的操作被插入或修改时。
  • Lambda 函数监听这些更改,处理新的操作,并判断是否需要立即执行。
  • 如果一个操作计划在 2分钟内 执行,Lambda 函数会 将其放入 Amazon SQS 队列中 以进行执行。

……

2. 拆解处理逻辑

监听 DynamoDB 流

每当 DynamoDB 中的新记录被插入或更新时,initiateProcessFromDynamoStream 函数就会被触发。

    export const 从DynamoDB流启动处理 = async (
      event: DynamoDBStreamEvent,
    ): Promise<void> => {
      try {
        const { Records } = event;
        if (!Records || Records.length === 0) {
          console.log("没有记录需要处理的DynamoDB流事件,这里结束。");
          return;
        }
        console.log(`接收到${Records.length}条记录了。`);

进入全屏,退出全屏

  • 该函数检查事件中是否新增了记录
  • 如果没有新记录,函数会提前退出。

处理每条记录

该函数逐一遍历每个记录,提取其详细信息,并决定是否需要处理它。

    const processingPromises = Records.map(async (record: DynamoDBRecord) => {
      const { eventName, dynamodb } = record;

      if (!dynamodb?.NewImage) {
        console.log("缺少 NewImage,跳过此记录。");
        return Promise.resolve();
      }

      const cleanedImage = unmarshall(dynamodb.NewImage as Record<string, any>);
      console.log(
        "清理后的 NewImage:",
        JSON.stringify(cleanedImage, null, 2),
      );

      if (!["INSERT", "MODIFY"].includes(eventName || "")) {
        console.log(`跳过 eventName ${eventName} 的记录。`);
        return Promise.resolve();
      }
      console.log(`处理 eventName ${eventName} 的记录。`);

全屏模式退出全屏

  • 该函数从DynamoDB流中提取出新增或更新的记录
  • 过滤掉无关的记录,即那些没有NewImage或不是新插入/修改的记录。

检查立即执行

该函数通过计算时间差来检查操作是否需要立即执行。

    const { status, retryCount, id, executionTime } = cleanedImage;

    // 检查执行时间是否在缓冲时间内
    const 当前时间 = Date.now();
    const timeUntilExecution = executionTime - 当前时间;

    if (timeUntilExecution > TWO_MINUTES_IN_MS) {
      console.log(`跳过id为${id}的记录:执行时间不在2分钟缓冲时间内。`);
      return Promise.resolve();
    }

进入全屏模式,退出全屏

  • 当前时间与动作的 执行时间 进行对比。
  • 如果动作 距离当前时间超过2分钟 ,则会被 跳过 (稍后会在定期检查时处理)。
  • 如果动作 需要马上执行 ,则继续进行处理。

确保状态有效及重试次数限制

    if (!status || ![STATUSES.PENDING].includes(status)) {
      console.log("跳过记录:缺少或无效的状态信息。");
      return Promise.resolve();
    }

    if (retryCount !== undefined && retryCount > CONSTANTS.MAX_RETRY) {
      console.log(`跳过记录:重试次数超出最大限制:${retryCount}`);
      return Promise.resolve();
    }

    if (!id) {
      console.log("跳过记录:缺少记录ID。");
      return Promise.resolve();
    }

全屏 退出全屏

  • 确保动作处于有效的PENDING状态后再进行处理。
  • 检查是否重试次数已超出限制以防止无限重试。
  • 确保动作具有有效的ID后再将其放入队列中。

将该操作发送到SQS执行

    try {
      await sendMessage(cleanedImage);
    } catch (error: any) {
      await fail(id, `发送图片失败: ${error.message}`);
    }

全屏模式 退出全屏

  • 如果该操作可以立即执行,它将被发送到SQS,并由履行服务处理。
  • 如果遇到SQS故障,该操作会被标记为失败状态并记录以供调试。

    • *

3. 为什么这种方法可靠

  • 实时执行: 在 2 分钟内安排的动作会立即执行,而不是等待定期检查。
  • 自动过滤: 需要稍后执行的动作将被 跳过,并在适当的时候由 EventBridge 处理。
  • 错误处理: 如果动作 无法加入到 SQS 队列 ,它将被标记为 失败状态 而不是丢失。
  • 可扩展性: Lambda 函数可以 同时处理多个事件,确保没有动作被遗漏。

频繁动作管理:处理重复动作的执行

有些定时任务需要在固定的时间间隔重复运行。系统通过三个关键字段来处理重复执行:

  • repeat – 表示动作是否需要重复执行。
  • executionRemainder – 跟踪动作还需执行几次。
  • frequency – 定义执行之间的间隔时间。

    • *

1. 处理重复执行任务

complete(id, notes) 这个函数负责管理动作的完成情况。如果动作被设定为重复,它会更新执行时间和剩余执行次数。

扣除执行余量

    const newExecutionRemainder = repeat && executionRemainder > 0 ? executionRemainder - 1 : 0;
    if (newExecutionRemainder < 0) {
      throw new Error("你不能将执行剩余次数设为负数。");
    }

全屏 退出全屏

  • 如果动作是反复执行的,执行余数减1
  • 如果余数小于0,会抛出错误以避免意外情况。

2. 完成最后的执行

    if (repeat && newExecutionRemainder === 0) {
      await update(id, {
        status: STATUSES.COMPLETED,
        ttl: calculateTTL(), // 计算过期时间
        executionRemainder: 0,
        metadata: {
          ...metadata,
          executionResponses: [...(metadata?.executionResponses || []), notes], // 执行响应
        },
      });

      console.log("最终执行已成功完成:", id);
      return;
    }

全屏模式 退出全屏

  • 如果没有剩余的执行,操作将被标记为完成
  • TTL(Time-To-Live) 设置为两周后自动删除记录
  • 更新执行元数据以实现跟踪和可观测性。

3. 安排下一次运行:

如果这个动作还没有执行完,函数就会安排下一次执行。

    if (repeat && newExecutionRemainder > 0 && frequency) {
      const frequencyInMs = getFrequencyInMilliseconds(frequency);
      if (!frequencyInMs) {
        throw new Error(`无效的频率值: ${frequency}`);
      }

      await update(id, {
        status: STATUSES.待处理,
        executionTime: executionTime + frequencyInMs,
        executionRemainder: execution余量,
        metadata: {
          ...metadata,
          executionResponses: [...(metadata?.executionResponses || []), notes],
        },
      });

      console.log("动作已成功更新:", id);
      return;
    }

全屏模式 退出全屏

  • 执行时间通过添加频率字段中的间隔进行更新。
  • 动作状态被设置为等待,以便它可以再次被处理。
  • 元数据被更新以记录执行历史信息。

    • *

4. 频变

系统将预设的频率转换成毫秒,从而更新执行时间。

    // 定义频率持续时间
    const frequencyDurations: Record<string, number> = {
      // 十分钟持续时间
      十分钟: 10 * 60 * 1000,
      // 每小时持续时间
      每小时: 60 * 60 * 1000,
      // 每天持续时间
      每天: 24 * 60 * 60 * 1000,
      // 每周持续时间
      每周: 7 * 24 * 60 * 60 * 1000,
      // 每月持续时间
      每月: 30 * 24 * 60 * 60 * 1000,
    };

全屏 退出全屏

这可以根据预先设定的时间间隔灵活安排。


5. 保障幂等性和数据的完整性

对于一次性操作,系统确保只执行一次。

    await update(id, {
      status: STATUSES.COMPLETED, // 状态已完成
      ttl: calculateTTL(),
      metadata: { ...metadata, notes },
    });
    console.log("操作已完成,带TTL的ID为:", id);

全屏/退出全屏

  • 不会重复的动作会立即标记为已完成
  • TTL 确保数据在被删除前只保留有限时间。

    • *

为什么这个方法有效

  • 自动调度 – 系统会自动设定下一次执行时间。
  • 防止过度执行 – 当余数为时,执行会停止。
  • 高效跟踪 – 每次执行都更新元数据,方便调试和监控。
  • 数据完整性 – 确保频率值有效,并正确减少执行余数。

看看这张图

动作执行:通过消除重复确保可靠运行

系统利用 Amazon SQS FIFO 队列Redis 去重机制 来处理预定任务,以确保每个操作只被执行一次,防止重复执行。


1. 使用 FIFO 队列处理操作

  • 消息被发送到一个Amazon SQS FIFO 队列,确保所有操作遵循先进先出顺序进行处理。
  • FIFO 队列防止重复消息处理,防止同一个消息被处理多次。
  • 这种方法非常适合需要严格顺序处理和确保消息只被处理一次的情况。

    • *

2. 利用 Redis 来实现去重

如果不使用FIFO队列的话,系统会用Redis来处理去重问题,然后将消息发送到标准的SQS队列。

Redis 如何去重
  • 每条消息根据以下内容分配一个去重ID(deduplication ID)

  • 唯一标识符

  • 状态

  • 可选的重试次数
    const 去重ID = `${messageBody.id}-${messageBody.status}-${messageBody.retryCount || 0}`;
    const redisKey = `sqs-deduplication:${去重ID}`;

全屏,退出

  • 在将消息发送到SQS之前,Redis 会检查去重ID是否已经存在
    const redisCheck = await RedisClient.get(redisKey);
    if (redisCheck.success && redisCheck.data) {
      console.log(
        `检测到重复消息如下,跳过 ID 为 ${deduplicationId} 的消息发送`,
      );
      return;
    }

进入全屏 退出全屏

  • 如果发现重复,消息就不会发送,避免重复处理。

    • *

3. 给SQS发消息

如果该操作不重复,它将被发送到SQS队列进行处理。

    const command = new SendMessageCommand({
      QueueUrl: queueUrl,
      MessageBody: JSON.stringify(messageBody),
    });
    await executeSQSCommand(command);
    console.log(`消息已成功发送到队列 ${queueUrl}`);

全屏模式(按ESC退出)。

退出全屏模式

  • 系统确保消息不会出现不必要的重复。
  • 操作进入队列后会进入执行环节。

4. 在 Redis 中存储唯一数据

发送消息之后,去重ID会被存入Redis,并设置TTL为5分钟,以确保短期内不重复。

    await RedisClient.set(redisKey, true, 300); // 300秒也就是5分钟

进入全屏。退出全屏。

  • 较短的过期时间确保了需要时需要重试的操作仍然会被处理。
  • Redis 帮助管理临时的去重,同时不影响长期操作的执行。

5. 优雅地处理错误

如果向 SQS 发送消息失败了,根据失败类型处理错误的处理:

    if (error.name === "TimeoutError") {
      throw new AppError({
        ...CommonErrors.REQUEST_TIMEOUT,
        message: "发送消息到SQS时超时了。",
        metadata: { queueUrl, messageBody, error: error.message },
      });
    }
    throw new AppError({
      ...CommonErrors.INTERNAL_SERVER_ERROR,
      message: "发送消息到SQS失败了。",
      metadata: { queueUrl, messageBody, error: error.message },
    });

打开全屏 关闭全屏

  • 当出现超时时会采用特定的重试方法。
  • 其他失败情况会记录相关元数据以帮助诊断和解决相关问题。

为什么这样做有效

  • FIFO 队列确保时间敏感的任务能按严格的顺序进行并去重。
  • Redis 的去重功能在没有 FIFO 队列时防止不必要的重复处理。
  • 错误处理机制会在必要时自动重试消息。

执行操作:处理来自SQS的计划操作

一旦计划的操作达到执行时间,它们将由一个Lambda函数来执行处理,该函数从Amazon SQS队列读取消息。该函数确保操作被执行正确,并相应地更新其状态,并在需要时处理错误或重试操作。

1. 从SQS中的处理任务

fulfill函数监听来自SQS的事件,每个记录都表示一个需要执行的预定任务。

    export const fulfill = async (event: { Records: SQSRecord[] }): Promise<void> => {
      const { Records } = event;

      if (!Records || Records.length === 0) {
        console.log("如果没有SQS消息记录需要处理,直接返回。");
        return;
      }

切换到全屏, 切回正常显示

  • 该函数检查是否有需要处理的新记录
  • 如果没有记录,则直接退出。

2. 确保操作正确执行

对于队列中的每个动作:

  • 如果操作已超出 最大重试次数限制,则会被标记为失败状态。
    if (retryCount >= CONSTANTS.MAX_RETRY) {
      await handleFailure(
        id,
        receiptHandle,
        metadata?.retryReason || "已达最大重试次数",
      );
      continue;
    }

点击全屏 查看退出全屏

  • 如果该操作是首次执行,其状态将设为IN_PROGRESS(进行中)。
    // 如果这是第一次尝试
    if (retryCount === 0) {
        // 我们开始
        await start(id);
    }

点击查看全屏, 点击退出全屏

3. 处理不同类型的动作:

即使调度器不允许没有有效服务的情况下安排操作,也存在一种防御机制(NO_ACTION),用来处理操作被手动修改或在数据库中遭到破坏的情况。

  • 如果动作类型不被识别,就将其标记为无动作,并从队列中移出。
    if (!Object.values(ACTIONS).includes(scheduledAction?.action as Actions)) {
      await noAction(id);
      // noAction(id) 表示没有执行任何操作
      await deleteMessage(receiptHandle);
      // deleteMessage(receiptHandle) 表示删除消息
      continue;
    }

点击进入全屏,点击退出全屏

  • 如果这个操作有效,我们就按照它的类型来处理它。

处理一个普通的任务

    case ACTIONS.EXECUTE_TASK:
      result = await taskExecutionService.performTask(scheduledAction.data);
      await complete(id, result);
      break;

切换到全屏 退出全屏

  • 调用一个外部服务执行任务(例如,处理一个用户请求)。
  • 完成后标记该操作为完成

处理通知

    case ACTIONS.SEND_ALERT: // 发送警报
    {
      const { recipient, messageType, ...messageData } = scheduledAction.data; // 收件人、消息类型和消息数据
      const processedMessageData = processMessage(messageData); // 处理消息数据

      result = await notificationService.send(
        recipient,
        messageType,
        processedMessageData,
      ); // 发送通知

      await complete(id, result); // 完成发送操作
      break; // 结束发送操作循环
    }

全屏模式;退出全屏

  • 使用通知服务发送一个提醒或通知
  • 在执行后将操作标记为完成状态

4. 处理失败与重试

如果某个操作失败了,系统会执行指数回退重试,然后将其标记为永久失败。

将一个操作标记为失败

    const handleFailure = async (id: string, receiptHandle: string, reason: string): Promise<void> => {
      console.error(`ID为 ${id} 的操作已超过最大重试次数。失败了。原因:${reason}`);
      await fail(id, reason);
      await deleteMessage(receiptHandle);
    };

全屏
退出

  • 如果一个动作超过重试限制,它将标记为失败。
  • 消息会被移出队列,以防止进一步处理。

带退避的重试

    const handleProcessingError = async (
      id: string,
      receiptHandle: string,
      retryCount: number,
      error: any,
    ): Promise<void> => {
      console.error(`处理消息时遇到错误,消息ID为: ${id}。错误信息: ${error.message || error}`);

      await applyExponentialBackoff(retryCount, id);
      const actionMarkedForRetry = await retry(
        id,
        error instanceof AppError
          ? JSON.stringify(error?.metadata) || error?.message || error?.code || error?.name
          : `处理错误: ${error}`,
      );
      await sendMessage(actionMarkedForRetry);
      await deleteMessage(receiptHandle);
    };

全屏,退出全屏

  • 如果发生错误,系统将采用指数回退策略增加重试次数
  • 失败的操作将重新放入SQS队列进行重试。

5. 最终执行阶段

一旦一个动作顺利完成,它从SQS(简单队列服务)中移除。

await deleteMessage(receiptHandle);
console.log(`消息ID ${id} 的消息已成功处理。`);

全屏显示 退出全屏

  • 如果成功处理了SQS事件中的所有记录,则会输出汇总日志。
    console.log(`${Records.length} 条记录已处理。`);

进入全屏模式 退出全屏

为什么这种方法奏效

  • 确保每次操作都能被执行 – 每个操作在永久失败前都会带有退避策略的重试
  • 处理不同类型的操作 – 支持通知、任务、订阅更新以及其他类型的计划任务
  • 防止重复执行 – 使用SQS FIFO或Redis的去重功能来防止重复处理。
  • 可靠的态管理机制 – 用IN_PROGRESS、COMPLETED、FAILED或NO_ACTION四种状态更新数据库
  • 防御性机制NO_ACTION 是一种保护措施,以防操作在数据库中被人为更改。

展示层(Presentation层):为实现可观测性而暴露端点

展示层由一个单个的Lambda函数组成,该函数作为API,通过Amazon API Gateway暴露HTTP端点。这些端点允许用户观察、管理和交互计划操作,确保实时监控和管理。

1. 利用 API 网关公开 HTTP 接口

一个无服务器的API是使用AWS LambdaAPI Gateway构建的,提供着与计划任务相关的关键功能的访问权限。

  • 按状态和数量获取行动记录

  • 按其当前状态(例如待办、已完成、失败)获取操作。
  • 提供计数摘要以跟踪执行情况趋势。
  • 处理失败的操作。

  • 允许用户手动重新尝试失败的操作。
  • 确保失败的任务可以再次处理,而无需等待自动重试周期。
  • 删除

  • 提供了一个端点来移除旧的或不必要的操作
  • 帮助通过管理过期记录来保持数据库的干净。

2. 与监控面板集成在一起

这些端点所提供的数据可以在仪表板上进行可视化展示,实现实时监控。

图片说明

  • 显示各状态的动作计数以追踪性能。
  • 允许用户通过界面手动重试或删除操作
  • 提供对系统健康及执行可靠性的见解。

【图片说明】

构建预定行动系统面临的挑战

开发一个可靠且可扩展的定时任务系统会遇到一些需要仔细解决的挑战。

  • 所谓的 '竞态问题'

  • 当多个进程同时尝试更新或执行同一操作时,可能会发生不一致。
  • 合适的锁机制、去重处理以及FIFO队列有助于防止重复执行。
  • 在整个周期中的每个节点进行负载测试

  • 系统必须在高负载条件下进行测试,以确保调度、执行、重试和完成过程能够正确扩展。
  • 测试包括数据库性能、SQS消息处理、Lambda执行限制以及API响应时间
  • 由两个流和调度器获取的任务

  • 计划在执行前2分钟内执行的操作由DynamoDB Streams处理,其他操作则依赖于EventBridge
  • 如果没有适当的协调,可能会出现重复执行。确保操作正确地转换状态(从待处理到进行中再到已完成)可以避免这个问题。
  • 理解工具的局限性

  • 并发处理: AWS Lambda 自动扩展,但高并发可能会导致速率限制和处理延迟问题
  • Lambda 运行时限制: 由于 Lambda 有一个最大执行时间,长时间运行的任务必须拆分成更小的任务或交给后台任务处理

未来的改进

  • 实时通知 webhook

  • 实现webhooks可以让外部服务在某个操作执行、失败或重试时接收到实时更新
  • 这减少了轮询的需求,并提高了系统的响应速度。
  • 未完成操作的处理器

  • 一个专门的处理器,用于检测并处理仍处于PENDING状态已经错过了执行时间的任务。
  • 这确保了由于系统故障、延迟或扩展性问题等原因,不会永久错过任何预定的任务。

这些改进将提高系统的可靠性、可观察性,并增强与外部系统的集成,让调度系统更加可靠和稳定。

结论:安排时间、扩展规模和保持冷静 🚀

构建一个可靠、可扩展且容错的计划任务系统不仅仅是设置一个 cron 作业并期望一切顺利——而是要在流程的每一步中确保每一步的韧性。从调度、执行、重试到监控,每一个组件都必须协同工作,以确保每项任务都能完成,每个通知都会被处理,每一份订阅都能得到管理。

通过这段旅程,我们解决了以下问题:

动态调度功能 使用 DynamoDB Streams, EventBridge 和 SQS

精准执行任务 通过结合使用 即时和延迟任务

可靠处理 采用 去重、重试和指数级回退

可扩展性 通过利用 无服务器架构 来处理高流量。

Observability 提供 API 来 监控、重试和删除计划的操作

当然,每个系统都有它的quirks和挑战——竞态条件、工具局限性以及意外故障——但是通过采用合适的设计模式、防御性编程以及未来改进(例如webhooks和遗漏操作恢复),这个系统可以发展成为一个更加强大、智能和自主的调度程序。

说到底,自动化是为了让我们生活更轻松——无论是管理用户订阅、发送通知,还是处理时效性交易等。计算机从不休息,但我们肯定需要休息的时间,因此设计一个在凌晨三点钟叫醒我们之前能够自行解决问题的系统总是值得的。

所以让我们为那些在我们不操心时也能自己运转的系统干杯!庆祝这些系统能自己搞定一切!🎉

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消