تصدير بيانات المستأجر بدون كسر الـ DB
تصدير بمستوى GDPR على بنية مشتركة. الـ query plan، الـ throttle، والإيصال.
جاري تحميل المقال…
تصدير بمستوى GDPR على بنية مشتركة. الـ query plan، الـ throttle، والإيصال.
جاري تحميل المقال…
يضغط مستأجر «صدّر كلّ بياناتي». بعد ثلاث دقائق، كلّ مستأجر آخر على نفس قاعدة البيانات يحصل على timeout. خطّة الاستعلام السذجة فتحت transaction، مسحت كلّ جدول ذي صلة، وحملت أقفالاً مشتركة طوال المسير. كلّما طال تشغيل الصادرات، تكدّست استعلامات أكثر خلفها.
الصادرات تكسر قواعد البيانات المشتركة أكثر ممّا يظنّ الناس. هذه هي البنية التي لا تفعل.
«أعطِ المستخدم كلّ بياناته» على نمط GDPR يتطلّب:
ما يفوّته الناس هو أنّ النقطتَين ١ و٤ تتعارضان على قاعدة بيانات مشتركة. النقطة ١ هي «صفوف كثيرة»؛ النقطة ٤ هي «صفوف كثيرة، بسرعة، بدون قفل الجدول». هذه هي المشكلة كلّها.
أوّل وظيفة صادرات شحنّاها فعلت الأمر البديهيّ:
DB::transaction(function () use ($tenant) {
$bundle = [];
foreach (TENANT_TABLES as $table) {
$bundle[$table] = DB::table($table)
->where('tenant_id', $tenant->id)
->get();
}
return $bundle;
});ثلاث مشاكل، بترتيب الشدّة:
get() يُجسِّد الجدول بأكمله لذلك المستأجر في
الذاكرة. مستأجر بـ ٢٠٠٠٠٠ صفّ فاتورة يُفجّر ذاكرة العامل.الإصلاح يتضمّن التخلّي عن كلّ من تلك الثلاثة.
class TenantExport implements ShouldQueue {
public function handle(): void {
$bundle = new ZipArchive();
$bundle->open($this->path, ZipArchive::CREATE);
foreach ($this->config->tables as $table => $cfg) {
$this->dumpTable($table, $cfg, $bundle);
}
$bundle->close();
Notification::route('mail', $this->tenant->email)
->notify(new ExportReady($this->path));
}
private function dumpTable(string $table, array $cfg, ZipArchive $zip): void {
$stream = fopen('php://memory', 'r+');
$writer = $this->csvWriter($stream, $cfg['columns']);
DB::table($table)
->where('tenant_id', $this->tenant->id)
->orderBy('id')
->chunkById(2000, function ($rows) use ($writer) {
foreach ($rows as $row) {
$writer->insertOne((array) $row);
}
usleep(200_000); // ٢٠٠ مللي ثانية تنفّس، كلّ chunk
});
rewind($stream);
$zip->addFromString("{$table}.csv", stream_get_contents($stream));
fclose($stream);
}
}خمسة تغييرات من النسخة المكسورة:
الحزمة متّسقة في النهاية. صفّ يُنشَأ خلال الصادرات قد يكون أو لا يكون فيها، حسب التوقيت. نقبل هذا. العملاء يهتمّون بالصادرات خلال ٢٤ ساعة؛ لا يهتمّون بأنّ اللقطة بدقّة الثانية. النصّ القانونيّ في سياسة الخصوصيّة لدينا يقول «الحالة الحاليّة وقت الصادرات، قد لا تتضمّن العمليّات من الستّين ثانية الأخيرة».
chunkById، لا chunk#chunk في Laravel يستخدم OFFSET ... LIMIT، الذي يصبح أبطأ بشكل
تربيعيّ على الجداول الكبيرة. chunkById يمشي
WHERE id > last_seen ORDER BY id LIMIT N. وقت خطّيّ، خطّة متوقّعة،
يستخدم الـ primary index.
league/csv يكتب في memory stream، ثمّ يُفرَّغ إلى الـ zip. على
المستأجرين الكبار جدّاً نبدّل إلى ملفّ على القرص (tmpfile())؛ بقيّة
الكود لا يتغيّر.
usleep(200ms) لكلّ chunk#هذا هو الـ throttle. بدونه، الصادرات تحرق قاعدة البيانات. معه، الصادرات تستخدم تقريباً ١/٦ من IO المتاح في الذروة. وصلنا إلى ٢٠٠ مللي ثانية بالتنصيف حتّى توقّف المستأجرون الآخرون عن الإبلاغ عن بطء خلال الصادرات.
وظيفة الصادرات تكتب صفّها الخاصّ إلى جدول tenant_exports:
tenant_id، path، row_count، started_at، finished_at،
requested_by. الإيصال هو ما يعرضه دعم العملاء حين يدّعي مستأجر أنّ
صادراته لم تكتمل. بدونه، أنت تتجادل من الذاكرة.
الجداول في الحزمة ليست schema الخامّ. نُسقط إلى «schema صادرات مستقرّ» يُحفَظ بجانب الجدول:
const EXPORT_TABLES = [
'invoices' => [
'columns' => ['id', 'number', 'created_at', 'amount', 'status', 'notes'],
'transform' => InvoiceExportTransform::class,
],
// ...
];الـ transform يملأ الحقول غير المُطبَّعة. للفواتير يربط اسم جهة الاتّصال، اسم الشركة، البنود كعمود JSON. للعميل الذي ينزّل الحزمة، «فاتورة ٤٧٢٩ لـ Acme Corp بـ ١٤٥٠ ر.س. في ٢٠٢٦-٠٥-٠٨» أكثر فائدة من «صفّ في invoices، صفّ في contacts، صفّ في line_items، ابحث عنه بنفسك».
ثلاث فئات:
وثّق هذا في نصّ الخصوصيّة. العملاء الذين يسألون لماذا لا تحوي صادراتهم العنصر X أسهل في التعامل حين يكون X في قائمة منشورة.
وظيفة الصادرات، على مستأجر بـ ٥٠٠٠٠ فاتورة، تستغرق حوالي ٤ دقائق، تذروة عند ١٨٪ CPU قاعدة البيانات، ولا تؤثّر على عمليّات أيّ مستأجر آخر. شغّلنا ستّ صادرات متزامنة عبر مستأجرين مختلفين بدون تدهور قابل للقياس على مسار القراءة الحيّ.
الجزء التالي من سلسلة تعدّد المستأجرين عن سجلّات تدقيق تتّسع فعلاً، وهي مشكلة متعلّقة بشدّة بقيود مختلفة جدّاً. صفحة السلسلة تتابع البقيّة.