あるSPソーシャルゲームのチューニングメモ

  • 19
    いいね
  • 0
    コメント

はじめに

ある稼働中のスマートフォンゲームアプリで機能追加・データ増加等により明るみに出てきたボトルネックを解消する作業を行いました。
その対応内容をまとめてみました。
既存ソースに不具合的なコードがあったのでプログラム修正の話も含みます。

概要

アプリ
iOS、Androidのネイティブアプリ(ほとんどがWebviewでページを表示するブラウザアプリ)
カードバトル系ゲーム、GvGバトルあり

サーバ構成
ざっくりですがこんな感じ(チューニング後のバージョンも含む)
図はとりあえず書いてみましたが書き方間違ってたらすいません。

Ver 備考
PHP 5.4 EC2、PHP-FPM
FuelPHP 1.8 EC2
MySQL 5.6 RDS、マスタ系・プレイヤー系など複数サーバに分散されている
Nginx 1.10.2 EC2
Memcached 1.4.15 EC2、セッション用に外部Memcached、DBデータのキャッシュ用にWEBサーバのローカルMemcached
Redis 2.8.19 ElastiCache

Net.png

WEBサーバが通常40台でGvGバトル時は60台に増やすというスケール設定になっている。
CPUもメモリも使用率高くないのにサーバ台数増やさないとレスポンスが悪くなる。
WEBサーバ:CPU10%ぐらい、メモリ10%ぐらい
DBサーバ:CPU10~20%ぐらい、メモリ20%ぐらい
これ以上サーバを増やしたくないし、できれば減らしたい。

調査で使うもの

New Relic
https://newrelic.com/
これをWEBサーバのどれか1台にインストールしておきます。
最初の30日は有料版の機能も使えるのでその期間で一気にボトルネックを探りました。
URLごとにどんなメソッドやSQLが何回実行されているかなど見れるのでチューニングが捗ります。
無料で使える機能だけでも割と使えます。

Linuxコマンド

TIME_WAITの数をチェック
$ netstat -anp | grep TIME_WAIT | wc -l
$ ss -anp | grep TIME_WAIT | wc -l
調整後の負荷テスト
$ ab -l -n 10000 -c 100~1000 http://xxx.xxx.xxx.xxx/

ソフトウェアをバージョンアップできるならしておく

実はこれあらかたチューニングが終わってしまった後にやりました。
ステージング環境と同じバージョンと思っていたら全然違かったというオチで…。

元のVer 更新したVer
FuelPHP 1.6 1.8
Nginx 1.6.2 1.10.2
Memcached 1.4.13 1.4.15
libevent  2.0.18 2.0.21

FuelPHPは変化なかったですけど、その他はabコマンドのテスト結果が少しよくなりました。
バージョンアップしても問題ないシステムならまず最新版入れてみましょう。
FuelPHPはPHP7とセットで入れ替えられるならやる価値あるかもしれません。

ボトルネックを解消していく

さっそくNew Relicの解析グラフを見てボトルネックを探ります。
といっても負荷がかかっているURLが上位に表示されるし、詳細を見ると負荷がかかっているメソッドなどが赤く表示されるため速攻であたりがつけられます。
なければ特定するのにめちゃくちゃ時間がかかると思うし本当に便利です。
ソフトウェアごとに分けて書いたので対応した時系列はぐちゃぐちゃです。

Memcached

ローカルMemcachedにはUNIXドメインソケット接続

WEBサーバのローカルにMemcachedがあるのはイマイチな気がしましたが、外部Memcachedにすると今度は負荷が集中するのが気になったのでUNIXドメインソケット接続を試しました。
これが結構速かったので結果的に外部Memcachedにするより良い方法だったかもしれません。
また後述するTCP接続のTIME_WAIT問題への影響も0になります。

変更前
memcached -d -u memcached -m 3072 -c 65535 -P /var/run/memcached/memcached-sock.pid
変更後
memcached -d -u memcached -m 1024 -c 65535 -P /var/run/memcached/memcached-sock.pid -b 2048 -U 0 -C -s /var/run/memcached/memcached.sock

