@acidlemonについて
|'-')/ acidlemonです。鎌倉市在住で、鎌倉で働く普通のITエンジニアです。
30年弱住んだ北海道を離れ、鎌倉でまったりぽわぽわしています。
Twitterに書くには文字数が足りないけど公式サイトのブログに書くほどでもない小ネタを供養する記事です。うろ覚えなところもあるのでオフィシャルな記事よりはだいぶ信頼性低めです。
今回の問題って完全にバックエンドはAPIしか提供してなくて、参加者がどんなサービスか知るために提供していたフロントエンドはSPAになってて最初からnginxで配信していました。例年だと静的ファイルはバックエンドが頑張って配信するみたいになってることが多いですが、そもそもベンチマーカーはHTML/CSS/JSには最初の整合性チェックフェーズでしかアクセスしないので、そこに力入れてもらう必要ないから最初からnginxで適切な状態にしてしまおう! という感じで出しました。
SPAなフロントエンドが提供されたことは過去にもありまして、大体Reactが使われていました。今年は私が実装するということで、私が一番手慣れてるVue.js(v3)でやりました。私は普段からNuxt使わずに筋力でルーティング書いちゃうことが多いので、Nuxtはつかってません。
あと、今回の問題はマルチテナントSaaSということで admin.t.isucon.dev はSaaS管理者画面、それ以外のサブドメインはテナント用の画面をだす必要があって、Vueのエントリポイントである main.ts でサブドメイン見てロードするAppのVueコンポーネントとルーター切り分けるという、だいぶパワーのある実装になっています。
if (host.split('.')[0] === 'admin') {
createApp(AdminApp).use(adminRouter).mount("#app")
} else {
createApp(TenantApp).use(tenantRouter).mount("#app")
}
importじゃなくてrequireで動的ロードしろよ説ありますが、まぁこれ動作確認用で提供してるだけなのでパフォーマンスとかはそこまで気にしなくていいから、エイヤーでimportしてしまいました。
これは小ネタオブ小ネタですが、ISUCONのお題アプリって最初はレスポンス返ってくるまでめちゃ遅いので、いくつかのエンドポイントを呼ぶときはLoadingっぽいのを伝えるぐるぐるを入れる必要があります。
実務だとBootstrap Iconsでなんかカワイイローディングアイコンを入れてCSSアニメーションで回すことが多いのですが、今回はフロントエンドの依存モジュール増やしたくない(あまりそこに時間をかけたくない)ということで、まんまるなひらがなを1文字くるくる回すという感じでグルグルを実装しました。
SaaS管理者の画面だとひらがなの「の」が回って、テナント側の画面だとひらがなの「め」が回るようになっています。これはあとでちゃんとしたアイコンに差し替えようと思ってたのですが、出題チーム内でも「の」が回るの新しい、カワイイ、などと好評だったのでそのまま残しました。
最初の参考実装はGoで書いていたのですが、他の言語に移植するときにいろいろと「そのまま移植できない!」が出てきてその言語用にちょっとずつ実装方法が異なっている部分があって、それを簡単にご紹介します。
出題チームが各言語の移植者にお願いしていたこととして、SQLiteの書き込み排他制御には必ずflock(に相当するsyscall)を呼ぶというのがあります。
flockを呼ぶ際、Goではブロッキングモードで呼び出して、ロックが獲得できるまでずっと待つという仕様になっており、ほとんどの言語はブロッキングモードで実装していたのですが、Node.jsだけはシングルプロセスシングルスレッドで動く以上ブロッキングで待ってしまうと全部のリクエスト処理が止まってしまってベンチマークが完走しません。
ということで、Node.jsはflockをノンブロッキングモードで使うようにして、EAGAIN
のときは10msごとにリトライして取れるまで無限にflock取るのを試す、といった実装になっています。
Javaはもっと大変でした。最初はnioパッケージにあるFileChannelを利用してFileLockを取る実装を試してもらっていました。しかしJavadocにも書いてあるとおり別プロセスとの排他制御にはこれが使えるのですがJVM内のマルチスレッドにおける排他制御にはこれが使えません。実際にFileLockで実装したものはベンチが安定せず、Javaだけflockを使うのを諦めました。
代わりにどうしたかというと、普通のJavaのお作法通り、Lockオブジェクトを作ってsynchronized
で保護する、という感じでやっています。
ちなみに「JNIでsyscallでflock(1)
呼ぶ」とか「外部プロセス起動でflock(2)
呼ぶ」とか「古のテクニックとしてmkdirがアトミックになるのを利用してロックをとるという20世紀の技があるんですけど…」のアイディアも出ましたが、まぁそこまではしなくていいんじゃない、ということでsynchronized
に落ち着きました。
Rustではsqlxというcrate(であってるのかな)を使っていたのですが、これはMySQLとSQLiteを両方サポートしていて、これを使ってクエリログを取るときに取れたデータにデータベースエンジンなどを区別する方法がない、ということでGoの参考実装通りの移植ができないということがわかりました。
出題チームで検討した結果、環境変数の名前をちょっと変えつつ、MySQLとSQLite両方でることは許容する、ログの形式も他の言語と揃えられないのでデフォルト設定の状態で出す、という形で落ち着きました。
ちなみにPerlのDBIx::Tracerも似たような感じでDBIのexecuteなどをフックしてコールバックを呼ぶみたいな仕組みのためMySQLとSQLiteが混ざるのですが、こちらはコールバックに$dbh
が来るのでDriverの名前を調べてSQLiteのときだけ出す、が出来ています。
テナント追加APIに、謎のコメントが残されています。
// NOTE: 先にadminDBに書き込まれることでこのAPIの処理中に
// /api/admin/tenants/billingにアクセスされるとエラーになりそう
// ロックなどで対処したほうが良さそう
これは読んで字のごとくで、最初はベンチマークが全然回らないので顕在化しないんですが、APIの回転が良くなってくると、AdminのBilling APIのアクセスがたくさんくるようになりこれで引っかかるようになるというやつがありました。
どういうことかというと、Adminのテナント追加APIでは、テナント情報をMySQLにINSERTして、その後SQLiteのDBファイルを作るという順番で処理していました。Admin Billingを叩くワーカーはテナント追加APIとは別のワーカーとして動いており、並列にアクセスが来る可能性があるため「テナント追加APIでMySQLにINSERTした直後〜SQLiteのDBファイルを作るまでの間」というごくわずかな時間の間にAdmin Billingが叩かれるとSQLiteのファイルを開けなくて500エラーになる、ということです。
…ということで高速化すると謎の500エラーに悩まされる…という状態になることが分かったので、ひとまずコメントを入れてお知らせしていた、という話でした。どうすればよかったかというと、簡単なのはINSERTをトランザクションでちゃんと囲んでSQLiteのDBファイルを作ってからMySQLにコミットするとかですかね。もう1個の方法としては予選の解説に書いた「そもそもDBファイルを作るタイミングを変更する」という手もありました。
…さて、いかがでしたでしょうか。小ネタでした。