上周有位做服务端维护的同行丢过来一句话:“蒸汽幻想这次新版要放百级宠物,服务器那边到底要动哪些地方?”这问题看着简单,真要答起来得从配置文件一路捋到数据库表结构。蒸汽幻想服务器修改这件事,很多人第一反应是找到宠物等级上限那个数字,把 60 改成 100,重启,完事。我最早也是这么想的,结果上线半小时就被反馈打脸:宠物面板显示 100 级,属性却纹丝不动;再打两只怪,等级直接掉回 60。这类“数字改对了但系统不认”的问题,恰恰是百级宠物这种量级的改动里最耗时间的部分,它暴露的不是手误,而是整条数据链路没有一起跟着扩。
这篇东西我按实际动手的顺序讲:服务端里哪几层文件参与宠物等级计算、百级之后的经验曲线和成长公式怎么定、落库时表结构该怎么改、客户端和服务端对不上时怎么一步步查、以及怎么不停服把新版本推上去。手里已经有一份服务端、会看配置文件但还没系统梳理过链路的同行能直接抄作业;完全没接触过服务端的新手也能看懂,因为每一步我都会把“为什么要这么改”讲清楚,而不是只丢一串配置。
1. 从“改个数字”到“改一条链路”:服务端的三层结构
1.1 配置、数据库、脚本各自管什么
蒸汽幻想这类服务端的宠物系统,相关数据基本分散在三个地方,任何一处没扩到 100,整条链路就断。第一层是配置表,管的是“模板”,比如宠物基础模板、成长系数表、技能解锁表,格式常见的是 ini、json、xml 或者 csv,编译后加载进内存。第二层是数据库,管的是“实例”,也就是每个玩家手里那只具体的宠物,等级、经验、资质、技能栏都在这里。第三层是脚本,管的是“逻辑”,升级判定、经验发放、进化触发、属性重算这些动作,通常挂在 lua 或 python 的钩子上。
我见过最多的翻车场景,是只改了配置表的上限,忘了脚本里还有一处硬编码。比如配置文件里写max_level = 100,脚本里却还有一句if level >= 60 then return end,结果就是经验一直涨、等级纹丝不动,玩家看着经验条满了就是不升级,比直接报错还难查。还有一种更隐蔽的,配置和脚本都对,但数据库字段类型是tinyint带符号,上限 127,100 级刚好卡在边缘没事,等你哪天想开到 128 级,数据直接截断成 -128,宠物瞬间变成负等级,属性计算全乱。
提示:动手之前先把这三层各扫一遍,用关键词搜
60、max_level、levelup、upgrade,把命中的位置全部列出来,别指望改一处就完事。
我把这三层各自的风险整理成一张表,改之前对着过一遍,能省掉大半返工:
| 层级 | 典型位置 | 改错后的表现 | 风险等级 |
|---|---|---|---|
| 配置表 | 宠物模板、成长表、技能表 | 面板不涨、技能不解锁 | 中,可热更 |
| 数据库 | 宠物实例表的 level / exp 字段 | 等级截断、经验溢出、回档 | 高,需停机或在线 DDL |
| 脚本 | 升级判定、属性重算、进化钩子 | 卡级、属性不刷新、逻辑死循环 | 中高,热更需谨慎 |
1.2 宠物等级在链路里被读写了几次
我在一次抓包加日志的过程中数过,一只宠物从“打死一只怪”到“面板数字变化”,等级和经验这两个字段至少被读四次、写三次。读的四次分别是:经验结算时判断是否满级、升级判定时比较上限、属性计算时取成长系数、下发协议时序列化。写的三次是:更新经验、更新等级、更新属性或者打脏标记。
这么多次读写意味着什么?意味着任何一次读到的上限值不一致,都会产生诡异现象。比如升级判定读的是新配置的 100,属性计算读的却是内存里缓存的旧模板 60,那就会出现“升到 100 级但属性按 60 级算”的情况,玩家一测就发现打怪伤害没变。排查这类问题的诀窍是打日志时把“当前等级、配置上限、计算用到的模板版本号”一起打出来,版本号对不上就是缓存没刷。
还有一个容易忽略的点:经验字段的数据类型。如果经验曲线是按平方或指数设计的,100 级单级所需经验很可能突破 21 亿,而 32 位有符号整型的上限就是 2147483647。超过之后在很多语言里会静默溢出成负数,玩家看到的是经验条倒退。这不是理论风险,我在两个不同项目上都撞见过,都是因为当初按 60 级上限估算的经验值,扩到 100 级后量级翻了几十倍。所以经验字段务必用 64 位整型,这是动手前必须确认的第一件事。
2. 百级宠物的数值底座:经验曲线与属性成长
2.1 经验曲线选型:线性、二次还是指数
百级宠物的核心不是“能升到 100 级”,而是“从 1 级到 100 级要花多久”。这个时间直接决定了新版本的内容消耗速度,定短了玩家三天毕业然后没事干,定长了玩家骂你逼氪。所以经验曲线必须先定,再谈其他。
常见的三种曲线各有脾气。线性曲线公式是exp(L) = a × L,实现最简单,但问题在于后期毫无阻力,60 级到 100 级的耗时占比太低,玩家感觉不到成长的分量。二次曲线是exp(L) = a × L²,前期升级快、后期明显变慢,是绝大多数老端游的选择,也是我最推荐的起点。指数曲线是exp(L) = a × r^L,r 稍微取大一点后期就爆炸,比如 r 取 1.1,100 级单级经验是 1 级的 1.1^99 倍,约 1.2 万倍,玩家根本承受不了,除非你同时在卖经验道具,否则不建议。
我实际用的是“二次为主 + 分段修正”的混合方案:1 到 30 级纯二次,让新手期痛快;30 到 70 级在二次基础上乘 1.2 的系数,把中期拉长;70 到 100 级再把系数抬到 1.5,让满级变成一件有仪式感的事。这样既保留了二次曲线平滑的优点,又能精准控制每个阶段的天数,改起来只动系数不动结构,回归测试的压力小很多。
参数怎么定?别拍脑袋,倒推。先问自己一个问题:一个正常玩家一天能在这只宠物身上投入多少有效战斗时间?假设是 2 小时,平均每小时能拿 8 万经验,那么一天就是 16 万。你希望一只宠物从 1 级养到 100 级花 15 天,总经验就是 240 万。而 1 到 100 级的sum(L²)约等于 338350,反推系数 a ≈ 2400000 / 338350 ≈ 7.1,取整成 7 或 8 都行。这个倒推过程一定要写进策划案里,不然下次有人想调快升级速度,不知道动哪个数字。
def exp_need(level: int) -> int: base = 7 e = base * level * level if 30 < level <= 70: e = int(e * 1.2) elif level > 70: e = int(e * 1.5) return e total = sum(exp_need(i) for i in range(1, 100)) print(total) # 打到 100 级需要的累计经验,用来核对天数2.2 属性成长:固定值、随机浮动还是资质两段式
等级上去了属性不涨,是百级宠物最常见的体验问题。属性成长的算法主要有三种思路,选哪种决定了你后面平衡工作的难度。
固定成长最省心,公式是attr = base + growth × (level - 1),每个模板一个固定成长值,多少级对应多少属性是唯一确定的。好处是平衡容易、客服好解释、玩家不会觉得被坑;坏处是每只同种宠物长得一模一样,缺少养成乐趣。
随机浮动是每升一级 roll 一次成长值,比如成长区间 4 到 6,运气好的一只比运气差的强一截。这种设计刺激,但会催生“洗宠”需求,也就是玩家反复刷初始属性,服务器压力上来了,还容易滋生交易纠纷。
资质两段式是老端游里最常见的折中:模板给一个基础值和成长值,玩家那只宠物再带一个资质系数,最终属性按(base + growth × (level - 1)) × 资质 / 100算。资质范围 80 到 120,用整数百分比存储,既不引入浮点误差,又保留了个体差异。我推荐这个方案,因为它在“可预期”和“有差异”之间找到了平衡点。
这里有个必须提醒的坑:属性计算全程用整数,不要用浮点。我见过服务端用 float 存成长值,客户端用 double 显示,两边一除一乘,100 级的时候差了 3 点攻击力,玩家截图对比就炸了。正确做法是把成长值和资质都放大 100 倍存整数,计算时先乘后除,最后再截断,误差控制在 1 点以内并且两端算法完全一致。
2.3 用锚点表把关键等级钉死
曲线公式定好之后,别急着上线,先拉一张锚点表出来自己看一遍。所谓锚点,就是挑几个关键等级,把单级所需经验、累计经验、对应属性、预计耗时都列出来,肉眼扫一遍有没有反常的地方。
| 等级 | 单级经验 | 累计经验 | 攻击力 | 预计累计耗时 |
|---|---|---|---|---|
| 1 | 7 | 0 | 12 | 0 小时 |
| 10 | 700 | 2.3 万 | 30 | 约 2 小时 |
| 30 | 6300 | 27 万 | 72 | 约 18 小时 |
| 50 | 2.1 万 | 118 万 | 128 | 约 4 天 |
| 70 | 5.1 万 | 330 万 | 190 | 约 9 天 |
| 90 | 8.5 万 | 590 万 | 258 | 约 13 天 |
| 100 | 10.5 万 | 730 万 | 296 | 约 15 天 |
这张表最大的价值是暴露拐点。比如你发现 68 级到 69 级的经验跳变特别突兀,那就是分段系数切换的位置没过渡好,玩家会在那一段明显卡住。再比如你发现 90 到 100 级的属性增长只有 38 点,而 80 到 90 级有 40 点,说明成长值该往上抬了,不然后期满级没有获得感。锚点表做出来之后发给测试跑一遍,比上线后被玩家找出来强得多。
3. 落库实操:宠物表结构改造与分批迁移
3.1 字段设计:哪些必须落库,哪些可以算出来
宠物实例表是这次改动的重灾区,字段类型没选对,后面全是麻烦。核心字段我按经验列一下,重点标出容易踩雷的类型。
CREATE TABLE `player_pet` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `uid` INT UNSIGNED NOT NULL, `template_id` INT UNSIGNED NOT NULL, `level` SMALLINT UNSIGNED NOT NULL DEFAULT 1, `exp` BIGINT UNSIGNED NOT NULL DEFAULT 0, `quality` TINYINT UNSIGNED NOT NULL DEFAULT 0, `growth` SMALLINT UNSIGNED NOT NULL DEFAULT 100, `skill_slots` TINYINT UNSIGNED NOT NULL DEFAULT 3, `bind_flag` TINYINT UNSIGNED NOT NULL DEFAULT 0, `create_time` INT UNSIGNED NOT NULL DEFAULT 0, `update_time` INT UNSIGNED NOT NULL DEFAULT 0, PRIMARY KEY (`id`), KEY `idx_uid` (`uid`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;几个关键选择说一下理由。level 用 SMALLINT UNSIGNED,上限 65535,开到 200 级都够,比 TINYINT 安全得多,成本也就多一个字节。exp 必须 BIGINT UNSIGNED,前面说过 32 位会溢出,这一条没有商量余地。growth 放大 100 倍存整数,资质系数同理,避免浮点在数据库和脚本之间来回转换产生精度漂移。
哪些字段不必落库?当前面板属性(攻击、防御、血量)其实可以实时算出来,落库的唯一理由是查询方便,比如后台要按战力排序发奖励。如果确实要冗余,那就必须维护一个“属性脏标记”,任何影响属性的字段变化都置脏,读取时先判断脏标记再决定用缓存还是重算,否则迟早出现面板和实际战斗数值不一致。
3.2 迁移脚本与回滚预案
改表结构在生产环境是高风险动作,尤其是宠物表这种可能几百万行的表。直接ALTER TABLE在大表上会锁表,锁表期间玩家所有涉及宠物的操作全部超时,表现就是“点宠物就转圈”。正确做法是分批、限流、可回滚。
动手之前先留后路,把表结构和数据各备份一份:
# 只备份结构,速度快,用于快速回滚表定义 mysqldump -u root -p --no-data game_db player_pet > player_pet_schema.sql # 备份数据,量大时加 --single-transaction 避免锁表 mysqldump -u root -p --single-transaction game_db player_pet > player_pet_data.sql然后改字段类型。如果用的是支持在线 DDL 的版本,加上ALGORITHM=INPLACE, LOCK=NONE,让变更期间读写不被阻塞:
ALTER TABLE player_pet MODIFY COLUMN `level` SMALLINT UNSIGNED NOT NULL DEFAULT 1, MODIFY COLUMN `exp` BIGINT UNSIGNED NOT NULL DEFAULT 0, ALGORITHM=INPLACE, LOCK=NONE;数据修正是另一件事,比如老数据里经验值是按 60 级上限算的,扩到 100 级后需要用新曲线重算一遍已有宠物的累计经验,否则一只 60 级的宠物在新曲线下可能只相当于 45 级。这类批量更新一定要加LIMIT分页跑,每批之间 sleep 一下,别一条 SQL 扫全表:
-- 分批修正,每次 1000 行,跑完一批再跑下一批 UPDATE player_pet SET exp = 0, level = 1 WHERE level > 100 AND id > 0 LIMIT 1000;回滚预案要提前写好并且演练过一次。回滚分两种:结构回滚(把字段类型改回去)和数据回滚(从备份恢复)。结构回滚相对安全,因为 SMALLINT 改回 TINYINT 时只要现有数据都在 127 以内就不会失败,但如果已经有玩家练到 100 级以上,改回去就会截断,这时候只能走数据回滚。所以我的做法是:新版本上线后的 72 小时内不做结构回滚,只做配置级回滚,也就是把上限临时调低,让新数据不再产生,等观察期过了再决定。
4. 显示 100 级却打不动怪:客户端与服务端对账
4.1 三处缓存导致的面板不同步
这是百级宠物上线后最高频的故障,表现是玩家宠物面板上写着 100 级,属性也对,但打怪的伤害跟 60 级时一模一样。这类问题的根因几乎都出在缓存上,而且是三层缓存中的某一层没刷新。
第一层是服务端进程内的模板缓存。配置表加载进内存后,除非收到 reload 指令,否则一直用旧值。你改了配置文件、重启了部分节点,但有些节点是热更的、有些是冷启的,就会出现同一台服务器上不同分线行为不一致。第二层是客户端本地缓存。很多客户端登录时拉一次宠物数据就存本地,之后靠服务端推送增量更新,如果推送协议里漏了某个字段,客户端就一直显示旧值。第三层是数据访问层的查询缓存,有些框架会缓存查询结果,更新后没失效,读到的还是旧行。
排查顺序我固定为“先服务端、再协议、最后客户端”。先在服务端升级逻辑里打一行日志,把算出来的最终攻击力打出来;如果日志里的值是对的,说明服务端没问题,问题在传输或展示;再看下发协议抓包,看字段有没有带上;最后让客户端同学清缓存重登验证。这个顺序能把排查范围每次砍掉三分之一,比瞎猜快得多。
| 现象 | 最可能的原因 | 验证方法 |
|---|---|---|
| 面板 100 级,伤害不变 | 服务端模板缓存未刷新 | 服务端日志打印计算值 |
| 面板 100 级,重登后正常 | 客户端本地缓存未更新 | 清缓存重登对比 |
| 面板 100 级,属性少一截 | 成长表未扩到 100 级 | 查成长表最大等级行 |
| 面板 60 级,经验已满 | 升级判定硬编码上限 | 搜脚本里的 60 |
4.2 技能表与宠物模板的隐式耦合
宠物等级扩到 100,牵扯的可不只是属性,还有技能解锁。绝大多数服务端的技能表里都有一个unlock_level字段,宠物到对应等级自动学会技能。如果你只改了等级上限,技能表里最高的解锁等级还停在 60,那宠物就是“100 级的身体、60 级的技能”,玩家一算伤害就发现不对劲。
反过来也有坑:技能表里配了 100 级解锁的新技能,但宠物模板里没有对应的技能槽位,或者技能槽位数量还是 3 个而新技能是第 4 个,客户端渲染时就会出现空白格子,严重的直接导致界面报错。这两个表必须一起改,改完用脚本跑一遍校验,把所有“解锁等级大于模板上限”和“技能槽位不足”的组合打印出来,一条条确认。
# 上线前的配置一致性自检,跑一遍能捞出一半问题 for pet in pet_templates: max_skill_level = max([s.unlock_level for s in skills_of(pet)] or [0]) if max_skill_level > pet.max_level: print(f"[警告] {pet.id} 模板上限 {pet.max_level},技能要求 {max_skill_level}") if len(skills_of(pet)) > pet.skill_slots: print(f"[警告] {pet.id} 技能数超出槽位")这段自检脚本我现在是当成上线前的固定流程在跑的,五分钟能跑完,比上线后半夜被叫起来强。
5. 不停服推新版:热加载的边界与灰度开关
5.1 哪些配置能热更,哪些必须重启
新版上线最理想的状态是不停服,但“能不能热更”这件事是有明确边界的,搞错了就是全服崩溃。我把常见改动分成三类。
能热更的:纯配置表的数值调整,比如经验曲线系数、成长值、掉落概率。这类改动服务端一般提供 reload 指令,重新加载文件并替换内存引用。但要注意,reload 的瞬间如果正好有玩家在升级,可能读到一半旧一半新,稳妥的做法是 reload 时加个短暂的写锁,或者选在在线人数低谷执行。
能热更但要谨慎的:脚本逻辑。lua 脚本热更后,已经在执行的协程不会中断,会继续跑完旧逻辑,新请求才走新逻辑。这意味着热更后的一小段时间内新旧逻辑并存,如果你的改动涉及数据结构变化(比如给宠物表加了个字段),旧逻辑读不到新字段就可能报错。
必须重启或停机维护的:数据库表结构变更、协议字段增删、服务端二进制里的硬编码上限。这三类没法靠 reload 绕过,只能走维护窗口。我的经验是,把必须停机的改动攒到一起,一个月做一次,其余全部走热更,这样玩家的体感最好。
5.2 灰度放量与快速回滚
百级宠物这种改动,绝对不要一次性全服开。灰度策略我一般分三级:先给内部账号和少量白名单 UID 开,观察一天看日志有没有异常、经验计算有没有溢出;再按服务器分组开,比如先开两组,对比这两组和未开组的在线时长、宠物相关客诉量;最后全量。
技术上就是加一个开关配置,判断当前玩家或者当前服务器是否在新版本名单里:
function can_use_new_pet_system(uid, sid) if is_whitelist(uid) then return true end local group = sid % 10 return group < get_config("new_pet_gray_ratio", 0) end灰度最重要的不是“开”,而是“关”。回滚开关必须做到秒级生效,也就是把配置里的比例从 30 改成 0,下一次请求就走旧逻辑。这里有个细节:已经在灰度期间升到 100 级的宠物怎么办?如果直接回滚,这些宠物的等级会超出旧上限,旧逻辑读到 100 级可能直接崩。所以灰度开关要和“等级上限开关”分开,回滚时只关新功能入口,不关等级上限,让已经升上去的宠物继续能正常运行,只是不再有新玩家进来。这个细节不做,回滚就等于制造事故。
6. 百级宠上线后的连锁反应:经济、PVP 与老宠物
6.1 产出与消耗的再平衡
宠物从 60 级扩到 100 级,属性涨了一大截,最直接的后果就是刷怪效率暴涨。我做过一次粗算:一只 60 级宠物的秒伤如果是 800,100 级大概能到 2000 以上,翻了两倍半。如果怪物血量和掉落不变,那单位时间的材料产出就是原来的两倍多,服务器整体经济会在几天内快速通胀。
这个账必须提前算。假设原来全服每小时产出材料 1 万件,消耗端(打造、强化、任务)也是 1 万件,价格稳定。扩到 100 级后产出变成 2.5 万件,消耗端没变,多出来的 1.5 万件就会砸在市场上,材料价格三天内腰斩。玩家发现自己辛苦攒的材料不值钱了,比升级慢更让人不满。
对应的手段有三个方向:抬高消耗端(新版本推出需要大量材料的新装备、新强化等级)、压低产出(新地图怪物掉落率下调,或者给宠物加一个“疲劳值”限制高强度刷怪时长)、引入新货币(把新材料做成独立体系,不和旧材料抢市场)。我一般三个一起用,力度小一点,避免一次调太猛引发反弹。
6.2 老宠物的三种处理方式
这个问题每次版本更新都绕不开:玩家手里那些已经练满 60 级的宠物怎么办?我见过三种做法,各有代价。
直接作废是最省事的,新版本只认新宠物,老宠物属性不变但不再成长。代价是玩家情绪,尤其是投入了大量资源培养的,会觉得之前的努力被清零,客诉会集中在版本更新后的第一周。
等级继承是给老宠物一个“升级通道”,比如 60 级老宠物可以花材料转成新体系下的 60 级,继续往上练。这个方案体验最好,但技术上要处理两套数值体系的映射,老宠物的属性和新宠物同等级属性可能对不上,需要一张转换表逐只校准。
转化补偿是把老宠物折算成材料返还,玩家用材料去养新宠物。这个方案本质上还是作废,但给了补偿,情绪上好接受一些。折算比例要算清楚,我一般按老宠物的培养成本 × 0.7 来给,留一点损耗,不然老玩家会觉得自己之前白玩了。
我倾向选择第二种,虽然技术上最麻烦,但它是唯一不伤害已有投入的方案。真要落地,转换表可以先用脚本批量生成,再人工抽查几十个典型样本,比对转换后的战力是否合理。
7. 几个真实故障的排查链路
7.1 宠物卡在 99 级升不上去
这个故障排查了将近四个小时。现象是玩家宠物经验条一直是满的,但就是不升 100 级。第一反应是经验溢出,查了 exp 字段,是 BIGINT,值也没超过上限,排除。接着看升级脚本,发现判定写的是if cur_exp >= need_exp and level < 100,逻辑没问题。再打日志,发现need_exp算出来是 0,也就是说 100 级的经验需求函数返回了 0,导致cur_exp >= 0恒成立但level < 100在 level 已经是 100 时又拦住,两边打架。
继续往上追,发现经验表是用一个数组存的,索引从 0 开始,数组长度只到 99,取第 100 项时越界返回了 null,脚本里做了个默认值 0 的兜底,于是就成了永远满足条件。根因是经验表数组没扩到 101 个元素,配置里写了 100 级上限,但经验表只配到 99 级。修复方案是把经验表补齐到 101 项,同时在取表的地方加一个越界告警,让这类问题在测试环境就暴露出来,而不是等玩家撞上。
7.2 批量刷宠脚本把主键跑冲突了
测试环境要造 500 只不同等级的宠物做压测,写了个脚本往表里插数据,结果跑到一半报主键冲突。查下来是脚本里用了固定 ID 生成方式,循环里id = 10000 + i,但表里已经有测试数据占用了这段区间,插入就撞了。
这件事的教训不是“换个 ID”,而是造数据脚本不能假设 ID 空间是空的。正确做法是让数据库自增,脚本只插业务字段,或者用 UUID 之类的唯一值。另外造完数据要留清理脚本,我在测试库里见过几万条忘记删的假宠物数据,后来做迁移的时候把估算的行数全算错了。
7.3 时间戳时区导致宠物“提前过期”
有一只限时宠物,配置里写了到期时间,服务端判断用的是数据库的NOW(),客户端显示用的是本地时间,两边差了 8 小时。结果就是服务端认为还没过期,客户端已经显示“已过期”,玩家点不动。这类问题在跨时区运营时特别常见,而且非常隐蔽,因为单看任何一端都没错。
我的处理原则是:所有时间戳统一存 UTC 秒数,所有比较都在服务端做,客户端只负责把 UTC 转成本地时间显示。配置表里的时间也全部用 UTC 时间戳,不要用“2024-01-01 00:00:00”这种字符串,字符串在不同环境解析出来的时区完全不可控。改完之后还要在协议里明确字段含义,标注是 UTC 还是本地,这个注释能救未来的自己。
最后分享一个我自己养成的小习惯:每次动宠物系统之前,先写一个“影响面清单”,把等级、经验、属性、技能、掉落、经济、协议、客户端缓存这八项列出来,逐项打勾确认改到了哪里、验证过没有。看起来笨,但百级宠物这种改动牵一发动全身,靠脑子记一定会漏,清单是唯一靠谱的东西。