攻克微服务中的最大难点:用户数据

本文转自http://blog.shurenyun.com/shurenyun-microservice-149/
本文是译文,翻译得不是太通俗易懂。
核心思想是:
1)服务的扩展能力往往受限于数据库的能力
2)每个微服务应该有独立的数据库,以保证扩展性
3)采用消息队列处理不同微服务 / 数据源之间的同步

只有我们摆脱自己的依赖时微服务才能起作用,换言之,存在于单一数据库上的多任务进程并不是真正的微服务。使用 Spring Boot/Dropwizard/Docker 并不代表大家所构建的就是微服务。再次强调,大家需要着眼于所处业务领域,而我们的数据才是实现微服务的关键所在。

考虑到我们正在着手尝试建立微服务架构,因此其中最重要的目标就是保证团队有能力以影响最低的方式通过不同速度对系统中的不同组件进行分别处理。因此,我们希望整个团队拥有自主性,能够找到最理想的实施手段并运行自己的服务,同时自由地根据业务需要做出快速变更。如果我们的团队能够满足这些既定要求,那么系统架构也将对应发生变化,即真正向微服务时代迈进。

为了实现这种自主性,我们需要“摆脱依赖性”,但这项目标显然是说着容易做起来难。我曾见证过无数从业者将微服务概念简单理解为“每项微服务都应该拥有并控制自己的数据库,且任意两项服务不应共享同一套数据库”。这样的理解非常合理,因为只要严格遵循上述要求,我们就不会遭遇由跨服务共享数据库所带来的读 / 写模式、数据模型冲突以及协调挑战等等。然而,单一数据库的实现方式也确实拥有诸多安全与便利性优势:ACID 事务、着眼点单一、易于理解、单点管理等等。那么在构建微服务时,我们该如何顺利将单一数据库拆分成多套规模较小的数据库?

下面让我们共同找出答案。首先,对于一家有意构建微服务的“企业”,我们需要明确以下几个问题:

  • 立足怎样的业务领域?现实情况又是如何?
  • 事务边界在哪里?
  • 微服务应当如何在不同边界之间实现通信?
  • 我们将数据库剥离出来会造成怎样的影响?

立足怎样的业务领域?

这个问题往往受到人们的忽视,但必须承认微服务实践在互联网企业与传统企业当中往往存在着巨大差异(而这种忽视也是造成项目失败的主要原因之一)。

在构建微服务架构以及确定相关数据使用理由(例如用于生产 / 消费等)之前,我们首先要对数据的含义拥有清晰而正确的认识。举例一个团购网站,在我们将信息存储在与“预订”相关的数据库并把其进一步迁移至微服务之前,我们先要理解“预订是什么”。就像在 HR 领域一样,大家可能还需要了解“帐户”、“员工”以及“索赔”等相关定义。

为了实现这一目标,我们需要立足于现实回答更多关于理解的问题。例如,“书是什么?”虽然这个例子非常简单,但却能够很好地反映实际工作中的概念理解过程。因此,思考书的定义,而后为其设计合适的数据模型。

书是由不同分页所构成吗?报纸算不算书籍?因为其同样拥有分页结构。那么书籍是否拥有硬质封面?或者代表的是每天定期出版的各种刊物?如果我本人撰写了一部书籍,出版商可能只会在作者名下列出单一条目。但如果某家书店在出售五本由我撰写的书籍,那么这些书是彼此不同还是同一本书的多份副本?我们该如何表示这种关系?如果书籍内容过长,被拆分成了多个分卷又该如何处理?每部分是否也应算是独立的书籍?又或者全部分卷加起来才算一本书?众多细小部分组合起来应该如何处理?由此组成的整体算不算是书籍?又或者每个部分都属于书籍?最后再提出一种比较复杂的假设,我出版了一本书,书店中摆着大量同样内容的印本,而每种印本又分为多个分卷。在这种情况下,我们该如何定义“书”的概念?

现实情况是,并不存在这样确切的定义。“书是什么”这个问题没有一个绝对的概念,因此我们在回答之前需要了解“谁在提问以及上下文背景是什么。”换言之,即如今的上下文为王概念。我们人类能够快速(甚至是无意识地)解决这种认识模糊性问题,因为我们头脑中存在着上下文背景,其中涵盖环境与问题本身。但计算机显然缺乏这种认知基础。我们需要在构建软件与建立数据模型时,为其指定相关上下文。用书的概念可以让这样的理解过程变得更为明确。我们所处的业务领域(或者说企业指向)拥有其帐户、客户、预订、索赔等事务,其概念显示更加复杂且易产生冲突 / 模糊性。因此,我们需要为其设定明确的边界。

那么新的问题来了,我们该如何绘制这些边界?为了实现对所在领域的建模,我们需要对实体、值对象以及聚合等概念设定上下文。换言之,我们借此建立并完善整套代表所在业务领域的模型,该模型包含上下文中关于边界的各项定义。这种明确的边界最终将成为我们的微服务,或者作为微服务的边界内组件,抑或二者兼有。无论如何,微服务的核心在于边界,DDD(即业务驱动型设计)亦是如此。

我们的数据模型(即希望如何表现物理数据存储中的概念——注意其中的明确区别)受到业务领域模型的驱动,而非前者驱动后者。在设定了边界之后,我们将能够识别模型当中“正确”与不正确的部分。这些边界同时也能够实现一定程度的自主性。为上下文“A”指定一个与上下文“B”不同的“书籍”定义(例如将上下文‘A’设定为一项搜索服务,负责搜索标题被指定为‘书’的条目; 而上下文‘B’则为检查服务,负责根据书籍总量(即标题加副本)处理事务)。

在这里,大家可能会提出质疑:“等等,Netflix、Twitter 或者领英并没有给出过任何关于业务驱动设计的说明。为什么我要遵循所谓 DDD 原则?”下面一起来看原因:

“人们都希望复制 Netflix,但却只能复制到其表象。他们复制到的只是结果,而并非过程”——Netflix 公司前任首席云架构师 Adrian Cockcroft

迈向微服务的旅程就是这么一段……旅程。每一家企业都拥有自己需要克服的坎坷与艰辛。其中并不存在任何固定或者便捷的规则,而只有权衡与取舍。复制某家企业的成功作法并不足以帮助我们同样顺利地完成这一流程 / 旅程,其甚至可能根本无法奏效。另外,也正是这样的思路才让我们的企业无法成为 Netflix。事实上,我一直认为无论 Netflix 的领域复杂性有多么可怕,其仍然无法与传统企业面临的难题相提并论。搜索并显示影片、发布推文、更新领英动态等等绝对要比保障索赔处理系统简单得多。这些互联网企业之所以能够快速迎接微服务,是因为其需要出色的产品上市速度并应对批量 / 规模化负载(向 Twitter 发布一条推文非常简单,但面向 5 亿用户发布推文并显示内容则非常复杂)。如今的企业将不得不同时面临来自领域与规模两个层面的复杂性挑战。因此,我们在这段转型旅程当中必须在领域、规模以及组织变更之间做出权衡。每家企业都面临着不同的难题,而这也必须成为我们的关注重点。

事务边界是什么?

重新回到我们的主要议题上来。我们需要利用业务驱动设计等手段帮助自身理解用于实现系统的模型,同时在单一上下文当中围绕这些模型绘制边界。因此,我们需要将客户、帐户、预订等事务视为被绑定至不同上下文的不同事物,但最终亦需要立足于同一架构对这些相关概念进行分发,并在发生变更时调和这些模型所受到的具体影响。我们需要将这些情况全部纳入考量,但首先确立事务边界无疑是最为重要的前提。

遗憾的是,作为开发者的我们似乎仍然在以完全错误的方式进行分布式系统构建:我们仍然遵循着单一、孤立的关系型 ACID 数据库思路作为指导方针。我们还忽略了异步且可靠性较低的网络环境可能带来的风险。要知道,编写框架这类工作并不要求从业者对网络(包括 RPC 框架——数据库抽象方案亦严重忽略了网络因素)有多么了解,这意味着多数人都倾向于或者只知道如何利用点到点同步调用一切(包括 REST、SOAP 以及对象序列化 RPC 库等其它 CORBA)。我们构建的系统没有考虑到授权与自主间的平衡,而且最终往往只能依靠覆盖众多独立服务的两段式提交方式解决数据分发问题。或者,我们彻底忽略了以上各项要求。由此建立起的系统扩展性极差且相当脆弱——即使我们非要将其称为 SOA、微服务乃至迷你服务等等,其仍然不具备此类架构真正需要拥有的特性与优势。

那么我们到底该如何理解事务边界?我将其理解为业务常量当中最小的基本单位。无论大家是利用数据库的 ACID 属性以实现这种基础性,抑或是选择二段式提交,其实都无关紧要。真正的重点在于,我们希望尽可能缩小这些事务边界(在理想状态下,应该是单一事务对应单一对象),从而确保其可扩展能力。当我们建立自己的业务领域模型时,使用 DDD 技术以明确实体、值对象与聚合。在此上下文当中,聚合代表的是那些包含其它实体 / 值对象的对象,负责执行不变量(单一有界上下文当中可包含多组聚合)。

举例来说,让我们设定以下几种用例:

  • “允许客户搜索航班信息。”
  • “允许客户在特定航班中选择座位。”
  • “允许客户预订航班。”

我们在这里可以设置三种有界上下文:搜索、预订与票务(当然,我们也可以设置更多相关上下文,包括支付、忠诚度、待机、升舱等,但这里我们就只强调这三种)。搜索负责显示特定路线以及与给定时间相符的航班选项。预订则负责利用客户信息建立预订流程(包括姓名、地址、会员编号等)、座位偏好以及支付信息。票务将负责与航空公司沟通并完成机票签发。在各有界上下文当中,我们都需要确定事务边界以执行约束 / 恒量。我们不需要在有界上下文当中考虑基本事务(亦称为原子事务,我们将在下一章节中对此进行详尽说明)。

那么我们要如何在建模时允许考虑最小事务边界(在本示例中,其应被简化为航班预订流程)?也许设置一套“航班”聚合是不错的作法,其中可以囊括时间、日期、路线以及客户、机型以及预订等实体条目?听起来很有搞头,其中单一航班拥有具体的机型、座位、客户与预订条目。航班聚合负责在预订创建过程中追踪机型及座位等信息。在这种情况下,我们可以选择在数据库当中建立一套数据模型(这是一种标准的关系模型,其中包含约束与外键等),或者在源代码中生成一套对象模型(继承 / 组合),下面我们看看具体作法会带来怎样的变数。 ​在利用预订、机型以及航班等信息创建一条预订时,其中是否真的存在恒量?换言之,如果我们向航班聚合当中新增一种机型信息,那么我们是否真的应当将客户与预订包含在该事务当中?答案恐怕是否定的。我们在这里需要考虑的是如何利用可组合数据模型建立聚合。然而,这样的事务边界太过庞大。如果我们面对着大量航班、座位与预订信息的变更,则可能出现众多事务冲突。而且这种作法显然不具备可扩展性(而且一次航班计划变更就会带来大量订单失败,从而造成糟糕的客户体验)。

那么我们显然需要把事务边界设定得更小一点。

也许预订、可选座位以及航班三者自有拥有自己的独立聚合。一条预订当中可包含客户信息、偏好以及可能的支付信息。可用座位聚合则包含有机型与内部配置。航班聚合由日程安排与路线构成,等我们能够在不影响航班规划以及机型 / 可用座位等事务的前提下,随时调整预订内容。从领域的角度来看,我们希望实现这样的效果。我们不需要在机型 / 航班 / 预订之间实现 100% 的严格一致,而是希望确保管理员能够正确记录航班规划变更、供应商正确提供机型配置并由客户正确完成预订。那么我们该如何完成在航班中“选择特定座位”这样的操作?

在预订过程中,我们可能需要调用可用座位聚合并要求其在飞机上保留一个座位。这一座位保留操作可能作为单一事务(例如保留座位 23A)实现,同时返回一个保留 ID。我们可以将该保留 ID 分配给对应预订,而后将该分配有座位的预订提交为“保留”点。其中的每个环节(包括保留座位与接受预订)都作为独立事务存在,且可在无需任何二段式提交或者二段式锁定的前提下独立进行。

需要注意的是,这里使用“保留”是一种业务要求。我们在这里并不进行实际座位分配,而只是保留该座位。此要求可能需要通过模型迭代对其进行约束,这是因为该用例的初始作用描述可能只是“允许客户选择某个座位”。开发人员可能会对其做出不同理解,例如“从剩余的座位中挑选并分配给客户,而后将该座位从清单中移除,避免其被重复出售”。这将会给我们的事务模型增加额外的负担,因为其中的业务并没有被真正作为恒定量处理。此业务在处理预订操作时表现良好,但却无法解决座位分配与航班超额销售等问题。

通过以上实例,大家应该已经清楚了为什么我们需要为单一聚合提供尽可能小、尽可能简单且最为基本的事务边界。不过事情到这里还没结束,因为我们现在需要解决新的问题,即如何将这些高度独立的事务整合起来。其中涉及多种不同数据成分(即我们创建了一条预订与座位保留设置,但其尚未被真正纳入登机牌 / 出票流程)。

微服务该如何在不同边界间完成通信?

我们希望保有真正的恒定业务量。在 DDD 的帮助下,我们可以选择将这些恒定量建模为聚合,并强制要求每一聚合对应一项事务。有时候我们可能需要在单一事务当中更新多种聚合(中专单一或者多套数据库),但此类场景算是意外情况。我们还需要在不同聚合之间保持一定程度的一致性(并最终在不同边界上下文间保持这种一致性),那么这一点要如何实现?

首先需要强调的是,分布式系统非常难以打理。事实上,几乎没人能够在毫无挫折的前提下在有限的时间内建立起一套分布式系统(可能出现的问题包括故障、不确定原因造成的性能缓慢或者服务停机、系统中出现非同步时间边界等),那么我们为什么非要使用这样一种系统?我们能否将其整合在跨越自身领域的一致性模型当中?在必要事务边界之间使用不同数据与领域组成部分,并在之后的特定时间点上重新实现一致性——这样的思路又是否可行?

正如之前所提到,我们一直在强调微服务的自主权价值。我们需要有能力对其它系统进行独立变更(具体包括可用性、协议与格式等形式)。这将把时间元素解耦出来,确保任意服务之间的对象在任意时间段内皆可实现这种自主性优势。

如前所述,在不同事务边界与有界上下文之间使用事件以完成一致性通信。事件属于恒定结构,用于捕捉特定时间点中应当被广播给受众的重要点。受众则接收自己感兴趣的事件并根据该数据制定决策、存储该数据、存储该数据的部分衍生信息、根据所制定决策更新部分自有数据等等。

继续以之前提到的航班预订为例,在通过 ACID 类事务进行预订信息存储时,我们要如何最终完成出票?

在这里,之前提到的票务有界上下文要发挥作用了。预订有界上下文将会发布一条类似于“新预订创建”之类的事件,而票务有界上下文则消息此事件并随后与后端(可能是传统)出票系统进行交互。其中显然要涉及某种集成与数据转换,我们可以利用 Apache Camel 来实现。另外,这一流程还带来了其它几个问题。我们该如何向数据库写入并立足于基本层面向设备发布一条队列 / 消息?另外,如果我们在不同事件之间进行了多次请求,结果会如何?再有,为每项服务指定单一配套数据库,效果又会有何时不同?

在理解情况下,我们的聚合将直接使用命令与领域事件(作为最优作法,即全部操作皆作为命令执行,而任何响应都作为事件响应存在),并能够更为明确地将内部使用的事件与有界上下文进行映射。

我们可以直接将事件(即新预订创建事件)发布至消息收发队列,而后由监听方从队列中进行消费,并将其插入至数据库当中——不使用 XA/2PC 事务,而是直接插入数据库自身。我们可以将该事件插入至某个既作为数据库又作为消息发布 - 订阅主题(在本示例中可能代表偏好航线)的特定事件。或者,我们也可以继续使用 ACID 数据库并将变更以数据流方式提交至数据库,而后由 Apache Kafka 等持久性复制日志系统利用某种事件处理器 / 流处理器进行事件处理。无论选择哪种方式,我们的最终目标是通过恒定时间事件点实现不同边界间的通信。

这种作法能够实现以下几大关键优势:

  • 避免使用成本高昂甚至可能无法实现的跨边界事务模型。
  • 我们可以对自身系统进行变更,而无需影响到系统其它组件的执行进程(包括计时与可用性)。
  • 我们可以决定与外界间的同步节奏,从而在不占用过多资源的前提下保持一定程度的同步性。
  • 我们可以将数据存储在自有数据库内,同时为自己的服务选择最合适的配套技术方案。
  • 我们可以在闲暇时段对自身模式 / 数据库做出变更。
  • 我们将拥有更出色的可扩展性、容错性以及灵活性。
  • 我们需要高度关注 CAP 定理以及用于实现存储 / 队列机制的各项技术。

但必须承认,这种作法也会带来以下弊端:

  • 其复杂程度更高。
  • 更难于调试。
  • 由于在查看事件时可能存在延迟,因此我们无法假设其它系统知晓实时情况(利用其它方案可能同样无法实现实时反馈,但这种作法的延迟性更为明显)。
  • 更加难以操作。
  • 我们需要高度关注 CAP 定理以及用于实现存储 / 队列机制的各项技术。

