はじめに
前回の記事では clickhouse-best-practices Skill を導入して、ゼロからDDLを書くときにルール照合が効く様子を見ました。
ただ、実務でClickHouseを触る場面のほとんどは「ゼロから設計する」ではなく「すでに動いているテーブルに対して、遅いクエリを後から直す」です。本記事ではこちらに振り切って、ClickHouse公式 agent-skills と mcp-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-practices と clickhouse-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-clickhouseを read-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集計
hits の UserID に対して、外部のセグメントマスタテーブル 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_hash247 MiB 418 ms 360 MiB 中間案② INNER JOIN+partial_merge110 MiB 295 ms 360 MiB After(推奨) dictGetDictionary置換49 MiB 402 ms 87 MiB per-query メモリ: 541 MiB → 49 MiB(▲91% 削減)。
user_segmentsは UserID → segment のルックアップで segment はわずか4種類(free/enterprise/pro/guest)。HASHEDDictionary化することで 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.UserIDはInt64なので、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.UserID は Int64、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 知識として)
uniqExact(URL)が I/O の主犯 —URLは可変長 String。2,630 万行分を丸ごと読むと 2.02 GiB の I/O が発生。URLHash Int64(スキーマに既存)を使えば同じ行数で Int64 × 8 bytes に激減します。uniqExactの全精度保持がメモリの主犯 — ハッシュセットを正確に保持するため 711 MiB。uniq()に変えると HyperLogLog(誤差 ≈2.5%)になり劇的に削減します。- 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 に放り込めば、同じ「Perxxx...」の引用付きで応答に乗る
おまけ: 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-orderbyCompliant — EventDate(sort key 第 2 カラム) でフィルタagent-discovery-schema が発火した理由
datasets.hitsは 105 カラム・約 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に安全に繋ぐにはどうするか」に移ります。今回の検証が、その移行期にいるチームの参考になれば幸いです。