Photo by Maksym Kaharlytskyi on Unsplash

ปรับแต่ง APIs ให้เร็วขึ้น, รองรับผู้ใช้มากขึ้น ผ่าน LRU Cache ด้วย Strapi Headless CMS

Max Veerapat Kumchom
7 min readJun 13, 2020

--

สวัสดีครับผมแมกซ์นะครับ 😋 สวัสดีวันหยุดสุดสัปดาห์ วันที่นอนตื่นสายๆ 🤤 เลยได้เวลาปล่อยของ บทความนี้จะมาแชร์อีกแล้ว อย่างต่อเนื่อง เป็นบทความต่อจากรอบที่แล้ว ที่ได้แชร์เครื่องมือที่น่าสนใจชื่อว่า Strapi ไปแล้ว 🤓 สำหรับใครยังไม่ได้อ่านบทความนั้น มาก่อน ก็เรียนเชิญไปแวะดูมาสักเล็กน้อยก่อน จะได้ไม่งงว่า บทความนี้มันมาพูดอะไรกัน ไม่เห็นจะรู้เรื่องเลย 😳 ลิ้งอยู่ข้างล่างเลยครับ สำหรับใครที่พร้อมแล้ว ก็ตามมาครับ (บทความนี้จะเน้นไปในด้านการใช้บน Strapi แต่ว่าใครที่ไม่ได้ใช้ ก็ศึกษาเป็นไอเดีย ไปปรับใช้กับระบบอื่นๆได้ครับ) 🤔

ก่อนจะเริ่มกันเหมือนเคย 😎 บทความนี้จะมาพูดถึงเรื่องการทำ Caching ระดับ API เป็นสิ่งสำคัญ ที่ผู้พัฒนา API หรือ Backend ควรจะทำความรู้จักไว้ เพราะสิ่งนี้สามารถทำให้ API ของเรานั้น มีประสิทธิภาพมากขึ้น 🧐 โดยมีหลายๆครั้ง ในกลุ่ม Facebook ต่างๆ มีผู้ตั้งคำถาม ทำนองว่า “ออกแบบระบบยังไงให้คนเข้าเยอะๆแล้วไม่ล่ม” 🤔 โดยก็มีผู้ที่มีความรู้มาช่วยกันให้คำตอบกันมากมาย โดยทั้งหมดนั้น ก็มีเทคนิคมากมายหลายวิธี ไม่ว่าจะเป็นการ ทำ Load Balance, Queue, Infra, Scaling และอื่นๆ ซึ่ง Cache ก็เป็นหนึ่งในนั้น 😮

Cache คืออะไร สำคัญอย่างไร? คือส่วนของข้อมูล ที่ถูกเก็บซ้ำไว้จากข้อมูลหลัก เพื่อใช้ในการเรียกใช้ครั้งต่อๆไป โดยไม่จำเป็นต้องเรียกจากข้อมูลหลัก เพื่อประหยัดเวลาในการเข้าถึงข้อมูล และประหยัดทรัพยากรการประมวณผล 🤗 โดยจุดประสงค์เพื่อ เพิ่มความเร็วการเข้าถึงข้อมูลให้มากขึ้น 😎 โดยจะมีคำศัพท์สองคำคือ Cache hit และ Cache miss ก็คือ เจอของใน Cache และไม่เจอนั่นเอง ไปดูตัวอย่างประกอบ

ผู้บริโภค → สั่งของ → โรงงานผลิตหลัก → ส่งของ → ผู้บริโภค

ผู้บริโภค (Client) ทำการสั่งของ (Request) ไปยัง โรงงานผลิตหลัก (Backend) จากนั้นทำการส่งของกลับไปให้ผู้บริโภค (Client) 🤑

