Caching

Caching เป็นการ optimize ประสิทธิภาพที่ใช้กันทั่วไป ที่ผลลัพธ์ก่อนหน้าของการดำเนินการบางอย่างถูกเก็บไว้เพื่อให้ request ที่ตามมาสามารถใช้ค่าที่เก็บไว้นี้แทนที่จะเสียเวลาและทรัพยากรในการคำนวณค่านั้นใหม่

ตัวอย่างเช่น ลองพิจารณา microservice Recommendation ที่ต้องตรวจสอบระดับสต็อกก่อนแนะนำสินค้า—ไม่มีประโยชน์ที่จะแนะนำสิ่งที่เราไม่มีในสต็อก! แต่เราได้ตัดสินใจเก็บสำเนาของระดับสต็อกไว้ในพื้นที่ใน Recommendation (รูปแบบหนึ่งของ client-side caching) เพื่อพัฒนา latency ของการดำเนินการของเรา—เราหลีกเลี่ยงความจำเป็นในการตรวจสอบระดับสต็อกทุกครั้งที่เราต้องแนะนำอะไรบางอย่าง แหล่งข้อมูลที่แท้จริงสำหรับระดับสต็อกคือ microservice Inventory ซึ่งถือว่าเป็น origin สำหรับ client cache ใน microservice Recommendation เมื่อ Recommendation ต้องการค้นหาระดับสต็อก มันสามารถดูใน local cache ของตัวเองก่อน ถ้าพบ entry ที่ต้องการ นี่ถือว่าเป็น cache hit ถ้าไม่พบข้อมูล นั่นคือ cache miss ซึ่งส่งผลให้ต้องดึงข้อมูลจาก microservice Inventory downstream เนื่องจากข้อมูลใน origin สามารถเปลี่ยนแปลงได้แน่นอน เราจำเป็นต้องมีวิธี invalidate entry ใน cache ของ Recommendation เพื่อให้เรารู้เมื่อข้อมูลที่ cache ไว้ในพื้นที่นั้นเก่าเกินไปจนใช้ไม่ได้อีกต่อไป

Cache สามารถเก็บผลลัพธ์ของการค้นหาแบบง่ายๆ อย่างในตัวอย่างนี้ แต่จริงๆ แล้วมันสามารถเก็บข้อมูลชิ้นใดก็ได้ เช่น ผลลัพธ์ของการคำนวณที่ซับซ้อน เราสามารถ cache เพื่อช่วยพัฒนาประสิทธิภาพของระบบเราในฐานะส่วนหนึ่งของการช่วยลด latency เพื่อ scale แอปพลิเคชันของเรา และในบางกรณีแม้แต่เพื่อพัฒนาความทนทานของระบบของเรา เมื่อรวมกับความจริงที่ว่ามีกลไก invalidation หลายอย่างที่เราสามารถใช้ได้ และหลายที่ที่เราสามารถ cache ได้ นั่นหมายความว่าเรามีหลายแง่มุมที่ต้องพูดถึงเมื่อพูดถึง caching ในสถาปัตยกรรม microservice มาเริ่มด้วยการพูดถึงประเภทของปัญหาที่ cache ช่วยได้กัน

For Performance (เพื่อประสิทธิภาพ)

ด้วย microservice เรามักกังวลเกี่ยวกับผลกระทบเชิงลบของ network latency และต้นทุนของการต้อง interact กับ microservice หลายตัวเพื่อได้ข้อมูลบางอย่าง การดึงข้อมูลจาก cache สามารถช่วยได้มากในที่นี้ เพราะเราหลีกเลี่ยงความจำเป็นในการทำ network call ซึ่งก็ยังมีผลในการลดโหลดบน microservice downstream ด้วย นอกเหนือจากการหลีกเลี่ยง network hop มันยังลดความจำเป็นในการสร้างข้อมูลในทุก request ลองพิจารณาสถานการณ์ที่เรากำลังขอรายการสินค้าที่นิยมที่สุดตาม genre นี่อาจเกี่ยวข้องกับ join query ที่แพงในระดับฐานข้อมูล เราสามารถ cache ผลลัพธ์ของ query นี้ หมายความว่าเราจะต้องสร้างผลลัพธ์ใหม่แค่ตอนที่ข้อมูลที่ cache ไว้ถูก invalidate เท่านั้น

For Scale (เพื่อการ Scale)

ถ้าคุณสามารถเบี่ยงเบนการอ่านไปยัง cache ได้ คุณสามารถหลีกเลี่ยง contention บนส่วนต่างๆ ของระบบของคุณเพื่อให้มัน scale ได้ดีขึ้น ตัวอย่างของสิ่งนี้ที่เราได้ครอบคลุมไปแล้วในบทนี้คือการใช้ database read replica traffic การอ่านถูกให้บริการโดย read replica ลดโหลดบน primary database node และช่วยให้การอ่านสามารถ scale ได้อย่างมีประสิทธิภาพ การอ่านบน replica ทำกับข้อมูลที่อาจเก่า Read replica จะถูกอัปเดตในที่สุดโดยการ replication จาก primary ไปยัง replica node—cache invalidation รูปแบบนี้ถูกจัดการโดยอัตโนมัติโดยเทคโนโลยีฐานข้อมูล

ในภาพกว้างขึ้น caching เพื่อ scale มีประโยชน์ในสถานการณ์ใดก็ตามที่ origin เป็นจุด contention การวาง cache ไว้ระหว่าง client และ origin สามารถลดโหลดบน origin ช่วยให้มัน scale ได้ดีขึ้น

For Robustness (เพื่อความทนทาน)

ถ้าคุณมีชุดข้อมูลทั้งหมดพร้อมใช้งานใน local cache คุณมีศักยภาพที่จะทำงานได้แม้ origin จะใช้งานไม่ได้—นี่ในทางกลับกันสามารถพัฒนาความทนทานของระบบของคุณได้ มีบางสิ่งที่ควรสังเกตเกี่ยวกับ caching เพื่อความทนทาน สิ่งหลักคือคุณอาจต้องตั้งค่ากลไก cache invalidation ของคุณให้ไม่ evict ข้อมูลเก่าโดยอัตโนมัติ และเก็บข้อมูลไว้ใน cache จนกว่ามันจะถูกอัปเดตได้ มิฉะนั้น เมื่อข้อมูลถูก invalidate มันจะถูกลบออกจาก cache ส่งผลให้เกิด cache miss และความล้มเหลวในการได้ข้อมูลใดๆ เพราะ origin ใช้งานไม่ได้ สิ่งนี้หมายความว่าคุณต้องพร้อมที่จะอ่านข้อมูลที่อาจเก่ามากถ้า origin ออฟไลน์ ในบางสถานการณ์นี่อาจโอเค ในขณะที่บางสถานการณ์อาจเป็นปัญหาอย่างมาก

โดยพื้นฐานแล้ว การใช้ local cache เพื่อทำให้เกิดความทนทานในสถานการณ์ที่ origin ใช้งานไม่ได้ หมายความว่าคุณกำลังเลือก availability มากกว่า consistency

เทคนิคหนึ่งที่ผมเคยเห็นใช้ที่ Guardian และในภายหลังก็ที่อื่นด้วย คือการ crawl เว็บไซต์ "live" ที่มีอยู่เป็นระยะๆ เพื่อสร้างเวอร์ชัน static ของเว็บไซต์ที่สามารถใช้บริการในกรณีเกิด outage ได้ แม้ว่าเวอร์ชันที่ crawl มานี้จะไม่สดใหม่เท่าเนื้อหาที่ cache จากระบบ live แต่ในยามฉุกเฉิน มันสามารถมั่นใจได้ว่าจะมีเวอร์ชันของไซต์แสดงผลอยู่

Where to Cache (จะ Cache ที่ไหน)

ตามที่เราได้ครอบคลุมหลายครั้งแล้ว microservice ให้ตัวเลือกแก่คุณ และนี่ก็เป็นจริงอย่างแน่นอนกับ caching เรามีที่ที่แตกต่างกันมากมายที่เราสามารถ cache ได้ ตำแหน่ง cache ที่แตกต่างกันที่ผมจะระบุไว้ที่นี่มี trade-off ที่แตกต่างกัน และประเภทของการ optimize ที่คุณกำลังพยายามทำจะชี้ทางไปยังตำแหน่ง cache ที่สมเหตุสมผลที่สุดสำหรับคุณ

เพื่อสำรวจตัวเลือก caching ของเรา มาทบทวนสถานการณ์ที่เราดูไปก่อนหน้าใน "Data Decomposition Concerns" ที่เรากำลังดึงข้อมูลเกี่ยวกับยอดขายใน MusicCorp ใน Figure 13-9 microservice Sales เก็บ record ของสินค้าที่ถูกขายไป มันติดตามแค่ ID ของสินค้าที่ขายและ timestamp ของการขายเท่านั้น บางครั้ง เราต้องการถาม microservice Sales สำหรับรายการสินค้าขายดี 10 อันดับแรกในช่วงเจ็ดวันที่ผ่านมา

ปัญหาคือ microservice Sales ไม่รู้ชื่อของ record แค่ ID เท่านั้น มันไม่ค่อยมีประโยชน์ที่จะบอกว่า "สินค้าขายดีที่สุดในสัปดาห์นี้มี ID 366548 และเราขายได้ 35,345 ชุด!" เราต้องการรู้ชื่อของ CD ที่มี ID 366548 ด้วย microservice Catalog เก็บข้อมูลนั้น หมายความว่า ตามที่ Figure 13-9 แสดง เมื่อตอบสนอง request สำหรับสินค้าขายดี 10 อันดับแรก microservice Sales ต้องขอชื่อของ 10 ID อันดับแรก มาดูกันว่า caching อาจช่วยเราได้อย่างไร และประเภทของ cache ไหนที่เราสามารถใช้ได้

bms2 1309

Figure 13-9. ภาพรวมของวิธีที่ MusicCorp คำนวณหาสินค้าขายดี

Client-side (ฝั่ง Client)

ด้วย client-side caching ข้อมูลถูก cache นอกขอบเขตของ origin ในตัวอย่างของเรา สิ่งนี้สามารถทำได้ง่ายๆ เช่นการมี in-memory hashtable ที่ map ระหว่าง ID และชื่ออัลบั้มไว้ภายใน process Sales ที่กำลังรันอยู่ ตามใน Figure 13-10 นี่หมายความว่าการสร้างสินค้าขายดี 10 อันดับของเราไม่ต้องเกี่ยวข้องกับ Catalog เลย โดยสมมติว่าเราได้ cache hit สำหรับทุกการค้นหาที่เราต้องทำ สำคัญที่ต้องสังเกตว่า client cache ของเราอาจเลือก cache แค่บางส่วนของข้อมูลที่เราได้จาก microservice ตัวอย่างเช่น เราอาจได้ข้อมูลกลับมามากมายเกี่ยวกับ CD เมื่อเราถามข้อมูลเกี่ยวกับมัน แต่ถ้าสิ่งที่เราสนใจคือแค่ชื่ออัลบั้ม นั่นคือทั้งหมดที่เราต้องเก็บใน local cache ของเรา

bms2 1310

Figure 13-10. Sales เก็บสำเนาข้อมูล Catalog ไว้ในพื้นที่

โดยทั่วไป client-side cache มักมีประสิทธิภาพมาก เพราะมันหลีกเลี่ยง network call ไปยัง microservice downstream สิ่งนี้ทำให้มันเหมาะสมไม่เพียงแค่สำหรับ caching เพื่อพัฒนา latency แต่ยังเพื่อ caching เพื่อความทนทานด้วย