-m 1024:元々割り当てる量が多すぎた。でも事前にメモリ確保するわけじゃないみたいなので大き目でいいのかも…
-C:CASを無効(このシステムでは使っていない)
-U 0 :UDPを無効(もしかすると-sの時はなくても無効?)
-b 2048:バックログを2048(デフォルト:1024、ソケットにするので増やしてみた)

※MemcachedプロセスにはTCPかUNIXドメインソケットどちらか1つしか設定できません。
運用中のシステムで切り替える時にはTCP接続のMemcachedとUNIXドメインソケット接続のMemcachedをそれぞれ立ち上げておいて、UNIXドメインソケット接続に切り替えたソースを反映してからTCP接続のMemcachedを落とすというやり方になると思います。
PHP側で持続的接続している場合はWebサーバやPHP-FPMの再起動も忘れずに。
(再起動が難しいなら持続的接続のパラメータpersistent_idを変える)

またEC2(オートスケールで起動)で設定した際には何故か.sockファイルに書き込み権限がない状態でMemcachedが起動してしまいました。
しょうがないので起動スクリプトで書き込み権限つけるようにしました…。

永久キャッシュを止める

New Relicの解析を見ていて一定時間経過すると段々性能が劣化していくようなグラフが出ました。
どうもキャッシュが一定量(KEYの数?データ容量?)増えると性能が劣化するようです。
いろいろ試してみてこのシステムでは1時間ぐらいに設定するのが良さそうだったので、全キャッシュの有効期限を1時間以下になるように調整しました。
これでMemcachedのレスポンスが安定しました。

対策前
レスポンスタイムが5~20msぐらいで時間が経つにつれて段々増えていく。
最大で200ms~400msぐらいまで増えていた。
(毎日0時にキャッシュクリアされていたのでこのくらいで済んでいたのかも)
キャッシュクリアすると5~20msぐらいに戻る。

対策後
レスポンスタイムが5~20msぐらいで時間が経つにつれて段々増えていくが、
最大で40ms~50msぐらいまでで安定する。

※1コールではなくNew Relicの1分間の数値です。

ORMを用いた実装で大量のSELECTキャッシュ

基本的にマスタ系DBのSELECT結果をMemcachedにキャッシュし、2回目からはキャッシュを見る設計でした。
ループで何度も1レコードSELECTするという処理が結構あり、直接DB参照した方が速いのではないかというぐらいキャッシュを読みまくってる場合も。
何でもキャッシュすればいいってもんじゃない!というのが良くわかる例でした。

何度も呼ばれていて同じ結果を返してよいメソッドに変数キャッシュを入れてみます。

static $_cache = array();
function select($x) {
    $cacheKey = 'prefix_'.$x;
    if (isset(self::$_cache[$cacheKey])) {
        $result = self::$_cache[$cacheKey];
    } else {
        $cache = new \Memcached('dbcache_pool');
        try{
            $result = $cache->get($cacheKey);
        }catch(\CacheNotFoundException $e){
            $result= NULL;
            $sql = \Db::select()->from(xxxx)->where(xxxx);
            $row = $sql->execute()->current();
            if(!empty($row)) {
                $result = new Model_Db_Data_xxxx();
                $result->setRow($row);
            }
            $cache->add($cacheKey, $result);
            self::$_cache[$cacheKey] = $result;
        }
    }
    return $result;
}

※上の例では直接Memcachedを呼び出していますが実際にはMemcache管理用の独自クラスを使っています。
これが地味で確実に効果あります。何度も同じ処理を呼び出してますからね。
ついでにマスタ系データはキャッシュを上書きする必要がないのでMemcached::setからMemcached::addに変更しました。
無駄にsetし直さないようにという意図でしたが、以下のようなしょうもない処理も発見してくれました。

