【SQL Server】DB処理における好手悪手の考察【クエリ】

![CWxB7fjUwAAVtMW_.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254888/3826ba8d-bbfb-8a0f-a149-ef97fabffcd4.png)

## 環境
SQL Server を想定しているけど、大抵の DB で同様と思われ。

## 前文
DB の真偽値は`TRUE`、`FALSE`、`UNKNOWN`の3値だけど、`TRUE`、`FALSE`の2値と勘違いしている（漠然と思い込んでいる）人がいる。
それは DB の知識が浅いというより、プログラミングの知識に惑わされているからでは。
プログラミング言語なら真偽値は普通 2値だからね。それと混同していると思われ。

10年程前、[プログラマーの夫に買い物を頼んだら。](https://www.google.co.jp/url?sa=t&rct=j&q=&esrc=s&source=web&cd=&cad=rja&uact=8&ved=2ahUKEwilrNyd1pj8AhXRF4gKHZhIANEQFnoECAwQAQ&url=https%3A%2F%2Ftwitter.com%2Fkameidob%2Fstatus%2F83171696697290752&usg=AOvVaw39pLuyDD2ZT_jqC__C-0Tn "プログラマーの夫に買い物を頼んだら。")というネタがネット上で話題になったよね。改めて確認したら、初出は[reddit](https://www.reddit.com/r/funny/comments/fklb1/a_wife_asks_her_husband_a_software_engineer/ "A wife asks her husband, a software engineer... _ funny")だった模様。
やや苦しいネタだけど、プログラミングができる人間ならばなるほどねぇと思えるところがツボだったのだと思う。
そしてこのプログラミング脳は日常生活だけではなく、DB処理でも弊害になる場合が、というのが今回のお題。
プログラミングなら達者なのに、DB処理となると残念な人をよく見かけるので。

## コンバート処理における好手悪手
DB処理の定番はコンバート処理。特に昨今は BI、見える化に関わるコンバート処理要件も多い。
例えば年月日単位（日毎）に何らかの（売上とか）集計処理を行う場合を考える。
よくある処理の組み立て方は以下。（ストアドを想定）

### 1. 指定年月日のコンバート処理
引数は年月日。この引数で与えられた年月日に該当するレコードを対象にコンバート処理を行うストアドを用意。
以下のイメージ。

```SQL
CREATE PROCEDURE CONVERT_UNIT
      @ymd  DATE		-- コンバート対象年月日
AS
```

### 2. 開始～終了年月日のループ
コンバート対象の範囲で開始から終了までの日毎の年月日を引数に`1.`のストアドをコール。

簡単なフロー図を書けば以下。
![フロー図.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/254888/8a72c4da-b4c4-7191-a0a7-281e11fa2b89.png)
ごくごく標準的な実装。
指定された年月日をコンバートする処理を独立したストアドで用意する形式は言ってみればコードの`部品化`であり、ループ処理は`構造化プログラミング`の基本。教科書通りの実装に見える。これのどこが悪いんだと思われるかもしれない。
しかし個人的にこの手のストアドを見るに度に思うのは、この処理を実装（設計）した人は`BETWEEN`句を知らないのかな？


## 最適化の考察
前述のストアドの中身は以下のような実装になる。

```SQL
	INSERT INTO <コンバート先テーブル>
	SELECT <列名> FROM <集計元テーブル>
	WHERE	<年月日列> = @ymd				-- 引数で指定された年月日
	;
```

引数で指定された年月日でレコードを抽出して云々。
そして`BETWEEN`句を使用した実装なら以下のような実装になる。

```SQL
CREATE PROCEDURE CONVERT_UNIT
      @ymd_from		DATE		-- コンバート対象年月日(開始)
    , @ymd_to		DATE		-- コンバート対象年月日(終了)
AS
	<中略>
	INSERT INTO <コンバート先テーブル>
	SELECT <列名> FROM <集計元テーブル>
	WHERE	<年月日列> BETWEEN @ymd_from AND @ymd_to	-- 引数で指定された年月日(開始～終了)
	;
```

そもそも、集計に使用する列（例として挙げたコードの<年月日列>）にはインデックスが張られている筈。（処理速度を考慮しているなら）
`BETWEEN`句はインデックスが利くので、`WHERE`句で`<年月日列> = @ymd`とするのも`<年月日列> BETWEEN @ymd_from AND @ymd_to`とするのもコスト的には大差ない。
`BETWEEN`句の場合は対象レコードが増えるけど、日毎に処理する場合もトータルでは同じ。
`BETWEEN`句を使用したストアドを用意すれば、わざわざループで回す必要なんてない。

小分けしてコンバートするか、まとめてドンかの違い。そりゃ一般論として後者の方が速いよねという話でその直観は大抵正しい。
`BETWEEN`句を使用したクエリの場合、`GROUP BY`を行うなら`<年月日列>`も指定する必要があるといった多少の違いはあるだろうけど、実装としては殆ど同じで済むし。
速い方法と遅い方法があったら、特別な理由が無い限り前者を採用するべきじゃないのかな。

## BETWEENの問題点
しかし問題点もある。
開始～終了の範囲が大きすぎると、扱うデータが膨大になりパンクする恐れがある。
処理可能な上限を見極め、それを超える場合は分割してストアドをコールするといったワンクッション処理を噛ませる必要がある。
更にその上限値をパラメータ管理、例えばコンフィグテーブルがあればそこに格納する、処理側はその値で範囲を切り出す、といった感じ。
結局ループ処理じゃんって話になってしまうけど(笑)。

## INSERT処理
似た話として`INSERT`処理。

```SQL
INSERT INTO <テーブル名> VALUES(1, 'A');
INSERT INTO <テーブル名> VALUES(2, 'B');
INSERT INTO <テーブル名> VALUES(3, 'C');
```

上記のような 1クエリ 1 INSERT の複数実行より、下記のように`VALUES`コンストラクタに複数のデータセットを定義した 1クエリの実行の方が速い。正規化という観点からもいい感じ。

```SQL
INSERT INTO <テーブル名> VALUES
	  (1, 'A')
	, (2, 'B')
	, (3, 'C')
;
```

数レコード程度では速度差は実感できないだろうけど、それなりの量であれば体感的にも分かる程に差が出る。
CSVファイルの高速な取り込み方法としてバルクインサートなんてものがあるけど、内部的にはこれ。
しかし、プログラミング処理で動的にこの手のクエリを生成し実行、となると少々面倒。
レコード数が多ければクエリの長さが仕様上の制限、限界に達してしまうこともあるので、適度な量で切り分ける必要がある。

クエリというものはそれなりに動くものであれば話は簡単。
しかしより速いクエリ、となるとノウハウが必要だし手間がかかるもの、という好例だね。
冒頭の`BETWEEN`句の例にしろ、`INSERT`文の例にしろ、可能な限りまとめて実行、発行するクエリが少ないほど処理は速い。高速化ノウハウの代表例。

## 閑話休題
余程単純な要件でもない限り、その場の思い付きで組んだような実装は速度的に問題がある場合が多い。
よく夜間バッチ処理（DB処理）で運用開始当初は速度的に問題なかったものの、データ量の増加に伴い処理時間が伸び、終いには一晩かかっても終わらない、なんて事態に陥り、テコ入れ、パフォーマンスチューニングが必要となってしまうケースがある。

個人的な経験則なってしまうけど、そういった経緯でパフォーマンスが悪い部分を確認すると十中八九、そりゃこんな要領の悪い組み方をしていたら遅いでしょ、といったものばかり。
夜間処理はそれなりに時間の余裕があるので、組み方が適当だったりするんだよなぁ。

特にありがちなのが、単一のクエリ（`SELECT`文なり`UPDATE`文なり）で実装可能なのに、わざわざ`FETCH`のループで処理しているようなタイプ。
厳しいことを言ってしまうと、DBの理解が浅い人間が単一のクエリでの組み方が分からず、そこでプログラミング的手法、テキストファイルを一行ずつ読み込んで処理する、といった類の方法論を応用し何とかしようとするから無理がある。
これもまたプログラミングの知識に囚われた弊害の一例。

Stack Overflow の DB（クエリ）関係の Q&A でも頻出する指摘事項だけど、`FETCH`のループは遅い。特に速度が要求される場合は特別な理由がない限り使うべきではない。
この件はまた別の機会に取り上げたい。

## IF文について
最後に（知っている人には当たり前の話かもしれないけど）`IF`文について。
ストアドなどの処理分岐制御として `IF`文が使える。式の文法は原則`WHERE`句と同じ。
つまり`WHERE`句に書けるものは`IF`文にも書ける。
具体的には`IS NULL`、`IN`、`BETWEEN`、`LIKE`、`EXISTS`、`CASE`、`COALESCE`、`NULLIF`、等々。

```SQL
IF @a IS NULL THEN						-- @a が NULL の場合
...

IF @a IN (1, 5, 7) THEN					-- @a が 1, 5, 7 のいずれかの場合
...

IF @a BETWEEN 5 AND 10 THEN				-- @a が 5以上10以下の場合
...

IF @str LIKE 'A%' THEN					-- @str が'A'で始まる文字列の場合
...

IF EXISTS(SELECT 1 FROM <テーブル名> WHERE <条件>) THEN	-- レコードが存在する場合
...

IF	CASE @a								-- うーん、あまり良い例が思い浮かばない
		WHEN 'A'	THEN 1
		WHEN 'B'	THEN 2
		WHEN 'C'	THEN 3
	END = @b THEN
...

IF COALESCE(@a, @b, @c) IS NULL THEN	-- @a, @b, @c 全てが NULL の場合
...

IF @a / NULLIF(@b, 0) > 0 THEN			-- 0除算の回避
...
```

改めて SQL Server 公式を確認すると、`ALL`、`ANY`、`SOME`なんてものも。ここまでくると自分も使ったことがないなぁ（笑）。
`ISNULL`、`ISNUMERIC`の類は`式`ではなく`システム関数`なので除外。

何が言いたいかというと、一般的なプログラミング言語の`if`文は等号不等号等の比較式程度が殆どだけど、`SQL`における`IF`文で使用できる式はバリエーションが多く、プログラミング脳から頭を切り替える必要がある。

```SQL
DECLARE @CNT BIGINT = NULL;					-- レコード数格納変数
SELECT @CNT = COUNT(1) FROM <テーブル名>;	-- レコード数取得
IF @CNT > 0 THEN							-- レコード存在確認
...
```
レコードの存在判定で上記のような実装を見かけることがある。
確かに間違ってはいないけど……。
しかし前述のように素直に`EXISTS`句を使用した判定の方がスマートだよね。変数も必要ないし。レコードが存在する場合に限れば`COUNT`関数より`EXISTS`句の方が理論上速い筈だし。
これもまた（以下略）。