游戏思考14:对cache_server缓冲服务器的问题思考(读云风博客有感)

一、游戏服务器的作用类别

引用链接:MMORPG服务器类别介绍

二、原本cache_server的设计

  • 结构
    cache server 的协议设计非常简陋。就是顺序的提交请求,然后每个请求会有序的得到一个回应。这些请求要么是获取 GET 文件,要么是上传 PUT 文件。其中 PUT 文件在协议上不必回应。

  • 我的理解
    想一般的MMORPG游戏会根据客户端提供的id(雪花生成的),判断这个ID是不是人物的ID、工会的ID、NPC的ID等等(其实就是根据宏定义,用联合体强转,用前6位或7位做对比),然后根据客户端的请求信息返回给他对应的消息。(不清楚为啥云风的服务器需要GET\PUT大量文件?)

三、问题展现

  • 前提
    这些请求要么是获取 GET 文件,要么是上传 PUT 文件。其中 PUT 文件在协议上不必回应。
  • 问题
    1)问题一:PUT的问题
    由于 PUT 文件没有回应,所以客户端无法直接确定文件是否全部上传完毕;如果必须确认,只能在 PUT 文件结束后,再提交一个 GET 请求。如果收到了后续 GET 的回应,可以理解为前一个 PUT 已经结束。实际上,Unity 客户端没想去确认 PUT 是否结束,从 log 分析,它只是简单的在最后一个 PUT 结束后等待了一段时间再断开连接。
    2)问题二:这种依赖严格次序的协议,在面对两边数据量不对等、网络速度不对等的近况时,很难有一个健壮的实现。

四、假设是阻塞网络

  • 伪代码体现
while true do
  local req = get_request(fd)
  local resp = handle_request(req)
  put_response(fd, resp)
end
  • 注释
    即用一个死循环,依次获取网络请求,针对请求生成回应数据,然后将回应数据经网络发回。

  • 可能导致死锁的原因
    1)假设 get_request 是阻塞读网络,put_response 是阻塞写网络,那么就要求客户端也是严格的配合:客户端也必须提起一个请求后,等待回应,然后再提下一个请求。否则,若客户端连续提两个请求,服务器在处理第一个请求后,推送的回应客户端不去接收(因为客户端还在提第二个请求),就可能会死锁。
    2)死锁发生时,客户端在推送第二个请求(写操作),而服务器在推送第一个回应(写操作);两边都没在收取对方的数据,两侧的 api 都等待在写网络上(因为对端不读)。

五、读写分离带来的OOM(内存溢出)的问题

  • 现在服务器的基本做法
    一般会将网络读写分离到独立线程中,死锁不会发生。服务器收到新请求就能处理,产生出回应数据。而回应数据将缓存在网络线程中,等待客户端接收,而不会阻塞住上面的业务循环。那里的 put_response 是非阻塞的。

  • 基本做法的缺点
    因为请求和回应是不对等的,客户端可以轻易的发起大量的 GET 请求,一条几十字节的 GET 请求,很可能需要几十上白兆的回应包。巨量的回应包积压在网络线程的发送队列中,很快就会吃光所有的内存。

  • 做法优化
    所以,put_response 这个函数必须在内存耗光前阻塞住,前面的问题就会回来。所以,合理的服务器设计必须分离 get_request 和 put_response 到两个执行序列里。

六、早期unity的缓冲服务器的设计和现在unity的设计

只有一个简单的 js 文件,跑在 nodejs 服务中。nodejs 是基于回调机制的,请求处理放在了 socket 的 data 事件回调中,每个请求都会生成一个新的对象,这个对象会进入一个队列,由 socket 的可写事件触发出队列操作,将文件 pipe 到 socket 上。因为回应操作是由文件的 pipe 到 socket 依次完成的,这个过程可能很慢(取决于对端的接收进度),那么新请求非常可能积压在队列中。假设客户端一直推送请求,而疏于处理回应的话,这个队列将一直增长,直到 OOM 发生。

  • 现在unity的做法
    现在的 cacheserver 版本已经变得非常复杂,不太容易看清楚。我简单浏览了一下,觉得依旧存在这个隐患:在 server/command_processor.js 文件中,_onGet 函数会把要回应的 item 压入队列(this[kSendFileQueue].push(item)😉 这个队列可能无限增长。

七、最终优化的缓冲服务器方法

云风现在的实现也是类似的机制,伪代码如下:

-- request thread
while true do
  local req = get_request(fd)
  push_queue(q, req)
end

-- response thread
while true do
  local req = pop_queue(q)
  local resp = handle_request(req)
  put_response(fd, resp)
end
  • 云风做法
    这里的 push_queue 在达到队列预设的容量后,是会阻塞等待另一个线程的 pop_queue 取走再继续工作的。我们在做此修改后,把 queue 的容量设置为 8192 ,实际运行时,客户反馈以前正常的打包过程(其实会让服务器濒临 OOM 崩溃),现在有时会卡在和 cache server 的通讯上。经过线上观察(使用 skynet 预留的 debug console 的 debug 功能进入服务查看内部状态),发现这个 queue 很容易就满了,等待 pop_queue ;而能执行 pop_queue 的线程却阻塞在 put_response 上,也就是 unity 客户端拒绝接收前面那 8000 个请求产生的回应。

  • 选择点
    针对这种情况的合理推测是, unity 在某些极端情况下,一口气发了上万(甚至十万个)请求,它在这些请求全部从网络发出之前,没有跑网络接收的业务,导致数据全部堵在网络层;而服务器为了避免自己内存耗尽,只能暂停接收新的请求,结果就卡了。换句话说,针对客户端不合理的使用:不断地发送请求,拒绝处理回应,那么服务器若想一直服务下去,只能在内存耗尽卡住间二选一。当然还有拒绝服务的第三条路,即在异常情况(卡住)后,踢掉客户端。客户端发现断线,就会重连服务器再来一次。

  • 最终对策
    我们最终的对策是,优化队列,让队列中保存的数据足够的少(这里可以只讲客户端请求 id 保留在队列中,每个请求所需内存在 100 字节以下)然后增加队列的容量上限到百万级;当队列满时踢掉客户端。

  • 原博文传送们
    传送门

  • 做法建议
    1)非阻塞 API + 流式读写 + 线程池(这里流式读写啥意思,不懂?)
    2)可以记录一下客户端发送但没有接收的请求数,超出一个限额之后就不再把请求放进队列,而是往队列放进一个需踢掉客户端的标记(但不立即踢除)。这样该客户端能保证顺序接收到限额内的文件再被踢掉——使用者如果发现被踢掉,多跑几遍就是了,这种实现每次总能多接受到一点数据的。

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值