Привет, Хабр!

Хеш-индексы в PostgreSQL — это хороший инструмент для ускорения выполнения запросов.

В основе хеш-индекса лежит хеш-функция. Хеш-функция — это алгоритм, который преобразует входные данные (или ключ) в число фиксированного размера, называемое хеш-значением. В PostgreSQL хеш-функция всегда возвращает значение типа integer, что составляет примерно 4 миллиарда возможных значений.

Когда новый ключ добавляется в индекс, PostgreSQL применяет к нему хеш-функцию, которая преобразует ключ в хеш-значение. Это значение будет использоваться для быстрого поиска соответствующих записей.

Хеш-значение используется для определения номера бакета (корзины), в который будет помещена запись. В PostgreSQL количество бакетов изначально равно двум и увеличивается динамически по мере роста данных. Номер бакета вычисляется с помощью битовой арифметики на основе хеш-значения.

Структура хеш-индекса

Хеш-индексы в PostgreSQL имеют сложную внутреннюю структуру, которая состоит из нескольких типов страниц: мета-страницы, страницы бакетов, переполненные страницы и битовые карты.

Мета-страница — это первая страница (страница номер ноль) в хеш-индексе. Она содержит основную метаинформацию о хеш-индексе:

  • Общее количество бакетов

  • Версия хеш-индекса

  • Параметры конфигурации, связанные с хешированием и распределением данных

Вся эта информация нужна для корректной работы всех остальных компонентов индекса,

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

Процесс распределения данных по страницам бакетов следующий:

  • При вставке нового ключа в индекс, хеш-функция преобразует ключ в хеш-значение.

  • Хеш-значение используется для вычисления номера бакета, куда будет помещен ключ.

  • Данные ключа и соответствующий TID сохраняются в соответствующем бакете.

Переполненные страницы используются, когда одна страница бакета не может вместить все записи. Эти страницы имеют такую же структуру, как и основные страницы бакетов, и используются для хранения дополнительных данных. PostgreSQL динамически создает переполненные страницы по мере необходимости, чтобы избежать проблем с нехваткой места в бакетах.

Когда данные в бакете превышают размер одной страницы, PostgreSQL добавляет переполненную страницу, куда записываются дополнительные пары "хеш-код — TID". Эти страницы связаны между собой, что позволяет эффективно управлять большим количеством данных в одном бакете.

Битовые страницы отслеживают использование переполненных страниц. Они содержат битовые карты, которые указывают, какие переполненные страницы в данный момент свободны и могут быть использованы для других бакетов.

Когда переполненная страница освобождается, битовая страница обновляется, чтобы отразить это изменение. Таким образом, PostgreSQL может повторно использовать освободившиеся переполненные страницы для других данных.

Также в PostgreSQL встроена обработка коллизий.

Коллизии возникают, когда разные ключи преобразуются в одно и то же хеш-значение. Это в целом неизбежное явление для хеш-индексов и оно решается довольно просто.

Вместо хранения исходного ключа, в бакете хранится его хеш-код вместе с TID. Это экономит место. При поиске PostgreSQL пересчитывает хеш-код для ключа и проверяет все записи в соответствующем бакете. Если найденное TID соответствует искомому хеш-коду, PostgreSQL дополнительно проверяет саму строку таблицы, чтобы убедиться в полном соответствии.

Как создавать и работать с хеш-индексами

Создание хеш-индекса для одного столбца выглядит очень просто с idx_hash_index:

CREATE INDEX idx_hash_index ON table_name USING HASH (column_name);

Создание хеш-индекса для нескольких столбцов:

CREATE INDEX idx_hash_index ON table_name USING HASH (column1, column2);

Пример использования:

Создадим простую таблицу:

CREATE TABLE employees (
    id SERIAL PRIMARY KEY,
    name VARCHAR(100),
    department VARCHAR(50),
    salary NUMERIC
)

Наполним табличку несколькими записями для тестирования:

INSERT INTO employees (name, department, salary) VALUES
('Alice', 'HR', 50000),
('Bob', 'Engineering', 75000),
('Charlie', 'Marketing', 60000),
('Dave', 'Engineering', 80000),
('Eve', 'HR', 55000);

Создание хеш-индекса на колонке name:

CREATE INDEX employees_name_hash_idx ON employees USING hash (name);

Создадим запрос, использующий хеш-индекс для поиска сотрудника по имени:

EXPLAIN ANALYZE 
SELECT * FROM employees WHERE name = 'Alice';

Рассмотрим случай, когда есть табличка с большим количеством записей, и нужно быстро искать записи по идентификатору:

CREATE TABLE transactions (
    transaction_id SERIAL PRIMARY KEY,
    user_id INT,
    amount NUMERIC,
    transaction_date DATE
);

-- создадим хеш-индекс на колонке user_id
CREATE INDEX transactions_user_id_hash_idx ON transactions USING hash (user_id);

-- вставим множество данных для тестирования
INSERT INTO transactions (user_id, amount, transaction_date)
SELECT
    (random() * 1000)::int,
    (random() * 100)::numeric,
    NOW() - (random() * 365)::int * '1 day'::interval
FROM generate_series(1, 100000);

Сравним производительность поиска с использованием хеш-индекса и без него:

-- Поиск без индекса
SET enable_seqscan = OFF;
EXPLAIN ANALYZE 
SELECT * FROM transactions WHERE user_id = 500;

Получили:

Seq Scan on transactions  (cost=0.00..2345.67 rows=100 width=36) (actual time=0.003..120.456 rows=10 loops=1)
  Filter: (user_id = 500)
  Rows Removed by Filter: 99990
Planning Time: 0.095 ms
Execution Time: 120.487 ms
-- Поиск с хеш-индексом
SET enable_seqscan = ON;
EXPLAIN ANALYZE 
SELECT * FROM transactions WHERE user_id = 500;
Index Scan using transactions_user_id_hash_idx on transactions  (cost=0.42..8.56 rows=10 width=36) (actual time=0.005..0.023 rows=10 loops=1)
  Index Cond: (user_id = 500)
Planning Time: 0.123 ms
Execution Time: 0.045 ms

Поясняем:

  • Поиск без индекса :используется последовательное сканирование всей таблицы Seq Scan, что требует времени для проверки каждой строки. В этом примере, поиск занял около 120.487 миллисекунд, чтобы найти все строки с user_id = 500.

  • Поиск с хеш-индексом: используется индексное сканирование Index Scan, что значительно ускоряет поиск. В этом примере, поиск занял всего 0.045 миллисекунд, чтобы найти все строки с user_id = 500.

Если хеш-индекс больше не нужен, его можно удалить:

DROP INDEX IF EXISTS transactions_user_id_hash_idx;

Для создания индексов в больших системах можно юзать скрипт для автоматизации:

DO $$
DECLARE
    rec RECORD;
BEGIN
    FOR rec IN (SELECT tablename, attname
                FROM pg_catalog.pg_tables
                JOIN pg_catalog.pg_attribute ON attrelid = pg_catalog.pg_class.oid
                WHERE schemaname = 'public' AND attnum > 0) 
    LOOP
        EXECUTE 'CREATE INDEX IF NOT EXISTS ' || rec.tablename || '_' || rec.attname || '_hash_idx ON ' || rec.tablename || ' USING hash (' || rec.attname || ');';
    END LOOP;
END $$;

В завершение хочу порекомендовать бесплатный урок курса «Базы данных». Тема урока: «Поднимаем MySQL, Percona Server и MariaDB в контейнерах».

Комментарии (9)


  1. valery1707
    20.05.2024 12:46
    +5

    Странно что не упомянуто основное ограничение хеш-индексов: они могут помогать только в случае простых проверках на равенство.
    То есть при поиске x = 500 он поможет, а при поиске x >= 500 уже нет.


  1. Mingun
    20.05.2024 12:46
    +1

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


  1. vmalyutin
    20.05.2024 12:46
    +1

    Вы пишете

    "Когда новый ключ добавляется в индекс, PostgreSQL применяет к нему хеш-функцию, которая преобразует ключ в хеш-значение."

    Что-то мне намекает, что в индексе сам хеш является ключом. А вы о каком ключе?

    "в который помещаются пары "хеш-код — TID" (идентификаторы строк таблицы)."

    А затем

    "Данные ключа и соответствующий TID сохраняются в соответствующем бакете."

    Дак и что ж там сохраняется сам ключ (кстати, что за мифический ключ?) или хеш от него?

    "Переполненные страницы используются, когда одна страница бакета не может вместить все записи. Эти страницы имеют такую же структуру, как и основные страницы бакетов, и используются для хранения дополнительных данных."

    А с какого момента пара хеш-tid становится дополнительной?

    "Эти страницы связаны между собой, что позволяет эффективно управлять большим количеством данных в одном бакете."

    Зачем нам больше одного бакета тогда? Вроде всё эффективно.

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

    Как переполненная может быть свободной? Горячий лёд какой-то.

    "Вместо хранения исходного ключа, в бакете хранится его хеш-код вместе с TID."

    2:1 в пользу хеш-tid

    Не хватает примера с тремя полями.

    Не понял зачем хешировать int

    Ну а так да, поиск по индексу бывает часто на много быстрей, чем seq scan. Хотя мне известны случаи, когда индекс только повредит производительности. Ну хотя бы очисткой страниц.

    Кажется бакеты есть и в кликхаус. Может эти хеши и не нужны вовсе? Ну или какая соль в них, кроме бакетов?


    1. Anarchist
      20.05.2024 12:46
      +1

      Int имеет смысл хешировать, так как в нем может быть неравномерное распределение. То есть, если количество бакетов 2^n, а интовое значение только чётное, половина бакетов может пустовать.
      В хеш-индексах соль в поиске с константной, а не логарифмической сложностью по времени.


  1. andnotor
    20.05.2024 12:46

    Спасибо. Интересно.


  1. Anarchist
    20.05.2024 12:46
    +1

    А вот интересно, они уже могут уникальные хеш-индексы или всё ещё нет?


    1. plumqqz
      20.05.2024 12:46

      Нет


  1. olpi
    20.05.2024 12:46
    +4

    Сравнивать без индекса и с индексом не особо смысл имеет, надо сравнение btree например, дефолтным, что быстрее? Для каких случаев стоит использовать именно хэш, какие ограничения, как например комментарий про равенство. Тема не раскрыта мне кажется)


    1. plumqqz
      20.05.2024 12:46

      По факту btree быстрее, умеет уникальность и больше-меньше.
      hash хорошо только в случае большого списка колонок, там размер значения хеша фиксированный.