Что может подстерегать новичков при работе с SQL Server

В свое время я зачитывался Рихтером и усиленно штудировал Шилдта. Думал, что буду заниматься разработкой под .NET, но судьба на первом месяце работы распорядилась иначе. Один из сотрудников неожиданно покинул проект, и во вновь образовавшуюся дыру докинули свежего людского материала. Именно тогда и началось мое знакомство с SQL Server.

С тех пор прошло чуть меньше 6 лет, и вспомнить можно многое... Про бывшего клиента Джозефа из Англии, который переосмыслил жизнь за время отпуска в Таиланде, и в моем скайпе стал подписываться Жозефиной. Про веселых соседей по офису, с которыми приходилось сидеть в одной комнате: один страдал от аллергии на свежий воздух, а другой маялся от неразделенной любви к С++, дополняя это аллергией на солнечный свет. Чего только не было... Один раз по команде свыше пришлось на время стать Александром, отцом двух детей, и изображать из себя обросшего скилами сениора по JS. Но самый лютый треш, наверное, связан с историей про резиновую утку-пищалку. Один коллега снимал ею стресс и, однажды, в порыве эмоций, отгрыз ей голову. С тех пор уточка потеряла прежний лоск и вскоре была заменена на мячик, который он пытался иногда грызть... увы, уже безуспешно.

К чему это было рассказано? Если хотите посвятить свою жизнь работе с базами данных, то первое чему нужно научиться — это стрессоустойчивости. Второе — взять на вооружение несколько правил при написании запросов на T-SQL, которые многие из начинающих разработчиков не знают или игнорируют, а потом сидят и ломают голову: «Почему что-то не работает?»

NOT IN vs NULL

Долго думал, с какого примера стоило бы начать. Бесспорный лидер среди вопросов на собеседовании Junior DB Developer — конструкция NOT IN.

Например, нужно написать запрос, который вернет всем записи из первой таблицы, которых нет во второй. Очень часто начинающие разработчики не заморачиваются и используют NOT IN:

DECLARE @t1 TABLE (a INT)
INSERT INTO @t1 VALUES (1), (2)

DECLARE @t2 TABLE (b INT)
INSERT INTO @t2 VALUES (1)

SELECT * FROM @t1
WHERE a NOT IN (SELECT b FROM @t2)

Запрос вернул нам двойку. Давайте теперь во вторую таблицу добавим еще одно значение — NULL:

INSERT INTO @t2 VALUES (1), (NULL)

При выполнении мы не получим никаких результатов. Поменяем NOT IN на IN и сможем увидеть какую-то магию — IN работает, а NOT IN отказывается. Это первое, что нужно «понять и простить» при работе с SQL Server, который при операции сравнения руководствуется третичной логикой: TRUE, FALSE, UNKNOWN.

При выполнении SQL Server интерпретирует условие IN:

a IN (1, NULL) === a=1 OR a=NULL

NOT IN:

a NOT IN (1, NULL) === a<>1 AND a<>NULL

При сравнении любого значения с NULL возвращается UNKNOWN. 1=NULL, NULL=NULL. Результат будет один — UNKNOWN. А поскольку у нас в условии используется оператор AND, то все выражение вернет неопределенное значение.

Скажу честно, написано реально скучно. Но важно понимать, что такая ситуация встречается достаточно часто. Хороший пример из жизни: раньше колонка была NOT NULL, потом какой-то добрый человек разрешил записывать в нее NULL значение. Итог: у клиента перестает работать отчет после того, как в таблицу попадет хотя бы одно NULL значение.

Что делать? Можно явно отбрасывать NULL значения:

SELECT * FROM @t1
WHERE a NOT IN (
        SELECT b FROM @t2
        WHERE b IS NOT NULL
    ) 

Можно использовать EXCEPT:

SELECT * FROM @t1
EXCEPT
SELECT * FROM @t2

Если нет желания много думать, то проще использовать NOT EXISTS:

SELECT * FROM @t1
WHERE NOT EXISTS(
        SELECT * FROM @t2
        WHERE a = b
    )

Какой вариант запроса более оптимальный? Чуточку предпочтительнее выглядит последний вариант с NOT EXISTS, который генерирует более оптимальный predicate pushdown оператор при доступе к данным из второй таблицы.

Date Format

