阅读 381

如何在微服务中实现高效查询

前言

查询是软件系统的基本功能,在单体应用且使用一个数据库的情况下,查询操作往往可以通过编写sql及使用必要的索引实现。但在微服务中,由于微服务的自治·、隔离性,查询通常需要检索分散在各个服务中其私有数据库的数据,因此操作会比较复杂。

在微服务中通常有以下两种实现查询的方式:

  • API组合:客戶端调用各个服务,并组合返回的查询结果。该方式较为直接,简单
  • CQRS模式:即命令查询职责分离模式,它维护一个或多个数据的视图。该方式功能更强大,但实现和维护也更复杂

我们依次看这两种模式

API组合

API组合模式即

发起调用方调用有数据的服务,并组合返回的查询结果

当某个查询不仅仅需要自己服务的数据,而需要聚合多个服务的数据时,使用API组合模式是最直观的

它由两类角色组成:

  • API组合器:其通过调用数据提供者获取数据,并进行组装
  • 数据提供者:拥有部分数据的一个个服务

API组合器既可以是数据展示层,例如浏览器,移动端。也可以是后端服务,例如API gateway,或一个微服务

需要考虑的点

这看起来比较简单,工作中的查询功能很可能也这么实现,但需要考虑以下两个问题

  1. 确定由哪个组件扮演API组合器
  2. 如何编写高效的组合逻辑

对于问题一来说,一般有以下三种选择

  • 数据展示层:通常来说这不是个好的选择,第一是会加重前端的负担,前端需要更专注于和用户交互及数据填充,而不是组装聚合来自不同服务的数据,这可以交给后端团队负责。第二是效率较低,因为这样会发起多个从外部网络到服务内部机房的请求,这肯定没有机房内部的调用效率高。效率低下不可避免地会影响用户体验

  • API网关层:前端将请求发到网关服务,再由网关服务调用多个后端服务获取数据,并组装返回。因为网关服务和后端服务通常在一个机房,调用开销小。这能显著缓解上个方案中效率低下的问题。但可能使网关逻辑变得复杂

  • 独立的数据聚合服务:也可以在API网关后面使用单独的数据聚合符合,能减轻API网关的复杂度,但也会增加内部跳转次数

对于问题二来说,分布式系统需要降低服务间的延迟,API组合器需尽可能并行调用数据服务,并采用一些工具保障并行调用的正确性,例如java的CompletableFuture,go的channel等。若调用之间有依赖关系,则将需要的部分按照依赖顺序串行调用

推荐使用API网关或单独数据聚合服务的方式

优缺点

API组合器的优点为简单直观,但也有一些缺点:

  • 一定的调用成本:相较于单体架构,微服务的API组合器需要查询多个数据服务,有额外的网络请求开销
  • 可用性降低:需要API组合器和数据提供者同时可用,整个查询才可用。其可用性显然小于单个服务的可用性。例如有4个服务器,每个服务器的可用性为99%,那么整体可用性为 (99%)^4 = 96%!当然可以用一些手段来提高可用性,例如当数据服务不可用时,API组合器返回默认数据或缓存数据
  • 数据一致性问题:单体应用中,当某次查询需要数据都在同一个数据库的情况下,依靠数据库的隔离性,MVCC机制,可以使单个查询的数据从一个一致性视图中获取。这对于某些场景是硬性要求,例如用于数据库备份的查询。但想在微服务间获取一致性视图就没那么容易,需要进行额外处理

我们来看看另一种查询解决方案:CQRS

CQRS

命令查询职责分离(Command Query Responsibility Segregation)即

使用事件来维护从多个服务复制的只读视图数据,以此来实现查询

其将使用数据的模块分为两部分:

  • 命令端:负责数据的创建,修改,删除操作
  • 查询端:负责数据的查询操作

查询端通过订阅命令端发布的数据更新事件,使其数据和命令端数据保持同步

优缺点

