Files
clinicpro/.claude/prompt/fix-doctor-search-performance.md
T

13 KiB
Raw Blame History

رفع افت شدید سرعت سرچ پزشک (regression کامیت city-via-clinic)

پروژه

clinicpro (Backend — Doctrine DQL در DoctorRepository)

زمینه

در کامیت 8b9130011ae370fa3bad152612e88ec350f99fc2 برای حل مشکل «پزشکی که آدرس مستقیم ندارد و از آدرس کلینیک استفاده می‌کند، در شهر کلینیک نمایش داده نمی‌شد»، به متد findWithFilters() در DoctorRepository دو leftJoin اضافه شد. این تغییر مشکل شهر را حل کرد اما سرعت سرچ سایت عمومی (nobat724_frontGET /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')

علت دقیق کندی:

  1. d MEMBER OF cl.doctors در شرط JOIN → Doctrine آن را به یک subquery همبسته (EXISTS (SELECT ... FROM clinic_doctor ...) per row) ترجمه می‌کند که روی کل جدول Clinic برای هر ردیف پزشک ارزیابی می‌شود. این JOIN بدون شرط محدودکننده روی cl است، یعنی عملاً یک نیمه‌cross-join.
  2. این JOIN همیشه اجرا می‌شود — حتی وقتی هیچ فیلتر state/city ست نشده (یعنی اکثر سرچ‌ها که فقط name یا اصلاً فیلتری ندارند). هزینه را بی‌دلیل به همه‌ی کوئری‌ها تحمیل می‌کند.
  3. ->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_id
  • doctor_address.doctor_id
  • doctor_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.