一款SP社交游戏的调整备忘录

首先

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

简要概括

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

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

Ver備考PHP5.4EC2、PHP-FPMFuelPHP1.8EC2MySQL5.6RDS、マスタ系・プレイヤー系など複数サーバに分散されているNginx1.10.2EC2Memcached1.4.15EC2、セッション用に外部Memcached、DBデータのキャッシュ用にWEBサーバのローカルMemcachedRedis2.8.19ElastiCache
Net.png

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

用于调查的工具

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

アラート閾値(初期値)が少し高めなのでチューニング中は低く設定しましょう。
そうしないと重い箇所を特定できないので…。

ARM → 自分で設定したAPP名のリンク → SETTINGS:Application
Apdex T:0.1に設定

浏览器 → 自定义应用名称的链接 → 设置:应用设置
Apdex T:设定为1

※ソフトウェアによってベストな値を調整してください。
ボトルネックがあるシステムでは頻繁に黄色(Non Critical problem)か赤色(Critical problem)になると思いますが、それでよいです。
システムオールグリーンになるように直しましょう…。

ここでプログラムのエラーを見れます。
ARM → 自分で設定したAPP名のリンク → EVENTS:Errors
本番環境だけ発生しているようなエラーを見つけるのに役立つかも。

Linux 指令

$ 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更新したVerFuelPHP1.61.8Nginx1.6.21.10.2Memcached1.4.131.4.15libevent 2.0.182.0.21

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

在中文中,开发环境的规格越低越好。

開発環境のマシンスペックは少ないアクセスでもボトルネックに気づけるようになるべく低めに構築するのがよいと思います。
せっかくNew Relicを導入しても高スペックすぎると動作が速くてボトルネックを検知しづらくなります。
このシステムでは以下のような感じになっています。
(低すぎると動かなくなる可能性もあるのでシステムごとに要調整)

开发环境

種別インスタンス台数
WEBm1.medium1画像、css、js、ローカルMemcachedでDBデータキャッシュとPHP Sessionを兼用Redist2.small1
RDSdb.t1.micro1本番では分かれているDBも全部まとめて1台に集約

生产环境

種別インスタンス台数
WEBc3.2xlarge複数ローカルMemcachedでDBデータキャッシュRedisElastiCache(cache.m3.2xlarge)複数
RDSdb.m3.2xlarge複数
Memcachedc3.2xlarge複数PHP SessionCloudFront

画像、css、js

New Relicの閾値を低めに設定してチューニング開始した際は常に黄色か赤色になってました。
チューニング後は常にオールグリーンになってます。
あえてキツイ環境にしてプログラム改修せざるを得ない状況にするのも一つの手かと思います…。

解决瓶颈问题

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

Redisでのキャッシュについては↓こちらで書きました。
ローカルRedisでアプリケーションを高速にする(Linux+PHP+DB+Redis)

内存缓存

ローカル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が起動してしまいました。
しょうがないので起動スクリプトで書き込み権限つけるようにしました…。

ところで…WEBサーバのローカルにMemcached?APCuじゃダメなのか?

といったようなコメントが某所で書かれていたので、ここに追記。
実はAPCuに変更する案もありましたが、APCuはバッチ処理(CLI)ではほぼ使えません。
確かCLIではデフォルトでAPCuがOFFになってます。
またONにしたところで処理が終了するとキャッシュが解放されてしまうので、CLIでもキャッシュを共有したいならMemcachedやRedisなど別のソフトウェアを使うしかないと思います。
このシステムではキャッシュがないとバッチ処理がかなり遅くなってしまうので、もともとインストールしてあったMemcachedの設定を調整して使うことにしました。
あとはmemcached-toolみたいにコマンドorツール類があるとデバッグ&調査に便利ですね。

永久キャッシュを止める

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

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

对策后,时间响应在5到20毫秒左右,随着时间的推移逐渐增加,但最大稳定在40到50毫秒左右。

这是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::get() 2回、データ保存でMemcached::set() 2回実行されるような実装になっていて負荷が上がっていました。
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进行设置。

参考:【改版】FuelPHP的Agent类存在重大问题和临时解决方案。

複数同時INSERTはBULK INSERTする

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

参考:FuelPHPのDatabase_Query_Builder_Insertでバルクインサートが使用できた話
MySQLのBULK INSERTでデッドロックを回避する

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に。
他にはメモリ関連の数値などを増やして最終的に以下のようになりました。

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

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

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

location = /healthcheck {
    empty_gif;
    access_log off;
    break;
}

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

PHP-FPM的进程应尽量保持较少的数量。

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

pm = static
pm.max_children = 50
pm.max_requests = 1000
request_terminate_timeout = 30

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

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の設定を調べて調整してみました。

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

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 (Relational Database Service) 可以简化和自动化管理关系型数据库。

尽管内存容量为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が増えていく

特别是在主数据库中,Binlog_cache_disk_use的数量大幅增加。
所有数据库的设置都是共同的。

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

只有主数据库

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

云前端

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

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

詳細はAWSを参照のこと
AWS Documentation – 圧縮ファイルの供給

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

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

调整后的情况
正常情况:5-15辆(深夜时人少的时候为5辆,白天为10-15辆)
GvG战斗时:30辆

目前,我有点担心增加Memcached调用次数会成为瓶颈…
与调优之前相比,速度明显提高了,服务器数量也减少了一半以上,算是很不错吧。

参考资料

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

MySQL 5.6のインストール後にチューニングすべき項目
MySQLをインストールしたら、必ず確認すべき10の設定
MySQLのパラメータチューニングメモ
nginx – カーネルパラメーターのチューニング
【改訂版】FuelPHPのAgentクラスが重い問題と暫定的な対処方法
FuelPHPのDatabase_Query_Builder_Insertでバルクインサートが使用できた話
MySQLのBULK INSERTでデッドロックを回避する
locale情報のスリム化(locale-archiveとか)
パフォーマンスチェック

我用draw.io制作了网络图。

广告
将在 10 秒后关闭
bannerAds