Scaling Databases & Data Consistency · Scaling Writes and Data Size
Resharding and Moving Data Online
Adding capacity without downtime: consistent hashing, logical-shard moves, and the double-write → backfill → verify → switch recipe.
Scenario
Example company: a messaging app sharded by conversation_id % 8 across 8 databases. Growth means going to 12 databases. The plan: change the modulus to 12 during a maintenance window.
What you see in production
A dry run shows that changing % 8 to % 12 would move about two thirds of all rows — many terabytes — and every cache keyed by shard would go cold. A maintenance window of that size isn't acceptable.
The naive code
def shard_for(conversation_id):
return conversation_id % NUM_SHARDS # NUM_SHARDS: 8 → 12 remaps most keys
Reproduce it
Compare how many keys move when going from 8 to 12 shards with plain modulo hashing vs a consistent-hash ring with virtual nodes:
import bisect, hashlib
def h(s): return int(hashlib.md5(s.encode()).hexdigest()[:8], 16) # stable, well-spread hash
keys = [f"conv-{i}" for i in range(20_000)]
moved_mod = sum(1 for k in keys if h(k) % 8 != h(k) % 12)
print(f"modulo 8 -> 12: {moved_mod / len(keys):.0%} of keys move")
def ring(nodes, vnodes=200):
points = sorted((h(f"{n}#{v}"), n) for n in nodes for v in range(vnodes))
return [p for p, _ in points], [n for _, n in points]
def owner(r, key):
points, names = r
i = bisect.bisect(points, h(key)) % len(points)
return names[i]
r8 = ring([f"db{i}" for i in range(8)])
r12 = ring([f"db{i}" for i in range(12)])
moved_ring = sum(1 for k in keys if owner(r8, k) != owner(r12, k))
print(f"consistent hashing 8 -> 12: {moved_ring / len(keys):.0%} of keys move (ideal: {4 / 12:.0%})")
Output
modulo 8 -> 12: 66% of keys move consistent hashing 8 -> 12: 35% of keys move (ideal: 33%)
Root cause
With key % N, changing N changes the result for most keys. Resharding then means moving most of the data at once. Consistent hashing (or a fixed number of logical shards) only moves the data that must move to the new servers.
The fix
- Map keys to a fixed, large number of logical shards (or a hash ring with virtual nodes) and map logical shards to physical databases. Adding a database moves whole logical shards.
- Move each logical shard online with the recipe used by Notion and Stripe's online migrations:
- Double-write new changes to old and new locations (or replicate via CDC/logical replication).
- Backfill existing rows in batches.
- Verify — compare counts/checksums, shadow-read and diff.
- Switch reads, then writes, per logical shard; keep the old copy until you're confident.
LOGICAL = 1024
routing = {ls: "db0" for ls in range(LOGICAL)} # stored in a config service / DB, cached
def db_for(conversation_id: int) -> str:
return routing[zlib.crc32(str(conversation_id).encode()) % LOGICAL]
# move logical shard 37 to db9: double-write → backfill → verify → flip routing[37] = "db9"
-- on the source: publish the tables (or use a per-shard schema)
CREATE PUBLICATION move_ls37 FOR TABLE ls37.messages, ls37.conversations;
-- on the target: initial copy + streaming changes
CREATE SUBSCRIPTION move_ls37 CONNECTION 'host=db0 dbname=app' PUBLICATION move_ls37;
-- when lag is ~0: block writes briefly for ls37, flip routing, drop the subscription
Notion's migration followed double-write, backfill, verification and switch-over, using an audit log to replay writes reliably (Notion engineering); Stripe describes the same dual-write, read-switch, write-switch phases for moving data between tables online (Stripe engineering).
Trade-offs
- Consistent hashing needs virtual nodes to balance well; a routing table is simpler to reason about and lets you move hot shards deliberately.
- Double-writing from the application can diverge on partial failures — prefer an audit log/outbox or database-level replication as the source of truth for the copy.
- The cut-over needs a brief write pause (or a fencing mechanism) per logical shard; keep each unit small.
Detect it in production
- Move progress per logical shard: rows copied, replication lag, verification diffs.
- Per-shard error rates during cut-over; ability to flip routing back instantly.
Key points
key % Nmoves most data when N changes; use logical shards or consistent hashing.- Move data with double-write (or replication) → backfill → verify → switch, one small unit at a time.
- Keep a fast rollback: the old copy stays until verified.
Practice questions
Why does changing `id % 8` to `id % 12` force a huge migration?
Answer
The modulus changes the shard of most keys (about two thirds here), so most data must move at once. Consistent hashing or a fixed set of logical shards moves only what must move.
What are the phases of moving a shard online?
Answer
Double-write (or replicate) new changes, backfill existing data, verify the copy, then switch reads and writes — keeping the old copy for rollback.
Why are virtual nodes used in consistent hashing?
Answer
With few points per server, the ring splits the key space unevenly. Many virtual nodes per server smooth the distribution and spread the load of a removed or added server across many others.
Open in the app to mark lessons complete, take notes and track your progress.