前阵子我参与了一个虚拟社交平台的性能压测项目项目代号就叫“新兴元宇宙”。说得直白点就是一个仿元宇宙概念的3D虚拟社交App用户可以在里面捏脸、逛街、聊天、参加线上活动。产品方的需求很明确上线前想搞清楚这套系统到底能扛多少人同时在线哪些环节先扛不住以及如果是2000人、5000人并发进来服务器和客户端各自会是什么状态。于是就有了这篇文章——我会把整个并发压力测试的过程、踩坑记录、结果分析和工具使用细节都复盘一遍。先说结论纯HTTP接口层登录、创角、好友、私聊压到2000并发问题不大但一旦进入3D场景的移动同步和广播分发系统在1200并发左右就开始出现明显拐点。最后我们通过AOI兴趣域裁剪和广播消息合并把稳定支撑水位拉高到了1800并发以上。整个过程用到的工具包括JMeter、Locust、Prometheus、Grafana还有一些自研的小脚本。下面展开聊聊。1. 项目概述与核心需求拆解1.1 为什么虚拟社交平台的压测这么难搞传统Web系统的压测通常围绕“请求-响应”模型展开比如电商下单、资讯浏览用户的行为是短连接、无状态、低频率的。但虚拟社交平台完全不是这个路子它更像一个MMO游戏和实时通信系统的混合体。一个用户在广场上走两步客户端就要往服务器上报位置服务器要把这个位置广播给周围的其他用户同时还要处理用户之间的大头贴表情、语音、文字消息甚至未来可能有的手势交互。这意味着系统内存在大量高频的长连接和状态同步任何一点瓶颈都会被成倍放大。更麻烦的是用户在虚拟场景里的行为没法像电商那样用“浏览-加购-支付”的漏斗模型去模拟。两个用户在同一个坐标点待着可能是在聊天也可能只是路过某区域突然聚集了几百号人可能是因为某个活动也可能是系统故障导致所有人被传送到同一个出生点。所以测试场景不仅要覆盖接口请求还要模拟空间密度、移动频率、聚集热点这对压测脚本的编写和数据设计提出了挺高的要求。另外虚拟社交平台对实时性的要求极高。登录慢几秒还有用户能忍但你好友的头像在你面前移动时如果延迟超过500毫秒用户就会觉得“卡”。这种体验层面的指标没法靠看平均响应时间来衡量必须拆分到P95、P99分位值并且要结合网络链路一起分析。1.2 这次压测要回答的核心问题在正式开始之前我们跟业务方和开发团队一起拉齐了测试目标。概括起来就四个问题系统当前架构能支撑多少注册用户、多少DAU量级下的峰值在线从用户点击“进入世界”到看到自己角色出现在场景里端到端耗时是多少当某个区域比如活动广场出现热点聚集时服务器的CPU、内存、带宽、数据库连接池哪一项先到达瓶颈在极限压力下系统是平滑降级还是直接雪崩有没有兜底措施围绕这些问题我们设定了几个核心指标目标并发用户数为2000人要求接口错误率不超过0.5%登录接口P95响应时间低于1秒场景内移动上报的P95响应时间低于800毫秒服务器CPU使用率峰值不超过75%。这些指标不是随便拍的都是参考了行业同类产品的实际体验数据和当前集群配置算出来的。有一点要记住压测不是为了证明系统有多强而是为了在上线前把最弱的一环找出来。2. 测试环境与压测场景设计2.1 被测环境与压测机资源规划这次测试的部署架构相对简单前端挂了2台Nginx做负载均衡后面是4台应用服务器8核16G数据库用的MySQL主从缓存是Redis Cluster3主3从另外还有一台独立的WebSocket网关集群负责处理长连接。所有服务都在同一个云厂商的VPC内网里压测机和被测服务之间的网络 latency 基本在0.5ms以内可以忽略不计。压测机方面我们准备了三台8核16G的云主机系统盘是SSD。有一点需要特别提醒压测机的磁盘性能和内存同样重要。JMeter和Locust在跑高并发的时候会持续写日志和结果文件如果磁盘IO跟不上压测机自己就卡死了你以为是在压服务器其实是在测试自己。我们当时用fio对三台压测机的磁盘做了简单的顺序写测试发现其中一台的SSD持续写入只有不到200MB/s果断让云平台换了一块盘这个细节如果忽略掉后果就是压测数据不可信。这里我强调一下压力测试首先要保证压测机本身的稳定性。压测机资源不足导致的CPU瓶颈、磁盘瓶颈、网络瓶颈都会直接污染测试结果。一般建议在正式压测前先跑一轮小规模冒烟测试同时观察压测机的资源占用如果压测机CPU都90%了被测服务才20%那说明工具和负载机配置需要加码。2.2 虚拟社交场景拆解与用例设计这个虚拟社交平台的用户行为可以拆成六个核心场景。我们讨论了很多轮最终确定以下分类几乎覆盖了用户在平台上的所有动作场景编号场景名称核心操作并发占比S1登录与创角获取凭证、登录、创建角色、加载角色数据10%S2闲逛移动高频位置上报、周围玩家同步45%S3社交互动打招呼、加好友、发文字消息、表情互动20%S4热点聚集多人同时进入同一区域、活动广播15%S5语音通信模拟音频数据包上行下行、音视频信令8%S6混合路径登录后随机执行S2-S5行为符合“真实用户”操作路径2%占比不是随便拍的是参照产品方给出的用户行为埋点数据。比如闲逛移动在整体操作里占比最高因为虚拟社交平台本质上还是一个“逛”的地方用户在里面的停留时间内大部分时间都在移动和观察周围。语音通信虽然占比不高但它对带宽和网络延迟的敏感度最高所以即使只有8%的占比我们也专门为它设计了单独的测试轮次。场景设计里还有一个容易被忽略的点并发分布的时间模型。用户不是从一个按钮里同时涌出来的而是有波峰波谷的。所以我们在JMeter里用了阶梯线程组Stepping Thread Group而不是固定线程组让并发量从50开始每5分钟增加50人直到达到预设上限或系统出现明显瓶颈。这样做的好处是能观察系统在不同负载下的拐点而不是一上来就砸2000并发得到一个“崩了”或“没崩”的二值结果。2.3 压测数据准备与账号隔离虚拟社交平台的压测数据准备工作比普通业务系统繁琐得多。每个虚拟用户需要有自己的账号、昵称、头像、位置坐标和社交关系链。如果所有用户都用一个账号反复登录大概率会触发服务端的单账号踢下线机制而且数据库里同一行记录会被反复更新形成锁竞争完全测不出真实性能和稳定性。我们用了两种方式准备数据一是通过官方注册接口批量创建了5万个账号并为每个账号预设了地理位置均匀分布在三个大地图场景中二是编写脚本预置了部分好友关系因为加好友这个动作在压测过程中实时执行的话会对数据库产生额外的写压力可能会掩盖真正的性能瓶颈。这里有个坑要提醒一下批量创建的账号初始坐标可能重叠导致服务端AOI兴趣域管理模块在启动瞬间要处理大量“同点聚集”的包围盒计算。我们第一次压测时就因为这个原因开场5分钟服务器CPU直接飙到90%。后面改为均匀撒点并且使用大于1:10的账号池做轮询才把问题解决。3. 压测工具选型与脚本实现3.1 JMeter和Locust的分工这次项目里我们同时使用了JMeter和Locust很多朋友会问“到底哪个工具好”我的回答是要看你测的是什么协议、什么场景。JMeter的优势在于开箱即用、支持HTTP/HTTPS/WebSocket/数据库JDBC等多种协议而且线程模型稳定生成的HTML聚合报告非常直观适合做大规模HTTP接口层压测。另外它的插件生态很丰富像PerfMon Metrics Collector、Stepping Thread Group这类插件能帮我们省去很多自己写监控的工作。Locust的优势在于用Python写脚本灵活性极高。遇到需要自定义协议、维护长连接、模拟复杂用户行为比如登录后随机走动再发消息的场景JMeter的BeanShell和JSR223脚本写起来又长又难维护而Locust的代码组织方式就像写单元测试一样自然。所以我们用JMeter负责覆盖登录创角、好友互动这类HTTP场景的主压测轮次用Locust专门跑WebSocket长连接和语音信令部分。压测结果也证明这个分工是合理的。JMeter在800并发HTTP请求下表现稳定压测机资源占用大约45%Locust在模拟1000个WebSocket连接时单台压测机完好但一旦超过1800个连接Python的GIL瓶颈就出来了需要再横向扩展负载机。工具的单机能力上限是真实存在的不要等压测中途才去加机器。3.2 JMeter登录场景脚本的关键细节登录这个场景看着简单实际在虚拟社交平台里是一个串联操作。用户要完成一次完整登录必须按顺序请求多个路径获取设备凭证匿名Token通过账号密码换取登录凭证Session Ticket获取角色列表载入角色数据包含外观、坐标、背包等获取好友列表发送“进入世界”信令建立WebSocket长连接在JMeter里这些步骤全部放在一个线程组下的多个HTTP Sampler里并通过正则表达式提取器拿到上一步返回的Token和角色ID传给下一步。有几个细节要给新手提个醒线程组里的HTTP请求默认是串行执行的你只需要按顺序添加Sampler即可。但所有Sampler必须共享同一个Cookie管理器否则登录状态没法在后续请求中保持。获取Token时服务器的返回可能是JSON嵌套结构建议用JSON Extractor配合$.data.token这种JsonPath语法比正则表达式提取器稳定得多。真实用户在关键步骤之间会有思考时间Think Time压测时如果完全不加停顿所有用户都会用最大速率打请求测出来的是系统处理能力上限但不是真实用户体验。我们在登录串联链路里加了一个平均2秒、偏差1秒的高斯随机定时器来模拟真实操作的间隔。登录场景的第一轮施压结果其实也挺有意思当我们把登录场景单独压到500并发时系统一切正常P95响应时间只有340ms。这给我们造成了一种“系统性能很好”的错觉但后面一跑混合场景问题立刻就暴露了。所以说单一场景压测只能作为参考必须跑混合路径才能反映真实情况。3.3 Locust脚本实现长连接模拟的核心思路Locust压测WebSocket长连接的脚本核心不在于请求本身而在于连接的生命周期维护。我们写了一个自定义的Locust客户端在用户继承自Locust的HttpUser初始化时建立WebSocket连接然后通过一个后台协程定时发送心跳包和模拟的位置上报数据。import asyncio import json import websockets from locust import User, task, between, events class VirtualWorldClient: def __init__(self, host, token, avatar_id): self.host host self.token token self.avatar_id avatar_id self.ws None self._heartbeat_task None async def connect(self): uri fws://{self.host}/ws?token{self.token}avatar_id{self.avatar_id} self.ws await websockets.connect(uri) # 发送进入世界信令 await self.ws.send(json.dumps({type: enter_world, avatar_id: self.avatar_id})) # 启动心跳协程 self._heartbeat_task asyncio.ensure_future(self._heartbeat_loop()) async def _heartbeat_loop(self): while True: await asyncio.sleep(15) if self.ws: await self.ws.send(json.dumps({type: heartbeat, timestamp: asyncio.get_event_loop().time()})) async def move(self, x, y, z): await self.ws.send(json.dumps({ type: move, avatar_id: self.avatar_id, x: x, y: y, z: z, timestamp: asyncio.get_event_loop().time() }))在Locust用户类里我们把每个虚拟用户定义成一个独立的任务循环在on_start里asyncio.get_event_loop().run_until_complete(client.connect())。这里有一个比较重要的点Locust每个用户默认跑在一个greenlet里你可以在用户类里用gevent.sleep控制操作频率但WebSocket的异步读写必须依赖一个事件循环。所以我们为每个用户单独关联了一个asyncio事件循环而不是共用同一个循环否则一旦某个连接阻塞会拖累其他所有连接。长连接压测的失败率判定跟HTTP不一样。HTTP只要收到4xx/5xx或超时就判定失败而WebSocket需要自己处理消息类型。我们在脚本里对服务端下发的enter_world_ack、position_broadcast等消息做了超时判断如果超过5秒没收到服务端心跳或广播就认为这个连接已经假死并在Locust的请求统计里记录为一次失败。这样做的好处是能及时发现服务端连接泄漏的问题而不是等到连接数满才发现异常。3.4 监控体系和固态硬盘压测前置压测不是把压力发出去就完事了监控体系决定了你能不能在出问题时快速定位。我们使用了Prometheus Grafana作为监控栈在每台应用服务器上部署了node_exporter采集CPU/内存/磁盘/网络MySQL和Redis也接入了对应的exporter。另外在JMeter里配置了PerfMon Metrics Collector插件它可以通过Agent在应用服务器上实时采集性能指标并和JMeter的响应时间曲线显示在同一张图上这是定位“响应时间升高到底是服务器资源不够还是代码锁竞争”最快的方式。额外说一个容易忽略的环节压测机固态硬盘的写压能力。我们在压测过程中JMeter每秒钟会产生几千行日志再加上聚合报告和结果文件的写入如果压测机本身的SSD写入性能不稳定会出现一个诡异的现象——压测机本身的CPU和内存都正常但JMeter的响应时间曲线呈锯齿状周期性抖动。排查下来发现是结果文件写入时磁盘IO排队。所以在正式压测前对压测机磁盘做一轮写入性能测试是很有必要的我当时用fio跑了个简单的顺序写测试fio --namewrite_test --filename/tmp/fiotest.bin --size2G --rwwrite --bs1M --ioenginelibaio --iodepth32 --direct1当这个命令跑出来的顺序写吞吐低于300MB/s时建议换一台实例或者调整JMeter的日志级别把日志级别改成ERROR别让写日志成为压测瓶颈。4. 施压过程与关键瓶颈定位4.1 阶梯加压节奏设计我们采用了阶梯加压模式具体参数如下起始并发50每步增量50每步持续时长5分钟目标并发2000压测总时长约200分钟结合场景拆分多个轮次执行前面讲过阶梯加压是为了找拐点。我们一边观察JMeter的聚合报告一边盯着Grafana的实时监控面板。如果某个并发值下出现了错误率上升或P95耗时突然超过目标的迹象就立即记录当时的并发数和各项资源指标然后决定是继续加压还是回到上一档做持续稳定性验证。还要说明一下2000并发在实际虚拟社交业务里意味着什么。2000个在线用户不等于2000个同时在操作。根据经验在线用户里真正处于活跃状态在移动、聊天、互动的大约占30%-40%所以2000并发大概对应一个5000-6000人的在线频道这对一个刚起步的元宇宙社区来说已经是很可观的规模了。4.2 第一次翻车登录接口的数据库锁竞争第一轮测试跑的是登录创角场景并发从50升到300之前一切正常。当并发升到350时登录接口的错误率突然跳到12%响应时间从平均200ms飙升到3秒以上数据库服务器的CPU一下子打满。当时第一反应是数据库连接池不够用查了一下连接数才80多个远没到上限。再往MySQL慢查询日志一看发现大量INSERT INTO user_profiles ... ON DUPLICATE KEY UPDATE语句在执行并且waiting for table metadata lock的线程堆积非常严重。顺着这个问题查下去发现根因是注册接口和登录接口共用了同一个事务逻辑每次登录时如果系统查不到该账号对应的角色档案就会自动创建一个。而压测数据里有一部分账号虽然在账号表里存在但角色档案表里没有对应的数据导致多个并发线程同时尝试插入同一条用户档案记录。MySQL的InnoDB在重复键插入时会先获取共享锁冲突后升级为排他锁于是大量线程阻塞在锁等待上形成恶性循环。这个问题的修复方案其实很简单把账号注册和角色档案初始化完全分离。账号在数据准备阶段就已经注册完毕登录接口不再承担“若不存在则创建角色档案”的兜底逻辑。如果登录时真遇到档案缺失直接返回明确错误码让客户端走一次初始化流程。修复后登录场景压到800并发P95耗时稳定在180ms左右。这里想提醒的是压测不只是发现“响应变慢了”更关键的是要通过对日志、慢查询、线程栈的分析找到变慢的根因。很多时候性能瓶颈不在代码本身而在数据模型设计。4.3 第二次瓶颈AOI广播风暴打满网络带宽登录问题解决后我们开始跑最核心的S2闲逛移动场景。并发到800之前都算稳定但从900开始应用服务器的CPU占用率只有40%但网络出方向流量急剧上升直接冲到800Mbps以上内网带宽上限1Gbps同时WebSocket网关的内存也在持续上涨。用户端的表现就是所有移动操作都开始卡顿位置同步延迟从300ms一路涨到2秒。这个现象在当时看来有点反直觉CPU不高内存也不高数据库压力也不大但用户体感就是卡。我们通过Grafana的网络监控发现瓶颈在网络出口带宽。再看看代码逻辑问题出在AOI兴趣域管理模块当服务器收到一个玩家的移动消息后需要把新的位置广播给“周围的所有玩家”。但当时这个版本的AOI算法只是一个粗略的全量广播没有对广播范围做裁剪相当于每个玩家移动一下服务器就要给全服所有在线用户广播一次他的位置。假设200人在同一区域每人每秒移动2次那每秒就有2002199约80000条位置广播消息。每条位置消息大概几百字节累积起来就是几十MB/s的流量。如果场景里人数继续增加广播消息量会按O(n²)增长带宽早晚被打满。这个案例特别典型它说明虚拟社交平台的性能瓶颈往往不在服务器算力而在网络带宽和消息量级。解决方法是把全量广播改成基于九宫格的AOI裁剪只向目标玩家周围9个格子内的玩家发送位置更新。同时合并广播消息把同一秒内同一个目标玩家的20条位置更新打包成一条“批量移动”消息而不是逐条发送。优化后同样在900并发下网络出方向流量从800Mbps降到了200Mbps以内P95响应时间也回落到500ms以下。4.4 混合场景下的容量评估结论处理完AOI广播问题后我们重新跑了完整的混合场景目标并发2000分两轮第一轮阶梯加压到2000并观察30分钟稳定性第二轮直接把并发固定在1800跑4小时。最终结果汇总如下指标目标实测结果并发用户数20002000接口错误率≤0.5%0.32%登录P95耗时≤1000ms460ms移动上报P95耗时≤800ms520ms数据库CPU使用率峰值≤75%63%应用服务器CPU使用率峰值≤75%71%网络出方向带宽峰值≤85%82%峰值出现在900并发时AOI优化后整体回落系统在2000并发下整体稳定运行没有出现雪崩。按活跃用户占比40%折算当前集群配置大约可以支撑5000人在线其中同时活跃用户约2000人。如果产品的DAU目标超过这个量级建议优先横向扩展WebSocket网关并在网关层引入消息队列做异步解耦。5. 常见问题与排查技巧实录5.1 压测现场问题速查表把这次压测以及过去做过的几次压测遇到的典型问题整理成一个排查表方便大家以后直接对照问题现象可能原因排查思路与处理建议错误率不高但RT持续上升线程阻塞比Blocking Ratio偏高查线程池活跃线程数抓线程栈看阻塞点压测机CPU高但服务器无压力压测机性能不足增加负载机降低JMeter日志级别数据库连接池打满连接泄漏或慢SQL占用连接过久查连接获取/释放代码打开连接池监控响应正常但客户端表现卡顿服务端网络出口带宽瓶颈观察Grafana网络曲线检查广播消息量级Redis命中率正常但接口延迟高服务端序列化方式不当检查是否是JSON序列化大对象换Protobuf内存持续增长后OOM长连接数据未释放查WebSocket连接Map的Key是否引用未被回收压测结果周期性抖动压测机磁盘写入瓶颈或系统定期任务用fio测压测机磁盘查看crontab和定时任务5.2 虚拟社交平台压测的专属心得压完这个项目我最大的心得是虚拟社交平台的压测难点不在于“压”而在于“模拟得像”。HTTP接口层的压测工具和经验完全可以复用但一旦涉及位置同步、AOI广播、长连接心跳这些游戏化场景你必须有针对性地设计消息模型和数据分布并且把结果指标从“请求响应时间”转成“端到端延迟”和“吞吐量”。另外一个心得是关于排查工具的。性能压测中的问题排查最重要的是要有全局视角。CPU、内存、数据库、网络、GC日志、线程栈每一类数据都只能看到问题的某个切面。比如AOI广播那个问题如果只看应用服务器CPU你永远不会猜到瓶颈在网络带宽。所以压测过程中我强烈建议保持Grafana的多个面板同时可见至少覆盖网络出方向流量、CPU使用率、内存曲线、GC停顿时间、WebSocket连接数、Redis慢查询。还有一个偏门但重要的点不要一上来就用那些在社群里流传的“大压力工具”或者“CC压测脚本”。这类工具本身是DoS攻击的工具不分青红皂白地打流量打出来的数据对容量规划没有参考价值而且很容易把自己的服务器打挂、把自己的出口带宽占满还给运维带来误会。做正规的平台压测老老实实用JMeter、Locust这类可控制的工具按业务模型设计脚本和数据才是正道。6. 一些可以继续深挖的方向压力测试做到这个程度其实只能算“验证了当前版本的极限”距离“让系统自适应地应对流量波动”还有不少路要走。这次压测发现的AOI广播问题、数据库锁竞争问题解决的都只是特定场景下的痛点。按照目前行业里的做法往下走还有两个方向可以推进。一是工具链自动化。现在整个压测流程里环境准备、脚本部署、数据生成、监控看板、结果归档还是半自动的每次压测都要人工介入1-2小时。理想状态是做成一个压测调度平台输入目标并发和场景组合自动拉起压测集群、准备账号数据、跑完自动出报告。GitLab CI/Jenkins的流水线配合Docker容器完全可以把这套流程脚本化。二是容量预测模型。当压测数据积累到一定程度后可以根据历史几轮压测的测试结果建立资源消耗和在线用户数的回归模型。比如每增加100个活跃用户网关内存就会增加多少GB数据库QPS会增加多少网络出口带宽会增加多少Mbps。有了这个模型产品方在做活动预算时就可以提前估算需要扩容多少台服务器而不是每场活动都提心吊胆。最后再分享一个实用的习惯每次压测完把JMeter的聚合报告CSV、Grafana的监控截图、问题定位的日志片段整理成一个压测报告文档存档。版本迭代后做新一轮压测时直接拿上一轮的基线数据来对比会比肉眼猜“这次是变快了还是变慢了”靠谱得多。压测看不到“绝对性能”只能看到“相对变化”有基线才有说服力。