Добрую часть десятилетия я профессионально занимаюсь веб-приложениями, и за этот срок научилась пользоваться множеством разных систем и инструментов.
В процессе этого обучения я обнаружила, что самой полезной обычно оказывается официальная документация.
За исключением… Postgres.
Не потому, что официальная документация несовершенна (она прекрасна!), просто её очень много. Если распечатать её как стандартный PDF на бумаге формата Letter, то получится 3206 страниц (для текущей версии 17)1. Разработчик-джун вряд ли сможет сесть и прочитать её с начала до конца.
1. Если печатать на бумаге A4, то получится 3024 страницы; наверно, это ещё один аргумент в пользу стандарта.
Поэтому мне захотелось каталогизировать ту информацию, которая бы очень пригодилась мне, когда я только начинала работать с базой данных Postgres.
Надеюсь, это упростит процесс освоения для читателей моей статьи.
Стоит отметить, что многое из описанного может быть применимо и к другим системам управления базами данных (СУБД) SQL и вообще к базам данных в целом, но я слабо знакома с ними, так что не буду утверждать с уверенностью.
Нормализуйте данные, если только нет веских причин не делать этого
Нормализация баз данных — это процесс удаления дублирующихся или избыточных данных из схемы базы данных. Например, допустим, у вас есть сайт, на который пользователи могут загружать документы и подписываться на уведомления по электронной почте о том, что их документы просматривают другие. Если в базе данных есть таблица
documents
, то не следует добавлять в неё столбец user_email
: если пользователь захочет поменять электронную почту, то вы не должны обновлять сотни строк для всех загруженных им документов. Вместо этого каждая строка в documents
должна обозначать строку в другой таблице (например, users
) при помощи внешнего ключа (например, user_id
).Если поискать онлайн информацию по запросу «нормализация баз данных» («database normalization»), то можно найти кучу результатов о «первой нормальной форме» («1st normal form») и тому подобном. Вам необязательно знать все эти «нормальные формы», но неплохо в общих чертах знать процесс в целом, потому что это позволит вам создать схему базы данных, которую будет удобно поддерживать.
Иногда наличие избыточных данных (то есть денормализация схемы) может иметь смысл: обычно оно используется для ускорения чтения определённых данных, чтобы их не нужно быть вычислять заново при каждом новом запросе. Допустим, у вас есть приложение для управления сменами сотрудников в пекарне. Пользователю может потребоваться узнать, сколько часов он проработал в этом году.
Чтобы вычислить это, нам нужно, например, определить длительность его смен, а затем суммировать все эти часы. Логично будет вместо этого вычислять эту сумму периодически или при изменении количества отработанных часов. Эти данные можно денормализовать внутри базы данных Postgres или снаружи (например, в слое кэширования наподобие Redis). Следует учитывать, что за денормализацию данных всегда приходится расплачиваться, например, возможной несогласованностью данных или повышением сложности записи.
Прислушивайтесь к советам разработчиков Postgres
В официальной Postgres wiki есть длинный список с говорящим названием «Don’t do this» («Не делай этого»). Возможно, вы поймёте не все пункты этого списка. Это нормально! Если вы не понимаете, то, вероятно, и не совершите этой ошибки. Вот некоторые рекомендации, которые стоят упоминания:
- Просто используйте тип text для хранения всего текста.
- Просто используйте тип timestampz/time with time zone для хранения всех меток времени.
- Давайте имена таблицам в snake_case.
Изучите особенности SQL в целом
▍ Пожалейте свои мизинцы: необязательно писать все ключевые слова SQL заглавными буквами
В большинстве документации и туториалов SQL пишут вот так:
SELECT * FROM my_table WHERE x = 1 AND y > 2 LIMIT 10;
На самом деле, регистр для ключевых слов SQL не важен. Это значит, что показанный выше пример эквивалентен такому:
select * from my_table where x = 1 and y > 2 limit 10;
или такому:
SELECT * from my_table WHERE x = 1 and y > 2 LIMIT 10;
Насколько я знаю, это справедливо не только для Postgres. Ваши мизинцы будут вам благодарны.
▍ NULL — это странная штука
Вероятно, по другим языкам программирования вы знакомы со значением
null
или nil
. NULL
из SQL непохож на них. На самом деле, NULL
ближе к «неизвестно». Например, NULL = NULL
возвращает NULL
(потому что неизвестно, равно ли одно неизвестное другому неизвестному!). Это справедливо практически для всех операторов, а не только для =
(чуть позже мы рассмотрим некоторые исключения): если одна часть сравнения имеет значение NULL
, то результат будет NULL
.Есть несколько операторов, применяемые для сравнения с
NULL
, результатом которых не является NULL
:Операция | Описание |
---|---|
x IS NULL |
возвращает true , если x равно NULL , и false в противном случае |
x IS NOT NULL |
возвращает true , если x не равно NULL , и false в противном случае |
x IS NOT DISTINCT FROM y |
аналогично x = y , но NULL обрабатывается как обычное значение |
x IS DISTINCT FROM y |
аналогично x != y /x <> y , но NULL обрабатывается как обычное значение |
WHERE
выполняет сопоставление, только если условие равно true
. Это значит, что запрос вида SELECT * FROM users WHERE title != 'manager'
не вернёт строки, в которых title
равен NULL
, потому что NULL != 'manager'
— это NULL
.Ещё одна полезная функция для обуздания
NULL
— это COALESCE
: COALESCE
получает любое количество аргументов и возвращает первый, не являющийся NULL
:COALESCE(NULL, 5, 10) = 5
COALESCE(2, NULL, 9) = 2
COALESCE(NULL, NULL) IS NULL
Можно сделать psql
более полезным
▍ Исправление нечитаемого вывода
Вам когда-нибудь доводилось выполнять запрос к таблице со множеством столбцов или длинными значениями в этих столбцах, из-за чего вывод оказывался практически нечитаемым? Вероятно, так было потому, что у вас не включён пейджер. Терминальный пейджер позволяет просматривать файл (или таблицу, в случае
psql
) текста скроллингом окна просмотра по холсту большего размера. Без пейджера вывод просто сбрасывает текст в терминал, выполняя перенос каждый раз, когда заканчивается пространство.less
— это удобный пейджер для любой Unix-подобной системы. Можно настроить его в качестве своего пейджера, установив переменную окружения в ~/.bashrc
/~/.zshrc
/etc.:# Используем опцию "-S", чтобы обрезать длинные строки для скроллинга вместо их переноса
export PAGER='less -S'
Иногда даже просмотр в виде удобно отформатированной таблицы не очень полезен, особенно в случае таблиц из кучи столбцов. В таком случае можно переключиться в «расширенный» режим при помощи
\pset expanded
(или \x
) в сессии psql
. Чтобы использовать его по умолчанию, можно добавить в исходный каталог (например, ~/.psqlrc
) файл .psqlrc
и ввести в него \x
. При каждом запуске сессии psql
он сначала будет выполнять все команды из этого файла.▍ Уточнение двусмысленных null
Очень важно знать, когда в выводе значение равно
NULL
, но при настройках по умолчанию это не особо понятно. Можно передать psql
строку для вывода, когда он обращается к NULL
. Я настроила её как [NULL]
, выполнив \pset null '[NULL]'
Подойдёт любая строка в Unicode!
Если вы хотите использовать её по умолчанию, то тоже можно создать в исходном каталоге файл
.psqlrc
(например, ~/.psqlrc
) и добавить в него \pset null '[NULL]'
.▍ Использование автозаполнения
psql
, как и многие интерактивные консоли, имеет возможность автозаполнения. Она полезна, ведь SQL — достаточно строгий и структурированный язык. Можно просто начать вводить большинство ключевых слов или имён таблиц, а затем нажать Tab
, чтобы psql
заполнил остальное:-- начинаем вводить "SEL"
SEL
-- ^ нажимаем `Tab`
SELECT
▍ Использование кратких команд с обратной косой чертой
В
psql
есть целая куча полезных кратких команд для поиска, редактирования командной строки и тому подобного.Команда | Действие |
---|---|
\? |
Выводит список всех кратких команд |
\d |
Показывает список связей (таблиц и sequence), а также владельца соответствующих связей |
\d+ |
Аналогично \d , но также включает размер и некоторые другие метаданные |
\d table_name |
Показывает схему таблицы (список столбцов, в том числе тип, допустимость значения NULL и значение по умолчанию), а также все индексы или ограничения внешних ключей этой таблицы |
\e |
Открывает редактор по умолчанию (установленный переменной окружения $EDITOR ) для редактирования в нём вашего запроса |
\h SQL_KEYWORD |
Выводит синтаксис и ссылку на документацию для SQL_KEYWORD
|
▍ Копирование в CSV
Иногда нужно передать вывод команды кому-то для ввода его в Excel или другую программу. В Postgres можно очень легко копировать вывод любого запроса в CSV на локальной машине:
\copy (select * from some_table) to 'my_file.csv' CSV
2. Это позволяет избежать использования более стандартного выражения
COPY
: к сожалению, оно часто требует повышенных привилегий, которых у вас может и не быть.Если вы хотите, чтобы была добавлена начальная строка со всеми именами столбцов, то можно указать опцию
HEADER
:\copy (select * from some_table) to 'my_file.csv' CSV HEADER
Гораздо более полную информацию (в том числе и о том, как выполнить обратную операцию: вставить строки из данных в CSV!) можно найти в документации по этой команде.
▍ Использование сокращений и псевдонимов столбцов
При выполнении оператора
SELECT
в psql
можно переименовать каждый выводимый столбец (дать ему «псевдоним») командой AS
:SELECT vendor, COUNT(*) AS number_of_backpacks FROM backpacks GROUP BY vendor ORDER BY number_of_backpacks DESC;
Она также переименовывает столбец в выводе.
Более того,
GROUP BY
и ORDER BY
тоже имеют свои удобные сокращения: после SELECT
можно ссылаться на столбцы в выводе по их порядковому номеру. То есть предыдущий запрос можно переписать так:SELECT vendor, COUNT(*) AS number_of_backpacks FROM backpacks GROUP BY 1 ORDER BY 2 DESC;
Хоть это и полезно, не оставляйте это в запросах, которые вы выпускаете в продакшен — в будущем вы будете себе благодарны за это!
Может оказаться, что добавление индекса ничего не делает (особенно если он неправильно сконфигурирован)
▍ Что такое индекс?
Индекс — это структура данных, которая должна помогать в поиске данных, позволяя Postgres хранить «каталог ярлыков» строк таблицы.
Самый распространённый вид — это индекс «B-дерево», который представляет собой разновидность дерева поиска, работающее и для условий точного равенства (например,
WHERE a = 3
), и для условий-диапазонов (например, WHERE a > 5
).Однако нельзя попросить Postgres использовать конкретный индекс. Она должна предсказывать (при помощи статистики, которая ведётся для каждой таблицы), что это будет быстрее, чем просто прочитать таблицу сверху вниз для поиска релевантных данных (это называется «sequential scan» или «seq. scan» — произносится как «seek scan»). Вы можете увидеть, как Postgres планирует исполнить запрос, добавив
EXPLAIN
перед SELECT ... FROM ...
. Так вы получите «план запроса» («query plan»): план того, как Postgres будет искать данные, и её оценка того, сколько работы потребует каждая задача.Существует множество хороших руководств по тому, как читать вывод этих планов запросов, например, статья thoughtbot или пост pganalyze. Хорошим источником может быть и официальная документация (хотя для новичка там может быть чересчур много информации). Я считаю, что для анализа планов запросов часто бывает полезен этот инструмент.
▍ Индекс не особо полезен для таблицы, в которой почти нет строк
Это в особенности важно при разработке собственной локальной базы данных.
Есть вероятность, что в локальной базе данных у вас не миллионы строк.
Postgres может определить, что быстрее просто выполнить seq. scan и вообще не использовать индекс, если там всего сто строк.
▍ При индексировании множества столбцов важен порядок
Postgres поддерживает многостолбцовые индексы, выполняющие именно то, что и можно от них ожидать: при создании индекса столбцов
a
и b
подобным образом:CREATE INDEX CONCURRENTLY ON tbl (a, b);
оператор
WHERE
видаSELECT * FROM tbl WHERE a = 1 AND b = 2;
будет быстрее, чем если для
a
и b
были созданы два отдельных индекса. Это вызвано тем, что при одном многостолбцовом индексе Postgres достаточно обойти одно B-дерево, что позволяет эффективно комбинировать ограничения поискового запроса.Этот индекс ускоряет запросы, выполняющие фильтрацию
a
, так же, как индекс для одного a
.Но как насчёт запроса вида
SELECT * FROM tbl WHERE b = 5;
? Будет ли он тоже быстрее? Возможно, но не настолько быстрым, насколько мог бы быть. Оказывается, этот индекс не избавляет от необходимости отдельного индекса b
. Это вызвано тем, что B-дерево в индексе сначала использует ключи a
, а затем уже ключи b
. То есть нам придётся обойти все значения a
в индексе, чтобы найти все значения b
в индексе. Часто при необходимости использования в запросах комбинаций столбцов стоит иметь индексы для (a, b)
, а также отдельно для b
. Тем не менее, при необходимости можно будет использовать индексы a
и b
по отдельности.▍ При сопоставлении префиксов используйте text_pattern_ops
Допустим, вы храните в своей базе данных иерархическую систему каталогов при помощи техники materialized path (при которой в каждой строке хранится список всех id предков строк) и в какой-то части приложения вам нужно получить все каталоги-потомки. То есть вам нужен запрос к таблице для нахождения всех строк, для которых столбец соответствует какому-то общему префиксу:
-- % - это подстановочный символ: оператор "WHERE" здесь запрашивает "directories", в которых "path" начинается с "/1/2/3/"
SELECT * FROM directories WHERE path LIKE '/1/2/3/%'
Чтобы ускорить работу, вы добавляете индекс столбца
path
таблицы directories
:CREATE INDEX CONCURRENTLY ON directories (path);
К сожалению, он может и не использоваться: большинство типов индексов (в том числе и индекс «B-дерево», косвенно создаваемый в выражении
CREATE INDEX
выше) для своей работы требует упорядочивания значений. Чтобы Postgres могла выполнить простую посимвольную сортировку, необходимую для подобного сопоставления префиксов и для сопоставления паттерна в целом, вам нужно при определении индекса указать другой «класс оператора»:CREATE INDEX CONCURRENTLY ON directories (path text_pattern_ops);
Долго удерживаемые блокировки могут поломать ваше приложение (даже ACCESS SHARE
)
▍ Что такое блокировка?
Блокировка («lock») или мьютекс («mutex», сокращение от «mutual exclusion», «взаимное исключение») гарантирует, что одновременно опасные действия может выполнять только один клиент. Эта концепция встречается в разных областях, но особенно она важна в Postgres, как и в любой другой базе данных, потому что обновление отдельной сущности (будь то строка, таблица, представление и так далее) должно завершиться однозначным успехом или неудачей. Операции могут выполниться частично успешно, если их пытаются одновременно выполнить два разных клиента/процесса, поэтому операция должна получить блокировку соответствующей сущности.
▍ Как блокировки работают в Postgres
В Postgres есть множество разных уровней блокировок для таблиц с различной степенью ограничений. Вот несколько примеров по порядку снижения ограничений:
Режим блокировки | Примеры выражений |
---|---|
ACCESS SHARE |
SELECT |
ROW SHARE |
SELECT ... FOR UPDATE |
ROW EXCLUSIVE |
UPDATE , DELETE , INSERT |
SHARE UPDATE EXCLUSIVE |
CREATE INDEX CONCURRENTLY |
SHARE |
CREATE INDEX (не CONCURRENTLY ) |
ACCESS EXCLUSIVE |
Разнообразные формы ALTER TABLE и ALTER INDEX
|
Требуемый режим блокировки | Установленный режим блокировки | |||||||
---|---|---|---|---|---|---|---|---|
ACCESS SHARE |
ROW SHARE |
ROW EXCL. |
SHARE UPDATE EXCL. |
SHARE |
ACCESS EXCL. |
|||
ACCESS SHARE |
X | |||||||
ROW SHARE |
X | |||||||
ROW EXCL. |
X | X | ||||||
SHARE UPDATE EXCL. |
X | X | X | |||||
SHARE |
X | X | X | |||||
ACCESS EXCL. |
X | X | X | X | X | X |
Клиент 1 выполняет… | Клиент 2 хочет выполнить… | Может ли Клиент 2 начать? |
---|---|---|
UPDATE |
SELECT |
✅ Да |
UPDATE |
CREATE INDEX CONCURRENTLY |
? Нет, должен ждать |
SELECT |
CREATE INDEX |
✅ Да |
SELECT |
ALTER TABLE |
? Нет, должен ждать3 |
ALTER TABLE |
SELECT |
? Нет, должен ждать3 |
ALTER TABLE
(например, добавление ограничений) требуют менее жёсткой блокировки. Дополнительную информацию см. на странице этого выражения в документации.Полный список всей этой информации можно найти в официальной документации. Также есть хорошее руководство по конфликтам разных операций (именно о них, а не об уровнях блокировок мы думаем чаще всего).
▍ Как это может вызывать проблемы
В предыдущем разделе мы отметили, что если один клиент выполняет выражение
ALTER TABLE
, то это может помешать выполнению SELECT
. Это может быть очень плохо, если выполнение выражения ALTER TABLE
занимает много времени. Если вы обновляете основную таблицу (например, users
, на которую должны ссылаться все запросы вашего веб-приложения), все операции чтения SELECT
из этой таблицы просто будут ожидать завершения. А потом, разумеется, завершатся по таймауту, заставив приложение возвращать 503.Обычно медленные
ALTER TABLE
получаются из-за следующих причин:- Добавление столбца с неконстантным значением по умолчанию.4
- По моему опыту, это наиболее частая причина медленной работы с большим отрывом от остальных.
- Изменение типа столбца.
- Добавление ограничения уникальности.
4. Когда-то любое значение по умолчанию могло сделать столбец медленным, но это было исправлено в Postgres 11, которая кажется мне совсем новой, но была выпущена целых шесть лет назад.
Допустим, вы добавляете новый столбец в активно используемую таблицу. Вы не делаете ничего глупого со своими выражениями
ALTER TABLE
. Да, вы добавляете новый столбец, но он не имеет переменного значения по умолчанию. Но это всё равно может поломать ваше приложение.Дело в том, что выражение
ALTER TABLE
будет быстрым… когда получит блокировку. Но допустим, много лет назад вы создали какой-то внутренний дэшборд, выполняющий периодические запросы к этой таблице. Со временем этот запрос становился всё медленнее и медленнее. То, что раньше занимало миллисекунды, теперь занимает минуты. Это абсолютно нормально, ведь, в конце концов, это всего лишь выражение SELECT
, но если пока оно выполняется, начнёт исполняться выражение ALTER TABLE
, ему придётся подождать.Наверно, это вас не очень удивит. Немного неожиданным может быть следующее: все последующие выражения, выполняющие запрос к этой таблице, тоже будут вынуждены ждать. Это вызвано тем, что блокировки Postgres создают очередь:
Отличную статью именно о таком сценарии можно прочитать здесь.
▍ Длительные транзакции тоже могут вести себя столь же плохо
Транзакции — это способ группировки серии выражений баз данных, чтобы они действовали по принципу «всё или ничего» (иными словами, они «атомарные»). После запуска транзакции (например, при помощи
BEGIN
), вы скрываетесь от всех. Ни один другой клиент не сможет увидеть вносимые вами изменения. Вы можете завершить транзакцию (при помощи COMMIT
), что затем «опубликует» их для остальной части базы данных. В абстрактном смысле транзакции схожи с блокировками: они позволяют избегать проблем с другими клиентами, пытающимися вмешаться в ваши действия.Классический пример задачи, буквально требующий использования транзакций — это перевод денег с одного банковского счёта на другой. Выполняется уменьшение баланса на одном счету и увеличение баланса на другом. Если посреди выполнения база данных совершит аварийный выход или если баланс счёта, с которого выполняется перевод, станет отрицательным, то нужно будет отменить всю операцию. Это можно сделать при помощи транзакций.
Однако можно запросто «выстрелить себе в ногу», при написании транзакций, если они будут выполняться слишком долго. Это вызвано тем, что когда транзакция получает блокировку, она удерживает её, пока не будут внесены изменения. Например, допустим, Клиент 1 открыл
psql
и написал следующее:BEGIN;
SELECT * FROM backpacks WHERE id = 2;
UPDATE backpacks SET content_count = 3 WHERE id = 2;
SELECT count(*) FROM backpacks;
-- ...
Но тут Клиента 1 внезапно отвлекли. Несмотря на то, что Клиент 1, по сути, «закончил» обновлять строку с
id = 2
, он всё равно имеет блокировку5.5. В этом случае проблемная блокировка — это row lock, то есть (как можно было догадаться), блокировка строки. Блокировки строк работают достаточно похоже на блокировки на уровне таблиц, о которых мы говорили выше, но немного проще и имеют всего четыре уровня.
Если другой клиент захочет удалить эту строку, то выполнит следующее:
DELETE FROM backpacks WHERE id = 2;
-- ...
-- ?
Но эта операция просто зависнет. Она не сможет ничего удалить, пока Клиент 1 не вернётся и не выполнит коммит транзакции.
Можно представить, как это может привести к всевозможным сценариям, в которых клиенты удерживают блокировку гораздо дольше необходимого, не позволяя другим успешно выполнять запросы к базе данных или её обновления.
JSONB — это обоюдоострый меч
У Postgres есть очень мощная возможность: можно хранить эффективно сериализированный JSON с возможностью запросов к нему как значение в строке. Во многих смыслах это даёт Postgres все сильные стороны документоориентированной СУБД (как, например, MongoDB) без необходимости запуска нового сервиса или координации между двумя разными хранилищами данных.
6. В Postgres есть и значения JSON (в которых текст хранится как текст), и JSONB, в которых JSON преобразуются в эффективный двоичный формат. JSONB обладает множеством преимуществ (например, его можно индексировать!), поэтому в некоторых случаях можно оставить использование формата JSON только для особых случаев (по крайней мере, по моему опыту).
Однако эта возможность при неправильном использовании обладает недостатками.
▍ JSONB может быть медленнее, чем обычные столбцы
Хотя JSONB достаточно гибок, Postgres не позволяет отслеживать статистику столбцов JSONB; это может привести к тому, что эквивалентный запрос к одному столбцу JSONB окажется существенно медленнее, чем к нескольким «обычным» столбцам. Есть замечательный пост с демонстрацией того, что можно сделать выполнение в две тысячи раз медленнее!
▍ JSONB не такой самодокументируемый, как стандартная схема таблицы
Столбец JSONB, по сути, может содержать в себе что угодно — это одна из основных причин его мощи! Но в то же время это означает, что у нас практически нет гарантий того, как он будет структурирован. В случае обычной таблицы мы можем взглянуть на схему и увидеть, что вернёт запрос. Будет ли ключ записан в camelCase? Или в snake_case? Будут ли состояния описаны булевыми
true
/false
? Или при помощи перечислений типа yes
/maybe
/no
?При использовании JSONB мы не будем этого знать, поскольку он не имеет той же статической типизации, которую обычно имеют данные Postgres.
▍ С JSONB-типами Postgres достаточно неудобно работать
Допустим, у вас есть таблица
backpacks
со столбцом JSONBdata
, в котором есть поле brand
. Вы хотите найти backpacks
JanSport, потому что любите эстетику начала 90-х. Поэтому вы пишете запрос7:-- ВНИМАНИЕ: не работает!
select * from backpacks where data['brand'] = 'JanSport';
и получаете в ответ ошибку
ERROR: invalid input syntax for type json
LINE 1: select * from backpacks where data['brand'] = 'JanSport';
^
DETAIL: Token "JanSport" is invalid.
CONTEXT: JSON data, line 1: JanSport
7. Здесь используется относительно новый синтаксис subscripting, появившийся в Postgres 14 (на мой взгляд, он лучше, чем первоначальный синтаксис
->
, который кажется менее знакомым по сравнению с тем, как выполняется обход JSON в других языках).Как же так? Postgres ожидает, что тип правой части будет соответствовать типу левой части; поскольку это корректно форматированный документ JSON, он должен быть объектом JSON, массивом, строкой, числом, boolean или null. Помните, что ни один из этих типов не имеет ничего общего с типами Postgres наподобие
boolean
или integer
. А NULL
в SQL работает сильно иначе, чем null
JSONB, который больше похож на обычный тип8.8. Самое примечательная разница:
'null'::jsonb = 'null'::jsonb
равно true
, а NULL = NULL
равно NULL
Для правильного написания этого запроса нужно сделать так, чтобы Postgres могла выполнять приведение типов.
select * from backpacks where data['brand'] = '"JanSport"';
-- ^ На самом деле это эквивалент следующего (потому что Postgres знает, что левая часть - это "jsonb")
select * from backpacks where data['brand'] = '"JanSport"'::jsonb;
-- Или же можно преобразовать левую часть в "text" Postgres:
select * from backpacks where data->>'brand' = 'JanSport';
Обратите внимание на двойные кавычки внутри одинарных. Сам по себе
JanSport
не является валидным JSON.Более того, существует целая куча специфичных для JSONB операторов и функций, которую сложно запомнить за раз.
Заключение
Надеюсь, пост окажется для вас полезным. Благодарю Лилли Чилен, Монику Коуэн, Стивена Хармана и KWu за мотивацию и отзывы. Если у вас есть исправления, отзывы или комментарии, можете найти меня практически на всех сайтах под ником
hibachrach
.Telegram-канал со скидками, розыгрышами призов и новостями IT ?
Комментарии (34)
GerrAlt
18.11.2024 15:11Есть впечатление что нам картинке перепутаны t=1 и t=2: access share не конфликтует сам с собой, прочему же обычному селекту в t=1 придется ждать, когда в t=0 получена только access share?
Вот если вторым поставить что-то требующее access exclusive тогда да, будем ждать
KReal
18.11.2024 15:11Для меня до сих пор неочевидно, в чём опасность использования НЕ snake_case? Мизинцы поберечь? Так все равно шифт задействован.
Kernell
18.11.2024 15:11В том, что такие имена придётся оборачивать в двойные кавычки, иначе получите ошибку что таблица с таким именем не найдена, потому что PostgreSQL преобразует всё в snake_case
redfox0
18.11.2024 15:11потому что PostgreSQL преобразует всё в snake_case
Точнее нормализирует sql для целей кеширования плана запроса и в том числе делает lower. (Например, Oracle db наоборот делает в upper).
RolexStrider
18.11.2024 15:11Традиции, Сэр. В РоstgreSQL так принято. В MS SQL например, принято использовать PascalCase и называть ссылочные поля не some_id, a SomeKey.
RolexStrider
18.11.2024 15:11Значительная часть советов в статье - я считаю универсальная, а не только про PostgreSQL. Я бы от себя еще добавил:
Не используйте без крайней необходимости поля, допускающие NULL!
Рациональное объяснение: вот есть у нас поле типа int not null (предположим, что int у нас - 4 байта). Тогда это поле может содержать 2^32 значений. Всё отлично выровнено по границе байтов (кластеров, блоков, чего угодно). Но если это это же поле у нас имеет тип int null - нам нужно как-то обеспечить, чтобы оно хранило уже 2^32+1 значений. Куда девать этот "лишний" бит? Расширять поле на целый байт? Слишком накладно. Поэтому умные дядьки придумали для колонок с полями, которые могут содержать NULL отдельную сущность - "карты" или "битмапы" NULL-ов - по-сути отдельную табличку, хранящую в одном бите является значение NULL-ом или нет. Но это мало того, что дополнительная сущность - к ней движок БД должен обращаться при КАЖДОМ запросе, причем делая кучу вычислений смещений и битовых операций просто для того, чтобы узнать: "А не NULL ли у нас конкретно в этом поле этой строки лежит?"mayorovp
18.11.2024 15:11Эта табличка что, настолько секретная, что даже в планах запросов не отображается? Странно как-то.
Если бы я делал СУБД - я бы отдельный байт или несколько добавлял к каждой строке, где бы хранил по биту на колонку.
RolexStrider
18.11.2024 15:11Не скажу за все СУБД - но в тех с которыми имел дело - не отображается. Да и "табличкой" это я назвал весьма условно. По байту добавлять для каждого поля - А) Накладно Б) Теряем выравнивание по границе слова. А по слову добавлять - так вообще 4 байта теряем. Поэтому и битовые карты. Хотя может где-то есть и другие решения. В PostgreSQL это стараются оптимизировать как могут, например если в таблице меньше 8 колонок допускающих NULL-они действительно отдельный байт на строку выделяют, вот тут чуть подробнее:
https://dba.stackexchange.com/questions/197425/why-does-postgresql-create-an-additional-null-bitmap-if-all-fields-after-the-8th
Одно точно можно сказать - NULL-ы это всегда какой-никакой оверхед для движка БД.mayorovp
18.11.2024 15:11Если погулять начиная с вашей же ссылки, то там упоминается The null bitmap (per row). То есть делается ровно так как я и предложил - в каждой строке выделяется несколько байт, в которых хранится по биту на колонку.
Одно точно можно сказать - NULL-ы это всегда какой-никакой оверхед для движка БД.
Один бит на колонку (с округлением вверх до байта) - это очень очевидный оверхед, а другого нет.
RolexStrider
18.11.2024 15:11Позволю не согласиться - есть, на процессор при всех этих манипуляциях с битами. Хоть на современных процессорах он и минимален.
mayorovp
18.11.2024 15:11Повторюсь, это очевидный оверхед. Есть бит данных, его надо прочитать и проверить. Для этого он читается и проверяется.
Более того, эта проверка происходит даже если колонка не является Nullable, просто потому что проверить записан ли NULL в ячейке быстрее, чем проверять свойства колонки. Так что вы от оверхеда даже избавиться не можете, он заложен в физическую структуру БД.
redfox0
18.11.2024 15:11Ваша причина не использовать NULL кажется надуманной.
Но я яро одобряю использование ограничений NOT NULL на максимально возможное количество полей.
Kilor
18.11.2024 15:11... для анализа планов запросов часто бывает полезен этот инструмент.
depesz слегка закрылся от нас, так что используйте explain.tensor.ru
anton99zel
18.11.2024 15:11Postgres не позволяет отслеживать статистику столбцов JSONB
Зачем нужна такая статистика?
shushara4241
18.11.2024 15:11Для меня самым неочевидным однажды оказалось то, что при удалении данных из postgres - он не вернет освободившееся место на диске системе, чтобы это случилось, нужно использовать vacuum
RolexStrider
18.11.2024 15:11Это много где, не только в Postgres. Даже SQLite нужен vacuum (он там и называется так же), и MSSQL (а там - SHRINKFILE)
redfox0
18.11.2024 15:11Вы ещё Oracle DB не видели. "Как уменьшить разросшийся datafile на диске?" Краткий ответ: "Никак". Более подробный: "Создаёте новый datafile, монтируете к СУБД, перетаскиваете данные, отмонтируете старый и удаляете". Только шагов больше. Возможно, в современных версиях с этим стало попроще.
bogolt
18.11.2024 15:11Можно сделать
psql
более полезнымперейдя на https://www.pgcli.com/
это такой zsh для постгреса. Подсказывает очень много всего, набирать на нем команды становится намного приятнее. Разве что непривычно что запрос выполняется без
;
это удобно, но для тех кто привык жать энтер чуть что может быть опасным.
FSA
18.11.2024 15:11Какой-то поклёп на документацию PostgreSQL. По моему мнению она у него одна из лучших, наряду с PHP. В ней всегда можно найти подробности. И даже не обязательно ковыряться в самой документации, достаточно воспользоваться поисковыми системами. Они сами выведут вас на нужную страницу. Ну, и, что для меня немаловажно - документация есть на русском языке. А вот с MySQL у меня что-то не задавалось. Там почти не пользовался документацией. Да и уже лет 8 не пользуюсь. Может быть если надо было, то осилил.
redfox0
18.11.2024 15:11Ещё один подвох с NULL (из-за троичной логики), возможно, в PG он тоже есть:
select t.* from sometable t where i.id in (select id from sometable2);
Если подзапрос вернёт хоть один NULL, то оператор
in
будет всегда ложным.playermet
18.11.2024 15:11Подвох будет, если использовать
NOT IN
, потому чтоNOT(NULL)
дастNULL
. С простоIN
подвоха не будет.
QuickJoey
18.11.2024 15:11Для материализованного пути используйте расширение LTREE и соответствующие индексы, тогда можно будет искать не только по префиксу, и по вхождению и наименьшего общего родителя и много чего ещё.
CrushBy
Забыли упомянуть, что одна из важнейших причин необходимости денормализации - это индексы.
Например, если у вас таблицы чеков продажи и строк чеков, и вам нужно относительно быстро посчитать, например, какую-то функцию по строкам чеков за один день, то, если вы не денормализуете дату чека в строки, то не сможете построить по ней индекс. С индексом (например, тем же BRIN-индексом), у вас будет прогон только по нему и очень быстро отсечены нужные строки. А если будет нормализованно, то у вас сначала отберет все чеки за день по индексу (которых может быть сотни тысяч), а потом будет сотня тысяч пробегов по индексу со ссылкой строки чека на чек.
Никакой несогласованности не придется расплачиваться. Для этого транзакции есть и update conflict'ы. В большинстве логики денормализации это гарантирует целостность. Но да, немножко увеличивается запись. Зато вы значительно экономите на чтении. У нас, в платформе lsFusion можно вообще прозрачно включать/выключать материализацию/денормализацию одним признаком. Тем самым можно балансировать запись/чтения не меняя вообще никакой логики.
Kahelman
Ага, забили на нормализацию и раскидали email по -10-ти таблицам. А потом попробуйте изменить email …
Ksoo
Все надо с умом применять, пример выше очень релевантный, когда денормализацией, ты очень сильно упрощаешь аналитические выборки. И никакой проблемы со сменой там нету, так как дата чека не может поменяться.
Anarchist
Я не работал с postgresql лет десять как минимум, но, насколько я помню, если таблица B ссылается на поле X в таблице A, то прямая попытка изменить поле X по умолчанию извергнет ошибку. Но при создании внешнего ключа на поле X можно уточнить поведение. По умолчанию изменение поля X даёт поведение выше. Но это поведение можно изменить, установив синхронизацию полей в таблице B с ключами из таблицы A. Если поле X изменить после этого в A, то оно озменится и во всех связанных таблицах. Если это поле X меняется редко (а пользователи обычно меняют, например, e-mail не каждый день, и даже не каждый год), а аналитику приходится делать ещечасно, то замедление в работе БД при смене e-mail может оказаться не очень критично. Тут надо по ситуации.
Kahelman
Если вы раскидали email по 10-ти таблицам и настроили на все эти поля индексы/ссылки то вы убили производительность БД. Так как при вставке нового клиента: придётся изменять 10 таблиц, их индексы и т.д.
Плюс убили кучу места под хранение всего этого.
Как всегда на Хабре куча специалистов по ДЮ которые их в глаза похоже не видели.
Хотя бы почитайте документацию по SQLite, где вам автор расскажет как он устроен и как он его использует на примере fossil, где используется куча join
CrushBy
Да, но писать придется один раз, а вот читать могут тысячи и миллионы раз. И тогда на отсутствии JOIN, наличии дополнительных индексах и лучших планах вы выиграете многократно.
Kahelman
Статистику запросов в студию …
Реляционный БД заточены под выполнение большого количества join.
Если вы читаете таблицу несколько раз, то данные будут подтягиваться из горячего кеша и при правильно написанном фронт-енде будет выполняться уже скомпилированный и распарсенный запрос.
Кроме того, что гораздо более важно, при нормализованных таблицах, у вас большая часть нужных данных окажется в индексе, доступ к которому гораздо быстрее чем доступ к тем же данным в самой таблице.
В частности Oracle-овский оптимизатор умеет использовать поля из индекса вместо табличных данных при сложных запросах.
Ваши запросы из не нормализованных таблиц скорее всего будут типа:
Select * from table T where Id ….
Т.е. придётся читать полные блоки данных с диска.
Опитмизатор не перепишет вам запрос чтобы читать поле email из индекса.
В случае нормализованных данных:
Select * from t join emails on t.customer_id = emails.customer_id
Вместо таблицы emails может быть использован соответствующий индекс.
В результате придется делать меньше обращений к диску
CrushBy
Странное пожелание. Статистика может быть такое, что у вас одна транзакция в минуту и 100.000 запросов на чтение в секунду с JOIN'ом. Если суммировать время выполнения "лишних" записей (я сейчас хотя бы про CPU говорю), то оно будет гораздо меньше, чем выполнение 100.000 JOIN'ов пусть и супер-оптимизированных.
swa111
Один из примеров что были у меня - ведомости на начисление/удержание, все по заветам денормализации dPeriod (период расчета) и idCharge (Код оплаты) вынесены в Blt (Шапку ведомости)
Bltlist - содержит список сотрудников с суммами. Следующий запрос собирает информацию сколько сотрудник получил по указанному коду оплат в текущем расчетном периоде
```sql
select *
from
Blt
inner join Bltlist on
Blt.Id = BltList.IdBlt
where
Blt.dPeriod = '01.01.2024' and
Blt.idCharge = 10 and
Bltlist.idCard = 1000
```
PG читает из Bltlist по индексу idCard все записи с самих лохматых времен. Затем собирает все ведомости по индексу dPeriod, idCharge опять же все, в том числе где нашего сотрудника нет. затем делает хешджоин так как что слева что справа записей более 1000.
А теперь если делаем денормализацию и дублируем поля dPeriod, idCharge в Bltlist
```sql
select *
from
Blt
inner join Bltlist on
Blt.Id = BltList.IdBlt
where
Bltlist.dPeriod = '01.01.2024' and
Bltlist.idCharge = 10 and
Bltlist.idCard = 1000
```
Теперь PG по индексу dPeriod, idCharge, idCard читает только те записи, которые нам нужны ни каких лишних чтений
CrushBy
Ну во-первых, платформа lsFusion это умеет делать автоматически. Она изменит сама во всех денормализованных таблицах при изменении исходных данных. А, во-вторых, даже, если писать ручками, ну добавляешь тригер в базу в одном месте и все. По крайней мере, в случае простой денормализации.
ManulVRN
Как старорежимный человек скажу, что аналитику лучше бы делать на хранилище данных, а там да, в витринном слое разумная денормализация вполне уместна. Дабы ВИП не ждал, пока ему красивый график построится. Ну и в описанной задаче с чеками напрашивается партиционирование по датам.