@Lenciel

微服务架构里的数据处理

之前说过,实践微服务架构的最大收益在于对团队的改造:我们希望构建起彼此独立,以不同技术栈和不同速度进行工作,在需求变更时能够快速响应和更新而不会相互影响,具备良好自治性的团队。根据康威定律,如果我们的组织结构进化成这样,我们的软件才可以变得符合“微服务架构”。

要达到这种自治性,就需要“解耦”:这个词90%念叨它的人都不知道怎么做,有一些所谓的微服务架构实践指南上甚至有“每个微服务应该有自己的数据库,两个微服务之间不能共享数据库”这样的硬性条款。乍听起来这很棒,因为你不会遇到不同的服务读写模型不同带来的各种竞争,也不会遇到不同业务需要的数据模型不同带来的冲突等等。

但是这样的设计也会丧失一些单一数据库的优势:比如拥有ACID属性的事务,比如更加方便完成数据的管理和变更,比如大家讨论起问题来,使用的术语比较一致。

那么在实践微服务架构的时候,我们如何治理数据?首先,我们需要弄清下面的问题:

  • 什么是领域?什么是现实?
  • 什么是事务边界?
  • 不同的服务如何跨越边界进行通信?
  • 如果换种方式思考数据呢?

什么是领域?

在大多数关于微服务架构的讨论中,“领域”这个关键的概念,也就是DDD(Domain Driven Design)里面的Domain,都是没有被提及的。

我们在构建一个微服务之前,首先需要搞明白它解决的领域内数据是如何流转的(包括产生和消费的数据)。比如,如果我们迁移一个叫“短信服务”的系统到微服务架构上,那么你要了解这个领域里面的数据对象和数据的流转:发送方,接收方,白名单,黑名单,消息模板,定时器等等。

要了解这些数据对象首先要了解它们的“现实模型”:在现实生活里面,什么是一个短信的发送方?它有什么属性?如何进行数据建模?

很容易想到,发送方需要有个手机号,然后呢?

如果你负责的是业务安全,可能会想到,需要限制发送频次吗?按天限制还是按分钟?限制是全局的还是对单个发送方生效的?如果超过了频次需要加入黑名单吗?是永久加入还是惩罚性的?

如果你是做别的业务,你考虑的出发点就会大大不同:在没有“为了处理什么业务来进行设计,上下文是什么”的定义之前,对任何对象都没法有一个“客观现实的定义”。

明确“上下文”的重要性常常被我们忽略,可能是因为人类的大脑天生可以处理上下文。人类在沟通的时候,会根据自己的判断,把歧义去掉,把话题纳入正确的上下文里面去讨论。而电脑是做不到这点的:我们必须通过在某个确定的领域下进行数据建模,来让电脑明确我们要处理的业务。

对一个短信的发送方进行所谓的“领域建模”是相对容易的,真正的系统设计里面,我们往往面对的是复杂度高很多的,多个数据模型组成的领域模型的构建,这个时候就需要边界。

边界

什么时候需要划分边界?在DDD协会发布的材料里面建议,围绕Entities、Value Objects和Aggregates来进行领域建模,从而确定一个有边界的上下文

换句话说,我们定义和优化领域模型的过程里面,就会形成一个定义这个领域的上下文的边界。这一个个边界清晰的领域可以以微服务架构里面的一个个服务来实现,边界里面的一个个组成部分又可以细化成独立的领域,再进行边界的划分和实现。

所以,微服务架构里面,采用DDD进行实施,确定边界,很重要。

注意,我们的数据模型(我们对系统中的实体按照现实生活中它的属性建立的模型)应该驱动领域模型成形,不要反过来了。

当我们有了领域模型,形成了边界之后,这些边界划分的粒度,也可以一定程度上体现系统的自治性。

你可能会问,Netflix,Twitter,淘宝等等大厂,大家都在说搞微服务,可没有谁说过什么搞DDD,为什么它很重要?

其实并不是这样,你看Netflix的架构师是这么说的:

“People try to copy Netflix, but they can only copy what they see. They copy the results, not the process”