在非CQRS应用中,查询和命令读写同一个库,但其实查询和命令是两个不同的应用场景,将其分开有以下好处:

  1. 发挥自己的长处:命令端需要保证数据的事务性,以及写性能,查询端需要更多地考虑支持各种查询需求,支持高效率查询,要求更高的读性能。例如命令端采用关系型数据库以支持事务,查询端使用ES等组件,支持各种形式的高效率查询
  2. 合理划分团队:将查询和命令端分开,能让不同的团队负责不同的业务。例如命令端交给业务团队负责,而查询端交给专门的搜索团队负责,实现业务隔离及内聚,以提高生产效率
  3. 性能提升: 除此之外,这种方式的查询比CQRS的API组合模式更高效,因为程序不再需要每次查询都调接口获取其他服务的数据,而是查询本地的数据库。本地的数据库也可以支持各种查询模式,而API组合器模式可能还需要协调其他团队开发新接口以支持新的查询

CQRS也有一些弊端:

  1. 更高的复杂度:由于查询端需要额外服务一个或多个数据库,且这些数据库类型不同,因此提高了运维的复杂度,及开发人员的学习成本
  2. 数据复制的延迟:数据是通过事件的方式异步从命令端复制到查询端,通过来说复制速度很快,但受负载,网络环境的影响,也可能出现延迟较高的情况,但视图最终是一致的。该模式不能保证写后读的效果。若需要写后马上能读,则需要做一些额外的处理,例如短时间内读命令端,或者在客户端缓存一份刚保存的数据。因此CQRS模式适合在不需要那么严格实时性的场景使用。所幸大部分场景都不需要严格的实时性

需要考虑的点

在设计CQRS模式时。有一些需要考虑的点:

  1. 查询端数据库选择:在为查询端的数据库选型时,查询是首要考虑因素,通常来说NoSQL数据库是更好地选择,因为CQRS的查询端主要使用其提供的丰富的查询方式,例如若需要主键查询可使用redis,需要文档查询可使用mongodb,需要图查询可使用Neo4j。但使用SQL数据库也在考虑范围内,因为开发,运维人员通常对SQL数据库更熟悉,上手成本低,其次SQL数据库能满足报表需求中的各种复杂查询
  2. 支持更新操作:查询端需要消费原始数据的更新,插入,删除事件,以作用在视图数据上,因此查询端的数据库需要支持更新操作。有些数据库的更新方式有所限制,例如redis只支持按主键(key)更新,因此在设计更新事件时最好通过主键进行更新,若想按条件批量更新则比较困难
  3. 并发处理:当对同一记录的更新事件同时到达时,需要考虑并发安全性问题。若采用读取,更新,写入的形式则一定有并发安全问题,会导致更新丢失。这时需要对整个操作加锁,使其串行操作。或者用乐观锁控制,若乐观锁校验失败,进行重试处理
  4. 幂等性:查询端使用数据更新事件来更新数据,而常见MQ通常支持至少一次的消息保证,因此有必要在消息消费端也就是查询端,实现消息幂等性处理。某些操作天生是幂等的,例如按id删除一条记录。但某些操作如果不进行幂等处理,会导致业务问题,例如增加银行账户余额。非幂等性事件的处理需要通过唯一id来检测和丢弃重复事件。

在支持事务的SQL数据库中,为唯一id单独用一张表存放,并加上唯一索引可将唯一id记录的插入和该条数据的更新操在一个事务中执行,若id检测失败,更新操作也就不执行,同样若更新操作失败,id也会插入失败,下次还能使用该id

但大部分NoSQL数据库都不支持事务,因此可考虑使用单线程数据库,例如redis。使用lua脚本封装id检测和数据更新操作,使其具备原子性

总结

本文总结了在微服务分布式架构中实现查询的两种方式,API组合模式比较常见,其实现也直观简单,若无特殊需求,首选采用API组合器模式。CQRS模式具有更高的性能,支持更丰富的查询模式,以及隔离各技术团队。同时也有数据复制延迟不可控,实现复杂的缺点

参考文档

1.微服务结构设计模式

文章分类
后端
文章标签