游戏服务端开发部分总结(八股文+游戏模块功能实现)

什么是IOCP

完成端口,不用说大家也知道了,最后的压轴戏就是使用完成端口,对比上面几种机制,完成端口的做法是这样的:事先开好几个线程,你有几个CPU我就开几个,首先是避免了线程的上下文切换,因为线程想要执行的时候,总有CPU资源可用,然后让这几个线程等着,等到有用户请求来到的时候,就把这些请求都加入到一个公共消息队列中去,然后这几个开好的线程就排队逐一去从消息队列中取出消息并加以处理,这种方式就很优雅的实现了异步通信和负载均衡的问题,因为它提供了一种机制来使用几个线程“公平的”处理来自于多个客户端的输入/输出,并且线程如果没事干的时候也会被系统挂起,不会占用CPU周期,挺完美的一个解决方案,不是吗?哦,对了,这个关键的作为交换的消息队列,就是完成端口。

三次握手四次挥手

第一次:客户端发送请求到服务器,服务器知道客户端发送,自己接收正常。SYN=1,seq=x
第二次:服务器发给客户端,客户端知道自己发送、接收正常,服务器接收、发送正常。ACK=1,ack=x+1,SYN=1,seq=y
第三次:客户端发给服务器:服务器知道客户端发送,接收正常,自己接收,发送也正常.seq=x+1,ACK=1,ack=y+1

那四次分手又是为何呢?TCP协议是一种面向连接的、可靠的、基于字节流的运输层通信协议。TCP是全双工模式,这就意味着,当主机1发出FIN报文段时,只是表示主机1已经没有数据要发送了,主机1告诉主机2,它的数据已经全部发送完毕了;但是,这个时候主机1还是可以接受来自主机2的数据;当主机2返回ACK报文段时,表示它已经知道主机1没有数据发送了,但是主机2还是可以发送数据到主机1的;当主机2也发送了FIN报文段时,这个时候就表示主机2也没有数据要发送了,就会告诉主机1,我也没有数据要发送了,之后彼此就会愉快的中断这次TCP连接。如果要正确的理解四次分手的原理,就需要了解四次分手过程中的状态变化。

回收算法

引用计数法(略)

根搜索算法
Java和C#都是使用根搜索算法来判断对象是否存活。通过一系列的名为“GC Root”的对象作为起始点,从这些节点开始向下搜索,搜索所有走过的路径称为引用链(Reference Chain),当一个对象到GC Root没有任何引用链相连时(用图论来说就是GC Root到这个对象不可达时),证明该对象是可以被回收的。

标记-清除算法
标记-清除算法分为标记和清除两个阶段。该算法首先从根集合进行扫描,对存活的对象对象标记,标记完毕后,再扫描整个空间中未被标记的对象并进行回收

标记整理算法
标记整理算法的标记过程类似标记清除算法,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存,类似于磁盘整理的过程

复制算法

分代收集算法
新生代、老年代和永久代
新生代对象存活率低,就采用复制算法;老年代存活率高,就用标记清除算法或者标记整理算法

网络七层协议

1.应用层
与其它计算机进行通讯的一个应用,它是对应应用程序的通信服务的。例如,一个没有通信功能的字处理程序就不能执行通信的代码,从事字处理工作的程序员也不关心OSI的第7层。但是,如果添加了一个传输文件的选项,那么字处理器的程序就需要实现OSI的第7层。示例:TELNET,HTTP,FTP,NFS,SMTP等。
2.表示层
这一层的主要功能是定义数据格式及加密。例如,FTP允许你选择以二进制或ASCII格式传输。如果选择二进制,那么发送方和接收方不改变文件的内容。如果选择ASCII格式,发送方将把文本从发送方的字符集转换成标准的ASCII后发送数据。在接收方将标准的ASCII转换成接收方计算机的字符集。示例:加密,ASCII等。
3.会话层
它定义了如何开始、控制和结束一个会话,包括对多个双向消息的控制和管理,以便在只完成连续消息的一部分时可以通知应用,从而使表示层看到的数据是连续的,在某些情况下,如果表示层收到了所有的数据,则用数据代表表示层。示例:RPC,SQL等。
4.传输层
这层的功能包括是否选择差错恢复协议还是无差错恢复协议,及在同一主机上对不同应用的数据流的输入进行复用,还包括对收到的顺序不对的数据包的重新排序功能。示例:TCP,UDP,SPX。
5.网络层
这层对端到端的包传输进行定义,它定义了能够标识所有结点的逻辑地址,还定义了路由实现的方式和学习的方式。为了适应最大传输单元长度小于包长度的传输介质,网络层还定义了如何将一个包分解成更小的包的分段方法。示例:IP,IPX等。
6.数据链路层
它定义了在单个链路上如何传输数据。这些协议与被讨论的各种介质有关。示例:ATM,FDDI等。
7.物理层
OSI的物理层规范是有关传输介质的特性,这些规范通常也参考了其他组织制定的标准。连接头、帧、帧的使用、电流、编码及光调制等都属于各种物理层规范中的内容。物理层常用多个规范完成对所有细节的定义。示例:Rj45,802.3等。

