发布于 2026-01-06 2 阅读
0

系统设计面试题:实现一个调度器

系统设计面试题:实现一个调度器

人们常说,每个前端开发人员都必须实现自己的 JS 框架,每个 EPAM 开发人员都必须实现一个调度器。

假设我们正在开发一个流媒体平台(例如 Netflix),我们的业务团队要求我们实现两个重复性任务:

  1. 向付费用户收取月费。
  2. 将合作伙伴提交的视频上传到云存储,以便进行处理。

注意:后面的一些示例会使用 C#,但您无需掌握这门语言。大多数流行的语言/框架看起来都非常相似,代码适用于任何技术栈。

你们中的许多人在工作中都遇到过类似的挑战,最典型和常见的解决方案包括:

  • 使用定时器(线程)并编写自定义实现(适用于 EPAM 开发人员)。
  • 使用HangfireQuartz(适用于从事小型项目的经验丰富的开发人员)。
  • 使用外部进程外调度器,例如k8s cronjobs(适用于处理大型项目的经验丰富的开发人员)。

每种策略都有其独特的优点和缺点,我们稍后会进行比较。

首先,让我们定义一些常用词汇。

  • Scheduler简单来说,它指的是一个管理周期性运行的系统。作业会在特定触发条件发生时按计划运行。触发器用于支持各种调度选项(例如 Cron 定时任务、外部事件等)。
  • Job简单来说,就是执行你想要完成的任务。常见的任务示例包括对独立进程进行分布式计算、批量视频转换等等。
  • Business operation(转换视频,收取费用)。

需要注意的是,作业并不总是指操作,因此作业完成并不总是等同于操作结果。在某些情况下,一个作业可能只包含一个操作,但这并非总是如此。为了更好地理解这一点,我们来看一个收取费用的作业。假设您已成功收取了一笔费用(操作),但随后网络中断。调度器(例如 Quartz)将无法收到确认操作结果的信号,因此它会将该作业标记为未完成(或失败或状态未知),然后尝试再次运行该作业。这是一个操作已执行但作业未完成的示例,因为操作结果尚未记录。

值得注意的是,调度器并非运行作业的唯一方式。作业无需调度器即可在本地或按需运行。它可用于调试、单元测试和/或事件管理。

好的,回到调度器实现的问题。和其他面试一样,我们需要把手头的任务分解成更小的部分。我们还需要明确功能性需求和非功能性需求:

  1. 是要深入研究代码,还是只关注高层架构?
  2. 有哪些可用资源?正在使用哪些资源?
  3. 该系统是否已接入支付网关?
  4. 是否需要将上传情况通知其他系统?
  5. 计划开展的行动是长期行动还是短期行动?
  6. 这些操作会消耗资源吗?

付款

任何会影响用户资金的系统都必须可靠且稳定。性能延迟并非关键因素(例如:延迟 5 分钟扣款可能不会造成太大影响)。本任务的目标是实现一个作业并建模核心类。

首先,我们先从一些参数开始:

  • 总共有15万付费用户
  • 每日最高收费5000元。
  • 支付网关速度 = 500毫秒或更短时间内完成一笔支付。

计算结果显示,所有工作通常每天只需不到 40 分钟(5000 * 500 毫秒)即可完成。如果公司付费客户数量突然增加十倍会怎样?即便如此,任务完成时间仍然不到 4 小时。这意味着我们可以仅使用一个线程来执行任务(即所有付款可以按顺序进行,直到全部完成),而无需实时协调多个线程。

*** 注意:保持编程简洁对于提高效率和生产力至关重要。代码越少,开发速度越快,错误越少,操作越简便,调试工作也越少,最终能够为公司带来更高的利润率。

重要的是要考虑一些常见的错误和可能出错的情况:

  • 未发起用户收费,也未收取任何收入。
  • 用户被重复收费,需要退款。这种情况可能发生在两个并行任务同时处理同一订阅时。

在我们的项目中,我们已经有了所谓的“框架”payment servicesubscription service“设置”。我们希望重用现有的代码、模型等,以简化流程。这将使我们能够使用经过测试和验证的代码和模型,并根据我们特定应用程序的需求进行定制。


