دليل تقني

التزامن وسلامة البيانات: الأنماط اللي أنقذت الإنتاج عنا

أنماط تزامن إنتاجية لأنظمة المؤسسات. ملكية الحقول، القفل المتفائل، العقود التعاونية، مخازن الـ idempotency، إدارة الإصدارات، وطبقات حوكمة المعاملات.

24 يناير 202616 دقيقة للقراءةفريق هندسة أورنتس

حالة السباق اللي ما بتشوفها إلا بالإنتاج

حالات السباق غير مرئية بالتطوير. جهازك المحلي يشغّل عملية وحدة. مجموعة الاختبارات تشتغل بالتسلسل. كل شي يشتغل. بعدين تنشر على الإنتاج مع 4 pods للويب و3 pods للـ workers، وعمليتين يعدّلون نفس السجل بنفس الوقت. وحدة تكتب فوق تغييرات الثانية. البيانات تتلف بصمت. ما حدا ينتبه لحد ما يشتكي عميل.

صلّحنا حالات سباق عبر عدة أنظمة مؤسسات: منصات CMS مع 20 محرر وworkers بالخلفية، منصات تجارة مع معالجة طلبات متزامنة، وأنظمة ذكاء اصطناعي مع سير عمل agents متوازية. الأنماط بهالمقال هي اللي صمدت.

للسياق الأوسع، شوف دليل بنية الأنظمة ودليل البنية المدفوعة بالأحداث. لتزامن الـ CMS تحديداً، دليل سير عمل Pimcore يغطي هالأنماط بالتفصيل.

ملكية الحقول: مين يقدر يكتب شو

السبب الجذري لأغلب أخطاء التزامن بالمؤسسات: عدة كاتبين يعدّلون نفس السجل من خلال نفس مسار الحفظ بدون تنسيق.

نظام CMS عنده محررين يكتبون وصف المنتجات وworkers يولّدون صور مصغرة. كلهم ينادون save(). كلهم يحفظون الكائن كامل. إذا الـ worker حفظ بعد ما حمّل بس قبل ما يحفظ المحرر، حفظ المحرر يكتب فوق الصورة المصغرة حقت الـ worker. إذا المحرر حفظ أول، حفظ الـ worker يكتب فوق وصف المحرر.

الحل: خصّص كل حقل لمالك.

field_ownership:
    Product:
        editor_owned:
            - name
            - description
            - images
        system_owned:
            - thumbnail
            - searchIndex
            - checksum
            - lastSyncTimestamp
        shared:
            - categories
            - price
            - availability
المجالالمالكمسار التعديلاستراتيجية التعارض
ملك المحررمستخدمين الأدمنحفظ عاديما في تعارض (بس المحررين يكتبون)
ملك النظامWorkers/تكاملاتطبقة معاملات مع أقفالإعادة محاولة عند التعارض
مشتركالاثنينطبقة معاملات مع حل تعارضاتقابل للضبط: إعادة محاولة، تخطي، دمج

الحقول الملك للمحرر تمر من مسار الحفظ العادي. الحقول الملك للنظام تمر من طبقة معاملات مع أقفال وفحوصات إصدار. الحقول المشتركة تستخدم استراتيجيات حل تعارضات صريحة.

بدون ملكية حقول، أنت تعتمد على الحظ. مع ملكية الحقول، النظام يفرض مين يقدر يكتب شو ويحل التعارضات بشكل حتمي.

القفل المتفائل مع فحوصات الإصدار

القفل المتفائل يفترض إنه التعارضات نادرة. بدل ما يقفل قبل التعديل، يتحقق إذا السجل تغيّر بين التحميل والحفظ.

async function updateWithOptimisticLock(
    productId: string,
    updateFn: (product: Product) => void,
    maxRetries: number = 3,
): Promise<Product> {
    for (let attempt = 0; attempt < maxRetries; attempt++) {
        const product = await productRepo.findById(productId, { force: true });
        const versionBefore = product.versionCount;

        updateFn(product);

        // تحقق إنه الإصدار ما تغيّر قبل الحفظ
        const currentVersion = await productRepo.getVersionCount(productId);
        if (currentVersion !== versionBefore) {
            if (attempt === maxRetries - 1) {
                throw new ConcurrencyError(
                    `Product ${productId} was modified concurrently (version ${versionBefore} -> ${currentVersion})`
                );
            }
            continue; // أعد المحاولة ببيانات جديدة
        }

        await product.save();
        return product;
    }
}

