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

26次阅读

共计 2389 个字符,预计需要花费 6 分钟才能阅读完成。

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. 使用集群(或简单的主从), 将数据导出及数据备份等直接从库上操作, 更进一步是改造服务端逻辑代码, 将部分慢查询应用到从库中(主要不要)

正文完
 0