松散耦合的分布式系统会让云账单飙升吗
在构建分布式系统时,松散耦合是一个主要的考虑因素。关于耦合及其在分布式系统设计中的作用,我们可以为其写一整本书。许多集成模式都与耦合有关。十多年前,我对耦合进行了定义:
耦合描述了互连的系统的独立可变性,即系统 A 中的变化是否会对系统 B 产生影响。如果有影响,那么 A 和 B 就是耦合的。
以下几个重要的推论可以用来支撑这一定义:
耦合不是二元的——我们不能说两个系统是耦合的还是不耦合的,这里存在许多细微的灰色地带。
耦合有许多不同的维度,从位置耦合(硬编码 IP 地址)到数据格式耦合(大小端序、字符编码)或时间耦合(同步请求)。
这两个维度的耦合尤为明显:
设计时耦合决定了一个组件的功能变更在多大程度上需要对其他组件也做出修改。
运行时耦合描述了运维变更(如故障、间歇性故障或延迟增加)对其他系统的影响。
通用数据类型和稳定的接口是减少设计时耦合的常用方法,而异步消息传递和断路器通常用于减少运行时耦合。
在我的一次 re:Invent 演讲中,我也强调了解耦系统是有成本的。
例如,通过通用数据格式进行解耦需要在端点做转换,这会导致运行时和内存成本增加。
通过注册中心进行位置解耦需要额外的查询操作,消息路由通常由中央消息 Broker 负责处理,这会导致运行时成本和延迟增加。
因此,从某种程度上讲,云端的解耦也是需要付出代价的,这一点也就不足为奇了。然而,当我们看着月账单上的成本费用时,我们的反应可能是这样的:这真的值得吗?让我们来看一个实际的例子。
在一个无服务器研讨会上,我看到了下面这段代码(为简单起见,我省略了对象的许多字段):
dynamodb = boto3.resource("dynamodb")
table = dynamodb.Table(DYNAMODB_TABLE)
event_bridge = boto3.client("events")
domain_object = # set fields
table.put_item(Item=domain_object)
domain_object_status_changed_event = # set fields
event_bridge.put_events(
Entries=[{
'Time': get_current_date(request_id),
"Source": SERVICE_NAMESPACE,
"DetailType": "DomainObjectStatusChanged",
"Detail": json.dumps(domain_object_status_changed_event),
"EventBusName": EVENT_BUS
}]
)
代码非常简单,你不需要成为 AWS 无服务器专家就能理解它做了什么。这段 Python 代码接收来自 API Gateway(这里未显示)的传入请求,执行一些逻辑,然后将业务领域对象存储在 DynamoDB 表中。它还将事件发布到 EventBridge(AWS 的无服务器事件路由器),通知其他组件发生了变更。发送事件这种方式可以避免直接与对变更事件感兴趣的组件发生耦合。
交互的架构图如下所示:
这段代码的优点是:
它让 Lambda 函数可以控制事件的格式;
它很简单,并且最大限度地减少了运行时组件的数量;
它将事件接收者与发送者以及发送者的数据内部结构解耦,避免陷入共享数据库陷阱。
它的缺点是:
应用程序依赖,例如,哪个事件被发送到哪个位置被隐藏在应用程序代码中。要想知道事件的来源(例如为了添加一个字段),你必须查看环境变量 EVENT_BUS,并假设是接收这个变量的函数正在将事件发送到事件总线(可以借助分布式跟踪工具,如 X-Ray)。
写入数据库和发送消息不在同一个事务内。数据库插入失败可能可以通过异常或检查返回代码来处理,但如果发送事件失败,你就会遇到更大的问题,因为数据库更新已经完成了。你可以重试发送事件,也可以撤消数据库插入并向调用方返回错误。不管怎样,你最终都会编写更多的额外代码,或者接受系统出现不一致的状态。
无服务器的伟大之处在于它不只是代码的运行时,而是一套完整的全托管服务,可以帮助减少代码量。为了展示这种平台的强大功能,我把用自动化代码(以及相应的资源)替换应用程序代码的无服务器重构过程记录了下来。
上面的应用程序是一个理想的重构场景:不通过编写代码来发送事件,而是让 DynamoDB 为你发送事件。DynamoDB Streams 是一个很棒的特性,它可以发布变更日志,供其他系统使用。这非常适用于我们的场景!
有了 Streams,我们就可以避免编写所有与准备和发送应用程序事件相关的应用程序代码。但实际上 Streams 并没有发送事件,而是让轮询消费者主动读取。这也就是为什么 EventBridge 不能直接从 DynamoDB Streams 中获取到事件。但 AWS Lambda 可以,最近发布的 EventBridge Pipes 也可以:
Pipes 可以将事件发布到各种目的地,包括 EventBridge。
Pipes 还提供了另一个方便的特性:消息转换。我们需要这个特性,因为 DynamoDB Streams 发布的事件格式使用了 DynamoDB 数据结构,因此不适合作为业务领域事件(为了简单起见,这里的数据被截短了):
{
"version": "0",
"id": "89961743-7396-b27b-1234-1234567890",
"region": "ap-southeast-1",
"detail": {
"eventID": "faae270c2d33f15567451234567890",
"eventName": "INSERT",
"eventSource": "aws:dynamodb",
"awsRegion": "ap-southeast-1",
"dynamodb": {
"ApproximateCreationDateTime": 1675870861,
"Keys": {
"object_id": {
"S": "usa/anytown/main-street/152"
}
},
"NewImage": {
"object_last_modified_on": {
"S": "08/02/2023 15:41:00"
},
"address": {
"M": {
"country": { "S": "USA" },
"number": { "N": "152" },
"city": { "S": "Anytown" },
"street": { "S": "Main Street" }
}
},
"contract_id": { "S": "d4745ecb-ac96-4764-ba88" }
},
},
"eventSourceARN": "arn:aws:dynamodb:ap:12345:table/xyz/stream/"
}
}
你可以使用 EventBridge Pipes 的便捷转换编辑器来构建转换:
多亏了 Pipes,发送到 EventBridge 的事件看起来就像是最初由应用程序代码发送的事件:
{
"version": "0",
"id": "7ba242f7-5a2c-81d3-1234-1234567890",
"detail-type": "ContractStatusChanged-Pipes",
"source": "unicorn.contracts",
"account": "1234567890",
"time": "2023-02-08T16:06:56Z",
"region": "ap-southeast-1",
"detail": {
"object_last_modified_on": "08/02/2023 16:06:56",
"property_id": "usa/anytown/main-street/153",
"contract_id": "25a238b6-3143-41e2-1234-1234567890",
"contract_status": "DRAFT"
}
}
我配置了不同的 detail-type,这样就可以区分事件(因为还不能在控制台上设置 EventBridge 目标参数,所以我通过命令行来设置)。
架构就是一个(有意识地)做出权衡的过程,因此我们有必要看看这个解决方案需要做出哪些权衡。
这个解决方案通过采用平台服务来减少应用程序的代码量。这些服务可能可以通过自动化代码进行配置,比如面向文档的(JSON/YAML),或者是面向对象的(CDK)。我们通过自动化代码配置基础设施,而不是执行命令式语句。因此,生成的自动化代码通常不太容易出错。
编写自动化代码需要对云平台及其特性有更深入的了解。被困在熟悉的领域可能是开发人员倾向于显式编写逻辑而不是使用平台的原因之一。
相比使用 DynamoDB Streams,通过应用程序代码发送事件可以让你更好地控制数据格式,因为 Streams 仅限于数据库中持久化的字段。你还需要重构(或调优)内部数据库,让其对其他组件可见,这意味着它们变成耦合的了。
使用平台服务可以在数据库更新和事件发布之间提供更好的数据一致性,因为 DynamoDB Streams 负责管理事件发布。
重构的解决方案让应用程序拓扑变得更加显式化。你不再依赖传入的环境变量来了解哪个组件与哪个组件发生了交互。
一些开发人员或架构师可能会想,使用更多的平台服务是否也会增加被“锁定”的风险——即潜在的转换成本。情况可能并非如此,具体可以参考我最近写的关于无服务器锁定的文章。
新的解决方案似乎更加优雅,或者我可以说它们就是“云原生”的吗?没有与发送事件相关的代码,也不需要在 Lambda 函数中包含 EventBridge 库(或了解它的 API)。AWS 运行时负责管理事务完整性和重试逻辑并异步执行,这让 Lambda 函数变得更小、更快。
那么新的解决方案的成本如何呢?云账单会因为使用了额外的服务而增加吗?可能会,但云账单并不是你唯一要考虑的成本。
在大多数地区,EventBridge Pipes 的定价范围从每百万事件 0.40 美元到 0.50 美元,所以账单中将包含这项费用。从 DynamoDB Streams 中读取数据需要收费,但从 Lambda 或 Pipes 中读取时是没有费用的。
一个更小更快的 Lambda 函数抵消了部分 Pipes 成本。
另一方面,Lambda 函数由于消除了所有 EventBridge 代码而变得更小更快。为了估算这样能节省多少钱,我做了一个不是那么科学的测试,用 Postman 多次调用这个函数。从 Lambda 函数的指标中可以看到,原始版本发送事件在大约 65 毫秒(左边的蓝点)时触底,而 DynamoDB 处理事件将其降到了大约 14 毫秒(右下角的蓝点)——由于 DynamoDB 的异步处理,减少了 75%:
这 50 毫秒的时间值多少钱?Lambda 函数的成本为每 BG 秒 0.000016667 美元(每月 90 亿 GB 秒后可以获得批量折扣,也有按请求收费的,不过这也不会影响我们的比较)。数这么多个零并不容易,所以我们来快速计算一下:
$0.000016667美元每GB/秒
$0.016667美元每GB/毫秒(对于百万个请求)
$0.10416875美元128MB/50毫秒(对于百万个请求)
因此,我们这种基于经验数据的数学算法可以估算出:Lambda 执行时间大约相当于 Pipes 的 1/4(尽管我没有检查内存占用减少了多少,但这方面的成本也会进一步降低)。
那么,事件发送的解耦是否也会消耗成本?按照每百万请求额外 0.3 美元的粗略数字计算,开发人员花费 1 小时(150 美元)编写、测试和调试与发布事件相关的代码(还有重试和错误处理逻辑)相当于会生成 5 亿个事件。如果你运行的不是世界上首屈一指的电子商务网站,那么这些事件已经算很多了。
我已经在“Cloud Strategy - A Decision-based Approach to Successful Cloud Migration”一书的“It’s Time to Increase Your ‘Run’ Budget”章节里提到,你为开发工作付出的不是实际成本,而是机会成本,即开发人员在 1 小时内可以创造的价值。在一个运行良好的软件交付组织中,这些价值应该是工资成本的高倍数。这意味着你很容易就会被淹没在数十亿个事件中。
此外,计算云端成本可能会产生所谓的“地下室效应”:
当你把更亮的灯装到地下室里,可能会看到更多乱糟糟的东西。但你不能因此责怪光线太亮。
所以,不要责怪云计算让成本问题显露无疑。你所运行的任何一段应用程序代码都会产生基础设施成本,只是你在购买硬件之前看不到而已。在我最喜欢的例子中(见“The Software Architect Elevator”一书),应用程序的绝大多数 CPU 和内存都花在了解析 XML 和回收它所创建的无数对象上。这确实耗费了大量的成本,但这些成本都被隐藏在了硬件采购中(在将应用程序被迁移到弹性基础设施上时,这个问题就暴露出来了)。
了解成本细节是件好事,但要确保考虑到了总体成本,包括调试和解决数据不一致的问题、将代码升级到新的运行时或更新库、增加新的开发人员、更长的构建和测试周期等等所花费的时间。人们之所以会(错误地)认为成本上升,考虑范围太窄是其中的一个常见原因。架构师既能纵览全局也能着眼于细微处,所以你要确保把问题放大到合适的规模:
仅仅因为有形成本上升,并不意味着总体成本的上升。而恰恰因为成本变得可见,你才可以看到并管理好它们。
在改变系统的运行时架构时,成本并不是唯一需要考虑的问题。例如,性能也可能受到影响。我们已经注意到 Lambda 执行时间减少了大约 50ms,这对于这个示例应用程序的 Web 前端来说是非常了不起的。
但是,异步发送事件会增加发布事件所需的时间吗?我们通常应该优化同步执行时间(在我们的例子中是 Lambda 函数及其前面的 API 网关),即使它们会导致更长的异步执行时间。
为了了解我们节省的 50 毫秒是用什么换来的,Luc van Donkersgoed 发布了一份 AWS 无服务器消息延迟的比较(这里只显示 50 和 90 百分位):
P90
SNS Standard 73 ms 125 ms
148 ms 241 ms
DynamoDB Streams 213 ms 341 ms
可以看到,DynamoDB Streams 处于频谱的较高水平,可能是因为它们使用了轮询 / 拉取模式。
架构是一个关于边界的问题,无论是软件架构还是物理(建筑)架构。在下图中,我悄悄定义了“应用程序”和“集成”之间的边界:
这看起来似乎就是自然的职责分离——你几乎可以说表示服务的图标就是按照颜色进行分组的。但将架构画成一组表示服务的图标通常并不能说明全部情况,甚至可能会导致想法变得狭隘。
如果我们思考的是服务的意图,而不是它们的颜色,就会看到略微不同的视图。将 Pipes 视为应用程序的一部分实际上更有意义,因为它依赖于私有数据源,即应用程序数据库:
出站转换(Outbound Transform)是基于规范化数据模型的架构的一个常见元素,它确保发布的消息(包括事件)不会将实现细节泄漏到消息总线中:
拦截过滤器是一种更为常见的模式,在 Deepak Alur、John crubi 和 Dan Malks 所著的《J2EE 核心模式》中有详细说明。模式中的“过滤器”指的是管道和过滤器架构风格。早在 2005 年,我就在博客上写过出站过滤器和入站过滤器。还有一点值得注意的是,我们正在使用 Pipes 服务实现过滤器。
按照我自己的建议,将模式作为更加突出的前景,将服务作为装饰,那么画出来的架构图是这样的:
为了更好玩一些,我加入了“Sync or Swim”模式装饰(“鼻子”形状的东西),用以显示哪个组件在“推送”或“拉取”事件。我们通过这种方式让架构图变得更加丰富,包含了相关的分布式设计考虑因素而不会看起来很乱。
为了让分布式系统架构锦上添花,我们需要思考最后一个问题:
如果我们使用了出站过滤器,并假设实现了高度的自动化,那么我们还需要事件代理(Broker)吗?
这是一个很好的问题,关于这个问题,可以从 API308 这个视频中找到关于这个设计决策的一些想法:
基于这个比较,在端点附近添加 Pipes 并将 Amazon SNS 作为发布订阅通道来路由事件可能是一种可行的架构,并且实际上可以降低运行成本:从 SNS 到 Lambda 不收取通知费用,数据的收费为每 GB(即 100 万个 1KB 的消息)0.09 美元。因为它会随消息的大小而变化,所以你得自己动动手指头算一算,但这对我来说是确实是一个不错的买卖。此外,你还可以获得更高的扇出能力(同一种事件类型可以有更多的订阅者),并通过为要路由的每种事件类型配置事件代理来避免潜在的开发瓶颈。
因此,我们发现:
将所有东西变得松散耦合实际上可以让你云账单上的数字降下来。
这篇文章比我原先计划的要长一些。但跟往常一样,有很多值得反思的地方:
成本并不只是你从账单上看到的数字,你需要考虑的是解决方案的总体成本以及你所采用的各种方法所隐含的权衡。
设计决策的复杂性不是用代码行数来衡量的。你甚至可以通过一行代码引入依赖关系或做出关键假设。作为架构师,我们希望了解解决方案的结构,而不仅仅是代码。
无服务器为应用程序架构提供了很多选择:你可以通过代码或 DynamoDB Streams 发送事件,将事件发送到 EventBridge 路由器或 SNS 通道,或直接发送到另一个 Lambda。这一切仅仅需要几行自动化的代码。这是否让架构变得更有意义?是的!
分布式系统需要特殊考虑。你不只是在将一些随机的东西拼凑在一起,而是在定义应用程序的拓扑结构。定义边界很重要,模式图(而不仅仅是一组表示服务的图标)可以帮你更好地表达这些决策。
一些设计概念,如事件推送和拉取,经过了时间的考验——关于“Sync or Swim”的文章早在 2006 年就有了。
原文链接:
https://architectelevator.com/cloud/cloud-decoupling-cost/
声明:本文为 InfoQ 翻译,未经许可禁止转载。
揭秘 ChatGPT 背后的技术栈:OpenAI 如何将 Kubernetes 扩展到了 7500 个节点
从8000元起步到年产值超800亿,藏在郊县里的农牧数字化探索者
微信扫码关注该文公众号作者