• 记一次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. 使用集群(或简单的主从), 将数据导出及数据备份等直接从库上操作, 更进一步是改造服务端逻辑代码, 将部分慢查询应用到从库中(主要不要)
     
    阅读 6.9k发布于 2019-06-13
     
    https://segmentfault.com/a/1190000038792731
     
     
     
  • 相关阅读:
    DataItem 的使用[转帖]
    xmpp协议阅读总结
    smart pointer shared_from_this的使用
    std IO库, stringstream, 简看1
    const成员函数, const member function
    enum 随笔
    分隔和截断字符串, boost string algorithm library中的split和trim
    C++中异常处理
    boost::thread中的锁
    函数对象function object 以及boost::bind的一点了解
  • 原文地址:https://www.cnblogs.com/seasonzone/p/16048720.html
Copyright © 2020-2023  润新知