Persisted Queries & Caching
คำถามสุดท้ายในสี่ข้อคือ เราเคยเห็น request นี้มาก่อนไหม? GraphQL endpoint ที่ทำงานเดิมซ้ำทุก request ทั้งช้าและเสี่ยง เทคนิคในบทเรียนนี้ — persisted query, caching และ rate limiting — เปลี่ยนงานซ้ำ ๆ ให้ถูกและคาดเดาได้ แถมยังหด attack surface จากบทก่อนลงจนแทบไม่เหลือ
Persisted queries: ลงทะเบียนก่อน ส่ง ID ทีหลัง
หัวข้อที่มีชื่อว่า “Persisted queries: ลงทะเบียนก่อน ส่ง ID ทีหลัง”ปกติ 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
Caching ที่สองระดับ
หัวข้อที่มีชื่อว่า “Caching ที่สองระดับ”เพราะ 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 เครื่องมืออย่าง@cacheControldirective ของ 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 ทำไม่ได้
Rate limiting
หัวข้อที่มีชื่อว่า “Rate limiting”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"] พอซ้อนกับบทก่อนหน้า ภาพรวมก็ครบ 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 ทำงานได้กับ GET | client และ server ต้อง sync query manifest |
| ป้องกัน arbitrary query execution — เฉพาะ registered query | schema เปลี่ยนอาจทำให้ 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 ต่างกัน หรือใช้
@cacheControldirective
💡 ตัวอย่างจากของจริง
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