چرا صفحه محصولات فروشگاه کند است؟ بررسی از API تا Query دیتابیس
مقدمه
تصور کنید کاربری وارد یک فروشگاه اینترنتی میشود تا محصولات یک دستهبندی را مشاهده کند. صفحه باز میشود، اما نمایش محصولات چند ثانیه طول میکشد. فیلتر قیمت بهکندی کار میکند و با تغییر صفحه، کاربر دوباره باید منتظر بماند.
در نگاه اول ممکن است مشکل از سرعت اینترنت، سرور یا حتی رابط کاربری باشد؛ اما یکی از بخشهایی که باید بررسی شود، مسیر دریافت اطلاعات از API و دیتابیس است.
در یک فروشگاه اینترنتی، نمایش فهرست محصولات معمولاً شامل چند مرحله است:
مرورگر درخواست را به API ارسال میکند.
سرور درخواست را اعتبارسنجی و پردازش میکند.
دیتابیس اطلاعات موردنیاز را بازیابی میکند.
سرور نتیجه را پردازش و به مرورگر ارسال میکند.
رابط کاربری اطلاعات دریافتی را نمایش میدهد.
اگر هر یک از این مراحل بهینه نباشد، زمان پاسخگویی افزایش پیدا میکند.
در این مقاله، یک سناریوی واقعی از فروشگاه اینترنتی را بررسی میکنیم و یاد میگیریم چگونه گلوگاه عملکردی را پیدا کنیم؛ از Queryهای دیتابیس و ایندکسگذاری گرفته تا مشکل N+1، صفحهبندی و کش.
۱. ابتدا مشخص کنیم کندی از کجا شروع میشود
اولین اشتباه در عیبیابی عملکرد، این است که بدون اندازهگیری، مستقیماً سراغ تغییر دیتابیس برویم.
فرض کنید صفحه محصولات در مجموع ۲٫۵ ثانیه زمان میبرد تا اطلاعات موردنیاز را دریافت کند. این زمان ممکن است به شکل زیر تقسیم شده باشد:
مرحلهزمان فرضیارسال درخواست و شبکه۱۰۰ میلیثانیهپردازش سمت سرور۱۵۰ میلیثانیهاجرای Queryهای دیتابیس۱٬۸۰۰ میلیثانیهتبدیل و ارسال پاسخ۴۵۰ میلیثانیهمجموع۲٬۵۰۰ میلیثانیه
این اعداد صرفاً یک مثال فرضی هستند، اما نشان میدهند که چرا باید زمان هر بخش را جداگانه اندازهگیری کرد.
برای بررسی عملکرد، میتوان از ابزارهای زیر استفاده کرد:
Browser DevTools: بررسی زمان درخواستهای شبکه و حجم پاسخ API
Application Logs: ثبت زمان پردازش درخواست در سرور
PostgreSQL: تحلیل برنامه اجرای Query و آمار فعالیتها
APM Tools: بررسی توزیع زمان پاسخ و شناسایی درخواستهای پرهزینه
نکته مهم این است که زمان اجرای Query با کل زمان پاسخ API برابر نیست. زمان انتظار برای اتصال، انتقال داده، اجرای منطق برنامه و تبدیل پاسخ نیز باید در نظر گرفته شود.
۲. طراحی Query مناسب برای فهرست محصولات
فرض کنیم جدول محصولات فروشگاه شامل ستونهای زیر است:
idnamedescriptionpricecategory_idstockcreated_at
یک Query ساده ممکن است تمام اطلاعات را بدون محدودیت دریافت کند:
SELECT *
FROM products
WHERE category_id = 10;این Query دو مشکل احتمالی دارد:
اول، ممکن است تعداد زیادی محصول برگرداند. دوم، تمام ستونها را دریافت میکند؛ حتی اگر رابط کاربری فقط به نام، قیمت و تصویر محصول نیاز داشته باشد.
نسخه مناسبتر برای نمایش یک صفحه از محصولات میتواند چنین باشد:
SELECT id, name, price, created_at
FROM products
WHERE category_id = 10
ORDER BY created_at DESC, id DESC
LIMIT 20;در این نسخه:
فقط ستونهای موردنیاز دریافت میشوند.
تعداد نتایج محدود شده است.
ترتیب نمایش محصولات مشخص است.
ستون
idبهعنوان معیار دوم مرتبسازی، ترتیب پایدارتری ایجاد میکند.
البته این Query هنوز بهتنهایی تضمین نمیکند که اجرا سریع باشد. برای بررسی عملکرد باید حجم داده، ایندکسهای موجود و برنامه اجرای Query را نیز تحلیل کنیم.
۳. ایندکسگذاری؛ راهکار مؤثر اما نه جادویی
یکی از دلایل احتمالی کندی فهرست محصولات، نبود ایندکس متناسب با شرط فیلتر و ترتیب نمایش است.
برای Query قبلی، میتوان این ایندکس را بررسی کرد:
CREATE INDEX idx_products_category_created_id
ON products (category_id, created_at DESC, id DESC);این ایندکس ابتدا محصولات را بر اساس دستهبندی سازماندهی میکند و سپس ترتیب تاریخ و شناسه را در ساختار ایندکس در نظر میگیرد.
در نتیجه، PostgreSQL ممکن است بتواند رکوردهای دستهبندی موردنظر را با هزینه کمتری پیدا کند و ترتیب آنها را بدون مرتبسازی جداگانه تأمین کند.
با این حال، استفاده از ایندکس به انتخاب Query Planner، آمار جدول و توزیع دادهها بستگی دارد. برای جدول کوچک یا دستهبندیای که بخش بزرگی از محصولات را شامل میشود، اسکن ترتیبی ممکن است همچنان انتخاب بهتری باشد.
چگونه نتیجه را بررسی کنیم؟
در محیط آزمایشی و با دادههای مشابه محیط واقعی، برنامه اجرای Query را بررسی کنید:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, name, price, created_at
FROM products
WHERE category_id = 10
ORDER BY created_at DESC, id DESC
LIMIT 20;این دستور اطلاعاتی درباره زمان واقعی اجرا، مراحل اجرای Query و فعالیت بافرها ارائه میدهد.
در خروجی به موارد زیر توجه کنید:
تعداد ردیفهای واقعی در مقایسه با تخمین Query Planner
وجود عملیات پرهزینه مانند
Sortتعداد بلوکهای خواندهشده از حافظه یا دیسک
تفاوت میان زمان اجرای مراحل مختلف
استفاده یا عدم استفاده از ایندکس
EXPLAIN ANALYZE واقعاً Query را اجرا میکند؛ بنابراین برای Queryهای تغییردهنده داده باید با احتیاط استفاده شود. همچنین، نتیجه یک اجرای آزمایشی بهتنهایی معیار قطعی عملکرد در شرایط پرترافیک نیست.
۴. مشکل N+1 Query؛ زمانی که یک درخواست به دهها Query تبدیل میشود
یکی از مشکلات رایج در برنامههای فروشگاهی، اجرای تعداد زیادی Query برای دریافت اطلاعات مرتبط با هر محصول است.
فرض کنید API ابتدا ۲۰ محصول را دریافت میکند و سپس برای هر محصول، یک Query جداگانه جهت دریافت اطلاعات دستهبندی اجرا میکند.
الگوی سادهشده آن به این شکل است:
const products = await getProducts();
for (const product of products) {
product.category = await getCategoryById(
product.categoryId
);
}اگر هر تابع یک درخواست مستقل به دیتابیس ارسال کند، برای دریافت ۲۰ محصول ممکن است ۲۱ Query اجرا شود: یک Query برای محصولات و ۲۰ Query برای دستهبندیها.
این الگو به مشکل N+1 معروف است.
راهکار اول: استفاده از JOIN
اگر اطلاعات دستهبندی در همان درخواست موردنیاز باشد، میتوان آن را در یک Query دریافت کرد:
SELECT
p.id,
p.name,
p.price,
c.name AS category_name
FROM products AS p
JOIN categories AS c
ON c.id = p.category_id
WHERE p.category_id = 10
ORDER BY p.created_at DESC, p.id DESC
LIMIT 20;با این روش، بهجای اجرای یک Query اضافی برای هر محصول، اطلاعات موردنیاز در یک Query ترکیبی دریافت میشوند.
البته اگر اطلاعات دستهبندی از قبل در اختیار برنامه باشد، یا به دلایل معماری جداگانه دریافت شود، ممکن است نیازی به JOIN نباشد. هدف، کاهش رفتوآمد غیرضروری میان برنامه و دیتابیس است، نه ادغام اجباری تمام Queryها.
راهکار دوم: دریافت گروهی اطلاعات
در برخی سناریوها، استفاده از یک Query برای دریافت دستهبندیهای موردنیاز با شرط IN مناسبتر است:
SELECT id, name
FROM categories
WHERE id IN (2, 5, 8, 10);این روش در معماریهایی که لایههای داده جداگانه دارند نیز قابل استفاده است.
۵. صفحهبندی؛ چرا OFFSET همیشه انتخاب مناسبی نیست؟
در فهرست محصولات، معمولاً اطلاعات در صفحههای کوچکتر نمایش داده میشوند.
یک روش رایج استفاده از LIMIT و OFFSET است:
SELECT id, name, price
FROM products
ORDER BY id
LIMIT 20 OFFSET 10000;مشکل این روش آن است که برای رسیدن به صفحههای عمیق، دیتابیس ممکن است ناچار باشد تعداد زیادی رکورد را طی کند و کنار بگذارد.
در جدولهای بزرگ، صفحهبندی مبتنی بر نشانگر یا Cursor-based Pagination میتواند گزینه مناسبتری باشد.
برای مثال، اگر صفحه قبلی با شناسه 10000 پایان یافته باشد:
SELECT id, name, price
FROM products
WHERE id > 10000
ORDER BY id
LIMIT 20;این Query برای حرکت رو به جلو در فهرست مرتبشده بر اساس شناسه طراحی شده است.
اگر ترتیب اصلی محصولات بر اساس تاریخ ایجاد باشد، باید نشانگر متناسب با همان ترتیب تعریف شود؛ برای مثال، ترکیب created_at و id میتواند از ابهام در ترتیب رکوردهایی که تاریخ یکسان دارند جلوگیری کند.
نکته: Cursor-based Pagination برای پیمایش ترتیبی مناسب است، اما الزاماً جایگزین کاملی برای رفتن مستقیم به هر شماره صفحه نیست.
۶. فیلترها و جستوجو؛ بخشهایی که بهسادگی پرهزینه میشوند
فروشگاهها معمولاً فیلترهایی مانند دستهبندی، محدوده قیمت، موجودی و مرتبسازی دارند.
برای مثال:
SELECT id, name, price
FROM products
WHERE category_id = 10
AND price BETWEEN 100000 AND 500000
ORDER BY price ASC, id ASC
LIMIT 20;برای بهینهسازی این Query، باید الگوی واقعی استفاده از فیلترها را بررسی کرد. گاهی یک ایندکس چندستونی مفید است؛ اما انتخاب ترتیب ستونها باید با توجه به شرطها، میزان انتخابپذیری و ترتیب نتایج انجام شود.
برای جستوجوی متنی نیز باید به نوع جستوجو توجه داشت. جستوجوی دقیق، جستوجوی پیشوندی و جستوجوی شامل عبارت، رفتار یکسانی ندارند و ممکن است به روشهای ایندکسگذاری متفاوتی نیاز داشته باشند.
بنابراین، ساختن یک ایندکس عمومی برای تمام حالتهای فیلتر معمولاً راهکار مناسبی نیست.
۷. کش؛ چگونه درخواستهای تکراری را کاهش دهیم؟
اگر اطلاعات یک دستهبندی بارها درخواست شود و تغییرات آن نسبتاً کم باشد، کش میتواند تعداد Queryهای تکراری را کاهش دهد.
برای مثال، میتوان پاسخ API یا بخشی از دادههای پرتکرار را برای مدت مشخصی در یک لایه کش نگهداری کرد.
مزیتهای احتمالی کش عبارتاند از:
کاهش تعداد درخواستها به دیتابیس
کاهش زمان پاسخ در درخواستهای تکراری
کاهش فشار روی منابع سرور دیتابیس
اما کش یک هزینه مهم هم دارد: مدیریت تازگی اطلاعات.
فرض کنید قیمت محصول تغییر میکند، اما پاسخ قبلی همچنان در کش موجود است. در این حالت، کاربر ممکن است قیمت قدیمی را مشاهده کند.
برای جلوگیری از این مشکل باید سیاست مشخصی برای انقضای کش، بازاعتبارسنجی یا بیاعتبارسازی آن تعریف شود. انتخاب روش به حساسیت دادهها و نیازهای کسبوکار بستگی دارد.
همچنین، کش نباید جایگزین اصلاح Queryهای ناکارآمد شود؛ زیرا ممکن است درخواستهایی که در کش وجود ندارند همچنان کند باقی بمانند.
۸. بررسی حجم پاسخ API و پردازش سمت سرور
حتی اگر Query دیتابیس سریع باشد، API ممکن است همچنان کند عمل کند.
برای نمونه، دریافت توضیحات طولانی، مشخصات کامل و اطلاعات غیرضروری تمام محصولات میتواند حجم پاسخ را افزایش دهد.
در یک صفحه فهرست محصولات، معمولاً اطلاعات محدودی مانند شناسه، نام، قیمت، تصویر اصلی و وضعیت موجودی کافی است. جزئیات کامل محصول را میتوان هنگام ورود کاربر به صفحه اختصاصی آن دریافت کرد.
از سوی دیگر، تبدیل دادهها، محاسبه اطلاعات اضافی، فراخوانی سرویسهای خارجی و پردازشهای تکراری نیز میتوانند زمان پاسخ را افزایش دهند.
به همین دلیل، بهینهسازی باید هم Query دیتابیس و هم کل مسیر پردازش API را در بر بگیرد.
۹. یک برنامه عملی برای عیبیابی کندی صفحه محصولات
برای حل این مشکل در یک پروژه واقعی، میتوان مراحل زیر را اجرا کرد:
مرحله اول: ثبت خط مبنا
زمان پاسخ API، تعداد Queryها، حجم پاسخ و تعداد رکوردهای بازگرداندهشده را ثبت کنید.
مرحله دوم: پیدا کردن درخواستهای پرهزینه
مشخص کنید کدام Queryها بیشترین زمان کل را مصرف میکنند و کدامیک بیش از حد تکرار میشوند.
مرحله سوم: بررسی برنامه اجرای Query
از EXPLAIN ANALYZE و در صورت نیاز BUFFERS برای تحلیل Queryهای منتخب استفاده کنید.
مرحله چهارم: اصلاح هدفمند
ستونهای غیرضروری را حذف کنید، صفحهبندی مناسب پیادهسازی کنید، N+1 را برطرف کنید و ایندکسهای لازم را با آزمایش اعتبارسنجی کنید.
مرحله پنجم: اندازهگیری مجدد
همان سناریو را با حجم داده و شرایط قابل مقایسه اجرا کنید. تنها به یک اجرای موفق یا یک عدد میانگین اکتفا نکنید؛ زمان پاسخ در صدکهای بالاتر، مانند p95، نیز برای تجربه کاربران مهم است.
مرحله ششم: بررسی شرایط پرترافیک
در صورت امکان، با آزمون بار کنترلشده بررسی کنید که بهبود انجامشده در حضور درخواستهای همزمان نیز حفظ میشود یا خیر.
نتیجهگیری
کندی صفحه محصولات همیشه ناشی از یک Query ضعیف نیست. مشکل ممکن است از ایندکسگذاری نامناسب، اجرای N+1 Query، صفحهبندی غیربهینه، حجم زیاد پاسخ API، مدیریت اتصالها یا پردازش سمت سرور ناشی شود.
راهکار اصولی این است که ابتدا مسیر درخواست را اندازهگیری کنیم، سپس گلوگاه واقعی را شناسایی کرده و تغییرات هدفمند انجام دهیم.
در یک فروشگاه اینترنتی، ترکیب Queryهای بهینه، ایندکسگذاری متناسب با الگوی جستوجو، صفحهبندی صحیح و مدیریت آگاهانه کش میتواند عملکرد سیستم را بهبود دهد؛ البته نتیجه نهایی باید با دادههای واقعی و آزمایش قابل تکرار تأیید شود.
اصل کلیدی: بهینهسازی واقعی زمانی اتفاق میافتد که بدانیم کدام بخش کند است، چرا کند است و تغییر ما دقیقاً چه تأثیری بر آن گذاشته است.
پرسشهای متداول
آیا با ایجاد ایندکس میتوان هر Query کندی را سریع کرد؟
خیر. ایندکس برای برخی الگوهای دسترسی مفید است، اما مشکلاتی مانند انتقال داده زیاد، اجرای N+1 یا پردازش سنگین سمت سرور را بهتنهایی برطرف نمیکند.
چگونه بفهمیم مشکل از دیتابیس است یا API؟
با ثبت جداگانه زمان اجرای Queryها، پردازش سرور و زمان پاسخ کل درخواست میتوان محل اصلی تأخیر را دقیقتر مشخص کرد.
N+1 Query چیست؟
الگویی است که در آن دریافت یک مجموعه داده، به اجرای یک Query اولیه و سپس Queryهای جداگانه برای هر رکورد منجر میشود. در بسیاری از موارد، JOIN یا دریافت گروهی اطلاعات میتواند تعداد درخواستها را کاهش دهد.
آیا Cursor-based Pagination همیشه سریعتر است؟
خیر. این روش برای پیمایش ترتیبی در مجموعههای بزرگ معمولاً مزیت دارد، اما نتیجه به ترتیب مرتبسازی، ایندکسها و الگوی دسترسی بستگی دارد.
آیا کش جایگزین بهینهسازی دیتابیس است؟
خیر. کش میتواند درخواستهای تکراری را کاهش دهد، اما Queryهای ناکارآمد و مشکلاتی را که در درخواستهای بدون کش رخ میدهند، لزوماً برطرف نمیکند.
