◀ العودة إلى المدونة
DevOps

استراتيجيات cache باستخدام Redis

نشر في 30 Aug 2024· 7 min قراءة
#Redis#Cache#Performance

Redis: استراتيجيات تخزين مؤقت متقدمة

Redis أكثر بكثير من مجرد cache بسيط من نوع مفتاح-قيمة. إليك الاستراتيجيات التي تتيح لك الاستفادة القصوى منه.

لأن Redis يخزن البيانات في الذاكرة، فهو يستجيب عادةً في أقل من جزء من الألف من الثانية، بينما قد يستغرق استعلام SQL معقد عشرات الأجزاء من الألف. ويؤدي تخزين نتائج العمليات المكلفة مؤقتاً (الاستعلامات الثقيلة، استدعاءات API خارجية، حسابات التجميع) إلى تخفيف العبء عن قاعدة البيانات وتقليص زمن الاستجابة بشكل كبير. لكن الـ cache سيئ التصميم يخلق بدوره أخطاءه الخاصة: بيانات قديمة تُعرض على المستخدمين، وذروات حمل عند انتهاء صلاحية مفتاح، وذاكرة ممتلئة. واختيار الاستراتيجية لا يقل أهمية عن الأداة نفسها.

ماذا نخزّن مؤقتاً؟

المرشح الجيد هو بيانات تُقرأ أكثر بكثير مما تُعدَّل، ومكلفة الإنتاج، وتتحمل تأخراً طفيفاً: كتالوج المنتجات، صفحات المحتوى، الإعدادات، نتائج API لأطراف ثالثة. وعلى العكس، فإن رصيد الحساب أو المخزون في الوقت الفعلي أو البيانات الخاصة بمستخدم واحد ونادراً ما تُعاد قراءتها مرشحات سيئة: فتعقيد الإبطال (invalidation) غالباً ما يفوق الفائدة.

أنماط التخزين المؤقت

Cache-Aside (Lazy Loading)

هذا هو النمط الأكثر شيوعاً: يستشير التطبيق الـ cache أولاً؛ وفي حال عدم وجود القيمة (cache miss)، يقرأ قاعدة البيانات ثم يخزن النتيجة مع مدة صلاحية. ولا ينتهي في الـ cache إلا ما يُطلب فعلاً من بيانات.

class ProductService
{
    public function getProduct(int $id): Product
    {
        $cacheKey = "product:{$id}";
        $cached = $this->redis->get($cacheKey);

        if ($cached !== false) {
            return unserialize($cached);
        }

        $product = $this->repository->find($id);
        $this->redis->setex($cacheKey, 3600, serialize($product));

        return $product;
    }
}

ملاحظتان حول هذه الشيفرة. أولاً، تستخدم امتداد phpredis الذي يُرجع القيمة false لمفتاح غير موجود؛ أما مع Predis الذي يُرجع null، فاختبر القيمة null بدلاً من ذلك. ثانياً، تسلسل (serialize) كيان Doctrine كامل أمر هش (الـ proxies، والمجموعات المحمّلة عند الطلب، وتغيّر بنية الصنف بين عمليتي نشر). أنصح بتخزين مصفوفة أو DTO بسيط بدلاً من الكيان نفسه.

Write-Through

هنا، تحدّث كل عملية كتابة قاعدة البيانات والـ cache في العملية نفسها. يبقى الـ cache ممتلئاً، ولا تحتاج القراءة التالية إلى لمس قاعدة البيانات.

public function updateProduct(Product $product): void
{
    $this->repository->save($product);
    $this->redis->setex(
        "product:{$product->getId()}",
        3600,
        serialize($product)
    );
}

الترتيب مهم: قاعدة البيانات هي مصدر الحقيقة، لذا تُكتب أولاً. وإذا فشلت الكتابة في Redis بعد ذلك، يحتفظ الـ cache بنسخة قديمة إلى أن تنتهي مدة الـ TTL. لهذا السبب، تفضّل فرق كثيرة بديلاً أبسط: حذف المفتاح بعد الكتابة (DEL) وترك نمط cache-aside يعيد بناءه عند القراءة التالية. وبذلك نتجنب أيضاً تخزين بيانات لن يقرأها أحد.

التخزين المؤقت مع Symfony

في تطبيق Symfony، نادراً ما يكون من المفيد التعامل مع Redis مباشرة: فمكوّن Cache يوفر pools قابلة للضبط، وإدارة للوسوم (tags)، وحماية من ذروات الحمل.

# config/packages/cache.yaml
framework:
    cache:
        pools:
            app.cache.products:
                adapter: cache.adapter.redis
                default_lifetime: 3600
                provider: 'redis://redis:6379'
                tags: true

يجعل الخيار tags: true الـ pool داعماً للوسوم، وهو شرط لاستدعاء $item->tag(). يُحقن الـ pool عندئذٍ على شكل TagAwareCacheInterface، وتتيح السمة #[Target] اختياره صراحةً:

use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Contracts\Cache\ItemInterface;
use Symfony\Contracts\Cache\TagAwareCacheInterface;

final class ProductCatalog
{
    public function __construct(
        #[Target('app.cache.products')]
        private readonly TagAwareCacheInterface $cache,
        private readonly ProductRepository $repository,
    ) {
    }

    /** @return list<array{id: int, name: string}> */
    public function all(): array
    {
        return $this->cache->get('products_list', function (ItemInterface $item): array {
            $item->tag(['products']);

            return array_map(
                static fn (Product $p): array => ['id' => $p->getId(), 'name' => $p->getName()],
                $this->repository->findAll(),
            );
        });
    }
}