จากนั้นสินค้าได้รับความนิยมมาก 😻 ทำให้มีการสั่งของ เข้ามาจำนวนมากและส่งผลให้การขนส่งล่าช้า (Response time) สูงนั่นเอง 😲 ผู้บริหารจึงเพิ่มโกดัง (Cache) นำสินค้าที่ถูกสั่งบ่อยๆ เก็บตามต่างจังหวัด ทำให้การขนส่งทำได้รวดเร็วมากขึ้น กรณีมีสินค้าในโกดัง (Cache) ก็สามารถส่งของให้ลูกค้าได้เลย (Cache hit)โดยไม่ต้องส่งคำสั่งมายัง โรงงานผลิตหลัก (Backend) นั่นเอง 🎃

https://aws.amazon.com/th/blogs/database/caching-for-performance-with-amazon-documentdb-and-amazon-elasticache/

ในมุมมองของ API 🤖

  • แบบทั่วไป เมื่อ User ทำการ GET มาที่ Backend ของเรา เพื่อขอข้อมูล GET /articles?_limit=10 บทความ 10 อัน Backend เราก็ทำการประมวลผลคำขอ แล้วทำการไป Query ของใน Database ให้และส่งผลลัพธ์กลับไปยัง User ✌️

ตรงนี้จะสังเกตได้ว่า Backend ของเรานั้น ต้องใช้ทรัพยากร CPU ประมวลผลคำสั่งแกะ Request มาอ่าน ผ่าน Logic ต่างๆ ไป Query ของใน Database ซึ่งเป็น Hard disk ที่ใช้เวลาอ่านมาก 👎 และส่งของกลับไปยัง User สมมุติว่าใช้เวลาทั้งกระบวนการเท่ากับ 100 ms และเมื่อมีผู้ใช้อื่นๆ เข้ามาขออีกก็จะทำกระบวนการเดิมซ้ำๆ ส่งกลับผลลัพธ์เดิมๆกลับไป แล้วมันมีวิธีที่ดีกว่านี้ไหม ที่ทำให้เราได้ผลลัพธ์เร็วกว่านี้ 🤷‍♀️

  • แบบใช้ Cache เมื่อ User คนแรกทำการ GET มาที่ Backend ของเรา เหมือนในครั้งแรกเลย /articles?_limit=10 บทความ 10 อัน Backend ที่มีระบบ Cache จะไปทำการค้นหาของใน Cache ก่อน ซึ่งเก็บอยู่บน Ram เป็นลักษณะ Key-value ก็จะเกิด Cache miss เพราะยังไม่เคยบันทึกอะไรเลย 🙅‍♂️จากนั้นทำการ Query ของใน Database ตามปกติ ส่งผลลัพธ์กลับไปยัง User และทำการบันทึกผลลัพธ์นั้นลง Cache ไว้ด้วย สมมุติว่าใช้เวลาทั้งกระบวนการเท่ากับ 110 ms มากกว่าแบบทั่วไปเดิมด้วยซ้ำ เพราะเสีย CPU ไปกับการ อ่าน/เขียน Cache เห้ย! 👊

จุดที่น่าสนใจมันอยู่ต่อจากนี้ เมื่อ User คนที่สอง, ที่สาม และต่อๆไปทำการ ส่งคำขอแบบเดียวกัน มายัง Backend ก็จะทำการค้นหาของใน Cache ก่อน ซึ่งรอบนี้ต่างออกไป เพราะก่อนหน้า เคยมีการทำการบันทึกผลลัพธ์ของคนแรกไว้ จึงทำให้เกิด Cache hit Backend สามารถนำของบน Cache ส่งผลลัพธ์กลับไปยัง User ได้เลย 👍 ซึ่งเวลาทั้งกระบวนการ อาจจะเท่ากับ 10 ms เลย เพราะเสียทรัพยากรน้อยกว่า และไม่ได้แตะ Database ซึ่งเป็น Hard disk เลย เพียงหยิบข้อมูลบน Ram ไปตอบ 😽

Strapi LRU Caching middleware

This middleware caches incoming GET requests on the strapi API, based on query params and model ID. The cache is automatically busted everytime a PUT, POST, or DELETE request comes in.

