业务场景
我司聊天服务是基于开源的Ejabberd项目(v19.02)搭建,其中有一项以平台部API形式提供给各项目组游戏服,用于发送Announce(即公告)到指定聊天室的功能。内在实现是以一个平台维护的、在Ejabberd已注册的客户端账号(该账号并不存在于各项目的各聊天室中)调用Ejabberd提供的API(
)发送指定信息至对应聊天室。
所以该方案的实现,实质上是一个玩家(对应一个Ejabberd客户端账号)发送聊天消息到指定聊天室。而对于单个玩家发送消息至指定聊天室的频率,Ejabberd是有配置参数可以设置的,官方给出的参考值是:
,具体含义是:单个玩家在单个聊天室发言频率不能高于0.4s/条。
原始解决方案
原始解决方案简单粗暴;
1.对于每个请求(这里指调用公告API的HTTP请求,下同),将请求数据丢到以room_name(聊天室name)为key的redis单向队列中(以RPush、LPop方式构造),然后将room_name扔到全局的channel中;
2.在处理channel数据的routine函数中,以阻塞模式监听channel中是否有数据产生,如果有则已以获取的room_name为key的redis单向队列中pop一条数据,而后强制sleep 0.5s, 再调用Ejabberd提供的API接口send_stanza发送刚才pop获取的消息至指定聊天室。
明显的缺陷
最大的缺陷就是,对于每个请求都强制sleep了0.5s,不论该请求将发往哪个聊天室。也就是全局只有一个channel,所有调用公告接口的请求都被强制排队了。其次,redis队列是全局的,而channel归属单个gochat服务实例,如此可能造成消息到达时失序等问题。至于测试时为什么没有发现问题,当然是没有做压测的缘故。
造成的后果
对于普通玩家而言,该限制对实际操作几乎没有任何影响(无论是正常玩家的发言频率、还是客户端的发言频率限制都可以保证普通玩家发言频率低于0.4s),而对于依赖该API进行各类系统公告或有基于该API玩法的项目组而言,该方案的限制大大影响到了游戏的正常进行。以C2为例,上线后对于单条公告的延迟最高竟达到了80分钟。。
优化之旅
经过线上几个小时的检验,原始方案显然是行不通的,紧急回滚之后,便踏上优化之旅。
优化方案一
原始方案最大的缺陷当然就是不分room,对每条公告请求都强制排队并sleep 0.5s. 所以最先想到的优化方案便是在保持现有Ejabberd发言频率限制的前提下,将队列的粒度细化为针对每个聊天室分别排队。再经分析,具体的方案是:
1.将最初设计的针对每个聊天室分别排队方案修改为:建立100个channel,根据游戏服id对100取模,模相同的游戏服中所有聊天室公用一个channel(以C2项目为例,全服共600余服,每服所含聊天室数量不一,大到十几万,小至几十),每个channel最多可容纳1w个room;
2.针对1中的每个channel,再建立一个wait_channel,最多也可容纳1w个room;
3.对于每个请求,首先将请求数据丢到以room_name(聊天室name)为key的redis单向队列中(上文提到过),然后将该room丢到所在游戏服对应的channel中(1提到的取模);
4.起100个routine,每个routine对应处理1中对应序列的channel. 阻塞模式监听channel,如果有数据则从获取的room_name为key的redis单向队列中pop一条数据,并检查redis中是否有以room_id(对应room_name)为key的记录,若没有记录,则表示当前可以向该room发送公告消息,执行对应操作;
5.若4中redis有以room_id(对应room_name)为key的记录,则表示正有进程对该room进行公告操作,当前操作需等待,此时便将4中pop获取的数据丢到2建立的wait_channel中,在wait_channel中阻塞 模式监听数据,对于获取的每条数据都间隔0.1s查询对应rid在redis是否有记录,直至返回没有记录后,则表示当前可以向该room发送公告消息,执行对应操作。
缺陷
该方案理论上消除了全部请求都需要强制在一个队列排队0.5s的问题,但是却没有考虑另一个现实:向游戏服提供公告API功能的服务本身是以多副本形式存在的,每个副本的内存数据独立,但共享redis数据。
当前方案每个副本均维护了200个channel,每个副本接收到的请求都扔到了自己维护的channel,但是请求数据却写到了redis中。
在多个副本竞争redis中同一key下数据时并非原子操作,可能存在获取数据(Get操作)后、设置key对应TTL(Set操作)前,在进行逻辑操作期间其它副本先执行Set操作的可能,导致当前副本一直拿不到执行后续处理的机会,也就导致本身分发到当前副本的请求迟迟得不到处理,公告消息产生不可预测的延迟。
优化方案二
主要优化多副本、多channel、单redis导致的请求可能产生的延迟等问题。既然实际请求数据在redis中,对于同一聊天室当前是否可以发送数据的标记也记录在redis中,则实际上不需要副本中自行维护请求的channel队列,应将数据统一维护在redis中。具体方案:
1.redis中维护一张map,实际为map[string]queue类型,map中每个key对应一个room_name,value是一个单向队列,队列中每个元素对应一个请求数据;维护一个list,每个元素对应一个room_id。每当有新的请求道来,就向map和listpush相应数据;
2.单独起一routine(全局),采用redis的BLPop方法阻塞式的获取1中list的一个key,获取之后就起一routine单独处理该请求;
3.对于2中处理请求的routine,死循环采用redis的setNX方法获取room_id对应的锁,TTL设置为0.4s(SetNX(room_id, 1, 0.4s)),目的是获取当前routine对该room发送聊天消息的权限。获取权限后执行相应操作。
缺陷
对于单个副本而言,将所有接收到的HTTP请求都丢到了redis队列汇总,然后另起一全局routine循环从该队列中拿一个room_id,再起一临时routine处理该room_id对应的请求,实则有点多余。
最终方案
基于优化方案二的最终方案:
接收到请求后,直接调用redis的setNX方法检查当前是否可以发送消息到指定room_id(setNX实质上相当于该room_id的锁,拿到set的权限相当于获取锁),若可以发送则执行相应操作;若不可以发送则直接返错给调用方,简单直接。
同时评估项目组需求后,平台将上文提到的0.4s的限制降到了0.1s,尽量降低该限制对项目组需求的影响。
测试结果
在项目组配合修改调用及重试逻辑后,针对该接口的调用进行了贴近生产环境的压测,测试结果表明最终方案达到了生产环境的性能要求。
彩蛋
最终方案的性能要求是达到了,但是Ejabberd官方对send_stanza接口的实现中,并没有将以该接口发送的消息存档,也就是说,客户端重新登录后,之前收到的系统公告消息就丢掉了。这个缺陷对于强依赖该公告接口的项目而言是不可接受的。当两种基于send_stanza的补救方案(修改Ejabberd源码、构造该消息格式手动存档)因各种原因确认不可行后,此次所谓优化最终也被弃用,需要另寻出路。