亚马逊AWS官方博客

通过无服务器构建微服务的设计方法对比

本文由 Luca Mezzalira(Principal SA)与 Matt Diamond(Principal SA)联合撰写。

由于模块化可以在代码级别或者在基础设施级别表达,使用 AWS Lambda 设计工作负载会给开发人员带来问题。在使用无服务器运行代码时,需要进行额外的规划,从底层功能组件中提取业务逻辑。这种有意分离关注点的做法确保了可靠的模块化,为架构演化铺平了道路。

本文重点介绍同步工作负载,但类似的注意事项也适用于其它工作负载类型。在确定了 API 的边界上下文并与使用方就 API 合约达成一致之后,您就可以开始构建边界上下文的架构以及相关的基础设施。

使用 Lambda 函数构建 API 时有两种最常用的方法,分别是单一责任和 Lambda-Lith。不过,这篇博文探讨了这些方法的替代方案,可以兼顾这两种方法的优点。

单一职责 Lambda 函数

单一职责 Lambda 函数设计用于在无服务器架构中运行特定任务或处理事件触发的特定操作:

c:\temp\design1.png

这种方法在业务逻辑与功能之间提供了可靠的关注点分离。您可以在特定隔离的功能中进行测试,独立部署 Lambda 函数,减少会引入错误的表面,并在 Amazon CloudWatch 中更轻松地调试问题。

此外,单一用途函数可以实现高效的资源分配,因为 Lambda 会根据需求自动扩展、优化资源消耗并尽可能降低成本。这意味着您可以修改可供各个函数使用的内存大小、架构以及任何其它配置。此外,您可以更轻松地通过支持工单请求更新并发函数执行,因为您无需将流量聚合到单个 Lambda 函数来处理所有请求,而是可以根据单个任务的流量来请求增加特定的资源。

另一个优点是执行速度快。由于单一用途 Lambda 函数设计用于单个任务的业务逻辑,您可以更轻松地优化函数的大小,而不会像在其它方法中一样需要额外的库。由于包大小较小,这可以于缩短冷启动时间。

尽管有这些好处,但单纯依赖单一用途的 Lambda 函数会存在一些问题。虽然冷启动时间会有所减短,但您可能会遇到更多的冷启动次数,特别是对于零星或不经常调用的函数。例如,删除 Amazon DynamoDB 表中用户的函数,其触发频率可能会远低于读取用户数据的函数。此外,高度依赖单一用途 Lambda 函数会导致系统复杂性增加,尤其是随着函数数量越来越多时。

良好的关注点分离有助于维护代码库,但代价是缺乏内聚性。在具有相似任务的函数中,例如 API 的写入操作(POST、PUT、DELETE),多个函数中可能会有重复代码和行为。此外,更新通过 Lambda 层或者其它依赖项管理系统共享的公用库时,需要对每个函数进行多个更改,而无法通过对单个文件进行原子更改来实现。对于任何其它需要跨多个函数进行的更改也是如此,如更新运行时版本。

Lambda-lith:使用单个 Lambda 函数

当许多工作负载使用单一用途 Lambda 函数时,开发人员最终发现单个 AWS 账户中有大量的 Lambda 函数。开发人员面临的主要挑战之一是更新通用的依赖项或功能配置。除非实施了明确的治理策略来解决这个问题(例如使用 Dependabot 强制更新依赖项,或者对在预置时检索的参数进行参数化),否则开发人员可能会选择不同的策略。

这导致的结果是许多开发团队会朝着相反的方向发展,将与某个 API 相关的所有代码聚合到同一 Lambda 函数中。

Lambda-lith:使用单个 Lambda 函数

这种方法通常称为 Lambda-lith,因为它在同一个函数中收集了构成某个 API 的所有 HTTP 动词,有时甚至是多个 API。

通过这种方法,在应用程序的不同部分之间,您可以实现更好的代码内聚性和主机托管能力。在这种情况下,模块化在代码级别表达,应用了单一责任、依赖项注入和外观等模式来构建代码。对于维护大型代码库,开发团队应用的规程和代码最佳实践至关重要。

但是,与单一责任方法相比,考虑到 Lambda 函数数量的减少,此方法可以更轻松地对多个 API 更新配置或实施新标准。

此外,由于每个请求都会为每个 HTTP 动词调用相同的 Lambda 函数,因此代码中较少使用的部分更有可能获得更好的响应时间,因为此时执行环境更有可能具有完成请求所需的资源。

