สรุปสั้น

  • เขียน UPDATE / DELETE ต้องมี WHERE เสมอ และลองรันเป็น SELECT ก่อน
  • NULL ไม่เท่ากับอะไรเลย แม้แต่ NULL ด้วยกัน → ใช้ IS NULL
  • LEFT JOIN เก็บแถวฝั่งซ้ายไว้ทั้งหมด INNER JOIN เก็บเฉพาะแถวที่จับคู่ได้
  • index ทำให้ค้นเร็ว แต่ใช้ไม่ได้ถ้าเอาฟังก์ชันไปครอบคอลัมน์
  • ส่งค่าจากผู้ใช้ผ่าน prepared statement เสมอ (ดูตัวอย่างใน พื้นฐาน PHP)

ตัวอย่างในโน้ตนี้ใช้ MySQL และตาราง customers, orders

1. SELECT พื้นฐาน

SELECT id, name, email
FROM customers
WHERE status = 'active'
  AND created_at >= '2026-01-01'
ORDER BY created_at DESC
LIMIT 20;
  • ระบุคอลัมน์ที่ต้องใช้ แทน SELECT * จะเร็วกว่าและไม่พังเมื่อตารางเพิ่มคอลัมน์
  • LIKE 'สม%' ใช้ index ได้ แต่ LIKE '%ชาย' (มี % ข้างหน้า) ใช้ไม่ได้

ลำดับที่ฐานข้อมูลประมวลผลจริง

FROM → JOIN → WHERE → GROUP BY → HAVING → SELECT → ORDER BY → LIMIT

ลำดับนี้เป็นเหตุผลที่ใน WHERE ใช้ชื่อเล่น (alias) ที่ตั้งใน SELECT ไม่ได้

2. NULL

WHERE phone = NULL      -- ❌ ไม่เจออะไรเลย
WHERE phone IS NULL     -- ✅
WHERE phone IS NOT NULL
 
SELECT COALESCE(phone, '-') FROM customers;   -- แทน NULL ด้วยค่าอื่น
  • COUNT(*) นับทุกแถว COUNT(phone) นับเฉพาะแถวที่ phone ไม่เป็น NULL
  • WHERE status != 'cancelled' จะไม่รวมแถวที่ status เป็น NULL

3. JOIN

-- ลูกค้าที่มีออเดอร์ (ไม่มีออเดอร์ = ไม่แสดง)
SELECT c.name, o.id AS order_id, o.total
FROM customers c
INNER JOIN orders o ON o.customer_id = c.id;
 
-- ลูกค้าทุกคน ถ้าไม่มีออเดอร์ คอลัมน์ของ orders จะเป็น NULL
SELECT c.name, o.id AS order_id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id;
 
-- ลูกค้าที่ยังไม่เคยสั่งซื้อ
SELECT c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;

เงื่อนไขของตารางขวาใน LEFT JOIN

ถ้าเขียน WHERE o.status = 'paid' แถวที่ไม่มีออเดอร์จะหายไป กลายเป็น INNER JOIN โดยไม่รู้ตัว ถ้าอยากเก็บลูกค้าทุกคนไว้ ให้ย้ายเงื่อนไขไปไว้ใน ON ... AND o.status = 'paid'

4. GROUP BY และ HAVING

SELECT customer_id,
       COUNT(*)   AS order_count,
       SUM(total) AS revenue
FROM orders
WHERE status = 'paid'            -- กรองก่อนจัดกลุ่ม
GROUP BY customer_id
HAVING SUM(total) > 10000        -- กรองหลังจัดกลุ่ม
ORDER BY revenue DESC;

คอลัมน์ใน SELECT ต้องอยู่ใน GROUP BY หรืออยู่ในฟังก์ชันรวม (COUNT, SUM, MAX ฯลฯ) เท่านั้น

5. เพิ่ม แก้ ลบ ข้อมูล

INSERT INTO customers (name, email) VALUES ('Somchai', 'som@example.com');
 
UPDATE customers SET status = 'inactive' WHERE id = 42;
 
DELETE FROM customers WHERE id = 42;

ก่อนรัน UPDATE หรือ DELETE

  1. เปลี่ยนเป็น SELECT COUNT(*) FROM ... WHERE ... ก่อน ดูว่าจำนวนแถวตรงที่คาดไหม
  2. ลืม WHERE = แก้หรือลบทั้งตาราง

6. Transaction

ทำหลายคำสั่งให้ “สำเร็จทั้งหมด หรือไม่เกิดอะไรเลย” เช่น โอนเงิน

