なまえは まだ ない

思いついたことをアウトプットします

長年集めた蔵書を会社に寄贈したので、思い出とともに紹介する

この記事はヤプリ&フラー合同アドベントカレンダー17日目の記事です。

まえがき

ハッピーバースデー Dear 息子♪

というわけで、本日12月17日は皆さんご存知のとおり私の息子の6歳の誕生日です。 相変わらず手のかかる、そして世界一可愛くて愛おしい息子ですが、来年からなんと小学生です。びっくり。

記念すべき日ですが、今日は息子とはまったく関係のない話を書きます*1

我が家の蔵書事情

さて、突然ですが皆さん漫画はお好きでしょうか?

私は大好きです。 子供の頃でいえば月刊コロコロコミックに始まり、ジャンプやマガジン、サンデー、ガンガン……およそ平均的な少年として育った私にとって、漫画は非常に身近な存在でした。

子供の頃や大学生の頃など、コミックスは当然物理の書籍を購入していました。 当たり前ですが、物理の書籍は買えば買うほど場所を取ります。引越の荷物にもなりますし、数年前に家を建てたことで物理的なキャパシティの限界も決まってしまいました。

そういった事情もあり、ここ数年はそれ以前から購読していた作品を除き、新しく購入するコミックス電子書籍に移行していました。 そして今年の夏、物理で購読していた最後の作品が遂に完結してしまいました。これからはすべて電子書籍で購入することになります。なんだかちょっと寂しい。

これ以上増えなくなってしまったとはいえ、それなりに増えた蔵書の管理も大変です。これどうしようかなと思っていました。

弊社オフィスの漫画事情

ところで、弊社の柏の葉本社にはそれなりの数の漫画が置いてあります。少し古いですが、デジタルノートにも特集記事があります。

note.fuller-inc.com

私が所属する新潟本社にも漫画がありますが、柏の葉の蔵書量には及びません。 最近社内で新潟本社の漫画蔵書を増やそうという動きがあり、ちょうど良いと思って私の蔵書を会社に寄贈することにしました。

私が集めた漫画紹介

というわけで、ここからは私が社に寄贈した漫画紹介です*2

振り返ってみたらほとんどジャンプコミックスで、特に意外性もないラインナップでした。期待値低めでご覧ください。

魔法陣グルグル衛藤ヒロユキ

魔法陣グルグル

伝説のギャグ漫画。連載開始は1992年だそうです。全16巻。

とにかくギャグのテンポがめちゃくちゃ良くて、何度読んでも笑える作品です。 きっかけは忘れましたが、たしか中学生の頃にブックオフを巡りまくって買い揃えました。そんなに長いこと持ってたのかと思うと感慨深い。

ちなみに弊社には数名グルグルを理解してる人がいて、時たまグルグルのネタで遊んでます。ウニョラー!

暗殺教室松井優征

暗殺教室

地球を破壊する超生物が教師となり、落ちこぼれの中学生を暗殺者として育てながら、人生の大切なことを教えていく作品。2012年連載開始。全21巻+ガイドブック2冊。

ギャグテイストでありながら芯の通ったストーリーや伏線があり、メッセージ性もあって非常に深い作品でした。随所に出てくる時事ネタもキレてる。 松井先生が「この作品を考えた時点で結末は決まっていて、あとはそこにどのように綺麗に持っていけるかだった」というようなことを語っていましたが、完全にやられました。泣きました。

これはたしか最初から購入していたわけではなく、イトナが出てくるあたりから本誌で読み始めて、面白かったので後追いでコミックスを揃えた気がします。

火ノ丸相撲/川田

火ノ丸相撲

ジャンプとしては非常に珍しい、相撲をテーマにした作品。2014年連載開始。全28巻。

新人でありながらめちゃくちゃ画力が高く、迫力のある絵で一気に引き込まれました。 蓋を開けてみれば「友情・努力・勝利」で構成された王道のジャンプ作品で、展開ひとつひとつが非常にアツい物語です。

これは……というか大体の作品がそうなんですが、第一話を読んで面白い!と思って迷わず購入を決めた記憶があります。 ちなみに一番好きな試合は16巻のキリヒトの試合です。格好良すぎて笑ってしまうぐらい好きです。

