0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Claude Code にClickHouseの「Skill+MCP」を使わせるとSQLが最大92倍速くなった話

0
Last updated at Posted at 2026-05-27

はじめに

前回の記事では clickhouse-best-practices Skill を導入して、ゼロからDDLを書くときにルール照合が効く様子を見ました。

ただ、実務でClickHouseを触る場面のほとんどは「ゼロから設計する」ではなく「すでに動いているテーブルに対して、遅いクエリを後から直す」です。本記事ではこちらに振り切って、ClickHouse公式 agent-skillsmcp-clickhouse を組み合わせたClaude Codeに、1億行の hits テーブルに対する遅いSQLを3本直させてみました。

結論から数字で出します。

ケース テーマ Before After 改善
1 既存テーブルに後付けでMV化 461ms / 1億行スキャン 5ms / 942行スキャン 約92×
2 マスタJOIN → Dictionary化 289ms / per-query 541MiB 402ms / 49MiB per-queryメモリ約1/11
3 制約下の書き換え(uniqExact + DDL禁止) 1,079ms / 710MiB / 2.02 GiB I/O 307ms / 43.6MiB / 818 MiB I/O I/O約1/2.5+メモリ約1/16

ケース3は「DDL変更禁止」の制約下で Skill がどこまで効くかを見る題材です。「Skillが万能ではなく、制約下での判断や Skill ルール間の組み合わせをどう扱うか」もエンジニア視点では重要なので、最後に取り上げます。

おまけで agent-query-safety ルールが実演する MCPのガードレール挙動も末尾に。これが「本番接続させる前の安心材料」として地味に重要でした。

検証セットアップ

