เว็บเก่าขึ้น 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 คือปิดรายงานซึ่งเป็นสภาพที่โค้ดเก่าพึ่งพามาตลอด ผมไม่ขอระบุว่ารุ่นเก่าเป็นค่าอะไรเป๊ะ ๆ ขอพูดแค่ว่าโค้ดชุดนี้เขียนโดยสมมติว่ามันเงียบ

อาการนี้ไม่เกี่ยวกับ strict mode ของ MariaDB ที่ทำให้ error 1364/1366 โผล่หลังย้ายเครื่อง (อันนั้นผมเขียนไว้ใน แอปเก่าพังหลังย้ายเซิร์ฟเวอร์ — error 1364/1366 กับ MariaDB strict mode) ตรงนี้ฝั่ง SQL ตอบ 1062 เหมือนเดิมทุกปี สิ่งที่เปลี่ยนคือฝั่ง PHP ที่เลิกกลืน error
ทำไมการเช็กตอนขึ้นรุ่นถึงไม่เจอ
เพราะครั้งแรกของวันจาก IP ใหม่ INSERT ผ่านเสมอ curl ครั้งเดียว เปิดเบราว์เซอร์ครั้งเดียว หรือสคริปต์ที่วนทุกหน้าหนึ่งรอบ ล้วนเป็น "ครั้งแรกของวัน" ทั้งสิ้น และมันได้ 200 ทุกหน้า ตัวที่เจอบั๊กคือผู้เข้าชมจริงที่กดหน้าที่สองหรือกลับมาเปิดซ้ำ ซึ่งเกิดขึ้นตลอดเวลา
อีกเรื่องที่ซ่อนมันไว้ ถ้า IP ที่เห็นเปลี่ยนทุกครั้ง เช่นผ่าน proxy หรือ crawler ที่หมุน IP ก็จะไม่เจอแถวซ้ำเลย การทดสอบจากภายนอกบางแบบเลยผ่านหมดทั้งที่หน้าพังสำหรับคนจริง
เรื่องที่สอง: ตารางที่ไม่เคยมี
ระหว่างไล่เว็บพี่น้องของโมดูลเดียวกัน ผมเจอสี่เว็บที่ตอบ 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; }
}
แก้ที่ความหมาย
- ซ้ำคือพฤติกรรมที่ต้องการ
- ข้ามเงียบ แต่เฉพาะแถวซ้ำ
- error อื่นยังโยนตามปกติ
ปิดเสียงทั้งหน้า
- คืนพฤติกรรมของ 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 ซึ่งหมุนไปเรื่อย ๆ ลำดับที่ผมใช้ทุกครั้งหลังขึ้นรุ่นเป็นแบบนี้
- เทสสองรอบจาก IP เดียวกันทุกหน้า แล้ว diff status
- ค้น error log ของ PHP หาคำว่า
mysqli_sql_exceptionหลังรอบทดสอบ - ค้นโค้ดเก่าหา INSERT ที่ลงตารางซึ่งมี unique key เพราะนั่นคือจุดที่ซ้ำได้
- ค้นหา
if (!$conn)หลังmysqli_connectซึ่งต่อไปจะไม่ทำงานอีก
ข้อที่สามเป็นของแถมที่เปลี่ยนด้วยค่าเริ่มต้นเดียวกัน การต่อฐานข้อมูลล้มเหลวเดี๋ยวนี้โยน exception เหมือนกัน ไม่ได้คืน false เหมือนเดิม โค้ดที่เขียนว่า if (!$conn) { echo 'ต่อไม่ได้'; } จะไม่มีวันถึงบรรทัดนั้น

สรุปคือ
โค้ดเก่าส่วนใหญ่ไม่ได้ "ถูกต้อง" มันแค่ "ไม่เคยถูกบอกว่าผิด" และบางเรื่องที่มันผิดมาตลอดอย่างตารางที่ไม่เคยมี เพิ่งถูกเปิดโปงตอนค่าเริ่มต้นของ 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
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย