• 游戏思考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
    • 2
    • 3
    • 4
    • 5
    • 注释
      即用一个死循环,依次获取网络请求,针对请求生成回应数据,然后将回应数据经网络发回。

    • 可能导致死锁的原因
      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
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 云风做法
      这里的 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)可以记录一下客户端发送但没有接收的请求数,超出一个限额之后就不再把请求放进队列,而是往队列放进一个需踢掉客户端的标记(但不立即踢除)。这样该客户端能保证顺序接收到限额内的文件再被踢掉——使用者如果发现被踢掉,多跑几遍就是了,这种实现每次总能多接受到一点数据的。

  • 相关阅读:
    Llama2-Chinese项目:2.1-Atom-7B预训练
    lesson2(补充)关于const成员函数
    java小程序python电影院票务预订选座系统php
    基于Dango+微信小程序的广西东盟旅游资源信息管理系统+80003(免费领源码)可做计算机毕业设计JAVA、PHP、爬虫、APP、小程序、C#、C++、python、数据可视化、大数据、全套文案
    内网穿透(nc)
    【毕业设计】基于java的健康食谱推荐小程序源码
    ②【Docker】安装Docker可视化工具——Portainer
    跟着cherno手搓游戏引擎【26】Profile和Profile网页可视化
    kafka术语
    vue脚手架及常用指令集合
  • 原文地址:https://blog.csdn.net/weixin_43679037/article/details/125429824