我的书单

概述

到目前为止,我们的大部分精力都集中在构建多租户架构的所有基础元素上。这意味着深入研究控制平面,并弄清楚如何部署一组核心服务,以便我们将租户的概念引入SaaS环境。我们研究了租户的入职方式、如何建立租户身份、如何进行身份验证,以及——最重要的是——所有这些最终如何将租户上下文注入到我们应用程序的服务中。这应该让您充分认识到控制平面在SaaS环境中所扮演的角色,并说明了投资创建无缝策略以将基础租户结构引入多租户架构的重要性。

现在,我们可以开始将注意力转移到应用平面。在这里,我们可以开始思考如何将多租户应用于服务的设计和实现,从而使我们的应用程序焕发生机。在本章中,我们将开始探讨多租户工作负载的细微差别将如何影响我们设计和分解服务的方式。隔离、嘈杂邻居、数据分区——这些都是您在服务设计中需要考虑的新参数。您会发现,多租户为传统的服务设计讨论带来了新的挑战,迫使您采用新的方法来处理服务的规模、部署和占用空间。

租户的引入也会直接影响您实现服务的方式。我们将仔细研究多租户将如何以及在何处融入到服务代码中,并重点介绍可用于防止租户增加复杂性和/或增加服务整体占用空间的不同策略。我将探讨一些示例服务实现,并概述可用于将多租户结构推送到帮助程序和库中的工具和策略,以简化整体开发人员体验。

更广泛的目标是让您更好地了解在开始构建多租户服务时应该考虑的事项。从一开始就将此作为优先事项,可以对您的SaaS解决方案的效率、复杂性和可维护性产生重大影响。

设计多租户服务

在讨论如何构建多租户服务之前,我们需要先提升视角,考虑在识别系统中不同服务时需要关注的规模、结构和一般分解策略。服务边界以及如何在这些服务之间分配负载/责任,会在多租户模型中增加复杂性和前瞻性规划。

经典软件环境中的服务

为了更好地理解这一动态,让我们先从一个经典的应用场景开始,在这个场景中,应用程序的整个运行时环境是独立安装、部署和管理的。图7-1展示了在这种经典的安装式软件环境中,服务是如何部署的。

你会发现,服务完全专用于单个客户。在为这些环境设计服务时,你的重点主要在于找到一组能够满足单个客户的扩展性、性能和容错需求的服务。虽然不同客户对系统使用方式可能存在一些差异,但总体重点往往在于创建一种体验,该体验仅限于单个客户的行为和特征。

这种更窄的关注点使得确定服务边界相对简单。设计重点往往放在单一职责原则上,即确保服务分解后每个服务都有明确、清晰的范围和功能角色。这些服务应专注于完成单一且明确的任务。

图7-1 经典软件环境中的服务

池化多租户环境中的服务

现在,让我们看看一个完整的池化多租户环境。图7-2展示了一个支持多个租户共享基础设施资源的SaaS架构示例。

图7-2 池化多租户环境中的服务

表面上看,这里似乎唯一的变化只是使用这些服务的租户数量增加了。然而,这些租户在同一时间共享资源使用这些服务的事实,对您在设计每个服务时考虑其规模、分解方式及资源占用空间具有重要影响。

首先,您会注意到,在图7-2的顶部,我故意引入了不同规模的租户。这样做是为了说明租户对系统负载的影响可能存在巨大差异。一个租户可能占用系统的一部分资源,而另一个租户可能占用解决方案的全部资源,但对环境的负载却很小。这种组合方式可能千差万别。

在左侧,我还展示了新租户的接入过程。这旨在说明新租户可能在任何时候被引入您的环境中。您几乎无法预先预测这些新租户的工作负载和特性。此外,我还强调了这些新租户可能属于不同层级,拥有不同的经验和性能预期。

现在退一步,想想我们这里真正拥有的是什么。在这个环境中,所有租户共享的服务必须以某种方式预先满足每个租户角色在扩展、性能和资源消耗方面的需求。您需要高度关注确保这些租户不会产生“噪音邻居”现象,即一个租户的操作影响到另一个租户的体验。这些服务还需根据一套可能较为模糊的参数动态扩展。您当前采用的扩展策略可能与未来(甚至下一小时)服务扩展需求不匹配。服务级别协议(SLA)、分层配置、合规性要求等因素也可能影响决策。

这本质上是共享基础设施的优势与支持不断变化的客户消费模式现实之间的冲突。对于一些企业而言,这可能导致为应对需求和负载模式的波动而过度配置资源,这与SaaS商业模式所追求的效率和规模经济目标背道而驰。

从某种意义上说,这都是多租户池化架构的必然结果。即使服务存在过度配置,共享资源的整体价值仍可能远高于为每个租户单独部署基础设施。然而,服务设计本身可以提供更多工具和策略,以应对租户工作负载和配置的动态变化。总体而言,我们设计服务的方法侧重于为自己提供更多控制参数,以应对租户对环境施加的各种动态变化。

扩展现有最佳实践

设计多租户服务的过程仍将遵循许多用于识别候选服务的既定方法和策略。核心思想是,在应用这些概念时,您还需要将多租户设计考虑因素添加到影响服务设计的关键因素列表中,将基础最佳实践与多租户设计考虑因素相结合(如图7-3所示)。

图7-3 融合服务设计方法论

图7-3提供了一个更清晰的思维模型,展示了我所倡导的方法。您将看到团队常用于识别不同候选服务的常见服务设计方法论示例。尽管这些方法论具有明确价值,但它们通常未包含多租户设计中必须考虑的现实因素。是的,我明白最终会得到映射到领域中许多逻辑实体和操作的服务。然而,挑战在于,环境的多租户特性可能导致我引入一些服务和部署模式,这些模式在仅关注领域对象、操作和交互范围时并不会自然浮现。

为了强调这一点,我在图7-3的中心位置放置了SaaS作为占位符。其核心思想是,将多租户架构带来的所有设计考量与其他设计方法论相结合,确保这些概念在开始建模应用程序服务时始终处于核心位置。

为了进一步强调这一点,让我们深入探讨一些多租户可能对服务设计产生影响的常见领域。

处理噪音邻居

