15 พ.ย. 2568·อ่าน 2 นาที

ตารางเวลาที่เกิดซ้ำและโซนเวลาใน PostgreSQL: รูปแบบที่ควรรู้

เรียนรู้การจัดการตารางซ้ำและโซนเวลาใน PostgreSQL พร้อมรูปแบบการเก็บข้อมูลจริง กฎการเกิดซ้ำ ข้อยกเว้น และรูปแบบคิวรีที่ทำให้ปฏิทินถูกต้อง

ตารางเวลาที่เกิดซ้ำและโซนเวลาใน PostgreSQL: รูปแบบที่ควรรู้

ทำไมโซนเวลาและเหตุการณ์ที่เกิดซ้ำถึงพัง\n\nข้อผิดพลาดของปฏิทินส่วนใหญ่ไม่ใช่ข้อผิดพลาดทางคณิตศาสตร์ แต่เป็นข้อผิดพลาดด้านความหมาย คุณเก็บข้อมูลบางอย่างไว้ (ช่วงเวลาจริงหนึ่งจุด) แต่ผู้ใช้คาดหวังอีกอย่าง (เวลาในนาฬิกาท้องถิ่นในสถานที่เฉพาะ) ช่องว่างนี้คือสาเหตุที่ตารางเวลาซ้ำและโซนเวลาดูเหมือนถูกต้องในการทดสอบ แต่พอมีผู้ใช้จริงเข้ามาปัญหาก็เกิดขึ้น\n\nDaylight Saving Time (DST) เป็นตัวกระตุ้นคลาสสิก การเปลี่ยนแปลงที่เป็น “ทุกวันอาทิตย์เวลา 09:00” ไม่เหมือนกับ “ทุก 7 วันจาก timestamp เริ่มต้น” เมื่อออฟเซ็ตเปลี่ยนสองแนวคิดนั้นจะเบี่ยงกันเป็นหนึ่งชั่วโมงและปฏิทินของคุณจะผิดโดยไม่รู้ตัว\n\nการเดินทางและการผสมโซนเวลาเพิ่มชั้นปัญหาอีกชั้น การจองอาจผูกกับสถานที่ทางกายภาพ (เก้าอี้ร้านเสริมสวยใน Chicago) ขณะที่คนที่ดูอาจอยู่ London หากคุณปฏิบัติตารางที่ผูกกับสถานที่ราวกับว่าผูกกับบุคคล คุณจะแสดงเวลาท้องถิ่นผิดให้ฝ่ายใดฝ่ายหนึ่งอย่างแน่นอน\n\nรูปแบบความล้มเหลวที่พบบ่อย:\n\n- คุณสร้าง recurrence โดยการบวก interval กับ timestamp ที่เก็บไว้ แล้ว DST เปลี่ยน\n- คุณเก็บ “เวลาท้องถิ่น” โดยไม่เก็บกฎโซน ทำให้ไม่สามารถสร้างช่วงเวลาที่ตั้งใจได้ในภายหลัง\n- คุณทดสอบแค่วันที่ที่ไม่ข้ามขอบเขต DST\n- คุณผสม “event time zone”, “user time zone” และ “server time zone” ในคิวรีเดียว\n\nก่อนเลือกสคีมา ให้ตัดสินใจก่อนว่า “ถูกรึเปล่า” หมายถึงอะไรสำหรับผลิตภัณฑ์ของคุณ\n\nสำหรับการจอง คำว่า “ถูกต้อง” มักหมายถึง: การนัดหมายเกิดขึ้นตามเวลาในนาฬิกาที่ตั้งใจไว้ในโซนเวลาของสถานที่ และทุกคนที่ดูจะได้รับการแปลงเวลาที่ถูกต้อง\n\nสำหรับกะงาน คำว่า “ถูกต้อง” มักหมายถึง: กะเริ่มตรงเวลาท้องถิ่นคงที่ของร้าน ถึงแม้พนักงานจะเดินทางไปที่อื่น\n\nการตัดสินใจเดียวนี้ (ผูกตารางกับสถานที่ vs ผูกกับบุคคล) จะกำหนดทุกอย่าง: คุณจะเก็บอะไรอย่างไร วิธีสร้าง recurrence และวิธีคิวรีมุมมองปฏิทินโดยไม่มีเซอร์ไพรส์ชั่วโมงเดียว\n\n## เลือกรูปแบบคิดที่ถูกต้อง: instant vs local time\n\nบัคหลายอย่างมาจากการผสมสองแนวคิดของเวลา:\n\n- Instant: ช่วงเวลาสัมบูรณ์ที่เกิดขึ้นครั้งเดียว\n- Local time rule: เวลาบนผนังนาฬิกา เช่น “ทุกวันจันทร์เวลา 9:00 AM ใน Paris”\n\nInstant เหมือนกันทุกที่ “2026-03-10 14:00 UTC” เป็น instant ปกติจะใช้กับการโทรวิดีโอ, การออกเดินทางของเที่ยวบิน และ “ส่งการแจ้งเตือนในเวลานี้ตรงๆ”\n\nLocal time คือสิ่งที่คนอ่านบนนาฬิกาในสถานที่หนึ่ง “9:00 AM ใน Europe/Paris ทุกวันทำงาน” เป็น local time ชั่วโมงเปิดร้าน, คลาสที่เกิดซ้ำ และกะพนักงานมักยึดติดกับโซนเวลาของสถานที่ เวลานั้นเป็นส่วนของความหมาย ไม่ใช่แค่การแสดงผล\n\nกฎง่ายๆ ในการตัดสินใจ:\n\n- เก็บ start/end เป็น instant เมื่อเหตุการณ์ต้องเกิดตรงช่วงเวลาจริงเดียวทั่วโลก\n- เก็บ local date และ local time พร้อมกับ zone ID เมื่อเหตุการณ์จะต้องตามนาฬิกาในที่เดียว\n- ถ้าผู้ใช้เดินทาง ให้แสดงเวลาตามโซนของผู้ดู แต่เก็บตารางไว้ยึดกับโซนของสถานที่\n- อยา่ใส่โซนโดยเดาจากออฟเซ็ตเช่น "+02:00" ออฟเซ็ตไม่รวมกฎ DST\n\nตัวอย่าง: กะโรงพยาบาลคือ “จันทร์–ศุกร์ 09:00–17:00 America/New_York” ในสัปดาห์ที่เปลี่ยน DST กะยังเป็น 9 ถึง 5 ตามเวลาท้องถิ่น แม้ว่า instants ใน UTC จะเลื่อนไปหนึ่งชั่วโมง\n\n## ชนิดข้อมูลใน PostgreSQL ที่สำคัญ (และควรหลีกเลี่ยงอะไร)\n\nบัคของปฏิทินส่วนใหญ่เริ่มจากชนิดคอลัมน์ผิด ข้อสำคัญคือแยกช่วงเวลาจริงออกจากความคาดหวังบนหน้าปัดนาฬิกา\n\nใช้ timestamptz สำหรับ instant จริง: การจอง, การเช็คอิน, การแจ้งเตือน และสิ่งที่คุณต้องเปรียบเทียบข้ามผู้ใช้หรือภูมิภาค PostgreSQL จะเก็บเป็นช่วงเวลาสัมบูรณ์และแปลงสำหรับการแสดงผล ดังนั้นการเรียงลำดับและตรวจสอบการทับซ้อนจะถูกต้องตามคาด\n\nใช้ timestamp without time zone สำหรับค่าเวลาท้องถิ่นที่ไม่ใช่ instant ด้วยตัวเอง เช่น “ทุกวันจันทร์เวลา 09:00” หรือ “ร้านเปิดเวลา 10:00” จับคู่กับตัวระบุโซน แล้วแปลงเป็น instant เมื่อสร้าง occurrence เท่านั้น\n\nสำหรับรูปแบบการเกิดซ้ำ ชนิดพื้นฐานที่ช่วยได้:\n\n- date สำหรับข้อยกเว้นที่เป็นวัน (เช่นวันหยุด)\n- time สำหรับเวลาเริ่มต้นรายวัน\n- interval สำหรับระยะเวลา (เช่นกะ 6 ชั่วโมง)\n\nเก็บโซนเวลาเป็นชื่อ IANA (เช่น America/New_York) ในคอลัมน์ text (หรือในตาราง lookup ขนาดเล็ก) ออฟเซ็ตเช่น -0500 ไม่พอเพราะไม่รวมกฎ DST\n\nชุดปฏิบัติสำหรับหลายแอป:\n\n- timestamptz สำหรับ start/end instants ของการนัดหมายที่จองไว้\n- date สำหรับวันข้อยกเว้น\n- time สำหรับเวลาเริ่มต้นท้องถิ่นที่เกิดซ้ำ\n- interval สำหรับระยะเวลา\n- text สำหรับไอดีโซน IANA\n\n## ตัวเลือกแบบข้อมูลสำหรับแอปการจองและกะงาน\n\nสคีมาที่ดีที่สุดขึ้นกับความถี่ที่ตารางเปลี่ยนและระยะเวลาที่ผู้ใช้เรียกดูล่วงหน้า คุณมักต้องเลือกระหว่างการเขียนหลายแถวล่วงหน้าหรือสร้างเมื่อมีการอ่าน\n\n### ตัวเลือก A: เก็บทุก occurrence\n\nแทรกหนึ่งแถวสำหรับแต่ละกะหรือการจอง (ขยายแล้ว) คิวรีง่ายและเข้าใจง่าย แลกกับการเขียนหนักและการอัปเดตมากเมื่อกฎเปลี่ยน\n\nเหมาะเมื่อเหตุการณ์ส่วนใหญ่เป็นครั้งเดียว หรือเมื่อคุณสร้าง occurrence เฉพาะล่วงหน้าไม่นาน (ตัวอย่างเช่น 30 วันถัดไป)\n\n### ตัวเลือก B: เก็บเป็นกฎและขยายเมื่ออ่าน\n\nเก็บกฎตาราง (เช่น “สัปดาห์ละวันจันทร์และพุธ 09:00 ใน America/New_York”) และสร้าง occurrence สำหรับช่วงที่ร้องขอเมื่อจำเป็น\n\nยืดหยุ่นและประหยัดที่เก็บ แต่คิวรีซับซ้อนขึ้น มุมมองแบบเดือนอาจช้าลงถ้าไม่ทำ cache\n\n### ตัวเลือก C: กฎบวก occurrence แคช (ไฮบริด)\n\nเก็บกฎเป็นแหล่งความจริง และเก็บ occurrence ที่สร้างไว้สำหรับหน้าต่างเลื่อน (เช่น 60–90 วัน) เมื่อกฎเปลี่ยน ให้สร้างแคชใหม่\n\nนี่เป็นค่าดีจริงสำหรับแอปกะ: มุมมองเดือนเร็ว แต่ยังมีที่เดียวแก้แพทเทิร์น\n\nตารางที่ใช้งานได้จริง:\n\n- schedule: owner/resource, time zone, local start time, duration, recurrence rule\n- occurrence: instances ขยายแล้วพร้อม start_at timestamptz, end_at timestamptz, และสถานะ\n- exception: ตัวชี้ว่า “ข้ามวันนี้” หรือ “วันนี้ต่างไป”\n- override: แก้ไขต่อ-occurrence เช่น เปลี่ยนเวลเริ่ม, สลับพนักงาน, ติดธงยกเลิก\n- (optional) schedule_cache_state: ช่วงล่าสุดที่สร้างไว้เพื่อรู้ว่าจะเติมถัดไปอย่างไร\n\nสำหรับการคิวรีช่วงปฏิทิน ให้ทำดัชนีเพื่อ “โชว์ทุกอย่างในหน้าต่างนี้”:\n\n- บน occurrence: btree (resource_id, start_at) และบ่อยครั้ง btree (resource_id, end_at)\n- ถ้าคุณคิวรี “overlaps range” บ่อย: สร้าง tstzrange(start_at, end_at) และเพิ่ม gist index\n\n## แทนกฎการเกิดซ้ำโดยไม่ทำให้เปราะบาง\n\nตารางเวลาที่เกิดซ้ำพังเมื่อกฎซับซ้อนเกินไป ยืดหยุ่นเกินไป หรือเก็บเป็น blob ที่ไม่สามารถคิวรีได้ รูปแบบกฎที่ดีคือรูปแบบที่แอปคุณตรวจสอบได้และทีมอธิบายได้อย่างรวดเร็ว\n\nสองแนวทางที่พบบ่อย:\n\n- ฟิลด์แบบกำหนดเองเรียบง่ายสำหรับแพทเทิร์นที่คุณรองรับจริง (กะรายสัปดาห์, วันที่เรียกเก็บรายเดือน)\n- กฎแบบ iCalendar (RRULE-style) เมื่อคุณต้อง import/export ปฏิทินหรือรองรับหลายการผสม\n\nทางสายกลางที่ใช้ได้: อนุญาตชุดตัวเลือกจำกัด เก็บเป็นคอลัมน์ และถือว่า RRULE เป็นเพียงรูปแบบแลกเปลี่ยนเท่านั้น\n\nตัวอย่าง กฎสัปดาห์สามารถแสดงด้วยฟิลด์เช่น:\n\n- freq (daily/weekly/monthly) และ interval (ทุก N)\n- byweekday (อาร์เรย์ของ 0-6 หรือ bitmask)\n- bymonthday (1-31) ตัวเลือกสำหรับกฎรายเดือน\n- starts_at_local (local date+time ที่ผู้ใช้เลือก) และ tzid\n- until_date หรือ count (หลีกเลี่ยงการรองรับทั้งสองถ้าไม่จำเป็น)\n\nสำหรับขอบเขต ชอบเก็บ duration (เช่น 8 ชั่วโมง) แทนการเก็บ end timestamp สำหรับทุก occurrence ระยะเวลาคงที่เมื่อชั่วโมงเปลี่ยน คุณยังคำนวณ end ต่อ occurrence ได้: occurrence start + duration\n\nเมื่อขยายกฎ ให้ปลอดภัยและจำกัด:\n\n- ขยายเฉพาะภายใน window_start และ window_end\n- เพิ่มบัฟเฟอร์เล็กๆ (เช่น 1 วัน) สำหรับเหตุการณ์ข้ามคืน\n- หยุดหลังจำนวน instance สูงสุด (เช่น 500)\n- กรองผู้สมัครก่อน (โดย tzid, freq, และ start date) ก่อนสร้างจริง\n\n## ขั้นตอนทีละขั้น: สร้างตารางเวลาที่ปลอดภัยจาก DST\n\nรูปแบบที่เชื่อถือได้คือ: ถือแต่ละ occurrence เป็นแนวคิดปฏิทินท้องถิ่นก่อน (วันที่ + เวลาในท้องถิ่น + โซนของสถานที่), แล้วแปลงเป็น instant เมื่อต้องเรียงลำดับ, ตรวจสอบข้อขัดแย้ง, หรือแสดงผลเท่านั้น\n\n### 1) เก็บความตั้งใจเป็นท้องถิ่น ไม่ใช่เดาการเป็น UTC\n\nบันทึกโซนเวลาของตาราง (ชื่อ IANA เช่น America/New_York) พร้อมเวลาเริ่มต้นท้องถิ่น (เช่น 09:00) เวลาท้องถิ่นนี้คือสิ่งที่ธุรกิจต้องการ แม้ DST จะเปลี่ยน\n\nยังเก็บระยะเวลาและขอบเขตที่ชัดเจนสำหรับกฎ: วันที่เริ่ม และทั้ง end date หรือ repeat count ขอบเขตป้องกันบัคการขยายไม่รู้จบ\n\n### 2) แยกข้อยกเว้นและ overrides ออกมาต่างหาก\n\nใช้สองตารางเล็ก ๆ: หนึ่งสำหรับวันที่ข้าม, อีกหนึ่งสำหรับ occurrence ที่เปลี่ยนแปลง จับคีย์ด้วย schedule_id + local_date เพื่อจับคู่ recurrence ต้นฉบับอย่างสะอาด\n\nรูปร่างที่ใช้งานได้จริงดูเหมือน:\n\nsql\n-- core schedule\n-- tz is the location time zone\n-- start_time is local wall-clock time\nschedule(id, tz text, start_date date, end_date date, start_time time, duration_mins int, by_dow int[])\n\nschedule_skip(schedule_id, local_date date)\n\nschedule_override(schedule_id, local_date date, new_start_time time, new_duration_mins int)\n\n\n### 3) ขยายเฉพาะในหน้าต่างที่ร้องขอ\n\nสร้างวันที่ท้องถิ่นผู้สมัครสำหรับช่วงที่คุณกำลังเรนเดอร์ (สัปดาห์, เดือน) กรองตามวันในสัปดาห์ แล้วใช้ skips และ overrides\n\nsql\nWITH days AS (\n SELECT d::date AS local_date\n FROM generate_series($1::date, $2::date, interval '1 day') d\n), base AS (\n SELECT s.id, s.tz, days.local_date,\n make_timestamp(extract(year from days.local_date)::int,\n extract(month from days.local_date)::int,\n extract(day from days.local_date)::int,\n extract(hour from s.start_time)::int,\n extract(minute from s.start_time)::int, 0) AS local_start\n FROM schedule s\n JOIN days ON days.local_date BETWEEN s.start_date AND s.end_date\n WHERE extract(dow from days.local_date)::int = ANY (s.by_dow)\n)\nSELECT b.id,\n (b.local_start AT TIME ZONE b.tz) AS start_utc\nFROM base b\nLEFT JOIN schedule_skip sk\n ON sk.schedule_id = b.id AND sk.local_date = b.local_date\nWHERE sk.schedule_id IS NULL;\n\n\n### 4) แปลงสำหรับผู้ดูเมื่อที่สุดท้ายเท่านั้น\n\nเก็บ start_utc เป็น timestamptz สำหรับการเรียงลำดับ, ตรวจสอบข้อขัดแย้ง, และการจอง แปลงเป็นโซนของผู้ดูตอนแสดงผลเท่านั้น วิธีนี้หลีกเลี่ยงความประหลาดใจจาก DST และทำให้มุมมองปฏิทินสม่ำเสมอ\n\n## รูปแบบคิวรีเพื่อสร้างมุมมองปฏิทินที่ถูกต้อง\n\nหน้าจอปฏิทินโดยทั่วไปเป็นคิวรีช่วง: “โชว์ทุกอย่างระหว่าง from_ts และ to_ts” รูปแบบที่ปลอดภัยคือ:\n\n1) ขยายเฉพาะผู้สมัครในหน้าต่างนั้น\n2) ใช้ข้อยกเว้น/overrides\n3) เอาแถวสุดท้ายออกมาเป็น start_at และ end_at ในรูป timestamptz\n\n### การขยายรายวันหรือรายสัปดาห์ด้วย generate_series\n\nสำหรับกฎสัปดาห์ง่ายๆ (เช่น “ทุกจันทร์–ศุกร์ 09:00 ท้องถิ่น”) ให้สร้างวันที่ท้องถิ่นในโซนของตาราง แล้วแปลงแต่ละวันที่ท้องถิ่น + เวลาเป็น instant\n\nsql\n-- Inputs: :from_ts, :to_ts are timestamptz\n-- rule.tz is an IANA zone like 'America/New_York'\nWITH bounds AS (\n SELECT\n (:from_ts AT TIME ZONE rule.tz)::date AS from_local_date,\n (:to_ts AT TIME ZONE rule.tz)::date AS to_local_date\n FROM rule\n WHERE rule.id = :rule_id\n), days AS (\n SELECT d::date AS local_date\n FROM bounds, generate_series(from_local_date, to_local_date, interval '1 day') AS g(d)\n)\nSELECT\n (local_date + rule.start_local_time) AT TIME ZONE rule.tz AS start_at,\n (local_date + rule.end_local_time) AT TIME ZONE rule.tz AS end_at\nFROM rule\nJOIN days ON true\nWHERE EXTRACT(ISODOW FROM local_date) = ANY(rule.by_isodow);\n\n\nวิธีนี้ดีเพราะการแปลงเป็น timestamptz เกิดขึ้นต่อ occurrence ดังนั้นการเปลี่ยน DST จะถูกใช้ในวันที่ถูกต้อง\n\n### กฎซับซ้อนขึ้นด้วย recursive CTE\n\nเมื่อกฎขึ้นกับ “nth weekday”, ช่องว่าง, หรือ interval แบบกำหนดเอง, recursive CTE สามารถสร้าง occurrence ถัดไปซ้ำ ๆ จนกว่าจะผ่าน to_ts ให้ยึดการเรียกซ้ำไว้ในหน้าต่างเพราะจะไม่รันไม่รู้จบ\n\nหลังจากได้แถวผู้สมัคร ใช้ overrides และ cancellations โดยการ join ตารางข้อยกเว้นบน (rule_id, start_at) หรือคีย์ท้องถิ่นเช่น (rule_id, local_date) ถ้ามีบันทึกยกเลิก ให้ตัดแถวออก ถ้ามี override ให้แทนที่ start_at/end_at ด้วยค่าจาก override\n\nรูปแบบประสิทธิภาพที่สำคัญที่สุด:\n\n- จำกัดช่วงแต่เนิ่นๆ: กรองกฎก่อน แล้วค่อยขยายเฉพาะภายใน [from_ts, to_ts)\n- ทำดัชนีตาราง exception/override บน (rule_id, start_at) หรือ (rule_id, local_date)\n- หลีกเลี่ยงการขยายหลายปีสำหรับมุมมองเดือน\n- แคช occurrence ที่ขยายเฉพาะถ้าคุณสามารถ invalidate ได้อย่างสะอาดเมื่อกฎเปลี่ยน\n\n## จัดการข้อยกเว้นและ overrides อย่างสะอาด\n\nตารางซ้ำมีประโยชน์ก็ต่อเมื่อคุณสามารถทำลายมันได้อย่างปลอดภัย ในแอปการจองและกะงาน “สัปดาห์ปกติ” เป็นกฎฐาน และทุกอย่างอื่นคือข้อยกเว้น: วันหยุด, ยกเลิก, การย้ายการนัดหมาย, หรือการสลับพนักงาน ถ้าข้อยกเว้นถูกเพิ่มทีหลัง มุมมองปฏิทินจะเบี้ยวและเกิดข้อมูลซ้ำ\n\nเก็บสามแนวคิดแยกกัน:\n\n- สคีมาฐาน (กฎการเกิดซ้ำและโซนเวลา)\n- Skips (วันที่หรือตัวอย่างที่ต้องไม่เกิด)\n- Overrides (occurrence ที่มีรายละเอียดเปลี่ยน)\n\n### ใช้ลำดับความสำคัญคงที่\n\nเลือกลำดับหนึ่งและรักษาให้สม่ำเสมอ ตัวเลือกที่พบบ่อย:\n\n1) สร้างผู้สมัครจาก recurrence พื้นฐาน\n2) ใช้ overrides (แทนที่รายการที่สร้าง)\n3) ใช้ skips (ซ่อนมัน)\n\nทำให้กฎอธิบายง่ายในประโยคเดียวสำหรับผู้ใช้\n\n### หลีกเลี่ยงข้อมูลซ้ำเมื่อ override แทนที่ instance\n\nข้อมูลซ้ำมักเกิดเมื่อคิวรีส่งทั้ง occurrence ที่สร้างและแถว override ป้องกันโดยใช้คีย์คงที่:\n\n- ให้แต่ละ instance ที่สร้างมีคีย์คงที่ เช่น (schedule_id, local_date, start_time, tzid)\n- เก็บคีย์นั้นในแถว override เป็น “original occurrence key”\n- เพิ่ม unique constraint เพื่อให้มี override ต่อ occurrence ต้นฉบับแค่หนึ่งรายการ\n\nจากนั้นในคิวรี ให้ยกเว้น occurrence ที่สร้างแล้วที่มี override ตรงกัน และ union กับแถว override แทน\n\n### เก็บการตรวจสอบย้อนหลังโดยไม่สะดุด\n\nข้อยกเว้นคือที่ที่มีข้อพิพาทเกิดขึ้น (“ใครแก้กะฉัน?”) ใส่ฟิลด์ audit พื้นฐานบน skips และ overrides: created_by, created_at, updated_by, updated_at, และเหตุผลโดยย่อได้\n\n## ความผิดพลาดที่พบบ่อยซึ่งทำให้บัคเลื่อนหนึ่งชั่วโมง\n\nบัคหนึ่งชั่วโมงมักเกิดจากการผสมความหมายของเวลา: instant (จุดบนเส้นเวลา UTC) กับการอ่านนาฬิกาท้องถิ่น (เช่น 09:00 ทุกวันจันทร์ใน New York)\n\nความผิดพลาดคลาสสิกคือเก็บกฎเวลาท้องถิ่นเป็น timestamptz ถ้าคุณบันทึก “จันทร์เวลา 09:00 America/New_York” เป็น timestamptz หนึ่งค่า คุณได้เลือกวันที่เฉพาะ (และสถานะ DST) แล้ว ต่อมาพอจะสร้างจันทร์ถัดไป ความตั้งใจเดิมว่า “เสมอ 09:00 ท้องถิ่น” หายไป\n\nสาเหตุอีกประการคือพึ่งพาออฟเซ็ตคงที่เช่น -05:00 แทนชื่อโซน IANA ออฟเซ็ตไม่รวมกฎ DST เก็บไอดีโซน (เช่น America/New_York) และให้ PostgreSQL ใช้กฎที่ถูกต้องในแต่ละวัน\n\nระวังช่วงเวลาที่แปลง หากคุณแปลงเป็น UTC เร็วเกินไปในขณะสร้าง recurrence คุณอาจตรึงออฟเซ็ต DST และนำไปใช้กับทุก occurrence รูปแบบที่ปลอดภัยคือ: สร้าง occurrence ในเงื่อนไขท้องถิ่น (date + local time + zone) แล้วแปลงแต่ละ occurrence เป็น instant\n\nข้อผิดพลาดที่ปรากฏบ่อย:\n\n- ใช้ timestamptz เพื่อเก็บเวลาในวันท้องถิ่นที่เกิดซ้ำ (ควรใช้ time + tzid + กฎ)\n- เก็บแต่ออฟเซ็ต ไม่ใช่ชื่อโซน IANA\n- แปลงขณะที่กำลังสร้าง recurrence แทนที่จะทำทีหลัง\n- ขยาย recurrence “ตลอดไป” โดยไม่มีหน้าต่างเวลาจริง\n- ไม่ทดสอบสัปดาห์เริ่ม/สิ้นสุด DST\n\nการทดสอบง่าย ๆ ที่จับปัญหาส่วนใหญ่: เลือกโซนที่มี DST, สร้างกะรายสัปดาห์เวลา 09:00, และเรนเดอร์ปฏิทินสองเดือนที่ข้ามการเปลี่ยน DST ยืนยันว่าแต่ละ instance แสดงเป็น 09:00 ท้องถิ่น แม้ instants ในพื้นฐานจะแตกต่างกัน\n\n## รายการตรวจสอบด่วนก่อนปล่อย\n\nก่อนปล่อย ตรวจสอบพื้นฐาน:\n\n- ทุกสคีมาผูกกับสถานที่ (หรือหน่วยธุรกิจ) ที่มีชื่อโซนเวลา เก็บไว้ในสคีมาเอง\n- เก็บไอดีโซน IANA (เช่น America/New_York) ไม่ใช่ออฟเซ็ตดิบ\n- การขยาย recurrence สร้าง occurrence เฉพาะภายในช่วงที่ร้องขอ\n- ข้อยกเว้นและ overrides มีลำดับความสำคัญที่อธิบายได้ชัดเจน\n- ทดสอบสัปดาห์เปลี่ยน DST และผู้ดูในโซนเวลาต่างจากตาราง\n\nทำ dry run ที่เป็นจริง: ร้านใน Europe/Berlin มีกะรายสัปดาห์เวลา 09:00 ท้องถิ่น ผู้จัดการดูจาก America/Los_Angeles ยืนยันว่ากะยังคงเป็น 09:00 Berlin ทุกสัปดาห์ แม้แต่ละภูมิภาคจะเปลี่ยน DST ในวันต่างกัน\n\n## ตัวอย่าง: กะพนักงานรายสัปดาห์มีวันหยุดและการเปลี่ยน DST\n\nคลินิกขนาดเล็กมีหนึ่งกะซ้ำ: ทุกวันจันทร์ 09:00–17:00 ในโซนท้องถิ่นของคลินิก (America/New_York) คลินิกปิดวันหยุดหนึ่งวันจันทร์ พนักงานคนหนึ่งเดินทางในยุโรปสองสัปดาห์ แต่ตารางของคลินิกต้องยึดกับเวลาบนผนังของคลินิก ไม่ใช่ตำแหน่งของพนักงานขณะนั้น\n\nทำให้สิ่งนี้ทำงานถูกต้องได้โดย:\n\n- เก็บกฎ recurrence ยึดกับวันที่ท้องถิ่น (weekday = Monday, เวลาในท้องถิ่น = 09:00–17:00)\n- เก็บโซนเวลาของสคีมา (America/New_York)\n- เก็บวันที่เริ่มใช้งานเพื่อให้กฎมีจุดยึดชัดเจน\n- เก็บข้อยกเว้นเพื่อยกเลิกวันหยุดจันทร์นั้น (และ overrides สำหรับการเปลี่ยนแปลงครั้งเดียว)\n\nตอนนี้เรนเดอร์ช่วงปฏิทินสองสัปดาห์ที่ครอบคลุมการเปลี่ยน DST ใน New York คิวรีจะสร้างวันจันทร์ในช่วงวันที่ท้องถิ่นนั้น แนบเวลาท้องถิ่นของคลินิก แล้วแปลงแต่ละ occurrence เป็น instant (timestamptz) เพราะการแปลงเกิดขึ้นต่อ occurrence DST จึงถูกจัดการถูกต้องในวันนั้น\n\nผู้ดูต่างคนต่างเห็นเวลาในนาฬิกาที่ต่างกันสำหรับ instant เดียวกัน:\n\n- ผู้จัดการใน Los Angeles จะเห็นเวลาเร็วกว่าบนหน้าปัด\n- พนักงานที่เดินทางใน Berlin จะเห็นเวลาช้ากว่า\n\nแต่คลินิกได้สิ่งที่ต้องการ: 09:00–17:00 New York ทุกวันจันทร์ที่ไม่ได้ยกเลิก\n\n## ขั้นตอนถัดไป: นำไปใช้, ทดสอบ, และรักษาความเรียบร้อย\n\nนิยามแนวทางเรื่องเวลาให้ชัดเจนตั้งแต่ต้น: คุณจะเก็บเฉพาะกฎ, เก็บเฉพาะ occurrence, หรือไฮบริด? สำหรับหลายผลิตภัณฑ์การจองและกะงาน ไฮบริดใช้งานได้ดี: เก็บกฎเป็นแหล่งความจริง, เก็บแคชเลื่อนถ้าจำเป็น, และเก็บข้อยกเว้น/overrides เป็นแถวจริง\n\nเขียน "สัญญาเวลา" ของคุณไว้ในที่เดียว: อะไรนับเป็น instant, อะไรนับเป็น local wall time, และคอลัมน์ไหนเก็บแต่ละอย่าง นี่จะป้องกันเมื่อจุดหนึ่งส่งคืน local time ขณะที่อีกจุดส่งคืน UTC\n\nเก็บการสร้าง recurrence ในโมดูลเดียว ไม่กระจัดกระจายเป็นชิ้น SQL ถ้าต้องเปลี่ยนการตีความ "9:00 AM local" คุณจะมีที่เดียวที่ต้องแก้\n\nถ้าคุณกำลังสร้างเครื่องมือการจัดตารางโดยไม่เขียนทุกอย่างด้วยมือ AppMaster (appmaster.io) เป็นทางเลือกที่ใช้งานได้สำหรับงานประเภทนี้: คุณสามารถออกแบบฐานข้อมูลใน Data Designer, สร้างตรรกะ recurrence และข้อยกเว้นใน business processes, และยังได้ backend และโค้ดแอปจริง\n

ง่ายต่อการเริ่มต้น
สร้างบางสิ่งที่ น่าทึ่ง

ทดลองกับ AppMaster ด้วยแผนฟรี
เมื่อคุณพร้อม คุณสามารถเลือกการสมัครที่เหมาะสมได้

เริ่ม
ตารางเวลาที่เกิดซ้ำและโซนเวลาใน PostgreSQL: รูปแบบที่ควรรู้ | AppMaster