تطبّق الدالة get() نمط cache-aside نيابةً عنك: لا تُنفَّذ دالة الاستدعاء إلا عند غياب المفتاح. كما تحمي من cache stampede (عشرات الطلبات التي تعيد حساب القيمة نفسها في اللحظة نفسها، مباشرة بعد انتهاء صلاحيتها) بفضل قفل (lock) وانتهاء صلاحية مبكر احتمالي، يمكن ضبطه عبر الوسيط الثالث $beta.

لاحظ أن النتيجة المخزنة مؤقتاً مصفوفة بسيطة، لا قائمة كيانات: فهي تُسلسَل دون مفاجآت وتبقى صالحة حتى لو تطوّر الكيان.

إبطال الـ cache

  • TTL: انتهاء صلاحية تلقائي بعد مدة محددة
  • Tags: إبطال جماعي باستخدام وسوم Symfony
  • Events: إبطال عند وقوع حدث Doctrine
  • Versioning: مفاتيح cache مرقّمة بالإصدارات

الـ TTL هو شبكة الأمان: حتى لو نُسي إبطال ما، ستتحدّث البيانات في النهاية. اختره وفق التأخير المقبول من الناحية الوظيفية، لا عشوائياً. وتتكامل الوسوم وأحداث Doctrine بشكل ممتاز: يُبطل مستمع الكيان (entity listener) الوسوم المعنية بمجرد إنشاء منتج أو تعديله أو حذفه.

use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\Events;
use Symfony\Component\DependencyInjection\Attribute\Target;
use Symfony\Contracts\Cache\TagAwareCacheInterface;

#[AsEntityListener(event: Events::postPersist, method: 'invalidate', entity: Product::class)]
#[AsEntityListener(event: Events::postUpdate, method: 'invalidate', entity: Product::class)]
#[AsEntityListener(event: Events::preRemove, method: 'invalidate', entity: Product::class)]
final class ProductCacheInvalidator
{
    public function __construct(
        #[Target('app.cache.products')]
        private readonly TagAwareCacheInterface $cache,
    ) {
    }

    public function invalidate(Product $product): void
    {
        $this->cache->invalidateTags(['products', 'product_'.$product->getId()]);
    }
}

نستمع إلى preRemove بدلاً من postRemove، لأن Doctrine قد يعيد معرّف الكيان إلى null بعد الحذف. وأخيراً، يعني الترقيم بالإصدارات إدراج رقم إصدار في المفتاح (product:v2:42). وتغيير الإصدار يجعل كل المفاتيح القديمة غير قابلة للوصول دفعة واحدة، وهذا عملي عندما يتغير تنسيق البيانات المخزنة أثناء عملية نشر؛ ثم تختفي المفاتيح القديمة من تلقاء نفسها بفضل الـ TTL الخاص بها.

ضبط Redis للاستخدام كـ cache

من دون حد للذاكرة، يكبر Redis إلى أن يستنفد ذاكرة الخادم. بالنسبة لنسخة مخصصة للتخزين المؤقت، حدّد سقفاً وسياسة إخلاء (eviction) في redis.conf:

maxmemory 512mb
maxmemory-policy allkeys-lru

مع allkeys-lru، يحذف Redis المفاتيح الأقل استخداماً مؤخراً عند بلوغ الحد. وإذا كانت النسخة نفسها تحتوي أيضاً على جلسات أو طوابير رسائل، فقد تمسحها هذه السياسة: استخدم حينها volatile-lru (التي لا تُخلي إلا المفاتيح ذات الـ TTL) أو، والأفضل، نسخاً منفصلة.

قياس فعالية الـ cache

يُحكم على الـ cache بنسبة نجاحه. يوفر الأمر INFO stats العدّادين keyspace_hits وkeyspace_misses:

redis-cli INFO stats | grep -E 'keyspace_(hits|misses)'
redis-cli INFO memory | grep used_memory_human
redis-cli --bigkeys

تشير نسبة نجاح منخفضة إلى مدد TTL قصيرة جداً، أو مفاتيح محددة أكثر من اللازم، أو بيانات نادراً ما تُعاد قراءتها. ويستعرض --bigkeys قاعدة البيانات عبر SCAN ويحدد المفاتيح الأكبر حجماً.

أخطاء شائعة

  • KEYS * في الإنتاج: يحجب هذا الأمر Redis طوال مدة استعراض كل المفاتيح. استخدم SCAN، أو الأفضل، الوسوم.
  • مفاتيح بلا TTL: تتراكم إلى ما لا نهاية. يجب أن يكون لكل ما في الـ cache مدة صلاحية.
  • الـ cache كمصدر للحقيقة: يجب أن يواصل التطبيق العمل، ولو ببطء أكبر، إذا أُفرغ Redis أو أصبح غير متاح.
  • مفاتيح بلا مساحة أسماء: أضف بادئة إلى مفاتيحك (app:product:42) لتجنب التعارض بين التطبيقات التي تتشارك النسخة نفسها.

خلاصة

ابدأ بنمط cache-aside مع TTL معقول، واعتمد على مكوّن Cache في Symfony بدلاً من استدعاءات Redis المباشرة، وأبطل عبر الوسوم انطلاقاً من أحداث Doctrine، وراقب نسبة النجاح. فالـ cache البسيط والمُبطَل جيداً أفضل من بنية معقدة تقدم بيانات قديمة.