高效消息处理系统设计
大部分的后端系统依赖于大量的消息处理实现各种功能服务,架构高效的消息处理系统是非常有必要的。这里笔者简单记录下日常开发常用的高效消息处理系统的设计思路。
针对游戏后台系统,状态维持在内存中。为保证高并发,必然启用多个线程处理消息,多线程环境下存在共享数据竞争的问题,比如玩家状态,玩家信息等等。传统控制共享数据串行访问的手段是锁控制,通过对共享数据的上锁和解锁来保证数据安全访问。但是锁控制会带来不必要的麻烦,第一个是并发编程难度大,需要控制锁的粒度来保证系统的效率;第二个是避免死锁。这些都需要耗费大量的心智成本。然后我们在仔细想想,一般的游戏业务中,在游戏逻辑下,不是所有的玩家都共享所有的状态,是部分玩家共享状态,比如位于同一个大厅,同一局游戏。针对这种需求,是不是可以将每一部分共享状态通过单线程访问;各个部分的状态之间隔离,互补访问。这样一是单线程访问共享数据无需通过锁保护,二是玩家的消息处理也可以多线程的高并发处理,利用多核资源。
下面来介绍一下这样的消息处理系统应该如何设计。我们可以以一条从网络框架中解析出来的一条消息为视角,看看它应该如何被消费处理。
网络框架解析出来一条消息,首先需要知道它需要被交给那一部分共享状态处理。这就是消息分发 / message dispatch。我们需要一个结构体,持有所有共享状态的消息入口,通过特殊标识标记这些入口,使得消息知道自己需要丢入哪一个共享状态中。
然后我们需要设计缓存处理消息的数据结构,有经验的同学马上就反应过来了,消息队列!这就是这个消息处理系统的第二个重要部分,消息被消费的地方–消息队列。这里我们需要并发安全的无锁的消息队列,具体如何实现,参见消息队列。消息投入到消息队列中。
然后消息队列需要被处理,所以消息队列又是任务队列,绑定着一个消息处理函数,处理执行对象是我们的共享状态,因为我们的消息处理需要这部分共享数据。
大致了解完这些,了解完这些,熟悉的同学会发现,这个设计模式其实很像reactor模式,所以不管是什么样的设计,万变不离其中。平时了解不同架构要留心其中共性,设计的意义。这样才能针对需求做出调整,设计合适的架构。
设计消息处理系统时,我们要明确三个角色:
- 消息生产者:产生消息的对象(接收到来自网络中的数据包,解析适配成系统内部消息,这种角色称为消息生产者)
- 消息消费者:消费消息的对象,也就是service。接受消息提供服务。
- 消息分发者:第三方存在,通常消息生产者和消息消费者在系统内不互相可见,所以需要第三方来进行消息的分发。这种第三方通常是全局存在的,全局唯一的,消费者和生产者都可以直接访问到其内存。
妥善处理这三个角色的动作是一个高效消息处理系统的基础,根据笔者的经验,将这三者的作用发挥好,设计出来的消息处理系统的性能不会差。
重点是消息的分发者,消息分发者的分发效率决定消息处理系统的性能。系统内存在大量的不同服务,比如登录服务、认证服务、场景服务等等,笔者建议对于每一个服务使用一个单独的消息分发者。这样消息分发者的职责可以不只只是转发消息,还可以对服务提供对象进行管理。消息分发着需要对外开放的接口是Publish。这样对于无状态的服务,可以向管理者注册多个冗余的服务,提高系统的承载能力。
1
2
3
type ServiceManager interface {
func Publish(msg Message) error
}
消息产生者调用Publish方法将消息发布到消息分发者,消息分发者会根据消息的类型将消息分发给对应的服务。服务进行消息处理。
消息消费者如何接受到消息呢?首先消息的执行和分发不能在同一个线程中,这样会导致消息的分发和执行效率低下。所以对于service,笔者的建议是每个service使用一个独立的任务队列,供消息分发者将消息投递到任务队列中。如何实现任务队列?可以查看笔者之前的博客。
看到这里,一个较为成熟的分布式消息处理系统的设计就完成了。service和dispatcher可以独立部署,所以可以是分布式的,也可以同时存在在一个内存中,看读者自己的需求。
这样设计的消息处理系统的好处是:
- 系统各部分的解耦合性高,独立工作,互不影响;
- 节点化,可以弹性伸缩;
提供单线程运行环境的高并发消息处理框架
首先解答第一个问题,为什么消息处理需要单线程环境。单线程环境意味着资源的访问是同步的,在开发上不需要考虑多线程访问资源带来的资源竞争问题,资源的访问不需要使用锁进行同步访问保护。这样做的好处:一是简化开发复杂度,无需考虑并发访问资源带来的数据访问错误的问题;二是减少了因锁竞争带来的性能损耗。游戏服务器对于响应性的要求很高,对于请求的响应不希望会因为锁竞争等待而导致延迟。
但是单线程环境下,如何保证系统的高并发呢?实现消息的并发处理的第一个重要的点是对消息进行分类,消息具备类型,需要访问固定的资源,这样访问相同资源的消息可以放在一个线程中处理。第二个重要的点是消息的分发,消息的处理对象需要具备接收不同线程分发过来的消息的能力,这个能力通过可休眠的单线程任务队列实现。
网络框架并发的处理数据流读取,解析成具备类型信息的消息,根据类型获取消息的分发者,分发者将消息投递当消息需要被处理的任务队列中。这个消息投递的过程其实就是订阅发布,消息处理对象向消息分发者注册自己感兴趣的消息类型,分发者接收到消息将消息发布到指定订阅者。
这样就实现了消息产生和消息处理的解耦,即保证了消息的并发处理能力,又保证了消息处理的同步性。
为什么说这样的消息处理系统是借鉴了Actor的设计模式呢?Actor是单独处理消息的单元,资源由Actor自己持有,不被其他Actor所共享。同时Actor是由消息驱动的,Actor是否启动线程处理消息是根据消息队列内是否具有待处理的消息。是不是和我们的消息处理框架的设计模式非常类似。
这个系统实现的根基是无锁的可休眠的单线程的消息处理队列,这个队列的实现我会单独写一篇博客记录。
为什么选择这样的模型,而不是常见的线程池模型?
传统的线程池只具备处理消息策略,而不持有消息处理所需资源,所以在并发场景下,资源具有竞争关系,可能会导致性能瓶颈。