并发创建房间问题的两种工程化解法

在实时对战、匹配类系统中,「创建房间 + 加入房间」是非常核心的一条链路。但只要这条链路中 存在会让出协程的操作(DB、RPC、跨节点加载实体等),并发问题就几乎不可避免。

一个典型现象是:

多个玩家几乎同时请求加入房间
系统判断“当前没有未满房间”
最终却创建出了多个未满房间

这并不是代码写错了,而是并发 + 协程让出下的必然结果。

下面总结两种在实际工程中可落地、可维护的解决思路。


问题的本质

核心矛盾只有一个:

创建房间的过程会让出协程,而“是否需要创建房间”的判断又不是原子操作。

只要多个请求在同一时间窗口内进入判断逻辑,就可能得到相同的结果,从而并发创建房间。

解决问题的关键不在于“写得更快”,而在于约束并发路径


方法一:固定 hived + 调度系统串行化

这是最通用、最稳妥的一种方案,适合大多数实时房间系统。

hived: 是一个负责创建/加入房间的服务, 也管理未满房间,每个节点都有一个副本。

1. 创建 + 加入房间固定在一个 hived

第一步,是明确房间的管理归属

约定规则:

  • 房间由某个 hived 创建
  • 该房间的「未满 / 可加入」状态,只能由创建它的 hived管理
  • 其他节点的 hived 不参与这个房间的调度与占用

这样可以避免:

  • 多个 hived 同时管理同一个房间
  • 虚位占用状态分散在多个节点
  • 房间状态不一致的问题

这是一个必要的架构前提


2. 使用调度系统,串行化 Join 请求

即使房间只归属一个 hived,仍然存在问题:

创建房间需要 DB / RPC / 加载实体
这些都会让出协程

解决办法不是“避免 yield”,而是:

在 hived 内部,把 Join 请求队列化,强制串行执行。


调度系统的核心思想

  • Join 请求不直接执行
  • 封装为 JoinTask
  • 所有任务进入本地队列
  • 同一时间只跑一个任务
  • 即使任务中途 yield,也不会有其他 JoinTask 并发执行

调度系统伪代码

JoinScheduler(调度器)

JoinScheduler = {
    queue = {},
    running = false,
}

function JoinScheduler:push(task)
    table.insert(self.queue, task)

    if not self.running then
        self.running = true
        fork(function()
            self:_run()
        end)
    end
end

function JoinScheduler:_run()
    while true do
        local task = table.remove(self.queue, 1)
        if not task then
            self.running = false
            return
        end

        task:run(self)
    end
end

要点:

  • fork 只发生一次
  • 一个调度循环处理完整个队列
  • 队列空了才退出

JoinTask(状态机任务)

function JoinTask:run(ctx)
    while true do
        if self.state == "PICK" then
            local room = ctx:pick_room(self.match_args)
            if room then
                self.room = room
                self.state = "JOIN"
            else
                self.state = "CREATE"
            end

        elseif self.state == "CREATE" then
            -- ⚠ 会让出协程
            self.room = ctx:create_room(self.match_args)
            self.state = "JOIN"

        elseif self.state == "JOIN" then
            -- ⚠ 会让出协程
            local ok = self.room:join(self.player_id)
            if ok then
                self:done(true, self.room)
                return
            end

            -- join 失败,重新 pick
            self.state = "PICK"
        end
    end
end

关键点:

  • create_room 和 join 都允许 yield
  • 但 整个 JoinTask 是串行执行的
  • 下一个 JoinTask 必须等当前任务彻底结束

效果

即使有 10 个玩家同时请求加入:

  • 第 1 个任务创建房间(yield)

  • 其余 9 个任务在队列中等待

  • 房间创建完成后: 后续任务直接 join

  • 最终只会创建 一个未满房间


方法一的总结

  • 逻辑清晰
  • 行为可预测
  • 对 DB / RPC / 跨节点操作友好
  • 非常符合 Skynet 的“单服务串行执行模型”

代价是:

  • 加入请求在本地有一个极短的排队过程

但换来的是绝对可控的房间数量和状态一致性


方法二:hived 收集请求,达标后再创建房间(纯内存方案)

这是另一种思路,偏向“匹配池”模型。

核心思想

在创建房间实体之前,所有操作都只发生在内存中。

流程变成:

  1. 玩家请求“获取空房间”
  2. hived 只做一件事:把玩家放入内存队列
  3. 当人数达到阈值(例如 10 人):
    • 才真正创建房间实体
    • 一次性把所有玩家加入房间

为什么这个方案天然安全?

  • 在创建房间之前:
    • 没有 DB
    • 没有 RPC
    • 没有跨节点
    • 没有协程让出
  • 所有逻辑都在一个服务、一个协程内完成

也就是说:

并发问题在房间创建前就被消灭了。


方法二的特点

优点:

  • 性能极高
  • 实现简单
  • 天然避免多个未满房间

限制:

  • 更适合「凑人数」的玩法
  • 需要额外处理:
    • 玩家超时
    • 取消匹配
    • 人数不足时的兜底策略
  • 不太适合“随进随玩”的即时房间

两种方法的取舍

维度方法一:调度系统方法二:内存收集
是否解决并发
是否依赖调度
是否涉及协程让出创建前无
适合玩法即时房间匹配型玩法
工程复杂度

总结

  • 并发创建多个房间,并不是偶发 bug,而是设计问题
  • 只要「创建房间」会让出协程,就必须对并发路径进行约束
  • 工程上有两条可靠路线:
    • 固定 hived + 本地调度串行化
    • 先内存收集玩家,再创建房间

选择哪一种,取决于你的玩法形态,而不是个人偏好。

但无论选择哪种,都有一个共识:

不要指望并发条件下的“判断 + 创建”天然安全。

这是房间系统设计中,最容易踩坑、也最值得一开始就想清楚的地方。

--完--