面试可能会问:
HTTP在应用层
socket在会话层
TCP/UDP在传输层
连接方式不同:
1、TCP/IP连接
作用:传输层协议,传输数据。
建立起一个TCP连接需要经过“三次握手”:
2、HTTP
作用:应用层协议,作用于数据交换(统一格式)
连接
HTTP连接最显著的特点是客户端发送的每次请求都需要服务器回送响应,在请求结束后,会主动释放连接。从建立连接到关闭连接的过程称为一次连接。
socket
连接
套接字之间的连接过程分为三个步骤:服务器监听,客户端请求,连接确认。
SOCKET原理
作用:为应用程序与TCP/IP协议交互提供了套接字(Socket)接口。

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

设计模式:

单例模式(如上图)

工厂模式(传奇的怪物创建就是工厂模式,不同怪物类的创建函数都写在CreateAnimal函数中,函数统一返回TAnimal类,根据传参怪物的类型返回不同的怪物)

原型模式
克隆类,(类似传奇游戏的Assign函数)
浅拷贝和深拷贝
原型模式中的拷贝对象可以分为:“浅拷贝”和“深拷贝”。
浅拷贝:
1、当类的成员变量是基本数据类型时,浅拷贝会复制该属性的值赋值给新对象。2、当成员变量是引用数据类型时,浅拷贝复制的是引用数据类型的地址值。这种情况下,当拷贝出的某一个类修改了引用数据类型的成员变量后,会导致所有拷贝出的类都发生改变。
深拷贝:
2、深拷贝不仅会复制成员变量为基本数据类型的值,给新对象。还会给是引用数据类型的成员变量申请储存空间,并复制引用数据类型成员变量的对象。这样拷贝出的新对象就不怕修改了是引用数据类型的成员变量后,对其它拷贝出的对象造成影响了。

Golang字符串转数字

func Atoi(s string) (int, error) {
   const fnAtoi = "Atoi"

   sLen := len(s)
   if intSize == 32 && (0 < sLen && sLen < 10) ||
      intSize == 64 && (0 < sLen && sLen < 19) {
      // Fast path for small integers that fit int type.
      s0 := s
      if s[0] == '-' || s[0] == '+' {
         s = s[1:]
         if len(s) < 1 {
            return 0, &NumError{fnAtoi, s0, ErrSyntax}
         }
      }

      n := 0
      for _, ch := range []byte(s) {
         ch -= '0'
         if ch > 9 {
            return 0, &NumError{fnAtoi, s0, ErrSyntax}
         }
         n = n*10 + int(ch)
      }
      if s0[0] == '-' {
         n = -n
      }
      return n, nil
   }

   // Slow path for invalid, big, or underscored integers.
   i64, err := ParseInt(s, 10, 0)
   if nerr, ok := err.(*NumError); ok {
      nerr.Func = fnAtoi
   }
   return int(i64), err
}

golang的gc回收针对堆还是栈?变量内存分配在堆还是栈?

这里不讲垃圾回收的机制

