Przejdź do głównej zawartości

Wzorce migracji baz danych

Migracje baz danych to fundament rozwijających się aplikacji. W miarę jak system rośnie, schemat bazy danych musi się adaptować — dodając funkcje, poprawiając wydajność i naprawiając błędy projektowe. Dzięki wsparciu AI możesz implementować zaawansowane wzorce migracji, które minimalizują ryzyko, zapewniają integralność danych i umożliwiają wdrożenia bez przestojów. Ten przewodnik obejmuje sprawdzone wzorce migracji baz danych w środowisku produkcyjnym.

Nowoczesne migracje baz danych opierają się na następujących zasadach:

Kompatybilność wsteczna

Zmiany muszą działać zarówno ze starymi, jak i nowymi wersjami aplikacji

Odwracalność

Każda migracja powinna mieć bezpieczną ścieżkę cofnięcia

Integralność danych

Nigdy nie trać danych, zawsze waliduj transformacje

Zero przestojów

Systemy produkcyjne muszą pozostać online podczas migracji

Śledź i stosuj zmiany w bazie danych systematycznie używając numerów wersji.

-- V1__Create_users_table.sql
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) NOT NULL UNIQUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- V2__Add_user_profiles.sql
CREATE TABLE user_profiles (
user_id UUID PRIMARY KEY REFERENCES users(id),
full_name VARCHAR(255),
bio TEXT,
avatar_url VARCHAR(500),
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- V3__Add_email_verification.sql
ALTER TABLE users
ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN verification_token VARCHAR(255),
ADD COLUMN verification_sent_at TIMESTAMP;
CREATE INDEX idx_users_verification_token
ON users(verification_token)
WHERE verification_token IS NOT NULL;

Umożliwiaj migracje bez przestojów poprzez utrzymanie kompatybilności wstecznej podczas przejść.

  1. Faza rozszerzenia: Dodaj nowe elementy schematu bez usuwania starych

    -- Dodaj nową kolumnę obok starej
    ALTER TABLE products
    ADD COLUMN price_cents INTEGER;
    -- Uzupełnij nową kolumnę danymi ze starej
    UPDATE products
    SET price_cents = CAST(price * 100 AS INTEGER)
    WHERE price_cents IS NULL;
  2. Faza przejścia: Zaktualizuj aplikację aby używała zarówno starych jak i nowych schematów

    // Zapisuj do obu kolumn
    async function updateProductPrice(productId, price) {
    await db.query(
    `UPDATE products
    SET price = $1,
    price_cents = $2,
    updated_at = CURRENT_TIMESTAMP
    WHERE id = $3`,
    [price, Math.round(price * 100), productId]
    );
    }
    // Czytaj z zapasowym rozwiązaniem
    async function getProductPrice(productId) {
    const result = await db.query(
    'SELECT price, price_cents FROM products WHERE id = $1',
    [productId]
    );
    // Preferuj nową kolumnę, wróć do starej
    const cents = result.rows[0].price_cents ??
    Math.round(result.rows[0].price * 100);
    return cents / 100;
    }
  3. Faza skurczenia: Usuń stare elementy schematu po pełnej migracji

    -- Upewnij się, że wszystkie dane zostały zmigrowane
    UPDATE products
    SET price_cents = CAST(price * 100 AS INTEGER)
    WHERE price_cents IS NULL;
    -- Ustaw nową kolumnę jako wymaganą
    ALTER TABLE products
    ALTER COLUMN price_cents SET NOT NULL;
    -- Usuń starą kolumnę
    ALTER TABLE products
    DROP COLUMN price;

Utrzymuj dwie wersje obiektów schematu dla płynnych przejść.

Migracja tabeli niebiesko-zielonej

-- Krok 1: Utwórz nową strukturę tabeli
CREATE TABLE orders_v2 (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
customer_id UUID NOT NULL REFERENCES customers(id),
order_number VARCHAR(50) NOT NULL UNIQUE,
status VARCHAR(50) NOT NULL,
total_amount_cents BIGINT NOT NULL,
currency VARCHAR(3) NOT NULL DEFAULT 'USD',
metadata JSONB DEFAULT '{}',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Krok 2: Utwórz wyzwalacze do synchronizacji danych
CREATE OR REPLACE FUNCTION sync_orders_to_v2()
RETURNS TRIGGER AS $$
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO orders_v2 (
id, customer_id, order_number, status,
total_amount_cents, currency, metadata,
created_at, updated_at
) VALUES (
NEW.id, NEW.customer_id, NEW.order_number, NEW.status,
CAST(NEW.total_amount * 100 AS BIGINT), 'USD', '{}',
NEW.created_at, NEW.updated_at
);
ELSIF TG_OP = 'UPDATE' THEN
UPDATE orders_v2 SET
customer_id = NEW.customer_id,
order_number = NEW.order_number,
status = NEW.status,
total_amount_cents = CAST(NEW.total_amount * 100 AS BIGINT),
updated_at = NEW.updated_at
WHERE id = NEW.id;
ELSIF TG_OP = 'DELETE' THEN
DELETE FROM orders_v2 WHERE id = OLD.id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER sync_orders_trigger
AFTER INSERT OR UPDATE OR DELETE ON orders
FOR EACH ROW EXECUTE FUNCTION sync_orders_to_v2();
-- Krok 3: Uzupełnij istniejące dane
INSERT INTO orders_v2 (
id, customer_id, order_number, status,
total_amount_cents, created_at, updated_at
)
SELECT
id, customer_id, order_number, status,
CAST(total_amount * 100 AS BIGINT),
created_at, updated_at
FROM orders
ON CONFLICT (id) DO NOTHING;
-- Krok 4: Przełącz aplikację na użycie nowej tabeli
-- Zaktualizuj konfigurację aplikacji
-- Krok 5: Porządki
DROP TRIGGER sync_orders_trigger ON orders;
DROP FUNCTION sync_orders_to_v2();
ALTER TABLE orders RENAME TO orders_old;
ALTER TABLE orders_v2 RENAME TO orders;

Zapewnij integralność danych podczas złożonych transformacji.

// Migracja danych wspomagana przez AI z walidacją
class DataMigrator {
constructor(db, logger) {
this.db = db;
this.logger = logger;
this.batchSize = 1000;
}
async migrateWithValidation(config) {
const {
sourceTable,
targetTable,
transform,
validate,
idColumn = 'id'
} = config;
let offset = 0;
let totalMigrated = 0;
let errors = [];
// Rozpocznij transakcję dla każdej partii
while (true) {
const batch = await this.fetchBatch(
sourceTable,
idColumn,
offset,
this.batchSize
);
if (batch.length === 0) break;
const migrationResult = await this.migrateBatch(
batch,
targetTable,
transform,
validate
);
totalMigrated += migrationResult.successful;
errors.push(...migrationResult.errors);
this.logger.info('Postęp migracji', {
offset,
batchSize: batch.length,
successful: migrationResult.successful,
failed: migrationResult.errors.length,
totalMigrated
});
offset += this.batchSize;
// Dodaj opóźnienie aby nie przeciążać bazy danych
await this.sleep(100);
}
return {
totalMigrated,
errors,
success: errors.length === 0
};
}
async migrateBatch(batch, targetTable, transform, validate) {
const successful = 0;
const errors = [];
await this.db.transaction(async (trx) => {
for (const record of batch) {
try {
// Transformuj dane
const transformed = await transform(record);
// Waliduj przed wstawieniem
const validationResult = await validate(transformed);
if (!validationResult.valid) {
errors.push({
id: record.id,
error: 'Walidacja nie powiodła się',
details: validationResult.errors
});
continue;
}
// Wstaw do tabeli docelowej
await trx(targetTable).insert(transformed);
successful++;
} catch (error) {
errors.push({
id: record.id,
error: error.message,
stack: error.stack
});
}
}
});
return { successful, errors };
}
}
// Przykład użycia
const migrator = new DataMigrator(db, logger);
await migrator.migrateWithValidation({
sourceTable: 'legacy_users',
targetTable: 'users',
transform: async (legacyUser) => ({
id: generateUUID(),
email: legacyUser.email_address.toLowerCase(),
username: legacyUser.user_name || generateUsername(legacyUser.email_address),
full_name: `${legacyUser.first_name} ${legacyUser.last_name}`.trim(),
phone: normalizePhoneNumber(legacyUser.phone),
created_at: legacyUser.registration_date,
legacy_id: legacyUser.id
}),
validate: async (user) => {
const errors = [];
if (!isValidEmail(user.email)) {
errors.push('Nieprawidłowy format e-mail');
}
if (user.username.length < 3) {
errors.push('Nazwa użytkownika zbyt krótka');
}
// Sprawdź duplikaty
const existing = await db('users')
.where('email', user.email)
.first();
if (existing) {
errors.push('E-mail już istnieje');
}
return {
valid: errors.length === 0,
errors
};
}
});

Testuj migracje bezpiecznie używając tabel cieni przed zastosowaniem w produkcji.

-- Utwórz tabelę cieni do testowania
CREATE TABLE users_shadow (LIKE users INCLUDING ALL);
-- Skopiuj dane do tabeli cieni
INSERT INTO users_shadow SELECT * FROM users;
-- Przetestuj migrację na tabeli cieni
ALTER TABLE users_shadow
ADD COLUMN preferences JSONB DEFAULT '{}',
ADD COLUMN last_login_at TIMESTAMP;
-- Waliduj tabelę cieni
DO $$
DECLARE
original_count INTEGER;
shadow_count INTEGER;
BEGIN
SELECT COUNT(*) INTO original_count FROM users;
SELECT COUNT(*) INTO shadow_count FROM users_shadow;
IF original_count != shadow_count THEN
RAISE EXCEPTION 'Niezgodność liczby wierszy: % vs %',
original_count, shadow_count;
END IF;
-- Dodatkowe zapytania walidacyjne
PERFORM 1 FROM users_shadow
WHERE preferences IS NULL
LIMIT 1;
IF FOUND THEN
RAISE EXCEPTION 'Znaleziono NULL preferences';
END IF;
END $$;
-- Jeśli walidacja przeszła, zastosuj w produkcji
ALTER TABLE users
ADD COLUMN preferences JSONB DEFAULT '{}',
ADD COLUMN last_login_at TIMESTAMP;
-- Porządki
DROP TABLE users_shadow;

Wykonaj zmiany schematu bez blokowania tabel.

// Wrapper do zmian schematu online MySQL/Percona
class OnlineSchemaChange {
constructor(db, config = {}) {
this.db = db;
this.config = {
maxLagSeconds: 5,
checkInterval: 1000,
...config
};
}
async alterTable(tableName, alterStatement, options = {}) {
const tempTable = `${tableName}_new`;
const oldTable = `${tableName}_old`;
try {
// Krok 1: Utwórz nową tabelę z pożądanym schematem
await this.createTableLike(tableName, tempTable);
await this.db.query(`ALTER TABLE ${tempTable} ${alterStatement}`);
// Krok 2: Ustaw wyzwalacze do synchronizacji danych
await this.createSyncTriggers(tableName, tempTable);
// Krok 3: Skopiuj istniejące dane w partiach
await this.copyDataInBatches(tableName, tempTable, options);
// Krok 4: Atomowa zamiana tabel
await this.db.transaction(async (trx) => {
await trx.raw(`RENAME TABLE
${tableName} TO ${oldTable},
${tempTable} TO ${tableName}`);
});
// Krok 5: Porządki
await this.dropTriggers(oldTable);
// Zachowaj starą tabelę do opcji cofnięcia
this.logger.info(`Migracja zakończona. Stara tabela zachowana jako ${oldTable}`);
} catch (error) {
// Cofnij
await this.cleanup(tempTable, tableName);
throw error;
}
}
async copyDataInBatches(source, target, options) {
const batchSize = options.batchSize || 1000;
let lastId = 0;
while (true) {
// Sprawdź opóźnienie replikacji
const lag = await this.getReplicationLag();
if (lag > this.config.maxLagSeconds) {
await this.sleep(this.config.checkInterval);
continue;
}
// Skopiuj partię
const result = await this.db.query(`
INSERT INTO ${target}
SELECT * FROM ${source}
WHERE id > ?
ORDER BY id
LIMIT ?
`, [lastId, batchSize]);
if (result.affectedRows === 0) break;
// Zaktualizuj postęp
lastId = await this.getMaxId(target);
// Ograniczaj prędkość aby nie przeciążać bazy danych
await this.sleep(100);
}
}
async createSyncTriggers(source, target) {
// Wyzwalacz wstawiania
await this.db.query(`
CREATE TRIGGER ${source}_insert_sync
AFTER INSERT ON ${source}
FOR EACH ROW
BEGIN
INSERT INTO ${target} SELECT NEW.*;
END
`);
// Wyzwalacz aktualizacji
await this.db.query(`
CREATE TRIGGER ${source}_update_sync
AFTER UPDATE ON ${source}
FOR EACH ROW
BEGIN
REPLACE INTO ${target} SELECT NEW.*;
END
`);
// Wyzwalacz usuwania
await this.db.query(`
CREATE TRIGGER ${source}_delete_sync
AFTER DELETE ON ${source}
FOR EACH ROW
BEGIN
DELETE FROM ${target} WHERE id = OLD.id;
END
`);
}
}

Kontroluj wdrażanie migracji używając flag funkcji.

// Migracje kontrolowane flagami funkcji
class FeatureFlagMigration {
constructor(db, featureFlags) {
this.db = db;
this.featureFlags = featureFlags;
}
async runConditionalMigration(migrationName, migration) {
const flagName = `migration_${migrationName}`;
// Sprawdź czy migracja powinna zostać uruchomiona
if (!await this.featureFlags.isEnabled(flagName)) {
this.logger.info(`Migracja ${migrationName} pominięta (flaga wyłączona)`);
return;
}
// Sprawdź czy już zastosowana
const applied = await this.db('schema_migrations')
.where('migration', migrationName)
.first();
if (applied) {
this.logger.info(`Migracja ${migrationName} już zastosowana`);
return;
}
// Uruchom migrację z monitorowaniem
const start = Date.now();
try {
await this.db.transaction(async (trx) => {
// Wykonaj migrację
await migration(trx);
// Zarejestruj ukończenie
await trx('schema_migrations').insert({
migration: migrationName,
applied_at: new Date(),
execution_time_ms: Date.now() - start
});
});
this.logger.info(`Migracja ${migrationName} ukończona`, {
duration: Date.now() - start
});
} catch (error) {
this.logger.error(`Migracja ${migrationName} nie powiodła się`, {
error: error.message,
duration: Date.now() - start
});
// Wyłącz flagę funkcji w przypadku błędu
await this.featureFlags.disable(flagName);
throw error;
}
}
}
// Użycie
const migrator = new FeatureFlagMigration(db, featureFlags);
await migrator.runConditionalMigration('add_user_preferences', async (trx) => {
await trx.schema.alterTable('users', (table) => {
table.jsonb('preferences').defaultTo('{}');
table.index('preferences', 'idx_users_preferences', 'GIN');
});
});
// Framework testowania migracji
class MigrationTester {
constructor() {
this.testDb = null;
}
async testMigration(migration, testCases) {
// Skonfiguruj bazę testową
await this.setupTestDatabase();
try {
// Zastosuj migrację
await migration.up(this.testDb);
// Uruchom przypadki testowe
for (const testCase of testCases) {
await this.runTestCase(testCase);
}
// Przetestuj cofnięcie jeśli dostępne
if (migration.down) {
await migration.down(this.testDb);
await this.verifyRollback();
}
} finally {
await this.teardownTestDatabase();
}
}
async runTestCase(testCase) {
const { name, setup, verify } = testCase;
console.log(`Uruchamianie testu: ${name}`);
// Skonfiguruj dane testowe
if (setup) {
await setup(this.testDb);
}
// Zweryfikuj oczekiwania
await verify(this.testDb);
}
}
// Przykład testu
const tester = new MigrationTester();
await tester.testMigration(addUserPreferencesMigration, [
{
name: 'Powinien dodać kolumnę preferences z wartością domyślną',
setup: async (db) => {
await db('users').insert({
email: 'test@example.com',
username: 'testuser'
});
},
verify: async (db) => {
const user = await db('users')
.where('email', 'test@example.com')
.first();
expect(user.preferences).toEqual({});
}
},
{
name: 'Powinien zachować istniejące dane',
setup: async (db) => {
const countBefore = await db('users').count();
return { countBefore };
},
verify: async (db, { countBefore }) => {
const countAfter = await db('users').count();
expect(countAfter).toEqual(countBefore);
}
}
]);

Wersjonuj wszystko

  • Śledź wszystkie zmiany schematu w kontroli wersji
  • Używaj sekwencyjnego wersjonowania
  • Nigdy nie modyfikuj istniejących migracji
  • Dokumentuj cel i ryzyko migracji

Testuj dokładnie

  • Testuj na wolumenach danych podobnych do produkcyjnych
  • Weryfikuj integralność danych po migracji
  • Testuj procedury cofnięcia
  • Używaj tabel cieni do walidacji

Monitoruj i mierz

  • Śledź czas wykonania migracji
  • Monitoruj wydajność bazy danych
  • Konfiguruj alerty dla długotrwałych migracji
  • Loguj wszystkie aktywności migracji

Planuj na niepowodzenia

  • Zawsze miej plan cofnięcia
  • Rób kopie zapasowe przed dużymi zmianami
  • Używaj transakcji gdzie to możliwe
  • Implementuj wyłączniki bezpieczeństwa dla migracji

Przed uruchomieniem jakiejkolwiek migracji w produkcji:

  • Kopia zapasowa utworzona i zweryfikowana
  • Migracja przetestowana na staging z danymi podobnymi do produkcyjnych
  • Plan cofnięcia udokumentowany i przetestowany
  • Wpływ na wydajność oceniony
  • Kompatybilność aplikacji zweryfikowana
  • Alerty monitorowania skonfigurowane
  • Zespół powiadomiony o oknie migracji
  • Flagi funkcji skonfigurowane (jeśli dotyczy)
// Dashboard monitorowania migracji
class MigrationMonitor {
async getDashboardMetrics() {
return {
pending: await this.getPendingMigrations(),
recent: await this.getRecentMigrations(),
failed: await this.getFailedMigrations(),
performance: await this.getMigrationPerformance(),
health: await this.getSchemaHealth()
};
}
async getMigrationPerformance() {
const metrics = await this.db('schema_migrations')
.select('migration', 'execution_time_ms', 'applied_at')
.orderBy('applied_at', 'desc')
.limit(20);
return {
averageTime: avg(metrics.map(m => m.execution_time_ms)),
slowest: metrics.sort((a, b) => b.execution_time_ms - a.execution_time_ms)[0],
trend: this.calculateTrend(metrics)
};
}
}

Opanuj migracje baz danych z: