群聊、WebSocket 和 WebRTC 都已经接通,Demo 里点击“发起视频”也能正常进入房间,但一遇到并发点击、弱网重试或服务重启,问题就会集中出现:
- 同一成员收到两次响铃;
- 发起人已经取消,其他人仍能点击接听;
- 两个人几乎同时接听,系统却创建了两次通话;
- Spring Boot 重启后,旧邀请重新弹出;
- 群消息里的通话卡片显示“进行中”,实际房间早已结束。
这类故障的核心通常不是 WebRTC 连接失败,而是群消息、瞬时通知和通话业务状态之间没有明确的所有权。
Tencent RTC 的 Social Messaging 方案覆盖单聊、群聊、社区、富媒体及直播间聊天等社交消息场景,可作为群消息能力的产品基础。本文不虚构具体 SDK 方法名,而是在其上设计应用侧通话邀请控制层;接入时应按所选平台的官方 SDK 文档替换文中的消息适配器。官方场景说明:https://trtc.io/solutions/social-messaging。
一、先确定边界:群消息不是通话状态数据库
一个群视频邀请至少涉及四类对象:
| 对象 | 主要职责 | 是否应作为最终事实 |
|---|---|---|
| 群消息 | 展示邀请卡片、结束结果和操作入口 | 否 |
| 推送或在线通知 | 提醒成员及时处理 | 否 |
| 应用服务 | 校验权限、推进邀请状态 | 是 |
| RTC 媒体房间 | 承载实际音视频 | 只负责媒体会话 |
最容易踩的坑,是看到一条“邀请中”的群消息,就直接允许用户进入房间。消息可能延迟、重复或者来自本地缓存,因此点击卡片后必须重新向业务服务查询并提交操作。
建议采用下面的原则:
数据库中的通话聚合状态决定“还能不能接”;群消息只负责告诉用户“发生过什么”。
这也能缓解团队常见的责任焦虑:前端、消息服务和 RTC 模块不需要互相猜测当前状态,只要共同服从一个可查询的业务状态机。
二、选择邀请模型:抢答式还是多人加入式
编码前先确认产品语义,否则并发控制会完全不同。
模型 A:抢答式咨询
一个群内发起咨询,任意一名成员接听后,其余成员不能再接。
- 适合客服咨询、值班响应;
RINGING -> ACCEPTED只能成功一次;- 接听操作需要数据库条件更新。
模型 B:多人加入式通话
邀请有效期内,多个群成员都可以加入同一个会话。
- 适合小组会议、兴趣群语音;
- 通话状态与成员状态应分开保存;
- 不能用第一次接听直接终止邀请。
下面以更容易发生并发冲突的“抢答式邀请”为例。多人加入式可以复用相同框架,但要增加成员表和容量、权限策略。
三、状态机:禁止任意字段覆盖
邀请主状态定义为:
CREATED -> RINGING -> ACCEPTED -> ENDED -> CANCELED -> EXPIRED合法转换如下:
| 当前状态 | 操作 | 目标状态 |
|---|---|---|
| CREATED | 邀请消息发布完成 | RINGING |
| CREATED/RINGING | 发起人取消 | CANCELED |
| RINGING | 合法成员接听 | ACCEPTED |
| CREATED/RINGING | 到达截止时间 | EXPIRED |
| ACCEPTED | 主动挂断或业务结束 | ENDED |
不要提供一个通用的updateStatus(callId, status)接口。它会允许迟到请求把ENDED改回ACCEPTED。
四、数据模型:状态、版本和消息关联必须同时保存
MySQL 表可以按下面的最小结构建立:
CREATETABLEgroup_call(call_idVARCHAR(64)PRIMARYKEY,group_idVARCHAR(64)NOTNULL,initiator_idVARCHAR(64)NOTNULL,accepted_byVARCHAR(64)NULL,room_refVARCHAR(128)NOTNULL,statusVARCHAR(16)NOTNULL,versionBIGINTNOTNULLDEFAULT0,expires_atDATETIME(3)NOTNULL,invite_message_idVARCHAR(128)NULL,created_atDATETIME(3)NOTNULL,updated_atDATETIME(3)NOTNULL,INDEXidx_group_status(group_id,status),INDEXidx_expire_scan(status,expires_at));字段设计有三个重点:
call_id是应用侧邀请身份,不能用前端临时组件 ID;version用于拒绝迟到更新,也便于前端识别旧事件;invite_message_id只负责关联群消息,不能替代call_id。
room_ref应是服务端生成或分配的不可猜业务引用。客户端不能自行提交任意房间标识后要求服务端广播。
五、完整流程:先建事实,再发邀请
步骤 1:创建邀请并校验群成员身份
服务端应检查:
- 发起人是否仍属于目标群;
- 当前群是否已有互斥通话;
- 发起频率是否满足产品规则;
- 邀请截止时间是否处于允许范围;
- 是否需要屏蔽、禁言或风险控制校验。
创建记录时先写入CREATED,不要因为群消息还没发出就让数据完全不存在。
publicrecordCreateCallCommand(StringrequestId,StringgroupId,StringinitiatorId){}@TransactionalpublicCallViewcreate(CreateCallCommandcmd){membershipGuard.requireMember(cmd.groupId(),cmd.initiatorId());duplicateGuard.requireNewRequest(cmd.initiatorId(),cmd.requestId());activeCallGuard.requireNoConflictingCall(cmd.groupId());GroupCallcall=GroupCall.created(idGenerator.nextCallId(),cmd.groupId(),cmd.initiatorId(),roomRefGenerator.next(),clock.instant().plus(invitePolicy.ttl()));repository.insert(call);returnCallView.from(call);}这里的requestId用于处理用户双击和客户端超时重发。它不等于call_id:前者标识一次创建命令,后者标识通话聚合。
步骤 2:通过消息适配器发布群聊卡片
定义应用自己的端口,避免业务代码绑定某个平台 SDK 的函数签名:
publicinterfaceSocialMessagePort{SendResultsendGroupCallCard(CallCardcard);voidsendCallStateNotice(CallStateNoticenotice);}publicrecordCallCard(StringcallId,StringgroupId,StringinitiatorId,longversion,InstantexpiresAt){}以上是本文定义的应用接口,不是 Tencent RTC 官方 API 名称。具体实现需要使用所选 Social Messaging SDK 支持的群消息能力,并按照官方文档完成鉴权、初始化和消息发送。
发送成功后,以条件更新把状态改为RINGING:
UPDATEgroup_callSETstatus='RINGING',invite_message_id=:messageId,version=version+1,updated_at=NOW(3)WHEREcall_id=:callIdANDstatus='CREATED';如果更新行数为零,说明邀请可能已被取消或超时。此时不能再把它强行恢复为响铃状态。
步骤 3:接听时使用数据库竞争,而不是 Java 锁
单机中的synchronized或ConcurrentHashMap无法约束多个 Spring Boot 实例,也会在重启后丢失。
接听接口应执行带前置状态的条件更新:
@TransactionalpublicAcceptResultaccept(StringcallId,StringoperatorId){GroupCallcall=repository.requireById(callId);membershipGuard.requireMember(call.groupId(),operatorId);if(!clock.instant().isBefore(call.expiresAt())){repository.expireIfPending(callId);returnAcceptResult.expired(callId);}intchanged=repository.acceptIfRinging(callId,operatorId,call.version());if(changed==0){GroupCalllatest=repository.requireById(callId);returnAcceptResult.rejectedByLatestState(latest.status());}returnAcceptResult.accepted(callId,call.roomRef());}对应 SQL:
UPDATEgroup_callSETstatus='ACCEPTED',accepted_by=:operatorId,version=version+1,updated_at=NOW(3)WHEREcall_id=:callIdANDstatus='RINGING'ANDversion=:expectedVersionANDexpires_at>NOW(3);两个成员同时接听时,只有一个请求能更新成功。失败的一方读取最新状态,并在 UI 中显示“已被其他成员接听”,而不是笼统提示网络错误。
步骤 4:客户端收到卡片后先查询,再决定是否响铃
客户端处理顺序建议为:
收到群消息 -> 按 callId 查询业务状态 -> 校验 groupId 与当前会话一致 -> 校验 expiresAt -> 仅在状态为 RINGING 时展示响铃 -> 点击接听后提交 accept 命令 -> 以服务端返回结果决定是否进入媒体房间消息负载中的状态只能用于快速渲染占位,不能直接授权进入房间。
六、服务重启恢复:扫描数据库,不重放内存定时器
为每个邀请创建一个ScheduledFuture看似直接,但它有三个问题:
- 服务重启后定时任务消失;
- 多实例会重复执行;
- 大量长时间定时任务难以统一治理。
更稳妥的做法是定期扫描数据库中的到期记录,再做条件更新:
@Scheduled(fixedDelayString="${call.expire-scan-delay-ms}")publicvoidexpirePendingCalls(){List<String>ids=repository.findExpiredPendingIds(clock.instant(),expirePolicy.batchSize());for(StringcallId:ids){intchanged=repository.expireIfPending(callId,clock.instant());if(changed==1){stateNoticePublisher.publishExpired(callId);}}}UPDATEgroup_callSETstatus='EXPIRED',version=version+1,updated_at=NOW(3)WHEREcall_id=:callIdANDstatusIN('CREATED','RINGING')ANDexpires_at<=:now;扫描器可以重复执行,因为状态条件保证已结束的邀请不会再次迁移。多实例部署时,可结合数据库行锁、任务分片或现有调度基础设施;具体方案取决于部署环境,不应默认依赖单机锁。
七、消息卡片如何避免一直显示旧状态
有两种常见方案。
方案 1:原消息保持不变,客户端查询最新状态
优点:
- 实现简单;
- 不依赖消息编辑能力;
- 历史事件保留完整。
代价:打开历史群聊时需要查询通话状态,可采用按callId批量查询,避免逐条请求。
方案 2:结束时再发送一条状态消息
例如发送“该邀请已结束”的群消息,客户端通过callId将卡片折叠为结束态。
优点是群内成员能看到明确结果;代价是消息数量增加,并且仍要处理状态消息乱序。
无论选哪种,客户端都应比较version:
typeCallSnapshot={callId:string;status:'CREATED'|'RINGING'|'ACCEPTED'|'CANCELED'|'EXPIRED'|'ENDED';version:number;expiresAt:string;};functionmergeCallState(local:CallSnapshot|undefined,incoming:CallSnapshot){if(!local||incoming.version>local.version){returnincoming;}returnlocal;}仅比较消息到达时间不够可靠,因为不同网络路径上的事件可能乱序。
八、权限与隐私:拿到群消息不等于获得通话权限
通话接听和进入房间前至少应重新确认:
- 用户当前仍是群成员;
- 用户未被业务规则禁止参与;
- 邀请尚未取消或过期;
- 接听者与
accepted_by一致; - 房间访问凭证由可信服务端按需签发;
- 群消息中不包含长期有效的敏感凭证。
退出群聊、被移除或被拉黑后,客户端缓存中的旧卡片不能继续作为访问依据。群聊属于社交关系展示层,授权必须由服务端在操作当下判断。
九、效果验证:专门制造状态冲突
不要只验证“发起—接听—挂断”这条顺利路径。建议按下面的故障清单验收。
并发测试
- 两个成员同时接听,只有一人获得成功结果;
- 同一用户连续点击接听,只产生一次状态迁移;
- 发起人与成员同时执行取消和接听,最终状态唯一且合法。
重启测试
- 在
CREATED状态重启服务,邀请最终能恢复或过期; - 在
RINGING状态重启,扫描器仍能将其转为EXPIRED; - 重启后不会重新激活已经
CANCELED的邀请。
消息乱序测试
- 先收到结束通知,后收到邀请卡片,UI 仍显示结束;
- 客户端离线后上线,历史邀请不会重新响铃;
- 重复收到同一张卡片,不会打开多个弹窗。
权限测试
- 发起后退出群聊,不能继续控制邀请;
- 接听前被移出群聊,服务端拒绝接听;
- 修改请求中的
groupId或roomRef,服务端不采信客户端值。
可观测性检查
每次状态迁移至少记录以下非敏感诊断字段:
callId、groupId、operatorId、fromStatus、toStatus、 expectedVersion、actualVersion、requestId、resultCode、timestamp不要记录媒体内容、长期访问凭证或不必要的聊天正文。排障入口应落在日志检索和管理后台,而不是再建立一个无人负责的反馈群。
十、常见坑与取舍
坑 1:用 WebSocket 在线状态判断成员是否能接听
在线只表示连接存在,不代表成员有权限,也不代表邀请有效。权限和状态必须由业务服务判断。
坑 2:为了防并发,把整个接听方法加synchronized
它只对当前 JVM 有效。多实例与服务重启场景仍会失效,应使用数据库条件更新或等价的共享一致性机制。
坑 3:群消息发送失败就删除业务记录
删除会失去问题证据,也可能与迟到回调冲突。保留CREATED记录并由恢复任务决定重发、取消或过期,更容易排障。
坑 4:把消息 ID 当成通话 ID
消息可能被转发、重发或因平台差异采用不同标识。应用必须拥有稳定的callId。
坑 5:只靠客户端倒计时结束邀请
客户端时间可能不准,应用也可能进入后台。服务端expires_at才是最终截止依据,客户端倒计时只负责展示。
可复用总结
群聊视频邀请要从 Demo 走向可靠实现,关键不是再增加一条 WebSocket 通道,而是确定三条边界:
- 群消息负责传播,数据库状态机负责裁决;
- 瞬时响铃可以丢弃,通话状态必须可查询、可恢复;
- 客户端可以发起操作,但不能自行决定权限和最终状态。
落地时可以依次完成:状态机建模、条件更新、消息适配、客户端二次查询、数据库超时扫描、权限复核和冲突测试。这样即使出现多实例并发、消息乱序或 Spring Boot 重启,系统也不会仅凭一条旧群消息让通话邀请“死而复生”。
**关系披露:**作者与 Tencent RTC 存在内容合作关系;本文以 Tencent RTC 官方 Social Messaging 文档作为实现能力参考,应用层状态机、接口命名与示例代码为通用工程设计,并非官方 SDK API 定义。