فحص الإصدار مو ذري بهالمثال. للذرية الحقيقية، استخدم دعم قاعدة البيانات:

-- PostgreSQL: فحص إصدار ذري + تحديث
UPDATE products
SET name = $1, version_count = version_count + 1
WHERE id = $2 AND version_count = $3;

-- إذا 0 صفوف تأثرت: تم اكتشاف تعديل متزامن
// TypeORM: @VersionColumn للقفل المتفائل التلقائي
@Entity()
class Product {
    @VersionColumn()
    version!: number;
    // TypeORM يتحقق من الإصدار تلقائياً عند الحفظ
    // يرمي OptimisticLockVersionMismatchError عند التعارض
}

القفل المتفائل يشتغل كويس لما التعارضات نادرة (أقل من 5% من الكتابات). لسيناريوهات التنافس العالي (عدة workers يعالجون نفس السجل)، استخدم الأقفال التعاونية بدلاً.

أقفال العقد التعاونية

لما عدة workers يتنافسون على نفس المورد، القفل التعاوني يسلسل الوصول. بخلاف الـ mutexes الموزعة، الأقفال التعاونية تستخدم دلالات العقد: القفل ينتهي بعد TTL، مما يمنع الـ deadlocks من workers متوقفة.

// Redis: SET NX EX ذري مع ملكية قائمة على الرمز
class RedisLockProvider {
    async acquire(key: string, ttlSeconds: number = 30): Promise<Lock | null> {
        const token = crypto.randomBytes(16).toString('hex');
        const acquired = await this.redis.set(key, token, 'NX', 'EX', ttlSeconds);
        return acquired ? new Lock(key, token, ttlSeconds) : null;
    }

    async release(lock: Lock): Promise<void> {
        // فحص وحذف ذري عبر سكريبت Lua
        const script = `
            if redis.call('get', KEYS[1]) == ARGV[1] then
                return redis.call('del', KEYS[1])
            else
                return 0
            end
        `;
        await this.redis.eval(script, 1, lock.key, lock.token);
    }

    async extend(lock: Lock, ttlSeconds: number): Promise<boolean> {
        const script = `
            if redis.call('get', KEYS[1]) == ARGV[1] then
                return redis.call('expire', KEYS[1], ARGV[2])
            else
                return 0
            end
        `;
        return !!(await this.redis.eval(script, 1, lock.key, lock.token, ttlSeconds));
    }
}

نبضات القفل

الـ TTL لحاله مو كافي. إذا عملية شغّلت أطول من المتوقع، القفل ينتهي وworker ثاني ياخذه. هلق عندك اثنين workers يشتغلون بنفس الوقت.

async function executeWithLock(key: string, operation: () => Promise<void>) {
    const lock = await lockProvider.acquire(key, 30);
    if (!lock) throw new LockError(`Could not acquire lock: ${key}`);

    // نبض: مدّد القفل إذا العملية أخذت وقت أطول
    const heartbeat = setInterval(async () => {
        const extended = await lockProvider.extend(lock, 30);
        if (!extended) {
            clearInterval(heartbeat);
            // القفل انسرق، ألغِ العملية
            throw new LockError(`Lock stolen during operation: ${key}`);
        }
    }, 15000); // مدّد كل 15 ثانية (50% من الـ TTL)

    try {
        await operation();
    } finally {
        clearInterval(heartbeat);
        await lockProvider.release(lock);
    }
}

تسلسل نطاق القفل

عمليات مختلفة تحتاج دقة قفل مختلفة:

النطاقنمط المفتاححالة الاستخدام
العنصرlock:product:123حفظ الكائن كامل
مجموعة الحقولlock:product:123:generatedAssetsتحديث جزئي (صور مصغرة فقط)
العمليةlock:product:123:thumbnail:enعملية واحدة محددة
نفس المنتج + نفس مجموعة الحقول  -> انتظر/أعد المحاولة (تسلسلي)
نفس المنتج + مجموعات مختلفة    -> متوازي (آمن)
منتجات مختلفة                  -> دائماً متوازي

النطاقات الأضيق تسمح بتوازي أكثر. الـ worker حق الصور المصغرة والـ indexer حق البحث يقدرون يعالجون نفس المنتج بنفس الوقت إذا قفلوا مجموعات حقول مختلفة.

مخازن الـ Idempotency

إعادة محاولات الشبكة، إعادة تسليم الرسائل، وإعادة تشغيل سير العمل تخلّي نفس العملية تنفذ عدة مرات. بدون idempotency، تحصل سجلات مكررة، أو شحنات مزدوجة، أو إيميلات مكررة.