環境

  • ClickHouse Server 26.5.1.882(Docker、clickhouse/clickhouse-server:26.5
  • Mac mini (Apple Silicon)、コンテナへのCPU/メモリ制限なし
  • データセット: ClickBench hits(Yandex.Metrica匿名化版、99,997,497 行、105カラム、14.22 GiB)
  • データ範囲: EventDate は 2013-07-02 〜 2013-07-31(today() 基準ではない点に後で効いてくる)

hits の取り込み

ClickBench公式のCREATE文を取得して、データベース datasets に作成し、S3公開バケットから直接INSERTします。

# DDL取得(26.5 は ClickBench 公式 create.sql をそのまま流用可能。
#  `auto_statistics_types` も system.merge_tree_settings に存在する)
curl -fsSL https://raw.githubusercontent.com/ClickHouse/ClickBench/main/clickhouse/create.sql \
  | sed 's/CREATE OR REPLACE TABLE hits/CREATE OR REPLACE TABLE datasets.hits/' \
  | sed 's/fsync_after_insert = 1, --.*$/fsync_after_insert = 0,/' \
  > /tmp/hits_create.sql

clickhouse-client --password ... < /tmp/hits_create.sql

# 取り込み(単一parquet 14.8GB、自分の環境では約7分半)
clickhouse-client --password ... --query "
  INSERT INTO datasets.hits
  SELECT * FROM url('https://datasets.clickhouse.com/hits_compatible/hits.parquet', 'Parquet')
  SETTINGS max_insert_threads=4, input_format_null_as_default=1
"

Skill導入

公式 agent-skills には clickhousectl 経由とnpm経由の2系統あります。今回は前者を使いました。

# clickhousectl 経由(CLIごと入る)
curl -fsSL https://clickhouse.com/cli | sh
clickhousectl skills --agent claude

# 入った場所
$ ls .claude/skills/
chdb-datastore  chdb-sql  clickhouse-architecture-advisor
clickhouse-best-practices  clickhouse-js-node-coding
clickhouse-js-node-troubleshooting
clickhousectl-cloud-deploy  clickhousectl-local-dev

本記事で使うのは clickhouse-best-practicesclickhouse-architecture-advisor の2本です。

MCP接続(mcp-clickhouse)

# uv 経由でmcp-clickhouseを取得
curl -LsSf https://astral.sh/uv/install.sh | sh

# Claude Codeに登録(ユーザースコープ)
claude mcp add clickhouse-poc -s user \
  -e CLICKHOUSE_HOST=localhost \
  -e CLICKHOUSE_PORT=8123 \
  -e CLICKHOUSE_USER=default \
  -e CLICKHOUSE_PASSWORD=test123 \
  -e CLICKHOUSE_DATABASE=datasets \
  -- /Users/yu24/.local/bin/uvx mcp-clickhouse

# 確認
$ claude mcp list | grep click
clickhouse-poc: /Users/yu24/.local/bin/uvx mcp-clickhouse - ✓ Connected

MCPの権限について: 本検証では mcp-clickhouseread-only ロール で運用しました。書き込み(CREATE TABLE / MV / DICTIONARY)は別途 curl http://localhost:8123 から実施しています。後述するように、agent-query-safety ルールが必須とする per-query SETTINGS の付与すら read-only ロールでは弾かれるケースがあり、この事実自体が「役割分離による defense in depth」の現れとして記事の最後に出てきます。

ケース1: 既存テーブルに後付けでMV化

Before: ダッシュボード用の日次集計

ダッシュボードが毎回叩く、典型的な「日付範囲 × 国 × 複合メトリクス」のクエリです。

SELECT EventDate,
       BrowserCountry,
       count()                          AS hits,
       sum(IsMobile)                    AS mobile_hits,
       sum(SearchEngineID > 0)          AS search_hits,
       avg(ResolutionWidth)             AS avg_w
FROM datasets.hits
WHERE EventDate BETWEEN '2013-07-01' AND '2013-07-31'
GROUP BY EventDate, BrowserCountry
ORDER BY EventDate, hits DESC
LIMIT 5;

system.query_log の実測値(warm 中央値)は次のとおりです。

query_duration_ms: 461
memory_usage:      14.42 MiB
read_rows:         99,997,497
read_bytes:        1.34 GiB

ダッシュボード視点では461msでも遅くはありません。問題は「ダッシュボードがロードされる度に1億行を毎回スキャンしている」ことです。同時アクセスが10人入れば即座にクラスタを圧迫します。

Claude Code (Skill+MCP) への依頼

このクエリ、ダッシュボードから何度も呼ばれるんだけど、もうちょっと高速化したい。テーブル自体は他のクエリでも使ってるから触りたくない。

Claude Code は MCP経由で system.tables / system.columns を引いて状態を確認したあと、次のように応答してきました(要点抜粋)。

適用したルール

ルール 判定 内容
schema-pk-filter-on-orderby 違反(元クエリ) ORDER BY 先頭の CounterID を WHERE に含まないため、プライマリインデックスが機能せず 100M 行フルスキャンが発生
query-mv-incremental 適用 インクリメンタル MV + AggregatingMergeTree で挿入時に集計を積み上げ、クエリ時には 942 行だけ読む構造に変換。-State / -Merge 関数ペアで部分集計をマージ
schema-types-lowcardinality 適用 BrowserCountry は国コード(カーディナリティ < 1,000)なので LowCardinality(String) を付与してディクショナリ圧縮を有効化

スキーマ探索の段階で「sort key 先頭の CounterID を本クエリは使っていないので pruning が効かない」と診断しているのが地味に効いています。ただ MV を提案するのではなく、なぜ Before が1億行スキャンになっているか(schema-pk-filter-on-orderby 違反)の根拠と、ターゲット表で LowCardinality(String) を当てに行くところまで返してくる。

実装した DDL

-- 1) AggregatingMergeTree ターゲット表
CREATE TABLE datasets.hits_daily_by_country
(
    EventDate      Date,
    BrowserCountry LowCardinality(String),     -- schema-types-lowcardinality
    hits           AggregateFunction(count),
    mobile_hits    AggregateFunction(sum, Int16),
    search_hits    AggregateFunction(sum, UInt8),
    avg_w          AggregateFunction(avg, Int16)
)
ENGINE = AggregatingMergeTree()
ORDER BY (EventDate, BrowserCountry);          -- ダッシュボードの GROUP BY と一致

