速いSQLは安いSQLなのか? TiDB Cloudで検証してみた

速いSQLは安いSQLなのか? TiDB Cloudで検証してみた

TiDB Cloud Starter で、3 種類の方法で速くした SQL の実行時間と RU を計測しました。実行時間とともに RU も大きく削減できたのは SQL が読むデータを減らした場合だけで、並列度を上げて速くした場合の RU は変わらず、ロック待ちをなくした場合の RU の削減は実行時間の削減より小さくなりました。
2026.10.14

はじめに

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 のチューニングと料金の関係を知りたい方

参照

背景

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
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 の性能と料金を考える際の判断材料になれば幸いです。


TiDB Cloudの導入・サポートはクラスメソッドにお任せください

クラスメソッドでは、TiDB Cloudの導入から運用支援まで、豊富なノウハウでお客様をサポートしています。パフォーマンスの最適化やスケーラビリティに課題を抱えている方は、ぜひご相談ください。
詳細な導入事例やサービス内容について知りたい方は、こちらからご確認いただけます。

TiDB Cloudのサポート詳細を見る

この記事をシェアする

関連記事