postgres logical-shards resharding harness
postgres logical-shards resharding harness
A reproducible harness for the cost of resharding. If you place a workspace’s
rows with physical = hash(workspace_id) % P, then adding one machine (P -> P+1)
changes the modulus, so almost every key rehashes and almost every row has to
physically move. The fix (what Notion did) is a fixed, large pool of logical
shards mapped to physical machines through a lookup table:
logical = hash(workspace_id) % 480 never changes, and physical = lookup[logical]
is the only thing you touch on a rebalance. Rescaling then re-points whole logical
shards at new machines — a known subset of rows — with zero application-side
rehash.
A physical shard is modeled as a Postgres schema (shard_p0 .. shard_p{P-1})
inside one digest-pinned Postgres 16 instance. Moving a logical shard = moving its
rows between schemas with INSERT INTO ... SELECT + DELETE in one transaction,
and we count the rows actually moved in SQL. A physical shard = a schema; a data
move = a real Postgres row move, counted by real row counts. No N-container theater.
Hash. blake2b(str(workspace_id), digest_size=8) -> int, used identically for
% P (physical modulo) and % L (logical). Not Python’s builtin hash().
Data. ~200,000 workspaces, each with a skewed 1–20 row count (cubic skew toward
1), keyed by workspace_id so a workspace’s rows co-locate. The captured run
generated 1,202,279 rows (avg 6.01 rows/workspace). Fixed seed 1234.
Experiments
- A. The modulo resharding tax. Placement is
hash % P. For each transitionP_old -> P_newin {4→5, 4→6, 4→8, 8→12} we count the rows whose(hash%P_old) != (hash%P_new)— they must physically move. Counted over the dataset’s real per-workspace row counts. Writesexp_a_modulo.csv. - B. Logical shards + lookup table (real data movement).
L=480logical shards,logical = hash % 480(invariant). We actually load all rows intoP_oldschemas via the lookup table, then rebalance toP_newby re-pointing the minimum set of logical shards (targetL/P_newper machine), moving their rows between schemas in one transaction and counting the moved rows in SQL. Done for 4→6 and 8→12. Key→logical churn is 0% by construction; we verify(count, sum(row_id))is identical before and after so no row is lost or duplicated. Writesexp_b_logical.csv. - C. Why the logical count should be highly composite. For
Pin {3,4,5,6,8,10,12,15,16,24,32} we distributeLlogical shards acrossPmachines (contiguous groups) and measure per-machine row-load imbalance. We compareL=480(highly composite,2^5·3·5) vsL=479(prime) vsL=500. A subset (L∈{480,479}, P∈{6,16}) is measured by real per-schema row counts (loaded and counted in SQL); the rest is shard-count arithmetic over the same real per-logical-shard row counts. Thesourcecolumn labels which is which. Writesexp_c_composite.csv.
Run it
Docker with Compose v2, plus Python 3.9+.
cd benchmarks/postgres-logical-shards
docker compose up -d --wait # postgres 16 on 127.0.0.1:55442
python3 -m venv /tmp/pglogical-venv && source /tmp/pglogical-venv/bin/activate
pip install -r requirements.txt
python benchmark.py # writes results/ ; console mirrors it
docker compose down -v
Env knobs (defaults in parens): PGHOST(127.0.0.1) PGPORT(55442)
PGPASSWORD(shardbench) N_WORKSPACES(200000) MAX_ROWS_PER_WS(20) L(480)
SEED(1234) RESULTS_DIR(results/).
Results (captured run: PostgreSQL 16.14, 1,202,279 rows)
A — modulo tax (rows that must move):
| transition | rows moved | pct rows | pct workspaces |
|---|---|---|---|
| 4→5 | 961,006 | 79.9% | 79.9% |
| 4→6 | 802,972 | 66.8% | 66.7% |
| 4→8 | 599,328 | 49.8% | 49.9% |
| 8→12 | 800,977 | 66.6% | 66.6% |
B — logical shards + lookup (real moves):
| transition | logical shards moved | rows moved | pct rows | key→logical churn | checksum identical |
|---|---|---|---|---|---|
| 4→6 | 160 | 401,550 | 33.4% | 0.0% | yes |
| 8→12 | 160 | 403,506 | 33.6% | 0.0% | yes |
Same 4→6 rescale: 66.8% of rows move under plain modulo vs 33.4% under the
lookup table — and the lookup table moves whole logical shards (a known set) with
0% key churn, while modulo rehashes two-thirds of every key.
C — why 480: 480 = 2^5·3·5 divides evenly (shards-per-machine spread = 0) for
every P tested; 479 (prime) never does (spread = 1 for all P); 500 only
for P ∈ {4,5,10}. See exp_c_composite.csv for the full table and measured vs
derived row loads.
summary.txt— structured headline numbers per experiment.console.log— full console output of the captured run.exp_a_modulo.csv/exp_b_logical.csv/exp_c_composite.csv— per-experiment data.run_metadata.csv— postgres version, image digest, hash function, all params.
What reproduced cleanly, what was lumpy
A and B reproduce sharply and are essentially exact: the modulo tax lands right on
the textbook (N-1)/N-style fractions (4→5 ≈ 80%, 4→8 ≈ 50%, 4→6 and 8→12 ≈ 66.7%),
and the lookup table moves exactly 160/480 = 33.3% of the data with a bit-for-bit
identical id checksum and 0% key churn. The lumpy part is C’s row-ratio column:
because 480 logical shards over ~1.2M rows put only ~2,500 rows in a shard, one extra
shard on a machine (the prime/500 case, spread = 1) is under ~1% of that machine’s
load, so the row ratios for 480 vs 479 look close. The crisp, honest signal in C is
the divides_evenly / shards_spread column — the arithmetic of divisibility —
not the row ratio; the row ratio only widens noticeably at large P (e.g. L=500,
P=32 hits 1.14x vs 480’s 1.06x).
These are laptop numbers. The point is the mechanism and the ratio — two-thirds of rows moved and every key rehashed under modulo vs a third of rows moved and zero key churn under a lookup table — not absolute throughput or a capacity statement.