ย้ายการคำนวณจาก PHP ไปทำในเบราว์เซอร์: 12 วินาที เหลือ 12 มิลลิวินาที
เว็บที่ช้าเพราะฐานข้อมูล ไม่ได้แปลว่าฐานข้อมูลช้า หลายทีมันแปลว่าเราถามผิดวิธี — หน้าเดียวยิง SQL หลักหมื่นครั้งเพราะสูตรถูกเขียนให้ทำงานทีละแถว พอย้ายสูตรออกจาก PHP ไปไว้ในเบราว์เซอร์ สิ่งที่ได้กลับมาไม่ใช่แค่ความเร็ว แต่เปลี่ยนวิธีใช้งานทั้งหน้า
พอดีมีงานหนึ่งที่ผมรับมาแก้ เป็นระบบจับคู่รายการจำนวนหลักพัน ผู้ใช้กรอกเงื่อนไขของตัวเองเข้าไปชุดหนึ่ง แล้วระบบจะไล่เทียบกับรายการที่มีอยู่ 1,090 รายการ พร้อมประวัติย้อนหลัง 5 ปีของแต่ละรายการ แล้วคำนวณออกมาเป็นลำดับความเป็นไปได้ ปัญหาคือหน้าผลลัพธ์โหลดนาน 12 วินาทีขึ้นไป กดเปลี่ยนตัวกรองทีก็รอใหม่อีก 12 วินาที ผมเลยรื้อสถาปัตยกรรมของหน้านี้ใหม่ทั้งหน้า ย้ายสูตรออกจาก PHP ไปคำนวณในเบราว์เซอร์ จบที่ 12 มิลลิวินาที
ชื่อไฟล์ ชื่อตาราง และชื่อฟิลด์ในบทความเป็นตัวอย่างที่ผมสมมติขึ้น โครงสร้างวิธีคิดกับโค้ดคือของจริงที่ใช้งานอยู่
ต้นเหตุ: สูตรที่ถูกเขียนให้ทำงานทีละแถว
โค้ดเดิมไม่ได้เขียนผิดนะครับ มันอ่านง่ายด้วยซ้ำ วนรายการทีละตัว แล้วในแต่ละตัวก็ไปถามฐานข้อมูลว่าประวัติปีที่แล้วเป็นยังไง ปีก่อนหน้าเป็นยังไง จำนวนที่รับเท่าไหร่ จำนวนที่ยื่นเท่าไหร่ ค่าต่ำสุด-สูงสุดเท่าไหร่ ถามแยกกันทีละคำถาม
// includes/matcher.php — โครงเดิม อ่านง่าย แต่ยิง SQL ทีละแถว
foreach ($items as $item) { // 1,090 รอบ
foreach ([2564, 2565, 2566, 2567, 2568] as $y) { // 5 ปี
// subquery ชุดนี้ยิงต่อ "รายการ x ปี" = 5 ครั้งต่อรายการเป็นอย่างน้อย
$h = $db->query("
SELECT quota, applied, passed, min_score, max_score
FROM item_history
WHERE item_code = ?
AND year = ?
AND variant_key LIKE ? -- ตรงนี้แหละตัวปัญหา
ORDER BY id LIMIT 1", [$item['code'], $y, '%'.$item['pattern'].'%']);
$item['history'][$y] = $h->fetch();
}
$item['buffer'] = buffer_of($item); // สูตรอยู่ฝั่ง PHP
$item['chance'] = chance_of($item);
}
นับจริงแล้วรายการหนึ่งกินตั้งแต่ 10 ถึง 25 คำสั่ง แล้วแต่ว่ามีประวัติกี่ปีและมีรูปแบบย่อยกี่ตัว คูณกับ 1,090 รายการก็ตกราวๆ 10,900 ถึง 27,250 คำสั่ง SQL ต่อการโหลดหนึ่งหน้า แต่ละคำสั่งเร็วมากนะครับ หลักเศษมิลลิวินาที ปัญหาไม่ได้อยู่ที่คำสั่งไหนช้า มันอยู่ที่จำนวนครั้งล้วนๆ ค่า round trip กับ parse ของแต่ละคำสั่งเมื่อเอามารวมกันหลักหมื่นครั้งก็กลายเป็น 12 วินาที
จุดที่ทำให้ผมตัดสินใจรื้อทั้งหน้าไม่ใช่แค่ 12 วินาทีนั้น แต่เป็นเรื่องที่ผู้ใช้กดเปลี่ยนตัวกรอง แล้วต้องรอ 12 วินาทีใหม่ทั้งที่ข้อมูลชุดเดิมเป๊ะ ไม่มีอะไรเปลี่ยนเลยนอกจากเงื่อนไขการกรอง คำนวณซ้ำของเดิมสิบรอบเพื่อคำตอบสิบแบบ
ชั้นที่หนึ่ง: ทำตารางสรุปไว้ล่วงหน้า
งานที่หนักที่สุดในลูปข้างบนคือการจับคู่ "รายการ" กับ "รูปแบบย่อย" ในประวัติแต่ละปี เพราะชื่อรูปแบบย่อยมันเขียนไม่เหมือนกันทุกปี บางปีเขียนยาว บางปีเขียนย่อ บางปีสลับคำ ผมเลยยกงานนี้ออกจาก request path ทั้งก้อน ทำเป็น materialized table คำนวณไว้ล่วงหน้าครั้งเดียว
-- ตารางสรุป: หนึ่งแถว = หนึ่งรายการ x หนึ่งปี ชี้ไปที่แถวประวัติที่จับคู่ได้แล้ว
CREATE TABLE item_history_matched (
item_code VARCHAR(32) NOT NULL,
year SMALLINT NOT NULL,
history_id INT UNSIGNED NULL, -- NULL = จับคู่ไม่ได้ (ตั้งใจให้เป็น NULL)
variant_key VARCHAR(255) NULL,
match_score INT NOT NULL DEFAULT 0,
quota SMALLINT NULL,
applied INT NULL,
passed INT NULL,
min_score DECIMAL(7,3) NULL,
max_score DECIMAL(7,3) NULL,
PRIMARY KEY (item_code, year),
KEY idx_year (year)
) ENGINE=InnoDB;
รอบแรกผมเขียนตัว precompute เป็น SQL ล้วน ใช้ LIKE บวก window function หา best match ต่อกลุ่ม ผลคือทำงานได้ แต่กิน ราว 90 วินาที ต่อการ rebuild หนึ่งรอบ และที่แย่กว่าคือมันจับคู่ผิดในเคสที่ชื่อรูปแบบย่อยเขียนแบบยาวผิดปกติ เพราะ LIKE มันไม่รู้จักโครงสร้างของคำ ผมเลยทิ้ง SQL เวอร์ชันนั้นแล้วเขียนใหม่เป็น Python ที่ตัดคำแล้วให้คะแนนเอง
# tools/precompute_matched.py — สร้างตารางสรุป (rebuild เต็ม ~1 วินาที)
import re
NOISE = {"หลักสูตร", "กลุ่ม", "ประเภท", "เกณฑ์", "รูปแบบ", "แบบ"}
SPLIT = re.compile(r"[\s|()/,.\-]+")
def tokens(s: str) -> set:
out = set()
for t in SPLIT.split(s or ""):
t = t.strip()
if not t or t in NOISE:
continue
out.add(t) # เก็บ token ตัวเลขหลักเดียวไว้ด้วย สำคัญมากกับ "รูปแบบที่ 3"
return out
ตัดคำแล้วเทียบเป็นเซ็ต เร็วกว่า LIKE คนละโลก rebuild เต็มทั้งตารางเหลือ ประมาณ 1 วินาที จาก 90 วินาที และที่สำคัญกว่าความเร็วคือมันเถียงกลับได้ ผมอ่านออกว่าทำไมแถวนี้ถึงจับคู่กับตัวนั้น
กฎจับคู่: ตัดทิ้งก่อน แล้วค่อยให้คะแนน
ตอนแรกผมทำเป็น scoring ล้วน คือให้คะแนนทุก candidate แล้วเลือกตัวคะแนนสูงสุด ปรากฏว่าได้ผลลัพธ์ที่ "ดูเหมือนถูก" แต่ผิดจริง เพราะตัวที่ผิดชนิดกันเลยแต่บังเอิญมีคำซ้ำเยอะ ก็ยังชนะตัวที่ถูกชนิดแต่เขียนสั้น ผมเลยแยกเป็นสองชั้น ชั้นแรกคือ hard reject ตัดออกจากการแข่งขันไปเลย ไม่ใช่แค่หักคะแนน
def hard_reject(item, cand) -> bool:
"""คืน True = ตัดทิ้ง ไม่ให้เข้ารอบให้คะแนน"""
# 1) กลุ่มสิทธิ์ไม่ตรง — ฝั่งหนึ่งระบุกลุ่มพิเศษ อีกฝั่งไม่ระบุ
if item.special_group != cand.special_group:
return True
# 2) ชนิดชุดเกณฑ์ระบุมาทั้งคู่ แต่คนละชนิด (SET-A / SET-B / SET-C)
if item.set_type and cand.set_type and item.set_type != cand.set_type:
return True
# 3) "รูปแบบที่ N" ระบุมาทั้งคู่ แต่ N ไม่ตรง
if item.pattern_no and cand.pattern_no and item.pattern_no != cand.pattern_no:
return True
return False
def soft_score(item, cand) -> int:
s = 0
if cand.legacy_code and cand.legacy_code == item.legacy_code:
s += 10000 # รหัสเก่าแบบ _vNN ตรงเป๊ะ ชนะทุกอย่าง
if item.pattern_no and item.pattern_no == cand.pattern_no:
s += 500
if item.set_type and item.set_type == cand.set_type:
s += 500
s += 10 * len(tokens(item.name) & tokens(cand.variant_key))
if cand.is_base_row:
s += 1 # tie-break: แถวฐานชนะแถวแตกย่อย
return s
def pick(item, candidates):
pool = [c for c in candidates if not hard_reject(item, c)]
if not pool:
return None # ตัดหมด = ยอมว่าง ดีกว่าเดาผิด
return max(pool, key=lambda c: (soft_score(item, c), -c.id))
ถ้า candidate โดน hard reject หมดทุกตัว ผมให้ผลลัพธ์เป็น NULL แล้วหน้าเว็บแสดงเป็นขีด ไม่ใช่ไปหยิบตัวคะแนนสูงสุดที่เหลือมาใส่ เพราะข้อมูลว่างที่ผู้ใช้เห็นว่าว่าง เสียหายน้อยกว่าข้อมูลผิดที่ผู้ใช้เชื่อว่าถูก หลังปรับกฎชุดนี้ตารางสรุปมี 2,696 แถวที่จับคู่ได้ และตรวจย้อนแล้วเหลือ 0 แถวที่ชนิดเกณฑ์หรือเลขรูปแบบขัดกันเอง
ชั้นที่สอง: ส่ง dataset ก้อนเดียว แล้วคำนวณในเบราว์เซอร์
พอมีตารางสรุปแล้ว การดึงข้อมูลของทั้งหน้าเหลือ query เดียวจริงๆ ผมเลยทำ endpoint ตัวเดียวที่ยิง join ชุดเดียวแล้วส่ง JSON ก้อนเดียวออกไป ไม่มี HTML ไม่มีการคำนวณ
// dataset_api.php — ส่งข้อมูลดิบทั้งก้อนของ session หนึ่ง ไม่คำนวณอะไรเลย
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: private, max-age=1800'); // 30 นาที ให้ตรงกับ TTL ฝั่ง client
$rows = $db->query("
SELECT i.code, i.name, i.weight_json, i.set_type, i.pattern_no,
m.year, m.quota, m.applied, m.passed, m.min_score, m.max_score
FROM items i
LEFT JOIN item_history_matched m ON m.item_code = i.code
WHERE i.active = 1
ORDER BY i.code, m.year")->fetchAll(PDO::FETCH_ASSOC);
$out = ['ts' => time(), 'items' => group_by_code($rows), 'subjects' => subject_map()];
echo json_encode($out, JSON_UNESCAPED_UNICODE); // ไม่ escape ไทย ประหยัดขนาดเยอะ
ก้อนนี้ออกมา ราว 1.3 MB ดิบ ฟังดูใหญ่ แต่พอเปิด gzip ที่ web server แล้วเหลือ ราว 107 KB เพราะ JSON ที่มีคีย์ซ้ำๆ แบบนี้บีบได้ดีมาก เวลาตอบกลับวัดได้ราว 185 มิลลิวินาที ต่อจากนั้นสูตรทั้งหมดย้ายมาอยู่ฝั่งเบราว์เซอร์
// assets/matcher.js — สูตรตัวเดียวกับ PHP แต่รันในเบราว์เซอร์
export function bufferOf(item, profile) {
const w = item.weight; // น้ำหนักถ่วงต่อหมวด
let sum = 0, div = 0;
for (const k of Object.keys(w)) {
sum += (profile[k] ?? 0) * w[k];
div += w[k];
}
return div === 0 ? null : sum / div;
}
export function matchItems(items, profile, filter) {
const out = [];
for (const it of items) { // 1,090 รอบ ในหน่วยความจำล้วน
if (filter.group && it.group !== filter.group) continue;
const b = bufferOf(it, profile);
if (b === null) continue;
out.push({ ...it, buffer: b, chance: chanceOf(it, b) });
}
out.sort((a, b) => b.chance - a.chance);
return out;
}
วนครบ 1,090 รายการพร้อมคำนวณ ใช้เวลา 12 มิลลิวินาที วัดด้วย performance.now() บนโน้ตบุ๊กธรรมดา ไม่ใช่เครื่องแรง แล้วการ render ตารางทั้ง 1,090 แถวลง DOM อีกไม่ถึง 200 มิลลิวินาที รวมแล้วกดเปลี่ยนตัวกรองแล้วผลเปลี่ยนทันตา ไม่ต้องมี spinner
ทุกครั้งที่กด คือเริ่มใหม่หมด
- เปลี่ยนตัวกรอง = ยิง SQL ใหม่ทั้งชุด
- ผู้ใช้ตรวจสูตรเองไม่ได้ ต้องเชื่อ
- โหลด DB ตามจำนวนผู้ใช้พร้อมกัน
- debug ต้องเปิด log ฝั่งเซิร์ฟเวอร์
โหลดข้อมูลครั้งเดียว ที่เหลือฟรี
- เปลี่ยนตัวกรอง = คำนวณใหม่ในหน่วยความจำ
- สูตรเปิดอ่านได้ใน DevTools ตรวจย้อนได้
- DB โดนแค่ครั้งเดียวต่อ session
- วาง breakpoint ดูค่ากลางได้ทีละตัว
เรื่องความโปร่งใสนี่เป็นเหตุผลที่ผมให้น้ำหนักมากพอๆ กับความเร็วนะครับ พอสูตรอยู่ในไฟล์ที่ใครก็เปิดดูได้ คนที่สงสัยว่าเลขมาจากไหนก็กด DevTools ไล่ดูเองได้ทีละบรรทัด ไม่ต้องเชื่อคำอธิบายของคนทำระบบอย่างเดียว
เก็บ dataset ไว้ใน localStorage อายุ 30 นาที
ถ้าผู้ใช้ปิดแท็บแล้วเปิดใหม่ หรือกดย้อนกลับ ก็ไม่มีเหตุผลจะโหลด 107 KB ใหม่ ผมเลยแคชไว้ที่ localStorage พร้อม timestamp
// assets/app.js — โหลด dataset โดยใช้แคชถ้ายังไม่หมดอายุ
const TTL = 30 * 60 * 1000; // 30 นาที
const KEY = (sid) => `ds_cache_${sid}`;
async function loadDataset(sid) {
try {
const hit = JSON.parse(localStorage.getItem(KEY(sid)) || 'null');
if (hit && Date.now() - hit.savedAt < TTL) return hit.data;
} catch (e) { /* JSON เสียหรือ quota เต็ม ให้ตกไปโหลดใหม่ */ }
const res = await fetch(`dataset_api.php?sid=${encodeURIComponent(sid)}`);
const data = await res.json();
try {
localStorage.setItem(KEY(sid), JSON.stringify({ savedAt: Date.now(), data }));
} catch (e) {
localStorage.removeItem(KEY(sid)); // เต็มก็ล้างทิ้ง อย่าให้หน้าพัง
}
return data;
}
โควตาปกติราว 5 MB ต่อ origin และเก็บได้แค่ string เท่านั้น เวลาเต็มมันจะโยน QuotaExceededError ออกมา ถ้าไม่ครอบ try/catch ไว้ ทั้งหน้าจะตายตรงบรรทัด setItem ทั้งที่ข้อมูลอยู่ในมือแล้ว ผมเลยให้ทุกทางที่ผิดพลาดตกลงมาที่ "โหลดใหม่จาก server" เสมอ แคชคือของแถม ไม่ใช่เส้นทางหลัก
TTL 30 นาทีผมเลือกจากพฤติกรรมข้อมูลจริงครับ ตารางสรุปมัน rebuild วันละครั้ง ถ้าตั้งสั้นกว่านี้ก็เสียประโยชน์ของแคชไปเปล่าๆ ถ้าตั้งยาวเป็นวันก็เสี่ยงว่าคนเห็นข้อมูลเก่าหลังจาก rebuild ใครจะเอาไปใช้ปรับตามรอบข้อมูลของตัวเองได้ตามสะดวก
parity test: สูตรสองชุดต้องออกตรงกันทุกแถว
ตรงนี้คือส่วนที่ผมถือว่าสำคัญที่สุดของงานทั้งหมด พอย้ายสูตรไป JS ผมไม่ได้ลบ includes/matcher.php ทิ้ง ผมเก็บไว้เป็นต้นฉบับความจริง แล้วทำเทสต์ที่บังคับว่าผลของสองฝั่งต้องตรงกันทุกแถว เพราะความเสี่ยงที่แท้จริงของสถาปัตยกรรมนี้ไม่ใช่ความเร็ว แต่คือวันที่มีคนแก้สูตรฝั่งเดียวแล้วอีกฝั่งค้างอยู่เวอร์ชันเก่า
// tests/dump_php_result.php — ให้ฝั่ง PHP คายผลเต็มความละเอียดออกมาเป็น JSON
$items = load_items();
$prof = json_decode(file_get_contents('tests/fixtures/profile.json'), true);
$rows = [];
foreach (match_programs($items, $prof) as $r) {
$rows[] = [
'code' => $r['code'],
'buffer' => sprintf('%.17g', $r['buffer']), // บังคับ 17 หลัก อย่าให้ปัดโชว์
'chance' => sprintf('%.17g', $r['chance']),
'year_used' => $r['year_used'],
];
}
file_put_contents('tests/out/php.json', json_encode($rows, JSON_UNESCAPED_UNICODE));
// tests/parity.mjs — รันสูตรฝั่ง JS ด้วย fixture เดียวกัน แล้วเทียบทีละแถว
import { readFileSync } from 'node:fs';
import { matchItems } from '../assets/matcher.js';
const EPS = 1e-12;
const items = JSON.parse(readFileSync('tests/fixtures/dataset.json', 'utf8')).items;
const prof = JSON.parse(readFileSync('tests/fixtures/profile.json', 'utf8'));
const php = new Map(JSON.parse(readFileSync('tests/out/php.json', 'utf8')).map(r => [r.code, r]));
const js = matchItems(items, prof, {});
let bad = 0;
if (js.length !== php.size) {
console.error(`[ERR] จำนวนแถวไม่ตรง: js=${js.length} php=${php.size}`);
bad++;
}
for (const r of js) {
const p = php.get(r.code);
if (!p) { console.error(`[ERR] ${r.code}: ฝั่ง PHP ไม่มีแถวนี้`); bad++; continue; }
for (const f of ['buffer', 'chance']) {
const a = Number(p[f]), b = r[f];
// ต่างกันเกิน EPS = สูตรหลุดกัน ไม่ใช่เรื่องปัดเศษ
if (!(Math.abs(a - b) <= EPS * Math.max(1, Math.abs(a)))) {
console.error(`[ERR] ${r.code}.${f}: php=${a} js=${b} diff=${Math.abs(a - b)}`);
if (++bad >= 20) { console.error('...ตัดที่ 20 แถวแรก'); break; }
}
}
if (String(p.year_used) !== String(r.year_used)) {
console.error(`[ERR] ${r.code}.year_used: php=${p.year_used} js=${r.year_used}`); bad++;
}
}
console.log(bad === 0
? `[OK] ตรงกันทั้ง ${js.length} แถว`
: `[FAIL] ไม่ตรง ${bad} จุด`);
process.exit(bad === 0 ? 0 : 1);
รันจริงแล้วได้ [OK] ตรงกันทั้ง 1,090 แถว และเวลาที่ใช้ต่างกันคือฝั่ง PHP 12 วินาที ฝั่ง JS 12 มิลลิวินาที บน fixture ชุดเดียวกัน ผมผูกเทสต์นี้ไว้กับ pre-commit hook เลยครับ
# .git/hooks/pre-commit — กัน commit ที่แก้สูตรฝั่งเดียว
changed=$(git diff --cached --name-only)
echo "$changed" | grep -qE 'assets/matcher\.js|includes/matcher\.php' || exit 0
php tests/dump_php_result.php && node tests/parity.mjs || {
echo "[BLOCK] parity test ไม่ผ่าน — สูตร PHP กับ JS ไม่ตรงกัน"; exit 1; }
การมีสูตรเดียวกันอยู่สองภาษาคือข้อเสียที่แลกมากับความเร็ว และมันจะทำร้ายคุณในวันที่ทุกคนลืมไปแล้วว่ามีอีกไฟล์หนึ่ง วิธีเดียวที่ผมเชื่อคือทำให้ เครื่องจำแทนคน — parity test ต้องรันอัตโนมัติ และต้องบล็อกการ commit ได้จริง ไม่ใช่แค่เตือน ถ้าทีมยังไม่มี CI ให้ผูกที่ pre-commit hook ไปก่อนก็ยังดีกว่าไม่มี
ขั้นตอนที่ผมเขียนติดไว้ในหัวไฟล์ทั้งสองตัวเลยคือ
- แก้สูตรใน
includes/matcher.phpก่อนเสมอ เพราะเป็นต้นฉบับความจริง - แก้
assets/matcher.jsให้ตรงกัน ทั้งลำดับการคำนวณและการปัดเศษ - รัน
php tests/dump_php_result.phpเพื่อคายเป้าเต็มความละเอียดออกมา - รัน
node tests/parity.mjsต้องได้ OK ครบทุกแถวก่อน commit - ถ้าแก้ตัวจับคู่ ต้อง rebuild ตารางสรุปด้วย แล้วรัน parity อีกรอบ
ตัวเลขก่อนกับหลัง
ที่อยากย้ำคือวิธีนี้ไม่ได้เหมาะกับทุกงานนะครับ เงื่อนไขที่ทำให้มันคุ้มมีอยู่สามข้อ หนึ่งคือ dataset ต้องมีเพดานที่รู้แน่ ระดับหลักพันแถวไม่ใช่หลักล้าน สองคือข้อมูลที่ส่งไปต้องเป็นข้อมูลที่ผู้ใช้คนนั้นมีสิทธิ์เห็นอยู่แล้วทั้งก้อน ถ้าต้องกรองสิทธิ์รายแถวห้ามส่งลงไปเด็ดขาด และสามคือสูตรต้องนิ่งพอที่จะดูแลสองชุดไหว ถ้าสูตรเปลี่ยนทุกอาทิตย์ก็อย่าทำแบบนี้ เก็บไว้ฝั่งเดียวแล้วไปแก้ที่ query แทนดีกว่า
ส่วนตัวผมได้บทเรียนจากงานนี้ข้อเดียวสั้นๆ ว่า ก่อนจะไปหาว่า query ไหนช้า ให้ไปนับก่อนว่ายิงไปกี่ครั้ง เพราะหลายงานที่ผมเจอมันไม่ได้ช้าที่คำสั่งเดียว แต่ช้าเพราะเราวางสูตรไว้ผิดชั้นตั้งแต่แรก ใครจะเอาโครงนี้ไปปรับใช้ ขนาด dataset กับ TTL ตั้งตามงานตัวเองได้ตามสะดวกครับ ตรงไหนผมอธิบายตกหล่นหรือพิมพ์ผิด เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
อยากให้เราทำให้ไหม?
ให้เราช่วยติดตั้ง ตั้งค่า ย้ายระบบ หรือแก้ปัญหาเซิร์ฟเวอร์ เพื่อให้คุณมีเวลาไปทำงานที่สำคัญกว่า
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



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