僕のヒーローアカデミア堀越耕平

僕のヒーローアカデミア

ヒーローが職業として注目を浴びる世の中で、"無個性"の主人公がヒーローを目指す物語。冒頭に書いた、私が最後に購入していた作品です。2014年連載開始。全42巻+公式ガイドブック、劇場版の特典4冊。

学園モノということもあってそれなりにキャラクターが多いのに、ひとりひとりが丁寧に描かれていてとても良いです。みんなのツボにはまるキャラクターがひとりはいるはず。 好きな話がありすぎて絞りきれませんが、あえて選ぶなら学園祭のところかな。後半にかけて爆豪くんの株がうなぎ登りになっちゃいますが、最推しは轟くんと耳郎ちゃんです。

これも第一話を読んで絶対買うぞと決めました。さすがに42巻まで続くとは思わなかった。

背すじをピン!と〜鹿高競技ダンス部へようこそ〜横田卓馬

背筋をピン!と

これまた珍しい、高校競技ダンス部のお話。ここから社会人になって以降の作品ですね。2015年連載開始。全10巻。

横田先生の作品は基本的に「悪い人」「胸糞悪い展開」が出てこないのが非常に良いのです。やさしいせかい。 自分に自信を持てない主人公、周りの熱に当てられて奮闘するけど、結果は簡単についてこず……というもどかしい展開。その裏で起きる先輩たちのバチバチに熱いハイレベルな戦い。青春をこれでもかと盛り込んだ青すぎる作品です。

連載開始の少し前に載っていた読切をなんとなーく覚えていて、その話が好きだったので嬉しくなって買った記憶があります。一番好きなのはやっぱり3巻の学園祭のところかな。

左門くんはサモナー/沼駿

左門くんはサモナー

悪魔を召喚できる能力を持つ主人公と、そのトラブルに巻き込まれるヒロインが織りなす学園ギャグ漫画。2015年連載開始。全10巻。

これは第一話がとてもキャッチーだったところと、ヒロインである天使ヶ原のツッコミのキレがめちゃくちゃ良くて好きになりました。いろんな作品でも取り上げられる悪魔の設定をきちんと踏襲しながら、これでもかという程にコミカルに仕上げています。天才の発想だと思いました。

デフォルメのしかたとかを見るに、作者はきっとドラえもんが好きなんでしょう。個人的にはルビとか行間にギャグを詰め込むテクニックが好きです。

約束のネバーランド白井カイウ出水ぽすか

約束のネバーランド

食用児として育てられた子どもたちが、農園からの脱走、その先の平和な世界を求めて奮闘する話。2016年連載開始。全20巻+短編集、ノベライズ3冊。

真実を知った子どもたちと大人、鬼との緊迫の頭脳戦が非常に魅力です。画も非常に綺麗で惹き込まれる。ラストの展開に切なさが残っていて、未だに自分の中で消化しきれずにいます。物語の間を埋めるノベライズも非常に面白いです。

これはとにかく第一話が衝撃でした。穏やかな話かと思ったら突然ゾワっとする違和感が現れ、後半にかけて一気に世界観を固めてくる演出。この作品や短編集のお話を通じて、世界観の作り込みが非常に上手いなと思ってます。私たちの日常やリアルな世界にひとつ、違和感やズレを仕込んだような。その違和感の入れ方が絶妙です。

シューダン!/横田卓馬

シューダン!

背筋をピン!との作者が、少年サッカーを題材にした作品。2017年連載開始。全4巻。

前作で描かれたやさしいせかいはそのままに、思春期真っ只中の小学生が織りなす心温まるストーリーが印象的です。みんなかわいい。物語後半の強豪チームとの試合、みんなの魅力たっぷりでとても熱くなります。この世界一生続いてほしいと思いました。

が、そこはジャンプで不遇といわれるサッカー漫画、残念ながらこれも例に漏れず4巻で終わってしまいました。後半、時系列が一気に進んで「え、打ち切り……?」と悲しんだものの、そのまま数話進んで「なんだ、大丈夫じゃん!」と思ったら連載終了してしまった。かなしい。

おわりに

