記事一覧クエリの最適化(content 除外 + 複合インデックス追加)

起票理由

一覧 API (GET /users/{name}/articles?type=...) のレスポンスとして content(longtext)と、それを Rust 側で変換した content_html まで返している。直近の TiDB 実行プランでは

  • Selection_16total_process_keys_size = 853780 (≈833KB / 130 行 → 1 行平均 ≈ 6.4KB)

  • Sort_10 のメモリが 398.1 KB

と、content 列がコストの大半を占めている。さらに、現状の一覧クエリの shape にハマる複合インデックスが無く、Selection 段で 130→40 行に絞る無駄スキャンと、TiDB 側での Sort が発生している。

順番に潰す。段階を分ける理由: content 除外はアプリ修正だけで完結し効果計測も容易、複合インデックス追加は本番 DDL を伴うため、独立して deploy・効果計測したい。

「トップを叩くと前記事のキャッシュが温まるせいで初回が重いのでは?」という仮説についても、後述「キャッシュ仮説の整理」で補足する。

アプリが叩いている 2 クエリ(前提整理)

現状 articles テーブルに対するクエリは以下の 2 つのみ(apps/blog-api/adapter/src/repository/users_articles.rs)。

Q1. 一覧 (find_published_by_user_name_and_type)

SELECT a.article_id, a.title, a.slug, a.user_id, a.content, a.thumbnail, a.description,
       a.status, a.`type`, a.published_at, a.created_at, a.updated_at
FROM articles a
JOIN users u ON a.user_id = u.user_id
WHERE a.status = 'published' AND a.`type` = ? AND u.name = ?
ORDER BY a.published_at DESC;

Q2. 詳細 (find_published_by_user_name_and_slug)

SELECT a.article_id, a.title, a.slug, a.user_id, a.content, a.thumbnail, a.description,
       a.status, a.`type`, a.published_at, a.created_at, a.updated_at
FROM articles a
JOIN users u ON a.user_id = u.user_id
WHERE a.status = 'published' AND a.slug = ? AND u.name = ?;

現状の articles のインデックス

PRIMARY KEY (`article_id`),
UNIQUE KEY `uq_articles_slug` (`slug`),
KEY `idx_articles_user_id` (`user_id`),
KEY `idx_articles_status_published_at` (`status`, `published_at`)

それぞれの当てはまり方:

インデックス

Q1 一覧

Q2 詳細

PRIMARY KEY (article_id)

使われない

TableRowID 取り回しで使われる

uq_articles_slug (slug)

使われない

Point_Get で使われる(完璧にハマる)

idx_articles_user_id (user_id)

6/30 のプランで使われた。ただし status/type は絞れない

使われない

idx_articles_status_published_at

published 全件スキャンになるので opt は選ばない(実際選ばれていない)

使われない

詳細 (Q2) は既存の uq_articles_slug で完璧に Point_Get できる。インデックス追加不要。一覧 (Q1) だけが不足している

現状: なぜ content を含めているか

短く言えば コードの再利用都合のみ。プロダクト要件として一覧で本文が必要なシーンは無い。

具体的な根拠:

  • apps/blog-api/adapter/src/repository/users_articles.rs

    • ArticleRow 構造体は一覧 (Q1) と詳細 (Q2) の両方で共有されており、SELECT 句もコピペで同じ列を並べている

  • apps/blog-api/kernel/src/model/article.rs

    • ドメインモデル Articlecontent: Content を必須フィールドとして抱えており、サマリ用の型が存在しない

  • apps/blog-api/api/src/handler/users_articles.rs

    • ArticleResponse も一覧・詳細で共用されており、ハンドラの map 内で全件に対して convert_markdown_to_html(&content) を呼んでいる(一覧でも本文 Markdown → HTML 変換が走る)

つまり、過去の DSQL 時代に「Article 全列を取って当てる」だけの実装で済ませた名残で、ドメイン的な根拠は無い。survey/2026-06-30-tidb-articles-explain-plan.md の所見「一覧 API は content を外して詳細 API に分離するのが筋」と方針は一致する。

一覧で content が消費されていないことの確認

getArticlesByType を呼んでいる箇所と、その下流のフィールド利用を grep で全て当たった結果:

呼び出し元

利用フィールド

content 利用

apps/web/src/app/page.tsx (top, tech)

ArticleCard 経由

なし

apps/web/src/app/type/note/page.tsx (note)

ArticleCard 経由

なし

apps/web/src/app/sitemap.ts

slug, updatedAt

なし

apps/web/src/app/feed/route.ts

title, description, publishedAt, slug

なし

apps/web/src/components/ArticleCard.tsx

slug, title, publishedAt, thumbnail

なし

article.content / article.contentHtml を読んでいるのは詳細ページ (apps/web/src/app/[userName]/articles/[slug]/page.tsx:119) のみで、ここは getArticleBySlug (= Q2) を別途叩いている。一覧 API のレスポンスから content / contentHtml を消してもフロントは一切壊れない


Phase 1: 一覧クエリから content を外す

期待効果

content を一覧 SELECT から外した場合の改善要素を、効きそうな順に並べる。

  1. TiKV → TiDB の coprocessor 結果転送量が激減

    • TiDB は cop[tikv] のレイヤで列プルーニングを行うため、SELECT に含まれない列は coprocessor の RPC 応答に乗らない

    • 現状 total_process_keys_size: 853KB のうち、title / slug / 各 ID / タイムスタンプ等を除いた content 部分が支配的。1 桁〜2 桁 KB 程度まで縮む見込み

  2. Sort_10 のメモリが 398KB → 数 KB に縮む

    • Sort は root operator なので、SELECT 列分のメモリしか使わない。content を外せば「一覧 40 行分」のメモリは ID + メタデータだけで済む

  3. Rust handler の Markdown → HTML 変換が消える

    • 現在は map 内で全件に対し convert_markdown_to_html を呼んでいる。comrak + syntect は記事数 × 本文サイズで線形に効くため、warm hit でも数 ms 削れる可能性が高い

  4. API レスポンス JSON のサイズが激減

    • Lambda → CloudFront → ブラウザのデータ転送量も同様に減る。Next.js 側の fetch.next.revalidate = 30 キャッシュにも乗りやすくなる

逆に 改善が小さい / 無い 項目:

  • TableRowIDScan_15 のディスク読みコストはほぼ変わらない。TiKV は行指向ストレージなので、列を絞っても RocksDB から読む block 単位は同じ

  • IndexRangeScan_14 のコストも変わらない(インデックスのみ走査するため content とは無関係)

  • → これらは Phase 2 のインデックス改善で潰す

変更案

1-A. リポジトリ層 (apps/blog-api/adapter/src/repository/users_articles.rs)

  • 一覧用に 新しい Row 構造体 ArticleSummaryRow を追加(content を持たない)

  • 一覧用 SELECT 句から a.content を削除

  • ArticleRow (= 全列)は詳細用にそのまま残す

  • ドメインモデルも分離する: kernel 側に ArticleSummary 型を追加 (content を持たない)

    • もしくは Article { content: Option<Content>, ... } にする簡易案もあり得るが、ドメイン的に「一覧の Article は本文を持たない」を型で表現するほうが安全

  • UsersArticlesRepository::find_published_by_user_name_and_type の戻り値を Vec<ArticleSummary> に変える

1-B. ハンドラ層 (apps/blog-api/api/src/handler/users_articles.rs)

  • 一覧用に 新しいレスポンス型 ArticleSummaryResponse を追加

    • フィールド: article_id, title, slug, description, type, thumbnail, ogp_url, published_at, created_at, updated_atcontentcontent_html を外す

  • UsersArticlesResponse.articles: Vec<ArticleResponse>Vec<ArticleSummaryResponse> に変更

  • get_users_articles 内で convert_markdown_to_html 呼び出しを削除

  • utoipa のスキーマも合わせて更新(OpenAPI 文書から content / contentHtml を消す)

1-C. フロント (apps/web/src/lib/api.ts)

  • Article 型を分割する案がきれい:

    • ArticleSummary — 一覧で返ってくる shape

    • Article — 詳細で返ってくる shape (content / contentHtml 必須に格上げ可能)

  • getArticlesByType の戻り値型を Promise<ArticleSummary[]> に変更

  • getArticleBySlug はそのまま Article を返す

  • ArticleCard の props は ArticleSummary を受け取るように緩める

1 の影響を受けない箇所

  • 詳細ページ (apps/web/src/app/[userName]/articles/[slug]/page.tsx) は getArticleBySlug (Q2) を別途叩いているので 無修正

  • 詳細クエリ (find_published_by_user_name_and_slug) も無修正

Phase 1 チェックリスト

  • [ ] kernel に ArticleSummary モデルを追加(content を持たない)

  • [ ] UsersArticlesRepository::find_published_by_user_name_and_type の戻り値を Vec<ArticleSummary> に変更

  • [ ] adapter 側で ArticleSummaryRow を追加し、SELECT から a.content を外す

  • [ ] handler に ArticleSummaryResponse を追加し、UsersArticlesResponse.articles の要素型を差し替え

  • [ ] handler から convert_markdown_to_html 呼び出しを削除(一覧側のみ)

  • [ ] utoipa のスキーマ更新を確認(OpenAPI 文書に content / contentHtml が残っていない)

  • [ ] フロント apps/web/src/lib/api.tsArticleSummary 型を追加、getArticlesByType の戻り値を差し替え

  • [ ] ArticleCard の props 型を ArticleSummary に変更

  • [ ] bun run check 通過

  • [ ] dev 環境に deploy、トップ・note タブ・RSS・sitemap がそれぞれ壊れていないことを確認

  • [ ] 変更後の同一クエリで EXPLAIN ANALYZE を取得し、Sort メモリと Selection の処理サイズが落ちていることを記録


Phase 2: 一覧クエリ向けの複合インデックス追加 + 旧インデックス削除

適用方針

差分 DDL (ALTER TABLE ADD INDEX / ALTER TABLE DROP INDEX) は tools/dsql-cli/dsl-tidb/schema/04_articles.sql末尾に追記済み(元の CREATE TABLE 定義は履歴として残してある)。日付コメント -- 2026-06-30 を頭に置いてある。本番 / dev TiDB に対しては 依頼者がこの差分を手動で実行する。Phase 2 のスコープは「複合インデックス追加」と「不要になる既存インデックスの DROP」の両方を含む。

現状のインデックスがハマっていない理由

6/30 の実行プラン (survey/2026-06-30-tidb-articles-explain-plan.md) で観測された構造:

Sort_10 (TiDB)                ← published_at DESC を後付けで Sort
└─IndexHashJoin_20            ← users (1 件) ⋈ articles (40 件)
  ├─Point_Get (uq_users_name)
  └─IndexLookUp_17
    ├─IndexRangeScan (idx_articles_user_id)  ← user_id で 130 行
    └─Selection (status='published' AND type='tech')  ← 130 → 40 を再フィルタ
      └─TableRowIDScan                       ← 130 行全列を取り直し
  • idx_articles_user_iduser_id だけのインデックスなので、status / type はインデックスで絞れず、TableRowIDScan で 130 行を引き直してから Selection で 40 行に弾いている。捨てる 90 行が無駄

  • published_at の並びもインデックスに無いので、Sort が TiDB 側で別途走る (398KB のメモリ消費の原因)

新規複合インデックス

列順の根拠

#

役割

選定理由

1

user_id

Join key (= 等価)

users.name を Point_Get で 1 件に絞った後、その user_id で絞る。最も selectivity が高い

2

status

フィルタ (= 等価)

'published' で等価絞り込み

3

type

フィルタ (= 等価)

'tech' / 'note' で等価絞り込み

4

published_at

ソートキー

等価条件の後ろに付けることで、TiDB がインデックス順序のまま読めば Sort が不要になる

「等価で絞る列を先に並べ、最後にレンジ/ソートキー」という MySQL / TiDB の複合インデックス設計の定石通り。

ORDER BY が DESC だが、TiDB は B+Tree インデックスを逆順スキャンできるので、DESC 指定なしの素直なインデックスで十分。

変更後の期待プラン

IndexHashJoin (or IndexJoin)
├─Point_Get (uq_users_name)              ← users を 1 件
└─IndexLookUp
  ├─IndexRangeScan
  │   idx_articles_user_status_type_published_at
  │   range: [user_id=?, status='published', type=?]   ← この時点で 40 行
  └─TableRowIDScan (40 行)               ← もう絞れないので全 40 行取り出すだけ
  • Selection 段が消える(インデックス段で 40 行に確定)

  • Sort 段も消える(インデックスの published_at 順をそのまま使う)

  • TableRowIDScan が 130 → 40 行に減る (約 1/3)

  • Phase 1 と組み合わさると、TableRowIDScan が引き当てる行も「content なし」になるので、ディスク I/O・block cache の圧迫の話を除けば、TiKV → TiDB 転送量はさらに小さくなる

削除する既存インデックス

新インデックスができると、現状の以下 2 つは redundant になるので 同 Phase で削除する

既存インデックス

redundant な理由

idx_articles_user_id (user_id)

新インデックスの prefix で完全代替可能

idx_articles_status_published_at (status, published_at)

一覧クエリでは使われていない。アプリのクエリは Q1 / Q2 のみなので純粋に冗長

依頼者が手動 DDL を当てる際の推奨順序:

  1. 新インデックスを ADD

  2. dev で EXPLAIN ANALYZE を取って新インデックスが選ばれていること・期待プラン通りであることを確認

  3. 旧インデックスを DROP(ADD と同じ deploy 単位で続けて実行可)

  4. 適用後に ANALYZE TABLE articles; を 1 度走らせて統計を更新

tools/dsql-cli/dsl-tidb/schema/04_articles.sql 側は元の CREATE TABLE 定義をそのまま残し、末尾に ALTER 文 3 行(ADD 1 / DROP 2)を追記する形にしてある(日付コメント -- 2026-06-30 付き)。

Q2(詳細)について

uq_articles_slug で完璧に Point_Get できているので インデックス追加不要。新規 ADD INDEX も DROP INDEX も Q2 のプランを変えない(slug は globally unique なので、user_id を引き当てる必要が無い)。

