@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 做配置中心」或者「每个服务都有自己独立的数据库」。微服务架构成功落地的关键,首先是人,然后是对数据进行仔细的研究和建模,最后才是确定框架和技术栈。

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

欢迎留言