Skip to content

yyyushiro/go_performance_experiment

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

12 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

はじめに

このポートフォリオは「デートプランをランダムに提案することでカップルのマンネリ化を解決する」ことをテーマとしたシンプルなAPIを題材としている。

各機能の性能検証と考察、改善を繰り返す中で、自らのCSに対する理解を深めることがこのポートフォリオの目的である。

環境

  • OS: macOS
  • Language: Go 1.25.6 (darwin/arm64)
  • Database: SQLite3 (modernc.org/sqlite), PostgreSQL17 (github.com/jackc/pgx/v5/stdlib)
  • Tooling: hey (Load testing), VSCode, Docker

Topics

  • SQLite: B-tree, Mutex, Locks(SHARED, RESERVED, etc), Journaling, SQL Optimization
  • Go: HTTP Method Selection, DB Connection Control
  • OS: Paging, Scheduling, Context Switch, OS Thread/ Goroutine, Memory Cache
  • Others: Big-O Notation, Docker

要約

  • SQLをB-treeとキャッシュの観点から効率化し、パフォーマンス改善を図った。
  • SQLiteの仕組み(ロック状態)を理解した上で、排他処理を実装した。
  • PostgreSQLを、SQLiteとの違いを踏まえて導入した。

開発ログ

以上の要約に至るまでの過程を以下に残す。

目次

  1. MVPの実装。
  2. B-treeの利用とSQL発行回数削減によるパフォーマンス改善。
  3. SQL文変更による欠番への対処とページキャッシュを用いたパフォーマンスの維持。
  4. 排他処理を組み込んだいいね機能の実装。
  5. Docker化+PosgtreSQLの導入によるパフォーマンス改善の試み。

1. MVPの実装。

MVP(/datePlan/ のみ)の実装。First commit.

2. B-tree、SQL発行回数削減によるパフォーマンス改善。

Summary:
  Total:        436.5398 secs
  Slowest:      12.5233 secs
  Fastest:      8.5823 secs
  Average:      11.7876 secs
  Requests/sec: 4.2310
  
  Total data:   379574 bytes
  Size/request: 205 bytes

Response time histogram:
  8.582 [1]     |
  8.976 [1]     |
  9.370 [1]     |
  9.765 [3]     |
  10.159 [4]    |
  10.553 [4]    |
  10.947 [13]   |■
  11.341 [22]   |■
  11.735 [733]  |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  12.129 [896]  |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  12.523 [169]  |■■■■■■■■


Latency distribution:
  10%% in 11.6198 secs
  25%% in 11.6806 secs
  50%% in 11.7621 secs
  75%% in 11.9052 secs
  90%% in 12.1158 secs
  95%% in 12.1959 secs
  99%% in 12.3350 secs

Details (average, fastest, slowest):
  DNS+dialup:   0.0001 secs, 0.0000 secs, 0.0055 secs
  DNS-lookup:   0.0000 secs, 0.0000 secs, 0.0019 secs
  req write:    0.0000 secs, 0.0000 secs, 0.0005 secs
  resp wait:    11.7874 secs, 8.5823 secs, 12.5232 secs
  resp read:    0.0000 secs, 0.0000 secs, 0.0021 secs

Status code distribution:
  [200] 1847 responses

【現在のパフォーマンスの分析】

以上は合計アクセス数1847件、同時接続数50人の場合の負荷テストの結果である。 /datePlan APIのみを叩き続けるものであり、DBには100,000件のデータが入っている。

Requests/secで表されるスループットはおよそ4件。 また、Detailsのresp waitで表されるGoプログラムとDBの合計処理時間の合計はおよそ12秒であることがわかった。

単純にデータを一件返すだけのAPIであることを考えるとこの速度には改善の余地があると考えられる。

【ボトルネックの特定とその分析】

ここで、現在のSQL文に注目する。

SELECT id, title, content FROM datePlans ORDER BY RANDOM() LIMIT 1

ボトルネックになっているのは ORDER BY RANDOM() の部分であると考えられる。

