Skip to content

How to Migrate from MySQL to Cloud Spanner with Zero Downtime

Core Concept LearningAugust 3, 20268 min read

Cloud Spanner and MySQL agree on almost nothing below the SQL syntax layer — Spanner shards data across nodes by key range, replicates synchronously across regions using TrueTime, and has no auto-increment primary key at all, while MySQL's storage engine assumes a single writable instance with a clustered index. Migrating the schema literally (same table shapes, same auto-increment ids) produces a Spanner database that runs, passes a smoke test, and then hotspots catastrophically in production the moment write volume rises — because sequential ids concentrate all new writes on one key range, one Spanner split, one small set of servers, defeating the horizontal scaling Spanner exists to provide.

This guide walks one running migration: an orders table with an auto-increment id, migrated to Spanner with zero read/write downtime for the application. You'll see why the schema needs to change (not just move), the dual-write cutover pattern that avoids a maintenance window, the verification step that catches divergence before cutover, and the specific hotspotting failure that sequential keys cause on Spanner — plus the fix. For choosing among storage engines before migrating at all, see how to choose a database and SQL vs. NoSQL.

MySQL and Spanner disagree below the SQL syntax layer
MySQL and Spanner disagree below the SQL syntax layer

Why the schema has to change, not just move

MySQL's AUTO_INCREMENT primary key is the first thing that must not survive the migration unchanged. Spanner distributes rows across servers by splitting the keyspace into contiguous ranges — a monotonically increasing id means every new row lands in the same range, on the same small set of servers, at exactly the rate new rows are created. That's the opposite of what Spanner is built for: it scales by spreading write load across ranges, and a hot, ever-growing tail range cannot be split fast enough to keep up, so write latency degrades under load in a way that never shows up in a low-traffic staging test.

