Материалы блога Planet PostgreSQL

Текстовые идентификаторы в проектировании БД PostgreSQL

Независимо от того, разрабатываете ли вы отдельное приложение или микросервис, вы неизбежно столкнетесь с темой совместного использования идентификаторов. Будь то URL-адреса веб-страниц, ресурсы RESTful API, документы JSON, экспорт CSV или что-то еще, идентификатор конкретных ресурсов будет доступен.
/orders/123
/products/345/variants/1
Хотя идентификатор — это просто число и не несет никаких негативных коннотаций, есть веские причины, по которым вы можете захотеть избежать его раскрытия. К таким причинам относятся:
  1. Безопасность и раскрытие данных: Числовые идентификаторы являются последовательными и предсказуемыми, что может раскрыть информацию о базовом источнике данных (например, об объеме данных) и обеспечить основу для перечисления идентификаторов.
  2. Неприкосновенность частной жизни и конфиденциальность: Могут возникнуть опасения по поводу сокрытия объема данных, на которые распространяются ссылки. Например, количество клиентов, клиентов или заказов может быть информацией, которую компания предпочитает держать в тайне.
  3. Неописательная природа: Целые числа в качестве идентификаторов могут привести к путанице. Идентификатор, такой как 123, не передает никакой дополнительной информации, что делает отладку в пограничных случаях более сложной.
Эти и другие причины (например, SEO-оптимизация) привели к более широкому использованию текстовых идентификаторов. Их удобочитаемость и универсальность делают их идеальными для внешнего обмена данными.
Однако при проектировании баз данных (или моделей данных) преимущества текстовых идентификаторов часто затмеваются проблемами, которые они порождают. Несмотря на то, что текстовые идентификаторы улучшают совместимость, они часто идут на компромисс с производительностью и хранением данных. В отличие от них, целые числа, естественно, быстрее и эффективнее обрабатывать, что приводит к снижению требований к хранилищу и более быстрому индексированию, сортировке и поиску — задач, для которых оптимизированы компьютеры.
В этой статье мы рассмотрим сценарии, в которых использование текстовых идентификаторов непосредственно в структуре базы данных может показаться естественным, и обсудим стратегии их эффективного использования.

Что делает текстовые идентификаторы привлекательными?

Будем честны — текстовые идентификаторы популярны не просто так. Для людей они гораздо более читабельны и, в некоторых случаях, добавляют дополнительный контекст. При тщательном выборе их также может быть легче запомнить.
Текстовые идентификаторы могут встраивать дополнительный контекст. Обратите внимание на номер заказа APAC-20241103-8237, в котором закодированы как регион, так и дата заказа. Еще одна популярная причина использования текстовых идентификаторов — их уникальность в распределенных средах.
Они особенно удобны, когда нужно взаимодействовать напрямую. Например, клиенты, копирующие номер заказа из электронного письма, или сотрудники службы поддержки, обсуждающие проблему, получают преимущества от удобочитаемого и понятного идентификатора. Это проще, интуитивно понятнее и с меньшей вероятностью вызовет головную боль, когда кто-то пытается вспомнить или поделиться этим.

Когда идентификатор — это не просто идентификатор

Проблемы с текстовыми идентификаторами возникают, когда они используются в качестве естественных ключей в модели данных или базы данных. Несмотря на свои преимущества, текстовые идентификаторы часто являются плохими первичными ключами по нескольким причинам:
  • Изменения контекста: Дополнительный контекст, предоставляемый текстовыми идентификаторами, скорее всего, изменится, что потребует обновлений. Несмотря на заверения в обратном, перемены неизбежны.
  • Проблемы с сортировкой: Сортировка текстовых идентификаторов может быть сложной задачей, особенно при сортировке по языковому стандарту или когда номера встроены в идентификатор (например, order1434 или order349).
Главный вопрос заключается в эффективности:
  • Текстовые идентификаторы обычно требуют больше места на диске, чем числовые. Каждый символ в текстовом поле занимает больше байтов, чем простое целое число, что приводит к увеличению размеров базы данных и снижению производительности, особенно при индексировании или обработке больших наборов данных.
  • Базы данных оптимизированы для числовых операций, что замедляет поиск, объединение и индексирование текстовых полей. Этот разрыв в производительности может существенно повлиять на большие наборы данных, что скажется на эффективности приложений.
  • Текстовые идентификаторы усложняют управление связями между таблицами. Дополнительные требования к хранилищу влияют не только на исходную таблицу, но и на все ссылающиеся сущности. Умножьте это увеличенное хранилище на количество ссылающихся таблиц, и вы получите представление об общем влиянии.
