основы баз данных [перевод]
вольный перевод статьи Tony Solomonik "Database Fundamentals"
примерно год назад я пытался выбрать базу данных для нового проекта и внезапно понял: я вообще не до конца понимаю, чем они отличаются.
я заходил на сайты разных баз данных — и видел в основном маркетинг и набор слов, которые ничего не объясняют.
в итоге я взял и прочитал две отличные книги: database internals (alex petrov) и designing data-intensive applications (martin kleppmann).
этого хватило, чтобы захотеть написать свою маленькую базу данных — я назвал её dbeel.
этот пост — короткий конспект этих книг с фокусом на фундаментальные проблемы, о которых думает инженер баз данных (например, в душе).
bashdb
начнём с самой простой базы данных, которую только можно придумать — буквально две bash-функции:
#!/bin/bash
db_set() {
echo "$1,$2" >> database
}
db_get() {
grep "^$1," database | sed -e "s/^$1,//" | tail -n 1
}
пример использования:
$ db_set 500 '{"movie": "Airplane!", "rating": 9}'
$ db_set 111 '{"movie": "Tokio Drift", "rating": 6}'
$ db_get 500
{"movie": "Airplane!", "rating": 9}
остановись на секунду и подумай: почему ты бы не стал использовать это в продакшене?
…
скорее всего, ты нашёл кучу проблем. сосредоточимся на основных:
- durability — данные могут потеряться при падении системы
- atomicity — запись может сохраниться частично
- isolation — параллельные операции ломают данные
- performance — поиск O(n)
что такое ACID
большинство баз данных стремятся гарантировать ACID:
- atomicity — либо всё записалось, либо ничего
- consistency — база не ломается от операций
- isolation — нет гонок данных
- durability — данные не теряются
durability
простое улучшение:
db_set() {
echo "$1,$2" >> database && sync -d database
}
но что делает sync?
- данные сначала пишутся в кеш (page cache)
- потом ОС когда-нибудь сбрасывает их на диск
fsync / fdatasync заставляют сделать это сразу.
разница:
- fsync — данные + метаданные
- fdatasync — только данные
минус — это дорого по времени.
isolation
можно добавить блокировки через flock:
db_set() {
(
flock 9 && echo "$1,$2" >> database && sync -d database
) 9>database.lock
}
db_get() {
(
flock -s 9 && grep "^$1," database | sed -e "s/^$1,//" | tail -n 1
) 9>database.lock
}
минус — блокируется вся база.
плохие новости
- atomicity нормально не решить в bash
- поиск всё ещё O(n)
но даже этот эксперимент даёт понимание, какие проблемы решают реальные базы данных.
storage engine
storage engine — это слой, который отвечает за:
- запись и чтение данных
- производительность
- работу с диском
главная проблема — диск очень медленный.
пример:
операция время
L1 cache ~0.5 ns
RAM ~100 ns
SSD ~150 μs
disk seek ~10 ms
вывод: нужно минимизировать обращения к диску.
mutable структуры: B-tree
B-tree — это структура, оптимизированная под диск:
- поиск O(log n)
- данные лежат рядом (spatial locality)
- читаем блоками (pages)
пример:
[7 | 16]
/ | \
[1 2 5 6] [9 12] [18 21]
поиск:
def get(node, key):
for i, child in enumerate(node.children):
if child.key == key:
return child.value
if child.key > key:
return get(node.nodes[i], key)
return get(node.nodes[-1], key)
immutable структуры: LSM tree
идея:
- писать только append
- сначала в память
- потом сбрасывать на диск в виде SSTable
дополнительно:
- WAL (write-ahead log)
- compaction (слияние файлов)
bloom filter
вероятностная структура:
- быстро говорит, что элемента точно нет
- иногда говорит, что он возможно есть
пример:
bloom = [0,0,0,0,0,0,0,0]
# вставка
bits = [hash1(key)%8, hash2(key)%8]
bloom[bits[0]] = 1
bloom[bits[1]] = 1
проверка:
if bloom[i] == 0:
# точно нет
WAL
лог всех операций:
- сначала пишем в WAL
- потом в основное хранилище
при старте:
- читаем WAL
- восстанавливаем состояние
уровни изоляции
- read uncommitted — можно читать грязные данные
- read committed
- repeatable read
- serializable
пример dirty read:
BEGIN;
SELECT age FROM users;
-- параллельно
UPDATE users SET age = 21;
SELECT age FROM users; -- 21 (грязное чтение)
распределённые системы
нужны для:
- отказоустойчивости
- масштабирования
но добавляют сложность.
CAP теорема
нельзя одновременно гарантировать:
- consistency
- availability
- partition tolerance
всегда выбираешь 2 из 3.
consistent hashing
распределение данных по узлам:
def get_node(nodes, key):
return nodes[hash(key) % len(nodes)]
проблема — при добавлении узла всё ломается.
решение — hash ring.
репликация
каждый элемент хранится на нескольких узлах.
можно настраивать:
- сколько узлов отвечает на чтение (R)
- сколько подтверждают запись (W)
условие согласованности:
R + W > N / 2;
anti-entropy
механизмы синхронизации:
- read repair
- hinted handoff
- merkle trees
- gossip
вывод
базы данных — это не просто “куда-то сохранить данные”.
это:
- работа с диском
- структуры данных
- конкуренция
- отказоустойчивость
- распределённые системы
и чем глубже ты копаешь, тем яснее становится: простых решений здесь почти нет.