← назад к разделу

Свою СУБД вы, скорее всего, писать не будете. Но выбирать между PostgreSQL и Cassandra, настраивать ClickHouse, объяснять коллеге, почему лишний индекс замедлил запись, — придётся. А для этого нужно представлять, что база делает на диске.

Хорошая новость: внутри почти любой базы данных лежит один из двух движков хранения — B-дерево или LSM-дерево. Понять эти два — и поведение большинства баз перестаёт быть магией. Разберём оба с начала: с самой примитивной «базы», какую можно представить.

LSM: только дописываем MemTable в памяти, ключи по порядку a=2 c=2 e=2 пришли c, a, e — легли по порядку сброс целиком на диске: сегменты только дописываются SSTable 2 свежий a=2c=2e=2 SSTable 1 старый b=1 c=1 f=1 после слияния a=2b=1c=2e=2f=1общий ключ c — осталась свежая версия B-дерево: перезапись на месте сначала запись идёт в журнал (WAL) c=2 потом правка доходит до страниц корень: d a=1 b=1 c=1 e=1 f=1 g=1 c=2 страница переписана на том же месте,даже если поменялись три байта чтение: ключ ровно в одном месте, путь к нему — три-четыре прыжка

Два движка расходятся в одном: LSM-дерево ничего не перезаписывает — новые данные копятся в памяти, сбрасываются на диск отдельным отсортированным сегментом, а разбирается с этим фоновое слияние. B-дерево, наоборот, находит нужную страницу и переписывает её целиком там же, где она лежала, — и потому вынуждено сначала записать изменение в журнал.

Обязательно

Самая простая база данных в мире

Вот «база данных» из двух строчек на Bash:

db_set () { echo "$1,$2" >> database; }
db_get () { grep "^$1," database | sed -e "s/^$1,//" | tail -n 1; }

db_set дописывает пару «ключ,значение» в конец файла. db_get находит последнее вхождение ключа. Примитивно — но кое-чему эта игрушка нас учит.

Запись здесь на удивление хорошая. Дописать строку в конец файла — самая дешёвая операция для диска. На жёстком диске головке не надо прыгать по пластине, всё пишется подряд. На SSD головки нет, но выигрыш остаётся: последовательная запись заполняет блоки целиком, и контроллеру реже приходится перекладывать соседние данные, чтобы освободить место. Настоящие базы используют ровно этот приём и называют его журналом (log) — последовательность записей, в которую можно только дописывать.

А вот чтение ужасное. Чтобы найти ключ, db_get просматривает весь файл от начала до конца. Чем больше данных, тем медленнее: это O(n) — время растёт линейно с размером базы.

Чтобы читать быстро, нужен индекс — вспомогательная структура рядом с данными, вроде оглавления книги. И тут же вылезает главный размен этого мира: индекс ускоряет чтение, но замедляет запись — при каждой вставке его тоже надо обновить. Поэтому базы не индексируют всё подряд: набор индексов выбирает разработчик под свои запросы.

Шаг первый: хеш-индекс

Самый простой индекс к нашему журналу — держать в памяти таблицу «ключ → в каком месте файла лежит его последнее значение». Тогда запись остаётся дешёвым дописыванием, а чтение — один прыжок сразу в нужное место файла.

Чтобы файл не рос до бесконечности, журнал режут на куски (сегменты) и время от времени в фоне делают уплотнение (compaction): выбрасывают устаревшие версии ключей и сливают сегменты в один поменьше.

Ровно так устроен реальный движок Bitcask. Но у подхода два потолка: все ключи обязаны помещаться в оперативную память, а запросы по диапазону («все ключи от А до Б») работать не будут — хеш-таблица ничего не знает о порядке ключей.

LSM-дерево: отсортированные кускиспросят на собеседовании

Оба потолка убирает одна идея — хранить каждый сегмент отсортированным по ключу. Такой отсортированный файл называют SSTable (sorted string table). Сортировка снимает сразу оба потолка: по диапазону теперь можно идти подряд, а в памяти достаточно держать не все ключи, а один на каждые несколько килобайт — нашли ближайший, дочитали небольшой кусок файла и попали в цель. Такой индекс называют разреженным, и стоит он в сотни раз меньше памяти. Вот как на этой идее работает движок:

  1. Новая запись сначала попадает в MemTable — небольшое отсортированное дерево прямо в оперативной памяти (плюс дописывается в короткий журнал на диске — на случай, если вырубят питание).
  2. Когда MemTable подрастает до нескольких мегабайт, её целиком сбрасывают на диск новым файлом-SSTable. Это снова дешёвая последовательная запись.
  3. Чтение ищет ключ сначала в MemTable, потом в самой свежей SSTable, потом в предыдущей — и так вглубь.
  4. Фоновый compaction сливает SSTable между собой: два отсортированных файла слить легко, а дубликаты одного ключа схлопываются до последней версии.

Механика четвёртого шага целиком помещается в один цикл:

живой пример

public class Compaction {
    public static void main(String[] args) {
        String[] older = {"b=1", "c=1", "f=1"};
        String[] newer = {"a=2", "c=2", "e=2"};
        StringBuilder merged = new StringBuilder();
        int i = 0, j = 0;
        while (i < older.length || j < newer.length) {
            char left = i < older.length ? older[i].charAt(0) : '\uffff';
            char right = j < newer.length ? newer[j].charAt(0) : '\uffff';
            if (left < right) merged.append(older[i++]).append(' ');
            else if (right < left) merged.append(newer[j++]).append(' ');
            else { merged.append(newer[j++]).append(' '); i++; }
        }
        System.out.println("старый:  " + String.join(" ", older));
        System.out.println("свежий:  " + String.join(" ", newer));
        System.out.println("слияние: " + merged.toString().trim());
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

package main

import (
	"fmt"
	"strings"
)

func main() {
	older := []string{"b=1", "c=1", "f=1"}
	newer := []string{"a=2", "c=2", "e=2"}
	var merged []string
	i, j := 0, 0
	for i < len(older) || j < len(newer) {
		left, right := byte(0xff), byte(0xff)
		if i < len(older) {
			left = older[i][0]
		}
		if j < len(newer) {
			right = newer[j][0]
		}
		switch {
		case left < right:
			merged = append(merged, older[i])
			i++
		case right < left:
			merged = append(merged, newer[j])
			j++
		default:
			merged = append(merged, newer[j])
			i++
			j++
		}
	}
	fmt.Println("старый:  " + strings.Join(older, " "))
	fmt.Println("свежий:  " + strings.Join(newer, " "))
	fmt.Println("слияние: " + strings.Join(merged, " "))
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

const older = ['b=1', 'c=1', 'f=1'];
const newer = ['a=2', 'c=2', 'e=2'];
const merged = [];
let i = 0, j = 0;
while (i < older.length || j < newer.length) {
  const left = i < older.length ? older[i][0] : '￿';
  const right = j < newer.length ? newer[j][0] : '￿';
  if (left < right) merged.push(older[i++]);
  else if (right < left) merged.push(newer[j++]);
  else { merged.push(newer[j++]); i++; }
}
console.log('старый:  ' + older.join(' '));
console.log('свежий:  ' + newer.join(' '));
console.log('слияние: ' + merged.join(' '));
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

older = ["b=1", "c=1", "f=1"]
newer = ["a=2", "c=2", "e=2"]
merged = []
i = j = 0
while i < len(older) or j < len(newer):
    left = older[i][0] if i < len(older) else "￿"
    right = newer[j][0] if j < len(newer) else "￿"
    if left < right:
        merged.append(older[i]); i += 1
    elif right < left:
        merged.append(newer[j]); j += 1
    else:
        merged.append(newer[j]); i += 1; j += 1
print("старый:  " + " ".join(older))
print("свежий:  " + " ".join(newer))
print("слияние: " + " ".join(merged))
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Один проход, память не нужна — потому слияние и живёт в фоне.

Это и есть LSM-дерево (log-structured merge-tree). На нём стоят RocksDB и LevelDB, Cassandra и HBase; тот же принцип «отсортированные куски + фоновое слияние» лежит в основе поискового индекса Lucene (а значит, и Elasticsearch) и движка MergeTree в ClickHouse.

Одно тонкое место: искать отсутствующий ключ дорого — придётся проверить MemTable и все SSTable до самой старой. Спасает фильтр Блума — компактная структура, которая по ключу быстро отвечает «точно нет» или «может быть, есть», и в первом случае экономит лишние чтения с диска.

Когда LSM не подходит

У LSM есть сценарий, в котором он проигрывает прямо: прочитать, изменить, записать одну строку. Каждое такое изменение — новая запись в журнал и новая версия, старая остаётся до слияния; чтение перед записью обходит все куски, где ключ может лежать. То есть обычное CRUD-приложение, которое правит одну строку по идентификатору, получает от LSM усиление и записи, и места.

Второй неудобный случай — частые удаления. Удаление в LSM это тоже запись (метка удаления), и до слияния она не освобождает ни байта, а чтение обязано её учитывать. Таблица, из которой регулярно удаляют, накапливает такие метки и замедляется — об этом статья про устройство Cassandra.

Отсюда правило: LSM берут под поток записи и данные, которые дописывают, а не правят. Под изменяемые данные с точечным чтением честнее B-дерево.

B-дерево: страницы фиксированного размера и WALспросят на собеседовании

B-дерево придумали ещё в 1970-х, и оно до сих пор стандарт по умолчанию: на нём построены индексы PostgreSQL, MySQL, Oracle и почти всех реляционных баз. Подход тут противоположный. База делится не на куски переменного размера, а на страницы одинакового размера (обычно от 4 до 16 КБ: у PostgreSQL это 8 КБ, у MySQL — 16), и эти страницы перезаписываются на том же месте, а не дописываются в конец.

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

живой пример

public class TreeHeight {
    public static void main(String[] args) {
        long keys = 1;
        for (int level = 1; level <= 4; level++) {
            keys *= 300;
            System.out.println("уровней: " + level + " → " + keys + " записей");
        }
    }
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

package main

import "fmt"

func main() {
	keys := int64(1)
	for level := 1; level <= 4; level++ {
		keys *= 300
		fmt.Printf("уровней: %d → %d записей\n", level, keys)
	}
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

let keys = 1;
for (let level = 1; level <= 4; level++) {
  keys *= 300;
  console.log(`уровней: ${level} → ${keys} записей`);
}
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

живой пример

keys = 1
for level in range(1, 5):
    keys *= 300
    print(f"уровней: {level} → {keys} записей")
Запустить

Запуск примеров доступен в платном доступе. Там этот же код выполняется прямо в статье: редактор, запуск и проверка рядом с абзацем. Три дня бесплатно →

Четырёх уровней хватает на миллиарды записей. Если вставка не влезает в страницу, страницу разбивают на две и правят ссылку у родителя.

Перезапись на месте — штука опасная: если питание вырубится ровно в момент разбиения страницы, индекс может остаться в «разорванном» виде. Поэтому каждое изменение сначала дописывают в журнал упреждающей записи (write-ahead log, WAL) и только потом применяют к самим страницам. После сбоя база проигрывает журнал заново и восстанавливает согласованность. Ирония: движок с перезаписью на месте всё равно таскает с собой журнал — тот, с которого мы начали.

шаг 1 правка в WAL шаг 2 страница делится сбой питание пропало старт база читает WAL итог дерево цело

Опасен не сам сбой, а промежуток между двумя записями: журнал заполняется до правки страниц, поэтому после перезапуска база доигрывает по нему незавершённое разбиение.

Индекс отдельно, строки отдельно

Важное уточнение, которое объясняет половину различий между базами: B-дерево — это структура индекса, а не обязательно способ хранения строк.

В PostgreSQL строки лежат в куче (heap) — неупорядоченном наборе страниц, а индекс хранит ключ и ссылку на место строки. Отсюда следствия: индексов может быть много и они равноправны, первичный ключ ничем не выделен, а чтобы прочитать строку по индексу, нужен ещё один переход — в кучу. Именно из этого перехода растёт смысл покрывающего индекса: если все нужные колонки есть в самом индексе, в кучу идти не надо.

В MySQL с движком InnoDB (и в ряде других баз) строки лежат внутри индекса первичного ключа, отсортированные по нему — это кластерный индекс. Чтение по первичному ключу получается за один проход, зато вторичный индекс хранит не ссылку на место, а значение первичного ключа, и поиск по нему превращается в два прохода по дереву. Плюс порядок вставки начинает иметь значение: случайный первичный ключ раскидывает вставки по всему дереву, последовательный — дописывает в конец.

Практический вывод для читателя: одинаковые на вид схемы в разных базах ведут себя по-разному, и «первичный ключ — это UUID» стоит в MySQL дороже, чем в PostgreSQL.

Сравнение: за что платимспросят на собеседовании

Выбор движка это выбор, за что платить, и цена видна на трёх операциях.

Первая плата — за запись. LSM обычно быстрее, потому что всё пишется последовательно. У B-дерева каждая запись — это и запись в WAL, и перезапись целой страницы (даже если поменялись три байта). На диск страница при этом уезжает не на каждую правку: её меняют в буферном кеше, и десять правок подряд превратятся в одну запись на диск при ближайшей контрольной точке — но записана страница будет целиком. Сколько лишних байт при этом реально уезжает на диск на каждый байт полезных данных, называют усилением записи (write amplification). Правда, у LSM усиление тоже есть — compaction переписывает данные по многу раз и при большом потоке записи начинает бороться с ней за диск.

Вторая — за чтение. B-дерево предсказуемее: ключ лежит ровно в одном месте, путь к нему — те же три-четыре прыжка. В LSM ключ может оказаться в нескольких кусках на разных стадиях уплотнения, поэтому «хвостовые» задержки (см. перцентили) у него капризнее — фоновый compaction иногда отбирает диск у обычных запросов.

Третья — за транзакции. В B-дереве удобно вешать блокировку прямо на диапазон ключей в дереве — одна из причин, почему реляционные базы с их изоляцией транзакций построены именно на B-деревьях.

Практическое правило: «много пишем, читаем в основном свежее» (события, метрики, лента) — территория LSM; «много читаем по ключам и диапазонам, нужны транзакции» — территория B-дерева. Точно решает только замер на своей нагрузке.

Где это применяется

Понимание движка превращает «волшебные» свойства баз в понятные следствия. Почему Cassandra тянет огромный поток записи? Последовательные SSTable. Почему в PostgreSQL лишние индексы замедляют вставку? Каждый индекс — ещё одно B-дерево, которое надо обновить.

Почему в ClickHouse данные обязательно сортируются по ключу таблицы? MergeTree — родня LSM, и сортировка для него и есть индекс. Почему после сбоя PostgreSQL стартует не мгновенно? Проигрывает WAL.

Где спотыкаются начинающие:

  • Сравнивают базы по маркетингу, а не по движку. «NoSQL быстрее» — фраза ни о чём: вопрос в том, быстрее на записи или на чтении и почему.
  • Забывают, что индекс не бесплатный. Пять индексов на таблице — пять деревьев, которые обновляются на каждой вставке.
  • Не следят за compaction. LSM-база без присмотра копит куски, пока не кончится диск или не просядут чтения.
  • Пугаются слова WAL. Это не экзотика: благодаря журналу база переживает выключение питания, на нём же держатся репликация и резервные копии.
Дополнительно: при первом чтении можно пропустить

Глубже: стратегии уплотнения и три усилениярасширенное

Куски надо периодически сливать, и как именно их сливать — главное эксплуатационное решение в LSM. Две основные стратегии.

Размерная (size-tiered). Куски примерно одного размера сливаются в один больший. Запись дешёвая (каждая строка переписывается немного раз), зато один ключ может лежать в нескольких крупных кусках сразу, и чтение обходит их все. Плюс нужен запас места: слияние двух кусков по 100 ГБ требует ещё 100 ГБ свободных.

Уровневая (leveled). Данные разложены по уровням, каждый следующий в десять раз больше, и внутри уровня куски не пересекаются по диапазонам ключей. Чтение трогает по одному куску на уровень — то есть предсказуемо мало; место расходуется экономно. Платит запись: одна строка за свою жизнь переписывается на каждом уровне, и общий объём записи выходит в разы больше.

Отсюда три величины, которыми измеряют любой движок хранения:

Усиление записи — сколько байт реально записано на диск на каждый байт данных. У уровневой стратегии оно высокое, у размерной ниже, у B-дерева своё (страница переписывается целиком плюс журнал).

Усиление чтения — сколько обращений к диску нужно, чтобы найти один ключ. У LSM оно тем больше, чем больше кусков может содержать ключ; у B-дерева равно высоте дерева.

Усиление места — сколько места занято сверх полезных данных. У размерной стратегии оно самое заметное: старые версии живут до слияния.

Выбирают по нагрузке: поток записи и чтение диапазонов — размерная; много точечных чтений и ограниченное место — уровневая. Для данных с временем жизни есть третья, по временным окнам, которая просто удаляет целые старые куски.

Глубже: фильтр Блума: цена вопросарасширенное

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

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

Глубже: почему прыжков по диску на самом деле нетрасширенное

Высота дерева в три-четыре уровня означает три-четыре чтения страниц, но на практике их обычно ноль: страницы лежат в буферном пуле — области памяти, где база держит недавно использованные страницы. Верхние уровни дерева используются в каждом запросе и потому живут в памяти всегда; с диска читаются только листья, и то не все.

Отсюда правило, которое важнее высоты дерева: решает объём памяти. Пока рабочее множество (часто читаемые страницы плюс индексы) помещается в память, база работает со скоростью памяти. Как только перестаёт — каждое обращение превращается в чтение с диска, и производительность падает скачком, а не плавно. Поэтому первый вопрос при настройке любой базы — сколько памяти отдано под кэш страниц, и сравним ли он с размером горячих данных.

Коротко

  • Дописывание в конец файла — самая дешёвая запись для диска; с журнала начинаются оба движка.
  • LSM: MemTable в памяти, сброс целиком новым SSTable, фоновое слияние сегментов, общий ключ схлопывается до свежей версии.
  • Отсутствующий ключ LSM ищет по всем сегментам; спасает фильтр Блума.
  • B-дерево: страницы по 8 КБ (PostgreSQL) или 16 КБ (MySQL) перезаписываются на том же месте, сотни ссылок со страницы — четыре уровня на миллиарды записей.
  • Перезапись на месте держится на WAL: сначала журнал, потом страницы.
  • Платим всегда: у LSM — хвостовые задержки и борьба compaction за диск, у B-дерева — усиление записи.
  • Стратегия уплотнения — главное решение в LSM: размерная дешевле по записи и требует места, уровневая даёт предсказуемое чтение ценой усиления записи.
  • Три величины для сравнения движков: усиление записи, чтения и места; LSM плох там, где строку читают, меняют и пишут обратно, — это обычный CRUD.
  • B-дерево — структура индекса: в PostgreSQL строки лежат в куче (отсюда смысл покрывающих индексов), в InnoDB — внутри индекса первичного ключа.
  • Прыжков по диску обычно нет: верхние уровни дерева живут в буферном пуле, и решает не высота дерева, а объём памяти под горячие страницы. Фильтр Блума стоит около десяти бит на ключ и ошибается в сторону лишнего чтения.

Что почитать дальше