fix: improve doctor search performance by optimizing query filters in findWithFilters method
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
# رفع افت شدید سرعت سرچ پزشک (regression کامیت city-via-clinic)
|
||||
|
||||
## پروژه
|
||||
|
||||
`clinicpro` (Backend — Doctrine DQL در `DoctorRepository`)
|
||||
|
||||
## زمینه
|
||||
|
||||
در کامیت `8b9130011ae370fa3bad152612e88ec350f99fc2` برای حل مشکل «پزشکی که آدرس مستقیم ندارد و از آدرس کلینیک استفاده میکند، در شهر کلینیک نمایش داده نمیشد»، به متد `findWithFilters()` در `DoctorRepository` دو `leftJoin` اضافه شد. این تغییر مشکل شهر را حل کرد اما **سرعت سرچ سایت عمومی (`nobat724_front` → `GET /api/v1/doctor`) را بهشدت پایین آورد**. این متد قلب صفحه `/doctors` است و در هر تغییر فیلتر/شهر/صفحه فراخوانی میشود، پس کندی آن مستقیماً تجربه کاربر را خراب میکند. پرفورمنس برای این پروژه اولویت بالا دارد.
|
||||
|
||||
## مشکل / هدف
|
||||
|
||||
دو `leftJoin` اضافهشده باعث افت سرعت میشوند:
|
||||
|
||||
```php
|
||||
->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()`:
|
||||
|
||||
```php
|
||||
$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های ثابت، و افزودن شرط داخل بلوک فیلتر:
|
||||
|
||||
```php
|
||||
$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 استفاده کن — پزشکانی که در شهر هدف کلینیک دارند را با یک کوئری جدا بگیر:
|
||||
|
||||
```php
|
||||
// جایگزین: لیست 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 یا لاگ مقایسه کن:
|
||||
|
||||
```bash
|
||||
# دیدن 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)`
|
||||
|
||||
```bash
|
||||
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`.
|
||||
@@ -49,17 +49,25 @@ class DoctorRepository extends ServiceEntityRepository
|
||||
->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']);
|
||||
$stateId = (int) $filters['state'];
|
||||
$clinicIds = $this->doctorIdsViaClinicLocation('province', $stateId);
|
||||
$qb->andWhere('pr.id = :state' . ($clinicIds ? ' OR d.id IN (:stateClinicDoctorIds)' : ''))
|
||||
->setParameter('state', $stateId);
|
||||
if ($clinicIds) {
|
||||
$qb->setParameter('stateClinicDoctorIds', $clinicIds);
|
||||
}
|
||||
}
|
||||
if (!empty($filters['city'])) {
|
||||
$qb->andWhere('ci.id = :city OR IDENTITY(ca.city) = :city')
|
||||
->setParameter('city', (int) $filters['city']);
|
||||
$cityId = (int) $filters['city'];
|
||||
$clinicIds = $this->doctorIdsViaClinicLocation('city', $cityId);
|
||||
$qb->andWhere('ci.id = :city' . ($clinicIds ? ' OR d.id IN (:cityClinicDoctorIds)' : ''))
|
||||
->setParameter('city', $cityId);
|
||||
if ($clinicIds) {
|
||||
$qb->setParameter('cityClinicDoctorIds', $clinicIds);
|
||||
}
|
||||
}
|
||||
if (!empty($filters['specialty'])) {
|
||||
$qb->andWhere('s.id = :specialty')->setParameter('specialty', (int) $filters['specialty']);
|
||||
@@ -95,6 +103,31 @@ class DoctorRepository extends ServiceEntityRepository
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* IDs of doctors who belong to a clinic whose own address (DoctorAddress with
|
||||
* doctor IS NULL) is in the given city/province. Used so doctors without a
|
||||
* direct address still surface under their clinic's location, without a
|
||||
* per-row correlated subquery on the main search.
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
private function doctorIdsViaClinicLocation(string $field, int $locationId): array
|
||||
{
|
||||
$column = $field === 'city' ? 'ca.city' : 'ca.province';
|
||||
|
||||
$rows = $this->getEntityManager()->createQueryBuilder()
|
||||
->select('cd.id AS doctorId')
|
||||
->from(Clinic::class, 'cl')
|
||||
->join('cl.doctors', 'cd')
|
||||
->join(DoctorAddress::class, 'ca', Join::WITH, 'ca.clinicId = cl.id AND ca.doctor IS NULL')
|
||||
->where(sprintf('IDENTITY(%s) = :loc', $column))
|
||||
->setParameter('loc', $locationId)
|
||||
->getQuery()
|
||||
->getScalarResult();
|
||||
|
||||
return array_values(array_unique(array_map('intval', array_column($rows, 'doctorId'))));
|
||||
}
|
||||
|
||||
public function findByClinicWithFilters(int $clinicId, array $filters): array
|
||||
{
|
||||
$page = max(1, (int) ($filters['page'] ?? 1));
|
||||
|
||||
Reference in New Issue
Block a user