แอปเป็น UTC คนคิดเป็นเวลาไทย — เพี้ยน 7 ชั่วโมงแบบไม่มี error
บั๊กเรื่องเวลาเป็นบั๊กที่น่ากลัวกว่าบั๊กที่ทำให้เว็บล่ม เพราะเว็บล่มมีคนบอกภายในสามนาที แต่เวลาเพี้ยนเจ็ดชั่วโมงจะเงียบสนิทจนกว่าจะมีคนบังเอิญนั่งเฝ้าหน้าจอตอนที่มันควรทำงาน แล้วพบว่ามันไม่ทำงาน
พอดีมีคนแจ้งมาว่าตั้งเวลาเผยแพร่ของชิ้นหนึ่งไว้บ่ายสาม แต่ของไปโผล่จริงตอนสี่ทุ่ม ตอนแรกผมคิดว่าเป็นเรื่องคิวทำงานไม่ทัน เลยไปดู log ของตัวรันงานตามเวลา ปรากฏว่าทุกอย่างปกติดี งานถูกหยิบไปทำตรงเวลาที่มันบันทึกไว้เป๊ะ ไม่มี error ไม่มีการลองใหม่ ไม่มีอะไรผิดสักบรรทัดเดียว ที่ผิดคือตัวเลขที่มันบันทึกไว้ต่างจากที่คนกรอกอยู่เจ็ดชั่วโมงพอดี
เจ็ดชั่วโมงคือระยะห่างระหว่างเวลาไทยกับ UTC ซึ่งพอเห็นตัวเลขนี้ก็รู้ทันทีว่าเป็นเรื่องอะไร แต่การรู้ว่าเป็นเรื่องเขตเวลากับการหาว่ามันเพี้ยนตรงจุดไหนของเส้นทางเป็นคนละเรื่องกัน เพราะในเส้นทางเดียวมีจุดที่แปลงเวลาได้ตั้งสี่ห้าจุด และแต่ละจุดก็แปลงถูกบ้างผิดบ้างปนกันไป
ชื่อฟีเจอร์ ชื่อตาราง และชื่อคอลัมน์ในบทความนี้ผมเปลี่ยนเป็นของสมมุติแล้ว แต่ลำดับของบั๊กและวิธีแก้คือของจริง
สามจุดที่ต้องตรงกัน ไม่ใช่จุดเดียว
สิ่งแรกที่ผมทำคือเลิกคิดว่าปัญหาอยู่ที่ "เขตเวลาของระบบ" แล้วแตกออกเป็นสามจุดที่ต้องตรวจแยกกัน
กฎที่ผมยึดหลังจากเคสนี้คือ เก็บอะไรลงฐาน อ่านกลับได้อะไร หน้าจอโชว์อะไร สามอย่างนี้ต้องตรงกันทั้งหมด ถ้าตรวจแค่จุดเดียวแล้วบอกว่าผ่าน แปลว่ายังไม่ได้ตรวจอะไรเลย เพราะบั๊กประเภทนี้เกิดจากการที่แต่ละจุดตีความค่าเดียวกันคนละแบบ ไม่ใช่จากการที่จุดใดจุดหนึ่งเสีย
ตั้งค่าใน env แล้วไม่มีผล
ทางแรกที่คนส่วนใหญ่ลองคือไปเปลี่ยนเขตเวลาของแอปให้เป็นเวลาไทยไปเลย จบเรื่อง ซึ่งในระบบนี้ทำไม่ได้ และลองแล้วก็ไม่มีอะไรเปลี่ยน เพราะไฟล์ตั้งค่าเขียนค่าตายตัวไว้ ไม่ได้อ่านจาก env
// config/app.php — ค่าตายตัว ใส่ APP_TIMEZONE ใน .env แล้วไม่มีผล
'timezone' => 'UTC',
// เขตเวลาสำหรับ "แสดงผล" แยกออกมาต่างหาก
'display_tz' => env('APP_DISPLAY_TZ', 'Asia/Bangkok'),
ตรงนี้มีคนเข้าใจผิดกันบ่อยมาก คือไปแก้ .env แล้วรัน config:cache แล้วเห็นว่าไม่มีอะไรเปลี่ยน ก็สรุปว่าแคชไม่ยอมอัปเดต แล้วไปนั่งไล่เรื่องแคชอยู่ครึ่งวัน ทั้งที่ความจริงคือไฟล์ตั้งค่าไม่เคยเรียก env() ตรงบรรทัดนั้นตั้งแต่แรก
# เช็คให้แน่ก่อนว่าค่าที่แอปใช้จริงคืออะไร อย่าเดาจากไฟล์ .env
php artisan tinker --execute='
echo "app.timezone = ", config("app.timezone"), PHP_EOL;
echo "display_tz = ", config("app.display_tz"), PHP_EOL;
echo "php ini = ", ini_get("date.timezone") ?: "(ว่าง)", PHP_EOL;
echo "now() = ", now()->toIso8601String(), PHP_EOL;
'
มันดูเหมือนทางลัดที่จบในบรรทัดเดียว แต่ผลคือค่าที่เขียนลงฐานทุกค่านับจากนั้นจะเป็นหน้าปัดไทย ในขณะที่ค่าเก่าทั้งหมดที่มีอยู่แล้วยังเป็น UTC ตารางเดียวกันเลยมีเวลาสองมาตรฐานปนกันโดยไม่มีอะไรบอกว่าแถวไหนเป็นแบบไหน ซึ่งแก้ยากกว่าปัญหาเดิมหลายเท่า ถ้าจะเปลี่ยนจริงต้องมี migration ที่ไล่แปลงค่าเก่าทั้งหมดพร้อมกัน และต้องหยุดระบบตอนทำ
ช่องกรอกเวลาไม่ได้ส่งเขตเวลามาด้วย
จุดที่สองอยู่ที่ฝั่งเบราว์เซอร์ ช่องกรอกชนิด datetime-local ส่งค่าออกมาเป็นสตริงหน้าปัดล้วนๆ เช่น 2026-10-21T15:00 โดยไม่มีข้อมูลเขตเวลาติดมาเลย ซึ่งเป็นพฤติกรรมตามมาตรฐาน ไม่ใช่บั๊ก ชื่อชนิดมีคำว่า local อยู่แล้ว
ปัญหาคือฝั่ง PHP รับสตริงนั้นมาแล้วต้องเดาเองว่าหน้าปัดนี้เป็นหน้าปัดของเขตเวลาไหน และค่าเริ่มต้นที่มันเดาคือเขตเวลาของแอป ซึ่งในระบบนี้คือ UTC
// ผิด — Carbon ตีความ 15:00 เป็นหน้าปัด UTC ทั้งที่คนกรอกหมายถึงบ่ายสามบ้านเรา
$at = Carbon::parse($request->input('publish_at'));
// ถูก — บอกให้ชัดว่าหน้าปัดนี้เป็นของเขตเวลาไหน
$at = Carbon::parse($request->input('publish_at'), config('app.display_tz'));
ความต่างของสองบรรทัดนี้คือเจ็ดชั่วโมงพอดี และไม่มีอะไรฟ้องเลย เพราะทั้งคู่คืนอ็อบเจกต์ที่ถูกต้องตามชนิดของมัน ไม่มี exception ไม่มี warning
// เทียบให้เห็นกับตา
$s = '2026-10-21T15:00';
echo Carbon::parse($s)->toIso8601String(), PHP_EOL;
// 2026-10-21T15:00:00+00:00 <- บ่ายสามที่ลอนดอน = สี่ทุ่มบ้านเรา
echo Carbon::parse($s, 'Asia/Bangkok')->toIso8601String(), PHP_EOL;
// 2026-10-21T15:00:00+07:00 <- บ่ายสามบ้านเรา ตามที่คนกรอกตั้งใจ
บรรทัดที่เขียนหน้าปัดไทยลงคอลัมน์ UTC
จุดที่สามเป็นจุดที่เจ็บที่สุด และเป็นจุดที่ทำให้เคสนี้อยู่รอดผ่านการตรวจมาได้หลายรอบ
สมมุติว่าเราแก้จุดที่สองถูกแล้ว ได้อ็อบเจกต์เวลาที่มีเขตเวลาเป็น +07:00 ติดอยู่ ถ้าเอาอ็อบเจกต์นั้นยัดลงฐานผ่านตัวสร้างคำสั่งตรงๆ สิ่งที่ถูกเขียนลงไปคือหน้าปัดไทย ไม่ใช่เวลาสากล เพราะขั้นตอนแปลงเป็นสตริงมันแค่จัดรูปตัวเลขที่เห็น ไม่ได้แปลงเขตเวลาให้
$at = Carbon::parse('2026-10-21T15:00', 'Asia/Bangkok'); // 15:00 +07:00
// ผิด — ตัวสร้างคำสั่งเขียน "2026-10-21 15:00:00" ลงคอลัมน์ที่ทุกคนอ่านเป็น UTC
DB::table('posts')->where('id', $id)->update(['publish_at' => $at]);
// ถูก — ปรับเป็นเวลาสากลก่อนเสมอ
DB::table('posts')->where('id', $id)->update(['publish_at' => $at->utc()]);
แปลงให้ ถ้าประกาศ cast ไว้
โมเดลที่มี casts บอกว่าคอลัมน์นี้เป็น datetime จะมีชั้นแปลงค่าคั่นให้ตอนเขียนและตอนอ่าน ทำให้ค่าที่ลงฐานเป็นเวลาสากลเสมอ แต่ต้องประกาศไว้จริง ไม่ใช่คิดเอาเองว่ามี
ไม่มีชั้นแปลงให้เลย
จัดรูปเป็นสตริงแล้วส่งลงไปตรงๆ ค่าที่ได้คือหน้าปัดของอ็อบเจกต์นั้น ซึ่งถ้าเป็นหน้าปัดไทยก็จะเขียนหน้าปัดไทยลงคอลัมน์ที่ทุกคนที่เหลือในระบบอ่านเป็น UTC
นี่คือเหตุผลที่ระบบเดียวกันมีทั้งจุดที่ถูกและจุดที่ผิดปนกัน เส้นทางที่เขียนผ่านโมเดลถูกหมด ส่วนเส้นทางที่ใครสักคนเคยเขียนด้วยตัวสร้างคำสั่งเพราะต้องอัปเดตหลายแถวรวดเดียวเพื่อความเร็ว เส้นทางนั้นเพี้ยน และมันเป็นเส้นทางที่ใช้น้อยกว่า เลยไม่มีใครสังเกต
# ไล่หาทุกจุดที่เขียนคอลัมน์เวลาผ่านตัวสร้างคำสั่ง
grep -rnE "DB::table\(.*\)->(update|insert)" app/ \
| grep -E "_at|_time|date"
# และไล่หาโมเดลที่ยังไม่ได้ประกาศ cast ให้คอลัมน์เวลาของตัวเอง
grep -rn "publish_at" app/Models/
สัญญาณเตือนที่ควรจับให้ได้เร็วกว่านี้
ย้อนกลับไปดู มีสัญญาณอยู่ก่อนแล้วสองอย่าง แต่ตอนนั้นไม่มีใครเอามาต่อกัน
อย่างแรกคือข้อความยืนยันหลังกดบันทึก กับเวลาที่โชว์ในหน้ารายการไม่ตรงกัน ข้อความยืนยันมักถูกสร้างจากตัวแปรในหน่วยความจำที่ยังมีเขตเวลาถูกต้องอยู่ ส่วนหน้ารายการอ่านค่ากลับมาจากฐาน ถ้าสองอันนี้ต่างกันเมื่อไหร่ แปลว่าขั้นตอนเขียนลงฐานทำอะไรกับค่านั้นระหว่างทาง
อย่างที่สองคือเวลาที่โชว์ในหน้ารายการต่างจากเวลาที่คนกรอกเป็นจำนวนคงที่เสมอ ไม่ว่าจะกรอกค่าไหน ความต่างที่คงที่เป๊ะแบบนี้บอกได้เลยว่าเป็นเรื่องเขตเวลา ไม่ใช่เรื่องการคำนวณ
ชั้นเดียวที่ตัดสินใจเรื่องเขตเวลา
หลังแก้เสร็จ ผมไม่อยากให้เรื่องนี้กลับมาอีก เลยรวบการตัดสินใจเรื่องเขตเวลาไว้ที่เดียว แล้วห้ามใครแปลงเองนอกจากผ่านตรงนี้
namespace App\Support;
use Carbon\CarbonImmutable;
final class Clock
{
/** รับหน้าปัดไทยจากฟอร์ม คืนค่าที่พร้อมเก็บลงฐาน (UTC เสมอ) */
public static function fromForm(?string $wall): ?CarbonImmutable
{
if ($wall === null || trim($wall) === '') {
return null;
}
return CarbonImmutable::parse($wall, config('app.display_tz'))->utc();
}
/** รับค่าจากฐาน คืนหน้าปัดไทยสำหรับเอาไปโชว์ */
public static function toDisplay(?CarbonImmutable $utc): ?CarbonImmutable
{
return $utc?->setTimezone(config('app.display_tz'));
}
/** ค่าที่เอาไปใส่ค่าเริ่มต้นให้ช่องกรอก — ต้องเป็นหน้าปัดไทยไม่มี tz */
public static function toFormValue(?CarbonImmutable $utc): string
{
return self::toDisplay($utc)?->format('Y-m-d\TH:i') ?? '';
}
}
แล้วบังคับให้ทุกเส้นทางวิ่งผ่านสามฟังก์ชันนี้เท่านั้น ข้อดีคือเวลาจะตรวจ ไม่ต้องไล่ทั้งโปรเจกต์ ตรวจแค่ว่ามีใครเรียก Carbon::parse หรือ setTimezone นอกไฟล์นี้ไหม
grep -rn "Carbon::parse\|CarbonImmutable::parse\|setTimezone" app/ resources/views/ \
| grep -v "app/Support/Clock.php"
# ผลลัพธ์ที่ต้องการคือว่างเปล่า
ชุดทดสอบที่ต้องพังด้วยเหตุผลที่ถูก
ข้อนี้ผมเรียนรู้มาด้วยความเจ็บ คือเขียนทดสอบไปแล้วมันเขียวหมด ทั้งที่ระบบยังเพี้ยนอยู่ เพราะตัวทดสอบเองก็ใช้ตรรกะแปลงเวลาชุดเดียวกับที่ผิด ผลเลยผิดตรงกันพอดีแล้วผ่าน
ทางแก้คือตัวทดสอบต้องยืนยันด้วยค่าดิบที่อยู่ในฐานจริง ไม่ใช่ค่าที่อ่านผ่านชั้นแปลงของแอปเอง
public function test_publish_at_is_stored_as_utc(): void
{
config(['app.display_tz' => 'Asia/Bangkok']);
$this->post('/posts', [
'title' => 'ทดสอบ',
'publish_at' => '2026-10-21T15:00',
])->assertRedirect();
// อ่านค่าดิบ ไม่ผ่าน Eloquent ไม่ผ่าน cast ไม่ผ่าน Clock
$raw = DB::selectOne('select publish_at from posts order by id desc limit 1');
$this->assertSame('2026-10-21 08:00:00', $raw->publish_at);
// แล้วค่อยเช็คว่าอ่านกลับมาโชว์แล้วได้หน้าปัดไทยเดิม
$post = Post::latest('id')->first();
$this->assertSame('2026-10-21 15:00', Clock::toDisplay($post->publish_at)->format('Y-m-d H:i'));
}
ตัวทดสอบข้างบนเช็คครบทั้งสามจุดที่ผมพูดถึงตอนต้น คือค่าที่เก็บลงฐานเป็นเวลาสากล ค่าที่อ่านกลับแปลงเป็นหน้าปัดไทยได้ถูก และหน้าปัดที่ได้ตรงกับที่คนกรอกเข้ามา ถ้าจะเขียนทดสอบเรื่องเวลาแค่ตัวเดียวในโปรเจกต์ ให้เขียนตัวนี้ เพราะมันจับบั๊กได้ทั้งสามแบบที่เล่ามา และมันพังทันทีถ้ามีใครไปเปลี่ยนเขตเวลาของแอปโดยไม่ได้ไล่แปลงข้อมูลเก่า
รายการตรวจที่ผมใช้
- ถามให้ได้ก่อนว่าคอลัมน์เวลาในฐานถูกตกลงกันว่าเป็นเขตเวลาอะไร แล้วเขียนไว้ในเอกสารของตาราง ไม่ใช่ปล่อยให้แต่ละคนเดาเอง
- ตรวจว่าค่าเขตเวลาที่แอปใช้จริงคืออะไร ด้วยการอ่านจากตัวแอปเอง ไม่ใช่อ่านจากไฟล์ env
- ไล่ทุกจุดที่รับค่าเวลาจากผู้ใช้ ต้องระบุเขตเวลาของหน้าปัดตอนแปลงทุกครั้ง ห้ามปล่อยให้เดา
- ไล่ทุกจุดที่เขียนคอลัมน์เวลาผ่านตัวสร้างคำสั่ง ต้องปรับเป็นเวลาสากลก่อนเขียนทุกจุด
- เทียบข้อความยืนยันหลังบันทึกกับค่าที่โชว์ในหน้ารายการ ต้องตรงกัน ถ้าต่างกันแปลว่ายังมีรูอยู่
- เขียนตัวทดสอบที่อ่านค่าดิบจากฐาน แล้วยืนยันว่ามันพังจริงถ้าเอา utc ออก ตัวทดสอบที่ไม่เคยเห็นมันแดงคือตัวทดสอบที่เชื่อไม่ได้
สรุป
บั๊กเรื่องเวลาไม่ได้เกิดจากใครเขียนโค้ดผิด แต่เกิดจากการที่คนละส่วนของระบบตกลงกันคนละอย่างว่าตัวเลขชุดหนึ่งหมายถึงอะไร แล้วไม่มีใครเขียนข้อตกลงนั้นลงไว้ที่ไหนเลย ทุกคนเดาถูกกันมาตลอดเพราะบังเอิญเส้นทางที่ใช้บ่อยมันแปลงให้อยู่แล้ว จนกระทั่งมีใครสักคนเขียนเส้นทางใหม่ที่ไม่ผ่านชั้นแปลงนั้น
สิ่งที่ผมทำหลังเคสนี้เลยไม่ใช่แค่แก้บรรทัดที่ผิด แต่เขียนข้อตกลงลงไปในเอกสารของตารางว่าคอลัมน์เวลาทุกตัวเป็นเวลาสากล และรวบการแปลงไว้ชั้นเดียว ที่เหลือคือตัวทดสอบหนึ่งตัวที่คอยเฝ้าไว้ ใครจะเลือกเก็บเป็นเวลาไทยทั้งระบบแทนก็ทำได้เหมือนกันครับ ไม่ผิด แค่ต้องเลือกอย่างใดอย่างหนึ่งแล้วเขียนไว้ให้ชัด อย่าปล่อยให้มีสองมาตรฐานอยู่ในตารางเดียวกัน ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย