Laravel متعدد المستأجرين، الجزء الثاني: queues لكل tenant
كيف Horizon بيتعامل مع عدالة الـ tenants لما واحد بحاول يستحوذ على الـ workers.
جزء منmulti-tenant-laravelجاري تحميل المقال…
كيف Horizon بيتعامل مع عدالة الـ tenants لما واحد بحاول يستحوذ على الـ workers.
جزء منmulti-tenant-laravelجاري تحميل المقال…
مستأجر واحد يرفع استيراداً من ٢٠٠٠٠٠ صفّ. الـ ٩٩ الآخرون يفتحون التطبيق وينتظرون انتهاء مزامنتهم اليوميّة. مع queue عالميّة واحدة، الـ ٩٩ ينتظرون. مع queues لكلّ مستأجر، المستأجر الصاخب يُبطئ نفسه فقط.
هذا هو الجزء الثاني من سلسلة Laravel متعدّد المستأجرين، عمّا يحدث للـ queues حين يكون لديك عملاء، لا مجرّد مستخدمين.
من علبة الكرتون، يعطيك Horizon queue لكلّ اسم (default، emails،
sync) و pool عمّال لكلّ queue. داخل queue، الوظائف FIFO. إن أضاف
المستأجر A ٥٠٠٠٠ وظيفة مزامنة في ٠٩:٠٠ والمستأجر B ٢٠٠ في ٠٩:٠١، تجلس
وظائف B خلف وظائف A. العمّال يطحنون عبر A أوّلاً.
هذا عادل إن فكّرت في الوظائف كوحدة العمل. غير عادل إن فكّرت في المستأجرين كوحدة العمل، وهو ما يجب أن تفعل، لأنّ عملاءك يفعلون.
نريد ضماناً مثل:
كلّ مستأجر نشط يحقّق على الأقلّ N من وظائف التقدّم في الدقيقة، بغضّ النظر عن عمق queue أيّ مستأجر آخر.
round-robin عبر المستأجرين النشطين يأخذك معظم الطريق، مع تعديلَين:
تحديداً، نقسّم الـ queue الأساسيّة حسب tenant id ونوجّه dispatcher مخصّصاً عبرها:
namespace App\Queue;
use Illuminate\Contracts\Queue\Job;
use Illuminate\Queue\Worker;
class TenantFairWorker extends Worker {
private array $rotation = [];
protected function getNextJob($connection, $queue): ?Job {
$tenantQueues = $this->activeTenantQueues();
if (empty($tenantQueues)) {
return null;
}
// round-robin: انزع الرأس، انظر إليه، ادفعه إلى الذيل.
$tenantQueue = array_shift($tenantQueues);
$tenantQueues[] = $tenantQueue;
$this->rotation = $tenantQueues;
return $connection->pop($tenantQueue);
}
private function activeTenantQueues(): array {
// عدّ فقط queues لها وظيفة واحدة على الأقلّ. مُخزَّن لـ ٢٥٠ مللي ثانية.
return Cache::remember('tenant-queues:active', 0.25, function () {
return Redis::keys('queues:tenant-*')
->filter(fn ($k) => Redis::llen($k) > 0)
->values()
->toArray();
});
}
}بضع قرارات تستحقّ إخراجها:
نسمّي الـ queues بـ tenant-{id} (مثل tenant-42). كلّها تعيش على
نفس instance Redis تحت نفس الاتّصال. حدود المستأجر هي اسم الـ queue
بحتاً. هذا يعني أنّنا لا نزال نحصل على مجموعة مقاييس واحدة، مجموعة
لوحات واحدة، مكان واحد للنظر.
النسخ السابقة حاولت توزيع الوظائف عبر الـ queues بالتناسب مع «من يحتاج العمل أكثر». ذلك المنطق انتهى بتكرار عمل العامل. القطع الأنظف: المنتجون يدفعون دائماً إلى queue مستأجرهم، والعامل يقرّر من يُخدَم تالياً.
تعداد مفاتيح Redis في كلّ pop وظيفة جيّد لعشرة queues، مؤلم لألفَين. ذاكرة مؤقّتة بـ ٢٥٠ مللي ثانية على المجموعة النشطة تمتصّ الحمل دون أن تجعل round-robin يبدو غير متساوٍ. (بمعدّل وظيفة ١٠٠/ث، تخدم ٢٥ وظيفة من نفس اللقطة قبل التحديث. هذا جيّد.)
round-robin وحده لديه مرض دقيق: إن كانت وظيفة مستأجر سريعة (ميكروثوانٍ) وأخرى بطيئة (ثوانٍ)، البطيء يجوع. الحدّ يصلح هذا. بعد خدمة K وظائف من نفس المستأجر في دوران واحد، يفرض العامل دوراناً بغضّ النظر عمّا إذا كان الـ queue فارغاً:
protected int $maxConsecutiveJobsPerTenant = 5;
private string $lastTenantQueue = '';
private int $consecutive = 0;
protected function shouldForceRotate(string $tenantQueue): bool {
if ($tenantQueue === $this->lastTenantQueue) {
$this->consecutive++;
return $this->consecutive >= $this->maxConsecutiveJobsPerTenant;
}
$this->lastTenantQueue = $tenantQueue;
$this->consecutive = 1;
return false;
}K = ٥ كانت النقطة المثاليّة لنا. K = ١ تخبّط لأنّ pop Redis ليس مجّاناً، و K = ٥٠ جوّع المستأجرين الصغار حين يكون كبير في منتصف وظيفة.
بدون تدخّل، مستأجر تفشل وظائفه كلّها وتُعاد محاولتها سيهيمن على المجموعة النشطة: في كلّ دورة، queue الخاصّ به لديه عمل، فيستمرّ round-robin بتضمينه، والوظيفة الفاشلة تُعاد محاولتها للأبد.
الإصلاح: آليّة حجر صحّيّ لكلّ مستأجر. إن فشلت أكثر من X وظيفة من مستأجر في Y دقيقة، ينتقل queue الخاصّ به إلى «مسار بطيء» يُؤخذ منه عيّنة بمعدّل ١/١٠. العملاء الذين يبلّغون عن مشاكل يصبحون فجأة هم من يُخدَم، بينما يتوقّف مستأجرو حلقات الفشل الهادئة عن الاحتكار.
استيراد ٢٠٠٠٠٠ صفّ يعمل كوظيفة واحدة يحجب العامل طوال الاستيراد. حتّى مع cap-per-cycle، «وظيفة واحدة» تعني «الحدّ لا يُفعَّل». الإصلاح في جانب المنتج: الاستيرادات الكبيرة يجب تقسيمها إلى وظائف أصغر كثيرة لحظة الإرسال. نفرض هذا بـ wrapper:
class TenantImport implements ShouldQueue {
public int $maxRowsPerJob = 500;
public function handle() {
if ($this->rows->count() > $this->maxRowsPerJob) {
// قطّع نفسك إلى نسخ أصغر.
foreach ($this->rows->chunk($this->maxRowsPerJob) as $chunk) {
static::dispatch($chunk)->onQueue("tenant-{$this->tenantId}");
}
return;
}
$this->processChunk($this->rows);
}
}مقاييس Horizon تجمع الوظائف عبر كلّ الـ queues. مع queues لكلّ مستأجر، ذلك المجموع يخفي التوزيع. أضفنا عرضاً مستأجراً بمستأجر: الوظائف الجارية، معدّل الفشل، p99 وقت الانتظار. بدونه، «الـ queue سليم» يمكن أن يكون صحيحاً في المتوسّط وخاطئاً للعميل المحدّد الذي يتّصل.
مستأجر «جار صاخب» لم يعد يؤثّر على الجميع. التصعيد الذي كان يأتي كـ «التطبيق كلّه بطيء» يأتي الآن كـ «نحن بطيئون اليوم، كلّ مستأجر آخر بخير» — وهو تشخيص أسرع بكثير.
الكلفة كانت قرابة أسبوع من الهندسة بالإضافة إلى الانضباط التشغيليّ لتقطيع الوظائف المُرسَلة. يستحقّ من اليوم الأوّل.
الجزء التالي عن feature flags لكلّ مستأجر، البنية التي تتسع لأكثر من أربعين flag والبنية التي لا تفعل.
صفحة السلسلة تتابع البقيّة.