الجداول المتكررة والمناطق الزمنية في PostgreSQL: أنماط
تعرّف على الجداول المتكررة والمناطق الزمنية في PostgreSQL مع صيغ تخزين عملية، قواعد تكرار، استثناءات وأنماط استعلام تبقي التقاويم صحيحة.

لماذا تفشل الأحداث المتكررة والمناطق الزمنية أحياناً
معظم أخطاء التقويم ليست أخطاء حسابية بحتة، بل أخطاء في المعنى. تخزّن شيئاً واحداً (لحظة زمنية مطلقة)، بينما يتوقع المستخدمون شيئاً آخر (وقت على الساعة المحلي في مكان معين). هذه الفجوة هي سبب أن الجداول المتكررة والمناطق الزمنية قد تبدو صحيحة في الاختبارات ثم تنهار عند ظهور المستخدمين الحقيقيين.
التوقيت الصيفي (DST) هو المحفز الكلاسيكي. تحويل مثل "كل أحد الساعة 09:00" ليس هو نفسه "كل 7 أيام من طابع زمني بداية". عندما يتغير التعويض، تنجرف هاتان الفكرتان بساعة ويصبح تقويمك خاطئًا بصمت.
السفر والمناطق الزمنية المختلطة يضيفان طبقة أخرى. قد يرتبط حجز بمكان فعلي (كرسي صالون في شيكاغو)، بينما المشاهِد في لندن. إذا تعاملت مع جدول مرتبط بالمكان كما لو كان مرتبطاً بالشخص، فستعرض وقتاً محلياً خاطئاً على أحد الطرفين على الأقل.
أوضاع الفشل الشائعة:
- تولّد التكرارات بإضافة فترة زمنية إلى طابع زمني مخزون، ثم يتغير التوقيت الصيفي.
- تخزّن "أوقات محلية" بدون قواعد المنطقة، فلا يمكنك إعادة بناء اللحظات المقصودة لاحقاً.
- تختبر فقط تواريخ لا تعبر حدود التوقيت الصيفي.
- تخلط "منطقة حدث" و"منطقة المستخدم" و"منطقة الخادم" في استعلام واحد.
قبل اختيار المخطط، قرّر ماذا يعني "صحيح" لمنتجك.
بالنسبة للحجز، عادةً ما يعني "صحيح": يحدث الموعد في وقت الساعة المقصود في منطقة زمنية المكان، ويحصل كل من يعرضه على تحويل صحيح.
بالنسبة للنوبة، غالباً ما يعني: تبدأ النوبة في وقت محلي ثابت للمتجر، حتى لو كان الموظف مسافراً.
هذا القرار الواحد (جدولة مرتبطة بالمكان مقابل الشخص) يحدد كل شيء: ما الذي تخزنه، كيف تولد التكرارات، وكيف تستعلم عن عرض التقويم بدون مفاجآت بساعة.
اختر النموذج الذهني الصحيح: لحظة مطلقة أم وقت محلي
كثير من الأخطاء تنبع من خلط فكرتين زمنيتين:
- لحظة (instant): لحظة مطلقة تحدث مرة واحدة.
- قاعدة وقت محلي: وقت على الساعة مثل "كل اثنين الساعة 9:00 صباحاً في باريس".
اللحظة متطابقة في كل مكان. "2026-03-10 14:00 UTC" هي لحظة. المكالمات المرئية، إقلاع الرحلات، و"أرسل هذا الإشعار في هذه اللحظة بالضبط" عادةً تكون لحظات.
الوقت المحلي هو ما يقرأه الناس على الساعة في مكان ما. "9:00 صباحاً في Europe/Paris كل يوم عمل" هو وقت محلي. ساعات المتجر، الدورات المتكررة، ونوبات الموظفين عادةً ثابتة بالنسبة لمنطقة المكان. المنطقة الزمنية جزء من المعنى، ليست تفضيلاً للعرض.
قاعدة بسيطة:
- خزّن
start/endكـ لحظات (timestamptz) عندما يجب أن يحدث الحدث في لحظة حقيقية واحدة عالمياً. - خزّن التاريخ والوقت المحلي مع معرف المنطقة (
tzid) عندما المقصود أن يتبع الحدث الساعة المحلية لمكان واحد. - إذا سافر المستخدمون، اعرض الأوقات في منطقة المشاهد، لكن احتفظ بالجدول مرتبطاً بمنطقته.
- لا تخمن المنطقة من تعويض مثل "+02:00". التعويضات لا تتضمن قواعد التوقيت الصيفي.
مثال: نوبة مستشفى هي "من الإثنين إلى الجمعة 09:00-17:00 America/New_York". في أسبوع تغيير التوقيت الصيفي، تظل النوبة 9 إلى 5 محلياً، رغم أن اللحظات UTC تتحرك بساعة.
أنواع PostgreSQL المهمة (وماذا تتجنّب)
معظم أخطاء التقويم تبدأ بنوع عمود خاطئ. المفتاح هو الفصل بين لحظة حقيقية وتوقع الساعة المحلية.
استخدم timestamptz للحظات الحقيقية: الحجوزات، تسجيل الدخول بالساعة، الإشعارات، وأي شيء تقارنه عبر مستخدمين أو مناطق. PostgreSQL يخزّنه كلحظة مطلقة ويحوّله للعرض، لذا الترتيب والتحقق من التداخل يعملان كما تتوقع.
استخدم timestamp without time zone لقيم الساعة المحلية التي ليست لحظات بحد ذاتها، مثل "كل اثنين الساعة 09:00" أو "يفتح المتجر في 10:00". اقترنها بمعرف المنطقة عند الحاجة ثم حوّل إلى لحظة فعلية فقط عند توليد الحدوث.
للقواعد المتكررة، الأنواع الأساسية المساعدة:
dateللحالات الاستثنائية القائمة على اليوم (عطلات)timeلوقت بداية يوميintervalلمدة الحدث (مثل نوبة 6 ساعات)
خزّن المنطقة كاسم IANA (على سبيل المثال America/New_York) في عمود text (أو جدول بحث صغير). التعويضات مثل -0500 غير كافية لأنها لا تحتفظ بقواعد التوقيت الصيفي.
مجموعة عملية للعديد من التطبيقات:
timestamptzلبدء/انتهاء الحجوزات كنقاط زمنيةdateلأيام الاستثناءtimeلبدء محلي متكررintervalللمدةtextلمعرف المنطقة IANA
خيارات نموذج البيانات لتطبيقات الحجز والمناوبات
المخطط الأفضل يعتمد على مدى تغير الجداول وعدد الأيام التي يتصفحها الناس للأمام. أنت عادة تختار بين كتابة الكثير من الصفوف مقدمًا أو توليدها عند عرض التقويم.
الخيار A: خزّن كل حدوث منفرد
أدرج صفًا لكل نوبة أو حجز (موسع بالفعل). من السهل الاستعلام عنه والتعامل معه. المقايضة هي عمليات كتابة كبيرة والكثير من التحديثات عند تغيير قاعدة.
ينجح هذا عندما تكون الأحداث في الغالب فردية، أو عندما تنشئ الحدوث فقط لفترة قصيرة مقدمًا (مثلاً الثلاثين يومًا القادمة).
الخيار B: خزّن قاعدة ووسّع عند القراءة
خزّن قاعدة جدول (مثل "أسبوعيًا الإثنين والأربعاء الساعة 09:00 في America/New_York") وولّد الحوادث للنطاق المطلوب عند الطلب.
مرن وخفيف التخزين، لكن الاستعلامات تصبح أكثر تعقيداً. عرض شهري قد يصبح أبطأ ما لم تخزّن نتائج مؤقتة.
الخيار C: قاعدة مع حوادث مخزنة مؤقتاً (هجينة)
احتفظ بالقاعدة كمصدر للحقيقة، وخزّن أيضاً الحوادث المولّدة لنافذة متداولة (مثلاً 60-90 يوماً). عندما تتغير القاعدة، أعد توليد الكاش.
هذا خيار افتراضي قوي لتطبيقات المناوبات: تبقى عروض الشهر سريعة، لكن لديك مكان واحد لتحرير النمط.
مجموعة جداول عملية:
- schedule: المالك/المورد، المنطقة الزمنية، وقت البدء المحلي، المدة، قاعدة التكرار
- occurrence: حالات موسعة بـ
start_at timestamptz,end_at timestamptz, وحقل الحالة - exception: علامات "تخطي هذا التاريخ" أو "هذا التاريخ مختلف"
- override: تعديلات لكل حدوث مثل تغيير وقت البداية، تبديل موظف، علم الإلغاء
- (اختياري) schedule_cache_state: آخر نطاق مولَّد لتعرف ماذا تملأ لاحقاً
لاستعلامات نطاق التقويم، إиндكس للحصول على “كل شيء في هذه النافذة”:
- على occurrence:
btree (resource_id, start_at)وغالباًbtree (resource_id, end_at) - إذا كنت تستعلم كثيراً عن "يتداخل مع النطاق": عمود مولَّد
tstzrange(start_at, end_at)مع فهرسgist
تمثيل قواعد التكرار بدون جعلها هشة
الجداول المتكررة تنهار عندما تكون القاعدة ذكية جداً أو مرنة جداً أو مخزنة ككتلة لا يمكن الاستعلام عليها. تنسيق قاعدة جيد هو الذي يمكن لتطبيقك التحقق منه ويمكن للفريق شرحه بسرعة.
نهجان شائعان:
- حقول مخصصة بسيطة للأنماط التي تدعمها فعلاً (نوبات أسبوعية، تواريخ فواتير شهرية).
- قواعد شبيهة بـ iCalendar (أسلوب RRULE) عندما تحتاج إلى استيراد/تصدير تقاويم أو دعم تركيبات كثيرة.
تسوية عملية: اسمح بمجموعة محدودة من الخيارات، خزّنها في أعمدة، واعتبر أي سلسلة RRULE للتبادل فقط.
مثال: يمكن التعبير عن قاعدة نوبة أسبوعية بحقول مثل:
freq(يومي/أسبوعي/شهري) وinterval(كل N)byweekday(مصفوفة 0-6 أو قناع بت)- اختياري
bymonthday(1-31) للقواعد الشهرية starts_at_local(التاريخ+الوقت المحلي الذي اختاره المستخدم) وtzid- اختياري
until_dateأوcount(تجنّب دعم كلاهما إلا إذا احتجت فعلاً)
للحواجز، فضّل تخزين المدة (مثلاً 8 ساعات) بدل تخزين وقت النهاية لكل حدث. المدة تبقى ثابتة عند تحول الساعة. يمكنك حساب وقت النهاية لكل حدث كـ: بداية الحدوث + المدة.
عند توسيع قاعدة، اجعلها آمنة ومقيدة:
- وسّع فقط داخل
window_startوwindow_end. - أضف هامشاً صغيراً (مثلاً يوم واحد) للأحداث الليلية.
- توقف بعد عدد أقصى من الحوادث (مثل 500).
- صفّ المرشحين أولاً (بـ
tzid,freq, وتاريخ البدء) قبل التوليد.
خطوة بخطوة: بناء جدول متكرر آمن من تأثير التوقيت الصيفي
نمط موثوق: اعتبر كل حدوث كفكرة تقويمية محلية أولاً (تاريخ + وقت محلي + منطقة زمنية المكان)، ثم حوّل إلى لحظة فقط عندما تحتاج للترتيب، فحوص التعارض، أو العرض.
1) خزّن النية المحلية، لا تخمن UTC
احفظ منطقة الجدول (اسم IANA مثل America/New_York) بالإضافة إلى وقت البداية المحلي (مثلاً 09:00). هذا الوقت المحلي هو ما يعنيه العمل حتى حين يتغير التوقيت الصيفي.
اخزّن أيضاً مدة وحدود واضحة للقاعدة: تاريخ بداية، وإما تاريخ نهاية أو عدد تكرارات. الحدود تمنع أخطاء "التوسع إلى ما لا نهاية".
2) نمذج الاستثناءات والتعديلات بشكل منفصل
استخدم جدولين صغيرين: واحد للتواريخ التي يجب تخطيها، وآخر للحوادث المعدلة. قم بمفتاحهما عبر schedule_id + local_date حتى تتطابق مع التكرار الأصلي بوضوح.
شكل عملي:
-- core schedule
-- tz is the location time zone
-- start_time is local wall-clock time
schedule(id, tz text, start_date date, end_date date, start_time time, duration_mins int, by_dow int[])
schedule_skip(schedule_id, local_date date)
schedule_override(schedule_id, local_date date, new_start_time time, new_duration_mins int)
3) وسّع فقط ضمن النافذة المطلوبة
ولّد تواريخ محلية مرشحة للنطاق الذي تعرضه (أسبوع، شهر). صفّها حسب يوم الأسبوع، ثم طبّق التخطي والتعديلات.
WITH days AS (
SELECT d::date AS local_date
FROM generate_series($1::date, $2::date, interval '1 day') d
), base AS (
SELECT s.id, s.tz, days.local_date,
make_timestamp(extract(year from days.local_date)::int,
extract(month from days.local_date)::int,
extract(day from days.local_date)::int,
extract(hour from s.start_time)::int,
extract(minute from s.start_time)::int, 0) AS local_start
FROM schedule s
JOIN days ON days.local_date BETWEEN s.start_date AND s.end_date
WHERE extract(dow from days.local_date)::int = ANY (s.by_dow)
)
SELECT b.id,
(b.local_start AT TIME ZONE b.tz) AS start_utc
FROM base b
LEFT JOIN schedule_skip sk
ON sk.schedule_id = b.id AND sk.local_date = b.local_date
WHERE sk.schedule_id IS NULL;
4) حرّك التحويل إلى جانب العرض في النهاية
احتفظ بـ start_utc كـ timestamptz للترتيب، فحوص التعارض، والحجوزات. فقط عند العرض، حوّل إلى منطقة المشاهد. هذا يتجنّب مفاجآت التوقيت الصيفي ويبقي عروض التقويم متسقة.
أنماط الاستعلام لتوليد عرض تقويم صحيح
شاشة التقويم عادةً استعلام نطاق: "أرني كل شيء بين from_ts و to_ts." نمط آمن:
- وسّع المرشحين فقط في تلك النافذة.
- طبّق الاستثناءات/التعديلات.
- أخرج الصفوف النهائية بـ
start_atوend_atكـtimestamptz.
توسع يومي أو أسبوعي باستخدام generate_series
للقواعد الأسبوعية البسيطة (مثل "كل اثنين-جمعة الساعة 09:00 محلياً"), ولّد التواريخ المحلية في منطقة الجدول، ثم حوّل كل تاريخ محلي + وقت محلي إلى لحظة.
-- Inputs: :from_ts, :to_ts are timestamptz
-- rule.tz is an IANA zone like 'America/New_York'
WITH bounds AS (
SELECT
(:from_ts AT TIME ZONE rule.tz)::date AS from_local_date,
(:to_ts AT TIME ZONE rule.tz)::date AS to_local_date
FROM rule
WHERE rule.id = :rule_id
), days AS (
SELECT d::date AS local_date
FROM bounds, generate_series(from_local_date, to_local_date, interval '1 day') AS g(d)
)
SELECT
(local_date + rule.start_local_time) AT TIME ZONE rule.tz AS start_at,
(local_date + rule.end_local_time) AT TIME ZONE rule.tz AS end_at
FROM rule
JOIN days ON true
WHERE EXTRACT(ISODOW FROM local_date) = ANY(rule.by_isodow);
هذا يعمل جيداً لأن التحويل إلى timestamptz يحدث لكل حدث على حدة، فتُطبّق تحولات التوقيت الصيفي على اليوم الصحيح.
قواعد أكثر تعقيداً مع CTE عودي
عندما تعتمد القواعد على "النسخة الن" من يوم الأسبوع، الفجوات، أو فواصل مخصصة، يمكن لأنساق CTE العودية توليد الحدوث التالي مراراً حتى يتجاوز to_ts. أبقِ العودية مربوطة بالنافذة حتى لا تعمل إلى ما لا نهاية.
بعد الحصول على الصفوف المرشحة، طبّق التعديلات والإلغاءات بربط جداول الاستثناء على (rule_id, start_at) أو على مفتاح محلي مثل (rule_id, local_date). إذا وُجد سجل إلغاء، احذف الصف. إذا وُجد تعديل، استبدل start_at/end_at بالقيم المعدّلة.
أنماط الأداء المهمة:
- حدّ النطاق مبكراً: صفّ القواعد أولاً، ثم وسّع فقط ضمن
[from_ts, to_ts). - ضع فهارس على جداول الاستثناء/التعديل على
(rule_id, start_at)أو(rule_id, local_date). - تجنّب توسيع سنوات من البيانات لعرض شهر واحد.
- خزّن الحوادث الموسعة فقط إن استطعت إبطالها نظيفاً عند تغيير القواعد.
التعامل مع الاستثناءات والتعديلات بنظافة
الجداول المتكررة مفيدة فقط إذا استطعت كسرها بأمان. في تطبيقات الحجز والمناوبات، "الأسبوع الطبيعي" هو القاعدة الأساسية، وكل شيء آخر استثناء: عطلات، إلغاءات، مواعيد منقولة أو تبديلات موظفين. إذا أُضيفت الاستثناءات لاحقاً، تنحرف عروض التقويم وتظهر تكرارات.
احتفظ بمفاهيم ثلاثة منفصلة:
- جدول أساسي (القاعدة المتكررة ومنطقتها الزمنية)
- التخطي (تواريخ أو حالات يجب ألا تحدث)
- التعديلات (حدوث موجود لكن بتفاصيل مختلفة)
استخدم ترتيب أسبقية ثابت
اختر ترتيباً واحداً واحتفظ به. اختيار شائع:
- ولّد المرشحين من القاعدة الأساسية.
- طبّق التعديلات (استبدال الحدث المولد).
- طبّق التخطي (إخفاؤه).
تأكد أن القاعدة سهلة الشرح للمستخدم في جملة واحدة.
تجنب التكرارات عندما يستبدل تعديل حدثاً
تحدث التكرارات عادة عندما يُرجع الاستعلام الحدث المولد والمعدل معاً. امنع ذلك بمفتاح ثابت:
- أعطِ كل حدث مولّد مفتاحًا ثابتًا، مثل
(schedule_id, local_date, start_time, tzid). - خزّن ذلك المفتاح على صف التعديل كمفتاح "الحدث الأصلي".
- أضف قيد فريد حتى لا يوجد أكثر من تعديل واحد لكل حدث أساسي.
ثم، في الاستعلامات، استبعد الأحداث المولدة التي لها تعديل مطابق وادمج صفوف التعديل.
حافظ على المراجعية بدون تعقيد
الاستثناءات هي موطن النزاعات ("من غيّر نوبتي؟"). أضف حقول مراجعة أساسية على skips و overrides: created_by, created_at, updated_by, updated_at, وسبب اختياري.
أخطاء شائعة تسبب فرق ساعة
معظم أخطاء الساعة الواحدة تأتي من خلط معنيين للوقت: لحظة (نقطة على خط UTC) وقراءة ساعة محلية (مثل 09:00 كل اثنين في نيويورك).
خطأ كلاسيكي هو تخزين قاعدة وقت محلي كـ timestamptz. إن حفظت "الاثنين الساعة 09:00 America/New_York" كـ timestamptz، فقد اخترت تاريخاً محدداً (وحالة التوقيت الصيفي). لاحقاً، عند توليد اثنينات مستقبلية، يختفي المقصد الأصلي ("دائماً 09:00 محلي").
سبب متكرر آخر هو الاعتماد على تعويضات UTC ثابتة مثل -05:00 بدلاً من اسم منطقة IANA. التعويضات لا تتضمن قواعد DST. خزّن معرف المنطقة (مثل America/New_York) ودع PostgreSQL يطبّق القواعد الصحيحة لكل تاريخ.
كن حذراً من متى تقوم بالتحويل. إذا حولت إلى UTC مبكراً أثناء توليد التكرار، يمكنك تجميد تعويض DST وتطبيقه على كل الحوادث. نمط أكثر أماناً: ولّد الحوادث بالمصطلحات المحلية (تاريخ + وقت محلي + منطقة)، ثم حوّل كل حدث إلى لحظة.
أخطاء متكررة:
- استخدام
timestamptzلتخزين وقت محلي متكرر (كنت بحاجة إلىtime+tzid+ قاعدة). - خزّن تعويض فقط، وليس اسم المنطقة IANA.
- التحويل أثناء توليد التكرارات بدل التأجيل حتى النهاية.
- توسيع تكرارات "إلى الأبد" بدون نافذة زمنية محددة.
- عدم اختبار أسابيع بداية ونهاية DST.
اختبار بسيط يكشف معظم المشكلات: اختر منطقة بها DST، أنشئ نوبة أسبوعية الساعة 09:00، واعرض تقويم لشهرين يشملان تغيير DST. تحقق أن كل حدوث يظهر كـ 09:00 محلياً، رغم اختلاف اللحظات UTC.
قائمة تحقق سريعة قبل النشر
قبل الإصدار، تحقق من الأساسيات:
- كل جدول مرتبط بمكان (أو وحدة عمل) مع اسم منطقة زمنية مخزون على الجدول نفسه.
- خزّن معرفات منطقة IANA (مثل
America/New_York)، لا التعويضات الخام. - توسعات التكرار تولّد حوادث فقط داخل النطاق المطلوب.
- لدى الاستثناءات والتعديلات ترتيب أسبقية موثّق واحد.
- تختبر أسابيع تغيير DST ومشاهد يرى من منطقة زمنية مختلفة عن الجدول.
قم بتشغيل محاكاة واقعية: متجر في Europe/Berlin لديه نوبة أسبوعية الساعة 09:00، ومدير يراها من America/Los_Angeles. تأكد أن النوبة تبقى 09:00 برلين كل أسبوع حتى عندما تعبر كل منطقة تغييرات DST في تواريخ مختلفة.
مثال: منوبات أسبوعية مع عطلة وتغيير DST
عيادة صغيرة تُشغّل نوبة متكررة كل يوم اثنين 09:00-17:00 في المنطقة المحلية للعيادة (America/New_York). العيادة مغلقة في عطلة محددة يوم اثنين واحد. موظف مسافر في أوروبا لمدة أسبوعين، لكن جدول العيادة يجب أن يبقى مرتبطاً بساعة العيادة المحلية، ليس بموقع الموظف الحالي.
لجعل هذا يتصرف بشكل صحيح:
- خزّن قاعدة تكرار مرساة بالتواريخ المحلية (يوم الأسبوع = الاثنين، أوقات محلية = 09:00-17:00).
- خزّن منطقة الجدول (
America/New_York). - خزّن تاريخ بداية فعّال حتى تكون القاعدة لها مرساة واضحة.
- خزّن استثناء لإلغاء ذلك الاثنين (وتعديلات للحالات الفردية).
الآن عرض نطاق تقويم لمدة أسبوعين يشمل تغيير DST في نيويورك. يولّد الاستعلام أيام الاثنين في ذلك النطاق المحلي، يلحق بها أوقات العيادة المحلية، ثم يحوّل كل حدث إلى لحظة مطلقة (timestamptz). لأن التحويل يحدث لكل حدث على حدة، يُعالَج DST في اليوم الصحيح.
مشاهد مختلفة ترى أوقاتاً محلية مختلفة لنفس اللحظة:
- مدير في لوس أنجلوس يراها في وقت أبكر على ساعته.
- موظف مسافر في برلين يراها متأخرة على ساعته.
العيادة تحصل ما تريده: 09:00 إلى 17:00 بتوقيت نيويورك كل اثنين غير الملغي.
الخطوات التالية: نفّذ، اختبر، واحفظ القابلية للصيانة
حدّد نهجك للوقت مبكراً: هل ستخزّن قواعد فقط، حوادث فقط أم هجين؟ لكثير من منتجات الحجز والمناوبات، الهجين مناسب: احتفظ بالقاعدة كمصدر للحقيقة، خزّن كاش متداول إن لزم، وخزّن الاستثناءات والتعديلات كصفوف ملموسة.
دوّن "عقد الوقت" الخاص بك في مكان واحد: ماذا يُعد لحظة، ماذا يُعد وقتاً محلياً، وأي أعمدة تخزّن كل منهما. هذا يمنع الاختلافات حيث يعيد طرف عرض الوقت المحلي بينما يعيد الآخر UTC.
اجعل توليد التكرار وحدة واحدة، لا شظايا SQL مبعثرة. إذا غيرت يوماً تفسيرك لـ "09:00 صباحاً محلياً"، تريد مكاناً واحداً للتعديل.
إذا كنت تبني أداة جدولة بدون كتابة كل شيء يدوياً، AppMaster (appmaster.io) مناسب عملياً لهذا النوع من العمل: يمكنك تصميم قاعدة البيانات في Data Designer، بناء منطق التكرار والاستثناءات في عمليات الأعمال المرئية، وتوليد كود خلفي وتطبيقات حقيقية.


