HTTP Caching สำหรับ API: ใช้ Cache-Control และ ETag ให้ประหยัดแบนด์วิดท์
API ของคุณอาจส่ง JSON เดิมหลายพันครั้งต่อวัน ลูกค้าขอ GET /v1/products/42 และได้รับข้อมูล 18 KB จากนั้นอีกห้านาทีถัดมาก็ขอข้อมูลเดิมอีกครั้ง แม้ไม่มีอะไรเปลี่ยนแปลง คุณยังต้องจ่ายค่าแบนด์วิดท์ การแปลงข้อมูล และการอ่านฐานข้อมูลอยู่ดี
HTTP มีเครื่องมือแก้ปัญหานี้อยู่แล้ว:
-
Cache-Controlกำหนดว่าการตอบสนองจะสดใหม่ได้นานแค่ไหน -
ETagทำหน้าที่เป็นลายนิ้วมือของทรัพยากร -
If-None-Matchช่วยให้ไคลเอนต์ตรวจสอบการเปลี่ยนแปลงโดยไม่ต้องดาวน์โหลดข้อมูลซ้ำ - เมื่อข้อมูลไม่เปลี่ยน เซิร์ฟเวอร์จะตอบ
304 Not Modifiedซึ่งไม่มี response body
แนวคิดเดียวกันนี้ใช้กับฝั่งไคลเอนต์ หากคุณอ่าน คู่มือการแคชการตอบสนอง API ใน React แล้ว บทความนี้คือส่วนฝั่งเซิร์ฟเวอร์
สามเลเยอร์ของการแคช HTTP
การแคช HTTP สำหรับ API แบ่งเป็นสามการตัดสินใจที่แยกจากกัน
1. ความสดใหม่ (Freshness)
ไคลเอนต์ใช้ response ที่แคชไว้ซ้ำได้นานแค่ไหนโดยไม่ต้องถามเซิร์ฟเวอร์:
Cache-Control: max-age=60
เป็นเวลา 60 วินาที ไคลเอนต์จะใช้สำเนาในเครื่องโดยไม่สร้าง network request ซึ่งประหยัดที่สุด แต่ก็มีความเสี่ยงที่สุด เพราะไคลเอนต์จะไม่ตรวจพบการเปลี่ยนแปลงจนกว่าเวลาจะหมดลง
2. การตรวจสอบ (Validation)
เมื่อสำเนาเก่าแล้ว ไคลเอนต์ไม่จำเป็นต้องดาวน์โหลดข้อมูลใหม่ทั้งหมด แต่จะถามว่า “มีการเปลี่ยนแปลงหรือไม่?” ด้วย fingerprint เดิม:
ETag: "33a64df551425fcc55e4d42a148795d9f2"
If-None-Match: "33a64df551425fcc55e4d42a148795d9f2"
หากข้อมูลยังเหมือนเดิม เซิร์ฟเวอร์จะตอบ 304 Not Modified โดยไม่มี body
อีกทางเลือกคือ Last-Modified คู่กับ If-Modified-Since ซึ่งอิงเวลาที่มีความละเอียดระดับวินาที และแม่นยำน้อยกว่า ETag
3. การทำให้เป็นโมฆะ (Invalidation)
เมื่อข้อมูลเปลี่ยน สำเนาเก่าจะหมดอายุอย่างไร?
- แคชส่วนตัวของไคลเอนต์หมดอายุตาม
max-age - แคชร่วมและ CDN อาจต้องใช้ explicit purge
- ใช้ TTL ที่สั้นลง
- ใช้
stale-while-revalidateเพื่อจำกัดระยะเวลาที่อนุญาตให้ใช้ข้อมูลเก่า
ความสดใหม่ช่วยประหยัดได้มากที่สุด การตรวจสอบช่วยตรวจจับการเปลี่ยนแปลง และการทำให้เป็นโมฆะช่วยรักษาความถูกต้อง API ส่วนใหญ่ต้องใช้ทั้งสามเลเยอร์
การทำงานของ 304 Not Modified
สมมติว่าเป็นเอนด์พอยต์สินค้า /v1/products/42
คำขอแรก
ไคลเอนต์ยังไม่มีข้อมูลในแคช:
GET /v1/products/42 HTTP/1.1
Host: api.example.com
เซิร์ฟเวอร์ส่งข้อมูลพร้อม metadata:
HTTP/1.1 200 OK
Cache-Control: private, max-age=60
ETag: "33a64df551425fcc55e4d42a148795d9f2"
Content-Type: application/json
Content-Length: 18432
ไคลเอนต์จะเก็บ body และ ETag ไว้ 60 วินาที ระหว่างนั้นจะไม่ติดต่อเซิร์ฟเวอร์
คำขอครั้งถัดไป
หลังจาก 60 วินาที สำเนากลายเป็น stale ไคลเอนต์จึงส่ง ETag เดิมกลับไปตรวจสอบ:
GET /v1/products/42 HTTP/1.1
Host: api.example.com
If-None-Match: "33a64df551425fcc55e4d42a148795d9f2"
หากทรัพยากรไม่เปลี่ยน เซิร์ฟเวอร์จะตอบ:
HTTP/1.1 304 Not Modified
Cache-Control: private, max-age=60
ETag: "33a64df551425fcc55e4d42a148795d9f2"
แทนที่จะส่งข้อมูล 18 KB response จะมีเพียง header ไม่กี่ร้อยไบต์ ไคลเอนต์จะทำเครื่องหมายสำเนาเดิมว่าสดใหม่อีก 60 วินาทีแล้วนำไปใช้ต่อ
ถ้าสินค้าเปลี่ยน เซิร์ฟเวอร์จะตอบ 200 OK พร้อม body และ ETag ใหม่ ดูรายละเอียดเพิ่มเติมได้จาก คำอธิบาย 304 Not Modified
304เป็นคำสั่งสำหรับแคช ไม่ใช่ข้อผิดพลาด
GET แบบมีเงื่อนไขยังต้องใช้ network round trip, การตรวจสอบสิทธิ์ และการคำนวณ ETag สิ่งที่ลดลงคือการถ่ายโอน payload และการแยกวิเคราะห์ข้อมูลฝั่งไคลเอนต์ใหม่ สำหรับเอนด์พอยต์ขนาดใหญ่ วิธีนี้อาจลดข้อมูล API ขาออกได้ 60–90 เปอร์เซ็นต์
Cache-Control ที่สำคัญสำหรับ API
ดูรายละเอียดคำสั่งทั้งหมดได้ในเอกสาร Cache-Control ของ MDN สำหรับ JSON API คำสั่งสำคัญมีดังนี้
no-store เทียบกับ no-cache
นี่คือความเข้าใจผิดที่พบบ่อยที่สุด:
-
no-storeหมายถึง ห้ามเขียน response ลงในแคชใดๆ ใช้กับข้อมูลอ่อนไหว เช่น token ข้อมูลธนาคาร หรือ PII -
no-cacheอนุญาตให้แคชเก็บ response ได้ แต่ต้องตรวจสอบกับต้นทางก่อนนำกลับมาใช้ทุกครั้ง
เมื่อใช้ no-cache คู่กับ ETag คุณยังได้ประโยชน์จาก 304 โดยไม่เสี่ยงแสดงข้อมูลเก่า ทีมที่ใช้ no-store กับทุก endpoint กำลังปิดการทำ conditional request และต้องส่ง payload เต็มทุกครั้ง
private
กำหนดให้แคชได้เฉพาะบนไคลเอนต์ของผู้ใช้ ห้ามแคชโดย shared cache หรือ CDN เหมาะกับ response ที่แตกต่างกันตามผู้ใช้ โดยเฉพาะ API ที่ต้องยืนยันตัวตน
หากไม่กำหนด private พร็อกซีที่ตั้งค่าผิดอาจส่งข้อมูลของผู้ใช้คนหนึ่งให้ผู้ใช้อีกคนได้
max-age
อายุความสดใหม่เป็นวินาที สำหรับ API ส่วนใหญ่เริ่มต้นที่ 30–300 วินาที โดยมีเป้าหมายเพื่อรองรับ traffic burst และลดการเรียกซ้ำ ไม่ใช่การเก็บข้อมูลไว้นานเป็นวัน
stale-while-revalidate
ตัวอย่าง:
Cache-Control: max-age=60, stale-while-revalidate=300
หมายถึงให้แคชเสิร์ฟสำเนาเก่าได้อีกสูงสุด 5 นาที พร้อมรีเฟรชข้อมูลเบื้องหลัง ผู้ใช้จะได้รับ response ทันที ขณะที่ต้นทางจะถูกอัปเดตตามมา CDN เช่น Cloudflare และ Fastly รวมถึงเบราว์เซอร์สมัยใหม่รองรับคำสั่งนี้
ค่าเริ่มต้นที่เหมาะสมสำหรับ read endpoint ที่ต้องยืนยันตัวตน:
Cache-Control: private, max-age=60, stale-while-revalidate=120
ETag: "9f8b2c41aa73e0d5"
ข้อกำหนดฉบับเต็มอยู่ใน RFC 9111 ซึ่งแทนที่ RFC 7234 หากพฤติกรรมของ CDN ไม่เป็นไปตามคาด ให้เริ่มตรวจสอบจาก RFC นี้
Strong ETag เทียบกับ Weak ETag
คำนำหน้า W/ ใช้แยก ETag สองประเภท
Strong ETag
ETag: "33a64df551425fcc"
รับประกันความเท่าเทียมกันแบบ byte-to-byte response ที่มี Strong ETag เดียวกันถือว่าเหมือนกันทุกไบต์ จึงเหมาะกับ byte-range request และจำเป็นสำหรับ concurrency control ผ่าน If-Match
Weak ETag
ETag: W/"33a64df551425fcc"
รับประกันเพียง semantic equivalence ไบต์อาจต่างกัน เช่น ลำดับฟิลด์หรือค่าเวลาเปลี่ยน แต่ความหมายของข้อมูลยังเหมือนเดิม จึงเหมาะกับการตรวจสอบแคช
ระวังการบีบอัดข้อมูล
Compression middleware, Nginx และบางเฟรมเวิร์กอาจเปลี่ยน Strong ETag เป็น Weak ETag เมื่อ gzip response เพราะไบต์หลังบีบอัดไม่ตรงกับต้นฉบับ
หาก concurrency check ล้มเหลวหลังผ่านพร็อกซี ให้ตรวจสอบว่ามีคำนำหน้า W/ เพิ่มเข้ามาหรือไม่
โดยทั่วไปให้ใช้ Strong ETag ที่คำนวณจาก body ซึ่งยังไม่ถูกบีบอัด และใช้ Weak ETag เมื่อข้อมูลเดียวกันอาจถูกส่งในรูปแบบไบต์ที่แตกต่างกัน
การสร้าง ETag: Body Hash หรือ Version Column
มีสองกลยุทธ์หลัก
Hash ของ response body
Serialize response แล้วคำนวณ hash:
- ใช้ MD5 หรือ SHA-1 ได้ เพราะ hash นี้เป็น fingerprint ไม่ใช่ security boundary
- แม่นยำตั้งแต่เริ่มต้น
- ไม่ต้องเปลี่ยน schema
ข้อเสียคือเซิร์ฟเวอร์ต้องสร้าง response เต็มรูปแบบและอ่านข้อมูลที่จำเป็นทุกครั้ง แม้จะตอบ 304 คุณจึงประหยัดแบนด์วิดท์ แต่ไม่จำเป็นต้องประหยัด CPU หรือ database load
Version column หรือ updated_at
สร้าง ETag จากข้อมูลที่อ่านได้ถูกและราคาถูก เช่น:
ETag: "42-v17"
conditional request จะใช้การค้นหาแบบมีอินเด็กซ์ แทนการ serialize response ทั้งหมด
ข้อควรระวังคือ version ต้องเพิ่มทุกครั้งที่ข้อมูลซึ่งส่งผลต่อ response เปลี่ยน รวมถึงข้อมูลในตารางที่เกี่ยวข้อง หากพลาด คุณอาจส่ง 304 ให้กับข้อมูลเก่า ซึ่งเป็น cache bug ที่ตรวจพบได้ยากที่สุด
แนวทางที่แนะนำ:
- เริ่มด้วย body hashing เพื่อความถูกต้อง
- ใช้ profiling หา hot endpoint
- เปลี่ยน endpoint ที่มี serialization cost สูงไปใช้ version-based ETag
Optimistic Concurrency ด้วย If-Match และ 412
ETag เดียวกับที่ช่วยลด bandwidth ในการอ่าน ยังช่วยป้องกัน lost update ในการเขียนได้ด้วย
สมมติว่าผู้ดูแลระบบสองคนโหลดสินค้าเดียวกัน:
- ผู้ดูแลระบบ A เปลี่ยนราคาและบันทึก
- ผู้ดูแลระบบ B แก้คำผิดแล้วบันทึกตามข้อมูลเก่า
- การบันทึกของ B เขียนทับราคาที่ A เพิ่งเปลี่ยน โดยไม่มีใครเห็นข้อผิดพลาด
ให้บังคับให้การอัปเดตอ้างอิง ETag ล่าสุด:
PUT /v1/products/42 HTTP/1.1
If-Match: "33a64df551425fcc55e4d42a148795d9f2"
Content-Type: application/json
เซิร์ฟเวอร์จะเปรียบเทียบ If-Match กับ ETag ปัจจุบัน:
- ตรงกัน: อัปเดตข้อมูลและตอบ
200พร้อม ETag ใหม่ - ไม่ตรงกัน: ตอบ
412 Precondition Failedและไม่แก้ข้อมูล - ไม่มี
If-Match: API ที่เข้มงวดควรตอบ428 Precondition Required
เมื่อได้ 412 ไคลเอนต์ควรดึงข้อมูลล่าสุด นำการเปลี่ยนแปลงของตนไปปรับใช้ แล้วลองใหม่ วิธีนี้เปลี่ยน silent data corruption ให้เป็น HTTP error ที่ชัดเจนและจัดการได้ ดูข้อมูลเพิ่มเติมได้จาก 412 Precondition Failed
CDN และพร็อกซีจัดการ Header เหล่านี้อย่างไร
Shared cache อยู่ระหว่างต้นทางกับไคลเอนต์ และใช้ header เดียวกันตามกฎของตัวเอง
-
privateยกเว้น response จาก CDN ส่วนs-maxage=600กำหนด TTL สำหรับ shared cache โดยเฉพาะ - CDN ส่วนใหญ่จะ revalidate กับต้นทางด้วย conditional request หากต้นทางตอบ
304CDN จะรีเฟรช metadata โดยไม่ต้องดาวน์โหลด body ใหม่ - ตรวจสอบ
Varyให้ถูกต้อง หาก endpoint เดียวกันส่งทั้ง JSON และ CSV ต้องมี:
Vary: Accept
ไม่เช่นนั้น shared cache อาจส่ง CSV ให้ไคลเอนต์ที่ต้องการ JSON
- ตรวจสอบพร็อกซีที่อาจเปลี่ยน Strong ETag เป็น Weak ETag ระหว่างบีบอัดข้อมูล
ตัวอย่าง Express
Express ตั้งค่า Weak ETag ให้โดยอัตโนมัติ แต่การจัดการเองจะช่วยให้ใช้ Strong ETag และรองรับ 412 สำหรับเส้นทางเขียนข้อมูล:
import crypto from "node:crypto";
import express from "express";
const app = express();
app.use(express.json());
function etagFor(payload) {
const hash = crypto.createHash("sha1")
.update(JSON.stringify(payload))
.digest("hex");
return `"${hash}"`;
}
app.get("/v1/products/:id", async (req, res) => {
const product = await db.products.find(req.params.id);
const etag = etagFor(product);
res.set("Cache-Control", "private, max-age=60, stale-while-revalidate=120");
res.set("ETag", etag);
if (req.get("If-None-Match") === etag) {
return res.status(304).end(); // fingerprint matches: no body
}
res.json(product);
});
app.put("/v1/products/:id", async (req, res) => {
const product = await db.products.find(req.params.id);
const currentEtag = etagFor(product);
const ifMatch = req.get("If-Match");
if (!ifMatch) {
return res.status(428).json({ error: "If-Match header required" });
}
if (ifMatch !== currentEtag) {
return res.status(412).json({ error: "Resource changed since you fetched it" });
}
const updated = await db.products.update(req.params.id, req.body);
res.set("ETag", etagFor(updated));
res.json(updated);
});
สังเกตว่า 304 ยังคงส่ง Cache-Control และ ETag ตาม RFC 9111 เพราะ response นี้ใช้สำหรับอัปเดต metadata ของสำเนาที่แคชไว้ จึงควรส่ง header ที่ไคลเอนต์ต้องใช้เพื่อรักษาความสดใหม่
ทดสอบ HTTP Caching ใน Apidog
โค้ดอาจดูถูกต้อง แต่ middleware และพร็อกซีอาจเปลี่ยนพฤติกรรมได้ ให้ทดสอบที่ระดับ HTTP โดยตรง
ใน Apidog ทำตามขั้นตอนนี้:
- ส่ง
GET /v1/products/42แล้วตรวจสอบ response headers ว่ามีETagและCache-Controlโดย ETag ต้องอยู่ในเครื่องหมายคำพูด - คัดลอกค่า ETag แล้วส่ง request เดิมอีกครั้งพร้อม header:
If-None-Match: "ค่าที่คัดลอกมา"
ควรได้ 304 พร้อม body ว่าง หากยังได้ 200 แสดงว่า validation layer ไม่ได้เปรียบเทียบ fingerprint
- เปลี่ยนข้อมูลสินค้าแล้วส่ง request ซ้ำ ยืนยันว่าได้
200พร้อม ETag ใหม่ - ทดสอบเส้นทางเขียนด้วย
PUTและ ETag ที่จงใจทำให้เก่า เช่น:
If-Match: "deadbeefcafe1234"
ควรได้ 412
เชื่อม request เหล่านี้เป็น test scenario:
- Request แรกอ่าน ETag จาก response headers แล้วเก็บในตัวแปร
- Request ที่สองส่งตัวแปรกลับเป็น
If-None-Matchและตรวจสอบ status304กับ body ว่าง - Request ที่สามส่ง
PUTด้วย ETag เก่าและยืนยัน status412
ดูไวยากรณ์เพิ่มเติมได้จาก คู่มือ API assertions
เรียกใช้ scenario นี้ใน CI แล้วการอัปเกรด middleware ที่ทำให้ ETag หายจะกลายเป็น pipeline failure แทนที่จะเป็นค่าใช้จ่ายแบนด์วิดท์ที่ค่อยๆ เพิ่มขึ้น คุณสามารถ ดาวน์โหลด Apidog และสร้าง scenario กับ endpoint ของคุณได้ทันที
คำถามที่พบบ่อย
no-cache และ no-store ต่างกันอย่างไร?
no-store ห้ามแคชโดยสมบูรณ์ ทุก request ต้องดาวน์โหลด response เต็มรูปแบบ
no-cache อนุญาตให้จัดเก็บ response ได้ แต่ต้อง revalidate ก่อนนำกลับมาใช้ทุกครั้ง เมื่อใช้คู่กับ ETag จะยังได้ 304 และประหยัด bandwidth
ใช้ no-store กับข้อมูลอ่อนไหวเท่านั้น การใช้กับทุก endpoint เป็นหนึ่งในข้อผิดพลาด Cache-Control ที่มีต้นทุนสูงที่สุด
ETag ใช้กับ POST ได้หรือไม่?
โดยทั่วไปไม่ เพราะ ETag อธิบายสถานะของทรัพยากรที่ URL และ POST มักใช้เพื่อสร้างทรัพยากรใหม่ อีกทั้งแคชมักไม่เก็บ POST response
ส่วน header ที่สำคัญสำหรับการเขียนคือ If-Match บน PUT, PATCH และ DELETE เพื่อป้องกัน lost update หากต้องการแคช POST response อาจเป็นสัญญาณว่าการดำเนินการนั้นควรออกแบบเป็น GET
304 ทำให้ API เร็วขึ้นหรือไม่?
304 ลดขนาดข้อมูลที่ถ่ายโอน แต่ไม่ได้หมายความว่าเซิร์ฟเวอร์ใช้ CPU น้อยลงเสมอไป เซิร์ฟเวอร์ยังต้องรับ request, ตรวจสอบสิทธิ์ และคำนวณ ETag
ประโยชน์หลักอยู่ที่ bandwidth, แบตเตอรี่มือถือ และเวลา render บนเครือข่ายช้า ควรวัด latency และ throughput ก่อนกับหลังใช้งาน ดูแนวทางได้จาก คู่มือการทดสอบประสิทธิภาพ API
ควรใช้ ETag หรือ Last-Modified?
หากทำได้ ให้ส่งทั้งสองอย่าง
- ETag ตรวจจับการเปลี่ยนแปลงภายในหนึ่งวินาทีและความแตกต่างของเนื้อหาได้แม่นยำกว่า
- เมื่อมีทั้งสอง header
If-None-MatchจะมีความสำคัญเหนือIf-Modified-Since -
Last-Modifiedยังมีประโยชน์เป็น fallback สำหรับไคลเอนต์รุ่นเก่า และเป็น heuristic ให้แคชบางประเภท
หากเลือกได้เพียงอย่างเดียว ให้ใช้ ETag
Top comments (0)