Azure SQL Databaseで3時間ごとに同じSQLが走り、DTUが100%に跳ね上がる現象は「誰が投げたSQLか」を掴めないと終わりません。クエリ ストアで内容を確認したうえで、Extended Eventsでclient_app_name/hostname/userを記録し、実行元アプリやジョブを特定する具体的な手順をまとめます。
なぜ「クエリの内容」は分かっても「実行元」は分からないのか
Azure SQL Databaseでは、パフォーマンス問題が起きると多くの人が最初にクエリ ストア(Query Store)やQuery Performance Insightを開きます。ここで「どのクエリが重いか」「いつ実行されたか」「CPUやI/Oをどれだけ使ったか」はかなりの精度で追えます。
一方で、これらの画面やビューが主に扱うのは“SQL文と実行統計”です。どのアプリケーション(アプリ名)やどのホスト(接続元)から飛んできたか、つまり発生元までを常時保存してくれる仕組みではありません。発生元特定には、実行時点の接続情報をログとして残す必要があります。
| 手段 | 分かること | 分かりにくい/分からないこと | おすすめ用途 |
|---|---|---|---|
| クエリ ストア | クエリ テキスト、実行回数、CPU/論理読み取り、待機、プラン変化 | 実行したアプリ名、ホスト名、接続元IP | 「何が重いか」を確定する |
| Query Performance Insight | 期間別の上位クエリ、CPU/DTU寄与、傾向 | 発生元(client_app_name 等) | 「いつから増えたか」「傾向」を掴む |
| DMV(sys.dm_exec_*) | いま動いているセッションのアプリ名・ホスト名・SQL | 過去に終わったクエリの発生元 | スパイクの瞬間を捕まえられるとき |
| Extended Events | 実行時のアプリ名/ホスト名/ユーザー/SQLをイベントとして記録 | フィルタ設計を誤るとイベント量が増える | 「誰が投げているか」を確実に突き止める |
| 監査(Auditing)/ログ基盤 | 認証・操作ログ、場合によってはSQLテキストや接続情報 | 要件・コスト・保持設計が必要 | セキュリティ要件や長期追跡が必要な場合 |
まず整理しておきたい前提:3時間おきのスパイクは「定期処理」の可能性が高い
「3時間ごとに同じクエリが走る」という周期性は、たとえば次のような実装でよく起きます。
- バッチジョブ/スケジューラ(Windows タスク、Cron、Kubernetes CronJob、Azure WebJob/Functions のタイマー)
- データ連携(ETL、データ同期、ログ集計、エクスポート)
- 監視・ヘルスチェックの誤設定(頻度が高すぎる、取得範囲が広い)
- キャッシュの期限切れ → 再構築が重い(3時間でキャッシュ破棄など)
この段階で「アプリ側の心当たり」を探すのも有効ですが、推測だけだと時間が溶けます。この記事では、SQL側で発生元を“証拠付きで”特定して、アプリ担当者とも合意形成できる状態まで持っていきます。
クエリ ストアでやるべき確認
質問の状況では、クエリ ストア上で query_id = 10126 として表示され、Portal でもSQLテキストが確認できているとのことです。ここでは「発生元特定の前準備」として、クエリの特徴を整理します。
query_id からクエリ テキストを取得する
すでに実施済みでも、手順として残しておきます。後でフィルタ文字列を作るときに役立ちます。
SELECT qt.query_sql_text
FROM sys.query_store_query q
JOIN sys.query_store_query_text qt
ON q.query_text_id = qt.query_text_id
WHERE q.query_id = 10126;
自動生成っぽい SELECT [列名] AS [_列名] 形式で sdk_token など特徴的な列を読んでいるなら、Extended Eventsのフィルタ条件に使える“識別子”になりやすいです。
「いつ」「どれくらい」重いのかを数値で固める
発生元特定の会話では「本当にそのクエリがDTUを使っているのか?」が争点になることがあります。クエリ ストアの実行統計をSQLで引けるようにしておくと、説明が一気にラクになります。
-- 直近の実行傾向をざっくり見る例(期間は適宜調整)
SELECT TOP (50)
q.query_id,
p.plan_id,
rs.last_execution_time,
rs.count_executions,
rs.avg_cpu_time,
rs.avg_logical_io_reads,
rs.avg_duration,
rs.max_cpu_time,
rs.max_logical_io_reads,
rs.max_duration
FROM sys.query_store_query q
JOIN sys.query_store_plan p
ON q.query_id = p.query_id
JOIN sys.query_store_runtime_stats rs
ON p.plan_id = rs.plan_id
WHERE q.query_id = 10126
ORDER BY rs.last_execution_time DESC;
ポイントは、last_execution_time が3時間間隔で並ぶか、max_cpu_time / max_logical_io_reads / max_duration が突出しているかです。これで「周期性」と「負荷の大きさ」を客観的に示せます。
より堅牢に追跡したいなら query_hash も控える
Query Storeには、クエリ テキストの正規化に基づいたquery_hash(同一形のクエリを同一視するためのハッシュ)が保持されています。環境によっては、テキストの微妙な差分(空白やコメントなど)でLIKEフィルタが揺れることがあるため、可能なら「query_hashを控える→ログ側でもハッシュを取る」運用が安定します。
SELECT query_id, query_hash
FROM sys.query_store_query
WHERE query_id = 10126;
ただし、Extended Events側でハッシュを直接フィルタできるか、取得できるアクションが何かは環境・イベント種別で差が出ることがあります。まずはLIKEで確実に捕まえ、必要になった段階でハッシュ運用へ寄せるのが安全です。
DTU 100%の“内訳”を見て、調査の優先順位を決める
DTUは「CPU」「データI/O」「ログI/O」など複数リソースの合成指標です。DTUが100%に張り付いているとき、まずどれがボトルネックになっているかを把握しておくと、発生元特定後の対策(インデックスなのか、書き込み削減なのか、スケールなのか)がブレません。
| Azure Portalで見やすい指標 | 高いときに疑うこと | 典型的な対策の方向性 |
|---|---|---|
| CPU(CPU Percentage) | 大きな集計/結合、ソート/ハッシュ、非効率なプラン、過度な並列実行 | クエリ/インデックス見直し、統計、プラン安定化、処理分割 |
| Data IO(Data IO Percentage) | フルスキャン、読み取り量が多い、SELECT列が多すぎる | 絞り込み条件の追加、カバリング/適切なインデックス、列削減、差分化 |
| Log IO(Log IO Percentage) | 大量更新/挿入、1回のトランザクションが大きい、ログ書き込み集中 | バッチ分割、トランザクション縮小、更新範囲削減、スケジュール調整 |
| Sessions / Workers | 接続数が多い、同時実行が多い、コネクションプール枯渇 | 接続管理の見直し、同時実行制御、プール設定、アプリ側スロットリング |
この“内訳”は、発生元特定の前でも確認できます。たとえば「Data IOが100%に近い」なら、今回のような自動生成SELECTが広範囲スキャンになっている可能性が高く、アプリ側で取得列や絞り込みを見直す価値が大きい、といった判断ができます。
最短で当たりを付ける方法:スパイク中にDMVで“現行セッション”を覗く
DTUが100%になっている瞬間に接続できるなら、まずはDMV(動的管理ビュー)で「今動いているSQL」と「接続元」を取れる場合があります。これは設定不要で、成功すれば最速です。
ただし、スパイクが短時間で終わる/接続できない時間帯に起きる場合は取り逃がしやすいので、次章のExtended Eventsが本命になります。
-- スパイク中に実行して「いま重いもの」を掴む(Azure SQL Databaseでもよく使う形)
SELECT TOP (50)
r.session_id,
s.login_name,
s.host_name,
s.program_name,
r.status,
r.cpu_time,
r.total_elapsed_time,
r.reads,
r.writes,
r.logical_reads,
DB_NAME(r.database_id) AS database_name,
SUBSTRING(t.text, (r.statement_start_offset/2)+1,
((CASE r.statement_end_offset
WHEN -1 THEN DATALENGTH(t.text)
ELSE r.statement_end_offset END
- r.statement_start_offset)/2)+1) AS running_statement
FROM sys.dm_exec_requests r
JOIN sys.dm_exec_sessions s
ON r.session_id = s.session_id
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) t
WHERE r.database_id = DB_ID()
ORDER BY r.cpu_time DESC;
この結果の program_name(≒アプリ名)や host_name(≒ホスト名)が埋まっていれば、その時点で発生元の候補はかなり絞れます。逆に、ここで捕まらない/情報が欠ける場合に備えて、次のExtended Eventsで“待ち伏せ”します。
本命:Extended Eventsで“その瞬間の接続情報”を捕まえる
Extended Events(拡張イベント)は、SQLの実行をイベントとして記録できる仕組みです。クエリの実行イベントに対して「どのアプリ名で接続してきたか」「どのホスト名か」「どのユーザーか」などをACTIONとして付与し、問題のクエリだけをフィルタして捕まえます。
発生元特定で集めたい情報
最低限、次の項目が取れると“アプリを指名”しやすくなります。
| 項目 | Extended Eventsの例 | 分かること | 活用のコツ |
|---|---|---|---|
| アプリケーション名 | sqlserver.client_app_name | どのクライアント/ライブラリ/アプリ名で接続しているか | 接続文字列のApplication Nameを設定していれば、ほぼ一発で特定できます |
| ホスト名 | sqlserver.client_hostname | 接続元サーバー名(VM名など) | PaaS経由だと抽象名になることがあります。その場合は別情報と組み合わせます |
| ユーザー名 | sqlserver.username | どのログイン/ユーザーで実行されたか | “共有アカウント”だと特定が難しいので、アプリごとにユーザーを分ける設計が有利です |
| SQLテキスト | sqlserver.sql_text / statement / batch_text | 実際に実行されたSQL | 「本当にあのクエリか」を突き合わせる証拠になります |
| 接続ID | sqlserver.client_connection_id | 同一接続を追跡するキー | DMVと突合してIPなどを追う“つなぎ”に使えます |
フィルタを決めるコツ:LIKEだけに頼らない
Extended Eventsで一番大事なのはフィルタ設計です。フィルタが広すぎるとイベント量が増え、逆に狭すぎると取り逃がします。まずは次の優先度で「クエリ固有の特徴」を探してください。
- 特徴的な列名(例:
sdk_tokenのように他に出にくいもの) - 特徴的なテーブル名(テレメトリ系、キュー系など)
- SQLコメント(アプリ側で埋め込めるなら最強。例:
/* service:xxx job:yyy */) - query_hash(クエリ ストアから取得できる場合は、文字列変化に強く運用しやすい)
まずは運用しやすいLIKEフィルタで捕まえ、見つかったらより厳密な条件(コメントやハッシュ)へ寄せていくのが現実的です。
そのまま使える拡張イベント セッション例(sdk_tokenを含むSQLだけ監視)
以下は「sdk_token を含むクエリだけ」を対象に、RPC(sp_executesqlなど)とバッチの両方を捕まえる構成例です。セッション名やフィルタ文字列は、実環境の“特徴語”に置き換えてください。
CREATE EVENT SESSION [capture_sdk_token] ON DATABASE
ADD EVENT sqlserver.rpc_completed(
SET collect_statement = (1)
ACTION (
sqlserver.client_app_name,
sqlserver.client_connection_id,
sqlserver.client_hostname,
sqlserver.sql_text,
sqlserver.username
)
WHERE (
[sqlserver].[like_i_sql_unicode_string]([statement], N'%sdk_token%')
)
),
ADD EVENT sqlserver.sql_batch_completed(
SET collect_batch_text = (1)
ACTION (
sqlserver.client_app_name,
sqlserver.client_connection_id,
sqlserver.client_hostname,
sqlserver.sql_text,
sqlserver.username
)
WHERE (
[sqlserver].[like_i_sql_unicode_string]([batch_text], N'%sdk_token%')
)
)
ADD TARGET package0.ring_buffer
WITH (
MAX_MEMORY = 4096 KB,
EVENT_RETENTION_MODE = ALLOW_SINGLE_EVENT_LOSS,
MAX_DISPATCH_LATENCY = 30 SECONDS,
MEMORY_PARTITION_MODE = NONE,
TRACK_CAUSALITY = OFF,
STARTUP_STATE = OFF
);
GO
ALTER EVENT SESSION [capture_sdk_token]
ON DATABASE
STATE = START;
GO
- rpc_completed:アプリが
sp_executesql等で発行する“RPC系”を捕まえます(ORMやドライバ経由で多い) - sql_batch_completed:SSMS等からのバッチ実行や、まとめて投げられるSQLを捕まえます
- ring_buffer:DB内メモリに保持するターゲットです。短期の切り分けに向きます(長期保持には不向き)
イベントは「3時間おきに1回だけ」という前提なら、ring_bufferでも十分なことが多いです。逆に、同じ特徴語を含むクエリが大量に流れる環境では、フィルタをさらに絞るか、短時間だけ開始して捕まえたらすぐ止める運用にしてください。
ライブ データで確認する手順(SSMS/Azure Data Studio)
- 上記SQLでセッションを作成し、
STATE = STARTで開始します。 - SSMSまたはAzure Data Studioでデータベースに接続し、Extended Events(拡張イベント)のセッション一覧からcapture_sdk_tokenを開きます。
- 「ライブ データの監視」を開き、DTUが跳ねるタイミングまで待ちます。
- イベントが記録されたら、client_app_name / client_hostname / username を確認し、実行元の候補を絞ります。
「ライブ データの監視」は分かりやすい一方、画面を開きっぱなしにしづらいこともあります。次は、SQLだけでring_bufferから取り出す方法です。
ring_bufferをSQLで取り出す(画面を開きっぱなしにできない場合)
ring_bufferターゲットに溜まったイベントは、DMVからXMLとして取得できます。まずはXMLを取り出します。
SELECT CAST(t.target_data AS XML) AS target_data
FROM sys.dm_xe_database_session_targets t
JOIN sys.dm_xe_database_sessions s
ON s.address = t.event_session_address
WHERE s.name = N'capture_sdk_token'
AND t.target_name = N'ring_buffer';
次に、XMLをパースして見たい項目を表形式で抜き出します(必要な列だけ残してください)。
;WITH x AS (
SELECT CAST(t.target_data AS XML) AS xdata
FROM sys.dm_xe_database_session_targets t
JOIN sys.dm_xe_database_sessions s
ON s.address = t.event_session_address
WHERE s.name = N'capture_sdk_token'
AND t.target_name = N'ring_buffer'
)
SELECT
n.value('@timestamp','datetime2') AS [timestamp],
n.value('(action[@name="client_app_name"]/value/text())[1]','nvarchar(256)') AS client_app_name,
n.value('(action[@name="client_hostname"]/value/text())[1]','nvarchar(256)') AS client_hostname,
n.value('(action[@name="username"]/value/text())[1]','nvarchar(256)') AS username,
n.value('(action[@name="client_connection_id"]/value/text())[1]','nvarchar(100)') AS client_connection_id,
n.value('(data[@name="duration"]/value/text())[1]','bigint')/1000 AS duration_ms,
n.value('(data[@name="cpu_time"]/value/text())[1]','bigint') AS cpu_time,
n.value('(action[@name="sql_text"]/value/text())[1]','nvarchar(max)') AS sql_text
FROM x.xdata.nodes('//event') AS e(n)
ORDER BY [timestamp] DESC;
イベントが取れたら、次の章の“読み解き”に進みます。逆にイベントが一切出ない場合は、フィルタ文字列が一致していないか、スパイクの原因クエリが別のイベント種別(ストアド実行など)で発生している可能性があります。その場合は、フィルタ語を見直すか、sqlserver.sp_statement_completed等の別イベント追加を検討します(ただしイベント量が増えるので注意)。
client_app_name / client_hostname の読み解き方
取得できた値を“人間が分かるアプリ名”に翻訳するのが最後の壁です。とくに client_app_name は、接続文字列の設定や利用ライブラリによって粒度が変わります。
| 取得値の例(client_app_name) | よくある意味 | 次に確認するポイント |
|---|---|---|
| .Net SqlClient Data Provider / Microsoft.Data.SqlClient | ADO.NET系ドライバの既定名 | 接続文字列にApplication Name=が未設定の可能性。アプリごとに設定すると一気に特定しやすくなります |
| Microsoft Entity Framework / EntityFrameworkCore | ORM経由(EF/EF Core)で発行 | どのサービスがEFを使っているか、バッチかWebか、対象テーブルを使う機能は何か |
| JDBC Driver / Microsoft JDBC Driver for SQL Server | Java系クライアント | バッチ基盤、ETL、Springアプリ、接続先の設定ファイル |
| ODBC Driver XX for SQL Server | ODBC経由 | BI/ETLツール、レガシーアプリ、SSISなどの可能性 |
| 自社サービス名(例:MyBatchJob, OrdersApi) | Application Nameが設定済み | ほぼ特定完了。ジョブ定義やリリース履歴と突き合わせる |
client_hostname はVM名がそのまま取れると強力ですが、App ServiceやFunctionsなどPaaS経由では抽象的な名前になることがあります。その場合は、username(どのDBユーザーか)や、取得できるなら接続ID/セッションIDを手掛かりに“どの経路か”を詰めます。
Extended Eventsで掴めないときの補助線
現場では「client_app_nameが全部同じ」「ホスト名が空」「ユーザーが共有アカウント」で詰まることがあります。そんなときは、SQL側だけで完結させようとせず、次の“補助線”を引くと突破できます。
接続文字列に Application Name を付ける(最もコスパが良い)
多くのドライバは、接続文字列でアプリ名を指定できます。たとえば .NET 系なら次のように付けるだけで、Extended Events/DMVの client_app_name(または program_name)が見違えるほど分かりやすくなります。
Server=tcp:xxx.database.windows.net,1433;
Database=xxx;
User ID=xxx;
Password=xxx;
Application Name=OrdersApi-Prod;
「どのアプリか分からない」を根本から減らせるので、複数アプリが同一DBを共有している環境では特におすすめです。
SESSION_CONTEXT(またはCONTEXT_INFO)で“実行元タグ”を入れる
アプリがDBに接続した直後に、セッションにタグを持たせる方法です。アプリ改修が必要ですが、うまく設計できると発生元の追跡が非常に楽になります。
- 接続直後に
SESSION_CONTEXTにサービス名やジョブ名をセットする - Extended Eventsで
context_info等をアクションとして捕まえる
監視のためにSQLコメントを付ける方法(/* service:xxx */)と組み合わせると、フィルタもログの可読性も上がります。
監査ログ/ログ基盤を使う
運用要件(長期保管、改ざん耐性、横断検索など)があるなら、監査ログやログ基盤(Log Analytics 等)に寄せる選択肢もあります。ここは組織のポリシーとコストに依存するため、まずはExtended Eventsで短期に発生元を特定し、必要なら長期運用に拡張する流れが現実的です。
発生元が分かったら:DTUスパイクを潰すための実践的な対策
「誰が投げているか」が確定したら、次はDTUスパイクの原因(CPUなのかI/Oなのか、スキャンなのか)を潰していきます。対策は大きく4系統に分かれます。
| 対策カテゴリ | 具体例 | 効果が出やすいケース | 注意点 |
|---|---|---|---|
| アプリ側の改修 | 取得列を減らす、ページング、キャッシュ、処理の分割、不要ジョブ停止 | 自動生成SQLが“必要以上に広いSELECT”になっている | 影響範囲が広いので、リリース計画とセットで進める |
| クエリチューニング | インデックス追加、統計更新、クエリ書き換え、ヒントの見直し | フルスキャンや大きなソート/ハッシュが発生している | インデックスは書き込みコストも増える。目的列とフィルタ条件に合わせて設計する |
| 実行タイミング調整 | 3時間ごと→夜間へ、他処理と被らない時間へ、段階実行 | 処理は必要だが「同時刻に集中」している | 根本解決ではないが、SLAを守る応急処置として有効 |
| スケール/プラン見直し | DTUの引き上げ、vCoreへの移行、読み取りスケール、サーバーレス検討 | 改善しても処理量が物理的に多い | コストが増える。先に“無駄な負荷”を削るのが基本 |
「SELECT 列名 AS _列名」が大量に並ぶクエリで起きやすい落とし穴
自動生成SQLでは、アプリ側の都合で「とりあえず全部取る」「画面で使う列は一部なのに全列をSELECTする」「結局WHEREが弱くて大量に読む」という形になりがちです。DTU 100%の原因がデータI/O(論理読み取り)に寄っているなら、次の観点で見直すと改善しやすいです。
- 本当に必要な列だけに絞れているか(列数を減らすとI/Oもネットワークも減る)
- 絞り込み条件が効いているか(インデックス設計の出発点)
- 3時間ごとの処理が差分になっているか(毎回フルスキャンになっていないか)
発生元が特定できれば、アプリ担当者と「どの画面/どのジョブが、なぜこの列を読むのか」を具体的に議論できます。これが、SQL側だけで闇雲にチューニングするより早いことが多いです。
切り分けが終わったら必ずやる:セッションの停止と削除
Extended Eventsは強力ですが、常時回しっぱなしにする設計ではありません(特にLIKEフィルタを緩くしている場合)。目的のイベントが取れたら、不要になった監視は止めて片付けます。
ALTER EVENT SESSION [capture_sdk_token]
ON DATABASE
STATE = STOP;
GO
DROP EVENT SESSION [capture_sdk_token]
ON DATABASE;
GO
まとめ:発生元が分かれば、DTUスパイクは“対話可能な問題”になる
クエリ ストアやQuery Performance Insightは「重いクエリの特定」には非常に有効ですが、「誰が投げたか」までは基本的に教えてくれません。3時間ごとにDTU 100%へ跳ねるような現象では、推測ではなく接続情報の証拠が必要です。
- クエリ ストアで対象クエリを確定し、特徴語(列名・テーブル名)を整理する
- Extended Eventsで問題クエリだけをフィルタし、client_app_name/hostname/usernameを記録する
- 得られた情報を手掛かりに、ジョブ/サービス/監視の実行元を特定する
- 特定後は、アプリ改修・チューニング・スケジュール・スケールを現実的に組み合わせて再発防止する
「謎のクエリ」を“名前のあるアプリ”に変えられた瞬間から、関係者全員が同じ土俵で改善に動けます。まずはExtended Eventsで、3時間後の次のスパイクを確実に捕まえてください。

コメント