普通视图

发现新文章,点击刷新页面。
昨天以前重归混沌的BLOG

第一次在生产环境使用 Vibe Coding

作者 重归混沌
2026年2月12日 13:17

从 25 年初大模型爆发开始,我的开发工作流就变成了下面这样:

  • 先自己实现功能。
  • 同时让 2~3 个大模型去 Review,然后我再逐一审核它们报告的问题是否真实存在(事实上有很多误报)。
  • QA 测试期间,再整体 Review 一遍。

后来开始使用 Cursor Tab 来加速整个写代码的过程,但是整个工作流的本质并没有发生什么改变。

随着越来越多的文章开始“嘎吹” Vibe Coding,我也浅尝辄止地试过将整个策划案丢给大模型,让它直接基于现有的代码去实现,结果它往往会将代码弄得一团糟。

在 25 年 12 月份时,我突然产生了一个好奇:

如果我不是在生产环境使用 Vibe Coding,如果我对代码质量的要求没有那么高,那 Vibe Coding 的上限到底在哪里?

为了避免我过度干涉大模型的决定,而只是在大方向上去当舵手,我特意选择使用 Cocos 而不是 Unity 来做一个塔防游戏(因为我真的会 Unity :D)。

通过一个月的业余时间,我完整地做完了一个塔防游戏的一个关卡,整个项目大概 2 万行代码左右(不算太多,但也不算太少)。虽然只有一个关卡,但整个逻辑代码是完整的,多个关卡只是配置和美术素材的事。

整个功能包括:打表工具、资源管理、拖动放置、正常攻击效果、战斗计算、肉鸽机制、战报保存和回放等。回放只是预留了接口还没有测试,不过整个设计都采用了定点数逻辑帧来实现,理论上不会有太大障碍。

我全程只调整了一个坐标系算法(大概 100 行),除此之外都是我提出想法或算法,代码由大模型实现。


我不禁在思考,这次还算成功的 Vibe Coding 体验和以前失败(直接丢策划案给大模型)的体验,最大的区别是什么?

除了我不再苛责代码质量、整个项目是从 0 开始之外,是否还有别的我没注意到的细微差异。

最终我还是找到了一些关键。

首先,这个项目完全是大模型实现的。

因此一些脚手架代码或者说是框架代码也是大模型实现的。

从本质上来讲,大模型既然选择了这种实现方式,就代表它被训练过,也代表它知道如何在这种体系下实现业务代码。

因此在写业务时只要稍加约束,就不会偏离主题太多。

而我们生产环境的代码一般都是“祖传代码”,这种代码可能从来没有被大模型见到过,更别提如何在这种体系下工作。

这就会导致生成的代码很容易不按祖传套路出牌,往往需要更强的 Prompt 约束。

其次,这次实验过程中,我并不是直接告诉大模型给我实现一个塔防游戏

说到这里,我对所有的鼓吹一句话生成一个App的人都非常反感。

一个没有任何约束的目标,本质上只是生成了一个大模型见过最多的同类型 App,这代表市面上已经有了成千上万的同类型 App

这种 App 就算做出来有什么意义呢?除了烧掉你的钱包之外,你并不敢也不会把这种 App 发布到生产环境,即使它完全没有 Bug

在整个塔防游戏的实现过程中,我是完全按正常的开发流程进行的。

比如我让它先实现地图加载,再实现敌人生成,然后实现拖放防御塔。

所有的功能实现都源于我的拆解,我从来不会让它一次性做两个功能(不是两个文件,有时一个功能需要修改数十个文件,这并不影响大模型的正确性)。

这个项目最初我只是想尝试使用 Vibe Coding 做一个最简单的游戏,我脑子里对整个蓝图并没有太多完整的概念,都是做完一步,再去看下一步做什么。

实践下来看,这反而促成了大模型的稳定输出。

最后,是策划案自身的问题。

之前的失败,固然可能是因为当时大模型还不够强,但更多还是策划案本身的问题。

  • 第一,策划案并不完整,很多时候策划会依赖于程序的“潜规则”或约定。
  • 第二,策划案描述的虽然是一个完整功能,但其内部往往包含非常多的复杂约束逻辑,有时一个策划案就把大模型的上下文吃完了。
  • 第三,策划案描述的其实是策划对于游戏运行规则的蓝图,它和代码实现之间其实隔着一道“天堑”。

再加上项目中的祖传框架,大模型其实很难越过这道天堑。


我想,只要我能帮助大模型越过这个天堑,将 Vibe Coding 用于生产环境也许并不是不可能。

刚好,我需要做一个新需求,不算太复杂,但是自己吭哧吭哧写,怎么滴也得 2~3 天。我打算使用 1~2 天来尝试一下,就算失败了,我稍微加加班也能弥补这两天的损失。

为了增强对大模型的约束,这次我拆解得比做塔防游戏时更细:

  1. 我先根据策划案设计好数据结构。这时就体现出来“程序 = 数据结构 + 算法”的金玉良言了,我的数据结构本身就对大模型形成了一个强有力的约束。
  2. 然后再让它根据数据结构,实现数据库存储、加载等基础功能。
  3. 再根据策划案拆解出 10+ 条客户端消息,定义好每条消息需要实现的功能。
  4. 让大模型结合已经定义好的数据结构,按我定义好的工作流(先去 Google Protobuf 中定义协议,再去生成协议代码,最后实现协议处理)实现功能。

需要说明的是,我并没有一次性将所有消息和功能定义都发给大模型,而是每实现完一个消息,再去实现下一个。为了让大模型严格遵循项目风格,我在每次使用大模型实现功能时都会给出两样东西:

  1. 本次实现的功能是什么。
  2. 与本次实现功能最相近的代码(让大模型参考风格和逻辑取舍)。

最终,我大概花了 1 天半的时间就实现了所有功能。

当然,因为我实现的是服务器功能,一旦产生数据 Bug 之后,影响会比客户端更恶劣,因此我最后又花了 2 天半做了全面仔细的 Review

老实讲,其实我一个 Bug 也没发现,只是发现了少量代码冗余,和有一处模块间的耦合没有适配。

PS. 事实上这篇文章是上线过了一个月之后,我才敢发出来的 ^_^!

API 设计的艰难抉择

作者 重归混沌
2025年12月2日 19:20

自从下决心Silly进行大规模重构以来,最近三个月,我几乎把所有业余时间都投入到了这次重构中。

虽然重构还未完全结束,不过总算大局已定。趁现在记忆还算清晰,先总结一下。

这次重构,更像是一次代码风格的统一。

这个仓库存在时间太久了,几乎囊括了我所有时期的代码风格,因此即便都是我写的,也存在风格不一致的情况。

在重构过程中,我重新明确了许多规范和原则。

借助Claude Code,实际编码的时间并不算多,更多的时间花在了取舍、折中和推翻已有设计上。


先是命名规则和命名空间

所有导出函数必须控制在两个单词以内,这一设计借鉴自 C 语言。

这样既能更好地兼容业务层的多样命名风格,也符合 Lua 的习惯。

如果超过两个单词,通常意味着模块功能过于复杂,需要拆分。

有些模块仅为了可读性而拆分,但内部实现高度耦合,因此需要共享一些函数。这些共享函数统一以 _ 开头。

之所以不用 __,是因为 Lua 保留了 __ 用作元方法,我猜测 Lua 是故意将 _ 开头的命名空间留给我们使用(没有深入考究 :D)。

Silly 的所有库都位于 silly 命名空间下,通过 require "silly.xxx" 引用。

其中,属于框架核心的基础能力——如时间、网络、任务等与 IO/time 紧密相关的部分——都放在 silly 根命名空间,例如:

  • silly.time
  • silly.net
  • silly.task

其他功能则拆分到二级命名空间中(第三级一定是模块名称,而不是子命名空间,以保持目录结构扁平),例如:

  • silly.net.tcp
  • silly.encoding.json
  • silly.adt.queue
  • silly.store.etcd

采用这种布局的原因在于,我希望二级命名空间覆盖常用功能,使用时能快速定位,同时避免命名空间过多导致混乱。

即便某些功能尚未实现,也可以直接补充到已有命名空间,无需频繁新增命名空间。

这也是我之前提到的“大局已定”的原因之一。

正是这种约束,让我在 silly.storesilly.security 两个二级命名空间上反复斟酌。

silly.security 为例,其出现的原因是我找不到 JWT 算法的合适归属:它既不属于 crypto,也不属于 cipher

经过几天斟酌,我选择了 security 这一含义宽泛的词,这样后续所有安全相关功能(如 OAuth2.0)都可以放入该命名空间。

silly.store的名字则更坎坷一些。

原先仓库中有一个直白的名字 silly.db,下面共有 silly.db.mysqlsilly.db.redis 两个模块。

然而,etcd 虽有持久化和强一致性,但严格来说并不是数据库,而且几乎找不到同类模块(可能只有 Zookeeper,但也不完全相同)。

如果放在根命名空间下又不合适,我几乎就要妥协要把 etcd 放入 silly.db

无奈之下,我和几个主流大模型都讨论了一下,其中一条回复提到:“如果以后打算支持 AWSS3 操作,也可以放入 store 命名空间”。这让我最终选择了 store 这个名字。

store 可以泛化为所有存储操作:虽然 etcd 不是数据库,但它属于存储,而 mysqlredis 也可归类于存储。

更关键的是,这符合我的设计理念:二级命名空间数量要适中,每个命名空间下的模块也要数量合适。当我要查找某个模块时,时间复杂度约为 m + n/m,其中 m 是二级命名空间数量,n 是所有模块的数量。

虽然store过于泛化,以至于查找redis/mysql时会产生干扰,不过我打算接受这个缺点, 有得必有失嘛。


然后是 API 设计。

在刚写 tcp/tls 模块时,我认为网络资源是有限的,因此必须手动管理连接。

既然要手动管理,__gc 就变得不那么必要,面向对象的调用方式也就没必要了。

再加上我发现 skynet 也是这样实现的,这给了我极大的信心。

早期的 API 设计是这样的:

local fd = tcp.connect("127.0.0.1:553")
local line = tcp.readline(fd, 5)
tcp.close(fd)

在随后实现 http / websocket 等协议时,我不断对 tcp/tls 做封装。

出于性能强迫症,我甚至用闭包生成不同的 read 函数以减少哈希查找次数,例如:

local function wrap_read(io)
    local read = io.read
    local readline = io.readline
    local fn = function(fd)
        local line = readline(fd, "\n")
        local n = string.pack("<I4", line)
        return read(fd, n)
    end
    return fn
end

在性能强迫症有极大好转的今天,我重新审视了这种模式 —— 我认为这是一种过度设计:它增加了实现复杂度,且浪费了动态语言鸭子类型(Duck Typing)的优势。

最终我把 tls/tcp/udpAPI 全部重构为 OO 风格,就像下面这样:

local conn = tcp.connect("127.0.0.1:553")
local line = conn:read("\n")
conn:close()

在这种设计下,使用连接的模块仅需要在入口使用不同的连接函数, 即可无缝切换tcp/tls的支持。

相比之前的闭包方案,这次的结构更加清晰,也不会再让连接相关的细节渗透到代码的各个角落。

与此同时,我还简化了另一对 API:读指定字节和读一行。过去它们是两个函数:

local line = tcp.readline(fd, "\n")
local data = tcp.read(fd, 1024)

在我意识到DuckTyping的同时,我认为使用readline特意区分读一行也是一种过度设计, 他并不符合动态语言的惯例。

当我们要读取一行时传入的必然是字符串,如果要读取指定字节数时传入的必然是数字。

此时完全可以通过第二个参数类型来确定到底是要读指定字节数还是要读取一行。

于是我去掉了 readline,只保留一个 read,用法如下:

local line = conn:read("\n")
local data = conn:read(1024)

我还简化了“读一行”分隔符的语义。

以前的实现支持任意长度的分隔符, 例如: tcp.readline(fd, "\r\n")

虽然 RedisRESP)和 HTTP 规范要求使用 CRLF(即 \r\n),但在实践中仅以 \n 切分也能工作。

于是我移除了长度大于 1 的分隔符支持——仍然可以自定义分隔符,但只允许长度为 1。

这样做能大幅简化实现、减少复杂度,如果将来确实需要,再加回来也不迟。

这次 API 重构中最困难的,是为 conn:read 明确定义语义。

我将 conn:read 定义为 Strict Read 模式,即:要么读取到完整满足条件的数据量(例如指定的字节数或行分隔符),要么就返回错误。它不会像传统的流式读取(Stream Read)那样,只要读到一点数据就返回(Short Read)。

以前 read 失败就返回 nil, error,但在全面考虑后发现很多场景没被覆盖,尤其是加入读取超时(read timeout)后,问题变得更加复杂,例如:

  • 如何判断是连接出错还是正常的 EOF?

  • 读取超时时,如何区分是 timeout 还是连接出错?

  • 在调用 conn:read(1024) 时连接断开或出错,如何把剩余数据读出?

经过大约3~4版的撤销和重构后,最终我选择了如下方案:

  • 如果 conn:read 能读到满足条件的数据(如指定字节数或遇到行分隔符),直接返回数据。

  • 如果因为 EOF 导致读取失败,返回 "" , "end of file"

  • 其他失败情况返回 nil, error

这样,在判断是否为 EOF 时,只需检查返回值是否为空字符串即可。

有一种特殊情况:调用 conn:read(0) 时,会返回 "" , nil

这是借鉴 Berkeley Socket API 的行为:调用 read(fd, buf, 0) 时会返回 0 而不是 -1

为了区分 timeout 与其他错误,我额外增加了 conn:isalive() 接口,返回布尔值表示连接是否仍存活。

发生非 timeout 错误时,conn:isalive() 总是返回 false

第三个问题最棘手。

我认为, Berkeley Socket API 之所以将阻塞式 read/recv 设计为, 只要读到至少一个字节就返回, 就是为了解决这个问题。

Lua Socket 则采用了更粗暴的三值返回:nil, error, partial,通过 partial 返回不满足条件的剩余数据。

我不想像 Berkeley Socket API 那样只要有数据就返回,一是因为他会增加别的模块的使用负担,二是因为 Lua/C 交互时会产生大量垃圾。

同时,我也不想像 Lua Socket 那样采取3返回值的设计。

斟酌再三,我引入了 conn:unreadbytes() 接口,他返回当前连接剩余未读取数据的字节数。

在现有的设计中,不管 conn:isalive()返回什么,只要缓冲区中有满足条件的数据都不会返回错误。

因此,我们完全可以使用conn:read(conn:unreadbytes())来读取剩余的数据。

终于,通过新增2个正交的 API,我终于覆盖了我能想到的所有场景。


将链接重构为 OO 模式之后,一个随之而来的问题摆在面前:到底要不要为每个 conn 对象设置 __gc 函数。

这又引发另一个更深层次的问题,在整个系统中,我们的 GC 引用链应该是怎么样的。

在以前的设计中,由于需要手动 close, 所有的 socket 对象都被全局的 socket_pool 引用着。

虽然为每个 socket 对象设置了 __gc 函数,但在强引用路径存在的情况下,这些 __gc 函数只会在进程结束时执行。

也因为此,当时的 GC 引用链是这样的:socket_pool -> socket -> suspend coroutine

其中,当coroutine执行tcp.read(fd, n)时,如果条件不满足,它会把当前coroutine挂到对应的socket对象上, 让 socket 成为协程的“锚点”,避免coroutineGC 回收。

Silly的调度器因此对挂起的coroutine仅使用弱引用,coroutine在被挂起前,必须先找到一个正确的“锚点”,否则就会被 GC 清理掉。

我不禁在思考,当前设计是否正确。

这种设计唯一的优点就是:被挂起的 coroutine 可以被 GC 回收。

但是,只有在 coroutine 之间互相等待并形成死锁(例如两个协程使用 silly.sync.channel 相互等待)时,它们才会因为无人强引用而一起被 GC 回收。

除此之外,大多数挂起的 coroutine 都必然会挂在某个全局引用链上(常见是 socket)。

缺点看起来就很显眼: 每个 C 扩展模块必须建立自己的强引用链,以保护挂起 coroutine 不被 GC 清理。

并且各个C模块的锚点并不相同,这增加了扩展模块的心智负担。并且这种设计看起来不那么简洁。

我仔细分析了线程以及 goroutine 的设计。

作为运行实体,线程与 goroutine 在系统中总是被区别对待,不会被当成普通内存对象来管理。

比如:pthread 通常要求显式退出,而挂起的 goroutine 如果没有妥善管理则会造成泄露。

如果让调度器对所有挂起的 coroutine 进行强引用,那么 conn 就可以改为弱引用,它的 __gc 也能在忘记 close 时承担兜底的清理职责。

更重要的是,扩展 C 模块时,不再需要费心为挂起的 coroutine 提供强引用锚点,整体设计会简洁不少。

当然,代价也摆在这里:挂起的 coroutine 将不再可能被 GC 回收。

好在 silly.task 模块提供了相关的观测接口,可以展示挂起 coroutine 的调用栈,方便排查问题。

权衡之后,我最终选择接受这个缺点,并将调度器中对挂起 coroutine 的引用改为强引用。

十年

作者 重归混沌
2025年8月31日 18:22

随着我敲下 git push origin master,轻轻按下回车,这一轮漫长的重构终于告一段落。

翻看提交记录时才发现,这些被重构的代码居然大多写于十年前,心里不免一阵唏嘘。

从 Git 仓库的第一个提交时间来看,silly 这个坑是我在 2015 年 6 月 27 日开的。当时的初衷,不过是想练习一下服务器编程。

可随着一次次思考与重构,我渐渐改变了初心。

