
วันเปิดขายบัตรคอนเสิร์ตดัง คนเป็นล้านกดเข้าเว็บพร้อมกัน แล้วหน้าเว็บก็หมุนค้าง ขึ้น error หรือแย่กว่านั้นคือจ่ายเงินแล้วแต่ที่นั่งหายไป คำถามที่ตามมาทุกครั้งคือ "ทำไมไม่เพิ่มเซิร์ฟเวอร์ให้มากกว่านี้" คำตอบสั้นๆ คือเพิ่มได้ แต่คอขวดจริงมักไม่ได้อยู่ที่จำนวนเซิร์ฟเวอร์
บทความนี้อธิบายว่าคอขวดอยู่ตรงไหน แล้วให้ลองเห็นกับตาในเครื่องตัวเองด้วย PostgreSQL ใน Docker ทั้งขีดจำกัด connection ของฐานข้อมูล ปัญหาขายที่นั่งซ้ำ และการล็อกที่นั่งให้ทีละคน ปิดท้ายด้วย connection pool คิวงาน และห้องรอ ที่ระบบรับคนจำนวนมากใช้กัน
จบบทความนี้คุณจะ:
- อธิบายได้ว่าทำไมเพิ่มเซิร์ฟเวอร์แล้วเว็บยังล่ม
- เปิด PostgreSQL ใน Docker แล้วดูขีดจำกัด connection ด้วยตาตัวเอง
- เห็น race condition ที่ทำให้ขายที่นั่งซ้ำ และกันได้ด้วย
SELECT ... FOR UPDATE - รู้ว่า connection pool, คิวงาน และห้องรอ แก้ปัญหาคนละชั้นยังไง
- แก้ปัญหาที่เจอบ่อยในแล็บ และเก็บกวาดเครื่องให้เรียบร้อย
เรื่องจริงที่เป็นข่าว
เดือนพฤศจิกายน 2022 Ticketmaster เปิดขายบัตรรอบพรีเซลทัวร์ของ Taylor Swift แล้วระบบรับไม่ไหวจนเป็นข่าวไปทั่วโลก บริษัทออกคำแถลงพร้อมตัวเลขนี้
| ตัวเลข | ที่มา |
|---|---|
| คำขอ 3,500 ล้านครั้ง สูงกว่าสถิติเดิม 4 เท่า | คำแถลงของ Ticketmaster (รวมบอต) |
| ผู้เข้ามาราว 14 ล้านราย | คำแถลงเดียวกัน |
| ขายบัตรได้กว่า 2 ล้านใบ ในวันนั้น | คำแถลงเดียวกัน |
อ่านตัวเลขนี้ให้ถูก ตัวเลขเป็นของบริษัทเอง ไม่ใช่ผลตรวจสอบอิสระ และบทความนี้ไม่ได้บอกว่าระบบของเขาล่มเพราะอะไร เราไม่รู้สถาปัตยกรรมจริงของระบบขายตั๋วรายไหน ใช้ตัวเลขนี้แค่ให้เห็นว่าคนมาพร้อมกันได้มากแค่ไหน ส่วนหลักการที่เหลือในบทความเป็นหลักการทั่วไปที่ใช้กับเว็บทุกแบบ
ศัพท์ที่ต้องรู้
| คำ | ความหมายแบบมือใหม่ | เทียบกับ |
|---|---|---|
| request | คำขอหนึ่งครั้งที่เบราว์เซอร์ส่งไปหาเซิร์ฟเวอร์ | กดปุ่มหนึ่งครั้ง |
| server | เครื่องที่ตอบ request | หน้าร้าน |
| scale out | เพิ่มจำนวนเซิร์ฟเวอร์ให้ช่วยกันรับ | เปิดหน้าร้านเพิ่ม |
| database (DB) | ที่เก็บข้อมูลกลาง เช่น ที่นั่งไหนขายแล้ว | ห้องเก็บของ |
| connection | สายที่เปิดคุยกับ DB ค้างไว้ | ช่องบริการ |
| bottleneck | จุดที่แคบที่สุด ทั้งระบบเร็วได้ไม่เกินจุดนี้ | คอขวด |
| race condition | สองคนทำพร้อมกันแล้วผลผิด เช่น ขายที่นั่งซ้ำ | มือแย่งที่นั่ง |
| lock | จองแถวข้อมูลให้ทีละคน คนอื่นต้องรอ | กุญแจบนที่นั่ง |
| transaction | ชุดคำสั่งที่สำเร็จทั้งหมดหรือไม่สำเร็จเลย | ใบสั่งซื้อหนึ่งใบ |
| connection pool | สายที่เปิดไว้ล่วงหน้าแล้วแบ่งกันใช้ | ช่องบริการจำนวนคงที่ |
| queue | ให้รอตามลำดับแทนการแย่งกัน | ห้องรอ |
คอขวดอยู่ตรงไหน
ลองไล่ตามทางที่การกดจองหนึ่งครั้งเดินทางครับ
- หน้าเว็บ scale out ได้ เพิ่มเครื่องได้เรื่อยๆ เพราะแต่ละเครื่องแค่ส่งหน้าเว็บและรับ request
- ทุกเครื่องต้องถาม DB ตัวเดียวกัน ที่นั่งมีชุดเดียว ข้อมูลจริงต้องอยู่ที่เดียว ไม่งั้นสองเครื่องอาจขายที่นั่งเดียวกันให้คนละคน
- DB รับ connection ได้จำกัด ใน PostgreSQL ค่า
max_connectionsโดยทั่วไปคือ 100 (ตามเอกสาร PostgreSQL หัวข้อ Connections and Authentication) - ที่นั่งเดียวกันต้องล็อก ยิ่งคนแย่งที่เดียวกันมาก ยิ่งต้องรอกันนาน
นึกภาพหน้าร้านที่เปิดเพิ่มได้เป็นพันร้าน แต่ทุกร้านต้องเดินไปต่อแถวที่ห้องเก็บของห้องเดียว เพิ่มหน้าร้านมากเท่าไร แถวหน้าห้องเก็บของก็ยิ่งยาว ทางแก้จึงต้องคุมจำนวนคนที่เข้าไปถึง DB ไม่ใช่แค่เพิ่มเครื่อง

