Lazy Load
เลื่อนการ initialize ทรัพยากรจนกว่าจะถูกใช้ครั้งแรก
เวลาโหลด object หนึ่งตัว มักมี object อื่นที่เกี่ยวข้องพ่วงมาด้วยเสมอ — Order มี Customer, Customer มี Address หลายรายการ, แต่ละ Address อาจโยงไปถึง Country ฯลฯ ถ้าโหลดทุกอย่างแบบ eager ตั้งแต่ต้น (โหลดทั้ง graph ของ object พร้อมกันครั้งเดียว) app จะเสียเวลาคำนวณ, ยิง query ฐานข้อมูล และจองหน่วยความจำให้กับข้อมูลที่ตอน runtime อาจไม่ถูกใช้เลยด้วยซ้ำ
Lazy Load แก้ปัญหานี้ด้วยการเลื่อนการสร้างหรือโหลด object จนกว่า code ฝั่งเรียกใช้จะแตะต้องมันจริง ๆ เป็นครั้งแรก จากนั้น cache ผลลัพธ์ไว้เพื่อไม่ต้องโหลดซ้ำ หลักคิดคือ “อย่าจ่ายค่าใช้จ่ายจนกว่าจะรู้แน่ว่าต้องใช้” — สอดคล้องกับหลักการ YAGNI ที่บอกว่าอย่าสร้างสิ่งที่ยังไม่จำเป็น
Martin Fowler ระบุไว้ใน Patterns of Enterprise Application Architecture ว่ามีสี่วิธีหลักในการ implement Lazy Load:
- Lazy Initialization — ใช้ค่า marker (มักเป็น
null) บอกว่า field ยังไม่ถูกโหลด ทุกครั้งที่เข้าถึง field จะเช็ก marker ก่อน ถ้ายังไม่โหลดก็โหลดแล้วเก็บผลไว้ - Virtual Proxy — object ที่มี interface เหมือน object จริงทุกประการ เมื่อ method ใด ๆ ถูกเรียกครั้งแรก มันจะไปโหลด object จริงมาแล้ว delegate ต่อ (ดู Proxy)
- Value Holder — object ที่มี method อย่าง
getValue/.Valueเมื่อถูกเรียกครั้งแรกจึงโหลดของจริง — นี่คือแนวทางที่Lazy<T>ของ .NET ใช้ - Ghost — object จริงที่ถูกสร้างขึ้นแบบ “ว่างเปล่า” (ไม่มีข้อมูลใน field) พอ method ใดถูกเรียก มันจะโหลดข้อมูลเต็มเข้ามาเติมใน field ของตัวเอง
ORM อย่าง Entity Framework, Hibernate ใช้ virtual proxy ให้ lazy loading แบบโปร่งใสกับนักพัฒนา แต่ก็แลกมาด้วยความเสี่ยงเรื่อง N+1 query ที่จะกล่าวถึงด้านล่าง
โครงสร้าง
หัวข้อที่มีชื่อว่า “โครงสร้าง”classDiagram
class Client {
+UseCustomer()
}
class Order {
-LazyCustomer customer
+Customer Customer
}
class LazyCustomerHolder {
-Customer cachedValue
-bool isLoaded
+Value() Customer
}
class Customer {
+Name
+Email
}
class CustomerRepository {
+GetById(id) Customer
}
Client --> Order
Order o-- LazyCustomerHolder
LazyCustomerHolder --> CustomerRepository
LazyCustomerHolder ..> Customer
CustomerRepository ..> Customer
Order ไม่ได้ถือ Customer ตรง ๆ แต่ถือ holder ที่รู้วิธีไปโหลด Customer ผ่าน CustomerRepository เมื่อถูกขอค่าครั้งแรกเท่านั้น (แนวทาง value holder — เทียบเท่ากับสิ่งที่ Proxy ทำในแนวทาง virtual proxy)
การทำงาน
หัวข้อที่มีชื่อว่า “การทำงาน”ลำดับการทำงานของ value holder / virtual proxy เหมือนกันในหลักการ: เช็กก่อนว่าเคยโหลดหรือยัง ถ้ายังก็ไปโหลดแล้ว cache ไว้ ถ้าเคยแล้วก็คืนค่าที่ cache ไว้ทันที
sequenceDiagram
participant C as Client
participant H as LazyHolder
participant R as Repository
C->>H: Value()
alt ยังไม่เคยโหลด
H->>R: GetById(id)
R-->>H: Customer
H->>H: cache ผลลัพธ์
else เคยโหลดแล้ว
H->>H: ใช้ cache เดิม
end
H-->>C: Customer
ประเด็นสำคัญคือ client (code ที่เรียก Order.Customer) ไม่รู้และไม่ต้องสนใจ ว่าข้างในเป็น lazy หรือ eager — มันแค่เรียก property ตามปกติ ความเลื่อนเวลาการโหลดถูกซ่อนอยู่หลัง encapsulation ทั้งหมด นี่คือจุดที่ทำให้ Lazy Load ต่างจากการเขียน code เช็ก if (x == null) กระจัดกระจายทั่ว codebase — ตรรกะการโหลดถูกรวมศูนย์ไว้ที่เดียว
ข้อควรระวังตัวใหญ่: เมื่อ lazy load ถูกซ่อนอยู่หลัง property ธรรมดา นักพัฒนาที่วน loop บน collection ของ entity (เช่น foreach (var order in orders) { var name = order.Customer.Name; }) มักไม่รู้ตัวว่ากำลังจุดชนวน round-trip ฐานข้อมูลหนึ่งครั้งต่อ entity หนึ่งตัว — นี่คือปัญหา N+1 query ที่ขึ้นชื่อ: query แรกดึง N รายการมา แล้วอีก N query ตามมาเพื่อโหลดข้อมูลที่เกี่ยวข้องทีละตัว Microsoft Learn เตือนเรื่องนี้ไว้ชัดเจนในเอกสารของ EF Core ว่า lazy loading “can cause unneeded extra database roundtrips to occur” และแนะนำให้ระวังเป็นพิเศษเมื่อใช้ในเส้นทางที่ performance สำคัญ
ตัวอย่าง code
หัวข้อที่มีชื่อว่า “ตัวอย่าง code”ตัวอย่างแรกใช้ Lazy<T> ของ .NET ซึ่งเป็นแนวทาง value holder ที่มีมาให้ในตัวภาษาอยู่แล้ว thread-safe โดย default:
public class Order{ private readonly Lazy<Customer> _customer;
public Order(int customerId, ICustomerRepository repository) { // ยังไม่ query ฐานข้อมูลตรงนี้ — แค่เก็บ delegate ไว้ก่อน _customer = new Lazy<Customer>(() => repository.GetById(customerId)); }
// โหลดจริงเมื่อ .Value ถูกอ่านครั้งแรกเท่านั้น ครั้งต่อไปคืนค่าเดิมจาก cache public Customer Customer => _customer.Value;}
public interface ICustomerRepository{ Customer GetById(int id);}ตัวอย่างที่สองแสดงแนวทาง lazy initialization แบบเขียนเองโดยไม่พึ่ง Lazy<T> — เผื่อกรณีต้องการ custom logic เช่น การ invalidate cache:
public class Order{ private Customer? _cachedCustomer; private readonly int _customerId; private readonly ICustomerRepository _repository;
public Order(int customerId, ICustomerRepository repository) { _customerId = customerId; _repository = repository; }
public Customer Customer { get { // marker คือ null — เช็กก่อนทุกครั้งที่เข้าถึง _cachedCustomer ??= _repository.GetById(_customerId); return _cachedCustomer; } }}และตัวอย่างการหลีกเลี่ยง N+1 ด้วยการ eager load แบบชัดเจนแทน lazy load เมื่อรู้ล่วงหน้าว่าจะต้องใช้ข้อมูลที่เกี่ยวข้องแน่ ๆ (ตัวอย่างสไตล์ EF Core):
// ไม่ดี: ถ้า Customer เป็น lazy-loaded proxy loop นี้จะยิง query แยกทุกรอบ (N+1)var orders = dbContext.Orders.ToList();foreach (var order in orders){ Console.WriteLine(order.Customer.Name); // 1 query ต่อ order!}
// ดี: eager load ด้วย Include() รวบทุกอย่างเป็น query เดียวvar ordersEager = dbContext.Orders .Include(o => o.Customer) .ToList();foreach (var order in ordersEager){ Console.WriteLine(order.Customer.Name); // ไม่มี query เพิ่ม}เมื่อไหร่ควรใช้
หัวข้อที่มีชื่อว่า “เมื่อไหร่ควรใช้”- ทรัพยากรมีต้นทุนสูงในการสร้าง (query ฐานข้อมูล, เรียก network, คำนวณหนัก) แต่ไม่ได้ถูกใช้เสมอไป ในทุก code path
- ต้องการลด startup time ของ application หรือ object graph ขนาดใหญ่ที่โหลดครั้งเดียวไม่คุ้ม
- ทรัพยากรถูกใช้ “บางครั้ง” ตาม flag, permission หรือเงื่อนไข runtime ที่รู้ผลช้า
- ต้องการ cache ผลลัพธ์ของการคำนวณที่ทำซ้ำได้ยาก (memoization แบบง่าย)
- ทำงานกับ object graph ขนาดใหญ่ที่โหลดทุกความสัมพันธ์แบบ eager จะกิน memory เกินจำเป็น
เมื่อไหร่ไม่ควรใช้
หัวข้อที่มีชื่อว่า “เมื่อไหร่ไม่ควรใช้”- ทรัพยากรนั้นถูกใช้เกือบทุกครั้งอยู่แล้ว — lazy load แค่เพิ่ม overhead ของการเช็ก branch โดยไม่ได้ประโยชน์อะไร
- อยู่ในเส้นทางที่วน loop บน collection ของ entity (เช่น API endpoint ที่คืน list) เพราะเสี่ยงปัญหา N+1 query สูงมาก — ควร eager load หรือใช้ Specification เพื่อระบุชัดเจนว่าต้องการข้อมูลอะไรบ้างแทน
- ทรัพยากรต้องพร้อมใช้งานทันทีเพื่อความถูกต้อง (เช่น validation ที่ต้องรู้ค่าตั้งแต่ constructor)
- ระบบเป็น multi-threaded และไม่ได้ทำ implementation ให้ thread-safe — การเช็ก-แล้ว-สร้างพร้อมกันจากหลาย thread อาจสร้าง object ซ้ำหรือ race condition ได้
- ความ “โปร่งใส” ของ lazy load (client มองไม่เห็นว่ามันจะไปแตะฐานข้อมูล) จะทำให้ code ยากต่อการมองเห็น performance cost — ในกรณีนี้ explicit loading อาจสื่อความตั้งใจได้ชัดกว่า
ข้อดีและข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีและข้อเสีย”| ด้าน | รายละเอียด |
|---|---|
| ข้อดี | ลด startup cost และหน่วยความจำที่ใช้โดยไม่จำเป็น |
| ข้อดี | ซ่อนความซับซ้อนของการโหลดไว้หลัง encapsulation — client เรียกใช้เหมือน property ปกติ |
| ข้อดี | cache ผลลัพธ์อัตโนมัติ ไม่ต้องโหลดซ้ำถ้าถูกเรียกหลายครั้ง |
| ข้อเสีย | เสี่ยงปัญหา N+1 query อย่างมากเมื่อใช้ใน loop หรือกับ collection |
| ข้อเสีย | ทำให้ performance ของ code คาดเดายากขึ้น เพราะ “การอ่าน property” อาจแอบยิง query ฐานข้อมูล |
| ข้อเสีย | ต้องระวังเรื่อง thread-safety หากใช้งานแบบ concurrent |
| ข้อเสีย | debug ยากขึ้นเล็กน้อย เพราะ stack trace ของ exception อาจไม่ตรงจุดที่ “ดูเหมือน” เกิดปัญหา |
ที่เกี่ยวข้อง
หัวข้อที่มีชื่อว่า “ที่เกี่ยวข้อง”- Proxy — กลไกหลักที่ virtual proxy variant ของ Lazy Load ใช้สร้าง object ที่ห่อหุ้มการโหลดแบบเลื่อนเวลา
- Specification — ใช้ระบุเงื่อนไขข้อมูลที่ต้องการอย่างชัดเจน แทนการพึ่ง lazy load ที่อาจก่อ N+1
- Repository — แหล่งที่มาของข้อมูลจริงที่ lazy holder เรียกใช้เมื่อถูก trigger โหลด
- Singleton — มักใช้ lazy initialization ร่วมกันเพื่อสร้าง instance เดียวเมื่อถูกเรียกครั้งแรก
- YAGNI — หลักคิดเบื้องหลัง Lazy Load: อย่าทำงาน (โหลดข้อมูล) ก่อนที่จะรู้แน่ว่าจำเป็นต้องใช้