MidТеория8 min

Merkle tree

Hash tree для верификации и сверки: Git, Bitcoin, DynamoDB anti-entropy, Cassandra repair, IPFS

Задача

Две реплики должны убедиться, что хранят одинаковые данные. Лобовое решение -- переслать весь датасет и сравнить -- не работает при гигабайтах/терабайтах. Нужна структура, позволяющая локально сравнить "отпечатки", а различия локализовать в конкретной области.

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"):

  1. Каждая реплика строит Merkle tree по своим данным (партиционируя диапазон ключей).
  2. Реплики обмениваются root'ами.
  3. Если root'ы разные -- обмениваются next-level хешами.
  4. Спускаются до листьев, находят конкретные расхождения.
  5. Обмениваются только расходящимися данными.

Сложность: 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 обменов хешами, что на порядки меньше пересылки всего датасета.

Практические нюансы

  1. Выбор хеш-функции: SHA-256 стандарт; Blake3 быстрее; для не-критичных задач BLAKE2, xxHash.
  2. Бикинг против second-preimage: в Bitcoin для защиты добавляют префикс 0x00 для листьев и 0x01 для inner nodes.
  3. Нечётное число листьев: Bitcoin дублирует последний; RFC 6962 (CT) продвигает ассиметричный подход.
  4. Incremental update: если данные меняются -- нужно пересчитывать O(log n) хешей от листа до корня; полное перестроение -- O(n).
  5. 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 возможны для не-критичных задач.