/* The job simply looks for ALL subscriptions that need to be
 charged, and then tries to charge a fee. We don’t know how, when,
 and how often we will run the job yet. But we don’t need those
 parameters in order to look at how to implement the job itself.
 Also since we want to process the payments sequentially, but 
 don’t know how many will have to be processed, we can code it to 
 just charge until all the job is complete in an infinite loop. */

while (true)
{
    /* Every payment should perform within its own scope 
       (database context and shared memory) to avoid any potential
       mistake when one thread is affecting another. */
    using var scope = _serviceScopeFactory.CreateScope();
    var paymentsService = scope.ServiceProvider.GetRequiredService<IPaymentsService>();

    try
    {
        /* The business operation can be split into two parts:
        First we’re looking for the subscription, we skip
        implementation of this method, but suppose the query 
        inside is looking for a subscription that is still active, 
        the last successful charge happened more than a month ago, 
        and there are no payment attempts within today. */
        var subscriptionId = await paymentsService.GetOldestOverdueSubscriptionIdAsync(cancellationToken);

       // After performing the search, if no subscriptions were found it means that we are done, the job is complete.
       if (subscriptionId == default)
            break;

        /* If, after performing the search, a subscription is
        found, the next thing to do is to charge money. We look
        for payment details, check that no other payments are 
        being processed simultaneously for this subscription, then
        lock the row and create a “payment attempt” entity. This 
        command will then be sent to the payment provider. The 
        result of charging will be a callback, in which all
        necessary status updates are performed. But this is out of
        the scope of our example job. */ 
        await paymentsService.ChargeForNextPeriodAsync(subscriptionId, cancellationToken);
    }
    catch (Exception ex)
    {
        /* Next we want to ensure the program will catch all
        errors. If we rethrow an exception during this process, it 
        should cause the job to fail. In our case, the job
        completion happens only when we’ve processed all pending
        subscriptions. */
    }
}
Enter fullscreen mode Exit fullscreen mode

public class PaymentAttempt
{
    public int PaymentAttemptId { get; set; }

    public decimal Amount { get; set; }

    public string CurrencyId { get; set; }

    /* Good practice is to save time zone information. If your
    company has more than one developer, or if you collaborate
    with other teams (or companies) in different time zones, 
    accounting for differences in time stamps is a critical 
    component of error-proof coding. Never assume that everyone
    knows about your conventions (using UTC, or adding a UTC 
    prefix to the name). This will save you from countless 
    mistakes and headaches. */
    public DateTimeOffset RequestedAt { get; set; }

    public DateTimeOffset? AcknowledgedAt { get; set; }

    public State StateId { get; set; }

    // Remember to add the row version (concurrency token) to make sure no data is overridden by mistake.
    [Timestamp]
    public byte[] RowVersion { get; set; }

    
}
Enter fullscreen mode Exit fullscreen mode
public class Subscription
{
    public int SubscriptionId { get; set; }

    public int CustomerId  { get; set; }

    

    /* In this example we are updating those properties 
        when a fee has been successfully charged. */
    public DateTimeOffset PaidUntilDate { get; set; }

    public int PaymentsLeftCount { get; set; }

    [Timestamp]
    public byte[] RowVersion { get; set; }

    [ForeignKey(nameof(PaymentPlanID))]
    public PaymentPlan PaymentPlan { get; set; }

    public List<PaymentAttempts> PaymentAttempts { get; set; }
}
Enter fullscreen mode Exit fullscreen mode

就是这样!这种方法的优点在于它不依赖于特定的框架或工具集。它可以作为控制台应用程序运行,也可以用于单元测试。最重要的是,并发作业可能会影响订阅。因此,在向支付网关发送请求之前,务必确保已锁定资源。所有现代数据库都支持资源锁定distributed locks,并且有数百个框架基于此。您可以在https://github.com/madelson/DistributedLock找到相关示例。这并非一项复杂的任务,但却是绝对必要的。

上传视频

每小时有 50 个视频上传到 SFTP 服务器。视频的可用时间至关重要。时间就是金钱,越快越好。视频首次上传时,必须确保其他系统收到通知。试想一下,如果你的流媒体服务商不理解这一点,你不得不苦苦等待自己喜欢的电视节目/剧集,那该有多糟糕。

对于这项任务,我们不能像处理支付数据那样,在一个线程中按顺序处理视频。此外,我们必须高度重视 SFTP/数据库/已发布事件之间的数据一致性,以防止对运营结果造成干扰。