Adrian Cockcroft, former Netflix Chief Cloud Architect

实践微服务框架的落地,并没有固定路径可走,每个公司都会有自己的实际情况,所以原样照搬Netflix或者任何一家公司的经验,都注定失败。

但这里的“实际情况”究竟是什么?为什么不能照搬?

有一种经常被挂在嘴边不去搞Netflix那一套的理由是,“我们不是Netflix,我们的业务没有那么复杂”。其实Netflix的业务虽然复杂,却远没有传统行业复杂:在互联网上提供流媒体服务,比起航空管理系统来说,还是要简单很多的。

互联网公司之所以采用微服务架构,并不在于它解决了复杂度的问题,而更多是:1. 增加和部署新功能的速度 2. 满足规模化发展的需要。就拿货车帮举例,要给一两个城市里面的司机货主配货,不那么复杂。要给几百个城市的上百万从业者配货,就没有那么容易了。

所以,微服务架构的实践,一定是业务领域、业务规模和组织架构的三方面需求动态平衡的结果。没有办法形成固定的套路,并不仅仅是技术方面的原因,比如系统的复杂度,而是因为每个公司在这三个方面的差别很大。

什么是事务边界?

我们需要DDD这样的技术来帮助我们理解我们用来实现系统的模型,并围绕模型划定有上下文的边界。在不同上下文的不同边界内,一个现实里的对象(比如发信人)可能有不同的数据模型。

但所有的模型和边界确定后,问题来了:数据模型表征的实体发生变化时,我们往往需要跨多个边界进行数据变更。

这听起来不难,但不幸的是,我们创建分布式系统的时候,仍然在使用一些错误的做法,比如通过单一的,关系型的,ACID的数据库来完成数据视图,没有仔细考虑分布式系统的异步性和网络的不稳定。

我们开发了各种框架来封装网络层,让我们对网络的情况一无所知(大量的RPC框架,数据库抽象层都是这么做的)。同时使用大量的同步调用的技术(REST,SOAP,各种类似CORBA的RPC框架),把remote的服务器当成local的服务器来调用。

我们设计的系统没有考虑自治性,而是用两段提交等方式来克服分布式系统带来的挑战。这样的思路必然带来异常脆弱的系统:无论你叫它SOA、microservice还是miniservice。

那么“事务边界”究竟如何定义?它是指考虑了业务变化的各种因素后,你能找到的最小的原子化单元。不管你是利用数据库的ACID特性还是两段提交来达到原子化,并不重要。重要的是我们让事务边界尽量的小,理想情况下最好一个对象一个(Vernon Vaughn有很多关于DDD Aggregates的文献里面提到了这种做法,注意这里我们说的对象也是指DDD里面的Value Objects)。

在一个确定的上下文里,Aggregates指的是一些Entities和Value Objects的封装,负责确保不变性。一个上下文边界里面可以有多个Aggregates。

比如,在开发系统的时候我们可能有下面的用例:

  • “允许司机找货”
  • “允许司机联系某个货主”
  • “允许司机预约对某个货物承运”

我们这里有三个上下文边界:搜索,联系和预约。搜索是根据出发地、目的地、价格等要素显示符合条件的货。联系是通过电话、短信等手段联系到发某条货的某个货主,进行价格的讨论。预约是司机和货主达成一致后交付少量担保金进入实际的承运流程。对不同的上下文边界,我们可以定义出不同的事务边界,来规约变量和不变量。这里我们不讨论跨上下文边界的原子事务。

如果我们的目标是一个较小的事务边界,我们如何来建模?可能我们会把货建模成有时间,路线,定价等Value Object和货物,货主等Entity的一个Aggregate,这个Aggregate聚合了对这些信息,可以对它完成预约。

这样的设计看起来很靠谱,在代码里面很容易就可以建出对应的对象模型,在数据库里面也很容易就可以建表。

