写点什么

保持单体,但拆分工作负载

  • 2023-05-31
    北京
  • 本文字数:3915 字

    阅读完需:约 13 分钟

保持单体,但拆分工作负载

本文最初发布于 incident.io。



我是单体架构的忠实粉丝。即使不是每个函数调用都需要一个网络请求,编写代码也已经够困难的了,而且这还没有考虑可观察性、RPC 框架以及让你可以在微服务环境中保持较高生产力的开发环境等方面的投资。


我就管理着一个 Ruby 单体应用程序,在 5 年的时间里,工程师从 20 名发展到 200 名,它的 Postgres 数据库从 10GB 增长到 5TB。无疑,当到达某个点时,痛苦会超过好处。


本文旨在探讨一项技术——拆分工作负载——它能够显著减轻痛苦,而且成本低,可以很快应用。如果做得好,你就可以更长久地享受单体的美好。


让我们开始吧!


大停机!


早在 2022 年 11 月就出现了一次停机,我们亲切地称之为“反复崩溃导致的间歇性停机”。


这可能是我们第一次真正遇到大故障,我们的应用程序在 32 分钟内反复崩溃,让人倍感压力,尤其是那些花了一整天时间构建事件工具的响应者。


我们事后进行了详细的分析,要点如下:


  • 我们的应用程序在 Heroku 上以 Go 单体的形式运行,我们使用了 Heroku Postgres 数据库,以及 GCP Pub/Sub 异步消息队列。

  • 我们的应用程序运行单个二进制文件的多个副本,包括 Web、worker 和 cron 线程。

  • 当二进制文件获取到错误的 Pub/Sub 消息时,无法处理的 panic 将导致整个应用程序崩溃,这意味着 Web、worker 和 crons 线程都将死亡。


这很糟糕,而且似乎很容易避免。如果我们将所有的东西都构建为微服务,那就只有负责处理该消息的服务会崩溃,对吗?


什么是可靠性?真是那样吗?


团队选择微服务架构,最常见的原因往往是可靠性或可扩展性。通常,这两个词可以互换使用。


这意味着:


  • 问题波及范围——比如,就像我们上面所说的,错误的 Pub/Sub 消息将被限制在它所在的服务中,而且通常允许服务优雅地降级(继续服务于大多数请求,只是某些功能失败)。

  • 每个微服务都可以管理自己的资源,例如设置 CPU 或内存限制,而且在需要时,服务可以扩展所需的任何资源。这可以防止不良代码路径把有限的资源耗尽并影响其他代码,就像在单体应用程序中可能发生的那样。


当然,微服务解决了这些问题,但也带来了大量的包袱(分布式系统问题、RPC 框架等)。如果我们想获得微服务的好处而又不想背这些包袱,就需要一些替代解决方案。


原则 1:永远不要混合工作负载


首先,我们应该遵循运行单体的基本原则,即永远不要混合工作负载。


对于 incident.io 应用,我们有三个关键的工作负载:


  • 处理传入请求的 Web 服务器。

  • 处理异步工作的 Pub/Sub 订阅者。

  • 按时间表执行的 Cron 作业。


我们打破了这个原则,在同一个进程中运行了所有这些代码(实际上是在同一个 Linux 进程中)。混合工作负载让我们面临着以下问题:


  • 代码库中特定部分的错误代码会导致整个应用崩溃,就像我们去年 11 月遇到的事件一样。

  • 如果我们部署了一个会占用大量 CPU 的 Pub/Sub 订阅者(可能是压缩 Slack 图片,或者是一个写得很糟糕的循环),整个应用程序都会受到影响,导致所有的 Web/worker/cron 活动变慢直至停止。在这个进程中,CPU 是一种有限的资源,如果我们消耗了 90%,其他工作就只能利用剩下的 10% 了。


在事件发生的同一天,我们按照工作负载的类型将应用程序拆分为不同的部署层。也就是说,在 Heroku 中创建三个独立的 dyno 层。这样,应用程序的三个独立部署就只需要处理自己所负责类型的工作负载了。



你可能会问,既然我们都这样做了,为什么不彻底点,改成独立的微服务呢?