-- 2) インクリメンタル MV
CREATE MATERIALIZED VIEW datasets.hits_daily_by_country_mv
TO datasets.hits_daily_by_country AS
SELECT EventDate,
       BrowserCountry,
       countState()                  AS hits,
       sumState(IsMobile)            AS mobile_hits,
       sumState(SearchEngineID > 0)  AS search_hits,
       avgState(ResolutionWidth)     AS avg_w
FROM datasets.hits
GROUP BY EventDate, BrowserCountry;

注意点として、MV は新規 INSERT のみ拾うため、既存データには別途 INSERT による backfill が必要です。ClickHouse には CREATE MATERIALIZED VIEW ... POPULATE という構文があり、これを指定すると MV 作成時に既存テーブルのデータを一括投入できます。ただし公式ドキュメントは POPULATE の使用を推奨していません。理由は、POPULATE による初期投入が走っている最中にテーブルへ挿入された新規 INSERT が MV に取り込まれず欠損するためです(POPULATE は内部的に CREATE TABLE ... AS SELECT ... 相当の動作で、SELECT 開始時点のスナップショットしか拾わない)。Claude はこの注意点には言及しませんでしたが、本番運用では POPULATE を避けて「空 MV を先に作成 → 既存データは別途 INSERT ... SELECT で backfill」と2段に分離するのが定石です。

After: 集計テーブルから読む

SELECT EventDate, BrowserCountry,
       countMerge(hits)       AS hits,
       sumMerge(mobile_hits)  AS mobile_hits,
       sumMerge(search_hits)  AS search_hits,
       avgMerge(avg_w)        AS avg_w
FROM datasets.hits_daily_by_country
WHERE EventDate BETWEEN '2013-07-01' AND '2013-07-31'
GROUP BY EventDate, BrowserCountry
ORDER BY EventDate, hits DESC
LIMIT 5;

Before / After 実測比較

指標 Before(hits 直叩き) After(hits_daily_by_country) 改善倍率
query_duration_ms(warm中央値) 461 ms 5 ms 約92×
read_rows 99,997,497 942 約106,000×
read_bytes 1.34 GiB 71.79 KiB 約19,400×
memory_usage 14.42 MiB 5.36 MiB 約2.7× 少
集約結果 (一致) (一致)

集計テーブルは合計 942 行 / 14.53 KiB に縮約(1億行 / 14.22 GiB から)。日次パーティション粒度のダッシュボードなら常時 sub-10ms で返せます。

このケースの学び

  • 「既存テーブルを触らずに MV + 集計テーブルを後付けする」順序が、Skillに具体的に書かれている(query-mv-incremental の例コードがほぼそのまま使える)
  • ターゲット表側で LowCardinality(String) を当てるなど、関連ルール(schema-types-lowcardinality)も自動で組み合わさる
  • スキーマ探索段階で「sort key 先頭が使われていない → 現状フルスキャン」という診断(schema-pk-filter-on-orderby 違反)が出る

ケース2: マスタJOIN → Dictionary化(per-queryメモリ約1/9)

Before: ユーザーセグメント別のJOIN集計

hitsUserID に対して、外部のセグメントマスタテーブル user_segments(17,630,976 行、segment列を持つ)をINNER JOINする典型パターンです。

-- セグメントマスタを別途用意(テストデータ)
CREATE TABLE datasets.user_segments
ENGINE = MergeTree ORDER BY UserID AS
SELECT
    UserID,
    multiIf(UserID % 5 = 0, 'enterprise',
            UserID % 3 = 0, 'pro',
            UserID % 2 = 0, 'free', 'guest') AS segment,
    rand() % 100 AS account_age_days
FROM (SELECT DISTINCT UserID FROM datasets.hits);

-- Beforeクエリ
SELECT u.segment,
       count()             AS hits,
       uniqExact(h.UserID) AS uu
