私が歌川です

@utgwkk が書いている

ISUCON10 本戦に出て打ちのめされた #isucon

チーム 😇😇😇 として、id:nonyleneid:wass80 と一緒にISUCON10の本戦に参加しました。最終得点は1380114206点で16位でした*1*2

最終ベンチマーク時点は final-submissionタグのcommit*3 にcheckoutしてました。いちばん点数が奮っていたときの結果を最終提出にしよう、ということで、残り10分でやりました。

github.com

お題

今回のお題は、パフォーマンスチューニングコンテスト「XSUCON」のポータルサイトをよくする、というものでした。ついにこのお題が出たか、というのと、これは大変なお題だなあ、というのを思いました。実際かなり重たい問題で、お題の発表直前にmirakuiさんが「やりすぎた」って言ってたのは間違ってなかったな、と思いました。

www.youtube.com

サーバー構成

サーバー番号 役割
1 envoy (ロードバランシング), nginx (静的ファイル配信), api (Golang)
2 web (Golang)
3 DB (MySQL)

自分がやったこと

他のチームメンバーがやったことは、チームメンバーのブログ記事を参照してください。

nonylene.hatenablog.jp

@utgwkk のコミットログもあわせてどうぞ。

最初はNew Relicを導入してベンチマークを回してボトルネックを把握し、後半はpprofでプロファイルして作戦を立てる、という方式で作業しました。

New Relic導入 (~11:04)

予選と同様に、Git管理が完了したらまずNew Relicを導入しました。今度はどのライブラリを使うかを調べることなく、予選よりもスムーズに導入できました。gRPCサーバー・クライアント向けのNew Relicインテグレーションが存在して流石だなと思いました*4

github.com

clarificationsのN+1解決 (~11:39)

GET /api/{contestant,admin}/clarifications で、clarificationに紐づくteamを取ってくる部分がN+1になっていたので、INクエリで取ってくるようにしました。スコアは200点ぐらい下がったけど、これが効かないはずはない、やってはいけないわけがないのでそのままマージしました。

github.com

ところでこの実装をしているときに、つい最近ブログに書いた事象にちょっとハマっておもしろかったです。ブログに書いててよかった。

blog.utgw.net

GET /api/audience/dashboard のキャッシュ (~12:04)

本戦当日マニュアルを見ると、 GET /api/audience/dashboard は1秒までキャッシュしてよい、と書いてあります。

アプリケーションは、データの更新から最大 1 秒古い情報を返すことができます。ただし、ベンチマーカーが検知しない限りはそれより古い情報を返しても構いません。

dashboardのクエリは明らかにやばく、また、ここを負荷低減できると効くことが分かっていたので取り組みました。GET /api/audience/dashboard にリクエストが来るたびに、キャッシュを作ったのが現在時刻より1秒以上前ならキャッシュを作って返す、というのを仕込みました。

github.com

このPR時点で9357点でした。

pprof導入 (~12:33)

echo向けのpprofラッパである sevenNt/echo-pprof を導入して、pprofによるプロファイリングができるようにしたかったのですが、なんとecho v4系には対応していませんでした。仕方ないのでコピペしてimportを書き換えて対応しました……。

github.com

ダッシュボードのgRPCのmarshal結果をキャッシュする (~13:04)

GET /api/audience/dashboard のキャッシュについて、gRPCのmarshalが重たいというのをpprofによって突き止めたので、marshalした結果をそのままキャッシュするように書き換えました。

github.com

GET /api/audience/dashboard のキャッシュをさらによくする (~14:41)

そろそろ大会規模を大きくして点数ボーナスを狙おう*5、ということでTeamCapacityを10から60にすると、14000点ぐらい出るけどリーダーボード上の最終 ID 検証に失敗しましたというcriticalエラーが出てベンチマークが通らなくなりました。

dashboardのクエリでテーブル全体がロックされて、キャッシュの更新が間に合わなくなる、という仮説のもと、goroutineで一定間隔ごとにキャッシュを更新する仕組みを入れましたが、同じcriticalエラーでベンチマークが通りませんでした。最終的にTeamCapacityを大きくするのは諦めることになりました。これはどうしたらよかったんだろうな……。