โดยของที่ผมจะมาพาเล่นวันนี้ก็คือ strapi-middleware-cache ตัวนี้นี่เอง โดยหลังจากที่เราทำความรู้จักการ Caching มาแล้ว 🥱 จะเห็นได้ว่า แบบนี้ User หลายๆคนที่ขอ GET มาต่อจากคนแรกก็ได้ ก็ได้ข้อมูลเร็วจริงถูก 👍 แต่ปัญหาคือถ้ามี User อื่นๆ เขียนบทความใหม่เข้ามา POST ข้อมูลบน Database ถูกอัพเดทแล้ว ข้อมูลจาก Cache ก็เก่าแล้วสิ เราจะให้ข้อมูลเก่ากลับไปเป็นผลลัพธ์ไม่ได้แล้วผิด 🙅 ใช่แล้วครับ

ตัว strapi-middleware-cache จึงมีวิธีในการจัดการ Cache ที่จะเก็บ Key โดยใช้ Query params และ Model ID ในที่นี้ ก็จะคือ /articles?_limit=10 และ Value เป็นผลลัพธ์บทความ 10 อันไว้ จากนั้น ถ้ามีใครมีทำการ PUT, POST, DELETE ซึ่งส่งผลต่อข้อมูล ก็จะทำการ Bust หรือทำการลบ Cache ที่เกี่ยวข้องอัตโนมัติ 👨‍🔧 ทำให้การขอ GET ถัดไปได้ข้อมูลที่สดใหม่ถูกต้อง

Strapi Middleware แบบ LRU Caching เพิ่มเติมให้ 🧑‍🏫 Middleware คือส่วนที่เข้ามาแทรกกลาง ระหว่างคำขอของ User ก่อนเข้าไปถึง Controller, Service, Model นั่นเอง ทำให้เกิดกระบวนเช็คของใน Cache เกิดขึ้นก่อนได้

💁‍♂ ใช้กลยุทธ์แบบ LRU (Least Recently Used) คือ Key ไหนที่เก็บใน Cache ถูกใช้น้อยจะได้ Score ต่ำและถูกลบออกไปก่อน คล้ายการจัดอันดับเพลงฮิต เพลงไหนที่ฮิตก็จะอยู่ในตาราง Top 20 เพลงไหนไม่ฮิตแล้วก็จะตกอันดับและหลุดตารางไป 🎶

🤨 เพราะอะไร? การออกแบบระบบ Caching นั้นจะต้องมี Cache replacement policies นโยบายของการแทนที่หรือลบทิ้งนั่นเอง Cache ควรจำกัดจำนวน Key และ Value ที่เราจะเก็บ 🤩 เพราะถ้าเก็บไว้ทุกอย่าง ไม่จัดลำดับความสำคัญ ก็จะทำให้สิ้นเปลือง Ram และสร้างปัญหาถัดไปได้ อาจจะทำให้ Cache เรามีขนาดใหญ่กว่า Database จริงๆของเราไปอีก ซึ่งไม่ OK 🙅‍♀️

อย่าโม้เลยเริ่มสักที 55 ไปกัน เริ่มโปรเจค Strapi ไปลุยกัน 🧑‍💻

yarn create strapi-app my-strapi-v3 --quickstart

หลังจากนั้นผมก็ทำการสร้าง Collection type article ที่มี Field เป็น title และ body เพื่อที่จะใช้เก็บบทความ และข้างในประกอบด้วย ชื่อเรื่องและเนื้อหา 🤴

หลังจากสร้างเสร็จ จะได้ Collection เปล่าๆ ผมจึงใช้ faker.js สร้างข้อมูลบทความหลอกๆ ขึ้นมา 1,000 บทความ เพื่อจะทำการทดสอบ ได้ข้อมูลดังนี้ 📥

ได้บทความแล้ว โดยแต่ละบทความมีข้อมูลดังนี้ 📨

หลังจากนั้นทดสอบขอข้อมูล ผ่าน GET ด้วย cURL ไป 🦾