先给出三个结论:
golang的垃圾回收是针对堆的(垃圾回收都是针对堆的,这里只是做一个简单的证明)
引用类型的全局变量内存分配在堆上,值类型的全局变量分配在栈上
局部变量内存分配可能在栈上也可能在堆上
堆和栈的简单说明:
1.栈(操作系统):由操作系统自动分配释放
2.堆(操作系统): 一般由程序员分配释放,例如在c/c++中,在golang,java,python有自动的垃圾回收机制

我们都知道变量占有内存,内存在底层分配上有堆和栈。

值类型变量的内存通常是在栈中分配
引用类型变量的内存通常在堆中分配
注意这里说的是"通常",因为变量又分为局部变量和全局变量。

当变量是全局变量时,符合上面所说的分配规则
但当变量是局部变量时,分配在堆和栈就变的不确定了
以下是摘录go圣经的一部分:

编译器会自动选择在栈上还是在堆上分配局部变量的存储空间,但可能令人惊讶的是,这个选择并不是由用var还是new声明变量的方式决定的。

var global *int

func f() {
    var x int
    x = 1
    global = &x
}

func g() {
    y := new(int)
    *y = 1
}

函数里的x变量必须在堆上分配,因为它在函数退出后依然可以通过包一级的global变量找到,虽然它是在函数内部定义的;用Go语言的术语说,这个x局部变量从函数f中逃逸了。相反,当g函数返回时,变量*y将是不可达的,也就是说可以马上被回收的。因此,y并没有从函数g中逃逸,编译器可以选择在栈上分配y的存储空间(译注:也可以选择在堆上分配,然后由Go语言的GC回收这个变量的内存空间),虽然这里用的是new方式。其实在任何时候,你并不需为了编写正确的代码而要考虑变量的逃逸行为,要记住的是,逃逸的变量需要额外分配内存,同时对性能的优化可能会产生细微的影响。

个人理解补充说明:go语言编译器会自动决定把一个变量放在栈还是放在堆,编译器会做逃逸分析(escape analysis),当发现变量的作用域没有跑出函数范围,就可以在栈上,反之则必须分配在堆。
可参考 Golang中的逃逸分析

原文链接:https://blog.csdn.net/csdniter/article/details/103617531

逃逸分析链接:https://blog.csdn.net/csdniter/article/details/114668837

人物视野

人物身上有两个双向链表,一个旧的可见链表,一个新的可见链表,每隔一个时间段比如0.5秒,把新链表赋给旧链表,然后根据视野范围和自身的位置在当前地图上搜索视野范围内的生物,每找到一个生物就去旧链表里寻找一遍,找到则删除旧链表中的节点,找不到则发送给客户端,找完旧链表之后统一加入新链表,然后把旧链表释放。
如何提高性能?
维护一个全局的闲置队列,旧链表释放的时候不是真的释放而是加入了闲置队列,这样新链表不需要重新创建节点,而是从闲置队列里面申请一个节点就可以了。
为什么两个链表?
单个链表无法满足条件,视野范围内一只怪消失的时候链表里的节点却没有释放。

地图

地图是一个一维数组,通过X轴和Y轴行列来计算出地图位置,加载地图资源,加载.map文件里的阻挡点,可通行的点,门之类的(数据流),把每一个元素作为一个结构体放在地图数组中,结构体中有一个链表,还有当前位置玩家数量之类的,当我们地图上出现一个生物的时候,找到对应坐标结构体的链表,加入其中。生物从其中一个格子移到另一个格子的时候就操作对应的链表。

地图管理器

地图管理器中有一个hash索引对应所有的地图,游戏主线程调用地图管理器run,地图管理器遍历hash调用每张地图的run,这样每张地图就能循环做相应的操作了,如检测地图超时踢人。

邮件系统

