こんにちは。
スマホアプリのピグパーティでサーバサイドエンジニアをしている阿久津と申します。
2018年1月、ピグパーティで使っているMongoDBを2.4から3.4にアップデートしました。

本記事では、その改修内容と、アップデートによるパフォーマンス改善についてまとめます。

1: MONGODBとは

MongoDBとは、NoSQLの一つでドキュメント指向のデータベースです。
MongoDBにおいて、各データはドキュメントと呼ばれ、コレクションと呼ばれる集合に対してドキュメントを保存します。
弊社でも複数のプロジェクトで利用されています。
具体的な機能は公式ドキュメントにまとまっています。

2: ピグパーティにおけるMONGODBクラスタの構成

MongoDB2.4でのクラスタ構成図
MongoDB2.4でのクラスタ構成図

MONGODB2.4でのクラスタ構成

MongoDB2.4での運用当時、ピグパーティではMongoDBのread/writeの負荷分散を行うために、レプリカセットを20セット組んでいました。
各レプリカセットは、mongodが3台(primary secondary passive)稼働し、passiveのインスタンスにarbiterも起動させていました。
さらにシャードのルーティングを管理するmongocインスタンスも3台体制で運用していました。
定点バックアップを行うために、毎日定時に各シャードクラスタのmongodとmongocのpassiveインスタンスを止めた上でバックアップを行っていました。

MongoDB2.4においては、ストレージエンジンにmmapベース(3.0からMMAPv1という名前になる)のものしか使用できず、データ更新の際に、DBレベルのロックがかかります。
同じDB内の複数のコレクションに対して更新処理を試みる場合、ロックの解放を待たなければなりませんでした。
そのために、当時はDBを5つに分け、更新頻度・1ドキュメントあたりのデータが多いコレクションを5つに分散させていました。
5つに分けてもパフォーマンスは十分に発揮できず、スロークエリが大量に出るようになっていました。

3: MONGODB3系について

MongoDB3.x系では、MongoDB2.x系に比べて多くの点でメリットがあります。
ストレージエンジンWiredTiger採用によりパフォーマンスが向上。ロックの粒度はドキュメントレベルロックになる (MMAPv1では3.0からコレクションレベルロックになる)
データ更新に伴うジャーナリング周りの改善
データの圧縮ロジックの改善
詳細を知りたい方は公式ドキュメントの方を参照してください。

これらの改善により、パフォーマンスの向上と、サーバ台数の削減によるコスト減が期待できるため、今回のアップデートに踏み切りました。
上記のメリットや、運用フローの改善を踏まえて、以下のように構成を変えました。

MongoDB3.4でのクラスタ構成図
MongoDB3.4でのクラスタ構成図

変更内容は主に3つとなります。

  • シャード数削減(20→5)によるインスタンス台数削減
  • バックアップ手順修正
  • DB数削減(5→1にまとめる)

4: アプリケーション(サーバ側)の改修内容

MongoDBアップデートを行うにあたり、アプリケーション(サーバ側)でもNode.jsのコードを改修しました。

NODE.JSのMONGODBドライバーアップデート

Node.js用のMongoDBドライバーを使っており、今回1.4から2.2にアップデートしました

2.2では、コレクションのドキュメント追加・更新・削除周りのapiが大きく変わっていたので修正を行いました。

例えば、ドキュメントの更新に使うCollection.updateというapiは、2.2ではdeprecatedとなり、単一ドキュメントの更新はupdateOne、複数ドキュメントの更新はupdateManyと2つに分けられました。
updateOneの方は、クエリに$inオペレータなどで複数ドキュメントに対する操作を行う場合にエラーを出すようになっているため、
意図せず複数ドキュメントに対する更新してしまう状況を防ぐことができます。

旧バージョン


/**
 * データを更新
 * @param {Object} query - 更新対象クエリ
 * @param {Object} update - 更新内容
 * @param {Function} callback - コールバック
 */
DefaultDataMapper.prototype.update = function(query, update, callback) {
  var options = {
    upsert: false
  };
  
  // mongoドライバー1.4でのupdate apiは単一・複数ドキュメントを更新できる
  this._col.update(query, update, options, function(err, resultData) {
    if (err) {
      err.message = 'MongoError (update): ' + err.message + '\n' +
        'query: ' + JSON.stringify(query) + '\n' +
        'update: ' + JSON.stringify(update) + '\n';
      return callback(err);
    }
    
    // updateしたドキュメント数を返す
    var numberOfUpdate = resultData.result && resultData.result.n || 0;
    return callback(null, numberOfUpdate);
  });
};