interface IdempotencyEntry {
    key: string;           // مفتاح ذو معنى تجاري
    scope: string;         // فئة العملية
    status: string;        // PENDING | COMPLETED | FAILED
    requestHash: string;   // SHA-256 للمدخلات المنسقة
    resultId?: string;     // معرّف المورد المنشأ
    expiresAt: Date;       // TTL للتنظيف
    createdAt: Date;
}

class IdempotencyStore {
    async checkAndAcquire(key: string, scope: string, requestHash: string): Promise<IdempotencyResult> {
        try {
            await this.db.insert('idempotency_keys', {
                key, scope, requestHash,
                status: 'PENDING',
                expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000),
            });
            return { acquired: true };
        } catch (error) {
            if (isDuplicateKeyError(error)) {
                const existing = await this.db.findOne({ key, scope });
                if (existing.status === 'COMPLETED') {
                    return { acquired: false, cached: true, resultId: existing.resultId };
                }
                if (existing.status === 'PENDING' && isStale(existing)) {
                    // PENDING قديم: المحاولة السابقة تعطلت، اسمح بإعادة المحاولة
                    await this.db.update({ key, scope }, { status: 'PENDING', createdAt: new Date() });
                    return { acquired: true };
                }
                return { acquired: false, inProgress: true };
            }
            throw error;
        }
    }

    async complete(key: string, scope: string, resultId: string): Promise<void> {
        await this.db.update({ key, scope }, { status: 'COMPLETED', resultId });
    }
}

تصميم المفتاح

مفتاح الـ idempotency لازم يكون ذو معنى تجاري:

العمليةمكونات المفتاحمثال
إضافة للمفضلةcustomerId + productVariantId + wishlistIdwish:cust_123:var_456:wl_789
إرسال تقييمcustomerId + productIdreview:cust_123:prod_456
إرسال إشعارrecipient + category + entityRef + dayBucketnotify:sara@beispiel.de:stock:var_456:2026-04-20
استبدال نقاط الولاءcustomerId + orderId + pointsredeem:cust_123:ord_789:500
سجل استيراد ERPsourceRecordId + importBatchIdimport:erp_456:batch_20260420

نموذجين مختلفين للـ idempotency:

  • Idempotency الـ API: للتعديلات اللي يبدأها المستخدم. العميل يوفر المفتاح أو يتولّد من hash المدخلات. الاستجابة المخزنة تُعاد عند التكرار.
  • Idempotency المهام: للمعالجة بالخلفية. مفتاح dedupe بحمولة المهمة. يستخدم قيود قاعدة البيانات، علامات الإكمال، أو فحوصات المفتاح التجاري.

لا تخلطهم أبداً. عندهم دورات حياة واستراتيجيات تنظيف مختلفة.

مشكلة انفجار الإصدارات

بالأنظمة اللي فيها workers بالخلفية ينادون save()، كل عملية حفظ تنشئ إصدار. مع 6 workers يعالجون كل تغيير منتج، عملية حفظ واحدة من المحرر تولّد 6+ إصدارات غير ضرورية. خلال أشهر، المنتجات تراكم آلاف الإصدارات اللي تستهلك التخزين، تبطئ واجهة تاريخ الإصدارات، وتخلّي التغييرات التحريرية الفعلية مستحيلة تنلاقى.

الحل: حراس إصدار بنطاق محدد يمنعون إنشاء الإصدارات أثناء عمليات النظام مع الحفاظ على الإصدارات لحفظ المحررين.

// حارس إصدار بعد مرجعي
class ScopedVersionGuard {
    private static refCount = 0;

    suppress(): void {
        ScopedVersionGuard.refCount++;
        if (ScopedVersionGuard.refCount === 1) {
            VersionManager.disable();
        }
    }

    restore(): void {
        ScopedVersionGuard.refCount--;
        if (ScopedVersionGuard.refCount === 0) {
            VersionManager.enable();
        }
    }
}

// الاستخدام: العمليات المتداخلة تشتغل صح
const outerGuard = new ScopedVersionGuard();
outerGuard.suppress();
try {
    product.setThumbnail(asset);
    product.save(); // ما ينشأ إصدار

    const innerGuard = new ScopedVersionGuard();
    innerGuard.suppress();
    try {
        product.setChecksum(hash);
        product.save(); // لسا ما ينشأ إصدار
    } finally {
        innerGuard.restore(); // العدّاد ينزل من 2 لـ 1، لسا مكبوت
    }
} finally {
    outerGuard.restore(); // العدّاد ينزل من 1 لـ 0، الإصدارات ترجع تشتغل
}

النتيجة: حفظ المحررين ينشئ إصدارات (سجل التدقيق محفوظ). حفظ الـ workers ينشئ سجلات عمليات (مراقبة بدون انتفاخ الإصدارات).

لكيف ننفذ هالشي بـ Pimcore تحديداً، شوف دليل سير عمل Pimcore اللي يغطي حارس الإصدار حق PimTx بالتفصيل.

الأخطاء الشائعة

  1. ما في ملكية حقول. بدونها، كل كاتب يتنافس على كل حقل من خلال نفس مسار الحفظ. حدّد مين يملك شو قبل ما تكتب أول سطر كود متزامن.

  2. قفل متفائل بدون إعادة محاولة. اكتشاف التعارض مو كافي. العملية لازم تعيد المحاولة ببيانات جديدة. حدّد عدد أقصى لإعادة المحاولة وتعامل مع الاستنفاد بأناقة.

  3. تعطيل قفل عام. علامة disable() عامة تخرب لما عدة عمليات تشتغل بنفس الوقت. استخدم حراس بنطاق محدد وعد مرجعي.

  4. مفاتيح idempotency بدون معنى تجاري. UUID عشوائي كمفتاح idempotency ما يمنع شي. المفتاح لازم يشفّر العملية التجارية: مين، شو، متى.

  5. ما في نبض على الأقفال الطويلة. إذا العملية أطول من الـ TTL، القفل ينتهي وworker ثاني يدخل. مدّد القفل عند 50% من الـ TTL.

  6. تجاهل سجلات PENDING القديمة. إذا worker تعطّل وهو ماسك مفتاح idempotency بحالة PENDING، العملية محجوبة للأبد. اكتشف واسترجع السجلات القديمة.

  7. القفل بالدقة الغلط. أقفال على مستوى العنصر تسلسل كل شي. أقفال مجموعات الحقول تسمح بالتوازي حيث هو آمن. اختر أضيق نطاق آمن.

  8. ما في استراتيجية إدارة إصدارات. كل save() ينشئ إصدار هو السلوك الافتراضي. بالأنظمة اللي فيها workers، هالشي ينشئ آلاف الإصدارات غير المفيدة. اكبت الإصدارات لعمليات النظام.

النقاط الرئيسية

  • ملكية الحقول تمنع أكثر حالات السباق شيوعاً. حدّد أي حقول ملك المحررين، أيها ملك الـ workers، وأيها مشتركة. سجل الملكية يحدد استراتيجية القفل وحل التعارضات.

  • القفل المتفائل للكتابات منخفضة التنافس. تحقق من عدد الإصدار قبل الحفظ. أعد المحاولة عند التعارض. استخدم دعم قاعدة البيانات (TypeORM @VersionColumn، تحديث PostgreSQL الشرطي) للذرية.

  • العقود التعاونية للموارد عالية التنافس. Redis SET NX EX مع ملكية قائمة على الرمز. نبض عند 50% من الـ TTL. سكريبتات Lua للعمليات الذرية. لا تستخدم mutexes موزعة بدون TTL أبداً.

  • مفاتيح الـ idempotency لازم تكون ذات معنى تجاري. شفّر دلالات العملية (مين + شو + متى) بالمفتاح. افصل idempotency الـ API عن idempotency المهام.

  • حراس الإصدار يحافظون على سجلات التدقيق بدون انفجار. كبت بعد مرجعي لعمليات النظام. التداخل يشتغل صح. حفظ المحررين لسا ينشئ إصدارات.

نطبق هالأنماط عبر مشاريع البرمجيات المخصصة وخطوط هندسة البيانات حقتنا. إذا عندك مشاكل تزامن بالإنتاج، تواصل مع فريقنا أو اطلب عرض سعر.

المواضيع المغطاة

أنماط التزامنحالات السباقالقفل المتفائلسلامة البياناتأقفال موزعةملكية الحقولidempotencyإدارة الإصدارات

جاهز لبناء أنظمة ذكاء اصطناعي جاهزة للإنتاج؟

فريقنا متخصص في بناء أنظمة ذكاء اصطناعي جاهزة للإنتاج. خلينا نحكي كيف نقدر نساعد.

ابدأ محادثة