Distributed systems · 13 min
Merkle tree
Two copies of a million rows find the few that differ by comparing about 41 short fingerprints, not a million rows.
Your ramen delivery app keeps its orders in two databases, one in Tokyo and one in Osaka, so either can serve customers if the other goes down. Last night the link between them dropped for 30 seconds.
A few changes reached Tokyo but never reached Osaka: an order marked delivered, a quantity corrected. This morning each copy holds just over a million orders, and a handful don’t match. Which ones, and how do you find them without shipping the whole table across the country?
Attempt one: ship every order across
Osaka sends all of its orders to Tokyo, and Tokyo checks them one by one. To keep the numbers round, say there are exactly 1,048,576 orders (that is 2²⁰, “about a million”) of about 1 KB each.
That is about 1 GB over the network to find perhaps five bad rows. Every night. This is the wrong way; it is just the number to beat.
Attempt two: send a fingerprint instead
Each side reads its own orders, sorted by order ID, and feeds them all, one after another, into a single hash: one fingerprint for the whole table. With SHA-256 that is 32 bytes. Osaka sends those 32 bytes to Tokyo.
If the two fingerprints match, the tables match. One tiny message and you are done. But last night they won’t match, and a mismatch tells you only “something, somewhere, is different”. To find what, you are back to sending a gigabyte.
Attempt three: one fingerprint per chunk
Cut the sorted orders into 1,024 chunks of 1,024 orders each, and fingerprint each chunk. Osaka sends 1,024 fingerprints, which is 32 KB. Tokyo finds the chunks whose fingerprints differ (five bad orders touch at most five chunks), and only those chunks come across: about 1 MB each.
1 GB has become a few MB. But look at what you sent first: a list of 1,024 fingerprints that are almost all equal. That list is just more data to compare, so you can fingerprint it too.
Fingerprints of fingerprints
Take that idea all the way down to single orders and all the way up to one code. Here it is with 8 orders, like the demo below:
- Hash each order on its own. These 8 codes are the leaves.
- Pair up neighbours, stick each pair’s two codes together, and hash that. You get 4 parents.
- Pair those up and hash again: 2 boxes.
- Once more: 1 box. That is the root.
With 1,048,576 orders you halve 20 times to get down to one box, so the tree has the leaves plus 20 levels above them. Just as log₂ 1,024 = 10, log₂ 1,048,576 = 20.
Count the boxes and each level has half as many as the one below: 8 + 4 + 2 + 1 = 15 for 8 orders. For a million it is 1,048,576 + 524,288 + … + 1, which comes to just under 2 million: always one less than twice the number of orders.
Guess first: Osaka missed the update to one order out of 8. Of the 15 boxes in Osaka’s tree, how many now differ from Tokyo’s?
Four: that order’s leaf, its parent, the box above that, and the root. Every other box only covers orders that are still the same, so it doesn’t change. With a million orders it is 21 boxes (the leaf plus one on each of the 20 levels above) out of about 2 million. A change leaves a trail of different boxes from its leaf straight up to the root.
Try it: two replicas, one tree each
- Press “Change a random block”: one block in Replica B’s list changes, and in B’s tree its leaf and every box above it up to the root change too. Then press “Compare next →” one step at a time. The walk ends after 7 comparisons, skipping every branch that matched.
- Under “At scale, 1 changed block among”, 1 million is already picked: about 41 comparisons (the next section shows why) against a full scan of 1,048,576 blocks. Click 1 billion: only 61.
- Press “Reset B”, type a change into the text boxes for block #0 and block #1, then press “Run the whole sync”. Note the count. Press “Reset B” again and do the same with #0 and #7.
- Switch to “Prove a block is included” and click any leaf. The steps under the tree list the sibling hashes (“Add sibling … on the left/right”): 3 of them for 8 blocks, and they are all a checker needs.
The demo calls orders “blocks” and calls Tokyo and Osaka “Replica A” and “Replica B”. It uses a short 8-character hash so it fits on screen. The box at its top lets you hash any text: change “3 ramen” to “4 ramen” and the whole code changes.
Walking down only the branch that differs
Tokyo and Osaka each build their tree locally. That reads every order once, on its own server, but sends nothing. Then they talk, starting at the top. Each comparison is Osaka sending one 32-byte code and Tokyo checking it against its own box in the same place. Osaka sends all the codes Tokyo asks for on one level in a single message, so it is one round trip per level:
- Compare the two roots. If they match, every order matches. Stop.
- If they differ, compare the two children of that box.
- A child that matches is skipped, along with everything under it.
- A child that differs gets its own two children compared, and so on.
- A leaf that differs is a bad order. Osaka sends just that one.
With one bad order in a million, every level below the root costs exactly 2 comparisons: one child matches, one doesn’t. That is 1 for the root plus 2 × 20 levels = 41 comparisons, in 21 round trips.
| Orders | Levels below the root | Comparisons to find 1 bad order | Orders a full scan reads |
|---|---|---|---|
| 8 | 3 | 7 | 8 |
| 1,024 | 10 | 21 | 1,024 |
| 1,048,576 | 20 | 41 | 1,048,576 |
| 1,073,741,824 | 30 | 61 | 1,073,741,824 |
That “exactly 2 per level” holds for one bad order. With several, it becomes an upper limit, because bad orders that share boxes share comparisons. The general rule: with n orders and k bad ones, the walk takes at most 1 + 2 × k × log₂ n comparisons. For last night’s five bad orders among a million: 1 + 2 × 5 × 20 = 201 comparisons, about 6 KB of fingerprints, then five orders. Compare that with 1 GB.
Here is the walk as code. Notice that a matching pair returns straight away; that early return is where all the saving comes from.
diff(a, b, level, i): # start at the root: diff(a, b, top, 0)
if a[level][i] == b[level][i]:
return [] # same: skip everything below
if level == 0:
return [i] # a leaf: this order differs
return diff(a, b, level - 1, 2*i) + diff(a, b, level - 1, 2*i + 1)When the tree stops helping
Guess first: Two orders are different in an 8-order tree. Which costs more to find: orders #0 and #1, or orders #0 and #7? (Try both in the demo.)
#0 and #7 costs more: 11 comparisons against 7. Orders #0 and #1 share every box above them, so the walk follows a single path down and splits only at the very end. Orders #0 and #7 sit in opposite halves, so the walk has to follow two separate paths all the way down.
So bad orders that sit close together are cheaper than scattered ones. And if most orders differ, the walk opens nearly every one of the 2 million boxes and then copies most of the orders anyway, which costs more than just copying the table. A Merkle tree is for the common case: two copies that are almost the same.
Keeping the tree current is cheap too. When one order changes, only the 21 boxes on its path to the root need new hashes, not the whole tree.
A bonus: proving one order is in there
The same tree answers a second question. Say the shop publishes its root every night, and an auditor wants to check that order #2 is really in the data without downloading a million orders.
You send order #2 plus one sibling code per level: the box next to its path at each step up. The auditor hashes the order, then joins it with each sibling in turn and hashes again. The proof says, for each sibling, whether it goes on the left or the right. Finally the auditor checks that the result equals the root. A shop can’t fake this with made-up siblings: landing on the published root with a changed order would mean finding two different texts with the same hash, which SHA-256 makes practically impossible. That is 3 codes for 8 orders, 20 for a million and 30 for a billion. Change the order by one letter and the result won’t match the root.
Who keeps a tree like this
- Cassandra runs a “repair” job that brings replicas back in line. It builds a Merkle tree on each, compares them, and sends only the rows under leaves that differ. Each leaf covers a range of rows, as in the note above.
- Amazon’s Dynamo paper (2007) describes the same idea for keeping replicas in sync in the background.
- Git names every file, folder and commit by the hash of its contents, and a folder’s hash covers the hashes inside it. That is why any change to any file gives a new commit ID.
- Certificate Transparency logs (public lists of every HTTPS certificate issued) and Bitcoin publish roots, so anyone can check that one certificate or one transaction is included with a short proof, like the auditor above.
Back to Tokyo and Osaka. Each night they compare 32-byte roots. Most nights that is one comparison and done. After a blip, a couple of hundred comparisons point straight at the handful of orders that need copying.
Remember
- Each box is the hash of its two children; the root depends on every row.
- Equal roots mean equal data. Unequal roots: walk down, skipping every branch that matches.
- One difference in a million rows takes about 41 comparisons, because the tree is only 20 levels deep.
- It pays off when copies are almost the same; if most rows differ, just copy everything.
- Proving one row is included takes one sibling hash per level: 20 for a million.
See it in a design
- Key-value store: Dynamo-style: consistent hashing, three replicas, quorums, hinted handoff and Merkle-tree repair.
- Dropbox file sync: Content-addressed 4 MB blocks, a metadata journal with cursors, and long-poll change notifications.
- Object storage: S3-like storage: stateless front ends, a sorted key index and erasure-coded data with constant repair.
Further reading and videos
- Merkle Tree with real world examples (Gaurav Sen)
- How Git and Cursor sync code (Merkle trees are super cool!) (Ben Dicken)
- Google SWE teaches systems design | EP42: Merkle Trees (Jordan has no life)
- Merkle Tree | Merkle Root | Blockchain (Telusko)
- Merkle tree (Wikipedia)
- Anti-entropy repair (DataStax (Cassandra))
- Git internals: Git objects (Pro Git)
- Amazon’s Dynamo (All Things Distributed)
- RFC 9162: Certificate Transparency Version 2.0 (IETF)