FROM datasets.hits h
INNER JOIN datasets.user_segments u ON h.UserID = u.UserID
WHERE h.EventDate BETWEEN '2013-07-10' AND '2013-07-12'
GROUP BY u.segment
ORDER BY hits DESC;
query_duration_ms: 244
memory_usage:      543 MiB
read_rows:         33,645,599
read_bytes:        359.63 MiB

26.x の JOIN エンジンは旧版に比べて大幅に改善されており、544MiB程度で1秒切る速度で完走します。とはいえ per-query で 543 MiB はそれなりに重く、同時クエリが10本来ればワーカーメモリ5GiB超を一過性に消費します。Cloudのメモリ予算下では問題になり得ます。

Claude Code の応答

ルールチェック結果

ルール 判定
query-join-choose-algorithm 違反 — デフォルト hash が右テーブル全体をメモリに展開
query-join-consider-alternatives 違反 — 毎回JOINせずDictionaryで代替すべき
query-join-filter-before 準拠(WHERE EventDate は自動プッシュダウン済み)
query-join-use-any (ANY JOIN) 不適用hits は同一UserIDで複数行あるため、ANY JOINは結果が変わる

Before / After 実測(4バリアント比較)

バリアント 手法 per-query メモリ 実行時間 読み取りバイト
Before INNER JOIN + hash(デフォルト) 541 MiB 289 ms 360 MiB
中間案① INNER JOIN + grace_hash 247 MiB 418 ms 360 MiB
中間案② INNER JOIN + partial_merge 110 MiB 295 ms 360 MiB
After(推奨) dictGet Dictionary置換 49 MiB 402 ms 87 MiB

per-query メモリ: 541 MiB → 49 MiB(▲91% 削減)user_segments は UserID → segment のルックアップで segment はわずか4種類(free/enterprise/pro/guest)。HASHED Dictionary化することで JOIN のたびに右テーブル全体(17.6M行)をハッシュテーブルに展開する処理を排除できる。

24.1当時の応答(decision-join-enrichment の判定マトリクスを official/derived ラベルで提示する系)と比べると、26.5+現行 Skill では JOIN アルゴリズム自体の選択肢(grace_hash / partial_merge)を中間案として並べて比較する応答に進化しています。これは query-join-choose-algorithm ルールが追加された影響で、「Dictionary化が筋だが、DDL変更したくないなら SETTINGS join_algorithm='partial_merge' の1行で 110MiB まで落とせる」というグラデーションが提案に含まれます。

After: Dictionary + dictGet

-- DDL: Dictionary 作成(一度だけ)
CREATE DICTIONARY datasets.user_segment_dict
(
    UserID  UInt64,
    segment String
)
PRIMARY KEY UserID
SOURCE(CLICKHOUSE(
    HOST 'localhost' PORT 9000
    USER 'default' PASSWORD '***'
    DB 'datasets' TABLE 'user_segments'
))
LAYOUT(HASHED())
LIFETIME(0);

-- After クエリ(JOIN なし)
SELECT
    dictGet('datasets.user_segment_dict', 'segment', toUInt64(h.UserID)) AS segment,
    count()             AS hits,
    uniqExact(h.UserID) AS uu
FROM datasets.hits h
WHERE h.EventDate BETWEEN '2013-07-10' AND '2013-07-12'
GROUP BY segment
HAVING segment != ''
ORDER BY hits DESC;

Claudeは Dictionary の PRIMARY KEY を UInt64 に切り替え、INNER JOIN相当の挙動は dictHas ではなく HAVING segment != '' で実現する案を返してきました(dictGetは未ヒットキーで空文字列を返すため)。hits.UserIDInt64 なので、dictGet 呼び出し時に toUInt64(...) のキャストが必須(さもないと Code: 70 CANNOT_CONVERT_TYPE が出る)。これは記事末尾の落とし穴コーナーで触れます。

結果の一致

INNER JOIN版と完全一致しています。

guest      1,557,863
free       1,550,804
pro        1,549,422
enterprise 1,150,118

メモリは「消える」のではなく「シフトする」

per-query メモリは 541 MiB → 49 MiB に減りますが、Dictionary 本体が常駐で約 1.62 GiB 確保されます(system.dictionaries.bytes_allocated、2フィールド構成の場合)。フィールド数が増えれば常駐量も増え、本記事冒頭の3フィールド版(UserID + segment + account_age_days)では約2.62 GiBを確保しました。

「Dictionary化でメモリが減りました!」と喜んで使うと、実体はクエリ実行時の一過性メモリが Dictionary常駐の固定メモリに振り替わっただけで、絶対量はむしろ大きく増えます(541 MiB 一過性 → 1.62 GiB 常駐)。

これが活きるのは「同じ dim を複数クエリ・複数ユーザーが使う」ケース。クエリAが541MiB、クエリBが541MiB……と都度ハッシュビルドしていたものが、1.62GiBの常駐領域1つに集約される。同時実行数が概ね3本以上なら常駐コストを上回って効く設計です(541MiB × 3 = 1.62GiB ≒ 1.62GiB)。逆に同時実行が常時1〜2本なら、Dictionary化は per-query 速度(ほぼ同等〜微改善)だけが残り、メモリ的にはむしろ重くなる点に注意。

Claude Codeの応答にもこの計算は含まれており、partial_merge を「DDL変更なしで今すぐ110MiBまで落とせる代替」として段階移行も提案してくれます。

落とし穴:toUInt64() キャスト忘れ

hits.UserIDInt64、Dictionary側は UInt64 のため、dictGet('...', '...', UserID) を直接書くと以下のエラーが出ます。

Code: 70. CANNOT_CONVERT_TYPE: Value in column Int64 cannot be safely converted into type UInt64

dictGet の内部ハッシュキーは UInt64 固定で、Int64→UInt64 の暗黙キャストは負値を弾くため明示的に toUInt64(UserID) を噛ます必要があります。元データに負の UserID が無いことを確認した上で。

このケースの学び

  • Dictionary 化の効果は実行時間(ほぼ同等)よりメモリ(per-query 1/11)に出る
  • 26.x の JOIN エンジン改善で「2GB超のハッシュビルドで秒オーダー」のような派手な Before は出にくくなった。派手な「30倍速」ではなく「同時実行下でのメモリ・キャッシュ効率」が Dictionary の本質になっている
  • 一方で常駐メモリ1.62 GiB のシフトが裏で起きているので、「同時実行が多い環境ほど効く」という設計判断は変わらない
  • Skillは query-join-choose-algorithm ルールにより grace_hash / partial_merge も中間案として提示する。DDL変更したくない場合は SETTINGS join_algorithm='partial_merge' 1行で 110 MiB まで落とせる
  • LIFETIME(0) だとDictionary は自動再ロードされない。長期運用なら LIFETIME(MIN 3600 MAX 7200) 等で1〜2時間間隔の自動更新を入れる

ケース3: 制約付き条件下での提案(uniqExact → uniq、Skillルールの境界例)

ここまでの2ケースは、Skillに該当ルールが手厚くありました。次は「新規テーブル作成・MV作成・ALTER は禁止、SELECT書き換えのみで改善せよ」という制約を入れた場合に、Skillがどこまで効くかを見ます。当初は「Skillにルール無し → general knowledge へフォールバック」を期待した題材ですが、結果は半分そう・半分違いました。

Before: uniqExact 多用

SELECT BrowserCountry,
       uniqExact(UserID)       AS uu,
       uniqExact(URL)          AS uurl,
       uniqExact(SearchPhrase) AS usp
FROM datasets.hits
WHERE EventDate BETWEEN '2013-07-10' AND '2013-07-20'
GROUP BY BrowserCountry
ORDER BY uu DESC
LIMIT 10;
query_duration_ms: 1,079
memory_usage:      710 MiB
read_rows:         26,373,152
read_bytes:        2.02 GiB

