EST. 2026 — บันทึกงานเซิร์ฟเวอร์จริง SYSADMIN · SECURITY · AI · DIRECTADMIN เล่มที่ 01
THE SYSADMIN ALMANAC
หน้าหลัก / Web / เว็บเก่าขึ้น 500 เฉพาะเข้าครั้งที่สองของวัน หลังขึ้น PHP 8.1
Web

เว็บเก่าขึ้น 500 เฉพาะเข้าครั้งที่สองของวัน หลังขึ้น PHP 8.1

โดย Ton-Or ·1 พ.ย. 2026·อ่าน 9 นาที
เว็บเก่าขึ้น 500 เฉพาะเข้าครั้งที่สองของวัน หลังขึ้น PHP 8.1

บั๊กที่โผล่เฉพาะครั้งที่สองของวัน จะรอดจากการทดสอบทุกแบบที่เปิดหน้าเดียวครั้งเดียว ตัวการคือค่าเริ่มต้นของ mysqli ที่เปลี่ยนไปตั้งแต่ PHP 8.1 และโค้ดเก่าที่เคยพึ่งความเงียบของมันมาเป็นสิบปี

สรุปใน 1 นาที

อาการ/ปัญหา
เว็บเก่าขึ้น 500 เฉพาะเข้าครั้งที่สองของวัน หลังขึ้น PHP 8.1
ใครควรอ่าน
คนที่ยกเว็บ PHP เก่าขึ้น PHP 8.1 ขึ้นไป
ทางแก้แบบสั้น
mysqli โยน exception เป็นค่าเริ่มต้นแล้ว เปลี่ยน INSERT ซ้ำเป็น INSERT IGNORE และทดสอบสองรอบทุกครั้งที่ขึ้น PHP ใหม่
ข้ามไปขั้นตอน

พอดีหลังผมถอด PHP รุ่นเก่าออกจากเครื่อง DirectAdmin แล้วย้ายเว็บที่เหลือขึ้นรุ่นใหม่ เว็บเก่าที่เขียนเองหลายตัวของลูกค้ารายหนึ่งกลับตอบ 500 ให้ผู้เข้าชมบางคน ทั้งที่ผมเช็กทุกหน้าแล้วได้ 200 หมด ผมเช็กอีกรอบทันทีเพราะเอะใจ และคราวนี้หน้าเดิมตอบ 500 เป็นอาการที่แปลกที่สุดเท่าที่เจอมา คือหน้าเดียวกัน IP เดียวกัน ครั้งแรกผ่าน ครั้งที่สองตาย

ชื่อโดเมน ชื่อตาราง และ user ในบทความนี้เป็นค่าตัวอย่างทั้งหมด ผมเก็บไว้เฉพาะตัวเลขกับกลไกของปัญหา

อาการ: ครั้งแรก 200 ครั้งที่สอง 500

โค้ดชุดเดียวกันถูกคัดลอกไปอยู่ในเว็บราวแปดเว็บ ทุกหน้ามี include โมดูลนับผู้เข้าชมเล็ก ๆ ที่ต้นหน้า ผลที่เห็นเวลาเปิดหน้าเดิมสองครั้งติดกันจากเครื่องเดียวกัน

$ for i in 1 2; do curl -s -o /dev/null -w '%{http_code}\n' https://example.com/index.php; done
200
500

ผมไล่ดูทั้งสาเหตุที่เคยเจอบ่อยในงานย้าย PHP ก่อน เช่น ฟังก์ชันที่ถูกถอด หรือ extension ที่ขาด แต่พวกนั้นทำให้ตายทุกครั้ง ไม่ใช่ตายเฉพาะครั้งที่สอง ข้อมูลที่ช่วยจริงคือ error log ของ PHP

PHP Fatal error:  Uncaught mysqli_sql_exception: Duplicate entry '203.0.113.7-<date>' for key 'uq_ip_day'
  in .../inc/counter.php

ข้อความนี้บอกครบ ตัวที่ตายคือคำสั่ง INSERT ในโมดูลนับผู้เข้าชม มันชนคีย์ซ้ำ และ PHP รุ่นใหม่โยน exception ที่ไม่มีใครจับ