$cache = new \Memcached('dbcache_pool');
$sql = \Db::select()->from(xxxx)->where(xxxx);
$rows = $sql->execute()->as_array();
if(!empty($row)) {
    foreach ($rows as $row) {
        $tmp = new Model_Db_Data_xxxx();
        $tmp->setRow($row);
        $result[] = $tmp;
        $cache->set($cacheKey, $result);  // この中がMemcached::setだと結果が正しくなってしまう
    }
}

実際には$cacheが独自クラスになってまして$cache->setの内部処理をMemcached::setからMemcached::addに置き換えました。
元の処理は合っている前提で見てなかったので気づかないんですよね…。
こういったキャッシュ自爆がタクサンアッタノデス。

カード情報の取得が多い

上で書いた通りデータ1件ごとにキャッシュされていて、カード枚数分のキャッシュ取得が走っていました。
こういったものが多数あり処理効率が悪くなっていました。
Redis(Memcachedでも可)に予めバッチ処理で1件ずつマスタデータをキャッシュしておき、1回で複数カードのキャッシュを取得できるようにしました。

function mget($keys) {
    $redis = \Redis::forge('name');
    $redis->pipeline();
    foreach ($keys as $ key) {
        $redis->get($key);
    }
    return $redis->execute();
}

pipelineで一気に複数keyを送るので効率がよくなります。
MemcachedならMemcached::getMultiを使えば同じことができます。

デッキのデータ取得が重い

デッキ編成画面で全デッキデータを取得しているため重くなっていました。
取得するデータを削ることができないため、Redisにデッキデータをgzip圧縮してキャッシュするようにしました。
1回目は遅いままですが、2回目以降はかなり速くなります。
GvGバトルでは選択したデッキ単体で戦うのでデッキ単位でキャッシュしています。
キャッシュの有効期限は長めに設定しておきます。
デッキ編成したり、デッキにあるカードのデータがレベルアップなどで変わったらキャッシュクリアします。

データ更新よりデータ取得が多いなら効果ありです。

FuelPHP

キャッシュ処理が非効率

キャッシュクラスがデータ取得でMemcached::get2回、データ保存でMemcached::set2回実行されるような実装になっていて負荷が上がっていました。
1回ずつになるように独自処理に置き変えました。
改修ついでに持続的接続、バイナリプロトコルなどオプションを加えました。
(持続的接続は元からあったかな)

$cacheServers = array(
  array('host' => 'xxx.xxx.xxx.xxx', 'port' => 11211),
  array('host' => 'xxx.xxx.xxx.xxx', 'port' => 11211),
);
static $_memcached = new \Memcached('dbcache_pool');
if (count(static::$_memcached->getServerList()) == 0) {
    static::$_memcached->addServers($cacheServers);
    static::$_memcached->setOptions(array(
        \Memcached::OPT_BINARY_PROTOCOL => true,
        \Memcached::OPT_DISTRIBUTION => \Memcached::DISTRIBUTION_CONSISTENT,
        \Memcached::OPT_LIBKETAMA_COMPATIBLE => true,
        \Memcached::OPT_SERVER_FAILURE_LIMIT => 3,
    ));
}

セッション処理で持続的接続を使っていない

セッションクラスではMemcachedが持続的接続になっていませんでした。
こちらもキャッシュ処理と同じように持続的接続、バイナリプロトコルの対応をしました。
たまに接続タイムアウトが発生していたのですが、持続的接続にした後は発生していません。
セッションは外部Memcachedだったのでより効果が大きかったですね。
レスポンスが1.5~2倍ぐらい速くなったと思います。
※接続が溢れないようにMemcachedの設定に注意(WEBサーバ台数 * PHP-FPMプロセス数)

Agentクラスが重い

FuelPHPのAgentクラス(ブラウザ情報を取得する処理)が激重でした。
いちおう対策はしてあってRedisでキャッシュされていましたが、このために毎回外部接続したくないのでCrossjoin/Browscapというライブラリを使って処理を差し替えました。
キャッシュせずに毎回処理させてますが高速です。