我希望它能真正跑在服务器项目里,而不仅仅是个玩具。

大约在 2018 年,我第一次在一个卡牌游戏的塔防玩法中,将 silly 引入了生产环境。

这一点真的要感谢领导的开明。虽然只是一个子玩法,但引入新框架总是存在风险,尤其是数据错乱这类问题。所幸,silly 在上线后并没有带来额外的 bug。

不过,在内网倒是踩过坑。由于我们使用私有协议,需要自定义 Marshal/Unmarshal。为了提升效率,我当时加了 Cache 来减少内存分配,结果在链表尾部忘了置 NULL。这个Bug一直提醒我至今,每次写链表都会特别小心。

随着稳定运行时间的增加,我对 silly 的信心也逐年增长。

在后来的 SLG 游戏 的服务器程序中,我干脆用 silly 重写了整个业务逻辑。只有“武将养成”部分依然保留着 C 代码,因为那些代码是从之前的 卡牌游戏 继承下来的。

可以说,在这10年里,我真的学会了很多,这个框架也一直在和我一起成长。

但也正因为它一直在线上稳定运行,面对一些核心模块(比如网络模块)的设计时,哪怕心里有了新的重构想法,也始终下不定决心去动。

直到最近,我决定再次研究 io_uring

之所以说“再次”,是因为早在 2021 年我就粗略看过它的原理。当时查到的结论是:在网络场景里,io_uring 并没有显著优势,于是便搁置了。

几年过去,以它的发展趋势,我觉得值得再看看如今的性能究竟如何。

于是我开了一个分支,用 io_uring 重写了网络部分(虽然实现还有点小 bug,但不影响测试数据)。结果显示:性能仅略快于当前版本。

升级计划虽然再次搁置,但在重构过程中我却发现,现有的设计在一些地方已经显得颇为些掣肘。

再加上 2022 年工作上的变动,silly 继续用于生产环境的可能性也不大了。

我想,是时候为 silly 重新定位了。 它将作为我编程思考与最新领悟的承载体。

当然,我依然会尽可能编写完善的测试来保证质量,只是在设计的选择上可能会更激进一些。

就在前几个月,我还跟别人感慨过:“我觉得自己写代码终于算是入门了。”但要我清晰解释“入门”的含义,却又说不清。

刚好可以借着这次对 silly 的重构,来使用代码解读一下我的感受。

在写这篇文章时,我依然无法完整表达“入门”的含义,但似乎已经能尝试用一些场景来说明。


我刚毕业时,公司使用的 XML 文件巨大(上百 MB)。

即便是 tinyXML,在性能上也有些吃力。于是我从周六中午开始,直到晚上 11 点左右,写了一个简易的 XML 解析器。

具体性能我已记不清了,但至少比常规 XML 解析器快了 3~5 倍。至少在程序里选择芯片时,原本卡顿的界面不再那么难以忍受。

当然,世上没有银弹,我也没强到逆天。

真正的原因是:我根据公司 XML 文件的特点,结合内存分配和 CPU Cache 等机制,对代码做了高度定制化。

换句话说,换一类复杂点的 XML 文件,我的解析器可能就不行了,也未必快。

有个小插曲:我把设计思路讲给同事听,他也实现了一个,但性能总比我略慢。可惜当时只顾着沾沾自喜,没有仔细对比差异在哪里。

如果是十年后的我再遇到这个问题,我可能不会做出相同的选择。

我可能会先考虑:到底是什么导致了卡顿?是 CPU 算力不足,还是单核性能不够,还是文件过大、IO 过慢?

假如确认是单核性能不足,我可能会考虑在打包安装包时,提前把 XML 拆成多个文件,运行时多线程加载,再合并数据。

如果多线程依然慢,我会结合表现层需求(界面是下拉框 + 芯片 ListBox,当切换厂商时显示对应芯片)去做二级索引:

  • 每个厂商的芯片数据预处理成独立 XML;
  • 再生成一张厂商表;
  • 打开界面时,只加载厂商列表和默认厂商的芯片数据;
  • 切换厂商时,按需加载对应文件;
  • 甚至后台异步加载所有文件。

这样,流畅度提升的数量级,绝对比单纯重写解析器要大得多。

在我之后的职业生涯中,这种“通过裁剪功能或换思路来提升性能”的场景一次次重演。


再说回这次的重构。跨线程通信时,我需要用到队列。

巧的是,当年写 XML 解析器的那个程序,也用了多线程。一个上位机要同时和 64 块烧录板通信。

为了简单,我给每个烧录板开了一个线程。正常烧录一切顺利,直到电饭煲厂家提了个新需求:每个设备都要烧录一个唯一的 device id,而且 id 由他们提供。于是我写了个 SPMC 队列来解决 64 个线程的竞争问题。

现在回头看,当时是否真的有必要,仍是个问号。我甚至没去验证这个队列相比加锁快多少,或者说“快这么多”到底有没有意义。

这次我特意问了 AI,它强烈推荐我用 SPSC 队列,因为通信只有两个线程。

但仔细思考后,我还是拒绝了。最终实现了一个简单的 PING-PONG 双缓冲机制来替代重锁。

相比 SPSC 队列的复杂性,在性能牺牲不大的情况下,我更倾向于选择更直观的设计。


有时候,我们看到别人的代码非常简单,我们可能会说,这么简单的代码谁不会写。

可当自己面对相同问题时,能否也选择如此简单的方案,这才是对我们的考验。

正如乔峰聚贤庄一战,太祖长拳照样胜过群雄——这便是“化腐朽为神奇”的能力。

我想说的“入门”大抵就是如此了, 能用最常规的数据结构和实现来解决问题,而不是像过去那样,总想着在局部动刀裁剪来换性能,而忽视了从全局出发去思考。

当然,还有一些难以言说的感受。

PS:既然我说“入门”,就意味着我还远未精通,只是刚好触到这一层认知而已。

在Go语言中如何使XML加载内存无限趋近于0

作者 重归混沌
2025年7月13日 16:55

TL;DR: 这不是最佳实践,除非你真的有内存问题,否则请不要使用它。

随着新开服务器数量越来越多,老服的日活越来越少。一般会选择在老服的ECS上部署新服进程, 以便可以充分利用CPU。

这就有一个不得不去面对的问题, 随着服务器进程的增加,XML配置表所占用的内存会线性增加。

如果XML配置占用内存为500MByte, 那么10个进程就占用5GByte内存, 这是一笔很可观的内存开销。

由于XML表都是只读的,因此我一直在找一种方法能让各进程共享同一份内存。

然而由于Go的CSP并发模型, 他用一层厚厚的抽象屏蔽了进程间共享内存的可能。

我们当然可以用goroutine来代替进程以便可以共享这部分XML所带来的内存, 随之而来的是隔离性的丧失, 还有大规模的重构。

这两年每每需要优化XML时, 我都会再次思考一下跨进程共享内存的可能性, 一直没有找到办法。

直到最近,我翻看最新Gounsafe包时,发现了不知道从哪个版本开始已经增加了4个函数:

func String(ptr *byte, len IntegerType) string
func StringData(str string) *byte
func Slice(ptr *ArbitraryType, len IntegerType) []ArbitraryType
func SliceData(slice []ArbitraryType) *ArbitraryType

我从这两组函数中看到了契机。


我们先来回顾一下stringslice的定义:

type stringStruct struct {
        str unsafe.Pointer
        len int
}
type slice struct {
        array unsafe.Pointer
        len   int
        cap   int
}

将XML中字段映射到内存中的slice[T]时, T一定都是简单(Plain)结构, 即T的成员全部都是值类型, 不可能为引用类型。

我们可以得出一个结论:在这种情况下,slice[T]string 拥有相同的内存布局,均由一个 header 和一个连续的内存块(memory chunk)构成。

这样将一行XML数据的全部内存数据映射到一个 bin 文件中就有了可能。

到目前为止,我们还没有没有改善任何内存问题, 所以还需要我们的主角 mmap 登场。

根据 MMU 的基础知识,当我们使用 mmap 将文件映射到进程地址空间时,内核会通过建立页表,将 Page Cache 中的数据页直接映射到进程的虚拟内存中。

当多个进程同时使用 mmap 将通过只读的方式将同一个 bin 文件映射到自己的地址空间时,他们共享的是同一份数据页内存。

这样我们在Unmarshal bin 文件时, 惟一的内存开销就是在堆上分配string/slice header所占用的内存, 当XML行中不包含变长数据时,他的额外内存开销甚至可以做到为0。


下面来展示一下如何做到这一切。

先来看一个未经处理的原始结构体:


type Pair struct {
    Key uint32
    Value uint32
}

type FooRow struct {
    ID uint32   `json:"id"`
    Group uint16    `json:"group"`
    Name strin  `json:"name"`
    Pairs []Pair
}

经过处理之后,结构体被拆分成如下形式:


type Pair struct {
    Key uint32
    Value uint32
}

type FooRowPlain struct {
    ID uint32 `json:"id"`
    Group uint16    `json:"group"`
}

type FooRow struct {
    *FooRowPlain
    Name string `json:"name"`
    Pairs []Pair
}

假设我们有这样一行 XML 数据:


row = &FooRow {
    FooRowPlain: {
        ID: 1,
        Group: 2,
    },
    Name: "foobar",
    Pairs: []Pair {
        {Key: 1, Value: 2},
        {Key: 3, Value: 4},
    },
}

来看一下将结构体 FooRow 序列化为 bin 文件之后的内存布局:

所有字段均为小端字节序(Little Endian),低字节在前(LSB at lowest address)

8 Bytes 4 Bytes 2 Bytes 2 Bytes 4 Bytes 4 Bytes 6 Bytes 2 Bytes 16 Bytes
length id group PADDING1 len(Name) len(Pairs) Name PADDING2 Pairs
48 0x01 0x02 0x00 0x06 0x05 ‘foobar’ 0x0000 0x00000001, 0x00000002, 0x00000003, 0x00000004

从设计上看,这个布局其实可以分为几块:

  • length
  • plain_chunk(FooRowPlain + PADDING)
  • length_chunk(len(Name), len(Pairs) + PADDING)
  • mem_chunk(Name + PADDING)
  • mem_chunk(Pairs + PADDING)

其中:

  • FooRowPlain 内的字段对齐由编译器自动处理,例如 PADDING1

  • FooRowPlain 中的字段有可能有 uint64,需要确保起始地址是 8 字节对齐。

  • length_chunk 本质是上一个 uint32 的数组,需要确保起始地址是 4 字节对齐。

  • mem_chunk 可能是 slice[T],而 T 中可能有 uint64 的字段,同样需要确保起始地址是 8 字节对齐。

乍一看很复杂,其实我们可以统一成一条规则:

确保 plain_chunk, length_chunk, mem_chunk 占用的内存大小均是 8 的整数倍即可。

有没有一种在写内存池的既视感 😀

下面简单展示一下Unmarshal中如何复用数据页来减少内存开销的。


func Unmarshal(buf *[]byte) *FooRow {
    length := binary.LittleEndian.Uint64(buf[0:8])
    chunk := buf[8:length]
    *buf = buf[length+8:]

    plain := (*FooRowPlain)(unsafe.Pointer(&chunk[0]))
    row := &FooRow{
        FooRowPlain: plain,
    }

    lengthOffset := (unsafe.Sizeof(FooRowPlain{}) + 7) / 8 * 8

    var nameSize, pairsSize uint32
    nameSize = binary.LittleEndian.Uint32(chunk[lengthOffset:lengthOffset+4])
    pairsSize = binary.LittleEndian.Uint32(chunk[lengthOffset+4:lengthOffset+8])

    // name
    chunkOffset := lengthOffset + (2*4+7)/8*8
    namePtr := (*byte)(unsafe.Pointer(&chunk[chunkOffset]))
    row.Name = unsafe.String(namePtr, int(nameSize))

    // pairs
    chunkOffset += (nameSize+7)/8*8
    count := pairsSize / unsafe.Sizeof(Pair{})
    pairsPtr := (*Pair)(unsafe.Pointer(unsafe.SliceData(chunk[chunkOffset:])))
    row.Pairs = unsafe.Slice(pairsPtr, count)

    return row
}

从上面代码可以清晰的看到,我们唯一的内存分配地方,就是在&FooRow{}时从堆内存分配了 50 字节。

如果FooRow中不包含stringslice等变长字段时,我们甚至可以做到0额外内存消耗。

PS. 这样做的同时,你将会失去配置文件的可读性。

PPS. 在加载配置文件时,加载失败往往比解析成错误的数据更好。因此最好在bin的头部,将字段名和偏移量写入,以便在Unmarshal时进行校验,以确保不会因为编译器或字段的改变,而默默的解析成错误数据。

对跨服玩法中的分布式一致性问题进行简单抽象

作者 重归混沌
2025年7月10日 17:29

在我所见过的常规跨服玩法中(跨服相关数据由跨服进程管理),通常或多或少都会包含以下几种典型的工作模式:

  1. 跨服在特定时机(如玩家某个操作之后、每日 0 点、赛季结束)需要根据当前状态进行结算,并向游戏服发放奖励。
  2. 玩家的请求在游戏服处理后得到一个结果,该结果需要同步到跨服(一个非常典型的例子是跨服排行榜)。
  3. 玩家在执行某个跨服操作(比如攻击玩家)成功时,需要从背包中扣除相关资源(战斗次数、体力、道具等)。

对于模式 1 和 2,实现数据一致性虽然需要花点功夫,但并不算困难。

只要做好消息重发机制,游戏服加上去重逻辑,基本就能实现。

而对于模式 3,我曾经很长一段时间都没有找到比较好的解法,因为它确实非常复杂。

下面来看一下,当游戏服收到玩家请求后,常见的两种处理策略:

  • 策略一:先扣资源,再请求跨服处理。这种方式面临以下几种情况:

    1. 跨服返回成功,游戏服也成功收到,皆大欢喜。

    2. 跨服返回失败,游戏服补偿玩家资源,也没问题。

    3. 跨服消息因各种原因(如链接断开、服务器重启等)丢失,类似 timeout 的场景:

      如果这条丢失的消息是失败的,那我们就无法对玩家进行资源补偿,导致玩家莫名承担了不应有的损失。

  • 策略二:先请求跨服处理,收到成功后再扣除资源。这同样存在问题:

    1. 在跨服消息返回之前,客户端连续发起多次请求。当跨服陆续返回成功消息时,玩家资源已不足,会形成恶意刷资源漏洞。

      这时,通常需要对该模块实现一个逻辑锁,确保所有消耗资源的操作不会并行执行。

      即便如此,如果资源是金币这类公共资源,也很难确保跨服消息返回时玩家还有足够的余额。

      因此还需要加上预转换逻辑:即在跨服处理之前先扣除玩家资源,并发放模块内临时道具,等跨服结果回来后再正式扣除这些道具。

      这样只要有逻辑锁的存在,我们就可以保证,玩家在跨服消息返回之后,可以成功扣除资源。

    2. 如果跨服消息最终丢失了,我们也失去了扣除资源的机会。

尽管以上两种策略都有缺陷,我们通常还是倾向于采用先请求跨服处理,再扣除资源的方式。

如果跨服消息的丢失是我们自身系统的问题,那就当这次操作白送了。只要这个策略没有被恶意利用的漏洞,仍在可接受范围内。

在我过去的一些分布式事务相关的文章评论区里,经常有人推荐我使用 消息队列中间件。但我认为它并不适用于这个场景。

消息队列中间件的主要作用是削峰填谷,其代价是消息延迟显著增加。在 Web 场景里延迟几秒可能没什么,但在游戏中,即便是卡牌游戏,几秒也难以接受。

而且游戏服务器的负载本身就高度集中,比如玩家通常会在每天的 0 点、12 点、18 点、22 点等时间段集中上线,刷怪、打 Boss、做任务等操作。

本着“良好抽象可以减少开发工作量、降低 Bug 数量”的原则,我对上述三种模式做了归纳,并抽象成了三个通用组件(它们都是在原有的 TCP 链接上进行,不会单独建立新的链接, 这也便于我们充分利用 TCP 的 FIFO 特性)。


先来看看模式 1的场景。

在这种场景下,奖励是由跨服主动发起的,而不是由玩家触发。既然不是玩家主动操作,那就算有点延迟,玩家也是感知不到的。

更关键的是,这类消息往往比较单纯,与功能内的其他消息不存在顺序相关性。

因此我借鉴了 消息队列中间件 的思路,设计了一个叫做 Mq 的组件。

Mq 会保证消息按顺序送达 —— 即必须在收到 Msg1回包之后,才会开始发送 Msg2

当然,Mq 只能负责重发顺序性,它无法确保消息只送达一次。

因此,在游戏服这边,我们需要针对不同的协议自行实现去重逻辑

好在由于 Mq 保证了消息的顺序性,我们只需要在消息中增加一个 ID 字段,并确保 ID 单调递增,就可以轻松实现去重。

当然,我们也可以在游戏服中实现一个通用的 消息去重 组件,进一步减轻业务层的负担。但目前我还没想到一个真正通用且完美的方案。


不同于模式 1 的维度是“服务器”,模式 2 的维度是“玩家”

同一条客户端消息(如 pve_battle),玩家 1 的整个请求链可以与玩家 2 的请求链交错执行。

如果此时仍然使用 Mq 组件,就会出现玩家 1 的请求执行速度影响玩家 2 的响应速度的情况。最终可能导致:CPU 负载很低,但响应延迟却很高

这类消息大多是由玩家在客户端主动触发,因此他们对处理延迟非常敏感。你玩游戏的时候也不希望点一下按钮后,要等 3 秒才看到结果吧 ^_^!

