惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

N
News and Events Feed by Topic
T
The Exploit Database - CXSecurity.com
P
Palo Alto Networks Blog
T
Threat Research - Cisco Blogs
Cloudbric
Cloudbric
Recent Commits to openclaw:main
Recent Commits to openclaw:main
I
Intezer
Attack and Defense Labs
Attack and Defense Labs
P
Privacy International News Feed
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
L
Lohrmann on Cybersecurity
C
Cybersecurity and Infrastructure Security Agency CISA
V2EX - 技术
V2EX - 技术
AWS News Blog
AWS News Blog
O
OpenAI News
L
LINUX DO - 最新话题
N
News | PayPal Newsroom
PCI Perspectives
PCI Perspectives
www.infosecurity-magazine.com
www.infosecurity-magazine.com
T
Troy Hunt's Blog
Latest news
Latest news
D
Darknet – Hacking Tools, Hacker News & Cyber Security
A
Arctic Wolf
Spread Privacy
Spread Privacy
G
GRAHAM CLULEY
T
Tor Project blog
博客园_首页
Know Your Adversary
Know Your Adversary
有赞技术团队
有赞技术团队
S
Secure Thoughts
美团技术团队
Apple Machine Learning Research
Apple Machine Learning Research
爱范儿
爱范儿
T
Tailwind CSS Blog
Application and Cybersecurity Blog
Application and Cybersecurity Blog
V
Visual Studio Blog
J
Java Code Geeks
Cisco Talos Blog
Cisco Talos Blog
Schneier on Security
Schneier on Security
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
S
Security Affairs
Jina AI
Jina AI
人人都是产品经理
人人都是产品经理
雷峰网
雷峰网
宝玉的分享
宝玉的分享
量子位
Last Week in AI
Last Week in AI
月光博客
月光博客
罗磊的独立博客
S
SegmentFault 最新的问题

重归混沌的BLOG

给silly实现了一个ernro模块 | 重归混沌的BLOG 给silly实现了一个ernro模块 | 重归混沌的BLOG 第一次在生产环境使用 Vibe Coding | 重归混沌的BLOG 第一次在生产环境使用 Vibe Coding | 重归混沌的BLOG API 设计的艰难抉择 | 重归混沌的BLOG API 设计的艰难抉择 | 重归混沌的BLOG 十年 | 重归混沌的BLOG 十年 | 重归混沌的BLOG 在Go语言中如何使XML加载内存无限趋近于0 | 重归混沌的BLOG 在Go语言中如何使XML加载内存无限趋近于0 | 重归混沌的BLOG 对跨服玩法中的分布式一致性问题进行简单抽象 | 重归混沌的BLOG 对跨服玩法中的分布式一致性问题进行简单抽象 | 重归混沌的BLOG Go语言逃逸分析之slice和map | 重归混沌的BLOG Go语言逃逸分析之slice和map | 重归混沌的BLOG 谈谈观测 | 重归混沌的BLOG 谈谈观测 | 重归混沌的BLOG 写了个AI Agent服务端 | 重归混沌的BLOG 写了个AI Agent服务端 | 重归混沌的BLOG 谈谈代码设计中“严丝合缝” | 重归混沌的BLOG 谈谈代码设计中“严丝合缝” | 重归混沌的BLOG 一次艰难的线上游戏服务器内存排查经历 | 重归混沌的BLOG 一次艰难的线上游戏服务器内存排查经历 如何基于LanguageServerProtocol来编写lint工具 谈谈游戏服务器中RPC模块的设计 谈谈游戏服务器代码抽象 最近碰到的一个分布式一致性问题 谈谈游戏服务器的自动化测试 对Raft协议的一点理解 使用mmap来学习/proc/pid/smaps 2023(完) 再次实现了一个Lua性能分析器 终于给Silly的定时器增加了取消功能 一次虚拟内存排查经历 游戏服务器分布式数据的一种同步的思路 为silly增加了互斥锁 2022(完) Go语言之闭包篇 一例误用unsafe包引起的内存问题 Go语言之内存篇 初识Go语言 重新抽象图形API 给Lua实现了一个数学库 谈谈跨平台图形API的抽象 寻路和Flocking算法的结合 行为树的一种高效实现 内测过程中Shader出现的问题 彻底解决多国语言 谈谈数据库的选型 再谈Lua热更新(终) 初窥Rust 关于游戏服务器的服务拆分 ECS的初步实现 ECS初探 屏幕空间(SreenSpace)的想象力 一些对辐射度量学的理解 深度缓冲和半透明渲染 Mysql的间隙锁 更新一些GPU相关知识 2020 地形渲染之爬过的坑 Lua5.3 GC源码阅读(5) 实现一个数据库存储队列 再学计算机图形学入门 游戏上线一个月后的反思 一次并发Bug 双向链表的三种实现 谈谈随机数的使用 再谈性能优化 2019 Lua中的函数式编程 重构登录逻辑 Unity资源管理(续) 谈谈Unity的资源管理 一次关于Cache的性能分析 历史之2018 DC3算法 移动平台native代码遭遇的坑 从CPU层面谈谈优化 开卷有益(UNIX编程艺术篇) GC竞争问题 通过Mesh投影来实现贴花系统 谈谈我对数据同步的理解 又一个类型提升引起的Bug Lua5.3 GC源码阅读(4) Lua5.3 GC源码阅读(3) Lua5.3 GC源码阅读(2) Lua5.3 GC源码阅读(1) 三角形光栅化时遇到的坑 一次git事故 再见2017 又一个lua调试器 客户端缓存落地方案 Paxos算法 HTTP服务器的特点 一次性能优化经历 关于CPU分支预测 C程序中让两个不同版本的库共存 实现了一个AOI模块 一个高可伸缩的游戏服务器架构 关于网络协议封装的一些新想法
再谈分布式服务架构
重归混沌 · 2020-08-09 · via 重归混沌的BLOG

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

在之前的架构中,我只考虑了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对象所对应的服务一定在此之后才能成功加入集群。