“噪音邻居”并非多租户环境独有的概念。开发人员通常需要考虑用户如何以及在何处可能对系统施加负载,导致系统饱和或性能下降。虽然这是一个普遍关注的领域,但多租户架构和共享基础设施的特性使得“噪音邻居”问题更加突出、复杂且具有挑战性。在SaaS环境中,噪音邻居可能导致整个系统崩溃,或至少降低多租户环境中其他租户的体验。因此,在设计多租户环境的服务时,您需要确保已测试服务在应对潜在噪音邻居条件时的假设。

噪声邻居在多租户环境中可能以多种形式出现。您的环境中可能存在某些操作具有高延迟或以易引发瓶颈的模式消耗资源。您可能还存在某些租户角色容易导致系统中特定服务集饱和的情况。

基本挑战通常与扩展性相关。当然,如果服务能够有效扩展以处理多种角色和工作负载,同时避免过度配置或影响其他租户,那么您的服务范围可能是合理的。我们的重点在于那些仅通过水平扩展无法有效或高效应对环境多租户现实的场景。请考虑图7-4中所示的示例服务。

图7-4 噪声邻居瓶颈

您会发现,我创建了一个目录管理服务,用于管理我电子商务SaaS解决方案中的所有产品。该服务提供了一个API,包含一组用于管理目录数据的基本操作。然而,如果您查看最左侧,我已高亮显示了每个API入口点的运行状态,并通过颜色来表示其当前状态。您会发现大多数操作处于健康状态或基本健康。然而,uploadThumbnail()操作似乎存在某种性能问题,导致了“噪音邻居”条件。

经分析,该函数恰好承担了大量计算任务,导致服务出现瓶颈。调用方上传图片后会触发图像缩放机制,生成多种尺寸的缩略图并在应用程序多个场景中复用。若保持现状,您可能倾向于通过横向扩展服务(可能导致过度配置)来解决问题,并期望此举能限制对租户的连锁影响。本质上,您可能在整个服务上进行扩展,而实际上只需提升单一操作的吞吐量。更优的方案是考虑将该操作提取并迁移至独立服务,使其能更比例地扩展以适应租户活动,同时避免因扩展整个目录管理服务而吸收的低效性。

总体思路是,您需要重新思考服务职责的范围,并考虑这些服务如何在多租户环境中扩展以应对不断变化的负载。在识别服务时,请关注那些可能成为“噪音邻居”的潜在候选区域。图7-5展示了这一概念的整体思路。

图7-5 噪声邻居分解思维模式

我刚刚演示了如何将一个给定的服务进一步分解为更小的服务,这些服务能够提供更精准的扩展选项,理想情况下能够限制“噪音邻居”现象并减少过度配置。产品服务将缩略图服务分离出来以更好地分担负载,订单服务则将独立的税费服务拆分出来。核心思想是,我们必须将这些“噪音邻居”邻居效应和扩展效率考虑因素融入服务设计中,利用这些信息识别出更细粒度分解策略可能带来更好效果的领域。

有人可能会问,这种方法是否真的仅适用于SaaS。答案是否定的。作为一条经验法则,任何环境都应探索通过更细粒度的服务来更好地解决性能和扩展问题。然而,不同之处在于,SaaS环境中租户角色和工作负载的多样性,迫使SaaS架构师必须更加严格地应对这类挑战。在多租户环境中,一个效率低下、受瓶颈限制或过度配置的服务更容易暴露问题,这可能对租户和SaaS环境的运营状况产生深远影响。因此,尽管这是一种良好的通用方法,但在设计多租户环境中的服务时,它值得获得更多的关注和投入。

值得注意的是,您的“噪音邻居”策略随时间演进是完全正常的。您在初始阶段选择的服务应随着系统演进和对“噪音邻居”条件观察的深入而逐步调整。建议从具有实际意义的服务开始,然后根据环境的运营特征应用本文所述策略。

识别孤岛服务

在第3章中,我们讨论了不同的部署模型以及这些模型可能要求您将租户的部分或全部资源部署在孤岛(专用)模型中。表面上,资源孤岛与服务设计似乎没有直接关联。然而,服务选择与这些服务是否、何时以及如何在孤岛环境中部署之间实际上存在强相关性。

每次选择将服务隔离时,通常是为了支持特定的系统或租户需求。例如,您可能需要将某些服务隔离以满足域内合规要求。分层、性能和隔离性也可能影响您选择以隔离模型部署的服务。

当然,每次你将资源进行孤立时,你都在做出一种权衡,这种权衡可能会影响你环境的运营、成本、部署和管理复杂性。因此,如果你要使用孤立的资源,最好限制需要在孤立环境中部署的服务数量。图7-6展示了环境的隔离要求如何影响服务占用空间的示例。

图7-6 基于隔离需求设计服务

该图展示了两种设计订单管理服务的方法,以满足解决方案中所有订单处理和履行需求。左侧示例中,该服务以池化模型部署,所有租户共享计算和存储资源。现在假设租户对该服务在池化模型中运行表示担忧。您的第一反应可能是将该服务迁移到完全隔离的部署模型,以确保每个租户运行该服务的专用副本。然而,经过进一步调查,您发现租户实际上仅关心订单处理部分的专用计算和存储资源。

与其将此服务完全孤立,您可能可以将服务分解为一个或多个服务,以满足客户的隔离需求。这正是我在本示例中所做的,将原有服务分解为两个独立的服务。在此,我们的全新订单处理服务采用孤立部署模型,每个租户均可访问专属服务,直接满足租户的隔离需求。作为此次调整的一部分,我还引入了一个新的订单履行服务,该服务继续以池化配置运行。

您可以想象这种方法如何应用于任何需要隔离资源的场景。例如,您可以根据合规性、噪声邻居或性能等原因,对大型服务进行拆分,以获得对隔离与非隔离资源更精细的控制。

这种选择哪些资源池化、哪些资源隔离的方法并不一定意味着将服务拆分为更多服务。它可能仅仅是根据服务对隔离的需求来分组服务。图7-7展示了如何根据服务对隔离与池化的需求来调整服务边界。

图7-7 根据隔离/池化需求对服务进行对齐