另一个需要考虑的因素是函数大小。在将同一个函数中的动词搭配用于某个 API 的所有依赖项和业务逻辑时,这种情况会更加突出。这可能会影响具有高峰工作负载的 Lambda 函数的冷启动。客户应评估这种方法的益处,尤其是在应用程序具有会受到冷启动影响的严格 SLA 时。开发人员可以关注所使用的依赖项,并在编程语言允许的情况下,实施诸如 Tree-Shaking、缩小和死码消除等技术来缓解这个问题。

这种粗放的方法不允许您单独调整功能配置。但是,您必须找到一种能够与所有代码功能相匹配的配置,这种配置可能需要更高的内存大小和更宽松的安全权限,而这会与安全团队定义的要求相冲突。

读取和写入函数

这两种方法都有所取舍,但还有第三种选项可以将它们的优势结合起来。

通常,API 流量会更多地倾向于读取或写入,这迫使开发人员在一个方面而不是另一个方面更多地优化代码和配置。

例如,考虑构建一个用户 API 的情况,该 API 允许使用者创建、更新和删除用户,还可以查找用户或用户列表。在这种情况下,您可以在没有批量操作的情况下一次更改一个用户,而每个 API 请求中可以获取一个或多个用户。在设计时,将 API 划分为读取和写入操作可以得到以下架构:

读取和写入函数

对于写入操作(创建、更新和删除)而言,代码内聚性是有益的,原因有很多。例如,您可能需要验证请求正文,确保其中包含所有必需参数。如果工作负载是写入密集型,则较少使用的操作(例如,Delete)将受益于热执行环境。例如,代码主机托管实现了代码在相似操作中重复使用,从而减少了使用共享库或 Lambda 层等来设计项目结构的认知工作。

从读取操作的角度来看,与写入操作相比,您可以减少捆绑到此函数的代码,加快冷启动速度,并大大优化性能。您还可以将部分或全部查询结果存储在执行环境的内存中,从而缩短 Lambda 函数的执行时间。

这种方法可以进一步提高函数的演变能力。想象一下这个平台变得越来越受欢迎的情况。现在,您必须通过改进读取来进一步优化 API,并使用 ElastiCache 和 Redis 添加旁路缓存模式。此外,您决定了使用第二个数据库来优化读取查询,该数据库针对未命中缓存时的读取容量进行了优化。

在写入方面,您与 API 使用者达成共识,即考虑到他们完全接受分布式系统的最终一致性性质,因此接收并确认用户的创建或删除便已足够。

现在,您可以在 Lambda 函数之前添加 SQS 队列来缩短写入操作的响应时间。您可以批量更新写入数据库操作,而不是单独处理每个请求,从而减少处理写入操作所需的调用次数。

CQRS 模式

命令查询责任分离(CQRS,Command query responsibility segregation)是一种成熟的模式,它将数据修改或系统的命令部分与查询部分分开。如果更新和查询对吞吐量、延迟或一致性有不同的要求,您可以使用 CQRS 模式将它们分开。

虽然不一定要从完整的 CQRS 模式开始,但您可以在最初的读取和写入实施中,从所强调的基础设施更轻松地进行演变,而无需对 API 进行大规模重构。

三种方法的比较

以下是三种方法的比较:

单一责任 Lambda-lith 读取和写入
优势
  • 可靠分离关注点
  • 细粒度配置
  • 更好地调试
  • 快速执行
  • 减少冷启动调用次数
  • 更高的代码内聚性
  • 维护更简单
  • 代码可根据需要内聚
  • 架构演变
  • 优化读取和写入操作
问题
  • 代码重复
  • 复杂的维护
  • 更高的冷启动调用次数
  • 粗粒度配置
  • 更长的冷启动时间
  • 将 CQRS 用于两种数据模型
  • CQRS 可提高系统的最终一致性

结论

随着架构的发展,开发人员通常会从单一职责函数转向 Lambda-Lith,但两种方法都存在相对的取舍。本文说明了如何根据读取和写入操作来划分工作负载,从而实现这两种方法的益处。

这三种方法对于设计无服务器 API 都是可行的,而要做出合适的选择,关键在于了解所要优化的内容。请记住,了解要在应用程序中表达的具体情境和业务需求,可以让您根据具体工作负载做出可接受的取舍。请保持开放的心态,找到解决问题并能平衡安全性、开发人员体验、成本和可维护性的解决方案。

有关更多无服务器学习资源,请访问 Serverless Land