✻ Урок 4.4 · Тема 4: Docker и Compose
SQL и PostgreSQL: запросы, индексы, EXPLAIN
Содержание урока
Зачем это нужно
Файл с заметками хорош, пока заметок сто и писатель один. Когда сервис живёт в нескольких копиях, десятки людей пишут одновременно, а данные нужно искать по условиям, файла не хватает. Для этого придумали базу данных (database): специальную программу, которая хранит данные, ищет в них, не даёт двум писателям испортить друг другу записи и не теряет сохранённое при сбое питания.
На работе ты будешь постоянно слышать «база тормозит». Половина таких случаев лечится одним индексом. Индекс (index) - это «оглавление» колонки: отдельная заготовка, по которой база находит нужные строки сразу, а не листает всю таблицу подряд, как ты ищешь слово в книге по алфавитному указателю, а не читая все страницы. Без индекса на миллионе строк поиск медленный, с индексом мгновенный. Увидеть, используется ли он, помогает план запроса (query plan): база по просьбе показывает, как именно собирается искать (команда EXPLAIN, разберём ниже). Ещё в базе бывают забытые WHERE (слово-условие в запросе: «только строки, где…»; забудешь его в команде удаления, и удалится всё), потерянные пароли и приложение, которое стартовало раньше базы. Все эти истории ты пройдёшь руками на настоящем PostgreSQL (популярная бесплатная база данных с открытым кодом, её используют тысячи компаний).
В этом уроке ты поднимаешь PostgreSQL 18 в контейнере, учишься читать и писать SQL (Structured Query Language, язык запросов: на нём ты «разговариваешь» с базой короткими фразами вроде «покажи все заметки автора Аня»), ломаешь скорость запроса и лечишь её индексом, а потом переводишь «Заметки» на хранение в базе.
Шаг проекта: «Заметки» переезжают с файла на PostgreSQL (STORE=postgres), /readyz проверяет базу, появляется демонстрационный /slowsql. Код приложения становится версией v4.
Что нужно знать
- Урок 4.2: Dockerfile: образ «Заметок» собирается из
Dockerfile, файлrequirements.txt(список чужих библиотек, которые нужны коду) в нём копируется первым слоем. Сегодня в него добавится первая настоящая зависимость (чужая библиотека, драйвер для общения с базой), и образ получит версиюnotes:0.4.0. - Урок 4.3: тома и сети Docker: том (volume) это папка Docker, которая переживает удаление контейнера. Сеть
notes-netдаёт контейнерам DNS-имена друг друга (имя превращается в адрес автоматически, урок 2.3). База будет жить в томе и в этой сети. - Урок 1.4: процессы и сигналы:
docker stopшлёт процессу сигнал SIGTERM («заверши работу аккуратно»), аdocker rm -fшлёт SIGKILL («убить немедленно»). Для базы это большая разница, ты её увидишь. - Урок 2.2: порты и TCP: порт это номер «двери» на машине, а
connection refusedзначит, что за дверью никто не слушает. - Урок 2.4: HTTP: коды ответа. В этом уроке встретятся 200 (успех), 500 (ошибка на стороне сервера) и 503 (сервис временно не может отвечать).
Картина целиком
Представь библиотеку. Читатель (клиент) не ходит по стеллажам сам. Он подходит к библиотекарю (сервер) и говорит: «Выдайте все книги Чехова, вышедшие после 1890 года». Библиотекарь знает, где что лежит, находит нужное по карточному каталогу (индекс), записывает выдачу в журнал (WAL, write-ahead log, «журнал упреждающей записи»: любое изменение сначала вписывается в него, и только потом в сами данные, поэтому после сбоя питания базу можно восстановить по журналу; подробно ниже) и следит, чтобы двоим не выдали одну и ту же последнюю книгу. Читателю неважно, как устроены стеллажи: он формулирует, что хочет, а не как искать.
PostgreSQL это такой библиотекарь. Клиентов у него два: psql (консольная программа, с которой ты вводишь запросы руками) и приложение «Заметки».
flowchart TB
subgraph HOST["Хост (твоя машина или ВМ)"]
CURL["curl :8080"]
subgraph NET["сеть Docker notes-net"]
APP["контейнер notes<br>app.py v4 (psycopg)"]
DB["контейнер db, alias db<br>PostgreSQL, порт 5432"]
end
VOL[("том notes-pgdata<br>файлы базы")]
end
CURL -->|"порт 8080 опубликован наружу"| APP
APP -->|"SQL по TCP, db:5432"| DB
DB --- VOL
Порт 8080 опубликован наружу, порт 5432 виден только внутри сети.
Дальше в теории каждая часть этой схемы разбирается по отдельности: что такое таблица (данные в строках и колонках, как лист в Excel) и SQL, как база защищает данные (ограничения: правила вроде «это поле не может быть пустым»; транзакции: группа действий, которая выполняется целиком или не выполняется вообще, как перевод денег: списали и зачислили либо ни то ни другое), почему запросы бывают быстрыми и медленными (индексы и EXPLAIN), как запускается PostgreSQL в контейнере и как приложение к нему подключается.
Теория
Зачем нужна база, если есть файл
Сейчас «Заметки» пишут по строке JSON (текстовый формат записи данных вида {"id": 1, "text": "купить молоко"}) в файл. Это работает, пока всё просто. Посмотри, где начинаются проблемы.
- Два писателя одновременно. Два запроса пришли в одну миллисекунду, оба открыли файл и дописали строку. В худшем случае строки перемешаются. В
app.pyдля этого стоит замокLOCK, но он работает только внутри одного процесса. Запусти две копии приложения, и замок бесполезен. - Поиск. Чтобы найти заметку по тексту, нужно прочитать весь файл. Заметок миллион: читаем миллион строк.
- Сбой посреди записи. Питание пропало, когда программа записала половину строки. В файле мусор, и никто не знает, какая запись последняя целая.
- Связи. Автор, его посты, комментарии к постам: в файле придётся самому следить, чтобы комментарий не ссылался на удалённый пост.
База данных берёт всё это на себя. Аналогия: файл это школьная тетрадь, в которую пишут все подряд. База это картотека с дежурным библиотекарем, который решает, кому и когда можно открыть ящик. Аналогия перестаёт работать в одном: библиотекарь это программа, поэтому ему нужны память, процессор и место на диске, и у него бывают свои поломки.
Прикинь сам: Заметок миллион, и нужно найти одну по тексту. Сколько строк прочитает приложение, которое хранит заметки в файле?
В худшем случае все миллион: файл читают подряд с первой строки. База с индексом найдёт нужную строку, прочитав одну.
Осторожно, частое заблуждение: «База данных» и «сервер базы данных» это разные вещи. Сервер (в нашем случае PostgreSQL) это работающая программа, а база это набор данных внутри неё. На одном сервере может жить много баз. В этом уроке их будет две: notes для приложения и lab для тренировки.
Главное: файл подходит, пока писатель один, а данных мало; база берёт на себя параллельную запись, поиск, сбои и связи.
Чтобы понять, как база всё это делает, посмотрим, как в ней лежат данные.
Проверь понимание: почему замок
LOCKвнутриapp.pyне спасает, если запустить две копии приложения, пишущие в один файл?
Ответ
Замок живёт в памяти одного процесса, вторая копия о нём ничего не знает. Каждая копия думает, что пишет в файл одна. База решает это на своей стороне: все копии приложения обращаются к одному серверу, и он сам расставляет записи по очереди.
Реляционная база: таблицы, строки, столбцы
Реляционная база (relational database) хранит данные в таблицах (tables). Ты видел таблицы в электронных таблицах: строки и столбцы. Здесь то же самое, но строже.
- Столбец (column) это одно поле у всех записей и у него есть тип: число, текст, время. В столбец с типом «число» нельзя положить слово.
- Строка (row) это одна запись: одна заметка, один автор.
- Таблица это набор строк с одинаковым набором столбцов.
Так выглядит таблица notes с тремя заметками (настоящий вывод psql, ты получишь такой же в задании 2 и в теории ниже):
id | text | created_at
----+-------------------+-------------------------------
1 | купить молоко | 2026-09-30 12:13:37.493464+00
2 | позвонить маме | 2026-09-30 12:13:37.493813+00
3 | оплатить интернет | 2026-09-30 12:13:37.493813+00
(3 rows)
Столбцов три: id (номер), text (текст), created_at (когда создана). Строк три. Слово «реляционная» происходит от relation (отношение): так математики называют таблицу. Между таблицами тоже бывают отношения, например «у автора много постов», их мы разберём в разделе про JOIN.
Как приложение «разговаривает» с базой? PostgreSQL это сервер: программа, которая работает постоянно, слушает порт 5432 и ждёт клиентов. Клиент подключается по сети (TCP, как в уроке 2.2), называет пользователя и пароль, а потом отправляет тексты запросов на языке SQL. Сервер выполняет их и возвращает таблицу-ответ.
SQL (Structured Query Language, «язык структурированных запросов») отличается от привычных языков программирования: ты описываешь, что хочешь получить, а не как это искать. Фраза «дай заметки с номером больше 10» не говорит, читать ли всю таблицу или заглянуть в индекс: это база решает сама.
Прикинь сам: В таблице
notesтри строки и три столбца. Сколько значений она хранит всего?
Девять: 3 × 3. Каждая из трёх строк это одна заметка с тремя полями.
Осторожно: SQL это язык, а не программа. PostgreSQL, MySQL, SQLite это разные программы, которые понимают почти один и тот же SQL. Ещё путают «таблицу в базе» и «файл»: файлы у базы свои, ты их не открываешь, а работаешь только через SQL.
Главное: реляционная база хранит данные в таблицах: у столбца свой тип, строка это одна запись, а сервер PostgreSQL это работающая программа, а не файл.
Данными управляют командами SQL. Их всего четыре.
Проверь понимание: чем строка отличается от столбца и сколько тех и других в таблице выше?
Ответ
Строка это одна запись целиком (заметка), столбец это одно поле у всех записей (например, text). В таблице три строки и три столбца.
SQL: четыре действия с данными
Почти всё, что делает приложение с данными, сводится к четырём действиям, их называют CRUD (Create, Read, Update, Delete): создать, прочитать, изменить, удалить. В SQL им соответствуют четыре команды.
| Действие | Команда | Что делает |
|---|---|---|
| создать | INSERT |
добавляет строку |
| прочитать | SELECT |
выбирает строки и столбцы |
| изменить | UPDATE |
меняет значения в существующих строках |
| удалить | DELETE |
удаляет строки |
Каждая команда заканчивается точкой с запятой: по ней клиент понимает, что запрос закончен. Ключевые слова (SELECT, WHERE) принято писать заглавными, но регистр не важен. Текстовые значения берут в одинарные кавычки: 'купить молоко'. Двойные кавычки означают имя таблицы или столбца, это другое.
Разберём на настоящем примере. Таблица notes создана командой из следующего раздела, сейчас нам важны только команды:
INSERT INTO notes (text) VALUES ('купить молоко');
Читается так: «вставь в таблицу notes в столбец text значение 'купить молоко'». Остальные столбцы (id, created_at) не названы: база заполнит их сама, как объясняется в следующем разделе. Ответ сервера INSERT 0 1 значит «вставлена 1 строка» (нуль это устаревший служебный номер, его можно игнорировать).
SELECT id, text FROM notes WHERE id > 1 ORDER BY id LIMIT 5;
По частям: SELECT id, text (какие столбцы показать), FROM notes (из какой таблицы), WHERE id > 1 (условие: только строки, где номер больше 1), ORDER BY id (сортировка по номеру), LIMIT 5 (не больше пяти строк). Звёздочка SELECT * означает «все столбцы». Настоящий ответ:
id | text
----+-------------------
2 | позвонить маме
3 | оплатить интернет
(2 rows)
UPDATE notes SET text = 'купить кефир' WHERE id = 1;
DELETE FROM notes WHERE id = 3;
Сервер ответил UPDATE 1 и DELETE 1: изменена одна строка, удалена одна строка. Это число нужно смотреть всегда. Если ты хотел изменить одну строку, а в ответе UPDATE 4000, что-то пошло не так.
Главная ловушка: забытый WHERE. Без условия UPDATE и DELETE относятся ко всем строкам таблицы. Вот что случилось бы с моей таблицей из четырёх заметок (запущено внутри транзакции, чтобы можно было откатить, о транзакциях чуть ниже):
BEGIN
UPDATE 4
id | text
----+------
1 | oops
2 | oops
5 | oops
6 | oops
(4 rows)
ROLLBACK
UPDATE 4 в ответе это сигнал тревоги. Защита: перед опасной командой открой транзакцию (BEGIN;), проверь число строк, а потом либо COMMIT;, либо ROLLBACK;. Ещё один приём: сначала напиши SELECT с тем же WHERE и посмотри, какие строки попадут под изменение.
NULL. В базе есть особое значение NULL: «нет данных». Это не ноль и не пустая строка, а отсутствие значения. Сравнение с ним ведёт себя необычно: x = NULL не бывает истинным, для проверки пишут x IS NULL. Именно поэтому count(столбец) не считает NULL (пример в разделе про JOIN).
RETURNING. У INSERT в PostgreSQL есть полезное дополнение: INSERT INTO notes (text) VALUES ('ещё одна') RETURNING id; сразу возвращает id созданной строки. Так приложение узнаёт номер новой заметки за один запрос. Мы увидим это в коде pg_add.
Прикинь сам: Ты выполнил
UPDATE notes SET text = 'x';на таблице из 4000 строк. Сколько строк изменится и что ответит сервер?
Все 4000: условия WHERE нет. Сервер ответит UPDATE 4000, и это число надо смотреть всегда.
Осторожно: команду DELETE FROM notes; считают «удалением таблицы»: на самом деле она удаляет все строки, а сама таблица остаётся. Удаляет таблицу команда DROP TABLE, и она куда опаснее.
Главное: четыре команды (
INSERT,SELECT,UPDATE,DELETE) покрывают CRUD, а число строк в ответе проверяют, ведь безWHEREменяется вся таблица.
Данные должны быть правильными. Это обеспечивает схема.
Проверь понимание: ты выполнил
UPDATE notes SET text = 'x';и сервер ответилUPDATE 4000. Что произошло и что ты сделаешь, если работал безBEGIN?
Ответ
Условия WHERE не было, поэтому текст сменился у всех 4000 заметок. Без BEGIN команда сама была маленькой транзакцией и уже применена, откатить её нельзя. Остаётся восстановление из резервной копии (бэкапа: сохранённого снимка данных, из которого базу можно поднять заново; как его делать, разберём ниже). Поэтому опасные команды выполняют внутри BEGIN и смотрят на число строк до COMMIT.
Схема: типы и ограничения
Таблицу нужно создать до первой записи. Описание таблиц называют схемой (schema). Для «Заметок» схема зафиксирована в контракте курса:
CREATE TABLE IF NOT EXISTS notes (
id serial PRIMARY KEY,
text text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
Разбор построчно:
CREATE TABLE IF NOT EXISTS notes: создай таблицуnotes, если её ещё нет. СловаIF NOT EXISTSделают команду безопасной для повторного запуска, приложение так делает при каждом старте.id serial PRIMARY KEY: столбецid. Типserialэто целое число, которое база выдаёт сама: 1, 2, 3 и так далее (автоинкремент).PRIMARY KEY(первичный ключ) значит «уникально и не пусто»: по нему строку можно назвать однозначно, и под него база автоматически строит индекс.text text NOT NULL: столбецtextтипаtext(произвольный текст).NOT NULLзапрещает пустое значение.created_at timestamptz NOT NULL DEFAULT now(): момент создания.timestamptz(timestamp with time zone) хранит время вместе с часовым поясом. Для серверных данных используй его, а неtimestampбез пояса: иначе через год никто не вспомнит, в каком поясе записаны времена.DEFAULT now()подставляет текущее время, если при вставке значение не указано.
Слова NOT NULL, PRIMARY KEY, DEFAULT, UNIQUE, REFERENCES называют ограничениями (constraints): правилами, которые база проверяет при каждой записи. Вот они за работой (настоящие ответы сервера):
ERROR: null value in column "text" of relation "notes" violates not-null constraint
DETAIL: Failing row contains (4, null, 2026-09-30 12:13:37.495103+00).
ERROR: duplicate key value violates unique constraint "notes_pkey"
DETAIL: Key (id)=(1) already exists.
Первая ошибка: я вставил NULL в text. Вторая: попытался вставить второй id = 1. В обоих случаях строка не записана, а база объяснила почему. Заметь на первой ошибке: Failing row contains (4, null, ...). Число 4 это номер, который serial уже выдал, а вставка не удалась. Он пропал: следующая вставка получила 5. Такие «дыры» в номерах нормальны, serial гарантирует только уникальность, а не непрерывность. Ниже (в разделе про контейнеры) увидишь и более заметный скачок.
Особое ограничение это REFERENCES (внешний ключ, foreign key): значение столбца обязано существовать в другой таблице. Пост не может ссылаться на автора, которого нет. В задании 2 ты увидишь это в деле.
Зачем всё это, если приложение и так не отправляет пустой текст? Ограничения защищают данные лучше кода приложения: приложений может быть несколько, скрипты и ручные правки тоже ходят в базу, а сама база одна. Ограничение в ней работает для всех клиентов сразу.
Прикинь сам: В таблице
notesуже есть строка сid = 1. Что произойдёт, если вставить ещё одну строку сid = 1, и сколько строк станет в таблице?
База откажет с ошибкой duplicate key value violates unique constraint. Строк останется столько же, ведь запись не выполнена.
Осторожно: PRIMARY KEY считают «просто номером строки». На самом деле это правило (уникально и не пусто) плюс автоматический индекс. serial не «заполняет пробелы» после удалённых строк: удалил строку 3, следующая будет 4 или больше, а не 3.
Главное: ограничения (
NOT NULL,PRIMARY KEY,REFERENCES) база проверяет при каждой записи от любого клиента, а не только от твоего приложения.
Теперь связи между таблицами: как склеить данные из нескольких.
Проверь понимание: зачем
NOT NULLв базе, если приложение и так проверяет, что текст не пустой?
Ответ
В базу пишет не только твоё приложение: миграции (скрипты изменения схемы), другие сервисы, ручные правки через psql. Ограничение в БД работает для всех клиентов и не зависит от багов конкретного кода.
JOIN и агрегаты: несколько таблиц вместе
Данные обычно лежат в нескольких связанных таблицах. Пример для тренировки: таблица авторов и таблица постов. В posts есть столбец author_id, который ссылается на id из authors. Хранить имя автора в каждом посте не нужно: достаточно номера, а имя подтянется при запросе.
authors posts
id | name id | author_id | title
----+------- ----+-----------+------------
1 | anna 1 | 1 | про nginx
2 | boris 2 | 1 | про TLS
3 | vera 3 | 2 | про Docker
JOIN («соединение») склеивает строки двух таблиц по условию. Как это происходит по шагам: для каждой строки левой таблицы база ищет в правой строки, у которых выполнено условие ON, и выдаёт склеенные пары. Есть два основных вида:
INNER JOIN(или простоJOIN) оставляет только пары, где совпадение нашлось. Автор без постов исчезнет.LEFT JOINоставляет все строки левой таблицы. Если справа совпадения нет, недостающие столбцы заполняютсяNULL.
Настоящий результат LEFT JOIN из моего прогона:
name | title
-------+------------
anna | про nginx
anna | про TLS
boris | про Docker
vera |
(4 rows)
Vera осталась в результате, а справа у неё пусто (NULL). Теперь агрегаты. Агрегатные функции (count, sum, max) сворачивают много строк в одно значение. GROUP BY делит строки на группы, и функция считается для каждой группы отдельно:
SELECT a.name, count(p.id) AS posts
FROM authors a
LEFT JOIN posts p ON p.author_id = a.id
GROUP BY a.name
ORDER BY posts DESC, a.name;
Здесь a и p это короткие псевдонимы таблиц (authors a), AS posts даёт столбцу-результату имя. Вычисление руками: группа anna состоит из двух склеенных строк, count(p.id) даёт 2; boris из одной, 1; у vera одна склеенная строка, но p.id в ней NULL, а count(столбец) NULL не считает, поэтому 0. Теперь замени count(p.id) на count(*), который считает строки, а не значения:
name | posts name | posts
-------+------- -------+-------
anna | 2 anna | 2 слева count(p.id), справа count(*)
boris | 1 boris | 1
vera | 0 vera | 1 <- строка-пустышка от LEFT JOIN
(Это два отдельных настоящих результата, я поставил их рядом для сравнения.) Итог: count(*) посчитал единственную строку-пустышку, которую породил LEFT JOIN, и соврал про vera. А INNER JOIN вообще выбросил бы vera:
name | posts
-------+-------
anna | 2
boris | 1
(2 rows)
Прикинь сам: В
authorsтри автора (anna, boris, vera), вpostsтри поста (2 у anna, 1 у boris). Сколько строк вернётINNER JOIN, а сколькоLEFT JOIN?
INNER JOIN вернёт 3 строки: vera без постов исчезнет. LEFT JOIN вернёт 4: vera останется с пустым title (NULL).
Осторожно, частое заблуждение: «JOIN это склейка столбцов» верно, но новички забывают про условие ON. Без него получается каждая строка с каждой, и на больших таблицах запрос никогда не заканчивается. Ещё путают count(*) и count(столбец): первый считает строки, второй только непустые значения.
Главное:
JOINсклеивает таблицы по условию:INNERоставляет только совпадения,LEFTсохраняет все строки левой таблицы.
Когда команд несколько и они связаны, нужны транзакции.
Проверь понимание: ты хочешь список авторов, у которых нет ни одного поста. Какой
JOINнужен и что отфильтруешь?
Ответ
LEFT JOIN, потому что INNER JOIN потерял бы именно этих авторов. После соединения оставляешь строки, где столбец из правой таблицы IS NULL: WHERE p.id IS NULL. Останется vera.
Транзакции и ACID
Перевод денег между счетами это два действия: списать у одного, зачислить другому. Если сервер упадёт между ними, деньги исчезнут. Нужен способ сказать базе: «эти два действия либо оба, либо ни одного». Это транзакция (transaction): группа команд «всё или ничего».
BEGIN;открывает транзакцию.- Дальше идут команды, и видишь их результат только ты.
COMMIT;фиксирует всё сразу, теперь это видят остальные.ROLLBACK;отменяет всё, будто ничего не было.
Аналогия: ты собираешь заказ в корзине интернет-магазина. Пока не нажал «Оплатить», склад ничего не списал и другие покупатели твоей корзины не видят. Нажал «Оплатить» (COMMIT), и заказ стал настоящим. Закрыл вкладку (ROLLBACK или обрыв связи), и корзина исчезла. Аналогия перестаёт работать в том, что корзина не блокирует товар для других, а транзакция с изменением строки блокирует её (об этом ниже).
Настоящий эксперимент с двумя клиентами. Клиент A открыл транзакцию и изменил заметку 1, но не сделал COMMIT. Пока идёт транзакция, клиент B читает ту же заметку:
-- клиент B, пока транзакция A открыта:
id | text
----+--------------
1 | купить кефир
(1 row)
B видит старый текст: чужие несохранённые изменения невидимы (это и есть изоляция). Если B попытается изменить ту же строку, он будет ждать, пока A закончит. Я поставил B лимит ожидания в 1 секунду:
ERROR: canceling statement due to lock timeout
CONTEXT: while updating tuple (0,4) in relation "notes"
Когда A выполнил COMMIT, B увидел из транзакции. Ещё один эксперимент: клиент открыл BEGIN, выполнил UPDATE notes SET text = 'не дойдёт до COMMIT'; (ответ UPDATE 4) и отключился, не дойдя до COMMIT. Следующий запрос показал таблицу без этих изменений: сервер сам откатил оборванную транзакцию.
ACID это четыре свойства, которые делают транзакции надёжными:
- Atomicity (атомарность): всё или ничего, как в примере с оборванным клиентом.
- Consistency (согласованность): после транзакции все ограничения (
NOT NULL,REFERENCES) выполнены, иначе транзакция отклонена. - Isolation (изоляция): параллельные транзакции не видят чужих несохранённых изменений.
- Durability (долговечность): после
COMMITданные не пропадут, даже если в ту же секунду отключат питание.
Как база обеспечивает долговечность? Перед изменением файлов данных она записывает описание изменения в отдельный журнал WAL (write-ahead log, «журнал упреждающей записи»). После сбоя сервер при старте перечитывает журнал и доделывает или отменяет то, что не закончилось. Журнал пишется последовательно и быстро, а таблицы обновляются позже.
Каждая одиночная команда без BEGIN это своя маленькая транзакция с автоматическим COMMIT. Поэтому забытый WHERE без BEGIN необратим (см. выше). А забытая открытая транзакция опасна по другой причине: она держит блокировки и не даёт фоновой очистке (vacuum) убирать старые версии строк. База хранит и старую, и новую версию изменённой строки, пока их могут читать открытые транзакции, а очистка их выбрасывает. Долгая транзакция, и таблица пухнет.
stateDiagram-v2
[*] --> Открыта: BEGIN
Открыта --> Открыта: UPDATE, INSERT (видишь только ты)
Открыта --> Зафиксирована: COMMIT (теперь видят все)
Открыта --> Отменена: ROLLBACK или обрыв соединения
Зафиксирована --> [*]
Отменена --> [*]
Прикинь сам: Клиент выполнил
BEGINи триUPDATE, после чего упал доCOMMIT. Сколько из трёх изменений сохранится?
Ни одного: сервер откатит транзакцию при разрыве соединения. Это атомарность: всё или ничего.
Осторожно: думают, что ROLLBACK можно сделать после COMMIT. Нельзя: COMMIT это точка невозврата. Ещё думают, что ошибка внутри BEGIN сама закроет транзакцию: она остаётся открытой в состоянии «прервана», и нужно выполнить ROLLBACK.
Главное: транзакция выполняется целиком или не выполняется совсем (ACID), а
COMMITэто точка невозврата.
База работает быстро, пока данных немного. На большом объёме помогают индексы.
Проверь понимание: клиент выполнил
BEGINи триUPDATE, после чего упал доCOMMIT. Что будет с изменениями?
Ответ
Сервер увидит разрыв соединения и откатит транзакцию: ни одно из трёх изменений не сохранится. Именно поэтому COMMIT это момент, после которого данные считаются надёжными.
Индексы и EXPLAIN
Запрос «найди заметку с текстом заметка 777777» среди миллиона строк. Без подсказки базе остаётся читать таблицу подряд от первой строки до последней и проверять условие в каждой. Это последовательное сканирование (Seq Scan). Оно честное, но время растёт вместе с таблицей.
Индекс (index) это отдельная структура, которая помогает искать быстрее. Аналогия: предметный указатель в конце учебника. Чтобы найти слово «транзакция», ты не листаешь все страницы, а смотришь в алфавитный указатель, находишь номер страницы и открываешь её. Аналогия перестаёт работать в том, что указатель в книге не меняется, а индекс нужно обновлять при каждой записи.
Как устроен индекс по умолчанию, B-tree (сбалансированное дерево): значения хранятся отсортированными, а поверх них лежат уровни «указателей», как оглавление оглавления. Чтобы найти значение, база спускается с верхнего уровня вниз, на каждом шаге отбрасывая большую часть данных. Для миллиона строк хватает трёх-четырёх шагов. Время растёт не пропорционально размеру таблицы, а очень медленно (логарифмически): в тысячу раз больше данных, всего на пару шагов больше.
У индекса есть цена, и её видно в настоящих числах. Размеры таблицы и индексов после моего эксперимента (миллион строк):
Name | Type | Size
big_notes | table | 65 MB <- сами данные
big_notes_pkey | index | 21 MB <- индекс под PRIMARY KEY (по id)
big_notes_text_idx | index | 39 MB <- индекс, который создали мы (по text)
Индекс по text весит больше половины таблицы. Есть и вторая цена: каждую вставку и обновление база делает дважды, в таблицу и во все индексы. Я вставил по 500 000 строк в две одинаковые таблицы, одна с дополнительным индексом по тексту, другая без:
без индекса по text: Time: 605.021 ms
с индексом по text: Time: 2308.908 ms
Запись с индексом оказалась почти в четыре раза медленнее. Поэтому индексы создают под реальные запросы, а не «на всякий случай»: каждый лишний это место на диске и тормоза при записи.
EXPLAIN показывает план запроса (query plan): как база собирается его выполнять, не выполняя. EXPLAIN ANALYZE запрос ещё и выполняет и показывает реальное время. Разбор плана для запроса SELECT * FROM big_notes WHERE text = 'заметка 777777' без индекса (настоящий вывод, PostgreSQL 18):
Gather (cost=1000.00..14532.43 rows=1 width=33) (actual time=12.857..15.123 rows=1.00 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Parallel Seq Scan on big_notes (cost=0.00..13532.33 rows=1 width=33) (actual time=10.845..11.753 rows=0.33 loops=3)
Filter: (text = 'заметка 777777'::text)
Rows Removed by Filter: 333333
Planning Time: 0.133 ms
Execution Time: 15.148 ms
Читать план надо снизу вверх и изнутри наружу (самая глубокая строка выполняется первой). Что в нём значит:
Parallel Seq Scan on big_notes: последовательное сканирование таблицы, причём сразу несколькими процессами.Workers Launched: 2значит два помощника плюс основной процесс: три исполнителя (loops=3).Gatherсобирает результаты.FilterиRows Removed by Filter: 333333: каждый из трёх исполнителей прочитал и выбросил по 333 333 строки ради одной нужной. Большое число здесь это главный признак «нужен индекс».cost=1000.00..14532.43: оценка стоимости в условных единицах (сколько работы, по мнению планировщика: это часть PostgreSQL, которая перебирает способы выполнить запрос и выбирает самый дешёвый). Первое число это стоимость до первой строки, второе до последней. Единицы не секунды, ими сравнивают варианты плана между собой.rows=1: сколько строк планировщик ожидает получить.actual time=...иrows=1.00это уже факт. Большая разница между ожиданием и фактом значит, что статистика устарела, и помогает командаANALYZE таблица(она пересчитывает статистику, которой пользуется планировщик).Execution Time: 15.148 ms: сколько запрос выполнялся на самом деле.
После создания индекса тот же запрос:
Index Scan using big_notes_text_idx on big_notes (cost=0.42..8.44 rows=1 width=33) (actual time=0.022..0.023 rows=1.00 loops=1)
Index Cond: (text = 'заметка 777777'::text)
Index Searches: 1
Execution Time: 0.040 ms
Вместо чтения всей таблицы Index Scan: нашёл значение в индексе и прочитал единственную нужную строку. Выигрыш: 15.148 ms против 0.040 ms, почти в 380 раз. На моей машине таблица целиком помещается в память, поэтому seq scan непривычно быстрый: у тебя цифры будут другими, а на слабом сервере с диском разница ещё больше.
Когда индекс не помогает. Проверено на той же таблице:
WHERE text LIKE '%777'(поиск по концу строки) остаётсяParallel Seq Scan,Rows Removed by Filter: 333000. B-tree отсортирован по началу строки, а искать «всё, что заканчивается на 777», по нему нельзя.- Если условие отбирает половину таблицы (
created_at > now() - interval '500000 seconds'вернул 499 984 строки из миллиона), планировщик сам выбираетSeq Scan. Идти по индексу и прыгать по таблице за каждой строкой дороже, чем прочитать всё подряд. - Даже
LIKE 'заметка 7777%'(поиск по началу строки) при обычной языковой сортировке (в этом образеen_US.utf8) индекс не использовал: для такого поиска нужен особый вид индекса (text_pattern_ops). Вывод один: не гадай, а смотриEXPLAIN.
Ловушка EXPLAIN ANALYZE. Он выполняет запрос. Для SELECT это безопасно, а для UPDATE и DELETE он реально изменит данные. Оборачивай в BEGIN … ROLLBACK:
BEGIN
Delete on cmp_plain (cost=3523.69..11406.54 rows=0 width=0) (actual time=0.193..0.194 rows=0.00 loops=1)
-> Bitmap Heap Scan on cmp_plain (cost=3523.69..11406.54 rows=218228 width=6) (actual time=0.186..0.187 rows=10.00 loops=1)
...
Execution Time: 0.225 ms
count
--------
499990 <- внутри транзакции 10 строк уже удалены
ROLLBACK
count
--------
500000 <- после отката все на месте
Здесь заодно виден пример устаревшей статистики: планировщик ожидал rows=218228, а факт rows=10.00, потому что таблицу только что заполнили и статистику ещё не пересчитали.
Прикинь сам: Запрос по тексту занимал 15 мс без индекса и 0,04 мс с индексом. Во сколько раз быстрее, и во сколько раз стала медленнее вставка (605 мс против 2309 мс)?
Поиск быстрее в 15 / 0,04 = 375 раз. Вставка медленнее примерно в 3,8 раза: каждую запись база делает и в таблицу, и во все индексы.
Осторожно, частое заблуждение: «Индекс ускоряет всё». Нет: он ускоряет поиск по своему столбцу для избирательных условий (отбирающих малую долю строк) и замедляет запись. Слово «избирательность» (selectivity) как раз про это: чем меньше доля отбираемых строк, тем полезнее индекс.
Главное: индекс ускоряет поиск избирательных условий, но замедляет запись и занимает место, а проверяют это через
EXPLAIN ANALYZE.
Теперь выясним, как с базой разговаривают приложение и человек.
Проверь понимание: запрос
WHERE created_at > ...тормозит на таблице в миллион строк. Что проверишь первым?
Ответ
EXPLAIN ANALYZE этого запроса. Если там Seq Scan, большой Rows Removed by Filter и большой Execution Time, а условие отбирает малую долю строк, нужен индекс по created_at. Если условие отбирает половину таблицы, индекс не поможет, планировщик сам выберет Seq Scan.
Клиент и сервер: как вообще разговаривают с базой
База это отдельная работающая программа, а не файл, который ты открываешь. Приложению, psql и коллеге нужен общий способ к ней обратиться. Без него каждая программа лезла бы в файлы базы сама, и мы вернулись бы к проблемам «двух писателей» из начала урока.
Банк и клиенты. Внутрь хранилища никто не заходит: все подходят к окошку, называют себя, просят операцию и получают ответ. Где аналогия перестаёт работать: к «окошку» базы могут подойти тысячи клиентов одновременно, и сервер ведёт их всех параллельно.
PostgreSQL слушает сетевой порт (по умолчанию 5432, как веб-сервер слушает 80 или 8080, урок 2.2). Клиент открывает соединение (connection) по TCP, представляется (имя пользователя, пароль, имя базы, к которой хочет попасть) и отправляет SQL-команды. Сервер выполняет каждую и возвращает результат: строки или слова вроде INSERT 0 1 («вставлена одна строка»). Когда клиент закончил, соединение закрывается. Открывать соединение не бесплатно (проверка пароля, выделение памяти на сервере), поэтому приложения держат несколько открытых заранее и переиспользуют их; это называют пулом соединений, ниже в разделе про приложение.
flowchart LR
P["psql (ты руками)"] -->|"TCP :5432"| S["сервер PostgreSQL<br>кто ты, какая база?<br>пароль проверен, SQL, ответ"]
A["«Заметки» (psycopg)"] -->|"TCP :5432"| S
S --> F[("файлы базы на диске<br>в томе notes-pgdata")]
Посмотрим на примере. Когда ты выполнишь docker exec -it db psql -U notes -d notes, происходит вот что. docker exec открывает оболочку внутри контейнера db. psql стартует и подключается к серверу на том же контейнере (адрес по умолчанию, локальный сокет), -U notes это «представиться пользователем notes», -d notes «хочу работать с базой notes». В ответ ты видишь приглашение notes=#: буква перед # это имя базы, а # значит, что у пользователя права суперпользователя (у обычного стоял бы >). Так ты сразу видишь, с кем ты разговариваешь.
Приложение делает то же самое, только не руками: берёт адрес из переменной DATABASE_URL вида postgresql://notes:пароль@db:5432/notes. Читается слева направо: протокол, пользователь, пароль, адрес сервера (db это имя контейнера в сети, урок 4.3), порт, имя базы.
Прикинь сам: В
DATABASE_URL=postgresql://notes:secret@db:5432/notesсколько раз встречается словоnotesи что оно означает в каждом месте?
Два раза: первое имя пользователя, второе имя базы. Хост здесь db, порт 5432, а пароль secret.
Осторожно: путают пользователя базы и пользователя Linux. Пользователь notes в PostgreSQL существует только внутри базы, к учётным записям операционной системы он не относится. Второе: «порт 5432 надо открывать наружу». Для работы приложения не надо: оно ходит к базе по внутренней сети Docker. Открытый наружу порт базы это лишняя дверь для чужих.
Главное: PostgreSQL это сервер на порту 5432: клиент подключается по сети, называет пользователя и базу, получает ответы на SQL.
К базе допускают не всех. Посмотрим, как устроены пользователи.
Проверь понимание: в
DATABASE_URLнаписаноpostgresql://notes:secret@db:5432/notes. Что в нём пароль, а что имя базы?
Ответ
Пароль это secret (между двоеточием после имени пользователя и @). Имя базы это последнее notes после слэша. Первое notes это имя пользователя, а db с портом 5432 это адрес сервера.
Пользователь и пароль в PostgreSQL: кто что может
Если любой, кто достучался до порта, может читать и удалять данные, база не защищена. Поэтому сервер требует представиться и проверяет, что этому пользователю разрешено.
Пропуск в офис: у каждого сотрудника свой, и на нём записано, в какие помещения ему можно. Где аналогия не работает: в PostgreSQL права выдают не только на «помещения» (базы), но и на отдельные «шкафы» (таблицы) и даже на отдельные действия (читать, вставлять, удалять).
Пользователь в PostgreSQL (он же роль, role) создаётся командой SQL или при первом запуске контейнера. Переменная POSTGRES_USER в образе postgres:18 создаёт пользователя с правами суперпользователя (может всё на этом сервере), POSTGRES_PASSWORD задаёт ему пароль, POSTGRES_DB создаёт базу. Они читаются только при первой инициализации пустого каталога данных. Запущенный позже контейнер на уже созданных данных эти переменные игнорирует: поменять POSTGRES_PASSWORD в команде запуска недостаточно, пароль остался внутри тома. Эту ловушку разберём подробно в следующем разделе.
Вот как это выглядит на деле. Для нашего учебного стенда достаточно одного пользователя notes с полными правами. В боевой системе делают иначе: отдельный пользователь для приложения с правом читать и писать только в нужные таблицы, и отдельный для бэкапов с правом только читать. Тогда кража пароля приложения не даёт удалить базу целиком. Этот принцип, «ровно столько прав, сколько нужно» (принцип наименьших привилегий), ты уже видел для пользователя в контейнере (урок 4.2).
Прикинь сам: У приложения утёк пароль. У пользователя права только
SELECTиINSERTв одну таблицу. Сколько таблиц злоумышленник сможет удалить?
Ни одной: DROP TABLE ему недоступен. С полными правами notes он удалил бы все.
Осторожно, частое заблуждение: что пароль можно хранить прямо в compose.yml или в Dockerfile. Нельзя: он попадёт в git или в образ и увидят все. Пароль передают отдельно (переменная окружения из файла, которого нет в git, урок 4.5).
Главное: у приложения должен быть отдельный пользователь с минимумом прав, а пароль не хранят в git и в образе.
Теперь запустим PostgreSQL в контейнере.
Проверь понимание: зачем приложению отдельный пользователь с ограниченными правами, если в учебном стенде хватает одного
notesс полными?
Ответ
Если пароль приложения утечёт (например, через ошибку в коде), с правами «только читать и писать в свои таблицы» злоумышленник не сможет удалить таблицы и базу целиком. С правами суперпользователя сможет всё.
PostgreSQL в контейнере
Образ (image) postgres:18 это готовый PostgreSQL, упакованный в контейнер (см. урок 4.2). При запуске контейнер с пустым каталогом данных выполняет инициализацию: создаёт кластер (так PostgreSQL называет каталог с данными одного сервера), пользователя и базу. Как узнать, какие имя и пароль задать? Через переменные окружения. Переменная окружения (environment variable) это именованная настройка, которую процессу передают при запуске; в Docker её задаёт флаг -e ИМЯ=значение. Образ читает три:
POSTGRES_USER: имя создаваемого пользователя (роль с правами администратора). У насnotes.POSTGRES_PASSWORD: его пароль.POSTGRES_DB: имя создаваемой базы. У нас тожеnotes.
Вот настоящие строки лога первого запуска (docker logs), по ним видно, что происходит:
fixing permissions on existing directory /var/lib/postgresql/18/docker ... ok
creating subdirectories ... ok
...
Success. You can now start the database server using:
...
waiting for server to start....LOG: starting PostgreSQL 18.6 ...
LOG: database system is ready to accept connections
done
server started
CREATE DATABASE
...
waiting for server to shut down....LOG: received fast shutdown request
Контейнер сначала запускает временный сервер, создаёт базу, останавливает его и только потом стартует настоящий. Это важно для готовности: в первые секунды сервер то отвечает, то нет, то снова нет.
Где лежат данные. Файлы базы должны жить в томе, иначе они исчезнут вместе с контейнером (урок 4.3). Начиная с PostgreSQL 18 образ хранит данные внутри версионного подкаталога: /var/lib/postgresql/18/docker (проверено: ls /var/lib/postgresql показал 18). Поэтому том монтируют на /var/lib/postgresql, а не на /var/lib/postgresql/data, как было в старых версиях. Ошибёшься с путём, и данные окажутся вне тома.
Подводный камень: переменные читаются только при инициализации. Если том уже содержит данные, образ пропускает инициализацию, и смена POSTGRES_PASSWORD ничего не меняет: пароль записан внутри данных кластера, а не берётся из переменной при каждом старте. Об этом сценарий в «Сломай и почини».
Порт 5432 наружу не публикуем. Флаг -p открывает порт контейнера на хосте и делает базу доступной всем, кто достучится до хоста. База нужна только приложению, а оно живёт в той же сети notes-net. Внутри сети порт доступен и без -p, а имя контейнера превращается в адрес встроенным DNS Docker (урок 4.3). Чтобы имя было понятным, мы задаём сетевой псевдоним --network-alias db: приложение обращается к db, как бы контейнер ни назывался.
Готовность. Сервер стартует несколько секунд. Программа pg_isready проверяет, принимает ли он подключения, и печатает один из ответов, которые я видел при запуске:
/var/run/postgresql:5432 - no response
/var/run/postgresql:5432 - rejecting connections
/var/run/postgresql:5432 - accepting connections
no response: сервера пока нет вообще, rejecting connections: он запущен, но ещё не готов, accepting connections: можно работать. Скрипты ждут именно последнюю строку циклом until ... do sleep 1; done.
Остановка и SIGKILL. docker stop шлёт SIGTERM: PostgreSQL заканчивает транзакции, сбрасывает данные и выходит. docker rm -f шлёт SIGKILL, сервер не успевает ничего: при следующем старте он восстанавливается из журнала WAL. Данные целы, но есть побочный эффект, который ты увидишь в задании 4: serial перескакивает вперёд (PostgreSQL заранее резервирует в журнале по 32 номера последовательности, и после аварийной остановки эти номера считаются использованными). В моём прогоне после docker rm -f следующая заметка получила id = 34 вместо 2.
Прикинь сам: Ты запустил контейнер с
POSTGRES_PASSWORD=one, потом пересоздал его с тем же томом иPOSTGRES_PASSWORD=two. Какой пароль действует?
one: переменные читаются только при первой инициализации пустого тома. Сменить пароль можно командой ALTER USER внутри базы.
Осторожно, частое заблуждение: «Задал пароль в docker run, значит он всегда действует». Пароль действует, пока том не создан заново. Ещё путают «контейнер запущен» и «база готова»: между ними несколько секунд.
Главное: контейнер читает
POSTGRES_USER,POSTGRES_PASSWORDиPOSTGRES_DBтолько при первом запуске с пустым томом, а готовность проверяютpg_isready.
База готова. Подключим к ней приложение.
Проверь понимание: ты изменил
POSTGRES_PASSWORDв командеdocker runи перезапустил контейнер с тем же томом. Какой пароль действует?
Ответ
Старый: пароль записан в данных кластера при первой инициализации, переменная больше не читается. Чтобы сменить пароль, нужен ALTER USER notes PASSWORD '...' внутри базы или новый пустой том (данные потеряются).
Приложение и база: подключение, параметры, проверка готовности
Как приложение находит базу и входит в неё? Ему передают строку подключения (connection string) в формате URL. У нас она лежит в переменной DATABASE_URL:
flowchart LR
U["postgresql://notes:ПАРОЛЬ@db:5432/notes"]
U --- A["postgresql: протокол"]
U --- B["notes (первое): пользователь"]
U --- C["ПАРОЛЬ: пароль"]
U --- D["db: имя хоста, сетевой псевдоним контейнера базы"]
U --- E["5432: порт"]
U --- F["notes (второе): имя базы"]
Приложение использует драйвер (библиотеку для общения с базой) psycopg версии 3. Он ставится через pip из requirements.txt, как вся зависимость Python-проекта.
Соединение на каждый запрос. Функция pg_connect() открывает новое соединение при каждом обращении. Это просто, но каждое соединение стоит времени (подключение по TCP, проверка пароля) и памяти: PostgreSQL запускает на каждое соединение отдельный процесс. Число соединений ограничено параметром max_connections (в этом образе 100, я проверил командой SHOW max_connections;). Под нагрузкой лимит кончится, и появится ошибка too many connections. Лекарство называется пул соединений (connection pool): приложение держит несколько открытых соединений и одалживает их запросам по очереди. В нашем учебном коде пула нет, и это осознанный долг.
Параметры запроса и SQL-инъекции. Вот как приложение добавляет заметку:
conn.execute("INSERT INTO notes (text) VALUES (%s) RETURNING id", (text,))
Текст заметки передаётся отдельно от SQL-команды, вторым аргументом (text,). Драйвер отправляет их серверу порознь, и данные никогда не становятся частью команды. Если бы код склеивал строку сам, пользователь мог бы прислать заметку x'); DROP TABLE notes; --, и получилась бы команда, которая удаляет таблицу. Это SQL-инъекция (SQL injection): данные, которые обманом превратились в код. Я отправил в приложение ровно такую заметку:
{"id": 34} 201
Заметка записалась как обычный текст x'); DROP TABLE notes; --, а таблица notes осталась на месте. Правило: значения от пользователя только параметрами (%s и кортеж), никогда не склейкой строк и не f"...".
Проверка готовности. Эндпоинт /healthz отвечает «процесс жив» и базу не трогает. Эндпоинт /readyz отвечает «могу обслуживать запросы» и проверяет базу запросом SELECT 1. Различие важно. Оркестратор (система, которая запускает контейнеры и следит за ними, в теме 5 это Kubernetes) использует две проверки:
- liveness («жив ли процесс»): если провалена, процесс перезапускают;
- readiness («готов ли принимать трафик»): если провалена, процесс не трогают, а просто не направляют к нему запросы.
Если бы /healthz проверял базу, то при недоступной базе оркестратор перезапускал бы все копии приложения, хотя перезапуск базу не вылечит: получился бы каскад перезапусков. Проверка базы место в readiness: «пока базы нет, трафик мне не шлите». Наше приложение при недоступной базе остаётся живым: /healthz даёт 200, /readyz 503, а /notes 500 {"error": "storage"}. Схему создаёт функция ensure_pg_schema() при первом успешном обращении к базе, поэтому приложение, которое стартовало раньше базы, само восстанавливается, как только база появилась.
Прикинь сам: Пользователь прислал заметку
x'); DROP TABLE notes; --. Сколько таблиц удалится, если приложение передаёт текст параметром(text,)?
Ни одной: заметка запишется как обычный текст, а таблица останется. Если подставить текст прямо в SQL, команда DROP TABLE выполнилась бы.
Осторожно: думают, что /readyz должен проверять всё подряд, включая внешние сервисы, от которых зависит сервис. Так делать опасно: сбой чужого сервиса снимет с трафика все ваши копии сразу. Проверяют только то, без чего запрос точно не выполнится.
Главное: значения от пользователя передают только параметрами,
/healthzпроверяет процесс, а/readyzбазу.
Остался вопрос о сохранности данных, и том тут не спасёт.
Проверь понимание: почему параметры передают как
(text,), а не подставляют текст прямо в SQL?
Ответ
Потому что тогда данные не становятся частью команды, и SQL-инъекция невозможна: что бы пользователь ни прислал, драйвер воспримет это как значение, а не как SQL. Заодно он сам разбирается с кавычками и спецсимволами.
Резервная копия: том это не бэкап
Том защищает данные от удаления контейнера, но не от ошибки человека: если кто-то выполнил DROP TABLE или DELETE без условия, том послушно сохранит уже пустую таблицу. Диск тоже может умереть вместе со всем томом. Копия, лежащая отдельно от базы, единственное, что спасает в таких случаях.
Том это записная книжка, которая лежит на вашем столе: она переживёт то, что вы уйдёте домой, но не переживёт пролитый на неё чай. Бэкап это ксерокопия книжки в другом здании. Оговорка: ксерокопия устаревает, её актуальность равна времени последнего копирования.
Для PostgreSQL есть утилита pg_dump: она подключается к базе как обычный клиент, читает данные и выводит их в виде текста из SQL-команд (CREATE TABLE ..., COPY ...). Чтобы восстановиться, этот текст скармливают psql в пустую базу. Читает pg_dump в одной транзакции со снимком данных, поэтому копия согласована, даже если во время копирования приложение пишет в базу.
Порядок такой:
pg_dumpделает текстовый файл (дамп).- Файл уносят с сервера (на другой диск, в объектное хранилище).
- Для восстановления создают пустую базу и подают дамп на вход
psql. - Проверяют, что данные на месте: копия, которую ни разу не восстанавливали, это гипотеза, а не бэкап.
Теперь на числах. Команды для нашего стенда (запуск не обязателен, они безопасны):
docker exec db pg_dump -U notes -d notes > notes-dump.sql
docker exec db createdb -U notes notes_restore
docker exec -i db psql -U notes -d notes_restore < notes-dump.sql
Разбор. Первая строка запускает pg_dump внутри контейнера, а > (перенаправление, урок 1.2) записывает вывод в файл notes-dump.sql на хосте. Вторая создаёт пустую базу notes_restore. Третья подаёт файл на вход psql (-i держит вход открытым, < подставляет файл вместо клавиатуры). После этого в notes_restore те же таблицы и строки, что были в notes в момент снимка. Размер файла зависит от данных: для нашей учебной базы это несколько килобайт.
Прикинь сам:
pg_dumpзапускается каждую ночь в 03:00, а все строки удалили в 10:00. Сколько часов данных потеряется?
7 часов: восстановишь состояние на 03:00, а записи с 03:00 до 10:00 пропадут. Том сохранил бы уже пустую таблицу.
Осторожно, частое заблуждение: «У меня есть том, значит есть бэкап». Нет: том лежит на том же диске, под тем же управлением и с теми же ошибками, что и база. Ещё путают копию и репликацию. Реплика (replica) - это второй сервер базы, который в реальном времени повторяет все изменения первого, как второй экземпляр документа, в который автоматически попадает каждая твоя правка. Поэтому реплика мгновенно повторяет и DROP TABLE (команда удаления таблицы), а копия хранит состояние на момент снимка.
Главное: том защищает от удаления контейнера, но не от ошибки человека; бэкап это копия в другом месте, которую хоть раз проверили восстановлением.
Проверь понимание: кто-то удалил все строки из таблицы в 10:00, а
pg_dumpзапускается каждую ночь в 03:00. Что вы восстановите и что потеряете?Ответ
Восстановите состояние на 03:00 и потеряете всё, что записано с 03:00 до 10:00. Чем чаще копии, тем меньше потеря. Для критичных баз добавляют непрерывное архивирование журнала WAL, чтобы восстанавливать состояние на любую минуту, но это отдельная тема.
Практика
Все команды выполняй там же, где ты работал в уроках 4.1-4.3 (ВМ с Docker). Сеть notes-net из урока 4.3 должна существовать: docker network inspect notes-net >/dev/null 2>&1 || docker network create notes-net. Если контейнер notes с прошлых уроков ещё запущен, останови его: docker rm -f notes 2>/dev/null. Выводы ниже настоящие, получены на postgres:18 (версия сервера 18.6). Значения, которые меняются от запуска к запуску (время, адреса, номера процессов), у тебя будут другими.
Задание 1. Поднимаем PostgreSQL 18 и заходим в psql
Цель: запустить базу в сети notes-net с томом и выполнить первый запрос.
Предскажи: после docker run с -d сервер стартует несколько секунд. Что увидит psql, если подключиться мгновенно? А что покажет \dt в свежей базе notes?
Ответ
Сразу после старта подключение не удастся: psql: error: connection to server on socket ... failed: No such file or directory, потому что сервер ещё инициализируется (временный сервер на init-фазе). \dt покажет Did not find any tables.: таблиц ещё нет.
Шаги. Сначала разберём команды.
Пароль. Команда openssl rand -base64 24 печатает 24 случайных байта в виде текста. Дальше | (пайп, урок 1.2) передаёт этот текст команде tr -d '/+=', которая удаляет символы /, + и =: / ломает разбор адреса, а + и = убраны про запас (в пароле внутри URL безопаснее обходиться буквами и цифрами). Конструкция $(...) подставляет результат команды на своё место, а export делает переменную PGPASS видимой для программ, которые ты запускаешь из этой оболочки.
- Сгенерируй пароль и сохрани в переменную оболочки (ниже он подставляется как
$PGPASS, в git его не кладём):
export PGPASS="$(openssl rand -base64 24 | tr -d '/+=')"
Команда молчит, это нормально. Переменная живёт только в этом окне терминала.
- Запусти контейнер. Разбор флагов:
-dработать в фоне;--name dbимя контейнера;--network notes-netподключить к сети;--network-alias dbдополнительное DNS-имя в сети;-e ...три переменные окружения для инициализации;-v notes-pgdata:/var/lib/postgresqlтомnotes-pgdata(Docker создаст его сам) на каталог данных;postgres:18образ. Порт наружу не публикуем: приложение обратится по имениdbвнутри сети.
docker run -d --name db --network notes-net --network-alias db \
-e POSTGRES_USER=notes -e POSTGRES_PASSWORD="$PGPASS" -e POSTGRES_DB=notes \
-v notes-pgdata:/var/lib/postgresql \
postgres:18
При первом запуске Docker скачает образ, это занимает время. В конце команда печатает длинный идентификатор контейнера.
- Дождись готовности и зайди в
psqlвнутри контейнера. Циклuntil ...; do sleep 1; doneповторяетpg_isready(проверка «принимает ли сервер подключения»), пока та не вернёт успех.docker exec -it db psql -U notes -d notesзапускает внутри контейнераdbклиентpsqlот имени пользователяnotes(-U) и подключает его к базеnotes(-d);-itнужен, чтобы можно было печатать команды руками.
until docker exec db pg_isready -U notes -d notes; do sleep 1; done
docker exec -it db psql -U notes -d notes
- Внутри
psqlвведи по строке (\qвыходит; команды с обратной косой чертой, как\dt, это команды самогоpsql, а не SQL):
SELECT version();
\dt
\q
Что должно получиться: цикл ожидания печатает по строке на каждую попытку, последняя accepting connections. У меня первая попытка попала в момент, когда сервер ещё не отвечал:
/var/run/postgresql:5432 - no response
/var/run/postgresql:5432 - accepting connections
Строк до успешной может быть больше (например, rejecting connections, если сервер уже запущен, но ещё не готов) или совсем не быть. Ответы psql (в интерактивном сеансе перед каждой командой стоит приглашение notes=#, ниже показан только вывод):
version
--------------------------------------------------------------------------------------------------------------------------
PostgreSQL 18.6 (Debian 18.6-1.pgdg13+2) on aarch64-unknown-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit
(1 row)
Did not find any tables.
Как читать вывод: в version() важны две вещи: 18.6 (мажорная версия 18, минорная у тебя может быть новее) и архитектура процессора (aarch64 у меня, у тебя может быть x86_64: это не ошибка). Did not find any tables. значит «таблиц нет»: база создана, но пуста.
Объясни себе:
- Почему том смонтирован на
/var/lib/postgresql, а не на/var/lib/postgresql/data? - Почему мы не публикуем порт 5432 на хост и как приложение найдёт базу?
Типичные ошибки:
docker: Error response from daemon: Conflict. The container name "/db" is already in use by container ...: старый контейнер с таким именем, убериdocker rm -f db.psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: No such file or directory(следомIs the server running locally and accepting connections on that socket?): сервер ещё не поднялся или контейнер упал, смотриdocker logs db.Bind for 0.0.0.0:5432 failed: port is already allocated(у Docker в текстеfailed to set up container networking: driver failed programming external connectivity ...): появляется, если ты добавил-p 5432:5432, а порт занят хостовым PostgreSQL или другим контейнером. Нам порт наружу не нужен.
Задание 2. SELECT, INSERT, JOIN и транзакция
Цель: набить руку на SQL в отдельной учебной базе lab, не трогая рабочую notes.
Предскажи: у автора без постов LEFT JOIN вернёт count(p.id) равный чему? А count(*)?
Ответ
count(p.id) даст 0 (NULL не считаются), count(*) даст 1 (строка-результат существует, хоть справа пусто).
Шаги. Команды ниже используют два приёма. docker exec db createdb -U notes lab запускает в контейнере утилиту createdb, которая создаёт пустую базу lab. Конструкция docker exec -i db psql ... <<'SQL' ... SQL (heredoc, урок 1.6) отправляет весь текст между <<'SQL' и строкой SQL на вход psql: так удобно выполнять много команд сразу. Флаг -i держит вход открытым; кавычки вокруг SQL запрещают оболочке трогать $ и другие символы внутри текста.
- Создай базу и таблицы:
docker exec db createdb -U notes lab
docker exec -i db psql -U notes -d lab <<'SQL'
CREATE TABLE authors (id serial PRIMARY KEY, name text NOT NULL UNIQUE);
CREATE TABLE posts (
id serial PRIMARY KEY,
author_id int NOT NULL REFERENCES authors(id),
title text NOT NULL
);
INSERT INTO authors (name) VALUES ('anna'), ('boris'), ('vera');
INSERT INTO posts (author_id, title) VALUES (1, 'про nginx'), (1, 'про TLS'), (2, 'про Docker');
SQL
Ожидаемый ответ: CREATE TABLE, CREATE TABLE, INSERT 0 3, INSERT 0 3 (по строке на команду; createdb ничего не печатает).
- Запрос с
LEFT JOIN(разобран в теории):
docker exec -i db psql -U notes -d lab <<'SQL'
SELECT a.name, count(p.id) AS posts
FROM authors a LEFT JOIN posts p ON p.author_id = a.id
GROUP BY a.name ORDER BY posts DESC, a.name;
SQL
- Транзакция с откатом и нарушением ограничения:
docker exec -i db psql -U notes -d lab <<'SQL'
BEGIN;
DELETE FROM posts;
SELECT count(*) AS after_delete FROM posts;
ROLLBACK;
SELECT count(*) AS after_rollback FROM posts;
INSERT INTO posts (author_id, title) VALUES (99, 'нет такого автора');
SQL
Что должно получиться (первый запрос):
name | posts
-------+-------
anna | 2
boris | 1
vera | 0
(3 rows)
и транзакции:
BEGIN
DELETE 3
after_delete
--------------
0
(1 row)
ROLLBACK
after_rollback
----------------
3
(1 row)
ERROR: insert or update on table "posts" violates foreign key constraint "posts_author_id_fkey"
DETAIL: Key (author_id)=(99) is not present in table "authors".
Как читать вывод: таблица-ответ состоит из строки заголовков, линии и строк данных, внизу (N rows). Ответы вида BEGIN, DELETE 3, ROLLBACK это «квитанции» команд: DELETE 3 значит, что внутри транзакции удалено три строки. after_delete равен 0, потому что внутри своей транзакции ты видишь свои изменения, а after_rollback вернулся к 3: ROLLBACK отменил удаление. Последний запрос упал с ERROR: внешний ключ не позволил сослаться на автора 99. Обрати внимание на DETAIL: база объясняет причину. Название ограничения posts_author_id_fkey она придумала сама.
Если сомневаешься, что делает запрос из этого задания, вставь его нейросети и попроси объяснить по частям (
SELECT,FROM,WHERE). Проверь ответ вpsql: нейросеть любит выдумывать столбцы, которых в твоей таблице нет.
Объясни себе:
- Почему
veraосталась в результате, и что изменится приINNER JOIN? - Что доказал
after_rollback? - Какую защиту дал
REFERENCESи зачем она, если приложение «и так» передаёт верныйauthor_id?
Типичные ошибки:
ERROR: relation "authors" does not exist: ты подключился не к той базе (нет-d lab) или таблицы не создались.ERROR: column "a.name" must appear in the GROUP BY clause or be used in an aggregate function: вSELECTесть столбец, которого нет вGROUP BY(у меня так получилось, когда я убралGROUP BY): добавь его или оберни в агрегат.ERROR: duplicate key value violates unique constraint "authors_name_key"иDETAIL: Key (name)=(anna) already exists.: повторно выполнил вставку авторов,UNIQUEсработал: это норма.
Задание 3. Медленный запрос, EXPLAIN и индекс
Цель: воспроизвести «база тормозит», увидеть Seq Scan в плане и вылечить его индексом.
Предскажи: в таблице 1 000 000 строк, и мы ищем одну по точному значению text. Какой узел плана будет до индекса и какой после? Во сколько раз ускорится: в 2, в 100 или в 10000?
Ответ
До индекса Seq Scan (читает всю таблицу), после Index Scan (или Bitmap Index Scan). Ускорение порядка сотен раз, точные цифры зависят от машины: у меня 15 мс и 0,04 мс, то есть почти в 380 раз.
Шаги. Команда generate_series(1, 1000000) выдаёт числа от 1 до 1 000 000, а INSERT ... SELECT вставляет в таблицу по строке на каждое число. 'заметка ' || g склеивает слово и число (|| это склейка строк). now() - (g || ' seconds')::interval вычитает из текущего времени g секунд: ::interval превращает текст в промежуток времени. ANALYZE big_notes пересчитывает статистику для планировщика.
- Наполни таблицу миллионом строк в учебной базе
lab(данные псевдослучайные, но воспроизводимые). На моей машине заполнение заняло пару секунд:
docker exec -i db psql -U notes -d lab <<'SQL'
CREATE TABLE big_notes (
id serial PRIMARY KEY,
text text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO big_notes (text, created_at)
SELECT 'заметка ' || g, now() - (g || ' seconds')::interval
FROM generate_series(1, 1000000) AS g;
ANALYZE big_notes;
SQL
Ожидаемый ответ: CREATE TABLE, INSERT 0 1000000, ANALYZE.
- Запрос без индекса. Флаг
-cвыполняет одну команду и выходит:
docker exec db psql -U notes -d lab -c \
"EXPLAIN ANALYZE SELECT * FROM big_notes WHERE text = 'заметка 777777';"
- Создай индекс и повтори запрос:
docker exec db psql -U notes -d lab -c \
"CREATE INDEX big_notes_text_idx ON big_notes (text);"
docker exec db psql -U notes -d lab -c \
"EXPLAIN ANALYZE SELECT * FROM big_notes WHERE text = 'заметка 777777';"
Что должно получиться (цифры будут другими, важны узлы плана). Без индекса (PostgreSQL 18 сам добавил строки Buffers, я их здесь опустил для краткости):
Gather (cost=1000.00..14532.43 rows=1 width=33) (actual time=12.857..15.123 rows=1.00 loops=1)
Workers Planned: 2
Workers Launched: 2
-> Parallel Seq Scan on big_notes (cost=0.00..13532.33 rows=1 width=33) (actual time=10.845..11.753 rows=0.33 loops=3)
Filter: (text = 'заметка 777777'::text)
Rows Removed by Filter: 333333
Planning Time: 0.133 ms
Execution Time: 15.148 ms
CREATE INDEX отвечает CREATE INDEX (у меня 1,5 секунды), а после индекса:
Index Scan using big_notes_text_idx on big_notes (cost=0.42..8.44 rows=1 width=33) (actual time=0.022..0.023 rows=1.00 loops=1)
Index Cond: (text = 'заметка 777777'::text)
Index Searches: 1
Buffers: shared hit=1 read=3
Planning:
Buffers: shared hit=50 read=1
Planning Time: 0.258 ms
Execution Time: 0.040 ms
Как читать вывод: сначала найди тип узла: Parallel Seq Scan до индекса и Index Scan using big_notes_text_idx после. Затем Rows Removed by Filter: 333333 (сколько строк прочитано зря) и Execution Time. Подробный разбор каждого поля плана есть в теории, раздел «Индексы и EXPLAIN». Первый запрос после создания индекса чуть медленнее второго (read=3 значит, что часть страниц пришлось прочитать не из кэша): поэтому при измерениях запрос повторяют.
Вставь нейросети вывод
EXPLAIN ANALYZEи спроси, где запрос тратит время. Сверь ответ с типом узла (Seq ScanилиIndex Scan) и числомRows Removed by Filter: нейросеть иногда советует индекс там, где условие отбирает половину таблицы.
- Посмотри, сколько места занял индекс, и проверь, что
LIKE '%777'его не использует:
docker exec db psql -U notes -d lab -c '\di+ big_notes*'
docker exec db psql -U notes -d lab -c \
"EXPLAIN ANALYZE SELECT * FROM big_notes WHERE text LIKE '%777';"
Первая команда покажет размеры индексов (у меня big_notes_pkey 21 MB и big_notes_text_idx 39 MB), вторая опять Parallel Seq Scan и Rows Removed by Filter: 333000.
Объясни себе:
- Что значит
Rows Removed by Filterи почему это признак проблемы? - Индекс занял место на диске. Что ещё стало дороже, и когда индекс не стоит создавать?
- Почему запрос
WHERE text LIKE '%777'индекс не использует?
Типичные ошибки:
ERROR: relation "big_notes" does not exist: работаешь в базеnotes, а неlab.- Планировщик выбрал
Seq Scanдаже после индекса: не выполненANALYZE(устаревшая статистика) либо запрос отбирает большую долю таблицы. ERROR: canceling statement due to statement timeout: на очень слабой ВМ генерация миллиона строк не уложилась в лимит, уменьши до 300000 вgenerate_series.
Задание 4. Шаг проекта: «Заметки» переезжают на PostgreSQL
Цель: приложение v4 хранит заметки в базе, /readyz проверяет БД, /slowsql показывает влияние медленного запроса.
Предскажи: запустишь новое приложение с STORE=postgres, но раньше базы. Что покажет docker logs, и что вернёт /readyz? А если приложение стартует раньше готовности БД, оно должно упасть или ждать?
Ответ
В логе будет предупреждение база пока недоступна: failed to resolve host 'db' (имя db ещё ни на что не указывает) или connection refused, а /readyz вернёт 503: процесс жив (/healthz даёт 200), но обслуживать запросы не может. Это ровно разница liveness и readiness. Приложение не падает: соединение открывается на каждый запрос, а схему оно создаёт при первом успешном обращении, поэтому как только база поднимется, /readyz станет 200.
Шаги.
- Перейди в репозиторий и создай
db/schema.sql(схема из теории; приложение создаёт таблицу само, файл нужен, чтобы схема была в git и её можно было применить вручную). Командаmkdir -p dbсоздаёт каталог (-p: без ошибки, если он уже есть), аcat > файл <<'SQL' ... SQLзаписывает текст в файл:
cd ~/notes
mkdir -p db
cat > db/schema.sql <<'SQL'
-- схема хранилища "Заметок"; повторный запуск безопасен
CREATE TABLE IF NOT EXISTS notes (
id serial PRIMARY KEY,
text text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
SQL
- Добавь зависимость.
psycopg[binary]это драйвер PostgreSQL для Python; слово в квадратных скобках просит установить вместе с ним готовую собранную часть, чтобы не нужен был компилятор. Запись>=3.2,<4значит «версия 3.2 или новее, но не 4». Одинарные кавычки нужны, чтобы оболочка не приняла<и>за перенаправление ввода-вывода:
echo 'psycopg[binary]>=3.2,<4' > requirements.txt
Сборка образа (в следующем шаге) поставит psycopg 3.3.6 (актуально на 2026-09-30, у тебя может быть новее из ветки 3).
- Замени
app.pyэталоном версии v4. Этот файл заменяет твою версию из урока 2.4 целиком и сохраняет все прежние эндпоинты (/healthz,/slow,/error,/leak,/burnи остальные). Ниже разобрано, что в нём нового.
curl -fsSL -o app.py https://raw.githubusercontent.com/distinguished-sre/learning/main/devops/project/notes/versions/v4.py
grep -c psycopg app.py
Разбор curl: -f (вернуть ошибку при 404, а не сохранить страницу ошибки), -sS (тихо, но ошибки показать), -L (идти по перенаправлениям), -o app.py (сохранить в файл). grep -c считает строки с указанным словом: ненулевое число значит, что файл получен.
Что нового в v4. Выбор хранилища и подключение драйвера. Драйвер импортируется только при STORE=postgres, поэтому в файловом режиме он не нужен:
STORE = os.environ.get("STORE", "file")
DATABASE_URL = os.environ.get("DATABASE_URL", "")
# В файловом режиме сторонних зависимостей нет.
if STORE == "postgres":
try:
import psycopg
except ImportError:
print("STORE=postgres требует psycopg[binary]>=3.2,<4", file=sys.stderr)
sys.exit(2)
elif STORE != "file":
print("STORE должен быть file или postgres", file=sys.stderr)
sys.exit(2)
Схема и функции работы с базой (по одной на действие):
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS notes (
id serial PRIMARY KEY,
text text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
)
"""
def pg_connect():
# Соединение на каждый запрос: просто, но без пула и с лишней задержкой.
# Компромисс осознанный, пул появится, когда нагрузка это оправдает.
return psycopg.connect(DATABASE_URL, connect_timeout=3)
def pg_init():
with pg_connect() as conn:
conn.execute(SCHEMA_SQL)
def pg_list():
with pg_connect() as conn:
rows = conn.execute(
"SELECT id, text, created_at FROM notes ORDER BY id"
).fetchall()
return [
{"id": r[0], "text": r[1], "created_at": r[2].isoformat()} for r in rows
]
def pg_add(text):
with pg_connect() as conn:
# параметры передаются отдельно от SQL: защита от SQL-инъекций
return conn.execute(
"INSERT INTO notes (text) VALUES (%s) RETURNING id", (text,)
).fetchone()[0]
def pg_ready():
try:
with pg_connect() as conn:
conn.execute("SELECT 1")
return True
except psycopg.Error:
return False
def pg_sleep(sec):
with pg_connect() as conn:
conn.execute("SELECT pg_sleep(%s)", (sec,))
Как читать этот код. with pg_connect() as conn: открывает соединение и закрывает его в конце блока, а при успехе делает COMMIT (при исключении откат). connect_timeout=3 не даёт приложению ждать недоступную базу дольше трёх секунд. pg_list превращает строки из базы в словари для JSON, а время в строку (isoformat()). pg_add использует RETURNING id, о котором шла речь в теории, и параметр %s. pg_ready возвращает True, если SELECT 1 прошёл, и False при любой ошибке драйвера. pg_sleep вызывает функцию PostgreSQL, которая просто ждёт sec секунд: для демонстрации.
Дальше в файле функции ensure_pg_schema() (повторяет pg_init(), пока не получится), list_notes(), save_note(), storage_ready(), initialize_storage(): они выбирают между файлом и базой по STORE. Обработчик делает так:
GET /readyzприSTORE=postgresотвечает 200ready, если база отвечает, иначе 503not ready;GET /notesиPOST /notesработают черезpg_list()иpg_add(); при недоступной базе отвечают 500{"error": "storage"}и пишут причину в лог;GET /slowsql?sec=N(демонстрационный, в реальном сервисе его бы не было): целоеNот 0 до 30 (иначе 400sec must be 0..30), вызываетpg_sleep(N)и отвечает 200slept N; приSTORE=fileотвечает 501{"error": "postgres only"};- в
mainприSTORE=postgresвызываетсяinitialize_storage(): она пишет предупреждение в лог, если база недоступна, но не падает.
Полный файл версии v4 лежит в репозитории курса: project/notes/versions/v4.py.
- Обновлять
Dockerfileне нужно: он из урока 4.2 уже копируетrequirements.txtпервым слоем и ставит зависимости. Пересобери образ (-tдаёт ему имя и версию, точка означает «контекст сборки: текущий каталог»):
docker build -t notes:0.4.0 .
В выводе (у тебя он будет в формате твоего docker build, я привожу существенные строки) слой с зависимостями теперь не из кэша, потому что requirements.txt изменился:
=> [4/6] RUN pip install --no-cache-dir -r requirements.txt
...
Successfully installed psycopg-3.3.6 psycopg-binary-3.3.6
=> [5/6] COPY app.py .
=> naming to docker.io/library/notes:0.4.0
Предупреждение WARNING: Running pip as the 'root' user ... и useradd warning: notes's uid 10001 is greater than SYS_UID_MAX 999 безвредны: первое про запуск pip от root внутри сборки, второе про то, что номер пользователя выбран больше системного диапазона. Размер образа вырос до 250 MB (у меня) из-за драйвера.
- Запусти приложение в
notes-net(пароль из переменнойPGPASS, порт публикуем только на localhost).-p 127.0.0.1:8080:8080открывает порт 8080 контейнера на порту 8080 только для самой машины.-e STORE=postgresвключает режим базы,-e DATABASE_URL=...собирает строку подключения из разобранных в теории частей,${PGPASS}подставляет пароль.docker rm -f notes 2>/dev/nullв начале убирает старый контейнер с этим именем, если он есть, а сообщение об ошибке отправляет в никуда:
docker rm -f notes 2>/dev/null
docker run -d --name notes --network notes-net -p 127.0.0.1:8080:8080 \
-e STORE=postgres -e APP_VERSION=0.4.0 \
-e DATABASE_URL="postgresql://notes:${PGPASS}@db:5432/notes" \
notes:0.4.0
- Проверь всю цепочку, включая переживание пересоздания базы. Команда
curl -s -o /dev/null -w '%{http_code}\n'не печатает тело ответа (-o /dev/null), а выводит только код (-w).-X POST -d '...'отправляет POST с телом.docker rm -f dbубивает контейнер базы (SIGKILL), а следующийdocker runсоздаёт новый контейнер на том же томе:
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/readyz
curl -s -X POST -d '{"text":"первая заметка в PostgreSQL"}' http://127.0.0.1:8080/notes
curl -s http://127.0.0.1:8080/notes
docker rm -f db
docker run -d --name db --network notes-net --network-alias db \
-e POSTGRES_USER=notes -e POSTGRES_PASSWORD="$PGPASS" -e POSTGRES_DB=notes \
-v notes-pgdata:/var/lib/postgresql postgres:18
until docker exec db pg_isready -U notes -d notes; do sleep 1; done
curl -s http://127.0.0.1:8080/notes
curl -s -w ' %{http_code}\n' 'http://127.0.0.1:8080/slowsql?sec=2'
Что должно получиться:
200
{"id": 1}
[{"id": 1, "text": "первая заметка в PostgreSQL", "created_at": "2026-09-30T12:14:57.854796+00:00"}]
db
d0c1...длинный идентификатор нового контейнера...
/var/run/postgresql:5432 - rejecting connections
/var/run/postgresql:5432 - accepting connections
[{"id": 1, "text": "первая заметка в PostgreSQL", "created_at": "2026-09-30T12:14:57.854796+00:00"}]
slept 2 200
Время в created_at у тебя будет своё, идентификатор контейнера тоже. Заметка пережила удаление контейнера db, потому что данные лежат в томе notes-pgdata.
Как читать вывод: 200 это /readyz: приложение видит базу. {"id": 1} это ответ на POST: база выдала заметке номер 1 через RETURNING. Строка db печатается командой docker rm -f db. Между двумя accepting connections цикл ждал, пока новый контейнер поднимется: rejecting connections значит «запущен, но не готов». Последняя строка: slept 2 это тело ответа /slowsql, 200 код (curl печатает его из-за -w), и запрос действительно занял две секунды: приложение отправило базе SELECT pg_sleep(2).
- Посмотри, что видит база, пока идёт медленный запрос. Первая команда запускает
/slowsqlна 20 секунд в фоне (&в конце возвращает управление терминалу), вторая через секунду спрашивает у базы, чем она занята.pg_stat_activityэто системная таблица со всеми текущими соединениями:
curl -s 'http://127.0.0.1:8080/slowsql?sec=20' >/dev/null &
sleep 1
docker exec db psql -U notes -d notes -c \
"SELECT pid, usename, state, wait_event, now()-query_start AS runs, left(query,40) AS query FROM pg_stat_activity WHERE datname='notes' AND pid <> pg_backend_pid();"
pid | usename | state | wait_event | runs | query
-----+---------+--------+------------+-----------------+---------------------
43 | notes | active | PgSleep | 00:00:01.057309 | SELECT pg_sleep($1)
(1 row)
Это тот же инструмент, которым на работе находят «зависшие» запросы. state = active значит «выполняется прямо сейчас», runs сколько уже идёт, pid номер процесса, которого при необходимости можно остановить командой SELECT pg_cancel_backend(pid). Условие pid <> pg_backend_pid() убирает из списка само соединение psql.
- Убедись, что
docker rm -f(SIGKILL) оставил след в номерах, а неожиданная заметка не ломает SQL:
curl -s -X POST -d '{"text":"вторая заметка"}' http://127.0.0.1:8080/notes
У меня ответ был {"id": 34}, а не {"id": 2}: после аварийной остановки PostgreSQL пропустил зарезервированные номера (см. теорию про SIGKILL). У тебя число может быть другим, и это нормально.
- Эксперимент «база пропала»: остановим её штатно и посмотрим на приложение.
docker stop db
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/healthz
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/readyz
curl -s -w ' %{http_code}\n' http://127.0.0.1:8080/notes
docker logs --tail 2 notes
docker start db
until docker exec db pg_isready -U notes -d notes; do sleep 1; done
curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/readyz
Ожидаемый вывод: db, затем 200 (healthz жив), 503 (readyz не готов), {"error": "storage"} 500, две строки лога (первая ERROR ошибка хранилища: failed to resolve host 'db': [Errno -2] Name or service not known, вторая запись access-лога с 500), db, ожидание готовности и в конце 200. Обрати внимание на текст: контейнер остановлен, и Docker убрал его имя из DNS, поэтому приложение видит не Connection refused, а «имя не найдено». Приложение при этом не перезапускалось и само восстановилось.
Состояние проекта после урока: app.py версии v4, requirements.txt, db/schema.sql, образ notes:0.4.0, PostgreSQL 18 в сети notes-net (5432 внутри сети), долг: пароль базы передаётся переменной окружения (закроется в теме 9).
Зафиксируй в git:
git add app.py requirements.txt db/schema.sql
git commit -m "Хранилище PostgreSQL: STORE=postgres, readyz по БД, slowsql"
Объясни себе:
- Почему
/readyzтеперь проверяет базу, а/healthzнет? Что произойдёт с трафиком, если БД недоступна, а мы проверяли бы её в liveness? - Зачем параметры передаются как
(text,), а не подстановкой строки в SQL?
Типичные ошибки:
FATAL: password authentication failed for user "notes"(в логе приложения целиком:connection failed: connection to server at "172.18.0.3", port 5432 failed: FATAL: password authentication failed for user "notes"): пароль вDATABASE_URLне совпадает с тем, что записан в томе при инициализации. ПроверьPGPASS(в новом окне терминала переменная пропала) или смени пароль черезALTER USER.failed to resolve host 'db': [Errno -2] Name or service not known: контейнеры не в одной пользовательской сети или база не запущена, проверьdocker network inspect notes-net.STORE=postgres требует psycopg[binary]>=3.2,<4и контейнер сразу завершился (код 2): образ собран до правкиrequirements.txt(или в нём нет драйвера), пересобериdocker build.- Таблицы нет (
relation "notes" does not exist) при ручной работе черезpsql: если приложение ни разу не подключалось к базе, схемы ещё нет, примени её вручную:docker exec -i db psql -U notes -d notes < db/schema.sql.
Сломай и почини
Четыре поломки на стенде из задания 4 (контейнеры db и notes работают, приложение пишет в базу). Скрипт ломает стенд, а ты диагностируешь и чинишь. Правило: сначала симптом, потом гипотезы, потом проверка, и только затем действие. Скрипт не требует sudo.
Скачай скрипт и запусти нужный сценарий. curl -fsSL -o сохраняет файл (разбор флагов был в задании 4), bash файл 1 запускает скрипт с аргументом «сценарий 1»:
curl -fsSL -o /tmp/break-4.4.sh https://raw.githubusercontent.com/distinguished-sre/learning/main/devops/project/notes/break/4.4/break.sh
bash /tmp/break-4.4.sh 1
Скрипт печатает Сценарий 1 готов. и подсказку, с чего начать. Аргумент fix возвращает всё как было (запускай его между сценариями). Исходник скрипта короткий и читается: посмотри его после того, как починишь сам.
Сценарий 1. Приложение живо, но /readyz отвечает 503
Симптом. bash /tmp/break-4.4.sh 1, затем:
curl -si http://127.0.0.1:8080/readyz | head -1
curl -s http://127.0.0.1:8080/notes
docker ps --format '{{.Names}}: {{.Status}}'
Ты увидишь HTTP/1.0 503 Service Unavailable, ответ {"error": "storage"} на /notes, а оба контейнера в списке Up.
Гипотезы: база упала (нет: db в списке); приложение потеряло сеть (проверь по имени); база принимает подключения, но приложение не пускают.
Проверка. Посмотри лог приложения и проверь подключение с паролем из DATABASE_URL:
docker logs --tail 3 notes
docker exec db pg_isready -U notes -d notes
В логе будет причина FATAL: password authentication failed for user "notes", а pg_isready ответит accepting connections: сервер жив, проблема в пароле. Разница в том, что процесс базы работает, а роль notes теперь имеет другой пароль.
Разбор. Пароль роли хранится в самой базе (в томе), а приложение берёт свой из переменной DATABASE_URL. Если значения разошлись (кто-то выполнил ALTER USER, или контейнер пересоздали с другим POSTGRES_PASSWORD на старом томе: переменная действует только при первой инициализации тома), база честно отвечает отказом. Заметь, что /healthz при этом отвечает 200: процесс приложения здоров, а /readyz 503: работать он не может. Так и должно быть.
Починка. Задай ролям тот пароль, что в приложении. Быстрый способ: bash /tmp/break-4.4.sh fix. Ручной: docker exec db psql -U notes -d notes -c "ALTER USER notes PASSWORD '...'" со своим PGPASS. Затем проверь curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8080/readyz (ожидаем 200).
Сценарий 2. Контейнеры живы, но приложение не находит базу
Симптом. bash /tmp/break-4.4.sh fix && bash /tmp/break-4.4.sh 2, затем curl -si http://127.0.0.1:8080/notes | head -1 даёт HTTP/1.0 500 Internal Server Error.
Гипотезы: сломан пароль (проверь лог); базы нет (docker ps покажет); контейнеры в разных сетях.
Проверка:
docker logs --tail 2 notes
docker network inspect notes-net --format '{{range .Containers}}{{.Name}} {{end}}'
В логе failed to resolve host 'db': [Errno -2] Name or service not known (это не «отказ», а «имя не найдено»: разница между DNS-ошибкой и Connection refused была в уроке 2.3). Во втором выводе останется одно имя notes, контейнера db в сети нет.
Разбор. Docker DNS даёт имя db только контейнерам, подключённым к одной пользовательской сети. Отключённый от сети контейнер работает, но для приложения его нет.
Починка: docker network connect --alias db notes-net db (или bash /tmp/break-4.4.sh fix). Проверка: /readyz возвращает 200.
Сценарий 3. Отчёт аналитика «вешает» базу
Симптом. bash /tmp/break-4.4.sh fix && bash /tmp/break-4.4.sh 3 создаст в базе lab таблицу events на 3 миллиона строк без индекса. В жалобе: «отчёт по событиям идёт дольше минуты, а сервис тормозит». Приложение живо, /readyz отвечает 200.
Гипотезы: мало памяти у контейнера; сеть; плохой запрос без индекса.
Проверка. Спроси у базы, чем она занята, и попроси план запроса (\timing печатает время выполнения):
docker exec db psql -U notes -d lab -c \
"SELECT pid, state, now()-query_start AS runs, left(query,50) AS query FROM pg_stat_activity WHERE datname='lab';"
docker exec db psql -U notes -d lab -c \
"EXPLAIN SELECT * FROM events WHERE user_id = 12345;"
В плане узел Parallel Seq Scan on events с Filter: (user_id = 12345): при трёх миллионах строк база читает всю таблицу ради нескольких десятков.
Разбор. Точно то же, что в задании 3, но масштаб больше, и поэтому пользователи чувствуют. Хвост длинного запроса занимает соединения и процессор, остальные запросы ждут.
Починка. Индекс по условию из запроса: CREATE INDEX events_user_id_idx ON events (user_id);, затем повторить EXPLAIN и убедиться, что узел стал Index Scan или Bitmap Heap Scan. Долгий запрос при необходимости останавливают SELECT pg_cancel_backend(<pid>);. Уборка стенда: bash /tmp/break-4.4.sh fix (удалит events).
Сценарий 4. «Открой базу наружу», а порт занят
Симптом. bash /tmp/break-4.4.sh fix && bash /tmp/break-4.4.sh 4, затем попробуй команду, которую скрипт напечатал:
docker run -d --name db-ext -p 127.0.0.1:5432:5432 -e POSTGRES_PASSWORD=x postgres:18
Docker ответит ошибкой вида Bind for 127.0.0.1:5432 failed: port is already allocated.
Гипотезы: порт занят другим контейнером; порт занят процессом на хосте; ошибка в самой команде.
Проверка:
docker ps --format '{{.Names}}: {{.Ports}}'
docker rm -f db-ext 2>/dev/null
Вторая команда убирает созданный, но не запущенный контейнер db-ext (Docker создаёт его перед попыткой занять порт). В списке видно, какой контейнер уже держит 127.0.0.1:5432. Если контейнеров с портом нет, ищи процесс на хосте (ss -ltnp | grep 5432, урок 2.2).
Разбор. Порт на одном адресе может слушать один процесс. Запомни и обратный вывод: базу наружу публиковать обычно не нужно, приложению хватает сети Docker. Если нужна разовая отладка, публикуют на 127.0.0.1, а не на все адреса.
Починка: остановить того, кто держит порт, или выбрать другой порт хоста (-p 127.0.0.1:5433:5432). Уборка: bash /tmp/break-4.4.sh fix.
Итог разделов. Во всех четырёх случаях приложение «живое», но не готово, и различить причины помогает только лог и прямая проверка базы. Порядок всегда один: /readyz и логи приложения, затем pg_isready, затем сеть, затем содержимое базы.
ИИ в помощь
Нейросеть хорошо объясняет SQL и читает планы запросов, но схему твоей базы она не видит и охотно выдумывает столбцы. Общие правила: ИИ-помощник.
Задача: написать и проверить запрос.
У меня PostgreSQL 18, таблица notes(id serial primary key, text text not null, created_at timestamptz not null default now()).
Напиши запрос, который вернёт 5 последних заметок, созданных за сутки.
Объясни каждую часть запроса. Не меняй таблицу и не используй других столбцов.
Проверь ответ: выполни запрос в psql внутри BEGIN, а затем ROLLBACK. Типичная ошибка нейросети: добавить столбец, которого нет в таблице, или забыть ORDER BY created_at DESC.
Задача: разобрать медленный запрос.
Вот запрос и вывод EXPLAIN ANALYZE: <вставь запрос и план>.
Таблица на миллион строк. Какой тип узла выполняется, сколько строк отброшено фильтром и почему запрос медленный?
Предложи индекс и объясни, при каких условиях он не поможет.
Проверь ответ: создай индекс и повтори EXPLAIN ANALYZE: должен появиться Index Scan. Типичная ошибка нейросети: советовать индекс для условия LIKE '%текст', которое B-tree не ускоряет.
Задача: проверить, нет ли в приложении SQL-инъекции.
Вот функция приложения, которая пишет заметку в PostgreSQL: <вставь код>.
Есть ли здесь SQL-инъекция? Покажи, как значения передаются в запрос, и как исправить.
Проверь ответ: убедись, что значения идут вторым аргументом execute, а не через f-строку или %. Попробуй отправить заметку x'); DROP TABLE notes; -- и проверь, что таблица цела.
Словарик урока
| Термин | Простыми словами |
|---|---|
| СУБД (DBMS) | программа, которая хранит данные и отвечает на запросы (PostgreSQL) |
| Реляционная база | данные в таблицах, связанных между собой по ключам |
| Таблица, строка, столбец | таблица это набор однотипных записей: строка это одна запись, столбец это одно свойство |
| SQL | язык запросов к реляционной базе |
| CRUD | четыре действия: создать, прочитать, изменить, удалить (INSERT, SELECT, UPDATE, DELETE) |
| Первичный ключ (PRIMARY KEY) | столбец, значение которого однозначно определяет строку |
| Внешний ключ (FOREIGN KEY) | столбец, ссылающийся на строку другой таблицы: база не даёт вписать несуществующую ссылку |
| Ограничение (constraint) | правило, которое база проверяет сама: NOT NULL, UNIQUE, REFERENCES |
| NULL | «значения нет»: не ноль и не пустая строка |
| JOIN | объединение строк двух таблиц по условию |
| Агрегат | функция над группой строк (count, sum) |
| Транзакция | группа команд, которая выполняется целиком или не выполняется вообще |
| ACID | четыре свойства транзакции: атомарность, согласованность, изоляция, долговечность |
| WAL | журнал, в который база записывает изменения до записи в файлы: помогает восстановиться после сбоя |
| Индекс | отдельная структура для быстрого поиска, ускоряет чтение и замедляет запись |
| EXPLAIN | команда, показывающая, как база собирается выполнить запрос (ANALYZE выполняет его и измеряет) |
| Seq Scan | чтение всей таблицы подряд |
| Строка подключения (DATABASE_URL) | адрес базы одной строкой: postgresql://пользователь:пароль@хост:порт/база |
| Параметризованный запрос | запрос, в котором значения передаются отдельно от текста SQL: защита от SQL-инъекций |
| SQL-инъекция | атака, при которой ввод пользователя становится частью SQL-команды |
| Liveness / readiness | «процесс жив» и «сервис готов работать»: у нас /healthz и /readyz |
| Пул соединений | набор заранее открытых соединений, которые переиспользуют |
| Реплика (replica) | второй сервер базы, который в реальном времени повторяет изменения первого |
| План запроса (query plan) | описание того, как база будет искать данные; его показывает EXPLAIN |
| Планировщик (planner) | часть PostgreSQL, которая выбирает самый дешёвый способ выполнить запрос |
| JSON | текстовый формат данных вида {"id": 1}, по такой строке «Заметки» писали в файл |
| Соединение (connection) | открытый канал между клиентом и сервером базы, по нему идут SQL-команды |
| Роль / пользователь | учётная запись внутри PostgreSQL с набором прав (не то же, что пользователь Linux) |
| Порт 5432 | порт, который PostgreSQL слушает по умолчанию |
pg_isready |
утилита, проверяющая, принимает ли сервер подключения |
Вопросы с собеседований
Раздел для повторения: ответь вслух, потом открой ответ. Вопросы с пометкой «часто» задают почти на каждом собеседовании по теме урока: начни с них. Короткие вопросы с пометкой «на скорость» тренируй на время: ответ за 30 секунд.
1. [middle] [часто] Что такое индекс в базе данных, как он ускоряет запросы и чем за это платишь?
Ответ
Индекс - отдельная структура (в PostgreSQL по умолчанию B-tree), по ней база находит строки без полного просмотра таблицы. Создаю его командой CREATE INDEX ON notes (user_id); на столбцы из WHERE, JOIN и ORDER BY. Платой становятся место на диске и замедление INSERT, UPDATE и DELETE, потому что индекс тоже обновляется. Эффект проверяю через EXPLAIN ANALYZE: ищу Index Scan вместо Seq Scan. На маленькой таблице планировщик может выбрать Seq Scan, и это нормально.
Что хотят услышать: структура для поиска без полного скана, B-tree, цена на запись и место, EXPLAIN ANALYZE, индексы не на всё подряд.
Красный флаг: «Индекс на каждый столбец, чтобы всё было быстро».
2. [junior] [часто] Что делает LEFT JOIN и чем он отличается от INNER JOIN?
Ответ
INNER JOIN оставляет только строки, у которых есть пара в обеих таблицах. LEFT JOIN сохраняет все строки левой таблицы, а для отсутствующих пар подставляет NULL (автор без постов остаётся в результате).
Что хотят услышать: пример с NULL и разница count(*) и count(колонка).
Красный флаг: «это одно и то же».
3. [middle] [часто] Расшифруй ACID.
Ответ
Atomicity (атомарность: всё или ничего), Consistency (согласованность: ограничения не нарушены до и после), Isolation (изоляция: параллельные транзакции не видят чужие незавершённые изменения), Durability (долговечность: после COMMIT данные переживут сбой, за это отвечает журнал WAL).
Что хотят услышать: по одному примеру на букву.
Красный флаг: называют только слова без смысла.
4. [junior] [на скорость] Чем реляционная база отличается от хранения в файле?
Ответ
База даёт параллельный доступ, транзакции, ограничения целостности, индексы для быстрого поиска и язык запросов. Файл же нужно блокировать и читать целиком, а согласованность обеспечивает только код приложения.
Что хотят услышать: конкурентная запись, целостность, поиск без чтения всего.
Красный флаг: «база нужна, потому что так принято».
5. [junior] Что такое первичный и внешний ключ?
Ответ
Первичный ключ однозначно определяет строку таблицы. Внешний ключ это столбец, ссылающийся на первичный ключ другой таблицы, и база не позволит сослаться на несуществующую строку (в задании 2 это была ошибка violates foreign key constraint).
Что хотят услышать: уникальность и ссылочная целостность.
Красный флаг: путают ключ с индексом или паролем.
6. [junior] [на скорость] Что такое транзакция?
Ответ
Группа команд, выполняемая как одно целое: либо все применяются (COMMIT), либо ни одна (ROLLBACK). Классический пример: перевод денег, где списание и зачисление не должны разделиться.
Что хотят услышать: атомарность и пример.
Красный флаг: «просто несколько запросов подряд».
7. [middle] Запрос по столбцу стал медленным. Что делаешь?
Ответ
Смотрю EXPLAIN (ANALYZE): если Seq Scan и много Rows Removed by Filter, добавляю индекс по столбцу из WHERE и повторяю EXPLAIN. Учитываю цену индекса: место и замедление записи.
Что хотят услышать: измерение до и после, цена индекса, ANALYZE.
Красный флаг: «добавить индекс на все столбцы».
8. [middle] Почему индекс не помогает для LIKE '%777'?
Ответ
B-tree упорядочен по началу значения, поиск по префиксу быстрый, а по окончанию нужно проверять все значения. Для таких случаев нужны специальные индексы (триграммы) или другая схема данных.
Что хотят услышать: упорядоченность индекса.
Красный флаг: «индексы просто иногда не работают».
9. [middle] Что такое SQL-инъекция и как от неё защититься?
Ответ
Ввод пользователя, склеенный с текстом SQL, начинает исполняться как команда. Защита: параметризованные запросы (%s и значения отдельным аргументом), права минимально нужные, проверка входа.
Что хотят услышать: параметры отдельно от текста запроса.
Красный флаг: «экранирую кавычки руками».
10. [middle] Чем /healthz отличается от /readyz и почему readiness проверяет базу?
Ответ
/healthz (liveness) отвечает, жив ли процесс, и при провале его перезапускают. /readyz (readiness) отвечает, готов ли сервис обслуживать запросы, и при провале с него снимают трафик, не перезапуская. Если база недоступна, перезапуск приложения не поможет, поэтому база относится к readiness.
Что хотят услышать: «не перезапускать зря».
Красный флаг: проверять базу в liveness.
11. [middle] Приложение не подключается к базе. Порядок диагностики?
Ответ
Лог приложения (точный текст ошибки), pg_isready на самой базе, сеть и DNS-имя (одна ли сеть, резолвится ли имя), пароль и DATABASE_URL, затем pg_stat_activity и max_connections. Тексты различают причины: failed to resolve host, Connection refused, password authentication failed.
Что хотят услышать: от симптома к слою, а не «перезапущу всё».
Красный флаг: сразу пересоздать том.
12. [junior] Чем WHERE отличается от HAVING?
Ответ
WHERE отбирает строки до группировки, HAVING отбирает уже сформированные группы, обычно по агрегатам. Например, авторов, у которых больше пяти заметок: SELECT author, count(*) FROM notes GROUP BY author HAVING count(*) > 5;. Условие по обычному столбцу лучше ставить в WHERE, так база обработает меньше строк. Агрегатные функции в WHERE писать нельзя.
Что хотят услышать: до и после группировки, агрегаты только в HAVING, фильтр в WHERE дешевле.
Красный флаг: Использовать HAVING вместо WHERE для любого условия.
13. [middle] Чем DELETE, TRUNCATE и DROP отличаются, и как не потерять данные случайно?
Ответ
DELETE удаляет строки по условию и оставляет таблицу. TRUNCATE быстро очищает таблицу целиком, структура остаётся. DROP TABLE убирает таблицу вместе со структурой. В PostgreSQL все три можно откатить внутри транзакции, пока она не закоммичена. Перед опасной командой открываю BEGIN, смотрю SELECT count(*) с тем же условием, после удаления сверяю число и только тогда COMMIT. DELETE без WHERE удаляет всё, поэтому условие проверяю дважды, а на проде сначала делаю бэкап.
Что хотят услышать: три команды и их масштаб, транзакция как страховка, DELETE без WHERE, бэкап.
Красный флаг: Выполнять DELETE на проде без WHERE и без транзакции.
Проверено на версиях
Настоящие выводы получены 2026-09-30 на PostgreSQL 18.6 (образ postgres:18, Debian 13, aarch64), psycopg 3.3.6, Python 3.13.15, Docker Desktop 29.6.2. Скрипт break.sh проверен на этом же стенде, все четыре сценария и fix.
Не проверено: архитектура x86_64 (числа времени и размеров будут другими, планы те же), команда ss на ВМ Multipass, интерактивный psql (баннер и приглашение), скачивание файлов с raw.githubusercontent.com, Ubuntu 26.04.
Итог урока: ты умеешь
- объяснить, чем база отличается от файла, и что такое таблица, строка, ключ
- писать
SELECT,INSERT,UPDATE,DELETEиJOINс агрегатами - объяснить транзакцию,
ROLLBACKи ACID - читать
EXPLAIN ANALYZE, отличатьSeq ScanотIndex Scanи создавать индекс - запустить PostgreSQL 18 в контейнере с томом и сетью и проверить
pg_isready - подключить приложение через
DATABASE_URL, знать зачем параметры запроса - отличить liveness от readiness и диагностировать четыре типовые поломки
Дальше: Docker Compose и PostgreSQL.
Проверь себя
Короткий тест по уроку: 5 вопросов из банка в 30. Засчитывается только полностью правильный ответ, порог 60%. Каждая новая попытка даёт другие вопросы, пока банк не закончится. Ответы видны после проверки.
Тест работает с включённым JavaScript.