ต้นเหตุ: โมดูลนับผู้เข้าชม กับค่าเริ่มต้นที่เปลี่ยน

โมดูลนี้ทำงานง่ายมาก ทุกครั้งที่มีคนเข้าหน้า มันจะ INSERT แถว (ip, วัน) ลงตาราง และตารางมี unique key บนสองคอลัมน์นี้ เพื่อให้ IP หนึ่งนับครั้งเดียวต่อวัน ครั้งแรกของวันจาก IP ใหม่เลยใส่แถวได้เสมอ ครั้งที่สองซ้ำ

<?php
// inc/counter.php — โมดูลนับผู้เข้าชมแบบเก่า (ย่อ ใช้ชื่อสมมติ)
$db = mysqli_connect('localhost', 'webuser', '<DB_PASS>', 'app_a');
$ip = $_SERVER['REMOTE_ADDR'];

// ตาราง visitors มี UNIQUE KEY (ip, day)
mysqli_query($db, "INSERT INTO visitors (ip, day) VALUES ('$ip', CURDATE())");
// ไม่มีการเช็กค่าที่คืนมา เพราะแต่ก่อนไม่เคยมีใครต้องเช็ก

บน PHP รุ่นก่อน 8.1 การ INSERT ซ้ำได้ error 1062 จาก MariaDB แต่ mysqli_query() แค่คืนค่า false เงียบ ๆ ไม่มีอะไรเกิดขึ้นกับหน้า มันทำงานแบบนี้มาหลายปีและไม่มีใครรู้ว่าตอนนั้นมันซ้ำทุกวัน

ตั้งแต่ PHP 8.1 ค่าเริ่มต้นของ mysqli_report() เปลี่ยนเป็น MYSQLI_REPORT_ERROR | MYSQLI_REPORT_STRICT ซึ่งหมายความว่าทุก error ของ mysqli ถูกโยนเป็น mysqli_sql_exception ถ้าไม่มี try/catch มันคือ fatal ของหน้าทันที ผมพิสูจน์ค่านี้บน PHP 8.4 ที่เครื่องตัวเองด้วยบรรทัดเดียว

$ php -r 'echo (new mysqli_driver)->report_mode, "\n";'
3
$ php -r 'mysqli_report(MYSQLI_REPORT_OFF); echo (new mysqli_driver)->report_mode, "\n";'
0

ค่า 3 คือ ERROR (1) รวมกับ STRICT (2) ส่วนค่า 0 คือปิดรายงานซึ่งเป็นสภาพที่โค้ดเก่าพึ่งพามาตลอด ผมไม่ขอระบุว่ารุ่นเก่าเป็นค่าอะไรเป๊ะ ๆ ขอพูดแค่ว่าโค้ดชุดนี้เขียนโดยสมมติว่ามันเงียบ

ประตูหมุนทางเข้าตึกเรียนที่ให้ผ่านได้คนละครั้ง เมื่อนักเรียนคนเดิมเข้าซ้ำ ประตูล็อกพร้อมไฟแดงและสัญญาณเตือนทั้งตึก
ประตูหมุนที่ให้ผ่านได้ครั้งเดียวต่อคนต่อวัน รุ่นเก่าปล่อยให้คนซ้ำเดินผ่านเงียบ ๆ รุ่นใหม่ปิดประตูและสั่งสัญญาณเตือนทั้งตึก
ก่อน PHP 8.1: mysqli เงียบ ถ้าไม่ถามเองINSERT ซ้ำerror 1062query คืน falseไม่มี exceptionไม่มีใครเช็กค่าโค้ดไปต่อหน้าแสดงผลHTTP 200PHP 8.1 ขึ้นไป: report_mode = 3 (ERROR และ STRICT)INSERT ซ้ำerror 1062โยน exceptionmysqli_sql_exceptionไม่มี try/catchfatal ทันทีหน้าตายHTTP 500ผู้เยี่ยมชมคนเดียวกัน (IP เดิม) บน PHP 8.1 ขึ้นไปครั้งที่ 1 ของวันinsert สำเร็จ ได้ 200ครั้งที่ 2 วันเดียวกันซ้ำ ได้ 500วันถัดไปไม่ซ้ำ ได้ 200 อีกครั้งเช็กหน้าเดียวครั้งเดียวเลยเห็นแต่ครั้งที่ 1 ที่ผ่านเสมอ
ข้อผิดพลาดเดิมที่เคยเงียบเปลี่ยนเป็น exception ที่ไม่มีใครจับ และมันโผล่เฉพาะครั้งที่สองของวัน
ไม่ใช่เรื่อง SQL mode

อาการนี้ไม่เกี่ยวกับ strict mode ของ MariaDB ที่ทำให้ error 1364/1366 โผล่หลังย้ายเครื่อง (อันนั้นผมเขียนไว้ใน แอปเก่าพังหลังย้ายเซิร์ฟเวอร์ — error 1364/1366 กับ MariaDB strict mode) ตรงนี้ฝั่ง SQL ตอบ 1062 เหมือนเดิมทุกปี สิ่งที่เปลี่ยนคือฝั่ง PHP ที่เลิกกลืน error

ทำไมการเช็กตอนขึ้นรุ่นถึงไม่เจอ

เพราะครั้งแรกของวันจาก IP ใหม่ INSERT ผ่านเสมอ curl ครั้งเดียว เปิดเบราว์เซอร์ครั้งเดียว หรือสคริปต์ที่วนทุกหน้าหนึ่งรอบ ล้วนเป็น "ครั้งแรกของวัน" ทั้งสิ้น และมันได้ 200 ทุกหน้า ตัวที่เจอบั๊กคือผู้เข้าชมจริงที่กดหน้าที่สองหรือกลับมาเปิดซ้ำ ซึ่งเกิดขึ้นตลอดเวลา

อีกเรื่องที่ซ่อนมันไว้ ถ้า IP ที่เห็นเปลี่ยนทุกครั้ง เช่นผ่าน proxy หรือ crawler ที่หมุน IP ก็จะไม่เจอแถวซ้ำเลย การทดสอบจากภายนอกบางแบบเลยผ่านหมดทั้งที่หน้าพังสำหรับคนจริง

ราว 8เว็บที่ใช้โมดูลเดียวกัน
200ครั้งแรกของวัน
500ครั้งที่สอง IP เดิม
3report_mode บน PHP 8.4
1062รหัส error คีย์ซ้ำ
4 เว็บพังทุกครั้งเพราะไม่มีตาราง

เรื่องที่สอง: ตารางที่ไม่เคยมี

ระหว่างไล่เว็บพี่น้องของโมดูลเดียวกัน ผมเจอสี่เว็บที่ตอบ 500 ทุกครั้งที่เข้า ไม่ใช่เฉพาะครั้งที่สอง สาเหตุคือในฐานข้อมูลของเว็บเหล่านั้นไม่มีตารางนับผู้เข้าชมอยู่เลย PHP รุ่นเก่ากลืน error "table doesn't exist" มาเป็นปี ๆ โดยที่ไม่มีใครรู้ว่าตัวนับไม่เคยนับอะไร พอ error ไม่ถูกกลืน เว็บเหล่านี้ก็ตายตั้งแต่ครั้งแรก

สาเหตุของมันเป็นตระกูลเดียวกัน คือ error ที่เคยถูกกลืน แล้วตอนนี้หยุดหน้าได้ ผมแก้โดยสร้างตารางที่ขาดให้ และใช้วิธีแก้ INSERT แบบเดียวกันกับเว็บแรก

วิธีแก้: ทำให้ "ซ้ำ" ไม่ใช่ error

ความตั้งใจของโมดูลคือนับ IP หนึ่งครั้งต่อวัน ดังนั้น duplicate ไม่ใช่ความผิดพลาดเลย มันคือพฤติกรรมที่ต้องการ แก้ให้ตรงความหมายด้วย INSERT IGNORE

-- ตารางตามที่โมดูลต้องการ (สร้างให้เว็บที่ขาด)
CREATE TABLE IF NOT EXISTS visitors (
  ip  VARCHAR(45) NOT NULL,
  day DATE        NOT NULL,
  UNIQUE KEY uq_ip_day (ip, day)
);

-- แก้ในโมดูล: ซ้ำก็ข้าม ผลที่ได้คือหนึ่งแถวต่อ IP ต่อวันเหมือนเดิม
INSERT IGNORE INTO visitors (ip, day) VALUES ('203.0.113.7', CURDATE());

ผมไล่แก้ทุกสำเนาของโมดูลโดยค้นชื่อตารางข้ามทุกต้นไม้ public_html เพราะโค้ดชุดนี้ถูกคัดลอกไปหลายที่ ไม่ได้ใช้ไฟล์ร่วมกัน

grep -rl "INSERT INTO visitors" /home/*/domains/*/public_html/ 2>/dev/null
# แล้วแก้เป็น INSERT IGNORE INTO ทีละไฟล์ (สำรองก่อนแก้)

ยังมีทางเลือกอื่นที่ควรรู้ ทางแรกคือครอบด้วย try/catch ของ mysqli_sql_exception เฉพาะจุดที่รู้ว่าซ้ำได้ ทางที่สองคือเรียก mysqli_report(MYSQLI_REPORT_OFF) ที่ต้นไฟล์เพื่อคืนพฤติกรรมเก่าทั้งหน้า ทางที่สองใช้ได้เป็นมาตรการชั่วคราวสำหรับแอปเก่าที่แก้ไม่ทัน แต่ส่วนตัวผมไม่แนะนำเป็นทางถาวร เพราะมันซ่อน error ที่ควรเห็นจริง ๆ ไปด้วย

<?php
// ทางเลือก: จับเฉพาะ duplicate (ตรวจรหัส 1062) ให้ error อย่างอื่นยังหยุดหน้าตามปกติ
try {
    mysqli_query($db, "INSERT INTO visitors (ip, day) VALUES ('$ip', CURDATE())");
} catch (mysqli_sql_exception $e) {
    if ($e->getCode() !== 1062) { throw $e; }
}
INSERT IGNORE

แก้ที่ความหมาย

  • ซ้ำคือพฤติกรรมที่ต้องการ
  • ข้ามเงียบ แต่เฉพาะแถวซ้ำ
  • error อื่นยังโยนตามปกติ
report_mode เป็น OFF

ปิดเสียงทั้งหน้า

  • คืนพฤติกรรมของ PHP รุ่นเก่า
  • ซ่อน error ที่เกิดจริงด้วย
  • ใช้เป็นทางผ่านเท่านั้น

หาโค้ดที่เสี่ยงแบบเดียวกันก่อนผู้ใช้จะเจอ

INSERT ซ้ำเป็นแค่หนึ่งในรูปแบบ โค้ดเก่าที่พึ่งความเงียบของ mysqli มีหน้าตาอีกหลายแบบ ผมเลยค้นด้วย grep แบบหยาบก่อนลงมือไล่ทีละไฟล์ ไม่ได้หวังให้ครบร้อยเปอร์เซ็นต์ แค่ให้รู้ว่ากองไหนต้องเปิดดูก่อน

# 1) คำสั่งที่เรียกโดดๆ ไม่เช็กค่าที่คืนมา
grep -rnE '^\s*mysqli_query\(' --include=*.php /home/webuser/public_html | head -50

# 2) @ หน้าฟังก์ชัน mysqli (ใน PHP 8 มันไม่ช่วยกัน exception)
grep -rnE '@\s*mysqli_' --include=*.php /home/webuser/public_html

# 3) การเช็กการต่อฐานข้อมูลแบบเก่าที่ต่อไปจะไม่ถูกรัน
grep -rnE 'if *\( *! *\$(conn|db|link)' --include=*.php /home/webuser/public_html

ข้อสองเป็นของที่หลายคนเข้าใจผิด เครื่องหมาย @ หน้าฟังก์ชันปิดได้แค่ warning กับ notice ไม่ได้ปิด exception ดังนั้นโค้ดที่เคยเขียน @mysqli_query(...) เพื่อให้เงียบ จะยังตายเหมือนเดิมบน PHP รุ่นใหม่ ผมเคยเห็นโค้ดเก่าที่ใส่ @ ไว้เกือบทุกบรรทัดที่คุยกับฐานข้อมูล เพราะคนเขียนรู้ดีว่ามันมีโอกาส error และเลือกทำให้เงียบ ซึ่งเป็นแนวคิดที่ใช้ได้ในยุคนั้น และใช้ไม่ได้อีกแล้ว

ผลของ grep ไม่ใช่คำตัดสิน ผมใช้มันเป็นรายชื่อไฟล์ที่ต้องเปิดอ่านและทดสอบต่อ ส่วนที่ขาดไม่ได้เลยยังคงเป็นการยิงหน้าจริงสองรอบในหัวข้อถัดไป

กฎทดสอบหลังขึ้น PHP ใหม่ทุกครั้ง

บทเรียนที่ผมเอาไปใช้คือการทดสอบหลังขึ้นรุ่นต้องมีรอบที่สอง ผมเขียนสคริปต์ crawl ทุกหน้าสองรอบจากเครื่องเดียวกัน บันทึก status ของแต่ละหน้าแล้ว diff กัน หน้าไหนรอบแรกได้ 200 แต่รอบสองได้ 5xx คือหน้านั้นมีบั๊กแบบครั้งที่สอง

#!/usr/bin/env python3
# two_pass.py urls.txt — เปิดทุก URL สองรอบจาก IP เดียวกัน แล้วเทียบ status
import sys, urllib.request, urllib.error

def status(u):
    try:
        return urllib.request.urlopen(u, timeout=20).status
    except urllib.error.HTTPError as e:
        return e.code
    except Exception:
        return 0

urls = [l.strip() for l in open(sys.argv[1]) if l.strip() and not l.startswith('#')]
r1 = {u: status(u) for u in urls}
r2 = {u: status(u) for u in urls}

bad = 0
for u in urls:
    if r1[u] < 500 and r2[u] >= 500:
        print(f'[ERR] {u}  รอบ1={r1[u]} รอบ2={r2[u]}')
        bad += 1
print(f'[{"FAIL" if bad else "OK"}] ผิดปกติ {bad} จาก {len(urls)} หน้า')
sys.exit(1 if bad else 0)

ข้อควรระวังคือต้องยิงจาก IP เดียวกันทั้งสองรอบ และถ้าเว็บอยู่หลัง Cloudflare ให้เทสผ่าน origin โดยตรง ไม่งั้น IP ที่โมดูลเห็นจะเป็นของ proxy ซึ่งหมุนไปเรื่อย ๆ ลำดับที่ผมใช้ทุกครั้งหลังขึ้นรุ่นเป็นแบบนี้

  1. เทสสองรอบจาก IP เดียวกันทุกหน้า แล้ว diff status
  2. ค้น error log ของ PHP หาคำว่า mysqli_sql_exception หลังรอบทดสอบ
  3. ค้นโค้ดเก่าหา INSERT ที่ลงตารางซึ่งมี unique key เพราะนั่นคือจุดที่ซ้ำได้
  4. ค้นหา if (!$conn) หลัง mysqli_connect ซึ่งต่อไปจะไม่ทำงานอีก

ข้อที่สามเป็นของแถมที่เปลี่ยนด้วยค่าเริ่มต้นเดียวกัน การต่อฐานข้อมูลล้มเหลวเดี๋ยวนี้โยน exception เหมือนกัน ไม่ได้คืน false เหมือนเดิม โค้ดที่เขียนว่า if (!$conn) { echo 'ต่อไม่ได้'; } จะไม่มีวันถึงบรรทัดนั้น