curl http://localhost:1337/articles
curl http://localhost:1337/articles\?_limit\=-1

อ่าน Log ของ Strapi จะเห็นได้ว่า Request แบบแรกขอทั้งหมด 100 บทความ อยู่ที่ประมาณ 18-27 ms ส่วนแบบที่สองคือส่ง limit เป็น -1 ก็คือขอทั้งหมด 1,000 บทความอยู่ที่ประมาณ 43-60 ms 👀

จากนั้นทำการติดตั้ง strapi-middleware-cache

yarn add strapi-middleware-cachetouch config/middleware.js

ตาม Setup ก็จะพาเราสร้างไฟล์ config/middleware.js และทำการตั้งค่าให้เรียบร้อย โดย settings จะชื่อว่า cache ทำการตั้งค่า ✍️

module.exports = ({ env }) => ({
settings: {
cache: {
enabled: true,
models: ["articles"],
},
},
});

enabled คือ การเปิดใช้ให้เป็น true
models คือ ส่วนที่เราต้องการจะ cache ในที่นี้คือ articles

จากนั้น Start ระบบกลับมาอีกครั้ง 🤠

yarn develop

จะปรากฏข้อความใน Log ดังนี้ 👻

debug [Cache] Mounting LRU cache middleware
debug [Cache] Storage engine: mem
debug [Cache] Caching route /articles/:id* [maxAge=3600000]

เท่ากับว่า ตอนนี้เราได้ทำการใช้ LRU cache middleware แล้ว โดยที่ Storage engine: mem และ Caching route /articles/:id* [maxAge=3600000] ทำการ Cache ทั้งหมดบน Route /articles ไปทดสอบกันว่ามันเร็วขึ้นแค่ไหนกัน 🤭

curl http://localhost:1337/articles
curl http://localhost:1337/articles\?_limit\=-1

ก็มาดิครับ! 🙀 จะเห็นได้ว่า Request แบบแรกของเก่าอยู่ที่ประมาณ 18–27 ms ของใหม่เพิ่ม Cache ขอ 1 ms พอ 🤫 ส่วนแบบที่สองคือส่ง limit เป็น -1 ของเก่าอยู่ที่ประมาณ 43–60 ms ของใหม่ 2–3 ms พอครับ 😳 จะสังเกตได้ว่า ตามหลักการที่คุยกันมาตั้งยาวในตอนแรกคือ Cache จะช้าเฉพาะครั้งแรกและครั้งต่อๆไป มันเร็วมาก

ว้าวกันพอหรือยัง? ต่อจากนี้จะ Deep dive ลงไปอีก 🤯 เพราะเจ้า strapi-middleware-cache เนี้ย สามารถให้เราเปลี่ยน Storage engine เป็น Redis ซึ่งเป็น Key–value database แบบ In-memory ที่นี้เราจะได้ไปดูกันว่า มันเก็บอะไรไว้ใน Cache บ้าง ผมขอสร้าง docker-compose.yml เพื่อจะสร้าง redis-server ขึ้นมา

docker-compose up -d

เรียบร้อยเราได้ redis-server มาแล้ว 🤗 จากนั้นต้อง ทำการตั้งค่าเพิ่มเติมเล็กน้อย

ไฟล์ config/middleware.js ได้ทำการเพิ่มเติม เปลี่ยนแปลงดังนี้ ✍️

module.exports = ({ env }) => ({
settings: {
cache: {
enabled: true,
type: "redis",
max: 500,
maxAge: 3600000,
redisConfig: {
host: "localhost",
port: 6379,
password: "123456",
db: 0,
},
models: ["articles"],
},
},
});

type คือ Storage engine จากเดิมไม่ได้กำหนด เปลี่ยนเป็น redis
max คือ จำนวน key ที่เราจะทำการเก็บ cache (อันดับเพลงฮิตนั่นเอง)
maxAge คือ เวลาหมดอายุของ cache หน่วย milliseconds กำหนดไว้ 1 ชม.
redisConfig คือ การตั้งค่าเชื่อมต่อ redis ของเรา

จากนั้น Start ระบบกลับมาอีกครั้ง 🥰

yarn develop

จะปรากฏข้อความใน Log ดังนี้

debug [Cache] Mounting LRU cache middleware
debug [Cache] Storage engine: redis
debug [Cache] Caching route /articles/:id* [maxAge=3600000]
debug [Cache] Redis connection established

ตอนนี้เราได้ทำการใช้ LRU cache middleware แล้ว โดยที่ Storage engine: redis 😎 และทำการ Redis connection established สมบูรณ์แล้วมาลองทดสอบกันเหมือนเดิม 🤖

ก็จะได้ผลลัพธ์ความเร็วดังนี้ 😻 ดูเหมือนว่าถ้าใช้ Redis จะไม่ได้ความเร็วที่เวอร์วังเหมือนกับ mem ธรรมดา อาจจะติด Overhead เรื่องการ Connection ไปยัง Redis แต่ไม่ใช่ประเด็นใหญ่ 🤫 เพราะยังไงเร็วกว่าไม่มี Cache แน่นอน แถม Redis ยังสามารถทำให้เรารัน Strapi แบบ Cluster หลายตัว และ เชื่อมไปยัง Redis ที่ทำ Redis Sentinel หรือ Redis Cluster ได้อีก ก็ต้องแลกมา 😹 เราจะมาดูกันต่อที่เราสงสัยว่ามันเก็บอะไรใน Cache โดยใช้ RedisInsight

พอเข้ามาสำรวจข้างใน Redis ก็จะพบว่า มีตารางเก็บ Key, Score ที่ผมเคยพูดถึงไป จะเรียงลำดับสิ่งที่ถูกขอ จากมากไปน้อย แบบ LRU Caching 👽

ส่วนในของ Key ที่เป็น /articles ที่เราขอเข้ามา ก็จะมี Value ที่เป็น Response Body เก็บเป็น JSON ไว้เลย เพื่อที่จะใช้เอาไว้ตอบ ตอน User ถัดไปขอเข้ามา เป็นอย่างนี้นี่เอง 👍

ยังไม่จบ เราต้องทดสอบมันทุกกระบวนการ ที่เราสงสัยว่า 🧐 ถ้ามีคนสร้างบทความใหม่ขึ้นมาละ Cache พวกนี้ควรที่จะถูก Bust ทิ้งไป เพื่อที่จะไม่ให้ User ที่เข้ามาขอได้ของที่เก่าไปใช้ ซึ่งไม่ถูก

curl -H 'Content-Type: application/json' \
-d '{"title": "new article", "body": "article body"}' \
-X POST http://localhost:1337/articles

หลังจาก POST ได้บทความใหม่ที่ id 1001 มาดูที่ Redis กัน 😏

บูมหายเกลี้ยงเลย 😧 อ่าาา แสดงว่าครั้งต่อไปในการขอ ก็จะทำให้เกิด Cache miss และต้องไปหยิบของใน Database นั่นเอง เท่านี้ก็แก้ปัญหา Cache ไม่อัพเดทแล้ว แล้วมันเกิดขึ้นได้ยังไงหล่ะ?

สงสัยต่อ? 🥵 เป็น Developer ที่ดี ก่อนที่เราจะเอาอะไรมาใช้ ต้องเข้าใจมันให้สุด ลงไปดู Source Code ตัว strapi-middleware-cache เข้าไปดูยัน node_modules ครับงานนี้ ขี้สงสัยจริง 😋

หลักการทำงานโดยคราวๆ เห็นได้ชัดว่าถ้า User ทำการ DELETE, POST, PUT มาละก็ จะมี Callback ในการ Bust Cache เหล่านั้นซะ เพราะข้อมูลมันอัพเดทแล้ว 🤔