แยกสำเนาฐานข้อมูลไม่ช่วยตอนจอง? ระบบใหญ่มักแยกสำเนาฐานข้อมูลไว้ให้อ่าน เช่น หน้าแสดงผังที่นั่ง แต่การจองคือการเขียน ซึ่งต้องเขียนที่ตัวหลักที่เดียวเพื่อไม่ให้ขายซ้ำ จุดที่ทุกคนแย่งกันจึงยังเป็นฐานข้อมูลตัวหลัก
เตรียมแล็บ PostgreSQL ด้วย Docker
แล็บนี้ใช้ Docker จะได้ไม่ต้องติดตั้งฐานข้อมูลลงเครื่อง ใช้เวลาราว 30–45 นาทีทั้งบท เปิด Docker Desktop ให้ทำงานก่อน แล้วพิมพ์
docker run --name pg-lab -e POSTGRES_PASSWORD=lab -p 5432:5432 -d postgres:18(ตัวเลขยาวๆ คือรหัสของ container)ครั้งแรก Docker จะดาวน์โหลด image ของ PostgreSQL ก่อน รอสักครู่ แล้วเช็กว่า container ทำงานอยู่
docker ps... postgres:18 ... Up ... pg-labเปิดหน้าต่างคุยกับฐานข้อมูลด้วย psql ซึ่งมีมาใน container อยู่แล้ว
docker exec -it pg-lab psql -U postgrespsql (18.x)
Type "help" for help.
postgres=#เห็น postgres=# แปลว่าพร้อมแล้ว คำสั่งในหัวข้อต่อจากนี้พิมพ์ในหน้าต่างนี้ ทุกคำสั่ง SQL ต้องปิดท้ายด้วย ; ส่วนออกจาก psql พิมพ์ \q
รหัส lab ใช้แค่ในแล็บนี้ อย่าใช้รหัสง่ายๆ แบบนี้กับฐานข้อมูลจริง และอย่าเปิดพอร์ตฐานข้อมูลให้คนนอกเข้าถึงได้
ดูขีดจำกัด connection
เริ่มจากถามฐานข้อมูลว่ารับได้กี่สาย
SHOW max_connections; max_connections
-----------------
100
(1 row)แล้วดูว่าตอนนี้ใช้ไปกี่สาย
SELECT count(*) FROM pg_stat_activity; count
-------
9
(1 row)ตัวเลขนี้รวมโปรเซสเบื้องหลังของ PostgreSQL ด้วย จึงอาจต่างไปตามรุ่น (ตอนทดสอบกับ PostgreSQL 18.6 ได้ 9) อยากรู้ว่าแต่ละสายคืออะไร ใช้คำสั่งนี้
SELECT backend_type, count(*) FROM pg_stat_activity GROUP BY 1; backend_type | count
------------------------------+-------
autovacuum launcher | 1
background writer | 1
checkpointer | 1
client backend | 1
io worker | 3
logical replication launcher | 1
walwriter | 1
(7 rows)client backend คือ psql ของเราเอง ลองเปิด Terminal อีกหน้าต่าง รัน docker exec -it pg-lab psql -U postgres อีกตัว แล้วนับใหม่ในหน้าต่างแรก จะเพิ่มขึ้น 1 ทุกคนที่ต่อเข้ามากินหนึ่งช่องจาก 100 ช่องนี้
อยากเห็น error ตอนเต็มกับตา
เปิด container อีกตัวที่ตั้งให้รับได้แค่ 5 สาย (ไม่ต้องเปิดพอร์ต เพราะต่อผ่าน docker exec)
docker run --name pg-tiny -e POSTGRES_PASSWORD=lab -d postgres:18 -c max_connections=5แล้วเปิด Terminal ทีละหน้าต่าง รัน docker exec -it pg-tiny psql -U postgres ในทุกหน้าต่าง ห้าหน้าต่างแรกเข้าได้ปกติ พอถึงหน้าต่างที่หก จะเจอ
psql: error: connection to server on socket "/var/run/postgresql/.s.PGSQL.5432" failed: FATAL: sorry, too many clients alreadyนี่คือภาพเดียวกับหน้าเว็บที่หมุนค้างแล้วพังตอนเปิดขาย แอปขอสายใหม่ไม่ได้ ก็ตอบผู้ใช้ไม่ได้ ดูเสร็จแล้วปิด psql ทุกหน้าต่างด้วย \q และลบ container ตัวนี้
docker rm -f pg-tinyอย่าแก้ max_connections ให้สูงมากๆ เพื่อหนีปัญหา แต่ละสายกินหน่วยความจำของเซิร์ฟเวอร์ ตั้งไว้สูงเกินไป ฐานข้อมูลอาจช้าลงทั้งตัวจนทุกคนได้รับผลกระทบ ทางที่ใช้กันคือ connection pool และคิว ซึ่งอยู่ในหัวข้อถัดๆ ไป
ลองขายที่นั่งซ้ำ
กลับมาที่ pg-lab สร้างตารางที่นั่ง 24 ที่ ยังไม่มีใครซื้อ
CREATE TABLE seats (
id int PRIMARY KEY,
buyer text
);
INSERT INTO seats (id) SELECT generate_series(1, 24);CREATE TABLE
INSERT 0 24เปิด psql สองหน้าต่าง เรียกว่า A และ B แทนคนสองคนที่กดจองพร้อมกัน ให้ทั้งคู่ดูที่นั่งหมายเลข 10
SELECT buyer FROM seats WHERE id = 10; buyer
-------
(1 row)ทั้งสองคนเห็นว่าว่าง ต่างคนต่างจอง พิมพ์บรรทัดแรกใน A แล้วพิมพ์บรรทัดที่สองใน B
UPDATE seats SET buyer = 'A' WHERE id = 10; -- ใน A
UPDATE seats SET buyer = 'B' WHERE id = 10; -- ใน BUPDATE 1ทั้งสองคำสั่งสำเร็จ แต่คนหลังเขียนทับคนแรก ดูที่นั่ง 10 อีกครั้งจะเห็นชื่อ B ถ้าแอปบอก A ไปแล้วว่าจองสำเร็จ ก็เท่ากับขายที่นั่งเดียวให้สองคน นี่คือ race condition
ทำไมในเว็บจริงถึงเกิดบ่อยกว่าในแล็บ ในแล็บเราพิมพ์ห่างกันหลายวินาที แต่ในวันเปิดขาย request นับพันมาถึงห่างกันไม่กี่มิลลิวินาที ช่วงระหว่าง "ดูว่าว่าง" กับ "เขียนว่าจองแล้ว" แคบแค่ไหนก็มีคนแทรกเข้ามาได้
ล็อกที่นั่งให้ทีละคน
PostgreSQL มีคำสั่ง SELECT ... FOR UPDATE ที่ล็อกแถวที่อ่านไว้จนกว่า transaction จะจบ ใครจะแก้หรือล็อกแถวเดียวกันต้องรอ ลองดูทีละขั้น