针对这种场景,我抽象并实现了 UserSync 组件。

Mq 不同,UserSync 对每个玩家的每个 cmd 仅保留最新的一条消息内容

来看一个最简单的时序示例:

  1. 玩家 1 向游戏服发送 pve_battle 请求,游戏服计算后认为积分为 100,向跨服排行榜发送消息:
    sync_rank{uid:1, score:100}

  2. 此时跨服链接断开,游戏服没有收到回包。

  3. 玩家 1 继续进行下一场 pve_battle,积分变为 200,游戏服再次发送:
    sync_rank{uid:1, score:200}

  4. 玩家 1 再次进行战斗,积分变为 300,游戏服发送:
    sync_rank{uid:1, score:300}

  5. 当跨服链接恢复时,UserSync只重试最新的一条消息
    sync_rank{uid:1, score:300},忽略之前的历史消息。

之所以这样设计,是因为这种消息既不能丢,又必须做到尽可能低延迟

因此在调用 UserSync.Send(uid, msg) 时,UserSync立即发送消息给跨服,并更新玩家当前消息内容

这个“立即”非常重要。根据我过往的实践经验,在打完 pve 战斗后,客户端的回包中通常需要包含当前排行榜排名

也就是说,在我调用完:UserSync.Send(uid, sync_rank{uid:1, score:300})之后,往往会立即跟一个 query_myrank 请求,用于查询当前排名。

这个排名必须是在我更新积分之后的排名

由于 UserSync 总是在第一时间尝试发送消息,而且 TCP 链接具有 FIFO(先进先出)特性,在一切正常的情况下,query_myrank 总是会返回最新积分更新之后的排名

即便遇到链接异常,也能保证:玩家的积分更新不会丢失


场景 3 是最复杂的,它通常发生在跨服 PVP 类玩法中,本质上是一个分布式事务,事务的两端分别是游戏服与跨服。

事实上,我已经被这个场景困扰了很多年。我曾多次尝试借鉴 Saga 模式 来解决,但都以失败告终。

直到最近,我才突然意识到:借助 MqUserSync 两个组件,其实可以在保持较低复杂度的前提下,解决跨服扣资源的问题。


当玩家发起一场跨服 PVP 战斗时,流程如下:

  1. 游戏服先扣除资源,然后生成结构:

    consume{uuid: <递增的 ID>, resource: <扣除的资源>}
  2. consume 嵌入到 pvp_battle 消息中,通过 UserSync 发送给跨服。

    由于 UserSync 对同一个玩家、同一个 cmd 只保留最新消息,在游戏服执行资源扣除之前,必须先检查该 cmd 是否还有未完成的同步。

    如果有,就需提示玩家稍后再试 —— 这个逻辑本质上就是一个业务逻辑锁

跨服收到pvp_battle 消息后,流程如下:

  1. 对消息进行去重。

  2. 然后根据当前跨服状态进行计算。

    • 如果一切正常:pvp_battle 中的 consume 就可以直接忽略。

    • 如果出现异常(例如对手已死亡、活动已过期等),跨服可以生成如下结构,并通过Mq发送回游戏服:

      consume_rollback{consume: consume}

游戏服收到 consume_rollback 消息后,流程如下:

  1. 根据其中的 consume.uuid 执行去重处理。

  2. consume.resource 中的资源返还给玩家。

至此,我们实现了一个最终一致性的分布式柔性事务。

一些旁支末节:

  • consume_rollback 的处理逻辑在游戏服只需要实现一次,之后所有跨服玩法都可以共用这一套流程。

  • 关于去重逻辑,其实有一个小技巧:

    只要每个服务器的 consume.uuid 是连续递增的,我们就可以下面结构来保存所有已处理的 uuid

    {start: int, end: int, fragments: array}

    由于这些消息都有时间相关性,最终一致达成之后,fragments 数组的体积一般不会太大。

相信这三种模式基本可以覆盖绝大多数跨服业务场景了。

ps. MqUserSync中的数据都会进行数据库落地。

Go语言逃逸分析之slice和map

作者 重归混沌
2025年6月18日 10:52

本来我以为,凭着之前写的 Go 语言三部曲(《初识 Go 语言》《Go 语言之内存篇》《Go 语言之闭包篇》),已经足够扫平工作中遇到的大部分障碍了。

虽然 Go 语言的逃逸分析有一些特殊的规则,但是他本身的实现却很朴素, 一直以来也没太重视 Go 语言的逃逸分析。

正所谓太阳底下没有新鲜事,Go 语言的逃逸分析也好,Rust 语言的无 GC 设计也罢,本质上和我们 C 语言时代“尽量分配在栈上”的思路是一致的。

换句话说,只要延续当年写 C 语言时那套“避免 malloc”的技巧,大概率变量就不会逃逸,同时也能顺利通过 Rust 编译器的生命周期检查。

这里用了“大概率”这个词,是因为无论是 Go 的逃逸分析,还是 Rust 的生命周期系统,说到底都只是对那套 C 语言技巧的一个子集。很多复杂的优化手段,编译器是分析不出来的。

最近在和同学讨论map的逃逸分析时发现一些有趣的现象,我觉得是时候总结一下了。


先来看一段代码,测试环境为Go 1.23.8

package main

func SliceUnescape(n int) int {
    s := make([]int, 0, 128)
    for i := 0; i < n; i++ {
        s = append(s, i*2)
    }
    sum := 0
    for _, v := range s {
        sum += v
    }
    return sum / len(s)
}

func SliceEscape(n int) int {
    s := make([]int, 0, n)
    for i := 0; i < n; i++ {
        s = append(s, i*2)
    }
    sum := 0
    for _, v := range s {
        sum += v
    }
    return sum
}

func MapUnescape(n int) int {
    m := make(map[int]int, n)   // 这里会不会逃逸取决于n的大小,具体可以对比C代码
    for i := 0; i < n; i++ {
        m[i] = i*2
    }
    sum := 0
    for _, v := range m {
        sum += v
    }
    return sum
}

func MapEscape(n int) any {
    m := make(map[int]int, n)
    for i := 0; i < 8; i++ {
        m[i] = i*2
    }
    return m
}

func main() {}
$ go build -gcflags="-m -l" a.go
./a.go:4:11: make([]int, 0, 128) does not escape
./a.go:16:11: make([]int, 0, n) escapes to heap
./a.go:28:11: make(map[int]int, n) does not escape
./a.go:40:11: make(map[int]int, n) escapes to heap

让我们来看段C代码,他是由Go代码编译成汇编语言,再人工反编译成的等效代码。

typedef struct {
    int *data;
    int len;
    int cap;
} slice;

typdef struct {
    tophash uint8_t[8]
    int key[8];
    int values[8];
} bmap;

typedef struct {
    count     int
    flags     uint8
    B         uint8
    noverflow uint16
    hash0     uint32
    buckets    *bmap
    oldbuckets unsafe.Pointer
    nevacuate  uintptr
    extra *mapextra // optional fields
} hmap;

int SliceUnescape(int n) {
    int buf[128];
    slice *s = alloca(sizeof(slice)); // 分配在栈上
    s->data = buf;
    s->len = 0;
    s->cap = 128;
    for (int i = 0; i < n; i++) {
        s->data[s->len++] = i * 2;
        if (s->len == s->cap) {
            int *ptr;
            s->cap *= 2;
            ptr = malloc(s->cap * sizeof(int));
            memcpy(ptr, s->data, s->len * sizeof(int));
            s->data = ptr;
            // 旧内存靠GC系统释放
        }
    }
    int sum = 0;
    for (int i = 0; i < s->len; i++) {
        sum += s->data[i];
    }
    return sum / s->len;
}

int SliceEscape(int n) {
    slice *s = malloc(sizeof(slice));
    s->data = malloc(n * sizeof(int));
    s->len = 0;
    s->cap = n;
    for (int i = 0; i < n; i++) {
        s->data[s->len++] = i * 2;
    }
    return s;
}

int MapUnescape(int n) {
    hmap *m;
    maptype *t = typeof(map[int]int); // 伪码,因为C语言没有反射类型系统
    if n <= 8 { // 8 这个值是目前Go编译器写死的,未来不保证不会改变
        m = alloca(sizeof(hmap));
        m->count = 0;
        m->flags = 0;
        m->B = 0;
        m->noverflow = 0;
        m->hash0 = 0;
        m->buckets = alloc(sizeof(bmap));
        m->oldbuckets = NULL;
        runtime.makemap(t, n, m);
    } else {
        // 当makemap第三个参数为NULL时,会强制将hmap分配在堆上
        m = runtime.makemap(t, n, NULL);
    }
    for i := 0; i < n; i++ {
        runtime.mapassign_fast64(t, m, i) = i*2;
    }
    sum := 0
    for _, v := range m {
        sum += v
    }
    return sum
}

void *MapEscape(int n) {
    hmap *m;
    maptype *t = typeof(map[int]int); // 伪码,因为C语言没有反射类型系统
    // 当makemap第三个参数为NULL时,会强制将hmap分配在堆上
    m = runtime.makemap(t, n, NULL);
    for i := 0; i < 8; i++ {
        runtime.mapassign_fast64(t, m, i) = i*2;
    }
    return m
}

使用 C 代码重写后,结论已经非常清晰了。

先来看 slice

slice 被识别为 不逃逸 时,struct slice 结构体本身和 slice.data 指向的内存会一起分配在上。

如果后续发生扩容,slice.data 才会从迁移到

slice 被识别为 逃逸 时,struct sliceslice.data指向的内存则会一起分配到上。

slice 的逃逸规则非常复杂,但有两点是确定的:

  • 使用 可变大小 make 创建的 slice 一定会逃逸, 即便初始容量非常小。从函数 SliceEscape 中可以很直观地看出这一点。
  • 使用 常量大小 make 创建的 slice 在某些情况下不会逃逸(复杂规则的来源就在于此,例如取地址传参等操作,都有可能会导致逃逸)。

再来看 map

map 被判定为 不逃逸 时,struct hmap 和其对应的 bucket 数组会一起分配在上。

map 被识别为 逃逸 时,struct hmap 结构和 bucket 数组也会一起分配到上。

相比于 slice 的逃逸规则,map 的逃逸判断要简单很多:

当我们使用 make 创建 map 时,如果指定的容量(即使是变量,在运行时也会被判定)小于等于 8,那么 hmapbucket 都会被分配在上。

否则,map 就会逃逸,hmapbucket 都会被分配到上。


总结一下。

slicemap 这类容器型数据结构,通常由一个 header 加上一个 buffer 组成。

当逃逸分析判断 slicemap 不逃逸时,headerbuffer一起分配在上。

当判断为逃逸时,headerbuffer 则会一起分配在上。

从上面简单的测试, 我们就可以得出一个结论: 由于某种不为人知的原因,Go 编译器总是倾向于让headerbuffer拥有相同的生命周期。

SliceEscape 为例,struct slice 明明可以保留在栈上,仅让其指向堆上的数据,这样的行为就相当于“未逃逸但触发过扩容”的 slice,可以节省一次内存分配与释放。

但 Go 编译器依然选择将 struct slice 一并分配到堆上。

从这个行为中,还可以顺带得出一条关于 GC 的不变式:

  • 栈内存 只能被 栈内存 引用
  • 堆内存 可以被 栈内存堆内存 引用

谈谈观测

作者 重归混沌
2025年5月21日 18:50

随着这几年我对 eBPFPrometheus 等工具的深入了解,我才逐渐意识到“可观测性”这个词背后蕴含的意义。

很早以前,我就在 Linux 上使用 /proc/topsar 等工具来排查问题,却从未意识到,“观测”竟然是一门独立的学问。

回顾我早期的编程经历,在设计模块时,我总是本能地选择时间复杂度最低的已知算法。

因为我并不知道这个模块的性能会对最终系统造成多大影响,只是凭着一种“贪心算法”的思维尽可能提升程序性能——也正是这种心态,造就了我后来的“性能强迫症”。

结果就是,代码变得不够简洁,而对整个系统性能的提升却微乎其微,甚至可能根本没有提升。我最近的重构再一次验证了这一点。

并不是我不想评估模块对系统的实际影响,而是现实中的负载情况实在太难模拟。

你测试出来的结果,往往和真实线上环境天差地别。多年来我也请教过很多人,但始终没有找到特别令人满意的答案。

在面对一个有问题的运行环境时,我们可以借助一些分析工具快速定位到热点函数,但这些工具通常都有一个弊端:它们往往会 Stop The World —— 比如 gdbgprof 等。

然而线上问题通常无法容许我们用 Stop The World 的方式进行排查。

这也正是“可观测性”弥足珍贵的原因之一:当系统出问题时,我们可以通过系统本身提供的可观测能力,去追踪和理解到底发生了什么。

不得不佩服 Linux 的设计者们,/proc 文件系统的设计在多年以前就已体现出极强的可观测性理念。


我并不想讲怎么样实现可观测性,毕竟我不是专家。

但我想谈谈观测给了我们一个什么样的视角。

想象一下,不管在任何工厂或车间,总会有各种各样的仪表盘。

一方面,说明这些仪表盘设计的非常合理, 另一方面,说明一个复杂的系统仅在宏观上暴露一些参数指标,足够我们排查很多问题。

而会看仪表的人并不需要对整个系统的细枝末节完全理解。

这从侧面也说明了,当我们通过观测来排查问题时,并不需要一上来就去了解整个系统的实现细节,从宏观视角就可以排查很多问题。

这一点很重要,前面铺垫了这么多,都是为了这个观点。

我也是这两年看完《性能之巅》才慢慢领会到的, 来举几个这两年真实发生的例子。


一次线上更新之后,一台机器的负载飙升到了 14.84,整台机器几乎被卡爆。将刚更新的程序进程杀掉之后,负载才明显下降。

运维怀疑是我们最近更新的代码引入了 Bug,要求我们自查代码。这是合理的猜测,也是最常见的原因。

巧的是,我这两年刚好研究完《性能之巅》,一直没什么机会实践,正所谓“拿着锤子到处找钉子”。

我首先用 top 查看了该进程的 CPU 和内存使用率,发现都只有 1.0% 左右,显然负载问题并不在计算上,初步判断是 IO 引起的。

于是我使用 iostat 1 查看磁盘负载,发现 %iowait 高达 75%,但 kB_read/skB_wrtn/s 加起来也才 200 左右。

结合这些数据,我怀疑是硬盘出现了问题。果然,负责该程序的同事自查代码后并未发现明显异常。最终联系云服务商确认,确实是他们的硬盘出了问题。

另一次是在某个凌晨,游戏服务器访问数据库时出现了 i/o timeout 错误。这里的 i/o timeout 是网络连接层的报错。

但如果网络连接迟迟没有返回,理论上也可能是数据库处理被卡住了。

我通过 Prometheus 查找出故障时段的监控数据,发现读取速度仅有 500KB/s 左右,而 IOPS 并无明显变化,但单次 IO 读取的耗时却突然飙升到了 200ms

综合来看,这很可能又是一场硬盘故障。与云服务商确认后,他们也承认是由于存储集群的某种原因触发了自动切换,导致了 IO 抖动。


上面两个案例中,我在分析问题时并没有去看程序的代码,甚至第一个程序的源码我根本没有阅读过。

这正说明,通过一些宏观指标,其实就可以定位出许多问题。不过,这两个案例还没有很好地凸显“视角”这个关键概念。

最近,我尝试对一个 Lua 程序的内存使用进行调优。同样地,我也没有看过这个 Lua 程序的源码。

我们可以假设这个程序只有一个接口 function foo(xx)foo 接收参数并返回计算结果,过程中会产生一堆垃圾对象。

也就是说,在调用 foo 前后各执行一次全量 GC,程序的内存使用应该没有任何变化。

在调优过程中,我发现每次调用 foo 函数之后,手动执行一次 lua_gc(LUA_GCSTEP, 10*1024),可以将程序的内存峰值从 560MB 降到 260MB

根据 Lua 手册中对 LUA_GCSTEP 的说明,10*1024 相当于让 GC 系统分配约 10MB 的对象。

不过我一开始测出的是一个错误的数据:我认为 foo 每次调用都会产生 50MB 的垃圾,其实只有首次调用才会产生这么多。

我原以为程序在每次调用 foo 后都会生成 50MB 的垃圾对象。但根据手册和我之前阅读 lgc.c 的经验,我始终无法理解:为什么在程序初始内存为 210MB(即执行 dofile("main.lua") 后)时,手动调用 lua_gc(LUA_GCSTEP, 10*1024) 居然能把内存峰值降低超过 300MB

根据默认 GC 参数,只要你分配了 100MB 内存,它就会尝试去 Mark200MB 的对象。也就是说,为了 Mark210MB 的初始内存,理论上需要实际分配 105MB

我虚假分配了 10MB,加上 foo 每次调用产生的 50MB,就需要实际再分配大约:105 / (50 + 10) * 50 = 87.5MB的内存,才能完成对所有存活对象的标记。

而根据 Lua增量 GC 实现,只有当对象被全部 Mark 完后,才会进入清理阶段。

因此,在进入清理阶段前,内存峰值至少已经达到了 297.5MB。而我还没有考虑,LUA_GCSTEP 实际上是要在 foo 分配完 50MB 之后才会执行,另外还有 SWEEPCOST 带来的额外内存负担。

按默认 GC 策略,GC 会等到内存分配到 297.5 * 2 = 595MB 时才会启动下一轮 GC,这个计算远远高于我的观查到的数据。

我和两位同学讨论了一下,他们都认为是 Lua 业务逻辑(非 VM 实现)干扰了 GC 行为,让我去分析业务代码。

