Same API, 2 s in One Cloud and 12 s in the Other: CockroachDB Leaseholders in a Multi-Cloud Cluster


We ran the same API test, with the same data and the same database, from two clouds. One run took about 2 seconds per transaction. The next took about 12 seconds. Then the rerun flipped: the slow cloud became fast and the fast cloud became slow.

This post walks through how we narrowed it down. The database was nearly idle, so this is not a "tune the SQL" story. It is a story about where the data lives in a distributed database, and why the order of your test runs can decide your results.

Case study, anonymized: this comes from a performance test of a banking application on a CockroachDB cluster stretched across Azure and AWS. Product names and exact figures are removed; the numbers below are rounded but keep the real proportions. The root cause is our leading theory, and section 07 shows the checks that confirm or rule it out.

01 · The setup

Database
CockroachDB, 3 nodes, replication factor 3
Azure
2 nodes (n1, n2) + API pods
AWS
1 node (n3) + API pods
Network
Site-to-site VPN between the two clouds

Both clouds ran the same API on Kubernetes, and each API connected to the database nodes in its own cloud. The load test used a single JMeter thread for 15 minutes per run, so concurrency was not a factor. We tested two APIs: Create Customer (many reads and a few writes) and Get Customer (mostly reads).

02 · The symptom

Run (in order)CloudAvg responseThroughput
Create Customer, run 1Azure≈ 2.3 s≈ 0.4 TPS
Create Customer, run 1AWS≈ 10 s≈ 0.1 TPS
Create Customer, rerunAWS≈ 1.3 s≈ 0.75 TPS
Create Customer, rerunAzure≈ 13 s≈ 0.08 TPS
Get Customer (next day)AWS≈ 0.75 s≈ 0.9 TPS
Get Customer (next day)Azure≈ 4.6 s≈ 0.14 TPS

The slowness did not belong to a cloud. It moved between runs: Azure was fast, then slow; AWS was slow, then fast. A problem that changes sides with the run order points away from the hardware and toward something that changes state between runs.

03 · What it was not

Database CPU
Mostly under 4%, never above 10%
SQL latency
Each statement about 1–5 ms on average
Disk
Near-zero reads, small steady writes
App pods
CPU 3–33%, memory steady

Nothing was saturated. If the database is idle and every statement is fast, the time must be going somewhere between the statements.

04 · The arithmetic that gave it away

The database console showed about 430 queries per second during the first Azure run, at about 0.43 transactions per second. That is roughly 1,000 SQL statements per API call, almost all of them small SELECTs, typically parameter and lookup reads.


Where the data is served fromPer read× 1,000 readsMatches
Same cloud as the API~2 ms≈ 2 sthe fast runs (1.3–2.3 s)
The other cloud, across the VPN~10–13 ms (inferred)≈ 10–13 sthe slow runs (10–13 s)

The ~2 ms figure comes from the database's own statement latency. The cross-cloud figure is what the slow runs imply; section 07 shows how to measure it directly.

That fit was the clue. A chatty API turns a 10 ms network hop into a 10-second response. The SQL was never slow; the number of round trips times the distance was.

05 · Leaseholders in 2 minutes

  • CockroachDB splits every table into ranges of data.
  • Each range has 3 copies (replicas), here one per node.
  • One replica per range is the leaseholder. It serves the reads and coordinates the writes for that range.
  • A read from Azure for a range whose leaseholder is on n3 in AWS has to cross the VPN, even though Azure holds two copies of the same data.

By default, for tables without a multi-region locality, CockroachDB uses follow-the-workload: it moves leases toward the region that is sending the most requests. That is usually helpful. In a test that alternates clouds, it means each run inherits the lease layout of the previous run, and the first run from the other side pays cross-cloud latency until the leases follow it.

Writes are different: with 2 of 3 replicas in Azure, a write needs Azure to acknowledge it (2 of 3 for quorum). So AWS writes cross the VPN in every layout; reads are what flip.

06 · The evidence so far

The Replication → Leaseholders per Node graph in the DB Console showed three phases:

PeriodActivityLeaseholders per node
Afternoon, day 1Create Customer runs, alternating cloudsConstant churn; one node swinging between ~27 and ~39 leases
OvernightNo loadAlmost flat
Morning, day 2Get Customer runsChurn again

Churn during every test and a flat line overnight is the signature of load-based lease movement. The total lease count per node changed only by about 10, but that alone does not tell you which leases moved. If those 10 are the ranges of the lookup tables that each API call reads hundreds of times, they are the whole problem.

We also saw a change in response time around the 8th minute of one run, which fits leases moving mid-run.

07 · How to confirm it

1. Which node is in which cloud?

SQL
SELECT node_id, locality FROM crdb_internal.gossip_nodes ORDER BY node_id;

2. Where are the leases, per table? Run this right after an Azure run and again right after an AWS run (needs admin or ZONECONFIG):

SQL
SELECT table_name, lease_holder_locality, count(*) AS ranges
FROM [SHOW RANGES FROM DATABASE appdb WITH TABLES, DETAILS]
GROUP BY 1, 2
ORDER BY 1, 2;

If the hot tables (the ones at the top of the Statements page for the API) switch from the Azure locality to the AWS locality between the two snapshots, the theory is confirmed.

3. Check the network round trip on the DB Console Network page. If the cross-cloud latency is about 10–13 ms, it matches the slow-run arithmetic.

4. Count the statements per API call from the Statements page (executions ÷ transactions). It tells the application team exactly how chatty the call is.

08 · Fix options and trade-offs

OptionWhat it doesTrade-offBest for
Lease preferencesALTER DATABASE appdb CONFIGURE ZONE USING lease_preferences = '[[+region=azure-region]]';One side is always local, the other always remoteStable, comparable test results now
GLOBAL tablesALTER TABLE param_lookup SET LOCALITY GLOBAL; (after setting up multi-region regions)Fast local reads everywhere; slower writesConfig and lookup tables that rarely change
REGIONAL BY ROWALTER TABLE customer SET LOCALITY REGIONAL BY ROW;Each row lives near the region that owns itTransactional data with a home region
Follower readsSELECT … AS OF SYSTEM TIME follower_read_timestamp()Reads slightly stale data (seconds)Reports and reads that can be a few seconds old
Less chatty APICache lookups, batch queriesNeeds application changesAlways: 1,000 reads per call is the root multiplier
Use your own locality names: the region value in lease_preferences must match the --locality flag the nodes were started with (region=…). Check it with the query in section 07 before applying. Note that once a database uses the multi-region abstractions (GLOBAL, REGIONAL BY ROW), follow-the-workload no longer applies to it.

Our plan: pin leases to the primary cloud so the next test cycle gives repeatable numbers, then work with the application team on GLOBAL locality for the lookup tables and on reducing the round trips per call.

09 · Lessons for multi-cloud databases

LessonWhy
Count round trips, not just query time1,000 × 2 ms looks fine; 1,000 × 12 ms does not. Latency multiplies.
Record lease placement before every test runOtherwise results depend on the previous run, not on the system
Never trust the first run after switching sidesFollow-the-workload needs time to move leases
Read lease counts per table, not per nodeBalanced node totals can hide hot ranges in the wrong cloud
Plan table localities with the app teamLookup tables, transactional tables and reports need different placement
© 2026 Tamilselvan Perumal · TechStarAdmin. Anonymized case study; figures are rounded and do not identify any company, product or customer. Views are my own. Please link back instead of copying.
Originally published at techstaradmin.blogspot.com.