有后端开发经验的同学应该了解,对于传统SQL数据库,我们通常以正规化(normalization)的方式来建模数据。正规化的好处是数据冗余少,不足之处是数据聚合Join会比较麻烦。实际Join的时候,需要将几张相关表,通过主键和外键关系才能Join起来。我们知道,Join是一种开销比较大的SQL运算,当数据量少的时候,这种开销通常OK。但是随着企业规模逐渐变大,数据库中的数据量也会越变越大,相应地,Join的开销也会越来越大。于是,Join变慢的问题就会越来越突出,通常表现为用户的查询慢,严重时,复杂的Join可能会导致数据库繁忙不响应甚至宕机。之前我在上家公司工作的时候,就曾经经历过几次复杂Join造成DB宕机的事故。可以说,单库Join性能慢的问题,是目前很多网站的普遍痛点问题。所以,去数据库Join,是很多企业当前正在做的数据库优化工作之一。
在分布式的微服务时代,数据聚合Join的问题并没有消失,它变成了另外一种形式。请看上图,假设有两个基础领域服务,一个叫customer-service,另外一个叫order-service。根据微服务有界上下文和职责单一的原则,customer-service只负责客户数据,order-service只负责订单数据。但是前端业务需要一个order-history-API,这个API支持查询用户的历史订单,它既要提供用户详细信息,也要提供用户的历史订单信息。为此,我们需要引入这样一个order-history-API服务,它同时去调用order-service和customer-service,获得数据后再在本地进行聚合Join,然后再对外提供聚合好的客户+订单历史数据。总体上,上图的order-history-API做的事情,就是所谓的分布式聚合Join。这个API还有两个专门的称谓,一个叫Aggregator聚合服务,另外一个叫BFF服务,BFF是Backend for Frontend的简称,它的主要工作也就是聚合Join。
在中大规模互联网系统中,分布式聚合Join非常常见。基本上你上任何一个大厂的网站,比方说天猫,京东,或者美团,携程等,它们的网站页面上的数据,大部分都是通过后台的分布式聚合服务聚合出来的。所以,聚合服务层(或者称BFF层),是现代互联网和微服务架构中普遍存在的一个架构层次。
在大部分场景下,分布式聚合服务可以满足需求,并且它还具有实时性和强一致性的好处,但是它同时也引入了新的问题:
企业实践表明,当互联网公司的体量规模发展到一定的阶段,为了解决分布式聚合Join慢的问题(或者是为了解决传统SQL数据库Join慢的问题),它们通常会采用另外一种称为数据分发+预聚合的新方式。
怎么理解这种方式呢?我再举个例子,请看上面的图。我们这里也有两个基础领域服务,一个是商品服务item-service,另外一个是订单反馈服务order-feedback-service。但是前端业务需要一个商品反馈服务item-feedback-service,它的数据是由item-service和order-feedback-service聚合的结果。为了实现这个order-feedbak-service,我们可以用前面的聚合(或者说BFF服务)来实现,但是那种做法可能每次查询的开销较大,性能无法满足要求。为了解决性能问题,我们可以改用之前讲解的数据分发技术,比方说事务性发件箱技术,或者CDC变更数据捕获技术,也就是基于数据分发+预聚合的思路来实现这个服务。当item-service或者order-feedback-service有数据变更的时候,我们把它们的变更,通过数据分发技术,分发到item-feedback-service这个聚合+查询服务。item-feedback-service可以根据本地已有的数据,加上发送过来的变更数据,实时/或者近实时的聚合计算出商品反馈数据,并存入本地数据库缓存起来。这个就是数据分发+预聚合的思路。
这个方式和前面的聚合层BFF方式是有本质区别的。前面的方式是每次请求都要触发重复计算的,而这里的方式是一次性预先聚合好,并且缓存起来,后面的查询都是查询的缓存数据,所以这是一个提前预聚合的思路。
细心的学员会发现,这个方式其实就是反正规化(denormalize)的方式。它把原来正规化的需要聚合Join的数据,通过反正规化方式预先聚合并缓存,这样可以大大加快后续的查询。另外,学过数据库的同学应该知道,数据库当中有物化视图(Materialized View)这样一个概念,它本质上也是一种预聚合的思路。物化视图把底层的若干张表,以反正规化的方式,实时地聚合起来,提供方便查询的视图View。并且,当底层数据表发生变更的时候,物化视图也可以实时同步这些变更(相当于实时聚合Join)。现在你应该明白,我们这里所讲的数据分发+预聚合方式,其实它的思想和物化视图是相同的,只不过我们这里讲的是分布式的物化视图。
实时预聚合能够大大提升查询的性能,但是它的技术门槛也比较高。当数据变更发生的时候,或者说当变更数据流过来的时候,你就需要对数据流进行实时运算。这个计算越实时,查询的实时性就越好,当然,所需要的技术门槛也越高。之前我们提到过的Kafka Stream,它就是支持实时流式聚合的一个开源产品。
上面讲的数据分发+预聚合的方式,在互联网领域还有一个更时髦的名称,叫CQRS,英文全称是Command/Query Responsibility Segregation,翻译成中文是命令/查询职责分离模式。
这个模式的总体形态,如上图所示。CQRS的左边是Command命令端,这一端通常只负责写入。CQRS的右边是Query查询端,这一端通常只负责读取。底层一般是数据分发技术,比如事务性发件箱、CDC还有MQ,它们将命令端的变更数据,实时或者近实时地同步到查询端。
写入端的数据存储,通常采用传统SQL数据库。而查询端则可以根据需要选择最适合的存储机制,比如说如果通过KV键查询的话,可以采用Redis或者Cassandra;通过关键字查询的话,可以采用ElasticSearch。当然,还可以引入离线批处理Hadoop,甚至是实时计算平台Spark/Flink等。不管查询端采用何种存储技术,它们的目标都是提升查询的规模化和性能。
总体上,从命令端到查询端,数据的流动变化过程,就是一个反正规化,适合各种快速查询需求的过程。在三层应用时代,为了提高查询性能,我们通常采用数据库的读写分离技术。到了微服务时代,这个技术的思路仍然适用,只不过它向上提升到服务层,演变成CQRS模式了。所以也可以说,CQRS是服务层的读写分离技术。
值得一提的是,合理应用CQRS技术,可以大大提升查询的性能,同时提升企业数据规模化的能力。但是对于CQRS/CDC这类技术,它们的技术门槛不低,一般小公司可能玩不起,只有到一定体量的公司才会考虑。后续,我会介绍一些CDC/CQRS技术在前沿大厂,比如Netflix的落地案例。
采用CQRS模式以后,客户从命令端写入数据,然后变更数据分发到查询端,查询端再聚合生成查询视图,这中间难免会有网络和聚合计算延迟,所以这个模式并不保证写入和查询数据的强一致性,而是演变成最终一致性。
最终一致性会带来UI更新的问题。举个例子,如PPT所示,用户通过UI到Order订单服务创建一个新订单,这个订单落到订单服务的数据库中,然后订单服务在返回用户响应的同时,后台再异步发消息到Order Query订单查询服务,然后订单查询服务收到消息,就去做聚合更新订单视图的工作,这个工作可能需要耗费一定的时间。如果新视图在被更新之前,用户又通过UI来查询新订单数据,那么他可能会查不到数据。也就是说,CQRS的最终一致特性,会引入一定的时间差,而且这个时间差还是不确定的。
另外,考虑到网络的不稳定和不可靠,数据分发组件可能会因为网络等因素而重发数据(At least Once语义),所以,查询端一般需要对数据进行去重或者做幂等处理。
为了解决最终一致性带来的时间差问题,业界通常有三种实践的UI更新策略,请看上图:
本文的最后,我们再来补充一张图,这张图表达的是从2005到2016,互联网网站架构发生的巨大变化。这张图来自ThoughtWorks的一篇文章,我做了适当的改编。
这张图还是比较容易看懂的,所以具体细节我这里不展开。我这里重点提一下2016年的网站架构的几个显著特点:
从总体架构上看,2016年的网站架构和2005年相比,最大的区别是2016年的网站架构是一个更大规模的读写分离架构。另外,支持2016年网站架构的底层技术,和本文所讲的内容,包括微服务架构,数据分发技术,CDC,还有BFF聚合服务等等,都是密切相关的。所以波波认为,理解本文的内容,是理解现代网站架构的一个基础。