Levelwise
فارسی
داده و ذخیره‌سازی

Redis

ردیس یک سرور داده در حافظه است که دستورها را یکی‌یکی اجرا می‌کند. برای همین هم خیلی سریع است و هم هر دستورش اتمی است. با آن شمارنده، کش، محدودیت درخواست و قفل توزیع‌شده می‌سازیم، به شرط اینکه محدودیت‌هایش را بشناسیم.

بازبینی نشدهبا کمک AI نوشته شدهزمان خواندن: ۱۶ دقیقهمثال فروش ویژه در فروشگاه اینترنتیکد C# و StackExchange.Redis

نویسنده: bezzad

مشکل: فروش ویژه

فروشگاه ما ساعت ۱۲ ظهر ۱۰۰۰ گوشی با تخفیف می‌فروشد. در دقیقه اول حدود ۲۰۰ هزار کاربر می‌آیند و بارها روی «خرید» می‌زنند. چند نیاز داریم:

  1. بیشتر از ۱۰۰۰ عدد فروخته نشود. حتی وقتی هزاران درخواست در یک لحظه می‌رسند.
  2. هر کاربر فقط یک عدد بخرد.
  3. کاربرهایی که مدام کلیک می‌کنند، محدود شوند.
  4. گزارش شبانه فروش، روی ۳ پاد فقط یک بار اجرا شود.

دیتابیس اصلی می‌تواند این کارها را بکند، ولی زیر این بار کند می‌شود. ردیس برای همین نوع کارها ساخته شده است.

چرا Redis سریع و اتمی است؟

دو دلیل:

  1. همه داده در حافظه است. خواندن و نوشتن معمولاً کمتر از یک میلی‌ثانیه طول می‌کشد.
  2. دستورها یکی‌یکی اجرا می‌شوند. ردیس دستورها را روی یک Thread اصلی، پشت سر هم اجرا می‌کند. پس هیچ دو دستوری وسط هم اجرا نمی‌شوند.
پاد ۱پاد ۲پاد ۳DECRGETDECRINCRصف دستورهااجراکنندهیکی‌یکیهر دستور اتمی استدو دستور هیچ وقت وسط هم اجرا نمی‌شوندیک دستور کند، همه را منتظر می‌گذاردمثلاً حذف یک کلید خیلی بزرگ
همین یکی‌یکی بودن، هم بزرگ‌ترین قدرت Redis است و هم بزرگ‌ترین خطرش.

نتیجه مهم: دستوری مثل DECR (یکی کم کن) اتمی است. اگر ۱۰ هزار درخواست با هم آن را صدا بزنند، هر کدام یک عدد متفاوت می‌گیرد. هیچ‌کدام عدد دیگری را نمی‌بیند.

ولی خطرش هم همین است. اگر یک دستور چند ثانیه طول بکشد، مثلاً حذف یک Hash با یک میلیون فیلد، در این مدت همه کلاینت‌ها منتظر می‌مانند.

ساختارهای داده

ردیس فقط «کلید و رشته» نیست. هر مقدار یک ساختار داده است و دستورهای خودش را دارد:

ساختار شکل مثال در فروشگاه
String یک مقدار یا یک عدد کش صفحه محصول، شمارنده موجودی با INCR و DECR
Hash چند فیلد زیر یک کلید سبد خرید: هر فیلد یک کالا، مقدارش تعداد
List لیست مرتب به ترتیب ورود آخرین کالاهایی که کاربر دیده است
Set مجموعه بدون تکرار کاربرهایی که در فروش ویژه خرید کرده‌اند
Sorted Set مجموعه با امتیاز، مرتب پرفروش‌ترین کالاهای امروز
Stream لاگ پیام‌ها با گروه مصرف‌کننده رویدادهای ساده بین سرویس‌ها

انتخاب ساختار درست مهم است. مثلاً برای «پرفروش‌ترین‌ها»، با Sorted Set لازم نیست همه کالاها را بخوانی و در برنامه مرتب کنی. ردیس آن‌ها را مرتب نگه می‌دارد.

کم کردن اتمی موجودی

حالا نیاز ۱ و ۲ با هم: موجودی کم شود و هر کاربر فقط یک بار. اگر این دو را با دو دستور جدا بنویسی، وسط آن‌ها ممکن است درخواست دیگری بیاید. مثلاً موجودی کم شود، ولی بعد معلوم شود کاربر تکراری بوده. یک گوشی گم می‌شود.

راه حل یک اسکریپت Lua است. ردیس کل اسکریپت را مثل یک دستور، یکجا و بدون وقفه اجرا می‌کند:

public sealed class FlashSale(IConnectionMultiplexer redis)
{
    // Returns 1 = bought, 0 = sold out, -1 = this user already bought
    private const string BuyScript = """
        if redis.call('SISMEMBER', KEYS[2], ARGV[1]) == 1 then return -1 end
        if tonumber(redis.call('GET', KEYS[1]) or '0') <= 0 then return 0 end
        redis.call('DECR', KEYS[1])
        redis.call('SADD', KEYS[2], ARGV[1])
        return 1
        """;

