语聊房麦位状态机在生产环境里是最容易出问题的模块之一,因为多个客户端同时操作同一个麦位时,稍有不慎就会出现状态不一致:用户已经下麦,麦位图标还亮着;网络断开重连后,本地状态和服务端对不上;两人同时申请同一个空位,结果双方都以为自己上麦成功。
这类 bug 在测试环境里难以稳定复现,却在高并发的线上房间里频繁出现。根本原因通常是两个:并发写入没有原子性保证,以及断线重连时的状态恢复路径设计有缺陷。本文覆盖语聊房麦位状态机的完整架构:状态转换图、用 Redis Lua 保证原子操作、断线重连的恢复策略、各类异常场景的处理方式。SDK 层的具体 API 调用见 《语聊房上麦功能怎么做》。

一. 麦位的完整状态和转换
每个麦位在任意时刻处于以下状态之一:
空位(empty)
├─[用户申请 / 房主邀请并同意]──► 有人-正常(occupied)
└─[房主锁定]──────────────────► 锁定(locked)
有人-正常(occupied)
├─[用户主动下麦 / 房主踢人]──► 空位(empty)
└─[房主禁言]─────────────────► 有人-禁言(muted)
有人-禁言(muted)
├─[取消禁言]────────────────► 有人-正常(occupied)
└─[用户下麦 / 被踢]─────────► 空位(empty)
锁定(locked)
└─[房主解锁]────────────────► 空位(empty)
触发状态转换的事件分两类:用户主动操作(申请上麦、主动下麦)和房主/管理员操作(批准、踢人、禁言、锁定)。每次转换都需要服务端写状态并通过信令广播变更。
注意”有人-禁言”下麦后直接到”空位”,不经过”有人-正常”。被禁言的用户被踢或自行下麦,麦位应该直接清空,不是解除禁言后再清空。
二. 服务端权威原则
整套状态机设计的基础是:服务端是麦位状态的唯一权威来源,客户端只做展示,不自己维护状态。
如果客户端自己维护状态(比如上麦时直接在本地更新 UI,不等服务端广播),会产生几个问题:
- 用户 A 的客户端显示上麦成功,用户 B 的客户端还没收到广播,看到麦位仍然是空的
- 用户断线重连后,本地状态和服务端实际状态不一致,没有机制修正
- 两个客户端几乎同时执行了不同的状态变更,最终状态取决于谁的消息后到,结果是随机的
正确的模型:客户端发操作请求 → 服务端执行状态变更 → 服务端广播结果 → 所有客户端根据广播更新 UI。客户端的 UI 更新总是滞后于服务端,这是正常的(通常 100-300ms 内完成),而不是优先更新本地然后等服务端确认。
三. 状态同步协议
客户端和服务端之间的状态同步用两层机制:
全量快照:进入房间时,客户端从服务端拉取所有麦位的当前状态(包括谁在每个位置上、是否禁言、是否锁定)。这是状态同步的起点。
增量更新:进入房间后,每次有状态变更,服务端通过信令频道向房间内所有客户端广播变更事件。客户端收到后更新对应麦位。
进入房间的正确顺序:
1. 先订阅信令频道(开始接收增量更新)
2. 再请求全量快照
3. 以快照为基础,后续用增量更新维护状态
这个顺序不能反。如果先拿快照再订阅,快照到达和订阅完成之间的时间窗口里发生的变更会丢失——客户端拿到的快照已经过时,而且它不知道自己错过了什么。
先订阅再拿快照,即使快照到达时有新的变更事件也在队列里,客户端可以按顺序处理:先应用快照作为基准,再应用收到的增量事件(过滤掉快照时间戳之前的事件,只处理更新的)。
四. 断线重连的状态恢复
断线重连是状态不一致最常见的触发场景。断线期间可能发生了多个状态变更(有人下麦、新人上麦、禁言解除),客户端持有的是旧快照。
重连后的处理流程:
1. SDK 自动重连 RTC 频道(声网 SDK 内部处理)
2. 应用层重新订阅信令频道
3. 向服务端重新请求麦位全量快照
4. 用新快照替换本地缓存,更新 UI
步骤 1 是 SDK 自动完成的,步骤 2-4 需要应用层处理。很多开发者只等 SDK 重连,没有做步骤 2-4,导致断线后麦位显示和实际不符。
SDK 触发 onConnectionStateChanged(Android)或 connectionChangedTo(iOS)回调,当状态变为 CONNECTED 时说明 RTC 重连成功,这时触发步骤 2-4:
// Android:监听连接状态变化
@Override
public void onConnectionStateChanged(int state, int reason) {
if (state == Constants.CONNECTION_STATE_CONNECTED
&& reason == Constants.CONNECTION_CHANGED_REJOIN_SUCCESS) {
// RTC 重连成功,重新同步麦位状态
rejoinSignalingChannel();
fetchSeatSnapshot();
}
}
如果用户本人在断线前是在麦位上的(broadcaster),重连后需要确认服务端是否已经把他的麦位释放。如果释放了,客户端需要把自己切回 audience;如果没有释放,可以恢复 broadcaster 状态继续说话。两种策略各有取舍,需要产品决定。
五. 并发冲突的防护
场景一:两个用户同时抢同一个空麦位
A 和 B 同时向服务端发了申请同一个空麦位的请求。服务端的解决方案是用原子操作写麦位状态,只有第一个到达的请求能成功:
-- 原子写麦位(Redis Lua 脚本)
local seatKey = "seat:" .. roomId .. ":" .. seatIndex
local current = redis.call("HGET", seatKey, "status")
if current == "empty" then
redis.call("HMSET", seatKey,
"status", "occupied",
"uid", uid,
"mutedByHost", "0"
)
return 1 -- 成功
else
return 0 -- 已被占用
end
返回 0 的请求,服务端向对应客户端返回”麦位已被占用”的响应。
场景二:房主批准 A 的申请,同时 A 自己撤回了申请
批准操作到达服务端时,A 的申请记录可能已被标记为”已撤回”。服务端在执行批准前需要检查申请状态是否仍为 “pending”,不是的话拒绝批准操作,防止把用户强行推上麦位。
场景三:同一用户在两端同时操作
用户在手机 A 申请了上麦,又在手机 B 撤回了申请。服务端需要通过 uid 唯一标识,两个操作针对同一个用户,后到的操作为准(用请求时间戳或乐观锁判断)。
六. 异常降级处理
信令消息丢失
信令通道在弱网下可能丢消息。如果客户端长时间没有收到信令广播,麦位状态可能已经过时。可以设置一个定期重同步策略:每隔 30-60 秒,客户端主动拉取一次全量麦位快照,用来修正可能的漂移。频率不要太高,否则会增加服务端压力。
服务端状态和 RTC 实际状态不符
出现”服务端麦位有人但 RTC 频道里没有这个 uid 的音频”的情况,通常是用户断线但服务端没有及时释放麦位。可以在 RTC 的 onUserOffline 回调里,让客户端通知服务端检查并修正麦位状态。服务端也可以定期扫描麦位上的用户是否仍在 RTC 频道里(通过声网的查询频道用户列表 API),不在的话释放麦位。
房间在异常情况下解散
服务端崩溃重启后,内存里的麦位状态丢失(如果用了无持久化的内存存储)。重启后房间状态需要从持久化存储恢复,或者通知客户端重新进入房间。不处理这种情况,用户看到的 UI 和服务端实际状态完全不匹配。
七. 如何排查状态不一致
状态不一致 bug 的特征:只在特定网络条件下复现、单人测试不出现、出现后刷新就”好了”。排查时可以沿这条线索走:
- 服务端状态是否正确:查询服务端当前的麦位数据(直接看 Redis 或数据库),确认服务端自己是否一致
- 信令是否正常发送:查日志确认服务端在状态变更时是否发出了信令广播
- 客户端是否收到信令:在客户端加 log,确认
onSeatUpdated回调是否触发 - 客户端处理是否正确:收到信令后,UI 是否按预期更新
断线重连导致的状态不一致,通常在步骤 2 就能发现:断线期间的信令没有发出(因为客户端不在信令频道里),重连后也没有重新拉取快照,所以客户端状态停留在断线前的旧状态。
声网控制台提供频道内用户列表和实时状态查询接口,在排查”服务端认为用户在麦位上但 RTC 里没有音频”时可以直接查 RTC 侧的状态,和服务端数据对比。