Client-side caching มีข้อเสียบางอย่าง อย่างแรก คุณมักถูกจำกัดมากขึ้นในตัวเลือกเกี่ยวกับกลไก invalidation—สิ่งที่เราจะสำรวจเพิ่มเติมในไม่ช้า อย่างที่สอง เมื่อมี client-side caching เกิดขึ้นมาก คุณสามารถเห็นความไม่สอดคล้องกันในระดับหนึ่งระหว่าง client ลองพิจารณาสถานการณ์ที่ microservice Sales , Recommendation และ Promotions ทั้งหมดมี client-side cache ของข้อมูลจาก Catalog เมื่อข้อมูลใน Catalog เปลี่ยนแปลง กลไก invalidation ใดก็ตามที่เรามีแนวโน้มจะใช้ไม่สามารถรับประกันได้ว่าข้อมูลจะถูก refresh ในเวลาที่แน่นอนเดียวกันในแต่ละ client ทั้งสามตัวนี้ นี่หมายความว่าคุณอาจเห็นมุมมองที่แตกต่างกันของข้อมูล cache ในแต่ละ client เหล่านั้นในเวลาเดียวกัน ยิ่งคุณมี client มากเท่าไร นี่ก็ยิ่งเป็นปัญหามากขึ้นเท่านั้น เทคนิคอย่าง notification-based invalidation ซึ่งเราจะดูในไม่ช้า สามารถช่วยลดปัญหานี้ได้ แต่จะไม่กำจัดมันไปเลย

อีกวิธีบรรเทาปัญหานี้คือการมี shared client-side cache บางทีอาจใช้เครื่องมือ caching เฉพาะทางอย่าง Redis หรือ memcached ตามที่เราเห็นใน Figure 13-11 ที่นี่ เราหลีกเลี่ยงปัญหาความไม่สอดคล้องกันระหว่าง client ที่แตกต่างกัน สิ่งนี้ยังอาจมีประสิทธิภาพมากกว่าในแง่การใช้ทรัพยากร เพราะเรากำลังลดจำนวนสำเนาของข้อมูลนี้ที่เราต้องจัดการ (cache มักจบลงด้วยการอยู่ในหน่วยความจำ และหน่วยความจำมักเป็นข้อจำกัดด้าน infrastructure ที่ใหญ่ที่สุดอย่างหนึ่ง) ข้อเสียคือ client ของเราตอนนี้ต้องทำ round trip ไปยัง shared cache

bms2 1311

Figure 13-11. Consumer หลายตัวของ Catalog ใช้ shared cache ตัวเดียว

อีกสิ่งหนึ่งที่ควรพิจารณาที่นี่คือใครมีความรับผิดชอบสำหรับ shared cache นี้ ขึ้นอยู่กับว่าใครเป็นเจ้าของและมันถูก implement อย่างไร shared cache แบบนี้สามารถทำให้เส้นแบ่งระหว่าง client-side caching และ server-side caching เบลอได้ ซึ่งเราจะสำรวจต่อไป

Server-side (ฝั่ง Server)

ใน Figure 13-12 เราเห็นตัวอย่างยอดขาย 10 อันดับแรกของเราใช้ caching ฝั่ง server ที่นี่ microservice Catalog เองรักษา cache ในนามของ consumer ของมัน เมื่อ microservice Sales ทำ request สำหรับชื่อของ CD ข้อมูลนี้ถูกให้บริการอย่างโปร่งใสโดย cache

ที่นี่ microservice Catalog มีความรับผิดชอบเต็มที่ในการจัดการ cache เนื่องจากธรรมชาติของวิธีที่ cache เหล่านี้มักถูก implement—เช่น โครงสร้างข้อมูลในหน่วยความจำ หรือ caching node เฉพาะทางในพื้นที่—จึงง่ายกว่าที่จะ implement กลไก cache invalidation ที่ซับซ้อนกว่า Write-through cache ตัวอย่างเช่น (ที่เราจะดูในไม่ช้า) จะ implement ได้ง่ายกว่ามากในสถานการณ์นี้ การมี server-side cache ยังทำให้ง่ายขึ้นในการหลีกเลี่ยงปัญหากับ consumer ที่แตกต่างกันเห็นค่า cache ที่แตกต่างกัน ซึ่งสามารถเกิดขึ้นได้กับ client-side caching

ควรสังเกตว่า แม้ว่าจากมุมมองของ consumer caching นี้จะมองไม่เห็น (มันเป็นเรื่องของ implementation ภายใน) นั่นไม่ได้หมายความว่าเราต้อง implement สิ่งนี้ด้วยการ cache ในโค้ดภายใน microservice instance เราสามารถ ตัวอย่างเช่น รักษา reverse proxy ไว้ภายในขอบเขต logical ของ microservice ของเรา ใช้ Redis node ที่ซ่อนอยู่ หรือเบี่ยงเบน query การอ่านไปยัง read replica ของฐานข้อมูล

bms2 1312

Figure 13-12. Catalog implement caching ภายใน ทำให้มันมองไม่เห็นสำหรับ consumer

ปัญหาหลักกับ caching รูปแบบนี้คือมันมีขอบเขตที่ลดลงในการ optimize สำหรับ latency เพราะ round trip โดย consumer ไปยัง microservice ยังคงต้องทำอยู่ ด้วยการ cache ที่หรือใกล้กับขอบเขตของ microservice cache สามารถมั่นใจได้ว่าเราไม่จำเป็นต้องทำการดำเนินการที่แพงเพิ่มเติม (เช่น database query) แต่ call นั้นยังต้องทำอยู่ดี สิ่งนี้ยังลดประสิทธิภาพของ caching รูปแบบนี้สำหรับความทนทานรูปแบบใดๆ ด้วย

สิ่งนี้อาจทำให้ caching รูปแบบนี้ดูมีประโยชน์น้อยลง แต่มันมีคุณค่ามหาศาลในการพัฒนาประสิทธิภาพอย่างโปร่งใสสำหรับ consumer ทั้งหมดของ microservice เพียงแค่ตัดสินใจ implement caching ภายใน microservice ที่ถูกใช้อย่างกว้างขวางทั่วองค์กรอาจได้ประโยชน์มหาศาลจากการ implement caching ภายในรูปแบบใดรูปแบบหนึ่ง ช่วยพัฒนา response time สำหรับ consumer หลายตัวในขณะที่ยังให้ microservice scale ได้อย่างมีประสิทธิภาพมากขึ้น