    public async Task<int> TryBuyAsync(int saleId, string userId)
    {
        var db = redis.GetDatabase();
        var tag = "{sale" + saleId + "}";
        var result = await db.ScriptEvaluateAsync(BuyScript,
            new RedisKey[] { tag + ":stock", tag + ":buyers" },
            new RedisValue[] { userId });
        return (int)result;
    }
}

چند نکته:

  1. شیء ConnectionMultiplexer را یک بار بساز و Singleton ثبت کن. ساختن اتصال برای هر درخواست، کند و پرهزینه است.
  2. ردیس منبع اصلی حقیقت نیست. هر خرید موفق را از طریق صف در دیتابیس هم ثبت کن. اگر Redis داده را از دست بدهد، می‌شود موجودی را دوباره حساب کرد.
  3. بخش داخل آکولاد در نام کلیدها را پایین‌تر، در بخش Cluster توضیح می‌دهیم.

محدود کردن درخواست

نیاز ۳: هر کاربر حداکثر ۱۰۰ درخواست در دقیقه. محدودکننده داخلی ASP.NET Core شمارنده را در حافظه هر پاد نگه می‌دارد. با ۱۰ پاد، حد واقعی می‌شود ۱۰۰۰. پس شمارنده باید مشترک باشد:

var key = $"rate:{userId}:{DateTime.UtcNow:yyyyMMddHHmm}";
var count = await db.StringIncrementAsync(key);
if (count == 1)
    await db.KeyExpireAsync(key, TimeSpan.FromMinutes(1));

if (count > 100)
    return Results.StatusCode(StatusCodes.Status429TooManyRequests);

این ساده‌ترین روش است (Fixed Window). دو ضعف دارد:

  1. اتمی نیست. اگر برنامه بین INCR و EXPIRE بمیرد، کلید بدون انقضا می‌ماند. برای کد واقعی، هر دو را در یک اسکریپت Lua بگذار.
  2. مرز پنجره. کاربر می‌تواند ۱۰۰ درخواست در ثانیه آخر یک دقیقه و ۱۰۰ درخواست در ثانیه اول دقیقه بعد بفرستد. روش‌های Sliding Window و Token Bucket این مشکل را ندارند.

قفل توزیع‌شده

نیاز ۴: گزارش شبانه روی ۳ پاد اجرا می‌شود. هر پاد زمان‌بند خودش را دارد. پس هر مشتری ۳ ایمیل می‌گیرد. یک راه، قفل در Redis است:

var token = Guid.NewGuid().ToString();
if (await db.LockTakeAsync("lock:daily-report", token, TimeSpan.FromMinutes(5)))
{
    try
    {
        await reports.SendDailyAsync(ct);
    }
    finally
    {
        await db.LockReleaseAsync("lock:daily-report", token);
    }
}

چرا به هر قفل یک مقدار یکتا (token) می‌دهیم؟ چون آزاد کردن قفل فقط وقتی انجام می‌شود که مقدار هنوز مال ما باشد. وگرنه ممکن است قفلی را آزاد کنیم که حالا مال پاد دیگری است.

چرا زمان انقضا لازم است؟ چون اگر پاد وسط کار بمیرد، قفل باید خودش آزاد شود. ولی همین انقضا یک خطر دارد:

پاد الفپاد بثانیه ۰۵۳۰۳۱۴۵کار با قفلمتوقف شده، مثلاً توقف طولانیبیدار شد و فکر می‌کند قفل داردقفل را گرفت و کار می‌کندقفل منقضی شدهر دو با هم کار می‌کنندزمان انقضای بیشتر فقط احتمال را کم می‌کند. خود کار باید تکرار را تحمل کند.
پاد الف نمی‌داند که متوقف بوده است. بعد از بیدار شدن، فکر می‌کند هنوز قفل دارد.

پس قفل Redis تضمین کامل نیست. برای کارهای مهم:

  1. خود کار را تکرارپذیر (Idempotent) کن. مثلاً برای هر مشتری و هر تاریخ ثبت کن «ایمیل رفت». اجرای دوباره ضرری ندارد.
  2. از Fencing Token استفاده کن. هر بار که قفل گرفته می‌شود، یک عدد افزایشی هم بگیر. دیتابیس نوشتنی را که عددش از آخرین عدد دیده‌شده کمتر است، رد می‌کند.
  3. اگر می‌شود، قفل را حذف کن. مثلاً گزارش شبانه را به یک CronJob در Kubernetes ببر تا اصلاً سه نسخه نداشته باشی.

ماندگاری و از کار افتادن

