Задача
Две реплики должны убедиться, что хранят одинаковые данные. Лобовое решение -- переслать весь датасет и сравнить -- не работает при гигабайтах/терабайтах. Нужна структура, позволяющая локально сравнить "отпечатки", а различия локализовать в конкретной области.
Merkle tree (Ralph Merkle, 1979) решает это за O(log n) сверки.
Определение
- Листья: hash(data_chunk_i).
- Внутренние узлы: hash(left_child_hash || right_child_hash).
- Корень: один хеш, представляющий весь датасет.
H(H12 || H34) ← Merkle root
/ \
H(H1||H2) H(H3||H4) ← inner
/ \ / \
H1 H2 H3 H4 ← leaves
| | | |
data1 data2 data3 data4
Изменение любого бита в data2:
- H2 поменяется.
- H(H1||H2) поменяется.
- Root поменяется.
Свойство 1: единственный хеш (root) аутентифицирует весь набор. Изменили один байт -- root поменялся.
Свойство 2: за O(log n) можно доказать, что конкретный лист входит в дерево с данным корнем. Нужен Merkle proof: хеши всех sibling'ов на пути от листа к корню.
Merkle proof (inclusion proof)
Доказать, что data2 принадлежит дереву с известным root'ом:
- Предоставить H1 (sibling листа) и H34 (sibling родителя).
- Проверяющий считает: H2' = hash(data2), затем hash(H1 || H2') = H12', затем hash(H12' || H34) = root'.
- Если root' == root -- доказано.
Размер proof: log₂(n) хешей. Для 1 млн листьев -- 20 хешей = 640 байт (при SHA-256). Крайне компактно.
Use cases
1. Git
Git -- Merkle DAG (не дерево, но идея та же):
- Blob -- объект с содержимым файла, адресуемый SHA-1/SHA-256 контента.
- Tree -- объект, содержащий список
(filename, mode, blob_or_tree_hash). Хеш tree'а = hash(список). - Commit -- содержит хеш root tree + родительский commit.
Клонирование Git: клиент получает commit, идёт по ссылкам tree → blobs. Integrity проверяется автоматически -- если хоть один объект повреждён, хеши не совпадут.
2. Bitcoin / блокчейны
Каждый блок Bitcoin содержит Merkle root транзакций блока. Преимущества:
- SPV (Simplified Payment Verification) клиенты: не хранят все транзакции, но могут проверить, что их транзакция попала в блок -- получив Merkle proof от full node.
- Анти-подмена: изменить транзакцию в блоке = поменять root = поменять hash блока = разорвать цепочку.
Ethereum пошёл дальше -- использует Merkle Patricia Trie для состояния (accounts, storage).
3. DynamoDB anti-entropy / Cassandra repair
Реплики могут разойтись из-за network partitions, пропущенных writes. Как их синхронизировать без полного сравнения?
Алгоритм (Cassandra "repair"):
- Каждая реплика строит Merkle tree по своим данным (партиционируя диапазон ключей).
- Реплики обмениваются root'ами.
- Если root'ы разные -- обмениваются next-level хешами.
- Спускаются до листьев, находят конкретные расхождения.
- Обмениваются только расходящимися данными.
Сложность: O(d log n), где d -- число расхождений.
4. IPFS / content-addressable storage
IPFS строит Merkle DAG из chunk'ов файлов:
- Большой файл делится на блоки.
- Каждый блок имеет свой CID (content ID = hash).
- "Файл" = Merkle DAG блоков с root CID.
- Запрос файла по CID -- распределённая загрузка блоков + автоматическая проверка целостности.
5. Certificate Transparency
Google CT логи хранят все выданные TLS-сертификаты в append-only Merkle tree. Любой может:
- Получить доказательство включения своего сертификата.
- Проверить, что лог не перезаписал историю (consistency proof).
6. ZFS / Btrfs / файловые системы
ZFS хранит Merkle tree над блоками данных. Любое чтение проверяет хеш. Silent bit rot детектируется автоматически.
Реализация
<?php
declare(strict_types=1);
/**
* Minimal Merkle tree using SHA-256.
* For odd number of leaves, the last one is duplicated (Bitcoin convention).
*/
final class MerkleTree
{
/** @var list<string> Hex-encoded node hashes, bottom level first. */
private array $levels = [];
/**
* @param list<string> $leavesData Raw leaf data (strings or serialized records).
*/
public function __construct(array $leavesData)
{
if ($leavesData === []) {
throw new \InvalidArgumentException('At least one leaf is required');
}
$current = array_map(fn(string $d): string => hash('sha256', $d), $leavesData);
$this->levels[] = $current;
while (count($current) > 1) {
// Duplicate last if odd
if (count($current) % 2 === 1) {
$current[] = end($current);
}
$next = [];
for ($i = 0; $i < count($current); $i += 2) {
$next[] = hash('sha256', $current[$i] . $current[$i + 1]);
}
$this->levels[] = $next;
$current = $next;
}
}
public function root(): string
{
return $this->levels[count($this->levels) - 1][0];
}
/**
* Proof of inclusion for leaf at index $leafIndex.
* @return list<array{hash: string, position: 'left'|'right'}>
*/
public function proof(int $leafIndex): array
{
$proof = [];
$idx = $leafIndex;
for ($level = 0; $level < count($this->levels) - 1; $level++) {
$nodes = $this->levels[$level];
$siblingIdx = $idx % 2 === 0 ? $idx + 1 : $idx - 1;
if ($siblingIdx < count($nodes)) {
$proof[] = [
'hash' => $nodes[$siblingIdx],
'position' => $idx % 2 === 0 ? 'right' : 'left',
];
}
$idx = intdiv($idx, 2);
}
return $proof;
}
/** Verify that leafData is included in a tree with given root using proof. */
public static function verify(string $leafData, array $proof, string $root): bool
{
$h = hash('sha256', $leafData);
foreach ($proof as $step) {
$h = $step['position'] === 'right'
? hash('sha256', $h . $step['hash'])
: hash('sha256', $step['hash'] . $h);
}
return hash_equals($h, $root);
}
}
// Example: reconcile two replicas
// $treeA = new MerkleTree($replicaA->rows());
// $treeB = new MerkleTree($replicaB->rows());
// if ($treeA->root() !== $treeB->root()) {
// // descend level by level to find diverging subtree
// }
Допустим, реплики A и B хранят отсортированные записи (keys 1..10000). Как найти расхождение?
1. Строят Merkle tree по своим данным (листья = hash(row_i)).
2. Обмениваются root.
3. A.root == B.root → данные совпадают, выходим.
4. Иначе обмениваются хешами level-1 (корней двух половин).
5. Для каждого поддерева: если хеши равны → пропустить; иначе рекурсивно спуститься.
6. Добравшись до листа с расхождением -- знаем, какая именно запись отличается. Запрашиваем её полностью.
Сложность: O(d × log n), где d -- число расходящихся записей. Для 10 расхождений в 10⁷ записей -- ~230 обменов хешами, что на порядки меньше пересылки всего датасета.
Практические нюансы
- Выбор хеш-функции: SHA-256 стандарт; Blake3 быстрее; для не-критичных задач BLAKE2, xxHash.
- Бикинг против second-preimage: в Bitcoin для защиты добавляют префикс
0x00для листьев и0x01для inner nodes. - Нечётное число листьев: Bitcoin дублирует последний; RFC 6962 (CT) продвигает ассиметричный подход.
- Incremental update: если данные меняются -- нужно пересчитывать O(log n) хешей от листа до корня; полное перестроение -- O(n).
- Sparse Merkle tree: полное бинарное дерево высоты 256 с "пустыми" ветвями -- даёт proof of non-inclusion. Используется в Ethereum state.
Merkle DAG
Если данные имеют граф-структуру (как в Git, IPFS), превращаем Merkle tree в Merkle DAG:
- Нод -- объект с хешом своего содержимого (включая ссылки на другие узлы).
- Ссылка = содержит хеш цели.
- Любое изменение распространяется по ссылочной цепочке вверх.
Свойство: content-addressable storage. Одинаковое содержимое -- один и тот же хеш -- один физический объект. Git использует это для дедупликации (два коммита с одинаковым файлом ссылаются на один blob).
Выводы
- Merkle tree -- дерево хешей, где корень аутентифицирует весь набор данных.
- Inclusion proof занимает O(log n) хешей -- достаточно, чтобы доказать, что конкретный лист в дереве.
- Сверка двух реплик через Merkle tree: O(d log n) вместо O(n).
- Git, Bitcoin, Ethereum, IPFS, Cassandra, DynamoDB, Certificate Transparency, ZFS -- везде Merkle trees.
- Merkle DAG (Git, IPFS) -- обобщение для content-addressable storage.
- SHA-256 стандарт, но BLAKE3/Blake2/xxHash возможны для не-критичных задач.