对于这种场景,我的租户已明确表示需要以孤岛模式部署特定功能。如图7-7所示,产品、订单和购物车服务均以孤岛模式部署,每个租户都拥有这些服务的专用实例。而在右侧,我有一组以池化模式运行的服务。

您也可以单纯从部署角度看待这个问题,认为某些服务是隔离的,某些是池化的,这种理解是正确的。然而,关键在于要尽可能审慎地考虑哪些服务应部署在隔离侧。因此,如果您在设计需要支持孤岛与池化混合模式的服务时,应考虑如何分解这些服务,以便尽可能多地将其部署在池化模型中。

这种隔离策略应成为服务设计思维的核心部分,需明确哪些用例和需求可能需要将服务部署在隔离模型中。此类需求通常包括合规性、隔离、安全、分层和性能。此外,需注意您可能完全基于内部运营需求对服务进行孤岛化。例如,某些服务在池化模型中无法满足租户需求。此时,可将该功能模块独立出来,并基于环境的实际运营情况,选择以孤岛化模型部署。

在某些情况下,您服务的孤岛式边界可能在设计初期就显而易见。然而,在其他情况下,您可能需要先收集数据并进行迭代,才能最终确定一套能够平衡孤岛与共享资源的服務架构。对我来说,我主要想避免的是,仅仅将服务搬入孤岛,而没有挑战自己去探索是否存在更具创意的分解方式。正如我之前提到的,您可能需要在从运行系统中收集更多运营洞察后,才能发现这些边界。

需要注意的是,这种孤岛策略应谨慎采用。它并非在运营或成本效率上最优。因此,你需要谨慎选择采用此方法的时机和场景。对于租户数量较少的系统,这可能是一个不错的策略。然而,对于租户数量较多的系统,这种策略将难以扩展和维护。

计算技术的影响

虽然可能不太明显,但您使用的计算技术也会对服务占用空间产生影响。容器、无服务器计算等计算架构会为多租户设计引入新的考量因素。例如,假设您计划让部分服务在无服务器计算模型中运行,而其他服务在容器计算模型中运行。事实证明,这些不同计算模型的本质会直接影响服务大小、范围和边界。为了更好地理解这一点,请参阅图7-8。

图7-8 计算与服务设计

在此示例中,我包含了两个几乎完全相同的订单服务实例。左侧的服务在容器计算模型中运行。同时,右侧的同一服务在无服务器模型中运行(本例中使用AWSLambda)。我已突出显示了该服务中包含的不同操作,并根据这些操作的负载大小调整了每个操作的框大小。在此情况下,可以清楚地看到createOrder()操作接收了绝大多数请求。甚至可能让人质疑该服务是否能够高效扩展,还是说它本质上将根据这一项操作来扩展——尽管其他操作并未受到显著压力。

现在,当我们查看基于容器的部署时,可能会考虑如何重构此服务以提升整体环境的扩展效率。在容器中,所有功能都被打包、扩展并集体部署,使得容器成为我们的扩展单元。

采用无服务器架构后,我们服务中部署的每个操作都对应一个独立的函数,这些函数可以独立部署、管理和扩展。因此,如果createOrder()或其他任何操作承受了过高的负载,该函数会自动进行扩展。这是无服务器架构的重要优势之一,因为扩展更加精细化,且管理扩展的责任转移给了平台。更好的消息是,如果明天负载分布发生变化,另一项操作开始承担所有负载,我无需调整服务缩放策略或负载分布。这使得在多租户环境中适应和优化不断变化的工作负载变得更加容易。

关键要点是,您使用的计算模型可能会对服务分解模型产生一定影响。虽然这不是主要因素,但它确实为设计思维增添了另一层复杂性。

考虑存储因素的影响

在设计服务时,我们通常还会考虑这些服务将访问和管理的数据的范围和性质。由于每个服务都应封装其管理的数据,因此必须考虑这些数据将如何被消费。如果沿着错误的数据边界拆分服务,可能会导致服务之间频繁通信,因为它们需要访问其他服务范围内的数据。

这些都是设计任何服务时需要考虑的通用因素。现在,当我们探讨多租户服务时,需要在设计考量中加入一些新因素。多租户数据可以存储在孤岛模型中,每个租户拥有独立的存储结构,或者存储在池化模型中,租户数据混杂在共享存储结构中。您还需考虑不同数据操作在客户竞争共享存储资源时如何实现有效扩展。想象一下,成千上万的租户同时查询一个关系型数据库,而该数据库将所有租户数据存储在共享表中。这些租户是否会耗尽存储技术的计算资源?是否会引发另一种“噪音邻居”问题?这些都是存储足迹可能影响服务范围和粒度的示例。在某些情况下,更粗粒度的服务可能是您的首选模型。在其他情况下,可能存在强有力的理由将服务分解为更小的单元以支持不同的数据特征。

在许多方面,您的服务存储需求也必须考虑之前提到的“噪音邻居”和“数据孤岛”讨论中所涉及的诸多因素。在存储方面,我们需要深入服务内部,思考多租户需求、租户角色以及工作负载如何影响服务占用资源的规模。这本质上是之前讨论的计算资源考虑因素的镜像。存储本身具有独立的计算和数据占用空间,必须同时满足噪声邻居、合规性、分层存储和隔离等要求。

关键要点是,存储在服务分解中可以且常常发挥重要作用。因此,当您坐下来识别服务及其范围和粒度时,务必确保为存储分配足够的关注。在某些情况下,这将较为直观,而在其他情况下,服务存储特性可能成为决定服务设计的关键驱动力。

使用指标分析设计

服务设计将持续演进。新功能、新租户、新层级和新工作负载将促使团队不断评估多租户架构的性能、可扩展性和效率。当然,在多租户环境中,判断服务设计是否实现预期体验往往更具挑战性。您可能通过基本监控数据得出系统行为的高层级结论,但这类数据通常无法评估单个租户或层级的资源消耗和活动模式。这使得对影响SaaS环境运行特征的深层因素进行分析变得困难。某个租户或层级的消耗模式是否影响了特定服务的扩展模式?基础层租户是否以某种方式影响了您的服务,进而影响了高级层租户?这些只是您需要评估服务设计有效性时需要考虑的洞察示例。