この部分で何が起こっているのか述べる。まず、DBは対象のデータ群に対して[ランダムな数値|対象データ]という形で一つ一つにペアを作り、それをメモリ上に作ったB-Treeに放り込んでソートする(対象データが重い場合はランダムな数値のみがメモリに来る)。そして、一番値が小さい一件のみを抽出し、それを返す。

データ数を N とすれば、これは O(NlogN) の計算量がかかる。また、メモリ使用量も O(N) である。実際にはメモリにデータが乗り切らずストレージとスワップしながらソートする可能性もあり、さらなるパフォーマンスの悪化もありうる。

【ボトルネックの解決策とその結果】

改めて、ただ一件のみを抽出するためだけにこの計算量とメモリ使用量を消費するのは非効率である。

ただしこの速度をB-treeインデックスで解決することはできない。なぜならば並べ替えはランダムであり、既存のB-treeはランダムソートをスキップすることができないからだ。

したがってデータ自体を並べ替えるのではなく、id を一件ランダムに指定して一件のみDBから抽出するという方法が適切であると考える。

SQL文は以下のようになる。

SELECT COUNT(id) FROM datePlans

SELECT id, title, content FROM datePlans WHERE id = ?

一文目は現在の行数をチェックするもの。その行数を利用して、ランダムに対象となるIDを生成する。

二文目はそのIDに該当するデータを得るもの。

これらを反映して、同様に合計アクセス数2000件、同時接続数50人という条件で負荷テストを行った結果が以下である(一部項目省略)。

Summary:
  Total:        97.6657 secs
  Slowest:      5.2664 secs
  Fastest:      0.1082 secs
  Average:      2.4029 secs
  Requests/sec: 20.4780
  
  Total data:   411071 bytes
  Size/request: 205 bytes

Details (average, fastest, slowest):
  DNS+dialup:   0.0001 secs, 0.0000 secs, 0.0066 secs
  DNS-lookup:   0.0001 secs, 0.0000 secs, 0.0026 secs
  req write:    0.0000 secs, 0.0000 secs, 0.0004 secs
  resp wait:    2.4028 secs, 0.1082 secs, 5.2663 secs
  resp read:    0.0000 secs, 0.0000 secs, 0.0024 secs

Status code distribution:
  [200] 2000 responses

スループットは20件となり5倍のパフォーマンス。

平均処理時間は2.4秒となりこれも5倍のパフォーマンスである。

これは大きな改善であると言える。

これらSQL文の計算量は、一文目に対して O(N), 二文目に対して O(1) であり、パフォーマンスの伸びに対応していると言える。

【更なる最適化】

デートプランを取得するたびに行数を計算するのは非効率なため、それらの操作を切り離す必要がある。 今回は行数をglobal variableとして保持することでそれを解決した。

Summary:
  Total:        0.0723 secs
  Slowest:      0.0106 secs
  Fastest:      0.0000 secs
  Average:      0.0017 secs
  Requests/sec: 27644.4666
  
  Total data:   411410 bytes
  Size/request: 205 bytes

Details (average, fastest, slowest):
  DNS+dialup:   0.0000 secs, 0.0000 secs, 0.0023 secs
  DNS-lookup:   0.0000 secs, 0.0000 secs, 0.0014 secs
  req write:    0.0000 secs, 0.0000 secs, 0.0004 secs
  resp wait:    0.0016 secs, 0.0000 secs, 0.0085 secs
  resp read:    0.0000 secs, 0.0000 secs, 0.0005 secs

Status code distribution:
  [200] 2000 responses

スループット27000件、処理速度平均0.0016秒という結果になった。

行数の計算が一回きりになったため、パフォーマンスが大幅に向上したと考えられる。

3. SQL文変更による欠番への対処とページキャッシュを用いたパフォーマンスの維持。

更新と削除機能の追加を行なった。

その結果、今まで使っていたid の指定方法が使えなくなる。つまり、ランダムにidを指定していては削除されてしまったidを指定してしまう可能性があるのだ。

この問題の解決方法は二つ存在する。

  1. 成功するまでidをランダムに取得し続ける。

  2. idの周辺にある一番近い要素を指定する。

方法1の場合SQL文を多く発行しかねないため、一回の発行で済む方法2を採用する。

方法2の場合、初めに考えられるSQL文は以下のものである。

SELECT id, title, content FROM datePlans WHERE id >= ? ORDER BY id ASC LIMIT 1;

これは指定されたidと同じか、それより大きく一番近いidを持つ行を一つ抽出するSQL文である。

主キーであるidにはインデックスが貼られているため、このSQL文の場合そのB-treeを利用することができる。つまりメモリ上でソートする必要がなく、B-treeを走査する分の $O(logN)$ で走らせることができる。

具体的には、OSがストレージからメモリへ1ページ(SQLiteの場合4KB)コピーし、そのページ内に収まるデータから存在するデータを探し、あればそれをプログラムに返す。

「存在するデータ」というのは、DBからデータを削除した直後はすぐにその穴が他のデータに埋められるわけではなく、どこかの時点でデータが埋めにくるまでずっと穴のままなので、ページを取ってきても最初の方はデータが存在しないということがありうるのである。

ただしB-treeの方からは削除されたデータのidは削除されているため、削除されたデータをプログラムに返してしまうということはない。

また、どのページを取ってくるか決めているのもB-treeである。つまり、現在メモリにあるページ内の木を辿ると、そのページには「次に読むべきページはこれ」という情報が得られるので、次にそのページを撮ってきてまた読む、ということを目的のidが見つかるまで繰り返す。

次にエッジケースへの対処に移る。上記のSQL文が考慮していないのはid以上のデータが一切存在しないケースである。

その場合は以下のSQL文を発行することにする。

SELECT id, title, content FROM datePlans WHERE id <= ? ORDER BY id DSC LIMIT 1;

一つ目のSQLが失敗する場合というのは、つまり存在するidに対して指定されたidが大きすぎる場合のことである。

その場合、B-treeは指定できるページの中で最もそのidに近いページを指定し、それをメモリに持ってきて探し始める。例えば現在持っている最大idが 500, 指定されたidが 600 だとすれば、idが500 のデータが入っているページを持ってきて探し始める。

ただしもちろん対象のidは存在しないので、一つ目のSQLは失敗することになる。

ここで二つ目のSQLに移るが、注目すべきは二つ目のSQLはページキャッシュを利用できるということだ。

なぜならば一つ目のSQLがページを引っ張ってきたことで、そのページがメモリ上のキャッシュに保存されているからである。結果二つ目のSQLはI/O無しで、B-tree走査とページの中を探すだけで計算を終了できる。

したがって、このケースではSQL文を1つ発行しようと2つ発行しようと処理時間はほとんど変わらないと考えられる。

4. 排他処理を組み込んだいいね機能の実装。

人気デートプランランキングを作るために、いいね機能を実装することにする。

【メソッド選定】

HTTPの面で言えば、datePlan/{id}/like にPOSTメソッドを送ることで実装が可能である。

今の所いいねの数を増やすだけなので、リクエストボディは存在しないが、それでもGETメソッドは使ってはいけない。

GETメソッドは冪等性つまり何回使用してもリソースの内容が変わらないものでなければならないからだ。いいねの数を増やすというのは明確にリソース変更にあたる。

キャッシュもGETメソッドの冪等性を前提にして作られているので、もしGETでデータを加工しようとしても代わりにキャッシュが返ってきて何も起こらないということもありうる。

PUTメソッドやPATCHメソッドもPOSTメソッドには劣る。PUTメソッドはリソースの完全な書き換えを要求するため処理が重くなるし、PATCHメソッドもただいいねの数をインクリメントするだけにしては重い。

したがってPOSTメソッドのみが今回の選択肢となる。

【排他制御の必要性】

今までのデートプランの追加や削除は高トラフィックを想定していなかった。なぜならば、実装こそしていないがこれらの操作は自分が追加したデートプランに対してのみ可能であり、物理的に超高速で追加や削除をすることは不可能だからだ。

その一方で、いいね機能は高トラフィックの対象になりうる。不特定多数が一気にいいねを押すことが可能だからである。

したがって、高トラフィック下でもいいねの数がずれないように排他制御を実装する必要性がある。