というわけで全162冊を寄贈しました。 いざ振り返ってみると、自分結構ギャグ漫画が好きなんだなと気づきますね。

今まで集めたものを手放すのは少々寂しさが残りますが、オフィスに行けば変わらず読めますし、これをキッカケに各作品を好きになってくれる人が増えればとても嬉しいです。

今回取り上げた漫画に興味が出た方、ぜひフラー株式会社の新潟本社に遊びに来てください。どうやら、ある手続きを踏むとオフィスに入り放題になるそうです。詳しくは以下のページをご覧ください。

recruit.fuller-inc.com

*1:「今日は」と書きましたが、このブログで過去息子に言及したことはありません。

*2:こんな内容の記事を想像してた方、残念でした。技術書など高価なものは持ち合わせていません。

ISUCON14延長戦の記録⑨ N+1問題の解消とクロージング

ISUCON14の延長戦をやってます

以下の記事の続きです。

furusax0621.hatenablog.com

前回は2つめの通知エンドポイントレスポンスをキャッシュし、ついに本戦当時の6位相当までスコアを伸ばすことができました。 今回はユーザーの通知エンドポイントの中で気になった実装の改善、最後のチューニングに触れていきます。

なお、最終的なコードは以下のリポジトリで公開しています。

github.com

椅子の評価情報を簡単に取得する

ユーザーの通知エンドポイントのレスポンスには椅子の評価情報が含まれています。 内容としてはこれまでに完了したライドの数と、これまでに受けた評価の平均値です。

github.com

初期実装は以下リンクのとおりで、椅子にこれまで割り当てられたライドの一覧を取得したあと、ライド毎に最新のステータスを取得するという、非常にわかりやすいN+1問題になっています。 https://github.com/furusax0621/isucon14-extend/blob/e5834de96efcf5e27cb7f4189951ee0f6fa8eb0d/webapp/go/app_handlers.go#L766-L817

これまでの実装により、ライドとその最新のステータスは単純なJOINができるようになりました。ライド数と評価の合計値も簡単な集計クエリで取得可能です。レスポンスに必要な平均値は、クエリで合計値を取得後にアプリケーション側で計算しました。

SELECT
    COUNT(r.evaluation) AS total_rides_count,
    SUM(r.evaluation) AS total_evaluation 
FROM rides AS r
JOIN ride_latest_statuses AS rl
    ON r.id = rl.ride_id
WHERE
    rl.status = 'COMPLETED'
    AND r.chair_id = ?

競技終了前の最終チューニング

スコアを伸ばすための最後の一押しとして、サーバーで記録している各種ログを無効化します。 ログ出力は多少なりとも計算コストやディスクへの書き込みコストが発生するため、これらを無効化することでパフォーマンス改善が見込めます。

アプリケーションコードからpproteinエージェントを削除する

計測用に導入したpproteinエージェントの実装を削除します。Pull Requestを作るのも面倒だったので、mainブランチに直プッシュしました。以下のコミットです。

github.com

MySQLのスロークエリログを無効化する

template-isucon-recipeで追加したスロークエリログの設定を削除します。設定は独立したファイルになっているため、そのファイルをそのまま削除すればOKです。

sudo rm -f /etc/mysql/mysql.conf.d/slow-query-log.cnf

MySQLのバイナリログを無効化する

MySQL 8.0ではデフォルトでバイナリログが有効になっており、この書き込みがパフォーマンスに影響を与えます。これも無効化しておくことでパフォーマンス改善が見込めます。

/etc/mysql/my.cnf などに以下の設定を追加しましょう。

[mysqld]
disbale-log-bin

Nginxのアクセスログを無効化する

アクセスログをalpで解析するための設定を削除しつつ、Nginx全体でアクセスログを無効化します。alp用の設定はtemplate-isucon-recipeによって反映されているので、ファイルを削除すればOKです。

sudo rm -f /etc/nginx/conf.d/alp.conf

アクセスログの無効化は /etc/nginx/nginx.conf を修正し、アクセスログの設定をしている箇所を探して以下のように記述しましょう。

access_log off;

終結

ここまでの変更をすべて反映し、何度かベンチマーカーを動かし、最終的に51,873点まで到達しました。本戦当時のスコア換算だと4位相当です。頑張ったけど1位には届かなかった……残念!

(宣言どおり、ちゃんとまとめ記事書いたぞ。えらい)

まとめ

今回、運営側の御厚意で得た延長戦のチャンスを使って、改めて問題を解いてみました。新しい発見や学びがあり、非常に充実したチャレンジでした。運営の皆さま、本当にありがとうございました。

一方で、最終的に3台目のサーバーを使えなかったこと、SSEなど実装しきれずに諦めたことなど、課題もたくさんあります。現時点でトップスコアは50万点を超えていることからも、まだまだ改善の余地はあるようです。まとまった時間があるときに、改めて考えたり、他の参加者の解法を解読したりしてみようと思います。

ISUCON14延長戦の記録⑧ インメモリキャッシュに手を出す(その2)

ISUCON14の延長戦をやってます

以下の記事の続きです。

furusax0621.hatenablog.com

前回は通知エンドポイントのひとつである、椅子の通知エンドポイントを高速化しました。 今回はもうひとつの通知エンドポイントにインメモリキャッシュを導入します。

なお、最終的なコードは以下のリポジトリで公開しています。

github.com

ユーザーの通知エンドポイントレスポンスをキャッシュする

前回と同様、レスポンスで返す情報のキャッシュを試みます。 このエンドポイントではユーザーがリクエストしているライドの情報、ライドに割り当てられた椅子の情報などを返しています。 椅子の情報をキャッシュしていくのは少し骨が折れそうだったので、ライドの情報に限定してキャッシュしてみました。

完全な差分は以下のPull Requestのとおりです。

github.com

実装に関してはほぼほぼ前回の修正と同様です。効果が大きかった変更も同様で、ライドをリクエストしてないユーザーへのレスポンスを高速に返せるようになりました。

// キャッシュからライド情報を取得
rideMapByUserIDMutex.RLock()
rideFromMap, ok := rideMapByUserID[user.ID]
rideMapByUserIDMutex.RUnlock()

if !ok {
    writeJSON(w, http.StatusOK, &appGetNotificationResponse{
        RetryAfterMs: 30,
    })
    return
}

https://github.com/furusax0621/isucon14-extend/blob/eda0047d9d96b8f1968eff0546d533f8a63cfe12/webapp/go/app_handlers.go#L670-L680

まとめ・次回予告

前回に引き続き、通知エンドポイントのレスポンスをキャッシュすることで高速化を試みました。 ベンチマークの結果はおよそ43,000点!本戦終了時のスコア換算だと6位相当です!すごい、うおおお!

いよいよネタが尽きてきた……が、ユーザーの通知エンドポイントを眺めていたら気になる箇所が出てきました。次回はそこの改善に取り組みます。つづく。

ISUCON14延長戦の記録⑦ インメモリキャッシュに手を出す

ISUCON14の延長戦をやってます

以下の記事の続きです。

furusax0621.hatenablog.com

前回は細かいチューニングをしつつ、MySQLサーバーを別インスタンスに切り出しました。スコアは本戦当時の上位入賞に食い込める28,000点まで伸びています。

またしてもやることが見えなくなってきたので、講評を読みながら改善点を探ることにしました。

なお、最終的なコードは以下のリポジトリで公開しています。

github.com

通知エンドポイントの高速化

解説・講評を読んでいると2種類の通知エンドポイント /api/app/notification /api/chair/notification の高速化について言及されています。当日のマニュアルにもあるとおり、これらのエンドポイントはSSE(Server Sent Events)への実装差し替えが可能です。が、正直SSEについての知識も実装できる自信もなかったため、JSON APIのまた愚直にキャッシュしていくことにしました。

椅子の通知エンドポイントレスポンスをキャッシュする

椅子の通知エンドポイントのレスポンスをよく見てみると、椅子がライドにマッチングしている場合、その情報を返すようです。 返すべきライドの情報はいくつかありますが、ステータス以外の情報は固定であることからこの情報をインメモリキャッシュできそうだと考えました。

完全な差分は以下のPull Requestで確認できます。

github.com

