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 مارس 202610 دقائق قراءة2.1k كلمات|

عمليات ترحيل قاعدة البيانات بدون توقف باستخدام Drizzle ORM

تعد عمليات ترحيل قاعدة البيانات هي العملية الأكثر خطورة في عمليات نشر الإنتاج. تؤدي عملية الترحيل التي تقفل جدولًا - حتى لمدة 200 مللي ثانية - إلى انتهاء مهلات الاتصال التي تتوالى إلى 500 خطأ تتسلسل إلى عواصف تنبيه في الساعة 2 صباحًا. تُظهر لك معظم البرامج التعليمية حول ترحيل ORM المسار "السهل": إيقاف تشغيل التطبيق، وتشغيل الترحيل، ثم إعادة التشغيل. هذا ليس توقفًا صفريًا؛ التي يتم جدولتها الصيانة.

تتطلب عمليات الترحيل الحقيقية بدون توقف نموذجًا عقليًا مختلفًا: يجب أن يكون مخططك متوافقًا مع الإصدارات السابقة مع رمز التطبيق قيد التشغيل حاليًا طوال عملية النشر. وهذا يعني أن الكود القديم والكود الجديد يجب أن يعملا في وقت واحد أثناء نافذة الطرح. نمط العقد الموسع يجعل هذا ممكنًا. يمنحك أسلوب SQL-first الخاص بـ Drizzle ORM التحكم في تنفيذه بشكل صحيح.

الوجبات الرئيسية

  • لا تقم مطلقًا بتشغيل drizzle-kit push في الإنتاج - قم بإنشاء ومراجعة SQL، ثم قم بالتطبيق مع التحكم
  • نمط عقد التوسيع: إضافة (توسيع) ← نشر كود جديد ← إزالة (عقد) قديم
  • إضافة عمود لاغٍ أمر آمن؛ إن إضافة NOT NULL بدون افتراضي أمر خطير
  • تتطلب إعادة تسمية العمود: إضافة عمود جديد ← إعادة التعبئة ← تحديث الكود ← إزالة العمود القديم (4 خطوات)
  • استخدم PostgreSQL CREATE INDEX CONCURRENTLY لتجنب أقفال الجدول أثناء إنشاء الفهرس
  • اختبر عمليات الترحيل على نسخة بحجم الإنتاج من قاعدة البيانات الخاصة بك قبل التقديم على الإنتاج
  • امتلك دائمًا خطة التراجع: لكل عملية ترحيل، اكتب واختبر SQL التراجع
  • تغييرات المخطط التي تتطلب إعادة كتابة الجدول (ALTER TYPE على جدول كبير) تحتاج إلى نوافذ صيانة

إعداد مخطط Drizzle ORM

// 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),
}));

توليد الهجرات:

# 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

نموذج العقد الموسع

المبدأ الأساسي: يتم تقسيم كل تغيير في المخطط إلى عمليات نشر متعددة متوافقة مع الإصدارات السابقة.

المرحلة الأولى: التوسيع (الإضافة دون انقطاع)

نشر تغييرات المخطط الإضافية فقط — أعمدة جديدة ذات إعدادات افتراضية، وجداول جديدة، وفهارس جديدة.

-- 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
});

المرحلة الثانية: نشر رمز التطبيق الجديد

انشر إصدار التطبيق الجديد الذي يقرأ ويكتب الأعمدة القديمة والجديدة. أثناء النشر المتدرج، تعمل المثيلات القديمة (لا يوجد دعم للشركة) والمثيلات الجديدة (دعم الشركة) في وقت واحد — يعمل كلاهما لأن العمود لاغي.

المرحلة الثالثة: الردم

قم بملء العمود الجديد للصفوف الموجودة على دفعات صغيرة لتجنب تأمين الجدول:

-- 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 $$;

المرحلة الرابعة: العقد (إزالة العمود القديم)

بعد تحديث رمز التطبيق بالكامل واكتمال عملية إعادة التعبئة، قم بإزالة العمود أو القيد القديم.


أنماط الهجرة الآمنة

إضافة عمود غير فارغ

-- 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';

إعادة تسمية عمود (عملية من 4 خطوات)

-- 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;

تغيير نوع التعداد

لا يسمح PostgreSQL بإزالة قيم التعداد دون إعادة كتابة الجدول بالكامل. لتغييرات التعداد الآمنة:

-- 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;

إنشاء الفهرس بدون قفل

يكتسب CREATE INDEX العادي ShareLock الذي يمنع كافة عمليات الكتابة طوال المدة. على طاولة كبيرة، قد يستغرق ذلك دقائق.

-- 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، أضف CONCURRENTLY يدويًا بعد الإنشاء:

-- 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;

هجرة الرذاذ في إنتاج CI/CD

لا تستخدم أبدًا drizzle-kit push في الإنتاج - فهو يطبق عمليات الترحيل دون خطوة مراجعة. استخدم drizzle-kit migrate مع ملفات SQL التي يتم التحكم في إصدارها:

# 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();
}

استراتيجيات التراجع

يجب أن يكون لكل عملية ترحيل عملية ترحيل مصاحبة مكتوبة ومختبرة قبل النشر:

// 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;

للتراجع في حالات الطوارئ:

# 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

استراتيجية اختبار الهجرة

// 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();
  });
});

العمليات الخطيرة التي تتطلب صيانة Windows

لا يمكن إيقاف بعض العمليات على جداول كبيرة جدًا (مئات الملايين من الصفوف):

