Məzmuna keç
Tez Base

Уникальный индекс в PostgreSQL пропускает дубли с NULL: найти, посчитать, починить

· Dərc edilib:

PostgreSQL SQL Server и PostgreSQL

В таблице стоит UNIQUE, а одинаковые строки в ней лежат парами. Если хотя бы одна колонка ключа допускает NULL, так и должно быть: PostgreSQL по стандарту SQL считает два NULL разными значениями, и строка с пустым полем не совпадает ни с одной другой, даже со своей копией. Ошибки нет, в журнале пусто, ограничение на месте.

Ниже порядок работы, которым мы проверяем служебные таблицы вокруг базы 1С: буфер обмена, журнал вызовов веб-сервиса, таблицу состояний интеграции. Сначала найти все такие ключи, потом посчитать, сколько строк на самом деле под защитой, потом решить, что с ними делать, и в конце доказать, что ремонт сработал. Все запросы прогнаны на PostgreSQL 17.10, поведение MS SQL Server проверено на 2022-й версии.

Воспроизвести за минуту

CREATE TABLE integration_event (
  id        bigserial PRIMARY KEY,
  entity_id uuid NOT NULL,
  state     text NOT NULL,
  kind      text,                       -- на ранних состояниях не заполняется
  UNIQUE (entity_id, state, kind)
);

INSERT INTO integration_event (entity_id, state, kind)
VALUES ('11111111-1111-1111-1111-111111111111', 'CREATED', NULL);
INSERT INTO integration_event (entity_id, state, kind)
VALUES ('11111111-1111-1111-1111-111111111111', 'CREATED', NULL);

SELECT count(*) FROM integration_event;  -- 2

Обе вставки проходят. Тот же ключ на MS SQL Server ведёт себя наоборот:

CREATE TABLE #t (a int NOT NULL, b int NULL, UNIQUE (a, b));
INSERT INTO #t (a, b) VALUES (1, NULL);
INSERT INTO #t (a, b) VALUES (1, NULL);  -- ошибка 2627, нарушение UNIQUE KEY
СУБДДве строки (1, NULL) под UNIQUE (a, b)
PostgreSQL до 15обе ложатся, переключить нельзя
PostgreSQL 15 и старшеобе ложатся, пока не объявлено NULLS NOT DISTINCT
MS SQL Serverвторая падает с ошибкой 2627

Из таблицы следует главное для тех, кто переезжает. Ключ, который годами держал дубли на MS SQL, после переноса на PostgreSQL создаётся без единого предупреждения и перестаёт защищать строки с пустым полем. Отчёт переноса чистый, схема та же.

Шаг 1. Найти все такие ключи одним запросом

Признак дыры простой: уникальный индекс, в ключе которого есть колонка без NOT NULL. Ищем по pg_index, а не по pg_constraint. Ограничение UNIQUE всегда опирается на индекс, поэтому в pg_index видно и объявленные ограничения, и индексы, созданные напрямую через CREATE UNIQUE INDEX. Имя ограничения, если оно есть, подтягиваем отдельным соединением.

Вариант для PostgreSQL 15 и старше:

SELECT n.nspname                                   AS shema,
       c.relname                                   AS tablitsa,
       i.relname                                   AS indeks,
       coalesce(con.conname, '-')                  AS ogranichenie,
       string_agg(a.attname, ', ' ORDER BY k.pos)  AS kolonki_bez_not_null,
       pg_get_expr(ix.indpred, ix.indrelid)        AS uslovie_indeksa
FROM pg_index ix
JOIN pg_class      i ON i.oid = ix.indexrelid
JOIN pg_class      c ON c.oid = ix.indrelid
JOIN pg_namespace  n ON n.oid = c.relnamespace
CROSS JOIN LATERAL unnest(ix.indkey::int2[]) WITH ORDINALITY AS k(attnum, pos)
JOIN pg_attribute  a ON a.attrelid = ix.indrelid AND a.attnum = k.attnum
LEFT JOIN pg_constraint con ON con.conindid = ix.indexrelid AND con.contype = 'u'
WHERE ix.indisunique
  AND NOT ix.indisprimary
  AND NOT ix.indnullsnotdistinct        -- поведение уже переключено, дыры нет
  AND k.pos <= ix.indnkeyatts           -- колонки из INCLUDE в ключ не входят
  AND NOT a.attnotnull
  AND n.nspname NOT IN ('pg_catalog', 'information_schema')
  AND n.nspname NOT LIKE 'pg_toast%'
GROUP BY n.nspname, c.relname, i.relname, con.conname, ix.indpred, ix.indrelid
ORDER BY 1, 2, 3;

На PostgreSQL 11-14 колонки indnullsnotdistinct ещё нет, запрос упадёт на ней. Уберите строку с этим условием, остальное работает без изменений: indnkeyatts появилась в 11-й вместе с INCLUDE.

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

Почему не два простых запроса

Первая версия проверки, которую мы готовили к публикации, была из двух запросов: по pg_constraint с contype = 'u' и по pg_index с отсевом частичных индексов. На тестовой схеме из семи таблиц она ошиблась пятью разными способами, и каждый из них встретится и у вас.

Что было в запросеЧто вышло на прогонеКак исправлено
нет фильтра по pg_toastна пустом кластере 88 лишних строк про служебные индексы chunk_id и chunk_seqnspname NOT LIKE 'pg_toast%'
attnum = ANY (indkey) берёт все колонки индексаколонка из INCLUDE (note) названа дырой, хотя в уникальность не входитпорядковый номер колонки не больше indnkeyatts
запрос по pg_constraint не знает про NULLS NOT DISTINCTограничение, где поведение уже переключено, попало в списокфлаг indnullsnotdistinct из pg_index
частичные индексы отсеяны целикоминдекс с условием WHERE active пропал из списка, а дыра у него та жеусловие выводится колонкой, решение принимает человек
ограничение и его индекс в двух запросаходна дыра выдана дважды под одним именемодин проход по pg_index

Два ложных срабатывания запрос оставляет, и их надо знать в лицо. Вычисляемая колонка вида coalesce(kind, '__NULL__') формально допускает NULL, хотя пустой не бывает никогда: такой индекс попадёт в список, и его можно вычеркнуть. Индекс по выражению, например (a, lower(b)), наоборот, выражение не покажет: в indkey у него ноль, и соединение с pg_attribute его теряет. Таких индексов в служебных таблицах обычно единицы, их проще проверить глазами по \d имя_таблицы.

Частичный индекс с условием поле IS NOT NULL в выдаче останется, и это правильно. Если рядом нет второго индекса на поле IS NULL, строки с пустым полем не защищены вовсе. Об этом в шаге 4.

Шаг 2. Посчитать долю строк без защиты

Список ключей не говорит, насколько всё плохо. Ключ, у которого пустое поле встречается в двух строках из тысячи, подождёт. Ключ, где пустых больше половины, почти ничего не защищает. Для каждой найденной колонки:

SELECT count(*)                                                        AS vsego,
       count(*) FILTER (WHERE kind IS NULL)                            AS bez_zashchity,
       round(100.0 * count(*) FILTER (WHERE kind IS NULL)
             / nullif(count(*), 0), 1)                                 AS dolya_pct
FROM integration_event;

Если необязательных колонок в ключе несколько, в FILTER пишется kind IS NULL OR другая_колонка IS NULL: одного пустого поля хватает, чтобы строка выпала из-под проверки.

Для масштаба наш случай. Служебная таблица истории внешних операций, ключ из трёх полей, третье заполняется только на поздних состояниях. За 66 суток в неё пришло около 2,2 тысячи событий, примерно 33 в сутки. Пустой признак стоял у 79 % строк. Под защитой было около 460 строк, мимо неё около 1 740. Нашёл это финальный тест миграции, который прогнал те же события второй раз и ждал ноль новых строк. Ваша доля будет другой, она целиком зависит от того, как заполняется конкретное поле, поэтому переносить к себе стоит запрос, а не цифру.

Накопленные дубли ищутся группировкой по тому же ключу:

SELECT entity_id, state, kind, count(*) AS strok
FROM integration_event
GROUP BY entity_id, state, kind
HAVING count(*) > 1;

Здесь работает то же расхождение, из-за которого дефект живёт годами. GROUP BY складывает пустые значения в одну группу, уникальный индекс их различает. Одна и та же база на вопрос “есть ли дубли” отвечает двумя способами, и группировка честнее.

Шаг 3. Решить, одна это сущность или две

Перед ремонтом стоит ответить на вопрос про смысл данных: две строки с одинаковым ключом и пустым полем описывают одно и то же или разное?

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

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

Шаг 4. Починить по версии СУБД

PostgreSQL 15 и старше

Сначала убрать то, что уже накопилось, иначе новый индекс не создастся: could not create unique index ... Key (...)=(..., null) is duplicated. Удаление оставляет в каждой группе строку с наименьшим id, пустые значения сравниваются через IS NOT DISTINCT FROM:

DELETE FROM integration_event e
USING integration_event d
WHERE e.entity_id = d.entity_id
  AND e.state     = d.state
  AND e.kind IS NOT DISTINCT FROM d.kind
  AND e.id > d.id;

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

Потом построить новый индекс без блокировки записи и подменить им старое ограничение:

CREATE UNIQUE INDEX CONCURRENTLY integration_event_uq_new
  ON integration_event (entity_id, state, kind) NULLS NOT DISTINCT;

ALTER TABLE integration_event
  DROP CONSTRAINT integration_event_entity_id_state_kind_key,
  ADD CONSTRAINT integration_event_uq UNIQUE USING INDEX integration_event_uq_new;

USING INDEX превращает готовый индекс в ограничение и переименовывает его, PostgreSQL сообщит об этом строкой NOTICE. Флаг NULLS NOT DISTINCT при этом сохраняется, \d integration_event покажет UNIQUE CONSTRAINT ... NULLS NOT DISTINCT. Для новой таблицы то же пишется прямо в объявлении: UNIQUE NULLS NOT DISTINCT (entity_id, state, kind).

CREATE INDEX CONCURRENTLY нельзя выполнять внутри транзакции. Если построение упало, остаётся невалидный индекс, его надо удалить и построить заново.

PostgreSQL 11-14

Настройки нет, остаётся два обхода, и у обоих своя цена.

Два частичных индекса, один на заполненное поле и один на пустое:

CREATE UNIQUE INDEX integration_event_uq_kind
  ON integration_event (entity_id, state, kind)
  WHERE kind IS NOT NULL;

CREATE UNIQUE INDEX integration_event_uq_no_kind
  ON integration_event (entity_id, state)
  WHERE kind IS NULL;

Частая ошибка здесь в том, что ставят только первый. Он защищает ровно те строки, которые и так были защищены, а пустые остаются как были. Второй минус этого обхода виден на ключе с несколькими необязательными колонками: на каждую комбинацию пустых полей нужен свой индекс, две колонки дают четыре индекса, три колонки восемь.

Вычисляемая колонка с подставным значением:

ALTER TABLE integration_event
  ADD COLUMN kind_key text
  GENERATED ALWAYS AS (coalesce(kind, '__NULL__')) STORED;

CREATE UNIQUE INDEX ON integration_event (entity_id, state, kind_key);

Проверено: вторая вставка (1, NULL) падает на Key (a, kind_key)=(1, __NULL__) already exists. Риск в том, что подставное значение живёт в одном домене с настоящими. Если внешняя система однажды пришлёт признак с текстом __NULL__, две разные строки станут одинаковыми. Берите значение, которого во входных данных не бывает физически, а для чисел значение вне допустимого диапазона, закреплённое CHECK. GENERATED ... STORED появился в PostgreSQL 12, на 11-й остаются только частичные индексы.

Оба обхода годятся как временная мера и хорошо работают как аргумент за обновление: защита от дублей на старой версии стоит двух индексов на каждое необязательное поле.

Шаг 5. Доказать, что защита работает

Проверка “повторный прогон прошёл без ошибок” здесь бесполезна: дубли записываются тихо, ошибок не бывает ни до ремонта, ни после. Проверять надо число новых строк, и оно обязано быть нулём.

Самый короткий способ без обработчика: вставить таблицу саму в себя внутри транзакции и откатить.

BEGIN;
CREATE TEMP TABLE do_povtora ON COMMIT DROP AS
  SELECT count(*) AS n FROM integration_event;

INSERT INTO integration_event (entity_id, state, kind)
SELECT entity_id, state, kind FROM integration_event
ON CONFLICT DO NOTHING;

SELECT (SELECT count(*) FROM integration_event)
     - (SELECT n FROM do_povtora) AS novyh_strok;
ROLLBACK;

Каждая строка, которую ограничение пропустило, ляжет второй раз, и novyh_strok покажет их число. На тестовой таблице из 100 строк, где признак заполнен у каждой пятой, до ремонта вышло 80, после ремонта 0. Идентификатор в выборку не берём, он придёт из последовательности. Последовательность после отката не возвращается, в id останется разрыв. Триггеры на вставку при такой проверке сработают, поэтому гоняйте её на копии.

Копия нужна свежая. Доля пустых значений на тестовой базе, поднятой полгода назад, к сегодняшнему проду отношения не имеет, а синтетические данные дают ноль процентов: автор теста заполняет все поля. Поднимать тест из последнего бэкапа ночным заданием умеет тестовая база из бэкапа рабочей, она печатает скрипт восстановления и расписание и сама к СУБД не подключается.

Если обработчик у вас есть, проверка ещё проще: прогнать его второй раз на тех же данных и сравнить число строк до и после. В регламенте переезда это одна строка, “повторный прогон создаёт ноль новых записей”, и она ловит весь этот класс дефектов.

Где это всплывает рядом с 1С

Таблицы самой платформы мы не трогаем. Зато всё, что заведено рядом с базой, наше целиком: промежуточные таблицы обмена, буферы интеграций, журналы вызовов веб-сервисов, очереди фоновых заданий. Уникальность там держит только схема, и именно там она чаще всего стоит на ключе с необязательным полем.

Сама семантика программисту 1С знакома по языку запросов: сравнение с NULL через равенство не срабатывает, нужен ЕСТЬ NULL. В результате запроса пустое значение видно глазами и его идут чинить. В ограничении уникальности его не видит никто.

Если служебная таблица подключена к конфигурации как внешний источник данных, долю пустых можно посчитать и запросом 1С, без доступа к серверу СУБД:

Запрос = Новый Запрос;
Запрос.Текст =
"ВЫБРАТЬ
|	КОЛИЧЕСТВО(*) КАК Всего,
|	ЕСТЬNULL(СУММА(ВЫБОР
|				КОГДА События.Признак ЕСТЬ NULL
|					ТОГДА 1
|				ИНАЧЕ 0
|			КОНЕЦ), 0) КАК БезЗащиты
|ИЗ
|	ВнешнийИсточникДанных.Интеграция.Таблица.СобытияИнтеграции КАК События";
Итог = Запрос.Выполнить().Выгрузить();

Имена источника, таблицы и полей условные. Сам ключ уникальности конфигурация не знает: ключевые поля внешней таблицы платформа использует для своих нужд и ограничений в СУБД не создаёт. Поэтому список ключей всё равно берётся запросом из шага 1.

Самый частый путь к этой дыре - переезд. Базу 1С перед переносом на PostgreSQL проверяет обработка “Проверка базы перед миграцией на PostgreSQL”: она смотрит, какие уникальные индексы схлопнут строки на невидимых символах, длины строковых измерений и другие места, где новая СУБД поведёт себя иначе. Пустые значения в ключах служебных таблиц она не ищет, это как раз шаг 1 отсюда, и в чек-лист переезда его стоит вписать отдельной строкой. Что ещё ломается на этом пути, разобрано в статье о миграции 1С на PostgreSQL. В обратную сторону свой набор расхождений, для него есть проверка перед переносом на MS SQL, а настройки самого сервера баз данных смотрит чек-ап СУБД.

Аудит служебных таблиц и репетицию переезда на копии мы делаем в рамках обслуживания баз 1С.

Прогоните запрос из шага 1 у себя и напишите, сколько уникальных ключей с необязательными колонками нашлось и какая доля пустых получилась в худшем из них. Нам интересно, 79 % это выброс или обычная картина для таблиц вокруг учётной системы.

Bunu sizin əvəzinizə həll edə bilərəm

Reqlament xidmətini elə qururuq ki, baza stabil işləsin, ehtiyat nüsxələr zəmanətlə bərpa olunsun, disklərdəki yer isə qəfil bitməsin.

Qiyməti
450 000 ₸-dənbirdəfəlik sazlama; müşayiət - ayda 150 000 ₸-dən; vergilərsiz

Yazmaq tezdir? Özünüz ölçün

Переезд на PostgreSQL падает беззвучно

Emalı aç

INFOSTART TECH EVENT 2026: конференция для 1с-специалистов. если собираетесь, регистрируйтесь по нашей ссылке. Регистрация →

Bunu da oxuyun

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

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

Регламентное задание 1С не выполняется: порядок проверки и скрипт для консоли

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

Перенос базы 1С с PostgreSQL на MS SQL: смещение дат, индексы и приёмка

Справочник к переезду базы 1С с PostgreSQL на MS SQL Server через ibcmd infobase replicate: какое смещение дат ставить и как узнать текущее, какие ключи индексов шире 900 байт создадутся молча и при чём тут версия SQL Server, чем принимать перенос вместо подсчёта строк. С регламентом по этапам и замерами на трёх базах.