但我并不这么认为——不管业务逻辑怎么写,在 GC 系统的视角下,它的行为是稳定的,它始终以内存分配量的 2 倍作为标记对象的阈值。

于是我逐步打印了每次调用 foo 后的内存变化,最终发现我原来的测量是错误的。实际上,只有第一次调用 foo 才会分配 50MB,后续每次调用只产生约 0.8MB 的垃圾。

这就合理了:调用 foo 十次产生约 8MB 的垃圾,加上我虚假分配的 10MB,共计约 108MB,就足够触发 GC 标记完 210MB 的内存。此时内存峰值大约为:210 + 0.8 * 10 ≈ 218MB

考虑到 GC 步长、首次 dofile 后仍残留的垃圾、以及 foo 首次调用产生的内存并非全是垃圾等因素,这个数字已经非常合理了,至少他是可解释的。


之所以举这三个例子,是希望引出这样一个宏观的抽象视角:

在一台计算机硬件上,运行着操作系统;在操作系统之上,运行着我们的应用程序;而我们的应用程序在设计上,往往又被进一步划分为多个抽象层。

当我们在排查问题时,可以尝试忽略其他抽象层与具体实现的干扰,只集中在某一个切面(Aspect)来分析问题究竟出在哪一层。

一旦我们确定了问题所在的抽象层,再去深入分析其具体实现细节,这将大大降低问题分析的复杂度,也能帮助我们更系统、更高效地定位和解决问题。

写了个AI Agent服务端

作者 重归混沌
2025年4月30日 21:00

TL;DR: 本文没有任何技术观点,只是总结了一下最近正在做的事

去年我在"星际蜗牛"上折腾Home Assistant时一直想找个方法把"小爱同学"或"天猫精灵"的语音输入给接管了。

折腾了几天之后,没找到比较好的方案,最后搁置了。

过年时,deepseek爆红之后,这个心思又活络起来,如果我能让智能音响的输入直接转发给deepseek就可以让家里小朋友尽快体验到大模型的便利。

这次我仔细分析了一下需求,输入端到底是不是智能音响其实一点都不重要,我要的只是一个可以语音唤醒的语音终端。

既然这样,我自己直接做个这样的语音终端可能也不会太难,这样至少可以All in control

实现以前我去Github搜了一下(毕竟10多年没画过PCB了,还是不太想碰这块,做起来太麻烦), 发现了一个可以完美实现我需求的方案,xiaozhi-esp32

作者开源了电路板方案、固件还有通信协议,甚至还有适配好有外壳的开发板可以购买。

我直接买了个立创·实战派ESP32-S3开发板的开发板(固件是一点都不想碰:D), 直接去开发对应的服务端程序了。

目前为止,家里24小时开机的设备,只有一台"星际蜗牛的黑群晖", 这上面还同时跑了一个"图书馆", 一个Home Assistant

这也注定了我不可能有太多资源来跑这个服务端, 所以我打算将目标内存限制在100MB以内。

当然这个内存限制直接就让我放弃了Python

最后我选择了我自己的网络框架来开发这个服务端, 这样也可以让我自己更好的了解大模型的调用方式。

100MB的内存太少了,他甚至都跑不起来一个VAD模型,更别提嵌入向量模型BAAI/bge-m3

因此在设计之初,我就已经打算非业务逻辑的功能全部独立出去,先本地部署,然后通过网络调用。

这样当我找到互联网上的免费平替后,可以直接替换。

也只有全部替换成功,这个服务端才有可能真正在"黑群晖"上跑起来。

以前从没来涉及过大模型相关的知识,比如怎么调用openai, 什么是嵌入向量,怎么做内容召回。

再加上,年后开工之后周末空闲时间也比较少, 所以一直到上周才算有了一个可以运行的版本。


整个系统跑下来分为这么几个部分。

语音唤醒和输入 (使用xiaozhi-esp32来实现)

这个没什么说的,直接使用官方的固件即可。

VAD 静音检查

当设备被唤醒之后,它会源源不断的发送声音片段给服务端。

如果服务端一股脑全部发给ASR服务,那这个价格是承受不起的。

所以一般都需要先用VAD模型找出有信息的声音片段, 再将这些片段发给ASR服务。

ASR 语音识别

拿到有信息声音片段之后,我们想知道用户说了什么,就需要使用ASR服务, 来将声音片段转换为文字。

这里真的要感谢腾讯,每月可以有5000次免费调用,对于个人使用场景足够了。

嵌入向量模型

在获取到用户的文本信息之后,在调用大模型之前,我们需要先召回与本次对话相关的聊天记录或知识。

这时就需要用到嵌入向量模型,将用户的问题转换为向量,然后去向量数据库中召回与该向量最相似的聊天记录。

这里要感谢SiliconFlow,他们提供了5种免费的向量模型,其实刚好有我需要的BAAI/bge-m3

向量数据库

为了召回记忆,还需要将聊天记录持久化,这里我选择了redis-stack, 巧的是redis-stack提供了30M的免费空间。

应该没有哪个话痨能聊满30M吧?至少我家小朋友暂时还没这个潜力, 尤其我还增加了记忆遗忘功能。

惟一的缺点是,公网链接redis可能会有延迟,不过等到延迟有明显影响时,再本地部署也来得及。

大模型

这里又要再次感谢SiliconFlow,他们提供了很多免费的小尺寸的大模型,在意图识别和参数提取等方面可以极大的降低延迟和成本。

甚至真正用来思考的全尺寸大模型,也可以用注册赠送的额度来使用。

直接使用官方DeepSeek V3价格也不是不能接受。

回答输出

大模型返回的回答以文字为主,语音大模型目前来看似乎不是主流。

因此我们还需要TTS服务,来将文字转换为语音然后发回给xiaozhi-esp32

其实这里有点纠结,因为国内给的额度都是一次性的,比如3个月多少字符,这种不具有可持续性,我更喜欢使用那种每个月有一定额度的。

所以最终注册了个Azure的账号,然后建了个Azure TTS F0层级的资源,然后使用Azure TTS的API来生成语音。

在试用了一周之后,发现Azure TTS由于节点不在国内,所以延迟非常高,甚至第一句话能延迟3s之多,这在语音交互中特别难受。

最后打算使用Edge TTS来生成语音,不过Edge TTS是逆向出来的API, 不是很稳定,只能先将就用了。


最后,谈谈对目前大模型的感受。

在做这个服务端之前,我其实预设了4个简单的Agent。 也许都不能够称为Agent, 因为它们没有Workflow, 仅仅是系统提示词和调用参数的不同。

  • 普通对话
  • 全科小学老师
  • 英语口语老师
  • 脑筋急转弯伙伴

其中英语口语老师脑筋急转弯伙伴算是彻头彻尾的失败了,因为他们并不是简单的问答模式。

英语口语老师需要先主动说出一句对话,然后小朋友回答一句,老师再点评, 这样循环。

在仅靠系统提示词的情况下,使用全尺寸DeepSeekV3模型,在一轮对话之后,指令的遵从性就变的非常差,有时甚至都不用一轮。

脑筋急转弯伙伴就更复杂,他有可能需要AI出题, 小朋友回答,然后AI再点评。或者小朋友出题,AI回答,小朋友再点评。

在这种情况下,AI甚至都坚持不到一轮对话结束,就开始胡言乱语了。

刚好最近看到一篇文章, 我觉得在目前的情况下,即使在有记忆的情况下,Workflow是不可取代的。

现在的大模型似乎很难尊从超出惟一要求之外的系统指令。

上面的英语口语老师脑筋急转弯伙伴之所以失败,是因为他们希望每次对话执行A,第二次对话执行B, 第三次对话执行C, 然后再从A开始。

最后,代码在此

谈谈代码设计中“严丝合缝”

作者 重归混沌
2025年3月14日 11:33

前几天修复了一个历史遗留 Bug,和同事讨论时他提到了一个词——“严丝合缝的设计”

这个词几乎瞬间击中了我,因为它与我过去坚持的设计理念完全一样,不过我将其称为“设计正确”。 但过往经验也证明,这类“设计正确”反而制造过不少 Bug,彼时我将问题归咎于“抽象不完善”。

支持这一理念的典型论据是:人体的各器官就是严丝合缝合作的。作为万物之灵的人类,值得我们从中汲取灵感。

现在重新审视这个问题时,我发现过去的理解过于片面了。

疫情期间学到的医学常识给了我新视角:人体各器官的合作并非绝对的严丝合缝,而是充满冗余和容错机制。最简单的例子,你把手指上的血管割破了,并不会造成你整个手指的缺血。

同样,若必须依赖“完美抽象”才能避免 Bug,这样的“严丝合缝的设计”真的是理想方案吗?


《程序员修炼之道(第二版)》中反复强调的核心观点是:代码应“易于修改”

无论是设计模式还是编程准则,终极目标都是应对需求变化。无法灵活调整的设计,终将沦为技术债务。

而我曾经的“设计正确”理念,本质是落入了契约式编程(Design by Contract)的陷阱

当时的逻辑是:A 模块调用 B 模块时已确保参数合法,因此 B 无需验证输入

尽管我早年就意识到“设计闭环”的重要性,但在实际编码时,“性能强迫症”仍驱使我走向极端。

近年来随着对性能执念的淡化,我开始反思:这种追求“设计正确”的代码真的易于修改吗?答案显然是否定的。

精密代码如同多米诺骨牌阵列,看似严谨,却可能因一处微小改动引发系统性崩溃。过去踩过的坑已充分印证了这一点


从“高内聚,低耦合”角度来重新审视这个问题。

若 B 模块要求调用方保证参数合法性,本质上已经形成了隐性耦合。用设计原则来描述,这违背了最少知识原则(Law of Demeter)

根据多年经验,实现“高内聚,低耦合”的关键只有一条:

设计模块(函数/类/服务)时,永远不要假设调用方会遵守任何约定

无论外部如何调用,模块内部必须维持自身数据一致性。

这既是我曾提过的“设计闭环”,也可称为防御式编程容错冗余

原则看似简单,实践却困难重重。

一个典型的开发场景是:先实现调用方 A,再开发被调用方 B。

当编写 B 时,由于已知 A 的实现细节,开发者会不自觉地利用这些信息优化 B 的性能

这恰恰是坏的味道的开始——尽管短期运行高效,却埋下长期隐患。

假设需求变更后,需要用新模块 C 替换 A:

由于 C 与 A 的相似性,开发者容易忽略细微差异。若 B 的实现依赖了 A 的某些隐性特征,便可能催生极难发现的边界 Bug。

更棘手的难题在于:容错冗余的粒度如何把握?函数级、类级、模块级还是服务级?这需要开发者基于场景反复权衡,而每个人心中的天平刻度未必相同。

我自己现在的准则是:

  1. 模块/类级/服务级:必须严格校验输入边界和状态约束,防止预期之外的调用弄乱内部数据。
  2. 函数级:可根据情况相信传入的数据,这也有利于我们放心大胆的对于函数进行抽象,而不必担心性能的销耗。

ps. 函数的抽象绝不仅仅是代码复用,甚至可以说根本不是为了代码复用——抽象的本质是建立认知边界。只有深刻理解这一点,才有可能做出更好的抽象决策。

一次艰难的线上游戏服务器内存排查经历

作者 重归混沌
2025年1月10日 16:41

TL;DR: 透明大页导致的。

我们的游戏服务器程序是采用Go程序编写的,后面在经过各种努力之后,终于将启动内存从350M降低到150M左右。

但是上线之后,奇怪的事情发生了。

由于一些原因,我们使用了两个云服务商来部署服务器。

一个空的游戏服进程, 在其中一个服务商的ECS上表现正常,即是正常的150+M内存, 但是另一个云服务商的ECS上,则启动内存有240+M之多,整整差了80M。

这我觉得非常诡异,我先是使用Go语自带的pprof拉了一下heap数据。两个进程的内存分配信息虽然不能说完全相同,但是也相关不大。

当然我知道,pprof是建立在应用程序的基础上进行的,也就是说内存管理那一块,其实是没法暴露出来的。

比如,在malloc之后,虽然调用了free, 但是你去top这个进程,一般来讲RES列不会立即变小,这和内存归还策略有关。

比如,每个操作系统线程也占了4M栈内存, 但是两个进程的线程数量并没有显著不同。

再比如,由于goroutine的调度问题,导致分配和释放顺序的差异造成的内存碎片率不一致,内存管理库有可能因为碎片的原因,无法归还大块内存。我查看了一下两台机器的CPU和内存,果然不一样。但是我仔细分析了一下,进程在启动阶段开启会分配内存的goroutine只有2~3个,理论上来讲就算对碎片率有影响,那也是有限,不可能会造成如此显著的差异。

于是我想,也许是进程打开了很多数据表文件,加载之后没有释放。由于操作系统页调度的原因,其中一个平台的操作系统将其从内存中移除了,所以表现的是150M。

我去查看了两台机器上进程的/proc/pid/smaps,发现并没有任何数据表文件的映射,我把别的文件映射大小全统计了一下,也不过几M而已。

最后,我其实已经没有办法了,去找ChatGPT找找灵感,他让我使用getconf PAGESIZE来查看一下系统的页大小。

当我看到这个答案时,我几乎觉得他就是正确答案,失望再次随之而来,两个系统的页大小均为4K

于是我将目光投向了claude, 再次问出同样的问题,并说出两个机器的页大小一致。

claude给了一个思路,让我将使用pmap -x <pid>来分别打印出来两个机器上的服务器进程,以期望能找出点什么规律。

我太懒了,所以我将pmap的输出结果直接丢给了claude让其对比,claude输出如下:

通过对比两个进程的内存信息,我发现了一些关键差异:

虚拟内存大小差异:

进程1: VmSize = 1762456 kB
进程2: VmSize = 1688404 kB
差异约 74MB,这与你观察到的 80MB 接近

实际内存使用差异:

进程1: VmRSS = 248608 kB

RssAnon: 191344 kB
RssFile: 57264 kB

进程2: VmRSS = 161064 kB

RssAnon: 101188 kB
RssFile: 59876 kB

主要差异来自:

匿名内存(RssAnon)相差约 90MB:191MB vs 101MB
线程数不同:进程1有12个线程,进程2有11个线程
数据段大小(VmData)略有差异:

进程1: 336904 kB
进程2: 328260 kB

然而这个信息仅能说明内存的差异真的是来自于内存分配造成的,而不是别的什么原因。

最惨的是,这时我的免费次数用完了,于是我又切回了ChatGPT

这一次我将两个进程的/proc/pid/smaps的输出喂给了ChatGPT

ChatGPT发现了一个有意思的结论:

这74M的匿名内存块的映射大小不一致,一台机器上的部分内存块竟然是另一台机器上的两倍左右(并不是所有内存块都这样,由于这是前几天的事情,具体数值我忘记了)。

然而事情也仅仅到此了,再继续询问ChatGPT,他也只会车轱辘话来回说,说明他已经黔驴技穷了。


两个小时之后,运维同学说,怀疑是透明大页导致, 两个云服务商的机器,一个开了透明大页, 另一个没开。

而开了透明大页的机器刚好就是进程内存占用过多的机器。

运维同学将机器配置改完之后,重启进程果然内存变得一致了。

事实上,这次的排查我费了很大的力气,从早上9:30就开始了,一直查到11:00多。

但是翻来覆去,一直将目光锁定在操作系统之内。而没有怀疑是操作系统本身的机制引起的。

甚至都没往PAGESIZE上面想,更别说透明大页了,这次的排查经历给了我很大的警醒,这几年我太聚焦业务之上的代码分析了,已经很少会将其和内核的一些特殊机制去联想了。后期需要改进一下。

如何基于LanguageServerProtocol来编写lint工具

作者 重归混沌
2024年12月18日 12:59

TLDR: 本文并没有任何设计思想,只是为编写基于LSP协议的自定义lint工具留下一丝线索,以便未来使用。

在最近这两年里,我断断续续在项目内写过不少针对项目内go代码的lint工具和代码生成器。

每次在编写lint工具时,就会发现如果能有语义的辅助就可以将lint做得更完美。

然而语义分析的复杂度和开发成本都太高了,所以每次最终都做成了基于AST的简易lint工具。

我时常在想,其实我不需要开发复杂的语义分析功能,只要我可以像vs-code那样,可以查找所有实现, 查找函数定义, 查找符号的所有引用, 我们编写出的lint工具的上限就会大大提高。

幸运的是我们只要编写一个headless的LSP客户端,便可以拥有vs-code中所有关于语义的能力。

这个想法其实我已经萌生了一年多,但一直没有动力和时间去尝试。

直到最近,我终于下定决心尝试一下。

虽然LSP有完整完善的文档,但是有时太全了也是一种负担。

