TRY CATCH в SQL Server: обработка ошибок в T-SQL на примерах

Разработчик смотрит на монитор с окном запроса к базе данных и красной строкой ошибки Программирование и разработка

В Microsoft SQL Server ошибку перехватывают с помощью конструкции BEGIN TRY ... END TRY BEGIN CATCH ... END CATCH: если инструкция в блоке TRY падает с ошибкой уровня выше 10, выполнение переходит в блок CATCH. Там функции ERROR_NUMBER(), ERROR_MESSAGE() и остальные из семейства ERROR_* сообщают номер, текст и строку ошибки, а ROLLBACK TRANSACTION и THROW откатывают изменения и передают ошибку дальше.

Конструкция похожа на try/catch в C# или JavaScript, но блока finally в T-SQL (Transact-SQL) нет. Все примеры запускались в SQL Server 2025 (версия 17.0), оператор THROW есть начиная с SQL Server 2012.

Синтаксис BEGIN TRY и BEGIN CATCH

BEGIN TRY
    -- инструкции, которые могут упасть
END TRY
BEGIN CATCH
    -- обработка ошибки
END CATCH;

Блок CATCH идёт сразу после END TRY: инструкция между ними даёт синтаксическую ошибку. Конструкция живёт в одном пакете и не может начаться в одной ветке IF ... ELSE, а закончиться в другой. Если в блоке TRY ошибок не было, CATCH пропускается и выполняется код после END CATCH.

Простейший пример с делением на ноль:

BEGIN TRY
    SELECT 1 / 0 AS Result;
END TRY
BEGIN CATCH
    SELECT ERROR_NUMBER()    AS ErrorNumber,
           ERROR_SEVERITY()  AS ErrorSeverity,
           ERROR_STATE()     AS ErrorState,
           ERROR_PROCEDURE() AS ErrorProcedure,
           ERROR_LINE()      AS ErrorLine,
           ERROR_MESSAGE()   AS ErrorMessage;
END CATCH;
ErrorNumber  ErrorSeverity  ErrorState  ErrorProcedure  ErrorLine  ErrorMessage
8134         16             1           NULL            2          Divide by zero error encountered.

Перед этой таблицей SQL Server вернёт пустой набор Result: SELECT упал на вычислении. До клиента ошибка не дошла, её перехватил CATCH.

Функции ERROR_NUMBER, ERROR_MESSAGE и другие

Функция Что возвращает
ERROR_NUMBER() номер ошибки
ERROR_SEVERITY() уровень серьёзности
ERROR_STATE() номер состояния
ERROR_PROCEDURE() имя процедуры или триггера, где произошла ошибка; в примере выше NULL
ERROR_LINE() номер строки; в процедуре счёт идёт от начала пакета с её CREATE PROCEDURE
ERROR_MESSAGE() полный текст сообщения с подставленными именами и значениями

Вне блока CATCH все шесть функций возвращают NULL. Внутри они работают в любом месте блока и в вызванной из него процедуре, так что сбор сведений об ошибке можно вынести в отдельную процедуру.

Блоки TRY CATCH можно вкладывать друг в друга, и каждый CATCH видит свою ошибку. Мы поставили в CATCH от деления на ноль вложенный TRY с CAST('abc' AS int): вложенный CATCH получил номер 245 (ошибка преобразования), а внешний после него снова вернул 8134.

Крупный план экрана с таблицей результатов запроса и размытыми строками кода над ней

Какие ошибки TRY CATCH не перехватывает

Блок CATCH не сработает в четырёх случаях:

  • предупреждения и сообщения с уровнем 10 и ниже: RAISERROR(N'...', 10, 1) в блоке TRY просто напечатает текст, и выполнение пойдёт дальше;
  • ошибки уровня 20 и выше, после которых сервер закрывает соединение;
  • прерывание запроса клиентом, обрыв соединения и KILL от администратора;
  • ошибки компиляции и разрешения имён объектов на том же уровне, что и сам TRY.
Читайте также:  Создание задачи "Фруктовый салат" на Python — пошаговое руководство для начинающих программистов

Поэтому опечатка в имени таблицы проходит мимо CATCH:

BEGIN TRY
    SELECT * FROM dbo.NoSuchTable;
END TRY
BEGIN CATCH
    SELECT ERROR_NUMBER() AS ErrorNumber;
END CATCH;
Msg 208, Level 16, State 1, Line 2
Invalid object name 'dbo.NoSuchTable'.

Если тот же SELECT лежит в хранимой процедуре, ошибка возникает уровнем ниже и CATCH её ловит:

CREATE PROCEDURE dbo.usp_ReadMissing
AS
    SELECT * FROM dbo.NoSuchTable;
GO
BEGIN TRY
    EXEC dbo.usp_ReadMissing;
END TRY
BEGIN CATCH
    SELECT ERROR_NUMBER() AS ErrorNumber, ERROR_LINE() AS ErrorLine;
END CATCH;

CATCH вернул номер 208 и строку 3, то есть строку SELECT внутри процедуры. Так же работает динамический SQL через sp_executesql.

TRY CATCH и транзакции: XACT_STATE и SET XACT_ABORT

Без TRY CATCH и с настройками по умолчанию (XACT_ABORT выключен) перевод между счетами может создать деньги из ничего. Таблица счетов запрещает отрицательный баланс:

CREATE TABLE Accounts (
    Id      int NOT NULL CONSTRAINT PK_Accounts PRIMARY KEY,
    Balance decimal(10, 2) NOT NULL CONSTRAINT CK_Accounts_Balance CHECK (Balance >= 0)
);
INSERT INTO Accounts (Id, Balance) VALUES (1, 100), (2, 50);
GO
BEGIN TRANSACTION;
UPDATE Accounts SET Balance = Balance + 200 WHERE Id = 2;
UPDATE Accounts SET Balance = Balance - 200 WHERE Id = 1;
COMMIT TRANSACTION;
Msg 547, Level 16, State 0, Line 3
The UPDATE statement conflicted with the CHECK constraint "CK_Accounts_Balance". The conflict occurred in database "Bank", table "dbo.Accounts", column 'Balance'.
The statement has been terminated.

Учебная база здесь называется Bank, у вас в сообщении будет имя своей базы. После этого SELECT показывает на первом счёте 100, на втором 250. Списание отменилось, зачисление осталось, и COMMIT TRANSACTION его зафиксировал. По умолчанию SET XACT_ABORT выключен, и при части ошибок SQL Server откатывает одну упавшую инструкцию, а транзакция продолжается.

Два монитора на столе: на одном схема таблиц базы данных, на другом окно с размытым SQL-кодом

Правильный вариант с TRY CATCH:

SET XACT_ABORT ON;

BEGIN TRY
    BEGIN TRANSACTION;
    UPDATE Accounts SET Balance = Balance + 200 WHERE Id = 2;
    UPDATE Accounts SET Balance = Balance - 200 WHERE Id = 1;
    COMMIT TRANSACTION;
END TRY
BEGIN CATCH
    SELECT XACT_STATE() AS XactState,
           @@TRANCOUNT  AS TranCount,
           ERROR_NUMBER() AS ErrorNumber;

    IF @@TRANCOUNT > 0
        ROLLBACK TRANSACTION;
END CATCH;
XactState  TranCount  ErrorNumber
-1         1          547

Балансы остались 100 и 50. XACT_STATE() в блоке CATCH показывает, что можно сделать с транзакцией:

Значение Что значит
1 транзакция открыта, её можно зафиксировать или откатить
0 открытой транзакции нет, ROLLBACK и COMMIT дадут ошибку
-1 транзакция незафиксируемая: разрешены только чтение и полный ROLLBACK

С выключенным XACT_ABORT тот же код вернул XactState = 1: COMMIT в блоке CATCH прошёл бы и зафиксировал половину перевода. Ставьте SET XACT_ABORT ON в каждую процедуру с явной транзакцией. Без TRY ошибка выполнения при этой настройке откатывает транзакцию целиком. Внутри TRY транзакция становится незафиксируемой (XACT_STATE() = -1), и CATCH обязан сделать ROLLBACK. На синтаксические ошибки настройка не влияет, RAISERROR её не учитывает. В триггерах она включена по умолчанию.

THROW и RAISERROR: чем отличаются

THROW без параметров внутри CATCH пробрасывает пойманную ошибку как есть. RAISERROR создаёт новую, и при повторном выбросе исходный номер теряется. Вставим дубликат ключа и перебросим ошибку двумя способами:

BEGIN TRY
    INSERT INTO Accounts (Id, Balance) VALUES (1, 10);
END TRY
BEGIN CATCH
    DECLARE @msg nvarchar(2048) = ERROR_MESSAGE();
    RAISERROR(@msg, 16, 1);  -- второй вариант: THROW;
END CATCH;
-- RAISERROR
Msg 50000, Level 16, State 1, Line 6
Violation of PRIMARY KEY constraint 'PK_Accounts'. Cannot insert duplicate key in object 'dbo.Accounts'. The duplicate key value is (1).
-- THROW
Msg 2627, Level 14, State 1, Line 2
Violation of PRIMARY KEY constraint 'PK_Accounts'. Cannot insert duplicate key in object 'dbo.Accounts'. The duplicate key value is (1).

Приложение, которое ловит нарушение уникальности по номеру 2627, после RAISERROR его не узнает.

Читайте также:  Рекурсия C++: как работает, примеры и когда лучше цикл
RAISERROR THROW
Номер своей ошибки из sys.messages или 50000 для текста любой от 50 000 до 2 147 483 647
Форматирование %s, %d есть нет, знак % в тексте пишут как %%
Уровень серьёзности задаётся всегда 16, при повторном выбросе исходный
SET XACT_ABORT ON не учитывает откатывает транзакцию
Повторный выброс только новой ошибкой THROW; без параметров

В новом коде используйте THROW. Своя ошибка выглядит так: THROW 50001, N'Недостаточно средств на счёте 1', 1;.

Точка с запятой перед THROW

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

BEGIN CATCH
    SELECT N'Ошибка поймана'
    THROW;
END CATCH;

Здесь THROW стал псевдонимом столбца: запрос вернул колонку с именем THROW и значением «Ошибка поймана», а ошибка молча пропала. Во втором случае точки с запятой нет после ROLLBACK TRANSACTION:

    IF @@TRANCOUNT > 0
        ROLLBACK TRANSACTION
    THROW;

SQL Server принял THROW за имя транзакции и ответил ошибкой 6401 «Cannot roll back THROW. No transaction or savepoint of that name was found.» Исходная ошибка потерялась, а при выключенном XACT_ABORT транзакция осталась открытой: @@TRANCOUNT после этого вернул 1.

Рука на клавиатуре ноутбука, на экране редактор кода с одной выделенной строкой

Шаблон хранимой процедуры с обработкой ошибок

Соберём всё в хранимую процедуру перевода денег. Сведения об ошибке пишем в таблицу журнала:

CREATE TABLE ErrorLog (
    Id             int IDENTITY PRIMARY KEY,
    ErrorNumber    int,
    ErrorSeverity  int,
    ErrorState     int,
    ErrorProcedure nvarchar(128),
    ErrorLine      int,
    ErrorMessage   nvarchar(4000),
    LoggedAt       datetime2 NOT NULL DEFAULT SYSDATETIME()
);
GO
CREATE PROCEDURE dbo.usp_Transfer
    @FromId int,
    @ToId   int,
    @Amount decimal(10, 2)
AS
BEGIN
    SET NOCOUNT ON;
    SET XACT_ABORT ON;

    BEGIN TRY
        IF @Amount <= 0
            THROW 50001, N'Сумма перевода должна быть больше нуля', 1;

        BEGIN TRANSACTION;
        UPDATE Accounts SET Balance = Balance - @Amount WHERE Id = @FromId;
        UPDATE Accounts SET Balance = Balance + @Amount WHERE Id = @ToId;
        COMMIT TRANSACTION;
    END TRY
    BEGIN CATCH
        IF @@TRANCOUNT > 0
            ROLLBACK TRANSACTION;

        INSERT INTO ErrorLog
            (ErrorNumber, ErrorSeverity, ErrorState, ErrorProcedure, ErrorLine, ErrorMessage)
        VALUES
            (ERROR_NUMBER(), ERROR_SEVERITY(), ERROR_STATE(),
             ERROR_PROCEDURE(), ERROR_LINE(), ERROR_MESSAGE());

        THROW;
    END CATCH;
END;

В CATCH сначала идёт ROLLBACK: в незафиксируемой транзакции INSERT в журнал запрещён, а в обычной запись откатилась бы вместе с переводом. Потом журнал. Последним идёт THROW, иначе ошибка не дойдёт до приложения: всё, что поймал CATCH, вызывающий код сам не видит.

Вызовем EXEC dbo.usp_Transfer 1, 2, 30 на счетах 100 и 50, затем ту же процедуру с суммами 500 и 0. Первый перевод прошёл, второй вернул ошибку 547 от CHECK, третий нашу 50001. На счетах 70 и 80, в журнале две строки:

ErrorNumber  ErrorProcedure    ErrorLine  ErrorMessage
547          dbo.usp_Transfer  15         The UPDATE statement conflicted with the CHECK constraint "CK_Accounts_Balance". ...
50001        dbo.usp_Transfer  12         Сумма перевода должна быть больше нуля

ERROR_LINE() считает строки от начала пакета с CREATE PROCEDURE: строка 15 соответствует списанию, строка 12 проверке суммы.

Читайте также:  Си или Си плюс плюс: чем отличаются C и C++ и что выбрать

Серверная стойка в полумраке с индикаторами на дисках и ноутбуком администратора рядом

Повтор транзакции при взаимоблокировке

Если две транзакции ждут друг друга, SQL Server выбирает одну жертвой, откатывает её транзакцию и возвращает ошибку 1205. CATCH её ловит, и транзакцию можно повторить после короткой паузы. Повтор пишется циклом WHILE:

DECLARE @Attempt int = 1;

WHILE @Attempt <= 3
BEGIN
    BEGIN TRY
        BEGIN TRANSACTION;
        UPDATE Accounts SET Balance = Balance - 10 WHERE Id = 1;
        UPDATE Accounts SET Balance = Balance + 10 WHERE Id = 2;
        COMMIT TRANSACTION;
        BREAK;
    END TRY
    BEGIN CATCH
        IF @@TRANCOUNT > 0
            ROLLBACK TRANSACTION;

        IF ERROR_NUMBER() <> 1205 OR @Attempt = 3
            THROW;

        SET @Attempt += 1;
        WAITFOR DELAY '00:00:02';
    END CATCH;
END;

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

Любую другую ошибку цикл сразу пробрасывает: повторять нарушение CHECK бессмысленно.

TRY CATCH в функциях и триггерах

В пользовательской функции TRY CATCH запрещён: функцию с BEGIN TRY внутри сервер не создаст и ответит ошибкой 443 «Invalid use of a side-effecting operator ‘BEGIN TRY’ within a function.» Проверки в функции пишут условиями с RETURN, а ошибку ловят там, откуда функцию вызвали.

Ошибка в триггере без собственного TRY CATCH попадает в CATCH вызывающего кода, если инструкция, которая запустила триггер, стоит в блоке TRY. Мы проверили это триггером AFTER INSERT с делением на ноль: CATCH получил ошибку 8134, ERROR_PROCEDURE() вернула имя триггера, а строка в таблицу не вставилась.

TRY CATCH в PostgreSQL и MySQL

В PostgreSQL и MySQL конструкции BEGIN TRY нет. Таблица accounts в примерах та же: балансы 100 и 50 и CHECK (balance >= 0). В PostgreSQL роль TRY CATCH играет блок EXCEPTION в PL/pgSQL:

DO $$
BEGIN
    UPDATE accounts SET balance = balance + 200 WHERE id = 2;
    UPDATE accounts SET balance = balance - 200 WHERE id = 1;
EXCEPTION
    WHEN check_violation THEN
        RAISE NOTICE 'Перевод отменён: % (SQLSTATE %)', SQLERRM, SQLSTATE;
END $$;

При перехвате PostgreSQL откатывает изменения внутри блока, и балансы остались 100 и 50 без явного ROLLBACK. Блок с EXCEPTION дороже обычного, без нужды его не ставят.

В MySQL 8.4 в начале процедуры объявляют обработчик ошибок:

DELIMITER //
CREATE PROCEDURE transfer(IN from_id INT, IN to_id INT, IN amount DECIMAL(10, 2))
BEGIN
    DECLARE EXIT HANDLER FOR SQLEXCEPTION
    BEGIN
        ROLLBACK;
        RESIGNAL;
    END;

    START TRANSACTION;
    UPDATE accounts SET balance = balance + amount WHERE id = to_id;
    UPDATE accounts SET balance = balance - amount WHERE id = from_id;
    COMMIT;
END //
DELIMITER ;

CALL transfer(1, 2, 200) вернул «Check constraint ‘accounts_chk_1’ is violated.», балансы не изменились. RESIGNAL без параметров передаёт ошибку дальше без изменений, как THROW; в SQL Server.

Ноутбук с двумя окнами терминала рядом, в каждом размытый код, на столе блокнот и кружка

Частые вопросы

Чем TRY CATCH лучше проверки @@ERROR?

@@ERROR хранит номер ошибки только до следующей инструкции, и проверять его приходится после каждой строки. TRY CATCH ловит ошибку в любом месте блока, а ERROR_* дают ещё текст и строку.

Как вернуть текст ошибки в приложение?

Закончить CATCH инструкцией THROW;: клиент получит исходные номер, уровень и текст. Если вместо этого вывести текст через PRINT или SELECT, для приложения запрос завершится без ошибки.

Оцените статью
bestprogrammer.ru
Добавить комментарий