مرحبا ، راديو مزود هو على الهواء مرة أخرى! لدينا اليوم حلاً للمشكلة التي نقلناها في بثنا السابق ، ووعدنا بحلها في المرة القادمة. وهذه المرة القادمة قد حان.
أثارت المهمة استجابة حية بين البشر في مجرة درب التبانة (وليس من المستغرب ، مع عبودية العمل التي ما زالوا يحترمونها لصالح الحضارة). لسوء الحظ ، على الكوكب الثالث ، تم تأجيل إطلاق مرصد الفضاء Spektr-RG في نهاية يوليو 2019 ، RC (التسلسل الزمني المحلي) ، بمساعدة التي كان من المخطط لها بث هذا البرنامج. اضطررت للبحث عن طرق نقل بديلة ، مما أدى إلى تأخير بسيط في الإشارة. ولكن كل شيء على ما يرام ينتهي بشكل جيد.
يجب أن أقول على الفور أنه لن يكون هناك سحر في تحليل المهمة ، ليست هناك حاجة للبحث عن الوحي هنا أو انتظار التنفيذ الفعال بشكل خاص (أو لا سيما في أي شيء آخر). هذه مجرد مهمة تحليل. في ذلك ، سيتمكن أولئك الذين لا يعرفون كيفية التعامل مع حل هذه المشكلات من معرفة كيفية حلها. علاوة على ذلك ، لا يوجد شيء فظيع هنا.
اسمحوا لي أن أذكرك الشرط.هناك العديد من الفواصل الزمنية المحددة بواسطة التاريخ والوقت لبداية ونهاية (مثال في بناء جملة PostgreSQL):
with periods(id, start_time, stop_time) as ( values(1, '2019-03-29 07:00:00'::timestamp, '2019-04-08 14:00:00'::timestamp), (2, '2019-04-10 07:00:00'::timestamp, '2019-04-10 20:00:00'::timestamp), (3, '2019-04-11 12:00:00'::timestamp, '2019-04-12 16:07:12'::timestamp), (4, '2018-12-28 12:00:00'::timestamp, '2019-01-16 16:00:00'::timestamp) )
مطلوب في استعلام SQL واحد (ج) لحساب مدة كل فاصل زمني في ساعات العمل. نعتقد أننا نعمل في أيام الأسبوع من الاثنين إلى الجمعة ، وتكون ساعات العمل دائمًا من الساعة 10:00 إلى الساعة 19:00. بالإضافة إلى ذلك ، وفقًا لجدول الإنتاج في الاتحاد الروسي ، هناك عدد من أيام العطل الرسمية التي لا تعمل ، وبعض أيام العطلة ، على العكس من ذلك ، هي أيام عمل بسبب تأجيل تلك العطلات نفسها. تقصير أيام ما قبل العطلة ليس ضروريًا ، فنحن نعتبرها كاملة. نظرًا لاختلاف العطلات من عام إلى آخر ، أي ، يتم تعيينها من خلال قائمة صريحة ، سنحصر أنفسنا في التواريخ فقط من 2018 و 2019. أنا متأكد من أنه ، إذا لزم الأمر ، يمكن استكمال الحل بسهولة.
من الضروري إضافة عمود واحد مع المدة في ساعات العمل إلى الفترات الأولية من الفترات . هذه هي النتيجة:
id | start_time | stop_time | work_hrs
لا نتحقق من البيانات الأولية للتأكد من صحتها ؛ فنحن دائمًا نعتبر start_time <= stop_time .
نهاية الشرط ، النسخة الأصلية هنا: https://habr.com/en/company/postgrespro/blog/448368/ .
تضفي المهمة درجة طفيفة على حقيقة أنني قد أعطيت عن وعي نصفًا جيدًا من الحالة في شكل وصفي (كما هو الحال عادةً في الحياة الحقيقية) ، تاركًا لتقدير التنفيذ الفني كيفية تحديد جدول العمل. من ناحية ، هذا يتطلب بعض مهارات التفكير المعماري. ومن ناحية أخرى ، كان التنسيق الجاهز لهذا الجدول قد دفع بعض القوالب إلى استخدامه. وإذا حذفت ، فالفكر والخيال سيعملان بشكل كامل. لقد أثمر الاستقبال تمامًا ، مما سمح لي أيضًا بإيجاد طرق مثيرة للاهتمام في الحلول المنشورة.
لذلك ، لحل المشكلة الأصلية بهذه الطريقة ، ستحتاج إلى حل مهمتين فرعيتين:
- حدد كيفية تعيين جدول عمل بشكل مضغوط ، وحتى يكون مناسبًا للاستخدام لحل.
- احسب فعليًا مدة كل فترة مصدر في ساعات العمل وفقًا لجدول العمل من المهمة الفرعية السابقة.
ومن الأفضل أن نبدأ بالشكل الثاني ، من أجل أن نفهم في الشكل الذي نحتاج إلى حل الأول. ثم حل الأول والعودة مرة أخرى إلى الثانية من أجل الحصول على النتيجة النهائية.
سنجمع النتيجة تدريجيًا ، باستخدام بناء جملة CTE ، والذي يسمح لنا بوضع جميع عينات البيانات الضرورية في استعلامات فرعية منفصلة ، ثم ربط كل شيء معًا.
حسنا ، دعنا نذهب.
احسب المدة في ساعات العمل
لحساب مدة كل فترة من الفترات في ساعات العمل في الجبهة ، تحتاج إلى عبور الفترة الأولية (اللون الأخضر على الرسم التخطيطي) مع الفواصل الزمنية التي تصف وقت العمل (البرتقالي). الفواصل الزمنية لساعات العمل هي من الاثنين 10:00 حتي 19:00 ، الثلاثاء من 10:00 حتي 19:00 وهلم جرا. تظهر النتيجة باللون الأزرق:

بالمناسبة ، لكي أكون أقل تشويشًا ، سأستمر في الإشارة إلى الفترات الأولية باعتبارها فترات أولية ، وسأتصل بفواصل ساعات العمل.
يجب تكرار الإجراء لكل فترة أولية. تم تحديد الفترات الأولية لنا بالفعل في علامة تبويب الفترات (start_time ، stop_time) ، سنعرض ساعات العمل في شكل جدول ، على سبيل المثال ، جدول (strat_time ، stop_time) ، حيث يوجد كل يوم عمل. والنتيجة هي منتج ديكارت كامل لجميع الفترات والفترات الأولية من وقت العمل.
يمكن حساب التقاطعات بالطريقة الكلاسيكية ، بعد النظر في جميع الخيارات الممكنة لفترات التقاطع - نتقاطع مع اللون الأخضر مع اللون البرتقالي ، والنتيجة هي اللون الأزرق:

مع الأخذ في كل حالة القيمة المطلوبة لبداية ونهاية النتيجة:
select s.start_time, s.stop_time
نظرًا لأن كل تقاطع لا يمكن أن يكون لدينا سوى خيار واحد من أربعة خيارات ، يتم دمجها جميعًا في طلب واحد باستخدام الاتحاد الكل .
يمكنك القيام بخلاف ذلك باستخدام نوع النطاق tsrange المتاح في PostgreSQL وعملية التقاطع المتوفرة بالفعل:
select tsrange(s.start_time, s.stop_time) * tsrange(s.start_time, s.stop_time) from periods p, schedule s
أوافق على ذلك - اه - أسهل قليلاً. بشكل عام ، يوجد الكثير من هذه الأشياء الصغيرة المريحة في PostgreSQL ، لذا فإن كتابة الاستعلامات عليها لطيفة للغاية.
توليد التقويم
عاد الآن إلى المهمة الفرعية مع جدول ساعات العمل.
نحن بحاجة إلى الحصول على جدول العمل في شكل فترات زمنية العمل من 10:00 إلى 19:00 لكل يوم عمل ، شيء مثل الجدول الزمني (start_time ، stop_time) . كما فهمنا ، سيكون من المناسب حل مشكلتنا. في الحياة الواقعية ، يجب تعيين جدول كهذا ، لمدة عامين فقط 500 سجل ، ولأغراض عملية ، سيكون من الضروري تعيين حتى عشر سنوات - هذا هو بضعة آلاف ونصف السجلات ، وهراء حقيقي لقواعد البيانات الحديثة. ولكن لدينا مشكلة سيتم حلها في طلب واحد ، وإدراج الجدول بأكمله في هذا غير عملي للغاية. دعونا نحاول تنفيذه بشكل أكثر إحكاما.
في أي حال ، نحن بحاجة إلى أيام العطل لإزالتها من الجدول الأساسي ، وهنا فقط قائمة مناسبة:
dates_exclude(d) as ( values('2018-01-01'::date),
وأيام عمل إضافية لإضافتها:
dates_include(d) as ( values
يمكن إنشاء تسلسل أيام العمل لمدة عامين من خلال دالة gener_series () خاصة ومناسبة جدًا ، حيث يتم إلقاء يومي السبت والأحد مباشرة على طول الطريق:
select d from generate_series( '2018-01-01'::timestamp , '2020-01-01'::timestamp , '1 day'::interval ) as d where extract(dow from d) not in (0,6)
نحصل على أيام العمل من خلال ربط كل شيء معًا: نقوم بإنشاء سلسلة من جميع أيام العمل في غضون عامين ، ونضيف أيام عمل إضافية من التواريخ ، وتشمل جميع الأيام الإضافية من التواريخ ، ونستبعدها :
schedule_base as ( select d from generate_series( '2018-01-01'::timestamp , '2020-01-01'::timestamp , '1 day'::interval ) as d where extract(dow from d) not in (0,6)
والآن نحصل على الفواصل الزمنية التي نحتاجها:
schedule(start_time, stop_time) as ( select d + '10:00:00'::time, d + '19:00:00'::time from schedule_base )
لذلك ، حصلنا على الجدول الزمني.
وضع كل ذلك معا
الآن سنحصل على التقاطعات:
select p.* , tsrange(p.start_time, p.stop_time) * tsrange(s.start_time, s.stop_time) as wrkh from periods p join schedule s on tsrange(p.start_time, p.stop_time) && tsrange(s.start_time, s.stop_time)
انتبه إلى حالة الاتصال ON ، فهو لا يتطابق مع سِجلين متماثلين من الجداول المرتبطة ، ولا توجد مثل هذه المراسلات ، ولكن يتم تقديم بعض التحسينات التي تقطع فترات وقت العمل التي لا تتقاطع بها فترةنا الأولية. يتم ذلك باستخدام عامل التشغيل && ، الذي يتحقق من تقاطع فترات tsrange . يؤدي هذا إلى إزالة الكثير من التقاطعات الفارغة حتى لا تتسبب في إعاقة العينين ، لكن من ناحية أخرى ، يزيل المعلومات حول تلك الفترات الأولية التي تقع خارج ساعات العمل تمامًا. لذلك نحن نعجب أن نهجنا يعمل ، وإعادة كتابة الطلب على النحو التالي:
periods_wrk as ( select p.* , tsrange(p.start_time, p.stop_time) * tsrange(s.start_time, s.stop_time) as wrkh from periods p , schedule s ) select id, start_time, stop_time , sum(upper(wrkh)-lower(wrkh)) from periods_wrk group by id, start_time, stop_time
في period_wrk ، نقوم بتحليل كل فترة مصدر إلى فواصل عمل ، ثم نعتبر مدتها الإجمالية. وكانت النتيجة عبارة عن منتج ديكارت كامل لجميع الفترات والفترات ، ولكن لم يتم فقد فترة واحدة.
كل شيء ، يتم استلام النتيجة. لم يعجبني قيم NULL للفواصل الزمنية الفارغة ، اسمح للاستعلام بعرض فاصل زمني صفري أفضل. لف الكمية في التماسك () :
select id, start_time, stop_time , coalesce(sum(upper(wrkh)-lower(wrkh)), '0 sec'::interval) from periods_wrk group by id, start_time, stop_time
كل ذلك يعطي النتيجة النهائية:
with periods(id, start_time, stop_time) as ( values(1, '2019-03-29 07:00:00'::timestamp, '2019-04-08 14:00:00'::timestamp) , (2, '2019-04-10 07:00:00'::timestamp, '2019-04-10 20:00:00'::timestamp) , (3, '2019-04-11 12:00:00'::timestamp, '2019-04-12 16:00:00'::timestamp) , (4, '2018-12-28 12:00:00'::timestamp, '2019-01-16 16:00:00'::timestamp) ), dates_exclude(d) as ( values('2018-01-01'::date),
يا هلا! .. يمكن الانتهاء من ذلك ، ولكن من أجل الاكتمال سننظر في بعض الموضوعات ذات الصلة.
مزيد من التطوير للموضوع
تقصير أيام ما قبل العطلة ، استراحات الغداء ، جداول زمنية مختلفة لأيام مختلفة من الأسبوع ... من حيث المبدأ ، كل شيء واضح ، تحتاج إلى إصلاح تعريف الجدول الزمني ، فقط أعط مثالين.
هذه هي الطريقة التي يمكنك بها ضبط وقت بدء ونهاية يوم عمل مختلفين حسب يوم الأسبوع:
select d + case extract(dow from d) when 1 then '10:00:00'::time
إذا كنت بحاجة إلى أخذ استراحات الغداء في الاعتبار من الساعة 13:00 إلى الساعة 14:00 ، فبدلاً من فاصل واحد في اليوم ، قم بما يلي:
select d + '10:00:00'::time , d + '13:00:00'::time from schedule_base union all select d + '14:00:00'::time , d + '19:00:00'::time from schedule_base
حسنا وهلم جرا.
إنتاجية
سأقول بضع كلمات عن الأداء ، حيث توجد دائمًا أسئلة حوله. لن أضغ الكثير بالفعل ، هذا قسم به علامة النجمة.
بشكل عام ، التحسين المبكر هو الشر. وفقا لسنوات عديدة من الملاحظة ، فإن قراءة الكود هي أهم ميزة لها. إذا تمت قراءة الكود جيدًا ، فمن السهل الحفاظ عليه وتطويره. تتطلب التعليمة البرمجية المقروءة جيدًا ضمنيًا كلاً من بنية الحلول الجيدة والتعليق المناسب والأسماء المتغيرة الجيدة والاكتناز دون التضحية بقابلية القراءة ، وما إلى ذلك ، أي أن ما يسمى بالكود جيدًا.
لذلك ، تتم كتابة الطلب دائمًا على أنه مقروء قدر الإمكان ، ونبدأ في تحسين ما إذا كان الأداء غير كافٍ وفقط. علاوة على ذلك ، سنقوم بتحسينه على وجه التحديد عندما يكون الأداء غير كافٍ وبقدر ما يصبح ذلك كافياً. إذا كنت تقدر بالتأكيد وقتك ، وعليك القيام بشيء ما.
لكن عدم القيام بعمل غير ضروري في الطلب أمر صحيح ؛ يجب عليك دائمًا محاولة مراعاة ذلك.
بناءً على ذلك ، سنقوم بتضمين تحسين واحد في الاستعلام على الفور - دع كل فترة مصدر تتقاطع فقط مع الفواصل الزمنية لوقت العمل التي لها نقاط شائعة (بدلاً من الشرط الكلاسيكي الطويل في حدود النطاق ، يكون أكثر ملاءمة لاستخدام عامل التشغيل && المدمج لنوع tsrange ). لقد ظهر هذا التحسين بالفعل في الطلب ، ولكنه أدى إلى حقيقة أن الفترات الأولية التي سقطت بالكامل خارج ساعات العمل قد اختفت من النتائج.
جلب هذا التحسين مرة أخرى. للقيام بذلك ، استخدم LEFT JOIN ، والذي سيوفر جميع السجلات من جدول الفترات . الآن سيبدو الاستعلام الفرعي period_wrk كما يلي:
, periods_wrk as ( select p.* , tsrange(p.start_time, p.stop_time) * tsrange(s.start_time, s.stop_time) as wrkh from periods p left join schedule s on tsrange(p.start_time, p.stop_time) && tsrange(s.start_time, s.stop_time))
يوضح تحليل الطلب أن الوقت في بيانات الاختبار قد انخفض بمقدار النصف تقريبًا. نظرًا لأن وقت التشغيل يعتمد على ما كان يقوم به الخادم في الوقت نفسه ، فقد أجريت بعض القياسات وأعطيت بعض النتائج "النموذجية" ، وليس أكبرها ، وليس أصغرها ، من الوسط.
استعلام قديم:
explain (analyse) with periods(id, start_time, stop_time) as ( ... QUERY PLAN
الجديد:
explain (analyse) with periods(id, start_time, stop_time) as ( ... QUERY PLAN
ولكن الشيء الأكثر أهمية هو أن مثل هذا الطلب سوف يتوسع أيضًا ، ويتطلب موارد خادم أقل ، نظرًا لأن المنتج الديكارتي الكامل ينمو بسرعة كبيرة.
وعلى هذا أود التوقف مع التحسينات. عندما حللت هذه المشكلة بنفسي ، كان لدي أداء كافٍ حتى في شكل أكثر فظاعة من هذا الطلب ، ولكن لم تكن هناك حاجة للتحسين. للحصول على تقرير عن بياناتي مرة واحدة كل ثلاثة أشهر ، يمكنني الانتظار لمدة عشر ثوانٍ إضافية. ساعة إضافية تنفق على التحسين في مثل هذه الظروف لن تؤتي ثمارها.
لكن اتضح أنه غير مهتم ؛ فما زلنا نفكر في الطريقة التي يمكن أن تتطور بها الأحداث إذا كانت هناك حاجة فعلية إلى التحسين من حيث وقت التنفيذ. على سبيل المثال ، نرغب في مراقبة هذه المعلمة في الوقت الفعلي لكل من سجلاتنا في قاعدة البيانات ، أي لكل عطس سيتم استدعاء هذا الطلب. حسنًا ، أو توصل لسبب خاص بك ، لماذا تحتاج إلى التحسين.
أول ما يتبادر إلى الذهن هو حساب مرة واحدة ووضع جدول في قاعدة البيانات بفواصل العمل. قد تكون هناك موانع: إذا تعذر تغيير قاعدة البيانات ، أو توقع وجود صعوبات بدعم من البيانات ذات الصلة في مثل هذا الجدول. بعد ذلك ، سيتعين عليك ترك توليد وقت العمل "سريعًا" في الطلب نفسه ، لأن هذا ليس استعلامًا فرعيًا ثقيلًا للغاية.
النهج التالي والأقوى (ولكن لا ينطبق دائمًا) هو التحسين الحسابي. وقد تم بالفعل عرض بعض هذه الأساليب في التعليقات على المقالة بشرط المشكلة.
أنا أحب هذا واحد أكثر من أي شيء. إذا قمت بإنشاء جدول مع كل أيام العمل (وليس فقط أيام العمل) من التقويم وحساب الإجمالي التراكمي لعدد ساعات العمل كل يوم من "إنشاء العالم" معين ، يمكنك الحصول على عدد ساعات العمل بين تاريخين مع عملية الطرح واحد. يبقى فقط أن نأخذ في الاعتبار بشكل صحيح ساعات العمل لليوم الأول والأخير - ولقد انتهيت. إليكم ما حصلت عليه في هذا النهج:
schedule_base(d, is_working) as ( select '2018-01-01'::date, 0 union all select d+1, case when extract(dow from d+1) not in (0,6) and d+1 <> all('{2019-01-01,2019-01-02,2019-01-03,2019-01-04,2019-01-07,2019-01-08,2019-03-08,2019-05-01,2019-05-02,2019-05-03,2019-05-09,2019-05-10,2019-06-12,2019-11-04,2018-01-01,2018-01-02,2018-01-03,2018-01-04,2018-01-05,2018-01-08,2018-02-23,2018-03-08,2018-03-09,2018-04-30,2018-05-01,2018-05-02,2018-05-09,2018-06-11,2018-06-12,2018-11-05,2018-12-31}') or d+1 = any('{2018-04-28,2018-06-09,2018-12-29}') then 1 else 0 end from schedule_base where d < '2020-01-01' ), schedule(d, is_working, work_hours) as ( select d, is_working , sum(is_working*'9 hours'::interval) over (order by d range between unbounded preceding and current row) from schedule_base ) select p.* , s2.work_hours - s1.work_hours + ('19:00:00'::time - least(greatest(p.start_time::time, '10:00:00'::time), '19:00:00'::time)) * s1.is_working - ('19:00:00'::time - least(greatest(p.stop_time::time, '10:00:00'::time), '19:00:00'::time)) * s2.is_working as wrk from periods p, schedule s1, schedule s2 where s1.d = p.start_time::date and s2.d = p.stop_time::date
سأشرح بإيجاز ما يحدث هنا. في استعلام الجدول الزمني table_base ، نقوم بإنشاء جميع أيام التقويم لمدة عامين ، ونحدد كل يوم علامة ما إذا كان يوم العمل (= 1) أم لا (= 0). علاوة على ذلك ، في استعلام الجدول الفرعي ، نعتبر أن وظيفة النافذة هي العدد الإجمالي التراكمي لساعات العمل من 2018-01-01. قد يكون من الممكن القيام بكل شيء في استعلام فرعي واحد ، ولكنه سيتحول إلى أكثر تعقيدًا ، مما قد يضعف من قابلية القراءة. بعد ذلك ، في الطلب الرئيسي ، نأخذ في الاعتبار الفرق بين عدد ساعات العمل في نهاية وبداية الفترة ، وبشكل خاطئ ، نأخذ في الاعتبار ساعات العمل في اليوم الأول والأخير من الفترة. يرتبط التسمم بتحويل الوقت قبل بداية يوم العمل إلى بدايته ، والوقت الذي يلي نهاية يوم العمل إلى نهايته. علاوة على ذلك ، إذا تمت إزالة جزء الطلب مع shedule_base والجدول الزمني في جدول منفصل محسوب مسبقًا (كما هو مقترح سابقًا) ، فسيتم تحويل الطلب إلى طلب بسيط تمامًا.
دعنا نقارن التنفيذ على عينة أكبر من أجل إظهار التحسين الذي تم القيام به بشكل أفضل ، لمدة أربع فترات من حالة المهمة يتم إنفاق المزيد من الوقت على إنشاء جدول عمل.
أخذت حوالي 3 آلاف فترة. سأقدم فقط ملخص السطر العلوي في EXPLAIN ، القيم النموذجية هي كما يلي.
الخيار الأصلي:
GroupAggregate (cost=265790.95..296098.23 rows=144320 width=36) (actual time=656.654..894.383 rows=2898 loops=1) ...
الأمثل:
Hash Join (cost=45.01..127.52 rows=70 width=36) (actual time=1.620..5.385 rows=2898 loops=1) ...
كسب الوقت كان بضعة أوامر من الحجم. مع زيادة عدد الفترات وطولها بالسنوات ، سوف تتسع الفجوة فقط.
يبدو أن كل شيء على ما يرام ، ولكن لماذا ، بعد إجراء هذا التحسين ، تركت الإصدار الأول من الطلب بنفسي حتى كان أدائه كافياً؟ نعم ، لأن الإصدار الأمثل هو بلا شك أسرع ، لكنه يحتاج إلى مزيد من الوقت لفهم كيفية عمله ، أي أن القراءة قد ازدادت سوءًا. أي أنه في المرة القادمة التي أحتاج فيها إلى إعادة كتابة الطلب وفقًا لظروفي التي تم تغييرها ، سأضطر (أم لا) إلى قضاء المزيد من الوقت لفهم كيفية عمل الطلب.
هذا كل شيء لهذا اليوم ، والحفاظ على مخالب دافئة ، وأقول وداعا لك حتى الإصدار التالي راديو SQL.