ส่วนถ้ามีการ GET จะ generateCacheKey โดยใช้ model และ ctx เป็น cacheKey จากนั้น cache.get ด้วย cacheKey เช็คถ้าเจอในบรรทัด 90 Cache hit ส่ง 200 พร้อม cacheEntry ข้อมูลจาก Cache ตอบ User ไปเลยจ้า return จบ ที่มาของความเร็ว 🦿

และถ้า Cache miss หล่ะ next ส่งต่อ Request ไปในส่วนของ Middleware หรือ Controller ถัดไป ในบรรทัด 96 👉

หลังจากนั้น ถ้าจบกระบวนการให้เช็คว่ามี body และ status เป็น 200 รึเปล่า ถ้าใช่ ละก็ cache.set เก็บลง Cache จ้า 👇 ด้วย cacheKey, ctx.body และ maxAge ทำให้การขอครั้งต่อไปด้วย Key นี้ก็จะเกิด Cache hit

มาลอง Load Testing เปรียบเทียบกันหน่อยว่าเป็นอย่างไร ส่งท้ายด้วยการนำ wrk — a HTTP benchmarking tool มาลองยิง แบบธรรมดาและแบบมี Cache 🤖

wrk -t 4 -c 10 -d 5s http://localhost:1337/articles

ข้างบนเป็นแบบธรรมดานะครับ ✌️ ข้างล่างเปลี่ยนเป็นใช้งาน Cache บน Redis 😻

Latency Avg: 85.03 ms
Requests/Sec: 93.20 ครั้ง

Latency Avg: 17.32 ms
Requests/Sec: 460.29 ครั้ง

แตกต่างกันอย่างมากทั้ง 🙏 เวลาเฉลี่ยและจำนวนการตอบกลับภายใน 1 วินาที ที่ทำได้รองรับจาก 93.20 คน → 460.29 คน ต่างกัน 4-5 เท่าตัวได้เลย 😱

นั่นแหละครับท่านผู้ชม ก็น่าจะอินและเห็นประโยชน์ของการใช้ Cache ขึ้นมาบ้างแล้ว ทั้งการจบ Connection ให้เร็วและใช้ทรัพยากรให้น้อยๆ ที่ผู้รู้หลายๆ ท่านอธิบายไว้ 😘 วันนี้ผมเข้าใจเลย หลักใจความสำคัญนี้ 🤓 ยิ่งถ้า Service ของคุณต้องไปเรียก Microservice อื่นๆ อีกด้วยละก็ การใช้ Cache โคตรแก้ปัญหาได้เลย เพราะแทบจะไม่ต้องเสีย Latency วิ่งไปที่อื่นเลย ก็สามารถตอบผลลัพธ์เหล่านั้นได้

แต่ตรงนี้ก็จะต้องมาคิดกันต่อว่า เราจะ Bust Cache ตอนไหนกันดี ก็เป็นอีกโจทย์ที่ต้องมาคิดกันต่อ เพียงเท่านี้ ระบบของคุณก็จะเร็วขึ้น, รองรับผู้ใช้มากขึ้น ตามที่โม้ไว้ในหัวข้อแล้ว ก็ต้องบอกอีกรอบว่าผมค่อนข้างใหม่สำหรับการสร้าง Backend API เอามากๆ ไปเจอวิธีการแบบนี้ถึงกะร้อง 😲 แถมยังใช้กับ Strapi ได้ด้วย ถ้าตรงไหนอธิบายผิดพลาดไป ก็ขออภัยด้วยนะครับ 🙏 ฝากปรบมือเป็นกำลังใจด้วยนะครับ 😝

ก่อนจากขอเรียนเชิญเข้า Facebook Group ด้วยกันครับ รวมกันเป็น Community เอาไว้ถามปัญหา อัพเดทข้อมูลข่าวสารต่างๆครับ 2 กลุ่มเลย ตอนนี้เริ่มมีเพื่อนๆ เข้ามากันแล้วขอบคุณครับ

👨‍💻 Strapi Thailand 🚀

👩‍💻 Headless CMS Thailand 🗿

--

--