Akka分布式游戏后端开发14 广播

49 阅读5分钟

本专栏的项目代码在Github上,如果感兴趣的话,麻烦点点Star,谢谢啦。

在游戏集群中,可能会有一些业务有广播的需求,例如将消息推送给指定的玩家、将消息推送给单个区服的所有玩家或者将消息推送给全服的玩家等等。在 Akka 中有两种方式实现广播机制,一种是 DistributedPubSub,另外一种是 Cluster Aware Router。下面来比较一下这两种实现方式的优缺点。

DistributedPubSub

在 Akka 中,DistributedPubSubCluster Aware Router 是实现广播功能的两种不同方法,各自有其优缺点。以下是它们的比较:

概述

DistributedPubSub 是 Akka 提供的一个发布-订阅机制,允许在分布式集群中广播消息或实现点对点通信。它基于 Akka Cluster,利用集群中的 Mediator 组件来管理订阅者和消息分发。

优点

  • 灵活性高:支持发布-订阅模式,允许动态添加或移除订阅者,适用于场景中需要点对点、组播或广播的复杂需求。
  • 集群透明性:消息的发布和订阅是集群透明的,不需要显式管理目标 Actor 的位置。
  • 负载均衡:支持通过 grouptopic 来组织 Actor,提供灵活的消息路由。
  • 内置容错:自动处理节点故障和订阅者变化,提供高可用性。
  • 消息过滤:可以通过 topic 精确控制消息的订阅范围,避免发送无关消息。

缺点

  • 性能:由于消息需要通过 Mediator 转发,随着订阅者数量增加,单点性能可能成为瓶颈。
  • 复杂性:需要手动维护订阅逻辑,比如显式注册和取消订阅。
  • 延迟:由于消息经过中心化的 Mediator,可能引入一定的延迟。

Cluster Aware Router

概述

Cluster Aware Router 是 Akka 的一种路由机制,支持集群环境中的 Actor 路由。它通过配置路由器将消息分发到集群中的多个 Actor 实例(即 routees)。

优点

  • 直接路由:支持多种路由策略(如广播、轮询、随机等),在广播场景下不需要依赖中心节点。
  • 性能高:广播消息直接发送到所有路由目标,避免了 DistributedPubSub 的中心化瓶颈。
  • 配置简单:只需在 application.conf 或代码中简单配置路由器即可使用。
  • 动态扩展性:可以自动检测集群中目标 Actor 的变化(如新增或移除节点),无须手动管理。

缺点

  • 灵活性较低:相比 DistributedPubSub,功能较为单一,主要用于静态的消息广播或路由,无法实现复杂的发布-订阅模式。
  • 路由开销:当路由器需要管理大量的 routees 时,路由管理的开销会增加。
  • 不支持主题过滤:无法像 DistributedPubSub 那样支持基于 topic 的精细化订阅,所有广播消息都会发给所有路由目标。

综合以上考虑,我们使用 Cluster Aware Router 的方式来实现集群广播机制,不过需要做一点小小的改造以支持订阅不同的主题。

Cluster Aware Router 实现广播

我们会在所有节点上启动一个 PlayerBroadcastActor 用于当作广播路由,其实不用所有节点都启动这个 Routee,大部分情况广播都是将消息推送给客户端,所以也只可以在 gate 节点启动 Routee 就好了,其它节点启动 Router 就行了。在 conf 中添加如下配置,我们的 PlayerBroadcastActor 在启动的时候,一定要叫 broadcastActor,否则无法正确路由。

akka {
  actor {
    deployment {
      /broadcastRouter {
        router = broadcast-group
        routees.paths = ["/user/broadcastActor"]
        cluster {
          enabled = on
          allow-local-routees = on
        }
      }
    }
  }
}

启动 Router

broadcastRouter = system.actorOf(FromConfig.getInstance().props(), "broadcastRouter")

上面也说到了,Cluster Aware Router 这种形式会将消息发送给所有启动了 Routee 的节点,无法将消息按 topic 进行广播,因此我们在 PlayerBroadcastActor 收到消息之后,根据消息内容里面带的 topic 进行区分。我们会在每个节点本地实现一个 EventBus,用于实现本地发布订阅。

data class PlayerBroadcastEnvelope(
    val topic: String,
    val include: Set<Long>,
    val exclude: Set<Long>,
    val message: GeneratedMessage,
) : Message
class PlayerBroadcastEventBus : LookupEventBus<PlayerBroadcastEnvelope, ActorRef, String>() {
    override fun mapSize(): Int {
        return 8192
    }

    override fun publish(event: PlayerBroadcastEnvelope, subscriber: ActorRef) {
        subscriber.tell(event)
    }

    override fun classify(event: PlayerBroadcastEnvelope): String {
        return event.topic
    }

    override fun compareSubscribers(a: ActorRef, b: ActorRef): Int {
        return a.compareTo(b)
    }
}

在我们的 Routee 收到 PlayerBroadcastEnvelope,直接将消息交由 PlayerBroadcastEventBus 处理:

override fun createReceive(): Receive {
    return receiveBuilder()
        .match(PlayerBroadcastEnvelope::class.java) { node.playerBroadcastEventBus.publish(it) }
        .build()
}

接收订阅消息

那么这些本地的 EventBus 如何订阅消息的呢?以 ChannelActor 为例,在 ChannelActor 启动的时候,会订阅一些通用的 topic

subscribe(Topic.ofWorld(requireNotNull(worldId) { "worldId is null" }))
subscribe(Topic.All_WORLDS_TOPIC)

如果在业务中有额外的订阅请求,那就需要将这些请求转发到对应的 ChannelActor 上实现特定 topic 的订阅。例如我的广播范围是我所在的公会,那么我只给每一个公会的所有玩家指定一个 topic,之后广播就按照这个 topic来就行了。比如在 WorldActor 上,我们要执行订阅操作,就需要将订阅操作发送给这个玩家所在的 ChannelActor

fun subscribe(world: WorldActor, playerId: Long, playerWorldId: Long, topic: String) {
    if (world.worldId == playerWorldId) {
        world.sessionManager.sendRaw(playerId, SubscribeTopic(topic))
    } else {
        world.tellWorld(SubscribeTopicCrossWorld(playerWorldId, playerId, topic))
    }
}
@Handle
fun handleSubscribeTopic(actor: ChannelActor, msg: SubscribeTopic) {
    actor.subscribe(msg.topic)
}

@Handle
fun handleUnsubscribeTopic(actor: ChannelActor, msg: UnsubscribeTopic) {
    actor.unsubscribe(msg.topic)
}
fun subscribe(topic: String) {
    subscribedTopics.add(topic)
    node.playerBroadcastEventBus.subscribe(self, topic)
}

fun unsubscribe(topic: String) {
    subscribedTopics.remove(topic)
    node.playerBroadcastEventBus.unsubscribe(self, topic)
}

广播的时候指定成员或者排除成员

@Handle
fun handlePlayerBroadcastEnvelope(actor: ChannelActor, msg: PlayerBroadcastEnvelope) {
    if (msg.include.isNotEmpty() && !msg.include.contains(actor.playerId)) {
        return
    }
    if (msg.exclude.contains(actor.playerId)) {
        return
    }
    actor.write(msg.message)
}

结尾

以上,我们就完成了集群中的广播机制。