По мере роста баз данных эти проблемы становятся все более выраженными. Сочетание повышенных требований к хранилищу и более медленных операций способствует раздуванию базы данных, что может привести к снижению производительности системы. Кроме того, раздутые индексы могут ввести в заблуждение планировщик запросов, заставив его делать неоптимальный выбор, например отдавать предпочтение последовательному сканированию, а не сканированию индекса, что еще больше усложняет регулярные операции.

Разве UUID не являются решением всей этой проблемы?

Смотря как. Несмотря на то, что UUID обеспечивают числовое представление и отлично подходят для генерации уникальных ключей в распределенных системах, они не всегда являются лучшим выбором:
  • По сравнению с BIGINT, существует несколько сценариев реального мира, которые требуют полного набора UUID. Преждевременная оптимизация чаще всего не стоит для большинства решений.
  • 16-байтовый (128-разрядный) размер хранилища UUID может быть менее эффективным, чем многие текстовые идентификаторы. Даже BIGINT, при размере 8 байт (64 бита), более эффективен с точки зрения хранения. Эта неэффективность распространяется на индексы, соединения и другие операции.
  • Вопрос мнения: UUID просто уродливые.
Более того, разные версии UUID предлагают разные преимущества. Например, UUIDv1 включает компонент временных меток, что делает его в некоторой степени сортируемым, в то время как UUIDv4 полностью случайный. Даже сортируемые UUID могут не иметь существенных преимуществ по сравнению с более оптимизированными вариантами, такими как BIGINT или тщательно структурированные текстовые идентификаторы.
В большинстве случаев компромиссы в производительности и удобочитаемости делают UUID менее привлекательными, если только их глобальная уникальность не имеет существенного значения для распределенных систем.

Примеры из реальной жизни

Давайте выйдем за рамки теории и рассмотрим практические примеры:
CREATE TABLE sessions (
    token TEXT PRIMARY KEY,
    user_id INT NOT NULL REFERENCES users(user_id),
    ...
);

CREATE TABLE products (
    sku TEXT PRIMARY KEY,
    label TEXT NOT NULL,
    ...
);

CREATE TABLE documents (
    document_id TEXT PRIMARY KEY,
    ...
);
В этих случаях использование текстовых идентификаторов в качестве первичных ключей может показаться логичным, потому что:
  • Они служат естественными ключами для сущности.
  • Скорее всего, они распространяются за пределы области службы, поэтому в любом случае нуждаются в хранилище.
  • От них не ожидается изменений.
  • Влияние на хранилище для одной таблицы кажется незначительным.
Однако при тщательном рассмотрении эта логика дает сбой. Текстовые идентификаторы часто распространяются за пределы области службы, что приводит к необходимости их обновления. Обновление первичных ключей – дело нетривиальное. Рассматривать:
  • Хотя в идеале SKU никогда не меняются, реальные сценарии, такие как ребрендинг, консолидация продуктов или смена поставщиков, могут потребовать обновлений. Несмотря на свою привлекательность, SKU являются плохими кандидатами на первичный ключ.
  • Случайно сгенерированные текстовые идентификаторы (например, токены сеанса) будут... TBD.
Реальная проблема возникает при ссылке на эти идентификаторы в таблицах:
CREATE TABLE session_logs (
    log_id BIGINT GENERATED ALWAYS AS IDENTITY,
    token TEXT NOT NULL REFERENCES sessions(token),
    ...
);

CREATE TABLE product_reviews (
    review_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    product_sku TEXT NOT NULL REFERENCES products(sku),
    ...
);

CREATE TABLE customer_orders (
    order_id TEXT PRIMARY KEY,
    customer_id TEXT NOT NULL REFERENCES customers(customer_id),
    ...
);

CREATE TABLE document_revisions (
    revision_id INTEGER GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    document_id TEXT NOT NULL REFERENCES documents(document_id),
    ...
);
В таких сценариях текстовые идентификаторы могут стать проблематичными. Во-первых, вы теряете возможность их модифицировать. Во-вторых, становятся очевидными повышенные требования к хранению. Например, дополнительные 100 байт на ссылку в миллионе записей приводят к дополнительным 100 МБ хранилища только для одной ссылки.
Однако хранение данных не является самой большой проблемой; индексация есть. В PostgreSQL индексация текстовых полей — будь то первичные или внешние ключи — требует значительно больше места, чем индексация числовых полей. Это приводит к раздутым индексам, более медленному поиску и более фрагментированным операциям, особенно в запросах, зависящих от отношений между внешними ключами. В результате планировщик запросов может прибегнуть к полному сканированию таблицы вместо сканирования индекса, что еще больше снизит производительность.
Эти проблемы с производительностью часто остаются незамеченными в средах разработки или тестирования, но могут привести к значительным сбоям в производстве.

