TIMESTAMPTZ กับ TIMESTAMP: บนแดชบอร์ดและ API ของ PostgreSQL
TIMESTAMPTZ กับ TIMESTAMP ใน PostgreSQL: การเลือกชนิดข้อมูลส่งผลต่อแดชบอร์ด, การตอบ API, การแปลงโซนเวลา และบั๊กจากการปรับเวลาออมแสง

ปัญหาจริง: เวลาเดียว หลายการตีความ
เหตุการณ์เกิดขึ้นเพียงครั้งเดียว แต่ถูกรายงานได้หลายรูปแบบ ฐานข้อมูลเก็บค่า หนึ่ง API ซีเรียไลซ์มัน แดชบอร์ดจัดกลุ่ม และแต่ละคนดูในโซนเวลาของตัวเอง หากชั้นใดชั้นหนึ่งสมมติแตกต่าง แถวเดียวกันอาจดูเหมือนสองช่วงเวลาแตกต่างกัน
นั่นคือเหตุผลที่การเลือก TIMESTAMPTZ กับ TIMESTAMP ไม่ใช่แค่ความชอบชนิดข้อมูล มันตัดสินว่าค่าที่เก็บหมายถึงวินาทีจริงหนึ่งค่า หรือเวลาบนหน้าปัดที่มีความหมายเฉพาะในสถานที่หนึ่ง ๆ
สิ่งที่มักพังก่อนคือ: แดชบอร์ดยอดขายแสดงยอดรวมรายวันต่างกันในนิวยอร์กและเบอร์ลิน ชาร์ตรายชั่วโมงมีชั่วโมงหายไปหรือซ้ำในช่วงการเปลี่ยน DST บันทึกตรวจสอบดูเรียงลำดับผิดเพราะสองระบบ “เห็นด้วย” ในวันที่แต่ไม่ใช่วินาทีจริง
โมเดลง่าย ๆ ช่วยลดปัญหาได้:
- Storage: สิ่งที่คุณบันทึกใน PostgreSQL และมันแสดงความหมายอะไร
- Display: วิธีที่คุณจัดรูปแบบใน UI, ส่งออก หรือรายงาน
- User locale: โซนเวลาของผู้ชมและกฎปฏิทิน รวมถึง DST
ถ้าผสมสิ่งเหล่านี้ คุณจะได้บั๊กการรายงานที่เงียบ ๆ ทีมซัพพอร์ตส่งออก “ตั๋วที่สร้างเมื่อวาน” จากแดชบอร์ด แล้วเทียบกับรายงานจาก API ทั้งคู่ดูเหมาะสม แต่หนึ่งอันใช้ขอบเขตเที่ยงคืนของผู้ชม ส่วนอีกอันใช้ UTC
เป้าหมายง่าย ๆ: สำหรับทุกค่าที่เกี่ยวกับเวลา ให้ตัดสินใจสองอย่างชัดเจน ตัดสินใจว่าจะเก็บอะไร และตัดสินใจว่าจะโชว์อะไร ความชัดเจนเดียวกันต้องไหลผ่านโมเดลข้อมูล, ผลลัพธ์ API และแดชบอร์ด เพื่อให้ทุกคนเห็นเส้นเวลาเดียวกัน
ความหมายจริงของ TIMESTAMP และ TIMESTAMPTZ
ใน PostgreSQL ชื่อดูชวนเข้าใจผิด พวกมันเหมือนจะอธิบายสิ่งที่ถูกเก็บ แต่จริง ๆ แล้วอธิบายวิธีที่ PostgreSQL ตีความอินพุตและจัดรูปแบบเอาต์พุต
TIMESTAMP (หรือ timestamp without time zone) เป็นเพียงวันที่และเวลาในปฏิทิน เช่น 2026-01-29 09:00:00 ไม่มีโซนเวลาแนบ PostgreSQL จะไม่แปลงให้ คนสองคนในโซนเวลาต่างกันอาจอ่าน TIMESTAMP เดียวกันแล้วถือว่าเป็นช่วงเวลาโลกจริงต่างกันได้
TIMESTAMPTZ (หรือ timestamp with time zone) แทนวินาทีจริง คิดว่าเป็น instant PostgreSQL นอร์มัลไลซ์ภายใน (โดยหลักการเป็น UTC) แล้วแสดงตามโซนเวลาของเซสชันที่กำลังใช้
พฤติกรรมที่ทำให้คนประหลาดใจส่วนใหญ่คือ:
- On input: PostgreSQL แปลงค่า
TIMESTAMPTZเป็นวินาทีเดียวที่เปรียบเทียบได้ - On output: PostgreSQL จัดรูปแบบ instant นั้นโดยใช้โซนเวลาของเซสชันปัจจุบัน
- For
TIMESTAMP: ไม่มีการแปลงอัตโนมัติทั้งตอนรับและแสดงผล
ตัวอย่างเล็ก ๆ แสดงความแตกต่าง สมมติแอปของคุณรับค่า 2026-03-08 02:30 จากผู้ใช้ ถ้าคุณใส่ลงคอลัมน์ TIMESTAMP PostgreSQL จะเก็บค่านาฬิกาแบบ wall-clock นั้นเป๊ะ ๆ ถ้าเวลาโลคอลนั้นไม่มีจริงเพราะการกระโดดของ DST คุณอาจไม่สังเกตจนกระทั่งรายงานพัง
ถ้าคุณใส่ลง TIMESTAMPTZ PostgreSQL ต้องใช้โซนเวลาในการตีความ ถ้าคุณระบุ 2026-03-08 02:30 America/New_York PostgreSQL จะเปลี่ยนเป็นวินาทีจริง (หรือโยนข้อผิดพลาดขึ้นอยู่กับกฎและค่านั้น) ต่อมาดashboard ในลอนดอนจะแสดงเวลาในนาฬิกาท้องถิ่นต่างไป แต่เป็นวินาทีจริงเดียวกัน
ความเข้าใจผิดทั่วไป: คนเห็นคำว่า “with time zone” แล้วคิดว่า PostgreSQL เก็บป้ายชื่อโซนเวลาต้นทาง มันไม่ได้ทำเช่นนั้น PostgreSQL เก็บแค่วินาทีจริง ไม่ใช่ป้ายชื่อ หากคุณต้องการโซนเวลาต้นทางของผู้ใช้เพื่อการแสดงผล (เช่น “แสดงตามเวลาโลคอลของลูกค้า”) ให้เก็บโซนนั้นแยกเป็นฟิลด์ข้อความ
Session time zone: การตั้งค่าที่ซ่อนอยู่เบื้องหลังความประหลาดใจหลายอย่าง
PostgreSQL มีการตั้งค่าซึ่งค่อย ๆ เปลี่ยนสิ่งที่คุณเห็น: session time zone สองคนสามารถรันคำสั่งเดียวกันบนข้อมูลเดียวกันแล้วได้เวลาบนหน้าปัดต่างกันเพราะเซสชันใช้โซนเวลาไม่เหมือนกัน
สิ่งนี้ส่งผลมากกับ TIMESTAMPTZ PostgreSQL เก็บวินาทีจริงแต่แสดงตามโซนเวลาของเซสชัน สำหรับ TIMESTAMP PostgreSQL ถือค่านั้นเป็นเวลาแบบปกติ มันจะไม่เลื่อนเพื่อแสดง แต่ session time zone ยังสามารถทำให้คุณเจอปัญหาเมื่อคุณแปลงเป็น TIMESTAMPTZ หรือเปรียบเทียบกับค่าที่รับรู้โซนเวลา
session time zone มักถูกตั้งโดยไม่รู้ตัว: config ตอนสตาร์ทแอป, พารามิเตอร์ไดรเวอร์, connection pool ที่ reuse เซสชันเก่า, เครื่องมือ BI ที่มีค่าเริ่มต้นของตัวเอง, งาน ETL ที่รับค่าตำแหน่งเซิร์ฟเวอร์, หรือคอนโซล SQL แบบแมนนวลที่ใช้ค่าพรีเฟอเรนซ์จากแลปท็อปของคุณ
นี่คือเหตุผลทีมมักโต้เถียง สมมติเหตุการณ์ถูกเก็บเป็น 2026-03-08 01:30:00+00 ในคอลัมน์ TIMESTAMPTZ เซสชันแดชบอร์ดที่ตั้งเป็น America/Los_Angeles จะแสดงเป็นเวลาเย็นวันก่อนตามโลคาล ในขณะที่เซสชัน API ใน UTC จะแสดงเวลาอีกแบบ หากชาร์ตรวมตามวันโดยใช้วันตามเซสชัน คุณอาจได้ยอดรวมรายวันที่ต่างกัน
-- Make your output consistent for a reporting job
SET TIME ZONE 'UTC';
SELECT created_at, date_trunc('day', created_at) AS day_bucket
FROM events;
สำหรับทุกอย่างที่ผลิตรายงานหรือผลลัพธ์ API ให้กำหนดโซนเวลาอย่างชัดเจน ตั้งค่ามันเมื่อเชื่อมต่อ (หรือรัน SET TIME ZONE ก่อน) เลือกมาตรฐานสำหรับผลลัพธ์เครื่อง (บ่อยครั้งเป็น UTC) และสำหรับรายงาน “เวลาโลคัลธุรกิจ” ให้ตั้งโซนธุรกิจภายในงาน ไม่ใช่บนแลปท็อปของใครบางคน หากคุณใช้ pooled connections ให้รีเซ็ตการตั้งค่าเซสชันเมื่อเช็กเอาท์การเชื่อมต่อ
วิธีที่แดชบอร์ดพัง: การจัดกลุ่ม, บัคเก็ต, และช่องว่างจาก DST
แดชบอร์ดดูเรียบง่าย: นับคำสั่งซื้อต่อวัน, แสดงการสมัครต่อชั่วโมง, เปรียบเทียบสัปดาห์ต่อสัปดาห์ ปัญหาเริ่มเมื่อฐานข้อมูลเก็บ “วินาที” แต่ชาร์ตเปลี่ยนมันเป็นหลาย “วัน” ขึ้นกับใครกำลังดู
หากคุณจัดกลุ่มตามวันโดยใช้โซนเวลาผู้ใช้ สองคนอาจเห็นวันที่ต่างกันสำหรับเหตุการณ์เดียว คำสั่งซื้อที่ทำเวลา 23:30 ในลอสแองเจลิสอาจเป็น “วันพรุ่งนี้” แล้วในเบอร์ลิน และถ้าคุณใช้ DATE(created_at) บน TIMESTAMP ธรรมดา คุณไม่ได้จัดกลุ่มตามวินาทีจริง คุณกำลังจัดกลุ่มตามการอ่าน wall-clock ที่ไม่มีโซนเวลาติดมาด้วย
ชาร์ตรายชั่วโมงยิ่งสับสนรอบ DST ในฤดูใบไม้ผลิ หนึ่งชั่วโมงท้องถิ่นอาจไม่มีจริง ทำให้ชาร์ตแสดงช่องว่าง ในฤดูใบไม้ร่วง หนึ่งชั่วโมงท้องถิ่นอาจเกิดซ้ำ ทำให้เกิดสปายกหรือบัคเก็ตซ้ำหากคิวรีและแดชบอร์ดไม่เห็นตรงกันว่าคุณหมายถึง 01:30 ตัวไหน
คำถามเชิงปฏิบัติช่วยได้: คุณกำลังชาร์ตวินาทีจริง (ปลอดภัยที่จะเปลี่ยนโซน) หรือเวลาตารางเวลาโลคัล (ห้ามแปลง)? แดชบอร์ดมักต้องการวินาทีจริง
เมื่อใดควรจัดกลุ่มตาม UTC หรือโซนธุรกิจ
เลือกกฎการจัดกลุ่มหนึ่งแบบและใช้ให้ทั่วทั้งระบบ (SQL, API, เครื่องมือ BI) มิฉะนั้นยอดรวมจะไหล
จัดกลุ่มตาม UTC เมื่อคุณต้องการซีรีส์ที่คงที่ระดับโลก (สถานะระบบ, ทราฟฟิก API, การสมัครทั่วโลก) จัดกลุ่มตามโซนธุรกิจเมื่อ “วัน” มีความหมายทางกฎหมายหรือการปฏิบัติการ (วันของสโตร์, SLA ฝ่ายบริการ, ปิดบัญชีการเงิน) จัดกลุ่มตามโซนของผู้ชมเฉพาะเมื่อการปรับแต่งสำคัญกว่าการเปรียบเทียบ (ฟีดกิจกรรมส่วนบุคคล)
นี่คือรูปแบบสำหรับการจัดกลุ่ม “วันธุรกิจ” ที่สอดคล้องกัน:
SELECT date_trunc('day', created_at AT TIME ZONE 'America/New_York') AS business_day,
count(*)
FROM orders
GROUP BY 1
ORDER BY 1;
ป้ายกำกับที่ป้องกันความไม่ไว้ใจ
ผู้คนจะหยุดเชื่อชาร์ตเมื่อจำนวนเด้งและไม่มีใครอธิบายได้ ทำป้ายกฎให้ชัดเจนใน UI: “Daily orders (America/New_York)” หรือ “Hourly events (UTC)” ใช้กฎเดียวกันในการส่งออกและ API
ชุดกฎง่าย ๆ สำหรับการรายงานและ API
ตัดสินใจว่าคุณกำลังเก็บวินาทีจริงหรือการอ่านเวลาโลคัล การผสมทั้งสองคือสาเหตุที่แดชบอร์ดและ API เริ่มไม่ตรงกัน
ชุดกฎที่ทำให้การรายงานคาดเดาได้:
- เก็บเหตุการณ์โลกจริงเป็นวินาทีด้วย
TIMESTAMPTZและถือว่า UTC เป็นแหล่งข้อมูลจริง - เก็บแนวคิดเชิงธุรกิจเช่น “billing day” แยกเป็น
DATE(หรือฟิลด์เวลาท้องถิ่นถ้าต้องการจริง ๆ) - ใน API คืนค่า timestamp เป็น ISO 8601 และสม่ำเสมอ: รวมออฟเซ็ตเสมอ (เช่น
+02:00) หรือใช้Zสำหรับ UTC เสมอ - แปลงที่ขอบระบบ (UI และเลเยอร์รายงาน) หลีกเลี่ยงการแปลงไปมากลางฐานข้อมูลและงานแบ็กกราวด์
ทำไมกฎนี้ทนทาน: แดชบอร์ดต้องจัดบัคเก็ตและเปรียบเทียบช่วง หากคุณเก็บวินาทีจริง (TIMESTAMPTZ) PostgreSQL สามารถเรียงลำดับและกรองเหตุการณ์ได้อย่างเชื่อถือได้แม้เมื่อ DST เปลี่ยน แล้วคุณเลือกวิธีแสดงหรือจัดกลุ่ม ถ้าคุณเก็บเวลาโลคัล (TIMESTAMP) โดยไม่มีโซน PostgreSQL จะไม่รู้ความหมายของมัน ดังนั้นการจัดกลุ่มอาจเปลี่ยนเมื่อ session time zone เปลี่ยน
เก็บ “วันธุรกิจโลคัล” แยกเพราะมันไม่ใช่วินาที “ส่งมอบใน 2026-03-08” เป็นการตัดสินใจเกี่ยวกับวัน ไม่ใช่วินาที หากคุณบังคับให้เป็น timestamp วัน DST อาจทำให้ชั่วโมงโลคัลหายหรือซ้ำ ซึ่งต่อมาปรากฏเป็นช่องว่างหรือสปายก
ทีละขั้นตอน: เลือกชนิดที่ถูกต้องสำหรับแต่ละค่าของเวลา
การเลือก TIMESTAMPTZ หรือ TIMESTAMP เริ่มจากคำถามเดียว: ค่านี้บรรยายวินาทีจริงที่เกิดขึ้นหรือเป็นเวลาโลคัลที่คุณต้องการเก็บไว้เป๊ะ ๆ?
1) แยกเหตุการณ์จริงออกจากเวลาที่กำหนดตามโลคอล
ทำบัญชีคอลัมน์เวลาของคุณอย่างรวดเร็ว
เหตุการณ์จริง (คลิก, การชำระเงิน, การเข้าสู่ระบบ, การจัดส่ง, การอ่านเซนเซอร์, ข้อความซัพพอร์ต) ควรเก็บเป็น TIMESTAMPTZ โดยปกติ คุณต้องการวินาทีที่ไม่กำกวม แม้ผู้คนจะดูจากโซนเวลาต่างกัน
เวลาที่กำหนดตามโลคอลต่างกัน: “ร้านเปิด 09:00”, “หน้าต่างรับของ 16:00–18:00”, “การเรียกเก็บรันวันที่ 1 เวลา 10:00 โลคอล” เหล่านี้มักเหมาะกับ TIMESTAMP บวกฟิลด์โซนเวลาหรือโลเคชันแยก เพราะเจตนามีผูกกับนาฬิกาท้องถิ่น
2) เลือกมาตรฐานและจดมันไว้
สำหรับผลิตภัณฑ์ส่วนใหญ่ ค่าเริ่มต้นที่ดีคือ: เก็บเวลาเหตุการณ์เป็น UTC นำเสนอในโซนเวลาผู้ใช้ จดบันทึกในที่ที่คนอ่านจริง ๆ: โน้ตสกีมา, เอกสาร API, และคำอธิบายแดชบอร์ด นอกจากนี้ให้กำหนดความหมายของ “วันธุรกิจ” (วัน UTC, วันโซนธุรกิจ, หรือวันโลคอลของผู้ชม) เพราะการเลือกนี้กำหนดการรายงานรายวัน
รายการตรวจสอบสั้นที่ใช้งานได้จริง:
- ติดแท็กแต่ละคอลัมน์เวลาเป็น “event instant” หรือ “local schedule”
- ตั้งค่าเริ่มต้น event instants เป็น
TIMESTAMPTZเก็บเป็น UTC - เมื่อต้องเปลี่ยนสกีมา ให้ backfill อย่างระมัดระวังและตรวจสอบตัวอย่างด้วยมือ
- มาตรฐานรูปแบบ API (รวม
Zหรือออฟเซ็ตสำหรับ instants) - ตั้งค่า session time zone อย่างชัดเจนในงาน ETL, ตัวเชื่อม BI, และ worker พื้นหลัง
ระวังงาน “แปลงและ backfill” การเปลี่ยนชนิดคอลัมน์อาจเปลี่ยนความหมายอย่างเงียบ ๆ ถ้าค่าที่มีเดิมถูกตีความภายใต้โซนเวลาที่ต่างกัน
ข้อผิดพลาดทั่วไปที่ทำให้คลาดเคลื่อนหนึ่งวันและบั๊ก DST
บั๊กเวลาส่วนใหญ่ไม่ใช่ “PostgreSQL แปลก” แต่เกิดจากการเก็บค่าที่ดูถูกต้องแต่มีความหมายผิด แล้วปล่อยให้เลเยอร์ต่าง ๆ เดา context ที่ขาดหายไป
ข้อผิดพลาด 1: เก็บเวลา wall-clock ราวกับว่าเป็นเวลาสัมบูรณ์
กับดักทั่วไปคือเก็บเวลาโลคัล (เช่น “2026-03-29 09:00” ในเบอร์ลิน) ลงใน TIMESTAMPTZ PostgreSQL จะตีความเป็นวินาทีจริงและแปลงตาม session time zone ถ้าความหมายตั้งใจคือ “เสมอ 9 AM โลคอล” คุณจะเสียความหมายไป การดูแถวเดียวกันภายใต้เซสชันอีกอันจะแสดงชั่วโมงที่เลื่อนไป
สำหรับนัดหมาย ให้เก็บเวลาโลคอลเป็น TIMESTAMP พร้อมโซนหรือโลเคชันแยก สำหรับเหตุการณ์ที่เกิดขึ้นจริง (การชำระเงิน, การเข้าสู่ระบบ) ให้เก็บเป็น TIMESTAMPTZ
ข้อผิดพลาด 2: สภาพแวดล้อมต่างกัน สมมติฐานต่างกัน
แลปท็อป สเตจ และโปรดักชันของคุณอาจไม่ใช้โซนเวลาเดียวกัน หนึ่งรันใน UTC อีกหนึ่งรันในโซนโลคอล รายงาน "group by day" เริ่มไม่ตรง ข้อมูลไม่เปลี่ยน แต่การตั้งค่าเซสชันเปลี่ยน
ข้อผิดพลาด 3: ใช้ฟังก์ชันเวลาโดยไม่รู้ว่ามันสัญญาอะไร
now() และ current_timestamp คงที่ภายในทรานแซกชัน clock_timestamp() เปลี่ยนทุกครั้งที่เรียก หากคุณสร้าง timestamp หลายจุดในทรานแซกชันเดียวและผสมฟังก์ชันเหล่านี้ การเรียงลำดับและระยะเวลาอาจดูแปลก
ข้อผิดพลาด 4: แปลงสองครั้ง (หรือน้อยเกินไป)
บั๊ก API ที่พบบ่อย: แอปแปลงเวลาโลคอลเป็น UTC แล้วส่งเป็นสตริงแบบ naive จากนั้นเซสชันฐานข้อมูลแปลงอีกครั้งเพราะสมมติว่าอินพุตเป็นโลคอล ตรงกันข้ามก็เกิดได้: แอปส่งเวลาโลคอลแต่ใส่ Z (UTC) ทำให้เวลาถูกเลื่อนเมื่อแสดง
ข้อผิดพลาด 5: จัดกลุ่มตามวันที่โดยไม่ระบุโซนที่ตั้งใจ
“ยอดรวมรายวัน” ขึ้นกับขอบเขตวันที่คุณหมายถึง ถ้าจัดกลุ่มด้วย date(created_at) บน TIMESTAMPTZ ผลลัพธ์จะตามโซนเวลาของเซสชัน เหตุการณ์ดึกอาจย้ายไปยังวันก่อนหรือวันถัดไป
ก่อนส่งแดชบอร์ดหรือ API ให้ตรวจเช็คพื้นฐาน: เลือกโซนการรายงานหนึ่งอันต่อชาร์ตและใช้มันให้สม่ำเสมอ, รวมออฟเซ็ต (หรือ Z) ใน payload ของ API, ให้สเตจและโปรดักชันสอดคล้องในนโยบายโซนเวลา, และชัดเจนว่าคุณหมายถึงโซนเวลาใดเมื่อจัดกลุ่ม
การตรวจสอบด่วนก่อนส่งแดชบอร์ดหรือ API
บั๊กเวลาไม่ค่อยมาจากคิวรีผิดเพียงชุดเดียว แต่เกิดเพราะ storage, reporting, และ API แต่ละชั้นสมมติเล็กน้อยต่างกัน
ใช้เช็กลิสต์สั้นก่อนส่ง:
- สำหรับเหตุการณ์โลกจริง (signups, payments, sensor pings) เก็บเป็น
TIMESTAMPTZ - สำหรับแนวคิดเชิงธุรกิจโลคอล (billing day, reporting date) เก็บเป็น
DATEหรือTIMEแทน timestamp ที่ตั้งใจจะ "แปลงทีหลัง" - ในงานตามตารางและรันเนอร์รายงาน ให้ตั้ง session time zone อย่างตั้งใจ
- ในการตอบ API ให้รวมออฟเซ็ตหรือ
Zและยืนยันว่าไคลเอ็นต์แปลงเป็นค่าที่รับรู้โซนเวลา - ทดสอบสัปดาห์การเปลี่ยน DST สำหรับอย่างน้อยหนึ่งโซนเป้าหมาย
การตรวจสอบแบบ end-to-end เร็ว ๆ: เลือกเหตุการณ์ขอบเขตที่รู้จักหนึ่งรายการ (เช่น 2026-03-08 01:30 ในโซนที่สังเกต DST) และติดตามมันผ่านการเก็บ, เอาต์พุตคิวรี, JSON ของ API, และป้ายชาร์ตสุดท้าย หากชาร์ตแสดงวันถูกแต่ tooltip แสดงชั่วโมงผิด (หรือกลับกัน) นั่นคือการไม่ตรงกันของการแปลง
ตัวอย่าง: ทำไมสองทีมถึงไม่เห็นตัวเลขวันเดียวกัน
ทีมซัพพอร์ตในนิวยอร์กและทีมการเงินในเบอร์ลินดูแดชบอร์ดเดียวกัน เซิร์ฟเวอร์ฐานข้อมูลรันใน UTC ทุกคนยืนยันว่าตัวเองถูก แต่ “เมื่อวาน” ต่างกันขึ้นกับใครถาม
นี่คือเหตุการณ์: ตั๋วลูกค้าถูกสร้างเวลา 23:30 ในนิวยอร์กวันที่ 10 มีนาคม นั่นคือ 04:30 UTC วันที่ 11 มีนาคม และ 05:30 ในเบอร์ลิน หนึ่งวินาทีจริง สามวันที่ปฏิทินต่างกัน
หากเวลาสร้างถูกเก็บเป็น TIMESTAMP (ไม่มีโซน) และแอปของคุณสมมติว่ามันเป็น “โลคอล” คุณอาจเงียบ ๆ เขียนประวัติใหม่ นิวยอร์กอาจถือ 2026-03-10 23:30 เป็นเวลานิวยอร์ก ขณะที่เบอร์ลินตีความค่าเดียวกันเป็นเวลาเบอร์ลิน แถวเดียวกันตกบนวันที่ต่างกันสำหรับผู้ชมต่างกัน
ถ้ามันถูกเก็บเป็น TIMESTAMPTZ PostgreSQL จะเก็บวินาทีจริงอย่างสม่ำเสมอและแปลงเมื่อมีการแสดง นี่คือเหตุผลที่ TIMESTAMPTZ vs TIMESTAMP เปลี่ยนความหมายของ “หนึ่งวัน” ในรายงาน
การแก้คือแยกสองแนวคิด: เวลาที่เหตุการณ์เกิด และวันที่รายงานที่คุณต้องการใช้
รูปแบบปฏิบัติ:
- เก็บเวลาเหตุการณ์เป็น
TIMESTAMPTZ. - ตัดสินใจกฎการรายงาน: viewer-local (แดชบอร์ดส่วนบุคคล) หรือโซนธุรกิจเดียว (การเงินบริษัท)
- คำนวณวันที่สำหรับรายงานในเวลาคิวรีโดยใช้กฎนั้น: แปลง instant เป็นโซนที่เลือก แล้วค่อยเอา date
ขั้นตอนต่อไป: มาตรฐานการจัดการเวลาในสแตกของคุณ
หากการจัดการเวลาไม่ถูกเขียนไว้ ทุกรายงานใหม่จะกลายเป็นการเดา ตั้งเป้าหมายให้พฤติกรรมเวลาน่าเบื่อและคาดเดาได้ทั่วทั้งฐานข้อมูล, API, และแดชบอร์ด
เขียน “สัญญาเรื่องเวลา” สั้น ๆ ที่ตอบสามคำถาม:
- Event time standard: เก็บ event instants เป็น
TIMESTAMPTZ(โดยทั่วไปเป็น UTC) เว้นแต่มีเหตุผลหนักแน่นไม่ทำ - Business time zone: เลือกโซนหนึ่งสำหรับการรายงาน และใช้สม่ำเสมอเมื่อกำหนด “day”, “week”, และ “month”
- API format: ส่ง timestamp พร้อมออฟเซ็ตเสมอ (ISO 8601 กับ
Zหรือ+/-HH:MM) และอธิบายว่าแต่ละฟิลด์หมายถึง “instant” หรือ “local wall time”
เพิ่มเทสเล็ก ๆ รอบการเริ่มและจบ DST พวกมันจับบั๊กแพง ๆ แต่เนิ่น ๆ ตัวอย่าง: ตรวจสอบว่า query “daily total” คงที่สำหรับโซนธุรกิจคงที่ข้ามการเปลี่ยน DST และว่าอินพุต API เช่น 2026-11-01T01:30:00-04:00 และ 2026-11-01T01:30:00-05:00 ถูกปฏิบัติว่าเป็นวินาทีต่างกันสองค่า
วางแผนการมิเกรตอย่างระมัดระวัง การเปลี่ยนชนิดและสมมติฐานในที่เดียวสามารถเขียนประวัติในชาร์ตใหม่อย่างเงียบ ๆ วิธีปลอดภัยขึ้นคือเพิ่มคอลัมน์ใหม่ (เช่น created_at_utc TIMESTAMPTZ), backfill ด้วยการแปลงที่ตรวจสอบแล้ว, อัปเดตการอ่านให้ใช้คอลัมน์ใหม่, แล้วอัปเดตการเขียน เก็บรายงานเก่าและใหม่ไว้ข้างกันสักพักเพื่อให้การเปลี่ยนยอดรวมรายวันเห็นชัด
If you want one place to enforce this “time contract” across data models, APIs, and screens, a unified build setup helps. AppMaster (appmaster.io) generates backend, web app, and APIs from a single project, which makes it easier to keep timestamp storage and display rules consistent as your app grows.
คำถามที่พบบ่อย
ใช้ TIMESTAMPTZ กับเหตุการณ์ที่เกิดขึ้นในช่วงเวลาจริง (signups, payments, logins, messages, sensor pings) เพราะมันเก็บวินาทีนาทีที่ชัดเจนเพียงค่าหนึ่งเดียวและสามารถจัดเรียง กรอง และเปรียบเทียบได้ข้ามระบบ ใช้ TIMESTAMP เฉพาะเมื่อค่านั้นตั้งใจให้เป็นเวลาแบบ wall-clock ที่ต้องเก็บไว้ตามที่เขียนจริง มักจะจับคู่กับฟิลด์โซนเวลา/ตำแหน่งแยกต่างหาก
TIMESTAMPTZ หมายถึงช่วงเวลาจริง (instant); PostgreSQL จะทำการนอร์มัลไลซ์ภายในและแสดงตามโซนเวลาของเซสชัน ส่วน TIMESTAMP เป็นเพียงวันที่และเวลาในนาฬิกาแบบไม่มีโซน ดังนั้น PostgreSQL จะไม่ปรับเปลี่ยนอัตโนมัติ ความแตกต่างสำคัญคือความหมาย: instant vs local wall time
เพราะโซนเวลาของเซสชันเป็นตัวกำหนดการจัดรูปแบบบนเอาต์พุตสำหรับ TIMESTAMPTZ และวิธีการตีความอินพุต หากสองเครื่องมือรันคำสั่งเดียวกันแต่เซสชันตั้งค่าเป็น UTC กับ America/Los_Angeles ผลลัพธ์ที่แสดงในรูปเวลาอาจต่างกัน สำหรับรายงานและ API ให้ตั้งค่าโซนเวลาเซสชันอย่างชัดเจนเพื่อไม่ให้ผลลัพธ์ขึ้นกับค่าเริ่มต้นที่ซ่อนอยู่
เพราะ “หนึ่งวัน” ขึ้นกับพรมแดนโซนเวลา ถ้าแดชบอร์ดหนึ่งจัดกลุ่มตามโซนเวลาผู้ดูและอีกแดชบอร์ดหนึ่งจัดกลุ่มตาม UTC (หรือโซนธุรกิจ) เหตุการณ์ดึกๆ อาจตกคนละวันที่และเปลี่ยนยอดรวมรายวัน แก้โดยเลือกกฎการจัดกลุ่มเดียวต่อชาร์ต (UTC หรือโซนธุรกิจที่ระบุ) และใช้สม่ำเสมอใน SQL, BI, และการส่งออก
DST ทำให้มีชั่วโมงที่หายไปหรือเกิดซ้ำ ซึ่งทำให้เกิดช่องว่างหรือบัคเก็ตซ้ำเมื่อจัดกลุ่มตามเวลาโลคอล หากข้อมูลของคุณเป็นวินาทีจริง ให้เก็บเป็น TIMESTAMPTZ แล้วเลือกโซนเวลาชัดเจนสำหรับการจัดบัคเก็ตในชาร์ต และทดสอบสัปดาห์การเปลี่ยน DST สำหรับโซนเป้าหมายเพื่อจับปัญหาแต่เนิ่นๆ
ไม่ใช่: PostgreSQL ไม่เก็บป้ายชื่อโซนเวลาต้นทางกับ TIMESTAMPTZ; มันเก็บเฉพาะวินาทีจริง เมื่อคุณคิวรี PostgreSQL จะแสดงตามโซนเวลาของเซสชัน ซึ่งอาจต่างจากโซนของผู้ใช้ หากต้องการแสดงตามโซนของลูกค้า ให้เก็บโซนนั้นแยกฟิลด์
ส่ง timestamp ในรูปแบบ ISO 8601 ที่รวมออฟเซ็ต และรักษาความสม่ำเสมอ ค่าเริ่มต้นที่ดีคือส่ง UTC พร้อม Z สำหรับเหตุการณ์ที่เป็น instant แล้วให้ไคลเอ็นต์แปลงเพื่อแสดง หลีกเลี่ยงการส่งสตริงแบบ "naive" เช่น 2026-03-10 23:30:00 เพราะไคลเอ็นต์จะเดาโซนต่างกัน
แปลงที่ขอบระบบ: เก็บเหตุการณ์เป็น TIMESTAMPTZ แล้วแปลงเป็นโซนที่ต้องการเมื่อแสดงหรือเมื่อต้องจัดกลุ่มสำหรับรายงาน หลีกเลี่ยงการแปลงไปมาภายใน trigger, background job หรือ ETL เว้นแต่มีข้อตกลงชัดเจน ปัญหาการรายงานส่วนใหญ่เกิดจากการแปลงซ้ำหรือผสมค่าสำคัญกับค่าสำคัญที่ไม่ระบุโซน
ใช้ DATE สำหรับแนวคิดเชิงธุรกิจที่เป็นวันจริง เช่น “billing day”, “reporting date”, หรือ “delivery date” ใช้ TIME (หรือ TIMESTAMP พร้อมโซนแยก) สำหรับตารางเวลาที่ต้องการเวลาโลคอล เช่น “เปิด 09:00” อย่าบังคับให้เป็น TIMESTAMPTZ เว้นแต่คุณตั้งใจจะเก็บเป็นวินาทีนาทีเดียว เพราะ DST และการเปลี่ยนโซนอาจเปลี่ยนความหมายที่ตั้งใจ
ตัดสินใจก่อนว่าค่าคือ instant (TIMESTAMPTZ) หรือ local wall time (TIMESTAMP พร้อมโซน) แล้วเพิ่มคอลัมน์ใหม่แทนการเขียนทับในที่เดิม Backfill ภายใต้โซนเวลาที่รู้จัก ตรวจสอบตัวอย่างแถวรอบเที่ยงคืนและขอบ DST รันรายงานเก่าและใหม่คู่กันสั้นๆ เพื่อสังเกตการเปลี่ยนแปลงก่อนจะลบคอลัมน์เก่า


