コンテンツにスキップ

パフォーマンス

Popcorn Web はセッション、CSRF、セキュリティヘッダ、リクエスト ID といった雑事を 既定で引き受けます。そのぶん遅くなるかというと、そうでもありません。todo の ベンチマークでは、手書きの net/http 実装よりも少ない CPU 時間でリクエストを 返しました。

もちろん、この順位はアプリケーションとマシンで入れ替わります。以下の数字は性能を 保証するものではなく、どのレイヤーから調べればいいかを決めるための目安です。

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 のリンカが解決できなかったためです。どちらも ビルドタグ一覧の話で、このページで決めることではありません。

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 本、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 は両方の半分に対して走らせることになります。

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 “本番リリース前に確認する設定”

コードを細かく最適化するより、開発用の設定を本番に持ち込まないことのほうが効きます。

config.dev.toml はデバッグログと SQL の記録を有効にしています。負荷試験と本番では APP_ENV=prod のように本番用の設定を選び、クエリログが切れていることを確かめて ください。調査のために一時的に上げた詳細ログも、終わったら戻します。

通常の本番ログを増やさずに遅いクエリを追う方法は クエリ診断にあります。

cookie バックエンドは外部ストレージを必要としません。暗号処理も、典型的な セッションならリクエストあたり 0.5 µs 程度です。受信レコードの開封と送信レコードの 封緘を合わせて、256 バイトで 0.45 µs、1 KB で 0.73 µs でした。ただしセッション レコードそのものをレスポンスで送り、ブラウザが次のリクエストで送り返すので、 レコードが大きくなるほど通信量が増えます。

rdbredisdynamofirestore なら、ブラウザに持たせるのは小さな識別子 だけです。通信量は抑えられますが、代わりにリクエストのたびにストレージへ問い合わせが 入ります。

[session]
enabled = true
backend = "rdb" # または redis, dynamo, firestore

小さなセッションを依存なしで扱いたいなら cookie、失効・永続化・サイズ・通信量を サーバー側で握りたいなら外部バックエンドが候補になります。性能だけで決めずに運用上の 要件で絞り、実際のセッションサイズとストレージのレイテンシで測ってください。 dev-volatile は再起動ですべて消える開発専用のバックエンドなので、本番では使えません。

違いの詳細はセッションにあります。

本番では接続を 1 つに絞る必要はなく、書き込み用と読み取り用を複数設定できます。 同じ group に読み取り接続をいくつも置けば、そのグループの中でラウンドロビンします。

[middleware.rdb]
enabled = true
default_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 = true
max_open_conns = 20
[[middleware.rdb.connections]]
group = "reader"
dsn = "postgres://app:${DB_PASSWORD}@reader-2.example/app"
readonly = true
max_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 本のあいだだけ コネクションを押さえ、周りの BEGINCOMMIT を払いません。プールをリクエスト単位 ではなくステートメント単位でサイズしても足りるのは、そのためでもあります。境界が 現れるのは pw.Transaction を書いた場所だけなので、トランザクションが背負う選択—— 分離レベル、読み取り専用にしてレプリカに引き受けさせるかどうか、遅い外部呼び出しに対して コミットを前に置くか後に置くか——は、どれが効くかを知っているコードの側に残ります。 クエリを参照してください。

レスポンス圧縮は既定で無効です。CDN やリバースプロキシが圧縮していない場合にだけ、 レスポンス圧縮を見ながら有効にします。

CSRF チェックはミドルウェアの中では目立つ処理ですが、上の計測でも 2.1 µs です。 除外していいのは、ベアラートークンで認証していて、ブラウザからクッキー付きで 呼ばれない API だけだと考えてください。この程度の時間のために保護範囲を広く外す 価値はありません。

[security.csrf]
enabled = true
include = ["/**"]
exclude = ["/api/**"]

比較するときは本番相当の設定を使い、同じ機能、同じレスポンス、同じデータで測ります。 知りたいことに測り方を合わせてください。ユーザーの待ち時間ならレイテンシ、収容力なら スループット、コード上の処理量なら CPU プロファイルです。

プロファイルで候補が絞れたら、そのレイヤーだけを差し替えてもう一度測ります。HTTP スタック自体が限界だと確かめられたなら、2つ目のトランスポートはビルドフラグ1つです。 何がかかって何が買えないかは、上のビルドターゲットにあります。