تحسين أداء Laravel العملي
الأداء هو شريان حياة SaaS — سيغادر المستخدمون إذا اضطروا للانتظار 3 ثوانٍ، والاستعلامات البطيئة قد تحرق مئات الدولارات من رسوم قاعدة البيانات في يوم واحد.
1. ما ستتعلمه
- تحسين استعلامات Eloquent: Eager Loading / Chunk / Cursor / Lazy
- استراتيجية التخزين المؤقت: تخزين مؤقت للمسارات/التهيئة/العروض + تخزين مؤقت للبيانات في Redis
- تحسين قاعدة البيانات: استراتيجيات الفهرسة / تحليل EXPLAIN / فصل القراءة والكتابة
- OPcache وضبط PHP-FPM
- اختبار الحمل: اختبار الحمل بـ k6 / Apache Bench وتحليل الاختناقات
2. القصة الحقيقية لشركة SaaS سريعة النمو
(1) المشكلة: زمن استجابة ShopMetrics ارتفع من 200 مللي ثانية إلى 5 ثوانٍ
نما عدد مستخدمي ShopMetrics من 1,000 إلى 50,000، وزادت بيانات الطلبات من 100,000 إلى 5 ملايين. لاحظت Alice أن لوحة المعلومات تستغرق 5 ثوانٍ للتحميل، ورأى Bob أن رسوم قاعدة البيانات الشهرية قفزت من 50 دولارًا إلى 500 دولار — كل ذلك بسبب الاستعلامات البطيئة التي تحرق الميزانية. كشف تحليل Charlie أن استعلامات N+1، والفهارس المفقودة، وغياب التخزين المؤقت كانت المتهمين الرئيسيين الثلاثة.
(2) أساليب التحسين المنهجية
من خلال أربع طبقات من التحسين — الاستعلامات، التخزين المؤقت، الفهارس، وOPcache — كل طبقة قلصت زمن الاستجابة إلى النصف، ليصل في النهاية من 5 ثوانٍ إلى 200 مللي ثانية.
قبل: 5000مللي ثانية (استعلامات N+1، بدون تخزين مؤقت، بدون فهارس)
الخطوة 1: 2500مللي ثانية (eager loading، إصلاح N+1)
الخطوة 2: 500مللي ثانية (إضافة فهارس + تحسين الاستعلام)
الخطوة 3: 200مللي ثانية (تخزين مؤقت Redis + OPcache)
الخطوة 4: 100مللي ثانية (CDN + نسخة قراءة)
(3) النتيجة
بعد تحسين Bob، انخفضت تكاليف قاعدة البيانات من 500 دولار/شهر إلى 150 دولار/شهر، وارتفع معدل الاحتفاظ بالمستخدمين من 70% إلى 90%.
3. تحسين استعلامات Eloquent
(1) مشكلة استعلامات N+1
// ❌ N+1: استعلام واحد للمستأجرين + N استعلام للمتاجر
$tenants = Tenant::all();
foreach ($tenants as $tenant) {
echo $tenant->shops->count(); // استعلام إضافي واحد لكل مستأجر!
}
// الإجمالي: 1 + N استعلام
// ✅ Eager loading: استعلامان فقط
$tenants = Tenant::with('shops')->get();
foreach ($tenants as $tenant) {
echo $tenant->shops->count(); // بدون استعلام إضافي
}
// الإجمالي: 2 استعلام
(2) مقارنة طرق تحسين الاستعلامات
| الطريقة | حالة الاستخدام | استخدام الذاكرة | الوصف |
|---|---|---|---|
all() |
كمية بيانات صغيرة | عالي | تحميل الكل في الذاكرة دفعة واحدة |
chunk() |
تكرار بيانات كبيرة | منخفض | معالجة في كتل من N سجل |
chunkById() |
تكرار بيانات كبيرة (أكثر استقرارًا) | منخفض | تقسيم حسب المعرف، غير متأثر بتغييرات البيانات |
cursor() |
قراءة قائمة على التدفق | منخفض جدًا | قراءة سطر بسطر قائمة على المولد |
lazy() |
بيانات كبيرة، لكن تحتاج عمليات تجميع | متوسط | تحميل كتل عند الطلب |
each() |
تكرار + معالجة | منخفض | chunk + استدعاء راجع |
(1) ▶ مثال: إصلاح N+1 في تحليل طلبات ShopMetrics
// ❌ قبل: لوحة المعلومات تحمل في 5 ثوانٍ
// المتحكم: 1 + 3*N استعلام لكل مستأجر
public function dashboard(Tenant $tenant)
{
$orders = $tenant->orders()
->whereMonth('created_at', now()->month)
->get(); // استعلام 1
$total = $orders->sum('total');
$byShop = $orders->groupBy('shop_id'); // تحميل كسول للمتاجر: N استعلام
$topProducts = $orders->flatMap->items; // تحميل كسول للعناصر: N استعلام
$customers = $orders->pluck('customer'); // تحميل كسول للعميل: N استعلام
}
// ✅ بعد: لوحة المعلومات تحمل في 500 مللي ثانية
// المتحكم: 4 استعلامات فقط
public function dashboard(Tenant $tenant)
{
$orders = $tenant->orders()
->with(['shop:id,name', 'items.product:id,name,price', 'customer:id,name'])
->whereMonth('created_at', now()->month)
->select(['id', 'shop_id', 'customer_id', 'total', 'created_at'])
->get();
$total = $orders->sum('total');
$byShop = $orders->groupBy('shop.name');
$topProducts = $orders->flatMap->items->take(10);
$customers = $orders->pluck('customer.name');
}
الناتج:
// تم التنفيذ بنجاح
(2) ▶ مثال: استخدام chunks و cursors مع مجموعات بيانات كبيرة
// معالجة 5 ملايين طلب بدون تجاوز الذاكرة
// الخيار 1: chunk (يعالج 500 في كل مرة)
Order::where('tenant_id', $tenant->id)
->chunk(500, function ($orders) {
foreach ($orders as $order) {
$this->calculateOrderMetrics($order);
}
});
// الخيار 2: chunkById (أكثر استقرارًا مع الكتابة المتزامنة)
Order::where('tenant_id', $tenant->id)
->select(['id', 'total', 'status'])
->chunkById(500, function ($orders) {
$metrics = $orders->groupBy('status')->map->sum('total');
Cache::put("metrics:{$tenant->id}", $metrics, 3600);
});
// الخيار 3: cursor (تدفق، أقل ذاكرة)
$total = 0;
foreach (Order::where('tenant_id', $tenant->id)->cursor() as $order) {
$total += $order->total;
}
// الخيار 4: lazy (طرق المجموعة متاحة)
$highValue = Order::where('tenant_id', $tenant->id)
->lazy(500)
->filter(fn ($o) => $o->total > 1000)
->count();
الناتج:
// تم التنفيذ بنجاح
4. استراتيجية التخزين المؤقت
(1) هرمية التخزين المؤقت في Laravel
flowchart TD
A[طلب] --> B{تخزين مؤقت للمسارات؟}
B -->|إصابة| C[مسارات مخزنة مؤقتًا]
B -->|فقد| D[تحليل المسارات]
D --> E{تخزين مؤقت للتهيئة؟}
C --> E
E -->|إصابة| F[تهيئة مخزنة مؤقتًا]
E -->|فقد| G[تحميل .env + ملفات التهيئة]
G --> H{تخزين مؤقت للبيانات؟}
F --> H
H -->|إصابة| I[إرجاع البيانات المخزنة مؤقتًا]
H -->|فقد| J[استعلام DB + تخزين النتيجة مؤقتًا]
I --> K[استجابة]
J --> K
(2) أنواع التخزين المؤقت وحالات الاستخدام
| نوع التخزين المؤقت | الأمر/الطريقة | حالة الاستخدام | الاستمرارية |
|---|---|---|---|
| تخزين مؤقت للتهيئة | config:cache |
تهيئة بيئة الإنتاج | طويل الأمد (حتى المسح) |
| تخزين مؤقت للمسارات | route:cache |
مسارات كثيرة، عمليات إغلاق قليلة | طويل الأمد (حتى المسح) |
| تخزين مؤقت للعروض | view:cache |
تجميع عروض Blade | طويل الأمد (حتى المسح) |
| تخزين مؤقت للبيانات | Cache::put() |
نتائج الاستعلام/بيانات التقارير | انتهاء صلاحية TTL |
| تخزين مؤقت للاستعلام | remember() |
استعلامات متكررة | انتهاء صلاحية TTL |
| OPcache | php.ini | كود PHP البايتكود | صالح حتى إعادة التشغيل |
(1) ▶ مثال: استراتيجية التخزين المؤقت بـ Redis في ShopMetrics
// app/Services/DashboardService.php
class DashboardService
{
public function getOverview(Tenant $tenant): array
{
return Cache::remember(
"dashboard:{$tenant->id}:overview",
now()->addMinutes(15),
fn () => $this->calculateOverview($tenant)
);
}
public function getTopProducts(Tenant $tenant, string $period = '7d'): Collection
{
return Cache::remember(
"dashboard:{$tenant->id}:top-products:{$period}",
now()->addHours(1),
fn () => $this->calculateTopProducts($tenant, $period)
);
}
public function getRevenueChart(Tenant $tenant, string $granularity = 'daily'): array
{
return Cache::remember(
"dashboard:{$tenant->id}:revenue:{$granularity}",
now()->addMinutes(30),
fn () => $this->calculateRevenueChart($tenant, $granularity)
);
}
public function invalidateTenantCache(Tenant $tenant): void
{
$prefix = "dashboard:{$tenant->id}";
// KEYS في Redis بطيء مع البيانات الكبيرة؛ استخدم العلامات أو المسح بالبادئة
Cache::getStore()->getConnection()->del(
...Cache::getStore()->getConnection()->keys("{$prefix}:*")
);
}
}
// المتحكم
class DashboardController extends Controller
{
public function __construct(
private DashboardService $dashboard
) {}
public function index(Tenant $tenant)
{
return response()->json([
'overview' => $this->dashboard->getOverview($tenant),
'top_products' => $this->dashboard->getTopProducts($tenant),
'revenue_chart' => $this->dashboard->getRevenueChart($tenant),
]);
}
}
الناتج:
// تم التنفيذ بنجاح
(3) علامات التخزين المؤقت وانتهاء الصلاحية الدفعي
// تخزين مؤقت مع علامات (مشغلات Redis/قاعدة البيانات فقط)
Cache::tags(['dashboard', "tenant:{$tenant->id}"])->put(
'overview',
$data,
3600
);
// إلغاء جميع تخزين لوحة المعلومات المؤقت لمستأجر
Cache::tags(["tenant:{$tenant->id}"])->flush();
// أو استخدام اصطلاحات مفاتيح التخزين المؤقت مع المسح بالبادئة
Cache::forget("dashboard:{$tenant->id}:overview");
Cache::forget("dashboard:{$tenant->id}:top-products:7d");
Cache::forget("dashboard:{$tenant->id}:revenue:daily");
5. تحسين قاعدة البيانات
(1) استراتيجية الفهرسة
// ترحيل: إضافة فهارس لاستعلامات ShopMetrics
Schema::table('orders', function (Blueprint $table) {
// فهرس عمود واحد
$table->index('tenant_id', 'idx_orders_tenant');
$table->index('created_at', 'idx_orders_date');
$table->index('status', 'idx_orders_status');
// فهرس مركب (الأكثر فائدة للاستعلامات متعددة الشروط)
$table->index(['tenant_id', 'created_at'], 'idx_orders_tenant_date');
$table->index(['tenant_id', 'status', 'created_at'], 'idx_orders_tenant_status_date');
// فهرس فريد
$table->unique(['tenant_id', 'external_id'], 'uniq_orders_tenant_external');
});
(2) مقارنة أنواع الفهارس
| نوع الفهرس | الصياغة | حالات الاستخدام | الوصف |
|---|---|---|---|
| Index | $table->index(col) |
WHERE/ORDER BY | B-tree قياسي |
| Unique | $table->unique(col) |
قيد فريد | B-tree + إزالة التكرار |
| Composite | $table->index([c1,c2]) |
استعلام متعدد الشروط | يتبع البادئة اليسرى |
| FullText | $table->fullText(col) |
بحث نصي | MySQL 8.0+ |
| Spatial | $table->spatialIndex(col) |
استعلام جغرافي | POINT/LINESTRING |
(3) EXPLAIN: تحليل الاستعلامات
-- تحليل استعلام بطيء
EXPLAIN SELECT * FROM orders
WHERE tenant_id = 1 AND status = 'completed'
ORDER BY created_at DESC LIMIT 20;
-- ابحث عن:
-- type: ALL (مسح كامل) → يحتاج فهرس
-- key: NULL (لم يُستخدم فهرس) → يحتاج فهرس
-- rows: رقم كبير → يحتاج تحسين
-- Extra: Using filesort → يحتاج فهرس مركب
(1) ▶ مثال: تشخيص وتحسين الاستعلامات البطيئة في ShopMetrics
-- ❌ قبل: مسح جدول كامل على 5 ملايين صف (3000مللي ثانية)
EXPLAIN SELECT * FROM orders WHERE tenant_id = 42;
-- type: ALL, rows: 5,000,000, key: NULL
-- إضافة فهرس
ALTER TABLE orders ADD INDEX idx_orders_tenant (tenant_id);
-- ✅ بعد: مسح فهرس (5مللي ثانية)
EXPLAIN SELECT * FROM orders WHERE tenant_id = 42;
-- type: ref, rows: 50,000, key: idx_orders_tenant
-- ❌ لا يزال بطيئًا: ترتيب بدون فهرس (500مللي ثانية)
EXPLAIN SELECT * FROM orders
WHERE tenant_id = 42 ORDER BY created_at DESC LIMIT 20;
-- Extra: Using filesort
-- ✅ فهرس مركب (3مللي ثانية)
ALTER TABLE orders ADD INDEX idx_orders_tenant_date (tenant_id, created_at DESC);
الناتج:
CREATE TABLE
(4) فصل القراءة والكتابة
// config/database.php
'mysql' => [
'read' => [
'host' => env('DB_READ_HOST', 'read-replica.shopmetrics.internal'),
],
'write' => [
'host' => env('DB_WRITE_HOST', 'primary.shopmetrics.internal'),
],
'sticky' => true, // القراءة من مضيف الكتابة بعد الكتابة في نفس الطلب
'driver' => 'mysql',
'database' => env('DB_DATABASE', 'shopmetrics'),
// ...
],
6. تحسين OPcache و PHP-FPM
(1) تهيئة OPcache
; php.ini أو php.d/opcache.ini
opcache.enable=1
opcache.memory_consumption=256 ; 256 ميغابايت لتخزين البايتكود المؤقت
opcache.interned_strings_buffer=32 ; 32 ميغابايت للسلاسل
opcache.max_accelerated_files=40000 ; Laravel لديه ~12000 ملف
opcache.validate_timestamps=0 ; إيقاف في الإنتاج (إعادة تعيين يدوية)
opcache.save_comments=1 ; مطلوب لتعليقات Laravel التوضيحية
opcache.jit=1255 ; PHP 8.2 JIT: وضع التتبع
opcache.jit_buffer_size=128M ; 128 ميغابايت مخزن JIT المؤقت
(2) ضبط PHP-FPM
; php-fpm.d/www.conf
pm = dynamic
pm.max_children = 50 ; = (الذاكرة - نظام التشغيل) / المتوسط لكل عملية
pm.start_servers = 10 ; = min_spare + (max - min) / 2
pm.min_spare_servers = 5
pm.max_spare_servers = 20
pm.max_requests = 1000 ; منع تسرب الذاكرة
(3) إعادة تعيين OPcache
# بعد النشر، أعد تعيين OPcache
php artisan optimize:clear
# أو عبر مسار
Route::get('/opcache-clear', function () {
if (app()->environment('production')) {
opcache_reset();
}
return 'OPcache cleared';
})->middleware('auth:api');
(1) ▶ مثال: نص برمجي بنقرة واحدة للتخزين المؤقت للإنتاج في ShopMetrics
#!/bin/bash
# deploy-optimize.sh — تشغيل بعد كل نشر
set -e
echo "→ مسح جميع التخزين المؤقت..."
php artisan optimize:clear
echo "→ تخزين التهيئة مؤقتًا..."
php artisan config:cache
echo "→ تخزين المسارات مؤقتًا..."
php artisan route:cache
echo "→ تخزين العروض مؤقتًا..."
php artisan view:cache
echo "→ تشغيل الترحيلات..."
php artisan migrate --force
echo "→ إعادة تشغيل عمال الطابور..."
php artisan queue:restart
echo "→ مسح OPcache..."
php artisan optimize
echo "✓ اكتمل التحسين!"
الناتج:
# تم تنفيذ الأمر بنجاح
7. اختبار الحمل
(1) مقارنة الأدوات
| الأدوات | اللغة | المزايا | حالات الاستخدام |
|---|---|---|---|
| Apache Bench (ab) | C | بسيط، بدون تبعيات | اختبار إجهاد سريع لنقطة نهاية API واحدة |
| k6 | JS | برمجة مرنة، متوافق مع CI | سيناريوهات معقدة + CI |
| wrk | C | تزامن عالي، موارد منخفضة | اختبار التزامن القصوي |
| JMeter | Java | واجهة رسومية وإضافات غنية | اختبار معقد على مستوى المؤسسة |
(2) نص اختبار الحمل بـ k6
// k6-scripts/dashboard-load.js
import http from 'k6/http';
import { check, sleep } from 'k6';
export const options = {
stages: [
{ duration: '30s', target: 20 }, // تصاعد إلى 20 مستخدم
{ duration: '2m', target: 20 }, // البقاء عند 20
{ duration: '30s', target: 50 }, // تصاعد إلى 50
{ duration: '2m', target: 50 }, // البقاء عند 50
{ duration: '30s', target: 0 }, // انخفاض
],
thresholds: {
http_req_duration: ['p(95)<500'], // 95% من الطلبات < 500مللي ثانية
http_req_failed: ['rate<0.01'], // معدل الخطأ < 1%
},
};
const BASE_URL = 'https://api.shopmetrics.io';
const TOKEN = __ENV.API_TOKEN;
export default function () {
const params = {
headers: { Authorization: `Bearer ${TOKEN}` },
};
// اختبار API لوحة المعلومات
const res = http.get(`${BASE_URL}/api/v1/dashboard`, params);
check(res, {
'status is 200': (r) => r.status === 200,
'response time < 500ms': (r) => r.timings.duration < 500,
'has overview data': (r) => JSON.parse(r.body).overview !== null,
});
sleep(1);
}
(3) أوامر اختبار الحمل السريعة
# Apache Bench: 100 مستخدم متزامن، 1000 طلب إجمالي
ab -n 1000 -c 100 -H "Authorization: Bearer $TOKEN" \
https://api.shopmetrics.io/api/v1/dashboard
# wrk: 50 اتصال لمدة 30 ثانية
wrk -t4 -c50 -d30s -H "Authorization: Bearer $TOKEN" \
https://api.shopmetrics.io/api/v1/dashboard
# k6: تشغيل اختبار الحمل مع فحوصات الحدود
k6 run --env API_TOKEN=$TOKEN k6-scripts/dashboard-load.js
8. مثال شامل: مقارنة قبل وبعد تحسين ShopMetrics
// ============================================
// شامل: تحسين API لوحة المعلومات
// قبل مقابل بعد مع تطبيق جميع التقنيات
// ============================================
// ❌ قبل: لوحة معلومات بطيئة (5 ثوانٍ، 200+ استعلام)
class DashboardController extends Controller
{
public function index(Request $request)
{
$tenant = $request->user()->tenant;
$orders = $tenant->orders()
->whereMonth('created_at', now()->month)
->get(); // يحمل جميع الأعمدة، بدون eager loading
return response()->json([
'total_revenue' => $orders->sum('total'),
'order_count' => $orders->count(),
'top_shops' => $orders->groupBy('shop_id') // N+1 على shop
->map->sum('total')
->sortDesc()
->take(5),
'recent_orders' => $orders->sortByDesc('created_at')
->take(10)
->map(fn ($o) => [
'id' => $o->id,
'customer' => $o->customer->name, // N+1 على customer
'total' => $o->total,
]),
]);
}
}
// ✅ بعد: لوحة معلومات سريعة (100مللي ثانية، 3 استعلامات، تخزين مؤقت)
class DashboardController extends Controller
{
public function index(Request $request)
{
$tenant = $request->user()->tenant;
$data = Cache::remember(
"dashboard:{$tenant->id}:overview",
now()->addMinutes(15),
fn () => $this->buildDashboardData($tenant)
);
return response()->json($data);
}
private function buildDashboardData(Tenant $tenant): array
{
// استعلام محسن واحد مع تجميعات
$stats = DB::table('orders')
->where('tenant_id', $tenant->id)
->whereMonth('created_at', now()->month)
->select([
DB::raw('SUM(total) as total_revenue'),
DB::raw('COUNT(*) as order_count'),
])
->first();
// أفضل المتاجر: استعلام 1
$topShops = DB::table('orders')
->join('shops', 'orders.shop_id', '=', 'shops.id')
->where('orders.tenant_id', $tenant->id)
->whereMonth('orders.created_at', now()->month)
->groupBy('shops.id', 'shops.name')
->orderByDesc('revenue')
->limit(5)
->select('shops.name', DB::raw('SUM(orders.total) as revenue'))
->get();
// الطلبات الأخيرة مع eager loading: استعلام 1
$recentOrders = Order::with('customer:id,name')
->where('tenant_id', $tenant->id)
->select(['id', 'customer_id', 'total', 'created_at'])
->latest()
->limit(10)
->get()
->map(fn ($o) => [
'id' => $o->id,
'customer' => $o->customer->name,
'total' => $o->total,
]);
return [
'total_revenue' => (float) $stats->total_revenue,
'order_count' => (int) $stats->order_count,
'top_shops' => $topShops,
'recent_orders' => $recentOrders,
];
}
}
flowchart LR
subgraph "قبل: 5000مللي ثانية"
A1[200+ استعلام SQL] --> A2[بدون تخزين مؤقت]
A2 --> A3[بدون فهارس]
A3 --> A4[تحميل N+1]
end
subgraph "بعد: 100مللي ثانية"
B1[3 استعلامات SQL] --> B2[تخزين مؤقت Redis 15دقيقة]
B2 --> B3[فهرس مركب]
B3 --> B4[Eager Loading]
end
A4 -->|تحسين| B1
❓ أسئلة شائعة
with())، واستخدم lazy loading فقط عندما تكون متأكدًا أنك لن تصل إلى البيانات المرتبطة. استخدم Laravel Debugbar لمراقبة عدد الاستعلامات — مشاكل N+1 ستكون واضحة فورًا.cursor() يستخدم أقل ذاكرة لكن يمكنه فقط المعالجة سطر بسطر؛ لا يمكنه تخطي الأسطر ولا يدعم أمان الكتابة المتزامنة. chunkById() أكثر استقرارًا ومناسب للمعالجة الدفعية في بيئات الإنتاج. يُستخدم chunkById() في معظم السيناريوهات.EXPLAIN للتحقق مما إذا كان الفهرس يُستخدم فعلاً (عمود key)؛ تحقق مما إذا كان ترتيب أعمدة الفهرس يتبع مبدأ البادئة اليسرى؛ تحقق مما إذا كان الفرز يحدث على مجموعة نتائج كبيرة (filesort)؛ فكر في استخدام فهرس تغطية لتقليل مسح الجدول.opcache.validate_timestamps=1. بخلاف ذلك، لن تسري تغييرات الكود، مما قد يسبب ارتباكًا أثناء التصحيح. تأكد من تفعيله في بيئة الإنتاج واضبطه على validate_timestamps=0.📖 ملخص
- استعلامات N+1 هي قاتل الأداء؛ eager loading يحل المشكلة بسطر واحد
- استخدم
chunkByIdأوcursorمع مجموعات بيانات كبيرة لمنع تجاوز الذاكرة - Redis يخزن نتائج الاستعلام مؤقتًا، باستخدام كل من TTL وانتهاء الصلاحية اليدوي كضمان مزدوج
- الفهارس المركبة تتبع مبدأ البادئة اليسرى؛ تحقق باستخدام EXPLAIN
- تحسين OPcache + PHP-FPM لجعل بيئة الإنتاج تطير
- تحقق من فعالية التحسين من خلال اختبار الحمل، بهدف p95 < 500 مللي ثانية
📝 تمارين
-
تمرين أساسي (⭐): حدد أي استعلام N+1 في ShopMetrics، وأصلحه باستخدام
with()، وقارن عدد الاستعلامات قبل وبعد التحسين (مسجلة باستخدامDB::enableQueryLog()). -
تمرين متقدم (⭐⭐): أضف تخزينًا مؤقتًا بـ Redis (TTL 15 دقيقة) إلى API لوحة معلومات ShopMetrics ونفذ مسحًا تلقائيًا للتخزين المؤقت عند تغير البيانات. اكتب نص k6 لاختبار معدل إصابة التخزين المؤقت.
-
تحدي (⭐⭐⭐): حسّن بالكامل API قائمة طلبات ShopMetrics — أضف فهرسًا مركبًا (
tenant_id + status + created_at)، واستبدل عمليات Collection باستعلامات تجميع DB::table، ونفذ التخزين المؤقت، واستخدمEXPLAINللتحقق من الأداء، بهدف p95 < 200 مللي ثانية.



