При записи объекта, версии которого должны сохраняться в Истории данных, информация о значениях реквизитов и табличных частях объекта сохраняется в таблицу `_DataHistoryQueue0`. Из этой таблицы-очереди данные должны переноситься в таблицу `_DataHistoryVersions` по команде `ИсторияДанных.ОбновитьИсторию();` или сразу при включенной опции «Обновлять историю данных сразу после записи». Но что если регламентное обновление истории не производилось, а при обновлении сразу после записи база начинает подвисать из-за большого количества одновременных записей, а значит и большого количества фоновых заданий?
В Highload-системах размер очереди истории данных может достигать больших значений: при размере базы 500 Гб размер таблицы `_DataHistoryQueue0` на нашем примере достигал 60 Гб. На очистку очереди через обновление истории требуется слишком много времени, особенно когда обработки требует не один десяток миллионов записей в очереди. В нашем случае механизм должен был бы обработать 12 миллионов записей.
Очевидным выходом из такой ситуации может стать очистка очереди `_DataHistoryQueue0` через sql-скрипт, который может как запускаться администратором системы раз в какое-то время вручную, так и автоматически по регламенту. Есть лишь одна проблема: для создания такого скрипта нам необходима дата записи истории данных, но отдельной колонки для дат в очереди нет. Данная статья освещает способ извлечения даты записи из таблицы `_DataHistoryQueue0` исключительно средствами Microsoft SQL Server 2022 (на старых версиях MSSQL извлечение даты также возможно, но сделать это сложнее в связи с отсутствием битовых сдвигов).
В следующих частях статьи мы постепенно пройдем по пути разработчика: от изучения структуры таблицы-очереди и извлечения нужных нам байтов до перевода байт в дату и внесения финальных штрихов в скрипт, чтобы он успешно справлялся с поставленной задачей – удалением лишних записей из таблицы `_DataHistoryQueue0`.
Таблица `_DataHistoryQueue0` содержит в себе следующие колонки:
- `_MetadataId` – уникальный ключ метаданных, который может использоваться для сопоставления с таблицей `_DataHistoryMetadata`.
- `_DataId` – уникальный идентификатор хранимого в истории данных объекта.
- `_Position` – порядковая версия хранимого в истории данных объекта.
- `_Content` – содержимое записи очереди истории данных.
- А также `_Fld814` и `_Fld789`, назначение которых в рамках этой статьи не разбиралось.
Каждую запись таблицы-очереди можно однозначно идентифицировать по трем колонкам: `_MetadataId`, `_DataId` и `_Position`. Если рассматривать в терминах 1С таблицу-очередь как Регистр сведений, то обозначенные выше три колонки – это измерения регистра, а свойство `_Content` – ресурс.
Возможность составить ключ записи из трех свойств пригодится нам в финальной части для оптимизации скрипта в вопросе удаления записей из `_DataHistoryQueue0`. Сейчас же подробно рассмотрим свойство `_Content`, поскольку именно в нем находится необходимая нам дата записи.
Значение поля _Content представляет из себя набор байт в формате TLV (Tag-Length-Value). Опираясь на исследования из статьи, получаем, что нужная нам дата содержится сразу после тега `0x30`. Автор статьи не упоминает это прямо, но для свойств с типом `varint` (variable int), в отличие от других типов, между тегом и значением свойства нет размера. Пример свойства даты в составе `_Content`: `0x000030C0A2DCFBA0909101`, само значение даты будет содержаться в части `0xC0A2DCFBA0909101`.
Т. к. все свойства до даты имеют размер, то общая логика алгоритма поиска даты в составе `_Content` будет выглядеть следующим образом:
Практическая реализация данного алгоритма на языке MSSQL будет выглядеть следующим образом:
``` CREATE FUNCTION get_date_from_content ( @content AS VARBINARY(max) ) RETURNS DATETIME WITH EXECUTE AS CALLER AS BEGIN DECLARE @type AS VARBINARY(1) DECLARE @length_bin AS VARBINARY(max) DECLARE @length AS BIGINT DECLARE @seconds AS BIGINT SET @content = SUBSTRING(@content, 2, DATALENGTH(@content)) SET @content = dbo.get_content_after_varint(@content) WHILE (DATALENGTH(@content) > 0) BEGIN SET @type = SUBSTRING(@content, 1, 1) SET @length_bin = SUBSTRING(@content, 2, DATALENGTH(@content)) SET @length = dbo.get_varint(@length_bin) IF (@type = 0x30) BEGIN SET @seconds = @length / 10000 RETURN dbo.get_date_from_bigint_s(@seconds) END SET @content = dbo.get_content_after_varint(@length_bin) SET @content = SUBSTRING(@content, 1 + @length, DATALENGTH(@content)) END RETURN NULL; END; GO ```
Как можно заметить, в функции `get_date_from_content` используется ряд вспомогательных функций. Одну из них, `get_date_from_bigint_s`, мы рассмотрим в следующем разделе. Сейчас сосредоточим внимание на разборе `varint`.
Перепишем приведенный в упомянутой статье алгоритм для получения числа из формата `varint` на язык MSSQL:
``` CREATE FUNCTION get_varint( @content AS VARBINARY(max) ) RETURNS BIGINT WITH EXECUTE AS CALLER AS BEGIN DECLARE @index AS INT; DECLARE @offset AS INT; DECLARE @value AS BIGINT; DECLARE @chunk AS BIGINT; SET @index = 0; SET @offset = 0; SET @value = 0; SET @chunk = 0; WHILE (1 = 1) BEGIN SET @index += 1; SET @chunk = SUBSTRING(@content, @index, 1); SET @value |= LEFT_SHIFT(@chunk & 0x7f, @offset); SET @offset += 7; IF NOT ((@chunk & 0x80) = 0x80) BREAK; END RETURN @value; END; GO ```
Также создадим вспомогательную функцию, которая позволит нам пропустить набор байт, входящих в varint, не получая при этом его числового значения:
``` CREATE FUNCTION get_content_after_varint( @content AS VARBINARY(max) ) RETURNS VARBINARY(max) WITH EXECUTE AS CALLER AS BEGIN DECLARE @index AS INT; DECLARE @offset AS INT; DECLARE @chunk AS BIGINT; SET @index = 0; SET @chunk = 0; WHILE (1 = 1) BEGIN SET @index += 1; SET @chunk = SUBSTRING(@content, @index, 1); IF NOT ((@chunk & 0x80) = 0x80) BREAK; END RETURN SUBSTRING(@content, @index + 1, DATALENGTH(@content)); END; GO ```
Таким образом, мы можем получать байты даты из свойства `_Content`. Следующим шагом преобразуем нашу дату из числа в тип `DATETIME` MSSQL.
Дата в свойстве `_Content` хранится в виде единицы измерения времени, единица которой равна 10-4 секунд. Т. е. для получения секунд нам необходимо разделить полученное из `_Content` число на 10 000.
Поскольку количество секунд отмеряется от «пустой даты» 1С, т. е. от 01.01.0001, то нам придется несколько схитрить и сначала прибавить полученное количество секунд к 01.01.1900, а затем вычесть 1899 лет, чтобы получить дату от «пустой даты» 1С.
Но здесь мы столкнемся с еще одной проблемой: функция `DATEADD` принимает на вход аргумент `INT`, а не `BIGINT`. При попытке преобразовать количество секунд, которое не помещается в INT, мы получим переполнение и скрипт не выполнится. В связи с этим мы разделим секунды на количество суток, поделив на 86400 (количество секунд в сутках) с округлением вниз, и на остаток секунд в последних сутках, взяв остаток от деления секунд на 86400.
Итоговая функция для получения даты из секунд, прошедших с «пустой даты» 1С, выглядит следующим образом:
``` CREATE FUNCTION get_date_from_bigint_s( @dt_s AS BIGINT ) RETURNS DATETIME WITH EXECUTE AS CALLER AS BEGIN RETURN DATEADD(YEAR, -1899, DATEADD(SECOND, @dt_s % 86400, DATEADD(DAY, @dt_s / 86400, '19000101'))) END; GO ```
На этом этапе у нас готовы все основные функции, которые нам необходимы.
В рамках задачи мы будем удалять записи, срок хранения которых вышел. При этом в ходе предварительного анализа было выяснено, что в базе нет записей, хранящихся дольше года, поэтому нет необходимости обрабатывать те объекты, срок хранения которых 12 месяцев.
Скрипт для выбора записей, которые были созданы год и более назад, представлен ниже:
``` SELECT COUNT(*) FROM [dbo].[_DataHistoryQueue0] WHERE dbo.get_date_from_content([_Content]) <= '20230731' GO ```
Для того, чтобы выбрать `_MetadataId`, которые нам не нужно обрабатывать, придется воспользоваться механизмом трассировки запросов SQL. Не прибегая к трассировке, однозначную связь между `_MetadataId` и именем объекта метаданных можно установить только через разбор данных, содержащихся в столбце `_Content` таблицы `Config`, в которой находится конфигурация информационной базы, что крайне сложно сделать, поскольку данные закодированы в формате ZIP.
Профайлер мы будем запускать из SQL Server Management Studio по кнопке меню Сервис -> SQL Server Приложение Profiler. В открывшемся окне профайлера необходимо будет установить соединение с SQL сервером, а затем на вкладке “Events Selection” необходимо убрать все флажки и оставить только флажок на событии TSQL -> Exec Prepared SQL. Затем необходимо установить фильтр по названию базы данных по колонке `DatabaseName`.
Открывая в 1С по очереди историю изменений каждого объекта, который должен храниться в базе дольше 3 месяцев, и записывая идентификатор метаданных, получим список `_MetadataId`, которые необходимо исключить из удаления.
В итоге наш скрипт для удаления записей из таблицы-очереди будет выглядеть следующим образом:
``` DELETE FROM [dbo].[_DataHistoryQueue0] WHERE [_MetadataId] NOT IN ( 0x8A75BB6E3004590F45A06B581CEEF4A2, 0xBE4FE18B73CE726B4509F392B1DFE632, 0x8169B63ABBFD04EE4A4444917D549218, 0xBA20A37C9742EC794B670349C97305DD, 0xABB1DCE1CCA4EBD34CC879957B04EBC4, 0x90979FE67516B81D4522F56D69EC59C6, 0x873AA9FAFA7853994C34E4BE318716F3, 0x87D1CB8D023DD564481FFB5110CEFB2F, 0x816065A0C64D131848D9CC82319B18A1, 0x8D2F98E82AAE11944AFB72537B86CF07, 0xBCAE1B918895B132476FAB97DC549688, 0xBCC8B09974E023B34910004572B83C8F, 0xB337CDDDD2968BEA42B6E36C050B09A5, 0xBC0986B5B5A4A65B43EDD06059AFD471, 0x984A2A2DA8517F384FA3464C5E0D9DD9, 0xA3307B6F835E2DA7413F86B3E16B931A, 0xA64BCCACFCABC3FB4D7079A3EAE285FE, 0x85F9086B89E1764B4965ED8B8EAC9A31, 0x9C09C3A3CFCCF41446A93DC280B2615F ) AND dbo.get_date_from_content([_Content]) < '20240431' GO ```
Последняя проблема – это переполнение лога транзакций при попытке удаления больше 7 миллионов строк одним запросом. Чтобы решить эту проблему, разобьем операцию удаления с помощью оператора `TOP`. Поместив запрос удаления в цикл, получим парциальное удаление данных, которое позволит избежать чрезмерного разрастания лога транзакций.
Поскольку при использовании запроса на удаление в цикле мы будем вынуждены каждый раз по новой запускать отбор по дате, который занимает значительное время, то в целях экономии времени имеет смысл создать временную таблицу, куда мы поместим записи, которые затем будем удалять.
В создании такой таблицы нам поможет знание того, что у `_DataHistoryQueue0` 3 ключевые колонки: `_MetadataId`, `_DataId` и `_Position`. Временную таблицу мы можем создать следующей командой:
``` CREATE TABLE dbo._DataHistoryQueue0_TmpForDeletion ( _MetadataId binary(16) NOT NULL, _DataId binary(20) NOT NULL, _Position numeric(9, 0) NOT NULL, CONSTRAINT PK_Queue PRIMARY KEY (_MetadataId, _DataId, _Position) ) GO ```
Переписав полученный в предыдущем разделе скрипт, получим:
``` -- Insert items in table for deletion. INSERT INTO dbo._DataHistoryQueue0_TmpForDeletion (_MetadataId, _DataId, _Position) SELECT _MetadataId, _DataId, _Position FROM [dbo].[_DataHistoryQueue0] WHERE [_MetadataId] NOT IN ( 0x8A75BB6E3004590F45A06B581CEEF4A2, 0xBE4FE18B73CE726B4509F392B1DFE632, 0x8169B63ABBFD04EE4A4444917D549218, 0xBA20A37C9742EC794B670349C97305DD, 0xABB1DCE1CCA4EBD34CC879957B04EBC4, 0x90979FE67516B81D4522F56D69EC59C6, 0x873AA9FAFA7853994C34E4BE318716F3, 0x87D1CB8D023DD564481FFB5110CEFB2F, 0x816065A0C64D131848D9CC82319B18A1, 0x8D2F98E82AAE11944AFB72537B86CF07, 0xBCAE1B918895B132476FAB97DC549688, 0xBCC8B09974E023B34910004572B83C8F, 0xB337CDDDD2968BEA42B6E36C050B09A5, 0xBC0986B5B5A4A65B43EDD06059AFD471, 0x984A2A2DA8517F384FA3464C5E0D9DD9, 0xA3307B6F835E2DA7413F86B3E16B931A, 0xA64BCCACFCABC3FB4D7079A3EAE285FE, 0x85F9086B89E1764B4965ED8B8EAC9A31, 0x9C09C3A3CFCCF41446A93DC280B2615F ) AND dbo.get_date_from_content([_Content]) < '20240431' GO -- Delete items. WHILE 1 = 1 BEGIN DELETE TOP (100000) dhq FROM dbo._DataHistoryQueue0 AS dhq INNER JOIN dbo._DataHistoryQueue0_TmpForDeletion AS tmp ON dhq._MetadataId = tmp._MetadataId AND dhq._DataId = tmp._DataId AND dhq._Position = tmp._Position IF @@rowcount < 100000 BREAK; END GO ```
Нам осталось лишь добавить несколько качественных улучшений: удаление создаваемых функций и временной таблицы, а также подсчет попавших во временную таблицу записей, чтобы легче было узнать, сколько строк удалено из таблицы-очереди. Итоговая версия скрипта представлена в Приложении 1.
В результате применения данного скрипта удалось очистить из таблицы `_DataHistoryQueue0` 2 из 12 миллионов строк. Размер таблицы сократился с 61 Гб до 55 Гб.
``` IF object_id('dbo._DataHistoryQueue0_TmpForDeletion', 'U') IS NOT NULL BEGIN DROP TABLE dbo._DataHistoryQueue0_TmpForDeletion END IF object_id('dbo.get_varint', 'FN') IS NOT NULL BEGIN DROP FUNCTION [dbo].get_varint END IF object_id('dbo.get_content_after_varint', 'FN') IS NOT NULL BEGIN DROP FUNCTION [dbo].get_content_after_varint END IF object_id('dbo.get_date_from_bigint_s', 'FN') IS NOT NULL BEGIN DROP FUNCTION [dbo].get_date_from_bigint_s END IF object_id('dbo.get_date_from_content', 'FN') IS NOT NULL BEGIN DROP FUNCTION [dbo].get_date_from_content END GO CREATE FUNCTION get_varint( @content AS VARBINARY(max) ) RETURNS BIGINT WITH EXECUTE AS CALLER AS BEGIN DECLARE @index AS INT; DECLARE @offset AS INT; DECLARE @value AS BIGINT; DECLARE @chunk AS BIGINT; SET @index = 0; SET @offset = 0; SET @value = 0; SET @chunk = 0; WHILE (1 = 1) BEGIN SET @index += 1; SET @chunk = SUBSTRING(@content, @index, 1); SET @value |= LEFT_SHIFT(@chunk & 0x7f, @offset); SET @offset += 7; IF NOT ((@chunk & 0x80) = 0x80) BREAK; END RETURN @value; END; GO CREATE FUNCTION get_content_after_varint( @content AS VARBINARY(max) ) RETURNS VARBINARY(max) WITH EXECUTE AS CALLER AS BEGIN DECLARE @index AS INT; DECLARE @offset AS INT; DECLARE @chunk AS BIGINT; SET @index = 0; SET @chunk = 0; WHILE (1 = 1) BEGIN SET @index += 1; SET @chunk = SUBSTRING(@content, @index, 1); IF NOT ((@chunk & 0x80) = 0x80) BREAK; END RETURN SUBSTRING(@content, @index + 1, DATALENGTH(@content)); END; GO CREATE FUNCTION get_date_from_bigint_s( @dt_s AS BIGINT ) RETURNS DATETIME WITH EXECUTE AS CALLER AS BEGIN RETURN DATEADD(YEAR, -1899, DATEADD(SECOND, @dt_s % 86400, DATEADD(DAY, @dt_s / 86400, '19000101'))) END; GO CREATE FUNCTION get_date_from_content ( @content AS VARBINARY(max) ) RETURNS DATETIME WITH EXECUTE AS CALLER AS BEGIN DECLARE @type AS VARBINARY(1) DECLARE @length_bin AS VARBINARY(max) DECLARE @length AS BIGINT DECLARE @seconds AS BIGINT SET @content = SUBSTRING(@content, 2, DATALENGTH(@content)) SET @content = dbo.get_content_after_varint(@content) WHILE (DATALENGTH(@content) > 0) BEGIN SET @type = SUBSTRING(@content, 1, 1) SET @length_bin = SUBSTRING(@content, 2, DATALENGTH(@content)) SET @length = dbo.get_varint(@length_bin) IF (@type = 0x30) BEGIN SET @seconds = @length / 10000 RETURN dbo.get_date_from_bigint_s(@seconds) END SET @content = dbo.get_content_after_varint(@length_bin) SET @content = SUBSTRING(@content, 1 + @length, DATALENGTH(@content)) END RETURN NULL; END; GO CREATE TABLE dbo._DataHistoryQueue0_TmpForDeletion ( _MetadataId binary(16) NOT NULL, _DataId binary(20) NOT NULL, _Position numeric(9, 0) NOT NULL, CONSTRAINT PK_Queue PRIMARY KEY (_MetadataId, _DataId, _Position) ) GO -- Insert items in table for deletion. INSERT INTO dbo._DataHistoryQueue0_TmpForDeletion (_MetadataId, _DataId, _Position) SELECT _MetadataId, _DataId, _Position FROM [dbo].[_DataHistoryQueue0] WHERE [_MetadataId] NOT IN ( 0x8A75BB6E3004590F45A06B581CEEF4A2, 0xBE4FE18B73CE726B4509F392B1DFE632, 0x8169B63ABBFD04EE4A4444917D549218, 0xBA20A37C9742EC794B670349C97305DD, 0xABB1DCE1CCA4EBD34CC879957B04EBC4, 0x90979FE67516B81D4522F56D69EC59C6, 0x873AA9FAFA7853994C34E4BE318716F3, 0x87D1CB8D023DD564481FFB5110CEFB2F, 0x816065A0C64D131848D9CC82319B18A1, 0x8D2F98E82AAE11944AFB72537B86CF07, 0xBCAE1B918895B132476FAB97DC549688, 0xBCC8B09974E023B34910004572B83C8F, 0xB337CDDDD2968BEA42B6E36C050B09A5, 0xBC0986B5B5A4A65B43EDD06059AFD471, 0x984A2A2DA8517F384FA3464C5E0D9DD9, 0xA3307B6F835E2DA7413F86B3E16B931A, 0xA64BCCACFCABC3FB4D7079A3EAE285FE, 0x85F9086B89E1764B4965ED8B8EAC9A31, 0x9C09C3A3CFCCF41446A93DC280B2615F ) AND dbo.get_date_from_content([_Content]) < '20240424' GO -- Delete items. WHILE 1 = 1 BEGIN DELETE TOP (100000) dhq FROM dbo._DataHistoryQueue0 AS dhq INNER JOIN dbo._DataHistoryQueue0_TmpForDeletion AS tmp ON dhq._MetadataId = tmp._MetadataId AND dhq._DataId = tmp._DataId AND dhq._Position = tmp._Position IF @@rowcount < 100000 BREAK; END GO SELECT Count(*) FROM dbo._DataHistoryQueue0_TmpForDeletion GO DROP TABLE dbo._DataHistoryQueue0_TmpForDeletion DROP FUNCTION dbo.get_varint DROP FUNCTION dbo.get_content_after_varint DROP FUNCTION dbo.get_date_from_bigint_s DROP FUNCTION dbo.get_date_from_content ```
Похожие статьи