عمليةخطرالتخفيف
ALTER TYPE USING على عمود كبيرإعادة كتابة جدول كامل، قفل طويلنافذة جدول الصيانة
ADD COLUMN NOT NULL بدون افتراضيفشل فوري في حالة وجود صفوفاستخدم عقد التوسيع المكون من ثلاث خطوات
CREATE INDEX (بدون متزامن)قفل الكتابة لمدةاستخدم دائمًا CONCURRENTLY
ALTER TABLE ... RENAME TOفورية، ولكنها تكسر الكود القديمفقط في مرحلة التوسيع بعد تحديث الكود
DROP TABLEلا رجعة فيهاحتفظ دائمًا بنسخة احتياطية؛ سياسة الحذف المبسط لمدة 30 يومًا
VACUUM FULLقفل الطاولة للمدةاستخدم VACUUM العادي + الجدول الكامل أثناء الصيانة

الأسئلة المتداولة

ما الفرق بين drizzle-kit push و drizzle-kit migrate؟

يطبق push مخططك الحالي مباشرةً على قاعدة البيانات دون إنشاء ملفات ترحيل - وهو مفيد للتطوير حيث لا تهتم بسجل الترحيل. يطبق migrate ملفات SQL الموجودة في دليل drizzle/ الخاص بك بالترتيب، ويتتبع ما تم تطبيقه. استخدم دائمًا migrate في الإنتاج حتى يكون لديك سجل متحكم فيه وقابل للمراجعة والتحكم في الإصدار لكل تغيير في المخطط.

كيف يتعامل Drizzle مع جدول التتبع drizzle_migrations؟

يقوم Drizzle بإنشاء جدول drizzle_migrations (أو __drizzle_migrations) يسجل ملفات الترحيل التي تم تطبيقها ومتى. قبل تطبيق الترحيل، فإنه يتحقق من هذا الجدول. لا تقم بإدراج أو حذف صفوف من هذا الجدول يدويًا - فهذه هي آلية تتبع الحالة الخاصة بـ Drizzle. إذا كنت بحاجة إلى العودة إلى حالة ترحيل سابقة، فقم بتشغيل SQL التراجع وحذف الصف المقابل يدويًا.

كيف يمكنني اختبار عمليات الترحيل مقابل قاعدة بيانات بحجم الإنتاج؟

قم باستعادة نسخة معقمة (تمت إزالة معلومات تحديد الهوية الشخصية) من قاعدة بيانات الإنتاج الخاصة بك إلى خادم مرحلي. قم بتطبيق الترحيل هناك وقياس: وقت ساعة الحائط، ووقت انتظار القفل (من pg_stat_activity)، وتضخم الجدول (من pg_stat_user_tables)، والتأثير على أداء الاستعلام (من EXPLAIN ANALYZE). إذا استغرق الترحيل أكثر من 5 ثوانٍ عند التشغيل المرحلي، فأعد تصميمه.

هل يمكنني استخدام المعاملات لعمليات ترحيل قاعدة البيانات؟

معظم DDL في PostgreSQL عبارة عن معاملات - يمكنك تغليف ALTER TABLE، CREATE TABLE، CREATE INDEX (ولكن ليس CREATE INDEX CONCURRENTLY) في معاملة والتراجع في حالة فشل أي خطوة. يقوم Drizzle بتغليف كل ملف ترحيل في المعاملة بشكل افتراضي. بالنسبة لعمليات الترحيل التي تستخدم CONCURRENTLY، يجب عليك تقسيمها إلى ملف ترحيل منفصل يتم تشغيله خارج المعاملة.

كيف أتعامل مع مقارنات Drizzle enum في TypeScript؟

تقوم أعمدة التعداد الخاصة بـ Drizzle بإرجاع قيمة السلسلة من قاعدة البيانات. عند المقارنة، قد لا يقوم TypeScript بتضييق نطاق الكتابة بشكل صحيح. قم بالإرسال بشكل صريح: if ((contact.status as ContactStatus) === 'active') أو استخدم تأكيد النوع في جملة Drizzle where: where(eq(contacts.status, 'active' as ContactStatus)). يؤدي هذا إلى تجنب أخطاء وقت التشغيل مع إبقاء TypeScript سعيدًا.


الخطوات التالية

تتطلب عمليات ترحيل قاعدة البيانات بدون توقف الانضباط - ولكن البديل (وقت التوقف عن العمل، تلف البيانات، التراجع في حالات الطوارئ عند الساعة 3 صباحًا) هو أسوأ بكثير. نمط العقد الموسع، CREATE INDEX CONCURRENTLY، والإعداد المناسب للتراجع يجعل تغييرات المخطط عملية روتينية وآمنة.

يدير ECOSIRE مخططات قاعدة البيانات لتطبيقات NestJS باستخدام Drizzle ORM وPostgreSQL 17 وخط أنابيب نشر بدون توقف تم اختباره عبر أكثر من 65 ملف مخطط وأكثر من 300 عملية ترحيل. استكشف خدماتنا الهندسية الخلفية للتعرف على كيفية تعاملنا مع عمليات قاعدة البيانات بأمان على نطاق واسع.

E

بقلم

ECOSIRE Research and Development Team

بناء منتجات رقمية بمستوى المؤسسات في ECOSIRE. مشاركة رؤى حول تكاملات Odoo وأتمتة التجارة الإلكترونية وحلول الأعمال المدعومة بالذكاء الاصطناعي.

الدردشة على الواتساب