記事一覧クエリの最適化(content 除外 + 複合インデックス追加)
起票日: 2026-06-30
ステータス: 計画策定済み(実装は依頼者側で実施予定)
起票理由
一覧 API (GET /users/{name}/articles?type=...) のレスポンスとして content(longtext)と、それを Rust 側で変換した content_html まで返している。直近の TiDB 実行プランでは
Selection_16のtotal_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 詳細 |
|---|---|---|
|
使われない |
TableRowID 取り回しで使われる |
|
使われない |
Point_Get で使われる(完璧にハマる) |
|
6/30 のプランで使われた。ただし status/type は絞れない |
使われない |
|
published 全件スキャンになるので opt は選ばない(実際選ばれていない) |
使われない |
→ 詳細 (Q2) は既存の uq_articles_slug で完璧に Point_Get できる。インデックス追加不要。一覧 (Q1) だけが不足している。
現状: なぜ content を含めているか
短く言えば コードの再利用都合のみ。プロダクト要件として一覧で本文が必要なシーンは無い。
具体的な根拠:
apps/blog-api/adapter/src/repository/users_articles.rsArticleRow構造体は一覧 (Q1) と詳細 (Q2) の両方で共有されており、SELECT 句もコピペで同じ列を並べている
apps/blog-api/kernel/src/model/article.rsドメインモデル
Articleもcontent: Contentを必須フィールドとして抱えており、サマリ用の型が存在しない
apps/blog-api/api/src/handler/users_articles.rsArticleResponseも一覧・詳細で共用されており、ハンドラの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 で全て当たった結果:
呼び出し元 |
利用フィールド |
|
|---|---|---|
|
|
なし |
|
|
なし |
|
|
なし |
|
|
なし |
|
|
なし |
article.content / article.contentHtml を読んでいるのは詳細ページ (apps/web/src/app/[userName]/articles/[slug]/page.tsx:119) のみで、ここは getArticleBySlug (= Q2) を別途叩いている。一覧 API のレスポンスから content / contentHtml を消してもフロントは一切壊れない。
Phase 1: 一覧クエリから content を外す
期待効果
content を一覧 SELECT から外した場合の改善要素を、効きそうな順に並べる。
TiKV → TiDB の coprocessor 結果転送量が激減
TiDB は
cop[tikv]のレイヤで列プルーニングを行うため、SELECT に含まれない列は coprocessor の RPC 応答に乗らない現状
total_process_keys_size: 853KBのうち、title/slug/ 各 ID / タイムスタンプ等を除いたcontent部分が支配的。1 桁〜2 桁 KB 程度まで縮む見込み
Sort_10のメモリが 398KB → 数 KB に縮むSort は root operator なので、SELECT 列分のメモリしか使わない。
contentを外せば「一覧 40 行分」のメモリは ID + メタデータだけで済む
Rust handler の Markdown → HTML 変換が消える
現在は
map内で全件に対しconvert_markdown_to_htmlを呼んでいる。comrak + syntect は記事数 × 本文サイズで線形に効くため、warm hit でも数 ms 削れる可能性が高い
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_at(contentとcontent_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— 一覧で返ってくる shapeArticle— 詳細で返ってくる 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.tsにArticleSummary型を追加、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_idはuser_idだけのインデックスなので、status/typeはインデックスで絞れず、TableRowIDScanで 130 行を引き直してからSelectionで 40 行に弾いている。捨てる 90 行が無駄published_atの並びもインデックスに無いので、Sort が TiDB 側で別途走る (398KB のメモリ消費の原因)
新規複合インデックス
列順の根拠
# |
列 |
役割 |
選定理由 |
|---|---|---|---|
1 |
|
Join key (= 等価) |
|
2 |
|
フィルタ (= 等価) |
|
3 |
|
フィルタ (= 等価) |
|
4 |
|
ソートキー |
等価条件の後ろに付けることで、TiDB がインデックス順序のまま読めば |
「等価で絞る列を先に並べ、最後にレンジ/ソートキー」という 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 な理由 |
|---|---|
|
新インデックスの prefix で完全代替可能 |
|
一覧クエリでは使われていない。アプリのクエリは Q1 / Q2 のみなので純粋に冗長 |
依頼者が手動 DDL を当てる際の推奨順序:
新インデックスを ADD
dev で
EXPLAIN ANALYZEを取って新インデックスが選ばれていること・期待プラン通りであることを確認旧インデックスを DROP(ADD と同じ deploy 単位で続けて実行可)
適用後に
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 |
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 と劇的に改善
TableRowIDScanのtotal_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.63msIndexLookUp_16の memory: 440.1 KB → 47.4 KBIndexHashJoin_19の memory: 572.2 KB → 187.9 KBTableRowIDScan_15の time: 2.14ms → 909µsTableRowIDScan_15のtotal_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/OFFSET も COUNT(*) も追加コストほぼ無しで載るので、API 側の DDL は不要。
設計判断(確定済み)
項目 |
採用案 |
|---|---|
URL 形式 |
|
1 ページ件数 |
10 件 |
1 ページ目の扱い |
|
ページネーション UI |
|
RSS |
最新 20 件で十分 ( |
sitemap |
全件必要 ( |
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 行に絞ってからTableRowIDScan。TableRowIDScan の 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)
UsersArticlesQueryにpage/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_pageにall相当(数値上限による表現で良い、例 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 ページ目( |
|
新規 |
tech 2 ページ目以降 |
|
既存 |
note 1 ページ目 |
|
新規 |
note 2 ページ目以降 |
|
リダイレクト |
URL 正規化(308) |
|
リダイレクト |
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_typeにpage/per_pageを追加し、戻り値を(Vec<ArticleSummary>, u64)に拡張[ ] Repository: COUNT 用クエリを追加(同 JOIN 条件)
[ ] Handler:
UsersArticlesQueryにpage/perPageを追加[ ] Handler:
UsersArticlesResponseにtotalCount/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.tsのgetArticlesByTypeシグネチャ更新、戻り値型をArticlesPageに[ ] フロント:
/page/[page]/page.tsx新設(tech)[ ] フロント:
/type/note/page/[page]/page.tsx新設(note)[ ] フロント:
/page/1//type/note/page/1の 308 リダイレクト(next.config.tsのredirects()に追記)[ ] フロント:
<Pagination>コンポーネント新設、Story 追加[ ] フロント: 各一覧ページに
<Pagination>配置、canonical 設定[ ] フロント:
feed/route.tsをperPage: 20に変更[ ] フロント:
sitemap.tsをperPage: '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 行) を読み出している
問題点:
keep order:falseで取っているため、TiDB 側でTopN(= Sort + Limit) を後付けする必要があり、TableRowIDScanで 37 行ぶん読んでから 10 行に絞っている。理想は IndexRangeScan の段で 10 行に絞ってから TableRowIDScan に渡す形 (Limit pushdown)。IndexHashJoinが users との JOIN のために 187.9 KB のメモリを消費し、Point_Get の往復で 945µs かかっている。u.nameからuser_idを確定する処理は完全に独立しているので JOIN は構造的に不要。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 DESC→ORDER 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% |
|
|
|
✅ |
|
37 |
10 |
-73% |
|
355,476 B |
83,220 B |
-77% |
|
1.08ms |
457µs |
-58% |
|
18.4 KB |
消滅 |
✅ |
|
187.9 KB |
消滅 |
✅ |
|
なし |
TiKV 側に push |
✅ |
合計メモリ |
約 206 KB |
28.6 KB |
-86% |
COUNT クエリ
観点 |
Baseline |
Phase 4 |
改善 |
|---|---|---|---|
合計時間 |
1.67ms |
892.8µs |
-47% |
|
82.0 KB |
消滅 |
✅ |
|
なし |
なし |
同等 |
生 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.tsはdescriptionだけだが、もし将来 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を参照