Redis caching แก้ปัญหาที่เรียบง่ายมาก คือ database ต้องตอบคำถามเดิมซ้ำแล้วซ้ำเล่า และทุกครั้งที่ถามซ้ำก็คือ query หนึ่งครั้ง ถ้าเว็บจองโรงแรมแสดงรายการ "โรงแรมยอดนิยมในกรุงเทพฯ" ชุดเดียวกันให้ทุกคนที่เข้ามา database ก็ต้องรัน query นั้นทุกครั้งที่มีคนเปิดหน้า ทั้งที่คำตอบแทบไม่เปลี่ยน Redis เก็บคำตอบนั้นไว้ใน memory แล้วตอบจากตรงนั้นแทน database จึงทำงานครั้งเดียว ไม่ใช่ทุก request
แต่ Redis ไม่ได้เป็นแค่ที่พักผลลัพธ์ของ query บทความนี้จะพูดถึง pattern แบบ cache-aside, data structure ของ Redis และงานที่แต่ละตัวเหมาะ, TTL กับ eviction, โมเดลแบบ single-threaded, persistence และตำแหน่งของ Pub/Sub
Redis คืออะไร
Redis คือ in-memory key-value data store ทุก key ชี้ไปที่ value หนึ่งค่า และข้อมูลทั้งหมดอยู่ใน RAM การอ่านจาก memory ไม่ต้องผ่าน disk I/O และไม่ต้องวาง query plan เลย operation ทั่วไปจึงเสร็จในเวลาต่ำกว่ามิลลิวินาทีฝั่ง server ในทางปฏิบัติ เวลาส่วนใหญ่ของการเรียก Redis หมดไปกับ network round trip
ข้อแลกเปลี่ยนคือ RAM มีจำกัดและแพง เราจึงต้องตัดสินใจเองว่าอะไรควรอยู่ใน Redis อยู่นานแค่ไหน และจะทำอย่างไรเมื่อ memory เต็ม
Pattern แบบ cache-aside สำหรับ Redis caching
Cache-aside (หรือ lazy loading) คือ pattern ที่แอปส่วนใหญ่ใช้:
- หา key ใน Redis ก่อน
- ถ้า hit ก็คืนค่าจาก cache ได้เลย
- ถ้า miss ให้ query database เก็บผลลง Redis พร้อม TTL แล้วคืนค่า
ใน Go 1.22 กับ github.com/redis/go-redis/v9:
func (s *HotelService) Popular(ctx context.Context, city string) ([]Hotel, error) {
key := "hotels:popular:" + city
b, err := s.rdb.Get(ctx, key).Bytes()
if err == nil {
var hotels []Hotel
if json.Unmarshal(b, &hotels) == nil {
return hotels, nil // cache hit
}
} else if !errors.Is(err, redis.Nil) {
s.log.Warn("redis get failed, falling back to database", "err", err)
}
hotels, err := s.repo.PopularHotels(ctx, city) // cache miss
if err != nil {
return nil, err
}
if b, err := json.Marshal(hotels); err == nil {
ttl := 5*time.Minute + time.Duration(rand.Int64N(int64(time.Minute)))
s.rdb.Set(ctx, key, b, ttl)
}
return hotels, nil
}รายละเอียดที่สำคัญเมื่อขึ้น production:
- Error จาก Redis ถูกนับเป็น miss ถ้า Redis ใช้ไม่ได้ service จะช้าลงแต่ยังทำงานต่อได้
- TTL มี jitter แบบสุ่ม key ที่ถูกเขียนพร้อมกันจะได้ไม่หมดอายุพร้อมกันแล้วรุมถล่ม database ในจังหวะเดียว
redis.Nilแปลว่า "ไม่เจอ key" ไม่ใช่ error
Cache invalidation
เมื่อข้อมูลต้นทางเปลี่ยน สำเนาใน cache ก็กลายเป็นของเก่า กลยุทธ์ที่ใช้กันบ่อย:
| กลยุทธ์ | ทำงานอย่างไร | ข้อแลกเปลี่ยน |
|---|---|---|
| TTL อย่างเดียว | ปล่อยให้ข้อมูลหมดอายุเอง | ง่าย แต่ข้อมูลอาจเก่าได้นานสุดหนึ่งรอบ TTL |
| ลบตอนเขียน | หลัง commit ลง database แล้ว DEL key ที่เกี่ยวข้อง | ข้อมูลสด แต่ต้องรู้ว่าการเขียนแต่ละครั้งกระทบ key ไหนบ้าง |
| Write-through | เขียน database และอัปเดต cache ในเส้นทางโค้ดเดียวกัน | cache อุ่นอยู่เสมอ แต่การเขียนช้าลงและโค้ดเยอะขึ้น |
ค่าเริ่มต้นที่ดีคือ ลบตอนเขียน และมี TTL เป็นตาข่ายรองรับ ให้ลบแทนการเขียนทับ เพราะถ้ามีคนเขียนสองรายพร้อมกันแล้วต่างคนต่างอัปเดต cache ค่าที่เก่ากว่าอาจค้างอยู่
ระวัง cache stampede ด้วย เมื่อ key ที่ถูกอ่านบ่อยหมดอายุ request จำนวนมากจะ miss พร้อมกันแล้วแห่ไป query database ทั้งหมด ใน Go ใช้ golang.org/x/sync/singleflight รวบการโหลด key เดียวกันที่เกิดพร้อมกันให้เหลือ query เดียวต่อ instance
Data structure ของ Redis และงานที่เหมาะ
Cache ทั่วไปเก็บได้แค่ string แต่ Redis เก็บค่าที่มีชนิด พร้อมคำสั่งที่ทำงานฝั่ง server นี่คือเหตุผลที่มันมีประโยชน์มากกว่าการเป็น cache:
| ชนิด | คำสั่งหลัก | งานที่ใช้บ่อย |
|---|---|---|
| String | GET, SET, INCR, SET ... NX | ค่าที่ cache ไว้, ตัวนับ, lock |
| Hash | HSET, HGET, HINCRBY | object ที่มีหลาย field เช่น user profile หรือ session |
| List | LPUSH, RPOP, BLMOVE | คิวแบบง่าย, รายการล่าสุด |
| Set | SADD, SISMEMBER, SCARD | tag, สมาชิกไม่ซ้ำ, "ผู้ใช้คนนี้โหวตแล้วหรือยัง" |
| Sorted set | ZADD, ZINCRBY, ZRANGE ... REV | leaderboard, การจัดอันดับ, index ที่เรียงตามเวลา |
| Stream | XADD, XREADGROUP, XACK | event log ที่ทนทาน พร้อม consumer group |
| HyperLogLog | PFADD, PFCOUNT | นับจำนวนไม่ซ้ำแบบประมาณ ใช้ memory น้อยมาก |
Rate limiting ด้วย INCR และ EXPIRE
Rate limiter แบบ fixed window ต้องการตัวนับหนึ่งตัวต่อ client ต่อนาที INCR เป็น atomic ต่อให้ request เข้ามาพร้อมกันก็นับไม่พลาด:
func Allow(ctx context.Context, rdb *redis.Client, ip string, limit int64) (bool, error) {
key := fmt.Sprintf("rate:%s:%d", ip, time.Now().Unix()/60)
pipe := rdb.TxPipeline()
count := pipe.Incr(ctx, key)
pipe.Expire(ctx, key, 2*time.Minute)
if _, err := pipe.Exec(ctx); err != nil {
return false, err
}
return count.Val() <= limit, nil
}Leaderboard ด้วย sorted set
Sorted set เรียงสมาชิกตาม score ให้เองตลอดเวลา การจัดอันดับจึงเหลือคำสั่งเดียว:
rdb.ZIncrBy(ctx, "hotels:bookings:2026-09", 1, "hotel:4821")
top, err := rdb.ZRangeArgsWithScores(ctx, redis.ZRangeArgs{
Key: "hotels:bookings:2026-09", Start: 0, Stop: 9, Rev: true,
}).Result()Session และ distributed lock
Hash ที่ตั้ง TTL ไว้ใช้ทำ session store ได้ตรงไปตรงมา และทุก instance ของแอปอ่านร่วมกันได้
สำหรับ distributed lock ให้ใช้ SET key token NX PX 30000 ซึ่งตั้งค่าเฉพาะเมื่อยังไม่มี key และกำหนดเวลาหมดอายุในคำสั่ง atomic เดียว แบบเก่าที่ใช้ SETNX แล้วตามด้วย EXPIRE แยกกัน อาจทิ้ง lock ที่ไม่มีวันหมดอายุไว้ถ้าโปรเซสตายตรงกลาง ตอนปลดล็อกให้ใช้ Lua script ที่ลบ key เฉพาะเมื่อค่ายังเป็น token ของเราอยู่ จะได้ไม่เผลอปลดล็อกของคนอื่น
Lock ของ Redis ใช้ได้ดีกับการกันงานซ้ำ เช่น สอง instance รัน cron job เดียวกัน แต่ไม่ใช่การรับประกันความถูกต้อง เพราะ GC pause ที่นานหรือการ failover อาจทำให้มีคนถือ lock ซ้อนกันได้ งานที่เกี่ยวกับเงินหรือสต็อกสินค้าให้บังคับกฎที่ database ตามที่อธิบายไว้ใน ป้องกัน race condition ในระบบ payment
TTL และ eviction: เมื่อ memory เต็ม
ตั้ง TTL ได้ด้วย EXPIRE หรือ option EX/PX ของ SET Redis ลบ key ที่หมดอายุสองทาง คือแบบ lazy ตอนมีคนเข้าถึง และแบบ active โดยสุ่มตรวจ key ที่มี TTL อยู่เบื้องหลัง
แต่ TTL อย่างเดียวไม่ได้จำกัดขนาด memory ต้องตั้ง maxmemory และเลือกว่าจะทำอะไรเมื่อถึงเพดาน:
maxmemory 2gb
maxmemory-policy allkeys-lfu| Policy | ลบอะไร |
|---|---|
noeviction (ค่า default) | ไม่ลบอะไร การเขียนจะ error เมื่อ memory เต็ม |
allkeys-lru | key ที่ไม่ได้ใช้นานที่สุด |
allkeys-lfu | key ที่ถูกใช้น้อยที่สุด |
volatile-lru / volatile-lfu | เหมือนข้างบน แต่เฉพาะ key ที่มี TTL |
volatile-ttl | key ที่ใกล้หมดอายุที่สุด |
allkeys-random / volatile-random | สุ่ม |
ถ้าใช้เป็น cache ล้วน ๆ ตัวเลือกที่ใช้กันทั่วไปคือ allkeys-lru หรือ allkeys-lfu ถ้า Redis เก็บข้อมูลที่หายไม่ได้ เช่น คิวหรือ session ให้คง noeviction ไว้ แล้วตั้ง alert เรื่องการใช้ memory แทน
ทำไม Redis ที่เป็น single-threaded ถึงเร็ว
Redis รันคำสั่งบน main thread เดียว ฟังดูเหมือนคอขวด แต่ทำงานได้ดีเพราะ:
- ข้อมูลอยู่ใน memory แต่ละคำสั่งจึงสั้น
- event loop รับ connection ได้เป็นพันโดยไม่ต้องมี thread ต่อ client
- ไม่มี lock ระหว่าง thread ให้แย่งกัน
การมี thread เดียวยังแปลว่า ทุกคำสั่งเป็น atomic INCR จากสอง client ไม่มีทางทำให้ค่าหาย ถ้า logic มีหลายขั้นตอน ให้ใช้ MULTI/EXEC หรือ Lua script ซึ่งรันโดยไม่มีคำสั่งอื่นแทรกเช่นกัน
ด้านกลับคือคำสั่งที่ช้าหนึ่งคำสั่งจะบล็อกทุกคน ให้ใช้ SCAN แทน KEYS * อย่าดึง collection ขนาดใหญ่ในคำสั่งเดียว และใช้ UNLINK กับ key ขนาดใหญ่ ตั้งแต่ Redis 6 มี I/O thread ให้เปิดใช้เพื่อช่วยอ่านเขียน network ได้ แต่การรันคำสั่งยังเป็น thread เดียวเหมือนเดิม
Redis persistence: RDB กับ AOF
Redis เก็บข้อมูลใน memory ก็จริง แต่ไม่จำเป็นต้องเสียทุกอย่างเมื่อ restart เพราะมีกลไก persistence สองแบบ:
| RDB (snapshot) | AOF (append-only file) | |
|---|---|---|
| วิธีทำงาน | fork แล้วเขียน snapshot ของข้อมูล ณ ขณะนั้น | บันทึกทุกคำสั่งเขียน แล้ว replay ตอนเปิดเครื่อง |
| ข้อมูลที่หายเมื่อ crash | ทุกอย่างหลัง snapshot ล่าสุด | ถ้าใช้ appendfsync everysec ประมาณหนึ่งวินาทีสุดท้าย |
| ขนาดไฟล์และการ restart | ไฟล์กะทัดรัด restart เร็ว | ไฟล์ใหญ่กว่า มีการ rewrite เป็นระยะเพื่อบีบให้เล็กลง |
| ต้นทุนตอนรัน | การ fork อาจหนักเมื่อข้อมูลเยอะ | เขียน disk ต่อเนื่อง |
เปิดทั้งสองแบบพร้อมกันได้ ตั้งแต่ Redis 7 AOF ใช้รูปแบบ multi-part และการ rewrite เริ่มจาก RDB preamble ทำให้ restart ยังเร็วอยู่:
appendonly yes
appendfsync everysec
save 3600 1 300 100 60 10000เลือกอย่างไร:
- Cache ล้วน ที่สร้างใหม่จาก database ได้: ใช้ RDB อย่างเดียว หรือไม่ต้อง persist เลย
- Session, rate limit, คิว: ใช้ AOF แบบ
everysecและมี RDB ไว้ทำ backup - Replication ไม่ใช่ backup เพราะ replica ก็คัดลอกความผิดพลาดของเราไปเร็วพอ ๆ กับข้อมูล
เมื่อมี AOF และ replica แล้ว Redis ใช้เป็น primary store ของข้อมูลบางประเภทได้ ตราบใดที่รู้ชัดว่าข้อมูลแต่ละชุดยอมหายได้แค่ไหน เรื่องการดูแล service ที่มี state โดยรวม อ่านต่อได้ที่ การรัน database ใน production
พื้นฐาน Redis Pub/Sub
Pub/Sub คือ messaging pattern ที่ publisher ส่งข้อความไปที่ channel แล้วทุก client ที่ subscribe channel นั้นอยู่ในขณะนั้นจะได้รับพร้อมกันหมด publisher ไม่รู้และไม่สนว่ามีใครฟังอยู่กี่คน
ความต่างจาก queue เหมือนโทรศัพท์กับวิทยุ queue ส่งข้อความแต่ละชิ้นให้ consumer คนเดียวรับไปทำ ส่วน Pub/Sub กระจายข้อความให้ทุกคนที่เปิดฟังอยู่ตอนนั้น
// subscriber
sub := rdb.Subscribe(ctx, "price-updates")
defer sub.Close()
for msg := range sub.Channel() {
log.Printf("%s: %s", msg.Channel, msg.Payload)
}
// publisher, elsewhere
rdb.Publish(ctx, "price-updates", `{"hotel":4821,"price":1990}`)Redis Pub/Sub เป็นแบบ ยิงแล้วลืม ข้อความไม่ถูกเก็บไว้ subscriber ที่หลุดหรือช้าก็พลาดข้อความไปเลย จึงเหมาะกับการแจ้งเตือนแบบสด สัญญาณล้าง cache และการกระจายข้อความไปยัง WebSocket server หลายตัว ซึ่งอธิบายไว้ใน การ scale WebSocket ด้วย Pub/Sub ถ้าข้อความหายไม่ได้ ให้ใช้ Redis Streams หรือ broker ที่มี acknowledgement อย่าง RabbitMQ
คำถามที่พบบ่อย
Redis เป็น cache หรือ database?
เป็นได้ทั้งสองอย่าง ส่วนใหญ่ใช้เป็น cache แต่เมื่อเปิด AOF persistence และ replication ก็ใช้เป็น primary store ของข้อมูลอย่าง session, ตัวนับ และ leaderboard ได้
Redis ข้อมูลหายเมื่อ restart ไหม?
ไม่หายถ้าเปิด persistence ไว้ ถ้าใช้ RDB จะเสียการเขียนหลัง snapshot ล่าสุด ถ้าใช้ AOF กับ appendfsync everysec จะเสียไม่เกินประมาณหนึ่งวินาที
Redis cache ควรใช้ eviction policy แบบไหน?
allkeys-lru หรือ allkeys-lfu โดย LFU จะเก็บ key ที่ถูกใช้บ่อยไว้แม้ไม่ได้ถูกแตะในไม่กี่วินาทีที่ผ่านมา ซึ่งมักเหมาะกับงานที่มี hot key
Redis เป็น single-threaded จริงไหม?
การรันคำสั่งเกิดบน main thread เดียว ทำให้แต่ละคำสั่งเป็น atomic แต่ Redis ใช้ thread เพิ่มสำหรับงานเบื้องหลัง และเลือกเปิดใช้กับ network I/O ได้
Redis Pub/Sub กับ Redis Streams ต่างกันอย่างไร?
Pub/Sub ส่งให้เฉพาะ subscriber ที่เชื่อมต่ออยู่และไม่เก็บอะไรไว้ ส่วน Streams เก็บข้อความ รองรับ consumer group และ acknowledgement และให้ consumer ตามอ่านต่อได้หลังหลุดการเชื่อมต่อ
Checklist สำหรับ Redis caching
- ใช้ cache-aside โดยทุก key มี TTL และ jitter
- นับ error จาก Redis เป็น cache miss
- ลบ key หลัง commit ลง database และมี TTL เป็นตาข่ายรองรับ
- ตั้ง
maxmemoryและ eviction policy ให้ตรงกับชนิดข้อมูล - ไม่มี
KEYS *หรือการอ่านก้อนใหญ่ในคำสั่งเดียวบน production - เลือก persistence ตามชุดข้อมูล ไม่ persist, RDB หรือ AOF คู่กับ RDB
- ใช้ Pub/Sub เฉพาะงานที่ข้อความหายได้
ถ้ากำลังเพิ่มชั้น cache หรือมี cache ที่ทำงานผิดปกติเมื่อโหลดสูง Vectorkub รับพัฒนาและจูนระบบ backend ที่ใช้ Redis