答案是,这种拆分既保留了单体的所有好处,同时又完全解决了我们上面提到的问题。每次部署都运行相同的代码,使用相同的 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 服务器代码将数据存储在进程本地缓存中,而 worker 又试图访问该缓存,那么你就要经历一段悲伤的时光了。


好消息是,这些依赖关系通常会体现为代码气味,只要将协调过程放到外部存储(如 Postgres 或 Redis)中就可以很容易地解决了。而且,只要更改一次就不会再出问题。在我看来,即使不拆分代码,这样做也是值得的。


注意,拆分工作负载的粒度并没有什么限制。我以前见过每个队列甚至每个作业类一个部署的情况,也见过单个应用程序环境中多达 20 个部署的情况。


原则 2:应用护栏


好了,我们的单体不再是一个运行所有东西的大代码包:它是三个独立、相互隔离、可以单独成功或失败的部署。太好了。


但是,大多数应用程序不只是进程中运行的代码。最主要的可靠性风险之一是恶意代码,甚至是行为良好但消耗了单体应用最宝贵的有限资源的定时代码。


比如数据库,我们用的是 Postgres。


即使你拆分了工作负载,底层数据存储也还是需要某种形式的保护。而这正是微服务(通常不共享任何东西)可以提供帮助的地方,每个服务部署都只能通过另一个服务 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) // 开始运行workers }}
复制代码


上述代码设置数据库池并允许针对 worker 进行专门的定制。在默认情况下,“最大活动连接数为 20,最大空闲连接数为 5,语句超时时间为 30 秒”。


从 app --help 的输出也许更容易看出来:


--workers.database.max-open-connections=20    Max database connections to open against the Postgres server--workers.database.max-idle-connections=5    Max database connections to keep open while idle--workers.database.max-connection-idle-time=10m    Max time to wait before closing idle Postgres server connections--workers.database.max-connection-lifetime=60m    Max time to reuse a connection before recycling it--workers.database.statement-timeout="30s"    What to set as a statement timeout
复制代码


大多数应用程序都会指定连接池的值,但关键是,我们有单独的池来处理我们想要限制的任何类型的工作,预计它可能(在事件情况下)消耗过多的数据库能力并影响服务的其他组件。


以下是两个例子:


  • eventsDatabase 是一个包含 2 个连接的池,供一个 worker 使用,而该 worker 会消耗每个 Pub/Sub 事件的副本,并将其推送给 BigQuery 以供稍后分析。我们不关心这个队列是否落后,但如果它给数据库造成了压力,那将非常糟糕,尤其是在我们的服务非常繁忙的时候(这是很自然的)。

  • triggersDatabase 包含 5 个连接,由一个 cron 作业使用,而该 cron 作业会扫描所有事件以获取最近的活动,帮助生成类似“已经有一段时间了,您是否想发送另一个事件更新?”这样的提示。这些查询非常昂贵,并且只要尽力而为即可,所以我们宁愿落后,也不愿为了跟上速度而伤害数据库。


使用这样的限制可以帮助你保护共享资源(如数据库能力),防止单体的任何一部分过度消耗。如果这些限制非常容易配置——就像我们通过共享的 database.ConnectOptions 辅助器一样——那么只需要很小的工作量就可以预先指定“我希望这个资源最多只能消耗 X,超过这个数要通知我”。


对于任何中等规模的单体,这一方案都很有用。但当代码库的不同部分由多个团队开发时,需要优先保证每个人免受其他人的影响。


你的单体该怎么处理?


保持单体就好!


不用说,你在扩展单体时会遇到问题,但症结在于:微服务并不都是好处,分布式系统的问题真的可能很糟糕。


所以我们要避免把婴儿连同洗澡水一起倒掉。当你遇到单体扩展问题时,问下自己“真正的问题是什么?”在大多数情况下,你可以在代码中添加护栏或限制,通过模仿微服务来获得一些好处,同时保留单代码库并避免 RPC 的复杂性。


原文链接:


https://incident.io/blog/monolith


声明:本文为 InfoQ 翻译,未经许可禁止转载。


延伸阅读:

花 8 年转型微服务却得不到回报,问题出在哪儿?