只有当你拥有这些更深入的洞察时,你才能真正开始评估你的服务设计是否成功地解决了我们一直在探讨的各种因素(如噪声邻居、分层、性能等)。获取这些指标意味着需要在你的服务中添加监控工具,以便收集分析服务运行状况所需的数据。图7-9展示了这种监控工具的conceptual示意图。

图7-9 暴露租户感知服务指标

左侧是一个包含监控工具的示例服务,这些工具会发布用于评估服务性能、可扩展性和运行特征的数据。中间部分展示了您可能为服务捕获的一些数据示例。您选择监控的内容将取决于服务的性质以及最能反映其活动特征的数据类型。所有记录的数据至少应包含租户上下文和租户层级(如果使用了层级)。

最后,右侧是一个用于聚合和分析这些数据的占位符。您将在这里使用您选择的工具来分析这些指标数据并评估服务运行时配置文件。我们在第12章中将深入探讨多租户操作和指标的整个领域。关键在于,在多租户环境中,您必须投资于捕获能够告诉您设计性能的指标和分析。没有这些数据,您将难以分析租户工作负载和活动变化对架构的影响。

一个主题,多种视角

在整个服务设计讨论中,我专注于寻找最能满足您环境中合规性、隔离性、存储模型、噪声邻居、分层和性能要求的粒度和部署模型。虽然每个因素都会为服务设计讨论带来独特的考量,但您也可以看到,用于解决这些需求的策略确实存在重叠。

在我们的设计讨论中,有两个基本主题贯穿始终。在某些情况下,您的设计策略将侧重于创建更细粒度的服务,以更好地满足多租户扩展和性能需求。在其他情况下,您可能考虑使用孤岛式部署来创建一个能够满足系统要求的配置。关键在于将这些可能性纳入您的设计思维,并寻找机会支持多租户工作负载的实际需求。

多租户服务内部

在多租户服务设计框架下,我们可以开始探讨构建多租户服务的核心要义。作为一个基本原则,我建议团队,在编写SaaS应用程序的业务功能代码时,应尽可能限制开发人员对多租户的感知。我们的重点在于引入哪些策略和技术,以减少开发人员在实现多租户服务时所承担的额外开销。

为了更好地理解多租户在您的服务中是如何实现的,让我们从一个不支持任何租户概念的基本服务开始。以下代码片段展示了一个订单服务,该服务负责根据给定的状态检索所有匹配的订单:

def query_orders(self, status):
    #获取数据库客户端(DynamoDB)
    ddb = boto3.client('dynamodb')
    #查询具有特定状态的订单logger.info("正在查询状态为%s的订单",status)
    try:
        response = ddb.query(
            TableName="order_table",
            KeyConditionExpression=Key('status').eq(status)
        )
    except ClientError as err:
        logger.error("找到订单错误,状态:%s。信息:%s:%s", status, err.response['Error']['Code'], err.response['Error']['Message'])
    else:
        return response['Items']

对于这项服务,我恰好选择了AWS NoSQL存储服务(Amazon DynamoDB)来存储我的订单。我用Python和Boto3(一个用于与AWS服务集成的库)编写了这个示例。订单数据将以“状态”键的形式存储在DynamoDB中,该键将用于访问我们系统中的订单。

总的来说,这段代码代表了一个相对简单的服务,它本质上将传入的状态作为参数,并在数据库中查询与该状态匹配的订单。您可能在开发过程中的某个阶段见过或编写过这个函数的变体。

就我们的目的而言,我们更感兴趣的是这里没有的内容。由于这段代码不在多租户环境中运行,因此代码中没有任何内容需要与多租户相关。它发出的日志数据、它访问的数据——所有这些都不需要考虑哪个租户实际上在调用这些操作。

作为多租户架构师,您的目标应该是让这些代码保持像这里一样简洁明了、熟悉易用。您必须找到一种方法来引入租户上下文并支持多租户结构,同时又不增加构建器体验的臃肿和开销。您越能将租户上下文移出构建器的视野,就越有机会将这些策略和策略集中应用于所有服务。

提取租户上下文

现在我们可以开始探讨,随着租户概念被注入代码,我们的服务代码将如何逐步演变。在考虑应用租户上下文之前,我们必须先明确租户上下文如何在服务中被正确处理。这需要我们首先回顾第四章和第六章中分别讨论的身份验证与认证相关主题。在这些章节中,我们探讨了租户上下文如何与特定租户用户绑定,并以JWT形式注入到解决方案的服务中。现在,我们可以深入探讨当该令牌进入服务上下文时,我们如何利用它。

如您所知,JWT会被嵌入到发送到服务的每个HTTP请求的头部中。该令牌以“承载令牌”(bearer token)的形式传递。术语“承载”对应于授予令牌持有者访问权限的概念。对于您的服务而言,这意味着您授权系统代表与该承载令牌关联的租户执行操作。如果您打开其中一个HTTP请求,会看到承载令牌作为请求的授权头部的一部分。请求格式如下:

GET /api/orders HTTP/1.1
    Authorization:Bearer<JWT>

您可以看出这是一个对/api/orders URL的基本GET请求,请求头中包含一个授权头,其值为“Bearer”后跟您的JWT内容。让我们看看需要添加到服务中以访问此令牌中嵌入的租户上下文的代码。需要注意的是,此令牌经过编码和签名,因此我们需要解包它以获取感兴趣的声明。以下示例在先前示例的基础上添加了代码,引入了从传入的JWT中提取租户上下文的步骤:

def query_orders(self,status):
    auth_header = request.headers.get('Authorization')
    token = auth_header.split("")
    if(token[0]!="Bearer")
        raise Exception('No bearer token in request')
    bearer_token=token[1]
    decoded_jwt = jwt.decode(bearer_token, "secret", algorithms=["HS256"])
    tenant_id = decoded_jwt['tenantId']
    tenant_tier = decoded_jwt['tenantTier']
    logger.info("Finding orders with the status of %s", status)
    ...

我已省略实际的查询执行代码,因为目前该部分代码未做修改。我们需要关注的代码片段是从中获取并提取租户上下文的代码块。该代码块首先从整体HTTP请求中提取授权头,并将auth_header设置为"Bearer <JWT>",其中JWT代表您的编码后的令牌。接下来,代码执行基本的字符串操作,将JWT的内容复制到一个单独的字符串中。该字符串随后使用JWT库进行解码。最终结果是解码后的JWT存储在decoded_jwt变量中。最后一步是从JWT的自定义声明中获取租户ID。根据您的解决方案性质,您可能还需要在此处访问其他声明(如角色、层级等)。

在此示例中,假设您的服务负责解码每个令牌。但存在其他选项。例如,您可以部署一个API网关,位于所有服务之前,处理每个入站请求。该网关可解析JWT、获取租户上下文,并将其注入每个服务。这将使您能够实现更复杂的策略来处理每次请求中访问租户上下文带来的延迟。这只是您可能考虑的替代策略之一。关键在于,在请求的前端某个位置,您需要有代码能够为每个请求完成获取租户上下文的流程(无论是从缓存中获取还是每次从JWT中提取)。

一旦这段代码被执行(无论它位于何处),您的服务现在将能够访问其进行其他下游操作所需的租户上下文。对租户上下文的处理展示了我们在前几章中讨论的注册和身份验证流程的价值,说明了服务如何在无需调用其他服务或机制来获取租户上下文的情况下,开始处理多租户场景。

带租户上下文的日志记录和指标

目前,我们的代码已具备访问租户上下文的能力。然而,它尚未利用该上下文进行任何操作。让我们从多租户服务中可应用上下文的领域之一开始:日志记录。日志记录是每个服务都会使用的核心机制之一,通过生成包含信息和调试线索的消息,为系统活动提供必要的故障排查和分析依据。

现在,想象一下在SaaS环境中使用这些日志,多个租户同时使用你的服务。默认情况下,如果你不对日志进行任何处理,它们将包含一堆与任何特定租户无关的混杂信息。这将使你几乎无法拼凑出任何一个租户的活动视图。

任何单一租户的活动视图。如果您是运维团队成员,被告知租户1出现问题但其他人未报告,您将难以通过日志识别导致该租户特定问题的日志消息和事件。即使找到错误消息,也很难明确将其与特定租户关联。

好消息是,现在我们已经可以随时获取租户上下文,因此可以将此上下文注入日志消息中。这将引入租户上下文,使运维团队能够通过租户、层级等维度分析日志。让我们看看在添加了租户感知日志记录后,我们的代码会是什么样子:

def query_orders(self,status):
    auth_header = request.headers.get('Authorization')
    token = auth_header.split("")
    if(token[0] != "Bearer")
        raise Exception('No bearer token in request')
    bearer_token = token[1]
    decoded_jwt = jwt.decode(bearer_token, "secret", algorithms = ["HS256"])
    tenant_id = decoded_jwt['tenantId']
    tenant_tier = decoded_jwt['tenantTier']
    logger.info("Tenant:%s, Tier: %s, Find orders with status %s", tenant_id, tenant_tier, status);
    ...

我刚刚修改了订单服务中的一条日志消息。我们的消息会在日志开头添加租户上下文。此上下文将添加到服务中的所有日志消息中,为团队提供更丰富的数据,帮助他们深入了解特定租户的行为。如果您正在查询日志,现在可以按特定租户的上下文进行过滤,从而构建更完整的视图,了解各个租户与系统交互的情况。这并没有什么魔法,但这是那些看似微小却能对环境运营状况产生巨大影响的改动之一。

相同的日志记录理念也应应用于多租户架构的指标监控。是的,我们希望日志能构建租户活动的取证视图,但我们还需要用于业务分析的指标数据,以描述租户的资源消耗和活动模式,而这些信息并不完全符合日志消息的运营视图。

心理模型是,我们服务中生成的指标代表着洞察,这些洞察专注于提供可用于分析和回答塑造您业务、运营和架构战略问题的数据。在此,您正在分析服务如何影响租户体验,并跟踪您衡量一系列关键指标的能力,这些指标可供业务和技术团队用于评估系统效能、敏捷性、效率等。我们在第12章将详细探讨这些指标的具体应用。不过目前,我们需要先考虑这些指标的发布如何融入多租户服务的整体架构。

让我们回到订单服务,添加一个指标调用,以提供一个更具体的指标事件发布示例:

def query_orders(self, status):
    ...
    tenant_id = decoded_jwt['tenantId']
    tenant_tier = decoded_jwt['tenantTier']

    logger.info("Tenant:%s,Role:%s, Find orders with status: %s", tenant_id, tenant_role, status);
    try:
        start_time = time.time()
        response = ddb.query(TableName = "order_table", KeyConditionExpression = Key(status).eq(status))
        duration = (time.time()-start_time)
        message={
            "tenantId": tenant_id,
            "tier": tenant_tier,
            "service": "order",
            "operation": "query_orders",
            "duration": duration
        }
        firehose = boto3.client('firehose')
        firehose.put_record(
            DeliveryStreamName = "saas_metrics",
            Record = message
        )
    except ClientError as err:
        logger.error(
            "Tenant: %s, Find order error status: %s. Info: %s :%s",
            tenant_id,
            status,
            err.response['Error']['Code'],
            err.response['Error']['Message']
        )
        raise err
    else:
        returnresponse['Items']

在此示例中,我已在订单服务查询中添加了指标记录。为了简化示例,我仅添加了用于跟踪查询持续时间的指标。随后,我创建了一个包含租户上下文和当前操作所有数据的JSON对象。现在,我需要将此指标发布到能够摄取并聚合这些指标事件的服务中。在此示例中,我使用了AWS流式数据管道(AmazonKinesisDataFirehose)来摄取指标数据,构建Firehose客户端并调用put_record()方法将指标事件发送到服务。

再次,你可以看到,指标的instrumentation本身并不代表一个特别复杂的过程。大部分工作将用于确定你想要捕获的内容以及如何引入发布指标数据的代码。如果你的团队广泛采用,投资很小,但回报可能相当可观。

讲述指标故事的挑战在于,并不存在一种适用于所有服务的通用指标方法。指标的价值显而易见,但具体细节却难以确定。这通常需要基于您自身对识别能为业务带来最大价值的指标的渴望来推动。与此同时,我也要指出,一些最有效的SaaS公司正是那些优先考虑指标,并努力识别能够最好地指导其评估系统内部和外部体验的洞察力的公司。

