บทเรียน 32 · Day 7 · Redis

Redis — cache ที่ไม่โกหก

~18 นาที · ต้องมี: บทเรียน 12 (errors.Is), บทเรียน 13 (json.Marshal), บทเรียน 24 (sync.Mutex)

cache คือการยอมตอบด้วยสำเนา เพื่อแลกกับความเร็ว — ผมวัดได้ว่ามันเร็วขึ้น 278 เท่า จริง
แต่ราคาที่จ่ายไม่ใช่ RAM — ราคาคือช่วงเวลาที่ระบบของคุณตอบผิด โดยไม่มี error สักบรรทัด บทนี้จะทำให้ cache โกหกให้ดูกับตา แล้วค่อยแก้

🧰 เตรียมเครื่องก่อนอ่าน — บทนี้รันของจริงทั้งบท ถ้าจะพิมพ์ตามต้องมีสามอย่าง

  1. Docker Desktop เปิดอยู่ แล้วรัน:
    docker run -d --name day7-redis -p 55379:6379 redis:7-alpine
  2. วิธีเรียก redis-cli — ไม่ต้องลง redis-cli ในเครื่อง ใช้ตัวที่อยู่ใน container ได้เลย ตั้งฟังก์ชันย่อไว้หนึ่งครั้ง (ใช้ได้ทั้ง zsh และ bash):
    R() { docker exec -i day7-redis redis-cli "$@"; }
    ต่อไปในบทนี้ บรรทัดที่เขียนว่า $ redis-cli GET k ให้พิมพ์ R GET k
  3. ฝั่ง 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
TYPEkey นี้เก็บชนิดอะไร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 ต่อก็ไม่มีประโยชน์

📊 วัดจริง: DB จำลอง 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 ทำงานทีละคำสั่งบน thread เดียว

เวลาที่ 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 แล้วจับมันโกหกให้ได้

เป้าหมายไม่ใช่ "ทำ cache ให้ทำงาน" — เป้าหมายคือ ทำให้มันโกหกให้ดูก่อน แล้วค่อยแก้ และเขียนเทสที่จับการโกหกได้

  1. เปิด 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"
  2. คุม TTL ให้ครบสามเคส: ทำให้ TTL คืน -2 · -1 · เลขบวก อย่างละครั้งด้วย redis-cli
    แล้วลอง EXPIRE กับ key ที่ไม่มี — ได้ error ไหม
  3. เขียน cache-aside ใน Go (go-redis/v9) สำหรับ Product โดย แยกสามทาง HIT / redis.Nil / error จริง
    วัดเวลาด้วย time.Since แล้วเรียก 5 ครั้ง — อธิบายให้ได้ว่าทำไม HIT ครั้งที่ 2 ช้ากว่าครั้งที่ 3
  4. ทำให้มันโกหก: แก้ราคาในต้นทางโดยไม่แตะ Redis แล้วอ่านผ่าน cache 3 ครั้ง
    พิมพ์ rdb.TTL(...) ทุกครั้งที่ HIT — ตัวเลขลดลงไหม
  5. แก้: เพิ่ม rdb.Del ในเส้นทางเขียน แล้วพิสูจน์ว่าอ่านครั้งถัดไปเป็น MISS และได้ค่าใหม่
  6. เขียนเทสพิสูจน์ (ต่อยอด Day 5): เทสที่ updatePrice → getProduct แล้ว assert ราคาใหม่
    เทสนี้ต้อง fail ถ้าคุณลบบรรทัด Del ออก — ถ้าลบแล้วยังผ่าน แปลว่าเทสยังไม่ได้เทสอะไร (ลอง t.Cleanup ล้าง key ก่อนทุกเทส)
  7. ท้าทาย 1: ลอง rdb.Set(ctx, "x", myStruct, time.Minute) โดยไม่ marshal — ทายก่อนรัน ว่า compile ผ่านไหม, error ว่าอะไร, และ EXISTS x ได้เท่าไหร่
  8. ท้าทาย 2: เปลี่ยน Addr เป็น 127.0.0.1:55999 (ไม่มีอะไรฟัง) แล้วตอบสามข้อ: NewClient error ไหม · error โผล่ตอนไหน · errors.Is(err, redis.Nil) ได้อะไร
  9. ท้าทาย 3: เขียนลูป SCAN ที่วนจน cursor == 0 เทียบกับเรียก SCAN 0 ครั้งเดียว — ใส่ key สัก 5,000 ตัวแล้วนับดูว่าได้ครบไหม
  10. เก็บกวาด: 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 อัตราส่วนน่าเชื่อถือกว่าตัวเลขดิบ)

อ้างอิง