头图

摘要:利用 Redis 实现房间业务管理的实践与思考。

文|即构业务后台开发团队

在一些互动场景中,比如语音聊天室、电商直播等,成员控制、连麦、献花、发弹幕等互动功能,通常要求后台服务器能够储存管理房间及房间内成员的数据。

那么如何组织、存储、操作这些数据以完成既定的业务,并且还要同时保证服务器和客户端之间的数据一致性,是实现这类音视频互动场景的业务后台需要考虑的问题之一。

RoomKit 作为即构科技推出的一款全新形态 LCEP(Low-code Engagement Platform)产品,高度抽象了音视频通话、白板涂鸦、文件演示、实时消息等通用能力,模块功能可以任意组装,让用户用低/零码的方式可完成多个业务场景搭建。所以在 Roomkit 这款产品的后台逻辑中,房间数据管理作为业务的核心部分,贯穿了整个开发过程。

Redis 作为一款高性能 kv 数据库,在后台开发中应用十分广泛,Roomkit 后台我们也使用了 Redis 进行房间数据管理。

那么本文我们就来看下,即构后台开发团队在利用 Redis 实现业务时遇到的技术难点和解决方案,读者在使用即构 aPaaS 层实现自己的业务遇到相似的问题时,也可以参考本文进行解决。

一、Roomkit 后台整体介绍

1、功能模块划分

根据业务逻辑,RoomKit 将代码划分为房间控制模块和功能插件模块两大模块。下面为大家详细介绍这些模块的功能。

房间控制模块

房间控制模块主要用于管理房间列表、房间状态、房间内成员的状态交互。

RoomKit 适用的场景多种多样,大班课、直播、小班课、视频会议、1v1,但实际这些场景可以划分为类视频会议场景和类直播场景。

这两个场景的逻辑侧重各不相同,类视频会议场景参与成员相对较少,但是成员之间交互很频繁;而类直播场景参与人数一般较多,但是主播与听众交互相对较少。基于这个标准 RoomKit 后台又将房间控制模块又划分为两个子模块。由于与本文主题无关,这里不再展开描述。

功能插件模块

功能插件模块指的是与 RoomKit 支持的插件功能,如共享、教学插件、IM 等,其逻辑与场景无关,均可作为独立模块进行开发,与房间控制模块的交互通过相互提供 handler 完成。

2、后台服务架构

RoomKit 后台基于 Redis 管理房间数据,并利用即构信令后台提供的即时推送能力,向客户端实时推送房间状态变化通知。下图是后台与其他服务和客户端之间的架构关系。

二、利用 Redis 管理房间数据的关键技术

下面我们主要以【房间控制模块】为例,介绍 Roomkit 后台在利用 Redis 实现房间业务时的一些关键点。

1、利用 Redis 储存房间数据

为了实现房间内的交互功能,Roomkit 后台需要记录房间、成员等状态信息。为了在业务服务器程序之间共享这些数据,我们选择将数据储存在 Redis 中。

Redis 的 hash 结构天然的可以用于记录房间状态等数据,对房间的设置,如开始上课操作,只需要更改对应的 field 即可。

而为了能够跟踪到当前处于打开状态的房间,Roomkit 后台将房间 ID 和创建时间记录到一个全局的 ZSET 中,后台会定时遍历这些房间以处理统计数据、检查离线成员等。

房间内成员状态同样会记录在一个 hash 结构中,而成员 ID 会被记录在与房间 ID 对应的 ZSET中,其中 score 为成员登陆或上次心跳时间,成员每次心跳都会更新 score 到当前时间。

利用 ZSET 按 score 排序的特性,后台可以很容易的筛选出离线的成员并移出房间。

2、Redis key 无过期时间设计

