记一次MongoDB高负载的性能优化

Last-Modified: 2019年6月13日11:08:19

本文是关于记录某次游戏服务端的性能优化, 此处涉及的技术包括: MongoDB(MMAPv1引擎), PHP

随着游戏导入人数逐渐增加, 单个集合的文档数已经超过400W, 经常有玩家反馈说卡, 特别是在服务器迁移后(从8核16G降到4核8G), 卡顿更严重了, 遂开始排查问题.

确认服务器压力

  1. 首先使用top 命令查看总体情况, 此时cpu占用不高, %wa比例维持在40%左右, 初步判断是磁盘IO过高
  2. 使用iotop命令以进程粒度来查看io统计, 发现MongoDB进程全速在读操作.
  3. 使用MongoDB自带的mongostat 命令, 发现 faults字段持续高达200以上, 这意味着每秒访问失败数高达200, 即数据被交换出物理内存, 放到SWAP

    由于未设置交换空间, 因此无法通过 vmstat 命令查看是否正在操作SWAP
  4. 在mongo shell中执行 db.currentOp() 确认当前存在大量执行超久的操作

到了此时基本确定问题所在了: 大量的查询(先不管是否合理)导致MongoDB不断进行磁盘IO操作, 由于内存较小(相较之前的16G)导致查询过的缓存数据不断被移出内存.

开始处理

减小单个集合大小

这一步骤主要是针对库中几个特别大的集合, 且这些集合中的数据不重要且易移除.

此处以Shop表为例(保存每个玩家各种商店的数据), 在移除超过N天未登录玩家数据后, 集合大小从24G降为3G

通过减小集合大小, 不仅可以提高查询效率, 同时可以加快每天的数据库备份速度.

慢日志分析

需要打开慢日志

profile=1
slowms=300

逐条确认所有慢日志, 分析执行语句问题

use xxx;
db.system.profile.find({}, {}, 20).sort({millis:-1});

此时的重点在于确认执行统计字段(execStats)中 阶段(stage)是全表扫描(COLLSCAN)的, 这是最大的性能杀手.

增加/修改索引

通过慢日志分析, 发现大部分全表扫描的原因在于:

  • 排行榜定期统计
  • 游戏逻辑需要对某些集合中符合条件的所有文档 update

针对这几种情况, 可以通过增加索引来解决.

举例1: 玩家等级排行榜

// 查询语句
db.User.find({gm:0}, {}, 100).sort({Lv:-1, Exp:-1});

// 移除旧索引, 增加复合索引
db.User.createIndex({Lv:-1, Exp:-1}, {background:true});
db.User.dropIndex({Lv:-1})

生产环境建索引一定要加 {background: true}, 否则建索引期间会引起大量阻塞.

还有删除旧索引前, 记得先建立好新的索引, 避免期间出现大量慢查询.

通过 explain("allPlansExecution")查询分析器可以看出, 此时最初阶段是 IXSCAN, 即扫描索引.

举例2: 玩家称号处理

// 查询语句
db.User.find({TitleData:{$exists:true}});

// 增加稀疏索引
db.createIndex({TitleData:1}, {sparse:true, background:true});

之所以使用稀疏索引, 是因为大部分玩家是不具有称号(TitleData字段), 使用稀疏索引时只会索引存在该字段的文档, 通过对比, User集合中, 默认的 _id_ 索引大小138MB, 刚建立的稀疏索引TitleData_1大小仅为8KB(最小大小).

修改查询语句

由于项目代码经过多手, 部分人员经验不足, 代码编写时未考虑到性能问题.

因此需要改造部分服务端代码, 这部分就是苦力活了, 逐个去修改, 属于业务代码优化.

举例1: 筛选玩家

// 原查询语句: 发放全服奖励
db.User.find({});

// 修改后: 筛选仅最近30天登陆, 利用现有索引 {LastVisit:-1}
db.User.find({LastVisit:{$gt: 30天前的时间戳}})

举例2: 公会成员信息

// 原查询语句: 在User集合中搜索指定公会成员
db.User.find({GuildId:xx});

// 修改后: 利用Guild集合中已有的GuildMembers成员列表, 逐个获取公会成员数据
db.Guild.find({Id:xx}, {Id:1, GuildMembers:1}, 1);
db.User.find({Id:{$in: [xx, xx, xx]}})

定时器增加锁

早期服务器数据量较小时, 每个分钟级定时器都能顺利在1分钟内跑完, 但一旦出现慢查询(未优化之前出现过十几分钟的), 上一个定时器未跑完, 下一个定时器又来了, 大量的慢查询语句堆在MongoDB中导致整个数据库被拖垮, 直接雪崩. 这是玩家反馈卡顿的最直接原因.

尽管经过上面优化后不会出现一个查询1分钟以上这种情况, 但是多个查询累加起来, 也有可能超过1分钟.

为了避免定时器脚本堆叠, 因此需要加个锁, 避免出现问题.

具体的加锁方案有:

  • memcached
  • redis

很简单.

避免客户端超时

定时器通常是用于执行一些耗时操作, 除了上面的锁问题外, 还有一个不可忽视的: 客户端超时.

PHP中对MongoDB的一些操作, 默认是30秒, 比如 find() 操作一旦超过30秒会抛出 "超时异常", 然而此时该语句还在MongoDB实例中执行.

由于定时任务未完成, 下一个定时器来的时候还是会继续尝试进行同样的操作..

解决方案很简单, 以php代码为例

$mongo->selectCollection('xx')->find([...])->timeout(-1);

更多的优化考虑

  1. 更换存储引擎: 将 MMAPv1 替换为 WiredTrigger
  2. 使用集群(或简单的主从), 将数据导出及数据备份等直接从库上操作, 更进一步是改造服务端逻辑代码, 将部分慢查询应用到从库中(主要不要)

相关推荐