TiDB 移行後の本番 articles 実行計画とリージョン分布

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_nameusersPoint_Getidx_articles_user_idIndexLookup を駆動するプラン。

実行プラン(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)で配置されているかを確認する。

articles テーブルの実 Region 配置(訂正版)

blog_prd.articles の実 Region 配置(訂正版)。現実は 1 物理 Region (REGION_ID=384) に全 130 行が収まり、その 3 Peer が Store 1 / 4 / 5 に分散して Leader が Store 4 に乗っている。INFORMATION_SCHEMA.TIKV_REGION_STATUSregion_count=5 に見えるのは、(region × index keyspace) ごとに行が多重化されるため(後述「region_count=5 の正体」参照)。

TiKV Region / Store / Leader の概念モデル(参考)

参考: 当初の概念モデル図。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 経由)

TiDB の内部構造 — Region / Peer / Leader / Follower / Store の関係

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 の集計

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_mbAPPROXIMATE_* 由来の推定値で、小さい表では大きく乖離する(実数は 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)

#

エントリ

IS_INDEX

物理キースペース

PRIMARY

1

(clustered PK のため独立した keyspace なし。論理エントリのみ)

uq_articles_slug

1

t<tableID>_i2_...

idx_articles_user_id

1

t<tableID>_i3_...

idx_articles_status_published_at

1

t<tableID>_i4_...

行データ(INDEX_NAME=NULL

0

t<tableID>_r<encoded clustered PK>

ポイントは ① と ⑤。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 数の正しい数え方

方法

結果

正しさ

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 が「偏っている」か

articles1 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 ... REGIONSSTART_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 |
+----------+--------------+--------------+--------------+--------------------+

カラム

意味

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 が無い原因の特定

詳細メトリクスと配置ルールを併せて確認:

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 候補から除外している?

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 <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

行数 (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。articlesSHOW 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 scheduleleader-schedule-limittolerant-size-ratio を確認

  • [ ] 概念図 region-leader-store.png を「articles は 1 物理 Region」「将来の自動分割後の予測図は別に併記」の構成に差し替え(別途進行中)