邮件系统单独开了一个线程,邮件记了两张数据库,一个单个玩家邮件数据库,一个全服系统邮件数据库。
为什么单独开个线程?
邮件系统有大量数据库操作需要处理,为了不影响游戏主线程性能,并实现多线程提高性能,单独开个线程execute处理数据库操作。
单人邮件在线则直接发,否则异步线程存入数据库,在玩家上线的时候启动一个数据库操作类,加入操作队列,邮件系统线程execute去操作队列中取节点执行,查找数据库中有没有该玩家的未发送邮件,创建一个回调类,把未发送邮件加入回调类中的list,把回调类加入统一处理回调类的队列中,邮件系统run函数从队列取节点进行回调发送邮件。
游戏主线程取队列,邮件线程存队列,如何解决操作队列取数据的时候加数据造成冲突?
存数据的时候给队列加锁,每次需要取数据的时候加锁,把队列赋给临时队列,创建一个新的队列继续执行原队列的其余操作,从临时队列取数据。
全服邮件发送方式:每个邮件都有一个邮件id,发全服邮件的时候给在线的玩家都发一份,并加入一个全服邮件队列,同时异步线程存入数据库,玩家每次收到一封邮件都要记录下玩家最后收到的邮件id,在玩家上线的时候通过id对比收取未收到的全服邮件,存入数据库是为了关服之后重启能够加载入全服邮件队列。
玩家邮箱满了怎么删除替换?
玩家已收到的邮件按时间顺序存放在玩家身上的队列中,按顺序优先删除已读无附件,不满足则删除无附件,不满足则删除已读,不满足则删除最早收到的邮件。

怪物寻人

普通的怪物类monster没有寻人,ATMonster继承了怪物类,run里面有个findtarg函数,会去当前视野链表(存储了所有视野范围内的生物)中遍历,和自身的坐标对比,找出距离自己最近且可以攻击的目标,将该目标设置为targCret,monster的run会不断调用攻击目标函数,如果有targCret并且在怪物的攻击范围内,那么就攻击,走伤害流程,如果目标不在攻击范围内,那么就把目标地点xy轴设置为怪物自身的目标地点xy轴。
同时,如果怪物的攻击动作失败了,那么就判断是否有攻击目标和目标地点xy轴,如果有,那么就GotoTargXY,GotoTargXY函数作用:根据自己当前坐标和目标坐标算出一个方向,调用walkto函数,walkto失败,随机改变一个方向继续走,walkto函数作用:根据方向和当前坐标轴计算出下一步的坐标轴,从地图数组链表中找到自己的位置,删除旧位置,添加到新的位置,并发送给客户端走路的协议和新的坐标轴。
每个怪物都要run,如何节省性能?
怪物有一个运行列表,只有在该列表当中的怪物才会运行run,当一张地图有玩家进入的时候会把该地图内的怪物加入运行列表中。
如何绕过障碍点?
遇到障碍点随机一个新的方向继续走,每走一步都去用最短路径算法动态计算目标位置太过消耗性能,而且怪物太智能对玩家体验不好,因此使用这种只计算出一个方向让怪物走过去的方法。

怪物掉落

怪物存在两种掉落,一个是单只怪的掉落(比如boss),一个是某一组怪物的掉落(比如说同一张地图内的统一掉落,某个等级范围内的精英怪组的掉落等等)。
游戏内有一个list存储了Monster表中配置的所有的怪物的怪物类,加载物品掉落的时候遍历该队列,根据怪物名字和文件夹路径拼成一个完整的文件路径,如果找到该文件,则代表这只怪物有单独掉落配置,加载解析。另有一张配置表MonItemConfig配置了怪物名字和掉落组的映射,如果根据怪物名字能找到对应的映射,则根据映射名字和文件夹路径拼成一个完整的文件路径,找到文件,加载解析。
解析掉落文件内的字符串格式(如1/2 物品名),将掉落的物品和对应的概率封装为一个类加入怪物类中的一个物品掉落list中,杀死怪物的时候去该list中随机物品的掉落。

怪物刷新

