ISUCON 7 予選に参加した

言語はチーム3人全員が触れたことのある PHP にした。

時刻 できごと 補足
13:09 Score 3473 初期状態のスコアを計測
13:38 DB server に slowlog 設定
14:26 message.channel_id と image.name に INDEX をはる
14:26 php-fpm を tcp から domain socket に変更
14:50 Score 4513
15:03 /message の N+1 問題修正
15:07 Score 4564
15:27 /history の N+1 問題修正
15:28 Score 4687
15:41 DBに入っているアイコン画像をファイルにキャッシュ
16:42 Score 8208
18:53 php-fpm のプロセス数調整、不要なforkしないように
18:53 Score 16768
19:26 カーネルパラメータ調整
19:26 Score 17673
19:41 画像自体を imagemagick で圧縮してみたが、負荷走行前チェックで失敗するのでrevert
19:43 アイコン画像のキャッシュが存在する場合は nginx だけで処理するように
19:43 Score 16111

(castaneaiが) やったことの詳細

message.channel_id と image.name に INDEX をはる

CREATE TABLE に INDEX(...) をつけただけ。少しだけ(+1000ぐらい?)だが、スコアが上がったと思われる。 断定できないのは、ほぼ同時にphp-fpm の tcp -> unix socket も入ったため、どちらが効いたのかはっきりしないため。

--- a/db/isubata.sql
+++ b/db/isubata.sql
@@ -12,7 +12,7 @@ CREATE TABLE image (
   id BIGINT UNSIGNED AUTO_INCREMENT NOT NULL PRIMARY KEY,
   name VARCHAR(191),
   data LONGBLOB,
+  INDEX(`name`)
 ) Engine=InnoDB DEFAULT CHARSET=utf8mb4;

 CREATE TABLE channel (
@@ -29,7 +29,7 @@ CREATE TABLE message (
   user_id BIGINT,
   content TEXT,
   created_at DATETIME NOT NULL,
+  INDEX(channel_id)
 ) Engine=InnoDB DEFAULT CHARSET=utf8mb4;

N+1 問題修正

ループの中で何回も SELECT しないようにして、最初に WHERE IN (...) でまとめて取って連想配列で紐付けるように変更。

@@ -347,13 +342,14 @@ $app->get('/history/{channel_id}', function (Request $request, Response $respons
     $stmt->execute([$channelId]);

     $rows = $stmt->fetchall();
+    $userIds = getKeys($rows, 'user_id');
+    $stmt = whereIn($dbh, 'user', $userIds);
+    $userMap = fetchMap($stmt);
     $messages = [];
     foreach ($rows as $row) {
         $r = [];
         $r['id'] = (int)$row['id'];
-        $stmt = $dbh->prepare("SELECT name, display_name, avatar_icon FROM user WHERE id = ?");
-        $stmt->execute([$row['user_id']]);
-        $r['user'] = $stmt->fetch();
+        $r['user'] = $userMap[$row['user_id']];
         $r['date'] = str_replace('-', '/', $row['created_at']);
         $r['content'] = $row['content'];
         $messages[] = $r;

DBに入っているアイコン画像をファイルにキャッシュ

アイコン画像をファイルにキャッシュして、キャッシュが存在すればDBに触らずにそのままファイルの中身を返す。

--- a/php/index.php
+++ b/php/index.php
@@ -491,19 +491,31 @@ function ext2mime($ext)

 $app->get('/icons/{filename}', function (Request $request, Response $response) {
     $filename = $request->getAttribute('filename');
-    $stmt = getPDO()->prepare("SELECT * FROM image WHERE name = ?");
-    $stmt->execute([$filename]);
-    $row = $stmt->fetch();

-    $ext = pathinfo($filename, PATHINFO_EXTENSION);
-    $mime = ext2mime($ext);
-
-    if ($row && $mime) {
-        $response->write(base64_encode($row['data']));
-        $response->withHeader('Content-Transfer-Encoding', 'base64');
-        return $response->withHeader('Content-type', $mime);
-    }
-    return $response->withStatus(404);
+    // --- cache
+       $cacheDir = realpath(dirname(__FILE__)) . '/cache';
+       if (!is_writable($cacheDir)) {
+               chmod($cacheDir, 0777);
+       }
+       $cachePath = $cacheDir . '/' . $filename;
+       if (!file_exists($cachePath)) {
+               $stmt = getPDO()->prepare("SELECT * FROM image WHERE name = ?");
+               $stmt->execute([$filename]);
+               $row = $stmt->fetch();
+               file_put_contents($cachePath, $row['data']);
+       }
+       // ---
+
+       $data = file_get_contents($cachePath);
+
+       $ext = pathinfo($filename, PATHINFO_EXTENSION);
+       $mime = ext2mime($ext);
+       if ($base64data && $mime) {
+               $response->write($data);
+               return $response->withHeader('Content-type', $mime);
+       }
+       return $response->withStatus(404);
 });

アイコン画像のキャッシュが存在する場合は nginx だけで処理するように

エラーは出てないものの、これでスコアはほぼ変わらなかったので、何か間違っていたかもしれない。

  • キャッシュの保存先を /home/isucon/isubata/webapp/public/icons (nginx document root 配下)に変更
  • nginx conf でそこに存在する場合はnginxから返すように
location / {
    ...
    set_proxy_header ...
    set_proxy_pass http://127.0.0.1:9000;
}

location / {
    ...
    try_files $uri $uri/ @proxy;
}

location @proxy {
    set_proxy_header ...
    set_proxy_pass http://127.0.0.1:9000;
}

今回のよかったこと

  • デプロイスクリプトをチームメンバーに準備してもらった(シェル叩くだけで、デプロイが完了する)(最高)

無駄に時間がかかってしまった作業

  • 2台あるうち片方がPHP実装になっておらず、1時間以上ベンチ結果を勘違いしていた

  • 動いているphp が ~/local/... 以下にあり、拡張機能のつなぎ込み方がわからなかった

知識不足を感じた項目

  • HTTP Cache Control
  • nginx の設定
  • 既読管理の設計

感想

去年は「正規表現をどうにかしたら良いのだろうな~」って思えたが、今年はそもそもどこがボトルネックなのかわからなかった。 画像系だろうねって話はでてたけど、画像の圧縮は許されていなかったので、では画像のどこ・・?となってうーん・・・わからん!!となって終了した。

後から上位チームのwrite-upを見ると、HTTPのCache-Control周りの話が多く、そのあたりについて何も知識がなかったのでだめだった・・と実感。