Как известно, многие реляционные базы данных, а в данном конкретном случае PostgreSQL, обещают нам, что наши транзакции будут соответствовать критериям ACID (Атомарность, Согласованность, Изолированность, Сохраняемость), при должном уровне конфигурирования тех или иных настроек.
Несмотря на название текста, заранее извиняясь перед читателем, отложим разговор о Durability-Сохраняемости, которая обеспечивает нам сохранность данных в случае отказа физического железа, прежде всего - отключение электричества, повреждение диска, сбой сети и любые иные катаклизмы. Вернемся мы к ней ближе к концу первой части и детальнее поговорим во второй.
А не будем мы о ней говорить прямо сейчас, потому что для начала хотелось бы поговорить о том, как в принципе выполняется запрос? Речь пойдет об одной машине Postgres, не рассматривая вопросы репликации и согласованности реплик.
Давайте представим ситуацию, что у нас есть база данных, в которой есть простенькая таблица:
CREATE TABLE movies (
movie_id SERIAL PRIMARY KEY,
title text,
);
От клиента поступает запрос
UPDATE movies SET title = 'The Lord of The Rings' WHERE movie_id = 71;
Разберемся, что же происходит с запросом дальше. Сразу уточню, что пайплайн запроса изобилует множеством различных механизмов и нюансов. Выделены ключевые из них, мимо которых нельзя было пройти.
Парсер
Запрос внутри Posgres передается парсеру. Парсер это механизм, который конвертирует строковое представление SQL запроса в древовидную структуру данных наподобие AST (Абстрактное синтаксическое дерево). Парсер разбивает SQL запрос на составные части, токенизирует его, выделяя ключевые компоненты и строит дерево запроса.
Вот так примерно выглядит дерево, которое построит парсер:
Rewrite
Построенное дерево запроса от парсера передается в Rewrite. Этот механизм может модифицировать дерево, если существуют какие-то Rules - правила, для данной операции в данной таблице.
Например, может существовать такое (пусть и глупое) правило:
CREATE RULE update_movies AS ON UPDATE TO movies DO INSTEAD
UPDATE movies SET title = 'Matrix'
WHERE movie_id d= 71;
В результате, на следующих этапах, будет выполняться уже обновленный с учетом правила запрос - а для этого необходимо перестроить дерево. Мы же перестраивать ничего не будем и сделаем вид, что в нашем случае такого правила не существует. Речь о нем зашла лишь для описания механизма, как составной части пайплайна.
Планировщик
Далее итоговое дерево запроса поступает к планировщику. Планировщик отвечает за оптимизацию запроса. Стратегию, которую изберет планировщик в каждом конкретном случае мы можем посмотреть с помощью EXPLAIN:
Executor
Когда план сформирован, запрос передается на исполнение. Процесс исполнения запроса проходит строго по плану, сформированному планировщиком.
В нашем конкретном случае планировщик сообщил, что есть индекс по Primary Key (movie_id) и нам нужно выгрузить в оперативную память с диска части индекса (называемые страницами, 8КБ по-умолчанию в Postgres), в которых в том числе содержатся интересующие нас данные.
Выглядит страница индекса примерно следующим образом:
+-------------------------+ 0
| Page Header | <-- Метадата, флаги, размер страницы и прочее.
+-------------------------+ 24 байта
| Special Area | <-- Метадата (поинтеры для B-дерева и прочее).
+-------------------------+ Размер Special Area варьируется
| Index Entry 1 | <-- Ключ (индексное значение) и поинтер на строку(TID).
+-------------------------+
| Index Entry 2 |
+-------------------------+
| ... |
+-------------------------+
| Index Entry N |
+-------------------------+
| Free Space | <-- Не использованное еще пространство для новых записей.
+-------------------------+ 8192 байт (8KB) - конец страницы
После того, как в индексе найдена запись с movie_id = 71, в ней также содержится ссылка (или указатель), которая хранит информацию о конкретной странице данных (не путать с страницей индекса, представленной выше), содержащей уже данные строки целиком - в нашем случае это (movie_id, title), а также смещение в рамках этой страницы, потому что страница содержит не одну строку (в 8КБ помещается гораздо больше данных).
Визуализация страницы данных:
+-------------------------+ 0
| Page Header | <- Информация о странице, флаги, контрольная сумма.
+-------------------------+ 24
| Item Pointer Array | <- Указатели на каждую строку в Row Data Area.
+-------------------------+ Переменная длина
| Free Space | <- Свободное место для новых строк.
+-------------------------+ Varies
| Row Data 3 | <- Полные данные строки (movie_id=91, title="...")
+-------------------------+
| Row Data 2 | <- Полные данные строки (movie_id=71, title="...")
+-------------------------+
| Row Data 1 | <- Полные данные строки (movie_id=23, title="...")
+-------------------------+ 8192 байт (8КБ)
Эти данные (не конкретная найденная строка, а страница, на которую ссылается индекс, еще до поиска строки по смещению, которое нам сообщил индекс) выгружаются в оперативную память, в ее заранее зарезервированный сегмент, называемый в Postgres shared buffer pool. Это объемная часть памяти, которая прежде всего играет роль кэша, храня страницы индексов и табличных данных по часто запрашиваемым запросам или пересечениям разных запросов, дабы ограничиться только I/O файловой системы и не обращаться непосредственно к дисковому устройству (SSD, иногда HDD).
Маленькое отступление от нашего запроса.
Основным критерием экономии является время - дисковые операции очень дорогие (медленные в сравнении с оперативной памятью, для SSD это разница в десятки или сотни раз), но в случае высоконагруженной системы речь может идти и об экономии непосредственных обращений к диску, потому что у каждого устройства есть свой предельный ресурс.
Например, у серверных SSD дисков IOPS (input/output operations per second) чаще в районе десятков тысяч операций на чтение в секунду и в несколько раз меньшее количество операций на запись в секунду. Помимо физического ограничения на количество операций в секунду, SSD диски подвержены износу и их срок службы ограничен. Мы не будем углубляться в эти вопросы (заинтересованные могут начать разбор вопроса с термина "write amplification") и ограничимся этой информацией, примя ее на веру.
Когда нужные данные выгружены в shared buffer, Исполнитель запроса (в нашем случае UPDATE запроса) вносит необходимые изменения. На выходе мы имеем бинарные данные, отражающие уже измененную версию того набора данных, который затронул наш запрос UPDATE. Теперь нам необходимо заменить старые данные, лежащие на диске на новые.
И на этом шаге мы подходим к той самой Durability. В дело вступает WAL.
Механизм Postgres записывает измененные данные в первую очередь в WAL (Write Ahead Log), который используется для сохраняемости, защиты от сбоев и восстановления данных. На данный момент достаточно представлять его как бинарный (мы не можем его прочитать, как текст) лог всех изменений.
Долгожданный COMMIT
Данные записываются в сегмент WAL (один из множества файлов размером 16мб по умолчанию) на диске. Предварительно определенное количество записей WAL накапливается в специальном буфере в памяти, но об этом позже. После записи в WAL те же данные сбрасываются на диск в непосредственные data files, заменяя старую версию. Data files это физическое представление табличных данных СУБД.
После сброса происходит фиксация - COMMIT, поскольку данные сохранены в WAL для защиты от сбоев и в data files. После фиксации транзакция успешно завершена и измененная версия данных становится доступна для чтения клиентом, как и для других операций.
О том, всегда ли в первую очередь данные пишутся в WAL, как настроить этот механизм иначе и оптимизировать его, какие существуют подводные камни и как найти баланс между надежностью и скоростью, а также о том, как работает сам WAL, поговорим во второй части, которая будет опубликована завтра.