一个人用 AI 写了个武侠网游,聊聊服务端架构踩过的坑
修改于04/11190 浏览开发心得
项目:《踏破江湖》—— 像素风武侠生存闯关,常驻服多人架构
引擎:星火编辑器(UrhoX),纯 Lua 全栈
服务端:基于 serverCloud API 的无数据库架构


大家好,我是《踏破江湖》的开发者。
这个项目是用TapTap制造做的一款像素武侠割草游戏,支持多人在线。从立项到现在,服务端代码大概 3000 行左右,跑通了存档、商城、邮件、GM、师徒、姻缘、组队七大系统。
目前游戏还没正式发布,已经给身边的玩家做过几轮实机测试,整个服务端架构在实际多人环境下跑下来基本稳定。
本帖不讲客户端渲染和玩法,专门聊聊服务端架构设计,重点讲三个问题:
1. 怎么支持百人同服不卡
2. 怎么做到玩家存档不丢档
3. 怎么在 serverCloud 每 60 秒 300 次读写限制下把七大系统跑起来
希望对同样在用TapTap制造做多人游戏的朋友有参考价值


一、整体架构
先上一张全景图:
server_main.lua
(统一入口,注册事件)
|
+--------------+--------------+
| |
HeadlessMock ConnectionManager
(服务端环境兼容) (连接生命周期管理)
|
+------+------+------+------+------+------+
| | | | | | |
Save Shop Mail GM Marri Mentor Team
Server Server Server Server Server Server (客户端)
| | | | | |
+------+------+------+------+------+
|
serverCloud API
(TapTap 云端存储层)
架构思路很简单:一个连接管理器 + N 个业务模块。每个业务模块只管自己的事,模块之间通过 ConnectionManager 提供的工具函数通信,不直接互相调用。
每个业务模块都有三个文件:
XXXServer.lua —— 服务端逻辑
XXXClient.lua —— 客户端逻辑
XXXShared.lua —— 事件名常量(客户端服务端共享)
这套结构的好处是加新功能不用改旧代码。加邮件系统的时候,SaveServer 一行没动,就是在 server_main.lua 里多加了一行 MailServer.Start()。


二、ConnectionManager:所有模块的地基
这个模块解决一个核心问题:各业务模块怎么知道玩家上线/下线了?
做法是提供注册回调的接口:
```lua
-- SaveServer 注册断开回调
CM.OnDisconnect("Save", function(userId)
-- 玩家下线,保存数据
FlushAndEnqueue(userId)
end)
-- MailServer 注册断开回调
CM.OnDisconnect("Mail", function(userId)
-- 玩家下线,持久化邮箱
PersistMailbox(userId)
end)
-- MarriageServer 注册断开回调
CM.OnDisconnect("Marriage", function(userId)
-- 清理内存中的匹配状态
CleanupMatchState(userId)
end)
```
玩家断开连接时,ConnectionManager 遍历所有注册的回调,逐个通知。每个回调用 pcall
包裹,一个模块出错不影响其他模块。
另外它还提供了统一的连接信息获取函数:
lua
local conn, connKey, userId = CM.GetConnInfo(eventData)
所有 Server 模块第一行都是这个,不用每个模块自己去解析 eventData。


