パフォーマンス
Popcorn Web はセッション、CSRF、セキュリティヘッダ、リクエスト ID といった雑事を
既定で引き受けます。そのぶん遅くなるかというと、そうでもありません。todo の
ベンチマークでは、手書きの net/http 実装よりも少ない CPU 時間でリクエストを
返しました。
もちろん、この順位はアプリケーションとマシンで入れ替わります。以下の数字は性能を 保証するものではなく、どのレイヤーから調べればいいかを決めるための目安です。
ビルドターゲット
Section titled “ビルドターゲット”1 つのソースから、3 通りの出し方があります。既定はホスト Go の net/http で、
この先に自分の事情が出てこないなら、それを選んでおけば大丈夫です。
pw build --backend fasthttp は、同じソースを fasthttp 向けにコンパイルします。
モードを切り替えるのではなく、ビルドが 2 つある、と考えてください。あなたが書いた
net/http のコードから pw generate がハンドラとバインダーとルート登録を作り、
どちらの半分をコンパイルするかはビルドタグが選びます。できあがったバイナリに
net/http のランタイムは 1 バイトも入りません。3 つめの TinyGo は、速さではなく
サイズのために選ぶものです。
| ビルド | net/http | fasthttp |
|---|---|---|
go build |
15.8 MiB | 15.5 MiB |
go build -ldflags="-s -w" |
9.9 MiB | 9.6 MiB |
tinygo build |
4.2 MiB | 5.6 MiB |
tinygo build -no-debug |
4.2 MiB | 5.6 MiB |
測ったのは Apple M3 上の
examples/helloworld
です。何を測ったかが効いていて、この数字のほとんどは同梱した SQLite が占めています。
2 つの列の差のうちトランスポートによるものは 300 KiB ほどしかないので、バイナリを
小さくしたいという理由で乗り換えても意味がありません。TinyGo なら話は別です。
strip したホストビルドの半分以下になります。
-no-debug がここで何も減らさないのは、TinyGo ではなく macOS の事情です。リンク後の
Mach-O には DWARF が最初から入っていません。デバッグ情報はオブジェクトファイルの側に
残るので、このフラグには削るものが無いわけです。フラグそのものが役立たずだと読まないで
ください。次の WASI の表では、このページで一番効くレバーになります。
最下段は、横ではなく 1 つ上の行と読み比べてください。ホスト Go では fasthttp が
わずかに小さいのに、TinyGo では 1.4 MiB 大きくなります。fasthttp ビルドが
置き換えではなく積み増しだからです。フォーク自身が net/http を import しているので
それは依然としてリンクされ、上に brotli、zlib、ルータ、WebSocket アップグレーダ、
SOCKS プロキシのダイアラが乗ります。ホスト Go のリンカはその大半を捨ててくれますが、
TinyGo のリンカはもっと多く残します。
つまり、既定から離れる 2 つの理由は、この表の上では逆を向いています。両方を欲張ると、 リクエストあたりのコストは最小、TinyGo バイナリは最大という組み合わせになります。
ネットワーク越しのデータベースドライバをリンクするなら、TinyGo には
-scheduler=threads が必要です。また tinygodriver は v1.2.4 より前だと fasthttp を
そもそもリンクできませんでした。zstd のデコーダが arm64 のアセンブリを呼んでいて、
TinyGo のリンカが解決できなかったためです。どちらも
ビルドタグ一覧の話で、このページで決めることではありません。
WASI では -no-debug がすべて
Section titled “WASI では -no-debug がすべて”tinygo build -target=wasip1 を使えば、同じアプリケーションがそのまま動くモジュールが
出てきます。そしてここでは、さっきのフラグが誤差では済みません。
tinygo build -target=wasip1 |
net/http | fasthttp |
|---|---|---|
| そのまま | 7.6 MiB | 13.4 MiB |
-no-debug |
2.9 MiB | 3.8 MiB |
wasm モジュールは DWARF をカスタムセクションとして自分の中に抱えます。落とせば
net/http ビルドで 62%、fasthttp ビルドで 72% 減ります。wasip2 はどちらも 0.1 MiB
以内に収まります。ブラウザ向けの -target=wasm はビルドできません。net/http の
JavaScript トランスポートが TinyGo でコンパイルを通らないからですが、サーバ用途で
必要になるものでもありません。
ここから言えることが 2 つあります。まず、**WASI の成果物には必ず -no-debug を
付けてください。**このページの他の選択を全部足すより効きますし、2.9 MiB といえば
ネイティブの TinyGo バイナリより小さいことになります。もう 1 つ、fasthttp の
割り増しがほとんど消えます。素の列から読める 5.8 MiB ではなく、0.9 MiB です。
フォークが足していたものの大半は、フォーク自身のデバッグ情報だったわけです。
そのモジュールをどこで動かすかは サーバーレスにあります。
リクエスト 1 本あたりの差
Section titled “リクエスト 1 本あたりの差”トランスポートの差が一番大きく出るのは、ほかに何も起きていないときです。そして そこは、その数字が一番役に立たない場所でもあります。
| リクエスト 1 本、8 goroutine | net/http | fasthttp |
|---|---|---|
| JSON レスポンス、ソケットなし | 1.8 µs, 21 allocs | 0.8 µs, 2 allocs |
| HTML ページ、ソケットなし | 2.5 µs, 39 allocs | 1.2 µs, 16 allocs |
| JSON レスポンス、ループバック経由 | 10.1 µs | 9.1 µs |
| 同上、1 ms のクエリを挟む | 172 µs | 167 µs |
時間よりもアロケーション数を見てください。こちらは決定的で、マシンが混んでいても 動きません。21 対 2 という差が実体で、プールされたリクエスト値が買っているのは そこです。
判断に使うのは下の 2 行です。ループバックのソケットは両者に同じだけかかるので、
比は 2 倍から 1 割程度まで縮みます。データベースのクエリを 1 本挟めば、差は誤差に
なります。helloworld のページ全体、つまりテンプレートの描画と SQLite への書き込みを
測ると、ホスト Go の net/http が 335 µs、ホスト Go の fasthttp が 344 µs、TinyGo の
net/http が 369 µs でした。同じものを 3 回測った、という以上のことは言えません。
乗り換える理由になるのは、本当にトランスポートが律速している負荷でのアロケーション
プロファイルか、TinyGo がイメージサイズにしてくれることです。データベースを叩く
ページが速くなることを期待して切り替えるのはやめてください。それに、2 つめのビルドを
持つコストとも見合わせてください。ハンドラはビルドタグで切り分けられるファイルに
置く必要がありますし、pw generate は両方の半分に対して走らせることになります。
レイヤーごとの処理時間
Section titled “レイヤーごとの処理時間”examples/todo に
20 並列で負荷をかけ、リクエスト 1 本あたりの CPU 時間を測りました。環境は Go 1.26.5、
Apple M3、同じマシンの Docker で動く PostgreSQL 17 です。
| 1 リクエストあたり | CPU 時間 |
|---|---|
| ミドルウェア全体 | 3.0 µs |
| └ CSRF チェック | 2.1 µs |
50 行を返す SELECT 1 回 |
39 µs |
| JSON のエンコードと書き出し | 36 µs |
| HTML の描画と書き出し | 100 µs |
| リクエスト全体(Popcorn Web) | 166 µs |
リクエスト全体(net/http の比較実装) |
219 µs |
JSON と HTML は別々のレスポンスなので、各行を足して全体になる表ではありません。 読むべきは桁です。ミドルウェア全体が数 µs なのに対して、単純なクエリでも数十 µs、 HTML の生成はさらにかかっています。データベースがネットワークの向こうにあれば、 クエリは数百 µs から数 ms になることもあります。
最後の 2 行の差は、上に並んだレイヤーの差だけでは説明がつきません。残りはシステム
コールに消えています。html/template はテンプレートを歩きながら、値ひとつごとに
ResponseWriter へ書き出します。比較実装は CPU の 4 分の 3 を write の中で使って
いました。一方、生成されたコンポーネントはいったんバッファに描画して、組み上がった
文書を 1 回で渡します。この行が表に無いのは、それがレイヤーではないからです。上の
レイヤーがソケットに届くまでの、経路の違いです。
まずは、自分のアプリケーションで時間を使っているレイヤーを測ってください。数 µs の ミドルウェアを外すのは、そこが本当にボトルネックだと分かってからで十分です。
本番リリース前に確認する設定
Section titled “本番リリース前に確認する設定”コードを細かく最適化するより、開発用の設定を本番に持ち込まないことのほうが効きます。
実行環境とログ
Section titled “実行環境とログ”config.dev.toml はデバッグログと SQL の記録を有効にしています。負荷試験と本番では
APP_ENV=prod のように本番用の設定を選び、クエリログが切れていることを確かめて
ください。調査のために一時的に上げた詳細ログも、終わったら戻します。
通常の本番ログを増やさずに遅いクエリを追う方法は クエリ診断にあります。
セッションバックエンド
Section titled “セッションバックエンド”cookie バックエンドは外部ストレージを必要としません。暗号処理も、典型的な
セッションならリクエストあたり 0.5 µs 程度です。受信レコードの開封と送信レコードの
封緘を合わせて、256 バイトで 0.45 µs、1 KB で 0.73 µs でした。ただしセッション
レコードそのものをレスポンスで送り、ブラウザが次のリクエストで送り返すので、
レコードが大きくなるほど通信量が増えます。
rdb、redis、dynamo、firestore なら、ブラウザに持たせるのは小さな識別子
だけです。通信量は抑えられますが、代わりにリクエストのたびにストレージへ問い合わせが
入ります。
[session]enabled = truebackend = "rdb" # または redis, dynamo, firestore小さなセッションを依存なしで扱いたいなら cookie、失効・永続化・サイズ・通信量を
サーバー側で握りたいなら外部バックエンドが候補になります。性能だけで決めずに運用上の
要件で絞り、実際のセッションサイズとストレージのレイテンシで測ってください。
dev-volatile は再起動ですべて消える開発専用のバックエンドなので、本番では使えません。
違いの詳細はセッションにあります。
データベース接続
Section titled “データベース接続”本番では接続を 1 つに絞る必要はなく、書き込み用と読み取り用を複数設定できます。
同じ group に読み取り接続をいくつも置けば、そのグループの中でラウンドロビンします。
[middleware.rdb]enabled = truedefault_group = "reader"write_group = "writer"migration_group = "writer"
[[middleware.rdb.connections]]group = "writer"dsn = "postgres://app:${DB_PASSWORD}@writer.example/app"max_open_conns = 20
[[middleware.rdb.connections]]group = "reader"dsn = "postgres://app:${DB_PASSWORD}@reader-1.example/app"readonly = truemax_open_conns = 20
[[middleware.rdb.connections]]group = "reader"dsn = "postgres://app:${DB_PASSWORD}@reader-2.example/app"readonly = truemax_open_conns = 20この設定なら、グループを指定しないクエリは reader へ行きます。読み取りしかしない
ハンドラはそのまま既定のグループに流して構いません。ただし Popcorn Web は SQL の
中身を見て接続先を推測したりはしないので、書き込む側には手を入れる必要があります。
// 単独の書き込みuser, err := queries.CreateUser(pw.SelectDB(r, "writer"), name)
// 書き込みトランザクションerr := pw.TransactionContext(pw.SelectDB(r, "writer"), func(ctx context.Context) error { return queries.RecordAudit(ctx, "user.created")})書き込んだ直後の値を読むなど、レプリカの反映を待てない読み取りも writer へ
向けてください。接続グループの詳しい動きは
リレーショナルデータベースにあります。
プールの上限は接続ごとの設定です。max_open_conns を増やす前に、全接続の上限を
足してアプリケーションのインスタンス数を掛け、データベース側の上限に収まるかを
確かめてください。
そのプールをサイズする前に、このフレームワークが取っていないコストを 1 つ知っておく
価値があります。リクエストが届いてもトランザクションは開きません。1 行読むだけ、
1 行書くだけのハンドラは、ステートメントを 1 本走らせ、その 1 本のあいだだけ
コネクションを押さえ、周りの BEGIN と COMMIT を払いません。プールをリクエスト単位
ではなくステートメント単位でサイズしても足りるのは、そのためでもあります。境界が
現れるのは pw.Transaction を書いた場所だけなので、トランザクションが背負う選択——
分離レベル、読み取り専用にしてレプリカに引き受けさせるかどうか、遅い外部呼び出しに対して
コミットを前に置くか後に置くか——は、どれが効くかを知っているコードの側に残ります。
クエリを参照してください。
圧縮と CSRF
Section titled “圧縮と CSRF”レスポンス圧縮は既定で無効です。CDN やリバースプロキシが圧縮していない場合にだけ、 レスポンス圧縮を見ながら有効にします。
CSRF チェックはミドルウェアの中では目立つ処理ですが、上の計測でも 2.1 µs です。 除外していいのは、ベアラートークンで認証していて、ブラウザからクッキー付きで 呼ばれない API だけだと考えてください。この程度の時間のために保護範囲を広く外す 価値はありません。
[security.csrf]enabled = trueinclude = ["/**"]exclude = ["/api/**"]アプリケーションを測る
Section titled “アプリケーションを測る”比較するときは本番相当の設定を使い、同じ機能、同じレスポンス、同じデータで測ります。 知りたいことに測り方を合わせてください。ユーザーの待ち時間ならレイテンシ、収容力なら スループット、コード上の処理量なら CPU プロファイルです。
プロファイルで候補が絞れたら、そのレイヤーだけを差し替えてもう一度測ります。HTTP スタック自体が限界だと確かめられたなら、2つ目のトランスポートはビルドフラグ1つです。 何がかかって何が買えないかは、上のビルドターゲットにあります。