基于租户上下文访问数据

日志记录和指标相对简单,主要聚焦于捕捉服务活动的洞察。现在让我们转向探讨租户上下文如何影响对单个租户数据的访问方式。

目前,我们的订单服务返回的数据尚未考虑租户上下文。事实上,如果不进行任何修改,该服务将为所有请求订单的租户返回相同数据。显然,这并非系统预期行为。为解决此问题,我们需将传入的租户上下文应用于查询,将订单视图限制为与调用租户关联的订单。

应用租户上下文的最自然且简单的方式是将租户添加到搜索参数中。我们已经拥有租户标识符,只需决定如何使用该标识符访问数据。您有多种选择。假设当前采用的是池化数据库模型,租户数据与订单数据存储在同一张表中。当数据池化时,我们可以在订单表中添加一个TenantId键,将每个订单与特定租户关联。此租户标识符将成为表的键。这意味着之前使用的状态现在将成为次要搜索参数,用于返回满足指定状态的租户的所有订单。

将租户上下文应用到查询的代码非常简单。在下面的示例中,我对服务中的查询部分进行了扩展,使用租户标识符作为键,状态作为过滤器:

response = ddb.query(TableName = "order_table", KeyConditionExpression = Key('TenantId').eq(tenant_id), FilterExpression = Attr('status').eq(status))

对数据库搜索的这一微小调整即可确保返回的订单仅限于与当前租户关联的订单。

在此场景中,我从最简单的用例开始。当我们开始考虑服务可能需要支持的各种存储策略组合时,数据访问的讨论会变得更加有趣。例如,假设您的系统为不同层级提供了不同的存储方案,如图7-10所示。

图7-10 支持分层存储模型

在此场景中,我们的订单服务处理来自基础层和高级层租户的请求。计算资源完全由这些租户共享。然而,如图7-10右侧所示,服务为每个层级采用了不同的存储策略。基础层租户的所有数据都存储在一个按租户ID索引的单一表中(与之前示例中的情况相同)。然而,高级层租户的数据则采用隔离模型存储,每个租户都有自己的专用存储空间。在此示例中,每个专用表都被分配了一个名称,以表明其与特定租户的绑定关系。

现在,考虑到这一新变化,让我们思考一下这对服务实现意味着什么。在服务代码的某个位置,您必须包含逻辑来检查每个租户的层级,以确定处理其请求应使用的表。并且,根据数据的存储方式,您可能需要在服务中定义多个执行路径,以支持识别和交互租户的订单数据。

让我们先采用一种暴力破解的方法来解决这个问题,知道我们需要进一步优化以简化流程。要让这个方案可行,我们需要在查询中添加一些映射操作,以确定要使用的表名(基于租户的层级)。我重新审视了服务中之前的查询,添加了一个新的getTenantOrderTable()函数,该函数根据租户的层级返回给定租户请求所使用的表名。以下是添加此功能的代码片段:

response=ddb.query(
    TableName = getTenantOrderTable(tenant_id, tenant_tier),
    KeyConditionExpression = Key('TenantId').eq(tenant_id),
    FilterExpression = Attr('status').eq(status)
)

def getTenantOrderTableName(tenant_id, tenant_tier):
    if tenant_tier == BASIC_TIER:
        table_name = "pooled_order_table"
    elif tenant_tier == PREMIUM_TIER:
        table_name = "order_table_" + tenantId
    return table_name

然而,这种方法假设基本层和高级层租户的表是相同的。在大多数情况下,它们确实相同;然而,我们的池化租户依赖于一个用于访问单个租户订单的TenantId键。该键在孤立的表中没有值或意义。许多团队会将此键保留在孤立表中,仅仅是为了避免支持额外的特殊行为。如果你选择从孤立资源中移除此键,你将需要更专业的代码来处理与数据的交互,以应对该键存在或缺失的情况。

当然,你的服务存储的数据类型和使用的技术会因场景而异。本文中讨论的示例只是多租户数据可能影响服务实现方式的众多方式之一。

支持租户隔离

我们刚才讨论的数据访问示例依赖于在查询中插入租户上下文来限制数据范围到指定租户。人们很容易认为,如果我们通过租户过滤这些查询,那么已经采取了所有措施来确保一个租户无法访问另一个租户的数据。从理论上讲,这是一个合理的期望。然而,在多租户环境中——租户隔离对租户的信任至关重要——仅通过租户过滤数据访问是不够的。

我们必须明确区分用于数据分区和访问的数据策略与用于强制实施租户隔离的数据策略。数据的存储和访问方式是我们所说的“数据分区”策略,该策略在第8章中进行了详细讨论。我们如何保护资源(包括数据)免受跨租户访问则被称为“租户隔离”,该主题在第9章中进行了详细阐述。当我们谈论隔离租户资源时,我们指的是多租户服务内部|179我们用于包围服务内部代码的措施,以确保开发人员不会有意或无意地跨越租户边界。因此,无论查询中包含何种租户参数,围绕该查询的租户隔离策略都会阻止该代码访问其他租户的资源。

这当然意味着,我们需要在服务实现中引入新的构造和机制来应用租户隔离策略。目标是让代码在访问任何资源之前以某种方式获取隔离上下文,并使用该上下文来限制资源访问范围仅限于当前租户。在该上下文应用后,任何对资源的交互尝试都将被限制为仅访问当前租户所属的资源。

现在,让我们看看如何将这一理论转化为更具体的实现,以便您更好地理解它在多租户服务中的应用。以我们正在访问DynamoDB的这个特定示例为例,我们可以通过配置会话时使用一组凭据来实现隔离目标,这些凭据会根据租户上下文限制数据访问范围。如果您回顾一下订单服务(Order)的起点,会看到Boto3客户端作为访问订单数据的客户端库被初始化。初始化代码如下:

def query_orders(self, status):
    ddb = boto3.client('dynamodb')

此处对Boto3库的初始化使用了更广泛的默认凭据集来初始化客户端。在此状态下,您的客户端以更广泛的范围初始化,允许其访问订单表中的任何项。这意味着此处的任何查询都可能访问任何租户的数据,无论传递给服务的租户上下文是什么。

