Как данные переживают выключение: файлы, БД, транзакции — обзор
Выдерните шнур питания из работающего компьютера. Всё, что программа держала «в уме» — открытые документы, счёт в игре, наполовину заполненная форма, — исчезает мгновенно и без следа. А вот файл, который вы сохранили минуту назад, останется. Между этими двумя судьбами данных лежит целый пласт компьютерных наук, который мы в этой статье и разберём: как данные переживают выключение.
Корень всего — физический факт из статьи про иерархию памяти: оперативная память (RAM) — волатильна. Её ячейки хранят биты, пока по ним идёт ток; пропал ток — пропали биты. Зато RAM быстрая. Диск (жёсткий или SSD) — энергонезависимый: намагниченные участки пластины или заряды в ячейках NAND сохраняются без питания. Зато диск в тысячи раз медленнее. Вся работа программы идёт в быстрой волатильной RAM, а «сохранить» — это перенести данные в медленное, но долговечное хранилище. Это разделение — не деталь реализации, а фундамент: именно оно порождает и понятие файла, и всю индустрию баз данных.
Это обзорная статья большой картины. Она даёт интуицию слоёв — от голых байтов на диске до транзакций — и честно показывает, где надёжность «протекает». За реляционной алгеброй, устройством движков хранения, планами запросов, репликацией и настройкой продакшн-СУБД — отдельный глубокий трек Базы данных, а за конвейерами обработки больших данных — Инженерия данных.
Волатильное против долговечного: два мира данных
Сначала прочувствуем границу физически. У программы есть данные в двух принципиально разных состояниях, и переход между ними — это и есть «сохранение» и «загрузка».
| В памяти (RAM) | На диске (persistent) | |
|---|---|---|
| Живёт | пока есть питание и процесс | пока файл не удалили |
| Скорость доступа | наносекунды | микросекунды (SSD) — миллисекунды (HDD) |
| Форма данных | объекты, указатели, графы ссылок | плоская лента байтов |
| Переживает выключение | нет | да |
| Адресация | по адресу в памяти | по имени файла + смещению |
Ключевая асимметрия в предпоследней строке порождает главную работу этого слоя. В памяти у вас богатые структуры из статьи про структуры данных: объект ссылается на другой объект по адресу, дерево держится на указателях. Но на диске указателей нет — там только пронумерованные байты. Значит, чтобы сохранить объект, его надо расплющить в последовательность байтов, а чтобы загрузить — собрать обратно. Эти две операции называют сериализацией и десериализацией, и без них не обходится ни одно сохранение.
{имя, возраст, друзья→[ссылки]}"] -->|сериализация| bytes["Лента байтов
7b 22 6e 61 6d 65 22 ..."] bytes -->|запись| disk[("Диск
именованный файл")] disk -->|чтение| bytes2["Лента байтов"] bytes2 -->|десериализация| obj2["Объект в RAM
восстановлен"]
Форматы сериализации — это выбор компромисса между читаемостью и компактностью. Текстовые (JSON, CSV, XML, YAML) человек может открыть и прочесть, они переносимы между языками, но громоздки и медленно разбираются. Бинарные (Protocol Buffers, MessagePack, родные форматы СУБД) компактны и быстры, но непрозрачны без схемы. Тут же вылезает первая протечка абстракции: сериализованные байты кодируют числа и текст по правилам из статьи про представление данных — и если писавшая сторона использовала один порядок байтов или кодировку, а читающая ждёт другой, вы получите мусор. «Сохранить и загрузить» звучит просто ровно до первого рассинхрона форматов.
Самый простой долговечный носитель — файл
Минимальный ответ на вопрос «как пережить выключение» дал ещё оператор ОС: файл. Из статьи про операционную систему мы знаем, что файл — это именованная последовательность байтов, а файловая система прячет от нас, на каких именно секторах диска эти байты разбросаны, и даёт удобный интерфейс: открыть, читать со смещения, писать, закрыть. Для массы задач этого достаточно — настройки, логи, экспорт, кэш.
import json
# Сохранить состояние: объект → JSON-текст → байты на диске
state = {"score": 4200, "level": 7, "inventory": ["меч", "щит"]}
with open("save.json", "w", encoding="utf-8") as f:
json.dump(state, f, ensure_ascii=False) # сериализация + запись
# Загрузить обратно: байты → JSON-текст → объект
with open("save.json", "r", encoding="utf-8") as f:
state = json.load(f) # чтение + десериализация
Пока данных мало и работает с ними один процесс — это идеальное решение, и городить базу здесь было бы избыточно. Но как только задача подрастает, у голого файла обнажаются четыре болезненные трещины, и каждая из них — это ровно та проблема, ради которой изобрели базы данных.
- Поиск — это перебор. Файл — плоская лента. Чтобы найти в файле на миллион записей пользователя по имени, надо прочитать и проверить их все: O(n) (см. алгоритмы и сложность). Никакого «прыжка» к нужной записи файл сам не умеет.
- Частичное обновление дорого. Чтобы изменить одно поле у записи в середине текстового файла, обычно приходится переписать файл целиком: вставка в середину плоской ленты сдвигает весь хвост.
- Одновременный доступ рушит данные. Если два процесса пишут в файл разом (та самая конкурентность), их записи перемешиваются и портят друг друга. Файл сам по себе не координирует писателей.
- Крах посреди записи оставляет кашу. Питание пропало, когда файл записан наполовину, — и на диске остаётся ни старая, ни новая версия, а битый гибрид. Гарантии «всё или ничего» у файла нет.
Что база данных даёт поверх файла
База данных (точнее — СУБД, система управления базами данных) — это не магия, а специально устроенный слой поверх тех же файлов, который системно закрывает все четыре трещины. По сути это «файлы, но с гарантиями и умным поиском». Вот что вы покупаете, переходя от файла к СУБД:
- Индексы — быстрый поиск вместо перебора. Внутри СУБД держит рядом с данными вспомогательные структуры (чаще всего сбалансированные B-деревья из статьи про структуры данных), которые превращают поиск по ключу из O(n) в O(log n). Это тот же приём «отсортированное дерево вместо линейного скана», только на диске.
- Язык запросов — вы описываете что хотите получить, а не как это выбрать. СУБД сама строит план: какой индекс использовать, в каком порядке соединять таблицы.
- Управление конкурентным доступом — сотни клиентов пишут одновременно, а СУБД следит, чтобы они не портили данные друг друга (это слой изоляции, к нему вернёмся).
- Транзакции и долговечность — гарантия «всё или ничего» и «раз подтвердил — переживёт крах». Это сердце темы, ему посвящён отдельный раздел ниже.
данных)) Реляционные SQL PostgreSQL, MySQL таблицы, схема, JOIN сильные транзакции Ключ-значение Redis, тот же dict на диске поиск по ключу O 1 кэш, сессии Документные MongoDB JSON-документы без жёсткой схемы гибкая структура Широких столбцов Cassandra огромные объёмы, запись горизонтальный масштаб Графовые Neo4j узлы и связи соцсети, маршруты
Разнообразие семейств выше — это не мода, а тот же закон «бесплатных обедов не бывает», что и у структур данных: реляционные СУБД дают строгую структуру и мощные транзакции ценой жёсткости схемы и труда масштабирования; ключ-значение — молниеносный доступ по ключу ценой отсутствия сложных запросов; документные — гибкость формы ценой более слабых гарантий; графовые заточены под связи, где реляционные JOIN’ы задыхаются. Выбор хранилища — это выбор, что вам должно быть дёшево. Начинать в 90% случаев стоит с реляционной СУБД: она универсальна и прощает ошибки проектирования.
Реляционная модель: таблицы, связи, SQL
Доминирующая модель уже полвека — реляционная, предложенная Эдгаром Коддом в 1970 году («A Relational Model of Data for Large Shared Data Banks», оригинал). Идея обманчиво проста: все данные — это таблицы (отношения). Строка (запись) — один объект, столбец (поле) — одно его свойство с фиксированным типом. Строки разных таблиц связывают не указателями, а ключами: у записи есть уникальный первичный ключ (id), а ссылка на неё из другой таблицы — это внешний ключ, хранящий тот же id.
Заметьте, что данные не дублируются: имя клиента лежит в одном месте (CUSTOMER), а заказы
лишь ссылаются на него по customer_id. Это нормализация — устранение дублей, чтобы
обновить факт можно было в единственном месте. Собирать разбросанные по таблицам данные обратно
в единую картину умеет операция JOIN — соединение по совпадающим ключам.
Работают с этим через SQL (Structured Query Language) — декларативный язык, где вы описываете результат, а не алгоритм:
-- «Сколько потратил каждый клиент — от больших сумм к меньшим»
SELECT c.name,
SUM(oi.quantity * p.price) AS total
FROM customer c
JOIN "order" o ON o.customer_id = c.id -- связали клиента с заказами
JOIN order_item oi ON oi.order_id = o.id -- заказы с позициями
JOIN product p ON p.id = oi.product_id
GROUP BY c.name
ORDER BY total DESC;
Вы не написали ни одного цикла и ни разу не упомянули индекс или порядок чтения строк — это всё
СУБД решит за вас. В этой декларативности — и сила (краткость, оптимизатор умнее ручного кода),
и первая протечка: стоит запросу стать медленным, и вам всё-таки придётся спуститься на уровень
ниже — посмотреть план выполнения (EXPLAIN), понять, какие индексы использованы, а где идёт
полный перебор таблицы. Абстракция «просто опиши, что нужно» держится, пока данных немного.
Транзакции и ACID: сердце надёжности
Вернёмся к главному вопросу — как данные надёжно переживают не только выключение, но и сбой посреди работы. Классический пример: перевод 100 рублей со счёта A на счёт B. Это две операции: списать у A и зачислить B. А теперь представьте, что питание пропало между ними: у A уже списали, B ещё не получил — сто рублей испарились. Никакая аккуратность кода тут не спасёт, потому что крах может случиться в любой момент. Нужна гарантия на уровне хранилища, что две операции происходят как одна неделимая. Эта гарантия называется транзакцией, а её свойства описывает знаменитая аббревиатура ACID:
- Atomicity (Атомарность) — «всё или ничего». Транзакция либо применяется целиком, либо не оставляет следов вовсе. Перевод не может «повиснуть наполовину».
- Consistency (Согласованность) — транзакция переводит базу из одного корректного состояния в другое, не нарушая правил (ограничения, внешние ключи). Сумма денег в системе до и после перевода — одинакова.
- Isolation (Изоляция) — параллельные транзакции не видят промежуточных, недоделанных результатов друг друга; итог такой, будто они шли по очереди. Это прямой ответ на трещину №3 голого файла.
- Durability (Долговечность) — как только транзакция подтверждена (COMMIT), её результат переживёт любой последующий крах, вплоть до отключения питания.
BEGIN; -- открыли транзакцию
UPDATE accounts SET balance = balance - 100 WHERE id = 'A';
UPDATE accounts SET balance = balance + 100 WHERE id = 'B';
COMMIT; -- атомарно зафиксировали обе строки
-- если между шагами что-то пошло не так — ROLLBACK, и база как будто ничего не видела
Жизненный цикл транзакции удобно представить конечным автоматом: она активна, пока идут операции, и завершается ровно одним из двух исходов — фиксацией или откатом.
Атомарность и откат «как будто ничего не было» — не философия, а конкретный механизм. Разберём перевод как последовательность и увидим, откуда берутся гарантии.
Как долговечность работает на самом деле
Обратите внимание на предпоследний шаг: СУБД подтверждает COMMIT только после того, как
дождалась записи журнала на диск. Здесь абстракция «сохранено» встречается с суровой физикой, и
это важнейший момент всей статьи. Когда программа делает write(), данные не оказываются
на диске мгновенно — они проходят вниз через несколько слоёв кэшей, и большинство этих слоёв
живёт в волатильной RAM.
Пока байты не спустились ниже фиолетовой линии — на физический носитель, — выключение питания
их сотрёт, даже если write() уже вернул «успех». Протолкнуть данные до самого низа заставляет
специальный системный вызов fsync(). Именно ожидание fsync — цена, которую СУБД платит
за букву D в ACID, и именно поэтому COMMIT не бесплатен: он ждёт реальный диск. Отключите
fsync ради скорости (некоторые делают это осознанно для некритичных данных) — и вернёте себе
окно, в котором подтверждённые данные всё же можно потерять.
Но как СУБД обеспечивает атомарность «всё или ничего», если крах может случиться прямо посреди применения изменений к страницам данных? Ответ — журнал упреждающей записи (Write-Ahead Log, WAL), тот самый, что мелькал на диаграммах. Правило железное: сначала запиши в журнал, что собираешься сделать, и лишь потом меняй сами данные.
Логика восстановления после перезапуска элегантна: СУБД читает журнал и для каждой транзакции
смотрит, есть ли в нём отметка COMMIT. Если есть — повторяет (redo) её изменения, доводя
данные до конца, даже если применить их до краха не успели. Если COMMIT нет — откатывает
(undo) любые частичные следы. В результате после любого сбоя база оказывается в состоянии,
где каждая транзакция либо целиком применена, либо целиком отсутствует. Никаких «повисших
наполовину» переводов. Журнал — единственный источник правды, а страницы данных лишь догоняют
его. Так атомарность и долговечность из абстрактных обещаний превращаются в порядок записи на
диск.
Где эта надёжность протекает
ACID выглядит как непробиваемая крепость, но у каждой её стены есть трещины, о которых полезно знать заранее.
- Durability настраивается и может быть выключена. По умолчанию COMMIT ждёт
fsync, но ради пропускной способности его ослабляют (synchronous_commit = offв PostgreSQL, режимы безfsync). Это осознанный обмен долговечности на скорость — и источник загадочных потерь данных, если кто-то включил это, не сказав остальным. - Изоляция бывает неполной. Полная изоляция (уровень serializable) дорога, поэтому по умолчанию СУБД дают ослабленные уровни, где параллельные транзакции всё же немного «видят» друг друга. Отсюда классические аномалии — неповторяющееся чтение, фантомы, потерянное обновление. Изоляция — это по сути задача из статьи про конкурентность, и она наследует всю её сложность.
- ORM прячет SQL — до поры. Библиотеки, отображающие таблицы на объекты языка, удобны, но скрывают, во что превращаются ваши вызовы. Классическая ловушка — проблема N+1: цикл по списку заказов, где на каждой итерации незаметно уходит отдельный запрос за клиентом, — и вместо одного JOIN’а получается тысяча запросов. Абстракция «объекты вместо таблиц» протекает ровно там, где важна производительность.
- Согласованность в распределённых системах — отдельная боль. Как только данные размазаны по нескольким машинам (репликация, шардинг ради масштаба), вступает в силу теорема CAP: при разрыве сети между узлами приходится выбирать между согласованностью (все видят одни и те же данные) и доступностью (система продолжает отвечать). Многие NoSQL выбирают доступность и согласованность в конечном счёте (eventual consistency): ваша запись видна не всем и не сразу. Кто ждёт от такой системы строгого ACID, получает неприятные сюрпризы.
- Схема — это долговременное обязательство. Данные живут годами и переживают много версий
кода. Изменить структуру таблицы на «живой» базе с миллиардом строк (миграция) — отдельное
инженерное искусство, а не команда
ALTER TABLEна удачу.
Как это складывается со всем остальным
Соберём слой персистентности в общую картину трека. Веб-сервер из статьи про
устройство веба обрабатывает запрос, держа данные в
волатильной RAM, — но за настоящим состоянием (профили, заказы, сообщения) он ходит в базу,
которая всё это переживает между запросами и перезагрузками. База, в свою очередь, стоит на
файлах из статьи про операционную систему,
использует fsync для долговечности, а её изоляция — это дисциплина
конкурентности. Индексы внутри — это
B-деревья из структур данных, а разница
между O(n)-сканом и O(log n)-поиском по индексу — та самая
сложность, что мы обсуждали абстрактно.
Персистентность — это не отдельный островок, а слой, который стоит на всех предыдущих и держит
на себе все следующие.
Типичные заблуждения
- «Записал в файл — значит, сохранил».
write()кладёт данные в кэш ОС, а не на носитель. Безfsyncвнезапное выключение питания их потеряет. «Сохранено» и «долговечно» — разные вещи. - «Нужна база — беру самую модную NoSQL». Для подавляющего большинства задач реляционная СУБД проще, надёжнее и мощнее. NoSQL решает конкретные проблемы масштаба и формы данных; без этих проблем он лишь отнимает у вас транзакции и JOIN’ы.
- «Транзакция — это просто обёртка вокруг нескольких запросов». Нет, это гарантия атомарности, изоляции и долговечности, за которую СУБД платит журналом и ожиданием диска. Убрать её «для скорости» — значит вернуть себе трещины голого файла.
- «ACID гарантирует, что данные всегда согласованы у всех». В распределённой системе с репликацией это уже не так: между узлами возможна задержка, и вы читаете устаревшую копию. CAP не обойти.
- «База сама разберётся, будет быстро». До первого миллиона строк без индекса или до первого N+1-запроса из ORM. Декларативность экономит труд, но не отменяет знания о том, что происходит под капотом.
Мини-итог
RAM забывает всё при выключении, диск — помнит; «сохранить» — это перенести данные из быстрого
волатильного мира в медленный долговечный, расплющив объекты в ленту байтов (сериализация).
Простейший долговечный носитель — файл, но у него четыре трещины: поиск перебором,
дорогое частичное обновление, поломка при конкурентной записи и каша при крахе посреди записи.
База данных системно закрывает их: индексы (B-деревья) дают быстрый поиск, язык запросов —
декларативный доступ, изоляция — безопасную конкурентность, а транзакции с ACID —
гарантию «всё или ничего» и «переживёт крах». Долговечность держится не на честном слове, а на
fsync (данные реально спустились ниже кэшей на носитель) и журнале упреждающей записи
(сначала пиши намерение, потом меняй данные; после краха — повтори закоммиченное, откати
остальное). И как всякая мощная абстракция, надёжность протекает: fsync можно выключить,
изоляция бывает ослабленной, ORM прячет дорогие запросы, а распределённость упирается в CAP.
Знать, где именно протекает, — и есть разница между «пользуюсь базой» и «понимаю базу».
Что дальше
Изоляция транзакций, о которую мы споткнулись, — это лишь один фасад огромной темы: что происходит, когда много всего исполняется одновременно. Почему параллельные процессы и потоки портят данные друг друга, что такое гонки и взаимные блокировки, и почему параллелизм — одна из самых коварных областей программирования, — в следующей статье.
Многозадачность: процессы, потоки, параллелизм и почему это сложно