تخزين التاريخ والوقت في قاعدة البيانات: UTC أولاً

خزّن كل لحظة وقعت فعلاً بتوقيت UTC، وخزّن كل موعد سيقع مستقبلاً وقتاً حائطياً محلياً مع معرّف منطقة زمنية من سجل IANA. هذه الإجابة شبه الكاملة. والسؤال خلفها هو ما يجب أن تطرحه على كل عمود وقت في مخططك: هل يسجّل هذا العمود لحظة وقعت فعلاً أم موعداً سيقع مستقبلاً؟
الخلط بين الحالتين مصدر معظم أخطاء التوقيت التي تظهر بعد شهور من الإطلاق، تماماً كما أن تخزين المبالغ المالية بعمود FLOAT يعمل طويلاً ثم يفشل فجأة. بقية المقال تفصّل متى ينطبق كل شقّ من القاعدة، وكيف تنفّذها في MySQL وPostgreSQL وSQLite وفي حقول JSON.
«كل شيء UTC» تصلح للماضي وتخذل المواعيد
اللحظة التي وقعت — دفعة أُنشئت، مستخدم سجّل دخوله، طلب أُلغي — نقطة واحدة محددة على خط الزمن العالمي، وUTC يصفها بلا لبس. أما الموعد المستقبلي فليس نقطة على خط الزمن، بل وعد مصوغ بقواعد محلية: حين يحجز مستخدم اجتماعاً الساعة 9 صباحاً بتوقيت عمّان بعد شهرين، فالذي قصده هو التاسعة على حائط مكتبه أياً كانت إزاحة الأردن عن UTC يومها.
الممارسة الراسخة هنا: احفظ ما أدخله المستخدم — الوقت الحائطي ومعرّف المنطقة مثل Asia/Amman — بوصفه مصدر الحقيقة، واجعل UTC قيمة مشتقة يُعاد حسابها مع كل تحديث لسجل المناطق الزمنية. وليست هذه فرضية احترازية؛ فالحكومات تعيد كتابة هذه القواعد بالفعل، والقسم التالي يسمّي من فعلها في المنطقة العربية ومتى.
قرار حكومي واحد أزاح الاجتماعات ساعة كاملة
سجل IANA يُحدَّث باستمرار لملاحقة قرارات الحكومات، وآخر إصداراته 2026c صدر في 2026-07-08. والمنطقة العربية من أنشط مناطقه: في الإصدار 2022e ألغى الأردن وسوريا التوقيت الشتوي واستقرّا على +03 دائماً، أي أن الساعة لم تتأخر في 2022-10-28 كما كان مقرراً. ومصر أعادت التوقيت الصيفي في الإصدار 2023a بدءاً من 2023، من آخر جمعة في أبريل إلى آخر خميس في أكتوبر. وفلسطين عدّلت مواعيده أكثر من مرة لارتباطها برمضان.
مثال يجسّد الخلط: اجتماع في الأردن حُجز قبل 2022-10-05 لموعد يقع بعد 2022-10-28، وخُزّن بقيمة UTC محسوبة على افتراض العودة إلى +02. حين ألغى الأردن التوقيت الشتوي انزاح الاجتماع ساعة كاملة عن الموعد الذي قصده صاحبه، من دون أن يتغير سطر واحد في الكود. لو خُزّن الوقت الحائطي مع Asia/Amman لأعاد النظام اشتقاق UTC الصحيح بعد تحديث السجل وانتهت المشكلة.
اعتياد +03:00 الثابتة أخطر عادة يكتسبها مطوّر خليجي
منطقة Asia/Riyadh على +03 طوال العام بلا توقيت صيفي. هذا الثبات يغري مطوّري الخليج بكتابة الإزاحة +03:00 مباشرة في الكود بدل معرّف المنطقة، والتطبيق يعمل بلا مشاكل ما دام مستخدموه في السعودية وجوارها. ثم يفتح السوق المصري — حيث عاد التوقيت الصيفي منذ 2023 — فتنحرف الأوقات المعروضة، أو يخدم مستخدماً في عمّان بقواعد ما قبل 2022 فيخطئ ساعة كاملة. الفارق الجوهري: الإزاحة تصف لحظة واحدة، أما معرّف المنطقة فيصف القواعد كلها بماضيها ومستقبلها.
TIMESTAMP في MySQL يحوّل خلف ظهرك ويتوقف عند عام 2038
في MySQL نوعان متشابهان في الشكل مختلفان في السلوك. TIMESTAMP لا يحفظ ما تكتبه، بل ناتج تحويله من منطقة الجلسة الزمنية إلى UTC، ثم يعكس التحويل عند كل قراءة؛ أما DATETIME فيحفظ الأرقام التي كتبتها حرفياً ولا يمسّها بأي تحويل. والمدى مختلف جذرياً: DATETIME يمتد من عام 1000 إلى 9999، أما TIMESTAMP فسقفه 2038-01-19 03:14:07 بتوقيت UTC — آخر ثانية يستوعبها عدّاد موقّع من 32 بت لثواني حقبة يونكس، أي 2,147,483,647 ثانية. هذه هي مشكلة عام 2038 بعينها، وحتى MySQL 9.x لم يوسّع المدى، فأي عمود TIMESTAMP تنشئه اليوم دينٌ مؤجل. في التخزين يستهلك TIMESTAMP أربعة بايتات وDATETIME خمسة، وكلاهما يدعم الثواني الكسرية حتى 6 أرقام.
الفخ الأكبر أن القيمة الافتراضية لـ time_zone هي SYSTEM. سيناريو متكرر: خادم محلي على +03 يكتب NOW() في عمود DATETIME لسنوات، ثم يُنقل النظام إلى خادم سحابي على UTC، فتنزاح الصفوف الجديدة 3 ساعات عن القديمة ويصبح العمود نفسه خليطاً صامتاً من منطقتين لا سبيل إلى تمييزهما لاحقاً. الحل: ثبّت منطقة الجلسة صراحة، أو اكتب UTC_TIMESTAMP() بنفسك بدل NOW()، ولا تخلط بين النهجين أبداً.
وإن أردت التحويل بأسماء المناطق مثل Asia/Riyadh داخل MySQL عبر CONVERT_TZ، فجداول المناطق الزمنية فارغة افتراضياً وتحتاج إلى تحميل:
mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root -p mysql
وبعد كل تحديث لهذه الجداول يجب إعادة تشغيل mysqld لأنه يخزّن بيانات المناطق مؤقتاً في الذاكرة.
timestamptz في PostgreSQL لا يحفظ منطقتك — والتوثيق يقولها صراحة
توثيق PostgreSQL واضح: timestamptz يخزّن القيمة داخلياً بتوقيت UTC، والمنطقة الأصلية التي أُدخلت بها القيمة لا يُحتفظ بها، والعرض يجري دائماً بمنطقة الجلسة. من يفترض أن النوع «يتذكّر» أن المستخدم أدخل 9 صباحاً بتوقيت الرياض سيُفاجأ؛ إن احتجت إلى العرض بمنطقة المستخدم فخزّن معرّف منطقته في عمود نصي مستقل.
ومع ذلك فالتوصية الرسمية حاسمة: صفحة «Don’t Do This» في موقع PostgreSQL تنص حرفياً على ألا تستخدم timestamp بلا منطقة زمنية، لأن الحساب عبر مواقع مختلفة أو عبر حدود التوقيت الصيفي يعطي نتائج خاطئة. والنوعان متساويان كلفةً: 8 بايتات، دقة ميكروثانية، ومدى من 4713 قبل الميلاد إلى عام 294276 — فلا مشكلة 2038 هنا ولا ثمن إضافي لاختيار timestamptz.
التحويل الصريح يمر عبر AT TIME ZONE:
SELECT timestamp '2001-02-16 20:38:40' AT TIME ZONE 'America/Denver';
يعيد هذا المثال من التوثيق 2001-02-17 03:38:40+00 من نوع timestamptz.
ولاحظ سلوكاً مقصوداً: now() وCURRENT_TIMESTAMP تعيدان الوقت مجمّداً عند بداية المعاملة لا عند كل جملة، حتى تحمل كتابات المعاملة الواحدة طابعاً زمنياً واحداً — وهي فلسفة الاتساق نفسها التي فصّلناها في مستويات العزل في قواعد البيانات.
SQLite وJSON يتركانك بلا نوع تاريخ أصلاً
توثيق SQLite يقولها بلا مواربة: لا توجد فئة تخزين مخصصة للتواريخ والأوقات. أمامك ثلاث صيغ: نص بصيغة ISO 8601 على شكل YYYY-MM-DD HH:MM:SS.SSS، أو رقم اليوم الجولياني في REAL، أو ثواني حقبة يونكس في INTEGER. اختر واحدة والتزمها في المشروع كله، والنص بصيغة ISO 8601 المثبّت على UTC هو الأسهل قراءةً وفرزاً.
وJSON بدوره لا يعرف نوع تاريخ؛ معيار RFC 8259 يحصر القيم في الكائن والمصفوفة والرقم والنص والقيم المنطقية وnull. المعيار العملي لحقول الوقت النصية هو RFC 3339، وهو صيغة مقيّدة من ISO 8601 على شكل 1985-04-12T23:20:50.52Z، وتعني اللاحقة Z فيها إزاحة صفرية عن UTC. ودالة toISOString() في JavaScript تعيد UTC دائماً بصيغة مثل 2011-10-05T14:48:00.000Z.
البديل الشائع عدد صحيح بحقبة يونكس، كما في حقل created لدى Stripe المحسوب بالثواني، بقيمة فعلية مثل 1679090539. وهنا فخ الوحدات: Stripe بالثواني وJavaScript بالميلي ثانية، وتمرير إحداهما مكان الأخرى يظهر فوراً بعرَضين مميزين — تواريخ في يناير 1970، أو سنوات بعيدة بعشرات آلاف السنين.
كل عمود وقت في سطر واحد
هذه القرارات تُتخذ مرة واحدة عند تصميم المخطط ويصعب التراجع عنها، شأنها شأن اختيار المفتاح الأساسي بين UUID وBIGINT:
| ماذا يسجّل العمود؟ | النوع الأنسب | القاعدة الحاكمة |
|---|---|---|
| لحظة وقعت: إنشاء طلب، دفعة، تسجيل دخول | timestamptz في PostgreSQL، أو DATETIME بقيم UTC_TIMESTAMP() في MySQL | UTC للتخزين دائماً، والتحويل للعرض فقط |
| موعد مستقبلي: اجتماع، رحلة، تذكير | وقت حائطي + معرّف IANA مثل Asia/Amman في عمود نصي مجاور | UTC قيمة مشتقة يُعاد حسابها عند كل تحديث للسجل |
| حقل وقت في JSON | نص بصيغة RFC 3339 ينتهي بـ Z، أو عدد صحيح بثواني حقبة يونكس | وثّق الوحدة صراحة: ثوانٍ أم ميلي ثانية |
| تاريخ بلا وقت: ميلاد، استحقاق فاتورة | DATE | لا تقحم منطقة زمنية فيما لا ساعة له |
| أي وقت في SQLite | TEXT بصيغة ISO 8601 مثبّتة على UTC | لا نوع مخصص، فالانضباط مسؤولية طبقة التطبيق |
قبل أول CREATE TABLE وبعد آخر ترحيل
في المشاريع الجديدة القرار مباشر: timestamptz في PostgreSQL لكل ما وقع، وDATETIME تكتبه حصراً بقيم UTC في MySQL مع تثبيت منطقة الجلسة صراحة، والمواعيد المستقبلية وقت حائطي مع معرّفها في عمود نصي. أما الأنظمة القائمة فافحصها بثلاثة أسئلة: بأي منطقة جلسة كُتبت الأعمدة القديمة، وهل ما زالت SYSTEM قابلةً للانزلاق عند نقل الخادم؟ وهل توجد إزاحة +03:00 مكتوبة يدوياً في الكود، وماذا سيرى مستخدم في القاهرة صيفاً؟ وهل من موعد مستقبلي مخزّن بقيمة UTC وحدها سيتزحزح مع أول قرار حكومي؟
الإجابة عن هذه الأسئلة اليوم أرخص كثيراً من ترحيل عمود يخلط منطقتين بعد سنتين في الإنتاج. والقاعدة كلها تُطوى في سطر يستحق أن يتصدّر ملف المخطط: الماضي لحظة فخُذ UTC، والمستقبل وعد فاحفظ الوعد بنصّه.