我们的目标是为每个尝试获取订单的请求限制此客户端的访问范围,在初始化客户端时使用包含调用租户上下文的范围。要实现这一点,我们需要更改客户端的初始化方式。应用此范围的代码将类似于以下内容:

def query_orders(self,status):
    sts = boto3.client('sts')
    tenant_credentials = sts.assume_role(
        RoleArn = os.environ.get('IDENTITY_ROLE'),
        RoleSessionName = tenant_id,
        Policy = scoped_policy,
        DurationSeconds = 1000
    )

    tenant_scoped_session = boto3.Session(
        aws_access_key_id = tenant_credentials['Credentials']['AccessKeyId'],
        aws_secret_access_key = tenant_credentials['Credentials']['SecretAccessKey'],
        aws_session_token = tenant_credentials['Credentials']['SessionToken']
    )

    ddb = tenant_scoped_session.client('dynamodb')
    ...

此解决方案包含几个关键组件。首先,请注意,我们第一段代码专注于获取基于当前租户标识符范围的更窄范围凭据。在此示例中,我们仍限于AWS服务家族内部,借助AWS安全令牌服务(STS)实现此范围限制。通过STS,我可以定义一个策略来限制对订单表的访问权限。我们在此不深入探讨该策略的细节,只需了解它本质上是限制访问数据库中与给定租户ID匹配的条目。因此,当我调用assume_role()函数并传入策略和租户标识符(从JWT中提取)时,该服务将返回一组凭证,这些凭证将限制访问仅属于当前租户的条目。这些凭证存储在tenant_credentials变量中。

获取凭证后,我们可以使用assume_role()调用返回的特定凭证值声明并初始化会话。以下是AWS服务在访问资源时通常使用的凭证值示例。

现在,我们只需声明我们的DynamoDB客户端(与之前相同)。不过,此次客户端是通过tenant_scoped_session变量创建的。这本质上是告诉Boto3使用我们在之前步骤中设置的凭据值来初始化客户端。现在,当我们使用此客户端调用查询命令时,它将继承范围策略并将其应用于通过此客户端发出的任何调用。

这种机制实现了真正的租户隔离,为任何数据库调用提供租户范围的上下文。现在,无论开发人员在查询中设置了什么值或配置,系统都会阻止服务访问当前租户无效的数据。

此示例应能帮助您更好地理解租户隔离如何影响多租户服务的资源占用。正确实现这一点对于构建解决方案的健壮隔离机制至关重要。然而,挑战在于存在诸多因素使得难以采用通用的隔离方案。您使用的技术、所处的云平台、消费的服务、资源的孤岛或池化范围——这些要素可能都需要不同的策略来描述和实施服务内的租户隔离。我们在此所遵循的隔离原则和思维方式仍然适用于任何服务。真正的差异在于这些概念的具体实现和落地过程中。

此外,需注意您的服务很可能与多种不同类型的资源交互。我们讨论的隔离策略和方法旨在适用于任何可能管理或涉及租户特定构造的资源。例如,队列可能需要某种形式的隔离。

隐藏并集中管理多租户细节

在我们开始讨论构建多租户服务时,我特别强调要能够引入这些概念,同时避免让开发体验变得臃肿或复杂。我的目标是隐藏并集中管理许多多租户相关构造,确保服务代码专注于实现应用程序的业务逻辑。

到目前为止,我尚未实现这一目标。事实上,如果将我们讨论的所有概念整合到订单服务的最终版本中,其规模和复杂度可能会增加三倍。您也可以想象,如果将这些代码分散在每个服务中,会多么低效——将通用概念和构造分散到系统中的每个服务。这至少代表着糟糕的编程实践。它还会限制我集中管理多租户策略和政策的能力。

这就是我们运用基础构建技能的地方,寻找将这些概念从服务中提取出来并封装到库中的自然机会。这种做法本身并非多租户特有的。作为多租户架构师,我更关注的是确保服务开发者的体验尽可能简洁高效。

回顾我们的示例,可以发现其中有部分代码可轻松迁移至辅助库。例如,我们为从JWT中获取租户上下文而添加的代码,可提取为独立函数。

代码只需从服务中提取出来,转换为类似以下形式的函数:

def get_tenant_context(request):
    auth_header = request.headers.get('Authorization')
    token = auth_header.split("")
    if(token[0] != "Bearer")
        raise Exception('No bearer token in request')
    bearer_token = token[1]
    decoded_jwt = jwt.decode(bearer_token, "secret", algorithms = ["HS256"])
    tenant_context = {
        "TenantId": decoded_jwt['tenantId'],
        "Tier": decoded_jwt['tenantTier']
    }
    return tenant_context

此新的get_tenant_context()函数接受一个HTTP请求,并执行我们之前描述的所有操作,以提取JWT、解码它并提取包含租户上下文的自定义声明。我对函数进行了一些调整,将所有自定义声明放入一个JSON对象中。这里返回的内容和方式将取决于自定义声明的具体内容。您可能需要单独的函数来获取特定的自定义声明(例如get_tenant_id())。这更多是风格问题,以及在特定环境中什么方法最有效。

关键在于,该库现在意味着任何需要提取多租户上下文的服务只需调用该库一次,即可减少服务中实际使用的代码量。此外,它还允许您修改JWT策略而不会导致这些更改在整个解决方案中传播。想象一下,如果您需要更改JWT的编码或签名方式。借助该库中的集中化功能,您现在可以在服务开发人员无法察觉的情况下进行这些修改。

同样的思路也可应用于我们之前提到的日志记录、指标、数据访问和租户隔离代码。这些领域均可通过引入标准化处理多租户概念的库来解决。

对于日志记录和指标,关键在于消除每次记录消息或指标时注入租户上下文的额外开销。现在,您可以简单地将请求上下文共享给这些调用,并让外部函数决定如何获取租户上下文并将其注入消息和事件中。