新バージョン


/**
 * 1docのみデータを更新
 * @param {Object} query - 更新対象クエリ
 * @param {Object} update - 更新内容
 * @param {Function} callback - コールバック
 */
DefaultDataMapper.prototype.update = function(query, update, callback) {
  var options = {
    upsert: false
  };
  // updateクエリのチェック
  this.checkUpdateQuery(update);

  // 1件のみ更新するupdateOneを使用する
  this._col.updateOne(query, update, options, function(err, resultData) {
    if (err) {
      err.message = 'MongoError (update): ' + err.message + '\n' +
        'query: ' + JSON.stringify(query) + '\n' +
        'update: ' + JSON.stringify(update) + '\n';
      return callback(err);
    }
    // updateしたドキュメント数を返す
    var numberOfUpdate = resultData.result && resultData.result.n || 0;
    return callback(null, numberOfUpdate);
  });
};

/**
 * 複数doc データを更新
 * @param {Object} query - 更新対象クエリ
 * @param {Object} update - 更新内容
 * @param {Function} callback - コールバック
 */
DefaultDataMapper.prototype.updateMany = function(query, update, callback) {
  var options = {
    upsert: false
  };
  // updateクエリのチェック
  this.checkUpdateQuery(update);

  // 複数ドキュメントの更新
  this._col.updateMany(query, update, options, function(err, resultData) {
    if (err) {
      err.message = 'MongoError (updateMany): ' + err.message + '\n' +
        'query: ' + JSON.stringify(query) + '\n' +
        'update: ' + JSON.stringify(update) + '\n';
      return callback(err);
    }
    // updateしたドキュメント数を返す
    var numberOfUpdate = resultData.result && resultData.result.n || 0;
    return callback(null, numberOfUpdate);
  });
};

updateMany, insertMany deleteManyに関しては、それぞれ過去update insert removeを使っている箇所で、複数ドキュメントに対する操作を行っている箇所を全て調べて修正していきました

// mongo driver 1.4の場合 $inによる複数ドキュメントの更新
db.UserFriend.update({_id: {$in: _.keys(userFriendMap.list)}}, update, callback);

// mongo driver 2.2の場合 updateを使わずにupdateManyを使うように修正
db.UserFriend.updateMany({_id: {$in: _.keys(userFriendMap.list)}}, update, callback);

更新・削除時のクエリの改修

MongoDBドライバーのアップデートと、apiの呼び出し周りの改修に加え、更新・削除時のクエリも改修を加えました。
MongoDB2.6以降から、更新系のクエリオペレータ($set $unset)が空オブジェクトの場合、以下のようなエラーが出るようになったので、
空オブジェクトを渡さないように修正を行いました。


'$set' is empty. You must specify a field like so: {$set: {: ...}}"

少々ややこしいですが、更新前の条件によって$setが必要な時に初めて初期化してデータを追加し、
完全に空オブジェクトの場合更新処理をスキップするようにしています。

修正前の例


var update = {$set: {}, $unset: {}};
if (isEnableX) {
  update.$set[userId] = 100
}

if (isEnableY) {
  update.$unset[userId] = '';
}

db.UserEvent.update({_id: userId}, update, callback);

修正後の例


var update = {};
if (isEnableX) {
  update.$set = update.$set || {};
  update.$set[userId] = 100
}

if (isEnableY) {
  update.$unset = update.$unset || {};
  update.$unset[userId] = '';
}
if (!update) {
  return callback();
}
db.UserEvent.update({_id: userId}, update, callback);

このクエリ改修は、影響範囲が非常に大きかったのですが、事前に複数ドキュメントの更新・削除をしている箇所のテストコードを書き、変更前・変更後で振る舞いが変わらないことをテストで保証できたので、気負わずに改修できました。

また、クエリの修正漏れがあった場合のために、更新系クエリで不正なクエリオペレータがあるかチェックするapiを作り、問題があった場合warnログを出すようにしています。
updateMany, insertManyを呼び出すラッパーメソッドの内部でこのapiを呼び出しています。
開発環境で時間をとって動作確認し、修正漏れがないか確認していきました。


/**
 * update query のチェックを行う。
 * クエリチェック用にログ出力する
 * @param {Object} update - 更新クエリ
 */
DefaultDataMapper.prototype.checkUpdateQuery = function(update) {
  var self = this;
  [
    '$set',
    '$push',
    '$unset',
    '$inc'
  ]
  .forEach(function(key) {
    if (_.isEmpty(update[key]) && !_.isUndefined(update[key]) && !_.isNull(update[key])) {
      self.logger.warn('invalid update query. collection = ' + self._col.namespace +
          ' update = ' + JSON.stringify(update));
    }
  });

  if (_.isEmpty(update)) {
    self.logger.warn('update object is empty. update = ' +   JSON.stringify(update));
  }
};

DBを1つにまとめる

前述した通り、MongoDB3.x系ではストレージエンジンにWiredTigerを使うことができ、ドキュメントレベルのロックになります。
これによりDBを5つに分けている必要はなくなったため、使用していたコレクションを全て1つのDBにまとめました。

サーバサイドのアプリ側での修正点は以上となります。

4:アップデート作業

実際のアップデート作業は、ピグパーティのインフラ周りの作業を担当する、サービスリライアビリティグループのエンジニアと協力して行いました。
事前に開発環境で十分に時間をとって動作確認を行ったのと、メンバー間でメンテナンス作業手順をすり合わせていたので、特に本番では焦ることなく作業を行えました。

具体的なアップデート作業は以下の通りです。

メンテナンス開始
各サーバのnode.js mongosプロセスを停止し、MongoDBクラスタへのアクセスがない状態にする。
mongos経由で旧MongoDB2.4のクラスタで運用していたデータをdumpする (各DB毎にデータ量が非常に多かったのでDB毎のdumpを行う)
dumpしたものをmongos経由で新MongoDB3.4のクラスタへrestoreする
restore後のデータ確認 (各コレクションのドキュメント数 インデックス シャード設定など)
各アプリケーションサーバにchefを適用してMongoDB3.4の状態にする
・各アプリケーションサーバのnode.js mongosを起動する
・待機系での動作確認(正常に更新処理が行えているかなどを確認)
・系統切り替えして稼働系アプリケーションサーバをMongoDB3.4のクラスタに向くようにする
・メンテナンス終了し、サーバ負荷・スロークエリ確認
・問題なければ待機系サーバのmongosをMongoDB3.4系にアップデート

事前の調査で、MongoDB2.4でのmongodumpとmongorestoreのパフォーマンスが悪く、DBによって最大でも6時間かかる見込みでした。
dump restoreを行う時点では、すべてのmongoDBへのアクセスは止まっているため、optionによって並列数を増やしてdumpする方法で対応しました。
これにより、予定よりかなり早くdump restore作業を完了することができました。

5: アップデート後のパフォーマンス向上・システム改善について

今回のアップデートによって、以下のようにシステムを改善することができました。
ピグパーティを実際プレイしてみても、以前では特に重かった処理が体感で軽くなっていたのを実感できました。

データ量削減

データ保存時の圧縮形式にsnappyを採用し、ディスクに保存されているデータ量を全体で85%ほど削減できました。

データ圧縮に関する解説はMongoDB公式ブログの記事で読むことができます。

スロークエリ

アップデート以前から、クエリ実行完了に100ms以上かかったクエリをスロークエリログとして出力しています。
今回のアップデートにより、1日のログ数だけでも1/5以上まで、劇的に減らすことができました。

MongoDBスロークエリの推移
MongoDBスロークエリの推移

MONGODインスタンス数削減

MongoDB2.4では、系全体で20シャードクラスタを組んでいましたが、5シャードまでクラスタ数を減らしました。
これにより、15 × 3 = 45台ものインスタンスを減らすことができ、コストとメンテナンスの負荷を大きく下げることができました。

さいごに

今回のアップデート対応により、パフォーマンス向上による快適なプレイの実現と、
コスト削減、運用負荷の軽減を達成でき、ユーザとチームにとってメリットの多い改修を行うことができました。
ピグパーティのシステムに関して、まだまだ技術的負債が残っているので、サービスの成長に合わせて今後もシステム刷新などの技術的挑戦を行ってまいります。

アバター画像
2013年新卒入社のサーバサイドエンジニアです。 Node.js Python MongoDBが好きです。