因此这里还是记录一下常用的几个textDocument请求参数:

  1. textDocument/didOpen:
  • 用处: 当一个文件被打开时,通知gopls来初始化对该文件的语言服务支持。这包括加载文件内容,进行语法检查,并准备相关的编辑操作支持。
  • 参数:
    {
    "textDocument": {
    "uri": "file:///path/to/your/file.go",
    "languageId": "go",
    "version": 1,
    "text": "package main\n\nfunc main() {\n    // your code here\n}"
    }
    }
  1. textDocument/didChange:

    • 用处: 当文件内容发生变化时,通知gopls来更新对该文件的语言服务支持。这包括处理文本的增量变化,如添加、删除和修改代码行。
    • 参数:
      {
      "textDocument": {
      "uri": "file:///path/to/your/file.go",
      "version": 2
      },
      "contentChanges": [
      {
      "range": {
      "start": { "line": 2, "character": 0 },
      "end": { "line": 2, "character": 0 }
      },
      "text": "    fmt.Println(\"Hello, world!\")"
      }
      ]
      }
  2. textDocument/didClose:

  • 用处: 当一个文件被关闭时,通知gopls来释放对该文件的语言服务支持。这有助于释放资源并清理掉不再使用的文档信息。
    {
    "textDocument": {
    "uri": "file:///path/to/your/file.go"
    }
    }
  1. textDocument/hover:

    • 用处: 请求文档中的某个位置的悬停信息,通常用于显示鼠标悬停在一个标识符上时的相关帮助内容,如类型信息、文档注释等。
      {
      "textDocument": {
      "uri": "file:///path/to/your/file.go"
      },
      "position": {
      "line": 3,
      "character": 5
      }
      }
  2. textDocument/signatureHelp:

    • 用处: 请求函数签名的帮助信息,当光标停在一个函数的定义上时,会返回函数签名的详细信息和参数描述。
      {
      "textDocument": {
      "uri": "file:///path/to/your/file.go"
      },
      "position": {
      "line": 5,
      "character": 10
      }
      }
  3. textDocument/documentSymbol:

    • 用处: 获取文件中的所有符号信息,包括函数、类型定义等,这对于导航代码结构非常有帮助。
      {
      "textDocument": {
      "uri": "file:///path/to/your/file.go"
      }
      }
  4. textDocument/references:

    • 用处: 查找文档中某个标识符的引用,以帮助开发者了解代码中的使用情况。
      {
      "textDocument": {
      "uri": "file:///path/to/your/file.go"
      },
      "position": {
      "line": 7,
      "character": 0
      }
      }
  5. textDocument/definition:

    • 用处: 请求一个标识符的定义位置,以便快速导航到其实现位置。
      {
      "textDocument": {
      "uri": "file:///path/to/your/file.go"
      },
      "position": {
      "line": 8,
      "character": 5
      }
      }
  6. textDocument/codeAction:

    • 用处: 提供可供用户选择的代码修复建议,如重构、添加注释、优化等。
      {
      "textDocument": {
      "uri": "file:///path/to/your/file.go"
      },
      "range": {
      "start": { "line": 10, "character": 0 },
      "end": { "line": 10, "character": 5 }
      },
      "context": {
      "diagnostics": []
      }
      }
  7. textDocument/codeLens:

    • 用处: 请求代码镜头(Code Lens)信息,如测试覆盖率、TODO 标记等,这些信息直接显示在代码行的旁边。
      {
      "textDocument": {
      "uri": "file:///path/to/your/file.go"
      }
      }
  8. textDocument/documentFormatting:

    • 用处: 对文档进行格式化,以符合编程语言的规范。例如,调整缩进、空格、行末等。
      参数:
      {
      "textDocument": {
      "uri": "file:///path/to/your/file.go"
      },
      "options": {
      "tabSize": 4,
      "insertSpaces": true
      }
      }

下面我将使用上述textDocument请求,编写一个简易的headless客户端,来查找某个函数的所有引用。

首先确保已安装 Go 语言并配置好环境。然后安装 gopls

go install golang.org/x/tools/gopls@latest

在这个工具中,我们使用gopls来查询所有调用了rpc.Call的函数体。

代码如下:

package main

import (
    "context"
    "fmt"
    "io"
    "io/ioutil"
    "log"
    "os"
    "os/exec"
    "path/filepath"
    "strings"

    "github.com/sourcegraph/jsonrpc2"
)

type SymbolKind int
type SymbolTag int

type DocumentSymbol struct {
    Kind  SymbolKind `json:"kind"`
        Name  string `json:"name"`
        Location Location `json:"location"`
}

type Location struct {
    URI   string `json:"uri"`
    Range Range  `json:"range"`
}

type Range struct {
    Start Position `json:"start"`
    End   Position `json:"end"`
}

type Position struct {
    Line      int `json:"line"`
    Character int `json:"character"`
}

type LSPClient struct {
    cmd     *exec.Cmd
    conn    *jsonrpc2.Conn
    rootURI string
}

type stdioReadWriteCloser struct {
    stdin io.WriteCloser
    stdout  io.ReadCloser
}

var _ io.ReadWriteCloser = (*stdioReadWriteCloser)(nil)

func (c stdioReadWriteCloser) Read(p []byte) (n int, err error) {
    n, err = c.stdout.Read(p)
    return n, err
}

func (c stdioReadWriteCloser) Write(p []byte) (n int, err error) {
    return c.stdin.Write(p)
}

func (c stdioReadWriteCloser) Close() error {
    return nil
}

// 简单的handler实现
type handler struct{}

func (h *handler) Handle(ctx context.Context, conn *jsonrpc2.Conn, req *jsonrpc2.Request) {
    if req.Method == "workspace/configuration" {
        // 返回空数组表示不支持配置
        conn.Reply(ctx, req.ID, []interface{}{})
        return
    }
    conn.Reply(ctx, req.ID, []interface{}{})
    // 可以在这里处理服务器推送的消息
}

func newLSPClient(projectPath string) (*LSPClient, error) {
    // 启动gopls作为语言服务器
    cmd := exec.Command("gopls", "serve")
    cmd.Stderr = os.Stderr

    // 创建管道用于JSON-RPC通信
    stdin, err := cmd.StdinPipe()
    if err != nil {
        return nil, fmt.Errorf("创建stdin管道失败: %v", err)
    }
    stdout, err := cmd.StdoutPipe()
    if err != nil {
        return nil, fmt.Errorf("创建stdout管道失败: %v", err)
    }

    // 启动gopls服务器
    if err := cmd.Start(); err != nil {
        return nil, fmt.Errorf("启动gopls服务器失败: %v", err)
    }

    // 创建JSON-RPC连接
    // 创建连接,传入完整的参数
    conn := jsonrpc2.NewConn(
        context.Background(),
        jsonrpc2.NewBufferedStream(stdioReadWriteCloser{
            stdin:  stdin,
            stdout: stdout,
        }, jsonrpc2.VSCodeObjectCodec{}),
        &handler{},
    )

    // 转换项目路径为URI
    absPath, err := filepath.Abs(projectPath)
    if err != nil {
        return nil, fmt.Errorf("获取绝对路径失败: %v", err)
    }
    rootURI := fmt.Sprintf("%s", absPath)

    return &LSPClient{
        cmd:     cmd,
        conn:    conn,
        rootURI: rootURI,
    }, nil
}
func (c *LSPClient) initialize() error {
    params := map[string]interface{}{
        "processId": os.Getpid(),
        "clientInfo": map[string]string{
            "name":    "Lint",
            "version": "0.1.0",
        },
        "locale":   "zh-cn",
        "rootPath": c.rootURI,
        "rootUri":  "file://" + c.rootURI,
        "trace": "off",
    }

    var result interface{}
    err := c.conn.Call(context.Background(), "initialize", params, &result)
    if err != nil {
        return fmt.Errorf("初始化失败: %v", err)
    }
    log.Println("LSP服务器初始化1")
    params = map[string]interface{}{}
    err = c.conn.Call(context.Background(), "initialized", params, nil)
    if err != nil {
        return fmt.Errorf("初始化失败: %v", err)
    }
    log.Println("LSP服务器初始化2")
    return nil
}

func (c *LSPClient) findReferences(filename string, line, character int) ([]Location, error) {
    // 确保文件路径是绝对路径
    absFilename, err := filepath.Abs(filename)
    if err != nil {
        return nil, fmt.Errorf("获取文件绝对路径失败: %v", err)
    }

    params := map[string]interface{}{
        "textDocument": map[string]string{
            "uri": fmt.Sprintf("file://%s", absFilename),
        },
        "position": map[string]int{
            "line":      line,
            "character": character,
        },
        "context": map[string]bool{
            "includeDeclaration": true,
        },
    }

    var locations []Location
    fmt.Println("findReferences start")
    err = c.conn.Call(context.Background(), "textDocument/references", params, &locations)
    fmt.Println("findReferences finish")
    if err != nil {
        return nil, fmt.Errorf("查找引用失败: %v", err)
    }

    return locations, nil
}

func (c *LSPClient) shutdown() error {
    return c.conn.Call(context.Background(), "shutdown", nil, nil)
}

func main() {
    // 项目根目录
    projectPath := "/home/xxx/app"
    filename := "/home/xxx/app/common/rpc/rpc.go"
    line := 113    // 方法所在行
    character := 15 // 方法所在列

    // 创建LSP客户端
    client, err := newLSPClient(projectPath)
    if err != nil {
        log.Fatalf("创建LSP客户端失败: %v", err)
    }
    defer func() {
        if err := client.shutdown(); err != nil {
            log.Printf("关闭LSP服务器失败: %v", err)
        }
    }()
    fmt.Println("newLSPClient finish")
    // 初始化语言服务器
    if err := client.initialize(); err != nil {
        log.Fatalf("初始化LSP服务器失败: %v", err)
    }
    // 查找引用
    references, err := client.findReferences(filename, line, character)
    if err != nil {
        log.Fatalf("查找引用失败: %v", err)
    }
    // 打印引用位置
    fmt.Println("找到的引用数量:", len(references))
    for _, ref := range references {
        //根据行号获取函数定义
        req := map[string]interface{}{
            "textDocument": map[string]string{
                "uri": ref.URI,
            },
        }
        var syms []DocumentSymbol
        err = client.conn.Call(context.Background(), "textDocument/documentSymbol", req, &syms)
        if err != nil {
            log.Fatalf("查找引用失败: %v", err)
        }
        for _, sym := range syms {
            if sym.Location.Range.Start.Line <= ref.Range.Start.Line && sym.Location.Range.End.Line >= ref.Range.Start.Line {
                content, err := ioutil.ReadFile(sym.Location.URI[7:])
                if err != nil {
                    log.Printf("无法读取文件 %s: %v", sym.Location.URI, err)
                    continue
                }
                    lines := strings.Split(string(content), "\n")
                fmt.Printf("函数定义: %s\n", sym.Name)
                for i := sym.Location.Range.Start.Line; i <= sym.Location.Range.End.Line && i < len(lines); i++ {
                    fmt.Printf("行 %d: %s\n", i+1, lines[i])
                }
                fmt.Println("---")
            }
        }
    }
}

这个示例中,我们的headless将会查找所有调用rpc.Call的函数的函数体。

程序的输出大概如下:

