Zero-Downtime Database Migrations with Drizzle ORM

Run database migrations without downtime using Drizzle ORM. Covers expand-contract pattern, backward-compatible schema changes, rollback strategies, and CI/CD integration for PostgreSQL.

E
ECOSIRE Research and Development Team
|19 Mart 20269 dk okuma2.0k Kelime|

Drizzle ORM ile Sıfır Kesinti Süreli Veritabanı Geçişleri

Veritabanı geçişleri, üretim dağıtımlarındaki en tehlikeli işlemdir. Bir tabloyu (200 ms bile olsa) kilitleyen bir geçiş, bağlantı zaman aşımlarına yol açarak 500 hataya ve gece saat 2'de uyarı fırtınalarına neden olur. Çoğu ORM geçiş öğreticisi size "kolay" yolu gösterir: uygulamayı kapatın, geçişi çalıştırın, yeniden başlatın. Bu sıfır kesinti süresi değil; bu planlı bakımdır.

Gerçek sıfır kesinti süreli geçişler farklı bir zihinsel model gerektirir: şemanız, dağıtım süreci boyunca o anda çalışan uygulama koduyla geriye dönük olarak uyumlu olmalıdır. Bu, eski kodun ve yeni kodun kullanıma sunma penceresi sırasında aynı anda çalışması gerektiği anlamına gelir. Genişletme-sözleşme modeli bunu mümkün kılar. Drizzle ORM'nin SQL öncelikli yaklaşımı, bunu doğru şekilde uygulamanız için size kontrol sağlar.

Önemli Çıkarımlar

  • drizzle-kit push ürününü hiçbir zaman üretimde çalıştırmayın; SQL'i oluşturun ve inceleyin, ardından kontrollü bir şekilde uygulayın
  • Genişletme-sözleşme modeli: ekleme (genişletme) → yeni kodu dağıtma → eskisini kaldırma (sözleşme)
  • Null yapılabilir bir sütun eklemek güvenlidir; NOT NULL'u varsayılan olmadan eklemek tehlikelidir
  • Bir sütunu yeniden adlandırmak şunları gerektirir: yeni sütun ekleyin → dolgu → kodu güncelleyin → eski sütunu kaldırın (4 adım)
  • Dizin oluşturma sırasında tablo kilitlenmelerini önlemek için PostgreSQL CREATE INDEX CONCURRENTLY kullanın
  • Üretime başvurmadan önce veri tabanınızın üretim boyutunda bir kopyası üzerinde geçişleri test edin
  • Her zaman bir geri alma planınız olsun: her geçiş için geri alma SQL'sini yazın ve test edin
  • Tablonun yeniden yazılmasını gerektiren şema değişiklikleri (büyük bir tabloda ALTER TYPE) bakım aralıkları gerektirir

Drizzle ORM Şeması Kurulumu

// packages/db/src/schema/contacts.ts
import { pgTable, uuid, varchar, text, timestamp, pgEnum, index } from 'drizzle-orm/pg-core';

export const contactStatusEnum = pgEnum('contact_status', ['active', 'inactive', 'archived']);

