Основы Computer Science Как данные переживают выключение: файлы, БД, транзакции — обзор
0%

Как данные переживают выключение: файлы, БД, транзакции — обзор

Как данные переживают выключение: файлы, БД, транзакции — обзор

Выдерните шнур питания из работающего компьютера. Всё, что программа держала «в уме» — открытые документы, счёт в игре, наполовину заполненная форма, — исчезает мгновенно и без следа. А вот файл, который вы сохранили минуту назад, останется. Между этими двумя судьбами данных лежит целый пласт компьютерных наук, который мы в этой статье и разберём: как данные переживают выключение.

Корень всего — физический факт из статьи про иерархию памяти: оперативная память (RAM) — волатильна. Её ячейки хранят биты, пока по ним идёт ток; пропал ток — пропали биты. Зато RAM быстрая. Диск (жёсткий или SSD) — энергонезависимый: намагниченные участки пластины или заряды в ячейках NAND сохраняются без питания. Зато диск в тысячи раз медленнее. Вся работа программы идёт в быстрой волатильной RAM, а «сохранить» — это перенести данные в медленное, но долговечное хранилище. Это разделение — не деталь реализации, а фундамент: именно оно порождает и понятие файла, и всю индустрию баз данных.

Это обзорная статья большой картины. Она даёт интуицию слоёв — от голых байтов на диске до транзакций — и честно показывает, где надёжность «протекает». За реляционной алгеброй, устройством движков хранения, планами запросов, репликацией и настройкой продакшн-СУБД — отдельный глубокий трек Базы данных, а за конвейерами обработки больших данных — Инженерия данных.

Волатильное против долговечного: два мира данных

Сначала прочувствуем границу физически. У программы есть данные в двух принципиально разных состояниях, и переход между ними — это и есть «сохранение» и «загрузка».

В памяти (RAM) На диске (persistent)
Живёт пока есть питание и процесс пока файл не удалили
Скорость доступа наносекунды микросекунды (SSD) — миллисекунды (HDD)
Форма данных объекты, указатели, графы ссылок плоская лента байтов
Переживает выключение нет да
Адресация по адресу в памяти по имени файла + смещению

Ключевая асимметрия в предпоследней строке порождает главную работу этого слоя. В памяти у вас богатые структуры из статьи про структуры данных: объект ссылается на другой объект по адресу, дерево держится на указателях. Но на диске указателей нет — там только пронумерованные байты. Значит, чтобы сохранить объект, его надо расплющить в последовательность байтов, а чтобы загрузить — собрать обратно. Эти две операции называют сериализацией и десериализацией, и без них не обходится ни одно сохранение.

Форматы сериализации — это выбор компромисса между читаемостью и компактностью. Текстовые (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)                        # чтение + десериализация

Пока данных мало и работает с ними один процесс — это идеальное решение, и городить базу здесь было бы избыточно. Но как только задача подрастает, у голого файла обнажаются четыре болезненные трещины, и каждая из них — это ровно та проблема, ради которой изобрели базы данных.

  1. Поиск — это перебор. Файл — плоская лента. Чтобы найти в файле на миллион записей пользователя по имени, надо прочитать и проверить их все: O(n) (см. алгоритмы и сложность). Никакого «прыжка» к нужной записи файл сам не умеет.
  2. Частичное обновление дорого. Чтобы изменить одно поле у записи в середине текстового файла, обычно приходится переписать файл целиком: вставка в середину плоской ленты сдвигает весь хвост.
  3. Одновременный доступ рушит данные. Если два процесса пишут в файл разом (та самая конкурентность), их записи перемешиваются и портят друг друга. Файл сам по себе не координирует писателей.
  4. Крах посреди записи оставляет кашу. Питание пропало, когда файл записан наполовину, — и на диске остаётся ни старая, ни новая версия, а битый гибрид. Гарантии «всё или ничего» у файла нет.

Что база данных даёт поверх файла

База данных (точнее — СУБД, система управления базами данных) — это не магия, а специально устроенный слой поверх тех же файлов, который системно закрывает все четыре трещины. По сути это «файлы, но с гарантиями и умным поиском». Вот что вы покупаете, переходя от файла к СУБД:

  • Индексы — быстрый поиск вместо перебора. Внутри СУБД держит рядом с данными вспомогательные структуры (чаще всего сбалансированные B-деревья из статьи про структуры данных), которые превращают поиск по ключу из O(n) в O(log n). Это тот же приём «отсортированное дерево вместо линейного скана», только на диске.
  • Язык запросов — вы описываете что хотите получить, а не как это выбрать. СУБД сама строит план: какой индекс использовать, в каком порядке соединять таблицы.
  • Управление конкурентным доступом — сотни клиентов пишут одновременно, а СУБД следит, чтобы они не портили данные друг друга (это слой изоляции, к нему вернёмся).
  • Транзакции и долговечность — гарантия «всё или ничего» и «раз подтвердил — переживёт крах». Это сердце темы, ему посвящён отдельный раздел ниже.

Разнообразие семейств выше — это не мода, а тот же закон «бесплатных обедов не бывает», что и у структур данных: реляционные СУБД дают строгую структуру и мощные транзакции ценой жёсткости схемы и труда масштабирования; ключ-значение — молниеносный доступ по ключу ценой отсутствия сложных запросов; документные — гибкость формы ценой более слабых гарантий; графовые заточены под связи, где реляционные 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.

Путь записи вниз и граница долговечности fsync

Пока байты не спустились ниже фиолетовой линии — на физический носитель, — выключение питания их сотрёт, даже если 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. Знать, где именно протекает, — и есть разница между «пользуюсь базой» и «понимаю базу».

Что дальше

Изоляция транзакций, о которую мы споткнулись, — это лишь один фасад огромной темы: что происходит, когда много всего исполняется одновременно. Почему параллельные процессы и потоки портят данные друг друга, что такое гонки и взаимные блокировки, и почему параллелизм — одна из самых коварных областей программирования, — в следующей статье.

Многозадачность: процессы, потоки, параллелизм и почему это сложно

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов