Skip to content

Latest commit

 

History

History
160 lines (118 loc) · 9.11 KB

1.整体架构设计.md

File metadata and controls

160 lines (118 loc) · 9.11 KB

luacluster整体架构设计

一,整体架构设计

luacluster整体架构非常的简洁。node对应进程, docker对应线程,每个docker有一个luavm和一个消息队列,在luavm里创建entity对象。net网络和log日志分别单独线程运行。没有任何多余累赘的东西非常简洁明了。

luacluster的设计目标是让任何entity之间,可以通过rpc的方式无脑异步调用。所谓rpc的方式就是在任何entity中使用entity的id,函数名,参数就可以异步调用任何entity。

luacluster是一个分布式和并行的系统。分布式代表着多进程,并行代表着多多线程。也就是说luacluster要想实现一个穿透进程和线程阻隔。能够让任意的entity之间像调用普通系统接口一样互相调用。哪么luacluster必然是一个异步的系统。也就是说任何在luacluster的entity之间的功能调用都是异步的。

例如在bigworld对象中通知entity进入sudoku空间的部分

local entityProxy = udpproxy.New(id)
entityProxy:OnEntryWorld(self.spaceType, self.beginx, self.beginz, self.endx, self.endz)

就是通过entity id创建一个远程对象代理。然后通过远程对象代理调用account的OnEntryWorld函数。如果entity是在当前进程内,就会查找docker id并投递到指定消息队列。如果在其他node就会使用udp协议发送过去。通过id就能找到对应entity的诀窍,是因为把ip地址,docker id,entity id都塞进了这个unint64里。你可以在entityid.h中找到entity id的定义。

typedef struct _EID {
	unsigned short id;
	unsigned int addr;//ipv4
	unsigned char dock;
	unsigned char port;//UDP端口号的偏移
}*PEID, EID;

typedef union idl64
{
	volatile EID eid;
	volatile unsigned long long u;
	volatile double d;
} idl64;

这样我们就实现了一个非常惊人和高效的异步通信系统。任意进程和线程中的对象通信只要最多2步就可以完成。找到upd端口发过去,找到线程队列发过去。在任意环境下只要拿到entity id就可以快速知道封包的目的地。**实现在分布式网络内的任何对象之间像普通函数调用一样的调用。**什么网络编程,什么多线编程可以统统见鬼去了。

二,关于消息风暴的问题。

万人同屏顾名思义要做服务器上处理1万个玩家的位置同步问题。1万个玩家的位置同步每次要产生1亿个消息。1万乘1万产生1亿个消息。请记住1亿这个数字后面我们要反复提及到。

首先我们先分析1亿个消息的产生流程。服务器会收到1万个客户端发起移动的请求。1万个请求是没有问题的,现在服务器处理10万个链接问题都不大。所以这1万个请求一般的服务器压力都不大。问题是这1万个请求,每个请求要产生1万个新的请求发送给其他的玩家。这样服务器就扛不住了。一下产生了1个亿的io需求,哪种消息队列都扛不住,直接mutex就锁死了。

所以我使用了CreateMsgList接口创建了一个消息list。哪么io请求就转变为插入1万个list的操作。然后将这个list和消息队列合并在一起。这样就1亿个io请求变为1万个io请求,io请求一下就压缩了1万倍。同样的道理我们也可以把发送给给客户端的封包进行压缩。处理1亿个封包请求很难但处理1万个封包难度就低很多了。下面是封包压缩的部分代码。

