Builds phased data and system migrations using feature flags, dual writes, backfills, and validation. Includes rollback plans and risk mitigation...
Execute safe, zero-downtime migrations with validation and rollback plans.
Phase 1: Deploy new code (disabled)
Phase 2: Enable for 1% traffic
Phase 3: Ramp to 10%, 50%, 100%
Phase 4: Remove old code
Phase 1: Write to both old and new
Phase 2: Backfill old → new
Phase 3: Read from new (write both)
Phase 4: Stop writing to old
Phase 5: Decommission old
Blue (current) → Green (new)
Switch traffic: Blue → Green
Rollback available: Green → Blue
# Migration Plan: MySQL → PostgreSQL
## Overview
**What:** Migrate user database from MySQL to PostgreSQL
**Why:** Better JSON support, improved performance
**When:** Q1 2024
**Owner:** Database Team
**Risk Level:** HIGH
## Current State
- MySQL 8.0
- 500GB data
- 100K users
- 1000 writes/min
- 10,000 reads/min
## Target State
- PostgreSQL 15
- Same data model
- No downtime
- Data validation 100% match
## Phases
### Phase 1: Dual Write (Week 1-2)
**Goal:** Write to both databases
**Steps:**
1. Deploy PostgreSQL cluster
2. Create schema in PostgreSQL
3. Deploy dual-write code
4. Enable dual writes (MySQL primary, PostgreSQL secondary)
**Code:**
```typescript
async function createUser(data: CreateUserDto) {
// Write to MySQL (primary)
const mysqlUser = await mysql.users.create(data);
// Write to PostgreSQL (secondary, fire and forget)
postgres.users.create(data).catch((err) => {
logger.error("PostgreSQL write failed", err);
});
return mysqlUser; // Still trust MySQL
}
```
Validation:
Rollback: Disable PostgreSQL writes
Goal: Copy historical data
Steps:
Script:
def backfill():
last_id = get_last_migrated_id()
batch_size = 1000
while True:
users = mysql.query(
"SELECT * FROM users WHERE id > %s LIMIT %s",
[last_id, batch_size]
)
if not users:
break
postgres.bulk_insert(users)
last_id = users[-1]['id']
save_checkpoint(last_id)
time.sleep(0.1) # Rate limit
Validation:
Rollback: Delete PostgreSQL data
Goal: Validate PostgreSQL reads
Steps:
Code:
async function getUser(id: string) {
const mysqlUser = await mysql.users.findById(id);
// Shadow read from PostgreSQL
postgres.users.findById(id).then((pgUser) => {
if (!deepEqual(mysqlUser, pgUser)) {
logger.warn("Data mismatch", { id, mysqlUser, pgUser });
metrics.increment("migration.mismatch");
}
});
return mysqlUser; // Still trust MySQL
}
Validation:
Rollback: Remove shadow reads
Goal: Read from PostgreSQL
Steps:
Code:
async function getUser(id: string) {
if (featureFlags.readFromPostgres) {
return postgres.users.findById(id);
}
return mysql.users.findById(id);
}
Validation:
Rollback: Flip feature flag off
Goal: PostgreSQL is now primary
Steps:
Code:
async function createUser(data: CreateUserDto) {
return postgres.users.create(data);
// No longer writing to MySQL
}
Validation:
Rollback: Re-enable MySQL writes
Goal: Remove MySQL
Steps:
Rollback: Not available (point of no return)
def validate_migration():
# Row counts
mysql_count = mysql.query("SELECT COUNT(*) FROM users")[0]
pg_count = postgres.query("SELECT COUNT(*) FROM users")[0]
assert mysql_count == pg_count
# Random sampling
sample = mysql.query("SELECT * FROM users ORDER BY RAND() LIMIT 1000")
for row in sample:
pg_row = postgres.query("SELECT * FROM users WHERE id = %s", [row['id']])
assert row == pg_row
# Checksums
mysql_checksum = mysql.query("SELECT MD5(GROUP_CONCAT(id, email)) FROM users")
pg_checksum = postgres.query("SELECT MD5(STRING_AGG(id::text || email, '')) FROM users")
assert mysql_checksum == pg_checksum
Mitigation:
Mitigation:
Mitigation:
**Week 0:** Migration announced
**Week 2:** Phase 1 complete (dual writes)
**Week 4:** Backfill complete
**Week 6:** Traffic shifted to PostgreSQL
**Week 8:** Migration complete