uniqExact は完全に正確なユニーク数を返しますが、内部で全要素のハッシュセットを保持するため、cardinality が高い列で使うとメモリと時間を食います。

Claude Code の応答

Skill の SKILL.md には次の運用方針が書かれています。

3. If no rule exists: Use the LLM's ClickHouse knowledge or search documentation
5. Always cite your source: rule name, "general ClickHouse guidance", or URL

実際の応答は、「Skillに該当ルールはある(ただし制約下で適用不可)」+「本質的な書き換えは general knowledge」という二段構えになりました。

Rules Checked

ルール 判定
schema-pk-filter-on-orderby 違反CounterID(ORDER BY 1列目)を飛ばして EventDate のみフィルタ
query-index-skipping-indices 参考情報 — BrowserCountry / URL に skip index なし(ALTER 禁止のため改善不可)

ボトルネック(一般的な ClickHouse 知識として)

  1. uniqExact(URL) が I/O の主犯URL は可変長 String。2,630 万行分を丸ごと読むと 2.02 GiB の I/O が発生。URLHash Int64(スキーマに既存)を使えば同じ行数で Int64 × 8 bytes に激減します。
  2. uniqExact の全精度保持がメモリの主犯 — ハッシュセットを正確に保持するため 711 MiBuniq() に変えると HyperLogLog(誤差 ≈2.5%)になり劇的に削減します。
  3. PREWHERE — ClickHouse 固有の最適化。WHERE を PREWHERE に変えると、EventDate 列だけ先読みして不一致行を除外してから残列を読みます。ただし今回はプランナーが自動昇格するため単独効果は小さいです。

ここが面白いところで、schema-pk-filter-on-orderby 違反は検出されるものの、ALTER 禁止の制約下では sort key 再設計ができないため、ルールとしては "違反だけど対処不可" 状態になります。Skillが「ある」だけでは足りなくて、制約と照らし合わせて actionable な改善案を組み立てるのは general knowledge の領域。Claude は URLHash への置換(既存カラムの転用)と PREWHERE の組み合わせという、Skillのどの単一ルールにも書かれていない組み合わせ技を提案してきました。