void DockerSendToClient(void* pVoid, unsigned long long did, unsigned long long pid, const char* pc, size_t s) {

	if (pVoid == 0)return;
	PDockerHandle pDockerHandle = pVoid;
	idl64 eid;
	eid.u = pid;
	unsigned int addr = eid.eid.addr;
	unsigned char port = ~eid.eid.port;
	unsigned char docker = eid.eid.dock;

	unsigned int len = sizeof(ProtoRoute) + s;
	PProtoHead pProtoHead = malloc(len);
	if (pProtoHead == 0)
		return;
...
    
    sds pbuf = dictGetVal(entryCurrent);
    size_t l = sdslen(pbuf) + len;
    if (l > pDocksHandle->packetSize) {
        DockerSendPacket(pid, pbuf);
        sdsclear(pbuf);
    }
    if (sdsavail(pbuf) < len) {
        pbuf = sdsMakeRoomFor(pbuf, len);

        if (pbuf == NULL) {
            n_error("DockerSendToClient2 sdsMakeRoomFor is null");
            return;
        }

        dictSetVal(pDockerHandle->entitiesCache, entryCurrent, pbuf);
    }
    memcpy(pbuf + sdslen(pbuf), pProtoHead, len);
    sdsIncrLen(pbuf, (int)len);

    pDockerHandle->stat_packet_count++;

当然封包压缩并不能解决我们的所有问题。处理1亿个请求并压缩的挑战也极为艰巨。在128核的服务器上,每个核心每秒钟只能处理20万次请求。每个lua操作都是对hashmap的操作,hashmap插入操作大约是20万次每秒。而1亿个请求需要78万次的处理能力。1亿个请求在128核上需要大概4秒钟以上才能处理完成。或者...每秒钟只处理20%的玩家。也就是说我们只能保证每秒钟处理20%的玩家请求。这样就要祭出我们另一个大杀器“状态同步”。我们要把玩家的移动描述成一段时间的状态。有位置,方向,速度,开始时间,停止时间的完整状态。这样在每个客户端就可以根据这些信息,推断出玩家移动的正确状态。

#MoveTo(x, y, z)功能中同步玩家状态的部分代码
local list = docker.CreateMsgList()
​    for k, v1 in pairs(self.entities) dolocal v = entitymng.EntityDataGet(k)
​      if v ~= nil thenlocal view = udpproxylist.New(v[1], list)
​        view:OnMove(self.id, self.transform.position.x, self.transform.position.y,self.transform.position.z
​        ,self.transform.rotation.x, self.transform.rotation.y, self.transform.rotation.z, self.transform.velocity
​        , self.transform.stamp, self.transform.stampStop)
​      endenddocker.PushAllMsgList(list)
​    docker.DestoryMsgList(list)

这样我们就能保证在任意状态下只有小于20%的玩家请求需要处理。但不要乐观虽然可以解决移动的问题。但当新玩家进入场景时,还必然要同步所有玩家的数据并把新玩家的数据同步给其他玩家。如果场景内有5千个玩家,再放入5千个玩家。两边玩家需要同步的数据是“5千 * 5千+5千 * 5千”一共5千万。虽然比1亿要少一半但前面分析过128核服务器只能处理2560万。“我们可以上258核心服务器”,“哦对对对”。

当然不用上256核心服务器了。我们可以换一个方式,就是每次只放进去500人。这样需要同步的数据最多就变成500 * 1万 + 1万 * 500,1000万次。这样就能满足我们的硬件需求了。

三,简单的hello world

luacluster的并行编程也是基于并行化的actor模式。设计思路和bigworld保持一致。但有两个方面和bigworld完全不同。

  1. 彻底去掉了def文件。依据就是lua本身就是脚本语言,完全可以自己定义自己。没有必要再用xml把接口重新定义一遍。极大的减轻了开发负担。
  2. 不再提供cell服务器。空间服务只提供用户可见一个功能。当用户注册到空间或移动时导致两个用户可见时。空间服务会发送进入视线广播。用户entity收到广播后会自己进行后续操作,空间服务不再参与。用户会定期整理可见范围内的其他用户。对离开视线后又超过一定时间的用户从可见列表删除。
  3. 提供原生的多线程功能,可以减少通信提高并行效率。
  4. 提供自动序列化和反序列化功能,让面向对象的rpc调用更简单。

那么下面我就用一段简单的代码来说明luacluster的并行编程。

  1. 首先声明AB两个类。A的初始化部分创建B并将自己的id赋值到B的属性AEntity上。
  2. B类创建成功后调用自己的Pong函数。Pong函数使用AEntity调用Ping形成死循环。
  3. 最后在配置文件中将AClass注册为全局对象,全局对象会在集群启动时被创建的,在集群内唯一对象。
---@class AClass
local AClass= class()
function AClass:Init()
	entitymng.EntityToCreate(sc.entity.DockerRandom, "BClass",{AEntity=self.id}) --创建对象B
end

function Ping(id)
	local bEntity = udpproxy(id) --依据id创建B对象通信代理
	bEntity.Pong() --调用对象方法Pong()
end
---@class BClass
local BClass= class()
function BClass:Init()
	BClass:Pong() --初始化时调用Pong触发调用循环
end

function BClass:Pong()
	local aEntity = udpproxy(self.AEntity) --依据id创建A对象的通信代理
	aEntity:Pong(self.id) --调用A对象的方法Pong
end
---sc.lua
sc.cluster.serves = {"AClass"} --注册A对象

我们来最后总结一下luacluster的并行编程。接受方通过创建AEntity实现接受远程调用。并将自己的id传递给BEntity。BEntity通过udpproxy创建代理对象后可以直接调用AEntity的对象。BEntity为什么通过id就可以找到AEntity呢?其实很简单这个uint64位的id包含了ip,port,线程id和对象id。所以在集群内任何地方只要拿到这个id就可以给对象发送调用。也可以把这个id放入redis中通过名字查询,相当于集群的DNS服务系统。