The fix is a key that distributes writes: a UUID (random, so writes spread uniformly), or a bit-reversed sequence (reverses the bits of an incrementing counter so sequential source values become effectively random in Spanner's keyspace, while an application join can still recover ordering from the original counter stored as a regular column). Interleaved tables are the second real schema change — Spanner lets a child table (order_items) be physically co-located with its parent row (orders) by declaring INTERLEAVE IN PARENT, which turns what would be a join in MySQL into a colocated read, but only if the child's primary key is prefixed with the parent's key — a schema decision that has no MySQL equivalent to translate from.

Quick reference

  • Replace AUTO_INCREMENT with a UUID or bit-reversed sequence; a literal same-shape migration is the single most common cause of post-migration hotspotting.
  • Use INTERLEAVE IN PARENT for genuinely 1:many child tables read together with their parent (order → order_items) — it turns a join into a colocated scan.
  • Spanner has no native AUTO_INCREMENT — generate ids in application code or via a GENERATE_UUID() default column value.
  • Foreign keys, check constraints, and most standard SQL types map directly; the primary-key strategy and interleaving are the two decisions with no direct MySQL analog.

Remember this

A sequential primary key that works fine in MySQL becomes a write hotspot in Spanner — redesign the key (UUID or bit-reversed sequence) before migrating a single row, not after the first production incident.

The dual-write pattern that avoids a maintenance window

A big-bang cutover (stop the app, dump MySQL, load Spanner, restart pointing at Spanner) is simple but forces real downtime proportional to data size — hours for a large orders table. The zero-downtime pattern instead runs both databases simultaneously for a transition period: backfill copies existing MySQL rows into Spanner (via Datastream's MySQL-to-Spanner replication, or a custom batch job for full control over the key transformation), while the application dual-writes every new write to both databases starting from a captured point, so no write during the transition is missed by either side.

Reads stay on MySQL throughout the transition — Spanner is a write-only shadow copy until verification passes. This ordering matters: dual-writing before backfill completes means Spanner has some rows from the backfill and some from live dual-writes with no gap, but you only find out if that's actually true by running a verification pass, not by assuming the two writers agree.

Dual-write cutover: backfill and live writes converge
Dual-write cutover: backfill and live writes converge

Quick reference

  • Capture a MySQL binlog position (or use Datastream's change stream) before starting backfill, so dual-writes and backfill have a well-defined non-overlapping boundary.
  • Dual-write failures on the Spanner side must not fail the user-facing request — MySQL is still the source of truth; queue failed Spanner writes for reconciliation instead.
  • Datastream (GCP's managed CDC service) handles ongoing MySQL → Spanner replication during backfill without custom binlog-parsing code.
  • Keep dual-writing for a full billing/reporting cycle if any downstream job reads by day/week boundary — cutting over mid-cycle can split one logical period across two sources.
Big-bang cutover — real downtime
1-- 1. stop application traffic2-- 2. mysqldump entire orders table (minutes to hours, scales with size)3-- 3. transform + load into Spanner4-- 4. repoint application config at Spanner5-- 5. resume traffic6-- Downtime = dump time + transform time + load time
Dual-write cutover — no downtime window
1async function createOrder(order: OrderInput) {2  const id = generateBitReversedId(); // Spanner-friendly key generated once3  await mysql.query("INSERT INTO orders (id, ...) VALUES (?, ...)", [id, ...]);4  try {5    await spanner.insert("orders", { id, ...toSpannerRow(order) });6  } catch (err) {7    // log + queue for reconciliation — MySQL write already succeeded and is the8    // source of truth during the transition; Spanner is a shadow copy, not yet live.9    await reconciliationQueue.push({ id, action: "retry-spanner-write" });10  }11  return id;12}13// Reads stay on MySQL until the verification pass below confirms parity.

Remember this

Dual-write with MySQL as the read source of truth throughout the transition — Spanner only becomes the primary read target after a verification pass proves it, not the moment dual-writing starts.

Verifying parity before you flip reads

Before cutting reads over to Spanner, run a row-count and checksum comparison for every table, and a sampled row-by-row diff on a meaningful subset (not just counts — two tables can have matching row counts and still disagree on content if a transformation bug silently corrupted a field). A common bug at this stage is a type-precision mismatch: MySQL's DECIMAL(10,2) and Spanner's NUMERIC don't always round identically at the edges, and a checksum comparison catches that in minutes where a production billing discrepancy would take weeks to notice and trace back to the migration.

Once verification passes, flip reads to Spanner behind a feature flag scoped to a small percentage of traffic first, watching error rates and p99 latency before expanding to 100% — the same staged-rollout discipline as any production database cutover. Keep MySQL dual-writing (as a rollback target) for a defined window after full cutover, not indefinitely — the goal is a bounded rollback window, not a permanent second database to maintain.

Quick reference

  • Run row-count parity and a sampled content checksum (not just counts) before flipping any read traffic — matching counts can still hide silent transformation bugs.
  • Stage the read cutover behind a percentage-based flag; watch p99 latency and error rate at 1%, then 10%, then 100% rather than an instant full switch.
  • Keep MySQL writable as a rollback target for a defined window (days, not indefinitely) after full cutover — define the rollback window before migration day, not during an incident.
  • Decimal/numeric type precision differences between MySQL and Spanner are a common silent-diff source — checksum-compare monetary columns specifically.

Remember this

Row counts alone don't prove parity — checksum a content sample before flipping reads, and stage the read cutover by percentage with a defined, time-bounded rollback window.

The hotspot failure that shows up only under real load

The realistic failure: a team migrates the orders table keeping the original auto-increment integer as the Spanner primary key (it's the path of least resistance — no application-code change needed) and everything passes staging tests, since staging traffic is too low to trigger the problem. In production, write volume climbs, and one Spanner split (the physical unit Spanner uses to distribute a key range across servers) absorbs 100% of insert traffic because every new order's key is numerically larger than all previous ones — write latency climbs, Spanner's split-management tries to react by splitting the hot range further, but a range that's hot because of the write pattern itself keeps re-concentrating on its newest edge no matter how many times it's split.

The diagnostic evidence is visible in Spanner's monitoring: a single split showing disproportionate CPU and write throughput compared to its siblings, correlated exactly with insert rate on the affected table. The recovery requires a real migration, not a config change — re-key the table with a UUID or bit-reversed id, backfill under the same dual-write discipline as the original migration, and cut over again. Prevention is cheaper than recovery here: choose the distributed-friendly key before the first row is written, not after the incident.

Zoom: a sequential id concentrates every insert on one split
Zoom: a sequential id concentrates every insert on one split

Quick reference

  • Spanner's per-node CPU and per-split write-throughput metrics (Cloud Monitoring) are the diagnostic signal for a hotspot — check them before assuming it's a query-plan problem.
  • A hotspot from a monotonic key doesn't self-heal by splitting further; the newest edge of the range stays hot regardless of how many times Spanner subdivides it.
  • Re-keying an already-live table means running the dual-write/backfill/verify cycle a second time against a new key — budget for that possibility during the original migration's design review.
  • See hot partitions and hotspot mitigation for the general pattern this specific Spanner case is one instance of.

Remember this

A monotonic primary key doesn't fail in staging — it fails under real production write volume, and the only real fix is re-keying the table, not tuning around a hot split after the fact.

Key takeaway

Set up a small orders table in both a local MySQL instance and a Spanner emulator, with a bit-reversed-sequence id instead of auto-increment, and write the dual-write path shown above. Expected result: inserting 1,000 rows through the dual-write function leaves both databases with matching row counts and a checksum match on a 10% sample. Then break it intentionally — insert another 1,000 rows using the original auto-increment id instead of the bit-reversed one, and use the emulator's split-metadata inspection (or a simple key-distribution histogram query) to confirm those 1,000 rows all land in a much narrower key range than the first batch. Recovery: re-run the batch with the bit-reversed key generator and confirm the key distribution spreads across the full range. Pass criterion: the bit-reversed batch shows a roughly uniform key distribution while the auto-increment batch visibly clusters — proving the hotspot mechanism before you ever see it in production.

Share:

Related Articles

This guide is for engineers who know basic SQL and key-value access but need to justify a production database choice. By

Read

As relational databases grow beyond millions to billions of rows, single-table query performance degrades due to massive

Read

Approximate Nearest Neighbor (ANN) search is the engine behind Retrieval-Augmented Generation (RAG) and semantic search.

Read

Explore this topic

Keep learning

Follow a structured path or browse all courses to go deeper.