怎么看这个边界是不是够小了?可以想想看,在我们变动一个预约里的货物信息时,是不是需要变动聚合在一起的所有值对象和实体呢?很明显你可能只需要改一下目的地,而不会动到货主:这里我们这样建模是因为这样聚合起来的数据模型比较直观方便而已,作为一个事务边界,它太大了。如果我们货物的属性,货主的属性以及预订的状态都经常发生变化,那就会产生各种事务冲突,不管你用悲观锁还是乐观锁都没用。并且这样的设计显然不好扩展,更不用说只要有一个地方出问题,就会影响大面积的业务。

如何我们把事务边界再放小一些呢?

比如把预约、货物承运信息和货物信息放到三个独立的Aggregate里面。预约仅仅封装货主和司机的信息,以及定金付款等预约相关的信息。货物是否还可以承运封装货物的运输信息。货物封装货物本身的一些属性。我们不需要这三者之间有强一致性,但是当货物被预约后,我们希望三部分都可以正确处理自己的状态:作为平台我们希望预约这个Aggregate感知交易的情况,作为货主希望可以配置和查看货物被承运的信息,作为司机希望可以查看和承运感兴趣的货物。那么我们如何去实现一个“司机找货并联系货主形成预约”的流程?

在预约里面,我们可以调用货物承运信息这个Aggregate,要它完成对某个货物的承运。这个预约的操作是个独立的事务,返回一个预约id。我们可以把这个预约id和这个预约关联起来,然后提交这个预约,这又是一个独立的事务,我们没有用到两段提交或者两段加锁。

要注意的是这里之所以可以这样处理,还是业务逻辑决定的:我们允许对一单货形成多个预约,而不是规定“从没有被预约过的货物里面选中一个预约,分配给某个司机,把它从可以找的货物列表里面去掉,不要再对这个货物进行预约”这样的业务逻辑。

这个简化的例子展示了我们可以怎样规划较小的事务边界。但是在很多情况下我们的数据并不是这么容易就可以处理的,比如当完成预约之后,司机和货主最终希望是形成担保交易开始进入承运环节,这就需要跨边界进行数据通信了。

如何进行跨边界的数据通信

当这样的需求发生时,如何在不同的Aggregates甚至不同的上下文边界保持数据的一致性?

考虑这些问题时我们首先要考虑分布式系统的特性:没有什么是可以预期的。无论是系统里面的某个部分出问题还是网络出问题都是非常常见的。正确的做法是直面这些挑战,让你的数据模型可以在它依赖的其他部分,别的边界里包含的系统出问题的时,继续工作,并稍后修复并保证一致性。

在之前提到过,微服务架构里面,自治的重要性:这其实并不是一个有弹性的软件系统的需求,任何有弹性的系统都这样

所以,在事务边界和上下文边界之间,通过事件通信,来进行同步和一致性的保证。“事件”可以被看成是系统的某个局部在某个确定的时间点的快照被拍下来之后发给其他的节点。各个节点都可以监听自己感兴趣的事件,保存其中的数据,根据其中的数据做响应。

继续前面的例子。当预约发生后,其中某个司机和货主最终谈成了并形成担保交易,如何把这个交易落盘?这里面有一些技术细节在于,我们如何保证对数据库的写操作和往消息队列里面发消息是原子的?在这些消息被处理的时候,如果又有预约发生呢?

理想情况下,Aggregates会直接使用命令和域事件:每个操作被实现成命令,每个返回被实现成一个事件。这样我们就可以更清楚地把上下文边界内部使用的事件和跨域使用的事件分开。我们既可以使用一个event store,它既有数据库的功能也有pub-sub的消息队列的功能,也可以使用ACID数据库并把数据库的变更都通过类似Debezium复制到持久化的日志服务如Kafka里面,然后处理事件。无论是使用哪种方法,核心在于我们希望使用产生于某个时间点的immutable的事件来进行通信。

这样做有很多的好处:

  • 避免在不同的上下文边界上建立高成本的,甚至是不可能完成的事务模型
  • 对某个系统的变更,不会影响其他系统的时序和可用性
  • 系统自己可以决定对外部事件的反应速度和方式,并最终达到一致性
  • 系统可以采用对自己最有效的方式进行数据存储
  • 更灵活,更弹性,更好扩展
  • 更容易变更数据库的schema
  • 需要更加深入地学习CAP等相关技术点,来实现你的存储和消息队列

当然,这样的设计也会带来:

  • 更大的复杂度
  • 很难调试
  • 由于拿到事件都有延迟,并且不知道系统其他部分什么时候拿到,所以不能在这方面有任何假设(这个问题各种模式都有,只是在这种模式下面特别明显)
  • 更难部署和维护
  • 需要更加深入地学习CAP等相关技术点,来实现你的存储和消息队列

你可以看到最后一条出现了两次,这是因为无论是否使用微服务架构或者DDD,如果你对分布式系统里的并发性,一致性的基本概念和常见解决方案都没有概念,仅仅靠对数据库的ACID特性的利用来搞定当今各种系统的开发,肯定会遇到各种各样的问题。

另一个与此有关的有趣概念是所谓的“CQSR模式”,其核心思想是读写分离。对于大部分的互联网公司,写操作都是非常简单的,比如增加一个司机或者货主,但是读操作是千奇百怪的。而另一些公司,则是读操作非常简单,写操作特别复杂。CQRS可以很好的帮助你更好的分隔事务边界和上下文边界。

那如果一个服务只有一个数据库,并且这个数据库不与其他服务进行分享呢?那么它可以订阅事件流中自己感兴趣的事件,然后往其他服务共享出来的数据库添加一些数据作为事件的响应。“共享数据库”在很多地方被批评说不是一种好的实现方式,其实只要是符合场景,并没有关系。记住,实践微服务架构,没有规矩,只有权衡。比如我们的好几个服务就共用了数据库,这些服务进程都是我们的团队来维护,仍然做到了良好的自治性。

如果换种方式思考数据呢?

如果我们换一种视角,把所有的东西都作为事件来处理,并且把这些事件持久化。在这种思想下,数据库,缓存,索引都可以被看作是发生在过去的事件持久化后的库存,而当前状态则是建立在这份库存基础上的系统状态的反映。

这样来思考和实现有下面几个好处:

  • 你可以把自己的数据库仅仅当成状态的快照来想,而不是“事实记录”
  • 你可以在变更了自己的系统时,重放过去发生过的所有事件来进行验证
  • 你可以在数据库的版本或者schema变更时,重放过去发生过的所有事件来进行验证
  • 你可以切换到全新的技术栈,然后重放过去发生过的所有事件来进行验证

更多关于这方面的内容可以看看Martin Kleppmann的“Turning the database inside-out with Apache Samza”

总结

关于微服务架构的实践很容易陷入的陷阱就是一上来就选框架或者选模式:“我们RPC用Dubbo,用Zookeeper做配置中心”或者“每个服务都有自己独立的数据库”。微服务架构成功落地的关键,首先是人,然后是对数据进行仔细的研究和建模,最后才是确定框架和技术栈。

而如何处理数据?先做仔细的业务领域研究,进行数据建模,从而推导出领域模型,确定上下文边界。结合业务特点、业务规模、技术栈等多方面考虑,确定事务边界。尽量不做跨边界的事务操作,在自治子系统内部搞定,通过事件驱动和达成最终一致性。

采用事件驱动实施微服务架构

事件

我们在实践微服务架构的时候,根本目的是为了在“商业层面”拥有更加敏捷的系统:更容易响应需求变化,更容易添加、发布和尝试新功能,从而跑在竞争对手前面。

要做到敏捷,系统首先必须是自治的(autonomous)。自治性(autonomy)可以说是敏捷系统的一个先决条件:系统的各个部分相互之间如何沟通,当某个部分失败时如何处理和自动恢复等等,都需要自治性。自治性意味着系统的各个部分可以独立运作,对其他系统,团队和流程的依赖都可以shed:也就是说,对服务A的修改不应该影响到依赖它的服务B,A服务挂了,B服务也应该健在。

自治性的系统是微服务架构造就的吗?并不见得:真正成功实践了微服务架构的公司,真正重要的也不是这个技术而是组织架构上的先行。自治的系统,比如开源社区,城市,自由股票市场,甚至是一个迁徙中的鸟群,它们都不断地适应环境,响应变化,在失败案例中不断地学习。

这类被称为“复杂自适应系统(Complex Adaptive Systems)”的系统,是经过科学家专门研究,得出了很多结论的。其中很重要的一个就是,自治的系统会对“事件”作出“响应”。

当有事件发生时,一个自治的系统,不管是蚁群,人类组织,还是一个软件服务,会选择“做什么”或者“不做什么”,来进行响应。整个复杂的系统,都是这些事件在驱动的。这其实也很好理解,想想我们每天醒来,会根据温度穿衣;开车上班,会根据信号灯启停。我们人类的整个生活都是在对各种事件作出响应。

软件系统也可以被构造成这样:独立,自治,容错,可扩展。

从授权到自治,以及最终一致性

在很多分布式系统的实现里面,人们都通过创建一个单一地址空间来适应不稳定的网络环境。这是个从很多方面来看都错误的做法,但是它实现起来要简单一些:通过RPC调用,让remote的对象处理一些任务,或者请求一些数据。以电商里面结算购物车为例:购物车服务调用计价服务,计价服务可能会调用物流服务,根据发货地区和物流服务商等因素调整价格,同时购物车服务又会调用仓储服务来获取信息和更新货架。这就是所谓的“授权管理”模式:我们调用对数据有修改权限的服务,完成相应的操作。这种模式就意味着大量的全局状态和互斥锁,并且需要大量的事务。

并且,这种基于授权的设计也会导致瓶颈产生:一个服务挂掉,就容易雪崩;不同服务对数据需求不一样的时候,API越来越乱,或者产生一个大而全的统一API,提供给每个服务大量不需要的细节数据。

如果我们换个角度来看系统:不是依靠调用方对某个资源或者服务可以行使的权力来驱动系统,而是通过时间和时间轴上发生的事件来驱动系统,就像我们自己的实际生活一样。还是以电商的系统举例,我们的物流服务有没有可能知道目前顺丰在某个区域搞活动,使用顺丰有优惠,然后把这个数据保存在自己的数据库里面,这样每个订单产生时,这些区域的订单默认使用顺丰?如果我们的服务都这样来设计,它们的研发就不需要考虑太多依赖方当前的状态。

最终一致性?

通过事件驱动,而不是通过“just-in-time”的授权查询使得系统里各个服务能够更加自治,更好容错,更有弹性。但是影响复杂自适应系统的自治性的一个因素,也会影响自治的事件驱动系统,那就是时延“delays”。

如果你发现了某个事件已经发生,你立刻就可以做出反应。比如,有车强行变道进入你的车道,你会马上变道避让或者刹车。但是如果在“知道事件发生”这部分有时延,你的反应就没法那么迅速了,比如你正在训家里小朋友,结果没有发现有车变道进来了,就会“咣”…软件系统也一样。

么最终一致性指什么呢?再以购物为例。如果不是事件驱动的,那么如果你往购物车里面添加了某个商品,这时候仓储服务如果出了问题,你的查询没有返回,你就只好死等在那里。但是如果大家都使用事件驱动,你添加购物车的时候,发出了一个事件。这个时候仓储服务不在线,前端上看来,你还是把商品加入了购物车。当仓储系统恢复时,它收到之前那个事件,发现这个商品已经卖光了,这个时候它抛出一个“库存不足”的事件。购物车服务,计费服务等服务就根据当前用户的状态,去消费这个事件(如果没有结算就在结算的时候通知用户,如果已经结算就要退款或者补货等等)。这样让用户不被阻塞,并最终保持状态一致,就叫做最终一致性。

需要的技术

关于事件,延迟和一致性,再多说几句。事件只有在能够保证它们的时序的时候,才是可用的。也就是说,一组事件的时序,必须对于消费方来说是可信的。这涉及分布式系统里面的另外一个难题,对于构建“transactionality”也同样重要,以后再细说。但总的来说,如果事件乱序了,那么我们不做手工的修复就没有保持最终一致性。Martin Kleppmann管这个叫做“perpetual inconsistency”。时延,乱序,是分布式系统里面的两个难题。

Vhost threshold