ردیس داده را در حافظه نگه می‌دارد. اگر ری‌استارت شود چه؟

  1. عکس دوره‌ای (RDB). هر چند وقت یک بار، کل داده روی دیسک نوشته می‌شود. تغییرهای بعد از آخرین عکس از بین می‌روند.
  2. لاگ نوشتن‌ها (AOF). هر دستور نوشتن به یک فایل اضافه می‌شود. داده کمتری از دست می‌رود، ولی کمی کندتر است.
  3. کپی (Replica) و Sentinel. یک یا چند کپی داریم. اگر سرور اصلی بمیرد، Sentinel یک کپی را اصلی می‌کند. چون کپی کردن ناهمزمان است، ممکن است آخرین نوشتن‌ها از بین بروند.

وقتی حافظه پر شود، رفتار Redis به تنظیم maxmemory-policy بستگی دارد. برای کش، معمولاً سیاستی مثل allkeys-lru را انتخاب می‌کنند تا کلیدهای کم‌استفاده حذف شوند. برای داده‌ای که نباید حذف شود (مثل موجودی)، حذف خودکار خطرناک است.

خوشه Redis (Cluster)

وقتی یک سرور کافی نیست، Redis Cluster داده را بین چند نود پخش می‌کند:

  1. فضای کلیدها به ۱۶۳۸۴ بخش (Hash Slot) تقسیم شده است. هر نود مالک تعدادی از این بخش‌ها است.
  2. برای هر کلید، Redis از نام آن یک hash می‌گیرد و بخش آن را پیدا می‌کند.
  3. دستورهای چندکلیدی (مثل اسکریپت Lua، تراکنش MULTI و MGET) فقط وقتی کار می‌کنند که همه کلیدها در یک بخش باشند. وگرنه خطای CROSSSLOT می‌گیری.
نود ۱slots 0 - 5460نود ۲slots 5461 - 10922نود ۳slots 10923 - 16383stock:p1stock:p2stock:p3stock:{sale42}:p1..p3سه کلید روی سه نوداسکریپت چندکلیدی خطا می‌دهدبخش داخل آکولاد یکسان استپس هر سه کلید در یک اسلات هستندCROSSSLOThash tagیک کلید همیشه روی یک نود است. نود بیشتر، بار یک کلید داغ را پخش نمی‌کند.

اگر بخشی از نام کلید داخل آکولاد باشد، فقط همان بخش برای پیدا کردن جای کلید استفاده می‌شود. به این Hash Tag می‌گویند. برای همین در کد فروش ویژه، هر دو کلید بخش مشترک «sale42» را داخل آکولاد دارند.

دو مشکل دیگر در Cluster:

  1. کلید داغ (Hot Key). کلید «تنظیمات صفحه اول» را همه درخواست‌ها می‌خوانند. این کلید روی یک نود است، پس همه بار روی همان نود است. راه ساده: یک کش محلی چندثانیه‌ای در هر پاد.
  2. کلید بزرگ (Big Key). یک Hash یا Set با میلیون‌ها عضو، کار روی آن را کند می‌کند و همه را منتظر می‌گذارد. کلیدها را کوچک نگه دار. برای حذف، به جای DEL از UNLINK استفاده کن که حافظه را در پس‌زمینه آزاد می‌کند. برای خواندن، به جای HGETALL از HSCAN استفاده کن.

اشتباه‌های رایج

اشتباه نتیجه راه درست
خواندن موجودی، چک در برنامه، بعد کم کردن فروش بیشتر از موجودی دستور اتمی یا اسکریپت Lua
ساختن اتصال جدید برای هر درخواست کندی و تمام شدن اتصال‌ها یک ConnectionMultiplexer مشترک
قفل بدون زمان انقضا با مرگ پاد، قفل برای همیشه می‌ماند انقضا، همراه با کار تکرارپذیر
آزاد کردن قفل بدون چک مقدار آزاد کردن قفل پاد دیگر مقدار یکتا برای هر قفل
ردیس تنها جای داده مهم از دست رفتن داده با ری‌استارت دیتابیس منبع حقیقت، Redis کمکی
دستورهای سنگین روی کلید بزرگ همه کلاینت‌ها منتظر می‌مانند کلید کوچک، UNLINK و SCAN
فکر اینکه Cluster بار یک کلید را پخش می‌کند یک نود داغ و بقیه بیکار کش محلی یا چند نسخه از کلید

خلاصه در شش خط

  1. ردیس داده را در حافظه نگه می‌دارد و دستورها را یکی‌یکی اجرا می‌کند. پس سریع و اتمی است.
  2. یک دستور کند یا یک کلید بزرگ، همه کلاینت‌ها را منتظر می‌گذارد.
  3. ساختار داده درست را انتخاب کن: شمارنده، Hash، Set، Sorted Set.
  4. چند قدم وابسته را با یک اسکریپت Lua اتمی کن.
  5. قفل Redis انقضا دارد و تضمین کامل نیست. کار را تکرارپذیر کن.
  6. در Cluster، کلیدهای یک اسکریپت باید در یک بخش باشند. کلید داغ را با کش محلی آرام کن.