共计 2389 个字符,预计需要花费 6 分钟才能阅读完成。
Last-Modified: 2019 年 6 月 13 日 11:08:19
本文是关于记录某次游戏服务端的性能优化, 此处涉及的技术包括: MongoDB(MMAPv1 引擎), PHP
随着游戏导入人数逐渐增加, 单个集合的文档数已经超过 400W, 经常有玩家反馈说卡, 特别是在服务器迁移后(从 8 核 16G 降到 4 核 8G), 卡顿更严重了, 遂开始排查问题.
确认服务器压力
- 首先使用
top
命令查看总体情况, 此时 cpu 占用不高,%wa
比例维持在 40% 左右, 初步判断是磁盘 IO 过高 - 使用
iotop
命令以进程粒度来查看 io 统计, 发现 MongoDB 进程全速在读操作. -
使用 MongoDB 自带的
mongostat
命令, 发现faults
字段持续高达 200 以上, 这意味着每秒访问失败数高达 200, 即数据被交换出物理内存, 放到 SWAP由于未设置交换空间, 因此无法通过
vmstat
命令查看是否正在操作 SWAP - 在 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);
更多的优化考虑
- 更换存储引擎: 将 MMAPv1 替换为 WiredTrigger
- 使用集群(或简单的主从), 将数据导出及数据备份等直接从库上操作, 更进一步是改造服务端逻辑代码, 将部分慢查询应用到从库中(主要不要)