ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

游戏后端压测CPU打满:Redis序列化与Mongo慢查询优化实录

游戏后端压测CPU打满:Redis序列化与Mongo慢查询优化实录 压测这种事最怕的不是结果差而是结果好得出奇等上线后被真实玩家瞬间教做人。前阵子我负责的一个游戏后端项目就卡在这么个不上不下的阶段单机压测时功能全通、接口全通但并发一上去CPU直接顶满TPS却死活上不去。更诡异的是业务代码简化到只剩空操作CPU照样100%。这十有八九是中间件在偷吃性能顺着这条线一路排查最后把Redis和Mongo都翻了个底朝天。这篇实录就是整个治理过程的完整复盘从火焰图定位到缓存结构重构、慢查询优化、写入模型改写每一步都有数据支撑和踩坑记录给同样被中间件性能问题折磨的人一个参考。1. 压测场景回顾一开始我只想验证登录接口的极限1.1 压测目标与环境配置在聊治理过程之前先把当时的环境交代清楚。这是一个典型的游戏后端服务核心业务包括玩家登录、角色创建、背包存取、战斗结算上报等服务端使用Java技术栈Spring Boot Netty长连接网关数据层依赖Redis做缓存和分布式锁MongoDB存储玩家档案和战斗流水。压测目标是验证登录接口在目标并发下的表现预期指标是单机支撑2000并发长连接、TPS不低于1500。压测工具用的是JMeter线程组按阶梯加压从200并发起步每5分钟增加200持续压测30分钟。为了保证压测有效我提前清理了Redis缓存和Mongo中的测试数据确保所有请求都打到真实链路上不走命中的捷径。服务器配置是4核8G的云主机Redis 和 MongoDB 分别为独立节点同机房内网。中间件版本方面Redis 5.0MongoDB 4.0。压测机与服务端在同一内网段避免网络延迟干扰数据判定。下表是压测环境的基础配置组件版本配置说明服务端Java 11 Spring Boot 2.34C8G业务节点Redis5.02C4G缓存与分布式锁MongoDB4.04C8G主从架构JMeter5.48C16G压测机阶梯加压1.2 第一轮压测的数据与问题第一轮压测结果让我印象非常深刻。200并发时一切正常TPS稳定在3000以上响应时间P99不到50ms。增加到600并发后服务端CPU开始爬到70%TPS还能维持在2600左右。但一旦并发超过1000服务端4个CPU核心全部打满TPS骤降到800请求超时率快速攀升JMeter侧P99延迟直接飙到2000ms以上。这里有个反常点正常情况CPU打满往往因为业务逻辑复杂或Full GC频繁但我已经把登录接口的Service层代码检查了一遍逻辑并不重理论上不该把4核全部吃满。更何况压测时大部分请求都命中了Redis缓存真正查Mongo的比例很低。于是拿top和jstat先做了个快速体检发现CPU的消耗分布集中在用户态而不是内核态GC也没有频繁回收的迹象。这时候基本断定是业务线程在做什么高CPU开销的事而且每秒钟发生次数非常多。结合压测路径嫌疑最大的就是Redis访问——毕竟每个请求都要读取登录态缓存。用top -Hp看了线程栈隐约看到很多线程卡在Redis序列化相关的调用上。这个线索直接把我带向了火焰图。提示压测出现CPU打满时先分清CPU消耗在用户态还是内核态。用户态消耗定点查业务逻辑和JSON/序列化操作内核态消耗重点查系统调用、网络栈和锁竞争。方向错了后面全白费。2. 从火焰图挖出真凶Redis 序列化竟然是 CPU 杀手2.1 火焰图的生成与解读这里需要用工具把CPU耗时精确抓出来。因为用的是Java服务推荐用async-profiler它可以直接生成HTML格式的火焰图不只是采样栈还能标注出每个方法的CPU占比。命令很简单./profiler.sh -d 120 -e cpu -o html -f /tmp/profile.html pid压测持续期间抓了120秒的火焰图打开第一眼就很扎心com.esotericsoftware.kryo.Kryo#writeClassAndObject和它的内部序列化逻辑占了接近40%的CPU。往下追发现所有走Redis缓存的写操作都用了KryoSerializer每次写入都对对象做Kryo序列化。Kryo本身性能不算差但问题出在用法上——每次调用都new Kryo()实例Kryo实例化时要初始化ClassResolver和InstantiatorStrategy这一步消耗非常大。火焰图里另一个隐蔽大户是com.esotericsoftware.kryo.util.DefaultClassResolver#readClass占CPU 15%这是反序列化时解析类名导致的因为对象的类名每次都要写入和读取。再加上业务对象是玩家信息类字段多嵌套深序列化开销被进一步放大。这段火焰图信息量很大我把关键占比整理如下方法CPU占比原因Kryo#writeClassAndObject40%每次new Kryo类注册失效DefaultClassResolver#readClass15%类名解析开销大Lettuce/Netty IO12%连接数过多频繁建连业务逻辑代码20%正常开销其他13%GC/日志等看到这个结果才恍然大悟CPU打满的根本原因是每个玩家请求都执行了多次Kryo序列化而每一次Kryo对象的创建都在重复构造底层ClassResolver。这种问题在低并发下不明显一旦并发冲到1000每秒几千次Kryo实例化就把CPU吃透了。2.2 为什么不能全盘反序列化深挖缓存结构设计的坑火焰图锁定了序列化之后我本想靠换序列化方案直接收工但仔细看火焰图发现另一个现象压测过程中JSON的序列化占比并不高反而是Kryo反复出现在栈顶。按照常规思路游戏后端缓存很少用Kryo更多是JSON或JDK序列化。于是我去翻了代码发现项目早期的序列化工具用的是JDK原生序列化性能差但能跑后来有同事优化过一版换成了Kryo却没有做Kryo的注册和池化。典型的高级工具错误用法。更麻烦的是缓存中存储的是对象整个类而不是拆解后的基础字段。玩家缓存里存了角色列表、装备列表、任务状态等一大坨嵌套对象每次读出来都要完整反序列化成玩家对象。其实业务经常只用到其中一两个字段比如等级、货币却要反序列化整坨数据。这个结构在压测前没有明显问题因为并发不高反正每次就多花几毫秒但并发上来后CPU和时间被无谓地放大了。当时我做了个对比实验只读取缓存中的玩家等级字段分别用Kryo全对象反序列化和只反序列化一个int字段存储时拆开存放结果后者消耗的CPU下降了60%。这算是压测过程中的第一个重要发现缓存治理的第一原则是“按需读取”不是“整存整取”。你存的时候方便取的时候代价都在暗处。基于这个现象后续的Redis缓存治理就有了清晰的思路一是换掉Kryo的错误用法做Kryo实例池化和注册二是尽量拆分缓存Key让高频字段走轻量级缓存低频大对象走全量缓存。3. Redis 治理实录从序列化到连接池的全面排查3.1 Kryo 池化与注册制既然确定了Kryo的实例化是CPU杀手第一个动作就是改掉每次new的写法。Kryo官方文档其实说得很清楚Kryo实例不是线程安全的但创建代价高所以正确的做法是使用ThreadLocal或者对象池每个线程持有一个独立的Kryo实例。我采用了ThreadLocal方案同时为每个需要序列化的类提前注册类ID。注册之后写入流中的类名变成固定的int ID不但省去了DefaultClassResolver的解析开销还能进一步压缩序列化体积。核心改造代码大概是这样的public class KryoSerializer { private static final ThreadLocalKryo KRYO_POOL ThreadLocal.withInitial(() - { Kryo kryo new Kryo(); kryo.setRegistrationRequired(true); kryo.register(PlayerProfile.class, 10); kryo.register(ItemStack.class, 11); kryo.register(QuestStatus.class, 12); kryo.setInstantiatorStrategy(new StdInstantiatorStrategy()); return kryo; }); public byte[] serialize(Object obj) { Kryo kryo KRYO_POOL.get(); ByteArrayOutputStream bos new ByteArrayOutputStream(); try (Output output new Output(bos)) { kryo.writeClassAndObject(output, obj); } return bos.toByteArray(); } public Object deserialize(byte[] data) { Kryo kryo KRYO_POOL.get(); try (Input input new Input(new ByteArrayInputStream(data))) { return kryo.readClassAndObject(input); } } }这里有两个细节必须提一下setRegistrationRequired(true)是必须的不开启的话相当于没注册Kryo还是会把完整类名写进序列化流StdInstantiatorStrategy是为了处理无默认构造函数的对象游戏里的玩家对象基本都是业务类不带空构造。改造后重新压测Kryo 相关CPU从40%降到12%TPS恢复到2400左右。但这只是第一层还没完全解决CPU打满的问题。3.2 缓存Key拆分冷热数据分离序列化优化解决了一部分问题但火焰图显示Redis读写操作的调用仍然偏多。继续分析发现玩家对象每次登录要全量拉取而且登录接口和角色查询接口都用同一个缓存Key导致一个接口的变化引发另一个接口的缓存失效重写。这里应用到第二个治理方案缓存Key按访问频率拆分。原结构player:{uid} - 整个PlayerProfile对象拆分后player:{uid}:basic - 玩家基础信息等级、金币、体力等高频字段 player:{uid}:inventory - 背包数据低频但体积大 player:{uid}:quests - 任务数据中频这样做的收益其实是双份第一份是减少反序列化体积高频接口只读basic字段不需要连带背包一起解码第二份是降低缓存更新成本玩家完成任务后只需更新quests段不会让整段缓存失效。压测数据上登录接口的RT从90ms降到40ms缓存读失败的次数也少了一截。这个拆分思路其实应该尽早设计游戏后端经常犯的错误就是将所有玩家数据打包成一个对象塞进Redis图省事。短期业务开发是快了但压测和线上大流量一来体积和序列化开销成倍增加。缓存结构本质上是在替业务承担流量压力结构设计偷的懒都会在压测报告里还回来。3.3 连接池参数校正Redis的客户端用的是Lettuce默认配置是共享连接模式。按理说Lettuce本身对连接复用做得很好但翻代码时发现早期有人为了兼容某个老接口手动设置了redis.lettuce.pool.max-active500。这导致压测时连接池疯狂创建连接Netty线程被打爆。这里重点解释一下为什么连接池参数会跑偏Lettuce的默认连接模式是基于Netty的单一连接多路复用它跟Jedis的连接池模式不同。Jedis每次操作独占一个连接Lettuce则是通过共享连接发送命令并异步等待响应。手动把Lettuce的max-active调到500等于强制Lettuce也走连接池老路每个线程都去获取连接创建连接时的握手开销和Netty事件循环压力都直线上升。在1000并发下这直接把服务端到Redis的内网连接数打满。修复方法很简单把连接池关掉回到Lettuce默认的共享连接模式spring: redis: lettuce: pool: enabled: false如果确实需要控制连接比如Redis节点有连接数上限可以适度调整为spring: redis: lettuce: pool: enabled: true max-active: 16 max-idle: 8 min-idle: 4关掉连接池后Redis操作耗时下降很明显内网连接数从500降到稳定在20左右。这个案例说明读配置要理解中间件设计哲学不能拿Jedis的习惯套Lettuce。3.4 大Key拆解与热点Key本地缓存刚才说的是整体结构压测过程中还发现两个很典型的Redis性能陷阱属于大Key和热点Key问题。先是大Key任务系统的某个玩家缓存因为存放了完整任务流水偶尔会达到2MB。这个Key每次读写都会占用Redis单线程的CPU压测过程中Redis节点CPU也一度到了70%。对这种大Key我拆成了列表式缓存每条任务单独建Key需要全量时用pipeline分批读取。这个方案也顺带解决了Mongo的慢查询——因为缓存失效后重建大缓存需要大量查询Mongo中的数据近80%都是无效重建。再说热点Key登录服的单日在线人数统计键被多个服务共用压测时并发集中写入同一个KeyRedis单线程排队导致写入延迟。处理方法是对这种计数热点使用本地缓存 异步批量上报Redis的策略。具体做法是在服务内存里维护一个计数器每10秒或累计100次后合并写一次Redis。这种方式牺牲了强一致性但对于在线人数这种非精确指标完全够用。Redis治理到这里服务端CPU整体降了一半但Mongo的慢查询开始浮出水面。压测时大量请求卡在Mongo写入服务端线程积压间接又把CPU拖高了。4. Mongo 治理实录慢查询和写入模型的重构4.1 慢日志定位每次战斗日志的写盘代价Mongo这边的问题比Redis更隐蔽。压测时发现TPS到2200左右就再也上不去了再往后加并发RT会急剧恶化。检查Mongo节点CPU只有35%内存和磁盘IO都正常不像是硬件瓶颈。打开Mongo的慢日志看看究竟是哪个操作拖了后腿db.adminCommand({ setParameter: 1, slowms: 100 })收集了10分钟慢日志发现一个显著规律battle_reports表的插入操作频繁超过200ms但单次写入的数据量并不大一条撑死几十KB。慢日志里还显示写入期间集合的锁等待次数很多说明写入并发竞争激烈。按理说单次写入只有几十KB不至于200ms。进一步检查发现表上的索引设计有问题——battle_reports上建了5个索引其中有一个针对playerId的组合索引索引字段是{ playerId: 1, battleTime: -1, reportType: 1 }。每次插入一条战斗报告Mongo除了写数据文件还要更新5个索引中的多个B-Tree节点。在压测场景下每秒插入几百条战斗报告索引更新开销被放到最大。此外写入的writeConcern设置的是majority这说明每次插入都要等待主从节点都确认写入。在测试环境主从延迟正常时没有感知但高并发大批量写入时majority写关注直接让写入RT翻倍。4.2 索引治理与写关注降级针对战斗日志表我做了三件事第一精简索引。先查一下哪些索引真正被查询使用db.battle_reports.aggregate([{ $indexStats: {} }])结果显示playerId battleTime的索引使用率最高其他索引几乎闲置。于是把5个索引砍到2个{ playerId: 1, battleTime: -1 }和{ reportId: 1 }唯一索引。索引少了写入时的维护成本立刻降下来。第二降级写关注。对于战斗报告这种容忍丢失的数据写关注从majority改为acknowledgedcollection.with_options(write_concernWriteConcern(w1))majority和w1的区别在于前者需要主节点和大多数从节点都写入成功才返回后者只需要主节点写入成功。对关键业务数据比如玩家充值记录你绝不该用w1但战斗流水这种原始数据真丢了一两条完全可以接受从成本收益上看降级是划算的。第三启用批量插入。原来的逻辑是每次战斗结束立即写一条report其实是循环单条插入。改成攒批写入每5秒或者累计50条批量执行一次operations [] for report in report_queue: operations.append(InsertOne(report)) if len(operations) 50: collection.bulk_write(operations) operations.clear()实测批量插入对吞吐量的提升非常显著同样的数据量下写入次数从每秒80次降到每秒不到10次Mongo节点的锁竞争大幅减少。4.3 从单文档高频更新到嵌入式文档模型治理完战斗日志还剩下一个最大的卡点玩家进程数据。原设计是每个玩家的任务进度、背包、技能等状态各自存放在独立的文档中。例如player_quests、player_inventory、player_skills三个集合。每次登录需要查三张表每次任务推进要更新一张表背包变动又要更新另一张表。逻辑上没毛病但在高并发游戏场景下玩家同时做多件事接任务、刷怪掉装备、技能升级可能导致一个玩家1秒内产生多次Mongo请求。更麻烦的是玩家下线时要一次性加载所有数据到内存三张表要串行查询多次。用explain()看执行计划大部分查询都命中索引但耗时和连接数仍然不理想。经过讨论我们把三个集合合并成一个player_state文档使用MongoDB的嵌套文档结构{ _id: uid_10001, basic: { level: 30, gold: 5000, energy: 66 }, inventory: [ { itemId: 101, count: 10 } ], quests: [ { questId: 2001, status: running } ] }用单个文档存放玩家核心状态一次性读取一次写入。每次进度更新就更新对应嵌套字段而不是更新多个集合中的多个文档。这个改动对压测数据的影响是很大的5. 压测优化前后数据对比与经验复盘经过Redis和Mongo两轮治理压测数据有了质的改变。为了方便对比我整理了优化前后的关键指标指标优化前优化后1000并发TPS8003100P99响应时间2000ms120ms服务端CPU使用率100%65%Redis CPU使用率70%30%Mongo慢查询数(100ms)每分钟120每分钟5以下连接数(Redis)50020服务端CPU从打满降到65%TPS从800提升到3100P99从2000ms降到120ms。更重要的是整个系统的稳定性提升了——并发从1000加到2000新方案下TPS还能稳定在2800左右不会出现雪崩式下跌。5.1 压测排查工具清单这次压测用到的工具值得记录一下下次遇到类似问题可以直接复用。工具用途使用场景top / top -Hp查看CPU/内存/线程第一轮症状确认jstat查看JVM GC情况排除GC导致的CPU问题async-profiler生成CPU火焰图定位方法级CPU热点redis-cli --bigkeys扫描Redis大KeyRedis大Key排查mongostat查看Mongo实时状态Mongo性能瓶颈判定db.collection.explain()Mongo执行计划索引使用分析JMeter压测请求模拟全流程压测5.2 游戏后端压测的避坑清单整个压测优化过程踩了不少坑整理几条最关键的经验第一压测前一定要确认缓存Key的结构。如果你在压测中途才发现缓存结构有问题前几轮的压测数据基本作废因为问题表现被缓存掩码了。先做代码审查再跑压力测试。第二Redis优化不是只看内存占用。很多时候内存没问题但CPU被序列化和网络IO吃掉了。缓存对象要尽量小巧存储内容要按需拆分不要为了省代码把所有字段塞一个大对象里。第三Mongo慢查询不能只看单条执行时间。高并发场景下锁等待和索引维护的代价往往超过执行本身。压测前先清理冗余索引设置合理的writeConcern能在源头避免很多问题。第四连接池不是越大越好。Lettuce和Jedis的机制不同用之前一定确认客户端库是否自带连接复用。手动配置大连接池在某些场景下反而会成为瓶颈。第五游戏后端压测的中间件治理要先于业务优化。通常开发阶段业务代码是重头但压测瓶颈往往在中间件层。因为业务代码大家都查得清楚而Redis序列化方式、Mongo索引设计、连接池参数这些细节平时很少引起注意。经历了这次压测优化我养成了一个习惯新功能开发时先把缓存设计和存储结构画出来给团队评审确认每个Key读取频率和写入频率再去写业务代码。中间件问题最好在设计期避免而不是等压测或者线上事故来提醒。这套治理方案在后续几个项目中复用效果都不错尤其是缓存Key拆分和Mongo批量写入的思路适用于大多数以RedisMongo为底座的游戏后端。
返回列表