这次我们不会编写这个案例的代码,而是专注于探索其高级组件。

成功完成这项工作需要 3 个简单的步骤:

  1. 程序启动时,会查找第一个未被其他作业锁定的文件。为此,我们会锁定该文件(例如,授予写入权限,即使我们只读取该文件,也会限制其他线程的访问),然后如果该文件尚未上传到云存储,则会将其上传到云存储。
  2. 如果数据库(SQL)中尚不存在该记录,则创建该记录。
  3. 从 sftp 中删除该文件。

除此之外,我们还有Kafka ConnectSQL 连接器,它会等待表中所有插入命令,并将结果发送到公司内的其他系统。听起来很简单,对吧?记住,解决问题并不总是需要编写代码,这一点很重要。

如果某个步骤出现故障导致作业停止运行,另一个进程(作业)将重复执行该操作序列,数据/文件不会丢失或重复。

采用这种方法的好处有:

  • 文件只会上传一次。
  • 文件上传后,数据库中就会有相应的记录。
  • 使用变更数据捕获 (CDC),文件上传事件将通过 Kafka 分发到其他子系统。

调度程序

现在我们来谈谈如何运行这些作业。

与应用内置的调度器相比,自研调度器绝非最佳选择。因为调度器与领域无关,你很可能无法为现有框架带来任何新功能。

那么,应用内调度器还是进程外调度器?你会选择哪一个?这主要取决于团队和你目前使用的工具。

应用内框架(Quartz、Hangfire)

  1. 它会轮询数据库、锁定表等等。这会给数据库带来额外的负载(虽然不严重,但还是会降低系统的响应速度)。
  2. 如果您有很多域服务,则需要为每个域服务安装调度程序 -> 每个数据库中都会有许多重复的非域表。
  3. 它将存储有关作业的序列化信息。这种方法灵活性较差,因为您必须考虑版本控制。
  4. 如果需要添加或更改作业,通常需要修改代码,可以通过在 API 中创建额外的端点来实现,也可以在每次程序启动时进行修改。
  5. 它可以通过数据库实现分布式锁来保证作业级别的并发性,但你仍然需要在业务运营中考虑这一点。
  6. 它需要外部存储设备。
  7. 代码依赖于框架、接口、属性等等。此外,框架通常使用反射来运行任务。这可能会导致与依赖注入容器相关的其他问题。
  8. 它与特定语言有关。如果您想使用其他语言,则需要使用其他调度程序。这可能会严重影响公司运营。

外部调度器(k8s cron作业)

  1. 这很大程度上取决于基础设施、作业配置经验以及团队的技术成熟度。
  2. 它内置的分析和可视化工具很差。
  3. 无法保证并发性。

我们来谈谈我所说的“团队技术成熟度”是什么意思吧?

假设我们决定每小时运行一次支付任务,最大并发数设置为 1;每秒运行一次视频任务,最大并发数设置为 50(如果您只有一个应用实例并使用应用内调度器,这是不可能的,因为您可能没有足够的 RAM)。您将任务运行时间设置activeDeadlineSeconds为 1 小时(您假设您的任务总是短于 1 小时)。

可能会出什么问题呢?
首先,除非你设置了正确的startingDeadlineSeconds属性,否则 CronJob 在暂停足够长的时间后不会再次运行。这意味着,如果任务由于某种原因耗时超过 1 小时才完成,并且这种情况发生多次,CronJob 可能会根据默认属性决定不再运行它!因此,你必须非常小心,避免此类错误。

结论

如果你有一个老旧的单体 .NET 应用,团队核心成员是初级到中级开发人员,基础设施不够成熟(例如主要使用 Netlify 或 Firebase),那么应用内调度器(Quartz 或 Hangfire)是可以接受的。事实上,在某些情况下,它甚至可能是更佳选择。但考虑到应用的更新、技术的成熟度和基础设施的完善,我始终会选择外部调度器(Kubernetes cronjob)。

如你所见,我们谈论调度时,远不止设置一个简单的定时器那么简单。保持正确的视角,从底层(从基础设施和数据库中结束并发标记)开始思考,然后再向上追溯。这样做可以确保你的实现即使在环境发生变化时也能正常运行。

文章来源:https://dev.to/dbolotov/system-design-interview-question-implementing-a-scheduler-4dh3