行 137: func (m *Manager) xxx() {
                //此处省略51行
行 188:         rpc.Call(...) {
                //此处省略15行
行 203: }

当我们要编写lint工具时,可以再基于这些函数体来构造AST语法树进行lint

这会大大节省我们开发lint工具的难度,并提升lint工具的开发效率。

ps. 在我们使用jsonRPC来调用language server的过程中,还有可能会被language server调用,因此func (h *handler) Handle(函数才会无脑返回,不然我们jsonRPCcall也会被一直阻塞。

最近碰到的一个分布式一致性问题

作者 重归混沌
2024年10月4日 13:47

在之前的游戏设计中,好友功能只在同一服务器内生效,涉及到以下几个操作:

  • A玩家想加B玩家为好友,首先需要向B玩家发出好友申请
  • B玩家可以选择删除申请,也可以同意。一旦同意,AB的好友列表中都会同时显示对方。
  • 成为好友后,A每天可以向B送花,AB的关系会因此增加友情值。当B收到A的花时,双方的友情值也会增加。每日送花的次数会在0点重置,但累计的友情值不会清空。
  • 根据友情值的累积,AB可以领取不同档次的奖励,每个档次的奖励只能领取一次。

最近策划提出了一个新需求,要求去掉服务器之间的限制,使所有服务器的玩家都可以互加好友,且在操作上与同服好友无异。

面对这个需求,我有两个选择:

  1. 单独做一个服务,将所有好友关系集中管理。这样可以避免数据一致性问题。
  2. 扩展现有的本服好友系统,将跨服好友纳入其中,但会涉及到同步问题。

虽然单独做一个服务能够较轻松地解决数据一致性问题,但也带来了新的挑战:

  • 其他模块可能依赖好友关系,如果把好友关系放在一个单独的服务中,依赖此关系的业务需要异步查询,增加了不确定性。
  • 当玩家数量和在线人数达到一定规模时,如果需要进行水平扩展,数据一致性问题仍然会重新浮现。
  • 由于游戏已上线,如果跨服好友和本服好友分开存储,两个系统的融合也会带来不小的复杂度。

因此,我最终选择了扩展本服好友系统,让它能够接管所有好友关系,而不仅仅是本服好友。毕竟,“复杂度留给自己,为他人提供简洁的接口”才是最佳选择。


好友关系是强双向的,因此将好友关系存储在各自玩家的服务器中必然涉及数据一致性问题。

在处理好友申请同意申请时问题不大,这些操作可以很容易地设计为幂等操作。比如,B同意A的好友申请时,可以先向A所在服务器添加好友关系,成功后再从B的申请列表中删除关于A的申请。即便在中途协议超时,也可以通过重试机制来解决。

然而,送花收花友情值的处理让我陷入了困境。

因为送花只能每天进行一次,而收花必须在送花之后才能进行。这两个操作都和每日的0点相关联,意味着送花操作无法保证幂等。

不过这也不是很重要,因为其最终的导向是友情值,而友情值才和奖励挂勾。

我最初的想法是,送花收花完成时,先在本地计算出最新的友情值,然后将这个值同步给好友的服务器。

但我很快发现这个策略存在问题:AB送的花会增加AB之间的友情值,而BA的花也会增加同样的友情值。如果双方同时进行送花收花操作,友情值可能会被相互覆盖。


这就像是数据库的双主机制,问题开始变得棘手。

经过与同事讨论。他建议不再进行数据同步,当需要使用友情值时,直接从对方服务器获取对方操作产生的友情值,然后与本地的友情值相加。

我很快否定了这个方案。

因为玩家登录时,客户端需要拉取好友列表进行红点提示或其他展示。

如果按照此方案,我必须查询所有好友所在的服务器。

最坏的情况是,有50个好友分别在50个不同的服务器,这会导致协议请求数量放大50倍。

尤其是停服维护再开服时,这个瞬间的消息量是不可接受的。

这些年我刻意对抗性能强迫症时养成了一个习惯,就算一个方案在性能上不可行,我也会忽略性能来思考他的结果究竟是不是对的,有没有可能达成更简洁的抽象。

经过仔细的思考之后发现,虽然性能问题让这个方案行不通,但它揭示了友情值机制的底层逻辑。

通过简化,我们可以将操作归纳为以下公式:假设玩家AB友情值F

  • A一次送花产生的友情值为a1,一次收花产生的友情值为a2
  • B一次送花产生的友情值为b1,一次收花产生的友情值为b2

那么,F的最终值就是a1 + a2 + b1 + b2

从公式中不难看出,F是由AB各自操作产生的友情值之和。

因此,我们无需在用到友情值时查询对方服务器,而是将友情值拆分为F_AF_B,在需要时将F视为F_A + F_B

这样,A送花时可以本地更新F_A,并尝试将F_A同步到B所在的服务器。B的操作也是如此。

这解决了双主问题,两人分别同步自己所有权的数据,即使同时操作也不会产生冲突。


ps. 这篇博客之所以值得一写,是因为在扩展已有代码时,很容易陷入旧有的思维框架,难以跳脱出来。当发现数据所有权冲突时,如果我们仔细思考一下,这块数据中,到底哪部分该由管理,也许就可以解决双主问题。

pps. 解决完所有权问题,之后的解决方案又回到了我之前提到的本地事务 + 重试的分布式编程范式。

ppps. 在游戏分布式编程中,强一致性几乎是不可能的,最终一致性才是我们追求的目标。

谈谈游戏服务器的自动化测试

作者 重归混沌
2024年7月7日 21:06

当我们要测试一个’算法’是否正确时,常常会打开代码编辑器为其编写测试代码。

这种测试往往被称为单元测试,即测试相对独立的最小单元。

由于算法往往都是一个独立的个体,没有依赖,因此很容易为其编写测试代码。

这里的“容易”指的是,我们除了设计testcase之外,不会有额外的心智负担。

当我们开始为业务逻辑编写测试代码时,其复杂程度往往会是我们放弃的开始。

举个例子,class A依赖class Bclass C

当我们为class A编写测试代码时,为了解决class Bclass C对测试结果的干扰,或者验证class A执行结果的正确性,往往需要为class Bclass C编写mock class。

这意味着我们在每写一个class的同时,也需要维护至少一个对应的mock class,以方便依赖的模块来编写测试代码。

在一些编程语言中,还意味着我们需要为每个class提供一个interface的定义。即,class A依赖的是interface Binterface C,而class Bclass C只是interface Binterface C的实现。不然无法实现测试时注入mock class的需求。

我简单尝试了一下,很快便放弃了。

一是因为它对业务有侵入性,需要强制定义interface;二是因为我认为,为每个class维护一个mock class,工作量太大了。

后来我尝试对这种测试思路做出一些简化。

在编写测试代码之前,我会先人工分析出这些class之间的依赖关系。先为依赖链中的末端class编写测试代码。

当某个class的测试代码成功执行后,就认为这个模块是正确的。然后再依次向上进行测试,直到测试完依赖链的顶端。

在这个过程中,我不会提供mock class,而是直接读取到所需要的class中的内部状态来验证结果是否正确。

以上面的依赖关系为例,我会先为class Bclass C编写测试代码。

当我认为class Bclass C没有bug时,就会开始为class A编写测试代码。

为了验证class A结果是否正确,我会直接读取class Bclass C的内部状态来验证。

但这依然不足以让我为游戏服务器编写测试代码,因为游戏服务器是有状态的,而且依赖关系远比我想象的更复杂。

有一天,我突然灵光一闪,想看看luaredis是怎么来做测试的。

我发现它们也没有mock class(这里的class仅代表某个代码单元,不局限于语法)。

但是作为互联网开源软件,它们的测试是非常到位的,不然无法保证质量。

它们采用的方式都很类似,都是针对特性进行测试,而不是某个函数或class

lua作为语言虚拟机,它是没有API的,但为了帮助测试,它会在测试模式下导出一些API用于感知当前luaVM的内部状态。

redis作为一个单独的服务,它是从协议层面去测试每一个特性的。

这给了我很大的启发。我想游戏服务器也是需要从协议层面去测试所有功能的,虽然这样代码覆盖率没有单元测试那么高,但想必对代码质量也是有足够帮助的。

既然通过协议来对游戏服务器进行测试,那就势必要启动真正的游戏服务器,这就会导致更多的外部依赖:时间配置数据库

  • 时间会要求我们在测试某个功能时,服务器时间必须处于某个指定的时间区间。

  • 配置会要求我们在测试某个功能时,某个配置必须包含多少条数据或什么数值,以便测试一些corner case

  • 数据库会有持久化问题,因此某个功能测过一遍之后,状态就被保存下来,下次就无法再次触发了。

这些外部依赖无一不在揭示着“测试代码不能重复执行”这一问题。

为了解决这个问题,我当时的设想是,每测试一个功能就将数据库清空,然后时间修改到本功能所需要的时间,同时为每一个testcase保留一份配置用于测试。

但随之而来的问题就是,因为清档了,所以每执行一个测试用例,都必须要创建新号。

而测试A模块时,它可能对B模块的数据有依赖,否则协议就无法成功执行。

我最初的想法是,在测试代码库中提供很多辅助条件函数。

随着testcase的增加,需要新增的辅助函数将会越来越少。

比如我要测试A模块,它需要玩家等级达到35级,那么就有一个辅助函数,它可以将玩家升到指定等级(是通过正常协议途径,而不是GM直接修改,因为升级有可能还会触发连带效应)。

后面再依赖玩家等级的testcase就不需要再编写这个辅助函数了。

这虽然很麻烦,但理论上它是完备的,可以测试任意代码。

为了验证这一想法,我挑了一个SLG的核心地图玩法,为其编写了测试代码,来证明它的可行性。

事实证明,这确实是可行的,但也确实是麻烦的。

因为我不仅遇到了为玩家升级,还碰到了为英雄升级,为技能升级,装备技能,操作队伍等一系列的测试辅助需求。

我想朝着这个方向继续简化,却一直没有头绪。


随着这两年我对游戏服务器理解的加深,以及见到了不一样的GM创号思路,我发现我终于可以简化这一流程了。

首先,我之前为每个testcase保存一份配置表是没有必要的。

一般来讲,每个模块只会需要有限的几个表或几行数据。

我们只需要在测试框架中提供修改配置表的能力即可。

在运行测试代码前,可以直接将配置改成测试代码需要的数据即可。

其次,为了解决模块间的数据依赖,使用辅助函数达到本模块要求是可行的,但不是必要的。

据我这两年的观察和反思,模块间的依赖其实没有那么紧密,大部分情况下只要分为达到达不到即可。

比如我需要玩家等级为30级,那么100级也是满足的。

因此提供一个GM指令,创建一个足够高级的号就足够了。

如果恰好我们需要测试的模块也被高级号给初始化了,我们的testcase代码可以直接操作数据库,清空这部分数据,然后重新登录即可。

lua在测试模式下导出一些API用于感知当前luaVM的内部状态,也给了我一些启示。

我们可以增加一个指定GM指令用来获取玩家或全服的状态,用于我们在测试代码中感知游戏服务器进程中的准确状态。

如果游戏服务器采用了分布式技术,还需要提供一个透传GM指令,用于获取分布式系统中某个服务进程中的状态。

除此之外,测试框架还需要提供重启集群和动态修改时间的能力。

总结如下,测试框架只需要提供如下能力即可满足绝大部分测试需求:

  1. 创建高级号的能力

  2. 直接访问数据库的能力

  3. 修改配置文件的能力

  4. 修改服务器时间的能力(包括进程运行时修改)

  5. 重启集群的能力,用于每测试一个用例,都清档重启

  6. 感知集群内进程状态的能力

至此,经历七年的思考,我认为这个测试方案终于达到了可以实践的阶段。

ps. 本文是我在实践2个月之后写出的,基本上可以确定心智负担极小。

pps. 让我们将视野拉高,由于每次测试都将数据库清空,因此数据库中的数据也可以等价于内存结构。与此同时,很多单元测试的技巧也可以拿来使用。比如要保持测试用例尽可能的小(一旦测试用例过于复杂,测试用例的正确性就不能得到保证)等。

对Raft协议的一点理解

作者 重归混沌
2024年4月19日 09:51

最近终于给silly实现了etcd driver

下一步就要基于etcd来实现我去年提到的分布式框架了

但在此之前,我需要知道etcd的边界在哪里,他是如何保证一致的,是否有幻读等问题。

由于etcd采用了raft共识算法,所以我需要了解raft的一些基本概念。

最近懒得阅读英文文章,因此在学习raft之前,我先查了一大堆中文资源,最后我都不太满意。

最终只能去啃CONSENSUS: BRIDGING THEORY AND PRACTICE, 于是就有本文。

CONSENSUS: BRIDGING THEORY AND PRACTICE中几乎介绍了需要实现raft中的方方面面,但我并没有看完,因为我只是想了解raft算法的边界。

因此本文只是按我的理解,从leader选举日志复制安全性三个方面来描述一下raft算法,其中我会着重描述安全性

因为我查到的所有中文资料中,对安全性的描述,我最不满意。


raft使用心跳机来触发leader选举,选举机制如下:

进程状态分为followercandidateleader三种。

所有服务器启动时都默认是follower状态。

follower接收到leadercandidate进程的有效RPC请求时,会保持在follower状态。

leader会定时向所有follower发送心跳包(没有日志条目数据的AppendEntries RPCs即为心跳包)来保持他的权威性。

如果followerelection timeout时间内没有收到心跳包,那么follower就会发起一次选举,来选择新的leader

整个选举流程如下:

  1. 一个folllower增加它的当前term(任期)并转换为candidate状态。
  2. candidate给自己投票,并向所有其他服务器发送RequestVote RPCs
  3. candidate 一直会保持在candidate状态,直到以下三种情况之一发生:
    • 得到了超过一半的服务器的投票,此时candidate会转换为leader状态。
    • 另一个candidate赢得了选举,此时candidate会转换为follower状态。
    • 选举超时,此时candidate会增加term并重新发起选举。

每个服务器在给定的term(任期)内按照先到先得(安全性部分会增加一些额外的限制)的原则, 只能投出一票。

超过一半原则会保证在一个特定的term内,只有一个candidate会胜出成为leader

一旦一个candidate胜出选举,他就会成为leader,并向所有服务器发送心跳包,来宣布他的权威性,并阻止新的选择。

在等待投票的过程中,一个candidate可能会收到其他服务器S发送AppendEntries RPC消息来声明服务器S(自己)成为leadercandidate就会检查服务器S发送的term是大于等于自己的term

如果是,那么candidate就会转换为follower状态,否则就拒绝这个RPC请求,然后继续保持在candidate状态。

可以看到整个选举过程,本质上就是使用paxos算法就term的值达成一致的过程

正如paxos一样,raft的选举过程也会产生活锁, 即永远都无法选出leader

raft为了解决这一问题,引入了随机化选举超时时间,这样就最大话限度地避免活锁


再来看看日志复制是怎么进行的,自此raft就和paxos没有任何关系了。

raft日志复制是基于quorum来保证可靠性的。

一旦一个leader被选出,他就会开始接受客户端的请求。

每个客户端请求包含了一个由复制状态机执行的command

复制状态机可以按日志写入顺序,从前往后执行日志中记录的command,以得到最终的结果。

例如:复制状态机(set x=1),(set x=5)的顺序执行完command, 那么x的最终值就是5。这也意味着只要集群中各节点中日志一致,各节点中关于某个变量的值都是一致的。

leader先将这个command作为一条新的日志(log entry)追加(append)到自己的日志中。然后对其他服务器并行发出AppendEntries RPCs,要求他们也将这条日志追加到自己的日志中。

当有超过半数的服务器都成功的将这条日志追加到自己的日志中,那么leader就会将这个日志条目应用到自己的状态机中(这就是大名鼎鼎的quorum)。

此时这条日志就处于committed状态, leader已经可以将状态机中的结果返回给客户端了。

在将结果返回给客户端之后,leader会不停的使用AppendEntries RPCs来重试直到所有followers最终都保存了所有的日志。

需要强调的是,这个committed状态是针对leader来说的,当一条日志处于commited状态,就意味着这条日志产生的结果可能已经被返回给客户端了,因此不能丢失。这一点对于后面理解安全性部分很重要。

raft保证所有处于commited的日志都会持久化,并被所有可用的状态机被执行。

整个log的组织方式如下图所示,每条日志(log entry)都会有一个惟一id, 这个idtermlog index组成,其中term就是产生本条日志的,当期leaderterm值。

当一条日志处于commited状态时,leader会将这条日志之前的所有日志都commit, 包括前任(整个集群中所有之前的)leader所产生的日志。

leader会始终记录当前已经处于committed中日志最大log index(max_log_index),这个索引会通过下一次AppendEntries RPCs(包括心跳)发送给followers

当一个follower知道一条日志处于committed状态时,他就会顺序的将这条日志之前的所有日志都应用到本地复制状态机

这条规则对于一致性没有任何帮助,只是为了防止当leader宕机时,follower成为leader后,follower能够快速的将自己的状态机和leader的状态机保持一致。

raft的日志机制会使得不同服务器间的日志保持了高度一致,这不仅会简化系统行为,而且会使结果更加可预测,也是确保安全性的重要组成部分。

raft日志有如下属性:

  1. 如果在两个服务器中的日志有相同的log indexterm,那么这两个日志包含相同的command
  2. 如果在两个服务器中的日志有相同的log indexterm, 那么这两个服务器中,这两条日志之前的所有日志,都是相同的。

第一个属性来源于这样的一个事实:leader在一个term内,对于给定的log index最多只能产生一条日志。

第二个属性则被AppendEntries中执行的一致性检查来保证。当发送一个AppendEntries RPCs时,leader会将紧接着本次新增日志(可能有多条)的上一条日志的log indexterm,发送给所有follower,如果follower发现自己不存在同样的日志,则他会拒绝这条新增日志。

举个例子,当leader发送了一个(last_term=2, last_log_index=1, entries=((term=2, log_index=2, command="x=1"),(term=2, log_index=3, command="x=2")))AppendEntries RPCs时,follower会检查自己的日志列表,如果发现自己的日志列表中不存在(term=2, log_index=1)的日志时,follower就会拒绝这个RPC请求。

正常情况下,raftleaderfollower之间的日志列表是一致的,但是当leaderfollower之间产生一系列的crash之后,日志列表可能就会变得不一致。

下图展示了一些leaderfollower之间的日志列表不一致的情况, 一个follower可能会丢失已经存在于leader日志列表中的日志,也可能会拥有不存在于leader日志列表中的日志。

raft中,leader会通过强制所有follower来复制自己的日志列表来保证一致性。

leaderfollower中的AppendEntries被拒绝时,leader会不停的向历史日志回退,直到找到第一个和follower相同的日志(term=T,log_index=L),然后使follower删除日志(term=T,log_index=L)之后的所有日志,并复制从leader中日志(term=T,log_index=L)之后的所有日志。

leader会通过找到和被复制的follower中最新一条相同的日志,然他开始往后复制,直到复制完leader中所有的日志,至此leaderfollower的日志列表就保持了一致。


上面的日志机制只保证了可以leaderfollower之间的日志列表保持一致,但是并不能保证已经处于committed的日志不会丢失。

下面来看看安全性是怎么做到这一点的。

raft规定,如果一个caididate没有包含所有已经处于committed的日志,那它就不可能在选举中胜出。

一个candidate为了被选举成功,必须要联系超过半数的服务器,这意味着,所有已经处于committed的日志至少会存在于被联系的一台服务器之中(同样基于quorum)。

这里需要重申一下,这个处于committed的日志仅仅指,他被前任leader已经应用到状态机并将结果返回给了客户端的日志。

并不是leader通过AppendEntries RPCs同步过的,已经处于committed中日志最大log index(max_log_index)

这一点需要思考一下才能得出结论,也是所有中文资料最模糊的地方,也是很关键的地方。


我们来看一个反例

假如所有的follower都维护了一个committed index, 这个committed index来源于每次leader发送的AppendEntries RPCs中的最大的已提交日志的log index

整个集群中日志内容如下, S1leader, 他已经将(term=1, log_index=2)复制到了大多数服务器中,因此S1已经将(term=1, log_index=2)标记为committed,并且为客户端返回了结果。

S1 (term=1, log_index=1, command="x=1"),(term=1, log_index=2, command="x=2")
S2 (term=1, log_index=1, command="x=1"),(term=1, log_index=2, command="x=2")
S3 (term=1, log_index=1, command="x=1"),(term=1, log_index=2, command="x=2")
S4 (term=1, log_index=1, command="x=1"),
S5 (term=1, log_index=1, command="x=1"),

根据规则,本次提交的log_index将会通过下次AppendEntries RPCs同步给所有follower

因此S2,S3,S4,S5服务中,此时leader已提交的最大的id(term=1,log_index=1)

如果此时S1 crash, S5当前持续的最大日志id(term=1,log_index=1),是大于等于S2,S3,S4的最大日志id的,因此S5有资格成为leader

这种情况下已经处于committed状态下的日志(term=1, log_index=2, command="x=2")就会丢失。

因此,如果一个caididate没有包含所有已经处于committed的日志,那它就不可能在选举中胜出只是一个必要条件,并不是用来实现的充分条件。


事实上,当一个candidate发出一个RequestVote RPC时,他会带上自己日志列表中最新日志的(term,log_index), 而集群中别的服务器收到RequestVote RPC时,会拿这个(term,log_index)和自己的最新日志(term,log_index)进行比较,如果发现自己的日志比candidate的日志(通过比较(term,log_index)二元组来确定),那么就会拒绝这个RequestVote RPC

由于RequestVote RPC会发给半数以上的服务器,因此当candidate胜出时,他必然会已经包含了所有已经处于committed的日志,不然他就收不到超过半数的投票。

之所以上面说是一个必要条件,是因为candidate也有可能包含超过已经处理committed的之外的日志,这些日志是否需要恢复会产生一个更微妙的问题。

这就需要再次增加一个限制条件来达成一致性,leader只能通过当前日志是否已经在集群中被复制超过半数来判断是否应该提交自己的当期日志,而不能用来判断是不是应该提交前任的日志。

下图展示了为何leader只能主动提交自己当期的日志。

假如在图(c)中,S1过度到term=4,并且继续提交(term=2,log_index=2),此时(term=2,log_index=2)在整个集群中的复制数量已经超过半数,因此S1可以将这条日志标记为committed

然后S1生成了一条新的日志(term=4,log_index=3), 在还没有来得及复制给follower之前crash掉了,此时S5term=3的情况成为leader, 由于他本地最新的日志为(term=3,log_index=2), 因此他可以胜出获得超过半数的投票。

此时他会强制所有follower来与自己保持一致,会覆盖掉已经处理committed状态下的日志(term=2,log_index=2), 这违反了raft的保证。

这里其实还隐含了一些更微妙的问题。在刚开始阅读原文时我没有注意到。

从图(c)之后,假设S1接受了(term=4, log_index=3)的请求并开始复制,他会先复制(term=2, log_index=2),然后再复制(term=4, log_index=3)

如果在集群中机器超过半数都已经复制到了(term=4, log_index=2), 但是在复制(term=4, log_index=3)时,S1再次crash了。

虽然(term=2, log_index=2)在集群中的复制数量已经超过了半数,但是由于(term=4, log_index=3)还没有处于committed

因此(term=2, log_index=2)还不能被标记为committed, 因此(term=2, log_index=2)是可以被别的leader覆盖掉的。

至此,raftleader选举日志复制安全性三个部分算法总算描述完了。

2023(完)

作者 重归混沌
2024年1月1日 20:52

2023年又已经走完了最后一秒。相比2022年,技术上的收获更少了,令我醍醐灌顶的突破已经很久没有出现过了。

回顾历年来的blog,我发现这几年的技术增长趋势越发的平缓,2023年更是几乎趋近于直线。

虽然技术突破几乎没有,经验总算还有一点点增加。

虽然现在工作语言已经切换为Go语言,但是我依然没有放弃对Silly的开发。

我常常反思并比较,,探究使用silly来实现工作中的业务逻辑的优势和劣势。

在这个过程中,我发现即使Silly框架只存在竞争而非并发问题,抽象出一个mutex也能大幅减轻业务逻辑的抽象负担

在为Silly增加对prometheus的支持时,我也发现了自己的一个盲点

尽管我对操作系统和硬件电路的宏观运作流程有清晰的认识,但对具体细节却知之甚少。

这个缺点或许与我多年来的学习习惯有关,我总习惯于先了解全局再深入细节,但现在看来应该适时深挖一下操作系统相关的知识了。

在过去一年的业务开发中,收获不多,能够抽象成通用模式的更是只有一种而已,那是一种在分布式中如何稳定同步数据源的思路。

我继续对silly增加etcd支持的过程中,我下定决心为timer增加了取消功能。值得一提的是,增加这个取消功能在实现上并没有花费太大代价。

在编写Go语言代码的过程中,我最羡慕的不是他的各种特性,而是其便捷的性能分析器。

有了这个性能分析器,我可以更从容地遵循过早的优化是万恶之源这一箴言。

因此,我非常期待也能在编写Lua代码时也可以Don't do it yet.

正巧我也有学习eBPF和Rust的需求。因此,我决定使用Rust编写eBPF代码,以实现Lua性能分析器的功能。 当然,同时学习两种未知领域的过程必然充满挑战,但痛苦终究是短暂的。

除了上述稍显具体的收获外,还有一种无法言喻的成长,那就是我的“性能追求症”得到了极大改善。

在工作中,由于一些特殊限制,我经常会编写那些以前因时间复杂度较高而避免的代码。

然而这些代码上线之后并没有出现性能问题。这大大增加了我对过早的优化是万恶之源的信心。

除此之外,我对游戏服务器框架有了新的理解。

我认为整个游戏服务器其实只需要分为Role ServerGlobal Server就够了。

Role ServerGlobal Server只是代表数据的职责,并不局限于某种具体的业务逻辑。

Role Server只用来处理所有和玩家自身相关的数据,而Global Server用来处理玩家和玩家之间的交互数据。

当然这只是一种猜想,如果有可能我2024年会实现出来以做验证。

虽然过去任务总是没法完全完成,但是这并不能阻止我定下2024的目标:

  1. silly增加完etcd相关的支持。
  2. 实现并验证上述服务器框架的猜想。
  3. 探索如何将这套框架实施在K8S上,以学习K8S。
  4. 继续优化silly的log模块,学习ELK,尽量能实现建议的分布式追踪功能。
  5. 如果还有时间,继续学习ECS,研究ECS在网络同步和服务器端的应用。

再次实现了一个Lua性能分析器

作者 重归混沌
2023年10月1日 17:38

去年学习Go语言时,有位同学说了一句让我至今仍深刻记忆的话:“我们有足够多的工具来进行性能分析,以找出性能问题的根源”。

后来我发现,Go语言的性能分析工具确实非常强大。更重要的是,它被设计成可以直接在生产环境中采样线上数据。

然而,当我写Lua代码时,我并没有自信能说出同样的话。尽管我之前曾多次实现Lua性能分析器。

这些分析器的实现原理与gprof类似,只是细节略有不同。在代码块进入时记录函数的进入时间,在退出时统计函数的执行时间和执行次数。

为了准确评估rpc:call等函数的CPU时间,还添加了一个选项用于去除coroutine的让出时间。

然而,这些性能分析器存在一些缺点:

首先,它们对宿主程序的性能影响很大。在以函数为区间进行耗时统计时,甚至可能达到1000%的性能影响。因此,不能在线上环境中使用,只能在开发期进行自测。

其次,它们只能统计Lua函数(包括C编写的闭包和lightCFunction),无法统计C模块内部的C函数开销。而使用其他C性能分析工具时,也无法分析与Lua函数相关的耗时。这在进行性能分析时会导致非常不连贯的感觉。

此外,当使用C的性能分析器进行分析时,我们会失去上下文信息。由于Lua是用C语言编写的虚拟机,当我们发现某个C函数的耗时很高时,无法确定是哪段Lua代码导致的。例如,当发现tremove函数的CPU使用率很高时,无法知道是哪段Lua代码引起的。

最后,这些性能分析器是实现在宿主进程中的。如果宿主进程陷入死循环,将无法获取任何性能分析数据。

但当时我并没有找到解决以上问题的好办法,直到最近我开始研究eBPF,我终于觉得自己可以解决这些缺点,并且实现一个和Go语言类似的性能分析器。

现在回想起来,已经过去一年了。


新的性能分析器和Go的性能分析器一样基于栈采样技术,这样可以做到对目标程序的性能影响最小。

和Go不同的是,我这次实现的Lua性能分析器和linux下的perf一样,是一个独立的程序。

这样可以做到对目标程序无侵入,并且在目标程序死循环的情况下,依然可以正常运行。

按照最初的想法,这并不是一件太困难的事情。只需要在bpf程序中获取CcallstackLuacallstack,然后在用户空间将它们合并。

最后,按照火焰图的格式进行输出并生成火焰图。

整个过程并不复杂。

然而,当我开始实际实现时,事情的发展远远超出了我的预期,整个过程触及了我知识的盲区。

我本以为eBPF发展了近9年,在内核空间获取Ccallstack应该只是一个API的事情。然而,现实却给了我一个沉重的打击。

现代编译器只要开启优化,默认情况下会抹去栈帧指针。而bpf中的内置API只能在栈帧指针保留的情况下轻易获取整个callstack

我面临两个选择:要求被性能分析的进程在编译时必须使用-fomit-frame-pointer编译选项,或者我必须手动进行栈回溯。

我的目标是对目标程序进行无侵入性能分析,我认为强制要求目标程序使用-fomit-frame-pointer也是一种形式的侵入。

目标程序需要不断忍受-fomit-frame-pointer带来的性能损失。

而且,我无法要求像libc等系统提供的so文件必须保留栈帧指针。

于是,我只剩下一种方案,就是手动进行栈回溯。

手动进行栈回溯也有两种方案。

一种是在bpf程序中将目标进程的完整栈数据复制到用户空间,然后使用libunwind进行栈回溯。

另一种是直接在bpf程序中进行栈回溯,并统计调用栈的出现次数,然后只将统计结果发送回用户空间。

很快,我否定了第一种方案。

bpf最初的目的是用于过滤网络数据包,因此eBPF程序应该基于此设计思路。

即在bpf程序中处理和加工所需的数据,然后只将需要的数据传回用户空间。

而且,如果我们在用户空间进行栈回溯,由于ringbuffer的异步性,我们无法及时采样到与收集到的栈数据相匹配的Lua调用栈(因为我们需要先回溯完Ccallstack才能获取L指针,然后再对L进行栈回溯,而这期间目标程序的Lua调用栈早已经发生变化)。

对已经抹去栈帧指针的callstack进行手动回溯,完全触及了我知识的盲区。

最初,我考虑仿照gdb的方案,通过调试信息进行栈回溯。

但是,调试信息的数据量太大,不方便传送到内核。而且,解析调试信息这样的任务也不太适合由bpf完成。

一个偶然的机会,我发现了elf文件中的一个名为eh_framesection,全名为exception handling frame。它被设计为在发生异常时为栈回溯提供完整的信息,这恰好符合我们的需求。

eh_frame是由一系列CFI指令组成的,用于为栈回溯提供指导信息。这些CFI指令按函数顺序执行,即程序执行到某一行代码时,要回溯所有寄存器的状态,需要执行函数开始到该行代码之前的所有CFI指令。

幸运的是,在回溯时我们只需要获取callerEIP和包含luaState *L变量的寄存器的值,因此可以忽略大多数寄存器的回溯信息。

为了加快bpf程序的执行速度,我们可以在将eh_frame数据发送给bpf之前进行预处理。

通过模拟CFI指令的执行,我们可以获得每行汇编对应的所有寄存器的回溯信息。

这样当我们在bpf中获取到对应的EIP时,可以使用二分查找快速获取所有寄存器的回溯规则。

为了更好地利用缓存,我们还可以生成一个类似于eh_frame_header的数组,只包含EIP,专门供bpf程序进行二分查找。

一旦获取到索引,我们可以再查询真正的eh_frame信息。


尽管上述方案没有问题,但它忽略了一个条件。

我们从elf文件读取的是相对虚拟地址(PC),而在bpf程序中获取的是经过内核映射后的绝对虚拟地址(VA)。

在对eh_frame进行预处理时,我们需要将其中的PC转换为VA。这就需要使用到elf文件的Program Header Table信息。

Program Header Table提供了整个elf文件在进程空间中映射的分段情况。根据/proc/<pid>/smaps中的映射信息,我们可以将PCVA进行转换。

具体的转换逻辑是,当<program Header Table>.p_offset/proc/<pid>/smaps中的offset相同时,表示它们属于文件的同一映射区域。

一旦我们获得了eh_frame中的PC,只需计算其在ELF文件映射块中的偏移量,加上/proc/</pid><pid>/smaps中的映射基地址,即可得到PC在进程空间中的绝对虚拟地址(VA)。

现在,我们终于可以在bpf程序中进行C的栈回溯了。


Lua的调用栈相对简单,只需要遍历整个L->ci链表即可。

但是有一个特别的问题,由于Lua中的函数都是动态的,有可能某个函数在当前分析的时刻存在,但过一会就被垃圾回收(GC)掉了。

因此,在回溯Lua的调用栈时,我们需要保留当前的所有文件信息,否则稍后可能就无法获取它们了。

然而,直接在Lua的调用栈中存储文件路径和行号会浪费大量空间。

简单计算一下,如果我们要支持的最大Lua调用栈深度为128,并且每个文件路径的最大长度为64字节,那么每个调用栈就需要浪费128 * 64 + 4个字节的存储空间。

这种存储量级是不可接受的,并且在对调用栈进行计数时,也会导致性能严重损失。

为了简化设计,我在bpf程序中创建了一个字符串映射表strings

在回溯Lua调用栈时,我们通过strings将字符串转换为uint32_t类型的id,然后使用id << 32 | line_num来构建一个虚拟地址。

为了标记这是一个合成地址而不是真实的虚拟地址,需要将即最终结果修改为(uint64)id << 32 | line_num | (1 << 63)

这种方法的之所以有效,是因为在于用户空间,地址的bit63永远为0

值得注意的是,strings是一个哈希表,因此无法保证冲突永不发生。

当字符串冲突时,我们将旧字符串和对应的id发送回用户空间,让用户空间进行存储,并为该槽位分配一个新的id

我们利用了一个事实,Lua中的大部分函数都是常驻的,因此它们的源文件TString指针很可能是相同的。

尽管冲突存在,但我们并不太关心它们。因此,我们可以将源文件TString的指针视为该字符串的哈希值,当哈希值不同时,我们直接认为这是两个不同的字符串。


bpf_helper中,有一个辅助函数bpf_get_stackid可将整个callstack映射为一个id。这对于生成火焰图非常有用。

由于我们正在手动进行栈回溯,我们需要自己实现该功能。然而,这也带来了一些好处。

通过与用户空间进行通信,我们可以利用用户空间的大内存来支持我们做一些bpf_get_stackid做不到的事情。

首先,我们需要定义一个名为stacks的哈希表。

当我们获取到一个栈回溯数据时,我们同时计算内核空间调用栈用户空间调用栈Lua调用栈的哈希值。然后,根据哈希值来确定stacks中对应的槽位。

如果槽位上已经有值,我们将比较它是否与当前的callstack相同,如果相同则数量加一。

如果不同,bpf_get_stackid将选择要么丢弃当前槽位上的旧callstack,要么丢弃新插入的callstack

由于我们可以与用户空间进行通信,我们可以选择将旧的callstack发送回用户空间,并让新的callstack占据槽位。


Lua调用栈和C调用栈也不是一帆风顺的。

Lua 5.4版本开始,Lua支持在C函数中使用yield功能。

这可能导致在L->ciLua调用信息链表)中出现某个C函数或C闭包,但在C调用栈中并不存在相应的信息。

目前的解决方案是采用一种启发式的匹配策略。

L->ci链表中的C函数与C调用栈中的C函数匹配时,我们认为从Lua调用栈的栈顶到当前C函数位置的部分是由当前C调用栈中的C函数产生的,并进行合并。


一些旁支末节。

在我最初学习eBPF程序时,我听说内核有一个bpf校验器,可以确保你编写的bpf程序永远不会损坏内核数据。

我一直觉得这很神奇,当时我在思考如果将这种技术应用于应用程序的检查中,会不会无敌。

最后才了解到,原来bpf校验器是采用宁可错杀一千也不可漏掉一人的方式进行检查的,各种报错会让人感到困惑和沮丧。

一个非常重要的知识点是,bpf校验器是在编译之后才开始校验的。

如果你写了相应的if检查,但校验器仍然报告你没有进行检查,那可能是因为你的检查被编译器优化掉了,你需要采用各种非常归的方法来阻止编译器的优化(我在这个问题上花了整整一个周末的时间来解决)。

最后源码在这里

一次虚拟内存排查经历

作者 重归混沌
2023年7月29日 23:45

事情的起因是这样的,我最近在给silly增加prometheus数据库支持。

在测试过程中发现,在docker中,silly刚起动就占了将近110MiB虚拟内存。

我将相同的代码在宿主机直接直行,虚拟内存只有48.32MiB。

与此同时, silly暴露给prometheus数据库的指标显示,应用程序分配了3.7MiB内存,而jemalloc一共给应用程序分配了9.38 MiB内存。

我打算先来看看这48.32MiB内存是不是合理的。

通过cat /proc/[pid]/smaps查看了一下虚拟内存的大致分配,下面是一些比较大的匿名内存段。

7ffff587e000-7ffff607e000 rw-p 00000000 00:00 0
Size:               8192 kB
...
7ffff607f000-7ffff687f000 rw-p 00000000 00:00 0
Size:               8192 kB
...
7ffff6880000-7ffff7080000 rw-p 00000000 00:00 0
Size:               8192 kB
...
7ffff7080000-7ffff7c00000 rw-p 00000000 00:00 0
Size:              11776 kB

其中三个8192KiB内存块肯定是三个线程栈所分配的虚拟内存,一共24MiB。

而11776KiB这块内存大小和jemalloc向应用程序分配的内存大小非常接近(稍大一些), 有理由怀疑这块内存其实就是jemalloc所使用的虚拟内存大小,毕竟jemalloc本身的元数据也是需要一些内存消耗的。

为了确认这一怀疑,我使用strace -f -k -e mmap ./silly/silly server-src/config来确认这一点(事实上这一步我遇到了困境,因为我没有加-f标志,导致strace不能跟踪所有线程的系统调用,以致于浪费了很多时间)。

下面是截取的一段jemalloc相关的mmap系统调用,可以看到这块内存刚好就是jemalloc所用掉的。

mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7ffff7fcf000
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(mmap64+0x43) [0xf3cd3]
 > /home/silly/silly(je_pages_boot+0x1c7) [0xb0877]
 > /home/silly/silly(malloc_init_hard_a0_locked+0x12d) [0x4f20d]
 > /home/silly/silly(malloc_init_hard+0x79) [0x4f4d9]
 > /home/silly/silly(__libc_csu_init+0x45) [0xc3895]
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(__libc_start_main+0x7a) [0x2402a]
 > /home/silly/silly(_start+0x2a) [0x1dcea]
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE, -1, 0) = 0x7ffff7fcf000
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(mmap64+0x43) [0xf3cd3]
 > /home/silly/silly(je_pages_boot+0x2b7) [0xb0967]
 > /home/silly/silly(malloc_init_hard_a0_locked+0x12d) [0x4f20d]
 > /home/silly/silly(malloc_init_hard+0x79) [0x4f4d9]
 > /home/silly/silly(__libc_csu_init+0x45) [0xc3895]
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(__libc_start_main+0x7a) [0x2402a]
 > /home/silly/silly(_start+0x2a) [0x1dcea]