Еще часто спотыкаются на различных нюансах с типами данных. Например, нужно получить текущее время. Выполнили функцию GETDATE. Скопировали результат и вставили его в запрос. Корректно ли так делать? Дата задается строковой константой, и в некоторой степени SQL Server позволяет вольности при ее написании:

SET DATEFORMAT DMY

DECLARE
      @d1 DATETIME = '05/12/2016'
    , @d2 DATETIME = '2016/12/05'
    , @d3 DATETIME = '2016-12-05'
    , @d4 DATETIME = '05-dec-2016'

SELECT @d1, @d2, @d3, @d4

Все значения однозначно интерпретируются:

----------- ----------- ----------- -----------
2016-12-05  2016-05-12  2016-05-12  2016-12-05

И это не будет приводить к проблемам до тех пор, пока бизнес-логику не начнут выполнять на другом сервере, на котором настройки могут отличаться:

SET DATEFORMAT DMY

DECLARE
      @d1 DATETIME = '05/12/2016'
    , @d2 DATETIME = '2016/12/05'
    , @d3 DATETIME = '2016-12-05'
    , @d4 DATETIME = '05-dec-2016'

SELECT @d1, @d2, @d3, @d4

Первый вариант может привести к неверному толкованию даты:

----------- ----------- ----------- -----------
2016-05-12  2016-12-05  2016-12-05  2016-12-05

Более того, подобный код может привести к ошибке. Например, нам нужно вставить данные в таблицу. На тестовом сервере все прекрасно работает:

DECLARE @t TABLE (a DATETIME)
INSERT INTO @t VALUES ('05/13/2016')

А у клиента, из-за разницы настройках сервера, вот такой запрос будет приводить к проблемам:

DECLARE @t TABLE (a DATETIME)
SET DATEFORMAT DMY
INSERT INTO @t VALUES ('05/13/2016')

Msg 242, Level 16, State 3, Line 28
The conversion of a varchar data type to a datetime data type resulted in an out-of-range value.

Так в каком же формате задавать константы для дат? Давайте посмотрим на еще один пример:

SET DATEFORMAT YMD

SET LANGUAGE English

DECLARE
      @d1 DATETIME = '2016/01/12'
    , @d2 DATETIME = '2016-01-12'
    , @d3 DATETIME = '12-jan-2016'
    , @d4 DATETIME = '20160112'

SELECT @d1, @d2, @d3, @d4
GO

SET LANGUAGE Deutsch

DECLARE
      @d1 DATETIME = '2016/01/12'
    , @d2 DATETIME = '2016-01-12'
    , @d3 DATETIME = '12-jan-2016'
    , @d4 DATETIME = '20160112'

SELECT @d1, @d2, @d3, @d4
GO

В зависимости от установленного языка, константы также могут по-разному интерпретироваться:

----------- ----------- ----------- -----------
2016-01-12  2016-01-12  2016-01-12  2016-01-12

----------- ----------- ----------- -----------
2016-12-01  2016-12-01  2016-01-12  2016-01-12

И напрашивается вывод использовать последние два варианта. Сразу скажу, что задавать месяц явно — это хорошая возможность наткнуться на «же не манж па сис жур» ошибку:

SET LANGUAGE French
DECLARE @d DATETIME = '12-jan-2016'

Msg 241, Level 16, State 1, Line 29
Échec de la conversion de la date et/ou de l'heure à partir d'une chaîne de caractères.

Итого — остается последний вариант. Если хотите, чтобы константы с датами однозначно толковались в системе вне зависимости от настроек и фазы Луны, то указывайте их в формате ISO (yyyyMMdd) без всяких тильд, кавычек и слешей.

Еще стоит обратить внимание на различие в поведении некоторых типов данных:

SET LANGUAGE English
SET DATEFORMAT YMD

DECLARE
      @d1 DATE = '2016-01-12'
    , @d2 DATETIME = '2016-01-12'

SELECT @d1, @d2
GO

SET LANGUAGE Deutsch
SET DATEFORMAT DMY

DECLARE
      @d1 DATE = '2016-01-12'
    , @d2 DATETIME = '2016-01-12'

SELECT @d1, @d2

Тип DATE, в отличие от DATETIME, корректно интерпретируется при различных настройках на сервере:

---------- ----------
2016-01-12 2016-01-12