使う前にbrowscap.iniファイルをダウンロード(&定期的に更新)しておく必要があります。
バッチ処理を作ってcronで設定するなりしましょう。

複数同時INSERTはBULK INSERTする

特に10~30レコードのINSERTを1レコードずつINSERTしている箇所が多々あったので、これをBULK INSERTに変えました。
ユーザーごとに発生するものだったのでかなり速くなったと思います。
なおBULK INSERTする際はINDEXの順になるようにソートしてから突っ込みます。
そうしないとデッドロックになってしまうケースがあるようです。

Linuxカーネルパラメータ

TCP/IPのTIME_WAITが大量にあった

一番負荷がかかるGvGバトル開催時にTIME_WAITが結構ありました。
サーバーによってまちまちですが2万~2万8千ぐらいでした。
これはMemcached関連の改修をした後の数値なので、改修前はレスポンスが悪くTIME_WAITがもっと多かったと推察します。
カーネルパラメータの設定を見るとnet.ipv4.ip_local_port_rangeが32768 - 65535だったので32767までしか受け付けられませんでした。
改修前は接続が溢れていてサーバー台数を増やす必要があったのかも…。
net.ipv4.ip_local_port_rangeを10000 - 65535に。
net.ipv4.tcp_fin_timeoutが10だったのを5に。
他にはメモリ関連の数値などを増やして最終的に以下のようになりました。

/etc/sysctl.conf(自分で変更したパラメータのみ記載)
vm.swappiness = 0
vm.overcommit_memory = 2
vm.overcommit_ratio = 99
net.core.rmem_max = 16777216
net.core.wmem_max = 16777216
net.ipv4.tcp_rmem = 4096 349520 16777216
net.ipv4.tcp_wmem = 4096 65536 16777216
net.ipv4.tcp_fin_timeout = 5
net.ipv4.ip_local_port_range = 10000 65535

調整後はTIME_WAITが半分ぐらいになりました。
これはtcp_fin_timeoutを半分にしたからですかね…。

Nginx & PHP-FPM

ELBのHealthCheckに指定していたURLがいわゆるマイページ(HOME)だった

ELB配下にあるサーバには一定秒数で死活監視の通信がきます。
この監視対象のURLがいわゆるマイページ(HOME)で設定されていました。
結構データ取得が走る場所なので無駄に負荷がかかっていました。
ひとまずWEBサーバが応答するかチェックできればよいのでNginxのempty_gifを使いました。
ゲームの調査をする際に邪魔なのでログ保存はOFFにしました。

nginx.conf
location = /healthcheck {
    empty_gif;
    access_log off;
    break;
}

サーバー台数増えるほどDBに負荷がかかるものなのであまりバカにはできないですね。

PHP-FPMのプロセスは少ない方がよい

プロセスが元々12個立ち上がっていました。
CPUコアは8個あり使用率も10%未満なので余裕がありました。
プロセスが多いともっと捌けるのではないかと思って50個まで設定変更を試しました。

/etc/php-fpm.d/www.conf
pm = static
pm.max_children = 50
pm.max_requests = 1000
request_terminate_timeout = 30

実際に運用してみて…
軽い処理がたくさん走る時はよくても、GvGバトルなどで重い処理が走る時はCPUパワーが分散されてよくないようです。
ということで再度調整します。

/etc/php-fpm.d/www.conf
pm = static
pm.max_children = 20
pm.max_requests = 2000
request_terminate_timeout = 30

今度はちゃんとabコマンドで負荷チェックしてみました。(最初からやりましょう…)

ab -l -n 10000 -c 1001000 http://xxx.xxx.xxx.xxx/

秒間500~1000リクエストぐらいしか捌けません…。
チェックしたURLはPHPが実行されていますが軽い処理のページです。
もうちょっと頑張れる気がしたのでNginxの設定を調べて調整してみました。

/etc/nginx/nginx.conf(追加したものだけ)
events {
    accept_mutex_delay 100ms;
}
sendfile_max_chunk 512k;
open_file_cache max=100000 inactive=20s;
open_file_cache_valid 30s;
open_file_cache_min_uses 2;
open_file_cache_errors on;

これで再度abコマンドでチェックしてみると秒間4000リクエストぐらい捌けるようになりました。
open_file_cache入れるとかなり違いますね。
実際に実機で見てみると以前より画面遷移がスムーズに感じます。

なおスレッドプールも試してみようと設定したらこのプラットフォームでは対応していないと怒られました。

ロケール情報の容量が大きい

PHP-FPMのマスタープロセスが読み込むロケール情報が結構な容量でした。
不要なロケールを削るとメモリを節約できます。

jaとen_GBとen_USとzh以外のロケール情報を削除
cd /usr/lib/locale/
sudo cp locale-archive locale-archive.bak
sudo localedef --list-archive | grep -v -e ^ja -e ^en_GB -e en_US -e ^zh | sudo xargs localedef --delete-from-archive
sudo cp -a locale-archive locale-archive.tmpl
sudo build-locale-archive

AWSだとスペックを細かく調整できないためメモリが結構余っていて無理にやる必要はなかったかも。
メモリの少ないサーバーで節約したいならばやりましょう。

RDS

メモリが30GBあるのにinnodb_buffer_pool_sizeが6GBという設定

全DBメモリ30GB搭載でしたがメモリを全然使っていない!!
一律で以下のように設定しました。
思い切って分離レベルも変えてます。
メモリはもうちょっと割り当てても大丈夫だったかなという感じがしてますが、RDSのモニタリングでアラートの閾値ギリギリなのでこのままにしています。
たぶんデフォルトがメモリ搭載量の3/4割り当てる設定だからですかね。
つまりinnodb_buffer_pool_sizeはRDSのデフォルト設定でよいはずです。
何故固定で書いたかというと元が3/4になるような設定になっていたのに6GBしか割り当てられてなかったからです!
(もしかすると再起動すれば直ったのかもしれません…)

innodb_buffer_pool_size = 21474836480
innodb_log_file_size = 4294967296
innodb_buffer_pool_instances = 10
innodb_max_dirty_pages_pct = 95
innodb_sync_array_size = 4
innodb_flush_neighbors = 0
sync_binlog = 1
sync_relay_log = 1
relay_log_info_repository = TABLE
master-info-repository = TABLE
binlog_format = ROW
tx_isolation = READ-COMMITTED
innodb_autoinc_lock_mode = 2

thread_cache_sizeとtable_open_cacheが足りてない

コネクションに関連するところはDB別に調整しました。
マスタ系DBは読みが中心で接続もテーブル数も多いので設定値を多めにしています。
show global statusでMax_used_connectionsとOpen_tablesを見て足りなそうなら値を調整します。
table_open_cache_instancesは16→2に変更しました。
MySQLのマニュアルにはCPU16コア以上で8~16にするとよいと書いてありました。
DBサーバを調べたら8コアだったので設定値が大きすぎでした。

max_connect_errors = 100000
max_connections = 1600~3000
thread_cache_size = 1600~3000
table_open_cache = 1000~16000
table_definition_cache = 900~8400
table_open_cache_instances = 2

Binlog_cache_disk_useが増えていく

特にマスタ系のDBでBinlog_cache_disk_useがたくさん増えていました。
全DBの設定が共通で

binlog_cache_size = 32768

となっていました。
Binlog_cache_disk_useが増えているDB(ほとんど全部でしたが…)のbinlog_cache_sizeを調整しました。
こちらもshow global statusで確認しながら上げていきます。

binlog_cache_size = 32768~98304

Aborted_connectsが増えていく

特にマスタ系のDBでたくさん増えていました。
キャッシュが足りないのかと思ってthread_cache_sizeをmax_connectionsと同じにしました。
またwait_timeが30~60だったのを180にしました。
これでAborted_connectsがほぼ増えなくなりました。
ついでにinteractive_timeoutがwait_timeと同じ値で設定されていたので28800にしました。
この値はmysqlコマンドで接続した時か明示的にオプション指定した時だけ?に使われるので小さく調整する必要はないはずです。
直接MySQLに接続してINDEX作成等する際にタイムアウトして不便だったので…。

thread_cache_size = 1000~3000
wait_time = 180
interactive_timeout = 28800

ソート系のメモリ割り当てが足りない

元々が524288~1048576ぐらいの割り当てでした。
各buffer_sizeは参考情報をもとにざっと割り当てました。
tmp_table_sizeはCreated_tmp_disk_tablesの値を見ながら調整しました。
0にはできないようなので上昇が緩やかになる数値で決めました。
max_heap_table_sizeが上限になるのでtmp_table_sizeと同じ数値に変えておきます。

read_buffer_size = 2097152
sort_buffer_size = 4192304
read_rnd_buffer_size = 4192304
join_buffer_size = 262144
max_heap_table_size = 268435456
tmp_table_size = 268435456

マスタ系DBだけは

read_buffer_size = 4192304
join_buffer_size = 1048576

としました。
本当はいらないはずですが、プログラムの実装をすぐ直せない面もあって増やしました。

Provisioned IOPSなのにディスクIOの設定がデフォルトのまま

ストレージがProvisioned IOPSになっているDBはディスクIOの設定も変えました。
デフォルトのままだったので性能発揮できていなかったと思います…。

innodb_io_capacity = 1000
innodb_io_capacity_max = 2000
innodb_lru_scan_depth = 1000

CloudFront

gzip圧縮機能を使っていなかった

AWSの管理画面でちょっと設定変更するだけでgzip圧縮機能が使えます。
未圧縮をキャッシュ済みの場合は、キャッシュから未圧縮のファイルを返してしまうので
gzip圧縮を設定した後にCloudFrontの全キャッシュを削除するとよいです。
これでテキストファイル(CSS、JSなど)の通信容量がかなり減ります。

詳細はAWSを参照のこと
http://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/ServingCompressedFiles.html

結局WEBサーバをどれだけ減らせたのか

チューニング前
通常時:40台
GvGバトル時:60台

チューニング後
通常時:5~15台(人が少ない深夜帯が5台、昼間は10~15台)
GvGバトル時:30台

これ以上はMemcachedのコール数がボトルネックで減らすのがちょっと怖いです…。
チューニング前よりかなり速くなったしサーバも半分以上減らせたし上出来でしょうかね。

参考情報

こちらの情報を参考にさせていただきました。
貴重な情報を公開してくれた方々に感謝します。

MySQL 5.6のインストール後にチューニングすべき項目
https://yakst.com/ja/posts/61
MySQLをインストールしたら、必ず確認すべき10の設定
https://yakst.com/ja/posts/200
MySQLのパラメータチューニングメモ
http://qiita.com/sh-ogawa/items/088edd8ef46eaa3ad79c
nginx - カーネルパラメーターのチューニング
http://qiita.com/sion_cojp/items/c02b5b5586b48eaaa469
【改訂版】FuelPHPのAgentクラスが重い問題と暫定的な対処方法
http://blog.a-way-out.net/blog/2015/02/26/fuelphp-agent-class-heavy/
FuelPHPのDatabase_Query_Builder_Insertでバルクインサートが使用できた話
http://qiita.com/miyatah/items/11bf1e9f0582efdc138f
MySQLのBULK INSERTでデッドロックを回避する
http://qiita.com/haracane/items/f9ad01134cf9e6ccd14b
locale情報のスリム化(locale-archiveとか)
http://blog.livedoor.jp/centosnotes/archives/3085708.html
パフォーマンスチェック
http://www.8wave.net/perform.html

ネットワーク図の作成で使いました。
https://www.draw.io/