原本只有小怪刷新,后来新做了boss刷新机制。
之前提到的monster表配置的主要是单个怪物类型的属性,比如血量之类的,另有一张mongen表配置怪物的刷新规则(怪物需要刷新的地点,刷新间隔,在地图上最多出现的数量之类的)。
把配置中每一行怪物生成的规则封装为一个mongen类放入mongenarrey数组中,类中有一个数组monarray根据当前组的最大怪物数量存放怪物monster类的实例,一个变量记录下当前组中活着的怪物数量,每当我们杀死一只怪的时候,从数组中找到对应的类,把里面的存活怪物记录减一并在monarray中删除,MonSupport单元run遍历mongenarrey数组,如果发现某一个类中记录的存活怪物数量少于最大怪物数量,并且已经到了刷新时间间隔,那么就找到该类里对应的monarray数组,把怪造出来加入monarray数组。
monarray怪物数组是什么?
怪物数组存放着所有活着的怪物实例,怪物被杀了就从数组中删除,新造出来就加入对应的数组。
怪物刷新一次性刷出来太多会不会影响服务器性能,而且对玩家不太友好?
每次在找到对应的mongen类中的怪物数组的时候记一个变量表示新刷出来的怪物数量,用于控制该类怪物一次性刷出来的最大数量,防止GM指令之类的清怪后一次性造出大量怪。

技能

技能有一个基类,里面有技能等级,技能冷却时间等等统一的成员变量。当我们需要新做一个技能的时候写一个类去继承技能基类,根据需要重写获取技能伤害和使用技能两个函数。
人物身上有一个类,类里面有一个hash,存放所有人物当前学会的技能类。客户端点击使用技能的时候把技能id发过来,根据技能id去查找是否学习过这个技能,如果技能不在冷却时间内,则调用该技能类的使用技能函数,获取到当前的攻击对象,获取技能初始伤害,走伤害流程继续根据双方属性计算最终伤害。
伤害流程分两类,第一种为瞬间伤害,即发出技能的时候直接从被攻击方身上扣血,还有一种为延时伤害,就是把伤害和一个延迟时间封装为一个节点加入到被攻击生物身上的延时队列中,节点按生效时间排序插入,生物的run函数从延时队列中取数据生效。
为什么要分延时队列?
根据不同的业务需求,可能有些伤害需要释放一个特效(比如法师释放火球术,火球放出来飞过去也要时间),然后延迟零点几秒生效。
延时队列存取会不会临界区冲突?
不会,因为调用生物身上的方法存入延时队列,取数据也是调用生物身上的方法,这是个单线程。

好友关系

一个关系基类,里面有一个成员列表保存着有哪些玩家保持着当前关系,两个类继承基类,一个单向关系,一个双向关系,后序所有关系如好友,关注,拉黑都继承自这两个类,这三个类放在玩家身上,玩家上线的时候向DB发送请求数据请求,DB查询以后把数据发送给GS,GS收到数据之后填充入身上的类中的成员列表中,并发送给客户端。创建一个关系管理单元,里面包含一个全局的hash,存了所有关系申请的消息请求缓存,(key为被申请方,value为list),关系管理单元的run每隔一分钟循环hash,检测并删除无效的好友请求(比如申请方和被申请方都下线了,或者申请时间超时了)。当玩家申请好友的时候,先去检测是否已经是好友了,再去检测玩家是否达到了好友上限,再去申请缓存中检测是否已经提交过申请,检测是否已超出了最大申请数量,如果超出了,那么就删除最旧的申请把新的申请加入缓存,通过一系列检测后发送给客户端。
玩家点击通过好友申请后把消息加入到双方的消息队列中通知双方好友申请成功,并把数据加入自身的好友列表中,向DB发送更新数据库请求。
为什么通过好友要加入消息队列?
因为这些消息都不需要实时反馈给玩家,加好友之类的操作就算延迟个两三秒也不会给玩家造成用户体验上的损失,通过异步操作可以减少服务器性能消耗。
为什么要把玩家数据放到DB请求?
方便统一进行数据库操作,而且玩家好友关系什么的不需要一上线立刻就呈现给玩家,通过DB返回消息到GS的操作消息队列里面,GS延迟取数据发送给客户端的形式减轻服务器压力。
为什么好友请求不保存在本地?
因为是游戏,没有必要,如果是社交软件之类的可以保存到本地。
关系数据库表设计?
关系类型,A玩家ID,B玩家ID,创建时间。

行会

有一个全局行会hash,hash中存着行会类,行会类里有各种行会操作,还有个hash存着行会成员。
玩家创建行会,先检测是否满足等级,用户是否验证,行会名是否带有敏感词,去行会hash中查找本区行会是否有重复名,是否正在创建中等条件,把userid加入创建列表,异步线程向nameserver发送httpget请求,得到返回json字符串,解析字符串,确认没有和其他区的重名,创建行会类,存入行会hash中,添加会长为行会成员,异步保存进数据库,在创建列表中删除,发送给客户端创建成功消息。加入行会时先找到对应的行会hash,行会类中有一个申请hash,加入申请hash,通知客户端并异步保存入数据库,会长同意申请后加入行会。
run函数清除过期的行会申请。
行会创建是异步的,一个人只能创建一个行会,如何防止异步创建的时候他又点击创建行会?
创建的时候把userid加入创建列表,下次创建的时候检测到当前玩家正在创建行会中,那么就返回失败。

世界聊天如何实现的?

一个GS连接着多个网关,世界聊天就是给所有网关发送,网关发送给客户端,客户端再发送给对应的玩家。

行会广播如何实现?

遍历行会内在线的玩家,每个玩家对应一个gateidx,GS对应一个网关列表,每个网关内有一个数组,把所有在一组网关内的玩家加入对应网关对象的数组内,遍历网关列表,发送数据。

充值回调

大致流程为:点开充值服务,服务器生成订单号,发送给客户端并保存入数据库记为订单未完成状态,客户端将订单号发送给39,当玩家在39正式完成付费,39把订单号和订单信息发送给rechargeserver,rechargeserver发送给服务器,服务器去数据库校验并修改订单状态,然后发货。

游戏内充值

客户端向服务器发起充值请求,服务器校验充值信息是否合法,然后生成订单和订单号插入数据库,向客户端发送生成订单成功的消息和订单号,客户端将消息发送给充值平台,等待玩家付费后,充值平台将消息发送给rechargeserver,rechargeserver发送给服务器,服务器去数据库校验并修改订单状态,然后检查玩家是否在线,在线则直接发货,不在线则加入一个未发货列表,存入数据库,在玩家下次上线的时候读取并发货。

IDIP(公众号充值)流程

运营方把充值请求和信息发送给IDIP,IDIP收到以后先对比本地配置校验充值信息是否合法,然后生成订单存入数据库,此时订单状态为未完成,然后去更新订单为完成状态,IDIP返回告诉39充值成功的结果,并检查玩家是否在线,在线则直接发货,不在线则加入一个未发货列表,存入数据库,在玩家下次上线的时候读取并发货。

登陆流程

。。。

怎么给客户端分配GG?
客户端连接上LG后,给DB发送校验信息,DB维护着一个GG的连接队列,去队列里遍历,每个GG能承载300个用户,如果所有GG用户都满了,则表示服务器满员,否则把当前遍历到的GG的IP返回给LG,LG发送给客户端

GG进程保存所有的socket连接,可以开启多个GG进程连接DB进程,DB进程用GGList存放当前连接上的GG对象实例,每个GG对象实例里面有一个GGClients列表,里面保存着所有当前连接着的GGClient对象实例,这样我们为客户端具体分配哪个GG就可以去DB进程的GGList里查找。

通过记录sessionid来防止玩家进入游戏选角列表之前重复进入。

我开了多个GG进程,客户端Socket连接来的时候怎么知道连哪个GG?或者说客户端往GG进程发送CM_LoginSDK的时候怎么知道该往哪个GG发送呢?
从DB进程找到空闲的GG实例之后把对应的GG下标,IP,端口之类的发送给LG进程,LG进程发送给客户端,此时客户端才知道该连接哪个GG进程,才可以给对应的GG进程发送CM_LoginSDK协议。

数据存储

每个人物每隔5分钟存储一次数据,人物当前的数据做成一个序列化对象,GS把二进制序列化数据发送给DB,DB收到后反序列化,账号角色数据的管理类中存放着PTID的hash,每个账号节点下面都有一个角色链表,我们获取到角色节点,把新的数据更新进去(assign深拷贝),把该角色节点封装成一个SaveNode加入保存队列,专门负责存数据的线程异步去队列中取节点进行SQL保存。
GS有一个缓存队列,如果DB断开,则把数据存入缓存队列,当DB重连上后发送给DB,DB保存着所有数据的缓存,当玩家登录的时候把数据推送给GS

  • 2
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值