ภาพก่อนหลัง สมุดเยี่ยมที่เสมียนเคยปล่อยลายเซ็นซ้ำเงียบ ๆ เทียบกับเสมียนเข้มงวดที่หยุดทั้งแถว และหลังแก้คือเสมียนที่ถูกบอกให้ข้ามลายเซ็นซ้ำ
เสมียนรุ่นเก่าไม่ว่าอะไร เสมียนรุ่นใหม่หยุดทั้งแถวเพราะลายเซ็นซ้ำ ทางแก้คือบอกเสมียนให้ข้ามลายเซ็นซ้ำ ซึ่งตรงกับสิ่งที่สมุดเยี่ยมตั้งใจจะทำอยู่แล้ว
แก้ที่ต้นเหตุINSERT IGNORE INTO visitors(ip, day) VALUES (...)ข้ามแถวซ้ำ ไม่โยน errorนับหนึ่งครั้งต่อ IP ต่อวันเหมือนเดิมความหมายของโมดูลไม่เปลี่ยนทางอื่น: ครอบ try/catchหรือ mysqli_report(MYSQLI_REPORT_OFF)เป็นแค่ทางผ่านชั่วคราวกฎทดสอบหลังขึ้น PHP ใหม่crawl รอบที่ 1ทุกหน้า จาก IP เดียว บันทึก statuscrawl รอบที่ 2ทุกหน้า จาก IP เดิม บันทึก statusdiff รอบ 1 กับรอบ 2status ต่างกัน = เจอบั๊กแบบนี้ดู mysqli_sql_exception ใน error log ด้วย
ซ้าย: ทำให้แถวซ้ำไม่ใช่ข้อผิดพลาด ขวา: ทดสอบให้เจอตั้งแต่ก่อนผู้ใช้จริงเจอ

สรุปคือ

โค้ดเก่าส่วนใหญ่ไม่ได้ "ถูกต้อง" มันแค่ "ไม่เคยถูกบอกว่าผิด" และบางเรื่องที่มันผิดมาตลอดอย่างตารางที่ไม่เคยมี เพิ่งถูกเปิดโปงตอนค่าเริ่มต้นของ PHP เปลี่ยน ผมไม่ได้ว่าโค้ดเหล่านั้นเขียนมาแย่ แต่การขึ้น PHP รุ่นใหม่เป็นการตรวจสอบโค้ดแบบฟรี ๆ ที่เราต้องยอมรับผลของมันให้ครบ

ข้อที่ผมเก็บไว้ใช้ต่อมีดังนี้

  • ค่าเริ่มต้นของ mysqli_report() เปลี่ยนตั้งแต่ PHP 8.1 error ของ mysqli ทุกตัวกลายเป็น exception ถ้าไม่มีใครจับ หน้าตาย
  • บั๊กชนิดนี้โผล่เฉพาะเงื่อนไขซ้ำ เช่นครั้งที่สองของวันจาก IP เดิม การเปิดหน้าครั้งเดียวไม่มีวันเจอ
  • error ที่โค้ดเก่าเคยกลืน เช่นตารางที่ไม่เคยมี คือหนี้เก่าที่ถูกเรียกเก็บตอนขึ้นรุ่น ไม่ใช่บั๊กใหม่
  • INSERT IGNORE แก้ที่ความหมาย ส่วน mysqli_report(MYSQLI_REPORT_OFF) เป็นแค่ทางผ่านชั่วคราว
  • หลังขึ้น PHP รุ่นไหนก็ตาม ยิงทุกหน้าสองรอบจาก IP เดียวกันแล้วเทียบ status ก่อนบอกว่าเสร็จ

ผลหลังแก้ ทุกเว็บที่ได้รับผลกระทบตอบ 200 ทั้งครั้งแรกและครั้งต่อ ๆ มา ส่วนตัวผมจะรัน two_pass.py ทุกครั้งที่ขึ้น PHP รุ่นใหญ่ จะใช้หรือไม่ตามสะดวกครับ แต่ถ้าไม่ทำรอบสอง บั๊กลักษณะนี้จะรอผู้ใช้จริงมาเจอแทน ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ

อยากให้เราทำให้ไหม?

ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า

คุยเรื่องงานกับเรา

เคยเจองานแบบนี้ไหม? ติดตรงไหน?

มาแลกวิธีกันต่อในบอร์ด LinuxThai

→ คุยต่อในบอร์ด

ความคิดเห็น 0

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