【排他制御の実装1: Go上での操作】

初めはGoのコード上でいいね数をインクリメントするという手段を試みる。つまり、

指定idlikeを取得→likeをGo側でインクリメント→インクリメントした値をUPDATE

という流れである。

事前にこの手段で正常にAPIが作動することを確認しておく。

$ curl -X POST http://localhost:8080/datePlan/1/like
{"id":1,"like":1}


$ curl -X POST http://localhost:8080/datePlan/1/like
{"id":1,"like":2}


$ curl -X POST http://localhost:8080/datePlan/1/like
{"id":1,"like":3}

datePlan/{id}/likeというエンドポイントをPOSTメソッドで叩くことで正常にlikeの数が更新されていることがわかる。

【実装1の実験】

それでは、次にheyライブラリを使い、このAPIが高トラフィック上でも整合性を取れるか、また高パフォーマンスで動作するかを確認する。

$ hey -n 1000 -c 100 \
-m POST \
http://localhost:8080/datePlan/1/like

Status code distribution:
  [200] 5 responses
  [500] 995 responses

100人が同時に合計1000件アクセスするという実験を行なった(結果一部省略)。

すると、成功した(200 response)は僅か5件、他はサーバーエラーになっていることがわかる。

$ curl -X GET http://localhost:8080/datePlan/1
{"id":1,"title":"インドカレー屋に行く","content":"インドカレーは穏やかな色になるとなぜか辛くなる","category":"Indoor","like":"5"}

ただしlikeが5件であるから通過した分では一応データの整合性が取れていることがわかる。SQLiteがリクエストを失敗させたことで守ったということだ。

【サーバーエラーについて】

サーバーエラーのメッセージは以下のものである。

database is locked (5) (SQLITE_BUSY)

このエラーメッセージを理解するためには、SQLiteの特徴をある程度学ぶ必要がある。

以下、SQLite3 における File Locking と Concurrency の仕組みについての一次資料から得た情報である。

ーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーー

SQLiteはデータの整合性を担保するために、二つの概念を利用している。それはロックとジャーナルである。

【ロック】

まずSQLiteは読み書きしたいプロセスないしはスレッドにこれらのロック状態を付与する。

UNLOCKED: 誰もデータベースに触れておらず、またキャッシュもデータベースと一貫性が取れている状態。

SHARED: プロセスがデータベースを読み込んではいるが、だれも書き込んではいない状態。SHAREDロックは何個でも共存することができる。ただし、SHAREDロックがいる状態での他プロセスの書き込みは禁止されている。

RESERVED: プロセスが現在はDBを読み込んでいるだけだが、将来的に書き込むという意思表示をしている状態。ただ一つのみのRESERVEDロックが存在できる。SHAREDロックは何個でもRESERVEDロックと共存できる。

PENDING: RESERVEDロックを持っていたプロセスが読み込みが終わり、今書き込みたいと意思表示をしている状態。ただSHAREDロックがいる状態で書き込みはできないため、SHAREDロックを持ったプロセスの読み込みが終わるのを待っている状態である。

EXCLUSIVE: 全てのSHAREDロックが掃けて、データをまさに書き込む直前に取得するロック。いかなるロックとも共存することはできない。

なお、PENDINGロックやEXCLUSIVEロックを得てもプロセスはRESERVEDロックを保持し続ける。

【ジャーナル】

プロセスはRESERVEDロックを取得した直後、ジャーナルと呼ばれるファイルに変更前のDBの情報を保存しておく。無事に更新が終われば、EXCLUSIVEロックを解除する直前にジャーナルは削除される。

このジャーナルはサーバーが落ちたときなどの緊急事態に'hot'になり、SQLiteはこのhotなジャーナルを使って情報を復元する。

サーバーが落ちたこと自体をSQLiteは判断することはできない。SQLiteはサーバーが落ちている間の記憶はないからだ。

それではサーバーが落ちたときをどのように内部的には判断するのか。

それはRESERVEDロックとジャーナルの有無から判断される。

ジャーナルが存在するということは、つまりあるプロセスがDBの内容を変更しようとしていたということである。それにもかかわらずRESERVEDロックを持つプロセスが存在しないのはおかしい。つまりサーバーに不都合が生じたのだ、とSQLiteは判断するのである。

ーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーーー

これらの情報を念頭に置いた上で、次の引用(上の文献と同じ)について考える。

If the process that wants to write is unable to obtain a RESERVED lock, it must mean that another process already has a RESERVED lock. In that case, the write attempt fails and returns SQLITE_BUSY.

ここで言っているのは、もし書き込もうとしているプロセスがRESERVEDロックを取得できなかった場合、SQLITE_BUSYというメッセージが帰ってくるということだ。

これは僕が得たエラーメッセージと一致している(下再掲)。

database is locked (5) (SQLITE_BUSY)

つまりあまりにもリクエスト数が多すぎるあまりみんなでRESERVEDロックの権利を奪い合ってしまい、結局ほとんどのリクエストはRESERVEDロックを取得できず失敗してしまったということだ。

【改善案】

以上より、どうにかして各プロセスがRESERVEDロックを握っている状態を短くしなければならない。そうすることで各プロセスがRESERVEDロックを握るチャンスが増大する。

それを実現するための手段としては、Go上にわざわざデータを持って来ずにlikeを操作するというものが挙げられる。つまりDB上でlikeをインクリメントするのだ。

以下がDB上でlikeをインクリメントするためのSQL文である。

UPDATE datePlans SET "like" = "like" + 1 WHERE id = ? RETURNING "like"

このSQL文によるメリットは二つある。

一つはSQL文が二文から一文に減少することだ。初めの実装案ではGo側でインクリメントするためにid指定してlikeを持ってきていたが、この改善案ではDB上で直接インクリメントするので持ってくる必要がない。

こうすることで、「一文目でlikeを読み込むためにSHAREDロックを取得しようとしたけど他のプロセス(ここではスレッド)が二文目を実行するためにRESERVEDロックを持っていたせいで無理だった」というケースを防いでいる。

二つ目はGoとDBの間で値をやりくりする必要が無くなるということだ。プロセス間通信は大きなオーバーヘッドとなる。この改善案はidとlikeを一回ずつ受け渡しするだけであり、オーバーヘッドが少ない。したがって実行時間の削減につながる。

【改善案の実験と考察】

以上のSQL文をコードに組み込み、同様の条件(計アクセス数1000件、アクセス人数100人)で実験を行う。

$ hey -n 1000 -c 100 \
-m POST \
http://localhost:8080/datePlan/1/like

Status code distribution:
  [200] 1 responses
  [500] 999 responses

実験結果としては、むしろ悪化している。1件しか通っていない。ただ5件と1件は統計的に有意な差ではないと考えて良いだろう。

  $ curl -X GET http://localhost:8080/datePlan/1
{"id":1,"title":"インドカレー屋に行く","content":"インドカレーは穏やかな色になるとなぜか辛くなる","category":"Indoor","like":"1"}

通った一件に関してはやはり整合性は保持されている。

ここからわかることは、少しSQL文を改善した程度では高トラフィックには耐え難いということだ。

処理速度を多少改善したとしても、処理能力が向上するとは限らないことがわかった。

【改善案2】

改善案1の失敗を踏まえると、処理速度をむやみやたらに向上させるのではなく、おとなしくSQLiteの仕様に従って一個ずつリクエストを確実に捌けるような設定を適用するのが現実的な案であると考える。

処理速度と処理能力は比例するものではなく、しばしばトレードオフの関係にもなることがわかった。

設定すべき項目は2つ。

1つ目はSQLiteのタイムアウトの時間を伸ばすことだ。以下のように、Go側でdbを開くときに設定する。

db, err = sql.Open("sqlite", "datePlans.db?_busy_timeout=5000")

この_busy_timeout=5000が、「もしロックがかかっていて必要なロックが取得できなくても5秒間は待っておいてくださいね」という要求を表している。

