1. 这不是一次常规压测,而是一场后端服务的“生存压力测试”
我接手这个项目时,团队刚经历了一次线上事故:新版本上线后,凌晨三点用户涌入高峰期,游戏登录接口响应时间从200ms飙升到3.8秒,匹配队列积压超2万,充值回调失败率突破47%。运维甩来一张监控截图——CPU使用率曲线像一根被拉直的钢筋,死死钉在98%以上整整47分钟。这不是负载均衡没配好,也不是数据库连接池太小,而是整个后端服务在真实玩家行为下彻底“喘不过气”。我们决定不做表面优化,直接上JMeter做全链路压测,目标很明确:不只看TPS数字,要看每毫秒CPU花在哪、Redis里缓存是否在“假命中”、Mongo里那些被反复查询的文档到底有没有索引覆盖。标题里说的“从CPU打满到Redis/Mongo全面治理”,不是修修补补,是把整条数据通路拆开、逐段验算、重新设计。如果你正在维护一个日活50万+的实时对战类游戏后端,或者正被“明明配置够高却扛不住并发”的问题困扰,这篇实录里的每一个参数、每一行日志、每一次堆栈采样,都是我在生产环境里亲手抠出来的。它不讲理论模型,只讲“当时为什么这么改”“改完监控图怎么变”“下次再遇到类似问题该先看哪三行日志”。
2. 压测方案设计:拒绝“模拟用户点击”,直击真实业务脉冲
2.1 为什么放弃标准JMeter脚本?真实玩家行为根本不是线性增长
很多团队压测第一反应是打开JMeter,录个登录+进房间+发消息的流程,设个1000线程循环跑。我们试过——结果TPS稳定在1200,CPU才65%,和线上凌晨三点的崩溃状态完全对不上。后来我们调取了上周事故时段的真实Nginx访问日志,用Python做了行为聚类分析,发现三个关键特征:
- 脉冲式爆发:玩家集中上线不是均匀分布,而是以“开服公告推送→3分钟内涌入72%用户”为典型模式,峰值QPS是均值的4.3倍;
- 读写比失衡:登录阶段写操作(生成token、更新last_login_time)占比仅12%,但后续匹配阶段读操作(查玩家段位、查对手列表、查历史战绩)占83%,且87%的读请求集中在5个核心集合(players、matches、rankings、items、guilds);
- 缓存穿透高频:事故期间Redis慢日志显示,有23%的GET请求key不存在,但这些key都带固定前缀(如
player:profile:123456789),说明客户端未做空值缓存,每次查不到就穿透到Mongo。
所以我们重构了JMeter脚本,不再模拟“单用户完整流程”,而是按真实流量比例拆解成四组独立线程组:
| 线程组 | 模拟场景 | 占比 | 核心操作 | 关键参数 |
|---|---|---|---|---|
| 登录脉冲组 | 开服瞬间涌入 | 35% | POST /auth/login, 写Redis token, 更新Mongo players.last_login | Ramp-up 15s, 并发数=预估峰值QPS×0.35 |
| 匹配读取组 | 实时匹配查询 | 48% | GET /match/available?tier=gold, 批量查players+rankings+matches | 使用CSV Data Set Config加载10万玩家ID,随机取500个ID批量查询 |
| 装备变更组 | 高频写操作 | 12% | PUT /player/items, 更新Mongo players.items数组, 同步写Redis player:items:123456 | 每次请求带唯一trace_id,便于链路追踪 |
| 排行榜刷新组 | 定时聚合写 | 5% | POST /rankings/refresh, 调用Mongo聚合管道计算top100, 写入Redis zset | 固定每30秒触发一次,模拟后台任务 |
提示:JMeter中必须开启“Backend Listener”并配置InfluxDB+Grafana,否则你看到的只是平均响应时间,而真实瓶颈往往藏在P95/P99的毛刺里。我们曾因忽略这点,在优化前误判“接口很稳”,结果上线后还是崩。
2.2 监控体系不是锦上添花,而是压测的“听诊器”
没有精准监控的压测,就像蒙眼做手术。我们搭建了三层监控:
- 基础设施层:
top -H实时抓取Java进程各线程CPU占用,配合jstack定位热点线程;iostat -x 1观察磁盘await是否超100ms(Mongo写入瓶颈信号); - JVM层:通过JMX暴露
java.lang:type=MemoryPool,name=PS Eden Space等指标,重点盯住Young GC频率(>5次/秒即告警)和Full GC次数(压测中出现1次即终止); - 应用层:在Spring Boot Actuator基础上,自定义了
/actuator/metrics/redis:hit-rate和/actuator/metrics/mongo:query-time-p95端点,用Micrometer上报到Prometheus。
最关键的发现来自jstack和arthas的组合使用。当CPU打满时,我们执行arthas thread -n 5,发现TOP5线程全是nioEventLoopGroup,但真正耗时的操作在RedisTemplate.opsForValue().get()的序列化反序列化环节——因为默认用JDK序列化,反序列化一个2KB的Player对象平均耗时18ms,而Jackson只需2.3ms。这个细节,任何APM工具都不会直接告诉你“序列化太慢”,只会标红“Redis响应慢”。
2.3 压测目标不是“扛住多少QPS”,而是“找出第一个断裂点”
我们设定的压测终止条件非常具体:
- CPU持续>90%超过30秒 → 立即停止,记录当前QPS;
- Redis连接池等待队列长度>50 → 记录连接池配置与实际使用率;
- Mongo慢查询日志(>100ms)数量每分钟>50条 → 抓取对应query profile;
- JVM Young GC频率>3次/秒且Eden区使用率>95% → 分析对象创建热点。
这种“故障驱动”的压测逻辑,让我们在第一次压测就定位到三个关键断裂点:Redis序列化瓶颈、Mongo缺少复合索引、玩家数据分片不均。如果只盯着TPS数字,这些深层问题会一直被掩盖。
3. CPU打满根因深挖:不是代码写得烂,而是资源调度错配
3.1 表面现象:Java进程CPU 99%,但jstat显示GC正常
第一次压测跑到QPS=1800时,服务器CPU飙到99%,jstat -gc <pid>显示GC一切正常(Young GC 2次/秒,Full GC 0),jmap -histo <pid>也没发现内存泄漏对象。这说明问题不在堆内存,而在CPU时间片分配。我们用perf top -p <pid>抓取热点函数,结果出乎意料:
32.78% [java] libnet.so sys_read 18.42% [java] libjvm.so os::Linux::safe_cond_timedwait 12.65% [java] libzip.so ZIP_GetNextEntry 7.31% [java] libnio.so Java_java_nio_channels_FileChannelImpl_transferTo0sys_read占比最高?这通常意味着线程在等待I/O。但我们的服务基本不读文件,所有数据都走Redis/Mongo。继续用strace -p <pid> -e trace=read,write,recv,send跟踪,发现大量recvfrom系统调用阻塞在Redis连接上——原来Redis客户端用的是同步阻塞模式,每个请求都独占一个线程等待网络响应,而线程数又远大于Redis连接数,导致大量线程陷入WAITING状态,不断被OS调度切换,白白消耗CPU。
3.2 解决方案:从Lettuce异步客户端切入,而非简单加机器
很多人第一反应是“加Redis节点”或“扩容服务器”。但我们先做了更低成本的改造:将Jedis全部替换为Lettuce,并启用异步API。关键配置如下:
// Redis配置类 @Bean public RedisClient redisClient() { // 启用连接池,最大连接数=CPU核数×2(非绝对,需实测) ClientResources resources = DefaultClientResources.builder() .ioThreadPoolSize(Runtime.getRuntime().availableProcessors() * 2) .computationThreadPoolSize(Runtime.getRuntime().availableProcessors()) .build(); RedisURI redisUri = RedisURI.Builder.redis("10.0.1.10", 6379) .withPassword("xxx") .withTimeout(Duration.ofSeconds(2)) .build(); return RedisClient.create(resources, redisUri); } // 业务代码改造:从同步get变为异步thenApply public CompletableFuture<Player> getPlayerAsync(String playerId) { StatefulRedisConnection<String, String> connection = redisClient.connect(); RedisStringReactiveCommands<String, String> commands = connection.reactive(); return commands.get("player:profile:" + playerId) .map(json -> objectMapper.readValue(json, Player.class)) // Jackson反序列化 .toFuture(); // 转为CompletableFuture便于编排 }这里有两个关键点必须强调:
- 连接池大小不是越大越好:我们实测发现,当
ioThreadPoolSize设为CPU核数×4时,线程上下文切换开销反而增加12%,最终选定×2是平衡点; - 反序列化必须脱离IO线程:Lettuce的
reactive()命令返回的是Mono,map()操作在IO线程执行,若在此处做Jackson解析,会阻塞Netty事件循环。正确做法是.publishOn(Schedulers.boundedElastic())切到专用线程池,但考虑到Player对象解析简单,我们选择.map()后立即.toFuture(),由调用方线程处理。
改造后,同样QPS=1800,CPU降至62%,Redis连接数从320降为48(复用率提升6.7倍)。这不是性能提升,而是资源利用效率的重构。
3.3 深层陷阱:Mongo聚合查询的“隐式排序”吃光CPU
CPU下降后,我们继续加压到QPS=2500,CPU又缓慢爬升至85%。这次perf top指向libmongoc.so的_mongoc_cluster_run_command函数。抓取Mongo慢日志,发现一条查询高频出现:
db.players.aggregate([ { $match: { tier: "diamond", level: { $gte: 50 } } }, { $sort: { exp: -1 } }, // 问题在这里! { $limit: 10 } ])表面看是查钻石段位50级以上的玩家Top10,但$sort在内存中执行,当匹配到100万玩家时,排序耗尽CPU。而实际上,我们只需要Top10,完全可以用索引覆盖。解决方案是创建复合索引:
// 创建索引:先按tier和level过滤,再按exp排序 db.players.createIndex({ "tier": 1, "level": 1, "exp": -1 })创建后,执行explain()显示executionStats.executionTimeMillis: 3(原为1280ms),且stage: IXSCAN证明走了索引。更重要的是,CPU占用直接下降18个百分点——因为排序操作从CPU密集型变成了索引扫描的I/O密集型,而SSD的I/O延迟远低于CPU排序的计算延迟。
注意:Mongo索引字段顺序极其关键。
{tier:1, level:1, exp:-1}能覆盖查询,但{tier:1, exp:-1, level:1}就不能,因为level:{$gte:50}是范围查询,必须放在索引末尾才能生效。这是很多团队踩坑的地方。
4. Redis全面治理:从“缓存”回归“分布式协作内存”
4.1 缓存雪崩不是“Redis挂了”,而是“所有Key在同一时刻失效”
事故复盘时,我们发现一个致命设计:所有玩家Profile缓存设置统一过期时间2小时,且用EXPIRE命令单独设置。结果就是每天凌晨2点(服务器时间),Redis里200万+个player:profile:*Key同时过期,后续请求全部穿透到Mongo,瞬间打垮数据库。这不是Redis的问题,是缓存策略的设计缺陷。
我们改为“逻辑过期+后台刷新”双保险:
public Player getPlayerWithLogicExpire(String playerId) { String key = "player:profile:" + playerId; String json = redisTemplate.opsForValue().get(key); if (json == null) { // 缓存穿透防护:先设空值,防止大量请求穿透 redisTemplate.opsForValue().set(key + ":lock", "1", Duration.ofSeconds(3)); // 尝试获取分布式锁 if ("1".equals(redisTemplate.opsForValue().get(key + ":lock"))) { Player player = mongoTemplate.findById(playerId, Player.class, "players"); if (player != null) { // 序列化后写入,过期时间设为随机值(2h±15min),避免集体失效 long expire = 7200 + ThreadLocalRandom.current().nextInt(-900, 900); redisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(player), Duration.ofSeconds(expire)); } redisTemplate.delete(key + ":lock"); } return player; } // 检查是否逻辑过期(JSON里存expireTime字段) Player player = objectMapper.readValue(json, Player.class); if (player.getExpireTime() < System.currentTimeMillis()) { // 后台异步刷新,不影响当前请求返回 CompletableFuture.runAsync(() -> refreshPlayerCache(playerId)); } return player; }这个方案的核心在于:
- 随机过期时间:避免Key集中失效,实测将缓存穿透峰值降低83%;
- 逻辑过期标记:在Player对象里加
expireTime字段,比Redis物理过期更可控; - 后台刷新机制:用户无感知,且刷新失败不影响服务可用性。
4.2 Redis内存暴增真相:不是数据太多,而是序列化膨胀
压测中Redis内存使用率从45%飙升到92%,INFO memory显示used_memory_human: 15.23G,但DBSIZE只有280万Key。用redis-cli --bigkeys扫描,发现最大的Key是rankings:global:zset,大小1.2GB。导出后分析,发现一个ZADD命令写入的value竟达8KB——而实际只需要玩家ID和分数两个字段。
根源在于序列化方式。原代码用RedisTemplate默认的JdkSerializationRedisSerializer,序列化一个含12个字段的RankingEntry对象,生成字节数组长达7.8KB。换成Jackson后:
// 自定义序列化器 RedisTemplate<String, Object> redisTemplate = new RedisTemplate<>(); redisTemplate.setKeySerializer(new StringRedisSerializer()); redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); // 替换为Jackson同样对象序列化后仅1.1KB,内存占用直接下降86%。更关键的是,Jackson序列化后的JSON可读,方便调试和人工干预(比如用redis-cli直接GET查看内容)。
实操心得:不要迷信“序列化越小越好”。我们曾尝试Protobuf,序列化后仅0.8KB,但反序列化耗时比Jackson高40%,且开发调试成本陡增。最终选择Jackson,是综合了体积、速度、可维护性的结果。
4.3 分布式锁失效:不是RedLock算法错,而是锁释放时机不对
游戏中“玩家背包操作”必须加锁,原代码用SET key value NX EX 10实现,看似没问题。但压测时发现,当某个操作耗时超过10秒(如批量合成装备),锁自动释放,其他请求进入,导致数据错乱。
根本原因在于:锁的持有者不知道自己是否还持有锁。正确做法是用Lua脚本保证原子性:
-- 加锁:只有key不存在时才设置,且设置唯一value(clientID) if redis.call('set', KEYS[1], ARGV[1], 'NX', 'EX', ARGV[2]) then return 1 else return 0 end -- 解锁:必须验证value匹配才删除,防止误删他人锁 if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end我们在Java中封装为:
public boolean tryLock(String lockKey, String clientId, int expireSeconds) { return (Long) redisTemplate.execute(lockScript, Collections.singletonList(lockKey), clientId, String.valueOf(expireSeconds)) == 1L; } public boolean unlock(String lockKey, String clientId) { return (Long) redisTemplate.execute(unlockScript, Collections.singletonList(lockKey), clientId) == 1L; }这个方案确保了锁的“持有者专属”,即使操作超时,也不会被其他线程误删。实测后,背包操作数据一致性从99.2%提升至100%。
5. Mongo深度治理:从“文档数据库”回归“高性能查询引擎”
5.1 索引不是越多越好,而是要匹配查询模式
我们原有索引共17个,但db.players.getIndexes()显示,其中9个从未被explain()命中。最典型的错误是为每个字段单独建索引:
// 错误:为单字段建索引,无法支持复合查询 db.players.createIndex({ "tier": 1 }) db.players.createIndex({ "level": 1 }) db.players.createIndex({ "exp": -1 }) // 正确:按高频查询模式建复合索引 db.players.createIndex({ "tier": 1, "level": 1, "exp": -1 }) // 覆盖 $match + $sort db.players.createIndex({ "guild_id": 1, "join_time": -1 }) // 覆盖公会成员列表判断索引是否有效,不能只看explain().executionStats.nReturned,更要关注executionStats.executionStages.stage:
IXSCAN:走了索引,健康;COLLSCAN:全表扫描,危险;SORT:内存排序,CPU杀手;PROJECTION:只返回需要字段,高效。
我们用db.setProfilingLevel(1, { slowms: 50 })开启慢查询日志,每天分析TOP10慢查询,针对性建索引。两周内,慢查询数量从日均237条降至3条。
5.2 分片不是解决性能的银弹,而是数据分布的精密手术
原Mongo集群是3节点副本集,数据量达12TB时,单节点磁盘IO持续95%。我们计划分片,但没直接按playerId哈希分片,因为会导致“查公会成员”这类跨分片查询变慢。
最终采用复合分片键:{ "guild_id": "hashed", "player_id": 1 }。这样:
- 同一公会玩家尽量落在同一分片(
guild_id哈希值相近); player_id作为范围分片,支持按ID区间查询(如查某玩家最近10条操作);- 查询
guild_id=123时,MongoDB能路由到特定分片,避免广播查询。
分片过程花了3天,关键步骤:
- 在config server上启用分片:
sh.enableSharding("game_db") - 对集合分片:
sh.shardCollection("game_db.players", { "guild_id": "hashed", "player_id": 1 }) - 预分片:
sh.splitAt("game_db.players", { "guild_id": NumberLong("1000"), "player_id": ObjectId("...") }),避免初期数据倾斜
分片后,磁盘IO降至45%,但要注意:分片增加了运维复杂度,必须严格监控sh.status()中的chunk分布,确保各分片数据量偏差<15%。
5.3 写放大陷阱:不要在Mongo里存“可计算字段”
玩家文档里有个字段total_power,是装备属性、技能等级、宠物战力的总和。每次装备变更,都要$inc更新这个字段。压测发现,db.players.updateOne()操作占CPU 22%,且wiredTiger.cache:tracked_dirty_bytes持续增长。
根源在于:total_power是冗余字段,完全可由应用层计算。我们改为:
- 删除Mongo中的
total_power字段; - 查询时,用
$addFields在聚合管道中实时计算:db.players.aggregate([ { $match: { _id: ObjectId("...") } }, { $addFields: { total_power: { $add: ["$equipment.power", "$skills.level", "$pets.strength"] } } } ]) - 写操作减少37%,WiredTiger脏页缓存压力显著下降。
经验教训:MongoDB的写操作成本远高于读。任何“为读而写的冗余”,在高并发下都会成为瓶颈。宁可在应用层多算几次,也不要让Mongo承担计算压力。
6. 常见问题与排查技巧实录:那些文档里不会写的实战细节
6.1 “Redis连接池耗尽”不是配置太小,而是连接泄漏
现象:压测中redis.clients.jedis.exceptions.JedisConnectionException: Could not get a resource from the pool报错频发,调大maxTotal到200仍无效。
排查过程:
redis-cli info clients显示connected_clients: 198,接近上限;- 用
netstat -anp | grep :6379 | grep ESTABLISHED | wc -l确认真实连接数; - 发现Java应用里有
Jedis jedis = jedisPool.getResource()但没jedis.close()。
根本原因:Jedis连接不是用完就销毁,而是归还到连接池。但若忘记close(),连接会一直占用,直到超时。解决方案:
// 必须用try-with-resources,确保close()执行 try (Jedis jedis = jedisPool.getResource()) { String value = jedis.get("key"); // do something } // 自动调用jedis.close()或升级到Lettuce,其连接是长连接复用,无需手动管理。
6.2 “Mongo查询变慢”可能和WiredTiger缓存无关,而是journal刷盘阻塞
现象:某天凌晨Mongo查询延迟突增,db.serverStatus().metrics.document显示returned突降,但wiredTiger.cache:maximum_bytes_configured还有30%余量。
深入检查db.serverStatus().wiredTiger.log,发现sync_time_ms高达1200ms(正常<50ms)。原因是journal日志刷盘策略被修改为commitIntervalMs=1000(默认100ms),导致写操作等待更久。
修复:db.adminCommand({ setParameter: 1, wiredTigerEngineConfig: "log=(enabled=true,commitIntervalMs=100)" })
6.3 “CPU突然飙升”未必是代码问题,先查antimalware service executable
Windows服务器上,压测时CPU莫名飙到100%,taskmgr显示antimalware service executable占90%。这是Windows Defender实时扫描JVM生成的临时文件(如hsperfdata_*)所致。
解决方案:
- 排除JVM目录:
Set-MpPreference -ExclusionProcess "java.exe" - 或禁用实时防护(生产环境慎用):
Set-MpPreference -DisableRealtimeMonitoring $true
6.4 JMeter压测结果不准?检查这三件事
- 时间戳精度:JMeter默认用
System.currentTimeMillis(),在高并发下可能重复。改用System.nanoTime()或引入UUID.randomUUID(); - DNS缓存:JMeter默认缓存DNS,若压测域名指向多个IP,可能导致流量不均。在
jmeter.properties中设dns_cache_ttl=0; - SSL握手开销:HTTPS压测时,
ssl_context_name未复用会导致频繁握手。在HTTP Sampler中勾选“Use KeepAlive”并配置https.default.protocol=TLSv1.2。
6.5 最容易被忽视的“隐形瓶颈”:日志框架的同步刷盘
压测中,logback.xml配置<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">未设<immediateFlush>false</immediateFlush>,导致每次logger.info()都强制刷盘,I/O等待拖慢整个线程。
改为异步日志:
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender"> <appender-ref ref="FILE"/> </appender>CPU占用立降5%——日志不该成为性能杀手。
7. 优化效果与后续演进:从“能跑”到“跑得聪明”
最终压测结果对比(QPS=3200,持续15分钟):
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 平均响应时间 | 1280ms | 186ms | ↓85.5% |
| P95响应时间 | 4200ms | 310ms | ↓92.6% |
| CPU使用率 | 98% | 41% | ↓57% |
| Redis内存占用 | 15.2GB | 2.3GB | ↓84.9% |
| Mongo慢查询/分钟 | 237 | 0 | ↓100% |
| 服务可用性(SLA) | 99.1% | 99.99% | ↑0.89% |
但这不是终点。我们正在推进的下一步:
- Redis多级缓存:本地Caffeine缓存+Redis集群,减少网络跳转;
- Mongo时间序列优化:玩家操作日志改用Time Series Collection,压缩率提升60%;
- JVM容器化调优:在Docker中启用
-XX:+UseContainerSupport,让JVM正确识别cgroup内存限制。
最后分享一个小技巧:每次上线前,用curl -s http://localhost:8080/actuator/metrics/redis:hit-rate | jq '.measurements[0].value'写个Shell脚本,自动检查缓存命中率是否低于95%。低于阈值则自动回滚——把经验变成自动化防线,才是压测优化的终极形态。