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

【課題 3】N+1 を消したのに速くならない

読了目安 約3分

教科書どおりの N+1 を 2 か所直して、スコアがほぼ動かなかった回の記録です。

この章の目次

インデックスが効いたので、次はコードを読みます。

課題

ランキング取得の中心部分です。

Go
pss := []PlayerScoreRow{}
if err := tenantDB.SelectContext(
	ctx,
	&pss,
	"SELECT * FROM player_score WHERE tenant_id = ? AND competition_id = ? ORDER BY row_num DESC",
	tenant.ID, competitionID,
); err != nil {
	return fmt.Errorf("error Select player_score: %w", err)
}
ranks := make([]CompetitionRank, 0, len(pss))
scoredPlayerSet := make(map[string]struct{}, len(pss))
for _, ps := range pss {
	if _, ok := scoredPlayerSet[ps.PlayerID]; ok {
		continue
	}
	scoredPlayerSet[ps.PlayerID] = struct{}{}
	p, err := retrievePlayer(ctx, tenantDB, ps.PlayerID)
	if err != nil {
		return fmt.Errorf("error retrievePlayer: %w", err)
	}
	ranks = append(ranks, CompetitionRank{
		Score:             ps.Score,
		PlayerID:          p.ID,
		PlayerDisplayName: p.DisplayName,
		RowNum:            ps.RowNum,
	})
}

参加者の詳細を返すハンドラです。 p は、URL で指定された参加者の行です。

Go
cs := []CompetitionRow{}
if err := tenantDB.SelectContext(
	ctx, &cs,
	"SELECT * FROM competition WHERE tenant_id = ? ORDER BY created_at ASC", v.tenantID,
); err != nil && !errors.Is(err, sql.ErrNoRows) {
	return fmt.Errorf("error Select competition: %w", err)
}

pss := make([]PlayerScoreRow, 0, len(cs))
for _, c := range cs {
	ps := PlayerScoreRow{}
	if err := tenantDB.GetContext(
		ctx, &ps,
		"SELECT * FROM player_score WHERE tenant_id = ? AND competition_id = ? AND player_id = ? ORDER BY row_num DESC LIMIT 1",
		v.tenantID, c.ID, p.ID,
	); err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			continue
		}
		return fmt.Errorf("error Select player_score: %w", err)
	}
	pss = append(pss, ps)
}

psds := make([]PlayerScoreDetail, 0, len(pss))
for _, ps := range pss {
	comp, err := retrieveCompetition(ctx, tenantDB, ps.CompetitionID)
	if err != nil {
		return fmt.Errorf("error retrieveCompetition: %w", err)
	}
	psds = append(psds, PlayerScoreDetail{
		CompetitionTitle: comp.Title,
		Score:            ps.Score,
	})
}

この 2 つのコードには、ループの中のクエリが合わせて 3 か所あります。 どこか、そしてどう直すかを答えてください。


解答例

ランキング側

retrievePlayer が、重複を除いた参加者の数だけ呼ばれています。 参加者を先にまとめて引いて、map から取り出します。

Go
pls := []PlayerRow{}
if err := tenantDB.SelectContext(
	ctx, &pls, "SELECT * FROM player WHERE tenant_id = ?", tenant.ID,
); err != nil {
	return fmt.Errorf("error Select player: %w", err)
}
playerByID := make(map[string]PlayerRow, len(pls))
for _, p := range pls {
	playerByID[p.ID] = p
}

参加者詳細側

大会ごとに 1 クエリ、さらにスコアごとに大会を引き直しています。 後者はとくに無駄で、大会の情報は cs としてすでに手元にあります。

スコアは 1 クエリにまとめられます。 row_num の降順に並べれば、大会ごとに最初に現れた行が最新のスコアです。

Go
rows := []PlayerScoreRow{}
if err := tenantDB.SelectContext(
	ctx, &rows,
	"SELECT competition_id, score, row_num FROM player_score WHERE tenant_id = ? AND player_id = ? ORDER BY row_num DESC",
	v.tenantID, p.ID,
); err != nil {
	return fmt.Errorf("error Select player_score: %w", err)
}
scoreByCompetitionID := make(map[string]int64, len(rows))
for _, ps := range rows {
	if _, ok := scoreByCompetitionID[ps.CompetitionID]; ok {
		continue
	}
	scoreByCompetitionID[ps.CompetitionID] = ps.Score
}

// 大会のタイトルは既に cs にあるので、引き直さない
psds := make([]PlayerScoreDetail, 0, len(cs))
for _, comp := range cs {
	score, ok := scoreByCompetitionID[comp.ID]
	if !ok {
		continue
	}
	psds = append(psds, PlayerScoreDetail{
		CompetitionTitle: comp.Title,
		Score:            score,
	})
}

この書き換えには、第 3 章とは別のインデックスが要ります。 competition_id で絞らなくなったからです。

SQL
CREATE INDEX idx_ps_player_row
  ON player_score (tenant_id, player_id, row_num, competition_id, score);

第 3 章と同じく、初期化スクリプトと 10_schema.sql の両方に足します。

効果

スコア
インデックスを足した10319
ランキングの N+1 を消した10434
参加者詳細の N+1 を消した10105

動きませんでした。 3 回の走行のばらつきの範囲です。

なぜ効かないのか

Part 6 では、N+1 の害は DB への往復が N 回起きることだと説明しました。 ここでの往復先は SQLite です。

SQLite を使うと、アプリケーションが直接ストレージ上のファイル(.dbや.sqliteなど)を読み書きします。 ネットワークもプロセス間通信もしないため、データベースの操作にかかる時間が桁違いに少なく、回数を減らしても合計はあまり減りません。

ただし、このことことだけが理由だとは決められません。 ランキング側は N+1 を消すと同時に、テナントの参加者を全員取る SELECT * に変えています。 減った往復のぶんを、増えた読み出しが打ち消した可能性が残ります。

この全件取得は第 6 章で pprof に出てきます。 30 秒のプロファイルのうち 3.06 秒を、この 1 行が使っていました。 切り分けるなら、必要な参加者 ID だけを IN でまとめて取る形にして測り直します。

教訓

定石を当てて効かなかったときは、そこで粘らずに計測へ戻ります。 「N+1 だから直すべき」ではなく、「計測がここを指しているから直す」の順です。

次に見るのは、計測がずっと指し続けているのに手をつけていない場所です。 REPLACE INTO id_generator が 17914 回走っています。