ในกรณีของสถานการณ์สินค้าขายดี 10 อันดับแรกของเรา เราต้องพิจารณาว่า caching รูปแบบนี้อาจช่วยได้หรือไม่ การตัดสินใจของเราจะขึ้นอยู่กับว่าความกังวลหลักของเราคืออะไร ถ้าเป็นเรื่อง end-to-end latency ของการดำเนินการ server-side cache จะประหยัดเวลาได้แค่ไหน? Client-side caching น่าจะให้ประโยชน์ด้านประสิทธิภาพที่ดีกว่าเรา

Request cache (Cache ของ Request)

ด้วย request cache เราเก็บคำตอบที่ cache ไว้สำหรับ request ดั้งเดิม ดังนั้นใน Figure 13-13 ตัวอย่างเช่น เราเก็บ entry สินค้าขายดี 10 อันดับที่แท้จริง Request ที่ตามมาสำหรับสินค้าขายดี 10 อันดับแรกส่งผลให้ผลลัพธ์ที่ cache ไว้ถูกส่งคืน ไม่ต้องค้นหาในข้อมูล Sales ไม่ต้อง round trip ไปยัง Catalog —นี่คือ cache ที่มีประสิทธิภาพที่สุดอย่างมากในแง่การ optimize เพื่อความเร็ว

bms2 1313

Figure 13-13. การ cache ผลลัพธ์ของ request สินค้าขายดี 10 อันดับแรก

ประโยชน์ที่นี่ชัดเจน นี่มีประสิทธิภาพสูงมาก อย่างหนึ่งเลย อย่างไรก็ตาม เราต้องตระหนักว่า caching รูปแบบนี้เจาะจงมาก เราได้ cache แค่ผลลัพธ์ของ request เฉพาะนี้เท่านั้น นี่หมายความว่าการดำเนินการอื่นๆ ที่ไปที่ Sales หรือ Catalog จะไม่ได้ hit cache และดังนั้นจะไม่ได้ประโยชน์อะไรเลยจาก optimization รูปแบบนี้

Invalidation (การ Invalidate)

There are only two hard things in Computer Science: cache invalidation and naming things.

Phil Karlton

Invalidation คือกระบวนการที่เรา evict ข้อมูลออกจาก cache ของเรา มันเป็นไอเดียที่เรียบง่ายในแนวคิดแต่ซับซ้อนในการปฏิบัติ ถ้าไม่ใช่ด้วยเหตุผลอื่นก็เพราะมีตัวเลือกมากมายในแง่ของวิธี implement มัน และมี trade-off มากมายที่ต้องพิจารณาในแง่การใช้ข้อมูลที่อาจเก่า โดยพื้นฐานแล้ว มันขึ้นอยู่กับการตัดสินใจว่าในสถานการณ์ไหนที่ข้อมูล cache ชิ้นหนึ่งควรถูกลบออกจาก cache ของคุณ บางครั้งสิ่งนี้เกิดขึ้นเพราะเราถูกบอกว่ามีข้อมูลเวอร์ชันใหม่พร้อมใช้งาน บางครั้งก็อาจต้องการให้เราสมมติว่าสำเนาที่ cache ไว้ของเราเก่าแล้วและดึงสำเนาใหม่จาก origin

เมื่อพิจารณาตัวเลือกเกี่ยวกับ invalidation ผมคิดว่าเป็นความคิดที่ดีที่จะดูตัวเลือกไม่กี่อย่างที่คุณสามารถใช้ในสถาปัตยกรรม microservice โปรดอย่ามองว่านี่เป็นภาพรวมที่ครอบคลุมทุกตัวเลือกนะ!

Time to live (TTL)

เป็นหนึ่งในกลไกที่ง่ายที่สุดที่จะใช้สำหรับ cache invalidation แต่ละ entry ใน cache ถูกสมมติว่า valid แค่ในช่วงเวลาหนึ่งเท่านั้น หลังจากเวลานั้นผ่านไป ข้อมูลจะถูก invalidate และเราดึงสำเนาใหม่ เราสามารถระบุระยะเวลาความ valid โดยใช้ระยะเวลา time to live (TTL) ง่ายๆ—ดังนั้น TTL ห้านาทีหมายความว่า cache ของเราจะยินดีให้ข้อมูล cache ได้นานถึงห้านาที หลังจากนั้น entry ที่ cache ไว้จะถือว่าถูก invalidate และต้องการสำเนาใหม่ รูปแบบต่างๆ บนแนวคิดนี้อาจรวมถึงการใช้ timestamp สำหรับการหมดอายุ ซึ่งในบางสถานการณ์อาจมีประสิทธิภาพมากกว่า โดยเฉพาะถ้าคุณกำลังอ่านผ่าน cache หลายระดับ

HTTP รองรับทั้ง TTL (ผ่าน header Cache-Control ) และความสามารถในการตั้ง timestamp สำหรับการหมดอายุผ่าน header Expires บน response ซึ่งมีประโยชน์อย่างมาก นี่หมายความว่า origin เองสามารถบอก client downstream ได้ว่าพวกเขาควรสมมติว่าข้อมูลสดใหม่นานแค่ไหน กลับมาที่ microservice Inventory ของเรา เราอาจนึกภาพสถานการณ์ที่ microservice Inventory ให้ TTL ที่สั้นกว่าสำหรับระดับสต็อกของสินค้าที่ขายเร็ว หรือสำหรับสินค้าที่เราเกือบหมดสต็อก สำหรับสินค้าที่เราขายไม่ค่อยมาก มันสามารถให้ TTL ที่ยาวกว่าได้ นี่แสดงถึงการใช้ HTTP cache control ในระดับที่ค่อนข้างขั้นสูง และการปรับแต่ง cache control เป็นรายตัว response แบบนี้เป็นสิ่งที่ผมจะทำก็ต่อเมื่อกำลังปรับแต่งประสิทธิภาพของ cache TTL แบบเดียวที่ใช้กับทุกอย่างสำหรับ resource type ที่กำหนดเป็นจุดเริ่มต้นที่สมเหตุสมผล

