تصميم مخططات قواعد البيانات: أنماط تصمد مع الزمن
قواعد التطبيع، واصطلاحات التسمية، والحذف الناعم، وسجلات التدقيق، وأنماط تعدد المستأجرين، واستراتيجيات الإصدار، وقرارات المخطط التي ندمت عليها. مع التركيز على PostgreSQL.
المخطط هو عقد مع ذاتك المستقبلية. كل عمود تضيفه، وكل قيد تتجاوزه، وكل "سنصلح هذا لاحقًا" — كل ذلك يتراكم. عملت على أنظمة حيث قرار مخطط واحد سيئ اتُخذ قبل ثلاث سنوات يكلف الفريق الآن سبرنت كاملًا كل ربع سنة في حلول بديلة.
قاعدة البيانات تعيش أطول من كل شيء. إطار الواجهة الأمامية سيتغير. طبقة الـ API ستُعاد كتابتها. استراتيجية النشر ستتطور. لكن البيانات؟ البيانات تبقى. والشكل الذي أعطيته لها في اليوم الأول يلاحقك إلى الأبد، لأن ترحيل جدول يحتوي على 200 مليون صف ليس كإعادة هيكلة مكوّن React.
هذا ما تعلمته عن اتخاذ قرارات المخطط التي لا تطاردك. مع التركيز على PostgreSQL، لأنها ما أستخدمه وأثق به، لكن معظم هذه الأنماط تنطبق على أي قاعدة بيانات علائقية.
اصطلاحات التسمية: الشيء الممل الأكثر أهمية#
رأيت نقاشات حول اصطلاحات التسمية أكثر من النقاشات حول القرارات المعمارية الفعلية. إليك ما استقررت عليه بعد سنوات من التنقل بين المشاريع:
Snake_case لكل شيء. الجداول، والأعمدة، والفهارس، والقيود. لا camelCase، ولا PascalCase. PostgreSQL يحوّل المعرّفات غير المقتبسة إلى أحرف صغيرة على أي حال، فـ createdAt تصبح createdat ما لم تضعها بين علامات اقتباس مزدوجة في كل مكان. لا تقاتل قاعدة البيانات.
أسماء جداول بصيغة الجمع. الجدول يحتوي على صفوف كثيرة. users وليس user. orders وليس order. order_items وليس order_item. هذا يُقرأ بشكل طبيعي في الاستعلامات: SELECT * FROM users WHERE ... — أنت تختار من مجموعة.
لاحقة _id للمفاتيح الأجنبية. user_id، order_id، tenant_id. المفتاح الأساسي هو ببساطة id. هذا واضح لا لبس فيه. عندما تربط الجداول، users.id = orders.user_id تُقرأ كجملة طبيعية.
لاحقة _at للطوابع الزمنية. created_at، updated_at، deleted_at، published_at، expires_at. تعرف دائمًا أنها نقطة زمنية.
بادئة is_ للقيم المنطقية. is_active، is_verified، is_published. بعض الناس يستخدمون has_ لقيم الملكية المنطقية (has_mfa_enabled)، لكنني أبقي الأمر بسيطًا وأستخدم is_ لكل شيء.
لاحقة _count للعدادات المكررة. comment_count، follower_count. يجعل من الواضح أن هذا رقم مخزّن مؤقتًا، وليس حسابًا مباشرًا.
المسألة هي: الاتساق يتفوق على الكمال. رأيت فرقًا تقضي أسابيع في مناقشة ما إذا كان يجب أن يكون email_address أو email أو email_addr. فقط اختر نمطًا واحدًا وطبّقه في كل مكان. أسوأ اصطلاح تسمية هو الذي يُطبّق بشكل غير متسق.
-- Good: consistent, readable, no surprises
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL,
display_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
is_verified BOOLEAN NOT NULL DEFAULT false,
login_count INTEGER NOT NULL DEFAULT 0,
last_login_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Bad: inconsistent casing, mixed conventions, ambiguous names
CREATE TABLE User (
UserID SERIAL PRIMARY KEY,
EmailAddress VARCHAR(255),
name VARCHAR(100),
passwd VARCHAR(255),
active BOOLEAN,
verified INT, -- is this 0/1? a verification level?
lastLogin TIMESTAMP, -- camelCase in a snake_case world
created TIMESTAMP -- created what? when?
);ملاحظة أخيرة: لا تستخدم أبدًا كلمات محجوزة كأسماء أعمدة. type، order، user، group، table — كلها تعمل إذا وضعتها بين علامات اقتباس، لكنها ستسبب لك مشاكل في أطر ORM، ومنشئي الاستعلامات، وكل أداة لتوليد SQL الديناميكي. استخدم kind بدلًا من type، و sort_order بدلًا من order. ذاتك المستقبلية ستشكرك.
الأعمدة القياسية: ما يحصل عليه كل جدول#
كل جدول في مخططاتي يبدأ بنفس الهيكل الأساسي. بدون استثناءات.
CREATE TABLE things (
id BIGSERIAL PRIMARY KEY,
-- ... domain columns ...
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);نقاش UUID مقابل BIGSERIAL#
هذا أحد تلك القرارات التي تولّد حرارة أكثر بكثير من الضوء. إليك المقايضة الفعلية:
BIGSERIAL (عدد صحيح متزايد تلقائيًا):
- 8 بايت. مضغوط. سريع في الفهرسة والربط.
- مرتّب — يمكنك الترتيب حسب
idللحصول على ترتيب الإدراج. - قابل للتنبؤ — يمكن للمستخدم تخمين معرّفات أخرى بزيادة معرّفه.
- لا يعمل جيدًا في الأنظمة الموزعة (يتطلب تنسيقًا).
UUID v4 (عشوائي):
- 16 بايت. فهارس أكبر، ربط أبطأ (لكنه نادرًا ما يكون العنق الزجاجة).
- غير قابل للتنبؤ — لا تسريب معلومات.
- يعمل في الأنظمة الموزعة بدون تنسيق.
- موقعية فهرس سيئة — UUIDs العشوائية تُجزّئ فهارس B-tree.
UUID v7 (مرتّب زمنيًا، RFC 9562):
- 16 بايت، لكنه مرتّب زمنيًا فموقعية B-tree ممتازة.
- غير قابل للتنبؤ بما يكفي للاستخدام الخارجي.
- جديد نسبيًا، لكن PostgreSQL 17+ لديها
gen_random_uuid()ويمكنك استخدامuuid_generate_v7()مع الإضافات.
موقفي الحالي: BIGSERIAL للجداول الداخلية، UUID v7 لأي شيء يُعرض على العالم الخارجي. إذا ظهر معرّف في عنوان URL، أو استجابة API، أو حمولة webhook، استخدم UUIDs. إذا كان جدول ربط داخلي لا يراه المستخدمون أبدًا، فـ BIGSERIAL مناسب.
-- For an API-facing resource
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
owner_id UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- For an internal mapping table
CREATE TABLE project_members (
id BIGSERIAL PRIMARY KEY,
project_id UUID NOT NULL REFERENCES projects(id),
user_id UUID NOT NULL REFERENCES users(id),
role TEXT NOT NULL DEFAULT 'member',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (project_id, user_id)
);الطوابع الزمنية: دائمًا TIMESTAMPTZ#
استخدم TIMESTAMPTZ، وليس TIMESTAMP. النسخة "TZ" تخزّن القيمة بتوقيت UTC وتحوّلها عند القراءة بناءً على المنطقة الزمنية للجلسة. النسخة بدون TZ تخزّن ما تعطيها إياه بدون سياق منطقة زمنية — مما يعني أنه إذا كتب خادمان في مناطق زمنية مختلفة في نفس الجدول، تحصل على فساد بيانات صامت.
-- Always this
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
-- Never this
created_at TIMESTAMP NOT NULL DEFAULT NOW()مشغّل updated_at#
PostgreSQL ليس لديها ON UPDATE CURRENT_TIMESTAMP مثل MySQL. تحتاج إلى مشغّل (trigger):
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Apply to every table that has updated_at
CREATE TRIGGER set_updated_at
BEFORE UPDATE ON users
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();
CREATE TRIGGER set_updated_at
BEFORE UPDATE ON projects
FOR EACH ROW
EXECUTE FUNCTION update_updated_at_column();نعم، تحتاج مشغّلًا واحدًا لكل جدول. نعم، إنه ممل. اكتب مساعد ترحيل يُنشئ المشغّل تلقائيًا عند إضافة جدول. إنه يستحق ذلك، لأن البديل هو تذكّر تعيين updated_at = NOW() في كل استعلام UPDATE عبر تطبيقك بأكمله — وستنسى.
التطبيع: متى تكسر القواعد#
كل مقرر علوم حاسب يعلّم التطبيع حتى الصيغة العادية الثالثة (3NF). القواعد هي:
- 1NF: كل عمود يحتوي على قيمة ذرية واحدة. لا مصفوفات، ولا قوائم مفصولة بفواصل.
- 2NF: كل عمود غير مفتاحي يعتمد على المفتاح الأساسي بأكمله (ذو صلة بالمفاتيح المركّبة).
- 3NF: لا تبعيات متعدية. إذا كان العمود A يحدد العمود B، و B يحدد C، فلا يجب أن يكون C في نفس الجدول مع A.
في الممارسة العملية، 3NF هي النقطة المثالية للجداول التبادلية. يجب أن تبدأ من هناك وتنحرف فقط عندما يكون لديك سبب محدد وقابل للقياس.
إليك نظام طلبات مُطبّع بشكل صحيح:
CREATE TABLE customers (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
sku TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
price_cents INTEGER NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
customer_id BIGINT NOT NULL REFERENCES customers(id),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'confirmed', 'shipped', 'delivered', 'cancelled')),
total_cents INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE order_items (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL REFERENCES orders(id) ON DELETE CASCADE,
product_id BIGINT NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
unit_price_cents INTEGER NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);لاحظ unit_price_cents في order_items. هذا إلغاء تطبيع مقصود. نحن نأخذ لقطة من السعر وقت الطلب، لأن سعر المنتج قد يتغير لاحقًا. هذا أحد أكثر الأسباب شيوعًا وصحة لإلغاء التطبيع.
متى يكون إلغاء التطبيع هو القرار الصحيح#
جداول التقارير. إذا كانت لوحة التحليلات تحتاج إلى ربط 8 جداول للعرض، أنشئ جدول تقارير غير مُطبّع واملأه بمهمة خلفية. مخططك التبادلي يبقى نظيفًا، واستعلامات التقارير تبقى سريعة.
-- Denormalized reporting table, populated by a cron job
CREATE TABLE order_reports (
id BIGSERIAL PRIMARY KEY,
order_id BIGINT NOT NULL,
customer_email TEXT NOT NULL,
customer_name TEXT NOT NULL,
product_names TEXT[] NOT NULL, -- array is fine here
item_count INTEGER NOT NULL,
total_cents INTEGER NOT NULL,
order_status TEXT NOT NULL,
ordered_at TIMESTAMPTZ NOT NULL,
report_generated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);العروض المادية (Materialized Views). MATERIALIZED VIEW في PostgreSQL لا تحظى بالتقدير الكافي. إنها لقطة غير مُطبّعة تُحدّثها عند الطلب. مثالية للوحات المعلومات.
CREATE MATERIALIZED VIEW monthly_revenue AS
SELECT
DATE_TRUNC('month', o.created_at) AS month,
COUNT(DISTINCT o.id) AS order_count,
SUM(o.total_cents) AS revenue_cents,
COUNT(DISTINCT o.customer_id) AS unique_customers
FROM orders o
WHERE o.status != 'cancelled'
GROUP BY DATE_TRUNC('month', o.created_at)
ORDER BY month DESC;
-- Refresh it nightly
REFRESH MATERIALIZED VIEW CONCURRENTLY monthly_revenue;أعمدة JSON للسمات الديناميكية. عندما تحتاج صفوف مختلفة إلى حقول مختلفة — متغيرات المنتجات، وإرسال النماذج، وتفضيلات المستخدمين — عمود JSONB غالبًا ما يكون أفضل من جدول EAV (الكيان-السمة-القيمة) الكابوسي. المزيد عن هذا لاحقًا.
القاعدة التي لا أكسرها أبدًا#
لا تُلغِ تطبيع جداول مصدر الحقيقة أبدًا. أَلغِ تطبيع النسخ، واللقطات، والتقارير، والذاكرة المؤقتة. البيانات الأساسية تبقى مُطبّعة. عندما تصبح النسخة غير المُطبّعة قديمة أو فاسدة (وسيحدث ذلك)، تُعيد بناءها من المصدر المُطبّع.
المفاتيح الأجنبية والقيود: أفضل كود لن تكتبه أبدًا#
سمعت كل عذر لتجاوز المفاتيح الأجنبية. "إنها تبطئ الكتابة." "نحن نفرضها في التطبيق." "نحتاج مرونة."
كل هذه الأعذار خاطئة.
المفاتيح الأجنبية هي أكثر شيء مؤثر يمكنك إضافته إلى مخطط. إنها تمنع فئات كاملة من الأخطاء التي لا يمكن لأي كمية من كود التطبيق اكتشافها — حالات السباق، والفشل الجزئي، والصفوف اليتيمة من المعاملات الفاشلة. المفتاح الأجنبي هو ضمان من محرك قاعدة البيانات نفسه، مُنفّذ على مستوى التخزين. hook beforeDelete في ORM الخاص بك هو مجرد اقتراح.
-- Always do this
ALTER TABLE orders
ADD CONSTRAINT fk_orders_customer
FOREIGN KEY (customer_id) REFERENCES customers(id);
-- The "we'll handle it in code" alternative:
-- Hope. Hope is not a strategy.استراتيجيات ON DELETE#
هنا يصبح الأمر دقيقًا. ماذا يحدث عندما تحذف صف أب؟
RESTRICT (الافتراضي): الحذف يفشل إذا كانت هناك صفوف أبناء. استخدم هذا لمعظم العلاقات. لا يمكنك حذف عميل لديه طلبات — هذا منطق أعمال مُشفّر في المخطط.
-- Customer can't be deleted while they have orders
ALTER TABLE orders
ADD CONSTRAINT fk_orders_customer
FOREIGN KEY (customer_id) REFERENCES customers(id)
ON DELETE RESTRICT;CASCADE: صفوف الأبناء تُحذف تلقائيًا. استخدم هذا بحذر وعمد. جيد لعلاقات "جزء من" حيث لا يكون للابن معنى بدون الأب.
-- Deleting an order deletes its line items — they're meaningless alone
ALTER TABLE order_items
ADD CONSTRAINT fk_order_items_order
FOREIGN KEY (order_id) REFERENCES orders(id)
ON DELETE CASCADE;
-- Deleting a project deletes its memberships
ALTER TABLE project_members
ADD CONSTRAINT fk_project_members_project
FOREIGN KEY (project_id) REFERENCES projects(id)
ON DELETE CASCADE;SET NULL: عمود المفتاح الأجنبي يُعيَّن إلى NULL. استخدم هذا عندما تكون العلاقة اختيارية والصف الابن لا يزال ذا معنى بمفرده.
-- If a manager leaves, their reports still exist — just unassigned
ALTER TABLE employees
ADD CONSTRAINT fk_employees_manager
FOREIGN KEY (manager_id) REFERENCES employees(id)
ON DELETE SET NULL;قاعدتي الأساسية: افتراضيًا RESTRICT، استخدم CASCADE لعلاقات التركيب، واستخدم SET NULL للارتباطات الاختيارية. إذا لم تكن متأكدًا، RESTRICT هو دائمًا الخيار الآمن — تخفيف القيد أسهل من استعادة البيانات المحذوفة.
قيود الفحص: تأمين رخيص#
قيود الفحص لا تكلف شيئًا تقريبًا وقت الكتابة وتمنع البيانات القمامة من دخول نظامك إلى الأبد:
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL CHECK (LENGTH(name) > 0),
price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
discount_pct NUMERIC(5,2) CHECK (discount_pct BETWEEN 0 AND 100),
weight_kg NUMERIC(10,3) CHECK (weight_kg > 0),
status TEXT NOT NULL CHECK (status IN ('draft', 'active', 'archived')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);كل قيد فحص تضيفه هو خطأ تحقق أقل ستحتاج لتصحيحه في الإنتاج. قاعدة البيانات هي خط الدفاع الأخير. استخدمها.
قيود الفرادة والفهارس الجزئية الفريدة#
قيود الفرادة بسيطة للحالات البسيطة:
ALTER TABLE users ADD CONSTRAINT uq_users_email UNIQUE (email);لكنها تصبح مثيرة للاهتمام مع الفهارس الجزئية — قيود فرادة تنطبق فقط على صفوف معينة:
-- Only one active subscription per user (but they can have many cancelled ones)
CREATE UNIQUE INDEX uq_active_subscription
ON subscriptions (user_id)
WHERE status = 'active';
-- Only one primary address per user
CREATE UNIQUE INDEX uq_primary_address
ON addresses (user_id)
WHERE is_primary = true;هذه إحدى ميزات PostgreSQL القاتلة. استخدمها.
الحذف الناعم: النمط الذي يحبه الجميع ويكرهونه#
الحذف الناعم بسيط في المفهوم: بدلًا من DELETE FROM users WHERE id = 42، تفعل UPDATE users SET deleted_at = NOW() WHERE id = 42. الصف يبقى في قاعدة البيانات لكنه يُفلتر من الاستعلامات العادية.
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email TEXT NOT NULL,
display_name TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- The partial index: filter out deleted rows efficiently
CREATE INDEX idx_users_active ON users (email) WHERE deleted_at IS NULL;كل استعلام في تطبيقك يحتاج الآن WHERE deleted_at IS NULL. كل. استعلام. واحد. أطر ORM تساعد في هذا (Prisma لديها middleware، Drizzle لديها .where(isNull(deletedAt)))، لكنها ضريبة على كل عملية قراءة. إذا نسيتها مرة واحدة فأنت تعرض بيانات "محذوفة" للمستخدمين.
مشكلة القيد الفريد#
هنا يصبح الحذف الناعم قبيحًا. إذا كان لديك UNIQUE (email) ومستخدم حذف حسابه حذفًا ناعمًا، بريده الإلكتروني لا يزال في الجدول. لا يمكنه إعادة التسجيل بنفس البريد. ومستخدم جديد بنفس البريد لا يمكنه التسجيل أيضًا.
الحل هو فهرس جزئي فريد:
-- Email must be unique, but only among non-deleted users
CREATE UNIQUE INDEX uq_users_email_active
ON users (email)
WHERE deleted_at IS NULL;هذا يعمل، لكن الآن تحتاج أن تتذكر هذا النمط لكل عمود فريد في كل جدول قابل للحذف الناعم. يمكن إدارته مع الانضباط، لكنه تعقيد اخترت تحمّله.
مشكلة المفتاح الأجنبي#
الحذف الناعم يتفاعل بشكل سيئ مع المفاتيح الأجنبية. إذا كان orders.user_id يشير إلى users.id مع ON DELETE RESTRICT، وحذفت مستخدمًا حذفًا ناعمًا... لا يحدث شيء. المفتاح الأجنبي لا يعمل لأنك لم تحذف الصف فعلًا. المستخدم "ذهب" من منظور التطبيق لكنه لا يزال موجودًا جدًا في قاعدة البيانات.
هذا يعني أن كود تطبيقك يجب أن يتعامل مع الحالة التي يكون فيها الكيان المُشار إليه محذوفًا حذفًا ناعمًا. كل ربط، وكل بحث، وكل مرة تتبع فيها مفتاحًا أجنبيًا — تحتاج للتحقق من deleted_at IS NULL على الجدول المُشار إليه أيضًا. أو لا تفعل، ويعرض تطبيقك "طلب من [مستخدم محذوف]" وهو إما خطأ أو ميزة حسب من تسأل.
أسلوبي#
أستخدم الحذف الناعم فقط للكيانات الموجهة للمستخدم حيث الاستعادة مطلب عملي — حسابات المستخدمين، والمشاريع، والمستندات. أشياء قد يحتاج موظف الدعم لاستعادة حذفها. لكل شيء آخر، أستخدم الحذف الصلب مع سجل تدقيق (المزيد عن هذا تاليًا).
-- Soft delete: user-facing, recoverable
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
content TEXT NOT NULL,
owner_id UUID NOT NULL REFERENCES users(id),
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_documents_active ON documents (owner_id) WHERE deleted_at IS NULL;
-- Hard delete: internal, not recoverable from UI (but audited)
CREATE TABLE api_keys (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
key_hash TEXT NOT NULL,
last_used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- When deleted, it's gone. The audit_log records that it existed.سجلات التدقيق: اعرف ما تغيّر ومن فعله#
كل تطبيق غير بسيط يحتاج إلى إجابة على "ماذا حدث لهذا السجل؟" في مرحلة ما. سجلات التدقيق هي كيف تقدم تلك الإجابة بدون البحث في سجلات التطبيق.
نمط جدول التدقيق المنفصل#
الأسلوب الأبسط: جدول audit_log واحد يسجّل كل تغيير.
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
table_name TEXT NOT NULL,
record_id TEXT NOT NULL, -- text to handle both UUID and BIGINT PKs
operation TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
old_values JSONB, -- NULL for INSERT
new_values JSONB, -- NULL for DELETE
changed_fields TEXT[], -- which columns changed (for UPDATE)
changed_by UUID REFERENCES users(id),
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
ip_address INET,
user_agent TEXT
);
CREATE INDEX idx_audit_log_table_record ON audit_log (table_name, record_id);
CREATE INDEX idx_audit_log_changed_by ON audit_log (changed_by);
CREATE INDEX idx_audit_log_changed_at ON audit_log (changed_at);التحدي هو ملؤه. يمكنك فعل ذلك في كود التطبيق (صريح، لكن سهل النسيان) أو بالمشغّلات (تلقائي، لكن أصعب لتمرير السياق مثل changed_by).
الأسلوب القائم على المشغّلات#
المشغّلات تلتقط كل تغيير تلقائيًا، حتى من SQL الخام أو عمليات مدير قاعدة البيانات:
CREATE OR REPLACE FUNCTION audit_trigger_function()
RETURNS TRIGGER AS $$
DECLARE
old_data JSONB;
new_data JSONB;
changed TEXT[];
col TEXT;
BEGIN
IF TG_OP = 'DELETE' THEN
old_data := to_jsonb(OLD);
INSERT INTO audit_log (table_name, record_id, operation, old_values, changed_at)
VALUES (TG_TABLE_NAME, OLD.id::TEXT, 'DELETE', old_data, NOW());
RETURN OLD;
ELSIF TG_OP = 'UPDATE' THEN
old_data := to_jsonb(OLD);
new_data := to_jsonb(NEW);
-- Find changed fields
FOR col IN SELECT key FROM jsonb_each(new_data)
LOOP
IF old_data->col IS DISTINCT FROM new_data->col THEN
changed := array_append(changed, col);
END IF;
END LOOP;
-- Only log if something actually changed
IF array_length(changed, 1) > 0 THEN
INSERT INTO audit_log (table_name, record_id, operation, old_values, new_values, changed_fields, changed_at)
VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'UPDATE', old_data, new_data, changed, NOW());
END IF;
RETURN NEW;
ELSIF TG_OP = 'INSERT' THEN
new_data := to_jsonb(NEW);
INSERT INTO audit_log (table_name, record_id, operation, new_values, changed_at)
VALUES (TG_TABLE_NAME, NEW.id::TEXT, 'INSERT', new_data, NOW());
RETURN NEW;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-- Apply to tables you want audited
CREATE TRIGGER audit_users
AFTER INSERT OR UPDATE OR DELETE ON users
FOR EACH ROW EXECUTE FUNCTION audit_trigger_function();
CREATE TRIGGER audit_orders
AFTER INSERT OR UPDATE OR DELETE ON orders
FOR EACH ROW EXECUTE FUNCTION audit_trigger_function();العيب: المشغّلات لا تعرف أي مستخدم تطبيق أجرى التغيير. يمكنك التغلب على هذا بمتغيرات الجلسة:
-- In your application, before the query:
SET LOCAL app.current_user_id = 'uuid-of-current-user';
-- In the trigger function:
changed_by_id := current_setting('app.current_user_id', true)::UUID;هذا يعمل لكنه يبدو هشًا. في الممارسة، أستخدم أسلوبًا هجينًا: المشغّلات لالتقاط البيانات، وكود التطبيق لتعيين سياق الجلسة.
نمط جدول التاريخ#
للجداول التي تحتاج فيها إلى التاريخ الكامل للإصدارات (ليس فقط "ما الذي تغيّر" لكن "ما كانت الحالة في الوقت T")، جدول تاريخ مخصص أنظف:
CREATE TABLE documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
content TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
owner_id UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE document_history (
id BIGSERIAL PRIMARY KEY,
document_id UUID NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
title TEXT NOT NULL,
content TEXT NOT NULL,
version INTEGER NOT NULL,
changed_by UUID REFERENCES users(id),
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (document_id, version)
);قبل كل تحديث لـ documents، انسخ الحالة الحالية إلى document_history وزِد الإصدار. الآن يمكنك إعادة بناء المستند في أي نقطة زمنية، وعرض الفروقات بين الإصدارات، وحتى استعادة الإصدارات القديمة.
المقايضة هي التخزين. إذا كان عمود content كبيرًا ويتغير بشكل متكرر، جدول التاريخ يمكن أن ينمو بسرعة. لمعظم التطبيقات، هذا مقبول — التخزين رخيص ويمكنك أرشفة الإصدارات القديمة إلى تخزين بارد إذا لزم الأمر.
تعدد المستأجرين: ثلاثة أساليب، اختر ألمك#
تعدد المستأجرين هو أحد تلك الأشياء التي يسهل إضافتها في البداية ويكاد يستحيل إضافتها لاحقًا. إذا كانت هناك أي فرصة أن تطبيقك سيخدم منظمات متعددة، ابنِه من اليوم الأول.
مستوى الصف: tenant_id على كل جدول#
الأسلوب الأكثر شيوعًا. كل جدول لديه عمود tenant_id، وكل استعلام يُفلتر بواسطته.
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
plan TEXT NOT NULL DEFAULT 'free' CHECK (plan IN ('free', 'pro', 'enterprise')),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
project_id UUID NOT NULL REFERENCES projects(id),
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'todo',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Every index should include tenant_id for query performance
CREATE INDEX idx_projects_tenant ON projects (tenant_id);
CREATE INDEX idx_tasks_tenant_project ON tasks (tenant_id, project_id);الخطر هو تسرب البيانات — WHERE tenant_id = ... واحد منسي وأنت تعرض بيانات المستأجر أ للمستأجر ب. أمان مستوى الصف (RLS) في PostgreSQL يقضي على هذه الفئة من الأخطاء:
-- Enable RLS on the table
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
-- Create a policy based on a session variable
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
-- Force RLS even for table owners
ALTER TABLE projects FORCE ROW LEVEL SECURITY;الآن، حتى لو نسي كود تطبيقك شرط WHERE tenant_id = ...، PostgreSQL يضيفه تلقائيًا. هذا دفاع بالعمق، وهو أحد أقوى الحجج لاستخدام PostgreSQL في الأنظمة متعددة المستأجرين.
-- In your application's connection middleware:
SET LOCAL app.current_tenant_id = 'tenant-uuid-here';
-- Now all queries on RLS-enabled tables are automatically filtered
SELECT * FROM projects;
-- PostgreSQL internally adds: WHERE tenant_id = 'tenant-uuid-here'المزايا: قاعدة بيانات واحدة، عمليات بسيطة، استخدام فعّال للموارد، سهولة الاستعلامات عبر المستأجرين للمدير. العيوب: يتطلب انضباطًا (أو RLS)، كل استعلام يمس tenant_id، أصعب لإعطاء المستأجرين نسخة احتياطية/استعادة خاصة بهم.
مخطط لكل مستأجر#
كل مستأجر يحصل على مخطط PostgreSQL خاص به. جميع المخططات تتشارك نفس قاعدة البيانات، لكن الجداول معزولة بمساحة الأسماء.
-- Create a schema for each tenant
CREATE SCHEMA tenant_acme;
CREATE SCHEMA tenant_globex;
-- Tables live in the tenant's schema
CREATE TABLE tenant_acme.projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Set the search_path for the current tenant
SET search_path TO tenant_acme, public;
-- Now unqualified queries hit the tenant's schema
SELECT * FROM projects; -- queries tenant_acme.projectsالمزايا: عزل قوي، لا خطر تسرب البيانات بين المستأجرين، سهولة النسخ الاحتياطي/الاستعادة لكل مستأجر، يمكن أن يكون للمستأجرين اختلافات في المخطط. العيوب: تعقيد ترحيل المخطط (تحتاج لترحيل N مخطط)، إدارة تجمّع الاتصالات، PostgreSQL لديها حدود عملية حول ~10,000 مخطط.
قاعدة بيانات لكل مستأجر#
كل مستأجر يحصل على قاعدة بيانات خاصة به. أقصى عزل.
المزايا: عزل كامل، توسع مستقل، سهولة النسخ الاحتياطي/الاستعادة، يمكن وضع المستأجرين الكبار على أجهزة مخصصة. العيوب: كابوس إدارة الاتصالات، استحالة الاستعلامات عبر المستأجرين، الترحيل يجب أن يعمل N مرة، عبء تشغيلي كبير.
أيهما تختار؟#
لمعظم تطبيقات SaaS: ابدأ بمستوى الصف + RLS. إنها الأبسط في التشغيل، و RLS تعطيك عزلًا قويًا بما يكفي للغالبية العظمى من حالات الاستخدام. انتقل إلى مخطط لكل مستأجر فقط إذا كانت لديك متطلبات عزل تعاقدية (عملاء المؤسسات، الصناعات المنظمة). قاعدة بيانات لكل مستأجر لعندما يجب أن تضمن عزلًا فيزيائيًا — وحتى في تلك الحالة، فكّر في قواعد البيانات المُدارة حيث يُتعامل مع العبء التشغيلي نيابة عنك.
أعمدة JSON/JSONB: مخرج الطوارئ#
JSONB في PostgreSQL رائع. يمنحك مرونة قاعدة بيانات المستندات داخل نظام علائقي. لكن مثل أي أداة قوية، من السهل إساءة استخدامه.
متى يكون JSONB هو الخيار الصحيح#
السمات الديناميكية المحددة من المستخدم. منصة تجارة إلكترونية حيث كل فئة منتج لها سمات مختلفة — الأحذية لها size و color، الإلكترونيات لها voltage و wattage. بدلًا من جدول EAV أو عمود لكل سمة ممكنة:
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
category_id UUID NOT NULL REFERENCES categories(id),
price_cents INTEGER NOT NULL CHECK (price_cents >= 0),
attributes JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Example data:
-- Shoe: {"size": "10", "color": "black", "material": "leather"}
-- Laptop: {"ram_gb": 16, "storage_gb": 512, "screen_inches": 14.0}إعدادات التطبيق والتكوين. تفضيلات المستخدم، رايات الميزات، إعدادات الإشعارات — أشياء تتغير شكلها بشكل متكرر ولا تحتاج سلامة علائقية.
CREATE TABLE user_settings (
user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
preferences JSONB NOT NULL DEFAULT '{
"theme": "system",
"notifications": {"email": true, "push": false},
"locale": "en",
"timezone": "UTC"
}',
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);حمولات Webhook، واستجابات API، وبيانات الأحداث. أي شيء حيث المخطط يتحكم فيه نظام خارجي وقد يتغير بدون إشعار.
فهارس GIN على JSONB#
بدون فهارس، الاستعلام داخل JSONB يتطلب مسحًا كاملًا للجدول. فهارس GIN تجعله سريعًا:
-- Index the entire JSONB column (good for @> containment queries)
CREATE INDEX idx_products_attributes ON products USING GIN (attributes);
-- Query: find all products with color = "black"
SELECT * FROM products
WHERE attributes @> '{"color": "black"}';
-- Query: find products with any of these attributes
SELECT * FROM products
WHERE attributes ?| ARRAY['color', 'size'];للمسارات المحددة التي تستعلم عنها كثيرًا، فهرس مستهدف أكثر كفاءة:
-- Index a specific path
CREATE INDEX idx_products_color ON products ((attributes->>'color'));
-- Now this is a regular B-tree lookup
SELECT * FROM products
WHERE attributes->>'color' = 'black';فخ الترحيل#
هنا يعضّك JSONB: لا يمكنك بسهولة إضافة قيود NOT NULL أو قيم افتراضية لحقول داخل JSON. إذا أضفت حقلًا مطلوبًا جديدًا لسمات المنتج، تحتاج لملء كل صف موجود بأثر رجعي. مع عمود عادي، الترحيل يتعامل مع هذا ذريًا. مع JSONB، أنت تكتب UPDATE يمس كل صف وتأمل أن كود تطبيقك يتعامل مع الحقول المفقودة بأناقة حتى يكتمل الملء.
-- Adding a new column: clean, atomic, one statement
ALTER TABLE products ADD COLUMN weight_kg NUMERIC(10,3);
-- Adding a new JSONB field: messy, requires a backfill
UPDATE products
SET attributes = attributes || '{"weight_kg": null}'
WHERE NOT (attributes ? 'weight_kg');
-- Plus update application code to handle missing weight_kg
-- Plus update validation logic
-- Plus update every API response that includes attributesقاعدتي: إذا كنت تستعلم عن حقل JSONB في شرط WHERE أكثر من مرتين في الأسبوع، فمن المحتمل أنه يجب أن يكون عمودًا. JSONB مخرج طوارئ رائع. لكنه خيار افتراضي سيئ.
ترحيل المخطط: لا تكسر الإنتاج في الثالثة صباحًا#
ترحيل المخطط هو حيث تلتقي النظرية بالواقع. مخططك يبدو رائعًا على الورق، لكن الآن تحتاج لتعديل جدول يحتوي على 50 مليون صف خلال ساعات العمل بدون أي توقف.
أدوات الترحيل#
استخدمت معظمها. آراء مختصرة:
Drizzle (TypeScript): المفضل لديّ حاليًا. مخطط ككود، استعلامات آمنة الأنواع مولّدة من المخطط، SQL ترحيل نظيف. أمر push للتطوير سريع.
Prisma (TypeScript): تجربة مطوّر رائعة للمخططات البسيطة. يعاني مع ميزات PostgreSQL المتقدمة (الفهارس الجزئية، الأنواع المخصصة، RLS). محرك الترحيل قد يتخذ قرارات مفاجئة.
Flyway (Java/CLI): صلب كالصخر، مُختبر في المعارك، SQL أولًا. إذا كنت تكتب ترحيلات SQL خام، Flyway يتتبعها بشكل موثوق. لا سحر، لا مفاجآت.
golang-migrate (Go/CLI): مشابه لـ Flyway لكن أخف. رائع لمشاريع Go أو عندما تريد فقط مشغّل ترحيل up/down بسيط.
مشكلة التوقف الصفري#
أخطر تغييرات المخطط هي تلك التي تقفل الجدول. في PostgreSQL، ALTER TABLE ... ADD COLUMN مع قيمة افتراضية كانت تقفل الجدول بأكمله طوال مدة إعادة الكتابة. منذ PostgreSQL 11، القيم الافتراضية البسيطة (الثوابت) هي بيانات وصفية فقط وفورية. لكن عمليات أخرى لا تزال تقفل:
-- SAFE: metadata-only, instant (PostgreSQL 11+)
ALTER TABLE users ADD COLUMN bio TEXT;
ALTER TABLE users ADD COLUMN is_premium BOOLEAN NOT NULL DEFAULT false;
-- DANGEROUS: rewrites the entire table, full table lock
ALTER TABLE users ALTER COLUMN email TYPE VARCHAR(500);
ALTER TABLE users ADD COLUMN score INTEGER NOT NULL DEFAULT compute_score();
-- DANGEROUS: scans the entire table to validate
ALTER TABLE orders ADD CONSTRAINT check_total CHECK (total_cents >= 0);إضافة أعمدة NOT NULL بأمان#
لا يمكنك ببساطة إضافة NOT NULL إلى جدول موجود به بيانات — يفشل لأن الصفوف الموجودة ليس لها قيمة. الأسلوب الساذج:
-- This locks the table and rewrites it. Don't do this on a big table.
ALTER TABLE users ADD COLUMN avatar_url TEXT NOT NULL DEFAULT 'https://example.com/default.png';منذ PostgreSQL 11، هذا في الواقع آمن للقيم الافتراضية الثابتة — إنه بيانات وصفية فقط. لكن إذا كان الافتراضي دالة أو تحتاج لملء بقيم محسوبة، استخدم نمط التوسيع-الانكماش.
نمط التوسيع-الانكماش (Expand-Contract)#
هذا هو المعيار الذهبي لتغييرات المخطط بدون توقف. ثلاث مراحل:
المرحلة 1: التوسيع — أضف العمود الجديد كقابل للقيمة الفارغة. انشر كود التطبيق الذي يكتب في كلا العمودين القديم والجديد.
-- Migration 1: Add the nullable column
ALTER TABLE users ADD COLUMN normalized_email TEXT;المرحلة 2: الترحيل — املأ الصفوف الموجودة على دفعات. تطبيقك يكتب بالفعل في العمود الجديد للبيانات الجديدة.
-- Migration 2: Backfill in batches (don't do this in one statement for large tables)
UPDATE users SET normalized_email = LOWER(TRIM(email))
WHERE normalized_email IS NULL
LIMIT 10000;
-- Repeat until all rows are backfilledالمرحلة 3: الانكماش — بمجرد ملء جميع الصفوف، أضف قيد NOT NULL وأزل العمود القديم (إن أمكن).
-- Migration 3: Add constraint (use NOT VALID to avoid full table scan, then validate separately)
ALTER TABLE users ADD CONSTRAINT users_normalized_email_not_null
CHECK (normalized_email IS NOT NULL) NOT VALID;
-- Migration 4: Validate the constraint (takes a ShareUpdateExclusiveLock, not an AccessExclusiveLock)
ALTER TABLE users VALIDATE CONSTRAINT users_normalized_email_not_null;حيلة NOT VALID + VALIDATE CONSTRAINT حاسمة. إضافة قيد فحص عادةً تمسح الجدول بأكمله أثناء الاحتفاظ بقفل ثقيل. NOT VALID يضيف القيد بدون مسح (ينطبق فقط على الكتابات الجديدة)، و VALIDATE CONSTRAINT يمسح بقفل أخف لا يمنع القراءات أو الكتابات.
إنشاء الفهارس#
إنشاء الفهارس على الجداول الكبيرة يمنع الكتابات افتراضيًا. دائمًا استخدم CONCURRENTLY:
-- BLOCKS WRITES: don't do this on a live table
CREATE INDEX idx_users_email ON users (email);
-- NON-BLOCKING: use this instead
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);CONCURRENTLY يستغرق وقتًا أطول ولا يمكن تشغيله داخل معاملة، لكنه لا يقفل الجدول. المقايضة تستحق دائمًا في الإنتاج.
القرارات التي ندمت عليها#
كل مخطط فيه ندم. إليك ندمي.
استخدام SERIAL بدلًا من UUID للمعرّفات الخارجية#
في بداية مشروع، استخدمت مفاتيح أساسية SERIAL وعرضتها مباشرة في عناوين URL: /users/42، /orders/1337. هذا سرّب معلومات (المنافسون يمكنهم تخمين عدد مستخدمينا)، وجعل ترقيم الصفحات قابلًا للتخمين، وانكسر عندما احتجنا لدمج قواعد بيانات من منطقتين. التحول إلى UUIDs للمعرّفات الموجهة خارجيًا تطلّب ترحيلًا متعدد الأشهر.
الدرس: استخدم UUIDs لأي شيء يُعرض خارج قاعدة بياناتك. استخدم SERIAL/BIGSERIAL لجداول الربط الداخلية إذا أردت، لكن لا تدع عددًا صحيحًا متزايدًا تلقائيًا يظهر في عنوان URL أبدًا.
بدون قيود ("سنضيفها لاحقًا")#
أطلقنا جدولًا بدون قيود فحص لأن "نحن نتحرك بسرعة وسنضيفها لاحقًا." خلال أسبوعين، كانت البيانات تحتوي على أسعار سالبة، وأسماء فارغة، وقيمة order_status تساوي "oof" كتبها شخص ما أثناء الاختبار ولم ينظّفها أبدًا.
إضافة القيود بأثر رجعي تطلّبت:
- إيجاد جميع البيانات غير الصالحة
- تحديد ما يُفعل بها (إصلاح، أو حذف، أو إعفاؤها)
- كتابة ترحيل يصلح البيانات ويضيف القيد
هذا استغرق وقتًا أطول مما كانت ستستغرقه إضافة القيد في اليوم الأول. ابدأ بالقيود. خفّفها إذا لزم الأمر. أبدًا العكس.
سلاسل مفصولة بفواصل بدلًا من المصفوفات أو جداول الربط#
ذات مرة خزّنت الوسوم كسلسلة مفصولة بفواصل: "javascript,react,nextjs". الاستعلام كان كابوسًا:
-- This is how you query comma-separated values. Don't do this.
SELECT * FROM posts WHERE tags LIKE '%react%';
-- This also matches "react-native" and "preact"
-- What I should have done:
CREATE TABLE post_tags (
post_id UUID NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
tag_id UUID NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (post_id, tag_id)
);أو على الأقل، استخدم نوع المصفوفة الأصلي في PostgreSQL:
CREATE TABLE posts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
tags TEXT[] NOT NULL DEFAULT '{}',
-- ...
);
CREATE INDEX idx_posts_tags ON posts USING GIN (tags);
-- Clean queries
SELECT * FROM posts WHERE 'react' = ANY(tags);
SELECT * FROM posts WHERE tags @> ARRAY['react', 'nextjs'];المصفوفات مقبولة للقوائم البسيطة التي لا تحتاج سمات خاصة بها. اللحظة التي تحتاج فيها بيانات وصفية عن العلاقة (مثل "من أضاف هذا الوسم" أو "متى أُضيف")، تحتاج جدول ربط.
استخدام "type" كاسم عمود#
-- Seemed fine at the time
CREATE TABLE notifications (
id BIGSERIAL PRIMARY KEY,
type TEXT NOT NULL, -- "email", "sms", "push"
-- ...
);
-- Then you try to query it in literally any ORM:
-- notification.type ← conflicts with every language's type system
-- "type" is a reserved word in most SQL dialects
-- You end up quoting it everywhere: SELECT "type" FROM notificationsاستخدم kind، أو category، أو notification_type. أي شيء غير type. بالمثل، تجنب order (استخدم sort_order أو position)، و user (استخدم account أو ضع بادئة)، و group (استخدم team أو group_name).
عدم إضافة created_at من البداية#
جدول واحد لم يحصل على created_at لأن "لا نحتاجه." بعد ثلاثة أشهر، احتجنا لتصحيح مشكلة ولم يكن لدينا أي فكرة متى أُنشئت السجلات. إضافته بأثر رجعي يعني أن جميع الصفوف الموجودة حصلت على نفس الطابع الزمني (طابع الترحيل)، مما جعل البيانات التاريخية عديمة الفائدة.
كل جدول يحصل على created_at. بدون استثناءات. التكلفة هي عمود واحد. تكلفة عدم وجوده غير معروفة حتى تحتاجه.
وضع منطق الأعمال في عروض قاعدة البيانات#
ذات مرة أنشأت سلسلة عروض — active_users تُفلتر users، و premium_active_users تُفلتر active_users، وعرض تقارير يربط جميعها. عمل بشكل رائع حتى غيّر شخص ما جدول users وانكسرت العروض الثلاثة بصمت. مُحسّن الاستعلامات أيضًا واجه صعوبة في التحسين عبر طبقات العروض المتعددة.
العروض رائعة للراحة وأنماط الوصول للقراءة فقط. إنها سيئة كمكان لتشفير منطق الأعمال المتغير. أبقِ قواعد الأعمال في كود التطبيق حيث تكون مُصدّرة، ومُختبرة، وقابلة للنشر. استخدم العروض كاختصارات تقارير، وليس كلبنات بناء معمارية.
تجميع كل شيء معًا#
هذا ما يبدو عليه مخطط مصمم جيدًا عندما تجمع هذه الأنماط. نظام إدارة مشاريع مبسّط:
-- Extensions
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- for gen_random_uuid()
-- Updated_at trigger function (reusable)
CREATE OR REPLACE FUNCTION update_updated_at_column()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Tenants
CREATE TABLE tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL CHECK (LENGTH(name) > 0),
slug TEXT NOT NULL UNIQUE CHECK (slug ~ '^[a-z0-9-]+$'),
plan TEXT NOT NULL DEFAULT 'free'
CHECK (plan IN ('free', 'pro', 'enterprise')),
settings JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TRIGGER set_updated_at BEFORE UPDATE ON tenants
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Users
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
email TEXT NOT NULL,
display_name TEXT NOT NULL CHECK (LENGTH(display_name) > 0),
password_hash TEXT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT true,
is_verified BOOLEAN NOT NULL DEFAULT false,
last_login_at TIMESTAMPTZ,
deleted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_users_email_per_tenant
ON users (tenant_id, email)
WHERE deleted_at IS NULL;
CREATE INDEX idx_users_tenant ON users (tenant_id) WHERE deleted_at IS NULL;
CREATE TRIGGER set_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
-- Enable RLS
ALTER TABLE users ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON users
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
-- Projects
CREATE TABLE projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
name TEXT NOT NULL CHECK (LENGTH(name) > 0),
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'archived', 'deleted')),
owner_id UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_projects_tenant ON projects (tenant_id);
CREATE INDEX idx_projects_owner ON projects (owner_id);
CREATE TRIGGER set_updated_at BEFORE UPDATE ON projects
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON projects
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
-- Tasks
CREATE TABLE tasks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
title TEXT NOT NULL CHECK (LENGTH(title) > 0),
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'todo'
CHECK (status IN ('todo', 'in_progress', 'review', 'done')),
priority INTEGER NOT NULL DEFAULT 0 CHECK (priority BETWEEN 0 AND 4),
assignee_id UUID REFERENCES users(id) ON DELETE SET NULL,
due_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_tasks_tenant_project ON tasks (tenant_id, project_id);
CREATE INDEX idx_tasks_assignee ON tasks (assignee_id) WHERE assignee_id IS NOT NULL;
CREATE INDEX idx_tasks_status ON tasks (tenant_id, status) WHERE status != 'done';
CREATE TRIGGER set_updated_at BEFORE UPDATE ON tasks
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
ALTER TABLE tasks ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON tasks
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
-- Task comments
CREATE TABLE task_comments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id),
task_id UUID NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
author_id UUID NOT NULL REFERENCES users(id),
body TEXT NOT NULL CHECK (LENGTH(body) > 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_task_comments_task ON task_comments (task_id);
CREATE TRIGGER set_updated_at BEFORE UPDATE ON task_comments
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
ALTER TABLE task_comments ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON task_comments
USING (tenant_id = current_setting('app.current_tenant_id')::UUID);
-- Audit log (no RLS — admin-only table)
CREATE TABLE audit_log (
id BIGSERIAL PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES tenants(id),
table_name TEXT NOT NULL,
record_id TEXT NOT NULL,
operation TEXT NOT NULL CHECK (operation IN ('INSERT', 'UPDATE', 'DELETE')),
old_values JSONB,
new_values JSONB,
changed_fields TEXT[],
changed_by UUID REFERENCES users(id),
changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_audit_log_tenant_table ON audit_log (tenant_id, table_name, record_id);
CREATE INDEX idx_audit_log_changed_at ON audit_log (changed_at);لاحظ الأنماط:
- كل جدول لديه
tenant_idو RLS مفعّل (باستثناءtenantsوaudit_log). - كل جدول لديه
created_atوupdated_atمع مشغّلات. - UUIDs لجميع المفاتيح الأساسية (الكيانات الموجهة خارجيًا).
- قيود فحص على تعدادات الحالة، والأطوال، والنطاقات.
- مفاتيح أجنبية مع سلوك
ON DELETEمناسب. - فهارس مصممة لأنماط الوصول (مستأجر + مشروع، مستأجر + حالة).
- حذف ناعم فقط على
users(حيث استعادة الحساب مطلوبة)، أرشفة قائمة على الحالة للمشاريعprojects.
أفكار ختامية#
تصميم المخطط ليس مُبهرًا. لم يقدّم أحد أبدًا محاضرة مؤتمر بعنوان "أضفت قيود فحص لكل جدول ووفّرت ستة أشهر من التصحيح." لكن هذا بالضبط ما يفعله تصميم المخطط الجيد — يمنع المشاكل بهدوء لدرجة أنك لا تعرف حتى أنها كانت ستوجد.
الأنماط في هذا المقال ليست مبتكرة. إنها نتيجة سنوات من كتابة الترحيلات، وتصحيح فساد البيانات، وإعادة هيكلة المخططات تحت حمل الإنتاج. كل واحد منها موجود لأنني، أو شخص عملت معه، فعلها بالطريقة الأخرى أولًا ودفع الثمن.
ابدأ بالقيود. استخدم المفاتيح الأجنبية. أضف created_at لكل شيء. اختر اصطلاح تسمية وطبّقه بلا هوادة. استخدم RLS لتعدد المستأجرين. كن حذرًا مع JSONB. اختبر ترحيلاتك على بيانات بحجم الإنتاج قبل نشرها.
قاعدة البيانات هي الأساس. إذا أصبتها، كل ما يُبنى فوقها يصبح أبسط. إذا أخطأت فيها، لا يمكن لأي كمية من كود التطبيق الذكي أن تنقذك.