จอง 23:00 แล้วหายไปวันก่อนหน้า: เก็บ UTC แต่คิดเป็น local — และทำไม DATE() ตรงๆ ถึงตกวัน
เก็บเวลาเป็น UTC เสมอเป็นเรื่องถูก แต่ถ้า business logic ตัดสินเรื่อง "วัน/slot" บน UTC ดิบๆ การจองตอนดึกจะเด้งไปวันก่อนหน้า — บทความนี้เล่า off-by-one-day bug จากงานจองคิวจริง พร้อมกับดัก index ที่ตามมา
อ่าน ~8 นาที
เก็บ UTC, คิดเป็น local: กฎเหล็กและทำไม
ผมเคยดูแลระบบจองคิว/นัดหมายของ SaaS ไทยแห่งหนึ่ง ทุก timezone ในระบบคือ Asia/Bangkok ผู้ใช้ทั้งหมดอยู่ไทย ร้านอยู่ไทย ลูกค้าอยู่ไทย ฟังดูเหมือนไม่มีอะไรต้องคิดเรื่อง timezone เลยด้วยซ้ำ แต่กลับเป็นระบบที่ผมเจอ off-by-one-day bug ที่เจ็บที่สุด — ลูกค้าจองคิว 5 ทุ่มของวันที่ 20 แล้วมันไปโผล่ในตารางวันที่ 19
แก่นของบทความนี้คือกฎเหล็กข้อเดียวที่ผมยึดตลอด: เก็บ instant (จุดเวลาบนเส้นเวลาสากล) เป็น UTC เสมอ แต่ทุกการตัดสินใจเรื่อง "วัน" หรือ "slot" ต้อง convert เป็น local timezone ก่อน สองครึ่งนี้แยกกันคนละชั้น การเก็บเป็น UTC ทำให้เวลาไม่กำกวมและเทียบลำดับก่อน/หลังได้ถูกเสมอ แต่คำถามแบบ "การจองนี้อยู่วันไหน" เป็นคำถามของมนุษย์ที่มีความหมายเฉพาะใน timezone ของมนุษย์คนนั้น ถ้าคุณตอบคำถามนั้นด้วยตัวเลข UTC ดิบ คุณกำลังตอบผิดเงียบๆ ในช่วงเวลาก้ำกึ่งของวัน
เคส off-by-one: 23:00 Bangkok = 16:00 UTC → DATE() ตกวันก่อนหน้า
Bangkok คือ UTC+7 เสมอ (ไม่มี DST) เวลา 23:00 ของวันที่ 20 ตามเวลาไทย เมื่อเก็บเป็น UTC จะกลายเป็น 2026-01-20T16:00:00Z — ยังเป็นวันที่ 20 อยู่ ดูเหมือนปลอดภัย แต่ลองเวลา 23:00 ปลายๆ ของช่วงดึก หรือกรณีที่ offset ดันข้ามเที่ยงคืน UTC สิ่งที่ทำให้ระบบผมพังคือโค้ดที่ถามว่า "การจองนี้อยู่วันไหน" ด้วยการเรียก DATE() บนค่า UTC ตรงๆ
-- ผิด: DATE() ทำงานบนค่า UTC ดิบ
-- 23:00 Bangkok ที่ offset ทำให้ instant ตกไปหลังเที่ยงคืน UTC
-- จะถูกจัดกลุ่มผิดวัน
SELECT DATE(start_time) AS booking_day
FROM appointments
WHERE start_time = '2026-01-20 17:30:00+07';
-- start_time UTC = 2026-01-20 10:30:00Z → DATE = 2026-01-20 (บังเอิญถูก)
-- 23:30 Bangkok ยัง "รอด" เพราะ +7 ยังไม่ดันข้ามเที่ยงคืน UTC:
SELECT DATE('2026-01-20 23:30:00+07'::timestamptz);
-- instant UTC = 2026-01-20 16:30:00Z → ยังได้ 2026-01-20
-- ประตูที่พังจริงคือช่วงเช้ามืด (00:00–06:59 น. Bangkok):
-- offset +7 ดัน instant ย้อนกลับไปวันก่อนหน้าในเวลา UTC
SELECT DATE('2026-01-20 05:00:00+07'::timestamptz);
-- instant UTC = 2026-01-19 22:00:00Z → DATE = 2026-01-19 (เด้งเป็นวันก่อน!)
ประเด็นที่ต้องเข้าใจคือ DATE(timestamptz) ใน Postgres จะตัดวันตาม session timezone ปัจจุบัน ถ้า session เป็น UTC มันจะตัดวันบนตัวเลข UTC ซึ่งไม่ใช่วันที่ผู้ใช้เห็น การแก้ที่ถูกต้องคือ convert เป็น local ก่อนแล้วค่อยตัดวัน:
-- ถูก: แปลงเป็น Bangkok ก่อน แล้วค่อยเอาส่วนวันที่
SELECT (start_time AT TIME ZONE 'Asia/Bangkok')::date AS booking_day
FROM appointments;
-- 23:30 Bangkok ของวันที่ 20 → ได้ 2026-01-20 เสมอ ไม่ว่า session TZ จะเป็นอะไร
หลักการที่ transferable: อย่าปล่อยให้ session timezone เป็นตัวแปรลับที่ตัดสินผลลัพธ์ ระบุ timezone ที่ต้องการอย่างชัดเจนในทุก query ที่แปลง instant เป็นวัน ถ้าคำตอบเปลี่ยนตามว่าใครรัน query จากที่ไหน แปลว่าโค้ดนั้นมี bug รออยู่
TIMESTAMPTZ vs naive timestamp: อะไรเก็บ offset อะไรไม่เก็บ
ความสับสนนี้เริ่มจากชื่อ type ที่ทำให้เข้าใจผิด ใน Postgres TIMESTAMPTZ (timestamp with time zone) ไม่ได้เก็บ timezone ไว้จริงๆ มันเก็บเป็น instant UTC ภายใน แล้วแปลงเข้า/ออกตาม session timezone ตอน read/write ส่วน TIMESTAMP (without time zone) เก็บตัวเลข "ผนังนาฬิกา" ดิบๆ โดยไม่รู้ว่านั่นเวลาโซนไหน — เป็นค่ากำกวมที่แปลเป็น instant ไม่ได้ถ้าไม่มีบริบทเพิ่ม
กฎที่ผมใช้: คอลัมน์ที่แทน "เวลาเกิดเหตุจริง" ต้องเป็น TIMESTAMPTZ เสมอ ตอนจองคิว ตอนสร้าง order ตอน log event ทั้งหมดคือ instant ที่มีจุดเดียวบนเส้นเวลา ส่วน naive TIMESTAMP เหมาะเฉพาะกับสิ่งที่เป็น "เวลาผนังนาฬิกาลอยๆ" จริงๆ เช่น "ร้านเปิด 09:00" ที่ไม่ผูกกับวันใดวันหนึ่ง ในระบบผม ตอนแรก staff schedule เก็บเป็น local time ดิบ แล้วเราต้อง migrate เป็น UTC เพราะพอเอาไปคำนวณ slot จริง มันแปลงเป็น instant ไม่ได้ถ้าไม่ผูก timezone
บทเรียนสำคัญจากตรงนี้: migration timezone เป็นงานที่ เสี่ยงมากและต้องมี rollback path ที่ทดสอบแล้ว เราเคยพลาดรอบหนึ่ง — migrate schedule ทั้งชุดผิดทิศ แล้วค้นพบตอน slot คำนวณเพี้ยน สิ่งที่ช่วยไว้คือมี migration rollback เตรียมไว้คู่กันเสมอ ไม่ใช่แก้ทางเดียวแล้วภาวนา
Test บังคับ: จองเวลาดึกต้องไม่ตกวัน (boundary test)
bug ประเภทนี้มองไม่เห็นด้วยตาเพราะเคสกลางวันทำงานถูกเสมอ 14:00 Bangkok = 07:00 UTC วันเดียวกัน ตัดวันยังไงก็ตรง มันพังเฉพาะขอบของวัน ฉะนั้น test ที่จับมันได้ต้องยิงตรงขอบ ผมตั้งกฎว่า ทุก PR ที่แตะ booking flow ต้องผ่านเคส: จองเวลาดึก (23:00) ของวันที่ X ต้องปรากฏในวันที่ X ไม่ใช่ X-1
// boundary test — จับ off-by-one-day โดยเฉพาะ
test('จอง 23:00 Bangkok ต้องอยู่วันเดิม ไม่เด้งไปวันก่อน', async () => {
// instant = 23:00 ของวันที่ 20 ตามเวลาไทย
const startAt = '2026-01-20T23:00:00+07:00';
const booking = await createBooking({ startAt });
// ถามระบบว่า "การจองนี้อยู่วันไหน" ตามมุมมองผู้ใช้ (Bangkok)
const dayInApp = await getBookingLocalDate(booking.id);
// ต้องเป็น 20 ไม่ใช่ 19 — ถ้าได้ 19 แปลว่ามีที่ไหนตัดวันบน UTC ดิบ
expect(dayInApp).toBe('2026-01-20');
});
หลักการ transferable: boundary test ต้องเล็งที่ขอบจริงของ bug ไม่ใช่ค่ากลางๆ ที่ผ่านง่าย สำหรับ timezone ขอบคือช่วงที่ local date กับ UTC date ไม่ตรงกัน — เที่ยงคืนถึงตี 7 และ 5 ทุ่มถึงเที่ยงคืน สำหรับ offset +7 ใส่เคสพวกนี้เป็น regression test ถาวร เพราะมันจะถูกเผลอทำพังซ้ำได้ง่ายมากเวลามีคน refactor query ตัดวันในอนาคต
Functional index พัง: WHERE ที่ convert timezone ไม่ match indexed expression → seq scan
พอแก้ให้ตัดวันถูก ปัญหาถัดมาโผล่ทันที — performance ตก โค้ดผมมี index บนวันที่แบบนี้:
-- index เดิม ตัดวันบน UTC ดิบ
CREATE INDEX idx_appt_date
ON appointments (shop_id, (DATE(start_time)));
แต่ query ที่แก้แล้วใช้ expression คนละตัว:
-- query ที่ตัดวันเป็น local
WHERE shop_id = $1
AND (start_time AT TIME ZONE 'Asia/Bangkok')::date = $2
Postgres จะใช้ functional index ได้ก็ต่อเมื่อ expression ใน WHERE ตรงเป๊ะกับ expression ที่ใช้สร้าง index DATE(start_time) กับ (start_time AT TIME ZONE 'Asia/Bangkok')::date เป็นคนละ expression ในสายตา planner ผลคือ index ถูกเมิน แล้ว query กวาดทุก row ของร้านนั้น (seq scan) พอข้อมูลโต query จองคิวที่ควรจบใน 5ms กลายเป็นหลายร้อย ms
นี่เป็นกับดักที่คลาสสิกมาก: การแก้ correctness ไปทำลาย performance เงียบๆ เพราะ index ไม่ throw error มันแค่ไม่ถูกใช้ ถ้าไม่เปิด EXPLAIN ดูก็ไม่มีทางรู้ กฎที่ผมยึด: ทุกครั้งที่แก้ expression ใน WHERE ที่มี index รองรับ ต้องรัน EXPLAIN ANALYZE ยืนยันว่ายังเห็น Index Scan ไม่ใช่ Seq Scan ก่อนบอกว่างานเสร็จ
แก้ index ให้ตรง expression หรือใช้ range query [00:00 local, +1 วัน)
มีสองทางแก้ที่ผมพิจารณา และแต่ละทางมี trade-off ชัดเจน
ทางที่ 1: สร้าง functional index ให้ตรง expression ใหม่ — ตรงไปตรงมาแต่ต้องมั่นใจว่า expression match เป๊ะทุก query
-- index ที่ตรงกับ expression ตัดวันแบบ local
CREATE INDEX idx_appt_local_date
ON appointments (shop_id, ((start_time AT TIME ZONE 'Asia/Bangkok')::date));
ทางที่ 2: เลี่ยง functional index ทั้งหมด ใช้ range query บน timestamp ตรงๆ — วิธีนี้ผมชอบกว่าเพราะมันใช้ index ธรรมดาบน start_time ที่มักมีอยู่แล้ว และไม่ต้องพึ่ง expression พิเศษเลย
-- คำนวณขอบวันเป็น instant ก่อน (ในโค้ดแอป) แล้วส่งเป็น range
-- day = '2026-01-20' ในมุมมอง Bangkok
-- lo = 2026-01-20 00:00:00 +07 → 2026-01-19 17:00:00Z
-- hi = 2026-01-21 00:00:00 +07 → 2026-01-20 17:00:00Z
WHERE shop_id = $1
AND start_time >= $lo -- ครึ่งเปิดซ้าย: รวมเที่ยงคืน
AND start_time < $hi; -- ครึ่งปิดขวา: ไม่รวมเที่ยงคืนวันถัดไป
range query แบบ half-open [lo, hi) ดีตรงที่มันเปรียบเทียบ instant กับ instant ตรงๆ ไม่มีการ transform column เลย planner จึงใช้ b-tree index บน (shop_id, start_time) ได้ทันที และการใช้ขอบเขตครึ่งเปิดครึ่งปิดกันปัญหา slot เที่ยงคืนซ้ำหรือหายที่รอยต่อของวัน หลักการ transferable: ถ้าเลี่ยงการ transform column ใน WHERE ได้ ให้เลี่ยง — ย้ายการคำนวณขอบไปไว้ในโค้ดแอปแล้วส่ง instant ที่คำนวณเสร็จแล้วเข้า query เพื่อให้ index ธรรมดาทำงานได้
อย่าลืม DST-free zone ก็ยังพังได้จาก offset — Bangkok +7 ก็ตกวัน
คนมักคิดว่า timezone bug เป็นเรื่องของ DST (เวลาออมแสง) เท่านั้น ระบบที่อยู่ในโซนไม่มี DST อย่าง Bangkok เลยรู้สึกปลอดภัย — นี่คือความเข้าใจผิดที่ทำให้ผมพลาด off-by-one-day bug ตัวนี้ทั้งที่ระบบไม่เคยแตะ DST เลย
ความจริงคือ ต้นตอของ bug นี้คือ offset ไม่ใช่ DST ตราบใดที่ offset ไม่ใช่ศูนย์ (Bangkok คือ +7 คงที่) local date กับ UTC date ก็ไม่ตรงกันในช่วงขอบของวันอยู่ดี DST แค่ทำให้ offset เปลี่ยนตามฤดู ซึ่งเพิ่มความซับซ้อนอีกชั้น แต่ปัญหาตัดวันผิดเกิดได้แม้ offset จะคงที่ ระบบที่ users อยู่โซนเดียวกันหมด offset คงที่ ไม่มี DST — ก็ยัง off-by-one-day ได้ครบทุกประตูถ้า business logic ตัดวันบน UTC ดิบ
สรุป: UTC สำหรับเก็บ, local สำหรับตัดสินใจ
ทุก pitfall ในบทความนี้มาจากรากเดียว: การเผลอเอาค่า UTC ที่เก็บไว้ถูกต้องแล้ว ไปตอบคำถามของมนุษย์ ("วันไหน", "slot ไหน") โดยไม่แปลงกลับเป็น local ก่อน แยกสองชั้นนี้ให้ขาดจากกัน แล้ว bug ประเภทนี้จะหายไปเกือบหมด
- เก็บ instant เป็น UTC (
TIMESTAMPTZ) — เวลาเกิดเหตุจริงต้องเป็น instant ที่ไม่กำกวม เทียบก่อน/หลังได้เสมอ - ตัดสินใจเรื่องวัน/slot บน local เสมอ — convert
AT TIME ZONEก่อนตัดวัน อย่าเรียกDATE()บน UTC ดิบ และอย่าปล่อยให้ session timezone เป็นตัวแปรลับ - รู้ว่า type ไหนเก็บ offset —
TIMESTAMPTZ= instant UTC ภายใน; naiveTIMESTAMP= ผนังนาฬิกาลอยๆ ใช้เฉพาะเวลาที่ไม่ผูกวัน - boundary test เล็งขอบวัน — จอง 23:00 ต้องไม่ตกไป X-1; ใส่เป็น regression test ถาวร เพราะ refactor จะทำพังซ้ำง่าย
- แก้ correctness แล้วเช็ค index — expression ใน
WHEREต้อง match indexed expression เป๊ะ ไม่งั้น seq scan; รันEXPLAIN ANALYZEยืนยันก่อนปิดงาน - เลี่ยง transform column ด้วย range query — คำนวณขอบวันเป็น instant ในโค้ดแอป ส่ง
[lo, hi)ให้ b-tree index ธรรมดาทำงาน - offset ก็พอทำพังแล้ว ไม่ต้องมี DST — โซนคงที่อย่าง +7 ก็ off-by-one-day ได้ครบ อย่าประมาทเพราะไม่มีเวลาออมแสง
- migration timezone ต้องมี rollback ที่ทดสอบแล้ว — แปลงข้อมูลเวลาผิดทิศคือหายนะเงียบ เตรียมทางถอยคู่กันเสมอ