Эффективное повторное введение текстовых идентификаторов

Цель этой статьи — не полностью отговорить от использования текстовых идентификаторов, а подчеркнуть, почему они не подходят в качестве первичных ключей. Вот несколько стратегий для эффективного решения этих проблем:

1. Введите суррогатный ключ

Одним из простых решений является введение суррогатного первичного ключа, заменив ссылки на текстовые идентификаторы:
CREATE TABLE products (
    product_id INT GENERATED BY DEFAULT AS IDENTITY,
    sku TEXT NOT NULL,
    label TEXT NOT NULL,
    ...
);

CREATE INDEX products_by_sku ON products(sku);

CREATE TABLE product_reviews (
    review_id SERIAL PRIMARY KEY,
    product_id INT REFERENCES products(product_id),
    ...
);
Такой подход обеспечивает эффективное извлечение данных по SKU с помощью индекса.

2. Используйте таблицы сопоставления для большей гибкости

Таблицы сопоставления позволяют:
* 	Update identifiers without affecting the parent entity.
* 	Maintain a history of text identifiers linked to a specific entity.
CREATE TABLE products (
    product_id INT GENERATED BY DEFAULT AS IDENTITY,
    label TEXT NOT NULL,
    ...
);

CREATE TABLE product_skus (
    product_sku_id INT GENERATED BY DEFAULT AS IDENTITY,
    product_id INT REFERENCES products(product_id),
    sku TEXT NOT NULL,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    deleted_at TIMESTAMPTZ
);

CREATE UNIQUE INDEX unique_product_skus ON product_skus (product_id, sku) WHERE deleted_at IS NULL;
Такой подход позволяет учитывать изменение номеров SKU без ущерба для целостности данных. Похожим вариантом использования может быть привязка таблицы сопоставления к варианту продукта:
CREATE TABLE product_variants (
    variant_id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    product_id INT NOT NULL REFERENCES products(product_id),
    sku TEXT NOT NULL,
    name TEXT NOT NULL,
    ...
);

3. Декомпозируем значение текстового идентификатора

Текстовые идентификаторы часто содержат значимую информацию, такую как регионы, даты или категории. Декомпозировав идентификатор, вы можете хранить эти контекстные данные отдельно, что повышает гибкость и производительность.
Например, вместо того, чтобы хранить идентификатор заказа, такой как ORD-EMEA-00789 напрямую, вы можете разработать более надежную схему:
CREATE TABLE orders (
    order_id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    region_id INT NOT NULL REFERENCES regions(region_id),
    order_date DATE NOT NULL,
    ...
);

CREATE TABLE regions (
    region_id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    name TEXT NOT NULL,
    code TEXT NOT NULL,
    ...
);

INSERT INTO regions (name, code) VALUES
('Europe, the Middle East, and Africa', 'EMEA'),
('Asia Pacific', 'APAC');

To generate a user-friendly order number, you can create a function:

CREATE OR REPLACE FUNCTION get_order_number(p_order_id INT)
RETURNS TEXT AS $$
DECLARE
    v_region_code TEXT;
    v_formatted_order_id TEXT;
    v_order_number TEXT;
BEGIN
    SELECT r.code INTO v_region_code
    FROM orders o
    JOIN regions r ON o.region_id = r.region_id
    WHERE o.order_id = p_order_id;

    IF NOT FOUND THEN
        RETURN NULL;
    END IF;

    v_formatted_order_id := TO_CHAR(p_order_id, 'FM00000');
    v_order_number := 'ORD-' || v_region_code || '-' || v_formatted_order_id;

    RETURN v_order_number;
END;
$$ LANGUAGE plpgsql;
Этот метод позволяет эффективно хранить и извлекать структурированные данные, обеспечивая при этом читаемый и значимый идентификатор для внешнего использования.

4. Обратимые текстовые идентификаторы

