运行部署时可拆分工作负载的单体架构


微服务会带来大量相关包袱问题(分布式系统问题、RPC 框架等)。如果我们想要在没有包袱的情况下享受微服务的好处,我们将需要一些替代解决方案。

规则 1:永远不要混合工作负载
首先,我们应该应用运行单体的基本规则,即:永远不要混合你的工作负载。
对于我们的incident.io应用程序,我们有三个关键工作负载:

  • 处理传入请求的 Web 服务器。
  • 处理异步工作的 Pub/Sub 订阅者。
  • 按计划触发的 Cron 作业。

我们曾经通过在同一个进程中运行所有这些代码(实际上是同一个 linux 进程)来打破这个规则。通过混合工作负载,我们可以:

  • 代码库特定部分的错误代码导致整个应用程序崩溃,就像我们 11 月的事件一样。
  • 如果我们部署一个 CPU 负载过大的 Pub/Sub 订阅者(可能压缩 Slack 图像,或者一个写得不好的无限循环),我们会影响整个应用程序,导致所有 web/worker/cron 活动减慢到停止。该进程中的 CPU 是一种有限的资源,通过消耗其中的 90%,我们将只剩下 10% 用于其他工作。

在事件发生的同一天,我们针对每种工作负载类型将我们的应用程序拆分为单独的部署层。这意味着在 Heroku 中创建三个独立的 dyno 层,对于那些不熟悉 Heroku 的人来说,这意味着应用程序的三个独立部署仅处理其自身类型的工作负载。

您可能会问,如果我们正在这样做,那为什么不完全采用单独的微服务呢?

答案是这种拆分保留了单体应用的所有优点,同时完全解决了我们上面提出的问题。每个部署都运行相同的代码,使用相同的 Docker 映像和环境变量,唯一不同的是我们运行启动代码的命令。

不需要复杂的开发设置,也不需要 RPC 框架,它是相同的旧单体,只是操作方式不同。

应用程序入口点代码看起来有点像这样:

package main

var (
  app     = kingpin.New("app", "incident.io")
  web     = app.Flag("web", "Run web server").Bool()
  workers = app.Flag("workers", "Run async workers").Bool()
  cron    = app.Flag("cron", "Run cron jobs").Bool()
)

func main() {
    if *web {
    // run web
  }
    if *workers {
    // run workers
  }
    if *cron {
    // run cron
  }

    wait()
}

您可以轻松地将其添加到任何应用程序,对于您的本地开发环境,您可以让所有组件在单个热重载进程中运行(这是许多微服务商店的白日梦!)。

作为盲目切换的一个小陷阱,请注意假设所有内容都在同一进程中运行的代码很难识别、存在微妙的错误并且难以修复。例如,如果您的 Web 服务器代码将数据存储在工作人员尝试使用的进程本地缓存中,那么您将有一段悲伤的时光。

好消息是这些依赖项通常是代码异味,通过将协调推送到外部存储(例如 Postgres 或 Redis)可以轻松解决,并且在您进行初始更改后不会再次出现。在我看来,即使您不拆分代码也值得做。

请注意,您拆分这些工作负载的粒度没有限制。我以前见过每个队列甚至作业类的部署,单个应用程序环境最多部署 20 个。

规则 2:应用护栏
好的,所以我们的整体不再是一大堆运行所有东西的代码:它是三个独立的、隔离的部署,它们可以独立地成功或失败。

即使拆分了您的工作负载,您也始终拥有需要某种形式保护的底层数据存储。这就是通常不共享任何东西的微服务可以提供帮助的地方,每个服务部署只能通过另一个服务 API 间接消耗数据库时间。

这在我们的单体中是可以解决的,我们只需要围绕资源消耗创建护栏和限制。可以任意粒度的限制。

在我们的代码中,我们的 Postgres 数据库周围的护栏如下所示:

package main

var (
  // ...
  workers = app.Flag("workers", "Run async workers").Bool()
  workersDatabase = new(database.ConnectOptions).Bind(
        app, "workers.database.", 20, 5, "30s")
)

func main() {
  // ...
  if *workers {
    db, err := createDatabasePool(ctx, "worker", workersDatabase)
        if err != nil {
            return errors.Wrap(err, "connecting to Postgres pool for workers")
        }

    runWorkers(db) // start running workers
  }
}

此代码设置并允许自定义专门用于工作人员的数据库池。默认值表示“最多 20 个活动连接,最多允许 5 个空闲连接,语句超时 30 秒”。

大多数应用程序都会为其连接池指定值,但关键的时刻是,我们为任何类型的工作准备了单独的池来限制或限制,预计它可能 - 在事件情况下 - 消耗过多的数据库容量并影响服务的其他组件。

  • eventsDatabase这是一个工作人员使用的 2 个连接池,该工作人员使用每个 Pub/Sub 事件的副本并将其推送到 BigQuery 以供以后分析。我们不关心这个队列落后,但如果它冲洗数据库,那将是非常糟糕的,特别是如果这种情况发生——而且它自然会发生——在我们的服务最忙的时候。
  • triggersDatabase一个 cron 作业使用 5 个连接扫描所有事件的最近活动,帮助推动像“已经有一段时间了,你想发送另一个事件更新吗?”。这些查询代价高昂,而微调是尽力而为,因此我们宁愿落后也不愿伤害试图跟上的数据库。

使用这样的限制可以帮助您保护共享资源(如数据库容量)不被单体应用的任何部分过度消耗。

显然,在扩展单体应用时会遇到问题,但秘诀在于:微服务并非一帆风顺,分布式系统问题可能非常棘手。

所以我们不要把婴儿和洗澡水一起倒掉。当您遇到整体扩展问题时,请尝试问自己“这里真正的问题是什么?”。大多数时候,您可以在代码中添加护栏或构建限制,以模拟微服务的一些优势,同时保持单一代码库并避免 RPC 复杂性。