恥ずかしながらRedisなどインメモリデータストアに関する知見が乏しいので、sync.RWMutexとmapで愚直に実装しました。mapは椅子のIDをキーに、椅子がマッチングされているライドの情報をもつマップとして宣言しています。mapを更新するタイミングは以前導入した chairs.is_free カラムと一緒です。マッチングしたタイミングでmapに追加し、通知エンドポイントで COMPLETED を椅子に通知したタイミングで削除すればOKです。

実装してから気付いたのですが、このmapを導入することによって椅子にライドが割り当てられてないときのレスポンスを高速に返せるようになりました。ちょうど以下の実装箇所です。

rideMapByChairIDMutex.RLock()
ride, ok := rideMapByChairID[chair.ID]
rideMapByChairIDMutex.RUnlock()

if !ok {
    writeJSON(w, http.StatusOK, &chairGetNotificationResponse{
        RetryAfterMs: 30,
    })
    return
}

https://github.com/furusax0621/isucon14-extend/blob/eda0047d9d96b8f1968eff0546d533f8a63cfe12/webapp/go/chair_handlers.go#L211-L220

map導入前は椅子のIDをキーに最新のライド情報を取得し、ライドが存在しないか完了していれば割り当てられてないと判断していました。 データベースアクセスが必要な上、ロジックの都合上トランザクションを作成する必要があるのでどうしても重くなってしまうようです。 これをデータベースアクセスなしで判断できるようになったのは、とても効果が大きかったようです。

まとめ・次回予告

2つある通知エンドポイントのひとつである椅子の通知エンドポイントを修正し、椅子毎のライド情報をキャッシュできるようにしました。 これを導入したところ、スコアが37,900点程度にまで伸びました。どんどん上がりますね。楽しくなってきた。

次回はもうひとつの通知エンドポイントのキャッシュをしてみます。つづく。

ISUCON14延長戦の記録⑥ MySQLサーバーを別インスタンスにする

ISUCON14の延長戦をやってます

以下の記事の続きです。

furusax0621.hatenablog.com

前回はISUCON14の目玉のひとつ(?)であるマッチングアルゴリズムの改善をしました。 これまでの改善も含め、スコアを一気に17,000点まで伸ばすことができました。

なお、最終的なコードは以下のリポジトリで公開しています。

github.com

やることがなくなってきた

マッチングアルゴリズムの改善まで済ませたところで、次にやるべきことが思いつかなくなりました。 自身の発想力のなさに嫌気が指します。

とりあえず苦し紛れに入れた改善を紹介します。スコアはわずかですが伸び、18,700点程度になりました。

インデックスの追加

改善の糸口を見つけるためにスロークエリログを眺めていたところ、クーポンを検索するクエリでインデックスが効いてないことに気付きました。 クーポンはユーザー増加によって使われる機会が増える機能です。初期実装では他の箇所がボトルネックになっていたことで、このクエリが上位に出てこなかったのでしょう。

追加したインデックスは以下のPull Requestで確認できます。

github.com

ライドの最新ステータスをテーブル管理する

ライドのステータスはride_statusesテーブルで管理されています。以前改善した椅子の位置情報と同様、履歴テーブルとして実装されています。アプリケーションの各所でこのテーブルから最新のステータスを取得する関数が呼ばれており、N+1問題になっている箇所もありました。

そこでライド毎の最新ステータスのみを管理するride_latest_statusesテーブルを追加し、ridesテーブルとJOINすることでライドの情報と最新ステータスを一度に取得できるようにしました。

なお、このテーブルについてもライドの初期データの情報を復元する必要がありますが、初期データの中身を覗いたところ登録されるライドはすべて完了済みになっていそうでした。 本来であれば椅子の位置情報と同様にride_statusesテーブルを時系列順に取得してUPSERTをかけるべきですが、ここは一定サボって固定で COMPLETED を挿入しています。

完全な差分は以下のPull Requestを参照してください。

github.com

MySQLサーバーを別インスタンスに分離する

そろそろ一台で色々やりくりする時期も終わったかなと判断し、ここでMySQLサーバーを別インスタンスに切り出すことにしました。 アプリケーションコード的には env.sh に記載されているホストを書き換えるだけで良いです。以下のPull Requestのとおりです。

github.com

※ なぜかInitializeエンドポイントがコケるようになったので、ライドの最新ステータスの挿入をUPSERTにしています

