データベースを速くする、その先へ
前の章でindexやcache tableを入れ、上位SQLは計測上ほぼ0秒になりました。それでもアプリは、状態が届くたびに履歴をDBへ書き、画面を開くたびにDBから読みます。
ここでAgentは、少し大きな問いを立てました。
このデータは、ベンチ中ずっとDBに置いておく必要があるだろうか?
memoryに置くとは
memoryは、アプリが動いている間だけ使える高速な作業机です。DBは再起動しても残る倉庫です。倉庫へ毎回取りに行くより、よく使うものを机へ置くほうが速く読めます。
ただし、机には弱点があります。
- アプリを再起動すると消える
- 複数サーバーの机は、自動では同じ内容にならない
- 同時に読み書きすると、順番や壊れ方を考える必要がある
- 置きすぎるとmemoryが足りなくなる
「memoryは速い」で終わらず、公式仕様と初期化の流れを確認します。
今回の設計
POST /initializeのときだけ、初期データをDBからmemoryへ読み込みます。その後のベンチ中は、次をmemoryで処理しました。
- userとISUの存在確認
- ISUごとのcondition履歴
- 最新condition
- graph用の時刻範囲検索
- trend用の最新状態
- 短時間だけ再利用するJSON
conditionは時刻順のsliceへ置きます。sliceは、同じ種類のデータを順番に並べる箱です。新しいデータは多くの場合末尾へ追加でき、古い時刻が混ざったときだけ並べ直します。
初期化時にはDBから作り直せるため、「ベンチ開始前の状態へ戻す」要件も守れます。競技終了後まで新規データを永続化する仕様ではないことを、公式マニュアルとベンチ動作から確認して選びました。
20,306点から43,682点へ
応用編の再基準と設定調整を挟み、memory化前後は次のようになりました。
| 段階 | スコア | 見えたこと |
|---|---|---|
| 応用編の再基準 | 21,088 | DBの大きな山は消え、Go・network・GCが候補 |
| OS・DB・Go設定 | 23,358 | 小幅な伸び。設定だけでは上限を動かしにくい |
| conditionをmemoryへ | 43,682 | pass。ほぼ同じ構造の仕事をDBから外して約1.87倍 |
| 再測定 | 42,244 | 4万点台を再現 |
GCは、使わなくなったmemoryをGoが片づける仕組みです。memory化した直後は、JSONやsliceを大量に作るため、pprofでGCが見えるようになりました。ボトルネックを消すと、次の山が姿を見せます。
「先に大きく確保」が裏目に出た
sliceは満杯になると、より大きな箱を作って中身を移します。それを避けようと、新規ISUごとに16,384件分を先に確保しました。速そうに聞こえますよね。
結果は42,244点から34,599点へ低下しました。
理由は、1台あたりの無駄が小さく見えても、新規ISUの数を掛けると大きなmemoryになるからです。実測に合わせて1,024件へ縮め、GC設定も比較すると47,190点まで戻りました。
ここには、ISUCONで何度も使える教訓があります。
1回分の費用ではなく、最大件数を掛けた費用を考える。
JSONを読む費用
pprofでは次にencoding/jsonが目立ちました。Goの標準JSON parserは、さまざまなJSONを正しく読める頼もしい道具です。しかしcondition POSTの形は決まっています。
そこで、よく来る正規の形だけを少ないallocationで読むfast pathを作りました。allocationは、新しいmemory領域を用意することです。文字列やsliceを毎回作る量が減ると、parser自身だけでなくGCの仕事も減ります。
入力順が違う、escape文字があるなど、想定外の形は標準parserへ戻します。速い道だけを作り、正しく読める範囲は狭めません。
この変更とtrend cacheなどを合わせ、55,572点。その後bufferとsessionの再利用を調整し、1台構成の最高57,208点になりました。
一番多い入口だけ、薄くする
3台へ分けた後もpprofとaccess logを取り直すと、condition POSTだけが約10万回ずつworkerへ来ていました。形が決まった短いrequestを受けるために、毎回同じWeb frameworkの経路を通っています。
そこでworkerでは、port 3000にcondition POST専用の小さなHTTP serverを置きました。ここでいう「小さい」は、機能を削ったという意味です。
- request lineと必要なheaderを読む
- condition POSTなら専用handlerへ渡す
- それ以外はport 3002の従来Echoアプリへつなぐ
- JSONの形が想定外なら、これまでの安全なparserへ戻す
専用serverで77,109点、lock範囲とresponse JSONを見直して80,671点になりました。ただし、すべてのAPIを手書きにしたわけではありません。回数が圧倒的に多く、入力が決まっている一本道だけを薄くしました。
この変更は初心者が最初に選ぶものではありません。slow queryやindexを直す前にHTTP parserを書き始めると、速くなる前に講習が終わります。pprofでGoのHTTP処理が見え、access logで対象routeの回数が分かってから出すカードです。
大きな構造変更を頼むプロンプト
計測結果から、現在のDBアクセスを小さく直し続けるべきか、構造を変えるべきか判断してください。
- 公式仕様から、再起動後も永続化が必要なデータと、initializeで復元できるデータを分ける
- user、ISU、condition、trend、graphの読み書き経路を図にする
- memory化する場合は、初期load、同時read/write、時刻順、重複、再初期化を設計する
- 変更前後で同じAPI responseになるtestを追加する
- memory量は「1件の大きさ×最大件数」で見積もる
- benchmark、pprof、vmstat、HTTP statusを再計測する
- 標準JSON parserを外す場合は、互換用fallbackを残す
- 改善しなかった設定や事前確保は戻し、理由を記録する
いきなり実装せず、守る仕様と失敗時の戻し方を先に説明してください。計測結果から、現在のDBアクセスを小さく直し続けるべきか、構造を変えるべきか判断してください。
- 公式仕様から、再起動後も永続化が必要なデータと、initializeで復元できるデータを分ける
- user、ISU、condition、trend、graphの読み書き経路を図にする
- memory化する場合は、初期load、同時read/write、時刻順、重複、再初期化を設計する
- 変更前後で同じAPI responseになるtestを追加する
- memory量は「1件の大きさ×最大件数」で見積もる
- benchmark、pprof、vmstat、HTTP statusを再計測する
- 標準JSON parserを外す場合は、互換用fallbackを残す
- 改善しなかった設定や事前確保は戻し、理由を記録する
いきなり実装せず、守る仕様と失敗時の戻し方を先に説明してください。memory化は強いカードです。でも、仕様を読み、正しさをtestし、memory量を測って初めて採用できます。大きな変更ほど、ガードレールも大きくしましょう。