START TRANSACTION;
UPDATE wallets SET balance = balance - 500 WHERE user_id = 1;
UPDATE wallets SET balance = balance + 500 WHERE user_id = 2;
COMMIT;      -- หรือ ROLLBACK; ถ้ามีอะไรผิดพลาด

อัปเดตยอดแบบ balance = balance - 500 ในคำสั่งเดียว ปลอดภัยกว่าอ่านค่ามาคำนวณในโค้ดแล้วเขียนกลับ เพราะถ้ามีสองคำขอเข้ามาพร้อมกัน แบบหลังจะเขียนทับกันจนยอดผิด

7. Index

index เหมือนสารบัญท้ายหนังสือ ช่วยให้หาแถวได้เร็วโดยไม่ต้องไล่ทั้งตาราง

CREATE INDEX idx_orders_customer_created ON orders (customer_id, created_at);
 
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;   -- ดูว่าใช้ index ไหม
  • index หลายคอลัมน์ใช้ได้ตามลำดับจากซ้าย: index (customer_id, created_at) ช่วยค้น customer_id อย่างเดียวได้ แต่ช่วยค้น created_at อย่างเดียวไม่ได้
  • ครอบคอลัมน์ด้วยฟังก์ชันแล้วใช้ index ไม่ได้
WHERE DATE(created_at) = '2026-09-29'                                   -- ❌ ใช้ index ไม่ได้
WHERE created_at >= '2026-09-29' AND created_at < '2026-09-30'          -- ✅
  • index ทำให้อ่านเร็ว แต่เขียนช้าลงเล็กน้อย ไม่ต้องใส่ทุกคอลัมน์

8. Key และ Constraint

  • PRIMARY KEY: ตัวระบุแถวที่ไม่ซ้ำ
  • FOREIGN KEY: บังคับว่า orders.customer_id ต้องมีอยู่จริงใน customers
  • UNIQUE: กันข้อมูลซ้ำ เช่น email
  • NOT NULL และ DEFAULT: กันค่าว่างที่ไม่ควรว่าง

ให้ฐานข้อมูลบังคับกฎเหล่านี้ อย่าหวังพึ่งการเช็กในโค้ดอย่างเดียว

9. เครื่องมือขั้นกลางที่ควรรู้จัก

CTE (WITH): ตั้งชื่อ query ย่อยให้อ่านง่าย

WITH paid AS (
  SELECT customer_id, SUM(total) AS revenue
  FROM orders WHERE status = 'paid'
  GROUP BY customer_id
)
SELECT c.name, p.revenue
FROM paid p JOIN customers c ON c.id = p.customer_id;

Window function: จัดอันดับโดยไม่ยุบแถว (MySQL 8+)

-- ออเดอร์ล่าสุดของลูกค้าแต่ละคน
SELECT * FROM (
  SELECT o.*,
         ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY created_at DESC) AS rn
  FROM orders o
) t
WHERE rn = 1;

10. ปัญหา N+1

ดึงรายการมา 1 ครั้ง แล้ววน loop query ทีละแถวอีก N ครั้ง เช่น ดึงลูกค้า 100 คน แล้ว query ออเดอร์ทีละคน = 101 query

-- แทนที่จะวน loop ให้ดึงทีเดียว
SELECT * FROM orders WHERE customer_id IN (1, 2, 3, ...);

ใน Laravel ใช้ Customer::with('orders')->get() (eager loading) แทน

11. แบ่งหน้า (Pagination)

-- OFFSET: ง่าย แต่ยิ่งหน้าลึกยิ่งช้า เพราะต้องข้ามแถวทิ้ง
SELECT * FROM orders ORDER BY id DESC LIMIT 20 OFFSET 10000;
 
-- Keyset: เร็วคงที่ จำ id ตัวสุดท้ายของหน้าก่อนไว้
SELECT * FROM orders WHERE id < 98765 ORDER BY id DESC LIMIT 20;

จุดที่มักพลาด

  • ลืม WHERE ใน UPDATE/DELETE
  • เทียบ NULL ด้วย =
  • เงื่อนไขของตารางขวาใส่ใน WHERE ทำให้ LEFT JOIN กลายเป็น INNER JOIN
  • เก็บเงินเป็น FLOAT → ใช้ DECIMAL(12,2) แทน
  • เวลาไม่ตรงเพราะ timezone ของ server, database และแอปไม่ตรงกัน → ตกลงกันให้ชัด เช่น เก็บเป็น UTC
  • ต่อ string SQL จาก input ผู้ใช้ → SQL injection

แหล่งอ้างอิง