Постановка задачи
Вам нужно со временем развивать схему базы данных, сохраняя нулевой downtime, обеспечивая целостность данных и поддерживая возможность отката.
Принципы миграций
| Принцип | Описание |
|---|
| Backward Compatible | Старая версия приложения работает с новой схемой |
| Forward Compatible | Новая версия приложения работает со старой схемой (во время rollout) |
| Reversible | Для каждой миграции есть скрипт отката |
| Idempotent | Повторный запуск миграции даёт тот же результат |
| Small & Incremental | Предпочитайте много небольших миграций вместо изменений «одним большим взрывом» |
Безопасные паттерны миграций
1. Добавление колонки (безопасно)
-- Миграция: add_email_verified_column
-- Безопасно: новая колонка со значением по умолчанию, чтение от неё пока не зависит
ALTER TABLE users ADD COLUMN email_verified BOOLEAN DEFAULT false;
-- Код приложения можно задеплоить до или после
-- Старый код игнорирует новую колонку, новый код её использует
2. Переименование колонки (в несколько шагов)
Шаг 1: Добавить новую колонку
-- Миграция 001: add_full_name_column
ALTER TABLE users ADD COLUMN full_name VARCHAR(255);
Шаг 2: Заполнить данные (backfill)
-- Миграция 002: backfill_full_name
UPDATE users SET full_name = CONCAT(first_name, ' ', last_name) WHERE full_name IS NULL;
Шаг 3: Задеплоить код, который пишет в обе и читает из новой
// Версия кода 2: двойная запись
class User extends Model
{
protected $fillable = ['first_name', 'last_name', 'full_name'];
public function save(array $options = [])
{
// Писать в обе во время перехода
if ($this->isDirty('first_name') || $this->isDirty('last_name')) {
$this->full_name = $this->first_name . ' ' . $this->last_name;
}
return parent::save($options);
}
public function getDisplayNameAttribute()
{
// Читать из новой колонки; при необходимости — fallback на старую
return $this->full_name ?? ($this->first_name . ' ' . $this->last_name);
}
}
Шаг 4: Удалить старые колонки (после того, как все pods на новом коде)
-- Миграция 003: remove_old_name_columns (запускать после полного деплоя)
ALTER TABLE users DROP COLUMN first_name;
ALTER TABLE users DROP COLUMN last_name;
3. Добавление ограничения NOT NULL (в несколько шагов)
-- Шаг 1: добавить колонку как nullable
ALTER TABLE orders ADD COLUMN customer_id INTEGER;
-- Шаг 2: выполнить backfill существующих строк
UPDATE orders SET customer_id = (
SELECT id FROM customers WHERE customers.email = orders.customer_email
);
-- Шаг 3: добавить ограничение (после завершения backfill)
ALTER TABLE orders ALTER COLUMN customer_id SET NOT NULL;
-- Шаг 4: добавить внешний ключ
ALTER TABLE orders ADD CONSTRAINT fk_orders_customer
FOREIGN KEY (customer_id) REFERENCES customers(id);
4. Удаление колонки (безопасная последовательность)
// Шаг 1: прекратить читать колонку в коде
// Удалить все SELECT-запросы, которые включают эту колонку
// Шаг 2: прекратить писать в колонку
// Удалить все INSERT/UPDATE-операторы, которые устанавливают эту колонку
// Шаг 3: задеплоить и убедиться, что ошибок нет
// Шаг 4: удалить колонку (миграция)
-- Миграция: drop_legacy_status_column
-- Запускать только после деплоя изменений кода
ALTER TABLE orders DROP COLUMN legacy_status;
Инструменты миграций, специфичные для framework
Laravel Migrations
// database/migrations/2024_01_15_create_orders_table.php
class CreateOrdersTable extends Migration
{
public function up()
{
Schema::create('orders', function (Blueprint $table) {
$table->id();
$table->foreignId('user_id')->constrained()->onDelete('cascade');
$table->string('status')->default('pending');
$table->decimal('total', 10, 2);
$table->timestamps();
$table->softDeletes();
$table->index(['user_id', 'status']);
});
}
public function down()
{
Schema::dropIfExists('orders');
}
}
// Запустить миграции
php artisan migrate
// Откатить последний batch
php artisan migrate:rollback
// Откатить конкретные шаги
php artisan migrate:rollback --step=3
Ruby on Rails Migrations
# db/migrate/20240115_add_tracking_to_orders.rb
class AddTrackingToOrders < ActiveRecord::Migration[7.0]
# Отключить транзакцию для больших таблиц
disable_ddl_transaction!
def change
# Добавить колонку с concurrent index
add_column :orders, :tracking_number, :string
add_index :orders, :tracking_number,
algorithm: :concurrently,
if_not_exists: true
end
end
# Запустить миграции
rails db:migrate
# Откат
rails db:rollback STEP=1
Django Migrations
# orders/migrations/0002_add_tracking.py
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('orders', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='order',
name='tracking_number',
field=models.CharField(max_length=100, null=True, blank=True),
),
migrations.AddIndex(
model_name='order',
index=models.Index(fields=['tracking_number'], name='tracking_idx'),
),
]
# Запустить миграции
python manage.py migrate
# Создать миграцию на основе изменений модели
python manage.py makemigrations
# Откат
python manage.py migrate orders 0001
Миграции больших таблиц
Online Schema Change с pt-online-schema-change (MySQL)
# Добавить колонку в большую таблицу без блокировок
pt-online-schema-change \
--alter "ADD COLUMN new_field VARCHAR(255)" \
--execute \
--host=localhost \
--user=root \
--ask-pass \
D=mydb,t=large_table
Concurrent Index в PostgreSQL
-- Создать индекс без блокировки записей
CREATE INDEX CONCURRENTLY idx_orders_created ON orders(created_at);
-- Если не удалось — сначала удалите некорректный индекс
DROP INDEX CONCURRENTLY IF EXISTS idx_orders_created;
Batch-обновления для больших таблиц
// Laravel batch update
class BackfillUserSettings extends Migration
{
public function up()
{
// Обрабатывать пакетами, чтобы избежать проблем с памятью
User::query()
->whereNull('settings')
->chunkById(1000, function ($users) {
foreach ($users as $user) {
$user->update(['settings' => json_encode(['theme' => 'light'])]);
}
});
}
}
# Rails batch update
class BackfillUserSettings < ActiveRecord::Migration[7.0]
disable_ddl_transaction!
def up
User.where(settings: nil).find_in_batches(batch_size: 1000) do |batch|
User.where(id: batch.map(&:id)).update_all(settings: '{"theme": "light"}')
sleep(0.1) # Снизить нагрузку на базу данных
end
end
end
Миграции в CI/CD pipeline
GitLab CI job для миграций
migrate-database:
stage: deploy
image: myapp:${CI_COMMIT_SHA}
script:
# Проверить наличие ожидающих миграций
- php artisan migrate:status
# Запустить миграции
- php artisan migrate --force
# Проверить миграцию
- php artisan migrate:status | grep -q "No pending migrations"
rules:
- if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
changes:
- database/migrations/**/*
environment:
name: production
Kubernetes Job для миграций
apiVersion: batch/v1
kind: Job
metadata:
name: db-migration-${CI_COMMIT_SHA}
namespace: production
spec:
backoffLimit: 0
template:
spec:
restartPolicy: Never
initContainers:
- name: wait-for-db
image: busybox
command: ['sh', '-c', 'until nc -z postgres 5432; do sleep 2; done']
containers:
- name: migrate
image: myapp:${CI_COMMIT_SHA}
command: ["php", "artisan", "migrate", "--force"]
envFrom:
- secretRef:
name: app-secrets
Стратегии отката миграций
Поддержка отката на уровне приложения
// Feature flag для отката
class OrderController extends Controller
{
public function store(Request $request)
{
$order = new Order();
$order->user_id = auth()->id();
$order->total = $request->total;
// Новое поле с feature flag
if (Feature::active('new-order-tracking')) {
$order->tracking_number = $this->generateTracking();
}
$order->save();
return response()->json($order);
}
}
Снимок базы данных перед миграцией
#!/bin/bash
# pre-migration-backup.sh
set -e
BACKUP_NAME="pre-migration-$(date +%Y%m%d-%H%M%S)"
# Создать snapshot
pg_dump $DATABASE_URL > /tmp/${BACKUP_NAME}.sql
# Загрузить в S3
aws s3 cp /tmp/${BACKUP_NAME}.sql s3://db-backups/migrations/${BACKUP_NAME}.sql
# Сохранить имя backup для потенциального отката
echo $BACKUP_NAME > /tmp/last-migration-backup
echo "Backup created: $BACKUP_NAME"
Тестирование миграций
Тестируйте миграции в CI
test-migrations:
stage: test
services:
- postgres:15
variables:
DATABASE_URL: postgres://test:test@postgres:5432/test
script:
# Применить все миграции
- php artisan migrate --force
# Запустить down-миграции
- php artisan migrate:rollback --step=999
# Снова запустить up
- php artisan migrate --force
# Заполнить данными (seed) и проверить
- php artisan db:seed
- php artisan test --filter=DatabaseTest
Тестирование на shadow database
test-migration-on-copy:
stage: pre-deploy
script:
# Создать копию production базы данных
- pg_dump $PROD_DATABASE_URL | psql $SHADOW_DATABASE_URL
# Запустить миграцию на shadow
- DATABASE_URL=$SHADOW_DATABASE_URL php artisan migrate --force
# Запустить smoke-тесты
- DATABASE_URL=$SHADOW_DATABASE_URL php artisan test --filter=SmokeTest
# Удалить shadow database
- psql $SHADOW_DATABASE_URL -c "DROP DATABASE shadow_db"
Чек-лист миграции
До миграции
- [ ] Подтвердить владение данными и зависимости
- [ ] Миграция протестирована в staging-окружении с данными, близкими к production
- [ ] Скрипт отката протестирован
- [ ] Резервная копия базы данных создана и проверена через тренировочное восстановление
- [ ] Команда уведомлена об окне миграции
- [ ] Дашборды мониторинга готовы
Во время миграции
- [ ] Мониторить CPU и I/O базы данных
- [ ] Мониторить уровень ошибок приложения
- [ ] Мониторить задержки запросов
- [ ] Мониторить время ожидания блокировок
- [ ] Проверять lag репликации (если применимо)
После миграции
- [ ] Убедиться, что миграция завершилась успешно
- [ ] Запустить smoke-тесты
- [ ] Проверить логи приложения на ошибки
- [ ] Запустить reconciliation-проверки (сравнить количество записей, суммы или хэши)
- [ ] Обновить документацию
- [ ] Очистить старые колонки/таблицы (после льготного периода)
Мышление Senior Database Engineer
Senior-инженеры относятся к базам данных как к критически важной инфраструктуре. Они инвестируют в:
- Backups и тренировочные восстановления — никогда не запускайте крупную миграцию без проверенного backup
- Ясность владения данными — перед изменением базы данных определите, какая система владеет каждым набором данных
- Безопасные паттерны эволюции схемы — аддитивные, backward-compatible шаги с точками отката
- Reconciliation-проверки — автоматизированное сравнение количества записей и целостности данных
Распространённые ошибки, которых следует избегать
- Запуск крупных миграций без протестированных backup
- Внесение ломающих изменений схемы без версионирования
- Игнорирование lag репликации во время тяжёлых backfill
- Добавление индексов без проверки влияния на запросы
- Отсутствие документации по шагам миграции
Относитесь к миграциям как к production-изменениям, а не как к разовым скриптам. Когда операции с базой данных предсказуемы и безопасны, всю систему становится проще развивать.
Связанные статьи в Wiki