পাঠ ৩২ · ৫৮-এর মধ্যে · মডিউল ৭
Home / Courses / Full-Stack Web Frameworks / N+1 কোয়েরি ও Eager Loading

N+1 কোয়েরি সমস্যা ও Eager Loading

N+1 queries and eager loading
১২ মিনিট পড়া মধ্যম · Intermediate Python কোডসহ সম্পূর্ণ বাংলায়

এই পাঠে যা শিখবেন

  • N+1 কোয়েরি সমস্যা ঠিক কীভাবে ঘটে — একটি লুপের ভেতর প্রতিটি parent-এর জন্য আলাদা কোয়েরি
  • eager loading কীভাবে সব children একসাথে ব্যাচড আনে সমস্যাটি সমাধান করে
  • একটি সত্যিকারের কোয়েরি-কাউন্টার দিয়ে দুই পদ্ধতির প্রকৃত কোয়েরি সংখ্যা মেপে তুলনা করা
  • N বাড়লে কেন নাইভ পদ্ধতির কোয়েরি সংখ্যা রৈখিকভাবে ($O(N)$) বাড়ে, কিন্তু eager loading-এর সংখ্যা স্থির থাকে

১ · N+1 কোয়েরি সমস্যা কী

N+1 কোয়েরি সমস্যাN+1 Query ProblemN-টি parent রেকর্ডের প্রতিটির সম্পর্কিত children আলাদাভাবে লোড করার ফলে মোট N+1টি কোয়েরি চালানো — যেখানে ব্যাচ করে মাত্র ২টি কোয়েরিতেই একই ডেটা পাওয়া সম্ভব ছিল। সাধারণত ঘটে ORM-এর "লেজি লোডিং" (lazy loading) ডিফল্ট আচরণের কারণে — L30-এর Post.author-এর মতো একটি রিলেশনশিপ অ্যাক্সেসর প্রতিবার কল হলেই একটি নতুন লুকআপ ("কোয়েরি") চালায়। এটি একটি রো-র জন্য ঠিক আছে, কিন্তু N-টি parent-এর একটি তালিকার উপর লুপ করে প্রতিটির জন্য আলাদা করে এটি কল করলে কোয়েরি সংখ্যা দ্রুত বেড়ে যায়।

নাইভ (N+1) ১টি: সব parent ১০টি: প্রতি parent-এর children আলাদাভাবে মোট: ১১টি কোয়েরি Eager Loading ১টি: সব parent ১টি: সব children এক ব্যাচে মোট: ২টি কোয়েরি
১০টি parent-এর জন্য নাইভ পদ্ধতি ১১টি পৃথক কোয়েরি চালায়, eager loading একই ডেটা মাত্র ২টি ব্যাচড কোয়েরিতে আনে — N যত বড় হবে, পার্থক্য তত বাড়বে।

২ · কোয়েরি-কাউন্টার দিয়ে বাস্তবে যাচাই

নিচের কোড সেলে একটি সত্যিকারের query_count কাউন্টার তৈরি করা হয়েছে — প্রতিবার কোনো "কোয়েরি" ফাংশন কল হলে এটি একবার বাড়ে। ১০টি parent রেকর্ড (প্রতিটির ২-৪টি child) নিয়ে নাইভ ও eager, দুই পদ্ধতিই চালানো হয়েছে এবং শেষে কাউন্টারের প্রকৃত মান প্রিন্ট ও যাচাই করা হয়েছে।

Python
import random
random.seed(7)   # প্রতিবার একই ফলাফল পুনরুৎপাদনযোগ্য রাখতে

# ---------- ডেটা প্রস্তুত করা: ১০ parent, প্রতিটির ২-৪টি child ----------
parents_table = [{"id": i, "name": f"Parent-{i}"} for i in range(1, 11)]

children_table = []
next_child_id = 1
for parent in parents_table:
    n_children = random.choice([2, 3, 4])
    for _ in range(n_children):
        children_table.append({
            "id": next_child_id,
            "parent_id": parent["id"],
            "name": f"Child-{next_child_id}",
        })
        next_child_id += 1

# ---------- "কোয়েরি" ফাংশন -- প্রতিটি কল একটি বাস্তব ডেটাবেস রাউন্ড-ট্রিপের সমতুল্য ----------
query_count = 0

def query_get_all_parents():
    global query_count
    query_count += 1
    return list(parents_table)

def query_get_children_for_parent(parent_id):
    global query_count
    query_count += 1
    return [c for c in children_table if c["parent_id"] == parent_id]

def query_get_all_children():
    global query_count
    query_count += 1
    return list(children_table)


# ---------- নাইভ (N+1) পদ্ধতি ----------
query_count = 0
parents = query_get_all_parents()             # ১টি কোয়েরি
naive_result = {}
for parent in parents:                        # প্রতিটি parent-এর জন্য আলাদা কোয়েরি -- মোট N-টি
    naive_result[parent["id"]] = query_get_children_for_parent(parent["id"])

naive_query_count = query_count
print(f"নাইভ (N+1) পদ্ধতি -- মোট কোয়েরি সংখ্যা: {naive_query_count}")
assert naive_query_count == 1 + len(parents)   # ১ (parent তালিকা) + ১০ (প্রতি parent-এর children) = ১১


# ---------- Eager loading পদ্ধতি ----------
query_count = 0
parents = query_get_all_parents()              # ১টি কোয়েরি
all_children = query_get_all_children()        # আরেকটি কোয়েরি -- সবার children একসাথে ব্যাচে

children_by_parent = {}
for child in all_children:                     # এই লুপ কোনো নতুন কোয়েরি চালায় না -- ইতিমধ্যে আনা ডেটার উপর কাজ
    children_by_parent.setdefault(child["parent_id"], []).append(child)

eager_result = {parent["id"]: children_by_parent.get(parent["id"], []) for parent in parents}

eager_query_count = query_count
print(f"Eager loading পদ্ধতি -- মোট কোয়েরি সংখ্যা: {eager_query_count}")
assert eager_query_count == 2   # ১ (parent তালিকা) + ১ (সব children এক ব্যাচে) = ২, N নির্বিশেষে


# ---------- দুই পদ্ধতির ডেটা ফলাফল অভিন্ন কি না যাচাই ----------
same_data = naive_result == eager_result
print(f"\nদুই পদ্ধতির ফলাফল ডেটা হুবহু অভিন্ন: {same_data}")
assert same_data

print(f"\n--- সারাংশ (১০টি parent-এর জন্য) ---")
print(f"নাইভ (N+1):      {naive_query_count} কোয়েরি")
print(f"Eager loading:   {eager_query_count} কোয়েরি")
print(f"পার্থক্য:         {naive_query_count - eager_query_count} কম কোয়েরি eager loading-এ")

    
লক্ষ্য করুন query_count একটি সাধারণ গ্লোবাল কাউন্টার, কিন্তু এটি সত্যিকারভাবে প্রতিটি "কোয়েরি" ফাংশন কলের সাথে বাড়ে — কোনো হার্ডকোড করা সংখ্যা নয়। নাইভ পদ্ধতিতে for parent in parents লুপের ভেতর query_get_children_for_parent() ঠিক ১০ বার কল হয় (১০টি parent থাকায়), প্রতিটি কলে কাউন্টার ১ বাড়ে — তার সাথে শুরুর query_get_all_parents()-এর ১ যোগ করলে মোট ১১। Eager পদ্ধতিতে for child in all_children লুপ শুধু ইতিমধ্যে আনা all_children লিস্টের উপর কাজ করে, কোনো নতুন "কোয়েরি" ফাংশন কল করে না — তাই কাউন্টার আটকে থাকে ২-এ।
মূল কথা · Key takeaway

N+1 কোয়েরি সমস্যা ঘটে যখন একটি রিলেশনশিপ N-বার, একবার করে প্রতিটি parent-এর জন্য, লোড করা হয় — মোট কোয়েরি সংখ্যা $O(N)$ হারে বাড়ে। Eager loading সব children একটি ব্যাচড কোয়েরিতে একসাথে এনে এই সংখ্যা $O(1)$-এ (parent সংখ্যা নির্বিশেষে স্থির) নামিয়ে আনে। উপরের উদাহরণে ১০টি parent-এর জন্য পার্থক্য ছিল ১১ বনাম ২ কোয়েরি — বাস্তব অ্যাপ্লিকেশনে হাজার হাজার parent থাকলে এই পার্থক্যই একটি পেজকে কয়েক মিলিসেকেন্ডে বনাম কয়েক সেকেন্ডে লোড হওয়ার পার্থক্য গড়ে দিতে পারে।

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিজে কিছুক্ষণ ভাবুন — তারপর "→ উত্তর" চাপুন।

প্র ০১ যদি parent সংখ্যা ১০-এর বদলে ১০০ হতো (children-এর সংখ্যা যাই হোক না কেন), তাহলে naive_query_count ও eager_query_count-এর মান কী হতো?

naive_query_count হতো 1 + 100 = 101 — কারণ এটি সরাসরি parent সংখ্যার উপর নির্ভরশীল (প্রতিটি parent-এর জন্য ঠিক একটি অতিরিক্ত কোয়েরি)। কিন্তু eager_query_count তখনো 2-ই থাকত — কারণ eager loading-এর কোয়েরি সংখ্যা parent সংখ্যার উপর নির্ভর করে না, সবসময় ঠিক দুইটি (parent তালিকা + সব children এক ব্যাচে)। এটাই দেখায় eager loading-এর সুবিধা N বাড়ার সাথে সাথে আরও বেশি গুরুত্বপূর্ণ হয়ে ওঠে।

প্র ০২ eager loading পদ্ধতিতে for child in all_children লুপটি কেন কোনো নতুন "কোয়েরি" গণনা করায় না, যদিও এখানেও একটি লুপ আছে?

কারণ এই লুপটি এমন একটি লিস্টের (all_children) উপর কাজ করছে যা ইতিমধ্যে একবারের query_get_all_children() কল দিয়ে সম্পূর্ণভাবে আনা হয়ে গেছে — লুপটি শুধু ইতিমধ্যে মেমরিতে থাকা ডেটাকে parent_id অনুযায়ী পুনর্গঠন (গ্রুপিং) করছে, কোনো নতুন ডেটাবেস-স্টাইল কল করছে না। নাইভ পদ্ধতির লুপের সাথে মূল পার্থক্য এখানেই — নাইভ লুপের প্রতিটি ধাপে একটি নতুন query_...() ফাংশন কল হয়, eager-এর লুপে হয় না।

প্র ০৩ naive_result == eager_result সমতা যাচাই কেন গুরুত্বপূর্ণ — শুধু কোয়েরি সংখ্যা কম হলেই কি যথেষ্ট না?

না — একটি অপটিমাইজেশন কোয়েরি সংখ্যা কমালেও যদি ভুল বা অসম্পূর্ণ ডেটা ফেরত দেয়, তাহলে সেটি একটি পারফরম্যান্স উন্নতি নয়, একটি নতুন বাগ। naive_result == eager_result যাচাই নিশ্চিত করে eager loading শুধু দ্রুতই নয়, বরং ঠিক একই ডেটা (প্রতিটি parent-এর ঠিক সঠিক children) ফেরত দেয় — অর্থাৎ এটি একটি সঠিক অপটিমাইজেশন, ভুল শর্টকাট নয়।

অনুশীলন

  1. চিন্তা করুন: যদি প্রতিটি parent-এর children-এর পাশাপাশি প্রতিটি child-এর জন্য আবার একটি আলাদা "grandchild" রিলেশনশিপও নাইভভাবে (লুপের ভেতর) লোড করা হতো, তাহলে মোট কোয়েরি সংখ্যা আনুমানিক কেমন দেখাত (parent সংখ্যা N ও গড় children সংখ্যা C হলে)?

    আনুমানিক 1 + N + (N × C) — ১ (parent তালিকা) + N (প্রতি parent-এর children) + N×C (প্রতিটি child-এর জন্য আলাদা grandchild কোয়েরি, মোট children সংখ্যা প্রায় N×C)। এটিকে "N+1" এর একটি গভীরতর সংস্করণ বলা যায় — প্রতিটি অতিরিক্ত স্তরের নেস্টেড রিলেশনশিপ নাইভভাবে লোড করলে কোয়েরি সংখ্যা আরও দ্রুত বাড়ে, যা বাস্তব অ্যাপ্লিকেশনে এই সমস্যাটিকে আরও ক্ষতিকর করে তোলে।

  2. পরীক্ষা করুন: উপরের কোড সেলে parents_table-এর সংখ্যা ১০ থেকে বাড়িয়ে ৩০ করুন (range(1, 11)-কে range(1, 31)-এ বদলে), সেল আবার চালান, এবং naive_query_count ও eager_query_count-এর নতুন মান লক্ষ্য করুন।

    naive_query_count এখন 31 হবে (1 + 30), কিন্তু eager_query_count তখনো 2-ই থাকবে — parent সংখ্যা তিনগুণ বাড়ানোর পরেও eager loading-এর কোয়েরি সংখ্যা একদমই বদলায়নি। এটি হাতে-কলমে প্রমাণ করে eager loading-এর কোয়েরি সংখ্যা $O(1)$ (parent সংখ্যা নির্বিশেষে স্থির), যেখানে নাইভ পদ্ধতির সংখ্যা $O(N)$ (parent সংখ্যার সাথে রৈখিকভাবে বাড়ে)।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

  • পরবর্তী পাঠ L33 সেশন-ভিত্তিক অথেন্টিকেশন — মডিউল ৮-এ অথেন্টিকেশন ও অথোরাইজেশন শুরু হচ্ছে।
  • কোর্সের সম্পূর্ণ সিলেবাস দেখুন ৫৮টি পাঠ আর্কিটেকচার প্যাটার্ন, ফ্রন্ট-এন্ড/ব্যাক-এন্ড ফ্রেমওয়ার্ক ফান্ডামেন্টাল, স্টেট ম্যানেজমেন্ট, REST API, ORM, অথেন্টিকেশন, রেন্ডারিং স্ট্র্যাটেজি ও ডিপ্লয়মেন্ট।
  • Database Management Systems কোর্স সহোদর কোর্স JOIN-ভিত্তিক কোয়েরি লিখে একই ডেটা একটি মাত্র SQL কোয়েরিতে আনার প্র্যাকটিস সেই কোর্সে শেখানো হয়েছে।
  • সব Courses দেখুন ABCL TECH C, C++, Python, Java, JavaScript, DSA, DBMS, Discrete Mathematics, System Design, Cybersecurity, Cloud Computing & DevOps, Computer Networks, Operating Systems, Computer Architecture, Programming Languages & Compiler Design, Software Engineering & Git, Theory of Computation, Engineering Economics ও Full-Stack Web Frameworks — সব এক জায়গায়।
আগের পাঠ
কোয়েরি বিল্ডার বনাম রॉ SQL বনাম ORM