以下、MySQLサーバーを分離するためにやったコード変更以外の作業を紹介します。

インスタンスからのアクセスを許可する

MySQLサーバーがネットワーク経由で接続を受け付けるには、いくつかの設定が必要です。

まずはMySQLユーザーのホスト指定です。MySQLユーザーにはユーザー名の他にホストの概念があり、このホストが一致しないと例えユーザー名とパスワードが一致してもログインできません。 MySQLにrootユーザーでアクセスし、 mysql.user テーブルの情報を確認しましょう。ISUCON14の環境では、 sudo mysql でrootユーザーとしてログインすることが可能でした。

SELECT host, user FROM mysql.user;

ここでアプリケーションで利用しているユーザーのホストが localhost などになっている場合、他のホストからアクセスできるようユーザーを作成するなどの作業が必要です。幸いISUCON14では % (任意のホスト)が指定されていたため、この作業は不要でした。

次はMySQLサーバーが通信を受け付けるIPアドレス帯の指定です。 /etc/mysql.my.cnf/etc/mysql/mysql.conf.d/mysqld.cnf ファイルを確認し、 bind-address パラメータの値を確認します。MySQLのデフォルトは 127.0.0.1 (ローカルホストのみ許可)になっています。一番手軽なのは、これを 0.0.0.0 (任意のホスト)に書き換えることです。

[mysqld]
bind-address = 0.0.0.0

各種サービスの設定

MySQLサーバーを動かすインスタンスでは、アプリケーションやNginxサーバーが稼働している必要はありません。 不要なサービスは停止させ、CPUやメモリのリソースを有効活用できるようにしましょう。

sudo systemctl stop isuride-go.service
sudo systemctl disable isuride-go.service
sudo systemctl stop nginx.service
sudo systemctl disable nginx.service

また、スロークエリログを収集するためにpproteinエージェントを起動します。 競技序盤ではアプリケーションコードにpproteinエージェントを統合することを想定し、template-isucon-recipeはpproteinエージェントのサービスはインストールのみ行い、起動しないようになっています。 この先の改善のヒントを探すためにも、pproteinエージェントを起動しておきます。

sudo systemctl start pprotein-agent.service
sudo systemctl enable pprotein-agent.service

まとめ・次回予告

MySQLサーバーを分離したところ、さらにスコアが伸びて28,000点近くまで到達しました。本戦最終スコア換算でいうと25位に相当します。すごい、上位入賞だ!

スロークエリログの出力をオフにするなど細かいチューニングまで含めると33,500点まで届きました。本戦最終スコア換算で14位相当、特別賞を受賞できるラインです。

ここからもうひと伸びしたいなぁ〜と、再び講評を読んでヒントを探っていきます。つづく。

ISUCON14延長戦の記録⑤ マッチングアルゴリズムを改善する

ISUCON14の延長戦をやってます

以下の記事の続きです。

furusax0621.hatenablog.com

前回は椅子の総移動距離を管理するカラムを追加することにより、非常に重いクエリを軽量化しました。が、スコアはまだまだ伸びません。

なお、最終的なコードは以下のリポジトリで公開しています。

github.com

マッチングアルゴリズム

アプリケーションマニュアルにあるとおり、椅子とライドのマッチングは一定間隔(初期実装では500ms)でリクエストされる内部APIによって実現しています。 この実装がよろしくなく、ベンチマークの結果を見ると次のようなログが頻出します。ライドがマッチングされるまでの時間に多くのユーザーが不満を抱えていることがわかります。

100.0%のライドは椅子がマッチされるまでの時間、50.0%のライドはマッチされた椅子が乗車地点までに掛かる時間、100.0%のライドは椅子の実移動時間に不満がありました

本戦当時も、このログからマッチングアルゴリズムが足を引っ張ってるなと気付くことができました。今回はここの改善に取り組みます。

なお、初期実装では「ここがまずいから直してくれ」と言わんばかりのコメントが書いてあります。当時これを見つけてめっちゃ笑いました。

// MEMO: 一旦最も待たせているリクエストに適当な空いている椅子マッチさせる実装とする。おそらくもっといい方法があるはず…

アルゴリズムの改善

完全な変更内容は以下のPull Requestにまとまっています。

github.com

改善方針

初期実装のアルゴリズムでは、一回のリクエストで常にひとつのライドしかマッチングしないようになっています。 あまりにも効率が悪いので、以下のように一回のリクエストでバルク的にマッチングする方針としました。

  1. マッチング待ちのライドをすべて取得する
  2. 現在空いている椅子を、その位置情報とともにすべて取得する
  3. ライド毎にループする
    1. 椅子毎にループし、ライドの始点との距離を計算する
    2. 最も近い距離にいた椅子を抽出し、ライドとのマッチング対象とする
    3. 椅子が複数のライドに割り当てられないよう、2で抽出された椅子を椅子の一覧から除外する
  4. ライドと椅子のマッチング情報を登録する
  5. 椅子を空いてない状態( is_free = FALSE )にする

3のループはライドの登録日時順で回しました。 ライドに長時間椅子がマッチングしないとクリティカルエラーになってしまうため、とにかく一番待たせているライドを最優先でマッチングするようにしています。

また、最も近い距離にいた椅子を割り出すためのしきい値として 400 という距離を設定しています。 解説記事にもあるとおり、ライドはサービス提供エリア内を移動することから、同エリア内に存在する椅子に限定してマッチングさせ、別エリアにいるような椅子とのマッチングは見送ったほうが効率が良くなります。 400 は解説記事の参考実装に出てくるしきい値で、その値をそのまま頂戴しました。

Too many connections との格闘

上記の改善アルゴリズムを実装すると、負荷走行中に Too many connections というメッセージとともに500エラーが頻発し、ベンチがコケるようになりました。トランザクションの構成など細かい調整をしましたが、根本的な解決にはならなかったため、MySQLサーバーのパラメータを調整することにしました。

MySQLサーバーのデフォルト値では、max_connectionsパラメータは151に設定されています。database/sql パッケージのコネクションプールはデフォルトで無制限にコネクションを確立するようで、おそらくアルゴリズム改善によってユーザーが増加し、同時リクエスト数が増えたことでコネクション数の上限をオーバーしてしまったのでしょう。

この問題を解決するため、 /etc/mysql/mysql.conf.d/conn.cnf ファイルを作成し、次のような設定を入れました。

[mysqld]
max_connections = 1000

併せて、アプリケーションコード側でもコネクションプールの設定を調整します。設定根拠は以下の記事を参考にしました。昔会社の先輩に教えてもらった記事で、今でもバイブル的にブックマークしている記事のひとつです。

dsas.blog.klab.org

SetMaxOpenConnsMySQLサーバーのmax_connectionsを最大値としつつ、同時接続するサーバー数で割ります。現状は1インスタンスしか使ってないのでそのまま1,000を設定しました。 SetMaxIdleConnsSetMaxOpenConns 以上であれば良いとのことなのでそのまま1,000とします。 SetConnMaxLifetime はベンチマーカーが負荷走行をかける時間以上にせっていしておけば良さそうです。負荷走行が60秒程度で完了するので、少し長めにとって80秒としました。

db.SetMaxOpenConns(1000)
db.SetMaxIdleConns(1000)
db.SetConnMaxLifetime(80 * time.Second)

これらの設定を追加することで、無事Too many connecitonsから解放され、ベンチが通るようになりました。

まとめ・次回予告

この変更を取り込んだ結果、スコアが一気に17,000点程度にまで上昇しました。これまでにないジャンプアップ、そして本戦当時の最高スコアを大幅更新です。やった!

ようやく、今まで見たことない景色を見れる位置まできました。また更にチューニングを続けていきます。つづく。

ISUCON14延長戦の記録④ 激重の集計クエリを軽量化する

ISUCON14の延長戦をやってます

以下の記事の続きです。

furusax0621.hatenablog.com

前回はN+1どころじゃない問題を解消するために、椅子の空き状況と最新位置情報をデータベースで管理できるようにしました。 エンドポイント単体はとても軽量になりましたが、残念ながらスコアは思うように伸びませんでした。

なお、最終的なコードは以下のリポジトリで公開しています。

github.com

重すぎる集計クエリ