2つ目はSQLiteへの接続数を1に固定することだ。以下のように、Go側でdbを開いた後に設定する。 (参照:Managing connections

db.SetMaxOpenConns(1)

これはGoがDBと通信しようとするときに、コネクション(通信数)の数を1に固定することで、同時に複数の読み込みや書き込みをすることを防ぐものである。

具体的には、db.Exec(query)の際にクエリをDBに送れるのは一度に1スレッド・プロセスのみであるということである。

なお、コネクションはデフォルトで使い回す設定になっているため、コネクションをスレッド同士でバケツリレーしているイメージで捉えれば良い。

コネクションがない状態でクエリを実行しようとすると、待ち行列と言われるキューにそのクエリが入れられ、コネクションが来るまで待つことになる。

これは一度に一回しか書き込めないSQLiteとはとても相性が良いものとなる。ただし読み込みでさえ一度に一回しかできないのはデメリットともなる。

加えて言うならば、2つ目の設定で通信数を絞っている以上、DB内でロック解除を待つことはないので1つ目の設定は必要ないのではないかと思われる。

しかし、実務では他のターミナルからDBを操作したり、他にも色々触ることが多いため、念の為どちらも設定しておくと言うのが鉄板らしい。

【改善案2の実験と考察】

以上の設定を反映し、あらためて同様の条件(総アクセス数1000、同時アクセス人数100)で実験を行う。

$ hey -n 1000 -c 100 \                        
-m POST \
http://localhost:8080/datePlan/1/like

Summary:
  Total:        0.2851 secs
  Slowest:      0.1576 secs
  Fastest:      0.0003 secs
  Average:      0.0234 secs
  Requests/sec: 3507.3895
  
  Total data:   19893 bytes
  Size/request: 19 bytes

Response time histogram:
  0.000 [1]     |
  0.016 [492]   |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  0.032 [248]   |■■■■■■■■■■■■■■■■■■■■
  0.047 [128]   |■■■■■■■■■■
  0.063 [69]    |■■■■■■
  0.079 [33]    |■■■
  0.095 [13]    |■
  0.110 [7]     |■
  0.126 [5]     |
  0.142 [1]     |
  0.158 [3]     |


Latency distribution:
  10%% in 0.0029 secs
  25%% in 0.0072 secs
  50%% in 0.0164 secs
  75%% in 0.0330 secs
  90%% in 0.0532 secs
  95%% in 0.0682 secs
  99%% in 0.1072 secs

Details (average, fastest, slowest):
  DNS+dialup:   0.0004 secs, 0.0000 secs, 0.0054 secs
  DNS-lookup:   0.0002 secs, 0.0000 secs, 0.0028 secs
  req write:    0.0000 secs, 0.0000 secs, 0.0010 secs
  resp wait:    0.0229 secs, 0.0003 secs, 0.1531 secs
  resp read:    0.0000 secs, 0.0000 secs, 0.0001 secs

Status code distribution:
  [200] 1000 responses

すべてのレスポンスに成功した。スループットも高く、およそ半数のリクエストが0.02秒以下に収まっており、良いパフォーマンスを発揮していると言える。

$ curl -X GET http://localhost:8080/datePlan/1
{"id":1,"title":"インドカレー屋に行く","content":"インドカレーは穏やかな色になるとなぜか辛くなる","category":"Indoor","like":"1000"}

likeの数も整合性が取れている。

ここからわかるのは、接続数を1本に絞ったとはいえ、この程度のリクエスト数なら高速で終わるということだ。

できるだけパフォーマンスを改善しようとするその前に、まず一番安全であるものを実装し、それをベンチマークとしてから改善に移るのが心理的・開発速度的に良いのかもしれないと考えた。

5. Docker化によるPosgtreSQLの導入によるパフォーマンス改善の試み。

SQLiteからPostgreSQLにDBを代替するため、プロジェクトのDocker化を行った。

コンテナはGoアプリとPostgreSQLの二つ。

SQLiteは前述したように書き込みする間は他のすべてのプロセスを排除する一方で、PostgreSQLはMVCCなる仕組みによって書き込みと読み取りが互いを排除しない設定になっている。

これによってあるデータを読み込みつつ他のデータを書き込むということが可能になる。

では、4と同様にlikeを高トラフィック下で変更したらどうなるだろうか。

$ hey -n 1000 -c 100 \
> -m POST \
> http://localhost:8080/datePlan/1/like

Summary:
  Total:        0.3581 secs
  Slowest:      0.2119 secs
  Fastest:      0.0003 secs
  Average:      0.0305 secs
  Requests/sec: 2792.8764
  
  Total data:   20368 bytes
  Size/request: 20 bytes

Latency distribution:
  10%% in 0.0018 secs
  25%% in 0.0064 secs
  50%% in 0.0180 secs
  75%% in 0.0424 secs
  90%% in 0.0807 secs
  95%% in 0.1079 secs
  99%% in 0.1534 secs

Details (average, fastest, slowest):
  DNS+dialup:   0.0004 secs, 0.0000 secs, 0.0058 secs
  DNS-lookup:   0.0002 secs, 0.0000 secs, 0.0036 secs
  req write:    0.0000 secs, 0.0000 secs, 0.0009 secs
  resp wait:    0.0300 secs, 0.0003 secs, 0.2064 secs
  resp read:    0.0000 secs, 0.0000 secs, 0.0001 secs

Status code distribution:
  [200] 966 responses
  [500] 34 responses

1000件中966件が成功、34件が失敗していることがわかる。

また、以下のエラーメッセージが得られた。

[::1]:5432 (localhost): server error: FATAL: sorry, too many clients already (SQLSTATE 53300)

つまりPostgreSQLのコネクションを使い果たしており、それによってデータ変更が拒否されている状態である。

これを改善するためには、やはりGoアプリ側でコネクション数を絞ってやる必要がある。

    db.SetMaxOpenConns(25)      // 同時に開く接続の最大数
    db.SetMaxIdleConns(25)      // アイドル状態で保持する接続数
    db.SetConnMaxLifetime(5 * time.Minute)  // 接続の最大生存時間

以上の表現を追加し、再び同じ実験を行う。

ちなみに、SetMaxIdleConnsは多ければ多いというものではなく、コネクションの数だけPostgre側でプロセスを保持する必要があるため、大きなサービスではSetMaxOpenConnsよりやや小さな値にすることでたとえば夜間でのプロセス量を減らしメモリ消費量を削減するといったことをするらしい。

$ hey -n 1000 -c 100 \                        
-m POST \
http://localhost:8080/datePlan/1/like

Summary:
  Total:        0.2465 secs
  Slowest:      0.1071 secs
  Fastest:      0.0002 secs
  Average:      0.0218 secs
  Requests/sec: 4057.5571
  
  Total data:   20967 bytes
  Size/request: 20 bytes

Response time histogram:
  0.000 [1]     |
  0.011 [303]   |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  0.022 [284]   |■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■■
  0.032 [203]   |■■■■■■■■■■■■■■■■■■■■■■■■■■■
  0.043 [99]    |■■■■■■■■■■■■■
  0.054 [56]    |■■■■■■■
  0.064 [27]    |■■■■
  0.075 [10]    |■
  0.086 [10]    |■
  0.096 [4]     |■
  0.107 [3]     |


Latency distribution:
  10%% in 0.0043 secs
  25%% in 0.0094 secs
  50%% in 0.0178 secs
  75%% in 0.0297 secs
  90%% in 0.0445 secs
  95%% in 0.0551 secs
  99%% in 0.0837 secs

Details (average, fastest, slowest):
  DNS+dialup:   0.0004 secs, 0.0000 secs, 0.0056 secs
  DNS-lookup:   0.0002 secs, 0.0000 secs, 0.0022 secs
  req write:    0.0000 secs, 0.0000 secs, 0.0009 secs
  resp wait:    0.0214 secs, 0.0002 secs, 0.1070 secs
  resp read:    0.0000 secs, 0.0000 secs, 0.0001 secs

Status code distribution:
  [200] 1000 responses

SQLiteに比べて大きくパフォーマンスが向上しているわけではないが、すべてのリクエストが通っている。

パフォーマンス向上が見られないのは、DBの種類に関わらず同じデータの書き換えはどのみち排除されるからであると考えられる。

About

簡単なAPIの性能改善をテーマにしたポートフォリオ。

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors