使用Spring Cloud和Reactor在微服务中实现Event Sourcing
当在微服务架构中构建应用时,状态管理成为分布式系统的问题,相比于传统monolithic应用,将状态管理通过事务机制实现,微服务能够通过一种跨多个不同应用和数据库的新的分布式事务来管理一致性。
这篇文章中,我们将看看微服务中的数据一致性和高可用性的权衡。本文以一个在线电子商务网站为案例,使用Spring Boot和Spring Cloud实现,展示如何使用Reactor在微服务中实现Reactive流,从而通过event sourcing事件溯源实现分布式事务,最后,我们会使用Docker 和 Maven构建、运行、协调多容器 应用。
由于每个微服务可能拥有自己的专有数据库,微服务本身虽然是无状态的,但是微服务管理的状态是保存在自己数据库中,如果不同微服务之间需要共享状态,也就是说需要访问同一个数据库的数据表怎么办?或者说,多个微服务需要访问同一个微服务以便获得同一个状态怎么办?
最终一致性Eventual Consistency
当建立微服务时,我们被强迫面对状态的最终一致性问题,这是因为每个微服务都拥有自己的数据库资源,每个数据库都配置了不同的一致性和可用性权衡策略。
最终一致性是一种用于描述在分布式系统中数据的操作模型,在分布式系统中状态是被复制然后跨网络多节点保存,在关系数据库集群中,最终一致性被用来在集群多个节点之间协调数据复制的写操作,数据库集群中这种写操作挑战是:各个节点接受到的写操作必须严格按照复制的次序进行,这个次序是有时间损耗的,从这个角度看,数据库在集群节点之间的这种状态复制还是可以被认为是一种最终一致性,所有节点状态在未来某个时刻最终汇聚到一个一致性状态,也就是说,最终达成状态一致性。
当构建微服务时,最终一致性是开发者 DBA和架构师频繁打交道的问题,当开始在分布式系统中进行状态处理时,头疼问题更加严重。核心问题是:
如何在保证数据一致性基础上保证高可用性呢?
事务日志
几乎所有数据库都支持高可用性集群,大多数数据库对系统一致性模型提供一个易于理解的方式,保证强一致性模型的安全方式是维持数据库事务操作的有序日志,理论上理由非常简单,一个事务日志是一系列数据更新操作的动作有序记录集合,当其他节点从主节点获得这个事务日志时,能够按照这种有序动作集合重新播放这些操作,从而更新自己所在节点的数据库状态,当这个事务日志完成后,次节点的状态最终会和主节点状态一致,
这种事务日志非常类似于财务中记账模型,或者类似银行储蓄卡打印出来的流水账,哪天存入一笔钞票(更新操作),哪天又提取了一笔钞票(更新操作),最后当前余额是多少(代表数据库当前状态)。
Event Sourcing
Event sourcing事件溯源是借鉴数据库事务日志的一种数据持久方式,在ES中,事务单元变得更细粒度,使用一系列有序的事件来代表存储在数据库中的领域模型状态,一旦一个事件被加入事件日志,它就不能被移走或重新排序,事件被认为是不可变的,事件序列只能被追加方式存储。
因为微服务将系统切分成一个个松耦合的小系统,每个系统后面都独占自己的数据库,虽然,微服务是无态的,但是它需要操作自己数据库的状态,如何保证微服务之间操作数据库数据的一致性成了微服务实践中重要问题,使用ES能够帮助我们实现这点。.
聚合可以被认为是产生任何对象的一致性状态,它提供校订方法用来进行重播产生对象中状态变化的历史。它能使用事件流提供分析数据许多必要输入,能够采取补偿方式对不一致应用状态实现事件回滚。
Reactor
Project Reactor 是一个开源基于JVM实现Reactive流规范的开发框架,是Spring生态系统一个成员,在微服务中,经常在一个上下文下需要和其他微服务交互操作,由于微服务架构天然属性是最终一致性,而最终一致性并不保证数据的安全性。它提供我们一个使用异步非堵塞方式进行通讯的方式,这里正是使用Reactor目的所在。
很少情况下,领域模型状态会被跨微服务共享,但是如果在微服务之间需要共享状态怎么办?或者说多个微服务需要访问同一个数据库数据表怎么办?在微服务中ES只保存有序事件的日志,使用事件流取代领域模型在数据库中存储,我们可以存储有序事件流来代表对象的状态,这样,意味著我们就不再使用基于HTTP的RESTful进行微服务之间同步通讯,这些同步会造成堵塞和性能延迟。
代码案例
我们以电子商务商店中购物车服务为案例,展示Reactor + ES是如何实现的:
购物车服务Shopping Cart Service是一个MYSQL数据库拥有者,有一个数据表称为cart_event. 这个表包含用户操作动作产生的有序事件日志,用户操作就是反复将商品加入购物车或去除等各种购物车管理操作。
购物车事件有如下:
// These will be the events that are stored in the event log for a cart public enum CartEventType { ADD_ITEM, REMOVE_ITEM, CLEAR_CART, CHECKOUT }
CartEventType是枚举类型,已经列出了4种不同的事件类型。这些事件类型中的每一个都代表用户在购物车上执行的动作。根据ES,这些购物车事件可以影响用户的购物车的最终状态结果。当用户添加或删除一个商品条码到他们的购物车时,一个动作产生一个事件,会对购物车中进行递增或递减一行条目。当这些事件使用同样顺序进行回放时,同样一系列的条目会被重新创建或删除:
idcreated_atlast_modifiedcart_event_typeproduct_idquantityuser_id1146099097164514609909716450SKU-12464202146099281639814609928163981SKU-12464103146099282647414609928264740SKU-12464204146099283287214609928328720SKU-12464205146099283602714609928360271SKU-1246450
我们看到每行都有一个唯一时间戳来确保严格顺序,使用整数来代表4个购物车事件类型,product_id 和数据quantity都是每次加入购物车的商品条码信息。
那么使用什么存储库保存事件流?目前ES方面标准的存储库是 Apache Kafka。我们使用它来存储我们的事件序列。也就是说,微服务之间共享状态是通过共享Kafka的事件日志实现的。
下面我们回到购物车,购物车微服务提供一个REST API方法接受来自Web端的事件。Web端发出事件的控制器 ShoppingCartControllerV1.java:
@RequestMapping(path = "/events", method = RequestMethod.POST) public ResponseEntity addCartEvent(@RequestBody CartEvent cartEvent) throws Exception { return Optional.ofNullable(shoppingCartService.addCartEvent(cartEvent)) .map(event -> new ResponseEntity(HttpStatus.NO_CONTENT)) .orElseThrow(() -> new Exception("Could not find shopping cart")); }
在上面的代码示例,我们定义了一个用于收集来自客户端新的CartEvent对象的控制器方法。这种方法的目的是在向事件日志追加事件。当客户端调用REST API检索用户的购物车,它将产生一个购物车聚合,使用Reactive流合并了所有购物车事件流。
下面在ShoppingCartServiceV1.java中使用Reactor产生购物车事件流:
public ShoppingCart aggregateCartEvents(User user, Catalog catalog) throws Exception { // 从kafka获得某个用户的购物车操作事件流 Flux<CartEvent> cartEvents = Flux.fromStream(cartEventRepository.getCartEventStreamByUser(user.getId())); //执行事件流的事件直至最后一个事件发生的最终状态。也就是购物车的最终状态 ShoppingCart shoppingCart = cartEvents .takeWhile(cartEvent -> !ShoppingCart.isTerminal(cartEvent.getCartEventType())) .reduceWith(() -> new ShoppingCart(catalog), ShoppingCart::incorporate) .get(); // Generate the list of line items in the cart from the aggregate shoppingCart.getLineItems(); return shoppingCart; }
在上面的代码示例中,我们可以看到三个步骤来生成购物车对象,然后返回到客户端。第一步是从事件存储的数据源中创建一个Reactive流。一旦流建立,我们可以从事件流中产生我们的聚合。这些事件流不断改变购物车状态直至到最终状态,然后就可以将最终购物返回给用户客户端。
在reactive流的聚合的reduce中,我们使用了一个称为incorporate方法,这个方法是接受CartEvent对象,而CartEvent对象是用来改变购物车状态的
下面是ShoppingCart.java:
public ShoppingCart incorporate(CartEvent cartEvent) { // Remember that thing about safety properties in microservices? Flux<CartEventType> validCartEventTypes = Flux.fromStream(Stream.of(CartEventType.ADD_ITEM, CartEventType.REMOVE_ITEM)); // The CartEvent's type must be either ADD_ITEM or REMOVE_ITEM if (validCartEventTypes.exists(cartEventType -> cartEvent.getCartEventType().equals(cartEventType)).get()) { // Update the aggregate view of each line item's quantity from the event type productMap.put(cartEvent.getProductId(), productMap.getOrDefault(cartEvent.getProductId(), 0) + (cartEvent.getQuantity() * (cartEvent.getCartEventType() .equals(CartEventType.ADD_ITEM) ? 1 : -1))); } // Return the updated state of the aggregate to the reactive stream's reduce method return this; }
在上面代码中我们看到ShoppingCart的incorporate方法实现,我们接受一个CartEvent对象然后确保事件类型是正确的,我们确保事件类型是 ADD_ITEM 或 REMOVE_ITEM.
下一步是更新购物车中每个条目的聚合视图,通过映射相应的事件类型到商品条目的数量递增或递减。最后我们返回这样一个带有最终可变状态的购物车给客户端。
整个源码案例:
https://github.com/kbastani/spring-cloud-event-sourcing-example
你可以使用 Docker Compose 运行这个案例,步骤如下:
首先下载Docker, 使用下面命令初始化一个虚拟VM:
$ docker-machine create env event-source-demo --driver virtualbox --virtualbox-memory "11000" --virtualbox-disk-size "100000" $ eval "$(docker-machine env event-source-demo)"
安装好下面必备组件:
- Maven 3
- Java 8
- Docker
- Docker Compose
在下载源码项目根目录执行:
sh run.sh
系统会自动下载依赖和安装。