DEV Community

Cover image for การแคช API ด้วย ETag และ Cache-Control: คำขอแบบมีเงื่อนไขช่วยลดปริมาณข้อมูลได้อย่างไร
Thanawat Wongchai
Thanawat Wongchai

Posted on Originally published at apidog.com

การแคช API ด้วย ETag และ Cache-Control: คำขอแบบมีเงื่อนไขช่วยลดปริมาณข้อมูลได้อย่างไร

HTTP Caching สำหรับ API: ใช้ Cache-Control และ ETag ให้ประหยัดแบนด์วิดท์

API ของคุณอาจส่ง JSON เดิมหลายพันครั้งต่อวัน ลูกค้าขอ GET /v1/products/42 และได้รับข้อมูล 18 KB จากนั้นอีกห้านาทีถัดมาก็ขอข้อมูลเดิมอีกครั้ง แม้ไม่มีอะไรเปลี่ยนแปลง คุณยังต้องจ่ายค่าแบนด์วิดท์ การแปลงข้อมูล และการอ่านฐานข้อมูลอยู่ดี

ลองใช้ Apidog วันนี้

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
Enter fullscreen mode Exit fullscreen mode

เป็นเวลา 60 วินาที ไคลเอนต์จะใช้สำเนาในเครื่องโดยไม่สร้าง network request ซึ่งประหยัดที่สุด แต่ก็มีความเสี่ยงที่สุด เพราะไคลเอนต์จะไม่ตรวจพบการเปลี่ยนแปลงจนกว่าเวลาจะหมดลง

2. การตรวจสอบ (Validation)

เมื่อสำเนาเก่าแล้ว ไคลเอนต์ไม่จำเป็นต้องดาวน์โหลดข้อมูลใหม่ทั้งหมด แต่จะถามว่า “มีการเปลี่ยนแปลงหรือไม่?” ด้วย fingerprint เดิม:

ETag: "33a64df551425fcc55e4d42a148795d9f2"
If-None-Match: "33a64df551425fcc55e4d42a148795d9f2"
Enter fullscreen mode Exit fullscreen mode

หากข้อมูลยังเหมือนเดิม เซิร์ฟเวอร์จะตอบ 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
Enter fullscreen mode Exit fullscreen mode

เซิร์ฟเวอร์ส่งข้อมูลพร้อม metadata:

HTTP/1.1 200 OK
Cache-Control: private, max-age=60
ETag: "33a64df551425fcc55e4d42a148795d9f2"
Content-Type: application/json
Content-Length: 18432
Enter fullscreen mode Exit fullscreen mode

ไคลเอนต์จะเก็บ body และ ETag ไว้ 60 วินาที ระหว่างนั้นจะไม่ติดต่อเซิร์ฟเวอร์

คำขอครั้งถัดไป

หลังจาก 60 วินาที สำเนากลายเป็น stale ไคลเอนต์จึงส่ง ETag เดิมกลับไปตรวจสอบ:

GET /v1/products/42 HTTP/1.1
Host: api.example.com
If-None-Match: "33a64df551425fcc55e4d42a148795d9f2"
Enter fullscreen mode Exit fullscreen mode

หากทรัพยากรไม่เปลี่ยน เซิร์ฟเวอร์จะตอบ:

HTTP/1.1 304 Not Modified
Cache-Control: private, max-age=60
ETag: "33a64df551425fcc55e4d42a148795d9f2"
Enter fullscreen mode Exit fullscreen mode

แทนที่จะส่งข้อมูล 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
Enter fullscreen mode Exit fullscreen mode

หมายถึงให้แคชเสิร์ฟสำเนาเก่าได้อีกสูงสุด 5 นาที พร้อมรีเฟรชข้อมูลเบื้องหลัง ผู้ใช้จะได้รับ response ทันที ขณะที่ต้นทางจะถูกอัปเดตตามมา CDN เช่น Cloudflare และ Fastly รวมถึงเบราว์เซอร์สมัยใหม่รองรับคำสั่งนี้

ค่าเริ่มต้นที่เหมาะสมสำหรับ read endpoint ที่ต้องยืนยันตัวตน:

Cache-Control: private, max-age=60, stale-while-revalidate=120
ETag: "9f8b2c41aa73e0d5"
Enter fullscreen mode Exit fullscreen mode

ข้อกำหนดฉบับเต็มอยู่ใน RFC 9111 ซึ่งแทนที่ RFC 7234 หากพฤติกรรมของ CDN ไม่เป็นไปตามคาด ให้เริ่มตรวจสอบจาก RFC นี้

Strong ETag เทียบกับ Weak ETag

คำนำหน้า W/ ใช้แยก ETag สองประเภท

Strong ETag

ETag: "33a64df551425fcc"
Enter fullscreen mode Exit fullscreen mode

รับประกันความเท่าเทียมกันแบบ byte-to-byte response ที่มี Strong ETag เดียวกันถือว่าเหมือนกันทุกไบต์ จึงเหมาะกับ byte-range request และจำเป็นสำหรับ concurrency control ผ่าน If-Match

Weak ETag

ETag: W/"33a64df551425fcc"
Enter fullscreen mode Exit fullscreen mode

รับประกันเพียง 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"
Enter fullscreen mode Exit fullscreen mode

conditional request จะใช้การค้นหาแบบมีอินเด็กซ์ แทนการ serialize response ทั้งหมด

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

แนวทางที่แนะนำ:

  1. เริ่มด้วย body hashing เพื่อความถูกต้อง
  2. ใช้ profiling หา hot endpoint
  3. เปลี่ยน endpoint ที่มี serialization cost สูงไปใช้ version-based ETag

Optimistic Concurrency ด้วย If-Match และ 412

ETag เดียวกับที่ช่วยลด bandwidth ในการอ่าน ยังช่วยป้องกัน lost update ในการเขียนได้ด้วย

สมมติว่าผู้ดูแลระบบสองคนโหลดสินค้าเดียวกัน:

  1. ผู้ดูแลระบบ A เปลี่ยนราคาและบันทึก
  2. ผู้ดูแลระบบ B แก้คำผิดแล้วบันทึกตามข้อมูลเก่า
  3. การบันทึกของ B เขียนทับราคาที่ A เพิ่งเปลี่ยน โดยไม่มีใครเห็นข้อผิดพลาด

ให้บังคับให้การอัปเดตอ้างอิง ETag ล่าสุด:

PUT /v1/products/42 HTTP/1.1
If-Match: "33a64df551425fcc55e4d42a148795d9f2"
Content-Type: application/json
Enter fullscreen mode Exit fullscreen mode

เซิร์ฟเวอร์จะเปรียบเทียบ 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 หากต้นทางตอบ 304 CDN จะรีเฟรช metadata โดยไม่ต้องดาวน์โหลด body ใหม่
  • ตรวจสอบ Vary ให้ถูกต้อง หาก endpoint เดียวกันส่งทั้ง JSON และ CSV ต้องมี:
  Vary: Accept
Enter fullscreen mode Exit fullscreen mode

ไม่เช่นนั้น 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);
});
Enter fullscreen mode Exit fullscreen mode

สังเกตว่า 304 ยังคงส่ง Cache-Control และ ETag ตาม RFC 9111 เพราะ response นี้ใช้สำหรับอัปเดต metadata ของสำเนาที่แคชไว้ จึงควรส่ง header ที่ไคลเอนต์ต้องใช้เพื่อรักษาความสดใหม่

ทดสอบ HTTP Caching ใน Apidog

โค้ดอาจดูถูกต้อง แต่ middleware และพร็อกซีอาจเปลี่ยนพฤติกรรมได้ ให้ทดสอบที่ระดับ HTTP โดยตรง

ใน Apidog ทำตามขั้นตอนนี้:

  1. ส่ง GET /v1/products/42 แล้วตรวจสอบ response headers ว่ามี ETag และ Cache-Control โดย ETag ต้องอยู่ในเครื่องหมายคำพูด
  2. คัดลอกค่า ETag แล้วส่ง request เดิมอีกครั้งพร้อม header:
   If-None-Match: "ค่าที่คัดลอกมา"
Enter fullscreen mode Exit fullscreen mode

ควรได้ 304 พร้อม body ว่าง หากยังได้ 200 แสดงว่า validation layer ไม่ได้เปรียบเทียบ fingerprint

  1. เปลี่ยนข้อมูลสินค้าแล้วส่ง request ซ้ำ ยืนยันว่าได้ 200 พร้อม ETag ใหม่
  2. ทดสอบเส้นทางเขียนด้วย PUT และ ETag ที่จงใจทำให้เก่า เช่น:
   If-Match: "deadbeefcafe1234"
Enter fullscreen mode Exit fullscreen mode

ควรได้ 412

เชื่อม request เหล่านี้เป็น test scenario:

  • Request แรกอ่าน ETag จาก response headers แล้วเก็บในตัวแปร
  • Request ที่สองส่งตัวแปรกลับเป็น If-None-Match และตรวจสอบ status 304 กับ body ว่าง
  • Request ที่สามส่ง PUT ด้วย ETag เก่าและยืนยัน status 412

ดูไวยากรณ์เพิ่มเติมได้จาก คู่มือ 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)