After(推奨:案C uniq + URLHash + PREWHERE

SELECT BrowserCountry,
       uniq(UserID)       AS uu,
       uniq(URLHash)      AS uurl,   -- String → Int64 hash で I/O 半減
       uniq(SearchPhrase) AS usp
FROM datasets.hits
PREWHERE EventDate BETWEEN '2013-07-10' AND '2013-07-20'
GROUP BY BrowserCountry
ORDER BY uu DESC
LIMIT 10;
パターン クエリ時間 メモリ 読み込み 精度
Before (元クエリ) 1,079 ms 710 MiB 2.02 GiB 完全一致
案A: uniq() + URLHash 467 ms (-57%) 44.1 MiB (-94%) 821.8 MiB (-59%) ≈2.5% 誤差
案B: uniqExact() + URLHash + PREWHERE 544 ms (-50%) 553.8 MiB (-22%) 820.1 MiB (-60%) 完全一致†
案C(推奨): uniq() + URLHash + PREWHERE 307 ms (-72%) 43.6 MiB (-94%) 817.9 MiB (-60%) ≈2.5% 誤差

†: URLHash は Int64 ハッシュのため、衝突確率は ≈3.4×10⁻⁷(実用上無視可)。

24.1当時は uniq 置換のみで約2倍速・メモリ約1/6 でしたが、26.5+現行 Skill ではClaude が URLHash 既存カラムの転用まで提案し、I/O 自体を60%削減できています。uniq 置換だけだとカラム読み込み(2.02 GiB)は変わらないため、メモリは下がっても I/O 律速の壁が残るんですが、URLHash 転用でその壁も突破できる。これは「Skillの単一ルールでは出てこないが、複数の知識を組み合わせれば見える最適化」の典型例です。

結果の誤差検証

uniq (HyperLogLog) の標準誤差は約 ±0.5〜2.5%。上位行で実測すると以下のようになります。

Country Before uu After uu 誤差
(1位) 3,268,469 3,260,961 -0.23%
h1 1,419,793 1,411,039 -0.62%
_i 140,115 140,632 +0.37%
nS〜B8(小カーディナリティ群) 5,159〜624 (完全一致) 0%

最大誤差 ±0.62% で、HLL 標準誤差の範囲内(カウントが小さい群は HLL の sparse モードで厳密一致)。URLHash 経由の uurl についても元の URL distinct数 (2,514,864) と URLHash distinct数 (2,830,046) が完全一致しないのは、ハッシュ衝突ではなく同一URLでも別レコードで微妙にエンコードが違うケース(URL末尾スラッシュやクエリパラメータ順)が URLHash 側で別ハッシュになる例があるためで、ビジネス上「URLのユニーク数」を1%精度で見るなら問題なし。

このケースの学び

  • Skillが該当ルールを発火しても「制約下で適用不可」になることがある(今回は schema-pk-filter-on-orderby 違反検出 → ALTER 禁止のため対処不可)。ルール検出と actionability は別物
  • 本質的な改善案(URLHash 既存カラムの転用 + PREWHERE)は Skillの単一ルールでは出てこない組み合わせ技だが、Claude が general knowledge として組み立てる
  • general ClickHouse guidance ラベルで返ってくる部分は、レビュー側もそれを承知で受け取れる。逆に言えば、組織固有のベストプラクティス(社内の uniqExact 禁止、独自カラムの転用ルール等)は、社内で同じフォーマットの追加ルールを書いて Skill に放り込めば、同じ「Per xxx...」の引用付きで応答に乗る

おまけ: agent-query-safety のガードレールが地味に効く

3ケースとも MCP 経由でClaude Codeから直接クエリを投げていますが、Skill配下の agent-query-safety ルール(Impact: CRITICAL)が MCP の運用と組になっている点は触れておく価値があります。

試しに datasets.hits の直近1週間の全カラムを見せてください という、エンジニアでも雑にやりがちなプロンプトを投げてみた応答。

Rules Checked

ルール 結果
agent-discovery-schema (CRITICAL) 発火 → スキーマ発見 7 ステップを実施
agent-query-safety (CRITICAL) 発火SELECT * × 100M 行テーブルのため LIMIT・SETTINGS 付与
schema-pk-filter-on-orderby Compliant — EventDate (sort key 第 2 カラム) でフィルタ

agent-discovery-schema が発火した理由

datasets.hits105 カラム・約 1 億行・14.22 GiB の大テーブル。スキーマ・ソートキー・スキップインデックスを確認せずにクエリすると、全グラニュールをスキャンするリスクがあります。

Steps 1–5(DB 一覧 → テーブル情報 → カラム定義 → ソートキー → スキップインデックス)を実行してから本クエリを発行。実データ範囲は 2013-07-02 〜 2013-07-31 で、today()-7 はゼロ行のため「データセット内最新7日間」= EventDate >= '2013-07-25' に読み替え。

agent-query-safety が発火した理由と対処

SELECT * を LIMIT なしで実行すると最大 2,800 万行を返す可能性があります(EXPLAIN ESTIMATE: 4 parts / 28,069,329 rows / 3,450 marks)。本来の安全SQLは以下のとおり。

SELECT *
FROM datasets.hits
WHERE EventDate >= '2013-07-25'
LIMIT 1000
SETTINGS max_execution_time = 30,
         max_rows_to_read   = 1000000000,
         timeout_before_checking_execution_speed = 0

今回の実際の対応: MCP が readonly モード(code 164)のため SETTINGS は付与不可。ただし MCP 自体が書き込み・設定変更を禁止するサーバーサイド制約として機能しており、agent-query-safety が「role-level hardening(ベルト+サスペンダー)」と呼ぶガードがサーバー側で担保済みです。代わりに LIMIT 5 で出力を抑制しました。

ここがハイライトで、「today() - 7 ではデータが無い」という事実を本クエリを投げる前のスキーマ探索 (Step 6) で気付いていることと、read-onlyロールが per-query SETTINGS を弾く挙動を観測して「サーバ側ガードが効いてる」と判断していること。agent-query-safety の付与する SETTINGS は実は3つ(max_execution_time / max_rows_to_read / timeout_before_checking_execution_speed)にコアが絞られており、24.1当時に7種類列挙していた版より整理されています。

per-query SETTINGS とロールガードの role-level hardening

応答にも明記されていますが、本検証の MCP は read-only ロールで動いており、SETTINGS を末尾に付けるとそれ自体が READONLY (code 164) エラーになる。これは Skill が推奨する「Role-level hardening (belt-and-suspenders)」が機能している例で、production では readonly=2 + settings profile + quotas でロール側に同等以上のガードを掛けるべき、と Claude が判断し代替として LIMIT 5 のみで対処してきました。

つまり、本番運用では「ロール側で readonly=2 + settings profile を縛り、エージェント側は per-query SETTINGS を付与し、両者が belt-and-suspenders で守る」のが Skill が推奨する設計。Skill単体ではなく、ClickHouse のロール設計と合わせて初めて完成する話で、エージェントを本番DBに繋ぐかを判断するアーキテクトには見ておいて欲しい部分です。

まとめ

1. 「既存DBに後から手を入れる」は Skill+MCP が一番効く領域
ゼロからのDDL設計は人間でも回せますが、「動いてる本番に触らずに高速化したい」場面は、引き出し(MV / Dictionary / Projection / 集計の事前化)が多くて判断が重い。Skill が「該当ルール → 例コード → 関連ルール(schema-types-lowcardinality 等)」までセットで提示するため、初手で大事故が起きにくい。

2. メモリ消費の改善は「消える」より「シフトする」を意識する
ケース2の per-query 541MiB → 49MiB(1/11)は劇的に見えるが、裏では Dictionary 本体 1.62 GiB が常駐している。同時実行が概ね3本以上で初めて常駐コストを上回って効く設計で、単発クエリの実測値だけ見ると本質を見誤る。26.x の JOIN エンジン改善で「素のJOINでも1秒切る」状況になったため、Dictionary 化の判断は「per-query 速度」よりむしろ「同時実行下の総メモリ予算」で行うべき。Skill は partial_merge を「DDL変更なしで今すぐ110MiBまで落とせる代替」として段階移行も提案してくれる。

3. ルール検出と actionability は別物
ケース3で schema-pk-filter-on-orderby 違反は検出されるものの、ALTER禁止の制約下では sort key再設計ができず "違反だけど対処不可" 状態になりました。本質的な改善(URLHash 既存カラム転用 + PREWHERE)は Skillの単一ルールでは出てこない組み合わせ技で、Claude が general knowledge として組み立てる領域。社内固有のベストプラクティスは同じフォーマットで Skill に追加すれば、同じ「Per xxx...」の引用付きで応答に乗ります。

4. ガードレールは「Skill単体」ではなく「Skill + ClickHouse ロール設計」で完成する
agent-query-safety の per-query SETTINGS は、ClickHouse 側の readonly=2 + settings profile と belt-and-suspenders の関係。本検証では実際に read-only ロールが per-query SETTINGS を弾く挙動を観測し、Claude が「サーバ側ガードが効いているので LIMIT のみで対処」と判断していた。本番接続を判断するアーキテクトは、Skillだけでなくサーバ側のロール設計も合わせて見る必要がある。

ClickHouseの導入直後はDDL設計が論点ですが、運用が始まれば論点は「動いているテーブルをどう速くするか」と「エージェントを本番DBに安全に繋ぐにはどうするか」に移ります。今回の検証が、その移行期にいるチームの参考になれば幸いです。

参照リンク

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?