🧰 เตรียมเครื่องก่อนอ่าน — บทนี้รันของจริงทั้งบท ถ้าจะพิมพ์ตามต้องมีสามอย่าง
- Docker Desktop เปิดอยู่ แล้วรัน:
docker run -d --name day7-redis -p 55379:6379 redis:7-alpine - วิธีเรียก redis-cli — ไม่ต้องลง redis-cli ในเครื่อง ใช้ตัวที่อยู่ใน container ได้เลย
ตั้งฟังก์ชันย่อไว้หนึ่งครั้ง (ใช้ได้ทั้ง zsh และ bash):
R() { docker exec -i day7-redis redis-cli "$@"; }
ต่อไปในบทนี้ บรรทัดที่เขียนว่า$ redis-cli GET kให้พิมพ์R GET k - ฝั่ง Go (ใช้ตั้งแต่หัวข้อ 5 เป็นต้นไป) ต้องต่อเน็ตครั้งเดียวเพื่อโหลด client:
go get github.com/redis/go-redis/v9— และฝั่ง Go จะต่อที่127.0.0.1:55379ไม่ใช่ 6379
เสร็จบทแล้วเก็บกวาดด้วย docker stop day7-redis && docker rm day7-redis
1 · cache คืออะไร และ Redis อยู่ตรงไหน
cache ไม่ใช่เทคโนโลยี มันคือกลยุทธ์เดียวที่ถูกทำซ้ำหลายชั้น: ถ้าคำตอบนี้แพงที่จะหามาใหม่ และมันไม่ค่อยเปลี่ยน — เก็บสำเนาไว้ใกล้ ๆ คนถาม
วิธีอ่านแผนภาพข้างล่าง: ไล่จากบนลงล่างทีละชั้น —
ชั้นไหนมีสำเนาอยู่แล้วก็ตอบจากชั้นนั้นเลย ไม่ลงชั้นถัดไป
ยิ่งอยู่บนยิ่งเร็วแต่ยิ่งเสี่ยงเป็นของเก่า · ยิ่งลงล่างยิ่งช้าแต่ยิ่งตรงกับความจริง
คอลัมน์กลางบอกว่าสำเนาไปนั่งอยู่ที่ไหน · คอลัมน์ขวาบอกว่าข้อดี/ข้อจำกัดของชั้นนั้น
ผู้ใช้กดปุ่ม
│ (ไล่ลงทีละชั้น — เจอสำเนาที่ชั้นไหน ตอบกลับจากชั้นนั้นทันที)
│
├─▶ browser cache สำเนาอยู่ในเครื่องผู้ใช้ เร็วสุด · เราสั่งลบไม่ได้
│
├─▶ CDN สำเนาอยู่ใกล้ผู้ใช้ทางภูมิศาสตร์ แชร์กันทั้งประเทศ
│
├─▶ in-process cache สำเนาอยู่ใน RAM ของ service เร็วมาก · แต่แต่ละ instance เห็นไม่เหมือนกัน
│
├─▶ Redis สำเนาอยู่ในเครื่องกลาง ทุก instance เห็นชุดเดียวกัน
│
└─▶ database แหล่งความจริง ช้าที่สุด · ถูกต้องที่สุด
จุดขายของ Redis จึงไม่ใช่ "เร็ว" เฉย ๆ — map ใน Go ก็เร็วกว่าอีก
จุดขายคือ เร็วและ "ของกลาง":
ของจริงเวลา deploy เราไม่ได้รันโปรแกรมตัวเดียว แต่รันสำเนาของโปรแกรมเดียวกันหลายตัวพร้อมกัน
เพื่อรับ request ได้เยอะขึ้น (แต่ละตัวเรียกว่า instance หรือ pod) — และแต่ละตัวมี RAM ของตัวเอง
ไม่เห็น map ของกัน
ถ้าเก็บ cache ไว้ใน map ของแต่ละตัว ลูกค้าคนเดียวกันกด 2 ครั้งอาจได้คนละคำตอบ
เพราะไปโดนคนละ instance · ส่วน Redis เป็นเครื่องกลาง:
instance ที่ 1 เขียน instance ที่ 7 อ่านเห็นทันที
และ restart แอปแล้ว cache ไม่หายตามไปด้วย
นี่คือเหตุผลที่ sync.RWMutex + map จาก
บทเรียน 24
ใช้แทน Redis ไม่ได้ทันทีที่คุณ deploy มากกว่าหนึ่ง instance
เวลาหิว คุณไม่ได้เดินไปตลาดใหม่ทุกครั้ง — คุณเปิดตู้เย็นก่อน
· ตลาด = ที่ที่ของจริงอยู่ สดที่สุด แต่ไปทีหนึ่งเสียเวลา
· ตู้เย็น = ของที่ซื้อมาแล้ววางไว้ใกล้มือ เปิดปุ๊บได้ปั๊บ
เปิดตู้แล้วเจอ = จบ ไม่ต้องไปตลาด · เปิดแล้วไม่เจอ = ต้องไปตลาด
แล้วซื้อกลับมาใส่ตู้ไว้ให้ครั้งหน้าด้วย — เท่านี้แหละที่ cache ทำ
ทีนี้เพิ่มอีกชั้น: หอพักหนึ่งหลังมีหลายห้อง
· ถ้าแต่ละห้องมีตู้เย็นของตัวเอง ห้อง 1 ซื้อนมมาใส่ ห้อง 7 เปิดตู้ตัวเองก็ไม่เห็น
— นี่คือ map ที่อยู่ใน RAM ของโปรแกรมแต่ละตัว
· ถ้าหอมีตู้เย็นกลางใบเดียวที่ทุกห้องใช้ร่วมกัน ห้อง 1 ใส่ ห้อง 7 เปิดเห็นทันที
และห้องไหนจะย้ายเข้าย้ายออก (= แอปเรา restart หรือ deploy ใหม่)
ของในตู้กลางก็ยังอยู่ — นี่คือ Redis
ที่ไม่เหมือนกัน: ของในตู้เย็นหยิบไปแล้วพร่องลง แต่ค่าใน cache อ่านกี่คนกี่ครั้งก็ไม่หมด · ตู้เย็นจริงไม่มีใครมาโยนของทิ้งเพราะตู้เต็ม แต่ Redis มี — ของหายเองได้ทั้งที่ยังไม่หมดอายุตอน RAM เต็ม (หัวข้อ 7) และถ้าตัวตู้กลางเองดับแบบไม่ทันเซฟ ของที่เพิ่งใส่ก็หายด้วย (หัวข้อ 2) — "อยู่ตลอด" จึงหมายถึงรอดจากการ restart ของแอปเท่านั้น · และที่ต่างกันจริง ๆ คือ ตู้เย็นไม่เคยบอกราคาผิด แต่ cache ทำได้ — มันยื่นของเมื่อวานให้คุณหน้าตาเฉยทั้งที่ของจริงเปลี่ยนไปแล้ว ซึ่งคือหัวใจของบทนี้
- cache hit / cache miss
- “เปิดตู้เย็นแล้วเจอ” = hit · “เปิดแล้วไม่เจอ ต้องไปตลาด” = miss · เวลาไล่ปัญหาให้ถามว่า “request นี้เป็น hit หรือ miss” จะตรงกว่าถามว่า “มันเอาค่ามาจากไหน”
- hit rate
- เปิดตู้เย็น 100 ครั้ง เจอกี่ครั้ง · ยิ่งสูงยิ่งประหยัดแรง database · ประโยคที่ใช้ได้จริง: “ลด TTL ลงแล้ว hit rate ตกไปเท่าไหร่”
- in-process cache
- ตู้เย็นส่วนตัวในห้อง = cache ที่อยู่ใน RAM ของโปรแกรมเราเอง (เช่น
map) · บอก senior ว่า “ตอนนี้เป็น in-process cache อยู่ ยังไม่ได้ย้ายไป Redis” เขาจะเห็นปัญหาทันที - instance / pod
- instance = สำเนาของโปรแกรมเดียวกันที่รันพร้อมกันหลายตัว · บน Kubernetes มักหมายถึง pod — หน่วย deploy ที่ห่อ container ของแอปเราไว้ (pod หนึ่งอาจมี container อื่นแถมอยู่ด้วย สองคำนี้จึงไม่ใช่คำเดียวกันเป๊ะ) · คำถามที่ถามแล้วได้เรื่อง: “ตอน deploy จริงเรารันกี่ instance” — ถ้ามากกว่า 1 แปลว่า in-process cache ใช้ไม่ได้แล้ว
- source of truth (แหล่งความจริง)
- ที่ที่ถือข้อมูลตัวจริง — ในบทนี้คือ database · ประโยคที่จะได้ยินบ่อยคือ “Redis ไม่ใช่ source of truth” แปลว่าห้ามมีของที่หายไม่ได้อยู่ใน Redis ที่เดียว
ขอบเขตของบทนี้ — ตัวเลขทุกตัวในหน้านี้วัดจาก
Redis กับ Go เท่านั้น (Redis 7.4.10 บน redis:7-alpine,
github.com/redis/go-redis/v9 v9.22.0, Go 1.26.5)
ชั้น browser / CDN ผมไม่ได้วัด จึงจะไม่มีตัวเลขของสองชั้นนั้นในบทนี้เลย —
เชื่อได้เฉพาะสิ่งที่รันให้ดู
2 · Redis อยู่ใน RAM — อย่าใช้เป็นแหล่งความจริง
ประโยคที่ได้ยินบ่อยคือ "Redis = data store ที่เก็บใน RAM" ถูกครึ่งเดียว และครึ่งที่ขาดเป็นครึ่งที่อันตราย ผมเลยไปเปิดดู config ของจริง:
$ redis-cli CONFIG GET save
save
3600 1 300 100 60 10000
$ redis-cli CONFIG GET appendonly
appendonly
no
แปลว่า image redis:7-alpine เขียนไฟล์ลงดิสก์ให้เองอยู่แล้ว
Redis มีวิธีเก็บลงดิสก์สองแบบ:
RDB = ถ่ายรูปข้อมูลทั้งก้อนเป็นครั้ง ๆ (snapshot) ตามเงื่อนไขข้างบน —
เปลี่ยน 1 คีย์ใน 3600 วิ หรือ 100 คีย์ใน 300 วิ หรือ 10000 คีย์ใน 60 วิ ถึงจะถ่ายหนึ่งครั้ง
AOF (append-only file) = จดทุกคำสั่งที่เขียนลงไฟล์ทันทีทีละคำสั่ง — ละเอียดกว่ามาก
แต่ค่า appendonly no ข้างบนแปลว่าปิดอยู่
สรุปคือตอนนี้มีแค่ "รูปถ่ายเป็นครั้ง ๆ" ไม่มีสมุดจดรายคำสั่ง —
ทุกอย่างที่เขียนหลังรูปล่าสุดจึงไม่มีหลักฐานบนดิสก์ ผมเลยทดลองสองแบบ:
✅ restart แบบสุภาพ (SIGTERM)
$ SET survivor "ฉันจะรอดไหม" # เขียนไว้ก่อน แล้วสั่ง restart
OK
$ docker restart day7-redis
$ GET survivor
ฉันจะรอดไหม
$ DBSIZE # DBSIZE = นับจำนวน key ทั้งหมดที่เหลืออยู่
5
$ LASTSAVE # เวลาที่ save ล่าสุด (Unix timestamp = วินาทีนับจากปี 1970)
1787057770
Redis เขียน RDB ก่อนตาย ข้อมูลรอด
❌ ถูกฆ่า (SIGKILL)
$ SET after_save "เขียนหลัง save ล่าสุด"
$ docker kill day7-redis
$ docker start day7-redis
$ GET after_save
← ว่าง
$ EXISTS after_save
0
ทุกอย่างหลัง save ล่าสุด หายจริง
⚠️ สรุปที่ถูกคือ: Redis ทำงานบน RAM และ persist ลงดิสก์แบบ best-effort
(best-effort = พยายามเก็บลงดิสก์ให้ แต่ไม่ได้รับประกันว่าจะครบ)
คนที่คิดว่า "RAM ล้วน" จะงงว่าทำไมข้อมูลเก่ายังอยู่หลัง restart ·
คนที่คิดว่า "persist แล้วปลอดภัย" จะเสียข้อมูลตอน crash
ทั้งสองความเข้าใจนำไปสู่ข้อสรุปเดียวกัน:
ของที่หายไม่ได้ ห้ามมีอยู่แค่ใน Redis —
cache ต้องสร้างใหม่จากต้นทางได้เสมอ
3 · 8 command ที่ต้องคล่อง
ทั้งหมดนี้รันจริงผ่าน redis-cli บน Redis 7.4.10 — output คือของจริง ไม่ใช่ของแต่ง
$ SET user:1 "Somchai"
OK
$ GET user:1
Somchai
$ GET user:999 # key ไม่มี → ว่างเปล่า
$ EXISTS user:1
1
$ EXISTS user:999
0
$ TYPE user:1
string
$ TYPE user:999 # key ไม่มี → none ไม่ใช่ nil
none
$ LPUSH mylist a b c # Redis ไม่ได้เก็บได้แค่ string — LPUSH สร้าง "list" ใส่ 3 ค่า
3 # คืนจำนวนสมาชิกใน list ตอนนี้ (บทนี้ใช้ list แค่เพื่อโชว์ WRONGTYPE)
$ TYPE mylist
list
$ GET mylist # GET ใช้กับ string เท่านั้น เอาไปอ่าน list ไม่ได้
WRONGTYPE Operation against a key holding the wrong kind of value
$ DEL user:1
1
$ DEL user:1 # ลบซ้ำ
0
$ EXISTS user:1
0
| command | ทำอะไร | คืนอะไร (ของจริง) |
|---|---|---|
SET | เขียนค่า (ทับของเดิมเสมอ) | OK |
GET | อ่านค่า | ค่า · ว่างเปล่าถ้าไม่มี key |
DEL | ลบ key — คือปุ่ม invalidate | จำนวน key ที่ลบได้จริง (1 / 0) |
EXPIRE | ตั้งวันหมดอายุให้ key ที่มีอยู่แล้ว | 1 ถ้าตั้งได้ · 0 ถ้าไม่มี key |
TTL | ถามว่าเหลืออายุเท่าไหร่ | วินาที · -1 · -2 (หัวข้อ 4) |
EXISTS | มี key นี้ไหม | 1 / 0 |
TYPE | key นี้เก็บชนิดอะไร | string · list · none |
SCAN | ไล่ดู key ทีละชุด | cursor (ตั๋วที่ Redis ให้ไว้ ยื่นคืนรอบหน้าเพื่อไล่ต่อ — ห้ามตีความตัวเลข) แล้วตามด้วยรายชื่อ key |
1. DEL ไม่คืน OK — มันคืนจำนวน ลบ key
ที่ไม่มีได้ 0 เฉย ๆ ไม่ error
→ ถ้าคุณสะกดชื่อ key ผิดตอน invalidate (สั่งลบสำเนาใน cache ทิ้ง) จะไม่มีอะไรเตือนคุณเลย
2. TYPE ของ key ที่ไม่มีคือ none ไม่ใช่ค่าว่าง
3. อ่านผิดชนิดได้ WRONGTYPE ซึ่งเป็น error จริง
ไม่ใช่ค่าว่าง — ต่อไปในหัวข้อ 8 จะเห็นว่าฝั่ง Go ต้องแยกมันออกจาก cache miss
💡 ทำไม output ข้างบนไม่มี (nil) กับ (integer)
เพราะผม pipe ผลลัพธ์ออกมา (pipe = ส่งผลลัพธ์ต่อให้โปรแกรมอื่นแทนที่จะพิมพ์บนจอ)
ถ้าพิมพ์ในเทอร์มินัลตรง ๆ (มี TTY = มีหน้าจอจริงรออ่านอยู่) redis-cli
จะตกแต่งให้:
(nil)
(integer) -2
(integer) 1
หน้าตาต่างกัน แต่เป็นค่าเดียวกัน — สำคัญตอนคุณเขียน shell script
เช็คผล เพราะ script จะเห็นบรรทัดว่างกับเลขเปล่า ไม่ใช่ (nil)
4 · TTL คืน -1 กับ -2 ต่างกันยังไง
$ TTL nokey:xyz
$ SET plain "no-expire"
$ TTL plain
$ EXPIRE plain 100
$ TTL plain
$ EXPIRE nokey:xyz 100
$ PERSIST plain
$ TTL plain
$ PERSIST plain
nokey:xyz คือ key ที่ไม่เคยมี · PERSIST คือคำสั่งถอดวันหมดอายุออก
— แต่ละบรรทัดคืนเลขอะไร? และ มีบรรทัดไหน error ไหม
$ TTL nokey:xyz # key ไม่มีเลย
-2
$ SET plain "no-expire"
$ TTL plain # มี key แต่ไม่ได้ตั้ง expire
-1
$ EXPIRE plain 100
1
$ TTL plain # มี expire แล้ว
100
$ EXPIRE nokey:xyz 100 # ตั้ง expire ให้ key ที่ไม่มี
0
$ PERSIST plain # ถอด expire ออก
1
$ TTL plain # กลับเป็นอมตะ
-1
$ PERSIST plain # ถอดซ้ำ
0
ไม่มีบรรทัดไหน error เลยสักบรรทัด — นี่คือประเด็นหลัก
EXPIRE กับ key ที่ไม่มีคืน 0 เงียบ ๆ
ผลตามมาที่ต้องจำ: โค้ดที่เขียน SET แล้วค่อย EXPIRE
แยกกันสองคำสั่ง ถ้า key หายไประหว่างนั้น
(โดน evict = Redis ทิ้ง key ทิ้งเองตอน RAM เต็ม ทั้งที่ยังไม่หมดอายุ —
รายละเอียดอยู่หัวข้อ 7 / หรือมีคน DEL)
จะได้ key ที่ไม่มีวันหมดอายุ โดยไม่มีใครรู้
→ ใช้ SET k v EX 60 คำสั่งเดียวจบ (ฝั่ง Go คือ
rdb.Set(ctx, k, v, 60*time.Second))
TTL คืน | แปลว่า | cache ควรทำยังไงต่อ |
|---|---|---|
-2 | ไม่มี key นี้ — ไม่เคยมี / หมดอายุไปแล้ว / โดน evict | ถือเป็น miss → ไปอ่านต้นทาง |
-1 | มี key แต่ไม่มีวันหมดอายุ — อมตะ | สงสัยไว้ก่อน: cache ที่ไม่มี TTL คือ cache ที่โกหกได้ตลอดกาล |
| เลขบวก | เหลืออีกกี่วินาที | ปกติ |
ที่ต้องเน้นคือ -2 ตอบได้แค่ "ตอนนี้ไม่มี" ไม่ได้บอกว่าทำไม
— ผมทดสอบ key ที่ตั้ง EX 3 แล้วรอให้หมดอายุ ได้แบบนี้:
$ SET session:abc "logged-in" EX 3
OK
$ TTL session:abc
3
$ GET session:abc
logged-in
--- sleep 2 ---
$ TTL session:abc
1
$ GET session:abc
logged-in
--- sleep 2 (รวม 4 วิ > TTL 3) ---
$ TTL session:abc
-2
$ GET session:abc ← ว่างเปล่า
$ EXISTS session:abc
0
$ TYPE session:abc
none
หลังหมดอายุ key มีสถานะเหมือนไม่เคยถูกสร้างทุกประการ
(TTL -2 · EXISTS 0 · TYPE none)
Redis ไม่เก็บหลักฐานว่าเคยมี — เดี๋ยวหัวข้อ 7 จะเห็นว่าเรื่องนี้ทำให้
debug ยากขึ้นแค่ไหน
เปิดตู้เย็นมองหานม เจอได้ 3 แบบ:
· เจอกล่องนม และข้างกล่องปั๊มวันหมดอายุไว้ → เหลืออีกกี่วันก็อ่านได้
= TTL คืนเลขบวก
· เจอของ แต่ไม่มีวันหมดอายุปั๊มไว้เลย (เช่น ขวดเกลือ) → อยู่ในตู้ไปเรื่อย ๆ
จนกว่าจะมีคนหยิบทิ้ง = -1
· ไม่เจออะไรเลยในตู้ → -2
ส่วน PERSIST ก็คือการลบวันหมดอายุที่ปั๊มไว้ออก ทำให้ของชิ้นนั้นกลายเป็นของที่อยู่ตลอดไป
ที่ไม่เหมือนกัน: นมหมดอายุยังตั้งอยู่ในตู้ให้เห็นว่า “เคยซื้อมา”
แต่ Redis ลบทิ้งจนไม่เหลือร่องรอย — “หมดอายุไปแล้ว” กับ “ไม่เคยมีเลย”
จึงหน้าตาเหมือนกันเป๊ะ (ได้ -2 ทั้งคู่) และนี่แหละคือเหตุผลที่มันไล่ปัญหายาก
- TTL (time to live)
- “อายุที่เหลือของ key” นับเป็นวินาที · ถามให้ตรงคำว่า “key นี้ตั้ง TTL ไว้กี่วินาที” ดีกว่าถามว่า “มันหายไปตอนไหน”
- expire
- “ถึงเวลาแล้วหายเอง” · คนละเรื่องกับการที่เราสั่งลบเอง — ประโยคที่แยกสองอย่างนี้ให้ชัดคือ “key นี้ expire เองหรือมีใคร DEL”
- evict
- “โดนโยนทิ้งทั้งที่ยังไม่หมดอายุ” เพราะ RAM เต็ม (ของจริงอยู่หัวข้อ 7) ·
เวลาข้อมูลหายก่อนเวลาให้ถามว่า “โดน evict หรือเปล่า” แล้วไปดูตัวเลข
evicted_keys - atomic
- “จบในคำสั่งเดียว ไม่มีช่องให้ใครแทรกกลางคัน” ·
SET k v EX 60เป็น atomic ส่วนSETแล้วค่อยEXPIREไม่ใช่ · ถาม AI ว่า “คำสั่งนี้ atomic ไหม” จะได้คำตอบตรงจุดกว่าถามว่า “เขียนแบบนี้ปลอดภัยไหม”
5 · cache-aside — pattern เดียวที่ต้องจำ
cache-aside แปลตรงตัวว่า "cache อยู่ข้าง ๆ" — ตัว cache ไม่รู้จัก database เลย โค้ดของเราเป็นคนเดินให้ทั้งสองฝั่ง:
อ่าน: ถาม Redis ──┬── เจอ (HIT) ──▶ ตอบเลย
│
└── ไม่เจอ (MISS) ──▶ ถาม DB ──▶ เขียนกลับเข้า Redis พร้อม TTL ──▶ ตอบ
เขียน: เขียน DB ──▶ ????? ← ช่องว่างตรงนี้คือทั้งบทเรียน
ก่อนอ่านโค้ด มีเรื่องเดียวที่ต้องรู้ก่อน: go-redis รายงาน "ไม่มี key" เป็น error
ไม่ใช่ค่าว่าง โดยใช้ error สำเร็จรูปชื่อ redis.Nil — เป็น sentinel error
(error ที่ประกาศไว้ตัวเดียวตายตัว ให้ทุกคนเอาไปเทียบว่า “ใช่กรณีนี้ไหม”)
ตัวเดียวกับที่ฝึกใน บทเรียน 12
แปลว่าเวลา Get คืน error มา เราต้องแยกให้ออกว่าเป็น
"ไม่มีของใน cache" (redis.Nil = เรื่องปกติ ไปอ่าน DB ต่อ) หรือ
"Redis มีปัญหา" (error ตัวอื่น = ต้องรายงานขึ้นไป)
รายละเอียดว่าทำไมเขาออกแบบแบบนี้ และพลาดแล้วเกิดอะไร อยู่ในหัวข้อ 8 กับดัก 1
func getUser(ctx context.Context, rdb *redis.Client, id int) (User, string, error) {
key := fmt.Sprintf("user:%d", id)
val, err := rdb.Get(ctx, key).Result()
if err == nil { // ── HIT
var u User
if err := json.Unmarshal([]byte(val), &u); err != nil {
return User{}, "", err
}
return u, "HIT", nil
}
if !errors.Is(err, redis.Nil) { // ── error จริง (เน็ตหลุด / redis ตาย)
return User{}, "", fmt.Errorf("redis error: %w", err)
}
u := queryDB(id) // ── MISS → ไปอ่านของจริง
b, _ := json.Marshal(u)
if err := rdb.Set(ctx, key, b, 60*time.Second).Err(); err != nil {
return User{}, "", err
}
return u, "MISS", nil
}
สามทาง ไม่ใช่สองทาง — HIT / MISS / error จริง
นี่คือรูปทรงเดียวกับที่ฝึกใน
บทเรียน 12:
อย่าถามแค่ว่า "มี error ไหม" ให้ถามว่า "error ตัวไหน"
ทุกคำสั่งรับ ctx เป็นตัวแรก — เหตุผลเดียวกับ
บทเรียน 25:
ถ้าผู้ใช้ปิดหน้าเว็บไปแล้ว การรอ Redis ต่อก็ไม่มีประโยชน์
sleep 200ms · เรียก 5 ครั้ง
call #1 MISS 205.699084ms name=Somchai
call #2 HIT 1.826875ms name=Somchai
call #3 HIT 800.417µs name=Somchai
call #4 HIT 636.333µs name=Somchai
call #5 HIT 692.833µs name=Somchai
MISS = 205.699084ms
HIT = 1.826875ms
เร็วขึ้น 112.6 เท่า
สังเกต HIT ครั้งแรก (1.8ms) ช้ากว่า HIT ครั้งถัดไป (0.6–0.8ms) ราวสองเท่า
เพราะ connection pool (ที่พักสายเชื่อมต่อที่เปิดค้างไว้ใช้ซ้ำ) ยังต้องเปิด connection ใหม่
ผมเลยรันรอบยืนยันแบบ warm-up ก่อน
(warm-up = ยิงคำสั่งทิ้งไปก่อนหนึ่งรอบให้ทุกอย่างพร้อม แล้วค่อยเริ่มจับเวลา):
MISS = 202.910542ms
HIT = 730.666µs
เร็วขึ้น 277.7 เท่า
ตัวเลขที่ควรจำคือ ~278 เท่า ไม่ใช่ 112 เท่า — และบทเรียนที่ติดตัวไปได้ทุก benchmark คือ อย่าเอาการวัดครั้งแรกมาสรุป (บทเรียนเดียวกับ Day 5: วัดสิ่งที่คุณตั้งใจวัดจริง ๆ)
- cache-aside
- รูปแบบที่ “cache อยู่ข้าง ๆ แล้วโค้ดเราเป็นคนเดินให้ทั้งสองฝั่ง” — อ่าน Redis ก่อน ไม่เจอค่อยไป database แล้วเขียนกลับเข้า Redis พร้อม TTL · ตัว Redis ไม่รู้จัก database เลย · บอก senior ว่า “ตรงนี้ใช้ cache-aside” เขาจะรู้ทันทีว่าใครเป็นคนเขียน cache
- warm-up
- “ยิงทิ้งไปก่อนหนึ่งรอบให้ทุกอย่างพร้อม แล้วค่อยเริ่มจับเวลา” — เหตุผลที่ตัวเลข 278 เท่าน่าเชื่อกว่า 112 เท่า · เวลาอ่าน benchmark ของใครให้ถามว่า “อันนี้ warm-up มาก่อนหรือเปล่า”
6 · หัวใจของบท — cache โกหกได้
กลับไปดูแผนภาพหัวข้อ 5 ตรง ?????
สมมติทีมการตลาดขึ้นราคาสินค้าผ่านหน้าแอดมิน โค้ดฝั่งนั้น
UPDATE ลง database เรียบร้อย แต่ไม่รู้จัก Redis — จะเกิดอะไร
// 1) อ่านครั้งแรก — cache ยังว่าง
p, s := getProduct(ctx, rdb, 1) // เสื้อยืด ราคา 300
// 2) ฝ่ายการตลาดขึ้นราคาใน DB เป็น 500 — ไม่แตะ Redis เลย
db[1] = Product{ID: 1, Name: "เสื้อยืด", Price: 500}
// 3) ลูกค้าอ่านผ่าน cache สามครั้ง
for i := 1; i <= 3; i++ {
p, s := getProduct(ctx, rdb, 1)
fmt.Printf("[%s] ลูกค้าเห็นราคา %d บาท <-- DB บอก %d\n", s, p.Price, db[1].Price)
}
getProduct คือ cache-aside ที่เขียนถูกต้องทุกบรรทัด
(แยก redis.Nil ครบ · ตั้ง TTL 60 วิ)
— ลูกค้าเห็นราคาเท่าไหร่ และมี error หรือ warning โผล่มาไหม?
=== 1) อ่านครั้งแรก (cache miss -> โหลดเข้า cache) ===
[MISS] เสื้อยืด ราคา 300 บาท
=== 2) ฝ่ายการตลาดขึ้นราคาใน DB เป็น 500 (ไม่ invalidate cache) ===
ค่าใน DB ตอนนี้ = 500 บาท
=== 3) ลูกค้าอ่านผ่าน cache ===
[HIT] ลูกค้าเห็นราคา 300 บาท <-- DB บอก 500
[HIT] ลูกค้าเห็นราคา 300 บาท <-- DB บอก 500
[HIT] ลูกค้าเห็นราคา 300 บาท <-- DB บอก 500
ค่าดิบใน Redis = {"id":1,"name":"เสื้อยืด","price":300}
TTL เหลือ = 1m0s (อีกเท่านี้ถึงจะเลิกโกหกเอง)
ไม่มี error ไม่มี warning ไม่มี log — ทุก request คือ HTTP 200 ที่เร็วสวยงาม แต่ตัวเลขที่ลูกค้าเห็นผิด
สังเกตอีกจุด: TTL เหลือ = 1m0s เท่าเดิมทุกครั้งที่ HIT
→ GET ไม่ต่ออายุ TTL
ข่าวดีคือมันจะเลิกโกหกเองแน่นอน · ข่าวร้ายคือคุณควบคุมไม่ได้ว่าเมื่อไหร่
แล้ว DEL หนึ่งบรรทัดแก้ได้จริง:
=== 4) แก้ด้วยการ invalidate: DEL key ===
DEL product:1 -> ลบไป 1 key
[MISS] ลูกค้าเห็นราคา 500 บาท <-- ตรงกับ DB แล้ว
⚠️ นี่คือบั๊กสายพันธุ์เดียวกับ data race ใน
บทเรียน 24 —
โปรแกรม "ทำงานสำเร็จ" ทุกครั้ง แค่คำตอบผิด
ต่างกันตรงที่ data race ยังจับได้ด้วย -race แต่
cache ที่โกหกไม่มีเครื่องมือไหนจับให้
เพราะในมุมของโปรแกรม มันไม่ได้ทำอะไรผิดเลย
อาการที่ผู้ใช้รายงานจะเป็น: "แก้ราคาไปแล้วนะ แต่หน้าเว็บยังเป็นอันเก่า
รีเฟรชกี่ทีก็เหมือนเดิม... เอ๊ะ เดี๋ยวนี้มันถูกแล้ว"
— คำว่า "เดี๋ยวมันหายเอง" คือลายเซ็นของบั๊ก cache
ราคาจริงของสินค้าอยู่ในระบบหลังบ้าน ส่วนที่ลูกค้าเห็นคือป้ายกระดาษที่ติดหน้าชั้น
วันหนึ่งฝ่ายจัดซื้อขึ้นราคาในระบบจาก 300 เป็น 500 แต่ไม่มีใครเดินไปเปลี่ยนป้าย
ป้ายไม่ได้พัง ไม่ได้ฉีก ไม่มีไฟกะพริบเตือน — มันแค่บอกราคาเมื่อวานอย่างมั่นใจ
และลูกค้าทุกคนที่เดินผ่านก็เชื่อมันหมด
วิธีแก้มีสองแบบ ตรงกับสองทางในหัวข้อ 7 พอดี:
· เดินไปดึงป้ายเก่าออกทันทีที่ขึ้นราคา = DEL — ตรงเดี๋ยวนั้น
· เขียนวันหมดอายุไว้บนป้าย ถึงวันต้องพิมพ์ใหม่อยู่ดี = TTL — ไม่ตรงทันที แต่กันลืม
ที่ไม่เหมือนกัน: ในซูเปอร์ยังมีเครื่องสแกนตอนจ่ายเงินคอยจับได้ว่าป้ายไม่ตรง แต่ในระบบของเราไม่มีขั้นตอนไหนเลยที่มาเทียบให้ — ลูกค้าเห็น 300 แล้วจบตรงนั้น ไม่มี error สักบรรทัดให้คุณรู้ตัว
- stale (ข้อมูลค้าง)
- “ป้ายราคาเก่า” — ค่าที่ยังอยู่แต่ไม่ตรงกับต้นทางแล้ว · ประโยคที่ตรงจุดคือ “หน้านี้เสิร์ฟข้อมูล stale อยู่หรือเปล่า” แทนที่จะพูดว่า “มันแสดงผลผิด”
- invalidate
- “ดึงป้ายเก่าออก” = สั่งลบสำเนาใน cache ทิ้ง เพื่อให้ครั้งหน้าไปอ่านของจริง ·
หัวข้อ 7 เขียนว่า “purge / invalidate” เพราะในบริบท Redis +
DELสองคำนี้คือเรื่องเดียวกัน แต่ฝั่ง CDN บางเจ้าแยกกัน: purge = ลบทิ้งจริง ส่วน invalidate = แค่มาร์กว่าเก่า แล้วให้ไปเช็คต้นทางรอบหน้า · คำถามที่ใช้ได้เลย: “เส้นทางเขียนนี้ invalidate cache ครบทุกจุดหรือยัง” - eventual consistency
- “เดี๋ยวก็ตรงกันเอง แต่ไม่รับประกันว่าเมื่อไหร่” · กรณีพึ่ง TTL ของเราดีกว่านั้นนิดหนึ่ง เพราะมีเพดานชัดว่าไม่ตรงได้ไม่เกินค่า TTL — ศัพท์ที่ตรงกว่าคือ bounded staleness · เวลาตกลงกับทีมให้คุยเป็นตัวเลขว่า “ยอมให้ข้อมูลไม่ตรงได้นานสุดกี่นาที”
7 · ทำยังไงให้ cache ไม่โกหก
มีสองทางที่คุณเลือกได้ และทางที่สามที่เลือกไม่ได้
⏳ TTL — ปล่อยให้หมดอายุเอง
rdb.Set(ctx, key, b, 60*time.Second)
ดี: ทำงานเองโดยไม่ต้องแก้โค้ดฝั่งเขียนสักบรรทัด ·
ต่อให้ลืมทุกอย่าง ข้อมูลก็ยังสดใหม่ภายในเวลาที่กำหนด ·
ใช้ได้แม้ต้นทางถูกแก้จากที่ที่เราคุมไม่ได้ (เช่น คนแก้ DB ตรง ๆ)
เสีย: ยอมรับล่วงหน้าว่าจะโกหกได้นานถึง TTL ·
TTL สั้น = hit rate ตก DB โดนหนัก · TTL ยาว = โกหกนาน ·
ไม่มีตัวเลขไหน "ถูก" มีแต่ตัวเลขที่คุณยอมรับได้
🗑️ purge / invalidate — สั่งลบทันที
rdb.Del(ctx, "product:1")
ดี: ตรงทันที — อ่านครั้งถัดไปเป็น MISS
แล้วได้ค่าใหม่จาก DB (พิสูจน์แล้วในหัวข้อ 6) ·
ตั้ง TTL ยาวได้โดยไม่ต้องกลัว → hit rate สูง
เสีย: ต้องไล่ใส่ทุกจุดที่เขียนข้อมูล ·
ลืมที่เดียว = โกหกที่เดียว · และ DEL key ที่สะกดผิด
คืน 0 เฉย ๆ ไม่ error —
คุณจะไม่มีทางรู้ว่าลบไม่โดน
ในโค้ดจริงใช้ทั้งคู่ ไม่ใช่เลือกอย่างใดอย่างหนึ่ง
DEL คือทางหลัก (ทำให้ถูกทันที) ·
TTL คือตาข่ายนิรภัย (จำกัดความเสียหายตอนคุณลืม)
วิธีคิดที่ใช้ได้จริง: "ถ้าลืม invalidate ทุกจุด ระบบจะผิดได้นานที่สุดกี่นาที —
ตัวเลขนั้นคือ TTL ของคุณ"
ทางที่สาม: key หายเองโดยที่คุณไม่ได้สั่ง
ผมตั้ง maxmemory 3mb กับ policy allkeys-lru
แล้วสร้าง key ที่มี TTL 3600 วินาที จากนั้นยัดข้อมูลขยะเข้าไปจนเต็ม
โดยไม่แตะ key นั้นอีกเลย:
ตั้ง important:1 TTL 3600 แล้ว *ไม่แตะอีกเลย* (ให้เป็น least-recently-used)
=== ยัด junk 3 รอบ รอบละ 50k (SET junk:N ...) ===
# ตัวเลขแต่ละรอบคือผลของ EXISTS important:1 หลังยัดจบรอบนั้น
รอบ 1: 1 ← ยังอยู่
รอบ 2: 0 ← หายแล้ว ทั้งที่ TTL ยังเหลืออีก ~3600 วิ
รอบ 3: 0
$ EXISTS important:1
0
$ GET important:1
$ TTL important:1 <-- -2 ทั้งที่ยังไม่ถึงเวลาหมดอายุ
-2
evicted_keys:190824
🔢 ทำไม evicted_keys ถึงมากกว่าจำนวน key ที่เพิ่งสร้าง?
การทดลองนี้สร้าง key ทั้งหมด 150,001 ตัว แต่ตัวเลขขึ้น 190824
เพราะ INFO stats เป็นตัวนับสะสมตลอดอายุของ container
ไม่ใช่ของการทดลองรอบนี้ — มันรวมการทดลองก่อนหน้าในหัวข้อเดียวกันเข้ามาด้วย
ถ้าอยากได้เลขเฉพาะรอบของตัวเอง ให้สั่ง CONFIG RESETSTAT ก่อนเริ่ม
(ผมรันซ้ำแบบรีเซ็ตสถิติแล้วได้ evicted_keys:134817 ซึ่งน้อยกว่า 150,001 ตามที่ควรเป็น)
ตั้งไว้ 1 ชั่วโมง หายไปในไม่กี่วินาที และ TTL คืน -2
เหมือนหมดอายุตามปกติเป๊ะ
— นี่คือเหตุผลที่หัวข้อ 4 บอกว่า -2 ตอบได้แค่ "ตอนนี้ไม่มี":
หมดอายุ · โดน evict · หรือไม่เคยมี ให้ผลเหมือนกันหมดทั้งสามกรณี
⚠️ เดี๋ยวก่อน — การทดลองข้างบนผม ตั้ง allkeys-lru เอง
ผมเลยรีสตาร์ท container ให้ค่ากลับเป็น default แล้วถามมันตรง ๆ ว่า default จริง ๆ คืออะไร
คำตอบคือ ไม่ใช่ allkeys-lru:
$ CONFIG GET maxmemory-policy
maxmemory-policy
noeviction
$ CONFIG GET maxmemory # restart ล้าง maxmemory กลับเป็น 0 (= ไม่จำกัด) ด้วย
maxmemory
0
$ CONFIG SET maxmemory 3mb # ตั้งเพดานใหม่ แต่คราวนี้ *ไม่* แตะ policy
OK
# แล้วยัด fill:1 ... fill:60000 จนชนเพดาน
$ SET newkey "เขียนได้ไหม"
OOM command not allowed when used memory > 'maxmemory'.
$ GET fill:1 <-- อ่านยังได้อยู่
xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
$ DEL fill:1 <-- ลบยังได้
1
noeviction เหมาะกับการใช้ Redis เป็น database ไม่ใช่ cache:
เต็มแล้วปฏิเสธการเขียน แทนที่จะทิ้งของเก่า
— ตรงข้ามกับที่ทุกคนคาดหวังจาก cache
อาการคือ cache ค้างอยู่กับข้อมูลชุดเดิมตลอดไป เพราะเขียนใหม่ไม่ได้
ส่วนอ่านยังปกติ ระบบจึงดูเหมือนไม่มีอะไรผิด
ใช้เป็น cache ต้องตั้งเอง:
CONFIG SET maxmemory-policy allkeys-lru
บทสรุปของหัวข้อนี้จึงไม่ใช่ "ตั้ง TTL ให้ดี" แต่คือ
อย่าถือว่า TTL เป็นสัญญา —
key หายได้ตลอดเวลาโดยไม่บอกล่วงหน้า
ซึ่งวนกลับไปกฎเดียวกับหัวข้อ 2: cache ต้องสร้างใหม่จากต้นทางได้เสมอ
ผลข้างเคียงที่ต้องรู้: cache stampede
ถ้า cache หายพร้อมกันหลาย key (deploy ใหม่ · Redis restart · โดน evict ยกชุด) แล้ว request วิ่งเข้ามาพร้อมกัน — ทุกตัวจะ miss พร้อมกัน แล้วแห่ไป DB พร้อมกัน ผมยิง 100 request พร้อมกันตอน cache ว่าง:
=== cache stampede: 100 request พร้อมกันตอน cache ว่าง ===
(อุดมคติคือ DB ควรโดนยิงแค่ 1 ครั้ง)
แบบไร้เดียงสา (ไม่มี lock) request=100 DB ถูกยิง 100 ครั้ง ใช้เวลา 269ms
แบบมี mutex + double-check request=100 DB ถูกยิง 1 ครั้ง ใช้เวลา 282ms
ตัวแก้คือ sync.Mutex จาก
บทเรียน 24
+ เช็ค cache ซ้ำอีกครั้งหลังได้ lock — เพราะระหว่างที่เรายืนรอคิว
คนที่เข้าไปก่อนหน้าอาจอ่าน DB แล้วเติม cache ให้เรียบร้อยแล้ว เราจึงไม่ต้องไปยิง DB ซ้ำ:
val, err := rdb.Get(ctx, key).Result() // เช็ครอบแรก (ไม่ต้องรอคิว)
if err == nil { return decode(val) }
mu.Lock() // miss → เข้าคิว ทีละคนเท่านั้น
defer mu.Unlock()
val, err = rdb.Get(ctx, key).Result() // ← double-check: เช็คซ้ำหลังได้คิว
if err == nil { return decode(val) } // คนก่อนหน้าเติมให้แล้ว จบเลย
u := queryDB(id) // คนแรกคนเดียวที่ได้ยิง DB
rdb.Set(ctx, key, encode(u), 60*time.Second)
— ต้นทุนแทบเป็นศูนย์: 269ms กับ 282ms
⚠️ ข้อจำกัดที่ต้องพูดให้ตรง — ผมทดสอบเฉพาะ sync.Mutex
ซึ่งกันได้แค่ภายในโปรเซสเดียว
ถ้า deploy หลาย pod แต่ละ pod มี mutex ของตัวเอง → ยังแห่ไป DB พร้อมกันได้อยู่
วิธีสำหรับหลาย instance (distributed lock ด้วย SET NX,
singleflight) ผมยังไม่ได้รันพิสูจน์ จึงไม่เขียนเป็นข้อสรุปในบทนี้
8 · สามกับดักที่คนใหม่เจอทุกคน
กับดัก 1 — redis.Nil ไม่ใช่ error จริง
cache miss คือเหตุการณ์ปกติที่สุดของ cache
แต่ go-redis รายงานมันเป็น error:
val = ""
err = redis: nil
err != nil = true
err == redis.Nil = true
errors.Is(err,redis.Nil)= true
redis.Nil ตัวมันเอง = "redis: nil" (type proto.RedisError)
❌ ตามสัญชาตญาณ Go
val, err := rdb.Get(ctx, key).Result()
if err != nil {
return err // → HTTP 500
}
โค้ดจะคิดว่า 'Redis พัง' แล้ว return 500 ทั้งที่แค่ cache miss: redis: nil
พังหนักที่สุดตอน deploy ใหม่ ตอนที่ cache ยังว่างทั้งหมด
= ทุก request เป็น 500
✅ ถามว่า error ตัวไหน
val, err := rdb.Get(ctx, key).Result()
switch {
case errors.Is(err, redis.Nil):
// miss → ไปอ่าน DB ต่อ
case err != nil:
// error จริง
default:
// hit → ใช้ val
}
cache miss -> ไปอ่าน DB ต่อ (ไม่ใช่ error)
ใช้ errors.Is ไม่ใช่ == เพราะชั้นบนอาจ
%w ห่อไว้ (บทเรียน 12)
คุณโทรไปถามร้านว่ามีของชิ้นนี้ไหม ผลที่ได้มีสองแบบที่ต้องทำต่อคนละทาง:
· ร้านรับสายแล้วบอกว่า “ของหมด” → เรื่องปกติมาก ไปหาที่อื่นต่อได้เลย
= redis.Nil (cache miss แล้วไปอ่าน database)
· โทรไม่ติดเลย → ผิดปกติ ต้องบอกใครสักคน ไม่ใช่เดินหน้าเหมือนไม่มีอะไรเกิดขึ้น
= error จริง
สองอย่างนี้ทำให้คุณไม่ได้ของเหมือนกัน แต่ถ้าเหมารวมว่าเป็นอย่างเดียวกัน จะพังคนละแบบ
ที่ไม่เหมือนกัน: โทรศัพท์แยกสองกรณีนี้ให้คุณด้วยเสียงที่ต่างกันชัดเจน
แต่ go-redis ส่งทั้งสองกรณีมาทางช่องเดียวกัน คือค่า err
คุณจึงต้องเปิดดูเองทุกครั้งว่าเป็น “error ตัวไหน” ด้วย errors.Is
โครง switch ฝั่งขวารับมือ error อื่นได้ถูกต้องด้วย เพราะทั้ง OOM
(out of memory = RAM เต็มจนเขียนเพิ่มไม่ได้) และ
WRONGTYPE ไม่ใช่ redis.Nil:
Set err = OOM command not allowed when used memory > 'maxmemory'.
errors.Is(err, redis.Nil)= false
type = *proto.OOMError
Get(list) val="" err=WRONGTYPE Operation against a key holding the wrong kind of value
errors.Is(err, redis.Nil)= false
⚠️ ญาติสนิทของกับดักนี้: NewClient ไม่เคย error
ผมต่อไปที่ port ที่ไม่มีอะไรฟังเลย:
NewClient สำเร็จทันที ไม่ error เลย (lazy connect)
Ping -> "", err = dial tcp 127.0.0.1:55999: connect: connection refused
Get -> err = dial tcp 127.0.0.1:55999: connect: connection refused
errors.Is(err, redis.Nil) = false <-- ระวัง! connection error ไม่ใช่ cache miss
NewClient แค่สร้าง struct ไม่ได้เปิด TCP จริง → config ผิดจะไม่รู้ตัวตอน
startup แต่ไปพังตอน request แรก
ทางแก้: Ping ตอน startup แล้ว fail fast —
if err := rdb.Ping(ctx).Err(); err != nil { log.Fatal(err) }
และสังเกตบรรทัดสุดท้าย: ถ้าคุณเขียนโค้ดแบบ "err ไหนก็ถือเป็น miss"
ระบบจะไปยิง DB รัว ๆ ตอน Redis ล่ม ซึ่งคือตอนที่ DB ต้องการความเมตตาที่สุด
- sentinel error
- “error สำเร็จรูปที่ประกาศไว้ตัวเดียวตายตัว ให้ทุกคนเอาไปเทียบ” เช่น
redis.Nil· ถามให้ตรงคำว่า “library ตัวนี้มี sentinel error อะไรบ้าง” จะได้ลิสต์ที่เอาไปเขียนerrors.Isได้ทันที - lazy connect
- “ยังไม่ต่อจริงจนกว่าจะใช้ครั้งแรก” — เหตุผลที่
NewClientไม่เคย error · ประโยคที่ใช้ได้: “client ตัวนี้ต่อแบบ lazy หรือต่อตอนสร้างเลย” - fail fast
- “เจอผิดปกติแล้วหยุดรายงานทันที ไม่ลากต่อ” — ใช้ได้ทุกจุดของโปรแกรม ไม่ได้ผูกกับตอนบูตอย่างเดียว ·
ในบทนี้เราใช้กับ startup:
Pingไม่ผ่านก็อย่าเพิ่งเปิดรับ request ดีกว่าไปพังตอนมีลูกค้าใช้ · พูดว่า “อยากให้ fail fast ตอน startup” ทีมจะเข้าใจทันทีว่าต้องเพิ่มเช็คตรงไหน
กับดัก 2 — โยน struct ลง Redis ตรง ๆ
rdb.Set รับพารามิเตอร์เป็น any → compile ผ่านสบายมาก
แล้วไปพังตอน runtime:
Set(struct) err = redis: can't marshal main.User (implement encoding.BinaryMarshaler)
Set(&struct) err = redis: can't marshal *main.User (implement encoding.BinaryMarshaler)
Set(map) err = redis: can't marshal map[string]int (implement encoding.BinaryMarshaler)
Set(slice) err = redis: can't marshal []int (implement encoding.BinaryMarshaler)
EXISTS ทั้ง 4 key -> 0 (ไม่มีอะไรถูกเขียนเลย)
ชนิดที่ go-redis รับได้ตรง ๆ มีเท่านี้:
string -> err=<nil> stored="str"
int -> err=<nil> stored="42"
float64 -> err=<nil> stored="3.14"
bool -> err=<nil> stored="1"
[]uint8 -> err=<nil> stored="bytes"
time.Duration -> err=<nil> stored="1000000000"
ทางแก้คือ marshal เองก่อน
(marshal = แปลงข้อมูลในโปรแกรมให้เป็นข้อความหรือไบต์ ก่อนส่งออกไปข้างนอก) —
json.Marshal จาก
บทเรียน 13:
b, err := json.Marshal(u)
if err != nil { return err }
rdb.Set(ctx, key, b, ttl) // []byte ผ่านฉลุย
ข่าวดีของกับดักนี้: มัน error ชัด และไม่เขียนขยะลง Redis
(EXISTS ได้ 0 ทั้งสี่ key)
ข่าวร้ายคือมันจะเงียบทันทีถ้าคุณเขียน rdb.Set(...) โดยไม่รับ error
— error ของเคสนี้เกิดที่ Set ไม่ใช่ที่ Marshal
สังเกตด้วยว่า bool true ถูกเก็บเป็น "1" และ
time.Duration เป็น nanosecond — อ่านกลับมาต้องแปลงเอง
กับดัก 3 — KEYS * เทียบกับ SCAN
$ MSET u:1 a u:2 b u:3 c p:1 x p:2 y
$ SCAN 0 MATCH "u:*"
...
$ SCAN 0 MATCH "u:*" COUNT 2
...
มี key ที่ขึ้นต้นด้วย u: อยู่ 3 ตัว —
สองคำสั่งนี้พิมพ์อะไรออกมาบ้าง? ต่างกันตรงไหน
$ SCAN 0 MATCH "u:*"
0
u:1
u:2
u:3
$ SCAN 0 MATCH "u:*" COUNT 2 # COUNT เป็นแค่ hint
3
u:1
บรรทัดแรกของ SCAN คือ cursor ไม่ใช่ key
เคสแรก cursor = 0 แปลว่าวนครบแล้ว ·
เคสที่สอง cursor = 3 แปลว่ายังไม่จบ
และได้ key มาแค่ตัวเดียวจากสามตัวที่ match
ถ้าเขียนโค้ดเรียก SCAN 0 ครั้งเดียวแล้วคิดว่าได้ครบ
job ไล่ลบ key ของคุณจะลบไม่ครบแบบสุ่ม ๆ ทุกครั้งที่รัน
โดยไม่มี error เลย
และ COUNT ไม่ใช่ limit — ผมขอ COUNT 1000
แต่ได้ batch กลับมาสูงสุด 1004 ตัว
→ อ่าน len() ของ slice จริงเสมอ อย่ายึด COUNT
// KEYS: ดึงหมดในคำสั่งเดียว
keys, err := rdb.Keys(ctx, "*").Result()
// SCAN: ต้องวนจน cursor == 0 เท่านั้น
var cursor uint64
for {
batch, cur, err := rdb.Scan(ctx, cursor, "*", 1000).Result()
if err != nil { break }
for _, k := range batch { /* ... */ }
cursor = cur
if cursor == 0 { break } // ← เงื่อนไขเดียวที่ถูกต้อง
}
ตำราส่วนใหญ่บอกว่า "ใช้ SCAN เพราะ KEYS ช้า"
— ผมวัดแล้วพบว่าเหตุผลนี้ผิด บน 1,000,000 key:
=== KEYS * (ดึงมาทีเดียวหมด) ===
ได้ 1000000 keys ใน 150.375625ms
heap ขณะถือผลลัพธ์ = 30.9 MB (โต +30.5 MB)
=== SCAN (ทยอยดึงทีละ batch 1000) ===
ได้ 1000000 keys (นับซ้ำ) ใน 862.022875ms
วน 1000 รอบ, batch ใหญ่สุด 1004 keys
KEYS เร็วกว่า 5.7 เท่า เพราะ SCAN
ต้องวิ่ง round-trip (ไป-กลับระหว่างโปรแกรมเรากับ Redis หนึ่งเที่ยว) ถึง 1,000 รอบ
ถ้าเราสอนว่า "SCAN เร็วกว่า" ผู้เรียนจะไป benchmark เองแล้วสรุปว่าอาจารย์ผิด
แล้วกลับไปใช้ KEYS — เหตุผลที่ถูกต้องอยู่ที่อื่น:
เวลาที่ Redis ใช้ฝั่ง server (จาก SLOWLOG, หน่วยไมโครวินาที):
146086 ← KEYS * = 146 ms
KEYS
*
57 ← SCAN 0 COUNT 100 = 57 µs
SCAN
0
COUNT
100
146 ms นั้นคือเวลาที่ client อื่นทั้งหมดถูกแช่แข็ง
ผมวัดผลกระทบด้วย redis-cli --latency (หน่วย ms):
A) baseline (ไม่มีอะไรกวน) : min: 0, max: 2, avg: 0.38 (447 samples)
B) ขณะมี KEYS * ยิงรัว ๆ : min: 0, max: 274, avg: 12.47 (245 samples)
C) ขณะมี SCAN ยิงรัว ๆ : min: 0, max: 2, avg: 0.38 (449 samples)
latency ของ API ที่ไม่เกี่ยวข้องกันเลยพุ่งจาก 2 ms เป็น 274 ms
ส่วน SCAN ที่ยิงจำนวนเท่ากันไม่กระทบเลยสักนิด
สังเกตจำนวน sample ตอน KEYS เหลือ 245 จาก 447 —
เครื่องมือวัด latency เองก็โดนบล็อกไปด้วย
ประโยคที่ถูกคือ "SCAN ไม่บล็อกคนอื่น" ไม่ใช่ "SCAN เร็วกว่า"
9 · ปิด Day 7 — เครื่องมือสี่ตัว คำถามเดียว
ทั้งวันนี้เราเจอเครื่องมือสี่ตัวที่ดูไม่เกี่ยวกันเลย แต่ทุกตัวตอบคำถามเดียวกัน: "ระหว่างเร็วกับจริง คุณกำลังแลกอะไรกับอะไร"
| เครื่องมือ | ได้อะไร | เสียอะไร | หลักฐานที่ต้องเห็น |
|---|---|---|---|
| mock บท 28 |
เทสเร็ว ไม่ต้องมี infra | เร็วแต่ไม่จริง — mock ตอบตามที่เราสั่ง ไม่ใช่ตามที่ระบบจริงตอบ | เทสผ่านหมด แต่ของจริงพัง |
| container จริง บท 29–30 |
เทสกับของจริง เจอบั๊กที่ mock ไม่มีวันเจอ | จริงแต่ช้า — ต้องรอ container พร้อม | เวลาเทสที่ต่างกันชัดเจน |
| migration บท 31 |
schema ทุกเครื่องตามทันโค้ด และย้อนกลับได้ | ต้องมีวินัย — ทุกการเปลี่ยน schema ต้องมีไฟล์ | รันซ้ำแล้วผลเหมือนเดิม |
| cache บทนี้ |
เร็วขึ้น ~278 เท่า | เร็วแต่โกหกได้ — ตอบผิดโดยไม่มี error | DB บอก 500 · ลูกค้าเห็น 300 |
สังเกตว่าคอลัมน์ "เสียอะไร" ของทั้งสี่แถวไม่มี error message เลยสักตัว —
ราคาของความเร็วไม่เคยจ่ายเป็น crash มันจ่ายเป็นความไม่ตรงกับความจริง
และนั่นคือทักษะที่ Day 7 พยายามสร้าง:
รู้ว่าตอนนี้กำลังมองของจริงหรือมองสำเนาอยู่
— mock คือสำเนาของ dependency · cache คือสำเนาของข้อมูล ·
container คือของจริงที่แพงกว่า · migration คือสิ่งที่ทำให้ "ของจริง" ของทุกคนเป็นอันเดียวกัน
📄 สรุปทั้งวันในหน้าเดียว — คำสั่ง, ตารางเทียบ, error ที่เจอบ่อย
และตารางกับดักรวมทั้งวันอยู่ที่
Cheat sheet Day 7
บทก่อนหน้า: บทเรียน 31 — Goose migration
ทดสอบความเข้าใจ
1. TTL mykey คืน -1 แปลว่าอะไร?
-1 = มี key แต่เป็นอมตะ · -2 = ไม่มี key
· เลขบวก = วินาทีที่เหลือ
ผมรันครบสามเคส: TTL nokey:xyz → -2 ·
SET plain แล้ว TTL plain → -1 ·
EXPIRE plain 100 แล้ว TTL plain → 100
ตัวเลือกที่ 1 คือความหมายของ -2 ไม่ใช่ -1 ·
ตัวเลือกที่ 2 ผิดเพราะพอหมดอายุแล้ว EXISTS ได้ 0
และ TYPE ได้ none ทันที ไม่มีสถานะ "ค้าง" ·
ตัวเลือกที่ 4 ผิดเพราะ TTL ไม่เคยคืนเลขติดลบเพื่อบอกเศษวินาที
2. ทำไมโค้ด production ถึงควรใช้ SCAN แทน KEYS *?
Redis ประมวลผลทีละคำสั่งบน thread เดียว —
SLOWLOG บอกว่า KEYS * บน 1 ล้าน key ใช้ 146,086 µs
ส่วน SCAN หนึ่งครั้งใช้ 57 µs
วัดผลกระทบต่อคนอื่นด้วย --latency:
baseline max 2 ms → ระหว่างมี KEYS max 274 ms
→ ระหว่างมี SCAN กลับมา max 2 ms
ตัวเลือกที่ 1 ผิดกลับด้าน: ผมวัดแล้ว KEYS เร็วกว่า
(150 ms vs 862 ms) เพราะ SCAN ต้อง round-trip 1,000 รอบ ·
ตัวเลือกที่ 2 ผิดเพราะ KEYS ยังใช้ได้ปกติทุกเวอร์ชัน —
ผมเพิ่งรันมันบน Redis 7.4.10 ได้ 1,000,000 key ในบทนี้เอง เอกสารทางการแค่เตือน
ไม่ได้ปิดมันทิ้ง (และนี่แหละคืออันตราย: มันทำงานได้เสมอ จนวันที่ข้อมูลเยอะพอ) ·
ตัวเลือกที่ 3 ผิดสลับกัน — SCAN ต่างหากที่คืนซ้ำได้ ถ้าต้องการ unique ต้อง dedup เอง
3. rdb.Get() กับ key ที่ไม่มี แล้วโค้ดเขียน if err != nil { return err } จะเกิดอะไร?
go-redis คืน redis.Nil (type proto.RedisError) เป็น error
เมื่อไม่มี key → err != nil เป็น true
ทั้งที่นี่คือเหตุการณ์ปกติที่สุดของ cache
ผลคือ โค้ดจะคิดว่า 'Redis พัง' แล้ว return 500 ทั้งที่แค่ cache miss
และพังหนักสุดตอน cache ว่างทั้งหมด = ทุก request เป็น 500 พร้อมกัน
ตัวเลือกที่ 1 ผิด (นั่นคือพฤติกรรมของ database/sql คนละแบบ) ·
ตัวเลือกที่ 3 ผิดเพราะไม่มี panic เลย มันเป็นแค่ค่า error ธรรมดา ·
ตัวเลือกที่ 4 ผิดเพราะ go-redis ไม่รู้จัก database ของคุณ — คนที่ต้องเดินให้สองฝั่งคือโค้ด cache-aside ของเราเอง
ทางแก้: errors.Is(err, redis.Nil) ก่อนเสมอ
(บทเรียน 12)
4. แอดมินแก้ราคาใน DB จาก 300 เป็น 500 แต่ cache ยังเสิร์ฟ 300 อยู่ — วิธีไหนทำให้ตรงทันที?
ผมรันจริง: DEL product:1 -> ลบไป 1 key แล้วอ่านครั้งถัดไปได้
[MISS] ลูกค้าเห็นราคา 500 บาท — ตรงกับ DB ทันที
ตัวเลือกที่ 2 ไม่ผิดทั้งหมดแต่ไม่ใช่ "ทันที" — มันแค่ย่นเวลาโกหก
และแลกมาด้วย hit rate ที่ตกจน DB โดนหนัก TTL จึงควรเป็นตาข่ายกันพลาด ไม่ใช่ทางแก้หลัก ·
ตัวเลือกที่ 3 ผิดชัดเจน: ผมพิมพ์ TTL ทุกครั้งที่ HIT ได้ 1m0s เท่าเดิม —
GET ไม่ต่ออายุ TTL และไม่ไปดู DB ·
ตัวเลือกที่ 4 ผิดเพราะ eviction เลือก key ตามความไม่ค่อยถูกใช้ ไม่ได้เลือกตามความเก่าของข้อมูล
— key ที่ลูกค้าอ่านบ่อยคือ key ที่ปลอดภัยจาก evict มากที่สุด
เป้าหมายไม่ใช่ "ทำ cache ให้ทำงาน" — เป้าหมายคือ ทำให้มันโกหกให้ดูก่อน แล้วค่อยแก้ และเขียนเทสที่จับการโกหกได้
- เปิด Redis:
docker run -d --name day7-redis -p 55379:6379 redis:7-alpine
ตั้งฟังก์ชันย่อไว้ก่อน (ไม่ต้องลง redis-cli ในเครื่อง — ใช้ตัวใน container):R() { docker exec -i day7-redis redis-cli "$@"; }
ถ้าจะใช้ redis-cli ของเครื่องตัวเองต้องระบุ port ที่ map ไว้เสมอ:redis-cli -p 55379(พิมพ์redis-cliเปล่า ๆ จะไปต่อ 6379 แล้วได้ connection refused)
แล้วยืนยันเวอร์ชันด้วยR INFO server— ต้องตอบให้ได้ว่าเลขที่เห็นคืออะไร ทำไมไม่ใช่ "7-alpine" - คุม TTL ให้ครบสามเคส: ทำให้
TTLคืน-2·-1· เลขบวก อย่างละครั้งด้วยredis-cli
แล้วลองEXPIREกับ key ที่ไม่มี — ได้ error ไหม - เขียน cache-aside ใน Go (
go-redis/v9) สำหรับProductโดย แยกสามทาง HIT /redis.Nil/ error จริง
วัดเวลาด้วยtime.Sinceแล้วเรียก 5 ครั้ง — อธิบายให้ได้ว่าทำไม HIT ครั้งที่ 2 ช้ากว่าครั้งที่ 3 - ทำให้มันโกหก: แก้ราคาในต้นทางโดยไม่แตะ Redis แล้วอ่านผ่าน cache 3 ครั้ง
พิมพ์rdb.TTL(...)ทุกครั้งที่ HIT — ตัวเลขลดลงไหม - แก้: เพิ่ม
rdb.Delในเส้นทางเขียน แล้วพิสูจน์ว่าอ่านครั้งถัดไปเป็น MISS และได้ค่าใหม่ - เขียนเทสพิสูจน์ (ต่อยอด Day 5):
เทสที่
updatePrice→getProductแล้ว assert ราคาใหม่
เทสนี้ต้อง fail ถ้าคุณลบบรรทัดDelออก — ถ้าลบแล้วยังผ่าน แปลว่าเทสยังไม่ได้เทสอะไร (ลองt.Cleanupล้าง key ก่อนทุกเทส) - ท้าทาย 1: ลอง
rdb.Set(ctx, "x", myStruct, time.Minute)โดยไม่ marshal — ทายก่อนรัน ว่า compile ผ่านไหม, error ว่าอะไร, และEXISTS xได้เท่าไหร่ - ท้าทาย 2: เปลี่ยน
Addrเป็น127.0.0.1:55999(ไม่มีอะไรฟัง) แล้วตอบสามข้อ:NewClienterror ไหม · error โผล่ตอนไหน ·errors.Is(err, redis.Nil)ได้อะไร - ท้าทาย 3: เขียนลูป
SCANที่วนจนcursor == 0เทียบกับเรียกSCAN 0ครั้งเดียว — ใส่ key สัก 5,000 ตัวแล้วนับดูว่าได้ครบไหม - เก็บกวาด:
docker stop day7-redis && docker rm day7-redis
NewClient ไม่ error เลย เพราะมันเป็น lazy connect —
แค่สร้าง struct ไม่ได้เปิด TCP จริง ผมรันได้แบบนี้:
NewClient สำเร็จทันที ไม่ error เลย (lazy connect)
redis: 2026/08/18 19:53:47 pool.go:762: redis: connection pool: failed to dial after 5 attempts: dial tcp 127.0.0.1:55999: connect: connection refused
redis: 2026/08/18 19:53:48 pool.go:762: redis: connection pool: failed to dial after 5 attempts: dial tcp 127.0.0.1:55999: connect: connection refused
Ping -> "", err = dial tcp 127.0.0.1:55999: connect: connection refused
Get -> err = dial tcp 127.0.0.1:55999: connect: connection refused
errors.Is(err, redis.Nil) = false
สามข้อสรุปที่ต้องเก็บไป:
1. config ผิดจะเงียบตอน startup แล้วไปพังตอน request แรก →
Ping แล้ว log.Fatal ตั้งแต่ตอนบูตดีกว่า
2. go-redis พ่น log ลง stderr เอง
โดยไม่ผ่าน logger ของแอป (retry dial 5 ครั้งต่อคำสั่ง) — Redis ล่มจริงเมื่อไหร่ log จะท่วมทันที
3. errors.Is(err, redis.Nil) เป็น false —
แปลว่าโครง switch สามทางในหัวข้อ 8 จัดการถูกต้อง
มันจะไม่เผลอถล่ม DB ราวกับว่าเป็น cache miss
💻 เกร็ดสำหรับ macOS (zsh) — ถ้าคุณจะย่อคำสั่งไว้ในตัวแปรแบบ
R="docker exec day7-redis redis-cli" แล้วเรียก $R SET k v
จะได้ zsh: command not found: docker exec day7-redis redis-cli ทั้งสคริปต์
เพราะ zsh ไม่ทำ word splitting กับตัวแปรที่ไม่ได้ quote (ต่างจาก bash)
ใช้ฟังก์ชันแทน ได้ทั้งสอง shell:
R() { docker exec day7-redis redis-cli "$@"; }
💬 ข้อ 6 คือข้อที่ผมอยากเห็นผลที่สุดของทั้งวัน —
เพราะเทสที่ "ผ่านทั้งที่ลบ Del ออกแล้ว" คือเทสที่หลอกคุณอยู่
ซึ่งเป็นบทเรียนเดียวกับเรื่อง mock ตอนต้นวัน
จบ Day 7 แล้ว — ถ้ามีจุดไหนที่รันแล้วได้ผลไม่ตรงกับในบทเรียน
ส่ง output มาได้เลย ผมอยากรู้ว่าเครื่องคุณต่างจากเครื่องผมตรงไหน
(ตัวเลขทั้งหมดในบทนี้วัดบน Docker Desktop macOS ARM64
อัตราส่วนน่าเชื่อถือกว่าตัวเลขดิบ)
อ้างอิง
- Redis —
TTL· EXPIRE — นิยามของ-1/-2อย่างเป็นทางการ - Redis —
SCAN— อ่านหัวข้อ guarantees: SCAN การันตีแค่ว่า key ที่อยู่ตลอดการวนจะถูกคืนอย่างน้อยหนึ่งครั้ง (ซ้ำได้) - Redis —
KEYS— เอกสารทางการเตือนเองว่าอย่าใช้ใน production - Redis Persistence — RDB กับ AOF ต่างกันยังไง และ default คืออะไร
- pkg.go.dev — go-redis v9 · repo + ตัวอย่าง
- golang.org/x/sync/singleflight — ทางเลือกที่สะอาดกว่า mutex สำหรับ stampede (บทนี้ยังไม่ได้ทดสอบให้)
- Cheat sheet Day 7 — คำสั่งและตารางกับดักรวมทั้งวัน