ทางร้อนต้องไม่แตะฐานข้อมูล: cache สั้น จำคำตอบว่าไม่มี ปิดแล้วต้องปิดจริง
ระบบที่ถูกเรียกวินาทีละหลายร้อยครั้งบนเครื่อง 2GB อยู่รอดด้วย Redis ตั้ง TTL ยังไง จำคำตอบว่าไม่มีทำไม และทำไมสวิตช์ปิดต้องล้าง cache ทันที จากระบบที่ใช้จริง
- ทางที่ถูกเรียกบ่อยที่สุด (hot path) ต้องตอบจาก cache ให้ได้ ฐานข้อมูลถูกถามแค่ตอน cache หมดอายุ
- จำคำตอบว่า "ไม่มี" ด้วย ไม่งั้นคำขอที่ผิดรัว ๆ จะทะลุไปถึงฐานข้อมูลทุกครั้ง
- cache ทำให้การเปลี่ยนแปลงช้าลงเท่า TTL เรื่องที่ต้องมีผลทันที เช่น ปิดสิทธิ์ ต้องลบ cache เอง
เรามีระบบส่งต่อไม้เทรด บัญชีต้นทางส่งรายการไม้ที่ถืออยู่ขึ้นเซิร์ฟเวอร์ แล้วบัญชีผู้ตามดึงไปเปิดตาม ผู้ตามแต่ละคน ดึงข้อมูลวินาทีละสองครั้ง ถ้ามีผู้ตาม 100 คน เซิร์ฟเวอร์ต้องตอบ 200 ครั้งต่อวินาที บนเครื่องแรม 2GB ที่มีฐานข้อมูลรันอยู่ในเครื่องเดียวกัน บทความนี้เล่าว่าเราทำยังไงให้ฐานข้อมูลแทบไม่รู้สึกถึงภาระนี้
ตัวอย่างใช้ Redis 7 กับ Go โค้ดย่อมาจากระบบจริง
หลักการ: แยกทางร้อนออกจากฐานข้อมูล
ทุกระบบมี ทางร้อน (hot path) คือส่วนที่ถูกเรียกบ่อยกว่าส่วนอื่นหลายร้อยเท่า ในระบบนี้คือ endpoint ที่ผู้ตามดึงรายการไม้ แต่ละครั้งต้องรู้สองอย่าง
- token ที่ผู้ตามส่งมา เป็นของบัญชีไหน และบัญชีนั้นยังเปิดให้ตามอยู่ไหม
- ตอนนี้บัญชีนั้นถือไม้อะไรอยู่
ถ้าทั้งสองข้อถามฐานข้อมูลทุกครั้ง 200 ครั้งต่อวินาทีก็คือ 200 query ต่อวินาที ตลอดเวลาที่ตลาดเปิด
หลักการ: ทางร้อนต้องตอบจาก cache ให้ได้ ฐานข้อมูลถูกถามเฉพาะตอน cache หมดอายุ
สถานะที่หายไปเองเมื่อต้นทางเงียบ
ข้อ 2 ไม่ต้องเก็บในฐานข้อมูลเลย บัญชีต้นทางส่งรายการไม้ขึ้นมาเรื่อย ๆ เราเก็บใน Redis โดยให้อายุ 60 วินาที
SET copy:state:a:42 '{"positions":[...]}' EX 60
ข้อดีคือถ้าต้นทางหยุดส่ง เช่น ปิดเครื่องหรือเน็ตหลุด สถานะจะ หายไปเองใน 60 วินาที ผู้ตามจะไม่ไปเปิดไม้ตามข้อมูลเก่าที่ค้างอยู่ ไม่ต้องเขียนงานเก็บกวาดเพิ่ม อายุของ key ทำหน้าที่นั้นให้
cache token: สั้น และเก็บสองค่าในที่เดียว
ข้อ 1 ต้องถามฐานข้อมูล แต่ถามครั้งเดียวแล้วจำไว้ 30 วินาที
func tokenLookup(ctx context.Context, tok string) (accountID int64, enabled, ok bool) {
key := "copy:tok:" + tok
if v, err := rdb.Get(ctx, key).Result(); err == nil {
if v == "-" {
return 0, false, false // จำไว้แล้วว่าไม่มี token นี้
}
return parse(v) // "42:1" = บัญชี 42 เปิดให้ตามอยู่
}
acct, err := db.AccountByCopyToken(ctx, tok)
if errors.Is(err, pgx.ErrNoRows) {
rdb.Set(ctx, key, "-", 30*time.Second) // จำคำตอบว่าไม่มีด้วย
return 0, false, false
}
if err != nil {
return 0, false, false // ฐานข้อมูลมีปัญหา: ห้ามจำว่าไม่มี
}
rdb.Set(ctx, key, fmt.Sprintf("%d:%d", acct.ID, b2i(acct.Enabled)), 30*time.Second)
return acct.ID, acct.Enabled, true
}
สังเกตสามจุด
- ค่าที่ cache มีทั้ง id และสถานะเปิดปิด เพราะทางร้อนต้องใช้ทั้งสองอย่าง ถ้า cache แค่ id ก็ต้องไปถามฐานข้อมูลเรื่องสถานะทุกครั้งอยู่ดี
- จำคำตอบว่าไม่มี (
"-") ถ้ามีคนยิง token มั่ว ๆ รัว ๆ หรือผู้ตามที่ token ถูกยกเลิกแล้วยังดึงต่อ คำขอเหล่านั้นจะจบที่ Redis ไม่ทะลุไปฐานข้อมูล - ฐานข้อมูลล่ม ห้ามจำว่าไม่มี ถ้าจำ token ที่ถูกต้องจะใช้ไม่ได้ไป 30 วินาทีทั้งที่ไม่ได้ผิดอะไร
คำนวณภาระที่ลดลง
| ไม่มี cache | มี cache 30 วินาที | |
|---|---|---|
| คำขอเข้า | 200 ครั้ง/วินาที | 200 ครั้ง/วินาที |
| ถามฐานข้อมูล (ผู้ตาม 100 คน ใช้ token เดียวกัน) | 200 ครั้ง/วินาที | 1 ครั้งต่อ 30 วินาที |
200 ครั้งต่อวินาทีเหลือ 1 ครั้งต่อ 30 วินาที คือลดลง 6,000 เท่า ส่วนที่เหลือ Redis ตอบเองจากหน่วยความจำ
ราคาที่จ่าย: การเปลี่ยนแปลงช้าลง 30 วินาที
cache ไม่ได้ฟรี ถ้าเจ้าของบัญชีเปลี่ยน token เพื่อตัดผู้ตามทั้งหมด หรือปิดสวิตช์ให้ตาม ค่าเก่าใน cache ยังอยู่ได้อีกถึง 30 วินาที ผู้ตามจึงดึงข้อมูลต่อได้ช่วงนั้น
บางเรื่องรอได้ แต่ สวิตช์ปิดที่ใช้เวลาครึ่งนาที ไม่ใช่สวิตช์ปิด ระบบจึงลบ cache เองทันทีในสองจังหวะ
// ตอนปิดสวิตช์ให้ตาม
rdb.Del(ctx, "copy:tok:"+tok)
// ตอนเปลี่ยน token: ลบของเก่าก่อนสร้างใหม่
rdb.Del(ctx, "copy:tok:"+oldTok)
TTL คือเวลาสูงสุดที่ยอมให้ข้อมูลเก่า เรื่องที่ยอมให้เก่าไม่ได้เลย ต้องลบ cache ตอนเปลี่ยน ไม่ใช่รอให้หมดอายุ
จำกัดการเขียนด้วย SET NX
ทางร้อนบางทางต้องเขียนด้วย เช่น อยากรู้ว่าผู้ตามแต่ละคนยัง online อยู่ไหม ถ้าบันทึกทุกครั้งที่ดึง ก็กลับไปที่ 200 ครั้งต่อวินาที เราจึงใช้ Redis เป็นตัวกั้น ให้บันทึกได้ไม่เกินหนึ่งครั้งต่อนาทีต่อผู้ตาม
fresh, err := rdb.SetNX(ctx, "copy:log:42:"+login, "1", 60*time.Second).Result()
if err == nil && fresh {
db.TouchFollower(ctx, 42, login) // เขียนฐานข้อมูลนาทีละครั้งต่อคน
}
SET NX สำเร็จเฉพาะตอนที่ key ยังไม่มี ภายใน 60 วินาทีหลังจากนั้น คำขอที่ตามมาทั้งหมดจะได้ false และไม่เขียนอะไร ผู้ตามที่ดึงวินาทีละสองครั้งจึงทำให้เกิดการเขียนนาทีละครั้ง แทนที่จะเป็น 120 ครั้ง
สังเกตว่าถ้า Redis error ก็ ไม่เขียน เหมือนกรณียังไม่ครบนาที เพราะถ้าตัวกั้นพังแล้วปล่อยผ่าน คำขอทั้งหมดจะไหลไปถึงฐานข้อมูลพร้อมกันในจังหวะที่ระบบกำลังมีปัญหาอยู่แล้ว เสียบันทึก online ไปนาทีหนึ่ง ดีกว่าทำให้ฐานข้อมูลล่มตาม
ตั้งค่า Redis ให้เป็น cache จริง ๆ
redis-server --maxmemory 128mb --maxmemory-policy allkeys-lru --save "" --appendonly no
- จำกัดหน่วยความจำ 128MB บนเครื่อง 2GB ที่ต้องแบ่งให้ฐานข้อมูลและแอปด้วย
- allkeys-lru เมื่อเต็ม ให้ทิ้ง key ที่ไม่ได้ใช้นานที่สุด แทนที่จะ error
- ไม่บันทึกลงดิสก์ ทุกอย่างใน Redis ของเราสร้างใหม่ได้ ข้อมูลจริงอยู่ในฐานข้อมูล ไม่ต้องเสีย I/O ไปเขียนดิสก์
ข้อผิดพลาดที่เจอบ่อย
- cache ไม่มี TTL ข้อมูลผิดค้างอยู่ตลอดไปจนกว่าจะมีคนสังเกต
- ลืมลบ cache ตอนข้อมูลเปลี่ยน สำหรับเรื่องที่ต้องมีผลทันที
- จำ error เป็นคำตอบว่าไม่มี ฐานข้อมูลสะดุดไม่กี่วินาที ผู้ใช้ถูกล็อกไปเท่า TTL
- cache ค่าที่ไม่ครบ แล้วต้องไปถามฐานข้อมูลเพิ่มอยู่ดี
- ใช้ Redis เก็บข้อมูลที่หายไม่ได้ ทั้งที่ตั้งค่าไว้เป็น cache ที่ทิ้งของได้
คำถามที่พบบ่อย
ถ้า Redis ล่มล่ะ
ระบบเราตั้ง Redis เป็นที่เก็บชั่วคราวล้วน ไม่บันทึกลงดิสก์ ข้อมูลจริงอยู่ใน PostgreSQL ถ้า Redis เริ่มใหม่ cache ว่าง คำขอช่วงแรกจะไปถึงฐานข้อมูลแล้วเติม cache กลับเอง ส่วนสถานะไม้ที่เปิดอยู่ ฝั่งต้นทางส่งมาใหม่ทุกไม่กี่วินาทีอยู่แล้ว
TTL ควรยาวแค่ไหน
ยาวเท่าที่ยอมให้ข้อมูลเก่าได้ ถามว่า "ถ้าค่านี้เปลี่ยน ผู้ใช้รอได้นานสุดกี่วินาที" คำตอบนั้นคือ TTL สูงสุด แล้วเรื่องที่รอไม่ได้เลย ให้ลบ cache ตอนเปลี่ยน
คำสั่งและค่าตั้งในบทความทดสอบบนระบบตัวอย่าง ลองบนเครื่องทดสอบก่อนใช้กับระบบจริงเสมอ