🔩 ねじき教室 Go と Web の教室

【課題 6】毎リクエストの固定費を削る

読了目安 約2分

全リクエストが必ず通る認証処理と、開発用のままになっている設定を見直します。

この章の目次

ハンドラごとの重い処理を削ったので、次は全リクエストが必ず通る場所を見ます。

課題

認証処理のプロファイルです。 CPU 全体の 9.2 % をここで使っています。

テキスト
ROUTINE ==== ...go.parseViewer
         0      1.98s (flat, cum)  9.21% of Total
         .       60ms    236:	cookie, err := c.Request().Cookie(cookieName)
         .      200ms    246:	keysrc, err := os.ReadFile(keyFilename)
         .      100ms    250:	key, _, err := jwk.DecodePEM(keysrc)
         .      700ms    255:	token, err := jwt.Parse(
         .      890ms    294:	tenant, err := retrieveTenantRowFromHeader(c)

該当するコードです。

Go
keyFilename := getEnv("ISUCON_JWT_KEY_FILE", "../public.pem")
keysrc, err := os.ReadFile(keyFilename)
if err != nil {
	return nil, fmt.Errorf("error os.ReadFile: keyFilename=%s: %w", keyFilename, err)
}
key, _, err := jwk.DecodePEM(keysrc)
if err != nil {
	return nil, fmt.Errorf("error jwk.DecodePEM: %w", err)
}
Go
var tenant TenantRow
if err := adminDB.GetContext(
	context.Background(), &tenant,
	"SELECT * FROM tenant WHERE name = ?", tenantName,
); err != nil {
	return nil, fmt.Errorf("failed to Select tenant: name=%s, %w", tenantName, err)
}

サーバーの起動部分です。

Go
e := echo.New()
e.Debug = true
e.Logger.SetLevel(log.DEBUG)

e.Use(middleware.Logger())
e.Use(middleware.Recover())

リクエストごとに繰り返す必要がないものを 4 つ挙げてください。


解答例

1. 公開鍵の読み込みとパース

public.pem の中身はプロセスが動いている間ずっと同じです。 sync.Once で最初の 1 回だけにします。

Go
var (
	jwtKeyOnce sync.Once
	jwtKey     any
	jwtKeyErr  error
)

func jwtPublicKey() (any, error) {
	jwtKeyOnce.Do(func() {
		keyFilename := getEnv("ISUCON_JWT_KEY_FILE", "../public.pem")
		keysrc, err := os.ReadFile(keyFilename)
		if err != nil {
			jwtKeyErr = fmt.Errorf("error os.ReadFile: keyFilename=%s: %w", keyFilename, err)
			return
		}
		key, _, err := jwk.DecodePEM(keysrc)
		if err != nil {
			jwtKeyErr = fmt.Errorf("error jwk.DecodePEM: %w", err)
			return
		}
		jwtKey = key
	})
	return jwtKey, jwtKeyErr
}

2. e.Debug = true

true にすると、レスポンスの JSON を人が読みやすいように整形します。 第 6 章のプロファイルに出ていた encoding/json.appendIndent がこの処理です。 ベンチマーカーは見た目を気にしません。

Go
e.Debug = false
e.Logger.SetLevel(log.ERROR)

log.OFF まで下げると、errorResponseHandler が出す内部エラーのログも消えます。 落ちたときに原因を追えなくなるので、ERROR は残します。

3. middleware.Logger()

全リクエストのログを 1 行ずつ書き出します。 アクセスログは nginx 側でも取っているので、二重です。

Go
// e.Use(middleware.Logger()) を消す
e.Use(middleware.Recover())

4. テナントの引き直し

tenant テーブルは、行が追加されるだけで更新されません。 一度引いたらメモリに置いておけます。

Go
var (
	tenantCacheMu sync.RWMutex
	tenantCache   = map[string]*TenantRow{}
)

func lookupTenantCache(name string) (*TenantRow, bool) {
	tenantCacheMu.RLock()
	defer tenantCacheMu.RUnlock()
	t, ok := tenantCache[name]
	return t, ok
}

/initialize はテナントを作り直すので、そのときにキャッシュも捨てます。 これを忘れると、消えたはずのテナントを返して失格します。

Go
func initializeHandler(c echo.Context) error {
	out, err := exec.Command(initializeScript).CombinedOutput()
	if err != nil {
		return fmt.Errorf("error exec.Command: %s %e", string(out), err)
	}
	resetTenantCache()
	// ...
}

おまけ: すでに手元にある値

ランキング取得には、こんなコードが残っています。

Go
var tenant TenantRow
if err := adminDB.GetContext(ctx, &tenant, "SELECT * FROM tenant WHERE id = ?", v.tenantID); err != nil {
	return fmt.Errorf("error Select tenant: id=%d, %w", v.tenantID, err)
}

引いた tenant から使うのは tenant.ID だけです。 その値は引数の v.tenantID と同じものです。 クエリごと消せます。

効果

スコア
取る量を減らした38522
公開鍵・Debug・Logger41693
テナントの引き直しをやめた44139

1 回あたりは小さくても、全リクエストが通る場所は回数で効いてきます。