三、存档系统:踩坑最多的地方
3.1 数据怎么存
用 serverCloud 的 BatchSet 做持久化。把游戏状态拆成三个大 JSON:
| Score Key | 内容 | 示例大小 |
|-----------|------|---------|
| core | 角色基础信息(名字、职业、等级) | ~200B |
| systems | 子系统数据(装备、宠物、经脉、技能...) | ~2-5KB |
| progress| 关卡进度、解锁记录 | ~1KB |
同时把战力、通关数、击杀数写成独立的 Int 字段,用于排行榜排序。
lua
serverCloud:BatchSet(userId)
:Set("core", coreJson)
:Set("systems", systemsJson)
:Set("progress", progressJson)
:SetInt("power", totalPower) -- 排行榜字段
:SetInt("cleared", clearedLevels) -- 排行榜字段
:Save(tag, callbacks)
关键设计:一次 BatchSet 写 ~10 个字段,但只算一次 API 写入。 这是控制 API 调用量的核心手段之一(后面"百人同服"章节详细算账)。
3.2 坑一:429 限流
问题: 玩家频繁上下线(比如网络不稳定反复重连),每次下线都触发保存,serverCloud 直接返回 429。
解决方案:离线写入队列
玩家下线
|
v
尝试立即写云端
|
+---> 成功 -> 结束
|
+---> 429 -> 放入 FIFO 队列
|
v
定时处理(每 0.3 秒取一条)
|
+---> 成功 -> 移除
|
+---> 又 429 -> 放回队尾,等 2 秒再处理
|
+---> 重试 5 次还失败 -> 丢弃(有上次成功的存档兜底)
队列做了去重合并:同一个玩家在队列里只保留最新的一份数据。
3.3 坑二:快速重连读到旧数据
问题: 玩家掉线后马上重连,存档还在队列里没写完,这时从云端读到的是旧存档。
解决方案:队列拦截
lua
function HandleSaveLoad(eventType, eventData)
-- 先查队列里有没有这个玩家的待写数据
local pendingSave = DequeueByUserId(userId)
if pendingSave then
-- 队列里的数据比云端新,直接用
SendSaveData(conn, pendingSave)
return
end
-- 队列没有,正常从云端读
serverCloud:BatchGet(userId):Key(...):Fetch(...)
end
这个坑不好复现但后果很严重——玩家辛辛苦苦刷了半天,重连后发现存档回档了。
3.4 坑三:客户端加载超时
问题: 服务端云存储偶尔响应慢,客户端一直卡在"加载中"。
解决方案:客户端双保险
lua
-- 客户端加载流程
1. 向服务端请求存档
2. 启动 8 秒超时计时器
3. 如果超时前收到 -> 正常加载,同时写一份到本地 save_cache.json
4. 如果 8 秒超时 -> 读本地 save_cache.json 兜底
这样即使服务端挂了,玩家也能进游戏(用的是上次成功的存档)。
3.5 定时自动保存
在线玩家每 5 分钟自动存一次,防止服务器崩溃丢太多进度:
```lua
FLUSH_INTERVAL = 300 -- 5 分钟
function FlushDirtyCaches()
for userId, cache in pairs(liveCache_) do
if cache.dirty then
EnqueueSave(userId, cache.saveObj)
end
end
end
```
注意这里不是直接 CloudWrite,而是走队列,避免同时刷 20 个玩家的存档把 API 打爆。


四、商城系统:Quota 是好东西
4.1 限购怎么做
星火的 serverCloud 提供了 Quota API,天然支持"每日 X 次"的限制:
lua
serverCloud.quota:Add(userId, "recharge_tier1", 1, 2, "day", 1, {
ok = function()
-- Quota 通过了,继续扣费发货
DoDeliverItem(...)
end,
error = function(code, reason)
-- 超限了
CM.SendFail(conn, "S2C_ShopFail", "今日已达上限")
end
})
Quota 是原子的——调用 Add 的瞬间就锁定了配额,不存在两个请求同时通过的问题。这比自己读计数器、判断、再写回去要安全得多。
4.2 复合操作用 BatchCommit
有些操作涉及多个步骤(检查限购 + 扣费 + 发货),需要保证原子性:
lua
local c = serverCloud:BatchCommit("buy_item")
c:QuotaAdd(userId, quotaKey, 1, dailyLimit, "day", 1)
c:ScoreAddInt(userId, "gold", -price)
c:Commit({
ok = function()
-- 全部成功,发货
end,
error = function()
-- 任一失败,全部回滚
end
})
这样不会出现"钱扣了但货没给"的问题。
4.3 每日特惠的确定性随机
每天刷新的特惠商品,需要所有玩家看到的是一样的:
```lua
function GetTodaySeed()
local t = os.date("*t")
return t.year * 10000 + t.month * 100 + t.day
end
math.randomseed(GetTodaySeed())
-- 接下来的 random 调用所有玩家一致
```
用日期做种子,同一天所有人的随机结果相同。
4.4 特权卡:全局广告计数
这个功能的需求比较有意思:所有地方看的广告(商城买东西、复活、双倍奖励...),前 100 次都计入特权卡的解锁进度。
实现方式是在商城数据的 JSON blob 里记一个全局计数器:
lua
shopData.dailyPrivAdDate = today
shopData.dailyPrivAdCount = count -- 今日已计入次数,上限 100
shopData.privTotalAds = total -- 累计总次数(永久)
任何模块触发看广告后,都调用商城的广告计数函数。达到阈值自动激活对应特权卡。


五、邮件系统:跨模块协作
邮件系统最有意思的设计是和其他模块的协作方式。
5.1 名字缓存共享
GM 发邮件时需要通过角色名查找 userId。角色名缓存在 SaveServer 里维护,通过引用传递给 MailServer:
```lua
-- server_main.lua
MailServer.Start(SaveServer.GetNameCache())
-- MailServer 内部
function Server.Start(extNameCache)
nameCache_ = extNameCache -- 直接拿到 SaveServer 的名字表
end
```
这样 MailServer 不需要自己再维护一份名字缓存,也不需要额外的云端查询。
5.2 在线推送 + 离线入库
```lua
function Server.DeliverMail(targetUserId, fromName, title, body)
-- 1. 写入内存邮箱
local box = mailboxes[targetUserId]
if box then
table.insert(box, mail)
else
-- 离线玩家,创建临时邮箱
mailboxes[targetUserId] = { mail }
end
-- 2. 如果在线,实时推送
local conn = CM.GetConnectionByUserId(targetUserId)
if conn then
CM.SendToConn(conn, "S2C_MailNew", mailData)
end
-- 3. 持久化到云端
PersistMailbox(targetUserId)
end
```


六、组队系统:客户端伪多人的设计取舍
组队系统是我项目里比较特殊的一个模块——它没有服务端代码。
6.1 为什么不做真实匹配
独立开发者做真实匹配面临一个现实问题:玩家不够多。测试期同时在线的人数是个位数,如果做真实匹配,等 30 秒大概率等不到人,体验非常差。
所以我选了另一条路:客户端本地生成 AI 队友,伪装成真实玩家。
6.2 怎么伪装的
"伪装"的关键在三点:
随机人设:从姓名池里随机组合武侠风名字,混入一些玩家风格的网名:
lua
SURNAMES = { "李", "慕容", "上官", "独孤", ... }
GIVEN_NAMES = { "逸风", "剑心", "摸鱼王", "肝帝", "追风少年", ... }
模拟匹配过程:不是一上来就满队,而是有一个 30 秒的"搜索"过程。提前生成一个时间表,到点了才"找到"队友:
lua
function GenerateMatchSchedule(playerLevel)
-- 10% 概率快速匹配(8秒内全齐)
-- 90% 正常匹配:前半程随机找到 0~2 人,超时后补齐
-- 结果:大部分时候等 20~30 秒,偶尔幸运快速满队
end
配合 UI 上的脉冲雷达动画和倒计时,体验上很接近真实匹配。
属性差异:AI 队友的等级在玩家等级 ±10 范围内随机,攻击和血量有 0.75x~1.25x 的随机波动,三种性格(激进/平衡/防御)影响战斗行为。
6.3 战斗中的 AI 行为
匹配完成后,队友会在战斗中提供真实的帮助:
```lua
-- AI 决策逻辑:
-- 1. 附近有敌人且在追击范围内 -> 追过去打
-- 2. 没有敌人 -> 跟随玩家
-- 3. 打完了 -> 回到玩家身边待命
-- 每种性格有不同参数:
-- aggressive: 攻击间隔 1.0s, 追击范围 200px
-- balanced: 攻击间隔 1.4s, 追击范围 150px
-- defensive: 攻击间隔 1.8s, 追击范围 100px
```
6.4 队伍加成
组队有实际的数值加成,鼓励玩家使用这个功能:
| 队伍人数 | 怪物强度 | 经验加成 | 掉落加成 | 银两加成 |
|---------|---------|---------|---------|---------|
| 2人 | ×1.5 | +20% | +50% | +20% |
| 3人 | ×2.0 | +30% | +100% | +30% |
| 4人 | ×2.5 | +40% | +150% | +40% |
怪物变强了,但总收益是正的。而且有队友分担伤害,高难度关卡更容易通关。
6.5 后续计划
等正式上线玩家数稳定后,打算做真正的服务端匹配。到时候加一个 TeamServer.lua就行,架构上是预留了的——所有队伍相关的逻辑(加成计算、队友属性生成)都在独立的 TeamConfig.lua里,服务端只需要把"假队友"换成"真玩家"就行。


