📚 本ガイドブックは、SQL Server をすでに使っている方、または開発現場にいきなり投入された方に向けた「あやふやをなくし、やってはいけないことを知る」ための実践書です。単なる機能説明ではなく、知らないと痛い目に遭う落とし穴と、ゲーム開発並みのチューニング思想を軸に解説します。あくまで個人環境での自己学習の結果をガイドブックにしていますので、サンプルはあくまでも雰囲気を見る用として捉えていただければ幸いです。
目次
- 実行環境と前提条件
- インデックスについて
- トランザクションについて
- tempdb について
- テーブル名の接頭辞
#と##について - 鬼門のサーバーまたがりのクエリーについて
- コラム:MSDTCは「素のネットワーク」を愛する
- 分離レベルと
WITH(NOLOCK)の罠 - よくある質問とトラブルシューティング
- SQL Server チューニングの現実と未来
実行環境と前提条件
本ガイドブックのサンプルコードは以下を前提とします。
| 項目 | 前提条件 |
|---|---|
| SQL Server バージョン | SQL Server 2016 以上(一部 2019 以上の機能を明記) |
| データベース | TestDB という名前のデータベースが存在すること |
| テーブル | 後述のサンプルDDLで作成した Orders・Accounts・Customers テーブル |
| ツール | SSMS(SQL Server Management Studio)または Azure Data Studio |
サンプルテーブルDDL
本ガイドブック全体を通して使用するサンプルテーブルです。まずこれを作成してからサンプルを実行してください。
USE TestDB;
GO
-- Customers テーブル
CREATE TABLE Customers (
CustomerId INT IDENTITY(1,1) PRIMARY KEY,
Name NVARCHAR(100) NOT NULL,
Email NVARCHAR(200) NOT NULL
);
-- Orders テーブル(主要なサンプル対象)
CREATE TABLE Orders (
OrderId INT IDENTITY(1,1) PRIMARY KEY,
OrderDate DATE NOT NULL,
CustomerId INT NOT NULL,
Amount DECIMAL(10, 2) NOT NULL,
Status NVARCHAR(50) NOT NULL DEFAULT 'New',
CONSTRAINT FK_Orders_Customers FOREIGN KEY (CustomerId) REFERENCES Customers(CustomerId)
);
-- Accounts テーブル(トランザクションサンプル用)
CREATE TABLE Accounts (
AccountId INT IDENTITY(1,1) PRIMARY KEY,
Balance DECIMAL(18, 2) NOT NULL DEFAULT 0
);
-- 動作確認用のサンプルデータ
INSERT INTO Customers (Name, Email)
VALUES (N'山田太郎', N'yamada@example.com'),
(N'佐藤花子', N'sato@example.com');
INSERT INTO Orders (OrderDate, CustomerId, Amount, Status)
VALUES ('2025-01-01', 1, 1000.00, 'New'),
('2025-01-15', 2, 2500.00, 'Shipped'),
('2025-02-01', 1, 800.00, 'New');
INSERT INTO Accounts (Balance)
VALUES (100000), (50000);
GO
⚠️ 注意:本番データベースでは上記DDLを実行しないでください。必ず開発・検証用の環境で使用してください。
インデックスについて
インデックスとは何か
インデックス(Index) とは、テーブル上のデータを高速に検索するための「索引」です。書籍の巻末索引と同じ仕組みで、特定の列の値に対してどのページ(ページ=SQL Serverのデータ格納単位)にデータがあるかを記録しています。
インデックスがない状態で検索を行うと、SQL Server はテーブルの全ページを先頭から末尾まで順番に読む テーブルスキャン(Table Scan) を行います。データ件数が少ないうちは問題になりませんが、数百万件・数千万件のテーブルになると、これが致命的なボトルネックになります。
なぜインデックスが重要なのか:ゲームのロードが遅い原因の大半がディスクのランダムアクセスであるように、SQLの遅さの大半は「どこを読むべきかわからない」ことによる全ページ読み込みです。インデックスはその「カーナビ」の役割を果たします。
インデックスの種類と使用するシーン
SQL Server には複数のインデックス種類があります。種類を誤ると、むしろパフォーマンスが悪化する場合があります。
| インデックス種別 | 説明 | 適したシーン |
|---|---|---|
| クラスター化インデックス | データ行そのものを物理的に並び替えて格納 | 主キー(1テーブルに1つのみ) |
| 非クラスター化インデックス | データとは別に索引を作成 | 検索・絞り込みに使う列 |
| 複合インデックス | 複数列を組み合わせたインデックス | WHERE句で複数列を組み合わせる検索 |
| カバリングインデックス | SELECT対象列もインデックスに含める | SELECT列とWHERE列が固定されているクエリ |
| フィルターインデックス | WHERE条件を付けて一部のデータのみ対象 | NULL以外・特定フラグのみを頻繁に検索する場合 |
| 列ストアインデックス | 列単位で圧縮格納(DWH向き) | 集計・分析クエリ(OLAP) |
🖥️ ローカルサーバー(自サーバー)での影響
ローカルサーバー上のテーブルへのクエリでは、インデックスが適切に利用されれば劇的な速度改善が見込めます。
実例比較:
-- インデックスなし:OrderDate で絞り込む(全件スキャン発生)
SELECT * FROM Orders WHERE OrderDate = '2025-01-01';
-- インデックスあり:OrderDate に非クラスター化インデックスを作成済み
CREATE INDEX IX_Orders_OrderDate ON Orders (OrderDate);
SELECT * FROM Orders WHERE OrderDate = '2025-01-01';
実行計画の確認方法:
-- 実行計画を確認する(Ctrl+M または先頭に SET SHOWPLAN_ALL ON を付加)
SET SHOWPLAN_ALL ON;
GO
SELECT * FROM Orders WHERE OrderDate = '2025-01-01';
GO
SET SHOWPLAN_ALL OFF;
出力例(インデックスなし):
Table Scan (Object: dbo.Orders) ... Estimated Rows: 1500000
出力例(インデックスあり):
Index Seek (IX_Orders_OrderDate) ... Estimated Rows: 120
Table Scan → Index Seek に変わっていれば、インデックスが使われています。Index Scan だった場合は、インデックスは使われているが全体スキャンになっているため、WHERE条件や複合インデックスの見直しが必要です。
🌐 サーバーまたがり(リモートサーバー)クエリでの影響
ここが最大の落とし穴です。
リモートサーバー(後述の4部構成名や OPENQUERY 等でアクセス)に対してクエリを発行する際、リモート側のインデックスが必ずしも活用されるとは限りません。
特に4部構成名(リンクサーバー名.データベース.スキーマ.テーブル)を使用した場合、SQL Server の最適化エンジンはリモート側のインデックス情報を十分に把握できず、ローカル側で全件フェッチしてからフィルタリングを行うという最悪のシナリオが発生することがあります。
-- NG例:リモートのテーブルから絞り込み(全件フェッチ後にフィルタが起きる危険性あり)
SELECT * FROM [RemoteServer].[SalesDB].[dbo].[Orders]
WHERE OrderDate = '2025-01-01';
これは、数百万件のデータをネットワーク越しに転送してから WHERE を適用するという、ネットワーク帯域の大量消費を引き起こします。
対策:リモートサーバー側にストアドプロシージャを用意し、絞り込み済みの結果だけをローカルに引き込む(後述の「サーバーまたがりのクエリー」セクションで詳述)。
トランザクションでのインデックスの影響
🖥️ ローカルサーバーでの影響
インデックスはSELECTを高速化しますが、INSERT / UPDATE / DELETE には逆効果になる場合があります。
なぜなら、データを変更するたびにインデックスも更新しなければならないからです。インデックスが多いテーブルほど、書き込み処理のコストが高くなります。
-- インデックスが多いテーブルへの大量INSERT(遅くなる可能性あり)
INSERT INTO Orders (OrderDate, CustomerId, Amount)
VALUES ('2025-01-01', 1001, 5000.00);
-- → Orders テーブルに10個のインデックスがあれば、10個全部を更新する
チューニングの方針:バッチ処理(大量INSERT/UPDATE)を行う前にインデックスを一時的に無効化し、処理が終わったら再構築するアプローチが有効です。
-- インデックスを無効化
ALTER INDEX IX_Orders_OrderDate ON Orders DISABLE;
-- 大量INSERTを実行(例:別テーブルからのデータ移行)
INSERT INTO Orders (OrderDate, CustomerId, Amount, Status)
SELECT OrderDate, CustomerId, Amount, Status
FROM SourceOrders
WHERE MigrationFlag = 1;
-- インデックスを再構築
ALTER INDEX IX_Orders_OrderDate ON Orders REBUILD;
⚠️ 注意:
DISABLE中はそのインデックスを使うクエリが実行できません(クラスター化インデックスを無効化するとテーブル全体がアクセス不能になります)。クラスター化インデックスは絶対に DISABLE しないでください。
🌐 サーバーまたがりのトランザクション中でのインデックスの影響
リモートサーバーを跨いだトランザクション(分散トランザクション)のなかでリモートテーブルへのアクセスが発生すると、インデックスが使われているかどうかに関係なく、ロックがリモートサーバー上に長時間残留する可能性があります。これがデッドロックや著しいパフォーマンス低下の原因になります。
後述の「サーバーまたがりのトランザクション」セクションで詳しく説明します。
このように使ってはいけない(インデックスのNG集)
❌ NG①:インデックス列を関数で加工してWHEREに使う
-- ❌ インデックスが無効化される!
SELECT * FROM Orders WHERE YEAR(OrderDate) = 2025;
-- ✅ 範囲指定に書き直すとインデックスが使われる
SELECT * FROM Orders WHERE OrderDate >= '2025-01-01' AND OrderDate < '2026-01-01';
理由:YEAR() のような関数を列に適用すると、SQL Server はインデックスを使って絞り込むことができず、全件スキャンに戻ります。これを「SARGable でない条件」と言います(SARGable = Search ARGument ABLE)。
❌ NG②:LIKE の先頭ワイルドカード
-- ❌ 前方ワイルドカードはインデックスが使えない
SELECT * FROM Customers WHERE Name LIKE '%山田%';
-- ✅ 前方一致ならインデックスが使われる
SELECT * FROM Customers WHERE Name LIKE '山田%';
❌ NG③:インデックスを作りすぎる
SELECT だけを高速化しようとしてインデックスを乱造すると、INSERT / UPDATE / DELETE が著しく遅くなります。また、インデックスはディスクスペースも消費します。目安は1テーブル5〜7個以下を意識してください。
💡 コラム:使われていないインデックスを発見する
運用中のサーバーに「本当に使われているのか分からないインデックス」が積み重なっていることは珍しくありません。以下のDMVクエリで、SQL Server 起動後に一度も使われていないインデックスを洗い出せます。
SQL-- 使われていない(またはほとんど使われていない)インデックスを確認 SELECT OBJECT_NAME(i.object_id) AS table_name, i.name AS index_name, i.type_desc, ISNULL(u.user_seeks, 0) AS user_seeks, ISNULL(u.user_scans, 0) AS user_scans, ISNULL(u.user_lookups, 0) AS user_lookups, ISNULL(u.user_updates, 0) AS user_updates -- 更新のたびにこのインデックスを維持しているコスト FROM sys.indexes i LEFT JOIN sys.dm_db_index_usage_stats u ON i.object_id = u.object_id AND i.index_id = u.index_id AND u.database_id = DB_ID() WHERE i.object_id > 100 -- システムテーブルを除外 AND i.type_desc = 'NONCLUSTERED' AND (u.user_seeks IS NULL OR u.user_seeks + u.user_scans + u.user_lookups = 0) ORDER BY ISNULL(u.user_updates, 0) DESC;
user_seeks / user_scans / user_lookupsがすべて 0 なのにuser_updatesが大きい場合、そのインデックスは「読まれずに更新コストだけ払っている」状態です。削除の候補になります。⚠️ ただし、このDMVは SQL Server の再起動でリセットされます。再起動後間もない時期の結果で判断しないようにしてください。
❌ NG④:断片化放置
インデックスは時間の経過とともに断片化(フラグメンテーション)します。断片化が進むと、インデックスがあっても遅くなります。定期的なメンテナンスを怠らないようにしてください。
-- インデックス断片化の確認
SELECT
index_id,
avg_fragmentation_in_percent,
page_count
FROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID('Orders'), NULL, NULL, 'LIMITED');
-- 断片化が30%未満 → REORGANIZE(オンラインで実行可能)
ALTER INDEX IX_Orders_OrderDate ON Orders REORGANIZE;
-- 断片化が30%以上 → REBUILD(ロック発生・オフラインが原則)
ALTER INDEX IX_Orders_OrderDate ON Orders REBUILD;
トランザクションについて
トランザクションとは何か?
トランザクション(Transaction) とは、「複数の処理をひとまとまりとして扱い、全部成功するか全部失敗するかのどちらかにする」仕組みです。
代表例として「銀行振込」が挙げられます。「A口座から引き落とす」と「B口座に入金する」は、どちらか一方だけ成功するとお金が消えたり増えたりします。この2つは必ずセットで成功・失敗しなければなりません。これがトランザクションの本質です。
SQL Serverでは、この整合性を ACID特性 と呼びます:
| 特性 | 正式名称 | 意味 |
|---|---|---|
| A | Atomicity(原子性) | 全部成功か全部失敗か |
| C | Consistency(一貫性) | 常にデータの整合性が保たれる |
| I | Isolation(独立性) | 他のトランザクションの影響を受けない |
| D | Durability(永続性) | コミット済みのデータは消えない |
SQL Server のトランザクションについて
SQL Server のトランザクションには3種類あります:
| 種類 | 特徴 | 用途 |
|---|---|---|
| 自動コミットトランザクション | 各DML文が1つのトランザクションとして即座にコミットされる | SQL Server のデフォルト動作。単独のINSERT/UPDATE/DELETE |
| 明示的トランザクション | BEGIN TRAN ~ COMMIT / ROLLBACK で明示的に制御 |
複数の処理をひとまとまりにしたい場合 |
| 暗黙的トランザクション | SET IMPLICIT_TRANSACTIONS ON で有効になる。DML/DDL文の実行開始時に自動で BEGIN TRAN が発行され、明示的な COMMIT / ROLLBACK が必要になる |
レガシーシステムや特定の互換性要件がある特殊なケース(通常は使わない) |
⚠️ よくある誤解:「暗黙的トランザクション」はSQL Serverのデフォルト動作ではありません。デフォルトは「自動コミット」です。暗黙的トランザクションは
SET IMPLICIT_TRANSACTIONS ONを明示的に設定した場合のみ有効になります。他のDBMSからの移行者が混乱しやすいポイントです。
-- 明示的トランザクションの基本構文
BEGIN TRANSACTION;
UPDATE Accounts SET Balance = Balance - 10000 WHERE AccountId = 1;
UPDATE Accounts SET Balance = Balance + 10000 WHERE AccountId = 2;
-- エラーがなければコミット
IF @@ERROR = 0
COMMIT TRANSACTION;
ELSE
ROLLBACK TRANSACTION;
現代的な書き方:
@@ERRORよりもTRY...CATCH構文を使うほうが安全で推奨されます:
BEGIN TRANSACTION;
BEGIN TRY
UPDATE Accounts SET Balance = Balance - 10000 WHERE AccountId = 1;
UPDATE Accounts SET Balance = Balance + 10000 WHERE AccountId = 2;
COMMIT TRANSACTION;
END TRY
BEGIN CATCH
-- エラー発生時にロールバック
ROLLBACK TRANSACTION;
THROW; -- エラーを呼び出し元に再スロー
END CATCH;
ローカルサーバーのトランザクションの動き
ローカルサーバー(自サーバー)内でのトランザクションでは、SQL Server のロックマネージャーがリソースを管理します。
ロックには以下の粒度があります(小→大):
| ロック粒度 | 説明 |
|---|---|
| 行ロック(Row Lock) | 特定の行だけをロック |
| ページロック(Page Lock) | 8KBのページ単位でロック |
| テーブルロック(Table Lock) | テーブル全体をロック |
| データベースロック(DB Lock) | データベース全体をロック |
SQL Server は最初は行ロックで始まり、ロック数が多くなると自動的に粒度を上げる ロックエスカレーション を行います。これが意図せずテーブルロックを引き起こし、他のユーザーを長時間ブロックする原因になります。
デッドロック発生のメカニズム:
-- セッション1
BEGIN TRANSACTION;
UPDATE Orders SET Status = 'Shipped' WHERE OrderId = 1;
-- この時点でOrderId=1の行をロック取得
-- セッション2(同時実行)
BEGIN TRANSACTION;
UPDATE Orders SET Status = 'Cancelled' WHERE OrderId = 2;
-- この時点でOrderId=2の行をロック取得
-- セッション1(続き)
UPDATE Orders SET Status = 'Shipped' WHERE OrderId = 2;
-- OrderId=2はセッション2がロック中 → 待機
-- セッション2(続き)
UPDATE Orders SET Status = 'Cancelled' WHERE OrderId = 1;
-- OrderId=1はセッション1がロック中 → 待機
-- → お互いが相手を待つ「デッドロック」発生!
SQL Server は約5秒でデッドロックを検知し、片方を「デッドロックの犠牲者」としてロールバックします。
デッドロック防止策:複数テーブル/行を更新する場合は、常に同じ順序でアクセスすることが基本です。
サーバーまたがり(リモートサーバー)のトランザクションの動き
ここが最も危険な地帯です。
ローカルサーバーとリモートサーバーにまたがるトランザクションは「分散トランザクション」になります。SQL Server はこれを処理するために Microsoft Distributed Transaction Coordinator(MS DTC) を使用します。
分散トランザクションは以下の「2相コミット(Two-Phase Commit)」で動作します:
フェーズ1(準備):
→ ローカルサーバー: 「コミットできますか?」
→ リモートサーバー: 「準備OK」
フェーズ2(確定):
→ ローカルサーバー: 「コミットしてください」
→ リモートサーバー: 「コミット完了」
この2段階の確認のために、ロックがネットワーク往復分だけ長く保持されることになります。これはデッドロックのリスクを著しく高めます。
重要な落とし穴:MS DTC が設定されていない環境では、分散トランザクションが
BEGIN TRANSACTIONのブロック内でリモートサーバーにアクセスした時点でエラーになります:
エラー メッセージ:
サーバー 'RemoteServer' ではリモート アクセスが許可されていないか、
サービス 'msdtc' が開始されていません。
このように使ってはいけない(トランザクションのNG集)
❌ NG①:トランザクションを長くかけすぎる
-- ❌ トランザクション内でユーザー入力待ちをするような設計
BEGIN TRANSACTION;
SELECT * FROM Customers; -- この結果をユーザーに見せて、次の操作を待つ
-- ← ここで数分間ロックが保持される
UPDATE Orders SET Status = 'Confirmed' WHERE CustomerId = 1;
COMMIT TRANSACTION;
トランザクション中はロックが保持されます。長時間のトランザクションは他のユーザーをブロックし続けます。
❌ NG②:TRY-CATCH の外側でトランザクションを開始する
-- ❌ 不完全なエラーハンドリング
BEGIN TRANSACTION;
UPDATE Accounts SET Balance = Balance - 1000 WHERE AccountId = 1;
-- ここでエラーが起きてもROLLBACKが呼ばれない
UPDATE Accounts SET Balance = Balance + 1000 WHERE AccountId = 2;
COMMIT;
必ず TRY...CATCH でラップし、CATCH ブロック内で ROLLBACK を行うことを徹底してください。
❌ NG③:ネストしたトランザクションを誤解する
SQL Server の BEGIN TRANSACTION はネストできますが、内側の COMMIT は外側のトランザクションを確定しません:
BEGIN TRANSACTION; -- トランザクションカウント: 1
BEGIN TRANSACTION; -- トランザクションカウント: 2
COMMIT TRANSACTION; -- カウントが2→1になるだけ(データは確定していない!)
ROLLBACK TRANSACTION; -- カウントが1→0 → 全部ロールバック!
@@TRANCOUNT で現在のネスト深さを確認できます。
❌ NG④:分散トランザクション内での大量データ操作
リモートサーバーへの UPDATE や INSERT を BEGIN TRANSACTION の中で大量に行うと、MS DTC のタイムアウトが発生したり、ネットワーク障害時にどちらのサーバーの状態が正となるか不定になる「インダウトトランザクション」が発生します。これは非常に厄介です。
tempdb について
tempdb とは何か?
tempdb は、SQL Server インスタンス内のすべてのデータベースが共有する特殊なシステムデータベースです。SQL Server が再起動するたびに中身は自動的にリセットされます(永続化されません)。
tempdb が使われる主なシーン:
| 用途 | 説明 |
|---|---|
一時テーブル(# / ##) |
セッション/グローバルスコープの一時テーブル |
テーブル変数(@table) |
内部的に tempdb を使う場合がある |
| スピル(Spill to Disk) | ソート・ハッシュ結合などがメモリに収まらない場合の作業領域 |
| スナップショット分離 | RCSI(行バージョン管理)の行バージョン格納 |
| カーソル | カーソルの作業領域 |
重要点:tempdb は全ユーザー・全データベースが共有します。1つのクエリが tempdb を大量に使うと、他の全ユーザーのクエリが遅くなるという影響が出ます。
tempdb のサイズと空き確認:
-- tempdb の現在の使用量を確認
SELECT
DB_NAME(database_id) AS DBName,
type_desc,
name,
size * 8 / 1024 AS size_MB,
CASE max_size
WHEN -1 THEN 'ディスク上限まで使用可(実質的なディスク容量が上限)'
ELSE CAST(max_size * 8 / 1024 AS VARCHAR) + ' MB'
END AS max_size_desc
FROM sys.master_files
WHERE database_id = DB_ID('tempdb');
出力例:
DBName type_desc name size_MB max_size_desc
tempdb ROWS tempdev 1024 ディスク上限まで使用可(実質的なディスク容量が上限)
tempdb LOG templog 512 ディスク上限まで使用可(実質的なディスク容量が上限)
⚠️
-1の意味に注意:max_size = -1は「無制限」ではなく、「ディスクの空き容量がなくなるまで使う」という意味です。つまりディスクが満杯になると SQL Server 全体が停止します。本番環境では最大サイズを明示的に制限することを強く推奨します。
このように使ってはいけない(tempdb のNG集)
❌ NG①:tempdb に大量データを長時間保持する
tempdb は全ユーザー共有のリソースです。大きな一時テーブルを長時間放置すると、他のユーザーに影響します。使い終わったら DROP TABLE #TempTable; で明示的に削除してください。
-- ❌ 使い終わっても消えるのを「SQL Serverに任せる」設計
CREATE TABLE #WorkData (Id INT, Value NVARCHAR(200));
INSERT INTO #WorkData
SELECT CustomerId, CAST(Amount AS NVARCHAR(200)) FROM Orders;
-- 何かの処理
-- → セッションが終わるまで tempdb を占有し続ける
-- ✅ 使い終わったらすぐに削除
DROP TABLE IF EXISTS #WorkData;
❌ NG②:ループ内で毎回テーブルを作成・削除する
-- ❌ ループ内での繰り返しCREATE/DROP(tempdbへの負荷大)
DECLARE @i INT = 1;
WHILE @i <= 10000
BEGIN
CREATE TABLE #Temp (Val INT);
INSERT INTO #Temp VALUES (@i);
-- 何かの処理
DROP TABLE #Temp;
SET @i = @i + 1;
END
-- ✅ ループの外でTRUNCATE+再利用
DECLARE @i INT = 1;
CREATE TABLE #Temp (Val INT);
WHILE @i <= 10000
BEGIN
TRUNCATE TABLE #Temp;
INSERT INTO #Temp VALUES (@i);
-- 何かの処理
SET @i = @i + 1;
END
DROP TABLE #Temp;
❌ NG③:テーブル変数として使ったほうがいい場面で一時テーブルを使う(逆も然り)
| 比較項目 | テーブル変数(@table) |
一時テーブル(#table) |
|---|---|---|
| 統計情報 | なし(推定行数が常に1件) | あり |
| インデックス | 作成できない(主キーのみ) | 追加可能 |
| 適したデータ量 | 数百件以下の小さなデータ | 数千件以上のデータ |
| スコープ | バッチが終われば消える | セッションが終われば消える |
| ログ書き込み | 最小限 | あり(ロールバック可能) |
判断基準:少量データで使い捨てなら
@table、大量データや複雑な処理には#tableを選択してください。
❌ NG④:tempdb のファイルが1つしかない(パフォーマンス問題)
SQL Server の tempdb はデフォルトで1ファイルですが、複数ファイルに分割することで並列アクセスの競合(PFS/GAM/SGAM ページの競合)を減らすことができます。
推奨設定:論理CPUコア数に応じて、最大8ファイルまで増やすことが Microsoft の公式推奨です。
-- tempdb にデータファイルを追加(4コアの例:4ファイルに)
ALTER DATABASE tempdb
ADD FILE (
NAME = 'tempdev2',
FILENAME = 'D:\SQLData\tempdb2.ndf',
SIZE = 1024MB,
FILEGROWTH = 256MB
);
テーブル名の接頭辞 # と ## について
これは何か
SQL Server では、テーブル名の先頭に特殊な記号を付けることで、格納場所とスコープを変えることができます。
| 記号 | 名称 | 格納先 | スコープ |
|---|---|---|---|
#テーブル名 |
ローカル一時テーブル | tempdb | 作成したセッションのみ(セッション終了で削除) |
##テーブル名 |
グローバル一時テーブル | tempdb | 全セッション共有(全員が参照を終えたら削除) |
| なし | 永続テーブル | 通常のデータベース | 明示的にDROPするまで存在 |
使用例:
-- ローカル一時テーブル(自分のセッションだけに見える)
CREATE TABLE #MyTemp (Id INT, Name NVARCHAR(100));
INSERT INTO #MyTemp VALUES (1, N'山田太郎');
SELECT * FROM #MyTemp;
-- セッション終了(接続が切れる)と自動削除
-- グローバル一時テーブル(全セッションから見える)
CREATE TABLE ##SharedTemp (Id INT, Name NVARCHAR(100));
INSERT INTO ##SharedTemp VALUES (1, N'共有データ');
SELECT * FROM ##SharedTemp; -- 別のセッションからも参照できる
-- 全セッションが参照を終えると自動削除
内部の話:
#TempTableは tempdb 内では#TempTable___________00000000001Aのようなシステムが付加したサフィックス付きの名前で格納されています。これにより、複数セッションが同名の#テーブルを作っても競合しない仕組みになっています。
このように使ってはいけない
❌ NG①:グローバル一時テーブル(##)を業務データの受け渡しに使う
-- ❌ 危険な使い方:セッション間のデータ共有に ## を使う
CREATE TABLE ##TransferData (Id INT, Status NVARCHAR(50));
-- ← セッションAがデータを入れて、セッションBが読む設計
-- 問題1:セッションAが切れたら ##TransferData は消える
-- 問題2:他の人も同じ名前で作ろうとするとエラーになる
-- 問題3:複数のユーザーが同時に使う場合の排他制御がない
セッション間のデータ共有には永続テーブルやキューテーブルを使ってください。
❌ NG②:IF OBJECT_ID('tempdb..#Temp') IS NOT NULL チェックを忘れる
バッチを再実行する際に、前回の実行で一時テーブルが残っているとエラーになります:
-- ❌ 再実行でエラーになるコード
CREATE TABLE #Temp (Id INT); -- 既に存在するとエラー: オブジェクト '#Temp' はすでに存在します
-- ✅ 安全なコード(SQL Server 2016以降)
DROP TABLE IF EXISTS #Temp;
CREATE TABLE #Temp (Id INT);
-- ✅ SQL Server 2014以前の書き方
IF OBJECT_ID('tempdb..#Temp') IS NOT NULL DROP TABLE #Temp;
CREATE TABLE #Temp (Id INT);
❌ NG③:ストアドプロシージャ内の一時テーブルに統計情報が古い問題
ストアドプロシージャ内で一時テーブルを作成してデータを入れると、最初のコンパイル時の統計情報でプランがキャッシュされます。2回目以降の実行でデータ量が大きく変わっても、古い実行計画が使われ続ける(パラメータスニッフィング問題の一時テーブル版)ことがあります。
-- 解決策:統計情報を明示的に更新する
DROP TABLE IF EXISTS #TempWork;
CREATE TABLE #TempWork (Id INT, Amount DECIMAL(10, 2));
INSERT INTO #TempWork
SELECT OrderId, Amount FROM Orders WHERE CustomerId = 1;
-- 統計情報を更新してからクエリを実行
UPDATE STATISTICS #TempWork;
SELECT o.OrderId, t.Amount
FROM Orders o
JOIN #TempWork t ON o.OrderId = t.Id;
鬼門のサーバーまたがりのクエリーについて
サーバーまたがりとは
サーバーまたがりのクエリー(Cross-Server Query) とは、ローカルサーバー(以下「自サーバー」)から、ネットワーク上の別の SQL Server(以下「リモートサーバー」)のデータにアクセスするクエリのことです。
SQL Server でリモートサーバーにアクセスする主な方法は以下の3種類です:
| 方法 | 形式 | 特性 |
|---|---|---|
| 4部構成名 | [サーバー].[DB].[スキーマ].[テーブル] |
リンクサーバー経由。最も直感的だが危険も多い |
| OPENROWSET | OPENROWSET('SQLNCLI17', '接続文字列', 'クエリ') |
アドホック接続。リンクサーバー不要 |
| OPENQUERY | OPENQUERY(リンクサーバー名, 'クエリ') |
リモート側でクエリを実行してから結果を返す |
用語について:「ローカル」「リモート」が標準的な用語です。以降は自サーバー(ローカル)・リモートサーバー(リモート)と呼びます。
高速なデータ取得のセオリー
🔑 大原則:「データを引き込む」のではなく「加工してから引き込む」
ネットワーク越しのデータ転送がボトルネックになります。リモートの全データをローカルに持ってきてからフィルタをかけるのは最悪のパターンです。
理想のフロー:
リモートサーバー:ストアドプロシージャで絞り込み・集計
↓ (絞り込み済みの少量データだけがネットワークを流れる)
自サーバー:受け取ったデータを使って以降の処理
💡 私の実践経験から:リモートSTORED PROCEDUREを自サーバーで呼び出す手法について
「リモートサーバーに設置したストアドプロシージャから結果を自サーバーに引き込む」という手法。これが非常に妥当かつ正攻法の手法です。
この手法の優れた点:
| 観点 | 評価 | 理由 |
|---|---|---|
| パフォーマンス | ⚡ 優秀 | リモート側でフィルタ・集計が完了してから転送されるため、ネットワーク転送量が最小 |
| インデックス活用 | ⚡ 優秀 | リモートのストアドはリモートのインデックスを最大活用できる |
| ロック影響 | ✅ 良好 | ストアド単独の実行はMSDTCを使わず、自サーバーのロック影響を最小化 |
| 保守性 | ✅ 良好 | リモート側のロジック変更が自サーバー側に影響しない |
-- リモートのストアドプロシージャを呼び出して結果をテーブル変数に入れる
DECLARE @Results TABLE (
OrderId INT,
Name NVARCHAR(200),
Amount DECIMAL(18, 2)
);
INSERT INTO @Results
EXEC [RemoteServer].[SalesDB].[dbo].[usp_GetMonthlyOrders]
@Year = 2025,
@Month = 1;
-- 取得したデータをローカルのデータと結合
SELECT r.*, c.Email
FROM @Results r
JOIN Customers c ON r.OrderId = c.CustomerId;
注意点:リモートのストアドを
EXECで呼んで結果をINSERT...EXECで受け取る際、自サーバー側もBEGIN TRANSACTION内にいる場合はMSDTCが必要になることがあります。BEGIN TRANSACTIONの外側で実行するか、設計を工夫してください。
4部構成名、OPENROWSET、OPENQUERY の説明と特性
📋 4部構成名(Four-Part Name)
-- 構造:[リンクサーバー名].[データベース名].[スキーマ名].[テーブル名]
SELECT * FROM [RemoteServer].[SalesDB].[dbo].[Orders];
特性:
- 最もシンプルに書けるが、最も危険
- SQL Server はクエリ最適化のためにリモートテーブルの統計情報を活用しようとするが、統計情報の収集にリモートアクセスが発生し、かえって遅くなることがある
- WHERE句の条件がローカルで評価される場合がある(つまりリモートのフルスキャン後にローカルフィルタ)
実際のトラブル事例(実体験):
-- リモートの数百万件テーブルから絞り込みしようとした例
SELECT *
FROM [RemoteServer].[SalesDB].[dbo].[Orders]
WHERE OrderDate BETWEEN '2025-01-01' AND '2025-01-31';
-- → リモートの Orders 全件(数百万件)がネットワーク転送された後に
-- ローカル側で WHERE フィルタが適用
-- → ネットワーク帯域の大量消費、処理時間が数十分に
-- → タイムアウトやブロックが発生し、他の処理まで影響
📋 OPENROWSET
-- 解決策は、OPENROWSET はアドホック接続(リンクサーバー設定不要)
SELECT *
FROM OPENROWSET(
'SQLNCLI17',
'Server=RemoteServer;Database=SalesDB;Trusted_Connection=Yes;',
'SELECT Id, Amount FROM dbo.Orders WHERE OrderDate >= ''2025-01-01'''
);
特性:
- リンクサーバーを事前設定する必要がない
- クエリ文字列を直接渡すため、リモートサーバー上でそのクエリが実行される(フィルタがリモートで行われる)
- ただし、文字列の中にSQLを書くため可読性が下がりやすい
- セキュリティポリシーで
Ad Hoc Distributed Queriesの有効化が必要
⚠️ 文字列内のシングルクォートに注意:OPENROWSET / OPENQUERY の内側クエリで文字列リテラルを使う場合、シングルクォートを
''と二重にする必要があります。これはミスが発生しやすいポイントです。
-- ❌ NG:シングルクォートが1つだと構文エラー
SELECT * FROM OPENROWSET(..., 'SELECT * FROM Orders WHERE Status = 'New'');
-- ✅ OK:シングルクォートを '' と二重にする
SELECT * FROM OPENROWSET(..., 'SELECT * FROM Orders WHERE Status = ''New''');
-- Ad Hoc Distributed Queries の有効化(要管理者権限)
EXEC sp_configure 'show advanced options', 1;
RECONFIGURE;
EXEC sp_configure 'Ad Hoc Distributed Queries', 1;
RECONFIGURE;
📋 OPENQUERY(推奨)
-- リンクサーバーを使ったOPENQUERY
SELECT *
FROM OPENQUERY([RemoteServer],
'SELECT Id, Amount FROM SalesDB.dbo.Orders WHERE OrderDate >= ''2025-01-01'''
);
特性:
- クエリがリモートサーバー側で実行されてから結果がローカルに返る(パススルークエリ)
- リモートのインデックスが最大限に活用される
- 4部構成名と比べて劇的にパフォーマンスが良い場合がある
4部構成名 vs OPENQUERY:実体験の失敗談から学ぶ
「リモートサーバーの tempdb を利用する時、OPENQUERY だと高速で、4部構成名だとブロックの嵐で処理を打ち切るしかなかった」という経験について
これは非常に重要な実体験です。なぜそうなったのかを解説します:
4部構成名のケース(地獄の始まり):
-- ❌ NG:リモートの大きな一時テーブルを4部構成名で参照
SELECT *
FROM [RemoteServer].[tempdb].[dbo].[SharedWork]
WHERE SomeColumn = 'TargetValue';
-- 内部で起きていること:
-- 1. SQL Server がリモートの SharedWork にアクセス
-- 2. クエリオプティマイザーがローカルでのフィルタリングを決定
-- 3. SharedWork の全件(数百万行)がローカルに転送開始
-- 4. ネットワーク・tempdb の両方に対して重いロックがかかり続ける
-- 5. 他のプロセスが tempdb やネットワークリソースを使えなくなる
-- 6. タイムアウト・ブロックが続出し、最終的に処理を強制終了するしかない
⚠️ さらに重要な注意:グローバル一時テーブル(
##)は、作成したセッションが終了すると自動的に削除されます。Linked Server 経由でのアクセスは別セッション扱いになるため、もとのセッションが生きている間しか参照できません。また、4部構成名でtempdbの##テーブルを参照するパターンは動作が不安定なため、実テーブルや OPENQUERY の使用を強く推奨します。
OPENQUERY のケース(解決の鍵):
-- ✅ 推奨:同じことをOPENQUERYで行う
SELECT *
FROM OPENQUERY([RemoteServer],
'SELECT * FROM tempdb.dbo.SharedWork WHERE SomeColumn = ''TargetValue'''
);
-- 内部で起きていること:
-- 1. フィルタリングがリモートサーバー内で実行される
-- 2. WHERE 条件に合致した少ない行だけがネットワークを流れる
-- 3. tempdb のロック時間は最小限
-- 4. 処理が高速に完了する
まとめると:
| 比較項目 | 4部構成名 | OPENQUERY |
|---|---|---|
| WHERE の実行場所 | ローカル(危険) | リモート(安全) |
| ネットワーク転送量 | テーブル全件 | 条件一致分のみ |
| リモートインデックス活用 | 不確実 | 確実 |
| tempdb ロック影響 | 長時間・広範囲 | 最小限 |
| 使い時 | 小さなテーブルのみ | 基本はこちらを使う |
このように使ってはいけない(サーバーまたがりのNG集)
❌ NG①:トランザクション内でリモートサーバーに大量書き込みをする
-- ❌ 分散トランザクション内での大量書き込み
BEGIN TRANSACTION;
INSERT INTO [RemoteServer].[SalesDB].[dbo].[OrderLog]
(OrderDate, CustomerId, Amount)
SELECT OrderDate, CustomerId, Amount
FROM Orders
WHERE Status = 'New'; -- 万件レベル
COMMIT;
-- → MS DTC のタイムアウト・ロック嵐・インダウトトランザクションのリスク大
❌ NG②:サーバーまたがりのJOINを複数テーブルスケールで行う
-- ❌ ローカルとリモートの複数テーブルを複雑にJOINする
SELECT a.*, b.*, c.*
FROM [RemoteServer1].[DB1].[dbo].[TableA] a
JOIN [RemoteServer2].[DB2].[dbo].[TableB] b ON a.Id = b.AId
JOIN Orders c ON b.Id = c.CustomerId;
-- → 最適化が効かず、最悪のプランで動く可能性
対策:リモートのデータをローカルの一時テーブルに取得してから JOIN する。
-- ✅ 段階的に取得してから JOIN
DROP TABLE IF EXISTS #StageA;
DROP TABLE IF EXISTS #StageB;
CREATE TABLE #StageA (Id INT, Val NVARCHAR(100));
INSERT INTO #StageA
SELECT Id, Val
FROM OPENQUERY([RemoteServer1], 'SELECT Id, Val FROM DB1.dbo.TableA WHERE IsActive = 1');
CREATE TABLE #StageB (AId INT, Val NVARCHAR(100));
INSERT INTO #StageB
SELECT AId, Val
FROM OPENQUERY([RemoteServer2], 'SELECT AId, Val FROM DB2.dbo.TableB WHERE IsActive = 1');
SELECT a.*, b.*, c.*
FROM #StageA a
JOIN #StageB b ON a.Id = b.AId
JOIN Orders c ON b.AId = c.CustomerId;
DROP TABLE #StageA;
DROP TABLE #StageB;
❌ NG③:リンクサーバーの接続タイムアウトを考慮しない
リンクサーバー経由でのクエリには、デフォルトのタイムアウト設定があります。長時間かかるクエリでは途中で切断されることがあります。
-- リンクサーバーの接続タイムアウト確認
SELECT name, connect_timeout, query_timeout
FROM sys.servers
WHERE is_linked = 1;
出力例:
name connect_timeout query_timeout
RemoteServer 0 0
0 は「タイムアウトなし(無制限)」です。本番環境では適切な値を設定することを検討してください。
❌ NG④:OPENROWSET/OPENQUERY にパスワードを平文で書く
-- ❌ パスワードを平文でクエリに書く(ログに残る)
SELECT *
FROM OPENROWSET('SQLNCLI17', 'Server=Remote;Database=DB;User=sa;Password=P@ssw0rd!',
'SELECT * FROM Orders');
パスワードが SQL ログやクエリプランキャッシュに残ります。Windows 認証(Trusted_Connection=Yes)を使うか、資格情報は適切に管理してください。
コラム:MSDTCは「素のネットワーク」を愛する
MSDTCとは何か
MSDTC(Microsoft Distributed Transaction Coordinator) は、複数のサーバーにまたがる 分散トランザクション を調整するWindowsのシステムサービスです。サーバーAとサーバーBに対して「どちらの更新も両方コミット、または両方ロールバック」というACIDの原子性を保証します。
前章の「サーバーまたがりのクエリー」において、BEGIN TRANSACTION の中からリモートサーバーへの書き込みを行うと、SQL Server は自動的にMSDTCへ分散トランザクションの調整を依頼します。
「なぜ急に出てくるのか」:リモートへの参照(SELECT)だけならMSDTCは起動しません。リモートへの更新(INSERT / UPDATE / DELETE)、または
BEGIN TRANSACTIONの中でリモートストアドを呼び出した場合にMSDTCが必要になります。
MSDTCの3つの落とし穴
落とし穴①:135番ポートだけでは動かない(RPC動的ポートの呪縛)
MSDTCの通信は2段階になっています。
| フェーズ | 使用ポート | 役割 |
|---|---|---|
| 交渉フェーズ | TCP 135(固定) | RPCエンドポイントマッパーが「実際に使うポート番号」を返す |
| 通信フェーズ | TCP 49152~65535(動的) | 実際のトランザクションデータが流れる |
問題のメカニズム:
クライアント → 135番ポートへ問い合わせ → RPCエンドポイントマッパー
← 「52000番ポートを使ってください」 ←
クライアント → 52000番ポートに直接接続 → ❌ プロキシが塞いでいて到達できない
nginxなどで135番ポートだけをフォワードしても、RPC エンドポイントマッパーが返した動的ポート番号への接続はプロキシを経由しません。クライアントはそのポートに 直接アクセスしようとする ため、プロキシの裏側で動的ポートが遮断されていると、セッションが確立できずに失敗します。
ポートを固定する方法(参考):Windowsの「コンポーネントサービス」→「MSDTCのプロパティ」→「セキュリティ」でMSDTCが使用するポートを1つに固定し、そのポートだけをファイアウォールで開けるという方法はあります。ただし、後述の「名前解決の問題」も同時に解決しなければ動きません。
落とし穴②:双方向の名前解決が必須(ホスト名の呪縛)
MSDTCのプロトコル(MS-DTCO)では、自分のホスト名を通信パケット内に埋め込んで相手に送ります。
サーバーAがサーバーBへ送るパケット内容:
「私はホスト名 SERVER-A です。確認応答は SERVER-A の 52000番ポートへ返してください」
これがnginxなどのプロキシ越しになると、次のことが起きます:
サーバーA(nginx 裏) ──→ nginx ──→ サーバーB
(proxy)
サーバーBが受け取るパケット:「返事は SERVER-A の 52000番ポートへ」
サーバーBの動作:SERVER-A を名前解決して直接接続しようとする
結果:SERVER-A は nginx の裏にいるため到達不能 → トランザクション タイムアウト
プロキシ側のIPやホスト名で通信を中継しても、DTCプロトコル内に埋め込まれたホスト名は書き換えられません。そのため、Callback(逆方向接続)のルートが見つからず、トランザクションがタイムアウトします。
補足:これはHTTPのリバースプロキシとは本質的に異なります。HTTPはアプリケーション層でヘッダを書き換えることができますが、MSDTCのRPCプロトコルはTCPトランスポート上でホスト名を直接埋め込むため、透過的な中継が構造上困難です。
落とし穴③:「設定は正しいのに動かない」という最も気づきにくいパターン
上記2つの問題が組み合わさると、以下のような「どこも間違っていないのに動かない」という状況が発生します:
| 確認項目 | 状態 | 実際の問題 |
|---|---|---|
| MSDTCサービスの起動 | ✅ 両サーバーで稼働中 | 問題なし |
| ファイアウォールの135番 | ✅ 開放済み | 動的ポートが未開放 |
| ホスト名のDNS登録 | ✅ 登録済み | プロキシ越しでは逆引き到達不能 |
| ネットワーク疎通 | ✅ ping 通る | プロキシを通るため実際のポートに届かない |
実体験より:「設定は全部正しいはず、それなのになぜ動かないのか」という状況に陥ったとき、まずネットワーク経路にnginxなどのプロキシが介在していないかを確認してください。すべてのMSDTCトラブルの中で最も気づきにくいパターンです。
現実的な判断:「分散トランザクションから降りる」
クラウド、プロキシ、異なるネットワークセグメントなど、複雑なネットワーク構成が前提の企業環境では、MSDTCに頼る設計自体がリスクです。次の代替アプローチへの切り替えを早めに検討してください。
| アプローチ | 概要 | 適したシーン |
|---|---|---|
| アプリケーション層でのリトライ処理 | 更新失敗時にアプリ側でリトライ・補正処理を書く | 更新頻度が低く、失敗時のリカバリが容易なケース |
| 結果整合性(Eventual Consistency)の許容 | 一時的な不整合を許容し、非同期で整合を取る | バッチ処理・ログ系テーブルなど即時整合が不要なケース |
| 更新処理をリモートストアドに委譲 | リモートサーバー側のストアドで更新を完結させる | ローカルからは結果の確認のみ行うケース |
| サーバー間の直接接続を確立 | プロキシを廃止し、サーバー同士が直接通信できるネットワーク構成にする | セキュリティ要件が許す場合の根本解決 |
現場の知恵として:「分散トランザクションを諦めて、アプリケーション側での整合性確保に切り替える」という判断は、決して設計の敗北ではありません。複雑なネットワーク制約の中で安定稼働を優先した、現実的な最適解です。動かない環境でMSDTCに固執し続けることこそ、最大のリスクです。
まとめ:MSDTCが要求する「3つの素直さ」
| 要求 | 内容 | プロキシ環境での問題 |
|---|---|---|
| ポートを隠さない | 135番に加え、動的ポート(49152~65535)への直接アクセスが必要 | 固定ポートしか通れない経路では動的ポートに到達できない |
| 名前を隠さない | 双方のサーバーがお互いのホスト名で直接到達できる必要がある | パケット内のホスト名はプロキシが書き換えられず、Callbackが迷子になる |
| 経路を曲げない | 両サーバー間で対称な通信が成立する必要がある | プロキシによる一方向の経路変換はDTCプロトコルと根本的に相性が悪い |
セキュリティを強化した企業ネットワークでは、これら3つのうちどれか1つでも欠けるとMSDTCは動作しません。その場合は早期に「アプリケーション側での代替設計」へシフトすることが、現場で生き残るための最適解です。
分離レベルと WITH(NOLOCK) の罠
分離レベルとは何か
ACID特性の「I(独立性)」を具体的にどの程度保証するかを制御するのが 分離レベル(Isolation Level) です。分離レベルを下げると「他のトランザクションの影響を受けやすくなる代わりに、ロックによる待機が減る」というトレードオフがあります。
SQL Server の分離レベルは以下の5段階です(上ほど厳密・遅い、下ほど緩い・速い):
| 分離レベル | ダーティリード | 反復不能読み取り | ファントムリード | 特性 |
|---|---|---|---|---|
| SERIALIZABLE | ❌ | ❌ | ❌ | 最も厳密。同一トランザクション内で同じ結果が保証 |
| REPEATABLE READ | ❌ | ❌ | ✅ | 同一行の再読みは保証。新規行の挿入は防げない |
| READ COMMITTED(デフォルト) | ❌ | ✅ | ✅ | コミット済みデータのみ読む。SQL Server の標準 |
| READ UNCOMMITTED | ✅ | ✅ | ✅ | コミット前のデータも読む。最も危険 |
| SNAPSHOT | ❌ | ❌ | ❌ | 行バージョン管理で読取時ロックを回避(tempdbを使用) |
用語解説:
- ダーティリード:他のトランザクションがまだコミットしていない(ROLLBACKされるかもしれない)データを読んでしまう
- 反復不能読み取り:同一トランザクション内で同じ行を2回読むと値が変わっている
- ファントムリード:同一トランザクション内で同じ条件で検索すると件数が変わっている
WITH(NOLOCK) の正体と危険性
現場では「とりあえずNOLOCKをつける」という習慣が広がっています。これは非常に危険です。
WITH(NOLOCK) は READ UNCOMMITTED 分離レベルをそのテーブルだけに適用するヒントです。
-- これはやっていることが同じ
SELECT * FROM Orders WITH(NOLOCK);
-- ↑ と ↓ は等価
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT * FROM Orders;
SET TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 戻すのを忘れると大惨事
WITH(NOLOCK) が引き起こす3つの問題
問題① ダーティリード:存在しないデータを読む
-- セッション1(ロールバックされる予定のトランザクション)
BEGIN TRANSACTION;
INSERT INTO Orders (OrderDate, CustomerId, Amount, Status)
VALUES ('2025-01-01', 1, 100000.00, 'New');
-- まだ COMMIT していない
-- セッション2(WITH(NOLOCK) で読んでいる)
SELECT * FROM Orders WITH(NOLOCK) WHERE Amount = 100000.00;
-- → ロールバックされるはずのデータが見えてしまう!
-- セッション1(ロールバック)
ROLLBACK TRANSACTION;
-- → セッション2が読んだデータは「幻のデータ」だった
問題② 同じ行が2回返ってくる(または行が消える)
インデックスの再構築中など、ページの物理的な移動が発生している際に WITH(NOLOCK) で読むと、同じ行が重複して返ってきたり、本来あるはずの行がスキップされることがあります。これは SQL Server の公式ドキュメントにも記載されている既知の現象です。
問題③ 隠れた状態管理
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED でセッションレベルに設定した場合、設定を戻し忘れるとそのセッションのすべてのクエリが永続的にダーティリードになります。
❌ WITH(NOLOCK) のNG集
❌ NG①:「ロック待ちが嫌だから」の乱用
-- ❌ 全テーブルにNOLOCKを貼る習慣
SELECT o.*, c.Name
FROM Orders o WITH(NOLOCK)
JOIN Customers c WITH(NOLOCK) ON o.CustomerId = c.CustomerId
WHERE o.Status = 'New';
-- → 集計レポートや在庫確認に使うと、架空の数字が混入する
❌ NG②:金額・在庫・ステータスなど「正確性が必要なデータ」にNOLOCKを使う
-- ❌ 金額をNOLOCKで確認(コミット前の架空データが混入する恐れ)
SELECT SUM(Amount) FROM Orders WITH(NOLOCK) WHERE CustomerId = 1;
-- → 別のトランザクションがロールバックした変更を読んでしまう可能性
✅ ロック競合を減らす正しいアプローチ
「NOLOCKをつけたい」という気持ちの背景には「読み取り時のロック待ちをなくしたい」というニーズがあります。これを安全に解決する方法があります。
① RCSI(Read Committed Snapshot Isolation)を有効にする
データベースレベルで設定すると、READ COMMITTED(デフォルト)の動作が変わり、読み取りがロックを取得しなくなります。行バージョン管理(tempdb を使用)を利用します。
-- データベースで RCSI を有効にする(要排他的アクセス)
ALTER DATABASE TestDB
SET READ_COMMITTED_SNAPSHOT ON;
RCSI を有効にすると:
WITH(NOLOCK)なしの通常クエリがロックをとらずに読める- ダーティリードは発生しない(コミット済みの最新スナップショットを読む)
- NOLOCKが不要になる
代償:tempdbへの負荷が増えます(前述の「tempdb」セクション参照)。
② 分離レベルを明示的に指定する
-- 特定のバッチのみ読み取りロックなしで実行する(使用後は必ず戻す)
SET TRANSACTION ISOLATION LEVEL SNAPSHOT;
SELECT * FROM Orders WHERE OrderDate = '2025-01-01';
SET TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 必ず戻す
まとめ:
| やりたいこと | 正しい方法 | 危険な方法 |
|---|---|---|
| 読み取りブロックをなくしたい | データベースで RCSI を有効にする | WITH(NOLOCK) を乱用する |
| 一時的に読み取りロックなしにしたい | SNAPSHOT 分離レベルを明示指定 |
READ UNCOMMITTED をセッションに設定したまま放置 |
| 「古くてもいいデータ」を読みたい | RCSI または SNAPSHOT | WITH(NOLOCK)(架空データが混入するリスクあり) |
よくある質問とトラブルシューティング
Q1. インデックスを作ったのに遅いままです。なぜですか?
A. 以下の順番で確認してください:
- 実行計画を確認する:
Index Seekになっているか、Table ScanやIndex Scanのままか確認する(前述参照) - SARGable でないWHEREを使っていないか確認:列に関数を適用したり LIKE '% ...' にしていないか
- インデックスの断片化を確認:
sys.dm_db_index_physical_statsで断片化率を確認し、30%超ならREBUILD - 統計情報が古い可能性:
UPDATE STATISTICS テーブル名を実行して最新化する - カーディナリティが低い列:Yes/No フラグのような取り得る値が少ない列はインデックスの効果が薄い
💡 コラム:統計情報とはなにか?なぜ古くなると遅くなるのか?
SQL Server はクエリを実行する前に「実行計画(Execution Plan)」を作成します。この計画を作る際に「このテーブルに対象データは何件あるか?」を推定するために使うのが統計情報(Statistics)です。
統計情報が古いと、実際には100万件あるテーブルを「1000件のはず」と誤判断し、インデックスを使わない非効率な実行計画を選択することがあります。
SQL-- 統計情報の最終更新日を確認する SELECT OBJECT_NAME(s.object_id) AS table_name, s.name AS stats_name, sp.last_updated, sp.rows, sp.rows_sampled, sp.modification_counter -- この値が大きいほど古い FROM sys.stats s CROSS APPLY sys.dm_db_stats_properties(s.object_id, s.stats_id) sp WHERE OBJECT_NAME(s.object_id) = 'Orders' ORDER BY sp.modification_counter DESC;
modification_counterが大きい(データの変更回数が多い)のにlast_updatedが古い場合は、手動更新を検討してください。SQL Server はデフォルトで自動更新しますが、大量データのテーブルでは自動更新の閾値(20%変更)に達しにくく、古い状態が続くことがあります。
Q2. デッドロックが頻発しています。どうすればよいですか?
A. デッドロックトレースを取得して根本原因を特定してください。
⚠️ 注意:デッドロックは SQL Server が即座に解消する(片方をロールバックする)ため、
sys.dm_exec_requestsでは捕捉できません。過去に発生したデッドロックを調べるには、system_health 拡張イベント を使います。
-- system_health 拡張イベントからデッドロックのXMLを取得する
SELECT
xdr.value('@timestamp', 'datetime2') AS deadlock_time,
xdr.query('.') AS deadlock_graph
FROM (
SELECT CAST(target_data AS XML) AS target_data
FROM sys.dm_xe_session_targets t
JOIN sys.dm_xe_sessions s ON t.event_session_address = s.address
WHERE s.name = 'system_health'
AND t.target_name = 'ring_buffer'
) AS data
CROSS APPLY target_data.nodes('//RingBufferTarget/event[@name="xml_deadlock_report"]') AS xdr_tab(xdr)
ORDER BY deadlock_time DESC;
上記クエリで取得した XML を SSMS の「デッドロックグラフ」で視覚化すると、どのセッションがどのリソースを巡って競合したかが一目で分かります。
現在進行中のブロッキングを確認する場合(デッドロックではなく、待機中のセッションを見る):
-- ブロッキングセッションの確認(リアルタイム)
SELECT
r.session_id,
r.blocking_session_id,
r.wait_type,
r.wait_time,
t.text AS sql_text
FROM sys.dm_exec_requests r
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) t
WHERE r.blocking_session_id > 0;
デッドロックは Extended Events(推奨)で継続的にキャプチャするか、SSMS の「拡張イベント」→「system_health」セッションから過去の履歴を確認できます。根本原因は多くの場合「テーブルアクセス順序の不一致」なので、全処理で同じ順序でテーブルに触る設計に変更することが最も効果的です。
Q3. tempdb が肥大化して、ディスクがいっぱいになりました。
A. 原因を特定してから対処してください:
-- tempdb を大量使用しているセッションを特定
SELECT
t.session_id,
t.internal_objects_alloc_page_count * 8 / 1024 AS internal_MB,
t.user_objects_alloc_page_count * 8 / 1024 AS user_MB,
r.blocking_session_id,
s.text AS sql_text
FROM sys.dm_db_task_space_usage t
JOIN sys.dm_exec_requests r ON t.session_id = r.session_id
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) s
WHERE t.internal_objects_alloc_page_count + t.user_objects_alloc_page_count > 0
ORDER BY (t.internal_objects_alloc_page_count + t.user_objects_alloc_page_count) DESC;
出力例:
session_id internal_MB user_MB sql_text
58 4096 0 SELECT ... ORDER BY 30列(ソート操作で爆発)
この例では、セッション58が 4GB のソート作業領域を tempdb に展開しています。クエリを改善(インデックス追加やORDER BYの見直し)して対処します。
Q4. サーバーまたがりのクエリがタイムアウトします。
A. 以下のチェックリストを確認してください:
- OPENQUERY を使っているか:4部構成名から OPENQUERY に切り替える(前述参照)
- リモートのインデックスが適切か:リモートサーバーに実際にログインして実行計画を確認する
- リンクサーバーの設定:
query_timeoutの値を確認・調整する - ネットワーク帯域の確認:転送量が多い場合、ネットワーク自体がボトルネックの可能性
- MS DTC の動作確認:分散トランザクションを使っている場合、MS DTC サービスが両サーバーで動作しているか確認
-- リンクサーバーの接続テスト
EXEC sp_testlinkedserver [RemoteServer];
成功時の出力:
EXEC sp_testlinkedserver [RemoteServer] が正常に完了しました。
Q5. ##グローバル一時テーブル は削除されたはずなのに「既に存在する」エラーが出ます。
A. グローバル一時テーブルは「全セッションがアクセスを終えたとき」に削除されます。他のセッションがまだ参照中の場合は削除されません。
確認方法:
-- ##テーブルを参照しているセッションを調査
SELECT
o.name,
r.session_id,
r.status,
t.text
FROM tempdb.sys.objects o
JOIN sys.dm_exec_requests r ON r.session_id != @@SPID
CROSS APPLY sys.dm_exec_sql_text(r.sql_handle) t
WHERE o.name LIKE '##%'
ORDER BY o.name;
Q6. GROUP BY を使ったクエリで ORDER BY がエラーになります。
A. GROUP BY を使ったクエリでは、ORDER BY に指定できる列は SELECT句に含まれているものか、集計関数の結果のみです。
-- ❌ NG:SELECT句にない OrderDate で ORDER BY しようとするとエラー
SELECT CustomerId, SUM(Amount) AS TotalAmount
FROM Orders
GROUP BY CustomerId
ORDER BY OrderDate; -- エラー: ORDER BY 句の列 'OrderDate' が無効です
-- ✅ OK:SELECT句に含まれる列または集計結果で並べ替える
SELECT CustomerId, SUM(Amount) AS TotalAmount
FROM Orders
GROUP BY CustomerId
ORDER BY TotalAmount DESC; -- 集計結果で並べ替え
-- または
ORDER BY CustomerId; -- GROUP BY 列で並べ替え
SQL Server チューニングの現実と未来
「これはゲーム開発並みの複雑さではないか」という問いに向き合う
本ガイドブックを通じて感じていただけたかもしれません——SQL Server を「まともに動かす」ためにやらなければならないことの多さを。インデックス設計、断片化メンテ、統計情報の鮮度管理、tempdb のファイル分割、NOLOCK を避けるためのRCSI設定、分散トランザクションの罠……。
これはゲーム開発で言えば、描画最適化・メモリ管理・ネットワーク遅延補正・物理演算の精度調整をすべて手動でやれと言われているようなものです。熟練者でなければ安定稼働すら難しい。
正直に言えば、これは「設計が悪い」のではありません。SQL Server がこれほどの複雑さを持つのは、30年以上の進化の中で膨大な要求仕様を詰め込み続けた結果です。しかし「それが理由だから仕方ない」と言い続けることが、この技術の未来にとって良いことかどうかは別の話です。
若手開発者が遠ざかるリスク
これは業界全体の問題として、静かに進行しています。
| 問題 | 現状 |
|---|---|
| 学習コストの高さ | インデックス・ロック・tempdb・分散トランザクションを理解するのに数年かかる |
| 失敗が見えにくい | 「動いているけど遅い」「たまにデッドロックする」はすぐにはわからない |
| エラーメッセージが不親切 | 「オブジェクト名 'X' は無効です」ではなぜ無効かまで教えてくれない |
| ドキュメントの深さ | 公式ドキュメントが豊富な反面、初学者が何から読めばいいかわからない |
| 「聞ける人」問題 | SQL が得意なベテランは数少なく、職場で質問できる環境が整っていない |
若手がリレーショナルデータベースよりも MongoDB・Firebase・DynamoDB などのNoSQLや、SQLを意識させない ORM に流れていく理由は明確です。「とりあえず動かせる」までの距離が圧倒的に短いからです。
この流れを「嘆かわしい」と言うのは簡単ですが、現場の開発者として正直なところ、彼らの選択は合理的です。複雑なものを複雑なまま維持し続けることを、次の世代に強いるのは持続可能ではありません。
Microsoft と業界は何をしているか
明るい話もあります。Microsoft はこの問題を認識しており、「人間がやらなくていいことをシステムが自動化する」方向に継続的に投資しています。
① Intelligent Query Processing(IQP)— SQL Server 2019 以降
従来は「人間が実行計画を見て、インデックスを張り、クエリを書き直す」という手作業だったチューニングの一部を、SQL Server のエンジン自体が自動調整するようになっています。
| 機能 | 説明 |
|---|---|
| アダプティブ結合 | 実行中にデータ量を見て、Hash Join か Nested Loop か自動選択 |
| 行数推定フィードバック | 実行後に統計情報のズレを学習し、次回の実行計画を自動修正 |
| メモリ許可フィードバック | ソート・ハッシュ操作のメモリ不足・過剰を自動調整 |
| 近似COUNT DISTINCT | 大量データの集計クエリを精度を保ちながら大幅高速化 |
-- IQP の有効化はデータベースの互換性レベルを 150(SQL Server 2019)以上にするだけ
ALTER DATABASE TestDB SET COMPATIBILITY_LEVEL = 160; -- SQL Server 2022
② Automatic Tuning(自動チューニング)— SQL Server 2017 以降 / Azure SQL
手動での実行計画管理を自動化する機能です。
-- 自動チューニングの状態確認
SELECT name, desired_state_desc, actual_state_desc, reason_desc
FROM sys.database_automatic_tuning_options;
-- 自動インデックス作成・削除を有効化(Azure SQL Database で特に有効)
ALTER DATABASE TestDB
SET AUTOMATIC_TUNING (CREATE_INDEX = ON, DROP_INDEX = ON);
注意:オンプレミスの SQL Server では
FORCE_LAST_GOOD_PLAN(退行した実行計画を自動で戻す機能)のみがデフォルト有効です。CREATE_INDEXの自動適用は主に Azure SQL Database 向けです。
③ Query Store — SQL Server 2016 以降
「なぜ急に遅くなったのか」を時系列で追跡できる機能です。実行計画の履歴を自動保存し、パラメータスニッフィングによる退行を視覚化・強制修正できます。
-- Query Store の有効化
ALTER DATABASE TestDB
SET QUERY_STORE = ON
(
OPERATION_MODE = READ_WRITE,
MAX_STORAGE_SIZE_MB = 500,
QUERY_CAPTURE_MODE = AUTO
);
-- 遅いクエリのトップ10を確認
SELECT TOP 10
qt.query_sql_text,
qrs.avg_duration / 1000.0 AS avg_ms,
qrs.count_executions
FROM sys.query_store_query_text qt
JOIN sys.query_store_query q ON qt.query_text_id = q.query_text_id
JOIN sys.query_store_plan qp ON q.query_id = qp.query_id
JOIN sys.query_store_runtime_stats qrs ON qp.plan_id = qrs.plan_id
ORDER BY qrs.avg_duration DESC;
④ Azure SQL Database / Managed Instance:クラウドが吸収してくれること
オンプレミスの SQL Server で人間が手動でやっていることの多くは、Azure SQL に移行することで消えます。
| オンプレミスで人間がやること | Azure SQL での扱い |
|---|---|
| tempdb のファイル分割 | 自動最適化済み |
| インデックス断片化メンテ | 自動メンテナンスウィンドウ |
| 統計情報の更新 | 自動更新(チューニング済み) |
| バックアップ管理 | 全自動 |
| パッチ適用 | 自動(ダウンタイム最小化) |
| スケールアップ/ダウン | ボタン1つまたは自動スケーリング |
「その複雑さをクラウドに押し込めて、開発者は本来の業務に集中できる」というのが Microsoft の現在の回答です。
現場の開発者へ:この複雑さとどう向き合うか
個人的な見解として:SQL Server のチューニング知識は、まるで「車のエンジンをキャブレターレベルで理解していないと運転できない」ような状態です。現代の車はECUが自動制御してくれるように、データベースもその方向に進んでいます。
若手開発者の方へ:今のSQL Serverの複雑さは、あなたが「向いていない」からわからないのではありません。それは本当に難しいのです。Query Storeと実行計画の読み方、インデックスのSARGable原則、この2つを押さえるだけでも現場の大半の問題は解決できます。全部を一度に覚える必要はありません。
ベテランの方へ:自分が長年かけて身につけた知識が若手に伝わらないとき、それは「今の若者はやる気がない」のではなく、「学習コストが高すぎる構造になっている」可能性を考えてみてください。ドキュメントに「なぜこうなるのか」を書く文化、失敗を責めない現場の雰囲気、それが最も効果的な「育成チューニング」です。
システム設計者の方へ:新規プロジェクトではSQL Server の採用理由を明確にしてください。大量の同時書き込みが要件にない場合、PostgreSQL・MySQL・Azure SQL との比較検討を行うことが現代の設計責任です。SQL Server が正解のケースは確実に存在しますが、「昔からそうだったから」という理由での選択は技術的負債になります。
まとめ:SQL Server の現実と期待
| 観点 | 現実 | 期待される変化 |
|---|---|---|
| チューニング複雑度 | ゲーム開発並みの専門性が必要 | IQP・自動チューニングで人間の介入を減らす方向 |
| 若手の参入障壁 | 高い。NoSQLやORMに流出 | クラウド移行でインフラ運用を隠蔽することで緩和可能 |
| マイクロソフトの姿勢 | Azure SQL・IQP・自動チューニングに積極投資 | AIとの統合(SQL Server 2025のベクター型・自然言語クエリ)で加速 |
| オンプレミスの未来 | セキュリティ・コンプライアンス要件があれば継続 | クラウドへの段階移行が現実的な選択肢 |
SQL Server は複雑です。しかしその複雑さは「難しくしてやろう」という意図ではなく、エンタープライズの現実に真剣に向き合ってきた証拠でもあります。複雑さを理解したうえで「どこを自動化し、どこを人間が管理するか」を選択できることが、現代のデータベースエンジニアに求められる素養です。
まとめ
📖 本ガイドブックでは、SQL Server の以下のテーマについて「なぜそうなるのか」と「やってはいけない」を軸に解説しました:
| テーマ | 最重要ポイント |
|---|---|
| インデックス | SARGable なクエリを書く。インデックスは多すぎてもNG。断片化を定期メンテする |
| トランザクション | 短く・TRY-CATCH で囲む。分散トランザクションは慎重に |
| tempdb | 全ユーザー共有の有限リソース。使い終わったら即DROP。複数ファイル化推奨 |
| #・##テーブル | ローカル一時テーブルを積極利用。##はビジネスロジックに使わない |
| サーバーまたがり | 4部構成名よりOPENQUERY。リモートにストアドを置いて結果を引き込む |
| MSDTC | プロキシ・クラウド環境では動的ポートとホスト名の問題で動作しないケースが多い。代替設計への早期シフトが現実解 |
| 分離レベル・NOLOCK | WITH(NOLOCK) は「速いが嘘をつく」。正しい対策は RCSI の有効化 |
| チューニングの現実 | 複雑さを認識しつつ、IQP・自動チューニング・クラウドで負荷を減らす方向へ |
SQL Server チューニングのゲーム開発的思想
🎮 ロード時間の短縮は「不要なデータの読み込みをやめること」から始まる
→ インデックスでフルスキャンを Index Seek に変える=必要な箇所だけ読む🎮 ゲームのラグはネットワーク往復にある
→ サーバーまたがりではリモートで絞り込み、少ないデータだけ転送する🎮 シェアードリソースは「全員が影響を受ける」前提で設計する
→ tempdb は全ユーザー共有。自分のクエリが他人のパフォーマンスを殺していないか常に意識する🎮 プロゲーマーだけが楽しめるゲームは、普及しない
→ チューニングの複雑さを次世代に丸投げせず、自動化・標準化・ドキュメント化で「誰でも参加できる現場」を作る
参考リンク一覧
| リンク | 詳細 |
|---|---|
| SQL Server 公式ドキュメント(インデックスの設計ガイド) | Microsoft 公式のインデックス設計ガイド |
| SQL Server トランザクション ガイド | ロック・行バージョン管理の詳細 |
| トランザクション分離レベル | 分離レベルの詳細リファレンス |
| Read Committed スナップショット分離(RCSI) | RCSI の設定と動作の詳細 |
| tempdb データベース | tempdb の最適化ガイド |
| リンク サーバー(SQL Server) | リンクサーバーと分散クエリの詳細 |
| OPENQUERY (Transact-SQL) | OPENQUERY 関数リファレンス |
| 分散トランザクション コーディネーター(MSDTC)の構成 | ファイアウォール越しのMSDTC設定ガイド |
| Intelligent Query Processing(IQP) | SQL Server 2019以降の自動最適化機能 |
| 自動チューニング | 自動チューニングの概要と設定方法 |
| Query Store の使用 | クエリパフォーマンス履歴の管理 |
更新日時:2026 年 04 月 16 日
本ガイドブックは、SQL Serverをすでに使っている経験者の方が「あやふやをなくし」「やってはいけないことを知る」ことを目的として作成されています。内容に関するご質問や追加テーマのご要望がございましたら、いつでもお気軽にお声がけください。