Когда речь заходит об оптимизации базы данных, разработчики обычно перечисляют привычный набор приёмов: слегка переписать запрос, накинуть индекс на колонку, денормализовать, сделать analyze, vacuum, cluster, и так по кругу. Классические техники, конечно, работают, но иногда креативный подход даёт гораздо больше.

Избавляемся от полного сканирования таблицы с помощью check-ограничений
Представим, что у нас есть таблица пользователей:
db=# CREATE TABLE users ( id INT PRIMARY KEY, username TEXT NOT NULL, plan TEXT NOT NULL, CONSTRAINT plan_check CHECK (plan IN ('free', 'pro')) ); CREATE TABLE
Для каждого пользователя мы храним имя и тарифный план. Планов всего два — free и pro, поэтому добавим check-ограничение.
Сгенерируем данные и проанализируем таблицу:
db=# INSERT INTO users SELECT n, uuidv4(), (ARRAY['free', 'pro'])[ceil(random()*2)] FROM generate_series(1, 100_000) AS t(n); INSERT 0 100000 db=# ANALYZE users; ANALYZE
Теперь в системе 100 тысяч пользователей.
Честные ошибки
Мы выдали доступ к этой таблице аналитику, чтобы он обращался к ней из своего любимого инструмента для сбора отчётности. Вот первый запрос, который он отправил:
db=# SELECT * FROM users WHERE plan = 'Pro'; id │ username │ plan ────┼──────────┼────── (0 rows)
Запрос ничего не вернул, аналитик в недоумении. Почему в системе нет пользователей на плане "Pro"?
Потому что план называется pro, а не Pro (с заглавной P). Обычная человеческая ошибка, такое может случиться с кем угодно. Но сколько она стоит?
Посмотрим план выполнения для запроса с несуществующим значением:
db=# EXPLAIN ANALYZE SELECT * FROM users WHERE plan = 'Pro'; QUERY PLAN ────────────────────────────────────────────────────────────────── Seq Scan on users (cost=0.00..2185.00 rows=1 width=45) (actual time=7.406..7.407 rows=0.00 loops=1) Filter: (plan = 'Pro'::text) Rows Removed by Filter: 100000 Buffers: shared hit=935 Planning: Buffers: shared hit=29 read=2 Planning Time: 4.564 ms Execution Time: 7.436 ms
PostgreSQL просканировал всю таблицу, хотя в поле есть check-ограничение: ни одна строка не может иметь значение Pro, и БД это гарантирует. Если условие заведомо ложно, зачем сканировать таблицу?
Используем constraint_exclusion
PostgreSQL может сообразить, что таблицу не надо сканировать, если в запросе есть условие, которое всегда ложно. Но по умолчанию он пропускает сканирование не всегда. Чтобы при построении плана учитывались ограничения, нужно выставить параметр constraint_exclusion:
db=# SET constraint_exclusion to 'on'; SET db=# EXPLAIN ANALYZE SELECT * FROM users WHERE plan = 'Pro'; QUERY PLAN ─────────────────────────────────────────────────────────────────────────────────────── Result (cost=0.00..0.00 rows=0 width=0) (actual time=0.000..0.001 rows=0.00 loops=1) One-Time Filter: false Planning: Buffers: shared hit=5 read=4 Planning Time: 5.760 ms Execution Time: 0.008 ms (6 rows)
Отлично. После включения constraint_exclusion PostgreSQL по check-ограничению понял, что строк не будет, и полностью убрал сканирование.
Когда уместен параметр constraint_exclusion
Что же такое constraint_exclusion и почему он не включен по умолчанию?
Сейчас constraint_exclusion по умолчанию для включён только для таблиц, секционированных с использованием наследования. Если включить его для всех таблиц, накладные расходы на планирование станут заметными, особенно для простых запросов, и чаще всего это не даст выигрыша.
По умолчанию параметру constraint_exclusion присвоено значение partition. С такой настройкой при обращении к секционированным таблицам целиком отбрасываются ненужные секции. Это называется partition pruning.
В документации сказано, что для простых запросов стоимость проверки всех ограничений может перевесить выигрыш. В итоге на планирование можно потратить больше времени, чем на само выполнение. Логично, что системные запросы реже запрашивают некорректные значения или противоречат ограничениям. Но для разовых ручных запросов из BI-инструментов это не так.
В BI- и отчётных системах пользователи часто пишут сложные запросы вручную. В такой среде ошибки, как у нашего аналитика, вполне нормальны. Поэтому в окружениях аналитики и хранилищ данных, где много разовых запросов, установка constraint_exclusion = on может реально экономить время и ресурсы за счёт исключения лишних полных сканирований таблиц.
Оптимизация под низкую кардинальность с индексом по функции
Предст��вьте таблицу продаж такого вида:
db=# CREATE TABLE sale ( id INT PRIMARY KEY, sold_at TIMESTAMPTZ NOT NULL, charged INT NOT NULL ); CREATE TABLE
Допустим, мы храним время продажи и сумму покупки. Создадим 10 миллионов продаж и проанализируем таблицу:
db=# INSERT INTO sale (id, sold_at, charged) SELECT n AS id, '2025-01-01 UTC'::timestamptz + (interval '5 seconds') n AS sold_at, ceil(random() 100) AS charged FROM generate_series(1, 10_000_000) AS t(n); INSERT 0 10000000 db=# ANALYZE sale; ANALYZE
Накидываем индекс B-Tree
Если ваши аналитики строят ежедневные отчёты по продажам, их запросы выглядят примерно так:
db=# EXPLAIN (ANALYZE ON, BUFFERS OFF, COSTS OFF) SELECT date_trunc('day', sold_at AT TIME ZONE 'UTC'), SUM(charged) FROM sale WHERE '2025-01-01 UTC' <= sold_at AND sold_at < '2025-02-01 UTC' GROUP BY 1; QUERY PLAN ────────────────────────────────────────────────────────────────────────────────── HashAggregate (actual time=626.074..626.310 rows=32.00 loops=1) Group Key: date_trunc('day'::text, sold_at) Batches: 1 Memory Usage: 2081kB -> Seq Scan on sale (actual time=6.428..578.135 rows=535679.00 loops=1) Filter: (('2025-01-01 02:00:00+02'::timestamp with time zone <= sold_at) AND (sold_at < '2025-02-01 02:00:00+02'::timestamp with time zone)) Rows Removed by Filter: 9464321 Planning Time: 0.115 ms Execution Time: 627.119 ms
PostgreSQL просканировал всю таблицу, и запрос выполнился примерно за 627 мс. Аналитики немного избаловались, и для них это долго. Вы делаете то, что обычно делают в таких случаях, — накидываете B-Tree-индекс:
db=# CREATE INDEX sale_sold_at_ix ON sale(sold_at); CREATE INDEX
Запускаем тот же запрос с индексом:
db=# EXPLAIN (ANALYZE ON, BUFFERS OFF, COSTS OFF) SELECT date_trunc('day', sold_at AT TIME ZONE 'UTC'), SUM(charged) FROM sale WHERE '2025-01-01 UTC' <= sold_at AND sold_at < '2025-02-01 UTC' GROUP BY 1; QUERY PLAN ─────────────────────────────────────────────────────────────────────────────────────────────────── HashAggregate (actual time=186.970..187.212 rows=32.00 loops=1) Group Key: date_trunc('day'::text, sold_at) Batches: 1 Memory Usage: 2081kB -> Index Scan using sale_sold_at_ix on sale (actual time=0.038..137.067 rows=535679.00 loops=1) Index Cond: ((sold_at >= '2025-01-01 02:00:00+02'::timestamp with time zone) AND (sold_at < '2025-02-01 02:00:00+02'::timestamp with time zone)) Index Searches: 1 Planning Time: 0.261 ms Execution Time: 187.363 ms
Время выполнения упало с ~627 мс до 187 мс, аналитики довольны. Но какой ценой?
db=# \di+ sale_sold_at_ix List of indexes ─[ RECORD 1 ]─┬──────────────── Schema │ public Name │ sale_sold_at_ix Type │ index Owner│ haki Table│ sale Persistence │ permanent Access method │ btree Size │ 214 MB Description │ ¤
Индекс весит 214 МБ. Это почти половина размера всей таблицы. Аналитики рады, а мы — не очень.
Создать индекс B-Tree по умолчанию привычно, но DBA и разработчики часто игнорируют цену хранения и стоимость обслуживания такого индекса. А ведь простыми мерами можно сэкономить место и деньги.
Смотрим на задачу по-новому
Сделаем шаг назад и посмотрим, что именно мы оптимизировали. Аналитикам нужен отчёт за день, а мы дали индекс, который обеспечивает точность до миллисекунд. Индексируя и дату, и время, мы дали гораздо больше, чем нас просили.
Что если индексировать только дату, без времени?
db=# CREATE INDEX sale_sold_at_date_ix ON sale((date_trunc('day', sold_at AT TIME ZONE 'UTC'))::date); CREATE INDEX
Так создаётся индекс по выражению. В качестве выражения здесь — дата продажи. Сначала задаём часовой пояс, потом обрезаем до даты, чтобы получилось так, как аналитики пишут в отчётах.
Сначала посмотрим размеры индексов:
db=# \di+ sale_sold_at_* List of indexes Name │ Table│ Access method │ Size ──────────────────────┼──────┼───────────────┼──────── sale_sold_at_date_ix │ sale │ btree │ 66 MB sale_sold_at_ix │ sale │ btree │ 214 MB
Индекс по выражению занимает всего 66 МБ, то есть в три с лишним раза меньше полного индекса. Да, date меньше timestamptz (4 байта против 8), но основная экономия не в этом. У такого индекса меньше различных значений, и PostgreSQL может сжать его с помощью дедупликации.
Чтобы проверить, как отработает более компактный индекс, сначала удалим полный:
db=# DROP INDEX sale_sold_at_ix; DROP INDEX
Немного поправим текст запроса, чтобы он смог использовать индекс по выражению (к этому ещё вернёмся):
db=# EXPLAIN (ANALYZE ON, BUFFERS OFF, COSTS OFF) SELECT date_trunc('day', sold_at AT TIME ZONE 'UTC'), SUM(charged) FROM sale WHERE date_trunc('day', sold_at AT TIME ZONE 'UTC')::date BETWEEN '2025-01-01' AND '2025-01-31' GROUP BY 1; QUERY PLAN ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── GroupAggregate (actual time=6.499..145.889 rows=31.00 loops=1) Group Key: date_trunc('day'::text, (sold_at AT TIME ZONE 'UTC'::text)) -> Index Scan using sale_sold_at_date_ix on sale (actual time=0.015..119.832 rows=535679.00 loops=1) Index Cond: ((date_trunc('day'::text, (sold_at AT TIME ZONE 'UTC'::text))::date >= '2025-01-01 00:00:00'::timestamp without time zone) AND (date_trunc('day'::text, (sold_at AT TIME ZONE 'UTC'::text))::date < '2025-02-01 00:00:00'::timestamp without time zone)) Index Searches: 1 Planning Time: 0.151 ms Execution Time: 145.913 ms
Индекс использовался, и запрос выполнился за 145 мс. Это примерно на 20 мс быстрее, чем с полным индексом, и в 4,5 раза быстрее, чем полное сканирование таблицы.
Проблема дисциплины
С индексом по функции есть тонкость: подход довольно хрупкий. Достаточно чуть-чуть поменять выражение, и база уже не сможет использовать индекс:
db=# EXPLAIN (ANALYZE OFF, COSTS OFF) SELECT (sold_at AT TIME ZONE 'UTC')::date, SUM(charged) FROM sale WHERE (sold_at AT TIME ZONE 'UTC')::date BETWEEN '2025-01-01' AND '2025-01-31' GROUP BY 1; QUERY PLAN ──────────────────────────────────────────────────────────────────────────────────── HashAggregate Group Key: ((sold_at AT TIME ZONE 'UTC'::text))::date -> Seq Scan on sale Filter: ((((sold_at AT TIME ZONE 'UTC'::text))::date >= '2025-01-01'::date) AND (((sold_at AT TIME ZONE 'UTC'::text))::date <= '2025-01-31'::date))
Запрос, по сути, тот же самый, но выражение изменили: вместо date_trunc использовали ::date. �� итоге база не смогла задействовать индекс по выражению.
Чтобы всегда использовать одно и то же выражение, нужна дисциплина, которой в реальных командах почти никогда не бывает. Наивно ждать, что это будет надёжно работать само по себе. Нужен способ принудительно использовать именно это выражение.
Раньше это решали с помощью представления:
db=# CREATE VIEW v_sale AS SELECT *, date_trunc('day', sold_at AT TIME ZONE 'UTC')::date AS sold_at_date FROM sale; CREATE VIEW
Представление добавляет вычисляемую колонку sold_at_date с тем же выражением, по которому мы определили индекс. Используя представление, мы гарантируем, что база сможет применить индекс:
db=# EXPLAIN (ANALYZE OFF, COSTS OFF) SELECT sold_at_date, SUM(charged) FROM v_sale WHERE sold_at_date BETWEEN '2025-01-01' AND '2025-01-31' GROUP BY 1; QUERY PLAN ────────────────────────────────────────────────────────────────────────────────────────────────────────────────── GroupAggregate Group Key: (date_trunc('day'::text, (sale.sold_at AT TIME ZONE 'UTC'::text)))::date -> Index Scan using sale_sold_at_date_ix on sale Index Cond: (((date_trunc('day'::text, (sold_at AT TIME ZONE 'UTC'::text)))::date >= '2025-01-01'::date) AND ((date_trunc('day'::text, (sold_at AT TIME ZONE 'UTC'::text)))::date <= '2025-01-31'::date))
Индекс применяется, и запрос быстрый, но...
Представление — рабочее решение, но у него та же проблема дисциплины: аналитики всё равно могут ходить напрямую в таблицу (и будут это делать). Можно отзывать права у таблицы или колдовать с search_path, подсовывая представление вместо таблицы, но есть путь проще.
Используем виртуальные генерируемые столбцы
Начиная с версии 14, PostgreSQL поддерживает генерируемые столбцы, которые автоматически заполняются выражением при вставке строки. Почти то, что нужно, но есть нюанс: результат выражения материализуется, то есть занимает дополнительное место. А мы как раз пытались это место экономить.
К счастью, с версии 18 появились виртуальные генерируемые столбцы. Они выглядят как обычные, но на деле их значение вычисляется при каждом обращении. По сути, то, чего мы пытались добиться через представление.
Сначала добавим в таблицу виртуальный генерируемый столбец с тем же выражением, которое индексировали:
db=# ALTER TABLE sale ADD sold_at_date DATE GENERATED ALWAYS AS (date_trunc('day', sold_at AT TIME ZONE 'UTC')); ALTER TABLE
Теперь выполним запрос через виртуальный генерируемый столбец:
db=# EXPLAIN (ANALYZE ON, COSTS OFF, BUFFERS OFF) SELECT sold_at_date, SUM(charged) FROM sale WHERE sold_at_date BETWEEN '2025-01-01' AND '2025-01-31' GROUP BY 1; QUERY PLAN ────────────────────────────────────────────────────────────────────────────────────────────────────────────────── GroupAggregate (actual time=7.047..162.965 rows=31.00 loops=1) Group Key: (date_trunc('day'::text, (sold_at AT TIME ZONE 'UTC'::text)))::date -> Index Scan using sale_sold_at_date_ix on sale (actual time=0.015..134.795 rows=535679.00 loops=1) Index Cond: (((date_trunc('day'::text, (sold_at AT TIME ZONE 'UTC'::text)))::date >= '2025-01-01'::date) AND ((date_trunc('day'::text, (sold_at AT TIME ZONE 'UTC'::text)))::date <= '2025-01-31'::date)) Index Searches: 1 Planning Time: 0.128 ms Execution Time: 162.989 ms
Используя виртуальный генерируемый столбец, мы можем быть уверены, что в запросе используется именно то выражение, которое мы индексировали. PostgreSQL в таком случае может применить индекс, и запрос работает быстро.
У этого подхода есть несколько плюсов:
Меньший индекс. Меньше различных значений, значит база может эффективнее применять дедупликацию и уменьшать индекс.
Более быстрый запрос, Узкий и точечный индекс требует меньше ресурсов, поэтому запрос отрабатывает быстрее.
Не требуется принуждение. Вычисляемую колонку использовать просто, а индекс гарантированно применим.
Полный порядок. Когда команда должна руками везде писать одно и то же выражение, ошибки и расхождения неизбежны, особенно с часовыми поясами. Виртуальная вычисляемая колонка убирает эту неопределённость.
Индексация виртуальных вычисляемых колонок
Следующий логичный шаг — создать индекс прямо на виртуальном столбце. К сожалению, на момент написания статьи PostgreSQL 18 не поддерживал такие индексы:
db=# CREATE INDEX sale_sold_at_date_ix ON sale(sold_at_date); ERROR: indexes on virtual generated columns are not supported
Будем надеяться, что индексы на виртуальных виртуальных генерируемых столбцах появятся в PostgreSQL 19.
Обеспечиваем уникальность через Hash-индекс
Представим систему, которая извлекает информацию из URL. Создадим такую таблицу:
CREATE TABLE urls ( id INT PRIMARY KEY, url TEXT NOT NULL, data JSON );
Добавим записи:
db=# INSERT INTO urls (id, url) SELECT n, 'https://' uuidv4() '.com/ ' uuidv4() '?p=' || uuidv4() FROM generate_series(1, 1_000_000) AS t(n); INSERT 0 1000000
Обработка веб-страниц может быть ресурсоёмкой, долгой и дорогой, поэтому важно гарантировать, что одна и та же страница не обрабатывается повторно.
Накидываем уникальный B-Tree по привычке
Чтобы URL не обрабатывались дважды, добавим ограничение уникальности на колонку url:
db=# CREATE UNIQUE INDEX urls_url_unique_ix ON urls(url);
Теперь один и тот же URL точно не обработается повторно:
db=# INSERT INTO urls(id, url) VALUES (1_000_001, 'https://hakibenita.com'); INSERT 0 1 db=# INSERT INTO urls(id, url) VALUES (1_000_002, 'https://hakibenita.com'); ERROR: duplicate key value violates unique constraint "urls_url_unique_ix" DETAIL: Key (url)=(https://hakibenita.com) already exists.
Уникальность ограничения обеспечивается уникальным индексом B-Tree. Заодно получаем приятный бонус: быстрый поиск конкретного URL.
db=# EXPLAIN (ANALYZE ON, BUFFERS OFF, COSTS OFF) SELECT * FROM urls WHERE url = 'https://hakibenita.com'; QUERY PLAN ────────────────────────────────────────────────────────────────────────────────────────── Index Scan using urls_url_unique_ix on urls (actual time=0.018..0.018 rows=1.00 loops=1) Index Cond: (url = 'https://hakibenita.com'::text) Index Searches: 1 Planning Time: 0.173 ms Execution Time: 0.046 ms
URL у страниц бывают очень длинными. Некоторые веб-приложения вообще кладут в URL почти всё состояние приложения. Для пользователей это удобно, но хранить такие URL слишком накладно.
Посмотрим размер таблицы и индекса B-Tree, который обеспечивает уникальность:
db=# \dt+ urls List of tables ─[ RECORD 1 ]─┬────────── Schema │ public Name │ urls Type │ table Owner │ haki Persistence │ permanent Access method │ heap Size │ 160 MB Description │ ¤ db=# \di+ urls_url_unique_ix List of indexes ─[ RECORD 1 ]─┬─────────────────── Schema │ public Name │ urls_url_unique_ix Type │ index Owner │ haki Table │ urls Persistence │ permanent Access method │ btree Size │ 154 MB Description │ ¤
Размер таблицы — 160 МБ, а индекс — аж 154 МБ.
Уникальный Hash-индекс
B-Tree хранит в листовых блоках сами индексируемые значения. Поэтому на больших значениях индекс B-Tree может разрастаться очень сильно.
В PostgreSQL есть другой тип индекса — Hash index (статья про Hash-индексы). Он хранит не сами значения, а их хеши, которые намного компактнее. На больших значениях с низкой повторяемостью Hash-индекс особенно хорош.
Звучит так, будто Hash-индекс отлично подходит для обеспечения уникальности. Попробуем создать уникальный Hash-индекс:
db=# CREATE UNIQUE INDEX urls_url_unique_hash ON urls USING HASH(url); ERROR: access method "hash" does not support unique indexes
Увы, PostgreSQL не поддерживает уникальные Hash-индексы. Но это не значит, что с его помощью нельзя обеспечить уникальность.
Как обеспечить уникальность с помощью Hash-индекса
В PostgreSQL есть специальный тип ограничений — ограничение-исключение (exclusion constraint). Это не самый известный и не самый популярный инструмент, который чаще вспоминают вместе с GIN/GiST как способ запрещать пересекающиеся диапазоны. Но с помощью exclusion-ограничений как раз и можно обеспечить уникальность с помощью Hash-индекса:
db=# ALTER TABLE urls ADD CONSTRAINT urls_url_unique_hash EXCLUDE USING HASH (url WITH =); ALTER TABLE
Так мы добавляем exclusion-ограничение, которое запрещает две строки с одинаковым URL, то есть гарантирует уникальность. Реализовано это через Hash-индекс. Получается, Hash-индекс обеспечивает уникальность.
Сначала проверим, что уникальность реально соблюдается:
db=# INSERT INTO urls (id, url) VALUES (1_000_002, 'https://hakbenita.com/postgresql-hash-index'); INSERT 0 1 db=# INSERT INTO urls (id, url) VALUES (1_000_003, 'https://hakbenita.com/postgresql-hash-index'); ERROR: conflicting key value violates exclusion constraint "urls_url_unique_hash" DETAIL: Key (url)=(https://hakbenita.com/postgresql-hash-index) conflicts with existing key (url)=(https://hakbenita.com/postgresql-hash-index).
Попытка вставить уже существующий URL привела к ошибке exclusion-ограничения.
Посмотрим, может ли этот Hash-индекс ускорять запросы с условием по конкретному URL.
db=# EXPLAIN (ANALYZE ON, BUFFERS OFF, COSTS OFF) SELECT * FROM urls WHERE url = 'https://hakibenita.com'; QUERY PLAN ──────────────────────────────────────────────────────────────────────────────────────────── Index Scan using urls_url_unique_hash on urls (actual time=0.010..0.011 rows=1.00 loops=1) Index Cond: (url = 'https://hakibenita.com'::text) Index Searches: 1 Planning Time: 0.178 ms Execution Time: 0.022 ms
Да, и в этом случае даже быстрее, чем B-Tree (0.022 мс против 0.046 мс).
И наконец, сравним размеры индексов двух типов:
db=# \di+ urls_url_* List of indexes Name │ Access method │ Size ─────────────────────┼───────────────┼──────── urls_url_unique_hash │ hash │ 32 MB urls_url_unique_ix │ btree │ 154 MB
Впечатляет. Hash-индекс в 5 раз меньше соответствующего B-Tree. Вместо хранения длинных URL в листовых страницах B-Tree Hash-индекс хранит только хеши, поэтому размер значительно уменьшается.
Ограничения «уникальных» exclusion constraints
Если обеспечивать ��никальность с помощью exclusion constraint + Hash-индекс, можно серьёзно сэкономить место и ускорить запросы. Но есть несколько оговорок:
⚠️ На колонку нельзя ссылаться внешними ключами
PostgreSQL требует, чтобы внешний ключ ссылался на ограничение уникальности. Так как нельзя создать ограничение уникальности, поддерживаемое Hash-индексом, внешний ключ на него сослаться не сможет:
db=# CREATE TABLE foo (url TEXT REFERENCES urls(url)); ERROR: there is no unique constraint matching given keys for referenced table "urls"
⚠️ Ограничения на INSERT ... ON CONFLICT
Предложение ON CONFLICT в INSERT очень популярно и удобно для синхронизации данных. Но использовать его с exclusion-ограничением не так просто.
Попытка использовать exclusion constraint через список полей в ON CONFLICT ... DO NOTHING может закончиться ошибкой:
db=# INSERT INTO urls (id, url) VALUES (1_000_004, 'https://hakibenita.com') ON CONFLICT (url) DO NOTHING; ERROR: there is no unique or exclusion constraint matching the ON CONFLICT specification
Сообщение намекает, что использовать exclusion-ограничение всё-таки можно. Это правда, но через ON CONFLICT ON CONSTRAINT:
db=# INSERT INTO urls (id, url) VALUES (1_000_004, 'https://hakibenita.com') ON CONFLICT ON CONSTRAINT urls_url_unique_hash DO NOTHING; INSERT 0 0
А вот ON CONFLICT ... DO UPDATE здесь вообще не работает, даже с ON CONFLICT ON CONSTRAINT:
db=# INSERT INTO urls (id, url) VALUES (1_000_004, 'https://hakibenita.com') ON CONFLICT ON CONSTRAINT urls_url_unique_hash DO UPDATE SET id = EXCLUDED.id; ERROR: ON CONFLICT DO UPDATE not supported with exclusion constraints
Лично я не люблю завязывать SQL на имена ограничений, поэтому для обхода обоих ограничений использовал бы MERGE:
db=# MERGE INTO urls t USING (VALUES (1000004, 'https://hakibenita.com')) AS s(id, url) ON t.url = s.url WHEN MATCHED THEN UPDATE SET id = s.id WHEN NOT MATCHED THEN INSERT (id, url) VALUES (s.id, s.url); MERGE 1
И напоследок проверим план выполнения, чтобы убедиться, что запрос может использовать Hash-индекс:
QUERY PLAN ──────────────────────────────────────────────────────────────────────────────────────────── Merge on urls t (cost=0.00..8.04 rows=0 width=0) -> Nested Loop Left Join (cost=0.00..8.04 rows=1 width=6) -> Result (cost=0.00..0.01 rows=1 width=0) -> Index Scan using urls_url_unique_hash on urls t (cost=0.00..8.02 rows=1 width=6) Index Cond: (url = 'https://hakibenita.com'::text)
Может и использует!
Несмотря на эти небольшие ограничения и неудобства, Hash-индекс — хороший способ обеспечить уникальность больших значений, на которые не должны ссылаться внешние ключи.