七、师徒系统:List API 实战
师徒系统用到了 serverCloud 的 List API,这个 API 和 Score API 的区别是:它是一对多的关系。
一个师父可以有多个徒弟,每个徒弟是 List 里的一条记录:
```lua
-- 读取徒弟列表
serverCloud.list:Get(masterUserId, "disclist", {
ok = function(list)
for _, item in ipairs(list) do
local disc = cjson.decode(item.content)
disc.listId = item.listid -- 关键:记住这个 ID 用于删除
end
end
})
-- 添加徒弟(事务中)
c:ListAdd(masterUserId, "disc_list", discipleJson)
-- 出师时删除(需要 listId)
c:ListDelete(listId)
```
注意点: list_id是 serverCloud 自动生成的,添加时不需要指定,但删除时必须用这个 ID。所以读取列表后一定要把 list_id存下来。


八、姻缘系统:双向数据的一致性
结婚操作需要同时修改两个玩家的数据,这里 BatchCommit 就很关键了:
lua
local c = serverCloud:BatchCommit("marriage_propose")
-- 写自己的伴侣信息
c:ScoreSet(myUserId, "partner", myPartnerJson)
-- 写对方的伴侣信息
c:ScoreSet(targetUserId, "partner", theirPartnerJson)
-- 双方亲密度初始化
c:ScoreSetInt(myUserId, "intimacy", 0)
c:ScoreSetInt(targetUserId, "intimacy", 0)
c:Commit(callbacks)
如果不用事务,可能出现"我记录了对方是我伴侣,但对方那边没写成功"的诡异情况。
离婚也是一样的道理,需要原子地清除双方数据。


九、GM 系统:调试利器
开发阶段 GM 系统帮了大忙。几个实用功能:
在线列表:实时看到所有在线玩家及其信息
踢人:测试断线重连逻辑
禁言/封禁:内存列表 + 过期自动清理,不需要落库
操作日志:最近 200 条操作记录
lua
-- 禁言检查(其他模块调用)
if GMServer.IsMuted(userId) then
CM.SendFail(conn, eventName, "你已被禁言")
return
end
目前权限是全开的(所有登录GM账号密码之后连接用户都是 GM),正式上线前改成白名单就行。


十、HeadlessMock:让服务端代码能跑起来
星火的服务端是 headless 模式,没有渲染相关的全局对象。但业务代码里难免会引用到 graphics
、renderer这些客户端才有的东西。
HeadlessMock 做的事情就是给这些对象建空壳,让 require 不报错:
lua
-- 服务端没有 graphics 对象,建个假的
if not graphics then
graphics = {
GetWidth = function() return 750 end,
GetHeight = function() return 1334 end,
GetDPR = function() return 1 end,
}
end
这样客户端和服务端可以共享 Shared 模块,不需要条件编译。


十一、百人同服:怎么不卡限频、不丢档
这是整个架构设计中最花心思的部分。serverCloud 的限制很明确:
每 60 秒最多 300 次读 + 300 次写(整个服务端共享)
单个 userId 写入频率不超过 5 次/秒(超过返回 429)
100 个玩家同时在线,如果每个人每分钟存一次档,那就是 100 次写/分钟。看起来在 300 次的限制以内?但现实没这么简单——商城购买、排行榜查询、邮件持久化、师徒操作都在同时消耗这 300 次配额。
下面算一下各模块的 API 消耗,以及我怎么把它控制在安全线以内。
11.1 各模块 API 调用量盘点
存档系统(最大头)
| 场景 | 读 | 写 | 频率 |
|------|-----|-----|------|
| 玩家上线加载存档 | 1次 BatchGet | — | 每人登录一次 |
| 在线自动保存 | — | 1次 BatchSet | 每人每5分钟 |
| 玩家下线保存 | — | 1次 BatchSet | 每人下线一次 |
| 快速重连(命中队列) | 0 | 0 | 直接用内存数据 |
100 人稳态: 自动保存 = 100人 ÷ 300秒间隔 ≈ 每 3 秒存 1 个人,即 ~20 次写/分钟。
但这里有个关键优化:自动保存走队列而不是直接写。
```lua
-- 不是这样(100人同时写):
for userId, cache in pairs(liveCache_) do
CloudWrite(userId, ...) -- 100个并发写入,直接爆炸
end
-- 而是这样(排队慢慢写):
for userId, cache in pairs(liveCache_) do
EnqueueSave(userId, cache.saveObj) -- 放进队列
end
-- 队列每 0.3 秒取一条处理,约 3.3 次/秒
```
队列处理速率 = 1 / QUEUE_INTERVAL = 1 / 0.3 ≈ 3.3 次/秒,即 ~200 次写/分钟。但实际上同一玩家在队列里会去重合并,100人的脏存档入队后,队列长度最多 100 条,处理完大约需要 30 秒。
商城系统
| 场景 | 读 | 写 | 频率 |
|------|-----|-----|------|
| 进商城加载数据 | 1次 BatchGet + N次 Quota:Get | — | 低频,打开一次 |
| 购买商品 | 1次 Quota:Add + 1次 GetInt | 1次 SetInt 或 BatchSet | 单次操作 |
| 每日特惠/月卡 | 1次 BatchGet | 1次 BatchSet | 每天每人最多几次 |
商城是低频突发型——平时没人用,打开的时候集中读几次。100 人服务器,同一时刻顶多几个人在买东西。
预估:~5 次读 + ~3 次写/分钟(日常态)
邮件系统
| 场景 | 读 | 写 | 频率 |
|------|-----|-----|------|
| 玩家上线加载邮箱 | 1次 BatchGet | — | 登录一次 |
| 收发邮件持久化 | — | 1次 BatchSet | 事件触发 |
| 玩家下线保存邮箱 | — | 1次 BatchSet | 下线一次 |
邮件读写量很低,最多的场景是 GM 群发(但这是管理操作,频率可控)。
预估:~2 次读 + ~2 次写/分钟
师徒 / 姻缘系统
操作频率极低(拜师、结婚一辈子也就几次),每次操作 1~2 次 BatchCommit。
预估:~1 次读 + ~1 次写/分钟(忽略不计)
排行榜
| 场景 | 读 | 写 | 频率 |
|------|-----|-----|------|
| 查看排行榜 | 1次 GetRankList + 1次 GetUserRank + N次 BatchGet(查名字)| — | 偶尔查看 |
名字缓存命中率高了以后,N 趋近于 0(名字查过一次就缓存了)。
预估:~3 次读/分钟
11.2 总账:100 人稳态
| 模块 | 读/分钟 | 写/分钟 |
|------|---------|---------|
| 存档(自动保存) | 0 | ~20 |
| 存档(上下线) | ~5 | ~5 |
| 商城 | ~5 | ~3 |
| 邮件 | ~2 | ~2 |
| 排行榜 | ~3 | 0 |
| 师徒/姻缘 | ~1 | ~1 |
| 合计 | ~16 | ~31 |
| 限制 | 300 | 300 |
| 使用率 | ~5% | ~10% |
稳态下 API 用量只有限制的 5%~10%,还有巨大的余量。
11.3 极端场景:100 人同时上线
最恐怖的场景是服务器重启后 100 人同时涌入:
100 次 BatchGet(加载存档)= 100 次读
如果全部读完触发商城初始化:+100 次 Quota:Get ≈ 100 次读
200 次读在一分钟内,还在 300 次限制以内。而且实际上玩家不会在同一秒全部连上来,TCP 连接本身就有天然的分散效果。
如果还不放心,可以在 ConnectionManager 里加个连接节流:
lua
-- 每秒最多处理 5 个新连接,多余的排队
local CONNECT_RATE = 5
这个我暂时没加,实测 60 人的时候还没触过限。
11.4 为什么不会丢档
四道防线,从近到远:
```
第一道: 内存缓存 (liveCache_)
↓ 玩家在线时所有存档操作都读写内存,只有在保存时才写云端
↓ 好处:客户端频繁保存不会频繁调 API
第二道: 离线写入队列 (writeQueue_)
↓ 玩家下线时先尝试直接写云端
↓ 429 了就放进队列慢慢写,去重合并,最多重试 5 次
↓ 好处:网络波动或限流不会直接丢数据
第三道: 定时兜底 (FlushDirtyCaches, 每 5 分钟)
↓ 防止服务器崩溃时内存里的脏数据全丢
↓ 最多丢 5 分钟的进度
第四道: 客户端本地缓存 (save_cache.json)
↓ 每次从服务端成功加载存档后,客户端写一份到本地
↓ 服务端挂了就用本地缓存兜底
↓ 好处:最坏情况玩家也能进游戏
```
最坏情况推演:服务器突然崩溃 → 内存缓存全丢 → 但 5 分钟前的自动保存已经通过队列写入了云端 → 玩家最多丢 5 分钟进度 → 而且客户端本地还有一份最近成功加载的存档。
实测了几次强杀服务器进程,重连后最多回档 2~3 分钟,玩家反馈可以接受。
11.5 单 userId 写入限流
serverCloud 对单个 userId 有 ~5 次/秒的写入限制。这个通过以下方式保证:
1. 在线时不主动调 API —— 客户端存档操作只更新内存缓存的 `dirty` 标记
2. 5 分钟一次兜底保存 —— 远低于 5 次/秒
3. 队列处理间隔 0.3 秒 —— 队列里不同玩家轮流写,同一玩家两次写入之间至少隔 `0.3 × 队列长度` 秒
4. 去重合并 —— 同一玩家在队列里只保留一份,不会重复写


