Здравствуйте! Уже который год сталкиваюсь с задачами администрирования баз данных 1С на MS SQL и постоянно слышу это слово "FULL" ("Полная", в смысле модель восстановления - по-русски). Долгое время данная тема существовала для меня на уровне: для рабочей базы пусть стоит Полная модель, не трогай, но если переполнится или срочно (!) надо место почистить - ставишь Простую, делаешь "шринк", вертаешь обратно, [выбиваешь долотом на стенке туалета специальную метку, чтобы не забыть вернуть Полную]. Нет, я знал, что такое транзакция, проходил когда-то давно курс баз данных, на котором слышал про журнал и даже про фишку его использования при резервном копировании что-то припоминал, но вот подход ... явно требовал доработки. Не буду рассказывать, как пришел к необходимости (если что - ни один котик не пострадал) изменения подхода, но опишу результат и некоторую теорию.
Введение
Подготовка к автоматизации резервного копирования
Создание процедуры актуализации логических имен файлов БД
Создание (настройка) планов обслуживания
Скрипт для резервного копирования журнала транзакций
Скрипт для резервного копирования базы данных с уменьшением размера файла журнала
Настройка оповещений на почту при ошибках
Заключение (с небольшим бонусом)
Постскриптум
Глава 1. Введение
Для начала расскажу про журнал транзакций. Думаю, файл со своей базой данных все видели и понимают, что в этой корзине хранятся, что называется, "все яйца" и гадать о том, что произойдет при повреждении этой корзины не хочется, да и Господь говорит "не думай о завтрашнем дне". Кроме того, идеальных приложений, как и пользователей, не поедающих над своей клавиатурой вкусного печенья - просто не бывает. Поэтому разработчиками баз данных еще в давние времена был придуман журнал транзакций, в который будут записываться все операции с базой данных в том виде, чтобы можно было по конкретной записи (набору записей) установить какие строки, в каких таблицах и главное КАК были изменены в определенный момент времени.
При полной модели восстановления транзакции фиксируются достаточно подробно, поэтому воспользовавшись таким журналом (в случае MS SQL - его архивными копиями, текущий их не устраивает, поскольку это еще одна большая корзина), а также полной резервной копией мы сможем восстановить состояние базы на любой описываемый журналом транзакций момент времени (после момента начальной резервной копии базы данных, конечно). Соответственно, получив сведения о критической ошибке с базой данных, если мы уже располагаем резервными копиями журнала транзакций, можно зайти в SSMS и выбрать точный момент времени, на который база данных будет восстановлена:
Для самых сложных случаев есть возможность вернуться к состоянию базы до конкретной транзакции, но для этого придется проанализировать (это несложно) журнал транзакций и данную транзакцию найти (это уже сложнее).
Если мы считаем (по какому-то стечению обстоятельств, не иначе), что резервных копий баз данных нам достаточно, чтобы "закрыть" вопрос сбережения ценных данных окончательно, то можно использовать Простую модель восстановления. Тогда в журнале будут записаны только те транзакции, которые активны сейчас (по каким-то причинам). Когда же логика сервера определит, что они более не нужны - журнал будет очищен, таким образом сохраняя свой (заданный) небольшой размер в файловой системе. В этом ее единственное преимущество.
Кстати! Развею популярное заблуждение: Полная резервная копия базы данных не сохраняет все содержимое журнала транзакций, а только текущие транзакции (по сути - как при Простой модели) и заданный размер журнала.
Для тех, кому важно порой восстановить последовательность операций в каком-то разрезе, а с резервным копированием они разобрались в другом ключе, можно использовать третий режим - С неполным протоколированием. На момент времени посредством такого журнала базу данных не восстановить, но задача выяснить, как получилось то, что получилось - будет вполне решаема.
Глава 2. Подготовка к автоматизации резервного копирования
Для большинства случаев актуальна первая модель, поэтому ее и рассмотрим. Нам понадобятся:
1. Настроенная административная учетная запись SQL-сервера;
2. Место для хранения резервных копий;
3. Базовые навыки программирования (будут скрипты);
4. Учетная запись почты с паролем для приложения (желательно).
5. Установленная в Автоматический запуск служба Агент SQL Server (#ИмяВашегоЭкземпляраСервера#)
6. Последняя версия программы MSSQL Server Management Studio (сейчас 19.2) - все действия будут производиться в ней
Вообще, для резервного копирования журнала транзакций существует три варианта действий:
1. ручной запуск (там же, где и для базы данных, только вместо Типа архивной копии Полная выбираем Журнал транзакций) - не годится для спокойного и уверенного администрирования
2. создание Плана обслуживания (вот, пример статьи по настройке таким способом)
3. создание резервных копий с применением языка T-SQL (план обслуживания тоже используется)
Третий вариант хорош тем, что позволяет нормальным образом обрабатывать ошибки на лету или, например, задавать свою маску файлов1.
Перед тем как приступить к его реализации, необходимо учесть момент, с которым нередко сталкиваются при использовании создания служебных копий баз данных с их переименованием: администраторы порой ограничиваются тем, что задают новое название в Обозревателе объектов, забывая о том, что для целей администрирования необходимо указать его и в реквизитах файлов. К счастью, можно написать довольно простую процедуру2 для такого переименования:
-- использование контекста главной системной базы необходимо, чтобы именно в нем сохранить процедуру
USE master;
IF OBJECT_ID('dbo.uspVerifyAndRepairDBName', 'P') IS NOT NULL
-- обновление процедуры, увы, весьма ограниченно, поэтому просто удаляем и пересоздаем ее
DROP PROCEDURE dbo.uspVerifyAndRepairDBName;
GO
--процедура для исправления неправильного логического имени файлов базы данных
--после восстановления из бэкапа, например. Применение - ниже (и в любой момент)
CREATE PROCEDURE dbo.uspVerifyAndRepairDBName
@dbName nvarchar(128)
AS
-- команда T-SQL, если нужно добавить в нее текст переменных, должна быть объявлена (и собрана) в строковом виде
-- и только затем исполнена, для обхода файлов конкретной базы используется таблица master..sysdatabases,
-- для сопоставления с файлами нужной базы смотрим ..sysfiles
-- полученные результаты помещаются во временную таблицу для обхода #mytemp
-- использованный тип обхода основан на построчном получении нужныж полей - обработанные строки удаляются, пока они есть
DECLARE @command nvarchar(2000) = ' DECLARE @logicalName varchar(128);
SELECT sysfiles.name INTO #mytemp FROM master..sysdatabases LEFT JOIN [' + @dbName + ']..sysfiles
ON (sysdatabases.name = ''' + @dbName + ''')
WHERE sysdatabases.filename = (SELECT sysfiles.filename FROM ['+@dbName + ']..sysfiles
WHERE sysfiles.filename LIKE ''%.mdf'')
SELECT @logicalName = name FROM #mytemp
WHILE @@rowcount <> 0
BEGIN
IF @logicalName <> ''' + @dbName + ''' AND @logicalName <> ''' + @dbName + '_log''
BEGIN
IF @logicalName LIKE ''%_log''
-- MODIFY FILE используется для приведения логических имен файлов в соответствие с физическими
EXEC(''ALTER DATABASE [' + @dbName + ']
MODIFY FILE (NAME = ['' + @logicalName + ''], NEWNAME = [' + @dbName + '_log])'')
ELSE
EXEC(''ALTER DATABASE [' + @dbName + ']
MODIFY FILE (NAME = ['' + @logicalName + ''], NEWNAME = [' + @dbName + '])'')
END;
DELETE #mytemp WHERE name = @logicalName
SELECT @LogicalName = name FROM #mytemp
END
DROP TABLE #mytemp'
-- PRINT @command
EXEC sp_executesql @command
GO
Выполнив данный скрипт на вашем сервере единожды (Alt+Т на русском, вставить текст и F5-Выполнить), вы сможете обеспечить благосостояние цветочных магазинов на долгие годы, потому что будете возвращаться домой счастливыми создать совсем уже простой скрипт для проверки и исправления этого неловкого момента (разумеется, если вы уже знаете как обойти весь список ваших рабочих баз, а не знаете - сейчас покажу):
Скрипт для исправления неактуальных логических названий файлов баз данных
-- Исправление устаревшего (неверного) логического названия файла (например, после восстановления из бэкапа)
USE master;
DECLARE @dbName varchar(128); -- Для хранения названия базы данных
-- В T-SQL для обхода результатов запроса есть два подхода: курсор (стандартный - применяется здесь) и временная таблица
-- для второго подхода характерна постепенная очистка этой таблицы по мере обхода - это не всегда естественно,
-- но при необходимости дополнительных условий и проверок - самое то, по моему скромному опыту
DECLARE DBCURSOR CURSOR FOR
SELECT name
FROM sys.databases
WHERE database_id > 4; -- Все пользовательские базы (первые 4 ID заняты системными - все просто)
-- указали запрос - теперь запускаем его на исполнение
OPEN DBCURSOR
-- Получение названия первой пользовательской базы
FETCH NEXT FROM DBCURSOR INTO @dbName
WHILE @@FETCH_STATUS = 0
BEGIN
-- Запускаем нашу проверку и переименование, затем переходим к следующей записи (вроде Для каждого получается)
EXEC dbo.uspVerifyAndRepairDBName @dbName
FETCH NEXT FROM DBCURSOR INTO @dbName
END
-- Не забываем закрывать и удалять курсор, иначе он будет существовать, пока не закроется текущее соединение
-- или окно выполнения скрипта: аккуратность - залог хорошего!
CLOSE DBCURSOR
DEALLOCATE DBCURSOR
Глава 2 (с хвостиком) Добавление процедуры для отлова ВСЕХ ошибок при исполнении пакета инструкции
В ходе работы над статьей выяснилось, что системная переменная @@Error, как и все остальные источники информации об ошибках выдают либо только последнюю ошибку, либо свои независимые коды и текст содержания ошибки. В случае, например, если мы в течение дня обрезаем журнал регистрации у какой-либо базы, не создав при этом свежей полной резервной копии базы, мы получим ошибку 4214 (Инструкцию BACKUP LOG невозможно выполнить, так как не существует резервной копии текущей базы данных.). Но из кода этого не увидим, потому как там будет более общая и весьма, надо сказать, обширная по возможным проблемам ошибка 3013. Но ... мы же можем просто создать-таки полную копию и не мотать администратора (хотя метнуть ручкой, там - нет, я не призываю, но ... можно). Поэтому, а также с целью получать более информативные сообщения об ошибках (вместо "Не удалось завершить задание. Запуск задания был произведен Расписание 9 (Рабочий.ВложенныйПлан_1). Последним выполнявшимся шагом был шаг 1 (ВложенныйПлан_1). ДЛИТЕЛЬНОСТЬ: 4 сек. ... ") воспользуемся методом, обнаруженном мною на сайте Пауло Сантоса (источник), только исправим небольшие ошибки и переведем на русский и рабочие рельсы - то есть обеспечим использование в скриптах автоматизации 3 степени (когда к их работе обращаются только при ошибках):
Скрипт для вывода строк с сообщениями о всех ошибках в ходе исполнения инструкций одного пакета
USE master;
IF OBJECT_ID('dbo.uspClearDBCCOutputBuffer', 'P') IS NOT NULL
-- обновление процедуры, увы, весьма ограниченно, поэтому просто удаляем и пересоздаем ее
DROP PROCEDURE dbo.uspClearDBCCOutputBuffer;
GO
CREATE PROCEDURE dbo.uspClearDBCCOutputBuffer
AS
BEGIN
EXEC sp_addmessage @msgnum = 1029384756, @severity = 1, @msgtext = N'%s', @lang = 'us_english', @replace= 'REPLACE';
Raiserror(1029384756, 0, 0, 'Очистка буфера')
SET NOCOUNT ON
-- Ошибку вызвали, теперь будем искать, где ее описание заканчивается - это и будет положение записи
DECLARE @dbccRow char(77)
,@gather tinyint = 0, @byte binary(1), @hex varbinary(4)
,@readingPos int = 0, @pos tinyint
,@eraserCount tinyint = 0, @contentSize int = 6144
DECLARE @buffer TABLE (col1 nchar(77))
DECLARE error_cursor CURSOR STATIC FORWARD_ONLY FOR
SELECT col1
FROM @buffer
ORDER BY col1
-- Открытие курсора для обхода полученных строк буфера
INSERT INTO @buffer
EXEC ('DBCC OUTPUTBUFFER(@@spid) WITH NO_INFOMSGS')
OPEN error_cursor
FETCH NEXT FROM error_cursor INTO @dbccRow
-- обнуление внутрицикловых переменных
SELECT @gather = 0
,@pos = 12 -- с 12ого символа представления ошибки в шестнадцатеричном формате в строках буфера
WHILE (@@FETCH_STATUS = 0)
BEGIN
WHILE (@gather < 2 AND @pos <= 57)
BEGIN
SELECT @byte = Convert(binary(1), Substring(@dbccRow, @pos, 2), 2)
SELECT @pos = @pos + 3
SELECT @readingPos = @readingPos + 1
-- 0. Поиск начала сообщения
IF (@gather = 0)
BEGIN
IF (@byte = 0x34)
SELECT @gather = 1
,@hex = 0x34
CONTINUE
END
-- 1. Получение длины сообщения
IF (@gather = 1)
BEGIN
SELECT @hex = @hex + @byte
IF (Len(@hex) = 4)
BEGIN
IF @hex = 0x342a5b3d
SELECT @gather = 2
ELSE
SELECT @gather = 0
END
CONTINUE --переход до следующего байта или строки
END
END -- WHILE (@pos <= 57)
IF (@gather = 2)
BREAK
SELECT @pos = 12
FETCH NEXT FROM error_cursor INTO @dbccRow
END -- (@@FETCH_STATUS = 0)
CLOSE error_cursor
DEALLOCATE error_cursor
SELECT @contentSize = @contentSize - @readingPos - 160
,@eraserCount = 1
WHILE (@contentSize > 152)
BEGIN
IF (@contentSize > 700) -- замечено, что сервер подобные сообщения с количеством байт больше 800 записывает некорректно
BEGIN
-- PRINT залетает в буфер под другим кодом - 171 (AB), поэтому в сообщениях мы этого не увидим
PRINT FormatMessage('%i. NOTA_BENE_OF_%s %i', @eraserCount, Space(271), @eraserCount)
SELECT @contentSize = @contentSize - 700
END
ELSE
BEGIN
PRINT FormatMessage('%i. NOTA_BENE_OF_%s %i', @eraserCount, Space(Convert(int, (@contentSize - 152) / 2)), @eraserCount)
SELECT @contentSize = 0
END
SET @eraserCount += 1
END
END
GO
IF OBJECT_ID('dbo.spGET_LastErrorMessage', 'P') IS NOT NULL
DROP PROCEDURE dbo.spGET_LastErrorMessage;
GO
CREATE PROCEDURE dbo.spGET_LastErrorMessage
@errorMessage nvarchar(MAX) OUT --в таблицу полученные результаты не перевести - ограничение возможностей INSERT-EXEC
,@dbName nvarchar(128) = ''
AS
BEGIN
SET NOCOUNT ON
DECLARE @dbccrow nchar(77)
,@hex binary(1)
,@byte tinyint
,@pos int
,@gather tinyint
,@byteLen int
,@count tinyint
,@msgLen int
,@nchar int
,@readingPos smallint
,@byteLC nvarchar(MAX) = ''
,@errNumber bigint
,@errState int
,@errLevel int
,@errMessage nvarchar(2000)
,@errInstance nvarchar(256)
,@errProcedure nvarchar(256)
,@errLine int
,@errorCount int = 0
/*
A buffer sample - Thanks to Paulo Jorge Santos (футболисту(?) - программисту)
00000000 04 01 00 c8 00 37 01 00 aa 30 00 50 c3 00 00 01 ...È.7..ª0.PÃ...
00000010 10 07 00 54 00 45 00 53 00 54 00 20 00 30 00 31 ...T.E.S.T. .0.1
00000020 00 0a 45 00 4e 00 54 00 45 00 52 00 50 00 52 00 ..E.N.T.E.R.P.R.
00000030 49 00 53 00 45 00 00 01 00 00 00 fd 03 00 f6 00 I.S.E......ý..ö.
00000040 00 00 00 00 00 00 00 00 aa 30 00 50 c3 00 00 02 ........ª0.PÃ...
00000050 11 07 00 54 00 45 00 53 00 54 00 20 00 30 00 32 ...T.E.S.T. .0.2
00000060 00 0a 45 00 4e 00 54 00 45 00 52 00 50 00 52 00 ..E.N.T.E.R.P.R.
00000070 49 00 53 00 45 00 00 02 00 00 00 fd 03 00 f6 00 I.S.E......ý..ö.
00000080 00 00 00 00 00 00 00 00 aa 30 00 50 c3 00 00 03 ........ª0.PÃ...
00000090 12 07 00 54 00 45 00 53 00 54 00 20 00 30 00 33 ...T.E.S.T. .0.3
000000a0 00 0a 45 00 4e 00 54 00 45 00 52 00 50 00 52 00 ..E.N.T.E.R.P.R.
000000b0 49 00 53 00 45 00 00 03 00 00 00 fd 02 00 f6 00 I.S.E......ý..ö.
000000c0 00 00 00 00 00 00 00 00 30 00 20 00 36 00 64 00 ........0. .6.d.
We need to scan the buffer for the byte marker 0xAA that starts an error message.
The problem with this approach is that if the buffer contains any user data it might
have the marker byte and thus provoking a false response.
П.С. Я немного изменил код для исправления ошибок и недочетов МНА
*/
-- Получаем вывод буфера скрипта (DBCC OUTPUTBUFFER работает вместо TRY...CATCH, примеры вызова в конце)
CREATE TABLE #DBCCOUT (col1 nchar(77))
INSERT INTO #DBCCOUT
EXEC ('DBCC OUTPUTBUFFER(@@spid) WITH NO_INFOMSGS')
-- Объявление курсора для обхода полученных строк буфера
DECLARE error_cursor CURSOR STATIC FORWARD_ONLY FOR
SELECT col1
FROM #DBCCOUT
ORDER BY col1
OPEN error_cursor
FETCH NEXT FROM error_cursor INTO @dbccrow
SET @pos = 12 -- начало представления ошибки в шестнадцатеричном формате в строках буфера
SET @gather = 0
SET @readingPos = 0
SET @errorMessage = @dbName + ' ***** '
-- Поехали!.
WHILE (@@FETCH_STATUS = 0)
BEGIN
WHILE (@gather < 12 AND @pos <= 57)
BEGIN
-- Получение шестнадцатиричного байта (*Convert - Символы 0x не добавляются слева для стиля 2)
SELECT @hex = Convert(binary(1), Substring(@dbccrow, @pos, 2), 2)
-- Преобразование байта в число
SELECT @byte = Convert(int, @hex)
-- Перемещение позиции чтения
SET @pos = @pos + 3
SET @readingPos = @readingPos + 1
IF (@errNumber IS NULL AND @readingPos < 4096)
SET @byteLC += Convert(nvarchar(2), @hex, 2) + ' '
IF (@readingPos >= 4096 and @pos >= 58) -- Прочитан последний байт буфера
BEGIN
IF (Len(@byteLC) < 3)
BREAK
IF(@readingPos = 4096)
SET @byteLC = Substring(@byteLC, 25, Len(@byteLC))
SET @dbccrow = Substring(@byteLC, 1, 57)
SET @byteLC = Substring(@byteLC, 58, Len(@byteLC))
SET @pos = 1
END
-- 0. Поиск маркера 0xAA (начало сообщения об ошибке)
IF (@gather = 0)
BEGIN
IF (@byte = 170)
BEGIN
SELECT @gather = 1
,@count = 0
,@nchar = 0
,@msgLen = 0
,@errNumber = 0
,@errMessage = ''
,@errInstance = ''
,@errProcedure = ''
,@errLine = 0
,@byteLen = 0
END
CONTINUE --переход до следующего байта или строки
END
-- 1. Получение длины сообщения (справочно, но порядок такой)
IF (@gather = 1)
BEGIN
SET @count = @count + 1
SET @byteLen = IsNull(@byteLen, 0) + @byte * Power(256, @count)
IF (@count = 2)
BEGIN
SET @count = 0
SET @gather = 2
END
CONTINUE --переход до следующего байта или строки
END
-- 2. Получение номера сообщения
IF (@gather = 2)
BEGIN
SET @errNumber = IsNull(@errNumber, 0) + @byte * Power(256, @count)
SET @count = @count + 1
IF (@count = 4)
BEGIN
SET @count = 0
-- Иногда в обработку попадают строки вида aa0000010000000000000000000000 - игнорируем их
IF (@errNumber = 1)
SET @gather = 0
ELSE
SET @gather = 3
END
CONTINUE --переход до следующего байта или строки
END
-- 3. Получение состояния сообщения
IF (@gather = 3)
BEGIN
SET @errState = @byte
SET @gather = 4
CONTINUE --переход до следующего байта или строки
END
-- 4. Получение уровня сообщения
IF (@gather = 4)
BEGIN
SET @errLevel = @byte
SET @gather = 5
CONTINUE --переход до следующего байта или строки
END
-- 5. Получение длины текста сообщения
IF (@gather = 5)
BEGIN
SET @msgLen = IsNull(@msgLen, 0) + @byte * Power(256, @count)
SET @count = @count + 1
IF (@count = 2)
BEGIN
SET @nchar = 0
SET @count = 0
SET @gather = 6
END
CONTINUE --переход до следующего байта или строки
END
-- 6. Получение текста сообщения
IF (@gather = 6)
BEGIN
IF (@msgLen > 1)
BEGIN
SET @nchar = IsNull(@nchar, 0) + @byte * Power(256, @count)
SET @count = @count + 1
IF (@count = 2)
BEGIN
SET @errMessage = IsNull(@errMessage, '') + nchar(@nchar)
SET @count = 0
SET @nchar = 0
END
IF (Len(@errMessage) = @msgLen)
SET @gather = 7
CONTINUE --переход до следующего байта или строки
END
--6.1 Если строка сообщения пустая (там пробел - читаем его весь), этот байт передается следующему обработчику
ELSE
BEGIN
SET @count = @count + 1
IF (@count = 2)
BEGIN
SET @gather = 7
CONTINUE
END
END
END
-- 7. Получение размера названия контекста вызова (сервер)
IF (@gather = 7)
BEGIN
SELECT @gather = 8
,@msgLen = @byte
,@nchar = 0
,@count = 0
CONTINUE --переход до следующего байта или строки
END
-- 8. Получение названия сервера
IF (@gather = 8)
BEGIN
IF (@msgLen > 0)
BEGIN
SET @nchar = IsNull(@nchar, 0) + @byte * Power(256, @count)
SET @count = @count + 1
IF (@count = 2)
BEGIN
SELECT @errInstance = IsNull(@errInstance, '') + nchar(@nchar)
SET @count = 0
SET @nchar = 0
END
IF (Len(@errInstance) = @msgLen)
SET @gather = 9
CONTINUE --переход до следующего байта или строки
END
ELSE --если @msgLen нулевая, этот байт передается следующему обработчику
SET @gather = 9
END
-- 9. Получение размера имени процедуры
IF (@gather = 9)
BEGIN
SELECT @gather = 10
,@msgLen = @byte
,@nchar = 0
,@count = 0
CONTINUE --переход до следующего байта или строки
END
-- 10. Получение имени процедуры
IF (@gather = 10)
BEGIN
IF (@msgLen > 0)
BEGIN
SET @nchar = IsNull(@nchar, 0) + (@byte * Power(256, @count))
SET @count = @count + 1
IF (@count = 2)
BEGIN
SET @errProcedure = IsNull(@errProcedure, '') + nchar(@nchar)
SET @count = 0
SET @nchar = 0
END
IF (Len(@errProcedure) = @msgLen)
SET @gather = 11
CONTINUE --переход до следующего байта или строки
END
ELSE --если @msgLen нулевая, этот байт передается следующему обработчику
SET @gather = 11
END
-- 11. Получение номера строки, сохранение результата и запуск этапа 0
IF (@gather = 11)
BEGIN
SET @errLine = IsNull(@errLine, 0) + @byte * Power(256, @count)
SET @count = @count + 1
IF (@count = 2)
BEGIN
IF (@readingPos > 4096) -- обработка сообщения, "перешагнувшего" на начало
SELECT @readingPos -= 4096 - 24
,@gather = 12 -- окончание поиска
SELECT @errorMessage = @errorMessage + FormatMessage('Сообщение %I64d, уровень %i, состояние %i,'
+ 'строка %i (%s) --- %s на сервере: %s (позиция буфера %i)', @errNumber, @errLevel, @errState
, @errLine, IIF(Len(@errProcedure) = 0, 'JUST_CODE', @errProcedure), @errMessage, @errInstance
, @readingPos) + CHAR(10)
SET @gather = 0
SET @errorCount = @errorCount + 1
END
CONTINUE --переход до следующего байта или строки
END
END -- WHILE (@gather < 12 AND @pos <= 57)
SET @pos = 12
FETCH NEXT FROM error_cursor INTO @dbccrow
END -- (@@FETCH_STATUS = 0)
IF (@dbName <> '' AND @errorCount = 0)
SET @errorMessage = @errorMessage + 'Ошибок нет ' + CHAR(10)
CLOSE error_cursor
DEALLOCATE error_cursor
END
GO
/**********************************************************************************************************************
********************************************* Пара процедур проверки методов *****************************************
**********************************************************************************************************************/
IF OBJECT_ID('dbo.CheckLastErrorMessages', 'P') IS NOT NULL
DROP PROCEDURE dbo.CheckLastErrorMessages;
GO
CREATE PROCEDURE dbo.CheckLastErrorMessages
AS
BEGIN
EXEC dbo.uspClearDBCCOutputBuffer
DECLARE @checkLines nvarchar(MAX) = ''
,@index int = 43 -- 40-50 операций SET/SELECT достаточно ЗДЕСЬ, чтобы сместить 1-е сообщение на конец буфера
WHILE (@index > 0)
BEGIN
SET @checkLines += 'sdg' -- другие операции на данную особенность не проверял, но результат BACKUP LOG, например,
SET @index -= 1 -- пишется строго в начало буфера, и остальные за ним (указатель записи смещается)
END
RAISERROR ('Проверка сообщения об ошибке в 13-й строке (подсчет идет по пакетам GO)', 16, 1)
PRINT 'Просто сообщение между ошибками'
RAISERROR ('Проверка сообщения об ошибке в 15-й строке', 16, 9)
RAISERROR ('', 16, 9) -- пустое сообщение
SELECT 1/0
BACKUP LOG НеМояБаза TO DISK = 'Нет\Пути' WITH RETAINDAYS = 2, NOFORMAT, NOINIT,
NAME = 'какое-то название', SKIP, REWIND, NOUNLOAD, STATS = 10
EXEC spGET_LastErrorMessage @checkLines OUT, @dbName = N'СамаяХорошаяБаза'
PRINT @checkLines
IF @checkLines LIKE '%Сообщение 8134,%'
EXECUTE msdb.dbo.sp_notify_operator @name=N'ВашаУчетнаяЗаписьОператора',
@subject=N'Срочно! Выброс энтропии! Не указаны цели деления!', @body=@checkLines;
PRINT 'Работаете дальше. А сейчас - пример очистки буфера'
BACKUP LOG ТожеНеМояБаза TO DISK = 'Нет\Пути' WITH RETAINDAYS = 2, NOFORMAT, NOINIT,
NAME = 'какое-то название', SKIP, REWIND, NOUNLOAD, STATS = 10
SELECT 0.1/2.3 AS Число
EXEC dbo.uspClearDBCCOutputBuffer
PRINT 'Сработала очистка буфера, ошибок не будет:'
SET @checkLines = ''
EXEC dbo.spGET_LastErrorMessage @checkLines OUT, @dbName = N'СамаяХорошаяБаза'
PRINT @checkLines
END
GO
IF OBJECT_ID('dbo.CheckErrorThatCanBeCatchedJustSecondTime', 'P') IS NOT NULL
DROP PROCEDURE dbo.CheckErrorThatCanBeCatchedJustSecondTime;
GO
CREATE PROCEDURE dbo.CheckErrorThatCanBeCatchedJustSecondTime
AS
BEGIN
DECLARE @checkLines nvarchar(MAX) = ''
SET @checkLines = ''
-- Некоторые ошибки не ловятся DBCC обычным способом, хотя если ... не чистить буфер и сработать в следующем пакете GO...
CREATE TABLE #foo ( c INT DEFAULT(0) )
BEGIN TRY
ALTER TABLE #foo ALTER COLUMN c VARCHAR(10)
--никогда не будет вызван
EXEC dbo.spGET_LastErrorMessage @checkLines OUT
PRINT @checkLines;
END TRY
BEGIN CATCH
EXEC dbo.spGET_LastErrorMessage @checkLines OUT; -- ну вот и посмотрим, с какого раза будет сообщение
PRINT @checkLines;
DROP TABLE #foo;
THROW
END CATCH
END
GO
DECLARE @checkLines nvarchar(MAX) = ''
EXEC dbo.CheckLastErrorMessages
RAISERROR ('Сообщение вне процедуры', 16, 9)
EXEC dbo.spGET_LastErrorMessage @checkLines OUT
PRINT @checkLines
-- Дальше проверка отлова ошибки, которая останавливает исполнение пакета
EXEC dbo.uspClearDBCCOutputBuffer
PRINT CHAR(10) + 'Дальше будут звездочки, красные системные ошибки'
DECLARE @index int = 10 -- Если сообщение, которое останавливает пакет не ловится, просто добавьте несколько "бессмысленных" операций
WHILE (@index > 0) -- присваивания или... не используйте очистку буфера (она как-то влияет именно на это)
BEGIN
SET @checkLines += 'sdg' -- другие операции на данную особенность не проверял, но результат BACKUP LOG, например,
SET @index -= 1 -- пишется строго в начало буфера, и остальные за ним (указатель записи смещается)
END
EXEC dbo.CheckErrorThatCanBeCatchedJustSecondTime
GO
--EXEC dbo.CheckErrorThatCanBeCatchedJustSecondTime -- тоже рабочий вариант, но для совсем ленивых, но (!) предусмотрительных
DECLARE @checkLines nvarchar(MAX) = ''
PRINT 'А вот теперь уже с информацией! Заметьте - в отдельном пакете!'
EXEC dbo.spGET_LastErrorMessage @checkLines OUT;
PRINT @checkLines;
GO
Понимаю, что скрипт сложный, но отлавливать при архивировании журналов ошибку 3013 совесть теперь не позволяет, а так ... бонусы приятные, как мне кажется
Глава 3. Создание (настройка) планов обслуживания
0. Итак, надеюсь, самый первый скрипт (для создания процедуры переименования) вы уже выполнили, поскольку не добавить запуск данной процедуры в оба плана обслуживания будет неправильно - эта строчка там будет. Приступим!
1. Переходим в Управление / Планы обслуживания. Нажимаем правой кнопкой Создать план обслуживания, даем ему название.
2. Перед нами откроется вкладка с нашим планом, у которого необходимо задать три момента:
3. В Управлении соединениями вместо Проверки подлинности Windows указываем нашего администратора SQL, чтобы задание выполнялось независимо от факта присутствия непонятно зачем авторизованного на сервере администратора Windows:
4. Настраиваем Расписание. Я использую ежедневное, каждые 30 минут, с 4 утра до полуночи. В промежуток, не охватываемый заданием обычно выполняются регламентные задания, которые очень сильно увеличивают размер нашего журнала транзакций, но восстанавливать что-то на этот момент, как минимум странно, дальше объясню почему - мы это потом обрежем (не будем сохранять)
5. Добавляем этап задания в область задач: идем Вид \ Панель элементов, разворачиваем Задачи плана обслуживания (если свернуты - не пугаемся) и переносим оттуда Задачу "Выполнение инструкций T-SQL". Естественно, изменяем ее название - на "Резервное копирование журналов транзакций пользовательских баз". Пишем скрипт, обращая внимание на переменную @ARCHIEVE_PATH, поскольку в ней хранится название вашего каталога с бэкапами для всех исследуемых баз (в скрипте автоматически подхватываются только пользовательские), а также название учетной записи оператора (для отправки сообщений по почте):
-- Резервное копирование журнала транзакций
USE master;
DECLARE @dbName nvarchar(25)
,@backupName nvarchar(128)
,@backupFileName nvarchar(255); -- Для хранения имени базы данных
DECLARE @errorText nvarchar(MAX); -- для получения текста ошибок при сохранении резервной копии
DECLARE @errorFlag int = 0, @errorFixed int = 0
DECLARE @messageText nvarchar(MAX) = ''; -- для получения текста сообщения по результатам работы пакета
--Имя каталога с архивами в SQL 2019 может быть считано из системной константы, но есть место и для ручного заполнения
DECLARE @ARCHIEVE_PATH nvarchar(99) = TRIM('\' FROM
CAST(ISNULL(SERVERPROPERTY('InstanceDefaultBackupPath'), 'ЗдесьДолжноБытьВашеИмяКаталогаАрхивов') as nvarchar(99)))
DECLARE DBCURSOR CURSOR FOR
SELECT name
FROM sys.databases
WHERE database_id > 4; -- Все пользовательские базы
OPEN DBCURSOR
-- обычная приставка к названию файла для унификации и уникальности резервных копий
DECLARE @fileTail nvarchar(24) = '_backup_' + FORMAT(SYSDATETIME(), 'yyyy_MM_dd_HHmm', 'en-US')
FETCH NEXT FROM DBCURSOR INTO @dbName
WHILE @@FETCH_STATUS = 0
BEGIN
SET @errorText = '******' + @dbName + '******' + CHAR(10)
SET @errorFlag = @errorFlag + 1
PRINT '************Начата обработка ' + @dbName
-- в первую очередь - очистка буфера от ошибок предыдущих баз данных
EXEC dbo.uspClearDBCCOutputBuffer
SELECT @backupName = @dbName + @fileTail
--создание каталога для архивных копий новой базы
SET @backupFileName = N'' + @ARCHIEVE_PATH + '\' + @dbName
EXECUTE master.dbo.xp_create_subdir @backupFileName
-- создание резервной копии журнала (DBCC OUTBUFFER плохо работает с EXEC, так как важен контекст вызова - используем переменные)
SET @backupFileName = @backupFileName + '\' + @backupName + '.trn'
-- параметры самые стандартные - взяты из созданного мастером задания (я бы половину убрал:])
BACKUP LOG @dbName TO DISK = @backupFileName WITH RETAINDAYS = 2, NOFORMAT, NOINIT,
NAME = @backupName, SKIP, REWIND, NOUNLOAD, STATS = 10;
-- Проверка на наличие ошибок и их обработка
EXEC dbo.spGET_LastErrorMessage @errorText OUT, @dbName
SET @messageText = @messageText + @errorText
-- 3202 И 3203 означают недостаточное место или ошибки с оборудованием хранилища - предупреждаем
IF @errorText LIKE '%Сообщение 3202,%' OR @errorText LIKE '%Сообщение 3203,%'
BEGIN
-- должна быть настроена [ВашаУчетнаяЗапись] оператора для уведомлений (Глава 4)
EXECUTE msdb.dbo.sp_notify_operator @name=N'ВашаУчетнаяЗапись',
@subject=N'Срочно! Серьезные ошибки при резервном копировании баз данных', @body=@errorText;
THROW 103202, @errorText, 1
END
-- 4214 ошибка при создании резервной копии журнала (нет действительной, то есть включающей момент начала журнала, резервной копии базы данных)
ELSE
BEGIN
IF @errorText LIKE '%Сообщение 4214,%'
BEGIN
-- сперва резервная копия базы - затем снова ... журнала транзакций
SET @messageText = @messageText + ' --- Предпринимается попытка исправления ошибки' + CHAR(10)
SET @errorText = ''
EXEC dbo.uspCleanDBCCOutputBuffer
SET @backupFileName = N'' + @ARCHIEVE_PATH + '\' + @dbName + '\' + @backupName + '.bak'
BACKUP DATABASE @dbName TO DISK = @backupFileName WITH RETAINDAYS = 2, NOFORMAT, NOINIT,
NAME = @backupName, SKIP, REWIND, NOUNLOAD, STATS = 10;
SET @backupFileName = N'' + @ARCHIEVE_PATH + '\' + @dbName + '\' + @backupName + '.trn'
BACKUP LOG @dbName TO DISK = @backupFileName WITH RETAINDAYS = 2, NOFORMAT, NOINIT,
NAME = @backupName, SKIP, REWIND, NOUNLOAD, STATS = 10;
-- если ошибки продолжаются, теперь уже с любым кодом - сообщение оператору
EXEC dbo.spGET_LastErrorMessage @errorText OUT, @dbName
SET @messageText = @messageText + @errorText
IF (@errorText NOT LIKE '%Ошибок нет%')
EXECUTE msdb.dbo.sp_notify_operator @name=N'ВашаУчетнаяЗапись',
@subject=N'Ошибки при создании резервной копии базы данных', @body=@errorText
ELSE
SET @errorFixed = @errorFixed + 1
END --IF @errorText LIKE '%Сообщение 4214,%'
ELSE
BEGIN
-- при всех прочих ошибках отправляется оповещение администратору (кроме настроенного для задания, если есть желание)
IF (@errorText NOT LIKE '%Ошибок нет%')
EXECUTE msdb.dbo.sp_notify_operator @name=N'ВашаУчетнаяЗапись',
@subject=N'Ошибки при создании резервной копии базы данных', @body=@errorText
ELSE
SET @errorFlag = @errorFlag - 1
END
END
FETCH NEXT FROM DBCURSOR INTO @dbName
END
CLOSE DBCURSOR
DEALLOCATE DBCURSOR
IF (@errorFlag > 0)
BEGIN
PRINT @messageText
SET @errorText = N'При выполнении задания возникли ошибки ' + CAST(@errorFlag as nvarchar(10)) + ' шт.'
+ ' - из них исправлено: ' + CAST(@errorFixed as nvarchar(10))
PRINT @errorText
EXECUTE msdb.dbo.sp_notify_operator @name=N'ВашаУчетнаяЗапись', @subject=@errorText, @body=@messageText;
END
GO
6. Сохраняем и в течение получаса получаем первую резервную копию журнала транзакций или выполняем задание вручную (из обозревателя объектов)
7. Итак, резервные копии журнала создаются, что дальше? Поскольку у нас Полная модель восстановления, при резервном копировании журнала транзакций, устаревшие (неактивные) транзакции из рабочего файла с расширением *.ldf удаляются (иногда чуть позже, логика сервера сама определяет подходящий момент), но файл может достаточно быстро разрастись при некоторых задачах (например, Реструктуризация таблиц), да и ночные "бдения" лучше поутру сразу забывать, поэтому нужно создать еще один план обслуживания - для очистки файла журнала транзакций и грамотного резервного копирования самой базы. Если у вас уже есть настроенный план ночного обслуживания базы данных, сами выбирайте куда добавить следующий скрипт, но я предлагаю добавлять его в конце, поскольку начальное состояние базы до всех регламентных операций можем получить из вчерашней полной резервной копии и архивов журналов транзакций, а новую копию лучше делать после всех действий - для очистки их следов и уборки одноразовой посуды. Так что создаем (если нет) новый план обслуживания, теперь уже ночного и добавляем туда инструкцию SQL следующего содержания:
--Очистка журнала транзакций (бэкапы есть) и резервное копирование БД
USE master;
DECLARE @dbName varchar(128); --Для хранения названия базы данных
DECLARE @command nvarchar(1000); --Для хранения команды сжатия всех пользовательских баз
DECLARE @recoveryModelName nvarchar(60); -- Для хранения названия модели восстановления базы данных
DECLARE @backupName nvarchar(128) -- Для хранения названия резервной копии
DECLARE @ARCHIEVE_PATH nvarchar(99) = TRIM('\' FROM CAST(ISNULL(SERVERPROPERTY('InstanceDefaultBackupPath'),
'[ЗдесьДолжноБытьВашеИмяКаталогаАрхивов]') as nvarchar(99)))
DECLARE DBCURSOR CURSOR FOR
SELECT name, [recovery_model_desc]
FROM sys.databases
WHERE database_id > 4; -- Все пользовательские базы
OPEN DBCURSOR
FETCH NEXT FROM DBCURSOR INTO @dbName, @recoveryModelName
WHILE @@FETCH_STATUS = 0
BEGIN
--Исправление устаревшего (неверного) логического названия файла (например, после восстановления из бэкапа)
EXEC dbo.uspVerifyAndRepairDBName @dbName
SELECT @backupName = @dbName + '_backup_' + FORMAT(SYSDATETIME(), 'yyyy_MM_dd_HHmm', 'en-US')
SELECT @command = '' + CHAR(10) --CHAR(10) - символ перевода строки (каретки)
--Выбор используемой базы данных
+ 'USE [' + @dbName + '];' + CHAR(10)
--Установка модели восстановления в Простую
+ 'ALTER DATABASE [' + @dbName + ']
SET RECOVERY SIMPLE;' + CHAR(10)
--Сжатие файла журнала (при желании можно и SHRINKDATABASE, но делать это каждый день было бы странно - это как уборка, сопряженная с полной перестановкой мебели)
+ 'DBCC SHRINKFILE ([' + @dbName + '_log], 8);' + CHAR(10)
--Установка модели восстановления в ранее установленную, если уверены, можете выставить SET RECOVERY FULL;' + CHAR(10)
+ 'ALTER DATABASE [' + @dbName + ']
SET RECOVERY ' + @recoveryModelName + ';' + CHAR(10)
--Резервное копирование (полное) базы данных в указанную директорию (уже без файла журнала, распухшего за сутки работы)
+ 'BACKUP DATABASE [' + @dbName + '] TO DISK = N''' + @ARCHIEVE_PATH + '\DAILY\' + @backupName + '.bak''
WITH NOFORMAT, NOINIT, NAME = N''' + @backupName + ''', SKIP, REWIND, NOUNLOAD, STATS = 10'
--PRINT @command; --Можно убедиться в корректности списка баз перед выполнением
EXEC sp_executesql @command
FETCH NEXT FROM DBCURSOR INTO @dbName, @recoveryModelName
END
CLOSE DBCURSOR
DEALLOCATE DBCURSOR
Данный скрипт, проверяет и в случае расхождения исправляет на актуальное название базы данных, меняет модель восстановления на Простую, сжимает (обрезает) файл журнала транзакций и возвращает на место указанную Вами(!) Полную модель восстановления (все облегченно выдыхают) и сохраняет резервную копию уже самой базы данных. Для обслуживания всех пользовательских баз используется обход по курсору базы данных (@DBCURSOR), из которого мы берем название базы (@dbName) и вставляем в команду обслуживания вместе с сгенерированным именем резервной копии - выполняем!
Глава 4. Настройка оповещений по почте при ошибках (неисправленных)
Раз уж мы настроились на спокойное и уверенное администрирование, было бы неплохо сделать оповещение об ошибках в выполнении наших Планов обслуживания. Особо рассказывать не буду, Мастер настройки компонента Database Mail достаточно простой и удобный, просто расскажу основные шаги:
1. Находите в Обозревателе объектов раздел Управление элемент Компонент Database Mail, щелкаете по нему правой кнопкой - Настроить ... - запускается Мастер, в котором нам в общем случае нужно выбрать первый пункт, т.е. создать новый профиль (название запомните), ввести для него настройки доступа к почте по кнопке Добавить, у меня такие:
4. На следующем экране указать данный профиль для отправки по умолчанию:
3. Добавить оператора оповещений с указанием допустимых дней и времени отправки оповещений. Находим в Обозревателе объектов Агент SQL Server / Операторы - щелкаем правой кнопкой Создать, заводим имя, почту и время отправки оповещений
4. Включить оповещение о предупреждениях в ходе работы Агента SQL Server, который, собственно, и выполняет Планы обслуживания, - щелкаем по нему правой кнопкой, заходим в Свойства и указываем следующее:
5. Ну а дальше осталось только в том же разделе Агент SQL Server раскрыть ветку Задания, найти там свое и в его Свойствах настроить уведомления:
6. Радоваться, мы закончили!
Глава 5. Заключение
Что хотелось бы сказать насчет выбора момента создания резервной копии. Словами "он важен" мы выскажем только наши догадки, а если по делу, то необходимо понимать, что программная логика сервера SQL, конечно, умна, но она не может знать и учитывать логики прикладного решения, поэтому, например, создав полную резервную копию во время выполнения сложных расчетов (но без архива журнала транзакций) мы рискуем при восстановлении такой копии получить некое переходное состояние, вот только сложный расчет уже запущен не будет и "досчитать" свою работу не сможет - только если будет запущен снова (если все срастется, ведь программисты были правильные). Механизмы SQL уже используют журнал транзакций для правильного сохранения резервной копии базы данных (если коротко: пока база пишется, она может быть изменена "сотни" раз, а для сохранения копии на конкретный момент времени подхватываются свежие транзакции из журнала - для приведения в соответствие). Хорошее дело - присоединяемся:
Скрипт для получения таблицы поминутной статистики количества транзакций по архивной копии Журнала
-- Создание процедуры для формирования таблицы поминутной статистики транзакций для базы
USE master
IF OBJECT_ID('dbo.uspTransactionLogLoadTable', 'P') IS NOT NULL
DROP PROCEDURE dbo.uspTransactionLogLoadTable;
GO
CREATE PROCEDURE dbo.uspTransactionLogLoadTable
@backupName nvarchar(255) = ''
AS
DECLARE @comm datetime2
SET @comm = SYSDATETIME()
SELECT LEFT([Begin Time], 16) AS BeginTime, COUNT([Transaction ID]) AS CountOfTran
FROM sys.fn_dump_dblog(NULL, NULL, N'DISK', 1, N'' + @backupName + '',NULL,NULL,NULL,NULL,NULL,NULL,
NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,
NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,
NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL,NULL)
WHERE Operation LIKE 'LOP_begin_XACT'
GROUP BY LEFT([Begin Time], 16) ORDER BY BeginTime;
-- архив журнала размером несколько гигабайт может анализироваться с десяток минут и более (я вас предупредил)
PRINT CAST(DATEDIFF(second, @comm, SYSDATETIME()) AS nvarchar(100)) + ' seconds'
GO
--пример запуска созданной процедуры
exec dbo.uspTransactionLogLoadTable N'[ИмяВашейРезервнойКопииЖурналаТранзакцийСУказаниемПолногоРасположения]'
Выполнив данный скрипт единожды, далее мы сможем запускать данную процедуру, указывая только последнюю строку с полным названием файла архива, чтобы даже при достаточно большом объеме всех сохраненных получасовых копий журнала и сложности в определении подходящего момента в Журнале регистрации 1С (ну вдруг, мало ли - я вообще-то для другого с этим ковырялся, но должно же это кому-то пригодиться))). В результате получаем результат, по которому нам понятно, что первые 7 минут сервер "отдыхал" - "действие" началось позже:
1. Хотелось сделать наименование архивов журналов, включающие поминутную нагрузку в виде количества транзакций в текущей базе, для более уверенного восстановления (если не на случай ЧП - в таком случае обычно все же на события опираются, но когда хочется получить базу на момент затишья), но выяснилось вполне благоразумная вещь: текущий журнал транзакций оптимизирован для максимально быстрой записи, а читается медленно, а вот архивная копия оптимизирована разработчиком уже в обратном направлении, причем разница в разы, почти на порядок в моем случае (например, архив лога на 30 ГБ и 6 миллионов записей анализируется за 8 минут, а тот же лог до архивирования - 74 минуты - есть разница?). Так что отступать от тактики, предложенной разработчиком, и создавать дополнительную нагрузку при сохранении копии не стал и другим советовать не буду.
2. Очень полезно хранить полезные скрипты где-нибудь, но бывает еще удобнее создать процедуру с понятным именем и просто вызывать ее при необходимости, тем более для нее можно указывать входные параметры.
Постскриптум
Если вы внимательно читали, то наверняка заметили, что в ходе написания статьи возникла проблема, требующая тщательного изучения и разработки - отображалась только крайняя ошибка из происшедших в ходе выполнения инструкции, а она не самая важная. Так вот указанное в статье решение ущербное, поскольку требует выполнения нескольких команд и не забывать про очистку буфера, не учитывает очередность сообщений, когда в ходе их записи в буфер произошел переход с конца буфера в его начало и, как было замечено неоднократно, почему-то иногда смесь команд, очистки буфера и особенностей выполнения приводит к тому, что сообщения не ловятся (в буфере появляются в урезанном виде или не появляются вовсе). Поэтому было придумано решение: требующие проверки на ошибки команды передаются на вход процедуры в виде текста, а там внутри уже происходит небольшая магия с метками начала и окончания отслеживания ошибок и на выход выдается итоговый текст. Так сообщения будут "отфильтрованы" (за неполным исключением ошибок, крушащих пакет) и код упростится до такого вызова:
EXEC dbo.uspExecWithErrorListing @command, @errorText OUT
Текст процедуры поиска сообщений об ошибках там также переписан, но утратил свою первоначальную лаконичность (нравятся мне эти ветки - хоть убейте!). По этой причине и возросшей сложности решения (около десятка служебных процедур) в статью не включаю. Приведенное решение можно скачать с "нашего" GIT-агрегатора по ссылке scholarnick/SQL Output Message Listing: Чтение сообщений (gitflic.ru)