PostgreSQL — одна из тех СУБД, у которых под капотом лежит не пара десятков типов, а целая зоопарк специализированных. Этот урок — про четыре утилитарных типа, которые ты будешь встречать каждый день, и про когда они оправданы, а когда нет.
UUID — глобально уникальные идентификаторы
UUID — 128-битный идентификатор, который статистически уникален без координации между серверами. Это альтернатива SERIAL/BIGSERIAL для систем, где id нужно генерировать до записи в БД — например, на клиенте мобильного приложения или в распределённой системе.
В Postgres 13+ есть встроенная функция gen_random_uuid() — она возвращает UUID v4 (полностью случайный). До 13 нужно было расширение pgcrypto или uuid-ossp.
Генерация UUID и базовая структура:
UUID хранится как 16 байт. Текстовая запись — 36 символов с дефисами (123e4567-e89b-12d3-a456-426614174000). Никогда не храни UUID как TEXT — это в два раза больше места и в разы медленнее на сравнении.
Когда UUID лучше SERIAL
- Распределённая генерация: клиент или внешний сервис создают id без обращения к мастеру.
- Безопасность через нечитаемость: SERIAL делает url-ы вида
/orders/1234— легко угадать соседние. UUID непредсказуем. - Слияние данных из нескольких источников: при миграции из двух БД с SERIAL начинаются конфликты id.
Когда UUID хуже SERIAL
- Производительность вставки: случайный UUID v4 ломает локальность B-tree индекса. Каждая вставка приземляется в случайное место, страницы кэша грязнят чаще. На массовой вставке разница может быть в разы. решает эту проблему, но он ещё не везде встроен.UUID v7
- Место на диске: 16 байт против 4 (
INT) или 8 (BIGINT). На сотнях миллионов строк это терабайты разницы. - Читаемость в логах:
/orders/1234понятнее, чем/orders/123e4567-....
Правило: если нет конкретной причины — используй BIGINT SERIAL/IDENTITY. Если есть распределённость или security-через-нечитаемость — UUID.
CITEXT — case-insensitive строки
В нашем customers колонка email TEXT NOT NULL UNIQUE. Это значит, что [email protected] и [email protected] — две разных строки, и UNIQUE их не отловит. На практике это создаёт дубликаты пользователей.
Стандартное решение — нормализовать email до lowercase на уровне приложения. Это работает, но требует дисциплины.
Лучшее решение — тип CITEXT из расширения citext:
CITEXT — сравнения и UNIQUE работают как case-insensitive:
CITEXT — это специальный TEXT, у которого оператор = и B-tree сравнения работают через lower(). Cтоит чуть больше CPU на каждое сравнение, но избавляет от целого класса багов.
Альтернатива без CITEXT — expression unique index CREATE UNIQUE INDEX ON users (lower(email)). Это тоже работает, но требует, чтобы любой запрос писал WHERE lower(email) = lower('...'). CITEXT делает всё прозрачно.
INET и CIDR — IP-адреса и сети
Многие команды хранят IP как VARCHAR(45) — потому что «строка же». Это неверно.
INET хранит IP-адрес (v4 или v6) с опциональной маской. CIDR — то же самое, но представляет сеть (не хост). Они занимают ровно 7 байт для v4 и 19 для v6 (в сравнении с 7-15 байтами у текста для v4) — но главное не место, а операции.
INET даёт настоящие сетевые операции:
Операторы:
<<— IP принадлежит сети.>>— сеть содержит IP.&&— две сети пересекаются.
Это разительно отличается от VARCHAR, где «принадлежит ли 192.168.1.10 к /24» — пишется отдельной функцией.
Типичный кейс — банлисты, гео-фильтры, аудит-логи. Везде, где IP — это не просто строка, а сущность, по которой нужно делать операции на сетевом уровне.
MONEY — и почему её не любят
MONEY существует. Использовать её — почти всегда плохая идея.
MONEY выглядит безобидно — но дальше начинаются проблемы:
Что не так:
- Зависимость от lc_monetary. Один и тот же литерал
'$1,234.56'парсится по-разному в зависимости от настройки сервера. На production это иногда выводит из себя. - Только одна валюта на колонку. MONEY ничего не знает про USD vs EUR. Хранить разные валюты в одной колонке нельзя.
- Один масштаб дробной части. По умолчанию — 2 знака. Биткоин или ставка процента «0.05%» сюда не помещаются.
- Конвертация в
NUMERICтеряет валютную метку (потому что её и не было).
Что использовать вместо:
NUMERIC(precision, scale)для суммы. Для центов часто пишутBIGINT(хранить целые центы) — это избегает плавающей точки.TEXTилиCHAR(3)для кода валюты (‘USD’, ‘EUR’).- Если нужно «много валют» — отдельная таблица
exchange_ratesили модель с парой(amount_cents, currency).
MONEY оставляют для тех систем, у которых одна валюта и где совместимость со старым кодом важнее чистоты. В новых проектах — обычно избегают.
Когда какой тип использовать вместо очевидного-но-неподходящего.
ENUM или TEXT + CHECK?
В нашей orders есть status TEXT NOT NULL CHECK (status IN ('pending', 'paid', ...)). Альтернатива — встроенный тип ENUM:
CREATE TYPE order_status AS ENUM ('pending', 'paid', 'shipped', 'delivered', 'cancelled', 'refunded');
CREATE TABLE orders (
...
status order_status NOT NULL
);
Преимущества ENUM:
- Занимает 4 байта (как INT), а не длина строки.
- Гарантирует валидный набор на уровне типа, не CHECK.
- Сортируется в порядке объявления, не алфавитно (
'pending' < 'paid' < 'shipped').
Минусы:
- Невозможно удалить значение (
ALTER TYPE ... DROP VALUEне существует). Только пересоздать тип. - Добавить значение можно (
ALTER TYPE ... ADD VALUE), но в начало списка только в Postgres 12+. - Сравнение ENUM-значений с TEXT требует явного cast’а.
Правило: ENUM хорош для стабильных, редко меняющихся наборов (статус заказа, тип события). Для часто-эволюционирующих (категории контента, теги) лучше TEXT + CHECK или отдельная справочная таблица.
ENUM в деле — обрати внимание, как сортируется по порядку объявления:
ORDER BY priority DESC отсортировал по «важности» (critical → low) автоматически, потому что внутри ENUM хранится порядковый номер.
Ещё пара полезных мелочей
Кроме того, что мы разобрали, в Postgres стоит знать:
BYTEA— бинарные данные. Не для больших файлов (лучше внешнее хранилище), но для хэшей, маленьких изображений, ключей — нормально.ENUM— фиксированный набор значений. Эффективно, но переименовать значение позже сложно — Postgres хранит порядок. Многие предпочитаютTEXT + CHECK constraint, что гибче.INTERVAL— продолжительность времени.'2 hours'::interval,'1 month'::interval. Складывается сTIMESTAMPTZиDATE.BIT(n)иBIT VARYING— битовые поля. Эффективно для флагов и масок, но используется редко: обычно прощеBOOLEAN-колонки или JSONB.
Чек-лист
UUID— 128 бит, статистически уникален без координации. Генерируйgen_random_uuid()(Postgres 13+).- UUID хорош для распределённых систем и нечитаемости URL. Плох для производительности B-tree (но v7 решает).
CITEXT(расширениеcitext) — case-insensitive строка. Идеально для email, имён пользователей.INET/CIDRдля IP-адресов и сетей. Дают операторы<<,>>,&&. Не храни IP какVARCHAR.MONEYлучше не использовать в новых проектах. Замена:NUMERIC(p,s)+ отдельная колонка кода валюты.- Для центов часто хранят
BIGINTнапрямую — никаких float-сюрпризов.