ข้ามไปยังเนื้อหา

Persisted Queries & Caching

คำถามสุดท้ายในสี่ข้อคือ เราเคยเห็น request นี้มาก่อนไหม? GraphQL endpoint ที่ทำงานเดิมซ้ำทุก request ทั้งช้าและเสี่ยง เทคนิคในบทเรียนนี้ — persisted query, caching และ rate limiting — เปลี่ยนงานซ้ำ ๆ ให้ถูกและคาดเดาได้ แถมยังหด attack surface จากบทก่อนลงจนแทบไม่เหลือ

ปกติ client จะส่ง query string เต็ม ๆ ไปทุก request ส่วน persisted query พลิกกลับด้าน คือลงทะเบียน operation text ไว้กับ server ล่วงหน้าแล้วได้ identifier สั้น ๆ กลับมา (มักเป็น SHA-256 hash) พอถึง runtime client ส่งแค่ ID เท่านั้น ได้ประโยชน์ใหญ่สองข้อ:

  • Security (allowlisting) ถ้า server รับ เฉพาะ ID ที่ลงทะเบียนไว้ ผู้โจมตีก็แต่ง query ที่ลึก ใช้ alias และแพงแบบบทก่อนไม่ได้อีก ชุด operation ที่รันได้ถูกแช่แข็งให้เหลือเท่าที่แอปคุณส่งเองพอดี เรามักเรียกวิธีนี้ว่า operation allowlist หรือ trusted documents
  • Performance ส่ง hash สั้น ๆ ผ่านสายแทน query หลายกิโลไบต์ และ server ข้ามการ parse text ที่เคยเห็นแล้วได้
// Build time: hash every operation your client ships and store the map.
const persisted = {
// sha256(query) -> the trusted operation text
'b1946ac9...': 'query Me { me { id name } }',
};
// Runtime: the client sends only { id }. Reject anything unknown.
function resolveOperation(id: string): string {
const query = persisted[id];
if (!query) {
throw new Error('PersistedQueryNotFound'); // not on the allowlist
}
return query;
}

Automatic Persisted Queries (APQ) เป็นรูปแบบที่นิยมกัน client ยิง hash ไปก่อนเลยแบบมองโลกในแง่ดี ถ้า server ไม่เคยเห็น hash นั้นจะตอบ PersistedQueryNotFound กลับมา แล้ว client ค่อยลองใหม่อีกครั้งด้วย query เต็ม พร้อม hash เพื่อให้ server ลงทะเบียนไว้ หลัง miss ครั้งแรก ทุก client ก็ใช้ hash สั้น ๆ ได้เลย APQ ดีมากสำหรับหดขนาด request แต่สังเกตว่า ไม่ใช่ allowlist ในตัวเอง เพราะจะลงทะเบียนอะไรก็ตามที่ client ส่งมา ถ้าต้องการ security ให้ใช้ allowlist ที่ ลงทะเบียนล่วงหน้า ถ้าต้องการประหยัด bandwidth ใช้ APQ

sequenceDiagram
  participant C as Client
  participant S as Server
  C->>S: send hash only
  S-->>C: PersistedQueryNotFound (first time)
  C->>S: retry with full query + hash
  S->>S: register hash -> query
  S-->>C: result
  Note over C,S: Subsequent requests send only the hash
Automatic Persisted Queries: การ miss ครั้งเดียวลงทะเบียน operation แล้วจากนั้นมีแต่ hash ที่เดินทาง

เพราะ persisted (หรือ allowlisted) operation เป็นชุดที่รู้จักและมีจำกัด จึง cache ได้สวยงาม

  • Response caching cache ทั้ง response ของ operation + variables + viewer ชุดหนึ่งไว้ตาม TTL สั้น ๆ request ที่เหมือนกันในช่วงนั้นจะเสิร์ฟจาก memory หรือ shared store (เช่น Redis) โดยไม่ต้องรัน resolver สักตัว ระวังเรื่อง key ให้ดี ต้องใส่ ใครเป็นคนถาม ลงไปด้วย จะได้ไม่เสิร์ฟข้อมูลส่วนตัวของ user คนหนึ่งให้อีกคน authentication context จากบทที่ 2 คือ key material ที่ต้องใช้พอดี
  • Per-field (entity) caching ละเอียดกว่า คือ cache แต่ละ object ตาม ID พร้อม TTL ของตัวเอง product(id: 5) ที่แพงจึงถูกใช้ซ้ำข้ามหลาย query ที่บังเอิญพูดถึง product 5 เครื่องมืออย่าง @cacheControl directive ของ Apollo Server และ response-cache plugin เปิดให้ field ประกาศ maxAge และ scope เป็น PUBLIC หรือ PRIVATE ได้
type Product {
id: ID!
# This field is cacheable for 60s and identical for everyone.
name: String! @cacheControl(maxAge: 60, scope: PUBLIC)
# Per-viewer data: cache privately, never share across users.
recommendation: String @cacheControl(maxAge: 30, scope: PRIVATE)
}

แบบนี้ยังเปิดทางให้ทำ static GET ได้ด้วย เพราะ persisted query เป็นแค่ ID บวก variables จึงส่งเป็น HTTP GET ได้ แล้ว CDN ก็ cache ได้เหมือน URL ทั่วไป ซึ่ง request GraphQL แบบ POST พร้อม body ทำไม่ได้

Caching ลด cost ที่ ทำซ้ำ ส่วน rate limiting จำกัด cost รวม ต่อ client แทนที่จะนับ raw HTTP request ตัว rate limiting ของ GraphQL ที่ได้ผลที่สุดจะหักจาก complexity score ในบทก่อน คือให้งบกับผู้เรียกแต่ละคน (เช่น 2000 cost unit ต่อนาที) แล้วหักตาม cost ที่คำนวณได้ของแต่ละ query client จะยิง query ถูก ๆ หลายตัวหรือ query แพง ๆ ไม่กี่ตัวก็ได้ แต่ยิง query แพงแบบไม่จำกัดไม่ได้

// Token-bucket style budget keyed by the authenticated user.
function charge(context, queryCost: number) {
const bucket = buckets.get(context.user?.id ?? context.ip);
if (bucket.remaining < queryCost) {
throw new GraphQLError('Rate limit exceeded', {
extensions: { code: 'RATE_LIMITED' },
});
}
bucket.remaining -= queryCost; // refilled on a schedule
}
flowchart TD
  Q["Incoming operation"] --> A["Allowlist check (persisted query ID)"]
  A -->|"unknown id"| Rej["Reject: PersistedQueryNotFound"]
  A -->|"known"| Cost["Complexity check"]
  Cost --> RL["Rate limit: debit cost budget"]
  RL -->|"over budget"| RLrej["Reject: RATE_LIMITED"]
  RL --> Cache{"Cached response?"}
  Cache -->|"hit"| Hit["Serve from cache (no resolvers)"]
  Cache -->|"miss"| Exec["Execute, then cache by viewer + TTL"]
แนวป้องกันประกอบกัน: operation ที่อยู่ใน allowlist ถูกตรวจ cost, rate-limit, และเสิร์ฟจาก cache เมื่อเป็นไปได้

พอซ้อนกับบทก่อนหน้า ภาพรวมก็ครบ authentication ระบุตัวผู้เรียก authorization คุม gate ทีละ field depth และ complexity analysis ปฏิเสธรูปทรงที่ผิดปกติ persisted query แช่แข็งชุด operation ที่อนุญาต caching ตัดงานซ้ำ และ rate limiting จำกัดส่วนที่เหลือ แต่ละชั้นเรียบง่าย แต่พอรวมกันแล้วทำให้ endpoint เดียวที่ยืดหยุ่นทั้งปลอดภัยและเร็วได้จริง

ข้อดีข้อแลกเปลี่ยน
persisted query ลด payload — ส่งแค่ hash แทน query stringต้อง register query ก่อน deploy — extra step ใน CI/CD
GET request ได้ — HTTP cache ทำงานได้กับ GETclient และ server ต้อง sync query manifest
ป้องกัน arbitrary query execution — เฉพาะ registered queryschema เปลี่ยนอาจทำให้ persisted query invalid
ลด parse และ validate overhead ในบาง implementationซับซ้อนสำหรับ development และ debugging

คิดว่า GraphQL Cache ได้แบบ REST โดยไม่ต้องทำอะไร อาการ:

  • ไม่ implement persisted query และ GET request
  • POST request ทุกครั้ง — CDN และ HTTP cache ทำงานไม่ได้
  • REST endpoint ที่ cache ได้ง่ายกว่าอาจเหมาะกว่าถ้า caching สำคัญมาก

Field-level Cache ที่ไม่สอดคล้องกัน อาการ:

  • cache TTL ต่างกันระหว่าง field ใน response เดียวกัน
  • response ที่รวม user profile (ไม่เปลี่ยน) กับ notification count (เปลี่ยนบ่อย)
  • แยก query ที่มี cache requirement ต่างกัน หรือใช้ @cacheControl directive

💡 ตัวอย่างจากของจริง

Apollo:

  • Apollo Persisted Queries — client ส่ง hash, server ค้นหา query จาก registry
  • production traffic ลด query payload size 80-90%

Shopify:

  • Storefront API ใช้ GET + persisted query สำหรับ CDN caching
  • product page cache ได้ที่ CDN — ลด latency สำหรับ read-heavy operation
persisted-query allowlist ที่ลงทะเบียนล่วงหน้าช่วยปรับปรุง security อย่างไร?
ทำไม Automatic Persisted Queries (APQ) ถึงไม่ใช่ allowlist ในตัวเอง?
เมื่อทำ response caching ข้อมูลส่วนตัว cache key ต้องมีอะไรอยู่ด้วย?
หน่วยที่มีประสิทธิภาพที่สุดในการ rate-limit GraphQL API คืออะไร?