13 KiB
رفع افت شدید سرعت سرچ پزشک (regression کامیت city-via-clinic)
پروژه
clinicpro (Backend — Doctrine DQL در DoctorRepository)
زمینه
در کامیت 8b9130011ae370fa3bad152612e88ec350f99fc2 برای حل مشکل «پزشکی که آدرس مستقیم ندارد و از آدرس کلینیک استفاده میکند، در شهر کلینیک نمایش داده نمیشد»، به متد findWithFilters() در DoctorRepository دو leftJoin اضافه شد. این تغییر مشکل شهر را حل کرد اما سرعت سرچ سایت عمومی (nobat724_front → GET /api/v1/doctor) را بهشدت پایین آورد. این متد قلب صفحه /doctors است و در هر تغییر فیلتر/شهر/صفحه فراخوانی میشود، پس کندی آن مستقیماً تجربه کاربر را خراب میکند. پرفورمنس برای این پروژه اولویت بالا دارد.
مشکل / هدف
دو leftJoin اضافهشده باعث افت سرعت میشوند:
->leftJoin(Clinic::class, 'cl', Join::WITH, 'd MEMBER OF cl.doctors')
->leftJoin(DoctorAddress::class, 'ca', Join::WITH, 'ca.clinicId = cl.id AND ca.doctor IS NULL')
علت دقیق کندی:
d MEMBER OF cl.doctorsدر شرط JOIN → Doctrine آن را به یک subquery همبسته (EXISTS (SELECT ... FROM clinic_doctor ...)per row) ترجمه میکند که روی کل جدولClinicبرای هر ردیف پزشک ارزیابی میشود. این JOIN بدون شرط محدودکننده رویclاست، یعنی عملاً یک نیمهcross-join.- این JOIN همیشه اجرا میشود — حتی وقتی هیچ فیلتر
state/cityست نشده (یعنی اکثر سرچها که فقطnameیا اصلاً فیلتری ندارند). هزینه را بیدلیل به همهی کوئریها تحمیل میکند. ->distinct()روی این محصول دکارتی بزرگشده، بهعلاوهیPaginatorکه برای شمارشtotalیک کوئریCOUNT(DISTINCT ...)جداگانه میسازد، هزینه را دوبرابر میکند.
هدف: پزشکِ بدون آدرس مستقیم که در کلینیک عضو است، همچنان در شهر/استان کلینیک نمایش داده شود (رفتار فعلی حفظ شود)، اما بدون افت سرعت — بهخصوص کوئریهای بدون فیلتر مکان باید به سرعت قبل از کامیت برگردند.
فایلهای مرتبط
| فایل | نقش |
|---|---|
src/Doctor/Repository/DoctorRepository.php |
متد findWithFilters() — کوئری اصلی سرچ پزشک |
src/Doctor/Entity/DoctorAddress.php |
موجودیت آدرس؛ clinicId (FK خام int)، province/city (ManyToOne)، doctor (ManyToOne، برای آدرس کلینیک NULL است) |
src/Clinic/Entity/Clinic.php |
رابطهی ManyToMany doctors (owner سمت کلینیک) |
docs/api/doctor.md |
مستندات endpoint — اگر رفتار/قرارداد عوض شد بهروزرسانی شود |
وضعیت فعلی
src/Doctor/Repository/DoctorRepository.php — متد findWithFilters():
$qb = $this->createQueryBuilder('d')
->leftJoin('d.specialties', 's')
->leftJoin('d.provinces', 'pr')
->leftJoin('d.cities', 'ci')
->leftJoin(Clinic::class, 'cl', Join::WITH, 'd MEMBER OF cl.doctors')
->leftJoin(DoctorAddress::class, 'ca', Join::WITH, 'ca.clinicId = cl.id AND ca.doctor IS NULL')
->distinct();
if (!empty($filters['state'])) {
$qb->andWhere('pr.id = :state OR IDENTITY(ca.province) = :state')
->setParameter('state', (int) $filters['state']);
}
if (!empty($filters['city'])) {
$qb->andWhere('ci.id = :city OR IDENTITY(ca.city) = :city')
->setParameter('city', (int) $filters['city']);
}
نکته مهم درباره دادهها (از کامیت قبلی استخراج شده):
Clinic.cityIdهمیشهNULLاست؛ شهر/استان کلینیک درDoctorAddressباclinic_idپر وdoctor_id IS NULLذخیره میشود.DoctorAddress.clinicIdیک ستونintخام است (نه رابطه به Clinic). آدرس کلینیک:clinic_id = <id>وdoctor_id IS NULL.- جدول واسط ManyToMany بین Clinic و Doctor:
clinic_doctor(ستونهایclinic_id,doctor_id).
وظایف
۱. حذف JOIN گران d MEMBER OF cl.doctors و فیلترکردن مکان با subquery شرطی
بهجای دو JOIN دائمی، شرط «پزشک از طریق کلینیک به این شهر/استان متعلق است» را فقط زمانی اضافه کن که فیلتر state یا city واقعاً ست شده باشد، و آن را بهصورت یک subquery هدفمند بنویس که از ایندکس استفاده میکند — نه یک JOIN روی کل جدول کلینیک.
ایده: پزشک d در شهر :city نمایش داده شود اگر یا آدرس مستقیم خودش (d.cities/d.provinces) مطابقت کند، یا عضو کلینیکی باشد که آن کلینیک یک DoctorAddress با همان شهر دارد.
راهحل پیشنهادی — حذف cl/ca از JOINهای ثابت، و افزودن شرط داخل بلوک فیلتر:
$qb = $this->createQueryBuilder('d')
->leftJoin('d.specialties', 's')
->leftJoin('d.provinces', 'pr')
->leftJoin('d.cities', 'ci')
->distinct();
if (!empty($filters['city'])) {
// پزشک مستقیماً در این شهر است، یا عضو کلینیکی است که آدرسش در این شهر است
$clinicCitySub = $this->getEntityManager()->createQueryBuilder()
->select('1')
->from(\App\Doctor\Entity\DoctorAddress::class, 'ca')
->join(\App\Clinic\Entity\Clinic::class, 'cl', Join::WITH, 'ca.clinicId = cl.id')
->where('ca.doctor IS NULL')
->andWhere('IDENTITY(ca.city) = :city')
->andWhere('d MEMBER OF cl.doctors')
->getDQL();
$qb->andWhere(sprintf('ci.id = :city OR EXISTS (%s)', $clinicCitySub))
->setParameter('city', (int) $filters['city']);
}
if (!empty($filters['state'])) {
$clinicStateSub = $this->getEntityManager()->createQueryBuilder()
->select('1')
->from(\App\Doctor\Entity\DoctorAddress::class, 'ca2')
->join(\App\Clinic\Entity\Clinic::class, 'cl2', Join::WITH, 'ca2.clinicId = cl2.id')
->where('ca2.doctor IS NULL')
->andWhere('IDENTITY(ca2.province) = :state')
->andWhere('d MEMBER OF cl2.doctors')
->getDQL();
$qb->andWhere(sprintf('pr.id = :state OR EXISTS (%s)', $clinicStateSub))
->setParameter('state', (int) $filters['state']);
}
نکته: اگر در تست مشخص شد که Doctrine اجازهی ارجاع به alias بیرونی d را داخل subquery (d MEMBER OF cl.doctors) نمیدهد (همان محدودیتی که در کامیت قبلی باعث شد از این روش به JOIN سوییچ شود)، آنگاه بهجای آن از یک subquery مستقیم روی جدول واسط ManyToMany استفاده کن — پزشکانی که در شهر هدف کلینیک دارند را با یک کوئری جدا بگیر:
// جایگزین: لیست id پزشکانِ متعلق به کلینیکهای این شهر را یکبار بگیر
$doctorIdsViaClinic = $this->getEntityManager()->createQueryBuilder()
->select('IDENTITY(cd.doctor)') // یا join مناسب بسته به مدل ManyToMany
->from(\App\Doctor\Entity\DoctorAddress::class, 'ca')
->join(\App\Clinic\Entity\Clinic::class, 'cl', Join::WITH, 'ca.clinicId = cl.id')
->join('cl.doctors', 'cd')
->where('ca.doctor IS NULL')
->andWhere('IDENTITY(ca.city) = :city')
->setParameter('city', (int) $filters['city'])
->getQuery()->getSingleColumnResult();
// سپس در کوئری اصلی:
$qb->andWhere('ci.id = :city OR d.id IN (:clinicDoctorIds)')
->setParameter('city', (int) $filters['city'])
->setParameter('clinicDoctorIds', $doctorIdsViaClinic ?: [0]);
این روش دوم تضمین میکند هیچ subquery همبستهی per-row اجرا نشود؛ یک کوئری کوچک جداگانه + یک IN (...) ساده. اگر تعداد پزشکان کلینیکی زیاد نیست (که نیست)، این سریعترین گزینه است.
هر کدام از دو روش که کار کرد و سریع بود را نگه دار؛ روش دوم (IN) را بهعنوان پیشفرض امن ترجیح بده.
۲. اطمینان از اینکه کوئری بدون فیلتر مکان دستنخورده بماند
بعد از تغییر، وقتی نه state و نه city ست نشدهاند، کوئری باید دقیقاً مثل قبل از کامیت باشد (فقط specialties/provinces/cities JOIN + فیلترهای name/gender/degree/active). هیچ JOIN یا subquery اضافهای نباید در این مسیر اجرا شود. این پرتکرارترین حالت سرچ است.
۳. اندازهگیری و تأیید بهبود سرعت
قبل و بعد از تغییر، زمان اجرای کوئری را با profiler یا لاگ مقایسه کن:
# دیدن SQL تولیدشده و زمان
ddev exec php bin/console doctrine:query:dql "<DQL تولیدشده>" # یا از Symfony profiler/توابع لاگ
سناریوهای تست (هر کدام باید سریع باشند):
- سرچ بدون فیلتر (فقط pagination) — باید به سرعت pre-commit برگردد
- سرچ با
nameتنها - سرچ با
city=104(اصفهان) — باید همان پزشکِ کلینیکیِ بدون آدرس مستقیم را هم نشان دهد (regression قبلی) - سرچ با
stateتنها
۴. بررسی نیاز به ایندکس دیتابیس
اگر بعد از بهینهسازی DQL هنوز کندی هست، بررسی کن آیا ستونهای زیر ایندکس دارند و در صورت نبود، migration اضافه کن:
doctor_address.clinic_iddoctor_address.doctor_iddoctor_address.city_id/doctor_address.province_id- جدول واسط
clinic_doctorروی(clinic_id, doctor_id)
ddev exec php bin/console doctrine:migrations:diff --no-interaction # فقط اگر ایندکس به Entity اضافه شد
ddev exec php bin/console doctrine:migrations:migrate --no-interaction
ایندکس را با #[ORM\Index(...)] روی Entity تعریف کن، نه SQL خام، تا migration درست تولید شود.
نکات مهم
- رفتار باید حفظ شود: پزشکی که هیچ
DoctorAddressباdoctor_idندارد ولی عضو کلینیکی است، باید در شهر/استان آن کلینیک ظاهر شود. این دقیقاً همان باگی است که کامیت اصلی حل کرد؛ نباید بشکند. - محدودیت Doctrine: ارجاع به alias بیرونی (
d) داخل subquery همبسته در DQL در بعضی حالتها پشتیبانی نمیشود — به همین دلیل کامیت قبلی به JOIN سوییچ کرد. اگر روش EXISTS خطا داد، مستقیم سراغ روش دوم (محاسبهیdoctorIdsجدا +IN) برو. این را در پیادهسازی تست کن، حدس نزن. ->distinct()فقط وقتی لازم است که JOINهای to-many (مثلspecialties/cities) ردیف تکراری بسازند. اگر فیلتر مکان را از JOIN خارج کردی،distinctهمچنان برایspecialtiesلازم است — نگهش دار اما مطمئن شو که محصول دکارتی بزرگتری نمیسازد.Paginatorبرای شمارشtotalیک کوئری جدا اجرا میکند؛ هر پیچیدگیای که به کوئری اصلی اضافه کنی، در شمارش هم تکرار میشود. کوئری را تا حد ممکن ساده نگه دار.- پارامتر خالی: اگر
doctorIdsخالی شد، از[0](یا یک id ناموجود) بهعنوان مقدارINاستفاده کن تا SQL معتبر بماند و نتیجهی خالی برگردد. - مستندات: اگر قرارداد response یا رفتار فیلتر تغییر کرد،
docs/api/doctor.mdرا در همان session بهروز کن (قانون استاندارد پروژه). اگر فقط پرفورمنس داخلی عوض شد و قرارداد ثابت ماند، نیازی به تغییر مستند نیست. - بدون N+1: مطمئن شو راهحل، کوئری اضافهی per-doctor تولید نمیکند (مثلاً lazy-load روی
addressesدر حلقه). فقط همان یک کوئری اصلی + حداکثر یک کوئری کمکی برایdoctorIds.