0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Go言語におけるパフォーマンス最適化とスケーラブルな設計

0
Posted at

pprof・goroutine・gRPCで「速く、止まりにくい」サービスを作る

Goは軽量なgoroutineとシンプルな並行処理の仕組みを持ち、APIサーバーやマイクロサービスの実装に向いています。とはいえ、最初は快適に動いていたサービスでも、アクセス数やデータ量、連携先サービスが増えるにつれて、次のような問題が起こりがちです。

  • CPU使用率は高くないのにレスポンスが遅い
  • goroutine数やメモリ使用量が増え続ける
  • 一時的な外部サービスの遅延が、全体の障害につながる
  • gRPCを導入したものの、接続やタイムアウトの設計が曖昧
  • 負荷が増えたときに、どこを改善すべきか分からない

こうした問題に対して、最初から複雑な最適化を施す必要はありません。重要なのは、計測によってボトルネックを特定し、負荷を制御できる設計を少しずつ取り入れることです。

この記事では、Goでサービスをスケールさせるために押さえたい、プロファイリング、goroutine管理、gRPC設計の基本を解説します。

最適化の第一歩は「計測すること」

パフォーマンス改善では、「たぶんここが遅い」という推測だけでコードを変更しないことが大切です。Goにはpprofという標準のプロファイリング機能があり、CPU、メモリ、goroutine、ロック競合などを調査できます。

たとえばHTTPサーバーであれば、開発環境や内部ネットワーク限定の運用環境でnet/http/pprofを有効にできます。

package main

import (
	"log"
	"net/http"
	_ "net/http/pprof"
)

func main() {
	go func() {
		// 外部公開しないアドレスで待ち受ける
		log.Println(http.ListenAndServe("127.0.0.1:6060", nil))
	}()

	select {}
}

CPUプロファイルを30秒取得する場合は、次のように実行します。

go tool pprof http://127.0.0.1:6060/debug/pprof/profile?seconds=30

調査時には、用途ごとに見るべきプロファイルを切り替えましょう。

症状 まず確認したいもの
CPUが高い、処理が遅い CPUプロファイル
メモリが増える、GCが多い heapプロファイル
処理が終わらず待機している goroutineプロファイル
同時実行時だけ急に遅くなる mutex・blockプロファイル
全体の待ち時間の流れを見たい execution trace

特に重要なのは、最も時間を使っている処理を先に改善することです。たとえばJSON変換、ログ出力、正規表現、不要な文字列連結、DB問い合わせなどが原因であれば、細かなアルゴリズム改善よりも大きな効果を得られることがあります。

goroutineは無制限に増やさない

Goではgoroutineを簡単に起動できます。

go processTask(task)

しかし、リクエストごとに多数のgoroutineを起動し、さらに各goroutineがDBや外部API、別のgRPCサービスを呼ぶ設計では、負荷が高まったときに待機中の処理が積み上がります。その結果、メモリ使用量が増え、タイムアウトが増え、下流サービスにも負荷をかける悪循環に陥ります。

そこで有効なのが、チャネルを使って並列実行数を制限する方法です。

type WorkerPool struct {
	sem chan struct{}
}

func NewWorkerPool(maxConcurrent int) *WorkerPool {
	return &WorkerPool{
		sem: make(chan struct{}, maxConcurrent),
	}
}

func (p *WorkerPool) Do(ctx context.Context, fn func(context.Context) error) error {
	select {
	case p.sem <- struct{}{}:
		defer func() {
			<-p.sem
		}()
		return fn(ctx)

	case <-ctx.Done():
		return ctx.Err()
	}
}

利用側では、必ずcontext.WithTimeoutと組み合わせます。

ctx, cancel := context.WithTimeout(context.Background(), 800*time.Millisecond)
defer cancel()

err := pool.Do(ctx, func(ctx context.Context) error {
	return callExternalService(ctx)
})

この設計のポイントは、処理能力を超える負荷が来た場合に、無制限に仕事を抱え込まないことです。これは「処理を諦める」ためではなく、サービス全体を守るためのバックプレッシャーです。

gRPCでは接続の再利用とdeadlineが重要

gRPCはHTTP/2とProtocol Buffersを利用するRPCフレームワークです。Goのマイクロサービス間通信で採用されることも多く、型安全なインターフェースと効率的な通信が魅力です。