github.com

プッシュ通知の実装 (~17:33)

GET /api/contestant/notifications へのリクエストが多いのでWeb Pushを実装しよう、ということになりました。参考実装となるアプリケーションを参照しつつ(コピペで)実装したところ、とりあえず動いてはいるようでした。しかし全く動作確認しないままでベンチマークは通ってて、push-notifications の値は正なのでプッシュ通知されているのかな……? という感じでした。Web Pushの仕組みを理解するのに時間がかかって手こずってしまいました。

github.com

16:24 時点のcommitで最終ベンチマークを行ったので、この実装は結局採用されないこととなりました。

TeamCapacityを調整する、祈る (~17:58)

TeamCapacityを60にはできないけど15とか20ぐらいにはできないか、というのを試していました。20だとベンチマークが通らず、15だと通るけど点数が7000点ぐらいで打ち止めになってしまいました。

16時ぐらいに最高得点が出ていたので、このときのcommitに戻ってベンチマークガチャを回そう、ということになりました。16:24 時点のcommitにcheckoutして、TeamCapacityを15にしたらfailしたので、すぐに10に戻して最終ベンチマークを回しました。13801点が出たので、これで作業を終了することにしました。

感想

おもしろかったけどうまくいかなかった!!! というのに尽きます。毎年学生がどんどん強くなっていってて、上位3位は学生チームですごすぎる。全く手が回らなかった、という感じなので精進しようと思います。

dashboardのキャッシュさえうまくいったら先に進めたのに……という念が強いです。リーダーボード上の最終 ID 検証に失敗しましたというcriticalエラーについては、Discordの感想戦チャンネルで ORDER BY teams.id を入れたら安定した気がするというのを聞いて、最新スコアと記録時間以外にも順序が仮定されていたのかが気になりました。競技終了まで全く心あたりのないエラーということで苦戦していて、いま思うとclarを投げたほうがよかったのかもしれません。LEFT JOIN地獄になってるのをバラしたりできなかったかな、というのも思います。プロファイルしてボトルネックを解消する、という動きにはなっていたはずなので、実装方針がよくなかったのだろうか。

当日マニュアルにConditional GETについての言及があり、すごく丁寧だと思いました。ISUCON7の予選でConditional GETに苦戦したのを思い出しました。

Go言語、さっと書けるけどcontextを引き回さないといけないというのがストレスだなと思いました。それ以外はコンパイル通ればヨシ! でデプロイ可能なので楽でした。

gRPCは出題されるだろうなと思いつつ知見がなく、手元に動作環境を用意するのを諦めたのでなかなか開発のボトルネックになっていました。クックパッドのスプリングインターンでgRPCに触れたのを思い出しました。

blog.utgw.net

今までで一番おもしろくヘビーな問題になっていたと思います、運営の皆様ありがとうございました!! 来年もISUCONに出たいし本戦にはもっと出たいです。

最後にベンチマークのご様子をお知らせして記事を締めます。(巨大な画像なので「続きを読む」を入れています)

*1:ISUCON10 本選の結果発表と全チームのスコア : ISUCON公式Blog

*2:本選スコアデータおよび順位の誤りについて : ISUCON公式Blog

*3:16:24 時点のcommit

*4:結局gRPCサーバー側は眺めてないけど……

*5:コンテストの参加人数によって点数の倍率が変わる

続きを読む

MySQLやPostgreSQLのWHERE INに空のリストを渡そうとすると文法エラーになる、SQLiteはエラーにならない

小ネタかつ備忘録です。

MySQL

この記事ではMySQL 8での実行結果を書いてるけど、5.6でも同様に文法エラーでした。

Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 13615
Server version: 8.0.21-0ubuntu0.20.04.4 (Ubuntu)

Copyright (c) 2000, 2020, Oracle and/or its affiliates. All rights reserved.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql> SELECT 1 FROM DUAL WHERE 1 IN ();
ERROR 1064 (42000): You have an error in your SQL syntax; check the manual that corresponds to your MySQL server version for the right syntax to use near ')' at line 1
mysql> SELECT 1 FROM DUAL WHERE 1 IN (1);
+---+
| 1 |
+---+
| 1 |
+---+
1 row in set (0.00 sec)

