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

ทำไมโซนเวลาและเหตุการณ์ที่เกิดซ้ำถึงพัง
ข้อผิดพลาดของปฏิทินส่วนใหญ่ไม่ใช่ข้อผิดพลาดทางคณิตศาสตร์ แต่เป็นข้อผิดพลาดด้านความหมาย คุณเก็บข้อมูลบางอย่างไว้ (ช่วงเวลาจริงหนึ่งจุด) แต่ผู้ใช้คาดหวังอีกอย่าง (เวลาในนาฬิกาท้องถิ่นในสถานที่เฉพาะ) ช่องว่างนี้คือสาเหตุที่ตารางเวลาซ้ำและโซนเวลาดูเหมือนถูกต้องในการทดสอบ แต่พอมีผู้ใช้จริงเข้ามาปัญหาก็เกิดขึ้น
Daylight Saving Time (DST) เป็นตัวกระตุ้นคลาสสิก การเปลี่ยนแปลงที่เป็น “ทุกวันอาทิตย์เวลา 09:00” ไม่เหมือนกับ “ทุก 7 วันจาก timestamp เริ่มต้น” เมื่อออฟเซ็ตเปลี่ยนสองแนวคิดนั้นจะเบี่ยงกันเป็นหนึ่งชั่วโมงและปฏิทินของคุณจะผิดโดยไม่รู้ตัว
การเดินทางและการผสมโซนเวลาเพิ่มชั้นปัญหาอีกชั้น การจองอาจผูกกับสถานที่ทางกายภาพ (เก้าอี้ร้านเสริมสวยใน Chicago) ขณะที่คนที่ดูอาจอยู่ London หากคุณปฏิบัติตารางที่ผูกกับสถานที่ราวกับว่าผูกกับบุคคล คุณจะแสดงเวลาท้องถิ่นผิดให้ฝ่ายใดฝ่ายหนึ่งอย่างแน่นอน
รูปแบบความล้มเหลวที่พบบ่อย:
- คุณสร้าง recurrence โดยการบวก interval กับ timestamp ที่เก็บไว้ แล้ว DST เปลี่ยน
- คุณเก็บ “เวลาท้องถิ่น” โดยไม่เก็บกฎโซน ทำให้ไม่สามารถสร้างช่วงเวลาที่ตั้งใจได้ในภายหลัง
- คุณทดสอบแค่วันที่ที่ไม่ข้ามขอบเขต DST
- คุณผสม “event time zone”, “user time zone” และ “server time zone” ในคิวรีเดียว
ก่อนเลือกสคีมา ให้ตัดสินใจก่อนว่า “ถูกรึเปล่า” หมายถึงอะไรสำหรับผลิตภัณฑ์ของคุณ
สำหรับการจอง คำว่า “ถูกต้อง” มักหมายถึง: การนัดหมายเกิดขึ้นตามเวลาในนาฬิกาที่ตั้งใจไว้ในโซนเวลาของสถานที่ และทุกคนที่ดูจะได้รับการแปลงเวลาที่ถูกต้อง
สำหรับกะงาน คำว่า “ถูกต้อง” มักหมายถึง: กะเริ่มตรงเวลาท้องถิ่นคงที่ของร้าน ถึงแม้พนักงานจะเดินทางไปที่อื่น
การตัดสินใจเดียวนี้ (ผูกตารางกับสถานที่ vs ผูกกับบุคคล) จะกำหนดทุกอย่าง: คุณจะเก็บอะไรอย่างไร วิธีสร้าง recurrence และวิธีคิวรีมุมมองปฏิทินโดยไม่มีเซอร์ไพรส์ชั่วโมงเดียว
เลือกรูปแบบคิดที่ถูกต้อง: instant vs local time
บัคหลายอย่างมาจากการผสมสองแนวคิดของเวลา:
- Instant: ช่วงเวลาสัมบูรณ์ที่เกิดขึ้นครั้งเดียว
- Local time rule: เวลาบนผนังนาฬิกา เช่น “ทุกวันจันทร์เวลา 9:00 AM ใน Paris”
Instant เหมือนกันทุกที่ “2026-03-10 14:00 UTC” เป็น instant ปกติจะใช้กับการโทรวิดีโอ, การออกเดินทางของเที่ยวบิน และ “ส่งการแจ้งเตือนในเวลานี้ตรงๆ”
Local time คือสิ่งที่คนอ่านบนนาฬิกาในสถานที่หนึ่ง “9:00 AM ใน Europe/Paris ทุกวันทำงาน” เป็น local time ชั่วโมงเปิดร้าน, คลาสที่เกิดซ้ำ และกะพนักงานมักยึดติดกับโซนเวลาของสถานที่ เวลานั้นเป็นส่วนของความหมาย ไม่ใช่แค่การแสดงผล
กฎง่ายๆ ในการตัดสินใจ:
- เก็บ start/end เป็น instant เมื่อเหตุการณ์ต้องเกิดตรงช่วงเวลาจริงเดียวทั่วโลก
- เก็บ local date และ local time พร้อมกับ zone ID เมื่อเหตุการณ์จะต้องตามนาฬิกาในที่เดียว
- ถ้าผู้ใช้เดินทาง ให้แสดงเวลาตามโซนของผู้ดู แต่เก็บตารางไว้ยึดกับโซนของสถานที่
- อยา่ใส่โซนโดยเดาจากออฟเซ็ตเช่น "+02:00" ออฟเซ็ตไม่รวมกฎ DST
ตัวอย่าง: กะโรงพยาบาลคือ “จันทร์–ศุกร์ 09:00–17:00 America/New_York” ในสัปดาห์ที่เปลี่ยน DST กะยังเป็น 9 ถึง 5 ตามเวลาท้องถิ่น แม้ว่า instants ใน UTC จะเลื่อนไปหนึ่งชั่วโมง
ชนิดข้อมูลใน PostgreSQL ที่สำคัญ (และควรหลีกเลี่ยงอะไร)
บัคของปฏิทินส่วนใหญ่เริ่มจากชนิดคอลัมน์ผิด ข้อสำคัญคือแยกช่วงเวลาจริงออกจากความคาดหวังบนหน้าปัดนาฬิกา
ใช้ timestamptz สำหรับ instant จริง: การจอง, การเช็คอิน, การแจ้งเตือน และสิ่งที่คุณต้องเปรียบเทียบข้ามผู้ใช้หรือภูมิภาค PostgreSQL จะเก็บเป็นช่วงเวลาสัมบูรณ์และแปลงสำหรับการแสดงผล ดังนั้นการเรียงลำดับและตรวจสอบการทับซ้อนจะถูกต้องตามคาด
ใช้ timestamp without time zone สำหรับค่าเวลาท้องถิ่นที่ไม่ใช่ instant ด้วยตัวเอง เช่น “ทุกวันจันทร์เวลา 09:00” หรือ “ร้านเปิดเวลา 10:00” จับคู่กับตัวระบุโซน แล้วแปลงเป็น instant เมื่อสร้าง occurrence เท่านั้น
สำหรับรูปแบบการเกิดซ้ำ ชนิดพื้นฐานที่ช่วยได้:
dateสำหรับข้อยกเว้นที่เป็นวัน (เช่นวันหยุด)timeสำหรับเวลาเริ่มต้นรายวันintervalสำหรับระยะเวลา (เช่นกะ 6 ชั่วโมง)
เก็บโซนเวลาเป็นชื่อ IANA (เช่น America/New_York) ในคอลัมน์ text (หรือในตาราง lookup ขนาดเล็ก) ออฟเซ็ตเช่น -0500 ไม่พอเพราะไม่รวมกฎ DST
ชุดปฏิบัติสำหรับหลายแอป:
timestamptzสำหรับ start/end instants ของการนัดหมายที่จองไว้dateสำหรับวันข้อยกเว้นtimeสำหรับเวลาเริ่มต้นท้องถิ่นที่เกิดซ้ำintervalสำหรับระยะเวลาtextสำหรับไอดีโซน IANA
ตัวเลือกแบบข้อมูลสำหรับแอปการจองและกะงาน
สคีมาที่ดีที่สุดขึ้นกับความถี่ที่ตารางเปลี่ยนและระยะเวลาที่ผู้ใช้เรียกดูล่วงหน้า คุณมักต้องเลือกระหว่างการเขียนหลายแถวล่วงหน้าหรือสร้างเมื่อมีการอ่าน
ตัวเลือก A: เก็บทุก occurrence
แทรกหนึ่งแถวสำหรับแต่ละกะหรือการจอง (ขยายแล้ว) คิวรีง่ายและเข้าใจง่าย แลกกับการเขียนหนักและการอัปเดตมากเมื่อกฎเปลี่ยน
เหมาะเมื่อเหตุการณ์ส่วนใหญ่เป็นครั้งเดียว หรือเมื่อคุณสร้าง occurrence เฉพาะล่วงหน้าไม่นาน (ตัวอย่างเช่น 30 วันถัดไป)
ตัวเลือก B: เก็บเป็นกฎและขยายเมื่ออ่าน
เก็บกฎตาราง (เช่น “สัปดาห์ละวันจันทร์และพุธ 09:00 ใน America/New_York”) และสร้าง occurrence สำหรับช่วงที่ร้องขอเมื่อจำเป็น
ยืดหยุ่นและประหยัดที่เก็บ แต่คิวรีซับซ้อนขึ้น มุมมองแบบเดือนอาจช้าลงถ้าไม่ทำ cache
ตัวเลือก C: กฎบวก occurrence แคช (ไฮบริด)
เก็บกฎเป็นแหล่งความจริง และเก็บ occurrence ที่สร้างไว้สำหรับหน้าต่างเลื่อน (เช่น 60–90 วัน) เมื่อกฎเปลี่ยน ให้สร้างแคชใหม่
นี่เป็นค่าดีจริงสำหรับแอปกะ: มุมมองเดือนเร็ว แต่ยังมีที่เดียวแก้แพทเทิร์น
ตารางที่ใช้งานได้จริง:
- schedule: owner/resource, time zone, local start time, duration, recurrence rule
- occurrence: instances ขยายแล้วพร้อม
start_at timestamptz,end_at timestamptz, และสถานะ - exception: ตัวชี้ว่า “ข้ามวันนี้” หรือ “วันนี้ต่างไป”
- override: แก้ไขต่อ-occurrence เช่น เปลี่ยนเวลเริ่ม, สลับพนักงาน, ติดธงยกเลิก
- (optional) schedule_cache_state: ช่วงล่าสุดที่สร้างไว้เพื่อรู้ว่าจะเติมถัดไปอย่างไร
สำหรับการคิวรีช่วงปฏิทิน ให้ทำดัชนีเพื่อ “โชว์ทุกอย่างในหน้าต่างนี้”:
- บน occurrence:
btree (resource_id, start_at)และบ่อยครั้งbtree (resource_id, end_at) - ถ้าคุณคิวรี “overlaps range” บ่อย: สร้าง
tstzrange(start_at, end_at)และเพิ่มgistindex
แทนกฎการเกิดซ้ำโดยไม่ทำให้เปราะบาง
ตารางเวลาที่เกิดซ้ำพังเมื่อกฎซับซ้อนเกินไป ยืดหยุ่นเกินไป หรือเก็บเป็น blob ที่ไม่สามารถคิวรีได้ รูปแบบกฎที่ดีคือรูปแบบที่แอปคุณตรวจสอบได้และทีมอธิบายได้อย่างรวดเร็ว
สองแนวทางที่พบบ่อย:
- ฟิลด์แบบกำหนดเองเรียบง่ายสำหรับแพทเทิร์นที่คุณรองรับจริง (กะรายสัปดาห์, วันที่เรียกเก็บรายเดือน)
- กฎแบบ iCalendar (RRULE-style) เมื่อคุณต้อง import/export ปฏิทินหรือรองรับหลายการผสม
ทางสายกลางที่ใช้ได้: อนุญาตชุดตัวเลือกจำกัด เก็บเป็นคอลัมน์ และถือว่า RRULE เป็นเพียงรูปแบบแลกเปลี่ยนเท่านั้น
ตัวอย่าง กฎสัปดาห์สามารถแสดงด้วยฟิลด์เช่น:
freq(daily/weekly/monthly) และinterval(ทุก N)byweekday(อาร์เรย์ของ 0-6 หรือ bitmask)bymonthday(1-31) ตัวเลือกสำหรับกฎรายเดือนstarts_at_local(local date+time ที่ผู้ใช้เลือก) และtziduntil_dateหรือcount(หลีกเลี่ยงการรองรับทั้งสองถ้าไม่จำเป็น)
สำหรับขอบเขต ชอบเก็บ duration (เช่น 8 ชั่วโมง) แทนการเก็บ end timestamp สำหรับทุก occurrence ระยะเวลาคงที่เมื่อชั่วโมงเปลี่ยน คุณยังคำนวณ end ต่อ occurrence ได้: occurrence start + duration
เมื่อขยายกฎ ให้ปลอดภัยและจำกัด:
- ขยายเฉพาะภายใน
window_startและwindow_end - เพิ่มบัฟเฟอร์เล็กๆ (เช่น 1 วัน) สำหรับเหตุการณ์ข้ามคืน
- หยุดหลังจำนวน instance สูงสุด (เช่น 500)
- กรองผู้สมัครก่อน (โดย
tzid,freq, และ start date) ก่อนสร้างจริง
ขั้นตอนทีละขั้น: สร้างตารางเวลาที่ปลอดภัยจาก DST
รูปแบบที่เชื่อถือได้คือ: ถือแต่ละ occurrence เป็นแนวคิดปฏิทินท้องถิ่นก่อน (วันที่ + เวลาในท้องถิ่น + โซนของสถานที่), แล้วแปลงเป็น instant เมื่อต้องเรียงลำดับ, ตรวจสอบข้อขัดแย้ง, หรือแสดงผลเท่านั้น
1) เก็บความตั้งใจเป็นท้องถิ่น ไม่ใช่เดาการเป็น UTC
บันทึกโซนเวลาของตาราง (ชื่อ IANA เช่น America/New_York) พร้อมเวลาเริ่มต้นท้องถิ่น (เช่น 09:00) เวลาท้องถิ่นนี้คือสิ่งที่ธุรกิจต้องการ แม้ DST จะเปลี่ยน
ยังเก็บระยะเวลาและขอบเขตที่ชัดเจนสำหรับกฎ: วันที่เริ่ม และทั้ง end date หรือ repeat count ขอบเขตป้องกันบัคการขยายไม่รู้จบ
2) แยกข้อยกเว้นและ overrides ออกมาต่างหาก
ใช้สองตารางเล็ก ๆ: หนึ่งสำหรับวันที่ข้าม, อีกหนึ่งสำหรับ occurrence ที่เปลี่ยนแปลง จับคีย์ด้วย schedule_id + local_date เพื่อจับคู่ recurrence ต้นฉบับอย่างสะอาด
รูปร่างที่ใช้งานได้จริงดูเหมือน:
-- 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) ขยายเฉพาะในหน้าต่างที่ร้องขอ
สร้างวันที่ท้องถิ่นผู้สมัครสำหรับช่วงที่คุณกำลังเรนเดอร์ (สัปดาห์, เดือน) กรองตามวันในสัปดาห์ แล้วใช้ skips และ overrides
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 สำหรับการเรียงลำดับ, ตรวจสอบข้อขัดแย้ง, และการจอง แปลงเป็นโซนของผู้ดูตอนแสดงผลเท่านั้น วิธีนี้หลีกเลี่ยงความประหลาดใจจาก DST และทำให้มุมมองปฏิทินสม่ำเสมอ
รูปแบบคิวรีเพื่อสร้างมุมมองปฏิทินที่ถูกต้อง
หน้าจอปฏิทินโดยทั่วไปเป็นคิวรีช่วง: “โชว์ทุกอย่างระหว่าง from_ts และ to_ts” รูปแบบที่ปลอดภัยคือ:
- ขยายเฉพาะผู้สมัครในหน้าต่างนั้น
- ใช้ข้อยกเว้น/overrides
- เอาแถวสุดท้ายออกมาเป็น
start_atและend_atในรูปtimestamptz
การขยายรายวันหรือรายสัปดาห์ด้วย generate_series
สำหรับกฎสัปดาห์ง่ายๆ (เช่น “ทุกจันทร์–ศุกร์ 09:00 ท้องถิ่น”) ให้สร้างวันที่ท้องถิ่นในโซนของตาราง แล้วแปลงแต่ละวันที่ท้องถิ่น + เวลาเป็น instant
-- 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 เกิดขึ้นต่อ occurrence ดังนั้นการเปลี่ยน DST จะถูกใช้ในวันที่ถูกต้อง
กฎซับซ้อนขึ้นด้วย recursive CTE
เมื่อกฎขึ้นกับ “nth weekday”, ช่องว่าง, หรือ interval แบบกำหนดเอง, recursive CTE สามารถสร้าง occurrence ถัดไปซ้ำ ๆ จนกว่าจะผ่าน to_ts ให้ยึดการเรียกซ้ำไว้ในหน้าต่างเพราะจะไม่รันไม่รู้จบ
หลังจากได้แถวผู้สมัคร ใช้ overrides และ cancellations โดยการ join ตารางข้อยกเว้นบน (rule_id, start_at) หรือคีย์ท้องถิ่นเช่น (rule_id, local_date) ถ้ามีบันทึกยกเลิก ให้ตัดแถวออก ถ้ามี override ให้แทนที่ start_at/end_at ด้วยค่าจาก override
รูปแบบประสิทธิภาพที่สำคัญที่สุด:
- จำกัดช่วงแต่เนิ่นๆ: กรองกฎก่อน แล้วค่อยขยายเฉพาะภายใน
[from_ts, to_ts) - ทำดัชนีตาราง exception/override บน
(rule_id, start_at)หรือ(rule_id, local_date) - หลีกเลี่ยงการขยายหลายปีสำหรับมุมมองเดือน
- แคช occurrence ที่ขยายเฉพาะถ้าคุณสามารถ invalidate ได้อย่างสะอาดเมื่อกฎเปลี่ยน
จัดการข้อยกเว้นและ overrides อย่างสะอาด
ตารางซ้ำมีประโยชน์ก็ต่อเมื่อคุณสามารถทำลายมันได้อย่างปลอดภัย ในแอปการจองและกะงาน “สัปดาห์ปกติ” เป็นกฎฐาน และทุกอย่างอื่นคือข้อยกเว้น: วันหยุด, ยกเลิก, การย้ายการนัดหมาย, หรือการสลับพนักงาน ถ้าข้อยกเว้นถูกเพิ่มทีหลัง มุมมองปฏิทินจะเบี้ยวและเกิดข้อมูลซ้ำ
เก็บสามแนวคิดแยกกัน:
- สคีมาฐาน (กฎการเกิดซ้ำและโซนเวลา)
- Skips (วันที่หรือตัวอย่างที่ต้องไม่เกิด)
- Overrides (occurrence ที่มีรายละเอียดเปลี่ยน)
ใช้ลำดับความสำคัญคงที่
เลือกลำดับหนึ่งและรักษาให้สม่ำเสมอ ตัวเลือกที่พบบ่อย:
- สร้างผู้สมัครจาก recurrence พื้นฐาน
- ใช้ overrides (แทนที่รายการที่สร้าง)
- ใช้ skips (ซ่อนมัน)
ทำให้กฎอธิบายง่ายในประโยคเดียวสำหรับผู้ใช้
หลีกเลี่ยงข้อมูลซ้ำเมื่อ override แทนที่ instance
ข้อมูลซ้ำมักเกิดเมื่อคิวรีส่งทั้ง occurrence ที่สร้างและแถว override ป้องกันโดยใช้คีย์คงที่:
- ให้แต่ละ instance ที่สร้างมีคีย์คงที่ เช่น
(schedule_id, local_date, start_time, tzid) - เก็บคีย์นั้นในแถว override เป็น “original occurrence key”
- เพิ่ม unique constraint เพื่อให้มี override ต่อ occurrence ต้นฉบับแค่หนึ่งรายการ
จากนั้นในคิวรี ให้ยกเว้น occurrence ที่สร้างแล้วที่มี override ตรงกัน และ union กับแถว override แทน
เก็บการตรวจสอบย้อนหลังโดยไม่สะดุด
ข้อยกเว้นคือที่ที่มีข้อพิพาทเกิดขึ้น (“ใครแก้กะฉัน?”) ใส่ฟิลด์ audit พื้นฐานบน skips และ overrides: created_by, created_at, updated_by, updated_at, และเหตุผลโดยย่อได้
ความผิดพลาดที่พบบ่อยซึ่งทำให้บัคเลื่อนหนึ่งชั่วโมง
บัคหนึ่งชั่วโมงมักเกิดจากการผสมความหมายของเวลา: instant (จุดบนเส้นเวลา UTC) กับการอ่านนาฬิกาท้องถิ่น (เช่น 09:00 ทุกวันจันทร์ใน New York)
ความผิดพลาดคลาสสิกคือเก็บกฎเวลาท้องถิ่นเป็น timestamptz ถ้าคุณบันทึก “จันทร์เวลา 09:00 America/New_York” เป็น timestamptz หนึ่งค่า คุณได้เลือกวันที่เฉพาะ (และสถานะ DST) แล้ว ต่อมาพอจะสร้างจันทร์ถัดไป ความตั้งใจเดิมว่า “เสมอ 09:00 ท้องถิ่น” หายไป
สาเหตุอีกประการคือพึ่งพาออฟเซ็ตคงที่เช่น -05:00 แทนชื่อโซน IANA ออฟเซ็ตไม่รวมกฎ DST เก็บไอดีโซน (เช่น America/New_York) และให้ PostgreSQL ใช้กฎที่ถูกต้องในแต่ละวัน
ระวังช่วงเวลาที่แปลง หากคุณแปลงเป็น UTC เร็วเกินไปในขณะสร้าง recurrence คุณอาจตรึงออฟเซ็ต DST และนำไปใช้กับทุก occurrence รูปแบบที่ปลอดภัยคือ: สร้าง occurrence ในเงื่อนไขท้องถิ่น (date + local time + zone) แล้วแปลงแต่ละ occurrence เป็น instant
ข้อผิดพลาดที่ปรากฏบ่อย:
- ใช้
timestamptzเพื่อเก็บเวลาในวันท้องถิ่นที่เกิดซ้ำ (ควรใช้time+tzid+ กฎ) - เก็บแต่ออฟเซ็ต ไม่ใช่ชื่อโซน IANA
- แปลงขณะที่กำลังสร้าง recurrence แทนที่จะทำทีหลัง
- ขยาย recurrence “ตลอดไป” โดยไม่มีหน้าต่างเวลาจริง
- ไม่ทดสอบสัปดาห์เริ่ม/สิ้นสุด DST
การทดสอบง่าย ๆ ที่จับปัญหาส่วนใหญ่: เลือกโซนที่มี DST, สร้างกะรายสัปดาห์เวลา 09:00, และเรนเดอร์ปฏิทินสองเดือนที่ข้ามการเปลี่ยน DST ยืนยันว่าแต่ละ instance แสดงเป็น 09:00 ท้องถิ่น แม้ instants ในพื้นฐานจะแตกต่างกัน
รายการตรวจสอบด่วนก่อนปล่อย
ก่อนปล่อย ตรวจสอบพื้นฐาน:
- ทุกสคีมาผูกกับสถานที่ (หรือหน่วยธุรกิจ) ที่มีชื่อโซนเวลา เก็บไว้ในสคีมาเอง
- เก็บไอดีโซน IANA (เช่น
America/New_York) ไม่ใช่ออฟเซ็ตดิบ - การขยาย recurrence สร้าง occurrence เฉพาะภายในช่วงที่ร้องขอ
- ข้อยกเว้นและ overrides มีลำดับความสำคัญที่อธิบายได้ชัดเจน
- ทดสอบสัปดาห์เปลี่ยน DST และผู้ดูในโซนเวลาต่างจากตาราง
ทำ dry run ที่เป็นจริง: ร้านใน Europe/Berlin มีกะรายสัปดาห์เวลา 09:00 ท้องถิ่น ผู้จัดการดูจาก America/Los_Angeles ยืนยันว่ากะยังคงเป็น 09:00 Berlin ทุกสัปดาห์ แม้แต่ละภูมิภาคจะเปลี่ยน DST ในวันต่างกัน
ตัวอย่าง: กะพนักงานรายสัปดาห์มีวันหยุดและการเปลี่ยน DST
คลินิกขนาดเล็กมีหนึ่งกะซ้ำ: ทุกวันจันทร์ 09:00–17:00 ในโซนท้องถิ่นของคลินิก (America/New_York) คลินิกปิดวันหยุดหนึ่งวันจันทร์ พนักงานคนหนึ่งเดินทางในยุโรปสองสัปดาห์ แต่ตารางของคลินิกต้องยึดกับเวลาบนผนังของคลินิก ไม่ใช่ตำแหน่งของพนักงานขณะนั้น
ทำให้สิ่งนี้ทำงานถูกต้องได้โดย:
- เก็บกฎ recurrence ยึดกับวันที่ท้องถิ่น (weekday = Monday, เวลาในท้องถิ่น = 09:00–17:00)
- เก็บโซนเวลาของสคีมา (
America/New_York) - เก็บวันที่เริ่มใช้งานเพื่อให้กฎมีจุดยึดชัดเจน
- เก็บข้อยกเว้นเพื่อยกเลิกวันหยุดจันทร์นั้น (และ overrides สำหรับการเปลี่ยนแปลงครั้งเดียว)
ตอนนี้เรนเดอร์ช่วงปฏิทินสองสัปดาห์ที่ครอบคลุมการเปลี่ยน DST ใน New York คิวรีจะสร้างวันจันทร์ในช่วงวันที่ท้องถิ่นนั้น แนบเวลาท้องถิ่นของคลินิก แล้วแปลงแต่ละ occurrence เป็น instant (timestamptz) เพราะการแปลงเกิดขึ้นต่อ occurrence DST จึงถูกจัดการถูกต้องในวันนั้น
ผู้ดูต่างคนต่างเห็นเวลาในนาฬิกาที่ต่างกันสำหรับ instant เดียวกัน:
- ผู้จัดการใน Los Angeles จะเห็นเวลาเร็วกว่าบนหน้าปัด
- พนักงานที่เดินทางใน Berlin จะเห็นเวลาช้ากว่า
แต่คลินิกได้สิ่งที่ต้องการ: 09:00–17:00 New York ทุกวันจันทร์ที่ไม่ได้ยกเลิก
ขั้นตอนถัดไป: นำไปใช้, ทดสอบ, และรักษาความเรียบร้อย
นิยามแนวทางเรื่องเวลาให้ชัดเจนตั้งแต่ต้น: คุณจะเก็บเฉพาะกฎ, เก็บเฉพาะ occurrence, หรือไฮบริด? สำหรับหลายผลิตภัณฑ์การจองและกะงาน ไฮบริดใช้งานได้ดี: เก็บกฎเป็นแหล่งความจริง, เก็บแคชเลื่อนถ้าจำเป็น, และเก็บข้อยกเว้น/overrides เป็นแถวจริง
เขียน "สัญญาเวลา" ของคุณไว้ในที่เดียว: อะไรนับเป็น instant, อะไรนับเป็น local wall time, และคอลัมน์ไหนเก็บแต่ละอย่าง นี่จะป้องกันเมื่อจุดหนึ่งส่งคืน local time ขณะที่อีกจุดส่งคืน UTC
เก็บการสร้าง recurrence ในโมดูลเดียว ไม่กระจัดกระจายเป็นชิ้น SQL ถ้าต้องเปลี่ยนการตีความ "9:00 AM local" คุณจะมีที่เดียวที่ต้องแก้
ถ้าคุณกำลังสร้างเครื่องมือการจัดตารางโดยไม่เขียนทุกอย่างด้วยมือ AppMaster (appmaster.io) เป็นทางเลือกที่ใช้งานได้สำหรับงานประเภทนี้: คุณสามารถออกแบบฐานข้อมูลใน Data Designer, สร้างตรรกะ recurrence และข้อยกเว้นใน business processes, และยังได้ backend และโค้ดแอปจริง