ただし、gRPCを使うだけで自動的に高性能になるわけではありません。よくある問題が、RPCのたびに接続を作ってしまうことです。

// 毎回Dialするのは避けたい例
conn, err := grpc.Dial("catalog-service:50051", opts...)

grpc.ClientConnはアプリケーション起動時などに作成し、クライアントを再利用する設計にします。

conn, err := grpc.DialContext(
	ctx,
	"catalog-service:50051",
	grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
	return err
}
defer conn.Close()

client := pb.NewCatalogServiceClient(conn)

なお、実運用ではTLSを利用し、insecure.NewCredentials()はローカル開発などに限定しましょう。

また、各RPCには必ずdeadlineを設定します。

ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()

res, err := client.GetProduct(ctx, &pb.GetProductRequest{
	Id: "product-123",
})

deadlineがないと、依存先が遅延・停止したときにリクエストが長時間残り続けます。サーバー側でもctx.Done()を監視し、クライアントが待つのをやめた処理は、できるだけ早く中断することが大切です。

Unary RPCとStreaming RPCは用途で使い分ける

gRPCには、1リクエスト・1レスポンスのUnary RPCと、継続的にデータを送受信できるStreaming RPCがあります。

Unary RPCは、商品情報取得、ユーザー更新、注文作成など、一般的なAPI操作に適しています。一方、リアルタイム通知、ログ転送、進捗配信、大量イベントの連続送信などではStreaming RPCが候補になります。

ただし、Streaming RPCは常に正解ではありません。長時間接続は負荷分散や障害復旧を複雑にすることがあります。そのため、「通信回数を減らせそうだから」という理由だけではなく、データが連続的に流れる業務要件があるかで選ぶべきです。

設計時には、次の観点を確認すると判断しやすくなります。

  • 通信は単発か、継続的か
  • クライアントが途中で切断した場合にどう復旧するか
  • 1本のストリームが長時間サーバー資源を占有しないか
  • メッセージごとの順序保証が必要か
  • 負荷分散や再接続をどのように扱うか

監視すべき指標を決めておく

改善後も、サービスの状態を継続して観測しなければ、次の負荷増加に対応できません。最低限、以下の指標を確認できるようにしておくとよいでしょう。

  • p50だけでなくp95・p99レイテンシ
  • gRPCのエラー率、deadline超過数、キャンセル数
  • goroutine数とメモリ使用量の推移
  • GC回数と割り当て量
  • DBや外部APIなど、依存先ごとの待ち時間
  • リトライ回数とキュー待ち時間

特にp99レイテンシは、一部の利用者が感じる「たまに非常に遅い」という問題を把握するのに役立ちます。平均値だけでは、混雑時のボトルネックを見逃してしまいます。

まとめ

Goでスケーラブルなサービスを作るためには、速そうな実装を書くことよりも、負荷が増えたときに壊れにくい仕組みを用意することが重要です。

  • pprofやトレースで、最初にボトルネックを測定する
  • goroutineや外部呼び出しの並列数を制御する
  • contextとdeadlineで、待ち続ける処理を防ぐ
  • gRPC接続は再利用し、通信パターンに応じてRPC方式を選ぶ
  • p99レイテンシや依存先の遅延を継続的に観測する

小さなサービスのうちからこれらの考え方を取り入れておくと、利用者や機能が増えたときにも、改善ポイントを見つけやすくなります。

さらに体系的に学びたい方へ

この記事では、Goにおける計測、並行処理の制御、gRPC接続・deadline設計といった実務で重要なポイントを扱いました。一方で実際の開発では、「この場面でWorker Poolは必要か」「Streaming RPCにするべきか」「プロファイル結果をどう設計改善につなげるか」といった判断が求められます。

こうした設計判断を、問題演習を通じて体系的に整理したい方には、学習リソースの一つとして Go言語上級:30問ドリルで学ぶ設計・gRPC・パフォーマンス最適化 講座 が参考になります。

Goの文法や基本的なWeb開発から一歩進み、性能・保守性・拡張性を踏まえて実装を選ぶ力を身につけたい場合に、この記事で扱った内容をさらに深掘りする選択肢として活用できます。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?