再谈分布式服务架构

在两年前,我曾经设计过一版,高可伸缩服务器架构, 但只进行了理论推演,并没有使用具体业务逻辑验证过。以这两年的经验来看,这个架构不具备可实施性。

在之前的架构中,我只考虑了Gate和其服务之间的交互。没有考虑其他服务之间也会有基于"玩家"的交互,还可能有基于"联盟"等其他对象的交互。

按这套模型继续演化下去,为了解决"任意两服务"之间的交互问题,势必需要所有服务都有一份相同的Agent对象。这样一来,整个架构的复杂性就大大提升了。

这两年里,我一直在思考,游戏服务器和WEB服务器最本质的区别是什么?为什么WEB可以很轻松的做伸缩, 而游戏服务器想要做对就很难。

现在,我想我有了答案。

WEB的经典模式其实是"逻辑"服务和"数据"服务分离。

每一次WEB请求都是一次全新的上下文来处理。如果需要延续之前的状态,就从"数据"服务获取之前的状态之后再处理。

而由于"逻辑"没有状态,因此几乎可以无限扩展。

如果事情到这里就完美解决,这个世界就不会有“理想情况”和“现实情况”之分了。

这个世界是平衡的,看似WEB可以轻松获取高可伸缩的能力,但其实高可伸缩的压力全落到了"数据"服务(大部分情况下,数据服务都是由数据库来完成)。

由于可能会有任意多个"逻辑"服务,同时访问和修改同一个数据。为了解决并发问题,数据库需要提供ACID功能。

当数据量和读写并发量上来以后,数据库就需要进行"分片"来提供可伸缩性。然而,数据库分片之后,ACID的功能就大打折扣。分布式情况下ACID的难度要远高于单机ACID, 而且性能也得不到保证。这也是为什么后来会有很多NOSQL的出现。NOSQL其实更应该叫NO ACID才对。

为了尽可能的不分片,人们总是倾向于先极限压榨"单机""数据"服务的吞吐量,比如设计一主多从机制来改善查询压力(大部分情况下,你看到网页过3秒跳转的,都是利用了这种机制。主机和从机之间的同步一般在1s以内,延迟3秒就是为了能确保在从机上查询出刚刚在主机上写入的数据)。甚至还会在数据库之前再加Redis或Memcached等缓存系统来改善数据库的压力,一般这种缓存数据服务都是NOSQL,其目的就是为了避免ACID和硬盘数据结构所带来的性能影响

因此可以得出一个结论,其实WEB的可伸缩性的限制并不是不存在,而是主要落在"数据"端。

而"数据"端的可伸缩设计,由于各种现实环境的掣肘,即使是到了现在,也依然没有一个银弹方案。可以认为依然是一地鸡毛。

而游戏服务由于性能原因,不太可能像WEB的工作流程那样,每一次对数据的查询和修改都直接穿透到"数据"服务。

大部分常见的做法是,在服务器启动和玩家登陆时,加载必要的数据到"逻辑"进程的内存中。

在接收到请求之后,直接在内存中处理相关数据,然后在合适的时间节点(比如每个请求处理之后,每个定时器事件处理之后,每隔一段时间之后)对脏数据进行落地。

这么比较下来,其实我们的游戏服务,更像是WEB的"数据"服务而不是"逻辑"服务。而从WEB"数据"服务的可伸缩设计历史来看,游戏服务的高可伸缩性也并不是这么容易就可以实现的。


由于我们需要操作的必要数据都已经在内存中了,我们也不可能再借助一主多从或redis/memcached等机制提升性能了。

我们可以提升性能的惟一手段就是伸缩,而伸缩性的惟一的手段就是“分片”(把玩家的数据,全服数据分摊到不同的游戏服务中)。

然而,由于现代分布式数据库领域都还没有特别完美的ACID解决方案。我们的游戏服务之间的交互更不可能提供ACID功能了。

幸运的是,这个世界是平衡的。在我们需要极高性能的同时,对错误的容忍度也相应提高了。

在此之前,先定义两个术语:

"错误" -> 是指产生了不可预料/推理的结果,比如并发过程中,两个线程同时对一个变量进行自增(没有使用原子操作指令)。这种结果是无法预料了,就算出了错了,你也很难推理出正确结果。

"异常" -> 是指产生了一些异常的,但是可推理出正确结果的操作。比如两个线程同时去对一个变量自增, 一个线程在自增前对此变量加锁,在锁定过程中,另一个线程尝试获取锁(非阻塞),如果获取失败,他直接打了一行log即算完成。在这种情况下,即使变量的最终值是不正确的,但是我们借助log是可以还原出这个变量最终值的。

我认为在游戏服务器的分布式领域中,我们只要阻止错误的发生就可以了。至于异常是避免不了的(比如超时)。

基于这个原则和我两年前的架构设计,我重新抽象了整个分布式架构

在这次的设计中,我不再为玩家设计Agent。而是为每一个服务设计Agent.

当一个服务SA会被其它服务调用时,SA需要提供一段Agent代码来暴露他的能力。

依赖SA的服务仅通过这段Agent代码来间接与SA交互。

