事件顺序保护

背景

        在分布式结构中,异步通信时,有时需要保证时间产生和消费的顺序。例如,商品购物中,修改订单,删除订单两个命令操作,如果异步事件产生乱序,删除订单事件先消费处理,那么在修改订单时肯定会出错。像上面情况还算好,最少报错还能及时发现处理,如果是两个修改命令操作乱序,导致数据产生错误结果,但不会报错,这个很难及时发现处理,从而造成更大的损失。为了保护异步事件消费的顺序,需要一些保障机制来解决。

顺序发送事件解决方案

        要解决事件整体顺序性, 就得先解决发送事件的顺序,并发必然会出现乱序,无论同一个进程的多线程,还是微服务的事件生产者的多个实例,都会让事件并行产生可能的乱序。以下是常用到的解决方案:

  • 加锁。对处理该关键业务和要发送事件一起加锁,并对数据库相关操作添加上事务。加锁的目的是让业务处理串行,再通过事务对处理的错误进行还原,特别是事件发送异常时还原数据库。不过,这种处理方案显然效率低下,但实现起来相对简单。
  • 事件存储。由于上面加锁方案中,需要把事件发布成功才算本次业务处理结束,如果消息发送失败就得重试,或者消息队列出现问题,也会导致整个业务失败,这样不仅性能低,而且可靠性也低。如果把要发布的事件按顺序存储在本地的关系数据库中,再通过另外一个进程使用数据抽取中间件,跟踪监控数据库日志,从而实时读取事件信息,然后发布这些事件。这样隔离开了事件发送,只要把事件存储到数据库就完成本次业务,从而提高了整体性能和可用性。当然,事件存储数据库时,需要包含在加锁和事务的范围内,才能保证顺序性。
  • 事件溯源。事件溯源是常用的设计模式,它的实现里,就是把各种操作事件存储起来,使用时通过这些事件还原出领域模型。而这些存储的事件刚好也用于事件发布,在存储事件时用类似乐观锁的方法,给事件加上递增的序号字段,设计存储数据库表的序号为唯一键,如果因为并发导致该序号事件被占用,那么就插入失败,然后重新更新领域模型验证事件,验证通过后再把新递增的序号事件插入。当然唯一键不一定只是一个字段序号,可能通过某个资源标识字段再加上序号字段作为联合唯一键,这样只需保证该资源的所有事件的顺序性。事件顺序存储后,再异步发送事件的流程就和上面的相同了。 这个方案中数据库也只有一次写操作,具有原子性,所以不用使用事务和加锁,从而性能和效率更高,当然实现起来也更加复杂。

顺序消费事件解决方案

        如果消费队列中事件都是有序的,并且必须保证消费顺序,那么只能有一个消费者来串行消费。为了避免丢失消息事件,需要在事件发送者确保收到消息队列发送成功回执确认消息,否则需再次进行发送,消费者也需在消费完成成功后才向消费队列回执消费成功的信息,再加上采用消息队列集群更能避免消息丢失。不过这些会让消息重复消费,一旦意外丢失发送回执信息,就会重复发送,导致消息队列中存在多个重复消息事件,同样地,消费者回执消费信息丢失,或者消费成功时,正要发送回执消息时,消费程序崩溃,没来得及发送,导致重启后重复消费该事件。如果这些重复消费的事件类型是幂等的还好,对业务没啥影响,如果不是,就需要有机制保证最多只能消费一次。为了保证同一个事件只能消费一次,需要消费者记录下已消费的事件标识,用于对比新消息事件是否已经被消费过。记录已消费的事件标识只需最近几个,根据产生重复消费机制可以看出重复事件一般都是紧挨着,无需记录所有已消费的事件标识。如果消息事件有序号属性,那么只需记录最新的事件序号即可,这样消费事件时,新事件必须比记录的事件序号大 1,当然溢出归 0 需特殊处理,这样既能保证消费顺序,又能避免消息丢失和重复消费。在完成消息事件消费业务逻辑处理和存储后,再更新消息事件标识或序号,这些需要在同一个事务里处理,防止业务更新完成后在更新消息事件标识或序号时崩溃,导致重复消费。

        多数情况下要求一个消费队列里的消息有序性,只要求同一个资源的相关事件是有序的,例如,电商中订单相关事件,只要求同一个订单 id 的操作事件是有序的,不同订单 id 的事件没有关系,不关心是否有序。为了提高性能,可以让多个消费者来消费同一个消费队列。

  • 但会产生新的竞争问题,如果消费队列中有一段消息都是同一资源的顺序事件消息,那么就会出现多个消费者同时取出该资源的多个消息,在同时消费时就会出现乱序。为了保证顺序性,就需要用上面的方式对比已消费的最新该资源的事件序号,只有满足取到的消息序号比已记录的事件序号大 1,才能让消费者正常处理,否则需要进入轮询检测,轮询间隔设置几毫秒,当其它合适的消费者消费完成,更新该资源的事件序号后,该消费者下一次轮询时就能满足条件,继续后续消费。如果消费者正在消费时崩溃,其余消费者刚好在轮询,此时跳出轮询的条件永远不会满足,从而引起阻塞,因此,需要在轮询时加一个最大次数,超过一定次数后,把消息重入队列,再取出最新消息,就可能会把那个崩溃的消费者的消息取出来消费,保证消费顺序性。
  • 为了更好的提高性能,可以让生产者把同一个资源在一段时间内的所有事件聚合成一个消息发送给消息队列,这样可以显著减少了消费竞争引起的轮询等待,不过会加大消费延时和生产者聚合消息时复杂度,所以这种优化方案适合经常出现相同的资源的批量事件,吞吐量尽量高,消费事件延时不敏感的场景中。
  • 如果消息队列采用的 kafka, 给topic 设置多个Partition分区,可以更好的并行, 发送时通过对资源 标识进行哈希分配分别到 partition分区,这样就能保证相同的资源标识的事件消息都在同一个partition 中并且有序。消费者组里创建的消费者数量最好和 partition分区数量相同,因为一个partition分区只能被一个消费者消费,因此当创建的消费者数量多于分区数量时,会让多余的消费者空闲,少于时又不能充分发挥性能。不过也正因为如此,不会出现多个消费者竞争同一个partition导致的乱序,从而让处理业务逻辑能更简单些,但重复消费的处理机制还是需要的。

总结

      为了保护事件顺序,需要根据性能要求选择合适的方案。如果性能不存在问题时,或者开始难以估算时,那么就用单个消息事件生产者和单个消费者,这样方案最简单,无需考虑并发问题。如果性能有问题,多数场景也是消费端,因此需要更多的消费者实例进程并发提高消费处理事件效率,但随之复杂性也显著提高。有的场景是为了高可用性,需要建立多个生产者实例和消费者实例,也让整个系统复杂性提高,不过为了可用性,还可以考虑用 k8s 这一类容器技术来保证各个服务实例的可用性。有的业务中的消息事件不是幂等的,因此需要处理相同的消息事件只能处理一次。总之,消息事件顺序保证尽量选择简单合适,能避免并发尽量避免,不要超前设计,从而承担没必要的成本和维护代价。

你可能感兴趣的:(技术方案,微服务,信息与通信,分布式,后端)