# TiDB 移行後の本番 `articles` 実行計画とリージョン分布 - 対象: 本番 TiDB クラスタ (`blog_prd`) - 調査日: 2026-06-28 - 関連: [DSQL → TiDB 移行プラン](../../98_tasks/2026-06-26-dsql-to-tidb-migration/index.md) - 元クエリ: [`2026-06-28-tidb.sql`](./2026-06-28-tidb.sql) DSQL → TiDB 移行直後の本番状態を確認するための survey。記事一覧クエリの実行計画と `articles` テーブルのリージョン分布を見て、現時点でのホットスポット懸念とインデックス設計の妥当性を評価した。 ## 対象クエリ 公開済み tech 記事の一覧(`shuntaka` ユーザー向け)。 ```sql 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` = 'tech' AND u.name = 'shuntaka' ORDER BY a.published_at DESC; ``` ## `articles` スキーマ(本番) ```sql CREATE TABLE `articles` ( `article_id` char(36) NOT NULL DEFAULT (uuid()), `title` varchar(500) NOT NULL, `slug` varchar(255) NOT NULL, `user_id` char(36) NOT NULL, `content` longtext NOT NULL, `thumbnail` text DEFAULT NULL, `description` text NOT NULL, `status` varchar(20) NOT NULL DEFAULT 'draft', `type` varchar(50) DEFAULT NULL, `published_at` datetime(6) DEFAULT NULL, `created_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), `updated_at` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (`article_id`) /*T![clustered_index] CLUSTERED */, UNIQUE KEY `uq_articles_slug` (`slug`), KEY `idx_articles_user_id` (`user_id`), KEY `idx_articles_status_published_at` (`status`,`published_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin; ``` 行数: 130(実カウント)。 ## 事前プラン(`EXPLAIN`) ``` id estRows task access object operator info Sort_10 41.76 root blog_prd.articles.published_at:desc └─IndexHashJoin_20 41.76 root inner join, inner:IndexLookUp_17, outer key:users.user_id, inner key:articles.user_id ├─Point_Get_29(Build) 1.00 root table:users, index:uq_users_name(name) └─IndexLookUp_17(Probe) 41.76 root ├─IndexRangeScan_14 130.00 cop[tikv] table:a, index:idx_articles_user_id(user_id) └─Selection_16(Probe) 41.76 cop[tikv] eq(articles.status, "published"), eq(articles.type, "tech") └─TableRowIDScan_15 130.00 cop[tikv] table:a ``` `uq_users_name` で `users` を `Point_Get` → `idx_articles_user_id` で `IndexLookup` を駆動するプラン。 ## 実行プラン(`EXPLAIN ANALYZE`、合計 1.24ms) ``` id actRows task access object execution info Sort_10 40 root time:1.24ms └─HashJoin_28 40 root inner join (build: users, probe: articles) ├─Point_Get_29 1 root table:users, index:uq_users_name(name) 839µs └─TableReader_32 40 root 1.11ms └─Selection_31 40 cop[tikv] eq(status,"published"), eq(type,"tech") └─TableFullScan_30 130 cop[tikv] table:a 566µs (proc), 1ms (kv_read_wall) ``` 事前プランの IndexLookup ではなく **TableFullScan + HashJoin** に振られた。130 行しかないので optimizer が「インデックス経由よりフルスキャンの方が安い」と判断した結果で、現時点では正解。 ## リージョン分布(`articles`) `articles` テーブルの Region がどの TiKV ノードに、どの役割(Leader / Follower)で配置されているかを確認する。 ```{figure} region-leader-store.png :alt: articles テーブルの実 Region 配置(訂正版) :width: 100% `blog_prd.articles` の実 Region 配置(訂正版)。**現実は 1 物理 Region (`REGION_ID=384`) に全 130 行**が収まり、その 3 Peer が Store 1 / 4 / 5 に分散して Leader が Store 4 に乗っている。`INFORMATION_SCHEMA.TIKV_REGION_STATUS` で `region_count=5` に見えるのは、`(region × index keyspace)` ごとに行が多重化されるため(後述「`region_count=5` の正体」参照)。 ``` ```{figure} region-leader-store-conceptual.png :alt: TiKV Region / Store / Leader の概念モデル(参考) :width: 100% 参考: 当初の概念モデル図。`articles` を 5 Region に分割した架空のレイアウトで TiKV における Region / Store / Leader / Peer の関係を学ぶための教材として有用。ただし**現実の `articles` は 1 Region** なので、図中②③で示される「5 Region × 3 Store のグリッド」「Store 4 に Leader が 5 個」「クラスタ全体 3/3/0」は実データのスナップショットとしては成立しない(実体については上の訂正版を参照)。データが ~96MB を超えて自動分割が走った将来は、おおむねこの形のレプリカ配置に近づく見込み。 ``` ### 前提: TiDB の用語整理 | 用語 | 意味 | | ---------- | ------------------------------------------------------------------------------------------------------------- | | **Store** | TiKV ノード 1 台。本クラスタは `STORE_ID = 1, 4, 5` の 3 台構成 | | **Region** | TiKV 内データをキー範囲で切った 1 シャード。デフォルト ~96MB で自動分割 | | **Peer** | Region のレプリカ。本クラスタはレプリカ数 3 で運用しているため、1 Region = 3 Peer | | **Leader** | 3 Peer のうち読み書きを実際に処理する 1 個。残り 2 個は Follower として待機(既定では読み取りも Leader 経由) | ```{figure} tidb-region-peer-leader-internals.png :alt: TiDB の内部構造 — Region / Peer / Leader / Follower / Store の関係 :width: 100% TiDB の内部構造を Region / Peer / Leader / Follower / Store の関係で図示したもの(例え話なし)。**上段**: Region はキー空間を区切った論理シャード。**中段**: 1 Region (REGION_ID=384) に対して 3 つの Peer (385/386/387) が作られ、それぞれ別の Store に配置される。Peer の中で 1 個だけが Leader (Peer 386 / Store 4)、残りが Follower。`Peer = レプリカそのもの`、`Leader/Follower = Peer が現在取っている役割` という関係。**下段**: 読み書きフローは Client → TiDB Server → Leader Peer の順で、Leader から Raft で Follower にログ複製される。 ``` ### Peer 配置と役割の不変条件 用語表と図を見たうえで、押さえておくと混乱しないポイントが 2 つある。 #### Peer は基本的に「別 Store」に配置される PD はデフォルトで **「同一 Region の Peer を同じ Store に重ねない」** 制約を持つ。同じ Store に複数 Peer を置くと、その Store 1 台のダウンで同じ Region の複数 Peer を同時に失い、可用性が破綻するため。本クラスタでも以下のように Store 重複なし: ``` articles (Region 384) の Peer 配置 Peer 385 → Store 5 Peer 386 → Store 4 ← Leader Peer 387 → Store 1 ``` より強い分離(zone / host / rack 単位で Peer を散らす)を効かせたい場合は Store に **Label** を付けて **Placement Rule** で制約を書く(例: `"同一 zone に Peer を 2 個以上置くな"`)。本クラスタは `LABEL: null` なので Store 単位の分離のみ。マルチ AZ 構成にする時は `zone` ラベルを付けるのが定石。 例外的に `max-replicas > Store 数` のとき PD は配置先を確保できずレプリカ作成に失敗するが、本クラスタは `max-replicas = 3` / Store 3 台なので問題ない。 #### Peer は Leader にも Follower にもなりうる Raft 上、**Voter 権を持つ Peer はすべて Leader 候補**。役割は固定ではなく Raft 再選挙や PD の手動操作で入れ替わる。 | 何が起きると | 何が起きるか | | -------------------------------------------------------------------------------- | -------------------------------------------------------------- | | Leader Peer の Store がダウン | Raft 再選挙が走り、残り Follower のうち 1 個が新 Leader に昇格 | | Leader から他 Peer への通信が `raft-election-timeout`(既定 10 sec)を超えて途絶 | 同じく再選挙 | | PD が `transfer-leader` を発行 | 指定した Peer に Leader 役が移る(Peer 自体は移動しない) | | PD の `balance-leader-scheduler` が偏りすぎと判断 | Score に基づいて Leader を別 Peer にトランスファ | 重要なのは **役割が入れ替わっても Peer の数も Store 配置も変わらない**こと。 ``` 時刻 t1: 時刻 t2 (再選挙後): Peer 385 = Follower Peer 385 = Leader ← 入れ替わり Peer 386 = Leader Peer 386 = Follower Peer 387 = Follower Peer 387 = Follower ``` ただし Peer の状態には 3 種類あり、**Learner だけは Leader になれない**(投票権が無いため。新 Peer 追加時にログ追いつき用として一時的に Learner で作られ、追いついたら Voter に昇格する遷移用ステータス)。本クラスタの Peer 385/386/387 はすべて Voter。 ### クエリ 1: Leader を持つ Store の集計 ```sql SELECT p.STORE_ID, COUNT(*) AS region_count, SUM(s.APPROXIMATE_KEYS) AS total_keys, SUM(s.APPROXIMATE_SIZE) AS total_size_mb FROM INFORMATION_SCHEMA.TIKV_REGION_STATUS s JOIN INFORMATION_SCHEMA.TIKV_REGION_PEERS p ON s.REGION_ID = p.REGION_ID WHERE s.DB_NAME = 'blog_prd' AND s.TABLE_NAME = 'articles' AND p.IS_LEADER = 1 GROUP BY p.STORE_ID ORDER BY total_keys DESC; ``` ```text +----------+--------------+------------+---------------+ | STORE_ID | region_count | total_keys | total_size_mb | +----------+--------------+------------+---------------+ | 4 | 5 | 5 | 20 | +----------+--------------+------------+---------------+ ``` 読み方: - `IS_LEADER = 1` で絞っているため、**Leader を持つ Store だけが結果に出る**。Follower しか持たない Store はここから消える。 - `STORE_ID=4` の 1 行だけ返ってきた = **`articles` の Region の Leader が Store 4 にある**ことを意味する。 - ⚠ `region_count=5` を **「Region が 5 個」と読むのは誤り**。実体は 1 物理 Region で、`TIKV_REGION_STATUS` が `(region × インデックスキースペース)` ごとに行を返す挙動で 5 倍に膨らんでいる(後述「`region_count=5` の正体」)。 - `total_keys` / `total_size_mb` は `APPROXIMATE_*` 由来の推定値で、小さい表では大きく乖離する(実数は `SELECT COUNT(*)` で 130 行)。 ### クエリ 2: Store ごとの Leader / Follower 内訳 ```sql SELECT p.STORE_ID, SUM(p.IS_LEADER = 1) AS leader_cnt, SUM(p.IS_LEADER = 0) AS follower_cnt, COUNT(*) AS peer_cnt FROM INFORMATION_SCHEMA.TIKV_REGION_STATUS s JOIN INFORMATION_SCHEMA.TIKV_REGION_PEERS p ON s.REGION_ID = p.REGION_ID WHERE s.DB_NAME = 'blog_prd' AND s.TABLE_NAME = 'articles' GROUP BY p.STORE_ID ORDER BY p.STORE_ID; ``` ```text +----------+------------+--------------+----------+ | STORE_ID | leader_cnt | follower_cnt | peer_cnt | +----------+------------+--------------+----------+ | 1 | 0 | 5 | 5 | | 4 | 5 | 0 | 5 | | 5 | 0 | 5 | 5 | +----------+------------+--------------+----------+ ``` 読み方: - `IS_LEADER` の条件を外し、Leader / Follower 両方を Store 別にカウントしている。 - 3 Store 全てに行が出る = **`articles` の唯一の Region (REGION_ID=384) は 3 Peer 構成で、各 Store に 1 Peer ずつ正しく分散**されている。冗長性 OK。 - `peer_cnt=5` / `follower_cnt=5` も上記と同じく `(region × keyspace)` 多重化のアーティファクト。実体は 1 Region。 - `leader_cnt` を見ると **Leader は Store 4、Follower は Store 1 と 5**。 ### クエリ 1 と 2 の関係 - クエリ 1 は **Leader 視点での集中度**を一発で見るための切り口(結果が 1 行に絞られる)。 - クエリ 2 は **全体の分散と偏り**を俯瞰するための切り口(Store ごとの内訳が出る)。 - 並べて読むと「レプリカ配置は健全、Leader 1 個が Store 4 に乗っている」という構図が分かる。 ### `region_count=5` の正体(重要) `SHOW TABLE blog_prd.articles REGIONS` で直接調べると **物理 Region は 1 個 (REGION_ID=384)** しかない。 ```text mysql> SHOW TABLE blog_prd.articles REGIONS\G *************************** 1. row *************************** REGION_ID: 384 START_KEY: 72000001 END_KEY: t_281474976710654_ LEADER_ID: 386 LEADER_STORE_ID: 4 PEERS: 385, 386, 387 SCATTERING: 0 WRITTEN_BYTES: 42 READ_BYTES: 245770 APPROXIMATE_SIZE(MB): 4 APPROXIMATE_KEYS: 1 1 row in set (0.00 sec) ``` ### なぜ「Region が 5 個」に見えていたか `INFORMATION_SCHEMA.TIKV_REGION_STATUS` の動作は **「1 物理 Region がカバーする (テーブル / インデックス) ごとに 1 行を返す」**。`articles` が論理的に持つ「テーブル / インデックス」の単位は何個か、を `IS_INDEX` / `INDEX_NAME` 列を含めて引いてみれば、5 行の正体が一発で分かる: ```sql SELECT REGION_ID, IS_INDEX, INDEX_ID, INDEX_NAME FROM INFORMATION_SCHEMA.TIKV_REGION_STATUS WHERE DB_NAME = 'blog_prd' AND TABLE_NAME = 'articles'; ``` ```text +-----------+----------+----------+----------------------------------+ | REGION_ID | IS_INDEX | INDEX_ID | INDEX_NAME | +-----------+----------+----------+----------------------------------+ | 384 | 1 | 1 | PRIMARY | | 384 | 1 | 2 | uq_articles_slug | | 384 | 1 | 3 | idx_articles_user_id | | 384 | 1 | 4 | idx_articles_status_published_at | | 384 | 0 | NULL | NULL | ← 行データ本体 +-----------+----------+----------+----------------------------------+ 5 rows in set (0.01 sec) ``` | # | エントリ | `IS_INDEX` | 物理キースペース | | --- | ---------------------------------- | ---------- | --------------------------------------------------------------- | | ① | `PRIMARY` | 1 | (clustered PK のため独立した keyspace なし。論理エントリのみ) | | ② | `uq_articles_slug` | 1 | `t_i2_...` | | ③ | `idx_articles_user_id` | 1 | `t_i3_...` | | ④ | `idx_articles_status_published_at` | 1 | `t_i4_...` | | ⑤ | 行データ(`INDEX_NAME=NULL`) | 0 | `t_r` | ポイントは ① と ⑤。**`articles` は CLUSTERED PK** なので、PRIMARY インデックスは独立した keyspace を持たず、行データそのもの (⑤) と同じ物理ストレージを共有する。それでも `TIKV_REGION_STATUS` ビューは「論理的に定義されている `PRIMARY` インデックス」を 1 行、「テーブルの行データ」をもう 1 行、と **両方を別エントリとして返す**仕様。 つまり「5」の内訳は実質「行データ 1 + セカンダリインデックス 3 + 論理 PRIMARY エントリ 1」で、物理的には 4 keyspace 分のデータが 1 Region に収まっている状態。5 行すべてが同一 `REGION_ID=384`・同一 `START_KEY` / `END_KEY` を持つのが**重複の動かぬ証拠**: ```text mysql> SELECT REGION_ID, START_KEY, END_KEY ... FROM INFORMATION_SCHEMA.TIKV_REGION_STATUS WHERE DB_NAME = 'blog_prd' AND TABLE_NAME = 'articles'\G 5 rows in set (0.01 sec) ← すべて REGION_ID=384、START/END_KEY も同一 ``` `region_count=5` / `peer_cnt=5` / `follower_cnt=5` などの「5」はすべて **同じ Region・同じ Peer を 5 通りの論理エントリ視点から数え直しただけ**。実体としては Region 1 個・Peer 3 個(Leader 1 + Follower 2)。 ### 物理 Region 数の正しい数え方 | 方法 | 結果 | 正しさ | | -------------------------------------------------------------------- | ---- | ---------------------- | | `SELECT COUNT(*) FROM TIKV_REGION_STATUS WHERE ...` | 5 | ❌ keyspace 多重化込み | | `SELECT COUNT(DISTINCT REGION_ID) FROM TIKV_REGION_STATUS WHERE ...` | 1 | ✅ | | `SHOW TABLE blog_prd.articles REGIONS` の行数 | 1 | ✅ | ### 所見: なぜ Leader が「偏っている」か `articles` は **1 Region しか持っていない**ので、そもそも Leader は 1 個しか存在せず、「偏り」と呼ぶには小さすぎる。Region のサイズ (~4MB) が TiKV の自動分割閾値 (~96MB) に到底届いていないので、分割が走らず Leader も増えない。 データが増えて自動分割が走れば(数十 MB 規模超)Region 数も増え、PD が複数 Store に Leader を分散させ始める。現フェーズでは健全な挙動。 実際の読み取り処理は Leader 経由なので、現状では本クエリの TiKV 側コストはすべて **`STORE_ID=4` の TiKV ノード(Region 384 の Leader)に集中**していることに注意。負荷観測時はまず Store 4 のメトリクスを見るべき。 ### どのレコードがどの Region にあるか `articles` の物理 Region は 1 個なので、**全 130 行が REGION_ID=384 に収まっている**。1 Region がカバーするキー範囲(hex で `7200000100000000FB` から `748000FFFFFFFFFFFFFE...`)に PK (`article_id` の UUID) が全部含まれる。 行ごとに所属 Region を引きたい場合は `SHOW TABLE ... REGIONS` の `START_KEY` / `END_KEY` と各行の PK を比較するか、TiDB Dashboard の Key Visualizer を使う。Region が複数に分割された将来は、`TIDB_DECODE_KEY()` でキーを `article_id` 文字列に戻して突き合わせるのが定石。 ## クラスタ全体の Store 別配置 `articles` 単独ではなく **クラスタ全体(システム表含む)の Region / Leader 分布**を `INFORMATION_SCHEMA.TIKV_STORE_STATUS` で確認した。これは PD が各 Store について保持しているメトリクスで、`balance-leader-scheduler` の判断材料そのもの。 ### クエリと結果 ```sql SELECT STORE_ID, LEADER_COUNT, REGION_COUNT, LEADER_SCORE, REGION_SCORE FROM INFORMATION_SCHEMA.TIKV_STORE_STATUS ORDER BY STORE_ID; ``` ```text +----------+--------------+--------------+--------------+--------------------+ | STORE_ID | LEADER_COUNT | REGION_COUNT | LEADER_SCORE | REGION_SCORE | +----------+--------------+--------------+--------------+--------------------+ | 1 | 3 | 6 | 3 | 15.832994492046943 | | 4 | 3 | 6 | 3 | 15.826141336779038 | | 5 | 0 | 6 | 0 | 15.827403321970076 | +----------+--------------+--------------+--------------+--------------------+ ``` | カラム | 意味 | | -------------- | ------------------------------------------------------------------------------ | | `LEADER_COUNT` | Store が Leader を持つ Region 数(全テーブル合算) | | `REGION_COUNT` | Store が(Leader / Follower 問わず)Peer を持つ Region 数 | | `LEADER_SCORE` | PD の Leader バランサー用スコア。既定戦略 `byCount` では `LEADER_COUNT` と同値 | | `REGION_SCORE` | Region バランサー用スコア。ディスク使用率・キャパシティから算出 | ### 観察 1. **クラスタ全体で物理 Region は 6 個、各 Store が全 Region の Peer を保持**(レプリカ数 3 × Store 3 = 18 Peer / 6 Region)。内訳は `articles` 1 Region + 他 5 Region(`users` / `tags` / `articles_tags` / システム表など)。 2. **`REGION_SCORE` はほぼ同値(15.82〜15.83)= データ量はきれいに分散**。冗長性・容量バランスは健全。 3. **Leader 分布 (3/3/0)**。Store 4 の 3 個のうち 1 個が `articles` の Region 384、残り 2 個と Store 1 の 3 個は他テーブル / システム表のもの。前述の「`articles` 単独で 5/0/0」は `TIKV_REGION_STATUS` の多重化アーティファクトで、実際には `articles` の Leader は最初から Store 4 に **1 個** 載っているだけ。PD が `balance-leader-scheduler` で再配置した形跡は無く、初期配置のままと考えられる。 4. **⚠ Store 5 だけは Leader 数 0**。これが新しい問い。 ### Store 5 に Leader が無い原因の特定 詳細メトリクスと配置ルールを併せて確認: ```text SELECT STORE_ID, ADDRESS, STORE_STATE_NAME, LABEL, LEADER_WEIGHT, LEADER_SIZE, REGION_WEIGHT, UPTIME FROM INFORMATION_SCHEMA.TIKV_STORE_STATUS ORDER BY STORE_ID\G SHOW PLACEMENT; ``` ```text *************************** 1. row *************************** STORE_ID: 1 ADDRESS: basic-tikv-2.basic-tikv-peer.tidb-cluster.svc:20160 STORE_STATE_NAME: Up LABEL: null LEADER_WEIGHT: 1 LEADER_SIZE: 6 REGION_WEIGHT: 1 UPTIME: 5h8m52.818864498s *************************** 2. row *************************** STORE_ID: 4 ADDRESS: basic-tikv-1.basic-tikv-peer.tidb-cluster.svc:20160 STORE_STATE_NAME: Up LABEL: null LEADER_WEIGHT: 1 LEADER_SIZE: 6 REGION_WEIGHT: 1 UPTIME: 5h8m32.8394295s *************************** 3. row *************************** STORE_ID: 5 ADDRESS: basic-tikv-0.basic-tikv-peer.tidb-cluster.svc:20160 STORE_STATE_NAME: Up LABEL: null LEADER_WEIGHT: 1 LEADER_SIZE: 0 REGION_WEIGHT: 1 UPTIME: 5h7m32.260283592s ``` ```text SHOW PLACEMENT; → Empty set ``` 設定面の容疑をすべて潰したところ、**Leader 不在は「設定」由来ではなかった**。 | 容疑 | 結論 | | ---------------------------------------------------------- | -------------------------------------------------- | | Placement rule で Store 5 を Leader 候補から除外している? | ❌ `SHOW PLACEMENT` 空 | | Store にラベルが付いていて配置制約がある? | ❌ 全 Store `LABEL: null` | | `LEADER_WEIGHT=0` で手動制御している? | ❌ 全 Store `LEADER_WEIGHT: 1` | | Store の状態異常(Offline / Disconnected)? | ❌ 全 Store `STORE_STATE_NAME: Up` | | 起動順序による初期偏り? | ✔ **Store 5 だけ ~1 分遅れて起動**(5h7m vs 5h8m) | ### 結論 Store 5 の起動がわずかに遅れている間にクラスタ全 6 Region の作成が走り、Stores 1 / 4 に Leader が確定。その後 PD は **`LEADER_SCORE` 差 3 程度(3 vs 0)では再配置の旨味なし** と判断して放置している、というのが最も整合する説明。これは小規模クラスタにおける `balance-leader-scheduler` の正常な収束結果で、設定ミスでもバグでもない。 Region 数が増えて(自動分割が走るくらいデータが溜まれば)Score 差が PD のトレラント閾値を超えるため、自然に Store 5 にも Leader が割り当てられるはず。即時に均衡化したいなら `pd-ctl operator add transfer-leader 5` で手動マイグレートも可能。 ## 開発環境(`blog_dev`)での再現 同じクエリを `blog_dev` に対して実行。スキーマは prd と完全に同一、行数は `articles=118` / `users=1`。 ### 実行コマンド 接続は本番と同じ Tailnet 経由(`mysql -h tidb.${TAILNET} -P 4000 -u root blog_dev`)。 行数確認: ```sql SELECT COUNT(*) AS articles_count FROM articles; SELECT COUNT(*) AS users_count FROM users; ``` スキーマ確認: ```text SHOW CREATE TABLE articles\G ``` 実行計画(本ドキュメント上部の「対象クエリ」と同一、`EXPLAIN ANALYZE` を前置): ```sql EXPLAIN ANALYZE SELECT a.article_id, a.title, a.slug, a.user_id, a.status, a.`type`, a.published_at FROM articles a JOIN users u ON a.user_id = u.user_id WHERE a.status = 'published' AND a.`type` = 'tech' AND u.name = 'shuntaka' ORDER BY a.published_at DESC; ``` ### 実行プラン(`EXPLAIN ANALYZE`、合計 2.53ms) ``` id actRows task access object execution info Sort_9 37 root time:2.53ms └─HashJoin_26 37 root inner join (probe: articles, build: users 側だが Probe=Point_Get) ├─IndexLookUp_34(Build) 37 root │ ├─IndexRangeScan_31 78 cop[tikv] table:a, index:idx_articles_status_published_at(status, published_at) range:["published","published"], stats:pseudo │ └─Selection_33(Probe) 37 cop[tikv] eq(articles.type, "tech") │ └─TableRowIDScan_32 78 cop[tikv] table:a └─Point_Get_27(Probe) 1 root table:users, index:uq_users_name(name) ``` ### `ANALYZE TABLE articles` 適用後 dev で `ANALYZE TABLE articles` を実行してから同じクエリを再計測。 ```sql ANALYZE TABLE articles; -- その後、同じ EXPLAIN ANALYZE を再実行 ``` ``` id actRows task access object execution info Sort_10 37 root time:946.8µs └─HashJoin_28 37 root inner join (build: users, probe: articles) ├─Point_Get_29(Build) 1 root table:users, index:uq_users_name(name) 520.6µs └─TableReader_32(Probe) 37 root 785µs └─Selection_31 37 cop[tikv] eq(status,"published"), eq(type,"tech") └─TableFullScan_30 118 cop[tikv] table:a 430.1µs (proc) ``` - プランが `IndexLookUp` から **`TableFullScan` に切り替わり、prd と同型**になった。 - 合計時間も **2.53ms → 946.8µs** と高速化(推定行数 estRows=38.34 が actRows=37 に揃ったため optimizer が正しいコストを計算できた)。 - `stats:pseudo` ラベルも消えた。 ### prd との差分(ANALYZE 前) | 観点 | prd | dev | | ------------------------ | ----------------------------------------- | ------------------------------------------------- | | 行数 (`articles`) | 130 | 118 | | 統計 | 解析済み | `stats:pseudo`(未解析) | | 駆動側 | TableFullScan | IndexLookUp on `idx_articles_status_published_at` | | 合計時間 | 1.24ms | 2.53ms | | 「死んでるインデックス」 | `idx_articles_status_published_at` 不使用 | **逆に使われている** | 行数はほぼ同じなのにプランが分岐したのは、dev 側に `ANALYZE TABLE` が走っていないため。optimizer は統計が `pseudo` だと「インデックスは selective」と仮定してフォールバックするので、prd(手動 ANALYZE 済み)の TableFullScan 判断とは別の道を選んでいた。実際に dev でも ANALYZE を流したら prd と同型に揃った(上記参照)。 なお prd 側は本調査の事前に手動で `ANALYZE TABLE articles` を実行済み。それが「prd では `idx_articles_status_published_at` が選ばれない」という結果に直結している。 ### 含意 - 本番側の survey で「`idx_articles_status_published_at` が死んでる」と書いたのは **prd の手動 ANALYZE 後の統計に依存した結果**で、絶対的な評価ではない。ANALYZE 未実施だった dev では同じインデックスが採用されていた。 - ANALYZE の有無で 2.7 倍ほどの差が出た(dev: 2.53ms → 946.8µs)。本番でも `ANALYZE TABLE articles` を周期実行する運用になっているか確認が必要(TiDB は auto-analyze デフォルト ON だが、小さい表だと閾値で発火しないことがある。今回は手動投入で初めて統計が入った)。 ## 所見 ### Leader 集中は実体としては「Region が 1 個しか無い」だけ TiKV の Region 分割閾値は既定 ~96MB。`articles` は `SHOW TABLE REGIONS` 上で `APPROXIMATE_SIZE(MB)=4` 程度しかなく、自動分割が走らないまま **1 物理 Region (REGION_ID=384)** で動いている。そもそも Leader が 1 個しか存在しないので「集中」と表現するのは過剰で、単に「分割が走っていない」状態。書き込み TPS / 読み込み QPS とも個人ブログ規模では十分捌ける。 データが増えて自動分割が走り出すまでは静観で OK。 ### PK が `char(36) DEFAULT (uuid())` の影響 ランダム UUID なのでクラスタード PK のロカリティは無いが、その代わり **書き込みは自然に分散**する。AUTO_RANDOM / PRE_SPLIT_REGIONS の追加対応は不要。 ### `idx_articles_status_published_at` が死んでいる `(status, published_at)` の複合インデックスが定義されているが、本クエリでは optimizer に選ばれていない。WHERE 句に `type='tech'` が含まれるため、行数が増えた将来は `(status, type, published_at)` 系に組み直すか、`user_id` 絞り込みを活かすなら `(user_id, status, type, published_at DESC)` のカバリングインデックスに寄せる方が筋が良い。 ただし行数が 1k を大きく超えるまではフルスキャンに勝てない見込み。 ### survey ファイル側の留意点 - 元 `.sql` の 1 個目のクエリ(`TIKV_REGION_STATUS.LEADER_STORE_ID` を直接参照)は TiDB バージョンによっては列が無く動かない。`TIKV_REGION_PEERS` と JOIN する 2 個目以降のクエリが正解パターン。 - 取得結果の `total_keys` は approximate である旨を本ドキュメントで補足済み。 - `INFORMATION_SCHEMA.TIKV_REGION_STATUS` は `(region × インデックスキースペース)` 単位で行を返すため、`articles` のように 1 物理 Region でも 5 行に膨らんで見える。物理 Region 数は **`SHOW TABLE ... REGIONS` を使うか `COUNT(DISTINCT REGION_ID)` で重複排除** すること。 ## 次のアクション - [ ] 行数が 1k を超えた時点で再度 `EXPLAIN ANALYZE` を取り直す - [ ] 1k 超で TableFullScan のまま遅延が見えたら `(user_id, status, type, published_at DESC)` を追加して再計測 - [ ] 自動分割が走って Region 数が増えたタイミング(数十 MB 規模超)で Leader 分布を再確認し、Store 5 にも Leader が割り当てられるかを観測 - [ ] 本番の auto-analyze 設定(`tidb_auto_analyze_ratio`, `tidb_auto_analyze_start_time` / `_end_time`)と最新 ANALYZE 履歴(`mysql.analyze_jobs`)を確認し、放っておいても統計が腐らない運用になっているか検証 - [ ] Region 数が増えても Store 5 への Leader 割り当てが起きない場合は `pd-ctl scheduler show` / `pd-ctl config show schedule` で `leader-schedule-limit` や `tolerant-size-ratio` を確認 - [ ] 概念図 `region-leader-store.png` を「articles は 1 物理 Region」「将来の自動分割後の予測図は別に併記」の構成に差し替え(別途進行中)