Phase 2 チェックリスト

  • [x] tools/dsql-cli/dsl-tidb/schema/04_articles.sql の末尾に ALTER 差分(ADD 1 / DROP 2)を追記

  • [x] dev TiDB に新インデックスを ADD

  • [x] dev TiDB で旧インデックス 2 本を DROP

  • [x] dev で ANALYZE TABLE articles; を実行

  • [x] dev で同一クエリの EXPLAIN ANALYZE を取得し、Selection 段が消えていること・TableRowIDScan の actRows が 40 前後になっていることを確認(達成: Selection 消滅 / TableRowIDScan 130→37

  • [x] Phase 1 検証(SQL 直叩き) で Sort メモリと総時間が落ちることを確認(達成: 7.3ms → 2.63ms / Sort メモリ 395KB → 18.6KB

  • [ ] prd TiDB に対しても同じ手順(ADD → DROP → ANALYZE)を適用

  • [ ] 1 週間程度、slow query log / tidb_statement_summary を観察し、新インデックスが選択されていること・他クエリで regression が出ていないことを確認

計測結果(dev, 2026-06-30)

ベースライン(6/30、idx_articles_user_id 経由 + content 込み)→ Phase 2 単独 → Phase 1 検証 (SELECT から a.content を抜いた SQL を直接実行) の 3 段で計測:

観点

Baseline

Phase 2 のみ

Phase 1 + Phase 2

総時間 (Sort_10 time)

7.33ms

7.3ms

2.63ms ✅ (-64%)

Sort メモリ

398 KB

395 KB

18.6 KB ✅ (-95%)

Sort 自体の CPU(cumulative の差分)

-

0.5ms

~30µs

IndexLookUp メモリ

486 KB

440 KB

47.4 KB

IndexHashJoin メモリ

572 KB

572 KB

187.9 KB

TableRowIDScan time

5.1ms

2.14ms

909µs

TableRowIDScan total_process_keys_size

853 KB

355 KB

355 KB(変化なし、後述)

Selection 段

あり (130→40)

消滅

消滅

TableRowIDScan actRows

130

37

37

観察:

  • Phase 2 単独では総時間は動かない(7.33 → 7.3ms)。構造(Selection 段消滅、130→37 行)は取れたが、ボトルネックが「Selection で content を弾く」→「TableRowIDScan で content を取って運ぶ」にスライドしただけだった

  • Phase 1(content 抜き)を入れた瞬間に効く。総時間 7.3 → 2.63ms、Sort メモリ 395 → 18.6 KB と劇的に改善

  • TableRowIDScantotal_process_keys_size は 355KB のまま変わらない。これは TiKV が RocksDB から読んだ行データの生サイズ(列プルーニング前)で、行そのものが小さくならない限り変わらない。一方で 列プルーニングは「読んだ後・TiDB に送る前」に効く ので、TiKV → TiDB の RPC 転送量と downstream のメモリは劇的に減る。実際 IndexLookUp / IndexHashJoin / Sort のメモリと TableRowIDScan time はすべて落ちている

  • keep order:false による Sort 残存は問題にならない。Sort 自体は ~30µs しか食っていない。hint での強制は不要

EXPLAIN ANALYZE 出力(dev, 2026-06-30)

ベースライン (6/30、idx_articles_user_id 経由 + content 込み) は survey ドキュメント 側に記載済みなのでそちらを参照。本セクションには本タスク適用後の 2 段階を載せる。

Phase 2 のみ(複合インデックス追加 + ANALYZE TABLE、SELECT は content 込みのまま)

id                              estRows  actRows  task        access object                                                                                  execution info                                                                                                          operator info                                                                                                                                                              memory     disk
Sort_10                         0.04     37       root                                                                                                       time:7.3ms,  loops:2, RU:8.433053                                                                                       blog_dev.articles.published_at:desc                                                                                                                                         395.3 KB   0 Bytes
└─IndexHashJoin_19              0.04     37       root                                                                                                       time:6.8ms,  loops:2, inner:{total:6.06ms, concurrency:5, task:1, construct:4.01µs, fetch:3.57ms, build:7.7µs, join:2.48ms}  inner join, inner:IndexLookUp_16, outer key:users.user_id, inner key:articles.user_id                                                                                       572.2 KB   N/A
  ├─Point_Get_27(Build)         1.00     1        root        table:users, index:uq_users_name(name)                                                          time:646.5µs, loops:3, Get:{num_rpc:2, total_time:595.9µs}                                                                                                                                                                                                                          N/A        N/A
  └─IndexLookUp_16(Probe)       38.34    37       root                                                                                                       time:3.52ms, loops:2, index_task:{total_time:947.2µs}, table_task:{total_time:2.22ms, num:1, concurrency:5}                                                                                                                                                                           440.1 KB   N/A
    ├─IndexRangeScan_14(Build)  38.34    37       cop[tikv]  table:a, index:idx_articles_user_status_type_published_at(user_id, status, type, published_at)  time:931.1µs, cop_task:{num:2}, total_process_keys:37, total_process_keys_size:6956                                       range: decided by [eq(user_id), eq(status, published), eq(type, tech)], keep order:false                                                                                    N/A        N/A
    └─TableRowIDScan_15(Probe)  38.34    37       cop[tikv]  table:a                                                                                          time:2.14ms, cop_task:{num:1}, total_process_keys:37, total_process_keys_size:355476                                     keep order:false                                                                                                                                                            N/A        N/A

Phase 1 + Phase 2(複合インデックス + SELECT から a.content を除外)

id                              estRows  actRows  task        access object                                                                                  execution info                                                                                                          operator info                                                                                                                                                              memory     disk
Sort_10                         0.04     37       root                                                                                                       time:2.63ms, loops:2, RU:8.167343                                                                                       blog_dev.articles.published_at:desc                                                                                                                                         18.6 KB    0 Bytes
└─IndexHashJoin_19              0.04     37       root                                                                                                       time:2.6ms,  loops:2, inner:{total:1.89ms, concurrency:5, task:1, construct:3.67µs, fetch:1.86ms, build:12.3µs, join:30.1µs}  inner join, inner:IndexLookUp_16, outer key:users.user_id, inner key:articles.user_id                                                                                       187.9 KB   N/A
  ├─Point_Get_27(Build)         1.00     1        root        table:users, index:uq_users_name(name)                                                          time:643.8µs, loops:3, Get:{num_rpc:2, total_time:565.9µs}                                                                                                                                                                                                                          N/A        N/A
  └─IndexLookUp_16(Probe)       38.34    37       root                                                                                                       time:1.81ms, loops:2, index_task:{total_time:758.8µs}, table_task:{total_time:983.4µs, num:1, concurrency:5}                                                                                                                                                                          47.4 KB    N/A
    ├─IndexRangeScan_14(Build)  38.34    37       cop[tikv]  table:a, index:idx_articles_user_status_type_published_at(user_id, status, type, published_at)  time:740.2µs, cop_task:{num:2}, total_process_keys:37, total_process_keys_size:6956                                       range: decided by [eq(user_id), eq(status, published), eq(type, tech)], keep order:false                                                                                    N/A        N/A
    └─TableRowIDScan_15(Probe)  38.34    37       cop[tikv]  table:a                                                                                          time:909.3µs, cop_task:{num:1}, total_process_keys:37, total_process_keys_size:355476                                     keep order:false                                                                                                                                                            N/A        N/A

差分の読みどころ:

  • Sort_10 の memory: 395.3 KB → 18.6 KB(content が projection から消えた直接の効果)

  • Sort_10 の time: 7.3ms → 2.63ms

  • IndexLookUp_16 の memory: 440.1 KB → 47.4 KB

  • IndexHashJoin_19 の memory: 572.2 KB → 187.9 KB

  • TableRowIDScan_15 の time: 2.14ms → 909µs

  • TableRowIDScan_15total_process_keys_size: 355476 → 355476(変化なし。TiKV の RocksDB 読み出しは列プルーニング前なので行サイズが変わらない限り同じ。減ったのは TiKV → TiDB の RPC 転送量と downstream のメモリ)

  • IndexRangeScan_14 の数値はすべて据え置き(インデックスのエントリのみ走査するので content とは無関係)

キャッシュ仮説の答え合わせ

「トップ初回が重いのは前記事のキャッシュが温まるせい」仮説に対しては:

  • block cache のフットプリント自体は減らない(TableRowIDScan の total_process_keys_size が 355KB のままという事実が証拠)。行は content 込みで丸ごと TiKV の RocksDB から読まれ block cache に載る

  • ただし TiKV→TiDB 転送と Sort メモリが劇的に減るので、warm hit / cold hit を問わず体感速度は確実に上がる(7.33 → 2.63ms)

  • block cache 圧迫を本気で潰したいなら articles_content への垂直分割(Phase 3 候補) が必要。本タスクのスコープ外

Phase 2 で踏み込まないこと

  • content 列の別テーブル切り出し(垂直分割) — block cache 圧迫を本気で減らすには有効だが、影響範囲が大きい。Phase 1 / Phase 2 の効果を見てから別途検討

  • Covering Index 化 — 新インデックスに title / description / thumbnail まで含めれば TableRowIDScan も消せるが、title (VARCHAR(500))description (TEXT) を含めるとインデックスが肥大化する。Phase 1 で TableRowIDScan の負荷自体が下がる(content がなくなる)ので、まず素直な複合インデックスで様子見


Phase 3: 一覧 API のページネーション + フロント prefetch fanout 削減

動機

Phase 1 / Phase 2 で Q1 自体は十分軽くなった (7.33ms → 2.63ms)。残るボトルネックは Q1 ではなく、フロントの next/link viewport prefetch によって裏で叩かれる Q2 (詳細) 群 である。

具体的には:

  • apps/web/src/components/ArticleCard.tsx<ProgressLink>next/link ラッパーで、prefetch 指定無し → production では viewport in 時に自動 prefetch

  • 一覧ページが 40 件の ArticleCard をレンダする以上、トップを開くと 40 個の /[userName]/articles/[slug] ルートが RSC prefetch され、各々で getArticleBySlug (Q2) が走る

  • revalidate: 30 なので Next.js キャッシュが切れるたびにまた走る

Q2 自体は uq_articles_slug の Point_Get なので 1 回は軽いが、40 倍に増幅されると一覧 API の Q1 1 回より圧倒的に重い。tidb_statement_summary で Q1 だけ追うと全体像を見誤る。

ページネーションして 1 ページ 10 件にすれば、viewport に並ぶ <Link> は 10 個 + ページネーション数個 に減り、prefetch fanout は約 4 分の 1。prefetch={false} で全切りせず体感速度を維持したまま fanout を絞れる。

Phase 2 の新インデックス (user_id, status, type, published_at) のおかげで LIMIT/OFFSETCOUNT(*) も追加コストほぼ無しで載るので、API 側の DDL は不要。

設計判断(確定済み)

項目

採用案

URL 形式

/page/[page] 動的ルート(searchParams ではない)

1 ページ件数

10 件

1 ページ目の扱い

//type/note をそのまま 1 ページ目として動作させる。/page/1 / /type/note/page/1 は 308 リダイレクト

ページネーション UI

1 2 3 9 [10] 形式。現ページ ±2 + 最初/最後 + 省略

RSS

最新 20 件で十分 (perPage=20)

sitemap

全件必要 (perPage=all または十分大きい上限)

Q1 SQL(変更後)

-- ページ取得
SELECT a.article_id, a.title, a.slug, a.user_id, a.thumbnail, a.description,
       a.status, a.`type`, a.published_at, a.created_at, a.updated_at
FROM articles a
JOIN users u ON a.user_id = u.user_id
WHERE a.status = 'published' AND a.`type` = ? AND u.name = ?
ORDER BY a.published_at DESC
LIMIT ? OFFSET ?;

-- 総件数(ページネーション UI 用)
SELECT COUNT(*)
FROM articles a
JOIN users u ON a.user_id = u.user_id
WHERE a.status = 'published' AND a.`type` = ? AND u.name = ?;

期待プラン:

  • ページ取得: Phase 2 の新インデックスのみで完結。IndexRangeScan でレンジを引いた後 Limit で 10 行に絞ってから TableRowIDScanTableRowIDScan の actRows = 10 で済む

  • COUNT: IndexRangeScan のみで完結し TableRowIDScan は走らない(インデックスのキーだけで件数が分かる)

API 変更

Repository (apps/blog-api/adapter/src/repository/users_articles.rs)

  • find_published_by_user_name_and_type のシグネチャを以下に変更:

    pub async fn find_published_by_user_name_and_type(
        &self,
        name: &str,
        article_type: &ArticleType,
        page: u32,
        per_page: u32,
    ) -> Result<(Vec<ArticleSummary>, u64)>  // (articles, totalCount)
    
  • per_page の上限は API ハンドラ側でバリデート(例: 1〜100、または all 相当の 500)

  • COUNT は同じ JOIN 条件で別クエリとして発行

Handler (apps/blog-api/api/src/handler/users_articles.rs)

  • UsersArticlesQuerypage / per_page を追加(Option<u32> で default 1 / 10)

  • UsersArticlesResponse を以下に拡張:

    pub struct UsersArticlesResponse {
        pub articles: Vec<ArticleSummaryResponse>,
        pub total_count: u64,
        pub page: u32,
        pub per_page: u32,
        pub total_pages: u32,
    }
    
  • per_pageall 相当(数値上限による表現で良い、例 500)を許容するか、別途 perPage クエリで all 文字列を許容するかは実装時に決める(後者の方が明示的で sitemap から使いやすい)

  • utoipa スキーマ更新

フロント変更 (apps/web)

src/lib/api.ts

  • 戻り値型を ArticlesPage に変更:

    export interface ArticlesPage {
      articles: ArticleSummary[];
      totalCount: number;
      page: number;
      perPage: number;
      totalPages: number;
    }
    export async function getArticlesByType(
      userName: string,
      type: 'tech' | 'note',
      opts?: { page?: number; perPage?: number | 'all' },
    ): Promise<ArticlesPage>;
    

ルート構成

URL

新規/既存

内容

/

既存

tech 1 ページ目(getArticlesByType(..., 'tech', { page: 1 })

/page/[page]

新規

tech 2 ページ目以降

/type/note

既存

note 1 ページ目

/type/note/page/[page]

新規

note 2 ページ目以降

/page/1/

リダイレクト

URL 正規化(308)

/type/note/page/1/type/note

リダイレクト

URL 正規化(308)

各ページに canonical (<link rel="canonical">) を入れて自分自身を指す。

Pagination コンポーネント

  • 新規 src/components/Pagination.tsx:

    interface PaginationProps {
      currentPage: number;
      totalPages: number;
      baseHref: string; // '/' or '/type/note'
    }
    
  • 出力: Prev   1  2 ... 8  9 [10]   Next

  • リンクは <Link> で繋ぐ → デフォルト prefetch で隣接ページの RSC が prefetch される(ページ遷移の体感は維持)

  • Story を追加(少件数/多件数/最初ページ/最終ページ)

RSS (src/app/feed/route.ts)

  • 全件 getArticlesByType(USER_NAME, 'tech')getArticlesByType(USER_NAME, 'tech', { perPage: 20 }) に変更

  • 一般的な RSS リーダーは最新 N 件で十分

sitemap (src/app/sitemap.ts)

  • 全件必要なので getArticlesByType(USER_NAME, 'tech', { perPage: 'all' }) を使う

  • revalidate = 60 なので COUNT + 全件取得が 60 秒に 1 回。Phase 1 で content 抜きなので転送量は十分軽い

prefetch fanout の比較

観点

現状

Phase 3 後

トップ表示時に走る Q2 (詳細)

最大 40

10

トップ表示時に走る Q1 (一覧)

1

1 + ページネーションリンクの prefetch(隣接 ~3 ページ)

Q1 自体の重さ

Phase 1/2 適用済みで軽い

同左(LIMIT 10 + COUNT で更に軽い)

Q2 の fanout が 4 分の 1。Q1 はページネーションリンクの prefetch で数回増えるが、Phase 1/2 + LIMIT のおかげで 1 回あたり極めて軽いので無視できる。

Phase 3 チェックリスト

  • [ ] Repository: find_published_by_user_name_and_typepage / per_page を追加し、戻り値を (Vec<ArticleSummary>, u64) に拡張

  • [ ] Repository: COUNT 用クエリを追加(同 JOIN 条件)

  • [ ] Handler: UsersArticlesQuerypage / perPage を追加

  • [ ] Handler: UsersArticlesResponsetotalCount / page / perPage / totalPages を追加

  • [ ] Handler: perPage=all (または上限 500) のハンドリング決定 + 実装

  • [ ] utoipa スキーマ更新

  • [ ] dev TiDB で EXPLAIN ANALYZE を取り、LIMIT/OFFSET クエリが新インデックスのみで完結(TableRowIDScan の actRows = 10、Sort が無い)していることを確認

  • [ ] dev TiDB で COUNT クエリが IndexRangeScan のみで完結している(TableRowIDScan 不要)ことを確認

  • [ ] フロント: lib/api.tsgetArticlesByType シグネチャ更新、戻り値型を ArticlesPage

  • [ ] フロント: /page/[page]/page.tsx 新設(tech)

  • [ ] フロント: /type/note/page/[page]/page.tsx 新設(note)

  • [ ] フロント: /page/1 / /type/note/page/1 の 308 リダイレクト(next.config.tsredirects() に追記)

  • [ ] フロント: <Pagination> コンポーネント新設、Story 追加

  • [ ] フロント: 各一覧ページに <Pagination> 配置、canonical 設定

  • [ ] フロント: feed/route.tsperPage: 20 に変更

  • [ ] フロント: sitemap.tsperPage: 'all' に変更

  • [ ] bun run check 通過

  • [ ] dev 環境で動作確認: 1 ページ目 / 中間 / 最終ページの表示、/page/1 リダイレクト、Prev/Next、最初/最後の数字リンク

  • [ ] prefetch 観測: Production ビルドで Network タブを開き、Filter _rsc でトップ表示時の prefetch が 10 件 + 隣接ページ分 に収まっていることを確認(変更前は最大 40 件)

  • [ ] API 側のアクセスログで、トップ初回ロード時の Q2 リクエスト数が変更前後でどう変わったかを記録

Phase 3 で踏み込まないこと

  • URL クエリパラメータ方式 (?page=2) への切り替え — 動的ルートで確定

  • 無限スクロール / Load More UI — ページ番号方式で確定

  • カーソル方式 (keyset pagination) への移行 — 記事数が数千件規模になるまで OFFSET 方式で十分。Phase 2 の新インデックスのおかげで OFFSET も B+Tree を順走するだけ

  • <Link prefetch={false}> への切り替え / hover-only prefetch — ページネーションで fanout が許容範囲に収まれば不要。Phase 3 適用後に Q2 観測値を見て、それでもまだ多ければ別途検討


Phase 4: JOIN 分離 + 複合インデックスに article_id を追加 + ORDER BY 安定化

動機

Phase 1 / 2 / 3 を経た時点で一覧クエリ (LIMIT 10) の EXPLAIN ANALYZE が以下の構造になっていた:

TopN_15 (root)                ← Sort + Limit fused, keep order:false
└─IndexHashJoin_26            ← users (1 件) ⋈ articles (37 件)
  ├─Point_Get (uq_users_name)
  └─IndexLookUp
    ├─IndexRangeScan_21 (idx_articles_user_status_type_published_at, keep order:false)
    └─TableRowIDScan_22       ← 37 行 (= Limit 後の 10 行ではなく Sort 前の 37 行) を読み出している

問題点:

  1. keep order:false で取っているため、TiDB 側で TopN (= Sort + Limit) を後付けする必要があり、TableRowIDScan で 37 行ぶん読んでから 10 行に絞っている。理想は IndexRangeScan の段で 10 行に絞ってから TableRowIDScan に渡す形 (Limit pushdown)。

  2. IndexHashJoin が users との JOIN のために 187.9 KB のメモリを消費し、Point_Get の往復で 945µs かかっている。u.name から user_id を確定する処理は完全に独立しているので JOIN は構造的に不要

  3. ORDER BY published_at DESC 単独では published_at が同値の記事が出た時に順序が不安定。Tiebreaker として article_id DESC を足すと、インデックス列の自然順と一致するため keep order:true, desc 経路が取れる。

変更内容

4-A. 新インデックス: article_id を末尾に追加

ALTER TABLE articles
  ADD INDEX idx_articles_user_status_type_published_at_id
    (user_id, status, `type`, published_at, article_id);
ALTER TABLE articles DROP INDEX idx_articles_user_status_type_published_at;

旧インデックス (user_id, status, type, published_at) は新インデックスの完全な prefix なので 100% redundant。Phase 2 と同じく ADD と同じ deploy 単位で DROP する。

4-B. クエリ修正 (apps/blog-api/adapter/src/repository/users_articles.rs)

  • 一覧クエリの JOIN を外し、a.user_id = (SELECT user_id FROM users WHERE name = ?) に変更

  • ORDER BY a.published_at DESCORDER BY a.published_at DESC, a.article_id DESC (安定化)

  • COUNT クエリも同じ subquery 化

ヒント (/*+ ORDER_INDEX(...) */) は 不要。dev 計測で ヒント無しでも opt が新インデックスを keep order:true, desc で選ぶことを確認済 (B-2 1.75ms vs B-4 ヒント付き 1.65ms で誤差レベル)。

期待プラン (実測通り)

IndexLookUp
├─Limit (cop[tikv], embedded)            ← Limit が TiKV 側に push down
│ └─IndexRangeScan
│     idx_articles_user_status_type_published_at_id
│     range: [user_id=?, status='published', type=?]
│     keep order:true, desc               ← published_at, article_id の自然順を逆走査
└─TableRowIDScan (10 行 ← Sort 前の 37 行ではなく LIMIT 後の 10 行)

計測結果 (dev, 2026-06-30)

一覧クエリ (LIMIT 10 OFFSET 0)

観点

Baseline (Phase 3 まで)

Phase 4

改善

合計時間

2.75ms

1.75ms

-36%

keep order

false

true, desc

TableRowIDScan actRows

37

10

-73%

TableRowIDScan 処理サイズ

355,476 B

83,220 B

-77%

TableRowIDScan time

1.08ms

457µs

-58%

TopN_15 (Sort + Limit)

18.4 KB

消滅

IndexHashJoin メモリ

187.9 KB

消滅

Limit pushdown

なし

TiKV 側に push

合計メモリ

約 206 KB

28.6 KB

-86%

COUNT クエリ

観点

Baseline

Phase 4

改善

合計時間

1.67ms

892.8µs

-47%

IndexHashJoin メモリ

82.0 KB

消滅

TableRowIDScan

なし

なし

同等

EXPLAIN ANALYZE

完全な SQL とログ全文は 2026-06-30-articles-list-join-elim.sql を参照。

主要な抜粋 (Phase 4 適用後の一覧クエリ):

IndexLookUp_33               actRows:10  time:1.75ms  memory:28.6 KB  limit embedded(offset:0, count:10)
├─Limit_32 (cop[tikv])       actRows:10  time:1.16ms  offset:0, count:10
│ └─IndexRangeScan_30        actRows:10  table:a, index:idx_articles_user_status_type_published_at_id(user_id, status, type, published_at, article_id)
│                                        range:["<uuid>" "published" "tech","<uuid>" "published" "tech"], keep order:true, desc
└─TableRowIDScan_31 (Probe)  actRows:10  time:457µs   keep order:false  total_process_keys_size: 83220

Phase 4 チェックリスト

  • [x] dev TiDB で baseline EXPLAIN ANALYZE 取得 (A-1 / A-2)

  • [x] 新インデックス ADD + ANALYZE

  • [x] dev TiDB で Phase 4 EXPLAIN ANALYZE 取得 (B-1 〜 B-4)

  • [x] keep order:true, desc がヒント無しで選ばれることを確認 (= 本番クエリにヒント不要)

  • [x] 旧インデックス DROP + ANALYZE

  • [x] DROP 後にも新クエリが同じ計画 (新インデックス + keep order:true, desc + Limit pushdown) を保つことを確認 (C-3)

  • [x] tools/dsql-cli/dsl-tidb/schema/04_articles.sql の末尾に Phase 4 の ALTER 差分追記

  • [x] apps/blog-api/adapter/src/repository/users_articles.rs を subquery + 安定化 ORDER BY に修正

  • [ ] bun run check 通過

  • [ ] PR 作成 + workflow_dispatch で dev デプロイ

  • [ ] dev デプロイ後にトップ / note タブ / RSS / sitemap が壊れていないことを確認

  • [ ] prd TiDB に対して同じ手順 (新インデックス ADD → ANALYZE → 旧 DROP → ANALYZE) を適用

  • [ ] prd デプロイ後 1 週間ほど slow query log / tidb_statement_summary を観察

Phase 4 で踏み込まないこと

  • cursor pagination (keyset pagination): Phase 3 末尾で議論済み。記事数が数千規模まで OFFSET 方式で十分。Phase 4 で keep order:true + Limit pushdown が効くようになったので、深い OFFSET でも IndexRangeScan が B+Tree を順走するだけで済む構造になった。


キャッシュ仮説の整理

「トップを叩いたら前記事のキャッシュが作られていて初回が重い」という仮説に対しての回答:

  • 方向性としては合っているTableRowIDScan は 130 行分のテーブル行(content 込み)を RocksDB から読み出し、TiKV の block cache に載せる。content が KB〜MB オーダーで存在するため、block cache のスロットを消費しやすい

  • Phase 1 単体では block cache 圧迫は減らない。行は丸ごと読まれて cache される。Phase 1 が効くのは「読んだあとに TiDB に運ぶ部分」と「Sort のメモリ」と「Rust の Markdown 変換」

  • Phase 2 を入れると TableRowIDScan の対象が 130 → 40 行に減るので、cold path で読みに行く行数自体が約 1/3 になり、block cache のフットプリントも縮む

  • block cache のフットプリント自体をさらに減らしたい場合は構造改善が必要articles から content を別テーブル (articles_content) に切り出す 垂直分割。これは Phase 3 候補

要するに、3 段ロケットの 1 段目・2 段目を本タスクで打ち上げ、ペイロードが軌道に乗らなければ 3 段目(垂直分割)を検討する。


リスク / 要確認事項

  • [ ] OpenAPI クライアントを誰かが生成して使っていないかutoipa のスキーマを変えると、生成済みクライアントの型と合わなくなる。現在の apps/web 側は手書きの型定義 (apps/web/src/lib/api.ts) なので影響なしのはず

  • [ ] 管理画面など別の consumer が content を読んでいないか。現状リポジトリ内の grep では一覧 API の消費者は apps/web の 4 箇所だけだが、.legacy/ 配下や別リポジトリの consumer が無いことを念のため確認

  • [ ] RSS で本文を載せる将来要件が無いか。現状 feed/route.tsdescription だけだが、もし将来 full-content RSS を出したくなったら別エンドポイント or 別クエリで取る方針にする(一覧本体は薄いまま)

  • [ ] 新インデックス追加時の書き込みコストarticles への INSERT / UPDATE は 4 列で 1 インデックス分のメンテが増える。記事更新頻度は webhook ベースで疎なので無視可

  • [ ] opt が新インデックスを選ばないケース。統計が古いと旧 index に流れることがある。適用後に ANALYZE TABLE articles; を 1 度走らせる

  • [ ] 計測: 各 phase 後に EXPLAIN ANALYZE を取り、survey ドキュメントに diff として残せると良い

スコープ外(別タスクで扱う)

  • content 列の別テーブル切り出し(垂直分割)

  • 6/28 と 6/30 で実行経路が割れた件の再現確認(統計のタイミング揃え)

  • Phase 4 適用後の prd で OFFSET 値依存で TableFullScan に化けるプラン分岐(観測: 2026-07-01)— 切り分け方針と暫定対策案は survey/2026-07-01-tidb-articles-list-plan-split.md を参照