从微服务转为单体架构、成本降低 90%,亚马逊内部案例引发轰动!


今日好文推荐


因低薪、高强度工作感到被公司“虐待”,一程序员跳槽前炮制惊天数据窃取案,勒索上千万终获刑


阿里取消 CTO 岗位;星火大模型“套壳”OpenAI?科大讯飞回应;近一半微软员工担心被 AI 抢饭碗|Q资讯


“TypeScript不值得!”前端框架Svelte作者宣布重构代码,反向迁移到JavaScript引争议


谷歌终于能与OpenAI 打擂台了!全新PaLM 2比肩GPT-4:一部手机就可运行,精通Python等20种语言


2023-05-31 21:003955

评论

发布
暂无评论
发现更多内容

元年科技:在数字化浪潮中,为企业建造一艘“方舟”

元年技术洞察

微服务 云原生 容器化 方舟企业数字化 PaaS 平台

亚中医疗——数据驱动预算管理升级,引领业务发展

元年技术洞察

数据驱动

Nftea: 世界杯文化、 NFT与 期权的首度碰撞

股市老人

js函数式编程讲解

hellocoder2029

JavaScript

还在为数据库事务一致性检测而苦恼?让Elle帮帮你,以TDSQL为例我们测测 | DB·洞见#7

腾讯云数据库

数据库 腾讯云 数据一致性 tdsql 腾讯云数据库

js对象和原型、原型链的关系

hellocoder2029

JavaScript

大咖说·先临三维|高精度3D视觉技术商业应用与实践

大咖说

科技 高精度3D视觉

直播CDN调度技术关键挑战与架构设计

京东科技开发者

架构 系统架构 直播 CDN加速 CDN技术

聚焦“教-学-评-测-练-管一体化”,推动新型人才培养!

华为云开发者联盟

云计算 后端 华为云

分享一招应对BOM表的检查方法,摆脱被BOM支配的“恐惧”

华秋PCB

检查 PCB PCB设计

JavaScript刷LeetCode拿offer-js版字典

Geek_07a724

JavaScript LeetCode

挖到宝了!2022阿里大佬手写“精品”Sping Boot学习笔记

小小怪下士

Java 程序员 阿里 springboot

JavaScript刷LeetCode拿offer-二叉树层序遍历篇

Geek_07a724

JavaScript LeetCode

js异步编程的三种模式

hellocoder2029

JavaScript

java Apache poi 对word doc文件进行读写操作

@下一站

编程 原创 word Java core 11月月更

前端leetcde算法面试套路之二叉树

js2030code

JavaScript LeetCode

JavaScript刷LeetCode拿offer-树的遍历

js2030code

JavaScript LeetCode

MASA MAUI Plugin (六)集成个推,实现本地消息推送[Android] 篇

MASA技术团队

blazor MASA MAUI Xamarin MASA Blazor

【Go电商实战05】结合项目解答使用Go中间件遇到的问题:中间件的概念和应用

王中阳Go

golang 学习方法 中间件 11月月更 电商实战

大数据培训学习的时候有什么方法吗

小谷哥

ONES 出席产业互联高峰论坛,分享金融企业研发管理实践

万事ONES

一文读懂NodeJs知识体系和原理浅析

coder2028

node.js

Python进阶(三十七)Windows7使用nginx+apache部署django项目

No Silver Bullet

Python Apache nginx django 11月月更

[力扣] 剑指 Offer 第一天 - 用两个栈实现队列

陈明勇

Go 数据结构与算法 力扣 11月月更

nodejs实现jwt

coder2028

node.js

用javascript分类刷leetcode3.动态规划(图文视频讲解)

js2030code

JavaScript LeetCode

零基础学习大数据培训难吗?

小谷哥

彻底搞懂nodejs事件循环

coder2028

node.js

Python进阶(三十八)利用matplotlib 进行折线图,直方图和饼图的绘制

No Silver Bullet

Python matplotlib 11月月更

Python进阶(三十九)使用matplotlib进行绘图分析数据

No Silver Bullet

Python 绘图 matplotlib 11月月更

保持单体,但拆分工作负载_架构_Lawrence Jones_InfoQ精选文章