在编写代码的过程中,我们经常会遇到内存泄漏的问题,而在利用 Redis 储存数据的时候,同样也会存在key泄漏的问题。Redis 在作为 cache 中间件使用时,为了避免 key 泄漏,通常都会对 key 设置过期时间。但是在 Roomkit 中,房间数据销毁是在房间结束时发生的,而房间结束时间是由成员控制的,设置过短的 ttl 会导致数据丢失,而设置太长的 ttl 实际上会导致 Redis 的 key 泄漏问题。

针对这个问题,Roomkit 后台采用了无过期时间设计,也就是不对 key 设置 ttl。

为了防止 key 泄漏,在 Roomkit 后台中,每一个动态创建的 key 都会被记录在一些固定的 key 中,在进行销毁的时候,从这些固定的key出发,就可以索引到所有的 key 了。

例如在共享模块中,为了能在成员退出房间时关闭该成员的共享内容,会在成员创建共享内容的同时,创建一个 key 为 personal_share:{uid} 的 SET 结构以记录这个成员创建的共享内容,而这个 key 自身则会被记录到另一个 key 为 share_recycle_bin 的 SET 结构中。

这样在房间结束时,通过获取并删除 cycle_bin 中记录的 key,达到清理数据的目的。

local keys=redis.call("SMEMBERS","share_recycle_bin")
redis.call("DEL",unpack(keys))

另外为了避免要回收的 key 过多导致的 Redis 执行阻塞过长,Roomit 在删除时还做了分批处理的优化。

3、多机协作遍历任务列表

为了实现对每个房间的检查和统计,需要定时遍历房间列表,从中取出需要检查的房间进行业务定义的检查。如果列表很长,仅凭单机完成检查工作需要较长的时间。在单机编程环境下,这类问题我们通常会使用线程池等技术解决。

而在多机环境下,我们会期望将这些任务均衡的摊派到各个服务器上,这就需要各个服务器之间进行协作。

Roomkit 后台利用 Redis 的单线程执行特性和 zscan 机制实现了分布式协作遍历。

简要实现如下:

local now = tonumber(redis.call("TIME")[1])
local cursor = redis.call("GET","cursor_key") 
if cursor=="0" then 
    local next_check_time = redis.call("GET","next_check_time_key") 
    if tonumber(next_check_time)>now then 
        return next_check_time-now 
    end
end
local scan_result = redis.call("ZSCAN","room_zset_key",cursor) 
redis.call("SET","cursor_key",scan_result[1]) 
if scan_result[1]=="0" then redis.call("SET","next_check_time_key",now+scan_interval) end 
return scan_result[2]

单个节点执行遍历时,使用 ZSCAN 命令获取一批需要检查的房间号,并将返回的 cursor 值更新到全局 cursor 上。如果 cursor 为 0,表示遍历完毕,这时候会设置下一次开始遍历的时间。在每次执行遍历时会检查这个时间,如果没有到开始时间,则返回。

房间遍历逻辑不与客户端直接交互,且支持水平扩容,实际部署上可以作为独立的功能组件使用,根据业务负载情况动态调整 worker 数量。

4、seq 与最终一致性

在强交互场景下,保持客户端与服务器的数据一致性是非常重要的,否则就会出现状态错乱的情况,影响交互效果。 所以我们采用了 seq 机制来保证数据变动的逻辑顺序。

事实上我们可以把客户端本地所持房间内数据看作是后台所持房间数据的副本,这样客户端(视为 follower ) 和后台 (视为 leader ) 就可以看作一个分布式的数据储存系统。

Roomkit 后台在对数据进行变更后,需要通过信令后台提供的房间内广播能力,向所有的客户端推送变更通知,通知包括变更事件和变更的详细数据,使得客户端与服务器保持一致。

但是客户端所处网络情况是非常复杂的,在弱网情况下可能存在通知丢失、乱序的情况,而通知的丢失和乱序会导致成员和房间状态异常。

由于弱网情况不可避免,为了保证客户端本地所持数据最终能够与后台数据一致,Roomkit 在每条通知上加上了一个用于校验的 seq 号,当后台数据状态发生变更时,seq 都会进行自增,因此 seq 实际上代表了后台数据的版本号。

客户端在首次进入房间时会拉取全量数据及相应的 seq ,然后通过通知里的数据对本地数据进行增量更新,并推进 seq。另外在客户端心跳时,后台也会返回最新的 seq ,客户端在发现本地的 seq 与心跳返回的 seq 不一致时,将再次拉取全量数据来与后台保持数据一致。

利用 seq 还可以避免一些全体操作导致的通知过长问题。

例如在小班课中进行全体闭麦操作,如果将所有人的变更都放到通知中,会因为消息过长而无法发送。因此后台针对这样的全体操作通知进行了优化,仅发送事件本身,不发送变更数据。客户端在接收到通知后,首先核对 seq 是否是本地seq 的下一 seq ,如果是,则认为全体操作作用的数据是与后台一致的,可以在本地进行操作重放,使数据变更到与后台一致,否则就需要拉取全局数据进行覆盖。

5、CAS 操作

竞态条件是在并发编程中最常见的问题,而这在多机环境下也是可能出现的。通常使用 Redis lua 等临界区技术可以避免这个问题,但是如果业务流不能在一个临界区内执行完怎么办?这时我们就需要使用 CAS 操作。

Roomkit 后台在一些复杂逻辑的实现上使用了 Redis 的 lua 脚本机制。但是众所周知,Redis 是单线程执行的,如果 lua 脚本比较复杂,会导致执行时间过长,阻塞其他命令执行。因此Roomkit 后台对部分过长的 lua 进行了拆分,并利用 CAS 避免竞态条件。

例如在演讲模式中,后台会将房间状态 hash 结构中 speaker 字段的值设置为当前主讲人的成员 ID ;如果主讲人直接退出房间,后台在处理通用成员退出逻辑的同时,还要选择一个在线成员设置为主讲人。而选择下一主讲人的逻辑较为复杂,如果放到成员退出逻辑中一起执行,可能会导致执行阻塞;而如果分为两个 lua 脚本执行,则有可能出现这样一种情况:在成员退出脚本执行完毕、设置主讲人脚本开始执行之前,有另外一个设置主讲人的请求到达后端并成功执行,这时候如果再执行设置主讲人脚本,将会覆盖设置主讲人的请求,导致客户端的异常表现。

解决方案一:利用CAS,在执行设置主讲人脚本时,首先查看 speaker 字段的值是否已经改变,如果已经改变则放弃执行。

local speaker=redis.call("HGET","room_stat_key","speaker")
if speaker~=left_speaker_id then return end
-- select next speaker...

这个解决方案仍然有个缺陷:在设置主讲人脚本开始执行之前进程 crash 了,这时候主讲人字段的值将不会改变,这就导致成员已退出房间,但主讲人仍然是该成员这样逻辑不一致的系统状态。

解决方案二:在成员退出脚本中,将 speaker 字段置为初始值也就是空值,代表目前没有主讲人,这样如果遇到进程crash 等情况,虽然无法执行设置主讲人脚本,系统仍然可以保持逻辑一致。

这样设置主讲人脚本逻辑就变更为查看 speaker 字段的值是否为空值,如果不是则放弃执行。

-- user left script
--...
local speaker=redis.call("HSET","room_stat_key","speaker","")
--...

-- set speaker script
--...
local speaker=redis.call("HGET","room_stat_key","speaker")
if speaker~="" then return end
-- select next speaker...

三、结语

本文总结了即构后台开发团队在实现Roomkit 后台业务时,如何利用 Redis 实现房间管理业务。在分布式环境下保证业务的正确性、保证数据的一致性,是广大后台开发者不懈追求的目标,关于这些问题的思考和实践希望对读者有所帮助。


ZEGO即构
30 声望15 粉丝

音视频云服务商