前阵子我参与了一个虚拟社交平台的性能压测项目,项目代号就叫“新兴元宇宙”。说得直白点,就是一个仿元宇宙概念的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 Cluster(3主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 = f"ws://{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 --name=write_test --filename=/tmp/fiotest.bin --size=2G --rw=write --bs=1M --ioengine=libaio --iodepth=32 --direct=1当这个命令跑出来的顺序写吞吐低于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小时。
最终结果汇总如下:
| 指标 | 目标 | 实测结果 |
|---|---|---|
| 并发用户数 | 2000 | 2000 |
| 接口错误率 | ≤0.5% | 0.32% |
| 登录P95耗时 | ≤1000ms | 460ms |
| 移动上报P95耗时 | ≤800ms | 520ms |
| 数据库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的监控截图、问题定位的日志片段整理成一个压测报告文档存档。版本迭代后做新一轮压测时,直接拿上一轮的基线数据来对比,会比肉眼猜“这次是变快了还是变慢了”靠谱得多。压测看不到“绝对性能”,只能看到“相对变化”,有基线才有说服力。