我在优势与弊端当中都列出了“高度关注 CAP……”这一条,因为尽管其对于大家而言可能是一种负担,但却有着必须这样做的理由!只有做到这一点,我们才能够时刻确保分布式数据系统当中不同形式数据的一致性与并发性!单纯认为“我们的数据库符合 ACID”已经不足以说明问题(特别是 ACID 数据库很可能默认存在一致性薄弱的问题)。

这种方案中涉及的另一项有趣概念名为“命令查询间隔责任”,这意味着我们要将实际模型与写入模型拆分成多个独立服务。请注意,之前提到过互联网企业往往无需面对复杂的领域模型,这一点已经通过其编写的简单模型得到了充分证明(例如可以将一条推文插入至一份分布式日志当中)。

然而,他们的读取模型却由于规模极大而复杂到丧心病狂。CQRS 能够帮助互联网厂商解决这些问题。在另一方面,传统企业的写入模型则往往更加复杂,而读取模型则由于只涉及普通的选择查询与 DTO 对象而显得比较简单。CQRS 是一种强大的拆分手段,能够在边界设置妥当后执行评估,同时在不同聚合与有界上下文之间完成数据变更。

那么,如果一项服务只具有一套数据库且不同其它服务分享该数据库,结果又会如何?在这种情况下,我们的受众可能会订阅事件流并将主聚合所使用的数据插入到一套共享数据库当中。这种“共享式数据库”的实际效果非常理想。请注意,具体实现方式并无规则,而只是做出权衡。在本示例当中,我们可以让多项服务共同围绕同一数据库起效,而且由于我们的团队拥有全部进程,所以无需在自主权方面做出妥协。因此,如果下次有人提到“微服务就应该拥有自己的数据库,而且不与其它服务共享该数据库”,大家可以云淡风清地回应“差不多吧”。

如果我们将之前章节中的概念推向逻辑极端,会造成怎样的结果?如果我们利用事件 / 流处理一切,同时又想保证事件间永远一致,该怎么办?如果我们将数据库 / 缓存 / 索引仅仅视为过往日志 / 事件流的一种持久性物化视力,而当前状态则属于全部事件的顺序集合,结果又会怎样?

最后,我们再来补充一些此种事件间通信方案所能带来的实际收益:

  • 现在大家能够将自己的数据库视为一种记录的“当前状态”,而非实际记录。
  • 大家可以引入新型应用并重读以往事件,从而检查其行为“会发生哪些变化”。
  • 大家可以完善审计记录而无需承担任何成本。
  • 大家可以引入新的应用程序版本并通过重播事件的方式完成详尽测试。
  • 大家可以更轻松地在新数据库中重播事件,从而明确数据库版本控制 / 升级 / 模式的变更理由。
  • 大家可以迁移至其它全新数据库技术(即现有关系型数据库已经无法满足需求,这时我们可能需要切换至其它特定数据库 / 索引机制)。

当我们在 aa.com 或者 united.com 网站上预订某次航班时,就能看到各项概念的起效过程。在选定某个座位时,该座位实际并没有被真正分配给我们,而只是得到了保留。在预订航班时,我们也还没有真正完成出票。使用过这类服务的朋友都知道,我们稍后会通过邮件或者短信得到出票确认。那么大家有没有经历过航班变化而导致实际座位与预订不符的情况?或者是已经登机完成,却发现由于超量出票而不得不站到终点?这就是事务边界、最终一致性、补偿事务乃至事故带来的真实案例。

故事带来的启示

这个故事带来的启示在于,数据、数据集成、数据边界、企业使用模式、分布式系统理论以及计时机制等等都属于微服务中的重要组成部分(因为微服务实际上就是一套分布式系统)。我发现很多朋友对于这项技术还存在着严重误解(例如‘我用了 Spring Boot,所以我建立的是微服务’、‘我需要解决服务发现与云端的负载均衡问题,而后才能实现微服务’乃至‘我必须为每项微服务提供一套对应的数据库’等等),并为微服务制定了一大堆无用的“规则”。别担心,众多大型厂商已经介入并开始销售各类相关产品,所以其它问题都将很快得到解决——只留下最困难的部分,数据。

文章来源:DZone 作者:Christian Posta