mmap(NULL, 2097152, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE, -1, 0) = 0x7ffff7a49000
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(mmap64+0x43) [0xf3cd3]
 > /home/silly/silly(je_pages_map+0x4c) [0xb00cc]
 > /home/silly/silly(je_extent_alloc_mmap+0x14) [0xa56c4]
 > /home/silly/silly(base_block_alloc.isra.21+0x281) [0x63681]
 > /home/silly/silly(je_base_new+0x70) [0x64030]
 > /home/silly/silly(je_base_boot+0x17) [0x64b97]
 > /home/silly/silly(malloc_init_hard_a0_locked+0x138) [0x4f218]
 > /home/silly/silly(malloc_init_hard+0x79) [0x4f4d9]
 > /home/silly/silly(__libc_csu_init+0x45) [0xc3895]
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(__libc_start_main+0x7a) [0x2402a]
 > /home/silly/silly(_start+0x2a) [0x1dcea]
mmap(NULL, 4190208, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE, -1, 0) = 0x7ffff784a000
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(mmap64+0x43) [0xf3cd3]
 > /home/silly/silly(je_pages_map+0x163) [0xb01e3]
 > /home/silly/silly(je_extent_alloc_mmap+0x14) [0xa56c4]
 > /home/silly/silly(base_block_alloc.isra.21+0x281) [0x63681]
 > /home/silly/silly(je_base_new+0x70) [0x64030]
 > /home/silly/silly(je_base_boot+0x17) [0x64b97]
 > /home/silly/silly(malloc_init_hard_a0_locked+0x138) [0x4f218]
 > /home/silly/silly(malloc_init_hard+0x79) [0x4f4d9]
 > /home/silly/silly(__libc_csu_init+0x45) [0xc3895]
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(__libc_start_main+0x7a) [0x2402a]
 > /home/silly/silly(_start+0x2a) [0x1dcea]
