TiDB 移行後の本番 articles 実行計画とリージョン分布
対象: 本番 TiDB クラスタ (
blog_prd)調査日: 2026-06-28
元クエリ:
2026-06-28-tidb.sql
DSQL → TiDB 移行直後の本番状態を確認するための survey。記事一覧クエリの実行計画と articles テーブルのリージョン分布を見て、現時点でのホットスポット懸念とインデックス設計の妥当性を評価した。
対象クエリ
公開済み tech 記事の一覧(shuntaka ユーザー向け)。
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 スキーマ(本番)
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)で配置されているかを確認する。
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 の正体」参照)。
参考: 当初の概念モデル図。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 台。本クラスタは |
Region |
TiKV 内データをキー範囲で切った 1 シャード。デフォルト ~96MB で自動分割 |
Peer |
Region のレプリカ。本クラスタはレプリカ数 3 で運用しているため、1 Region = 3 Peer |
Leader |
3 Peer のうち読み書きを実際に処理する 1 個。残り 2 個は Follower として待機(既定では読み取りも Leader 経由) |
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 への通信が |
同じく再選挙 |
PD が |
指定した Peer に Leader 役が移る(Peer 自体は移動しない) |
PD の |
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 の集計
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;
+----------+--------------+------------+---------------+
| 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 内訳
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;
+----------+------------+--------------+----------+
| 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) しかない。
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 行の正体が一発で分かる:
SELECT REGION_ID, IS_INDEX, INDEX_ID, INDEX_NAME
FROM INFORMATION_SCHEMA.TIKV_REGION_STATUS
WHERE DB_NAME = 'blog_prd' AND TABLE_NAME = 'articles';
+-----------+----------+----------+----------------------------------+
| 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)
# |
エントリ |
|
物理キースペース |
|---|---|---|---|
① |
|
1 |
(clustered PK のため独立した keyspace なし。論理エントリのみ) |
② |
|
1 |
|
③ |
|
1 |
|
④ |
|
1 |
|
⑤ |
行データ( |
0 |
|
ポイントは ① と ⑤。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 を持つのが重複の動かぬ証拠:
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 数の正しい数え方
方法 |
結果 |
正しさ |
|---|---|---|
|
5 |
❌ keyspace 多重化込み |
|
1 |
✅ |
|
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 の判断材料そのもの。
クエリと結果
SELECT STORE_ID, LEADER_COUNT, REGION_COUNT, LEADER_SCORE, REGION_SCORE
FROM INFORMATION_SCHEMA.TIKV_STORE_STATUS
ORDER BY STORE_ID;
+----------+--------------+--------------+--------------+--------------------+
| 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 |
+----------+--------------+--------------+--------------+--------------------+
カラム |
意味 |
|---|---|
|
Store が Leader を持つ Region 数(全テーブル合算) |
|
Store が(Leader / Follower 問わず)Peer を持つ Region 数 |
|
PD の Leader バランサー用スコア。既定戦略 |
|
Region バランサー用スコア。ディスク使用率・キャパシティから算出 |
観察
クラスタ全体で物理 Region は 6 個、各 Store が全 Region の Peer を保持(レプリカ数 3 × Store 3 = 18 Peer / 6 Region)。内訳は
articles1 Region + 他 5 Region(users/tags/articles_tags/ システム表など)。REGION_SCOREはほぼ同値(15.82〜15.83)= データ量はきれいに分散。冗長性・容量バランスは健全。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で再配置した形跡は無く、初期配置のままと考えられる。⚠ Store 5 だけは Leader 数 0。これが新しい問い。
Store 5 に Leader が無い原因の特定
詳細メトリクスと配置ルールを併せて確認:
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;
*************************** 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
SHOW PLACEMENT; → Empty set
設定面の容疑をすべて潰したところ、Leader 不在は「設定」由来ではなかった。
容疑 |
結論 |
|---|---|
Placement rule で Store 5 を Leader 候補から除外している? |
❌ |
Store にラベルが付いていて配置制約がある? |
❌ 全 Store |
|
❌ 全 Store |
Store の状態異常(Offline / Disconnected)? |
❌ 全 Store |
起動順序による初期偏り? |
✔ 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 <region_id> 5 で手動マイグレートも可能。
開発環境(blog_dev)での再現
同じクエリを blog_dev に対して実行。スキーマは prd と完全に同一、行数は articles=118 / users=1。
実行コマンド
接続は本番と同じ Tailnet 経由(mysql -h tidb.${TAILNET} -P 4000 -u root blog_dev)。
行数確認:
SELECT COUNT(*) AS articles_count FROM articles;
SELECT COUNT(*) AS users_count FROM users;
スキーマ確認:
SHOW CREATE TABLE articles\G
実行計画(本ドキュメント上部の「対象クエリ」と同一、EXPLAIN ANALYZE を前置):
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 を実行してから同じクエリを再計測。
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 |
|---|---|---|
行数 ( |
130 |
118 |
統計 |
解析済み |
|
駆動側 |
TableFullScan |
IndexLookUp on |
合計時間 |
1.24ms |
2.53ms |
「死んでるインデックス」 |
|
逆に使われている |
行数はほぼ同じなのにプランが分岐したのは、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」「将来の自動分割後の予測図は別に併記」の構成に差し替え(別途進行中)