แม้ว่าคุณจะไม่ได้ใช้ HTTP ไอเดียของ origin ที่ให้คำใบ้แก่ client เกี่ยวกับวิธี (และว่าควร) cache ข้อมูลหรือไม่นั้นเป็นแนวคิดที่มีพลังมาก นี่หมายความว่าคุณไม่ต้องเดาเกี่ยวกับสิ่งเหล่านี้ฝั่ง client คุณสามารถตัดสินใจได้อย่างมีข้อมูลรองรับจริงๆ ว่าจะจัดการข้อมูลชิ้นหนึ่งอย่างไร

HTTP มีความสามารถ caching ขั้นสูงกว่านี้ และเราจะดู conditional GET เป็นตัวอย่างของสิ่งนั้นในอีกสักครู่

หนึ่งในความท้าทายของ TTL-based invalidation คือแม้ว่ามันจะ implement ได้ง่าย มันก็เป็นเครื่องมือที่ค่อนข้างหยาบ ถ้าเราขอสำเนาข้อมูลใหม่ที่มี TTL ห้านาที และหนึ่งวินาทีต่อมาข้อมูลที่ origin เปลี่ยนแปลง cache ของเราจะทำงานกับข้อมูลที่เก่าไปแล้วตลอดสี่นาทีห้าสิบเก้าวินาทีที่เหลือ ดังนั้นความง่ายของการ implement ต้องสมดุลกับว่าคุณทนทานได้แค่ไหนต่อการทำงานกับข้อมูลที่เก่า

Conditional GETs

ควรค่าแก่การกล่าวถึง เพราะมันมักถูกมองข้าม คือความสามารถในการส่ง conditional GET request ด้วย HTTP ตามที่เราเพิ่งพูดถึง HTTP ให้ความสามารถในการระบุ header Cache-Control และ Expires บน response เพื่อให้ client-side caching ที่ฉลาดขึ้น แต่ถ้าเราทำงานกับ HTTP โดยตรง เรามีตัวเลือกอีกอย่างในคลังแสง HTTP ของเรา: entity tag หรือ ETag ETag ถูกใช้เพื่อตรวจสอบว่าค่าของ resource เปลี่ยนแปลงหรือไม่ ถ้าผมอัปเดต record ของลูกค้า URI ไปยัง resource นั้นจะเหมือนเดิม แต่ค่าจะแตกต่างกัน ดังนั้นผมจะคาดหวังให้ ETag เปลี่ยน สิ่งนี้ทรงพลังเมื่อเราใช้สิ่งที่เรียกว่า conditional GET เมื่อทำ GET request เราสามารถระบุ header เพิ่มเติม บอกให้ service ส่ง resource ให้เราแค่ถ้าเงื่อนไขบางอย่างถูกเจอ

ตัวอย่างเช่น ลองนึกภาพว่าเราดึง record ของลูกค้า และ ETag ของมันคืนกลับมาเป็น o5t6fkd2sa ในภายหลัง บางทีเพราะ directive Cache-Control บอกเราว่า resource ควรถือว่าเก่าแล้ว เราต้องการมั่นใจว่าเราได้เวอร์ชันล่าสุด เมื่อส่ง GET request ที่ตามมา เราสามารถส่ง If-None-Match: o5t6fkd2sa นี่บอก server ว่าเราต้องการ resource ที่ URI ที่ระบุ เว้นแต่มันตรงกับค่า ETag นี้อยู่แล้ว ถ้าเรามีเวอร์ชันล่าสุดอยู่แล้ว service จะส่ง response 304 Not Modified บอกเราว่าเรามีเวอร์ชันล่าสุด ถ้ามีเวอร์ชันใหม่กว่าให้ใช้ เราจะได้ 200 OK พร้อม resource ที่เปลี่ยนแปลงและ ETag ใหม่สำหรับ resource

แน่นอน ด้วย conditional GET เรายังคงทำ request จาก client ไปยัง server ถ้าคุณกำลัง cache เพื่อลด network round trip นี่อาจไม่ได้ช่วยคุณมากนัก ที่มันมีประโยชน์คือในการหลีกเลี่ยงต้นทุนของการสร้าง resource ใหม่โดยไม่จำเป็น ด้วย TTL-based invalidation client จะขอสำเนาใหม่ของ resource แม้ว่า resource นั้นจะไม่เปลี่ยนแปลง—microservice ที่รับ request นี้ก็ต้องสร้าง resource นั้นใหม่ แม้ว่ามันจะจบลงเหมือนกับสิ่งที่ client มีอยู่แล้วทุกประการ ถ้าต้นทุนของการสร้าง response สูง บางทีอาจต้องใช้ชุด database query ที่แพง conditional GET request ก็สามารถเป็นกลไกที่มีประสิทธิภาพได้

Notification-based (แบบใช้ Notification)

ด้วย notification-based invalidation เราใช้ event เพื่อช่วยให้ subscriber รู้ว่า local cache entry ของพวกเขาต้องถูก invalidate หรือไม่ ในความคิดของผม นี่คือกลไก invalidation ที่สง่างามที่สุด แม้ว่าจะต้องถ่วงดุลด้วยความซับซ้อนที่ค่อนข้างมากเมื่อเทียบกับ TTL-based invalidation

ใน Figure 13-14 microservice Recommendation ของเรากำลังรักษา cache ฝั่ง client Entry ใน cache นั้นถูก invalidate เมื่อ microservice Inventory ยิง event Stock Change ให้ Recommendation (หรือ subscriber อื่นๆ ของ event นี้) รู้ว่าระดับสต็อกเพิ่มขึ้นหรือลดลงสำหรับสินค้าที่กำหนด

bms2 1314

Figure 13-14. Inventory ยิง event Stock Change ซึ่ง Recommendation สามารถใช้เพื่ออัปเดต local cache ของตัวเอง

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

ข้อเสียที่นี่คือความซับซ้อนของ implementation เราต้องการให้ origin สามารถส่ง notification ได้ และเราต้องการให้ฝ่ายที่สนใจสามารถตอบสนองต่อ notification เหล่านี้ได้ ทีนี้ นี่เป็นที่ที่เหมาะจะใช้บางอย่างเช่น message broker เพราะโมเดลนี้เข้ากันได้ดีกับ interaction แบบ pub/sub ทั่วไปที่ broker หลายตัวมอบให้ การรับประกันเพิ่มเติมที่ broker อาจให้เราได้ก็อาจเป็นประโยชน์เช่นกัน ถึงอย่างนั้น ตามที่เราได้พูดถึงไปแล้วใน "Message Brokers" มี overhead ในการจัดการ messaging middleware และมันอาจมากเกินไปถ้าคุณใช้มันแค่เพื่อจุดประสงค์นี้เท่านั้น อย่างไรก็ตาม ถ้าคุณกำลังใช้ broker สำหรับรูปแบบอื่นๆ ของการสื่อสารระหว่าง microservice มันก็สมเหตุสมผลที่จะใช้เทคโนโลยีที่คุณมีอยู่แล้ว

ปัญหาหนึ่งที่ควรตระหนักเมื่อใช้ notification-based invalidation คือคุณอาจต้องการรู้ว่ากลไก notification กำลังทำงานจริงหรือไม่ ลองพิจารณาสถานการณ์ที่เราไม่ได้รับ event Stock Changed ใดๆ จาก Inventory มาสักพักแล้ว นั่นหมายความว่าเราไม่ได้ขายสินค้าหรือมีสินค้าถูกเติมสต็อกในช่วงนั้นหรือ? อาจจะ มันก็อาจหมายความว่ากลไก notification ของเราล่มและเราไม่ได้รับการอัปเดตอีกต่อไปแล้ว ถ้านี่เป็นความกังวล เราก็สามารถส่ง heartbeat event ผ่านกลไก notification เดียวกันได้— Recommendation ในกรณีของเรา—เพื่อให้ subscriber รู้ว่า notification ยังคงมาอยู่ แต่ไม่มีอะไรเปลี่ยนแปลงจริงๆ ถ้าไม่ได้รับ heartbeat event client สามารถสมมติว่ามีปัญหาและสามารถทำสิ่งที่เหมาะสมที่สุด—บางทีอาจแจ้งผู้ใช้ว่าพวกเขากำลังเห็นข้อมูลเก่า หรือบางทีก็แค่ปิดฟังก์ชันการทำงาน

คุณยังต้องพิจารณาว่า notification มีอะไรอยู่ด้วย ถ้า notification แค่บอกว่า "สิ่งนี้เปลี่ยนแปลงแล้ว" โดยไม่บอกว่าการเปลี่ยนแปลงคืออะไร เมื่อได้รับ notification consumer ก็จะต้องไปที่ origin เพื่อดึงข้อมูลใหม่ ในทางกลับกัน ถ้า notification มีสถานะปัจจุบันของข้อมูล consumer ก็สามารถโหลดมันเข้า local cache ของพวกเขาได้โดยตรง การมี notification ที่มีข้อมูลมากขึ้นอาจก่อให้เกิดปัญหาเรื่องขนาด และยังมีความเสี่ยงที่อาจเปิดเผยข้อมูลที่ละเอียดอ่อนมากเกินไปด้วย เราได้สำรวจ trade-off นี้อย่างละเอียดกว่านี้ไปแล้วตอนที่ดู event-driven communication ใน "What's in an Event?"

Write-through

ด้วย write-through cache cache จะถูกอัปเดตพร้อมกันกับสถานะที่ origin "พร้อมกัน" เป็นจุดที่ write-through cache ยุ่งยาก แน่นอน การ implement กลไก write-through บน server-side cache ค่อนข้างตรงไปตรงมา เพราะคุณสามารถอัปเดตฐานข้อมูลและ in-memory cache ภายใน transaction เดียวกันได้โดยไม่ยากมากนัก ถ้า cache อยู่ที่อื่น มันยากกว่าที่จะให้เหตุผลว่า "พร้อมกัน" หมายถึงอะไรในแง่ของ entry เหล่านี้ที่ถูก อัปเดต

เนื่องจากความยากลำบากนี้ คุณจะมักเห็น write-through caching ถูกใช้ในสถาปัตยกรรม microservice ฝั่ง server ประโยชน์นั้นชัดเจนมาก—หน้าต่างที่ client อาจเห็นข้อมูลเก่าสามารถถูกกำจัดได้แทบทั้งหมด สิ่งนี้ถูกถ่วงดุลด้วยความจริงที่ว่า server-side cache อาจมีประโยชน์โดยทั่วไปน้อยกว่า จำกัดสถานการณ์ที่ write-through cache จะมีประสิทธิภาพใน microservice

Write-behind

ด้วย write-behind cache ตัว cache เองจะถูกอัปเดต ก่อน แล้วจึง origin ถูกอัปเดตทีหลัง ในทางแนวคิด คุณสามารถคิดว่า cache เป็น buffer การเขียนลงใน cache เร็วกว่าการอัปเดต origin ดังนั้นเราเขียนผลลัพธ์ลง cache ทำให้การอ่านที่ตามมาเร็วขึ้น และเชื่อใจว่า origin จะถูกอัปเดตในภายหลัง

ความกังวลหลักเกี่ยวกับ write-behind cache คือศักยภาพในการสูญเสียข้อมูล ถ้า cache เองไม่ durable เราอาจสูญเสียข้อมูลก่อนที่ข้อมูลจะถูกเขียนไปยัง origin นอกจากนี้ ตอนนี้เราอยู่ในจุดที่น่าสนใจ—อะไรคือ origin ในบริบทนี้? เราคาดหวังว่า origin จะเป็น microservice ที่ข้อมูลนี้มาจาก—แต่ถ้าเราอัปเดต cache ก่อน นั่นเป็น origin จริงหรือ? อะไรคือแหล่งข้อมูลที่แท้จริงของเรา? เมื่อใช้ caching สำคัญที่ต้องแยกแยะว่าข้อมูลไหนถูก cache (และอาจเก่า) และข้อมูลไหนที่ถือว่าเป็นข้อมูลล่าสุดจริงๆ Write-behind cache ในบริบทของ microservice ทำให้เรื่องนี้ไม่ชัดเจนขึ้นมาก

แม้ว่า write-behind cache มักถูกใช้เพื่อ optimize ภายใน process ผมเคยเห็นมันถูกใช้น้อยกว่ามากในสถาปัตยกรรม microservice ส่วนหนึ่งเป็นเพราะรูปแบบ caching อื่นๆ ที่ตรงไปตรงมากว่าก็ดีพออยู่แล้ว แต่ส่วนใหญ่เป็นเพราะความซับซ้อนของการจัดการการสูญเสียข้อมูล cache ที่ยังไม่ได้เขียน

The Golden Rule of Caching (กฎทองของ Caching)

ระวังการ cache ในที่มากเกินไป! ยิ่งมี cache ระหว่างคุณกับแหล่งข้อมูลสดใหม่มากเท่าไร ข้อมูลก็ยิ่งอาจเก่ามากเท่านั้น และยิ่งยากขึ้นที่จะระบุความสดใหม่ของข้อมูลที่ client เห็นในที่สุด มันยังอาจ ยาก ขึ้นในการให้เหตุผลว่าข้อมูลต้อง invalidate ที่ไหน trade-off เกี่ยวกับ caching—การถ่วงดุลความสดใหม่ของข้อมูลกับการ optimize ระบบของคุณสำหรับโหลดหรือ latency—เป็นเรื่องละเอียดอ่อน และถ้าคุณไม่สามารถให้เหตุผลได้ง่ายๆ ว่าข้อมูลสดใหม่แค่ไหน (หรือไม่) สิ่งนี้จะยากขึ้น

ลองพิจารณาสถานการณ์ที่ microservice Inventory กำลัง cache ระดับสต็อก request ไปยัง Inventory สำหรับระดับสต็อกอาจถูกให้บริการจาก server-side cache นี้ เร่งความเร็ว request ตามนั้น ทีนี้ มาสมมติกันว่าเราตั้ง TTL สำหรับ internal cache นี้ไว้ที่หนึ่งนาที หมายความว่า server-side cache ของเราอาจล้าหลังระดับสต็อกจริงได้ถึงหนึ่งนาที ทีนี้ ปรากฏว่าเรากำลัง cache ฝั่ง client ภายใน Recommendation ด้วย ที่ซึ่งเราก็ใช้ TTL หนึ่งนาทีเช่นกัน เมื่อ entry ใน client-side cache หมดอายุ เราทำ request จาก Recommendation ไปยัง Inventory เพื่อได้ระดับสต็อกที่ล่าสุด แต่โดยที่เราไม่รู้ตัว request ของเราไป hit server-side cache ซึ่งในจุดนี้อาจเก่าไปแล้วถึงหนึ่งนาทีเช่นกัน ดังนั้นเราอาจจบลงด้วยการเก็บ record ใน client-side cache ของเราที่เก่าไปแล้วถึงหนึ่งนาทีตั้งแต่แรก นี่หมายความว่าระดับสต็อกที่ Recommendation กำลังใช้อาจเก่าไปได้ถึง สอง นาที แม้ว่าจากมุมมองของ Recommendation เราจะคิดว่ามันอาจเก่าไปแค่หนึ่งนาทีเท่านั้น

มีหลายวิธีในการหลีกเลี่ยงปัญหาแบบนี้ การใช้การหมดอายุแบบอิง timestamp เป็นจุดเริ่มต้นจะดีกว่า TTL แต่มันก็เป็นตัวอย่างของสิ่งที่เกิดขึ้นเมื่อ caching ถูกซ้อนกันอย่างมีประสิทธิภาพ ถ้าคุณ cache ผลลัพธ์ของการดำเนินการที่ในทางกลับกันก็อิงกับ input ที่ cache ไว้ คุณจะชัดเจนได้แค่ไหนว่าผลลัพธ์สุดท้ายสดใหม่แค่ไหน?

กลับมาที่คำพูดที่มีชื่อเสียงจาก Knuth ก่อนหน้านี้ premature optimization สามารถทำให้เกิดปัญหาได้ Caching เพิ่มความซับซ้อน และเราต้องการเพิ่มความซับซ้อนให้น้อยที่สุดเท่าที่จะเป็นไปได้ จำนวนที่เหมาะสมของที่ที่ควร cache คือศูนย์ อะไรก็ตามที่มากกว่านั้นควรเป็นการ optimize ที่คุณ ต้อง ทำ—แต่ต้องตระหนักถึงความซับซ้อนที่มันสามารถนำมาได้

Tip

มอง caching เป็นหลักว่าเป็นการ optimize ประสิทธิภาพ Cache ในที่ให้น้อยที่สุดเท่าที่จะทำได้เพื่อให้ง่ายขึ้นในการให้เหตุผลเกี่ยวกับความสดใหม่ของข้อมูล

Freshness Versus Optimization (ความสดใหม่ เทียบกับ การ Optimize)

กลับมาที่ตัวอย่างของเราเกี่ยวกับ TTL-based invalidation ผมได้อธิบายก่อนหน้านี้ว่าถ้าเราขอสำเนาข้อมูลใหม่ที่มี TTL ห้านาที และหนึ่งวินาทีต่อมาข้อมูลที่ origin เปลี่ยนแปลง cache ของเราจะทำงานกับข้อมูลที่เก่าไปแล้วตลอดสี่นาทีห้าสิบเก้าวินาทีที่เหลือ ถ้านี่ยอมรับไม่ได้ ทางแก้อย่างหนึ่งคือลด TTL ลง จึงลดระยะเวลาที่เราอาจทำงานกับข้อมูลเก่า ดังนั้นบางทีเราอาจลด TTL ลงเหลือหนึ่งนาที นี่หมายความว่าหน้าต่างของความเก่าของเราลดลงเหลือหนึ่งในห้าของที่เคยเป็น แต่เราก็เรียกไปยัง origin มากขึ้นห้าเท่า ดังนั้นเราต้องพิจารณาผลกระทบด้าน latency และโหลดที่เกี่ยวข้อง