---------- ----------
2016-01-12 2016-12-01

Но нужно ли держать этот нюанс в голове? Вряд ли. Главное помните, что задавать даты нужно в формате ISO, остальное уже от лукавого.

Date Filter

Далее рассмотрим, как фильтровать эффективно данные. Почему-то на DATETIME столбцы приходится наибольшее число костылей, так что с этого типа данных мы и начнем:

USE AdventureWorks2014
GO

UPDATE TOP(1) dbo.DatabaseLog
SET PostTime = '20140716 12:12:12'

Теперь попробуем узнать, сколько строк вернет запрос за определенный день:

SELECT COUNT_BIG(*)
FROM dbo.DatabaseLog
WHERE PostTime = '20140716'

Запрос вернет 0. Почему? При построении плана SQL Server пытается преобразовать строковую константу к типу данных столбца, по которому идет фильтрация:

Есть правильные и неправильные варианты вывести требуемые данные. Например, обрезать время:

SELECT COUNT_BIG(*)
FROM dbo.DatabaseLog
WHERE CONVERT(CHAR(8), PostTime, 112) = '20140716'

SELECT COUNT_BIG(*)
FROM dbo.DatabaseLog
WHERE CAST(PostTime AS DATE) = '20140716'

Или задать диапазон:

SELECT COUNT_BIG(*)
FROM dbo.DatabaseLog
WHERE PostTime BETWEEN '20140716' AND '20140716 23:59:59.997'

SELECT COUNT_BIG(*)
FROM dbo.DatabaseLog
WHERE PostTime >= '20140716' AND PostTime < '20140717'

Именно последние два запроса более правильными с точки зрения оптимизации. И дело в том, что все преобразования и вычисления на колонках, по которым идет поиск, может резко снижать производительность, но об этом чуть позже.

Поле PostTime не входит в индекс, и особого эффекта от использования «правильного» подхода при фильтрации нам не увидеть. Другое дело, когда нам нужно вывести данные за месяц. Чего только не приходилось видеть:

SELECT COUNT_BIG(*)
FROM dbo.DatabaseLog
WHERE CONVERT(CHAR(8), PostTime, 112) LIKE '201407%'

SELECT COUNT_BIG(*)
FROM dbo.DatabaseLog
WHERE DATEPART(YEAR, PostTime) = 2014
    AND DATEPART(MONTH, PostTime) = 7

SELECT COUNT_BIG(*)
FROM dbo.DatabaseLog
WHERE YEAR(PostTime) = 2014
    AND MONTH(PostTime) = 7

SELECT COUNT_BIG(*)
FROM dbo.DatabaseLog
WHERE EOMONTH(PostTime) = '20140731'

SELECT COUNT_BIG(*)
FROM dbo.DatabaseLog
WHERE PostTime >= '20140701' AND PostTime < '20140801'

И опять же, последний вариант более приемлем, чем все остальные. Кроме того, всегда можно сделать вычисляемое поле и создать на его основе индекс:

ALTER TABLE dbo.DatabaseLog
    ADD MonthLastDay AS EOMONTH(PostTime) --PERSISTED
GO
CREATE INDEX ix ON dbo.DatabaseLog (MonthLastDay)
GO

SELECT COUNT_BIG(*)
FROM dbo.DatabaseLog
WHERE MonthLastDay = '20140731'

В сравнении с прошлым запросом разница в логических чтениях будет очень существенная:

Table 'DatabaseLog'. Scan count 1, logical reads 782, ...
Table 'DatabaseLog'. Scan count 1, logical reads 7, ...

Но суть не в этом. Как я уже говорил, любые вычисления на индексных полях снижают производительность и приводят к увеличению логических чтений:

USE AdventureWorks2014
GO

SET STATISTICS IO ON

SELECT BusinessEntityID
FROM Person.Person
WHERE BusinessEntityID * 2 = 10000

SELECT BusinessEntityID
FROM Person.Person
WHERE BusinessEntityID = 5000

Table 'Person'. Scan count 1, logical reads 67, ...
Table 'Person'. Scan count 0, logical reads 3, ...

Если взглянуть на планы выполнения, то в первом случае SQL Server приходится выполнить IndexScan:

Во втором же случае мы увидим IndexSeek:

Convert Implicit

Теперь поговорим про такую редиску, как convert implicit, но для начала пример:

USE AdventureWorks2014
GO

SELECT BusinessEntityID, NationalIDNumber
FROM HumanResources.Employee
WHERE NationalIDNumber = 30845

SELECT BusinessEntityID, NationalIDNumber
FROM HumanResources.Employee
WHERE NationalIDNumber = '30845'

Смотрим на планы выполнения:

В первом случае — предупреждение и IndexScan, во втором — все хорошо. Что произошло? Столбец NationalIDNumber имеет тип данных NVARCHAR(15). Константу, по значению которой необходимо отфильтровать данные, мы передаем как INT. В итоге получаем неявное преобразование типов, которые может снижать производительность.

Решение достаточно простое — нужно контролировать, чтобы типы данных при сравнении совпадали. Особенно это актуально при использовании EntityFramework.

LIKE ’%...%’

Что еще нужно знать? Даже когда у вас есть покрывающий индекс, еще не факт что он будет эффективно использоваться. Классический пример: вывести все строки, которые начинаются с ...

USE AdventureWorks2014
GO

SET STATISTICS IO ON

SELECT AddressLine1
FROM Person.[Address]
WHERE SUBSTRING(AddressLine1, 1, 3) = '100'

SELECT AddressLine1
FROM Person.[Address]
WHERE LEFT(AddressLine1, 3) = '100'

SELECT AddressLine1
FROM Person.[Address]
WHERE CAST(AddressLine1 AS CHAR(3)) = '100'

SELECT AddressLine1
FROM Person.[Address]
WHERE AddressLine1 LIKE '100%'

Логические чтения:

Table 'Address'. Scan count 1, logical reads 216, ...
Table 'Address'. Scan count 1, logical reads 216, ...
Table 'Address'. Scan count 1, logical reads 216, ...
Table 'Address'. Scan count 1, logical reads 4, ...

Планы выполнения, по которым можно быстро найти победителя:

Результат является итогом, о чем мы говорили до этого. Если есть индекс, то на нем не должно быть никаких вычислений и преобразований типов, функций и прочего. Только тогда он будет эффективно использоваться при выполнении запроса.

Но что если нужно найти все вхождения подстроки в строку? Это задачка уже явно интереснее:

SELECT AddressLine1
FROM Person.[Address]
WHERE AddressLine1 LIKE '%100%'

Но сначала нам нужно узнать много чего занимательного про строки и их свойства.

Первое, что нужно помнить — строки бывают UNICODE и ANSI. Для первых предусмотрены типы данных NVARCHAR/NCHAR (по 2 байта на символ). Для хранения ANSI строк — VARCHAR/CHAR (1 байт — 1 символ). Есть еще TEXT/NTEXT, но про них лучше забыть изначально. И вроде бы на этом можно было закончить, но нет...

Если в запросе задается юникодная константа, то перед ней нужно обязательно ставить символ N. Чтобы показать разницу, достаточно простого запроса:

SELECT '致死罪 ANSI', N'致死罪 UNICODE'
---------- -------------
??? ANSI   致死罪 UNICODE

Если не указывать N перед константой, то SQL Server будет пытаться искать подходящий символ в ANSI кодировке. Если не найдет, то подставит знак вопроса.

COLLATE

Вспомнился один очень интересный пример, который любят спрашивать при собеседовании на позицию Middle/Senior DB Developer. Вернет ли данные следующий запрос?

DECLARE
      @a NCHAR(1) = 'Ё'
    , @b NCHAR(1) = 'Ф'

SELECT @a, @b
WHERE @a = @b

И да... и нет... Тут как повезет. Обычно я так отвечаю.

Почему такой неоднозначный ответ? Во-первых, перед строковым константами не стоит N, поэтому они будут толковаться как ANSI. Второе — очень многое зависит от текущего COLLATE, который является набором правил при сортировки и сравнении строковых данных.

USE [master]
GO

IF DB_ID('test') IS NOT NULL BEGIN
    ALTER DATABASE test SET SINGLE_USER WITH ROLLBACK IMMEDIATE
    DROP DATABASE test
END
GO

CREATE DATABASE test COLLATE Latin1_General_100_CI_AS
GO

USE test
GO

DECLARE
      @a NCHAR(1) = 'Ё'
    , @b NCHAR(1) = 'Ф'

SELECT @a, @b
WHERE @a = @b

При таком COLLATE вместо кириллицы мы получим знаки вопросов, потому что символы знака вопроса равны между собой:

---- ----
?    ?

Стоит нам поменять COLLATE на какой-нибудь другой:

ALTER DATABASE test COLLATE Cyrillic_General_100_CI_AS

И запрос уже не вернет ничего, потому что кириллица будет правильно интерпретироваться. Поэтому мораль тут простая: если строковая константа должна принимать UNICODE, то не надо лениться ставить N перед ней. Есть еще и обратная сторона медали, когда N лепиться везде, где можно, и оптимизатору приходится выполнять преобразования типов, которые, как я уже говорил, приводят к неоптимальным планам выполнения.

Что еще я забыл упомянуть про строки? Еще один хороший вопрос из цикла «давайте проведем собеседование»:

DECLARE
      @a VARCHAR(10) = 'TEXT' 
    , @b VARCHAR(10) = 'text'

SELECT IIF(@a = @b, 'TRUE', 'FALSE')

Эти строки равны? И да... и нет... Опять ответил бы я.

Если мы хотим однозначного сравнения, то нужно явно указывать COLLATE:

DECLARE
      @a VARCHAR(10) = 'TEXT' 
    , @b VARCHAR(10) = 'text'

SELECT IIF(@a COLLATE Latin1_General_CS_AS = @b COLLATE Latin1_General_CS_AS, 'TRUE', 'FALSE')

Потому что COLLATE могут быть как регистрозависимыми (CS), так и не учитывать регистр (CI) при сравнении и сортировке строк. Разные COLLATE у клиента и на тестовой базе — это потенциальный источник не только логических ошибок в бизнес-логике... Еще веселее, когда COLLATE между целевой базой и tempdb не совпадают.

Создадим базу с COLLATE, отличным от дефолтного:

USE [master]
GO

IF DB_ID('db') IS NOT NULL BEGIN
    ALTER DATABASE test SET SINGLE_USER WITH ROLLBACK IMMEDIATE
    DROP DATABASE test
END
GO

CREATE DATABASE test COLLATE Albanian_100_CS_AS
GO

USE test
GO

CREATE TABLE t (c CHAR(1))
INSERT INTO t VALUES ('a')
GO

IF OBJECT_ID('tempdb.dbo.#t1') IS NOT NULL
    DROP TABLE #t1
IF OBJECT_ID('tempdb.dbo.#t2') IS NOT NULL
    DROP TABLE #t2
IF OBJECT_ID('tempdb.dbo.#t3') IS NOT NULL
    DROP TABLE #t3
GO

CREATE TABLE #t1 (c CHAR(1))
INSERT INTO #t1 VALUES ('a')

CREATE TABLE #t2 (c CHAR(1) COLLATE database_default)
INSERT INTO #t2 VALUES ('a')

SELECT 'a' AS c
INTO #t3

DECLARE @t TABLE (c VARCHAR(100))
INSERT INTO @t VALUES ('a')

SELECT 'tempdb', DATABASEPROPERTYEX('tempdb', 'collation')
UNION ALL
SELECT 'test',   DATABASEPROPERTYEX(DB_NAME(), 'collation')
UNION ALL
SELECT 't',   SQL_VARIANT_PROPERTY(c, 'collation') FROM t
UNION ALL
SELECT '#t1', SQL_VARIANT_PROPERTY(c, 'collation') FROM #t1
UNION ALL
SELECT '#t2', SQL_VARIANT_PROPERTY(c, 'collation') FROM #t2
UNION ALL
SELECT '#t3', SQL_VARIANT_PROPERTY(c, 'collation') FROM #t3
UNION ALL
SELECT '@t',  SQL_VARIANT_PROPERTY(c, 'collation') FROM @t

При создании таблицы COLLATE наследуется от базы данных. Единственное отличие — для первой временной таблицы, для которой мы явно определяем структуру без указания COLLATE. В этом случае она наследует COLLATE от базы tempdb.

Сейчас остановимся на нашем примере, потому что если COLLATE не совпадают — это может привести к потенциальным проблемам. Например, данные не будут правильно фильтроваться из-за того, что COLLATE может не учитывать регистр:

SELECT *
FROM #t1
WHERE c = 'A'

Либо SQL Server будет ругаться на невозможность соединения таблиц из-за различающихся COLLATE:

SELECT *
FROM #t1
JOIN t ON [#t1].c = t.c

Последний пример очень часто встречается. На тестовом сервере все идеально, а когда развернули бэкап на сервере клиента, то получаем ошибку:

Msg 468, Level 16, State 9, Line 93
Cannot resolve the collation conflict between "Albanian_100_CS_AS" and "Cyrillic_General_CI_AS" in the equal to operation.

После чего приходится везде делать костыли:

SELECT *
FROM #t1
JOIN t ON [#t1].c = t.c COLLATE database_default

BINARY COLLATE

Теперь, когда «ложка дегтя» пройдена, посмотрим, как можно использовать COLLATE с пользой для себя. Помните пример про поиск подстроки в строке?

USE AdventureWorks2014
GO

SELECT AddressLine1
FROM Person.[Address]
WHERE AddressLine1 LIKE '%100%'

Данный запрос можно оптимизировать и сократить его выполнения. Но для того, чтобы была видна разница, нам нужно сгенерировать большую таблицу:

USE [master]
GO

IF DB_ID('db') IS NOT NULL BEGIN
    ALTER DATABASE test SET SINGLE_USER WITH ROLLBACK IMMEDIATE
    DROP DATABASE test
END
GO

CREATE DATABASE test COLLATE Latin1_General_100_CS_AS
GO
ALTER DATABASE test MODIFY FILE (NAME = N'test', SIZE = 64MB)
GO
ALTER DATABASE test MODIFY FILE (NAME = N'test_log', SIZE = 40MB)
GO

USE test
GO

CREATE TABLE t (
     ansi VARCHAR(100) NULL
   , unicod NVARCHAR(100) NULL
)
GO

;WITH
    E1(N) AS (
        SELECT * FROM (
            VALUES
                (1),(1),(1),(1),(1),
                (1),(1),(1),(1),(1)
        ) t(N)
    ),
    E2(N) AS (SELECT 1 FROM E1 a, E1 b),
    E4(N) AS (SELECT 1 FROM E2 a, E2 b),
    E8(N) AS (SELECT 1 FROM E4 a, E4 b)
INSERT INTO t
SELECT v, v
FROM (
    SELECT TOP(50000) v = REPLACE(CAST(NEWID() AS VARCHAR(36)) + CAST(NEWID() AS VARCHAR(36)), '-', '')
    FROM E8
) t
GO

Создадим вычисляемые столбцы с бинарными COLLATE, не забыв при этом создать индексы:

ALTER TABLE t
    ADD ansi_bin AS UPPER(ansi) COLLATE Latin1_General_100_Bin2

ALTER TABLE t
    ADD unicod_bin AS UPPER(unicod) COLLATE Latin1_General_100_BIN2

CREATE NONCLUSTERED INDEX ansi ON t (ansi)
CREATE NONCLUSTERED INDEX unicod ON t (unicod)

CREATE NONCLUSTERED INDEX ansi_bin ON t (ansi_bin)
CREATE NONCLUSTERED INDEX unicod_bin ON t (unicod_bin)
GO

Далее выполняем фильтрацию:

SET STATISTICS TIME ON

SELECT COUNT_BIG(*)
FROM t
WHERE ansi LIKE '%AB%'

SELECT COUNT_BIG(*)
FROM t
WHERE unicod LIKE '%AB%'

SELECT COUNT_BIG(*)
FROM t
WHERE ansi_bin LIKE '%AB%' --COLLATE Latin1_General_100_BIN2

SELECT COUNT_BIG(*)
FROM t
WHERE unicod_bin LIKE '%AB%' --COLLATE Latin1_General_100_BIN2

SET STATISTICS TIME OFF

И можем увидеть результаты выполнения, которые приятно удивят:

SQL Server Execution Times:
CPU time = 250 ms, elapsed time = 254 ms.

SQL Server Execution Times:
CPU time = 235 ms, elapsed time = 255 ms.

SQL Server Execution Times:
CPU time = 15 ms, elapsed time = 17 ms.

SQL Server Execution Times:
CPU time = 16 ms, elapsed time = 17 ms.

Вся суть в том, что поиск на основе бинарного сравнения происходит намного быстрее, и если нужно часто и быстро искать вхождение строк, то данные можно хранить с COLLATE, которые заканчивается на BIN.

ISNULL и COALESCE

Идем дальше. Что еще потенциально интересного? Есть две функции: ISNULL и COALESCE. С одной стороны все просто — если первый оператор NULL, то вернуть второй оператор или следующий, если мы говорим про COALESCE. С другой стороны, есть коварное различие между ними. Что вернут эти функции?

DECLARE @a CHAR(1) = NULL

SELECT ISNULL(@a, 'NULL'), COALESCE(@a, 'NULL')

DECLARE @i INT = NULL

SELECT ISNULL(@i, 7.1), COALESCE(@i, 7.1)

Ответ и вправду не очень очевидный:

---- ----
N    NULL

---- ----
7    7.1

Почему? Функция ISNULL преобразует к наименьшему типу из двух операндов. COALESCE преобразует к наибольшему типу. Вот мы и получаем такую радость, над которой я в первый раз очень долго просидел в попытках понять, «что не так».

Math

Еще интереснее, когда сталкиваешься с математикой на SQL Server. Вроде бы разницы не должно быть:

SELECT 1 / 3
SELECT 1.0 / 3

Но по факту оказывается, что она есть — все зависит от того, какие данные участвуют в запросе. Если целочисленные, то и результат будет целочисленным:

-----------
0

-----------
0.333333

Еще интересный пример, который часто встречается на собеседованиях в том или ином виде:

SELECT
      COUNT(*)
    , COUNT(1)
    , COUNT(val)
    , COUNT(DISTINCT val)
    , SUM(val)
    , SUM(DISTINCT val)
    , AVG(val)
    , SUM(val) / COUNT(val)
    , AVG(val * 1.)
FROM (
    VALUES (1), (2), (2), (NULL), (NULL)
) t (val)

Что вернет запрос? COUNT(*)/COUNT(1) вернет общее число строк. COUNT по столбцу вернет количество не NULL строк. Если добавить DISTINCT, то количество уникальных значений, которые не NULL.

Интереснее с подсчетом среднего. Операция AVG раскладывается оптимизатором на SUM и COUNT. И тут мы вспомним про пример выше. Если значения целочисленные, то какой будет результат? Целочисленный. Об этом часто забывают.

UNION ALL

Еще стоит упомянуть про UNION с приставкой ALL. Тут все просто: если мы знаем, что данные не пересекаются, и нас не волнуют дубликаты, то, с точки зрения производительности, предпочтительнее использовать UNION ALL. Если нужно убрать дублирование, то смело используем UNION.

Но тут еще важно знать об интересном различии между этими двумя конструкциями: UNION выполняется параллельно, а UNION ALL — последовательно. И это не относится к параллельным планам, просто это такая особенность доступа к данным, которая может помочь при оптимизации.

Предположим, нам нужно вернуть 1 строку, исходя из разного набора условий:

USE AdventureWorks2014
GO

DECLARE @AddressLine1 NVARCHAR(60)
SET @AddressLine1 = '4775 Kentucky Dr.'

SELECT TOP(1) AddressID, AddressLine1, AddressLine2
FROM Person.[Address]
WHERE AddressLine1 = @AddressLine1
    OR (AddressLine2 IS NOT NULL AND AddressID > 1500)

Тогда за счет использования OR в условии у нас будет IndexScan:

Table 'Address'. Scan count 1, logical reads 6, ...

Перепишем запрос с использованием UNION ALL:

USE AdventureWorks2014
GO

DECLARE @AddressLine1 NVARCHAR(60)
SET @AddressLine1 = '4775 Kentucky Dr.'

SELECT TOP(1) AddressID, AddressLine1, AddressLine2
FROM (
    SELECT TOP(1) AddressID, AddressLine1, AddressLine2
    FROM Person.[Address]
    WHERE AddressLine1 = @AddressLine1

        UNION ALL

    SELECT TOP(1) AddressID, AddressLine1, AddressLine2
    FROM Person.[Address]
    WHERE AddressLine2 IS NOT NULL
        AND AddressID > 1500
) t

И посмотрим на план выполнения:

После выполнения первого подзапроса, SQL Server смотрит, что вернулась одна строка, которой достаточно, чтобы вернуть результат, и далее не продолжает искать по второму условию.

Table 'Worktable'. Scan count 0, logical reads 0, ...
Table 'Address'. Scan count 1, logical reads 3, ...

Scalar func

О чем я еще забыл упомянуть? Специально для любителей ООП. Не используйте скалярные функции в запросах на T-SQL, которые оперируют большим числом строк.

Вот пример из жизни, которым я когда-то страдал, когда еще не знал о потенциальных минусах скалярных функций:

USE AdventureWorks2014
GO

UPDATE TOP(1) Person.[Address]
SET AddressLine2 = AddressLine1
GO

IF OBJECT_ID('dbo.isEqual') IS NOT NULL
    DROP FUNCTION dbo.isEqual
GO

CREATE FUNCTION dbo.isEqual
(
    @val1 NVARCHAR(100),
    @val2 NVARCHAR(100)
)
RETURNS BIT
AS BEGIN
    RETURN
        CASE WHEN (@val1 IS NULL AND @val2 IS NULL) OR @val1 = @val2
            THEN 1
            ELSE 0
        END
END
GO

Запросы возвращают идентичные данные:

SET STATISTICS TIME ON

SELECT AddressID, AddressLine1, AddressLine2
FROM Person.[Address]
WHERE dbo.isEqual(AddressLine1, AddressLine2) = 1

SELECT AddressID, AddressLine1, AddressLine2
FROM Person.[Address]
WHERE ISNULL(AddressLine1, '') = ISNULL(AddressLine2, '')

SET STATISTICS TIME OFF

Но за счет, того что каждый вызов функции ресурсоемкий, получаем вот такую разницу:

SQL Server Execution Times:
CPU time = 62 ms, elapsed time = 58 ms.

SQL Server Execution Times:
CPU time = 0 ms, elapsed time = 1 ms.

Кроме того, использование скалярных функций в запросе мешает SQL Server строить параллельные планы выполнения, что при больших объёмах данных может существенно подкосить производительность.

CURSORs

И самое важное, что нужно понимать при работе с SQL Server. Не используйте курсоры для модификации данных. Или используйте по минимуму их, когда других вариантов уже не остается. Часто можно встретить такой вот код:

DECLARE @BusinessEntityID INT

DECLARE cur CURSOR FOR
    SELECT BusinessEntityID
    FROM HumanResources.Employee

OPEN cur

FETCH NEXT FROM cur INTO @BusinessEntityID

WHILE @@FETCH_STATUS = 0 BEGIN

    UPDATE HumanResources.Employee
    SET VacationHours = 0
    WHERE BusinessEntityID = @BusinessEntityID

    FETCH NEXT FROM cur INTO @BusinessEntityID

END

CLOSE cur
DEALLOCATE cur

Который лучше переписать вот так:

UPDATE HumanResources.Employee
SET VacationHours = 0
WHERE VacationHours <> 0

Приводить время выполнения и число логических чтений не стоит, но поверьте, разница действительно есть. Как вариант, просто расскажу про недавний пример из жизни. Встретил скрипт, в котором было два вложенных курсора. При выполнении данный код приводил к таймауту на клиенте, а всего он выполнялся примерно 38 секунд. Переписал запрос без использования курсоров — 600 мс.


Что можно сказать для послесловия? Это мой первый опыт подготовки материала для DOU, и, наверное, вышло немного сумбурно. Однако, надеюсь, все, что здесь написано, будет кому-то полезным при написании запросов на T-SQL.

Похожие статьи:
Хотите сменить работу и начать карьеру в IT, но не знаете с чего начать? В ходе курса «QA тестирование — быстрый старт карьеры в IT»...
Встречайте свежий SQL Server дайджест. В этом выпуске: SQL Server 2016 CTP 3.0/3.1, Новогодняя встреча Ukrainian Data Community Kyiv, R в SQL Server и многое другое. SQL...
Петр Козлов начал свой путь в IT еще в 90-х, параллельно обучаясь на металлурга. Тогда людей, которые увлекались компьютерами,...
Меня зовут Ярослав Дышкант, я MGID Vietnam Country Head. В статье расскажу, что нужно знать специалисту, если руководство предлагает...
НБУ встановив ліміт для переказів з картки на картку між фізичними особами (йдеться про P2P-операції, person-to-person). Щомісячні...
Яндекс.Метрика