十二、总结:几条经
1. 先设计协议,再写逻辑
每个功能先写 XXXShared.lua定义好所有事件名,然后客户端和服务端各自实现。这样两边可以并行开发,也不会出现事件名拼写不一致的低级错误。
2. Quota 比自己计数安全
任何"每日 N 次"的需求,直接用 Quota API。自己读计数、判断、写回,并发一高就翻车。
3. BatchSet 是节省 API 配额的利器
一次 BatchSet 可以写 10 个字段只算 1 次写入。尽量攒一批再写,别一个字段一个字段写。
4. 队列思维解决限流
遇到 429 不要慌,也不要无脑重试。搞个队列慢慢消化,去重合并减少请求量。
5. 本地缓存是最后的防线
网络不可靠,云端不可靠,本地缓存是兜底的。哪怕数据旧一点,也比卡死或丢档强。
6. 模块之间能不直接依赖就不依赖
用回调注册代替直接调用,用引用传递代替全局变量。加新模块的成本应该接近零。
7. 不够真实的多人不如做好伪多人
测试阶段玩家少,真实匹配等 30 秒等不到人,体验远不如精心设计的 AI 队友。等玩家多了再切真实匹配也不迟,关键是把逻辑拆清楚,到时候换起来快。


serverCloud API 用了哪些
写到最后统计了一下,七大系统基本把 serverCloud 的 API 用了个遍:
| API | 用途 | 使用模块 |
|-----|------|---------|
| BatchGet / BatchSet | 批量读写存档 | 存档、商城、邮件 |
| SetInt / Add | 整数字段、增量操作 | 存档(排行榜字段)、商城(货币) |
| Quota:Add | 每日限购/限次 | 商城、姻缘、师徒 |
| BatchCommit | 原子事务 | 商城、姻缘、师徒 |
| List:Get / ListAdd / ListDelete | 一对多列表 | 师徒(徒弟列表) |
| GetRankList / GetUserRank | 排行榜 | 存档、姻缘、师徒 |
以上就是《踏破江湖》服务端架构的全部内容。目前还在测试打磨阶段,后续正式上线后如果有新的踩坑经验会继续更新。欢迎交流讨论,有问题可以在评论区聊。