Для сценариев, требующих повышенной безопасности или конфиденциальности, где идентификаторы не должны быть легко перечисляемыми или предсказуемыми, можно использовать обратимые текстовые идентификаторы. Они позволяют отображать пользователям кажущиеся случайными текстовые представления, сохраняя при этом эффективное внутреннее числовое хранилище.
Sqids — это проект, который может помочь в достижении этой цели. Он генерирует уникальные идентификаторы с удобным URL-адресом из чисел и может кодировать несколько числовых идентификаторов в одну строку. Вот пример использования проекта Sqids:
[42] -> JgaEBgznCpUZo3Kk
[42, 430004] -> lTiYlvsGkh59m1PQ
Сгенерированные идентификаторы обратимы благодаря знанию общего алфавита, что позволяет декодировать запросы без обращения к базе данных, что может быть полезно в средах с высокой пропускной способностью. Этот метод особенно полезен для идентификаторов пользователей, учетных записей или сеансов, обеспечивая баланс между необходимостью безопасности и операционной эффективностью.
Тем не менее, важно помнить, что это не является заменой надежных методов безопасности. Для обеспечения безопасности приложения по-прежнему необходимы надлежащие механизмы аутентификации и авторизации.
Продуманная интеграция этих стратегий позволяет использовать преимущества текстовых идентификаторов там, где это уместно, избегая при этом распространенных ошибок при проектировании баз данных. Этот баланс обеспечивает эффективные, удобные в обслуживании системы, отвечающие как техническим, так и коммерческим потребностям.

5. Используйте Создать столбцы

В пограничных сценариях, где необходимо хранить текстовые идентификаторы со встроенным контекстом, сгенерированные столбцы в PostgreSQL могут быть полезной функцией. Начиная с версии 12, PostgreSQL позволяет определять столбцы, значения которых автоматически вычисляются из других столбцов таблицы. Это обеспечивает стабильность без ручного вмешательства.
Например, можно определить функцию для обработки логики форматирования:
CREATE OR REPLACE FUNCTION get_formatted_order_id(p_region_id INT, p_order_id INT)
RETURNS TEXT AS $$
DECLARE
    v_region_code TEXT;
BEGIN
    v_region_code := CASE p_region_id
        WHEN 1 THEN 'EMEA'
        WHEN 2 THEN 'APAC'
        ELSE 'OTHER'
    END;

    RETURN 'ORD-' || v_region_code || '-' || LPAD(p_order_id::TEXT, 5, '0');
END;
$$ LANGUAGE plpgsql IMMUTABLE;
Функция помечена как IMMUTABLE, потому что PostgreSQL требует, чтобы сгенерированные столбцы использовали только неизменяемые функции, которые гарантированно возвращают один и тот же результат для одного и того же ввода каждый раз.
CREATE TABLE orders (
    order_id INT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
    region_id INT NOT NULL,
    order_date DATE NOT NULL,
    formatted_order_id TEXT GENERATED ALWAYS AS (
        get_formatted_order_id(region_id, order_id)
    ) STORED
);
Эта настройка гарантирует, что он автоматически обновляется при каждом изменении или изменении. Столбец физически сохраняется, что повышает производительность чтения часто запрашиваемых данных.formatted_order_idregion_idorder_id
Использование сгенерированных столбцов может упростить поддержание согласованности производных значений, таких как идентификаторы форматированного текста. Однако следует помнить об их характеристиках, таких как:
  • Автоматические обновления: система автоматически пересчитывает значение столбца при изменении столбцов, на которые имеются ссылки.
  • Требования к неизменяемости: Можно использовать только неизменяемые функции, что обеспечивает надежные и согласованные вычисления.

Заключение

Текстовые идентификаторы останутся с нами, и это здорово. Они удобочитаемы, запоминаются и могут упаковать много значимого контекста в простую строку. Они делают внешнее взаимодействие более плавным, будь то обращение клиента к номеру заказа или отслеживание проблемы службой поддержки. Они даже добавляют немного шарма и индивидуальности стерильным идентификаторам.
Тем не менее, важно держать их использование под контролем. Хорошее правило, которое я слышал, звучит так:
  • Используйте текстовые идентификаторы при внешнем общении — например, в URL-адресах или ответах API
  • Внутри компании всегда полагайтесь на числовые идентификаторы - суррогатные ключи, такие как INT или BIGINT (даже UUID, если вы хотите доминировать на планете), где это необходимо, чтобы поддерживать эффективность и целостность базы данных.
Такой подход позволяет использовать преимущества текстовых идентификаторов для внешней связи, оптимизируя при этом производительность и масштабируемость базы данных. Храня текстовые идентификаторы в специальных полях и используя числовые первичные ключи для внутренних операций, вы достигаете правильного баланса и делаете все возможное для поддержания производительности системы.
2024-11-17 06:00