数据访问是一个可能不太通用且可能需要特定服务本地辅助功能的领域。如您所记得的,我们曾讨论过一个用例,其中订单服务可能需要支持分层存储模型,每个层可能需要将请求路由到不同的订单表。在这种情况下,您可能需要依赖于传统数据访问库(DAL)或仓库模式,以创建一个专门的构造体,用于抽象化与服务存储交互的细节。在此,该DAL可封装所有多租户需求,包括在该层内实现隔离(完全脱离服务开发者的视图)。

现在,假设我们将所有多租户相关代码移至一个库中。服务代码将得到大幅简化,回归到多租户引入前的版本。以下代码引入了一个获取租户上下文的新函数,添加了一个注入租户上下文的日志封装器,添加了一个获取租户范围数据库客户端的函数,并使用订单DAL隐藏了映射层级到具体表的细节:

def query_orders(request, status):
    tenant_context = get_tenant_context(request)
    ddb = get_scoped_client(tenant_context, policy)

    log_helper.info(request, "Find order with the status of %s", status)

    try:
        response = get_orders(ddb, tenant_context, status)
    except ClientError as err:
        log_helper.error(
            request,
            "Find order error, status: %s. Info:%s:%s",
            status,
            err.response['Error']['Code'],
            err.response['Error']['Message']
        )
        raise
    else:
        return response['Items']

虽然这个概念在实际应用中可能会有很多细节差异,但这个示例让你大致了解这种方法如何影响服务实现。从许多方面来看,这归结于遵循基本的编程最佳实践。这里的优雅之处不在于这些库的具体实现细节,而在于它们为服务构建者带来的价值。

拦截工具与策略

此时,您可以看到将这些共享的多租户概念移至库中是多么合理。然而,除了将此代码移出服务外,您还应考虑使用不同的技术和语言构造来简化开发人员体验并集中多租户策略。

基本思路是,我们希望探索如何利用特定技术构造的内置能力来支持这些横向多租户需求,从而使您能够以最小的服务构建者协作成本引入并配置多租户操作和策略。

例如,考虑我们对租户隔离的处理方式。如果我的语言和工具提供了一种机制,允许我在服务与访问的资源之间插入处理逻辑,这可能使我能够在服务视图之外完全实现隔离模型中的某些方面。

在此提供指导的难点在于,实现这一方法的可能策略列表相当长。例如,每种语言及其配套框架都会带来独特的选项组合。此外,架构中涉及的不同技术栈和云服务可能包含可应用于此场景的自有构造。这些构造的具体应用方式将根据不同选项的具体细节而显著不同。虽然回顾所有可能性的范围是没有意义的,但我还是想突出几个示例策略,以帮助您更好地理解与这种思路相契合的不同类型机制。

切面(Aspects)

切面通常作为语言或框架构造引入。它们允许您将横切机制编织到代码中,从而在服务足迹中注入预处理和后处理逻辑。这使您能够在服务中引入全局策略和策略,这些策略可能与环境中的一些多租户机制相契合。图7-11展示了切面模型的概念视图。

图7-11 使用切面应用租户上下文

该图的中心是您的服务代码。您服务的开发过程中,大部分代码对周围的策略并不知情。借助面向切面编程,我可以将额外的处理逻辑以切面形式注入服务中,这些逻辑将在租户请求进入和退出时被执行。该代码通过我选择的切面工具或技术与服务紧密集成。

您可以想象这种设计如何完美适配处理、处理和应用租户上下文操作。例如,您可以使用一个切面来拦截进入服务的每个请求,添加预处理逻辑以从HTTP头中提取JWT、解码它,并为后续请求初始化租户上下文。您还可以考虑实现租户隔离模型的一部分,获取并注入所需的租户范围凭据,以强制执行隔离策略。

关键在于,这将成为所有服务中标准化的机制,作为全球策略的一部分嵌入其中(而非依赖开发人员在代码中调用相应的辅助函数)。

边车(Sidecar)

如果您使用Kubernetes构建多租户架构,可以考虑是否使用边车来为服务应用多租户策略。边车在Kubernetes环境中的独立容器中运行,能够位于服务与其他资源及服务之间。边车的优势在于其完全脱离服务视图。这使得您可以以无需服务配合的方式应用全局多租户策略。图7-12展示了边车模型的概念视图。

图7-12 使用边车实现水平概念

在图7-12的左侧,您可以看到我的应用程序服务,其中包含我的业务逻辑。而在最右侧,则是一些我的服务将与之交互的资源。这些资源可以是另一个服务、数据库,或是任何其他类型的构造。关键点在于,边车位于我和该资源之间。这使得边车能够在我的服务视图之外拦截并应用租户上下文,提取上下文并应用与我的服务及其消费的资源相关的任何策略。能够单独部署和配置此边车,使我能够创建一个更加健壮的多租户策略执行方案,从而对我的服务与其他资源的交互拥有更大的控制权。

中间件(Middleware)

一些开发框架支持中间件的概念。其理念是,您可以引入位于入站请求和目标操作之间的代码。这允许您拦截并应用任何将应用于整个服务的全局策略。这种中间件机制通常用于Node.js Express框架。该框架提供了实现我们本文介绍的许多多租户服务策略(租户上下文、隔离等)所需的所有内置结构。

AWSLambda层/扩展

我提到过,不同的云提供商及其服务可能包含一些非常适合实现某些跨领域多租户策略的结构。我认为值得在这里强调一个例子。如果您正在AWS上构建无服务器SaaS环境,则可以使用Lambda层或Lambda扩展将共享库迁移到独立机制中。

使用Lambda层,您基本上可以将所有辅助函数迁移到一个共享库中,然后独立部署该共享库。服务中的每个Lambda函数都可以引用此共享库中的代码,从而允许它们访问您拥有的不同辅助函数,而无需将这些代码作为每个服务的一部分。这使您可以完全独立地管理、版本控制和部署这些全局共享的结构。现在,例如,当您想要更新隔离机制时,您可以更新Lambda层中的代码,部署它,并让每个服务都使用此新功能进行更新。

另一方面,Lambda扩展更符合我们之前讨论过的方面模式,它允许您将自定义代码与Lambda函数的生命周期关联起来。例如,您可以使用Lambda扩展在请求进入服务函数时对其进行预处理。Lambda扩展的代码也可以位于Lambda层中。

构建多租户服务 | 构建多租户SaaS系统 | IT书单