Script
Cold Open
シーン1 鈴木の依頼
七月の中ばの月曜日。梅雨明けの前ぶれの、湿った風が窓から入ってくる。空は、うっすらと曇り、ところどころに、ようやく、夏の青を、覗かせている。
朝礼のあと、鈴木が二人を呼んだ。
「桜子さん、翔太くん。ちょっと、お願い」
鈴木のノートPCには、運用中のSaaS「サクラサポート」の管理画面。お問い合わせ一覧のページが開かれている。
「ここ、最近、社内ユーザーから『重い』って声が来てる。原因切り分けと、改善案を、二日くらいで、二人で“別々に”出してほしい」
「別々に、ですか」
「うん。同じデータ、同じ環境を見て、二人がどんなアプローチをするか、見てみたいから。競争じゃなくて、見比べたい」
鈴木は、わざわざ「競争じゃなく」と言った。けれど、桜子の視界の隅で、翔太が、ほんの少しだけ、口角を上げていた。
(中村くんは、もう、走り出してる)
桜子は、ノートに「お問い合わせ一覧API、二日」と書きながら、手のひらの汗を、こっそりブラウスで拭いた。
Part A
シーン2 翔太の十分
席に戻って、桜子がブラウザでサクラサポートを開いたとき、翔太のターミナルは、すでに激しく動いていた。
ローカルの開発環境を立ち上げ、シードデータを投入し、Chromeのデベロッパーツールでネットワークを開く。一連の動きが、コンマ数秒の隙間もない。
桜子が、自分のPython環境でリポジトリを開き直しているあいだに、翔太は早くも、自分のメモに何かを書きつけ始めた。
「望月さん、これ、たぶん SELECT N+1 だ」
「N、プラス、1」
「うん。問い合わせ一覧で、各行ごとにユーザーと、ユーザーの所属部署を引いてる。だから件数が増えると、クエリ数が線形以上に増える」
桜子は、翔太の画面を覗かせてもらった。SQLログが、ずらりと並んでいる。
SELECT * FROM inquiries WHERE ... LIMIT 50;
SELECT * FROM users WHERE id = 12;
SELECT * FROM users WHERE id = 14;
SELECT * FROM users WHERE id = 7;
SELECT * FROM departments WHERE id = 3;
SELECT * FROM departments WHERE id = 1;
...確かに、一覧APIが一回呼ばれるごとに、SELECT users と SELECT departments が、ずらずらと走っている。
「これ、JOINで一発にすれば、だいたい速くなる。SQLAlchemy 側だと joinedload 一行追加するだけ」
「……早い」
「いや、SELECT N+1 は教科書の典型だから。すぐ気づく」
翔太の手は、ブランチを切り、joinedload を加え、ローカルでブラウザを再読み込み、レスポンスタイムが半分以下になることを確認し、即座に git push まで完了した。
時計を見ると、まだ朝礼から、十分経っていない。
シーン3 桜子の手書き
桜子は、ノートに「SELECT N+1」と書き写した。
(中村くんが言ったとおり、たぶん、それは正解)
でも、桜子は、もう一度、画面を上から下まで見た。
お問い合わせ一覧の画面には、上部に「未読件数」のバッジが大きく表示されている。サイドバーには、ステータス別の集計、優先度別の集計。フィルタを変えるたびに、これらも更新される。
(一覧の遅さって、本当に、JOINだけで終わる話なのかな)
桜子は、迷ったが、自分のやり方で、もう少し時間を取ることにした。
まず、APIの中で、どこに時間がかかっているのかを、自分の手で計測することから始める。
import time
start = time.perf_counter()
inquiries = list_inquiries(filter=...)
elapsed_query = time.perf_counter() - start
start = time.perf_counter()
unread = count_unread(user_id=current_user.id)
elapsed_unread = time.perf_counter() - start
start = time.perf_counter()
stats = aggregate_status(filter=...)
elapsed_stats = time.perf_counter() - start桜子は、これをローカルに仕込み、データ件数を百件、千件、一万件と変えて、フィルタ条件も「全件」「未読のみ」「特定ステータスのみ」と切り替えて、何度も計測した。結果を、スプレッドシートに書き写していく。
昼が過ぎた頃、翔太の画面では、PR #84 がレビュー待ちで止まっていた。コミットメッセージは、
perf: avoid N+1 by joinedload in list_inquiries翔太は、自分のPRが Approved を待つあいだ、もう次の機能の調査を始めている。
桜子のスプレッドシートは、まだ、半分しか埋まっていなかった。
Part B
シーン4 焦りと、桜色
十八時半。
桜子は、まだ計測の続きをしていた。データ件数一万、未読のみフィルタ。レスポンスタイムが、なぜか他より突出して長い。elapsedquery は速い。elapsedstats も普通。けれど elapsedunread が、突き抜けて重い。
(未読件数。これ、なんで重いんだろう)
原因の方向だけが、ぼんやり見えてきた。けれど、まだ言葉にできない。
PCの画面の右上で、退勤時間を示すバッジが、赤く点滅している。
ちょうどそのとき、遥が桜子の机の横に、すっと立った。
「桜子さん。今日、帰ろう」
「あ、でも、まだ計測が」
「うん。続きは、明日でいい」
遥の声は、強くはなかったが、ふしぎと逆らえなかった。
「中村くんは、もうPRを出してるんですよ。私が遅くて」
「うん。彼は速い。今日は、その事実だけで、いったん閉じよう。明日の桜子さんが、続きを引き継いだ方が、いい仕事になる」
「……はい」
「あと一つだけ言うね。桜子さんが今やってる計測、無駄じゃないから」
桜子は、うつむいたまま、ありがとうございます、と言った。
遥は、それ以上は言わずに、自分の席へ戻っていった。机の角に、桜色のキーホルダーが揺れた。桜子は、片付けを始めた。
帰宅電車。
桜子は、スマホで翔太の git log を眺めていた。コミットメッセージが、無駄なく、整っている。
perf: avoid N+1 by joinedload in list_inquiries
test: add benchmark for list_inquiries with 10k rows
docs: note SELECT N+1 mitigation in CONTRIBUTING.mdまだ一日目の夕方なのに、既にPR、テスト、ドキュメントの三つのコミットが揃っている。
(わたし、何してたんだろう)
窓の外、小雨が降り始めていた。電車の窓に、桜子の輪郭が、ぼんやり映る。
手のひらで、鞄の桜色のキーホルダーを、強く握った。プラスチックの薄い感触が、いつもより冷たい。
(追いつけない)
その四文字が、胸の奥で、灰色に、にじんだ。
シーン5 観察が見つけたもの
翌朝。
桜子は、いつもより少しだけ早く出社した。スプレッドシートの続き、データ件数一万・未読のみフィルタの計測ログを、もう一度、上から見直す。
ある場所で、目が止まった。
countunread の中身を、桜子はまだ自分の目で読んでいなかった。前提として、第三者がきれいに書いてくれていると思い込んでいた。
ファイルを開く。
def count_unread(user_id: int) -> int:
inquiries = Inquiry.query.all()
count = 0
for inq in inquiries:
if inq.assignee_id == user_id and inq.read_at is None:
count += 1
return count(あ)
Inquiry.query.all()。
全件、メモリにロードしている。一万件あれば、一万件全部。それを、Pythonでループして、自分宛で未読のものを数えている。
翔太の見つけた SELECT N+1 は、一覧APIそのものの問題で、確かに、JOINで解決する。けれど、桜子が見つけたこの未読カウンタは、別の場所で、まったく違う理屈で、線形以上の遅さを生んでいた。
(観察してた、よかった)
桜子は、ぐっと、腿の上で手を握った。
次に、cProfile を一度だけ走らせて、countunread が呼び出し回数のうえでもサーバの大半を食っていることを、改めて数字で押さえた。
ncalls tottime cumtime function
1 1.235 1.235 count_unread
1 0.082 0.082 list_inquiries桜子は、ふた呼吸して、ノートに、改善案を書き出した。
[改善案]
1. SELECT N+1 (一覧API)
翔太のPR #84 を支持。joinedload で解決。
→ 自分はレビュアーとして賛成し、計測値で裏取りを補強する。
2. 未読件数 (count_unread)
全件ロード+Pythonループ → SQLでCOUNTに置き換え + 結果をRedisにキャッシュ。
ステップ:
a) まず SELECT COUNT(*) WHERE assignee_id = ? AND read_at IS NULL に置換 (低リスク)
b) 高頻度な画面なので、Redisに10秒だけキャッシュ (中リスク)
c) 件数表示は許容遅延10秒以内が要件か、PMに確認(一個ずつ、段階に分ける)
桜子は、その三段の手順を、しばらく見ていた。赤いエラーで詰まった日に書いた障害報告のテンプレートと、どこか同じ匂いがあった。
Part C
シーン6 二人の解と、鈴木の評価
午後、桜子はPR #88 を上げた。タイトルは、
perf(measured): two-step plan for inquiries list latency
- step1: support N+1 fix in #84 with measurements
- step2: replace count_unread full-scan with COUNT + 10s cache (TBD)PR本文には、計測した数字が表になって貼られていた。データ件数別、フィルタ別、elapsedquery elapsedunread elapsedstats。
遥のコメントは、すぐに付いた。
@yamashita-haruka: 段階分けと、計測表、いいね。
step2 の許容遅延の確認は、鈴木さんに相談してから入れよう。
それまではキャッシュなしの COUNT 置換だけで先に出しても、価値が出る夕方、鈴木は、桜子と翔太を会議室に呼んだ。
「PR、どっちも見たよ。最初に言うね、ありがとう、二人とも」
翔太と桜子は、並んで、軽く頭を下げた。
「翔太くんの #84 は、十分で SELECT N+1 を当てて、JOIN一発で半分以下にしたね。これは、典型を即座に当てる強さ」
「はい」
「桜子さんの #88 は、表で計測値を出して、一覧の遅さの正体が、JOINだけじゃないって示した。countunread の発見は、たぶん、計測してなかったら見つからなかった」
桜子は、目線を、机の角に落とした。
「これ、優劣の話じゃないよ。たぶん、現場では、両方ある方が、強い。早く典型を当てる人と、観察で別の山を見つける人。両方いてくれて、お問い合わせ一覧は、ちゃんと速くなる」
「……はい」
「翔太くん、桜子さんの計測表、自分のPRに取り込もう。レビューの根拠資料として、入れておくと、未来の人が読みやすい。桜子さん、step2のキャッシュは、許容遅延を僕がプロダクトオーナーに確認して、来週着手で進めて」
「分かりました」
「了解です」
会議室を出るとき、翔太は、桜子に、半歩遅れて、ひとことだけ言った。
「望月さん、countunread、僕、コード読んでなかった」
「中村くんは、一覧APIをすぐに当てたから」
「うん。でも、たぶん、僕は、見つけられなかった。あれ」
翔太の声は、いつもより、ほんの少しだけ、低かった。
桜子は、何と返していいか分からず、ただ、
「ありがとう。#84 のレビューは、わたし、ちゃんと数字で支持しますね」
とだけ、答えた。
Ending
シーン7 帰り道、自分の強み
二十時前。
桜子は、帰宅電車に揺られていた。窓の外、湿った夜の街路樹が、雨の名残でぬれている。
メモ帳を開く。
いつもの「新人日記」のあとに、桜子は、新しい見出しを書いた。今日まで、書いたことのない見出し。
わたしの強み(仮)仮、と書いた。まだ、自分で言うのは、少し恥ずかしかった。
- 観察。気になったところに時間をかける
- 段階分割。1個の改善を1ステップにせず、リスクごとに刻む
- 計測。数字で言えるようにする書き終えてから、桜子は、しばらくその三行を眺めていた。
翔太の十分には、追いつけない。たぶん、これからも、追いつけない。けれど、翔太の十分が見つけられない山に、桜子の二日が辿り着く。それは、別の道だった。
(同期は、ライバル)
桜子は、自分の中で、もう一度、その四文字を確かめた。
ライバルは、追いつかなきゃいけない相手じゃなくて、自分の道を選ばなきゃいけない理由をくれる、相手なのかもしれない。
メモの最後に、もう一行だけ書いた。
- 中村くんに、追いつかなくていい
追いついたフリをするより、別の山を、ちゃんと登る電車が駅に着いた。雨はもう、やんでいた。
桜子は、傘を畳まずに鞄に入れたまま、ホームを歩いた。