ล้างที่นั่งก่อน แล้วให้ A เริ่ม transaction และล็อกที่นั่ง 10
UPDATE seats SET buyer = NULL WHERE id = 10;BEGIN;
SELECT buyer FROM seats WHERE id = 10 FOR UPDATE;ตอนนี้ให้ B ทำแบบเดียวกัน
BEGIN;
SELECT buyer FROM seats WHERE id = 10 FOR UPDATE;(B ค้างอยู่ ไม่ตอบ กำลังรอ A)กลับไปที่ A จองแล้วจบ transaction
UPDATE seats SET buyer = 'A' WHERE id = 10;
COMMIT;ทันทีที่ A พิมพ์ COMMIT หน้าต่าง B จะได้คำตอบ
buyer
-------
A
(1 row)B เห็นว่ามีคนจองแล้ว จึงยกเลิก
ROLLBACK;ผ่านเมื่อ B ต้องรอจน A จบ แล้วเห็นชื่อ A โดยไม่มีการเขียนทับ ตามเอกสาร PostgreSQL หัวข้อ Explicit Locking แถวที่ล็อกด้วย FOR UPDATE จะกันคนอื่นแก้หรือล็อกซ้ำจนกว่า transaction จะจบ ในแอปจริง หลังได้ล็อกแล้วโค้ดต้องเช็กว่า buyer ยังว่างไหม ถ้าไม่ว่างก็บอกผู้ใช้ว่าที่นั่งนี้มีคนจองแล้ว
อีกวิธีที่สั้นกว่า
ถ้าแค่จองที่นั่งเดียว เขียนเงื่อนไขไว้ในคำสั่ง UPDATE เลยก็ได้
UPDATE seats SET buyer = 'B' WHERE id = 10 AND buyer IS NULL;UPDATE 0UPDATE 1 แปลว่าจองสำเร็จ ส่วน UPDATE 0 แปลว่ามีคนได้ไปก่อน ถ้าสองคนสั่งพร้อมกัน PostgreSQL ให้คนหลังรอคนแรกจบ แล้วเช็กเงื่อนไขใหม่ คนหลังจึงได้ UPDATE 0 ไม่ใช่เขียนทับ
ล็อกทำให้ถูก แต่ไม่ได้ทำให้เร็ว เห็นไหมว่า B ต้องรอ ถ้าคนเป็นหมื่นแย่งที่นั่งเดียวกัน ทุกคนต้องต่อแถวตรงนี้ และแต่ละคนก็ถือ connection ไว้ระหว่างรอด้วย ล็อกจึงแก้ปัญหาขายซ้ำ แต่ต้องใช้คู่กับวิธีคุมจำนวนคนในหัวข้อถัดไป
connection pool และคิวงาน
แทนที่ทุก request จะเปิดสายใหม่ไปหา DB แอปเปิดสายไว้จำนวนหนึ่ง (เช่น 20 สาย) แล้วแบ่งกันใช้ request ที่เกินต้องรอสายว่างในแอป แทนที่จะไปทำให้ DB เต็ม
| เรื่อง | ไม่มี pool | มี pool 20 สาย |
|---|---|---|
| request หมื่นตัว | พยายามเปิดหมื่นสาย | ใช้สายเดิม 20 สายผลัดกัน |
| ฝั่ง DB | เกิน 100 สาย DB ปฏิเสธ | DB เห็นแค่ 20 สาย ทำงานต่อได้ |
| ผลลัพธ์ | พังทั้งระบบ | ช้าลง แต่ไม่พัง |
ไลบรารีฐานข้อมูลส่วนใหญ่มี pool ในตัว เช่น ไลบรารี pg ของ Node.js
import pg from "pg";
// ทั้งแอปใช้สายร่วมกันไม่เกิน 20 สาย
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL, max: 20 });
export const query = (text, params) => pool.query(text, params);ระวังว่าถ้ารันแอป 10 เครื่อง แต่ละเครื่องมี pool 20 สาย รวมกันก็เป็น 200 สาย เกิน 100 อยู่ดี ระบบที่มีหลายเครื่องจึงนิยมวางตัวกลางอย่าง PgBouncer ไว้หน้า PostgreSQL ให้ทุกเครื่องมาต่อที่ PgBouncer แล้วมันแบ่งสายจริงจำนวนน้อยๆ ให้
อีกชั้นหนึ่งคือ คิวงาน รับคำสั่งจองไว้ในคิวก่อน แล้วให้ตัวทำงาน (worker) หยิบไปทำทีละรายการ คนกดได้คำตอบว่า "รับคำขอแล้ว" ทันที ส่วน DB ทำงานตามจังหวะที่รับไหว ไม่โดนทุกคนกระแทกพร้อมกัน
จำง่ายๆ pool คุมจำนวนสายที่ไปหา DB ส่วนคิวและห้องรอคุมจำนวนคนที่เข้ามาถึงระบบ ใช้ร่วมกันได้และควรใช้ร่วมกัน
ห้องรอ (Waiting Room)
ห้องรอคุมตั้งแต่หน้าประตู ให้คนส่วนเกินรออยู่ข้างนอก แล้วปล่อยเข้าทีละรอบเท่าที่ข้างในรับไหว คนที่ได้เข้าไปจะใช้เว็บได้ลื่น แทนที่ทุกคนจะเข้าไปพร้อมกันแล้วล่มกันหมด
ตัวอย่างบริการสำเร็จรูปคือ Cloudflare Waiting Room ตามหน้าเอกสาร ณ ต.ค. 2026
| ที่เอกสาร Cloudflare บอก | ทำไมช่วย |
|---|---|
| ส่งผู้ใช้ส่วนเกินไปห้องรอ ที่แต่งหน้าตาให้เข้ากับเว็บได้ | เซิร์ฟเวอร์ต้นทางไม่โดนคำขอท่วม |
| แสดงเวลารอโดยประมาณ ที่อัปเดตตลอด | คนรู้ว่าต้องรอนานแค่ไหน ไม่กดรีเฟรชซ้ำ |
| ตั้งช่วงเวลาพิเศษได้ (Scheduled Events) | ปรับการปล่อยคนเฉพาะช่วงเปิดขาย |
| กำหนดกฎยกเว้นได้ (Waiting Room Rules) | ไม่กันทราฟฟิกหรือหน้าที่ไม่จำเป็น |
เป็นบริการแบบเสียเงิน หน้าเอกสารระบุว่า Waiting Room ใช้ได้กับแพ็กเกจ Business หรือ Enterprise (และองค์กรที่ผ่านโครงการ Project Fair Shot) เราไม่ได้ลองใช้จริง จึงไม่ขอบอกว่าคิวเรียงลำดับแบบไหน ถ้าจะใช้ให้อ่านเอกสารฉบับเต็มก่อน
แก้ปัญหาที่เจอบ่อย
| อาการ | น่าจะเป็นเพราะ | ทำยังไง |
|---|---|---|
| docker: command not found | ยังไม่ติดตั้ง หรือไม่ได้เปิด Docker Desktop | ติดตั้งจาก docker.com แล้วเปิดโปรแกรมก่อน |
| port is already allocated | มี PostgreSQL อื่นใช้พอร์ต 5432 | เปลี่ยนเป็น -p 5433:5432 |
| Conflict ชื่อ pg-lab ซ้ำ | เคยสร้างไว้แล้ว | docker start pg-lab หรือลบก่อนตามหัวข้อเก็บกวาดด้านล่าง |
| psql B ไม่ค้างตอน FOR UPDATE | A ยังไม่ได้พิมพ์ BEGIN หรือ COMMIT ไปแล้ว | เริ่มหัวข้อล็อกใหม่ทั้งสองหน้าต่าง |
| current transaction is aborted | คำสั่งก่อนหน้าใน transaction ผิด | พิมพ์ ROLLBACK; แล้วเริ่มใหม่ |
| relation "seats" does not exist | ยังไม่ได้สร้างตาราง หรือต่อคนละ DB | สร้างตารางในหัวข้อขายที่นั่งซ้ำใหม่ |
ข้อความ error ตัวเต็มที่จะเห็นในสองกรณีหลัง
ERROR: current transaction is aborted, commands ignored until end of transaction block
ERROR: relation "seats" does not existเก็บกวาด
ลองครบแล้วหยุดและลบ container
docker stop pg-lab
docker rm pg-labpg-lab
pg-labข้อมูลในแล็บหายไปพร้อม container ซึ่งตั้งใจให้เป็นแบบนั้น ถ้าเคยสร้าง pg-tiny แล้วยังไม่ลบ ใช้ docker rm -f pg-tiny ด้วย
สรุป
- เพิ่มเว็บเซิร์ฟเวอร์ได้ แต่ทุกเครื่องต้องไปถามฐานข้อมูลตัวเดียวกัน คอขวดจึงมักอยู่ที่ฐานข้อมูล
- PostgreSQL รับ connection ได้จำกัด (ค่าทั่วไป 100) เต็มแล้วคนต่อไปเจอ
sorry, too many clients already - ไม่ล็อก สองคนจองที่นั่งเดียวกันได้ทั้งคู่ ใช้
SELECT ... FOR UPDATEหรือ UPDATE แบบมีเงื่อนไขเพื่อกันขายซ้ำ - ล็อกทำให้ถูกแต่ไม่ได้ทำให้เร็ว ต้องใช้คู่กับ connection pool คิวงาน และห้องรอ
| อยากรู้ / อยากทำ | คำตอบ |
|---|---|
| ทำไมเพิ่มเครื่องแล้วยังล่ม | ทุกเครื่องต้องไปถาม DB ตัวเดียวกัน |
| ดูขีดจำกัด | SHOW max_connections; |
| ดูว่าใช้ไปกี่สาย | SELECT count(*) FROM pg_stat_activity; |
| กันขายซ้ำ | BEGIN; SELECT ... FOR UPDATE; ... COMMIT; |
| ไม่ให้ DB เต็ม | connection pool (และ PgBouncer) |
| ไม่ให้คนท่วมระบบ | คิวงานและห้องรอ |
อ่านต่อจากเอกสารทางการ
- PostgreSQL: Connections and Authentication
- PostgreSQL: Explicit Locking
- Cloudflare Waiting Room
- Docker image ทางการ: hub.docker.com/_/postgres
พร้อมแล้วก็ไปทำ Quiz ท้ายบทด้านล่างเพื่อรับ XP ได้เลยครับ