
速いSQLは安いSQLなのか? TiDB Cloudで検証してみた
はじめに
TiDB Cloud Starter の料金は、主に SQL が消費した Request Unit (RU) の量で決まります。そのため、SQL を速くすれば料金も下がると考えたくなります。
本記事では、同じ結果を得る SQL を 3 種類の方法で速くし、実行時間と RU を同じ試行で計測して、速さと料金の関係を確認します。
実行時間とともに RU も大きく削減できたのは、SQL が読むデータを減らして速くした場合だけでした。並列度を上げて速くした場合の RU は変わらず、ロック待ちをなくした場合は、実行時間を 98.5% 削減できたのに対して RU の削減は 50.0% でした。
TiDB Cloud とは
TiDB Cloud は、MySQL 互換の分散データベース TiDB のマネージドサービスです。Starter は、使った分だけ課金されるプランで、条件を満たすインスタンスには月 5,000 万 RU の無料枠が与えられます。
検証環境
- TiDB Cloud Starter (AWS 東京リージョン)
- TiDB v8.5.3
- macOS
- Python 3.13
- PyMySQL 2.2.8
- 計測実施日: 2026 年 10 月 9 日
対象読者
- TiDB Cloud Starter を使っている開発者
- SQL のチューニングと料金の関係を知りたい方
参照
- TiDB Cloud Starter Pricing Details (PingCAP)
- Use Resource Control to Achieve Resource Group Limitation and Flow Control (PingCAP)
- System Variables (PingCAP)
背景
Starter の料金ページ では、RU の算出方法が次のように示されています。
- 読み取り: ストレージ読み取りのバッチ 2 回、読み取りリクエスト 8 回、読み取りデータ 64 KiB のそれぞれで 1 RU
- 書き込み: 書き込みのバッチ 2 回、書き込みリクエスト 2 回、書き込みデータ 2 KiB のそれぞれで 1 RU
- SQL の CPU 時間: 3 ms で 1 RU
- ネットワーク送信: パブリックエンドポイントで 1 KiB あたり 1 RU
この式に現れるのは処理の量です。一方、処理を並列に進めて速くなった時間や、ロックの解放を待っていた時間は、式には出てきません。であれば RU は、高速化の方法によって違う結果を示すはずです。SQL を速くすると、Starter の RU と料金は下がるのでしょうか。下がるとしたら、それはどのような高速化でしょうか。 3 種類の高速化で確かめていきます。
検証方法
題材は、EC サイトの注文履歴画面とします。
ある顧客の支払い済み注文を新しい順に 100 件、注文ごとの合計金額つきで返します。この処理を次の 3 組で速くし、速くする前と後の実行時間と RU を比べます。
| 組 | 速くする方法 | 比べる 2 つの条件 |
|---|---|---|
| 1 | 読むデータを減らす | 全注文を集計してから 100 件に絞る SQL (A) と、先に 100 件に絞ってから集計する SQL (B) |
| 2 | 並列度を上げる | A を tidb_executor_concurrency = 1 で実行した場合と、既定値の 5 で実行した場合 |
| 3 | ロック待ちをなくす | 同じ行を別のセッションがロックしている場合と、していない場合 |
テーブルとデータ
注文の orders と、注文明細の order_items を作ります。
注文は 10,000 件で、そのうち 5,000 件が顧客 42 の支払い済み注文です。明細は注文ごとに 10 件で、合計 100,000 件です。
orders には、顧客・状態・注文日時の順に並ぶ複合インデックスを張ります。
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
customer_id INT NOT NULL,
status TINYINT NOT NULL,
ordered_at DATETIME NOT NULL,
note VARCHAR(128) NOT NULL,
KEY idx_history (customer_id, status, ordered_at DESC, id DESC)
);
CREATE TABLE order_items (
order_id BIGINT NOT NULL,
line_no TINYINT NOT NULL,
qty TINYINT NOT NULL,
unit_price INT NOT NULL,
PRIMARY KEY (order_id, line_no)
);
データの投入は、後述のスクリプトの setup で行います。
投入したあとは、stats で統計情報が反映されたことを確認してから計測します。統計情報の反映前は、実行計画が統計情報のない推定 (stats:pseudo) で作られるためです。(※今回の環境では、投入から約 2 分後に自動で反映されました。)
$ python ru_bench.py setup
orders, order_items: (10000, 100000)
$ python ru_bench.py stats
A stats:pseudo False
B stats:pseudo False
比較する SQL
A は、顧客 42 の支払い済み注文 5,000 件をすべて明細と結合して集計し、そのあとで新しい順に 100 件に絞ります。
SELECT o.id, o.ordered_at, o.note,
COALESCE(SUM(i.qty * i.unit_price), 0) AS total_yen
FROM orders AS o
LEFT JOIN order_items AS i ON i.order_id = o.id
WHERE o.customer_id = 42 AND o.status = 1
GROUP BY o.id, o.ordered_at, o.note
ORDER BY o.ordered_at DESC, o.id DESC
LIMIT 100;
B は、先にインデックスで新しい順の 100 件を選び、その 100 件だけを明細と結合して集計します。
SELECT p.id, p.ordered_at, p.note,
COALESCE(SUM(i.qty * i.unit_price), 0) AS total_yen
FROM (
SELECT id, ordered_at, note FROM orders
WHERE customer_id = 42 AND status = 1
ORDER BY ordered_at DESC, id DESC
LIMIT 100
) AS p
LEFT JOIN order_items AS i ON i.order_id = p.id
GROUP BY p.id, p.ordered_at, p.note
ORDER BY p.ordered_at DESC, p.id DESC;
A と B が同じ 100 行を返すことは、計測の前に結果全体を比べて確認しました。(※相関サブクエリで注文ごとに合計を取る書き方も試しましたが、TiDB が B と同じ実行計画に書き換えたため、比較から外しています。)
組 2 の tidb_executor_concurrency は、SQL の実行時に演算子が使う並列度を決めるシステム変数で、既定値は 5 です。並列度 1 と 5 で A の実行計画が同じであることは、スクリプトの中で EXPLAIN の結果を比べて確認しています。
組 3 では、次の SQL を明示的なトランザクションの中で実行します。
SELECT id, note FROM orders WHERE id = 1 FOR UPDATE;
Starter の既定のトランザクションは悲観的トランザクションです。別のセッションが同じ行を FOR UPDATE でロックしていると、ロックが解放されるまで待ちます。ロックしている場合は、別のセッションが行をロックしてから 1 秒後に ROLLBACK で解放します。
計測の手順
1 回の実行ごとに、クライアントで測った経過時間と RU を記録します。経過時間は、SQL を送ってから全行を受け取るまでの時間です。RU は、実行の直後に同じセッションで SELECT @@tidb_last_query_info を実行し、返る JSON の ru_consumption から取得します。
$ python ru_bench.py bench ab
{"i": 0, "cond": "A", "sec": 0.106, "ru": 53.035}
{"i": 0, "cond": "B", "sec": 0.0287, "ru": 3.752}
(中略)
各組とも、ウォームアップとして 2 つの条件を 3 回ずつ実行したあと、2 つの条件を交互に 20 回ずつ実行します。結果は 20 回の中央値と、四分位 (下位 25% の値と上位 25% の値) で示します。
計測に使ったスクリプトの全体は次のとおりです。接続情報は、TiDB Cloud のコンソールの Connect 画面で確認した値を環境変数で渡します。
ru_bench.py
"""TiDB Cloud Starter で、SQL の実行時間と RU を対にして計測する。
接続情報は環境変数 TIDB_HOST, TIDB_PORT, TIDB_USER, TIDB_PASSWORD から読む。
python ru_bench.py setup # テーブル作成とデータ投入
python ru_bench.py stats # 統計情報の反映を確認 (stats:pseudo が False なら反映済み)
python ru_bench.py bench ab|conc|lock # 計測
"""
import json
import os
import ssl
import statistics
import sys
import threading
import time
import pymysql
DB = "ru_lab"
SETUP = [
f"CREATE DATABASE IF NOT EXISTS {DB}",
f"USE {DB}",
"CREATE TABLE IF NOT EXISTS digit (n INT PRIMARY KEY)",
"INSERT IGNORE INTO digit VALUES (0),(1),(2),(3),(4),(5),(6),(7),(8),(9)",
"""CREATE TABLE IF NOT EXISTS orders (
id BIGINT PRIMARY KEY,
customer_id INT NOT NULL,
status TINYINT NOT NULL,
ordered_at DATETIME NOT NULL,
note VARCHAR(128) NOT NULL,
KEY idx_history (customer_id, status, ordered_at DESC, id DESC)
)""",
"""CREATE TABLE IF NOT EXISTS order_items (
order_id BIGINT NOT NULL,
line_no TINYINT NOT NULL,
qty TINYINT NOT NULL,
unit_price INT NOT NULL,
PRIMARY KEY (order_id, line_no)
)""",
]
# 注文 10,000 件 (うち 1〜5,000 は顧客 42 の支払い済み注文) と、注文ごとに 10 件の明細
LOAD = [
"""INSERT INTO orders
SELECT x.id,
IF(x.id <= 5000, 42, IF(MOD(x.id, 100) = 42, 43, MOD(x.id, 100))),
IF(x.id <= 5000, 1, IF(MOD(x.id, 7) = 0, 0, 1)),
DATE_ADD('2026-09-01', INTERVAL MOD(x.id, 30 * 24 * 60) MINUTE),
RPAD(CONCAT('memo-', x.id), 128, 'x')
FROM (
SELECT 1 + a.n + 10*b.n + 100*c.n + 1000*d.n AS id
FROM digit a CROSS JOIN digit b CROSS JOIN digit c CROSS JOIN digit d
) AS x""",
"""INSERT INTO order_items
SELECT o.id, e.n + 1, 1 + MOD(e.n, 3), 100 + MOD(o.id + e.n, 500)
FROM orders AS o CROSS JOIN digit AS e""",
]
# 顧客 42 の支払い済み注文を新しい順に 100 件、合計金額つきで返す
QUERY_A = """SELECT o.id, o.ordered_at, o.note,
COALESCE(SUM(i.qty * i.unit_price), 0) AS total_yen
FROM orders AS o
LEFT JOIN order_items AS i ON i.order_id = o.id
WHERE o.customer_id = 42 AND o.status = 1
GROUP BY o.id, o.ordered_at, o.note
ORDER BY o.ordered_at DESC, o.id DESC
LIMIT 100"""
QUERY_B = """SELECT p.id, p.ordered_at, p.note,
COALESCE(SUM(i.qty * i.unit_price), 0) AS total_yen
FROM (
SELECT id, ordered_at, note FROM orders
WHERE customer_id = 42 AND status = 1
ORDER BY ordered_at DESC, id DESC
LIMIT 100
) AS p
LEFT JOIN order_items AS i ON i.order_id = p.id
GROUP BY p.id, p.ordered_at, p.note
ORDER BY p.ordered_at DESC, p.id DESC"""
LOCK_SQL = "SELECT id, note FROM orders WHERE id = 1 FOR UPDATE"
LOCK_HOLD_SEC = 1.0
def connect(database=DB):
return pymysql.connect(
host=os.environ["TIDB_HOST"],
port=int(os.environ.get("TIDB_PORT", "4000")),
user=os.environ["TIDB_USER"],
password=os.environ["TIDB_PASSWORD"],
database=database,
ssl=ssl.create_default_context(),
autocommit=True,
)
def measure(cur, sql):
"""SQL を実行して全行を受け取るまでの秒数と、その SQL の RU を返す。"""
start = time.perf_counter()
cur.execute(sql)
cur.fetchall()
sec = time.perf_counter() - start
cur.execute("SELECT @@tidb_last_query_info")
return sec, json.loads(cur.fetchone()[0])["ru_consumption"]
def setup():
cur = connect(None).cursor()
for sql in SETUP:
cur.execute(sql)
cur.execute("SELECT COUNT(*) FROM orders")
if cur.fetchone()[0] == 0:
for sql in LOAD:
cur.execute(sql)
cur.execute("SELECT (SELECT COUNT(*) FROM orders), (SELECT COUNT(*) FROM order_items)")
print("orders, order_items:", cur.fetchone())
def stats():
cur = connect().cursor()
for name, sql in (("A", QUERY_A), ("B", QUERY_B)):
cur.execute("EXPLAIN " + sql)
print(name, "stats:pseudo", "stats:pseudo" in str(cur.fetchall()))
def plan(cur, sql):
"""推定行数を除いた実行計画を返す。"""
cur.execute("EXPLAIN " + sql)
return [row[:1] + row[2:] for row in cur.fetchall()]
def pair_ab():
cur = connect().cursor()
queries = {"A": QUERY_A, "B": QUERY_B}
return list(queries), lambda cond: measure(cur, queries[cond])
def pair_conc():
cur = connect().cursor()
def run(concurrency):
cur.execute(f"SET SESSION tidb_executor_concurrency = {concurrency}")
return measure(cur, QUERY_A)
plans = []
for concurrency in (1, 5):
cur.execute(f"SET SESSION tidb_executor_concurrency = {concurrency}")
plans.append(plan(cur, QUERY_A))
print("same plan:", plans[0] == plans[1])
return [1, 5], run
def pair_lock():
holder = connect().cursor()
waiter = connect().cursor()
def run(cond):
if cond == "wait":
# 別のセッションが同じ行をロックし、LOCK_HOLD_SEC 秒後に解放する
holder.execute("BEGIN")
holder.execute(LOCK_SQL)
holder.fetchall()
timer = threading.Timer(LOCK_HOLD_SEC, lambda: holder.execute("ROLLBACK"))
timer.start()
waiter.execute("BEGIN")
result = measure(waiter, LOCK_SQL)
waiter.execute("ROLLBACK")
if cond == "wait":
timer.join()
return result
return ["no_wait", "wait"], run
def bench(pair, warmup=3, repeat=20):
conds, run = {"ab": pair_ab, "conc": pair_conc, "lock": pair_lock}[pair]()
for _ in range(warmup):
for cond in conds:
run(cond)
results = {cond: [] for cond in conds}
for i in range(repeat):
for cond in conds:
sec, ru = run(cond)
results[cond].append((sec, ru))
print(json.dumps({"i": i, "cond": cond, "sec": round(sec, 4), "ru": round(ru, 3)}))
for cond, rows in results.items():
for label, values in (("sec", [r[0] for r in rows]), ("ru", [r[1] for r in rows])):
q1, median, q3 = statistics.quantiles(values, n=4, method="inclusive")
print(f"{cond} {label}: median {median:.4g} (q1 {q1:.4g}, q3 {q3:.4g})")
if __name__ == "__main__":
{"setup": setup, "stats": stats, "bench": lambda: bench(sys.argv[2])}[sys.argv[1]]()
検証結果
読むデータを減らした場合
A と B を交互に 20 回ずつ実行した結果は次のとおりです。
$ python ru_bench.py bench ab
(中略)
A sec: median 0.1055 (q1 0.09584, q3 0.1099)
A ru: median 52.67 (q1 52.27, q3 53.08)
B sec: median 0.02899 (q1 0.0275, q3 0.03186)
B ru: median 3.742 (q1 3.727, q3 3.753)
| SQL | 実行時間 (秒) | 消費量 (RU) |
|---|---|---|
| A (全件を集計してから 100 件に絞る) | 0.106 | 52.67 |
| B (100 件に絞ってから集計する) | 0.029 | 3.74 |
B では、A に比べて実行時間を 72.5% 削減でき、20 回のすべてで B が A より短くなりました。RU は 92.9% 削減でき、実行時間よりも大きく下がりました。
並列度を上げた場合
A を並列度 1 と 5 で交互に 20 回ずつ実行した結果は次のとおりです。same plan: True は、2 つの並列度で実行計画が同じだったことを示します。
$ python ru_bench.py bench conc
same plan: True
(中略)
1 sec: median 0.1371 (q1 0.13, q3 0.1429)
1 ru: median 52.2 (q1 51.96, q3 52.47)
5 sec: median 0.106 (q1 0.1003, q3 0.109)
5 ru: median 52.8 (q1 52.31, q3 53)
| 並列度 | 実行時間 (秒) | 消費量 (RU) |
|---|---|---|
| 1 | 0.137 | 52.20 |
| 5 (既定値) | 0.106 | 52.80 |
並列度 5 では、並列度 1 に比べて実行時間を 22.7% 削減でき、20 回のうち 18 回で並列度 5 のほうが短くなりました。一方で RU はどちらも約 52 で、並列度を上げても RU は変わりませんでした。
ロック待ちをなくした場合
同じ行のロックを待つ場合と待たない場合を、交互に 20 回ずつ実行した結果は次のとおりです。
$ python ru_bench.py bench lock
{"i": 0, "cond": "no_wait", "sec": 0.0147, "ru": 2.55}
{"i": 0, "cond": "wait", "sec": 1.0085, "ru": 5.1}
(中略)
no_wait sec: median 0.01509 (q1 0.01459, q3 0.01573)
no_wait ru: median 2.55 (q1 2.55, q3 2.55)
wait sec: median 1.007 (q1 1.004, q3 1.008)
wait ru: median 5.1 (q1 5.1, q3 5.1)
| 条件 | 実行時間 (秒) | 消費量 (RU) |
|---|---|---|
| ロックを待つ | 1.007 | 5.10 |
| ロックを待たない | 0.015 | 2.55 |
ロック待ちをなくすと、実行時間を 98.5% 削減できました。RU の削減は 5.10 から 2.55 への 50.0% で、実行時間ほどは下がりませんでした。 RU は 20 回とも同じ値でした。
料金への換算
計測した RU の中央値に、AWS 東京リージョンの単価 $0.12/100 万 RU を掛けて、各 SQL を 100 万回実行した場合の料金相当額を求めました。あわせて、インスタンスあたり月 5,000 万 RU の無料枠に収まる実行回数を示します。
| 条件 | 消費量 (RU) | 100 万回の料金 (USD) | 無料枠内の回数 (万回) |
|---|---|---|---|
| A | 52.67 | 6.32 | 95 |
| B | 3.74 | 0.45 | 1,336 |
| A を並列度 1 で実行 | 52.20 | 6.26 | 96 |
| ロックを待つ | 5.10 | 0.61 | 980 |
| ロックを待たない | 2.55 | 0.31 | 1,961 |
A を B に書き換えると、100 万回あたりの料金相当額は約 $6.32 から約 $0.45 になります。並列度による差はほとんどありません。
わかったことと考察
3 組の結果を、変更前の値からの削減率でまとめると次のようになります。(※マイナスの値は増加を表します。)
| 速くした方法 | 実行時間の削減率 (%) | 消費量の削減率 (%) |
|---|---|---|
| 読むデータを減らす (A → B) | 72.5 | 92.9 |
| 並列度を上げる (1 → 5) | 22.7 | -1.2 |
| ロック待ちをなくす | 98.5 | 50.0 |
RU が実行時間と同じように下がったのは、読むデータを減らした組 1 だけでした。A は 5,000 件の注文と、その明細を集計してから 100 件に絞ります。B は 100 件の注文と、その明細だけを集計します。RU の算出式が読み取りの量と CPU 時間で決まることを考えると、処理するデータの量が減ったことが、そのまま RU の差になったと考えられます。
並列度は、同じ量の処理を複数のスレッドに分けるだけで、処理の量は変えません。そのため、実行時間は短くなっても RU は変わらなかったと思われます。ロック待ちも、待っている時間そのものは処理の量に含まれないため、実行時間ほど RU が動かなかったのでしょう。
ここから、Starter で SQL のチューニングをするときは、目的に応じて見る指標を分けるとよさそうです。料金を下げたいなら、実行時間ではなく @@tidb_last_query_info などで RU を確認し、読むデータの量を減らせる箇所を探すのが近道でしょう。一方、並列度の調整やロック競合の解消は、応答時間の改善として価値がありますが、料金を下げる手段としての効果は小さいと思われます。
まとめ
TiDB Cloud Starter で、3 種類の方法で速くした SQL の実行時間と RU を計測しました。実行時間とともに RU も大きく削減できたのは読むデータを減らした場合だけで、並列度を上げた場合は実行時間を 22.7% 削減しても RU は変わらず、ロック待ちをなくした場合は実行時間を 98.5% 削減できたのに対して RU の削減は 50.0% でした。今回の検証で速さとともに料金も大きく下がったのは、処理するデータの量を減らして速くしたときでした。本記事が、TiDB Cloud で SQL の性能と料金を考える際の判断材料になれば幸いです。