export const contacts = pgTable('contacts', {
  id:             uuid('id').primaryKey().defaultRandom(),
  organizationId: uuid('organization_id').notNull(),
  name:           varchar('name', { length: 255 }).notNull(),
  email:          varchar('email', { length: 255 }),
  phone:          varchar('phone', { length: 50 }),
  status:         contactStatusEnum('status').default('active').notNull(),
  notes:          text('notes'),
  createdAt:      timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
  updatedAt:      timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => ({
  orgIdIdx:  index('contacts_org_id_idx').on(table.organizationId),
  emailIdx:  index('contacts_email_idx').on(table.email),
  statusIdx: index('contacts_status_idx').on(table.status),
}));

Geçişler oluştur:

# Generate SQL migration (never auto-apply to production)
npx drizzle-kit generate

# Review the generated SQL before applying
cat drizzle/0001_add_contact_status.sql

# Apply to development database
npx drizzle-kit migrate

# For production: apply via your deployment pipeline

Genişletme-Sözleşme Modeli

Temel prensip: Her şema değişikliği, birden fazla geriye dönük uyumlu dağıtıma bölünür.

Aşama 1: Genişletme (Kesmeden Ekle)

Yalnızca eklemeli olan şema değişikliklerini dağıtın (varsayılanlara sahip yeni sütunlar, yeni tablolar, yeni dizinler).

-- Migration: 0010_expand_add_company.sql
-- Safe to apply while old code is running: nullable column with a default

ALTER TABLE contacts
  ADD COLUMN IF NOT EXISTS company_id UUID,
  ADD COLUMN IF NOT EXISTS company_name VARCHAR(255);

-- Old code ignores these columns; new code uses them
-- Both versions coexist during rolling deployment
// Drizzle schema after expansion
export const contacts = pgTable('contacts', {
  // ... existing columns ...
  companyId:   uuid('company_id'),           // nullable — old code ignores it
  companyName: varchar('company_name', { length: 255 }), // nullable — safe
});

Aşama 2: Yeni Uygulama Kodunu Dağıtın

Hem eski hem de yeni sütunları okuyup yazan yeni uygulama sürümünü dağıtın. Devamlı dağıtım sırasında, eski örnekler (şirket desteği yok) ve yeni örnekler (şirket desteği) aynı anda çalışır; sütun geçersiz kılınabildiğinden her ikisi de çalışır.

Aşama 3: Dolgu

Tablo kilitlenmelerini önlemek için mevcut satırlar için yeni sütunu küçük gruplar halinde doldurun:

-- Migration: 0011_backfill_company_name.sql
-- Run in small batches to avoid locking

DO $$
DECLARE
  batch_size INTEGER := 1000;
  offset_val INTEGER := 0;
  rows_updated INTEGER;
BEGIN
  LOOP
    UPDATE contacts
    SET company_name = 'Unknown'
    WHERE company_name IS NULL
      AND id IN (
        SELECT id FROM contacts
        WHERE company_name IS NULL
        ORDER BY id
        LIMIT batch_size
        OFFSET offset_val
      );

    GET DIAGNOSTICS rows_updated = ROW_COUNT;
    EXIT WHEN rows_updated = 0;

    offset_val := offset_val + batch_size;
    -- Brief pause between batches to reduce I/O pressure
    PERFORM pg_sleep(0.1);
  END LOOP;
END $$;

Aşama 4: Sözleşme (Eski Sütunu Kaldır)

Tüm uygulama kodu güncellendikten ve dolgu tamamlandıktan sonra eski sütunu veya kısıtlamayı kaldırın.


Güvenli Geçiş Modelleri

NOT NULL Sütunu Ekleme

-- WRONG — will fail if table has rows (no default, no nullable)
ALTER TABLE contacts ADD COLUMN tier VARCHAR(20) NOT NULL;

-- WRONG — locks table while it writes the default to every row
ALTER TABLE contacts ADD COLUMN tier VARCHAR(20) NOT NULL DEFAULT 'free';

-- CORRECT — three-step approach
-- Step 1: Add nullable column
ALTER TABLE contacts ADD COLUMN tier VARCHAR(20);

-- Step 2: Backfill existing rows
UPDATE contacts SET tier = 'free' WHERE tier IS NULL;

-- Step 3 (next deployment): Add NOT NULL constraint (instant if no NULLs exist)
ALTER TABLE contacts ALTER COLUMN tier SET NOT NULL;
ALTER TABLE contacts ALTER COLUMN tier SET DEFAULT 'free';

Bir Sütunu Yeniden Adlandırma (4 Adımlı İşlem)

-- Step 1: Add the new column
ALTER TABLE contacts ADD COLUMN full_name VARCHAR(255);

-- Step 2: Backfill + keep in sync with a trigger
UPDATE contacts SET full_name = name;

CREATE OR REPLACE FUNCTION sync_full_name() RETURNS trigger AS $$
BEGIN
  NEW.full_name := NEW.name;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER contacts_sync_full_name
  BEFORE INSERT OR UPDATE OF name ON contacts
  FOR EACH ROW EXECUTE FUNCTION sync_full_name();

-- Step 3: Deploy new code that writes to full_name, reads from both
-- Step 4 (next deployment, after all instances updated):
DROP TRIGGER contacts_sync_full_name ON contacts;
DROP FUNCTION sync_full_name;
ALTER TABLE contacts DROP COLUMN name;

Sıralama Türünü Değiştirme

PostgreSQL, tam tablo yeniden yazılmadan enum değerlerinin kaldırılmasına izin vermez. Güvenli numaralandırma değişiklikleri için:

-- Adding a new enum value is safe (instant, no lock)
ALTER TYPE contact_status ADD VALUE IF NOT EXISTS 'pending';

-- Removing an enum value requires a full table rewrite
-- Use the expand-contract pattern: add a new enum type, migrate, drop old
CREATE TYPE contact_status_new AS ENUM ('active', 'inactive', 'archived', 'pending');

-- Migrate data
ALTER TABLE contacts
  ALTER COLUMN status TYPE contact_status_new
  USING status::text::contact_status_new;

DROP TYPE contact_status;
ALTER TYPE contact_status_new RENAME TO contact_status;

Kilitlemeden Dizin Oluşturma

Normal CREATE INDEX, bu süre boyunca tüm yazma işlemlerini engelleyen bir ShareLock alır. Büyük bir masada bu işlem birkaç dakika sürebilir.

-- WRONG — locks writes during index creation
CREATE INDEX contacts_email_idx ON contacts(email);

-- CORRECT — concurrent index creation; no write lock
CREATE INDEX CONCURRENTLY IF NOT EXISTS contacts_email_idx
  ON contacts(email)
  WHERE email IS NOT NULL; -- Partial index for better performance

-- Drizzle note: drizzle-kit does not generate CONCURRENTLY by default
-- Edit the generated SQL migration to add CONCURRENTLY before applying

Drizzle geçiş dosyanıza, oluşturma işleminden sonra CONCURRENTLY öğesini manuel olarak ekleyin:

-- drizzle/0012_add_email_index.sql (edited after generation)
CREATE INDEX CONCURRENTLY IF NOT EXISTS contacts_email_idx
  ON contacts (email)
  WHERE email IS NOT NULL;

Üretim CI/CD'sinde Yoğun Geçiş

Üretimde asla drizzle-kit push kullanmayın; geçişleri inceleme adımı olmadan uygular. Sürüm kontrollü oluşturulan SQL dosyalarıyla drizzle-kit migrate kullanın:

# scripts/deploy-production.sh

echo "=== Running database migrations ==="

# Check if there are pending migrations
PENDING=$(npx drizzle-kit migrate --dry-run 2>&1 | grep "pending")
if [ -n "$PENDING" ]; then
  echo "Pending migrations detected:"
  echo "$PENDING"

  # Apply migrations
  npx drizzle-kit migrate
  echo "Migrations applied successfully"
else
  echo "No pending migrations"
fi
// Programmatic migration in main.ts (NestJS)
import { drizzle } from 'drizzle-orm/postgres-js';
import { migrate } from 'drizzle-orm/postgres-js/migrator';

async function runMigrations() {
  const migrationClient = postgres(process.env.DATABASE_URL!, { max: 1 });
  const db = drizzle(migrationClient);

  await migrate(db, {
    migrationsFolder: join(__dirname, '..', '..', '..', 'drizzle'),
  });

  await migrationClient.end();
}

// In bootstrap(), before app.listen():
if (process.env.RUN_MIGRATIONS === 'true') {
  await runMigrations();
}

Geri Alma Stratejileri

Her geçişin, dağıtımdan önce yazılıp test edilmiş bir tamamlayıcı geri alma geçişine sahip olması gerekir:

// drizzle/0013_add_company_id.sql (forward migration)
ALTER TABLE contacts ADD COLUMN IF NOT EXISTS company_id UUID;
CREATE INDEX CONCURRENTLY IF NOT EXISTS contacts_company_id_idx
  ON contacts(company_id);

// drizzle/rollbacks/0013_rollback_add_company_id.sql
DROP INDEX CONCURRENTLY IF EXISTS contacts_company_id_idx;
ALTER TABLE contacts DROP COLUMN IF EXISTS company_id;

Acil geri alma için:

# Emergency rollback script
# Run the rollback SQL, then redeploy the previous app version

psql "$DATABASE_URL" < drizzle/rollbacks/0013_rollback_add_company_id.sql
git checkout HEAD~1
pnpm build && pm2 restart ecosystem.config.cjs --update-env

Geçiş Test Stratejisi

// packages/db/src/tests/migration.spec.ts
import { describe, it, expect, beforeAll } from 'vitest';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import postgres from 'postgres';
import { drizzle } from 'drizzle-orm/postgres-js';

describe('Database Migrations', () => {
  let sql: ReturnType<typeof postgres>;

  beforeAll(async () => {
    sql = postgres(process.env.TEST_DATABASE_URL!);
  });

  it('all migrations apply cleanly on a fresh database', async () => {
    const db = drizzle(sql);
    await expect(migrate(db, { migrationsFolder: './drizzle' }))
      .resolves.not.toThrow();
  });

  it('migrations are idempotent (can be applied twice safely)', async () => {
    const db = drizzle(sql);
    await migrate(db, { migrationsFolder: './drizzle' });
    // Running again should be a no-op, not an error
    await expect(migrate(db, { migrationsFolder: './drizzle' }))
      .resolves.not.toThrow();
  });
});

Bakım Aralığı Gerektiren Tehlikeli İşlemler

Çok büyük tablolarda (yüz milyonlarca satır) bazı işlemler sıfır kesintiyle yapılamaz:

OperasyonRiskAzaltma
ALTER TYPE USING büyük sütundaTam tablonun yeniden yazılması, uzun kilitBakım zamanlaması penceresi
ADD COLUMN NOT NULL varsayılan olmadanSatırlar mevcutsa anında başarısızlıkÜç adımlı genişletme sözleşmesini kullanın
CREATE INDEX (AYNI ZAMANDA olmadan)Süre boyunca yazma kilidiHer zaman CONCURRENTLY kullanın
KOD0Anında, ancak eski kodu bozuyorYalnızca kod güncellendikten sonra genişletme aşamasında
KOD0Geri döndürülemezHer zaman bir yedeğiniz olsun; 30 günlük geçici silme politikası
KOD0Süre boyunca masa kilidiBakım sırasında normal VACUUM + zamanlama FULL'u kullanın

Sıkça Sorulan Sorular

drizzle-kit push ile drizzle-kit migrate arasındaki fark nedir?

push, geçiş dosyaları oluşturmadan mevcut şemanızı doğrudan veritabanına uygular; geçiş geçmişini umursamadığınız geliştirmeler için kullanışlıdır. migrate, drizzle/ dizininizdeki SQL dosyalarını, uygulanmış olan izlemeyi sırasıyla uygular. Üretimde her zaman migrate kullanın; böylece her şema değişikliğinin kontrollü, gözden geçirilebilir, sürüm kontrollü geçmişine sahip olursunuz.

Drizzle, drizzle_migrations izleme tablosunu nasıl ele alıyor?

Drizzle, hangi geçiş dosyalarının ne zaman uygulandığını kaydeden bir drizzle_migrations (veya __drizzle_migrations) tablosu oluşturur. Geçişi uygulamadan önce bu tabloyu kontrol eder. Bu tablodaki satırları manuel olarak eklemeyin veya silmeyin; bu, Drizzle'ın durum izleme mekanizmasıdır. Önceki bir geçiş durumuna geri dönmeniz gerekiyorsa geri alma SQL'nizi çalıştırın ve ilgili satırı manuel olarak silin.

Geçişleri üretim boyutunda bir veritabanına karşı nasıl test ederim?

Üretim veritabanınızın arındırılmış (PII'si kaldırılmış) bir kopyasını bir hazırlama sunucusuna geri yükleyin. Geçişi buraya uygulayın ve şunları ölçün: duvar saati süresi, kilit bekleme süresi (pg_stat_activity'den), tablo şişkinliği (pg_stat_user_tables'den) ve sorgu performansı üzerindeki etki (EXPLAIN ANALYZE'den). Geçiş işlemi hazırlama sırasında 5 saniyeden uzun sürerse yeniden tasarlayın.

Veritabanı geçişleri için işlemleri kullanabilir miyim?

PostgreSQL'deki DDL'lerin çoğu işlemseldir; ALTER TABLE, CREATE TABLE, CREATE INDEX'yi (fakat CREATE INDEX CONCURRENTLY DEĞİL) bir işleme sarabilir ve herhangi bir adım başarısız olursa geri sarabilirsiniz. Drizzle, varsayılan olarak her geçiş dosyasını bir işlemde sarar. CONCURRENTLY kullanan geçişler için, bunları bir işlemin dışında çalışan ayrı bir geçiş dosyasına bölmeniz gerekir.

TypeScript'te Drizzle numaralandırma karşılaştırmalarını nasıl halledebilirim?

Drizzle'ın numaralandırma sütunları veritabanından dize değerini döndürür. Karşılaştırma sırasında TypeScript türü doğru şekilde daraltamayabilir. Açıkça yayınlayın: if ((contact.status as ContactStatus) === 'active') veya Drizzle where yan tümcenizde bir tür iddiası kullanın: where(eq(contacts.status, 'active' as ContactStatus)). Bu, TypeScript'i mutlu tutarken çalışma zamanı hatalarını da önler.


Sonraki Adımlar

Sıfır kesinti süreli veritabanı geçişleri disiplin gerektirir; ancak alternatif (kesinti süresi, veri bozulması, sabah 3'te acil geri dönüşler) çok daha kötüdür. Genişletme-sözleşme modeli, CREATE INDEX CONCURRENTLY ve uygun geri alma hazırlığı, şema değişikliklerini rutin, güvenli bir işlem haline getirir.

ECOSIRE, Drizzle ORM, PostgreSQL 17 ve 65'ten fazla şema dosyası ve 300'den fazla geçişte test edilen sıfır kesinti süreli dağıtım hattı ile NestJS uygulamalarına yönelik veritabanı şemalarını yönetir. Veritabanı işlemlerini geniş ölçekte güvenli bir şekilde nasıl yürüttüğümüzü öğrenmek için Arka uç mühendislik hizmetlerimizi keşfedin.

E

Yazan

ECOSIRE Research and Development Team

ECOSIRE'da kurumsal düzeyde dijital ürünler geliştiriyor. Odoo entegrasyonları, e-ticaret otomasyonu ve yapay zeka destekli iş çözümleri hakkında içgörüler paylaşıyor.

WhatsApp'ta Sohbet Et