さくらコードSakura Code
小説/第8話

Novel

第8話 同期というライバル

第8話 同期というライバルのイメージイラスト

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ステップにせず、リスクごとに刻む
- 計測。数字で言えるようにする

 書き終えてから、桜子は、しばらくその三行を眺めていた。

 翔太の十分には、追いつけない。たぶん、これからも、追いつけない。けれど、翔太の十分が見つけられない山に、桜子の二日が辿り着く。それは、別の道だった。

 (同期は、ライバル)

 桜子は、自分の中で、もう一度、その四文字を確かめた。

 ライバルは、追いつかなきゃいけない相手じゃなくて、自分の道を選ばなきゃいけない理由をくれる、相手なのかもしれない。

 メモの最後に、もう一行だけ書いた。

- 中村くんに、追いつかなくていい
  追いついたフリをするより、別の山を、ちゃんと登る

 電車が駅に着いた。雨はもう、やんでいた。

 桜子は、傘を畳まずに鞄に入れたまま、ホームを歩いた。