mysql> SELECT 1 FROM DUAL WHERE 1 IN (2);
Empty set (0.00 sec)

mysql> SELECT 1 FROM DUAL WHERE 1 IN (1);
+---+
| 1 |
+---+
| 1 |
+---+
1 row in set (0.00 sec)

mysql>

MySQL :: MySQL 5.6 リファレンスマニュアル :: 9.5 式の構文を読むと、IN式の構文は以下のように定義されていて(抜粋)、

predicate:
    bit_expr [NOT] IN (subquery)
  | bit_expr [NOT] IN (expr [, expr] ...)

このうち後者は、括弧の中に1個以上の expr がないといけないことを表しているのですぐ分かる。 subquery はサブクエリのことで、これも空文字列にはならない。

PostgreSQL

psql (12.4 (Ubuntu 12.4-0ubuntu0.20.04.1))
Type "help" for help.

postgres=# SELECT 1 WHERE 1 IN ();
ERROR:  syntax error at or near ")"
LINE 1: SELECT 1 WHERE 1 IN ();
                             ^
postgres=# SELECT 1 WHERE 1 IN (1);
 ?column?
----------
        1
(1 row)

postgres=#

9.23. 行と配列の比較 の 9.23.1. IN を見ると、これもMySQLと同様に1個以上の value が求められているので、空だと文法エラーになる。subquery の場合もMySQLと同様。

SQLite

一方SQLiteではエラーになりません。

SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.
Connected to a transient in-memory database.
Use ".open FILENAME" to reopen on a persistent database.
sqlite> SELECT 1 WHERE 1 IN ();
sqlite> SELECT 1 WHERE 1 IN (1);
1
sqlite>

これは SQL Language Expressions の The IN and NOT IN operators 節を読むとちゃんと書いてあった。

When the right operand is an empty set, the result of IN is false and the result of NOT IN is true, regardless of the left operand and even if the left operand is NULL.

いかがでしたか

SELECT * FROM tbl WHERE id IN (?) みたいなクエリにbindするオブジェクトとしてうっかり空リストを渡して爆死、というのがないように気をつけたいですね。各種O/Rマッパーの実装はどうなっているのか気になるところでもあります。標準SQL的にはどうなのかというのも気になります。

追記

標準SQL (SQL:2003) では空リストは取れない、ということはSQLiteが独自実装をしていることになりそう。

github.com


この記事の本題と関係ないけど、SQLの文法エラーを踏み抜くことはかなり多い気がしている。O/Rマッパーを使っていないときは(使っていても?)、本質的に文字列のやり取りになるのでそういう印象がある。そういうときはBNFや状態機械の気持ちになると多少はましになるかもしれない。あるいは文法をつかみづらいほどに複雑なことをSQLだけで完結させないほうがよいという考え方もある。

Query Language Understood by SQLite はよいドキュメントで、状態機械を眺めながらSQLiteの文法をつかむことができる。慣れてくると、状態機械的にはこのトークンが来ることはないな、みたいな感覚が出てくる。それでも間違えて文法エラーになることはあるのでやっていくしかなさそう。SQLiteのIN句に空リストを渡せるのはこの状態機械を読んでて気づきました。

追記

ActiveRecordは WHERE 1=0 に変換する、というのを id:onk さんに教えてもらいました。コードを追うとたしかにそうなっていそう。

追記2

SQL::Maker では WHERE 0=1 に変換される(そういうテストがある)。

SQL::NamedPlaceholder では WHERE id IN (NULL) に変換される。

ぼんやりできる空間を大事にしてる

何気なくつぶやいたけど、いいこと言ってると思う。緊張状態のまま一日を過ごすのは身体に悪いと思っていて、気持ちを緩められる空間が必要。個室が好き。

blog.utgw.net

ちょっと前に聞いた家が安心な空間であることを死守しなければならないという言葉に感銘を受けた。心がけたいことだと思う。