さて、前回の修正を入れると次に目立ってくるのが /api/owner/chairs エンドポイントです。 出題者の解説記事にも「いわゆる観光名所」と言われている箇所ですね。

/api/owner/chairs エンドポイントはオーナー向けのエンドポイントで、自身が管理している椅子の総走行距離を計算して出力します。初期実装では、この情報の計算にはchair_locationsテーブルが利用されていました。chair_locationsテーブルは過去の位置情報をすべて保存している履歴テーブルになっているため、目を背けたくなるような複雑なクエリが実装されています。

https://github.com/furusax0621/isucon14-extend/blob/e5834de96efcf5e27cb7f4189951ee0f6fa8eb0d/webapp/go/owner_handlers.go#L198-L219

このクエリの改善について、解説記事には次のように記述されています。

また、最新以外の chair_locations はここでの total_distance を計算することにしか使われないことと、新しく椅子の位置情報が記録されるとき次の total_distance は1つ前の位置情報とそれまでの total_distance から計算できることに気がつけると、各椅子ごとに最新の位置情報と総走行距離を持つテーブルを作りUPSERTだけで管理できるようになり、ここでのクエリをはじめ様々なクエリをシンプルにすることができます。

前回の記事で椅子の最新の位置情報を記録する chair_last_locations テーブルを追加していました。このテーブルを少し調整すれば、問題のクエリを簡素にできそうです。

総走行距離を記録するカラム追加

解説記事に従い、chair_last_locationsテーブルに総走行距離を記録するtotal_distanceカラムを追加しました。デフォルト値に0を入れ、位置情報を更新する度に加算していく運用にします。

total_distance INTEGER NOT NULL DEFAULT 0 COMMENT '総移動距離'

前回の修正では位置情報を更新する度にUPSERTするクエリを実装していましたが、ここにtotal_distanceの更新を追加します。total_distanceは更新前のレコードを参照して更新しないといけないので、ちょっと書き方が難しくなります。この辺りが少し難しくてChatGPTに色々相談しながら作りました。

INSERT INTO chair_last_locations
    (chair_id, latitude, longitude, updated_at, total_distance)
    VALUES (?, ?, ?, ?, 0) AS new
ON DUPLICATE KEY UPDATE 
    total_distance =
        chair_last_locations.total_distance
            + ABS(chair_last_locations.latitude - new.latitude)
            + ABS(chair_last_locations.longitude - new.longitude),
    latitude = new.latitude,
    longitude = new.longitude,
    updated_at = new.updated_at;

INSERTのときは0を挿入し、UPDATEするときに元のレコードとの差分を計算して加算する、という実装です。 更新するカラムを記述する順序が大事らしく、total_distanceの更新式を最後に記述すると計算結果が0になってしまいます。 おそらくですがlatitudeとlongitudeの更新式を先に書いてしまうと、 chair_last_locations.latitudechair_last_locations.longitudenew の値になってしまうんだと思います(よくわかってないので有識者の方、教えてください!)。 試行錯誤している中でしばらくこの現象に気付けず、だいぶ長いこと頭を抱えました。

改善された集計クエリ

total_distanceカラムが誕生したことによって、初期実装にあった集計クエリが一切不要になりました。該当クエリは以下のようにchair_last_locationsテーブルを参照するだけで良くなりました。

SELECT c.id,
    c.owner_id,
    c.name,
    c.access_token,
    c.model,
    c.is_active,
    c.created_at,
    c.updated_at,
    IFNULL(cl.total_distance, 0) AS total_distance,
    cl.updated_at AS total_distance_updated_at
FROM chairs AS c
LEFT JOIN chair_last_locations AS cl
    ON c.id = cl.chair_id
WHERE c.owner_id = ?

リクエスト時点でまだ椅子が位置情報を送信していない可能性があるため、chair_last_locationsテーブルとはLEFT JOINする必要があります。その点にだけ注意が必要です。 これらを取り込んだ完全な変更内容は以下のPull Requestを参照してください。

github.com

まとめ・次回予告

非常に重い集計クエリを取っ払い、またひとつエンドポイントを軽量化できました。しかしまたしてもスコアは伸びず、3,500点程度に留まってます。 次スコアが伸びるのはいつになるんでしょうか。。つづく。