การถ่วงดุลแรงเหล่านี้ขึ้นอยู่กับความเข้าใจ requirement ของ end user และของระบบที่กว้างขึ้น ผู้ใช้ย่อมต้องการทำงานกับข้อมูลที่สดใหม่ที่สุดเสมอ แต่ไม่ใช่ถ้านั่นหมายความว่าระบบจะพังลงภายใต้โหลด ในทำนองเดียวกัน บางครั้งสิ่งที่ปลอดภัยที่สุดที่ควรทำคือปิดฟีเจอร์ถ้า cache ล้มเหลว เพื่อหลีกเลี่ยง overload บน origin ที่ก่อให้เกิดปัญหาร้ายแรงมากขึ้น เมื่อพูดถึงการปรับแต่งอย่างละเอียดว่าจะ cache อะไร ที่ไหน และอย่างไร คุณมักจะพบว่าตัวเองต้องถ่วงดุลไปตามหลายแกน นี่เป็นอีกเหตุผลหนึ่งที่ควรพยายามให้สิ่งต่างๆ ง่ายที่สุดเท่าที่จะทำได้—ยิ่ง cache น้อยเท่าไร ก็ยิ่งง่ายที่จะให้เหตุผลเกี่ยวกับระบบ

Cache Poisoning: A Cautionary Tale (Cache Poisoning: นิทานเตือนใจ)

ด้วย caching เรามักคิดว่าถ้าเราทำผิดพลาด สิ่งที่แย่ที่สุดที่จะเกิดขึ้นคือเราให้บริการข้อมูลเก่าไปสักพัก แต่จะเกิดอะไรขึ้นถ้าคุณจบลงด้วยการให้บริการข้อมูลเก่าตลอดไป? ย้อนกลับไปใน Chapter 12 ผมแนะนำ AdvertCorp ที่ผมกำลังทำงานเพื่อช่วย migrate แอปพลิเคชัน legacy ที่มีอยู่หลายตัวไปยังแพลตฟอร์มใหม่โดยใช้ strangler fig pattern สิ่งนี้เกี่ยวข้องกับการสกัดกั้น call ไปยังแอปพลิเคชัน legacy หลายตัว และที่ซึ่งแอปพลิเคชันเหล่านี้ถูกย้ายไปยังแพลตฟอร์มใหม่แล้ว การเบี่ยงเบน call แอปพลิเคชันใหม่ของเราทำงานอย่างมีประสิทธิภาพในฐานะ proxy Traffic สำหรับแอปพลิเคชัน legacy เก่าที่เรายังไม่ได้ migrate ถูก route ผ่านแอปพลิเคชันใหม่ของเราไปยังแอปพลิเคชัน legacy downstream สำหรับ call ไปยังแอปพลิเคชัน legacy เราทำสิ่งบำรุงรักษาเล็กๆ น้อยๆ บางอย่าง ตัวอย่างเช่น เรามั่นใจว่าผลลัพธ์จากแอปพลิเคชัน legacy มี HTTP cache header ที่เหมาะสมถูกใส่ไว้

วันหนึ่ง ไม่นานหลังจากการ release ตามปกติ มีบางอย่างแปลกๆ เริ่มเกิดขึ้น มี bug ถูกนำเข้ามาที่ทำให้ page กลุ่มเล็กๆ หลุดผ่านเงื่อนไข logic ในโค้ดใส่ cache header ของเรา ส่งผลให้เราไม่ได้เปลี่ยน header เลย น่าเสียดายที่แอปพลิเคชัน downstream นี้ก็ถูกเปลี่ยนไปแล้วก่อนหน้านี้ให้รวม HTTP header Expires: Never ไว้ด้วย สิ่งนี้ไม่เคยมีผลอะไรมาก่อน เพราะเรากำลัง override header นี้อยู่ ตอนนี้เราไม่ได้ override แล้ว

แอปพลิเคชันของเราใช้ Squid อย่างหนักในการ cache HTTP traffic และเราสังเกตเห็นปัญหาค่อนข้างเร็ว เพราะเราเห็น request มากขึ้นที่ bypass Squid เองเพื่อไป hit application server ของเรา เราแก้โค้ด cache header และ push release ออกไป และเรายังล้าง region ที่เกี่ยวข้องของ Squid cache ด้วยตัวเอง อย่างไรก็ตาม นั่นยังไม่พอ

ตามที่เราเพิ่งพูดถึง คุณสามารถ cache ในหลายที่—แต่บางครั้งการมี cache มากมายทำให้ชีวิตยากขึ้น ไม่ใช่ง่ายขึ้น เมื่อพูดถึงการให้บริการเนื้อหาแก่ผู้ใช้ของแอปพลิเคชันเว็บที่เปิดสู่สาธารณะ คุณอาจมี cache หลายตัวระหว่างคุณกับลูกค้าของคุณ ไม่เพียงแค่คุณอาจนำเว็บไซต์ของคุณไปไว้หลัง content delivery network เท่านั้น ISP บางตัวก็ใช้ caching ด้วย คุณควบคุม cache เหล่านั้นได้ไหม? และแม้คุณจะควบคุมได้ ก็มี cache หนึ่งที่คุณควบคุมได้น้อยมาก: cache ใน browser ของผู้ใช้

Page เหล่านั้นที่มี Expires: Never ติดอยู่ใน cache ของผู้ใช้จำนวนมากของเรา และจะไม่ถูก invalidate จนกว่า cache จะเต็มหรือผู้ใช้ล้างมันด้วยตัวเอง เห็นได้ชัดว่าเราไม่สามารถทำให้สิ่งใดสิ่งหนึ่งเกิดขึ้นได้ ตัวเลือกเดียวของเราคือเปลี่ยน URL ของ page เหล่านี้เพื่อให้พวกมันถูกดึงใหม่

Caching สามารถทรงพลังได้อย่างแท้จริง แต่คุณต้องเข้าใจเส้นทางทั้งหมดของข้อมูลที่ถูก cache จากแหล่งที่มาไปยังปลายทางเพื่อเข้าใจความซับซ้อนของมันอย่างแท้จริงและสิ่งที่อาจผิดพลาดได้