当然SA可能会被水平扩展多份,但是具体这次请求会被路由到哪一个SA的实例上,则由相应的Agent代码来决定,这样从调用方的角度来看,就像集群中永远只有一个SA实例存在一样。

这次的抽象和上次一样,我并不企图抹平分布式的事实,仅仅只是为了抹平同一个服务会被水平布署多份的事实。


在设计完这个抽象之后,一个自然而然的事实,摆在我面前。

假如,有三个服务A,B,C。每一个服务都有一段对应的Agent代码agent.A, agent.B, agent.C。

因此,在每个服务中,每一份Agent代码都各有一份实例:
A::agent.B,A::agent.C
B::agent.C,B::agent.A,
C::agent.A,C::agent.B

我们需要保证所有的X::agent.A,X::agent.B, X::agent.C实例中的对相应服务的连接是一致的。

讲道理,用配置文件是最简单,也是最可靠的,但是服务间的启动依赖需要自己实现代码来管理。

索性将服务发现一起实现了吧(坏处就是,即然是服务发现,就意味着节点在挂掉和重启之间,是有可能进行地址变更的)。

在WEB领域,一般服务发现典型的做法是,使用etcd或zookeeper。

但是对照一下上面对WEB的概述就会发现,他们的服务发现,一般是用于"逻辑"的服务发现,而不是"数据"的服务发现。

我们的游戏服务与WEB的"数据"服务相似。每一个实例都负责惟一的一份数据,而且我们游戏服务并没并有提供ACID。

试想一下,如果我们用’etcd’来做服务发现会发生什么情况。

我们有一个role_1服务负责处理uid: 1000-2000的玩家数据,由于某种原因,它失联了。我们认为他挂了,又启动了一个实例role_1_new来负责处理uid: 1000-2000的玩家数据。但其实role_1只是与集群中的部分节点失联而已。这时就会出现,role_1和role_1_new同时操作着1000-2000的玩家数据, 这种就属于"错误"而不是"异常"。因为这种操作不可推理,也不可逆。

一旦出现这种情况后,我们的期望应该是"异常"而不是"错误"。


为了解决这个问题,我们必须自己设计“服务发现”机制。

在这次设计中,我将所有实例分为两类,master和worker。

master用于协调所有worker之间的互联,以及提供某种一致性,不参与业务逻辑,全局只有一份。

worker节点则真正用于处理业务逻辑,worker可以再细分为不同种类的服务(比如auth, gate, role),具体由业务逻辑来决定。每个服务可以水平布署多份实例。

为了确保不会出上述两个服务实例处理同一份数据的情况。我将一个实例成功加入集群的过程分为两个阶段:"up", "run"。

在"up’阶段,worker携带服务名向master申请加入集群,master检查此服务是否全部健在(即已经有足够多的处于"up"或"run"状态的服务存在),如果全部健在,则向此worker返回加入失败,worker收到失败后自动退出。如果此服务有空缺(比如需要5个实例,但现在只有4个健康实例。master会定期检查所有worker的心跳状态,如果有worker心跳超时,则将其标记为"down"状态,但是并不通知集群,直到有新的worker替换它才会通知集群,这么做是为了防止过载误判。)则master向所有worker广播当前集群信息(所有的Worker的信息)。worker收到广播之后,如果发现某个实例被替换,则关闭掉相应的rpc连接,并返回成功。在所有worker都返回成功之后,master向申请的worker节点返回成功。

worker在"up"阶段完成之后,会拿到当前分配的slotid和当前服务被计划布署实例的个数capacity。其中slotid即为(1~capacity)中的一个值,一般用于做数据分片。

worker根据slotid和capacity开始从数据库加载所需数据。

加载完成之后,worker再向master申请接管消息处理。此时master会再次将集群信息广播给所有worker, 待所有worker都返回成功之后master返回成功。在worker收到广播消息之后,发现有新的“run”节点,则创建与其对应的rpc连接。

至此,新加入的实例,处于一致状态。

API设计类似如下:

----master实例
local capacity = {
        ['auth'] = 1,
        ['gate'] = 2,
        ['role'] = 2,
    }
    local ok, err = master.start {
        listen = addr,
        capacity = capacity
    }
---worker实例
    worker.up {
        type = "auth",
        listen = listen,
        master = master,
        proto = require "proto.cluster",
        agents = {
            ["gate"] = gate,
        }
    }
    worker.run(MSG)

其中在worker.up时需要提供关心的服务列表,提供其相应的agent对象。每个服务在新建立rpc连接时,都会回调其对应的agent.join,示例代码如下:

function M.join(conns, count)
    if not gate_count then
        gate_count = count
    else
        assert(gate_count == count)
    end
    for i = 1, count do
        local rpc = conns[i]
        if rpc then
            gate_rpc[i] = rpc
        end
    end
end

BTW, 这种组网机制还可以提供一些时序保证(就是上面提到的一致性),如果在worker.up或worker.run返回之后,Agent对象中还没有建立相应的rpc连接,那么这个Agent对象所对应的服务一定在此之后才能成功加入集群。



发表评论