Kompatybilność wsteczna
Zmiany muszą działać zarówno ze starymi, jak i nowymi wersjami aplikacji
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.sqlCREATE 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.sqlCREATE 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.sqlALTER TABLE usersADD 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_tokenON users(verification_token)WHERE verification_token IS NOT NULL;
<?xml version="1.0" encoding="UTF-8"?><databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.0.xsd">
<changeSet id="1" author="developer"> <createTable tableName="users"> <column name="id" type="UUID" defaultValueComputed="gen_random_uuid()"> <constraints primaryKey="true" nullable="false"/> </column> <column name="email" type="VARCHAR(255)"> <constraints nullable="false" unique="true"/> </column> <column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"> <constraints nullable="false"/> </column> </createTable>
<rollback> <dropTable tableName="users"/> </rollback> </changeSet>
<changeSet id="2" author="developer"> <createTable tableName="user_profiles"> <column name="user_id" type="UUID"> <constraints primaryKey="true" nullable="false" foreignKeyName="fk_profiles_users" references="users(id)"/> </column> <column name="full_name" type="VARCHAR(255)"/> <column name="bio" type="TEXT"/> <column name="avatar_url" type="VARCHAR(500)"/> <column name="updated_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"> <constraints nullable="false"/> </column> </createTable> </changeSet></databaseChangeLog>
Umożliwiaj migracje bez przestojów poprzez utrzymanie kompatybilności wstecznej podczas przejść.
Faza rozszerzenia: Dodaj nowe elementy schematu bez usuwania starych
-- Dodaj nową kolumnę obok starejALTER TABLE productsADD COLUMN price_cents INTEGER;
-- Uzupełnij nową kolumnę danymi ze starejUPDATE productsSET price_cents = CAST(price * 100 AS INTEGER)WHERE price_cents IS NULL;
Faza przejścia: Zaktualizuj aplikację aby używała zarówno starych jak i nowych schematów
// Zapisuj do obu kolumnasync 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ązaniemasync 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;}
Faza skurczenia: Usuń stare elementy schematu po pełnej migracji
-- Upewnij się, że wszystkie dane zostały zmigrowaneUPDATE productsSET price_cents = CAST(price * 100 AS INTEGER)WHERE price_cents IS NULL;
-- Ustaw nową kolumnę jako wymaganąALTER TABLE productsALTER COLUMN price_cents SET NOT NULL;
-- Usuń starą kolumnęALTER TABLE productsDROP COLUMN price;
Utrzymuj dwie wersje obiektów schematu dla płynnych przejść.
Migracja tabeli niebiesko-zielonej
-- Krok 1: Utwórz nową strukturę tabeliCREATE 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 danychCREATE 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_triggerAFTER INSERT OR UPDATE OR DELETE ON ordersFOR EACH ROW EXECUTE FUNCTION sync_orders_to_v2();
-- Krok 3: Uzupełnij istniejące daneINSERT 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_atFROM ordersON CONFLICT (id) DO NOTHING;
-- Krok 4: Przełącz aplikację na użycie nowej tabeli-- Zaktualizuj konfigurację aplikacji
-- Krok 5: PorządkiDROP 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życiaconst 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 testowaniaCREATE TABLE users_shadow (LIKE users INCLUDING ALL);
-- Skopiuj dane do tabeli cieniINSERT INTO users_shadow SELECT * FROM users;
-- Przetestuj migrację na tabeli cieniALTER TABLE users_shadowADD COLUMN preferences JSONB DEFAULT '{}',ADD COLUMN last_login_at TIMESTAMP;
-- Waliduj tabelę cieniDO $$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 produkcjiALTER TABLE usersADD COLUMN preferences JSONB DEFAULT '{}',ADD COLUMN last_login_at TIMESTAMP;
-- PorządkiDROP TABLE users_shadow;
Wykonaj zmiany schematu bez blokowania tabel.
// Wrapper do zmian schematu online MySQL/Perconaclass 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 funkcjiclass 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życieconst 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 migracjiclass 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 testuconst 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
Testuj dokładnie
Monitoruj i mierz
Planuj na niepowodzenia
Przed uruchomieniem jakiejkolwiek migracji w produkcji:
// Dashboard monitorowania migracjiclass 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: