节点的历史和优化
关于节点的知识,我们首先应该了解其历史和优化过程
Skynet开源时间:2012年8月1日
文章时间:2012年8月6日
看看云风对Skynet节点最初的设计思路
引用自:云风的 BLOG: Skynet 集群及 RPC (codingnow.com)
先谈谈集群的设计
最终,我们希望整个 skynet 系统可以部署到多台物理机上。这样,单进程的 skynet 节点是不够满足需求的。我希望 skynet 单节点是围绕单进程运作的,这样服务间才可以以接近零成本的交换数据。这样,进程和进程间(通常部署到不同的物理机上)通讯就做成一个比较外围的设置就好了
为了定位方便,我希望整个系统里,所有服务节点都有唯一 id 。那么最简单的方案就是限制有限的机器数量、同时设置中心服务器来协调。我用 32bit 的 id 来标识 skynet 上的服务节点。其中高 8 位是机器标识,低 24 位是同一台机器上的服务节点 id 。我们用简单的判断算法就可以知道一个 id 是远程 id 还是本地 id (只需要比较高 8 位就可以了)
我设计了一台 master 中心服务器用来同步机器信息。把每个 skynet 进程上用于和其他机器通讯的部件称为 Harbor 。每个 skynet 进程有一个 harbor id 为 1 到 255 (保留 0 给系统内部用)。在每个 skynet 进程启动时,向 master 机器汇报自己的 harbor id 。一旦冲突,则禁止连入
master 服务其实就是一个简单的内存 key-value 数据库。数字 key 对应的 value 正是 harbor 的通讯地址。另外,支持了拥有全局名字的服务,也依靠 master 机器同步。比如,你可以从某台 skynet 节点注册一个叫 DATABASE 的服务,它只要将 DATABASE 和节点 id 的对应关系通知 master 机器,就可以依靠 master 机器同步给所有注册入网络的 skynet 节点
master 做的事情很简单,其实就是回应名字的查询,以及在更新名字后,同步给网络中所有的机器
skynet 节点,通过 master ,认识网络中所有其它 skynet 节点。它们相互一一建立单向通讯通道。也就是说,如果一共有 100 个 skynet 节点,在它们启动完毕后,会建立起 1 万条通讯通道
// 特别注意:这里每个节点在启动时向master报告自己的节点id,之后可以向master查询别的节点,但是会产生巨量的通讯通道
// 留个彩蛋:这里说支持全局名字的服务是什么?需要再看看skynet的命名系统的历史以及优化过程
文章时间:2012年9月3日
看看云风的Skynet设计综述中,关于集群部分的描述
引用自:云风的 BLOG: Skynet 设计综述 (codingnow.com)
2012年9月3号,Skynet节点最开始,最原始的设计
单个 skynet 进程内的服务数量被 handle 数量限制。handle 也就是每个服务的地址,在接口上看用的是一个 32 位整数。但实际上单个服务中 handle 的最终限制在 24bit 内,也就是 16M 个,高 8 位是保留给集群间通讯用的
我们最终允许 255 个 skynet 节点部署在不同的机器上协作。每个 skynet 节点有不同的 id ,这里被称为 harbor id 。这个是独立指定,人为管理分配的(也可以写一个中央服务协调分配)。每个消息包产生的时候,skynet 框架会把自己的 harbor id 编码到源地址的高 8 位。这样,系统内所有的服务模块,都有不同的地址了。从数字地址,可以轻易识别出,这个消息是远程消息,还是本地消息
这也是 skynet 核心层做的事情,核心并不解决远程数据交互的工作
集群间的通讯,是由一个独立的 harbor 服务来完成的。所有的消息包在发送时,skynet 识别出这是一个远程消息包时,都会把它转发到 harbor 服务内。harbor 服务会建立 tcp 连接到所有它认识的其它 skynet 节点内的 harbor 服务上
harbor 间通过单向的 tcp 连接管道传输数据,完成不同的 skynet 节点间的数据交换
// 注意:harbor的这种连接方式,会在每个节点之间产生大量的tcp连接,就是云风一开始时候的设计
// 可以根据历史,发现skynet在最开始的版本中,就是把集群设计为了master-harbor的形式
文章时间:2014年6月9日
接着云风进行了优化设计
引用自:云风的 BLOG: skynet 的集群方案 (codingnow.com)
在过去,skynet 的集群限制在 255 个节点,为每个服务的地址留出了 8bit 做节点号。消息传递根据节点号,通过节点间互联的 tcp 连接,被推送到那个 skynet 节点的 harbor 服务上,再进一步投递
这个方案可以隐藏两个 skynet 服务的位置,无论是在同一进程内还是分属不同机器上,都可以用唯一地址投递消息。但其实现比较简单,没有去考虑节点间的连接不稳定的情况。通常仅用于单台物理机承载能力不够,希望用多台硬件扩展处理能力的情况。这些机器也最好部署在同一台交换机下
之前这个方案弹性不够。如果一台机器挂掉,使用相同的节点 id 重新接入 skynet 的后果的不可预知的。因为之前在线的服务很难知道一个节点下的旧地址全部失效,新启动的进程的内部状态已经不可能和之前相同
更上层的 skynet api 重新实现了一套更具弹性的集群方案
和之前的方案不同,这次我不打算让集群间的通讯透明。如果你有一个消息是发放到集群内另一台机器中的某个服务的,需要用特别的集群消息投递 api 。节点本身用字符串名字,而不是 id 区格。集群间的消息用统一的序列化协议(为了简化协议)
如果使用这套方案,就可以不用老的多节点机制了(当然也可以混用)
为了简化配置,你可以将 skynet 配置为 harbor = 0 ,关闭老的多节点方案
这样,address standalone master 等配置项都不需要填写
取而代之的是,配置一个 cluster 项,指向一个 lua 文件,描述每个节点的名字和地址
新的 cluster 目前只支持一个 rpc call 方法,用来调用远程服务
api 和 skynet.call 类似,但需要给出远程节点的字符串名字,且通讯协议必须用 lua 类型
这套新方案可以看成是对原有集群的一个补充。当你需要把多台机器部署到不同机房,节点间的关系比较弱,只是少部分具名服务间需要做 rpc 调用,那么新的方案可能更加合适一些。因为当远程节点断开联系后,发起 rpc 的一方会捕获到异常;且远程节点用名字索引,不受 255 个限制。断开连接后,也可以通过重连恢复服务
// 此时新的集群方案就出来了
根据此文章,了解具体的实现:云风的 BLOG: skynet cluster 模块的设计与编码协议 (codingnow.com)
文章时间:2014年6月21日
这篇文件记录的是云风把之前一些对master的优化想法进行实现
没错,skynet现在存在旧方案master-slave 和 新方案cluster
引用自:云风的 BLOG: 重新设计并实现了 skynet 的 harbor 模块 (codingnow.com)
skynet 是可以启动多个节点,不同节点内的服务地址是相互唯一的。服务地址是一个 32bit 整数,同一进程内的地址的高 8bit 相同。这 8bit 区分了一个服务处于那个节点
每个节点中有一个特殊的服务叫做 harbor (港口) ,当一个消息的目的地址的高 8 位和本节点不同时,消息被投递到 harbor 服务中,它再通过 tcp 连接传输到目的节点的 harbor 服务中
不同的 skynet 节点的 harbor 间是如何建立起网络的呢?这依赖一个叫做 master 的服务。这个 master 服务可以单独为一个进程,也可以附属在某一个 skynet 节点内部(默认配置)
master 会监听一个端口(在 config 里配置为 standalone 项),每个 skynet 节点都会根据 config 中的 master 项去连接 master 。master 再安排不同的 harbor 服务间相互建立连接,图形见原文
旧方案master-slave存在的问题:
当初为了简化设计,每两台机器间的连接使用了两条 TCP 连接。数据流在每条连接上都是单向的,即谁发起连接,谁就在这个连接上单向推送数据。这样做的好处是,如果双方都是可信的机器的话,可以省去握手的协议。如果采用一条连接,双工使用,势必需要在接受连接时询问对方是谁。组网代码的复杂度就高了许多。但是,两条连接的问题也很明显。当我可以向对方发送数据成功后,对方未必反向连接成功。就需要做更复杂的状态管理。当然,一旦组网成功,就没有太大区别了
最后优化为:
节点间不再需要两条连接,而只用一条。每个节点加入网络(首先接入 master)后,由 master 通知它网络中已有几个节点,他会等待所有现存节点连接过来。所以连接建立后,就关闭监听端口。如果再有新节点加入网络,老节点主动去连接新节点。这样做的好处是,已经在工作的节点不需要打开端口等待
2种方案的比较:
对于松散的集群结构,我推荐使用 skynet 的单结点模式,在上层用 tcp 连接互连,并只使用简单的 rpc 协议。在目前的 skynet 版本中,有封装好的 cluster 模块]可供使用。
这种做法要求明确本地服务的调用和远程调用的区别。虽然远程调用的性能可能略低,但由于不像底层 harbor 那样把本地、远程服务的区别透明化,反倒不容易出问题。且 tcp 连接使用了更健壮的 socketchannel ,一旦连接断开,发起 rpc 的一方会收到异常,也可以重试(自动重连)。
而底层的 harbor 假设机器间是可靠连接,不会断开。而一旦内部网络不健康,很可能会导致整个系统无法正常工作。它的设计目的并不是为了提供弹性扩展的分布式方案,而是为了突破单机性能上限的问题。
两个跨机方案各有利弊,所以还请设计系统的时候权衡。只使用其中一个方案或是两个同时用,应该都有适用的场合
时间:现在
我们看看目前Skynet中集群的设计
引用自:GettingStarted · cloudwu/skynet Wiki (github.com) 中的 cluster
skynet 在最开始设计的时候,是希望把集群管理做成底层特性的。所以,每个服务的地址预留了 8bit 作为集群节点编号。最多 255 台机器可以组成一个集群,不同节点下的服务可以像同一节点进程内部那样自由的传递消息
随着 skynet 的演进和实际项目的实践,发现其实把节点间的消息传播透明化,抹平节点间和节点进程内的消息传播的区别并不是一个好主意。在同一进程内,我们可以认为服务以及服务间的通讯都是可靠的,如果自身工作所处的硬件环境正常,那么对方也一定是正常的。而当服务部署在不同进程(不同机器)上时,不可能保证完全可靠。另外一些在同一进程内可以共享访问的内存(skynet 提供的共享数据模块就基于此)也变得不可共享,这些差异无法完全被开发者忽视
所以,虽然 skynet 可以被配置为多节点模式,但不推荐使用
目前推荐把不同的 skynet 服务当作外部服务来对待,skynet 发布版中提供了 cluster 模块来简化开发
重点阅读这篇文章,关于Skynet集群设计的方案
引用自:Cluster · cloudwu/skynet Wiki (github.com)
master/slave 模式
在 master/slave 模式中,节点内的消息通讯和节点间的通讯是透明的。skynet 核心会根据目的地址的 harbor id 来决定是直接投递消息,还是把消息转发给 harbor 服务。但是,两种方式的成本大为不同(可靠性也有所区别),在设计你的系统构架时,应充分考虑两者的性能差异,不应视为相同的行为
这种模式的缺点也非常明显:它被设计为对单台物理机计算能力不足情况下的补充。所以忽略了系统一部分故障的处理机制,而把整个网络视为一体。即,整个网络中任意一个节点都必须正常工作,节点间的联系也不可断开。这就好比你一台物理机上如果插了多块 CPU ,任意一个损坏都会导致整台机器不能正常工作一样
所以,不要把这个模式用于跨机房的组网。所有 slave 节点都应该在同一局域网内(最好在同一交换机下)。不应该把系统设计成可以任意上线或下线 slave 的模式
slave 的组网机制也限制了这一点。如果一个 slave 意外退出网络,这个 harbor id 就被废弃,不可再使用。这样是为了防止网络中其它服务还持有这个断开的 slave 上的服务地址;而一个新的进程以相同的 harbor id 接入时,是无法保证旧地址和新地址不重复的
cluster 模式
skynet 提供了更具弹性的 cluster 集群方案,它可以和 master/slave 共存。也就是说,你可以部署多组 master/slave 网络,然后再用 cluster 将它们联系起来。当然,比较简单的结构是,每个集群中每个节点都配置为单节点模式(将 harbor id 设置为 0)
参看具体实现:云风的 BLOG: skynet cluster 模块的设计与编码协议 (codingnow.com)
cluster需要注意的问题
1,cluster 间的消息次序:
另外一个cluster的消息顺序异常问题 · Issue #587 · cloudwu/skynet (github.com)
cluster.send调用乱序 · Issue #757 · cloudwu/skynet (github.com)
2,cluster 配置更新问题
3,cluster 使用了新的命名系统
详细见:Cluster · cloudwu/skynet Wiki (github.com)
一些优化
在这里不继续探讨集群master-slave 和 cluster的实现了,现在我们关心harbor节点建立的实现
节点的本质
一个harbor节点,本质就是一个Skynet进程
我们可以看看除了main服务,没有任何多余服务启动后,Skynet节点的打印日志
[root@localhost skynet]# ./skynet ./examples/config [:00000002] LAUNCH snlua bootstrap – 第二位启动的服务 [:00000003] LAUNCH snlua launcher [:00000004] LAUNCH snlua cdummy [:00000005] LAUNCH harbor 0 4 [:00000006] LAUNCH snlua datacenterd [:00000007] LAUNCH snlua service_mgr [:00000008] LAUNCH snlua main [:00000002] KILL self
在skynet_start.c文件中,第271行,skynet_harbor_init(config->harbor) 被调用
static unsigned int HARBOR = ~0; // 全局声明
void
skynet_harbor_init(int harbor) {
// 把配置config中的 harbor 向左移动24位,harbor作为服务的节点标记
// 参看《Skynet源码之:服务管理》中,skynet_handle_register函数,当中也有对服务handle中harbor的操作
HARBOR = (unsigned int)harbor << HANDLE_REMOTE_SHIFT; // HANDLE_REMOTE_SHIFT == 24
}
至此,在启动阶段节点的初始化就完成,留下一个 HARBOR 值
后面的内容是等bootstrap启动之后,才会触发的
节点的启动
其实更重要的是 skynet_harbor_send() 函数
void
skynet_harbor_send(struct remote_message *rmsg, uint32_t source, int session) {
assert(invalid_type(rmsg->type) && REMOTE);
skynet_context_send(REMOTE, rmsg, sizeof(*rmsg) , source, PTYPE_SYSTEM , session); // 把这个消息发往外部节点
}
当然,在发送前需要先确定这个消息是不是外部消息
int
skynet_harbor_message_isremote(uint32_t handle) {
assert(HARBOR != ~0);
int h = (handle & ~HANDLE_MASK);
return h != HARBOR && h != 0;
}
现在问题来了:REMOTE 这个值是如何确定的呢?
static struct skynet_context * REMOTE = 0; // 全局声明一个服务
// 在skynet_harbor_start()被赋值
void
skynet_harbor_start(void *ctx) {
// the HARBOR must be reserved to ensure the pointer is valid.
// It will be released at last by calling skynet_harbor_exit
skynet_context_reserve(ctx);
REMOTE = ctx; // REMOTE被赋值
}
所以在哪里调用了skynet_harbor_start()呢?
这就不得不说Skynet的4大服务模块:harbor.so,详细请看《Skynet源码之:service_harbor》
在skynet/service-src/service_harbor.c文件下,也就是harbor服务的实现
int
harbor_init(struct harbor *h, struct skynet_context *ctx, const char * args) {
h->ctx = ctx;
int harbor_id = 0;
uint32_t slave = 0;
sscanf(args,"%d %u", &harbor_id, &slave);
if (slave == 0) {
return 1;
}
h->id = harbor_id;
h->slave = slave;
if (harbor_id == 0) {
close_all_remotes(h);
}
skynet_callback(ctx, h, mainloop);
skynet_harbor_start(ctx); // 最终在这里调用 skynet_harbor_start()
return 0;
}
那么又是从哪里开始调用 harbor 服务的呢?
我们一路追寻下去,在bootstrap服务中会发现:
// 1,bootstrap服务 --> cdummy服务 --> harbor服务
// 如果配置cluster模式,即配置harbor = 0,不配置standalone,master,address的值
// 在skynet第二个启动的bootstrap服务中,第一位是log服务
// 除了第三个启动 launcher 服务外,还启动了 cdummy 服务
// 而 cdummy 服务就会启动 harbor
harbor_service = assert(skynet.launch("harbor", harbor_id, skynet.self()))
// 2,bootstrap服务 --> cslave服务 --> harbor服务
// 如果配置master-slave模式,即配置harbor = 1,standalone,master,address也有值
// 在启动的cslave服务中,也会启动harbor服务
harbor_service = assert(skynet.launch("harbor", harbor_id, skynet.self()))
// 现在 REMOTE 的值也明显了
// REMOTE 的值就是刚启动的harbor服务
节点发送消息
现在接着看看谁调用了 skynet_harbor_send() 来发送远程消息
在skynet/skynet-src/skynet_server.c文件下
int
skynet_send(struct skynet_context * context, uint32_t source, uint32_t destination, int type, int session, void * data, size_t sz) {
// ...代码省略...
// 检查消息是不是发给远程的
if (skynet_harbor_message_isremote(destination)) { // 如果是发给远程的,需要构造新的remote_message结构
// 结构体 remote_message 在 skynet_harbor.h 中定义
struct remote_message * rmsg = skynet_malloc(sizeof(*rmsg));
rmsg->destination.handle = destination;
rmsg->message = data;
rmsg->sz = sz & MESSAGE_TYPE_MASK;
rmsg->type = sz >> MESSAGE_TYPE_SHIFT;
skynet_harbor_send(rmsg, source, session);
} else { // 发给本地的消息处理
// ...代码省略...
}
}
// 最后等于说:如果检查到这个消息是发给外部节点的,就会经过struct remote_message包装后
// 通过skynet_harbor_send()发给 harbor 服务
// 最后由 harbor 服务统一发送消息给外部节点
此篇文章主要关注的是:Skynet启动时,节点是怎么初始化的
具体harbor的实现,功能等,以及为什么cluster模式 和 master-slave模式都需要开启harbor服务,详细请看《Skynet源码之:service_harbor》
节点总结
第一,节点的配置:
harbor = 0,不配置standalone,master,address的值,也就意味着启动 cluster 集群模式
第二,bootstrap做了什么
skynet.start(function()
local standalone = skynet.getenv "standalone" -- 获取 standalone 的值,但是我们没有配置,会返回 nil
-- launcher 服务的内容,详细请看《Skynet源码之:service_snlua》中第7点
local launcher = assert(skynet.launch("snlua","launcher")) -- 启动 launcher 服务
skynet.name(".launcher", launcher) -- 命名 launcher 服务为 ".launcher"
local harbor_id = tonumber(skynet.getenv "harbor" or 0) -- 获取 harbor 的值,如果没有就默认为 0
-- 下面我们主要关注 harbor 为 0 的情况
if harbor_id == 0 then
-- 确定 standalone 为 nil 之后,把 standalone 设置为 true
-- 作用是:需要启动 datacenterd 服务,待会后面可以看到
assert(standalone == nil)
standalone = true
skynet.setenv("standalone", "true")
local ok, slave = pcall(skynet.newservice, "cdummy") -- 这里启动了 cdummy 服务,让我们看看代码段1,详细了解
if not ok then
skynet.abort()
end
skynet.name(".cslave", slave) -- 这里就给 slave 服务进行了命名,让我们看看代码段2,详细了解
else
-- 我们先不关心 master-slave 模式
end
-- 可以看到:
-- 假如你是命名 ".cslave" 这种本地名字,skynet.name() 会给 服务管理handle_storage 发送消息进行本地节点服务-名字的绑定
-- 假如你是命名 "DATACENTER" 这种全局名字,skynet.name() 会给 ".cslave" 发送消息进行服务-名字的绑定
-- 而所谓的 ".cslave" 其实就是 cdummy 服务
--
-- 即开启 cdummy 服务,在本地节点命名为 .cslave,因为本地命名是通过 服务管理handle_storage 实现的
-- 当 cdummy 服务启动并被命名为 .cslave,可以注册全局名字,此时会向 cdummy 服务发送信息进行绑定
if standalone then
local datacenter = skynet.newservice "datacenterd"
skynet.name("DATACENTER", datacenter)
end
skynet.newservice "service_mgr"
-- ...代码省略...
end)
代码段1
-- cdummy.lua 文件中的部分代码
skynet.start(function()
local harbor_id = tonumber(skynet.getenv "harbor") -- 获取 harbor 的值
assert(harbor_id == 0) -- 检查 harbor 的值,确保是 0
skynet.dispatch("lua", function (session,source,command,...)
local f = assert(harbor[command])
f(...)
end)
skynet.dispatch("text", function(session,source,command)
-- ignore all the command
end)
harbor_service = assert(skynet.launch("harbor", harbor_id, skynet.self())) -- 在这里开启 harbor 服务
end)
-- 下面我们看下 cdummy 服务有什么命令
-- 绑定 名字-服务handle 的关系,即注册全局服务名字
function harbor.REGISTER(name, handle)
end
-- 根据 名字 查询服务handle
function harbor.QUERYNAME(name)
end
function harbor.LINK(id)
end
function harbor.CONNECT(id)
-- skynet.error("Can't connect to other harbor in single node mode")
end
代码段2
-- manager.lua 文件中的部分代码
function skynet.name(name, handle)
if not globalname(name, handle) then -- 判断是不是注册全局名字
c.command("NAME", name .. " " .. skynet.address(handle)) -- 进行本地名字注册
end
end
-- 全局名字注册 的判断函数
local function globalname(name, handle)
local c = string.sub(name,1,1) -- 抓取 name 的第一个字符
assert(c ~= ':') -- 如果是 : 开头,则直接报错
if c == '.' then -- 如果是 . 开头,表示要注册的是本地节点名字,返回false
return false
end
-- 下面就是全局名字的注册流程
assert(#name < 16) -- GLOBALNAME_LENGTH is 16, defined in skynet_harbor.h
assert(tonumber(name) == nil) -- global name can't be number
local harbor = require "skynet.harbor"
harbor.globalname(name, handle) -- 给 harbor 服务发消息,要注册全局名字
return true
end
-- 注册全局名字
-- skynet/lualib/skynet/harbor.lua的部分代码
function harbor.globalname(name, handle)
handle = handle or skynet.self()
skynet.send(".cslave", "lua", "REGISTER", name, handle)
end
-- 本地名字和全局名字的区别
--
-- 本地名字,就是发送 .name 和 handle 给服务管理模块,在handle_storage中绑定本地服务id和名字的关系
-- 全局服务,就是发送 NAME 和 handle 给服务cslave,服务cslave本质就是cdummy.lua,实质就是harbor c服务
--
-- 当别的节点发送消息过来,或者,要往别的节点发送消息,就需要通过harbor服务来实现
-- 特别注意,按流程:
-- local ok, slave = pcall(skynet.newservice, "cdummy")
-- skynet.name(".cslave", slave)
-- globalname(name, handle)
-- 而 globalname(name, handle) 最终又会调用:
-- skynet.send(".cslave", "lua", "REGISTER", name, handle)
--
-- 这就要求:一定要先完成 skynet.name(".cslave", slave) 本地服务的注册,才能根据本地服务名字来使用.cslave服务
-- 像后面的 skynet.name("DATACENTER", datacenter) 就是在 skynet.name(".cslave", slave) 完成之后的
-- 显然,一开始的 globalname(name, handle) 即 globalname(".cslave", handle) 会返回 false
-- 此时进入 c.command("NAME", name .. " " .. skynet.address(handle)) 的代码
-- 详细看代码段3
代码段3
// 最终会调用到 c 层面的函数
static const char *
cmd_name(struct skynet_context * context, const char * param) {
int size = strlen(param);
char name[size+1];
char handle[size+1];
sscanf(param,"%s %s", name, handle);
if (handle[0] != ':') {
return NULL;
}
uint32_t handle_id = strtoul(handle+1, NULL, 16);
if (handle_id == 0) {
return NULL;
}
if (name[0] == '.') {
return skynet_handle_namehandle(handle_id, name + 1); // 调用名字-handle绑定的函数,详细请看《Skynet源码之:服务管理》
} else {
skynet_error(context, "Can't set global name %s in C", name);
}
return NULL;
}
对于节点的建立,关涉到cluster集群、harbor的启动、消息的处理流程,比较复杂
可以先暂缓理解,先学习《Skynet源码之:service_harbor》、《Skynet源码之:服务实现》等再来综合理解