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

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:

  1. Lazy Initialization — ใช้​ค่า marker (มัก​เป็น null) บอกว่า field ยัง​ไม่​ถูก​โหลด ทุก​ครั้ง​ที่​เข้าถึง field จะ​เช็ก marker ก่อน ถ้า​ยัง​ไม่​โหลด​ก็​โหลด​แล้ว​เก็บ​ผล​ไว้
  2. Virtual Proxy — object ที่​มี interface เหมือน object จริง​ทุก​ประการ เมื่อ method ใด ๆ ถูก​เรียก​ครั้ง​แรก มัน​จะ​ไป​โหลด object จริง​มา​แล้ว delegate ต่อ (ดู Proxy)
  3. Value Holder — object ที่​มี method อย่าง getValue/.Value เมื่อ​ถูก​เรียก​ครั้ง​แรก​จึง​โหลด​ของ​จริง — นี่​คือ​แนวทาง​ที่ Lazy<T> ของ .NET ใช้
  4. 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 สำคัญ

ตัวอย่าง​แรก​ใช้ 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: อย่า​ทำงาน (โหลด​ข้อมูล) ก่อน​ที่​จะ​รู้​แน่​ว่า​จำเป็น​ต้อง​ใช้