mmap(NULL, 2097152, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE, -1, 0) = 0x7ffff7800000
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(mmap64+0x43) [0xf3cd3]
 > /home/silly/silly(je_pages_map+0x4c) [0xb00cc]
 > /home/silly/silly(je_extent_alloc_mmap+0x14) [0xa56c4]
 > /home/silly/silly(je_ehooks_default_alloc_impl+0xd3) [0x9de03]
 > /home/silly/silly(je_ecache_alloc_grow+0x9b9) [0xa5039]
 > /home/silly/silly(pac_alloc_real+0x93) [0xaf3e3]
 > /home/silly/silly(pac_alloc_impl+0xe5) [0xaf525]
 > /home/silly/silly(je_pa_alloc+0x57) [0xae547]
 > /home/silly/silly(je_arena_extent_alloc_large+0x9f) [0x5b56f]
 > /home/silly/silly(je_large_palloc+0xcb) [0xac53b]
 > /home/silly/silly(je_arena_palloc+0xd5) [0x5ede5]
 > /home/silly/silly(je_tsd_tcache_data_init+0xe2) [0xbc2a2]
 > /home/silly/silly(je_tsd_tcache_enabled_data_init+0x28) [0xbc948]
 > /home/silly/silly(je_tsd_fetch_slow+0x114) [0xbe3d4]
 > /home/silly/silly(malloc_init_hard+0x9b) [0x4f4fb]
 > /home/silly/silly(__libc_csu_init+0x45) [0xc3895]
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(__libc_start_main+0x7a) [0x2402a]
 > /home/silly/silly(_start+0x2a) [0x1dcea]
mmap(NULL, 4194304, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE, -1, 0) = 0x7ffff7400000
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(mmap64+0x43) [0xf3cd3]
 > /home/silly/silly(je_pages_map+0x4c) [0xb00cc]
 > /home/silly/silly(je_extent_alloc_mmap+0x14) [0xa56c4]
 > /home/silly/silly(base_block_alloc.isra.21+0x281) [0x63681]
 > /home/silly/silly(je_base_alloc+0x269) [0x644d9]
 > /home/silly/silly(je_rtree_leaf_elm_lookup_hard+0x100) [0xb5280]
 > /home/silly/silly(je_emap_register_boundary+0x357) [0x9ea17]
 > /home/silly/silly(je_ecache_alloc_grow+0x60b) [0xa4c8b]
 > /home/silly/silly(pac_alloc_real+0x93) [0xaf3e3]
 > /home/silly/silly(pac_alloc_impl+0xe5) [0xaf525]
 > /home/silly/silly(je_pa_alloc+0x57) [0xae547]
 > /home/silly/silly(je_arena_extent_alloc_large+0x9f) [0x5b56f]
 > /home/silly/silly(je_large_palloc+0xcb) [0xac53b]
 > /home/silly/silly(je_arena_palloc+0xd5) [0x5ede5]
 > /home/silly/silly(je_tsd_tcache_data_init+0xe2) [0xbc2a2]
 > /home/silly/silly(je_tsd_tcache_enabled_data_init+0x28) [0xbc948]
 > /home/silly/silly(je_tsd_fetch_slow+0x114) [0xbe3d4]
 > /home/silly/silly(malloc_init_hard+0x9b) [0x4f4fb]
 > /home/silly/silly(__libc_csu_init+0x45) [0xc3895]
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(__libc_start_main+0x7a) [0x2402a]
 > /home/silly/silly(_start+0x2a) [0x1dcea]
mmap(NULL, 3670016, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE, -1, 0) = 0x7ffff7080000
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(mmap64+0x43) [0xf3cd3]
 > /home/silly/silly(je_pages_map+0x4c) [0xb00cc]
 > /home/silly/silly(je_extent_alloc_mmap+0x14) [0xa56c4]
 > /home/silly/silly(je_ehooks_default_alloc_impl+0xd3) [0x9de03]
 > /home/silly/silly(je_ecache_alloc_grow+0x9b9) [0xa5039]
 > /home/silly/silly(pac_alloc_real+0x93) [0xaf3e3]
 > /home/silly/silly(pac_alloc_impl+0xe5) [0xaf525]
 > /home/silly/silly(je_pa_alloc+0x57) [0xae547]
 > /home/silly/silly(je_arena_extent_alloc_large+0x9f) [0x5b56f]
 > /home/silly/silly(je_large_palloc+0xcb) [0xac53b]
 > /home/silly/silly(je_arena_malloc_hard+0x50d) [0x5ec0d]
 > /home/silly/silly(je_malloc_default+0x5a1) [0x52371]
 > /home/silly/silly(silly_malloc+0x6) [0x23356]
 > /home/silly/silly(silly_socket_init+0x57) [0x215e7]
 > /home/silly/silly(silly_run+0x88) [0x22cb8]
 > /home/silly/silly(main+0x468) [0x1d888]
 > /usr/lib/x86_64-linux-gnu/libc-2.28.so(__libc_start_main+0xeb) [0x2409b]
 > /home/silly/silly(_start+0x2a) [0x1dcea]

有了宿主机的这些经验后,再去查看docker中那110MiB内存来源时就轻车熟路了。

首先使用docker run --rm --cap-add=SYS_PTRACE --security-opt seccomp=unconfined -it --entrypoint /bin/bash registry.cn-hangzhou.aliyuncs.com/findstr-vps/xxx来强行改写entrypoint为bash和添加strace的能力,之后所有的分析都和宿主机一样了。

通过cat /proc/[pid]/smaps发现了一大块内存一个字节都没有使用,他很可疑.

7ffff0021000-7ffff4000000 ---p 00000000 00:00 0
Size:              65404 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                   0 kB
Pss:                   0 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         0 kB
Referenced:            0 kB
Anonymous:             0 kB
LazyFree:              0 kB
AnonHugePages:         0 kB
ShmemPmdMapped:        0 kB
Shared_Hugetlb:        0 kB
Private_Hugetlb:       0 kB
Swap:                  0 kB
SwapPss:               0 kB
Locked:                0 kB
THPeligible:    1
VmFlags: mr mw me nr sd

再使用strace -f -k -e mmap ./silly/silly server-conf/config, 可以找到相应的callstack如下:

[pid    34] mmap(NULL, 134217728, PROT_NONE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE, -1, 0) = 0x7fffed87d000
 > /lib/x86_64-linux-gnu/libc-2.28.so(mmap64+0x43) [0xf3cd3]
 > /lib/x86_64-linux-gnu/libc-2.28.so(_IO_str_seekoff+0xd87) [0x80127]
 > /lib/x86_64-linux-gnu/libc-2.28.so(_IO_str_seekoff+0x157c) [0x8091c]
 > /lib/x86_64-linux-gnu/libc-2.28.so(_IO_str_seekoff+0x44ad) [0x8384d]
 > /lib/x86_64-linux-gnu/libc-2.28.so(__libc_malloc+0x116) [0x84626]
 > /lib/x86_64-linux-gnu/libc-2.28.so(fgets+0x1bb) [0x7022b]
 > /silly/silly(luaL_loadfilex+0x4d) [0x3a35d]
 > /silly/silly(silly_worker_start+0x19b) [0x2200b]
 > /silly/silly(thread_worker+0xb) [0x228fb]
 > /lib/x86_64-linux-gnu/libpthread-2.28.so(start_thread+0xf3) [0x7fa3]
 > /lib/x86_64-linux-gnu/libc-2.28.so(clone+0x3f) [0xf906f]

可以看到是libc中的fgets函数导致的,当我使用gdb断这个函数时,相同的系统调用又会出现在别的线程中调用的glibc函数中。

我怀疑这是glibc的一个bug。报着试试看的态度google了一下,没想到这是一个Feature(glibc 2.10+版本的多线程程序,glibc会预分配很多虚拟内存, 用来提高性能)。

但是奇怪的时,我的宿主机和docker的glibc的版本都是2.28, 宿主机却没有虚拟内存问题。

ldd (Debian GLIBC 2.28-10+deb10u2) 2.28
Copyright (C) 2018 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
Written by Roland McGrath and Ulrich Drepper.

7月30号补充:

在分析过程中,我还发现一个奇怪的现象。

mmap(NULL, 2097152, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE, -1, 0) = 0x7ffff7a49000 产出了块大内存,但是从smaps中发现,这块内存包含了文件/home/silly/silly/luaclib/sys.so的内存映射, 截取部分smaps如下:

7ffff7080000-7ffff7c00000 rw-p 00000000 00:00 0
Size:              11776 kB
7ffff7c23000-7ffff7c27000 r--p 00000000 08:00 253822                     /home/silly/silly/luaclib/sys.so
Size:                 16 kB
7ffff7c27000-7ffff7c3d000 r-xp 00004000 08:00 253822                     /home/silly/silly/luaclib/sys.so
Size:                 88 kB
7ffff7c3d000-7ffff7c40000 r--p 0001a000 08:00 253822                     /home/silly/silly/luaclib/sys.so
Size:                 12 kB
7ffff7c40000-7ffff7c41000 r--p 0001c000 08:00 253822                     /home/silly/silly/luaclib/sys.so
Size:                  4 kB
7ffff7c41000-7ffff7c42000 rw-p 0001d000 08:00 253822                     /home/silly/silly/luaclib/sys.so
Size:                  4 kB
7ffff7c42000-7ffff7c4e000 rw-p 00000000 00:00 0
Size:                 48 kB

理论上这是不可能的。我想了很久,觉得只有一种可能,那就是刚mmap之后,程序就使用munmap释放掉了这块地址空间。然后刚好这块地址空间就被OS拿来做sys.so文件的映射了。

为了验证这一猜想,我使用strace -f -e mmap,munmap ./silly/silly server-src/config确认了一下,果然就是这样,下面的strace的部分输出。

mmap(NULL, 2097152, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS|MAP_NORESERVE, -1, 0) = 0x7ffff7a49000
munmap(0x7ffff7a49000, 2097152)         = 0

jemalloc在mmap分配了一块内存后,随即就使用munmap给释放了。

这也给我一个提示, 当我们监控内存分配时,只监控mmap是不够用。至少需要同时监控mmap,munmap一起配合分析才行。如果有必要我们还需要监控brk系统调用。

ps. 由于linux会地址随机化,可能会导致多出来很多匿名内存段,不便于排查问题。可以使用echo 0 > /proc/sys/kernel/randomize_va_space来临时关闭内存地址随机化。

游戏服务器分布式数据的一种同步的思路

作者 重归混沌
2023年7月1日 08:38

在游戏服务器的分布式设计中,我们通常避免将密切交互或存在数据强耦合的两个模块分别实现在不同的进程中。

然而,在某些情况下,我们不得不通过RPC在不同进程之间进行通信。例如,在处理玩家请求时,A进程可能需要与B进程进行资源扣除,并继续执行逻辑。

由于网络的不稳定性,A进程向B进程发起的RPC可能会有三种可能的结果:成功、失败或超时。

在分布式设计中,超时问题是最具挑战性的,因为超时可能是由于B进程未收到请求,也可能是B进程执行后的响应未被A进程接收到。

因此,在重试超时的RPC时必须非常小心,因为有可能这个RPC已经执行成功了,只是响应包丢失了而已,这样就会多次扣除玩家资源。

幸运的是,同一个游戏服务器的一组进程通常位于同一个子网下,并且在停服维护期间也是同生共死的。因此,RPC超时的概率通常不会很大,除非服务器超载。

在这种前提下,针对RPC的超时情况,一般的做法是在调用方和被调用方打上详细的日志。如果真的发生了超时,可以根据日志给玩家进行相应的补偿。


最近我遇到了一个有点棘手的需求,经过简化后的内容是这样的:

我们需要在"跨服"给玩家定期(大概几十秒到几百秒不等)产出一定的资源到"本地服",这个产出的资源需要玩家手动领取。在玩家没有领取之前,只能暂存N个周期的资源产出,到达暂存上限后就停止产出,直到玩家手动进行领取之后,再继续产出。

这个需求的棘手之处在于,在维护时,"跨服"的生命周期很大程度上和"本地服"不一样,也就是说数据包丢失必然会发生,被丢包的玩家必然会丢失这一周期产出的资源。

这种情况下,就不能只打印日志了,因为这是一种必然会发生的事件,而且影响范围是所有玩家,每次维护后手动处理是不能接受的。

经过仔细思考后,我认为要将"跨服产出到本地服"这个行为拆成两部分来实现。

第一步是先将资源产出到"跨服",这一步在进程内完成,如果跨服进程被关闭,就直接不会产出,下次启动后根据时间戳可以自动恢复出相应需要产出的资源。

第二步是将"跨服"进程中暂存的所有资源同步到"本地服"。同步数据包可能会丢失,但是由于每次总是同步当前玩家暂存的所有资源,所以重试这个同步操作是幂等的。

如果仅仅是产出重试这一问题,到这里问题基本上已经解决了。但是,现实往往是复杂的。

玩家领取资源时,还需要将产出的资源扣除以便"跨服"可以继续产出,而只要行为是"扣除",无论怎么设计这个RPC请求都不可能被重试。

所以需要进一步改进"跨服"产出逻辑。

"跨服"记录的不再是暂存的资源数量,而是从玩家参与这个玩法时到现在一共产出的"总"产出周期(TotalCycle)和"总"产出资源(TotalResource),这两个值都是只增不减的。

"本地服"同样记录收从"跨服"收到的最新的TotalCycle和TotalResource。

"本地服"每次收到"同步"包之后,根据同步包中的TotalCycle和TotalResource减去"本地服"中的TotalCycle和TotalResource,就能得出本次同步一共产出了多少周期和多少资源。再将本次同步产出的周期和资源合并到"本地服"的暂存周期数(StashCycle)和暂存产出资源数(StashResource)。

当玩家领取奖励时,"本地服"只需要将存储的TotalCycle和StashCycle同步到"跨服",跨服就可以知道当前还有多少周期的产出资源未被领取。具体公式为:(StashCycle + 跨服.TotalCycle – 本地服.TotalCycle)。

这样在玩家领取奖励时,从扣除暂存资源到加入玩家背包又是在进程内完成的,通知跨服可以继续产出的行为又变成了幂等的。


在整个问题的解决思路中,其实无非就是一点。将一些分布式行为拆分成本地行为+ "同步"行为(同步是指幂等的行为),在拆分过程中,"单调"会是一个很有用的武器。

The post 游戏服务器分布式数据的一种同步的思路 first appeared on 重归混沌的BLOG.

2022(完)

作者 重归混沌
2023年1月1日 20:21

2022年终于过完了,细细数来,有用时间真的比往年都短。

我翻了翻Blog, 几乎没有什么转折性的收获。

因为服务器一直找不到突破方向,打算在客户端方向努努力。

年初的打算是,先打磨出一个可用的,基于Lua的客户端框架,以便能更深刻理解客户端的全部开发流程(虽然我改过客户端框架,写过还算可用的热更新框架,也写了一些Shader。 但是对于从0实现一个客户端框架,我确实没有经验)。

框架由具体需求驱动。因为我不是专业搞客户端的,所以边抄一个游戏,边实现框架是目前来讲最好的方式。

刚好这时我在网上找到了类似《王者荣耀》的全套资源,那就先抄个王者荣耀试试吧。

UI方面,我接入了FairyGUI

在接入时,我灵光一现,设计了一套多国语言机制。即使一年后今天,我重新审视这套机制,也并不能发现任何缺点。

这套机制几乎可以实现在任何语言和任何表结构下。

王者荣耀,地图中会有很多小兵参与战斗。

因此在做完登录流程之后,小兵的AI就是下一步要做的事。

游戏AI,行为树几乎是必不可少的标配。它对策划同学太友好了,实现起来复杂度又不高。

在看完《Behavior Trees in Robotics and AI》书中描述的行为树后。

我观察到一个现象,行为树表达出的逻辑,其实是状态机的一个子集。

在经过不复杂的“编译”过程后。我们可以做到在运行时以状态机的开销,来达成行为树描述的逻辑。

这样可以大大提升行为树的运行效率(对于策划而言,使用上还是行为树,只是在运行前才会将整棵树编译成一张跳转表)。

在研究行为树的过程中,发现了一本叫《Artificial Intelligence for Games, Second Edition》的书。

这本书中提到了一种叫做Flocking的算法,以前就对此类算法很有兴趣,就趁机研究了一下

后面两个月,其实就只干了一件事,抢菜。

复工之后,由于公开课《现代游戏引擎:从入门到实践》的出现,我暂停了实现客户端框架的计划。

相比实现一个客户端框架,从0实现一个客户端引擎,更能引起我的兴趣。

虽然此时的我,还没有写过一行Vulkan代码,没有看过如UE之类的开源引擎代码。

但依然想试着,根据课程内容和Unity的使用经验,来实现一个玩具引擎

在能基本渲染出一个场景后,我就迫不及待想要使用键盘来控制相机的移动。

在实现相关逻辑时,我重新思考了如何使用Lua,高效地编写3D数学相关代码

由于之前基于Vulkan抽象出来的RHI不够好(使用起来很别扭),再加上了解到了Bindless这一黑魔法。

打算将引擎直接重构成基于Bindless的RHI抽象。

比较惨的是,刚抽象完,公司就倒闭了,被迫中止客户端相关知识的学习。

根据市场需要,从零开始学习Go语言。

Go语言的优劣,我不想评判。但是他有很多不错的闪光点。

比如:这个这个还有这个

然后这个年就过完了。

再简单总结一下。

2022年技术上一共就干了三件事,结果还都没有一个完成的。

  1. 客户端框架,只做完UI相关部分
  2. 客户端引擎,只做到基于Bindless RHI的抽象,shader接口,资源格式等都还没有设计
  3. Go语言,只熟悉了,工作用最常用的底层机制,对调度及编译优化等相关的知识并不深入

2023年学习的时间不可能像以前那么多了,那么就少安排任务吧:

  1. 继续熟悉Go语言
  2. 熟悉K8S, etcd, kafka,ELK等这些在Go圈常用的基础设施
  3. 放弃之前基于静态容量的分布式架构设计,基于K8S来重新设计一套可以动态伸缩的服务器架构
  4. 研究ECS在服务端的应用

The post 2022(完) first appeared on 重归混沌的BLOG.

❌
❌