کتاب جامع Redis
v1.0 · 1405 · Persian Edition

کتاب جامع Redis

از مبانی تا موضوعات پیشرفته — یک مرجع کامل برای توسعه‌دهندگان

A-Talebifard
A-Talebifard
Full-Stack Engineer; WebGIS Developer; GeoAI Specialist
25
فصل جامع
120+
دستور و کد
20+
نمودار
100%
آفلاین
Chapter 01

مقدمه و تاریخچه Redis #

Redis (مخفف Remote Dictionary Server) یک پایگاه داده‌ی در-حافظه‌ای (in-memory)، کلید-مقداری (key-value) و متن‌باز است که در سال ۲۰۰۹ توسط سالواتوره سانفیلیپو (Salvatore Sanfilippo) که در جامعه‌ی توسعه‌دهندگان با نام antirez می‌شناسند، ساخته شد. هدف اولیه‌ی Redis، رفع مشکلات عملکردی پایگاه‌های داده‌ی سنتی در پروژه‌ی یک استارتاپ ایتالیایی به نام LLOOGG بود؛ جایی که سانفیلیپو متوجه شد MySQL برای پردازش واقع‌باز (real-time) بازدیدکنندگان سایت بیش از حد کند است. او به‌جای تلاش برای بهینه‌سازی MySQL، تصمیم گرفت سیستمی جدید بسازد که در آن تمام داده‌ها در حافظه‌ی اصلی نگهداری شوند تا دسترسی به آن‌ها در حد میکروثانیه باشد.

نخستین نسخه‌ی عمومی Redis در ۱۰ مارس ۲۰۰۹ منتشر شد و به‌سرعت توجه توسعه‌دهندگان را به خود جلب کرد. شرکت‌هایی مانند Twitter، GitHub، Stack Overflow، Instagram و Snapchat از همان سال‌های نخست به Redis روی آوردند. در سال ۲۰۱۵، سانفیلیپو پروژه را به شرکت Redis Labs (که امروز با نام Redis Inc. شناخته می‌شود) سپرد تا توسعه‌ی پایدار و تجاری آن تضمین شود. امروز Redis یکی از محبوب‌ترین پایگاه‌های داده‌ی جهان است و در رتبه‌بندی DB-Engines سال‌هاست که در صدر پایگاه‌های داده‌ی کلید-مقداری قرار دارد و در کل رتبه‌ای در میان ۱۰ پایگاه داده‌ی برتر دارد.

timeline title سیر تحول Redis section تولد و سال‌های نخست 2009 : انتشار اولین نسخه توسط antirez 2010 : پشتیبانی از Replication 2012 : معرفی Redis Cluster (نسخه ۳.۰) section بلوغ و گسترش 2015 : انتقال به Redis Labs 2017 : معرفی Redis Streams (نسخه ۵.۰) 2020 : ماژول‌های RedisJSON و RediSearch section دوران مدرن 2023 : توافق‌نامه‌ی SSPL/RSALv2 2024 : بازگشت به مجوز متن‌باز AGPL
// timeline-evolution-of-redis

چرا Redis این‌قدر محبوب شد؟

موفقیت Redis فقط به‌خاطر سرعت بالا نیست؛ بلکه ترکیبی از چند عامل کلیدی است که در کنار هم، ابزاری منحصربه‌فرد می‌سازند. نخست، سرعت فوق‌العاده: چون داده‌ها در RAM نگهداری می‌شوند، عملیات خواندن و نوشتن در حد ۱۰۰٬۰۰۰ تا ۵۰۰٬۰۰۰ عملیات در ثانیه روی یک سرور معمولی انجام می‌شود — عددی که برای پایگاه‌های داده‌ی دیسک‌محور تقریباً غیرممکن است. دوم، ساختار داده‌های غنی: برخلاف بسیاری از key-value storeهای ساده مانند Memcached که فقط رشته نگه می‌دارند، Redis از String، List، Hash، Set، Sorted Set، Bitmap، HyperLogLog و Stream پشتیبانی می‌کند و هرکدام دستورات اختصاصی خود را دارند. این غنای ساختاری، Redis را از یک cache ساده به یک پایگاه داده‌ی همه‌منظوره تبدیل کرده است.

سوم، سادگی و یادگیری آسان: دستورات Redis کوتاه، صریح و قابل پیش‌بینی هستند؛ SET key value، GET key، LPUSH list value و HSET hash field value. هیچ schema پیچیده‌ای وجود ندارد و حتی یک توسعه‌دهنده‌ی تازه‌کار می‌تواند در کمتر از یک ساعت شروع به کار کند. چهارم، پایداری و قابلیت اطمینان: با وجود in-memory بودن، Redis مکانیزم‌های RDB (snapshot) و AOF (Append-Only File) را برای ذخیره‌سازی روی دیسک ارائه می‌دهد تا در صورت reboot، داده‌ها از بین نروند. پنجم، توسعه‌پذیری افقی: با Redis Cluster می‌توان داده‌ها را روی ده‌ها سرور پخش کرد و ظرفیت را به‌جای چند گیگابایت، به چند ترابایت رساند.

ℹ️ اطلاعات کلیدی

کلمه‌ی Redis مخفف REmote Dictionary Server است. در نام‌گذاری اصلی، Dictionary به مفهوم ساختار داده‌ای «دیکشنری» (همان Hashtable یا Hash Map) اشاره دارد که در آن هر کلید به یک مقدار نگاشت می‌شود. این نام، فلسفه‌ی سادگی و وضوح Redis را نشان می‌دهد.

Redis چه چیزی نیست؟

برای درک درست Redis، باید بدانیم چه چیزهایی نیست. Redis یک جایگزین کامل برای پایگاه‌های داده‌ی رابطه‌ای مانند PostgreSQL یا MySQL نیست؛ زیرا هیچ پشتیبانی داخلی برای JOIN، foreign key constraint، transactionهای ACID کامل با isolation levelهای مختلف، یا SQL ندارد. اگرچه از تراکنش‌های ساده‌ای (MULTI/EXEC) پشتیبانی می‌کند، اما این تراکنش‌ها معنای متفاوتی دارند و برای سناریوهای پیچیده‌ی مالی مناسب نیستند. همچنین Redis برای ذخیره‌سازی داده‌هایی که در حافظه جا نمی‌شوند و باید روی دیسک بمانند، گزینه‌ی اقتصادی نیست؛ زیرا RAM حدود ۱۰ تا ۵۰ برابر گران‌تر از SSD است.

Redis یک پایگاه داده‌ی سندی (document database) مانند MongoDB هم نیست؛ هرچند ماژول RedisJSON این قابلیت را اضافه می‌کند. همچنین یک موتور جستجوی متن‌باز کامل‌عیار نیست؛ اما با ماژول RediSearch می‌توان جستجوهای فول‌تکست ساده‌ای روی آن پیاده کرد. در یک جمله، Redis ابزاری است که در کنار پایگاه داده‌ی اصلی شما قرار می‌گیرد و کارهایی را که باید سریع باشند، انجام می‌دهد: cache، session store، message queue، real-time analytics، leaderboard و rate limiter.

graph LR A[Client App] --> B[Application Server] B --> C[(Redis Cache
Fast Lookups)] B --> D[(Primary DB
PostgreSQL/MySQL)] C -.miss.-> B B -.fallback.-> D D --> B style C fill:#313244,stroke:#c084fc,color:#cdd6f4 style D fill:#313244,stroke:#2dd4bf,color:#cdd6f4 style A fill:#45475a,stroke:#89b4fa,color:#cdd6f4 style B fill:#45475a,stroke:#a6e3a1,color:#cdd6f4
// typical-cache-aside-architecture

موارد استفاده‌ی رایج Redis

Redis در طیف گسترده‌ای از سناریوها کاربرد دارد و انتخاب آن معمولاً به نیاز به «سرعت بالا + ساختار داده‌ی خاص» برمی‌گردد. در جدول زیر، مهم‌ترین موارد استفاده به همراه نوع داده‌ی مناسب برای هر کدام آمده است:

سناریوی استفاده ساختار داده‌ی مناسب مثال کاربردی
Cache کردن کوئری‌ها و اشیاءStringکش پاسخ API، کش صفحه‌ی HTML
Session StoreString یا Hashذخیره session کاربر در وب‌اپ
Message QueueList یا Streamصف کارهای پس‌زمینه (worker)
Leaderboard / رتبه‌بندیSorted Setجدول برترین‌های بازی آنلاین
Real-time AnalyticsHyperLogLogشمارش کاربران یکتای روزانه
Rate LimitingSorted Set یا String + INCRمحدود کردن ۱۰۰ درخواست در دقیقه
Pub/Sub و ChatPub/Sub یا Streamاعلان لحظه‌ای، چت زنده
تگ‌ها و دسته‌بندیSetپیدا کردن اشتراک بین دو دسته
Counter و MetricsString + INCRشمارش بازدید، like، share
GeospatialGEOپیدا کردن نزدیک‌ترین راننده تاکسی
💡 نکته عملی

قانون سرانگشتی این است: اگر داده‌ی شما کمتر از چند گیگابایت است، دسترسی سریع نیاز دارید، و از دست رفتن موقت داده (در صورت reboot) مشکل بزرگی نیست، Redis گزینه‌ی بسیار خوبی است. در غیر این صورت، از پایگاه داده‌ی اصلی خود استفاده کنید و Redis را فقط در لایه‌ی cache به‌کار ببرید.

تفاوت Redis با Memcached

Memcached قدیمی‌ترین رقیب Redis است و هنوز هم در پروژه‌هایی که فقط cache ساده می‌خواهند، استفاده می‌شود. اما تفاوت‌های مهمی بین آن‌ها وجود دارد که در انتخاب باید مد نظر قرار گیرد. Memcached فقط ساختار String را پشتیبانی می‌کند و همه‌ی مقادیر را به‌صورت blob خام ذخیره می‌کند؛ بنابراین اگر یک شیء JSON داشته باشید، باید آن را serialize و deserialize کنید. Redis به‌طور بومی از Hash، List، Set و Sorted Set پشتیبانی می‌کند و می‌توانید به فیلد خاصی از یک Hash دسترسی داشته باشید بدون اینکه کل شیء را بخوانید. این تفاوت، هم در سرعت و هم در مصرف حافظه تأثیر گذار است.

Memcached هیچ مکانیزم persistence ندارد؛ اگر سرور restart شود، تمام داده‌ها از بین می‌روند. Redis از RDB و AOF پشتیبانی می‌کند و می‌تواند داده‌ها را به‌صورت دوره‌ای یا لحظه‌ای روی دیسک بنویسد. Memcached از multithreading پشتیبانی می‌کند و می‌تواند از تمام هسته‌های CPU استفاده کند؛ اما Redis به‌طور پیش‌فرض single-threaded است (هرچند در نسخه‌ی ۶ به بعد با I/O threading این محدودیت تا حدی برطرف شده است). در نهایت، Memcached برای سناریوهای ساده‌ی cache با حجم بسیار بالا و بدون نیاز به ساختار داده‌ی پیچیده، هنوز هم انتخاب خوبی است؛ اما اکثر پروژه‌های مدرن Redis را ترجیح می‌دهند.

ویژگی Redis Memcached
ساختار دادهString, List, Hash, Set, Sorted Set, Stream, ...فقط String
Persistenceبله (RDB + AOF)خیر
Replicationبله (Master-Replica)خیر
Clusterبله (Redis Cluster)خیر (client-side sharding)
تراکنشبله (MULTI/EXEC)خیر
Pub/Subبلهخیر
Lua Scriptingبلهخیر
Max Value Size512 MB1 MB (پیش‌فرض)
ThreadingSingle-thread (با I/O threading اختیاری)Multithread
Max Key Length512 MB250 bytes

مجوز Redis و تغییرات آن

تاریخچه‌ی مجوز Redis کمی پیچیده است و درک آن برای تصمیم‌گیری در پروژه‌های تجاری اهمیت دارد. تا سال ۲۰۲۴، Redis تحت مجوز BSD 3-Clause منتشر می‌شد که یک مجوز متن‌باز بسیار آزاد است و اجازه‌ی استفاده‌ی تجاری، تغییر و توزیع را می‌دهد. در مارس ۲۰۲۴، Redis Inc. اعلام کرد که نسخه‌های جدید (از ۷.۴ به بعد) تحت مجوزهای SSPL (Server Side Public License) و RSALv2 (Redis Source Available License) منتشر خواهند شد. این تغییر باعث بحث‌های گسترده‌ای در جامعه‌ی متن‌باز شد، زیرا SSPL و RSALv2 توسط OSI (Open Source Initiative) به‌عنوان مجوزهای متن‌باز شناخته نمی‌شوند.

پس از این تغییر، چند پروژه‌ی fork مهم شکل گرفت: Valkey (پشتیبانی‌شده توسط Linux Foundation و AWS، Google، Oracle) که از نسخه‌ی ۷.۲ BSD شاخه گرفته شد و تحت مجوز BSD ادامه پیدا کرد، و Redict (پشتیبانی‌شده توسط Society for Free Open Source Software) که تحت LGPL منتشر می‌شود. در سپتامبر ۲۰۲۴، Redis Inc. در یک حرکت غیرمنتظره، اعلام کرد که مجوز AGPLv3 را هم به‌عنوان گزینه اضافه می‌کند تا دوباره به دنیای متن‌باز برگردد. در نتیجه، امروز می‌توانید Redis را تحت سه مجوز مختلف SSPL، RSALv2 یا AGPLv3 استفاده کنید — بسته به نیاز پروژه‌تان. برای پروژه‌های متن‌باز معمولی، AGPL معمولاً بهترین گزینه است.

⚠️ توجه حقوقی

اگر قصد دارید Redis را به‌صورت سرویس ابری به مشتریان ارائه دهید (SaaS)، حتماً با تیم حقوقی خود مشورت کنید. SSPL و AGPL الزاماتی برای اشتراک‌گذاری کد منبع کل stack شما دارند. برای اکثر پروژه‌های داخلی یا وب‌اپ‌های معمولی، هیچ مشکلی وجود ندارد؛ اما در سناریوهای پلتفرم‌به‌عنوان-سرویس باید محتاط بود.

معماری داخلی Redis — نگاهی گذرا

Redis از مدل event loop استفاده می‌کند که شبیه به Nginx یا Node.js است. یک thread اصلی، تمام کلاینت‌ها را می‌پذیرد، درخواست‌ها را پردازش می‌کند، پاسخ می‌دهد و حتی عملیات مدیریت حافظه را هم انجام می‌دهد. این طراحی single-threaded در نگاه اول محدودکننده به نظر می‌رسد؛ اما در عمل دو مزیت بزرگ دارد: نخست، بدون race condition است و تمام دستورات به‌صورت اتمیک اجرا می‌شوند؛ دوم، overhead context switching بین threadها حذف می‌شود. چون اکثر عملیات Redis در حد میکروثانیه تمام می‌شوند، یک thread می‌تواند صدها هزار عملیات در ثانیه را روی یک هسته‌ی CPU انجام دهد.

از نسخه‌ی ۶ به بعد، Redis از I/O Threading پشتیبانی می‌کند که در آن خواندن از socket و نوشتن به آن به threadهای جداگانه سپرده می‌شود، اما خود پردازش دستورات همچنان در thread اصلی انجام می‌شود. این قابلیت در سناریوهایی که تعداد کلاینت‌ها بسیار زیاد است (هزاران connection همزمان) یا اندازه‌ی پاسخ‌ها بزرگ است (مثلاً چند مگابایت)، به‌طور قابل توجهی بهبود ایجاد می‌کند. عملیات سنگین‌تری مانند SAVE (snapshot)، BGSAVE و AOFRewrite در processهای جداگانه (fork شده) انجام می‌شوند تا thread اصلی مسدود نشود.

graph TB subgraph "Redis Process" A[Main Thread
Event Loop] --> B[Command Processor] B --> C[Data Structures
in Memory] A --> D[I/O Threads
optional] end E[Client 1] -.-> A F[Client 2] -.-> A G[Client N] -.-> A subgraph "Background Processes" H[fork: BGSAVE] I[fork: AOF Rewrite] end C --> H C --> I H --> J[(disk: dump.rdb)] I --> K[(disk: appendonly.aof)] style A fill:#313244,stroke:#c084fc,color:#cdd6f4 style C fill:#313244,stroke:#a6e3a1,color:#cdd6f4 style H fill:#45475a,stroke:#f9e2af,color:#cdd6f4 style I fill:#45475a,stroke:#f9e2af,color:#cdd6f4
// redis-internal-architecture

نسخه‌های Redis و ویژگی‌های کلیدی هر کدام

در طول بیش از ۱۵ سال توسعه، Redis نسخه‌های مهم زیادی را دیده است. شناخت این نسخه‌ها به شما کمک می‌کند که بدانید کدام ویژگی‌ها در کدام نسخه اضافه شده و آیا ارتقا به نسخه‌ی جدید برای پروژه‌ی شما ارزش دارد یا خیر. در ادامه، مهم‌ترین نسخه‌ها و تحولات آن‌ها را مرور می‌کنیم:

نسخه سال ویژگی‌های کلیدی
2.x2009-2013ساختارهای داده‌ی پایه، Replication، RDB
3.x2015Redis Cluster (sharding)، Lua Scripting
4.x2017Modules API، PSYNC2 (بهبود replication)
5.x2018Redis Streams، نسخه‌ی ۵ از HyperLogLog بهبود یافت
6.x2020ACL، SSL/TLS، RESP3، I/O Threading، Client-side caching
7.x2022Functions (جایگزین Lua)، Multi-part AOF، sharded Pub/Sub
7.4+2024تغییر مجوز به SSPL/RSALv2/AGPL، بهبود‌های Vector Search

برای شروع یادگیری، توصیه می‌کنیم از نسخه‌ی ۷.۲ یا بالاتر استفاده کنید؛ زیرا تمام ویژگی‌های مدرن (ACL، Streams، Functions، Client-side caching) در آن پشتیبانی می‌شوند و داکیومنت رسمی هم بر اساس همین نسخه‌ها نوشته شده است. در طول این کتاب، فرض بر این است که شما از Redis ۷.x استفاده می‌کنید و در مواردی که یک ویژگی در نسخه‌ی خاصی معرفی شده، آن را ذکر خواهیم کرد.

📝 یادداشت

این کتاب بر اساس Redis نسخه‌ی ۷.۴ نوشته شده است. اگرچه اکثر دستورات در نسخه‌های ۶ به بعد به‌طور یکسان کار می‌کنند، در صورتی که از نسخه‌ی قدیمی‌تر استفاده می‌کنید، ممکن است برخی ویژگی‌ها (مانند ACL یا Functions) در دسترس نباشند. در هر فصل، نسخه‌ی مورد نیاز هر دستور ذکر شده است.

Chapter 02

نصب و راه‌اندازی Redis #

نصب Redis یکی از ساده‌ترین فرآیندها در میان پایگاه‌های داده است؛ زیرا Redis هیچ وابستگی خارجی پیچیده‌ای ندارد و تنها پیش‌نیاز آن کامپایلر C و کتابخانه‌ی libpthread است که در اکثر سیستم‌عامل‌ها به‌طور پیش‌فرض موجود است. در این فصل، روش‌های مختلف نصب Redis را در سیستم‌عامل‌های مختلف بررسی می‌کنیم: از Linux (Ubuntu/Debian، CentOS/RHEL، Arch)، macOS، Windows (از طریق WSL یا Docker) و Docker. سپس فایل پیکربندی redis.conf را معرفی می‌کنیم و مهم‌ترین تنظیمات ابتدایی را بررسی می‌کنیم.

نصب روی Ubuntu و Debian

ساده‌ترین روش نصب Redis روی Ubuntu و Debian استفاده از مدیریت بسته‌ی apt است. اما نسخه‌ای که در repository رسمی Ubuntu قرار دارد معمولاً قدیمی است؛ بنابراین توصیه می‌شود از repository رسمی Redis (معروف به redis.io) استفاده کنید تا آخرین نسخه‌ی پایدار را دریافت کنید. مراحل زیر را به‌ترتیب انجام دهید:

bash
# نصب پیش‌نیازها
sudo apt update
sudo apt install -y ca-certificates curl gpg

# اضافه کردن کلید GPG رسمی Redis
curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg

# اضافه کردن repository رسمی Redis
echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list

# نصب Redis
sudo apt update
sudo apt install -y redis

# بررسی نصب
redis-server --version
redis-cli --version

پس از نصب، سرویس Redis به‌صورت خودکار شروع می‌شود و در پس‌زمینه اجرا می‌شود. می‌توانید وضعیت آن را با systemctl بررسی کنید:

bash
# بررسی وضعیت سرویس
sudo systemctl status redis-server

# راه‌اندازی مجدد
sudo systemctl restart redis-server

# توقف
sudo systemctl stop redis-server

# فعال‌سازی برای شروع خودکار هنگام بوت
sudo systemctl enable redis-server

نصب روی CentOS، RHEL و Fedora

در توزیع‌های مبتنی بر Red Hat، می‌توانید از dnf یا yum استفاده کنید. repository رسمی Redis برای این توزیع‌ها هم در دسترس است:

bash
# اضافه کردن repository EPEL (در صورت نیاز)
sudo dnf install -y epel-release

# نصب Redis
sudo dnf install -y redis

# شروع سرویس
sudo systemctl start redis
sudo systemctl enable redis

# بررسی نصب
redis-cli ping
# خروجی انتظاری: PONG

نصب روی macOS

روی macOS، ساده‌ترین راه استفاده از Homebrew است. اگر Homebrew را نصب ندارید، ابتدا آن را از brew.sh نصب کنید. سپس با یک دستور می‌توانید Redis را نصب کنید:

bash
# نصب Redis
brew install redis

# شروع Redis به‌عنوان سرویس پس‌زمینه (شروع خودکار هنگام بوت)
brew services start redis

# یا اجرای دستی در foreground
redis-server

# توقف سرویس
brew services stop redis

# بررسی نسخه
redis-server --version

به‌طور پیش‌فرض، Redis روی پورت 6379 و فقط روی localhost اجرا می‌شود. این یعنی از بیرون سیستم قابل دسترس نیست — که برای محیط توسعه کاملاً امن است. در فصل ۱۸ (امنیت) یاد می‌گیرید چگونه Redis را برای دسترسی از راه دور پیکربندی کنید.

نصب روی Windows — چالش‌ها و راه‌حل‌ها

Redis به‌طور رسمی روی Windows پشتیبانی نمی‌شود؛ زیرا Redis به‌شدت به fork() و epoll در لینوکس وابسته است که در Windows معادل مستقیم ندارند. با این حال، سه راه برای اجرای Redis روی Windows وجود دارد که در ادامه معرفی می‌کنیم. توصیه‌ی ما برای محیط production، استفاده از Linux یا Docker است؛ اما برای توسعه روی Windows، گزینه‌ی WSL 2 یا Docker مناسب است.

گزینه ۱: WSL 2 (Windows Subsystem for Linux)

WSL 2 به شما اجازه می‌دهد یک توزیع لینوکس (مثل Ubuntu) را روی Windows اجرا کنید و از آنجا Redis را نصب کنید. این بهترین روش برای توسعه روی Windows است؛ زیرا عملکرد واقعی لینوکس را ارائه می‌دهد و سازگاری کامل با داکیومنت رسمی دارد. مراحل:

powershell
# نصب WSL 2 (در PowerShell با دسترسی ادمین)
wsl --install
# این دستور به‌طور خودکار Ubuntu را نصب می‌کند

# پس از راه‌اندازی مجدد، وارد Ubuntu شوید
wsl

سپس در محیط Ubuntu که در WSL اجرا شده، مراحل نصب Redis را مانند بخش «نصب روی Ubuntu» انجام دهید. می‌توانید از داخل Windows با آدرس localhost:6379 به Redis متصل شوید؛ زیرا WSL 2 به‌طور خودکار port forwarding انجام می‌دهد.

گزینه ۲: Docker

ساده‌ترین و قابل‌حمل‌ترین روش برای اجرای Redis روی هر سیستم‌عاملی، استفاده از Docker است. اگر Docker Desktop را روی Windows نصب دارید، با یک دستور می‌توانید Redis را اجرا کنید:

bash
# اجرای Redis با پورت پیش‌فرض
docker run -d --name my-redis -p 6379:6379 redis:latest

# اجرای Redis با persistence و حجم کاستوم
docker run -d --name my-redis \
  -p 6379:6379 \
  -v redis-data:/data \
  redis:latest redis-server --appendonly yes

# اتصال به Redis
docker exec -it my-redis redis-cli

# مشاهده لاگ‌ها
docker logs my-redis

# توقف و حذف
docker stop my-redis
docker rm my-redis

گزینه ۳: نسخه‌ی غیررسمی Microsoft

در سال ۲۰۱۶، تیم Microsoft یک پورت غیررسمی از Redis برای Windows ارائه کرد که هنوز هم در GitHub به آدرس microsoftarchive/redis موجود است؛ اما این نسخه دیگر به‌روزرسانی نمی‌شود و در نسخه‌ی ۳.۰.۵۰۴ متوقف شده است. به‌هیچ‌وجه برای production یا حتی توسعه‌ی پروژه‌های جدید از این نسخه استفاده نکنید؛ زیرا بسیاری از ویژگی‌های مدرن (Streams، ACL، Cluster) را ندارد.

🚨 هشدار مهم

هرگز از نسخه‌ی Windows-only Redis در محیط production استفاده نکنید. حتی اگر روی Windows Server کار می‌کنید، Redis را داخل Docker یا Hyper-V VM با لینوکس اجرا کنید. عملکرد و پایداری Redis روی Windows به‌مراتب پایین‌تر از لینوکس است و بسیاری از ویژگی‌ها (مانند AOF و fork) به‌درستی کار نمی‌کنند.

نصب از کد منبع (Build from Source)

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

bash
# نصب پیش‌نیازها
sudo apt install -y build-essential tcl

# دانلود کد منبع
curl -fsSL https://download.redis.io/redis-stable.tar.gz -o redis-stable.tar.gz
tar xzf redis-stable.tar.gz
cd redis-stable

# کامپایل
make

# اجرای تست (اختیاری اما توصیه می‌شود)
make test

# نصب در سیستم
sudo make install

# نصب به‌عنوان سرویس systemd
sudo utils/install_server.sh

اسکریپت install_server.sh به‌طور خودکار فایل پیکربندی، دایرکتوری داده و سرویس systemd را تنظیم می‌کند. در طول نصب، چند سؤال (مانند شماره پورت و مسیر فایل پیکربندی) از شما می‌پرسد که می‌توانید با Enter مقادیر پیش‌فرض را بپذیرید.

بررسی نصب و اولین اتصال

پس از نصب Redis به هر روشی، می‌توانید با redis-cli به آن متصل شوید و درستی کار را تأیید کنید. دستور redis-cli ping ساده‌ترین راه برای تست است:

bash
$ redis-cli ping
PONG

$ redis-cli
127.0.0.1:6379> SET name "Redis"
OK
127.0.0.1:6379> GET name
"Redis"
127.0.0.1:6379> DEL name
(integer) 1
127.0.0.1:6379> KEYS *
(empty array)

127.0.0.1:6379> INFO server
# Server
redis_version:7.4.0
redis_git_sha1:00000000
redis_git_dirty:0
redis_build_id:abc123...
redis_mode:standalone
os:Linux 6.5.0-...
arch_bits:64
...

دستور INFO اطلاعات کاملی درباره‌ی سرور، کلاینت‌ها، حافظه و آمار عملیات ارائه می‌دهد. این دستور برای مانیتورینگ و عیب‌یابی بسیار مفید است و در فصل ۲۳ به‌طور مفصل به آن می‌پردازیم. در پاسخ PONG می‌توانید مطمئن شوید که Redis به‌درستی نصب شده و در حال اجراست.

فایل پیکربندی redis.conf

فایل پیکربندی redis.conf قلب تنظیمات Redis است. مسیر این فایل بسته به روش نصب متفاوت است:

روش نصب مسیر پیش‌فرض
apt (Ubuntu/Debian)/etc/redis/redis.conf
brew (macOS)/opt/homebrew/etc/redis.conf
dockerداخل container در /usr/local/etc/redis/redis.conf
build from sourceredis-stable/redis.conf
WSL 2/etc/redis/redis.conf

می‌توانید هنگام اجرای redis-server مسیر فایل پیکربندی را به‌صورت دستی هم مشخص کنید:

bash
# اجرای Redis با فایل پیکربندی سفارشی
redis-server /path/to/my-redis.conf

# یا اجرا با پارامترهای خط فرمان (override فایل conf)
redis-server --port 6380 --maxmemory 256mb --appendonly yes

# اجرا در پس‌زمینه (به‌عنوان daemon)
redis-server --daemonize yes

# اجرا باLogLevel مشخص
redis-server --loglevel debug

مهم‌ترین تنظیمات redis.conf

فایل redis.conf بیش از ۲۰۰ تنظیم دارد که هر کدام با توضیحات کامل همراه است. در اینجا مهم‌ترین تنظیمات ابتدایی را مرور می‌کنیم. در فصل‌های بعدی، تنظیمات پیشرفته‌تر (مانند replication، cluster و ACL) را به‌طور مفصل بررسی خواهیم کرد:

ini
# ===== Network =====
bind 127.0.0.1 -::1                # فقط روی localhost گوش کن (امن)
port 6379                           # پورت پیش‌فرض
protected-mode yes                  # محافظت در برابر دسترسی بیرونی
timeout 0                           # idle timeout برای connection (0 = بدون timeout)
tcp-keepalive 300                   # ارسال keepalive هر ۳۰۰ ثانیه

# ===== General =====
daemonize no                        # در foreground اجرا شود (systemd مدیریت می‌کند)
supervised no                       # مدیریت procesos توسط systemd
pidfile /var/run/redis_6379.pid     # مسیر فایل PID
loglevel notice                     # debug | verbose | notice | warning
logfile /var/log/redis/redis-server.log  # مسیر فایل لاگ (خالی = stdout)
databases 16                        # تعداد دیتابیس (0 تا 15)

# ===== Snapshotting (RDB) =====
save 900 1                          # اگر ۱ کلید در ۱۵ دقیقه تغییر کرد، snapshot بگیر
save 300 10                         # اگر ۱۰ کلید در ۵ دقیقه تغییر کرد، snapshot بگیر
save 60 10000                       # اگر ۱۰۰۰۰ کلید در ۱ دقیقه تغییر کرد، snapshot بگیر
stop-writes-on-bgsave-error yes     # اگر snapshot ناموفق بود، writeها را متوقف کن
rdbcompression yes                  # فشرده‌سازی RDB با LZF
rdbchecksum yes                     # بررسی checksum فایل RDB
dbfilename dump.rdb                 # نام فایل RDB
dir /var/lib/redis                  # مسیر ذخیره فایل‌های RDB و AOF

# ===== Append Only File (AOF) =====
appendonly no                       # فعال‌سازی AOF (پیش‌فرض off)
appendfilename "appendonly.aof"     # نام فایل AOF
appendfsync everysec                # always | everysec | no
no-appendfsync-on-rewrite no        # در حین AOF rewrite، fsync انجام نشود
auto-aof-rewrite-percentage 100     # rewrite وقتی حجم ۱۰۰٪ افزایش یافت
auto-aof-rewrite-min-size 64mb      # حداقل حجم برای rewrite

# ===== Memory Management =====
maxmemory 256mb                     # سقف مصرف RAM
maxmemory-policy allkeys-lru        # استراتژی eviction
maxmemory-samples 5                 # تعداد نمونه‌ها برای LRU

# ===== Security (در نسخه ۶ به بعد، ACL جایگزین شد) =====
requirepass your_strong_password    # رمز عبور سرور

# ===== Replication (در فصل ۲۰ مفصل) =====
# replicaof <masterip> <masterport>
# masterauth <master-password>

# ===== Limits =====
maxclients 10000                    # حداکثر تعداد کلاینت‌های همزمان
💡 نکته مهم

تنظیم maxmemory و maxmemory-policy در محیط production حیاتی است. بدون این تنظیمات، Redis ممکن است تمام RAM سرور را پر کند و باعث OOM (Out Of Memory) kill شود. در فصل ۱۵ به‌طور مفصل به استراتژی‌های eviction می‌پردازیم.

اجرای Redis به‌عنوان Daemon در لینوکس

اگر Redis را از کد منبع نصب کرده‌اید و می‌خواهید به‌عنوان سرویس پس‌زمینه اجرا شود، می‌توانید از systemd استفاده کنید. یک فایل به مسیر /etc/systemd/system/redis.service ایجاد کنید با محتوای زیر:

ini
[Unit]
Description=Redis In-Memory Data Store
After=network.target

[Service]
User=redis
Group=redis
ExecStart=/usr/local/bin/redis-server /etc/redis/redis.conf
ExecStop=/usr/local/bin/redis-cli shutdown
Restart=always
Type=notify
NotifyAccess=all

[Install]
WantedBy=multi-user.target

سپس سرویس را فعال و شروع کنید:

bash
# ایجاد کاربر و گروه redis
sudo useradd -r -s /bin/false redis
sudo mkdir -p /var/lib/redis /var/log/redis /etc/redis
sudo chown redis:redis /var/lib/redis /var/log/redis

# کپی فایل پیکربندی
sudo cp redis-stable/redis.conf /etc/redis/
sudo chown redis:redis /etc/redis/redis.conf

# فعال‌سازی و شروع سرویس
sudo systemctl daemon-reload
sudo systemctl enable redis
sudo systemctl start redis
sudo systemctl status redis

چند نسخه‌ی Redis روی یک سرور

گاهی نیاز دارید چند نسخه‌ی Redis را همزمان روی یک سرور اجرا کنید — مثلاً برای تست، توسعه یا migration. برای این کار، کافی است هر instance را با پورت و مسیر داده‌ی متفاوت اجرا کنید:

bash
# instance اول روی پورت 6379
redis-server --port 6379 --dir /var/lib/redis-1 --daemonize yes

# instance دوم روی پورت 6380
redis-server --port 6380 --dir /var/lib/redis-2 --daemonize yes

# instance سوم روی پورت 6381
redis-server --port 6381 --dir /var/lib/redis-3 --daemonize yes

# اتصال به هر کدام
redis-cli -p 6379
redis-cli -p 6380
redis-cli -p 6381

این روش برای محیط توسعه و تست مفید است؛ اما در production توصیه می‌شود از Docker یا Redis Sentinel/Cluster استفاده کنید. در فصل ۲۴ به استقرار Redis با Docker و Kubernetes به‌طور مفصل می‌پردازیم.

ℹ️ خلاصه

در این فصل، روش‌های مختلف نصب Redis را در سیستم‌عامل‌های مختلف بررسی کردیم. برای شروع یادگیری، ساده‌ترین روش Docker یا نصب با apt روی Ubuntu است. در فصل بعدی با redis-cli و دستورات پایه به‌صورت عملی کار می‌کنیم.

Chapter 03

شروع با redis-cli #

redis-cli (Redis Command Line Interface) ابزار رسمی و اصلی برای تعامل با Redis است. این ابزار به شما اجازه می‌دهد دستورات را به‌صورت تعاملی اجرا کنید، اسکریپت بنویسید، اطلاعات سرور را مانیتور کنید و حتی داده‌ها را بین سرورها منتقل کنید. در این فصل، تمام قابلیت‌های اصلی redis-cli را با مثال‌های عملی بررسی می‌کنیم. این ابزار برخلاف ظاهر ساده‌اش، بسیار قدرتمند است و بسیاری از توسعه‌دهندگان تنها بخش کوچکی از قابلیت‌های آن را می‌شناسند.

اتصال به سرور Redis

ساده‌ترین حالت اتصال، اجرای redis-cli بدون هیچ پارامتری است. در این حالت، به 127.0.0.1:6379 متصل می‌شوید و وارد محیط تعاملی می‌شوید:

bash
$ redis-cli
127.0.0.1:6379>

برای اتصال به سرور دیگر یا پورت متفاوت، از فلگ‌های -h (host) و -p (port) استفاده کنید:

bash
# اتصال به یک سرور remote
redis-cli -h redis.example.com -p 6380

# اتصال با TLS/SSL
redis-cli -h redis.example.com -p 6379 --tls --cacert /path/to/ca.pem

# اتصال با رمز عبور (روش قدیمی)
redis-cli -h redis.example.com -a "your_password"

# اتصال با نام کاربری و رمز (ACL در نسخه ۶ به بعد)
redis-cli -h redis.example.com --user myuser --pass "my_password"

# انتخاب دیتابیس (0 تا 15)
redis-cli -n 1

# اتصال با Unix socket
redis-cli -s /var/run/redis/redis-server.sock
⚠️ هشدار امنیتی

استفاده از فلگ -a باعث می‌شود رمز عبور در تاریخچه‌ی shell (~/.bash_history) ذخیره شود و در خروجی ps aux قابل مشاهده باشد. در محیط production، از متغیر محیطی REDISCLI_AUTH استفاده کنید تا این مشکل برطرف شود:

bash
export REDISCLI_AUTH="your_password"
redis-cli -h redis.example.com
# بدون نیاز به -a

اجرای دستورات به‌صورت non-interactive

یکی از قابلیت‌های مهم redis-cli، اجرای دستورات بدون ورود به محیط تعاملی است. این قابلیت برای اسکریپت‌نویسی، cron job و CI/CD بسیار مفید است:

bash
# اجرای یک دستور
redis-cli SET greeting "Hello Redis"
redis-cli GET greeting
# خروجی: Hello Redis

# اجرای چند دستور در یک خط
redis-cli SET counter 0 && redis-cli INCR counter && redis-cli INCR counter

# استفاده از pipe (stdin)
echo "SET foo bar" | redis-cli
echo -e "SET foo bar\nGET foo" | redis-cli

# اجرای دستورات از یک فایل
redis-cli < commands.txt

# خواندن از stdin به‌صورت bulk (برای SET بزرگ)
cat large_value.txt | redis-cli -x SET mykey

دستورات پایه: SET، GET، DEL، EXISTS

بیایید با ساده‌ترین و پرکاربردترین دستورات Redis شروع کنیم. این دستورات روی ساختار String کار می‌کنند که پایه‌ی همه‌ی کلیدها در Redis است:

bash
127.0.0.1:6379> SET user:1001 "Alice"
OK

127.0.0.1:6379> GET user:1001
"Alice"

127.0.0.1:6379> EXISTS user:1001
(integer) 1

127.0.0.1:6379> EXISTS user:9999
(integer) 0

127.0.0.1:6379> DEL user:1001
(integer) 1

127.0.0.1:6379> GET user:1001
(nil)

# SET با انقضا (به ثانیه)
127.0.0.1:6379> SET session:abc123 "user_data" EX 3600
OK

# مشاهده TTL باقی‌مانده (ثانیه)
127.0.0.1:6379> TTL session:abc123
(integer) 3599

# SET فقط اگر کلید وجود نداشته باشد (NX = Not eXists)
127.0.0.1:6379> SET user:1001 "Bob" NX
(nil)  # چون user:1001 از قبل وجود داشت

# SET فقط اگر کلید از قبل وجود داشته باشد (XX = eXists)
127.0.0.1:6379> SET user:1001 "Bob" XX
OK  # چون کلید وجود داشت، مقدار به‌روزرسانی شد

دستور SET در نسخه‌های جدید Redis، فلگ‌های متعددی پشتیبانی می‌کند که ترکیب آن‌ها با دستورات جداگانه‌ی قدیمی (مانند SETNX، SETEX، PSETEX) منسوخ شده‌اند. جدول زیر فلگ‌های SET را نشان می‌دهد:

فلگ توضیح معادل قدیمی
EX secondsانقضا به ثانیهSETEX
PX millisecondsانقضا به میلی‌ثانیهPSETEX
NXفقط اگر وجود نداشته باشدSETNX
XXفقط اگر وجود داشته باشد
GETمقدار قبلی را برگردانGETSET
KEEPTTLTTL قبلی را حفظ کن
EXAT timestampانقضا در زمان Unix مشخص

مدیریت کلیدها: KEYS، SCAN، TYPE، RENAME

دستور KEYS تمام کلیدهای مطابق الگو را برمی‌گرداند. اما هشدار مهم: این دستور در محیط production ممنوع است! زیرا Redis single-threaded است و KEYS * می‌تواند صدها هزار کلید را اسکن کند و در این مدت، تمام دستورات دیگر مسدود شوند. به‌جای آن، از SCAN استفاده کنید که به‌صورت incremental کار می‌کند:

bash
127.0.0.1:6379> MSET user:1 "Alice" user:2 "Bob" user:3 "Carol" post:1 "Hello"
OK

# KEYS با الگو (خطرناک در production!)
127.0.0.1:6379> KEYS user:*
1) "user:1"
2) "user:2"
3) "user:3"

127.0.0.1:6379> KEYS *
1) "user:1"
2) "post:1"
3) "user:2"
4) "user:3"

# SCAN - روش امن (cursor-based)
127.0.0.1:6379> SCAN 0 MATCH user:* COUNT 100
1) "0"            # cursor بعدی (0 یعنی تمام شد)
2) 1) "user:1"
   2) "user:2"
   3) "user:3"

# TYPE - مشاهده نوع یک کلید
127.0.0.1:6379> TYPE user:1
string

127.0.0.1:6379> TYPE post:1
string

# RENAME - تغییر نام کلید
127.0.0.1:6379> RENAME user:1 user:1001
OK

# RENAMENX - فقط اگر کلید هدف وجود نداشته باشد
127.0.0.1:6379> RENAMENX user:2 user:1001
(integer) 0  # چون user:1001 از قبل وجود داشت

# EXPIRE - تنظیم TTL
127.0.0.1:6379> EXPIRE user:1001 60
(integer) 1

# PERSIST - حذف TTL
127.0.0.1:6379> PERSIST user:1001
(integer) 1
🚨 ممنوعیت KEYS در production

هرگز از KEYS * یا KEYS pattern روی دیتابیس production استفاده نکنید. روی یک دیتابیس با ۱ میلیون کلید، این دستور می‌تواند ۱ تا ۵ ثانیه طول بکشد و در این مدت، تمام درخواست‌های کلاینت‌ها مسدود می‌شوند. همیشه از SCAN با COUNT مناسب استفاده کنید. در فصل ۲۳ (مانیتورینگ) به‌طور مفصل به این موضوع می‌پردازیم.

دستورات اطلاعاتی: INFO، DBSIZE، CLIENT LIST

برای مشاهده‌ی وضعیت سرور، چند دستور مفید وجود دارد که هر یک اطلاعاتی متفاوت ارائه می‌کنند:

bash
# DBSIZE - تعداد کل کلیدها در دیتابیس فعلی
127.0.0.1:6379> DBSIZE
(integer) 1245

# INFO - اطلاعات کامل سرور
127.0.0.1:6379> INFO
# Server
redis_version:7.4.0
redis_mode:standalone
os:Linux 6.5.0-26-generic
arch_bits:64
process_id:1234
tcp_port:6379
uptime_in_seconds:86400
uptime_in_days:1

# Clients
connected_clients:5
cluster_enabled:0

# Memory
used_memory:1048576
used_memory_human:1.00M
used_memory_peak:2097152
used_memory_peak_human:2.00M
maxmemory:268435456
maxmemory_human:256.00M
maxmemory_policy:allkeys-lru

# Stats
total_connections_received:100
total_commands_processed:5000
instantaneous_ops_per_sec:15
...

# INFO با بخش خاص
127.0.0.1:6379> INFO memory
127.0.0.1:6379> INFO replication
127.0.0.1:6379> INFO clients
127.0.0.1:6379> INFO stats

# CLIENT LIST - لیست تمام کلاینت‌های متصل
127.0.0.1:6379> CLIENT LIST
id=1 addr=127.0.0.1:54321 laddr=127.0.0.1:6379 fd=8 name= age=3600 idle=0 flags=N db=0 sub=0 psub=0 ssub=0 multi=-1 qbuf=0 qbuf-free=20474 argv-mem=0 multi-mem=0 rbs=1024 rbp=0 obl=0 oll=0 omem=0 tot-mem=29898 events=r cmd=client|list user=default red=-1

# CLIENT INFO - نسخه‌ی خلاصه‌تر
127.0.0.1:6379> CLIENT INFO
id=1 addr=127.0.0.1:54321 ...

# CONFIG GET - مشاهده‌ی یک تنظیم
127.0.0.1:6379> CONFIG GET maxmemory
1) "maxmemory"
2) "268435456"

# CONFIG SET - تغییر تنظیم بدون restart
127.0.0.1:6379> CONFIG SET maxmemory 512mb
OK

# CONFIG REWRITE - ذخیره‌ی تغییرات در redis.conf
127.0.0.1:6379> CONFIG REWRITE
OK

انتخاب دیتابیس و عملیات بین دیتابیسی

Redis به‌طور پیش‌فرض ۱۶ دیتابیس (شماره ۰ تا ۱۵) دارد که می‌توانید با دستور SELECT بین آن‌ها جابه‌جا شوید. این مفهوم شبیه به schema در PostgreSQL است، اما تفاوت‌های مهمی دارد. دیتابیس‌های Redis کاملاً ایزوله نیستند و در صورت نیاز به ایزولاسیون واقعی، باید از Redis instances جداگانه یا Redis Cluster استفاده کنید:

bash
# دیتابیس پیش‌فرض ۰ است
127.0.0.1:6379> SET key_in_db0 "value"
OK

# انتقال به دیتابیس ۱
127.0.0.1:6379[0]> SELECT 1
OK
127.0.0.1:6379[1]> GET key_in_db0
(nil)  # در دیتابیس ۱ این کلید وجود ندارد

127.0.0.1:6379[1]> SET key_in_db1 "another"
OK

# MOVE - انتقال یک کلید به دیتابیس دیگر
127.0.0.1:6379[1]> MOVE key_in_db1 0
(integer) 1
127.0.0.1:6379[1]> SELECT 0
OK
127.0.0.1:6379> GET key_in_db1
"another"

# FLUSHDB - پاک کردن فقط دیتابیس فعلی
127.0.0.1:6379> FLUSHDB
OK

# FLUSHALL - پاک کردن تمام دیتابیس‌ها (!!!)
127.0.0.1:6379> FLUSHALL
OK
🚨 FLUSHALL = تمام داده‌ها!

FLUSHALL تمام داده‌های تمام دیتابیس‌ها را بدون هیچ تأییدی حذف می‌کند. همیشه قبل از اجرای آن، در کدام دیتابیس هستید را با SELECT یا مشاهده‌ی prompt چک کنید. در محیط production، می‌توانید با rename-command FLUSHALL "" در redis.conf این دستور را غیرفعال کنید.

مانیتورینگ لحظه‌ای: MONITOR و SLOWLOG

MONITOR یک ابزار دیباگ قدرتمند است که تمام دستورات اجرا شده در سرور را به‌صورت زنده نمایش می‌دهد. این دستور برای دیباگ و درک رفتار اپلیکیشن مفید است، اما به‌دلیل overhead زیاد، هرگز در production استفاده نکنید:

bash
127.0.0.1:6379> MONITOR
OK
1709827200.123456 [0 127.0.0.1:54321] "SET" "user:1" "Alice"
1709827200.123567 [0 127.0.0.1:54321] "GET" "user:1"
1709827200.123678 [0 127.0.0.1:54321] "INCR" "counter"
1709827200.123789 [0 127.0.0.1:54321] "LPUSH" "queue" "job_1"
# (Ctrl+C برای خروج)

SLOWLOG لیست دستوراتی که بیش از یک آستانه‌ی زمانی طول کشیده‌اند را نگه می‌دارد. این ابزار برای یافتن کوئری‌های کند و بهینه‌سازی عملکرد بسیار مفید است:

bash
# مشاهده ۱۰ دستور اخیر کند
127.0.0.1:6379> SLOWLOG GET 10
1) 1) (integer) 14              # شناسه‌ی رویداد
   2) (integer) 1709827200      # timestamp Unix
   3) (integer) 25000           # مدت اجرا به میکروثانیه (25ms)
   4) 1) "KEYS"
      2) "*"
   5) "127.0.0.1:54321"         # آدرس کلاینت
   6) "myworker"                # نام کلاینت

# تنظیم آستانه‌ی slowlog (مثلاً 10ms = 10000 میکروثانیه)
127.0.0.1:6379> CONFIG SET slowlog-log-slower-than 10000
OK

# تنظیم حداکثر تعداد رویدادهای ذخیره شده
127.0.0.1:6379> CONFIG SET slowlog-max-len 128
OK

# تعداد کل رویدادهای ذخیره شده
127.0.0.1:6379> SLOWLOG LEN
(integer) 5

# پاک کردن slowlog
127.0.0.1:6379> SLOWLOG RESET

خواندن و نوشتن داده به‌صورت bulk

برای انتقال داده‌های بزرگ، redis-cli چندین روش پیشنهاد می‌کند. اولین روش، خواندن از stdin با فلگ -x است که برای ذخیره‌ی فایل‌های بزرگ مناسب است:

bash
# ذخیره‌ی محتوای یک فایل به‌عنوان value
cat /etc/hosts | redis-cli -x SET hosts_file

# بازیابی
redis-cli GET hosts_file

# روش دیگر: استفاده از pipe برای حجم زیاد
# فرض کنید فایل commands.txt شامل هزاران دستور است
cat commands.txt | redis-cli --pipe

# مثال فایل commands.txt:
# SET key1 value1
# SET key2 value2
# HSET hash1 field1 val1
# LPUSH list1 item1

# تولید دستورات با awk و ارسال با pipe
awk '{print "SET user:" $1 " " $2}' users.txt | redis-cli --pipe

# خروجی --pipe:
# All data transferred. Waiting for the last reply...
# Last reply received from server.
# errors: 0, replies: 100000

تشخیص و رفع مشکلات اتصال

گاهی اوقات اتصال به Redis به‌درستی برقرار نمی‌شود. در این بخش، روش‌های عیب‌یابی را بررسی می‌کنیم. اولین قالب، استفاده از فلگ --verbose برای دیدن جزئیات اتصال است:

bash
# حالت verbose برای دیباگ اتصال
redis-cli -h redis.example.com -p 6379 --verbose

# تست اتصال بدون اجرای دستور
redis-cli -h redis.example.com -p 6379 PING

# اگر خطای "Could not connect" گرفتید:
# 1. بررسی کنید Redis در حال اجرا باشد
sudo systemctl status redis
# 2. بررسی کنید پورت باز باشد
ss -tlnp | grep 6379
# 3. بررسی فایروال
sudo ufw status
sudo ufw allow 6379
# 4. بررسی bind در redis.conf
# اگر bind 127.0.0.1 باشد، فقط localhost قابل دسترس است

# تست از راه دور با telnet یا nc
telnet redis.example.com 6379
# یا
nc -zv redis.example.com 6379

مدیریت اتصال و timeout

redis-cli به‌طور پیش‌فرض اتصال را باز نگه می‌دارد تا دستور جدید اجرا شود. اما در صورت نیاز می‌توانید timeout و سایر تنظیمات اتصال را کنترل کنید:

bash
# timeout اتصال (ثانیه)
redis-cli --timeout 30

# interval تلاش مجدد برای reconnect
redis-cli --retryAttempts 3 --retryDelay 5

# تنظیم نام کلاینت (در CLIENT LIST قابل مشاهده است)
redis-cli --clientname "my-app-worker"

# خاموش کردن رنگ (در CI/CD مفید است)
redis-cli --no-raw

# فرمت خروجی به‌صورت raw (بدون escape)
redis-cli --raw GET user:1

# فرمت CSV (برای پردازش با awk)
redis-cli --csv HGETALL user:1

# فرمت JSON (از نسخه ۷ به بعد)
redis-cli --json GET user:1

دستورات کاربردی برای عیب‌یابی

چند دستور کم‌شناخته اما بسیار مفید برای عیب‌یابی وجود دارند که هر توسعه‌دهنده‌ی Redis باید بداند:

bash
# DEBUG SLEEP - مسدود کردن سرور (فقط برای تست)
127.0.0.1:6379> DEBUG SLEEP 5
# (سرور برای ۵ ثانیه هیچ پاسخی نمی‌دهد)

# DEBUG OBJECT - اطلاعات داخلی یک کلید
127.0.0.1:6379> DEBUG OBJECT user:1
Value at:0x7f8a3c1a2000 refcount:1 encoding:embstr serializedlength:14 lru:1709827 lru_seconds_idle:30

# OBJECT ENCODING - نوع encoding داخلی
127.0.0.1:6379> OBJECT ENCODING user:1
"embstr"

127.0.0.1:6379> OBJECT ENCODING mylist
"listpack"  # یا "quicklist" برای لیست‌های بزرگ‌تر

# OBJECT REFCOUNT - تعداد ارجاعات
127.0.0.1:6379> OBJECT REFCOUNT user:1
(integer) 1

# OBJECT IDLETIME - مدت زمان idle (ثانیه)
127.0.0.1:6379> OBJECT IDLETIME user:1
(integer) 30

# OBJECT FREQ - فرکانس دسترسی (با maxmemory-policy LFU)
127.0.0.1:6379> OBJECT FREQ user:1
(integer) 3

# RANDOMKEY - یک کلید تصادفی
127.0.0.1:6379> RANDOMKEY
"user:1"

# LASTSAVE - آخرین زمان موفق BGSAVE
127.0.0.1:6379> LASTSAVE
(integer) 1709827200

# BGSAVE - ذخیره‌ی ناهمزمان
127.0.0.1:6379> BGSAVE
Background saving started

# SAVE - ذخیره‌ی همزمان (سرور را مسدود می‌کند!)
127.0.0.1:6379> SAVE
OK
💡 نکته طلایی

دستور OBJECT ENCODING برای درک نحوه‌ی ذخیره‌سازی داخلی یک کلید بسیار مفید است. Redis به‌صورت هوشمند ساختار ذخیره‌سازی را بر اساس اندازه‌ی داده تغییر می‌دهد — مثلاً یک Hash کوچک (< 128 فیلد) به‌صورت listpack ذخیره می‌شود، اما وقتی بزرگ‌تر می‌شود به hashtable تبدیل می‌گردد. این موضوع در فصل ۴ و فصل ۲۳ (مانیتورینگ) مفصل بررسی می‌شود.

خروجی JSON و اسکریپت‌نویسی

از Redis ۷ به بعد، redis-cli از فلگ --json پشتیبانی می‌کند که برای پردازش با ابزارهایی مانند jq بسیار مفید است:

bash
# خروجی JSON
redis-cli --json INFO memory
# {"used_memory":"1048576","used_memory_human":"1.00M",...}

# ترکیب با jq برای استخراج یک فیلد
redis-cli --json INFO memory | jq -r '.used_memory_human'
# 1.00M

# ترکیب با jq برای استخراج چند فیلد
redis-cli --json INFO memory | jq '{used: .used_memory_human, peak: .used_memory_peak_human, max: .maxmemory_human}'
# {
#   "used": "1.00M",
#   "peak": "2.00M",
#   "max": "256.00M"
# }

# ساختن یک اسکریپت مانیتورینگ ساده
#!/bin/bash
# monitor_redis.sh
while true; do
  USED=$(redis-cli --json INFO memory | jq -r '.used_memory_human')
  CLIENTS=$(redis-cli --json INFO clients | jq -r '.connected_clients')
  OPS=$(redis-cli --json INFO stats | jq -r '.instantaneous_ops_per_sec')
  echo "$(date) - mem: $USED, clients: $CLIENTS, ops/s: $OPS"
  sleep 5
done
📝 خلاصه فصل

در این فصل، با redis-cli به‌عنوان ابزار اصلی کار با Redis آشنا شدید. مهم‌ترین نکات عبارتند از: اتصال با فلگ‌های مختلف، اجرای دستورات به‌صورت non-interactive برای اسکریپت‌نویسی، استفاده از SCAN به‌جای KEYS، مانیتورینگ با INFO و SLOWLOG، و خروجی JSON برای پردازش با ابزارهای دیگر. در فصل بعدی، ساختارهای داده‌ی Redis را به‌صورت مفصل بررسی می‌کنیم.

Chapter 04

ساختار داده‌ها و Keyspace #

یکی از مهم‌ترین ویژگی‌های Redis که آن را از سایر key-value storeها متمایز می‌کند، تنوع ساختارهای داده‌ای است که پشتیبانی می‌کند. در حالی که Memcached یا Amazon DynamoDB (در حالت پایه) فقط رشته ذخیره می‌کنند، Redis هشت نوع داده‌ی اصلی را به‌صورت بومی پشتیبانی می‌کند و برای هر کدام ده‌ها دستور اختصاصی ارائه می‌دهد. در این فصل، قبل از ورود به جزئیات هر نوع داده، یک نمای کلی از همه‌ی آن‌ها، مفهوم Keyspace، Encoding داخلی و قوانین نام‌گذاری کلید را بررسی می‌کنیم. این فصل پایه‌ی theoretical لازم برای فصل‌های ۵ تا ۱۲ است که هر ساختار داده را به‌تفصیل پوشش می‌دهند.

نمای کلی ساختارهای داده‌ی Redis

Redis در نسخه‌ی ۷.۴، ساختارهای داده‌ی زیر را پشتیبانی می‌کند. هر کدام کاربرد خاص خود را دارند و انتخاب درست آن‌ها کلید موفقیت در طراحی سیستم است:

نوع توضیح Encoding‌های ممکن موارد استفاده
Stringدنباله‌ای از بایت‌ها (تا ۵۱۲ مگ)int, embstr, rawcache، counter، session
Listلیست مرتب از رشته‌ها (دو طرفه)listpack, quicklistqueue، stack، timeline
Hashنگاشت فیلد به مقدارlistpack, hashtableذخیره اشیاء، تنظیمات
Setمجموعه‌ی نامرتب از رشته‌های یکتاintset, listpack, hashtableتگ‌ها، روابط
Sorted SetSet با امتیاز (score) مرتبlistpack, skiplistleaderboard، رتبه‌بندی
Bitmapعملیات بیتی روی String(همان String)active users، فیلتر Bloom
Bitfieldاعداد صحیح درون String(همان String)شمارنده‌های فشرده
HyperLogLogتخمین کاردینالیتی(ساختار خاص با 12KB)UV، unique visitors
Streamlog append-only با consumer groupradix tree + listpackmessage queue، event log
Geoمختصات جغرافیایی(Sorted Set با رمزگذاری GeoHash)نزدیک‌ترین مکان
ℹ️ نکته مهم

در Redis، نوع داده‌ی هیچ کلیدی در زمان ساخت مشخص نمی‌شود؛ بلکه با اولین دستور نوشتن تعیین می‌گردد. مثلاً اگر اولین دستور LPUSH mylist a باشد، mylist از نوع List می‌شود. اما اگر بعداً SET mylist "hello" اجرا کنید، خطای WRONGTYPE می‌گیرید. این رفتار، از ثبات نوع داده اطمینان می‌دهد.

مفهوم Keyspace و نوع آن

تمام داده‌های Redis در یک فضای کلید مسطح (flat keyspace) نگهداری می‌شوند — برخلاف پایگاه‌های داده‌ی رابطه‌ای که جدول و schema دارند، یا MongoDB که collection دارد. این یعنی تمام کلیدها در یک دیتابیس (مثلاً دیتابیس ۰) در یک «دیکشنری» بزرگ ذهنی قرار دارند. برای شبیه‌سازی ساختار سلسله‌مراتبی، از قرارداد نام‌گذاری با کاراکتر دو نقطه (colon) استفاده می‌شود:

bash
# قرارداد رایج: entity:id:field
user:1001                # Hash با اطلاعات کاربر 1001
user:1001:name           # String فقط با نام
user:1001:settings       # Hash با تنظیمات کاربر
user:1001:followers      # Set با لیست دنبال‌کنندگان
user:1001:posts          # Sorted Set با پست‌های کاربر (زمان به‌عنوان score)

post:42                  # Hash با اطلاعات پست 42
post:42:likes            # Set با کاربرانی که لایک کرده‌اند
post:42:comments         # List با شناسه کامنت‌ها

# کلیدهای زمان‌دار با تاریخ
logs:2024:01:15          # Logهای ۱۵ ژانویه
metrics:cpu:2024-01-15   # CPU metrics روز مشخص

# کلیدهای موقت
session:abc123xyz        # String با داده‌ی session
rate_limit:user:1001     # String با شمارنده

این قرارداد فقط یک عرف است و Redis به آن کاری ندارد؛ اما ابزارهایی مانند redis-cli و رابط‌های مدیریتی (مانند RedisInsight) با این فرمت به‌خوبی کار می‌کنند و امکان فیلتر کردن با الگو را فراهم می‌کنند. به‌علاوه، SCAN MATCH user:1001:* به‌سادگی تمام فیلدهای مرتبط با یک کاربر را پیدا می‌کند.

قوانین نام‌گذاری کلید

انتخاب نام کلید، یکی از مهم‌ترین تصمیمات طراحی در Redis است. نام کلید هم به مصرف حافظه و هم به خوانایی و نگهداری کد تأثیر می‌گذارد. در اینجا چند قانون طلایی را مرور می‌کنیم:

۱. کوتاه اما معنادار باشد. کلید user:1001 بهتر از the_user_with_id_1001 است؛ زیرا در یک دیتابیس با میلیون‌ها کلید، صرفه‌جویی در چند بایت به‌صورت تجمعی، صدها مگابایت است. اما u:1 هم خیلی مبهم است و خوانایی کد را پایین می‌آورد. تعادل بین کوتاهی و خوانایی مهم است.

۲. از پیشوند معنادار استفاده کنید. پیشوند به جداسازی موجودیت‌ها کمک می‌کند: user:، post:، order:، session:. این پیشوندها هم در SCAN و هم در مانیتورینگ (با SLOWLOG) بسیار مفید هستند.

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

۴. نسخه‌ی schema را در کلید بگنجانید (اختیاری). اگر ساختار داده‌ی شما ممکن است در آینده تغییر کند، افزودن v1: به کلید به migration کمک می‌کند: user:v1:1001، user:v2:1001. این کار به‌خصوص در زمان تغییر encoding یا ساختار Hash مفید است.

⚠️ طول کلید و حافظه

Redis یک Dict داخلی دارد که هر کلید در آن با تمام رشته‌اش ذخیره می‌شود. اگر دیتابیس شما ۱۰ میلیون کلید دارد و هر کلید به‌طور میانگین ۳۰ بایت است، صرفاً نام کلیدها ۳۰۰ مگابایت حافظه اشغال می‌کند. به‌علاوه، هر کلید حدود ۶۰ بایت overhead (برای ساختار dictEntry، sds و...) دارد. در نتیجه، حتی یک کلید خالی ۶۰ بایت занимает. این موضوع در فصل ۲۳ (مانیتورینگ) مفصل بررسی می‌شود.

Encoding داخلی — چرا مهم است؟

یکی از زیبایی‌های طراحی Redis، تفکیک type (نوع منطقی داده) از encoding (نوع فیزیکی ذخیره‌سازی) است. وقتی شما یک Hash می‌سازید، Redis هوشمندانه تصمیم می‌گیرد که آن را با listpack (برای داده‌های کوچک) یا hashtable (برای داده‌های بزرگ) ذخیره کند. این تصمیم‌گیری به‌صورت خودکار و پویا انجام می‌شود و به Redis اجازه می‌دهد هم سرعت بالا و هم مصرف بهینه‌ی حافظه را ارائه دهد:

Type Encoding کوچک Encoding بزرگ آستانه‌ی تبدیل (پیش‌فرض)
Stringint (اگر عدد صحیح)embstr / rawint < 2^63؛ embstr <= 44 بایت
Listlistpackquicklistlist-max-listpack-size: -2 (8KB)
Hashlistpackhashtablehash-max-listpack-entries: 128، hash-max-listpack-value: 64
Setintset یا listpackhashtableset-max-intset-entries: 512، set-max-listpack-entries: 128
Sorted Setlistpackskiplist + hashtablezset-max-listpack-entries: 128، zset-max-listpack-value: 64
Streamlistpackradix treestream-node-max-bytes: 4KB

این آستانه‌ها در redis.conf قابل تنظیم هستند. مثلاً اگر Hash با بیش از ۱۲۸ فیلد دارید و می‌خواهید همیشه با listpack باشد (برای صرفه‌جویی در حافظه در ازای سرعت کمی پایین‌تر)، می‌توانید hash-max-listpack-entries 1000 تنظیم کنید. اما باید توجه داشت که listpack برای جستجوی خطی استفاده می‌شود، بنابراین در داده‌های بزرگ، O(n) می‌شود و عملکرد کاهش می‌یابد.

bash
# مشاهده‌ی encoding یک کلید
127.0.0.1:6379> HSET myhash f1 v1 f2 v2
(integer) 2
127.0.0.1:6379> OBJECT ENCODING myhash
"listpack"

# افزودن فیلدهای بیشتر تا آستانه عبور کند
127.0.0.1:6379> HSET myhash f3 v3 f4 v4 ... f129 v129
127.0.0.1:6379> OBJECT ENCODING myhash
"hashtable"  # به‌محض عبور از 128 فیلد، encoding تغییر کرد

عملیات عمومی روی کلیدها

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

bash
# DEL - حذف یک یا چند کلید
127.0.0.1:6379> DEL key1 key2 key3
(integer) 2  # تعداد کلیدهای حذف شده

# UNLINK - حذف ناهمزمان (بهتر برای کلیدهای بزرگ)
127.0.0.1:6379> UNLINK big_sorted_set
(integer) 1

# EXISTS - بررسی وجود (با شمارش)
127.0.0.1:6379> EXISTS key1 key2 key3
(integer) 2  # چند کلید وجود داشت

# EXPIRE - تنظیم TTL به ثانیه
127.0.0.1:6379> EXPIRE mykey 60
(integer) 1

# PEXPIRE - تنظیم TTL به میلی‌ثانیه
127.0.0.1:6379> PEXPIRE mykey 60000
(integer) 1

# EXPIREAT - انقضا در زمان Unix مشخص (ثانیه)
127.0.0.1:6379> EXPIREAT mykey 1709827200
(integer) 1

# TTL - زمان باقیمانده به ثانیه (-1 = بدون انقضا، -2 = کلید وجود ندارد)
127.0.0.1:6379> TTL mykey
(integer) 45

# PTTL - زمان باقیمانده به میلی‌ثانیه
127.0.0.1:6379> PTTL mykey
(integer) 45000

# PERSIST - حذف انقضا
127.0.0.1:6379> PERSIST mykey
(integer) 1

# TYPE - نوع داده
127.0.0.1:6379> TYPE mykey
string

# RENAME - تغییر نام (خطا می‌دهد اگر کلید وجود نداشته باشد)
127.0.0.1:6379> RENAME oldkey newkey
OK

# RENAMENX - فقط اگر هدف وجود نداشته باشد
127.0.0.1:6379> RENAMENX oldkey newkey
(integer) 1

# COPY - کپی کردن یک کلید (نسخه ۶.۲ به بعد)
127.0.0.1:6379> COPY source dest
(integer) 1
127.0.0.1:6379> COPY source dest DB 1 REPLACE
(integer) 1

# RANDOMKEY - یک کلید تصادفی
127.0.0.1:6379> RANDOMKEY
"user:1001"

# TOUCH - به‌روزرسانی LRU (بدون تغییر داده)
127.0.0.1:6379> TOUCH mykey
(integer) 1

# OBJECT HELP - کمک کامل
127.0.0.1:6379> OBJECT HELP

دستور UNLINK و حذف ناهمزمان

دستور DEL به‌صورت همزمان کار می‌کند: حافظه‌ی اشغال شده توسط کلید را فوراً آزاد می‌کند. برای کلیدهای کوچک (چند کیلوبایت) این کار آنی است؛ اما اگر کلید یک Sorted Set با میلیون‌ها عضو باشد یا یک List بزرگ باشد، DEL می‌تواند چند صد میلی‌ثانیه یا حتی چند ثانیه طول بکشد و در این مدت، سرور مسدود می‌شود. به‌جای آن، از UNLINK استفاده کنید که عملیات حذف را از کلیدزدایی جدا می‌کند:

sequenceDiagram participant C as Client participant R as Redis Main Thread participant B as Background Thread C->>R: UNLINK bigkey R->>R: حذف کلید از keyspace R-->>C: OK (fast, ~microseconds) R->>B: صف آزادسازی حافظه Note over B: آزادسازی در پس‌زمینه
(بدون مسدود کردن main thread)
// unlink-vs-del

UNLINK فوراً کلید را از keyspace حذف می‌کند و پاسخ OK را در حد میکروثانیه برمی‌گرداند؛ سپس آزادسازی حافظه‌ی واقعی به یک thread پس‌زمینه سپرده می‌شود. این موضوع برای کلیدهای بزرگ حیاتی است. از نسخه‌ی ۴ به بعد، DEL و UNLINK رفتار مشابهی دارند؛ اما در عمل، UNLINK همیشه امن‌تر است.

SCAN — تکرار امن روی کلیدها

دستور SCAN برای تکرار روی کلیدها بدون مسدود کردن سرور طراحی شده است. این دستور به‌صورت cursor-based کار می‌کند: شما یک cursor اولیه (معمولاً ۰) می‌دهید و سرور یک cursor جدید برمی‌گرداند که باید در فراخوانی بعدی استفاده شود. وقتی cursor به ۰ برگردد، یعنی تکرار تمام شده است. مزیت اصلی SCAN این است که در هر فراخوانی، فقط تعداد محدودی کلید (با COUNT) پردازش می‌کند و در نتیجه، سرور مسدود نمی‌شود:

bash
# SCAN cursor [MATCH pattern] [COUNT count] [TYPE type]

127.0.0.1:6379> SCAN 0 MATCH user:* COUNT 100
1) "42"  # cursor بعدی (0 = تمام شد)
2) 1) "user:1"
   2) "user:2"
   3) "user:99"
   ...

127.0.0.1:6379> SCAN 42 MATCH user:* COUNT 100
1) "0"   # تمام شد
2) 1) "user:100"
   2) "user:101"

# فیلتر بر اساس TYPE
127.0.0.1:6379> SCAN 0 TYPE hash COUNT 100
1) "0"
2) 1) "user:1"   # فقط کلیدهایی که از نوع Hash هستند
   2) "user:2"

نکات مهم درباره‌ی SCAN:

۱. COUNT یک پیشنهاد است نه الزام. ممکن است در یک فراخوانی، کمتر یا بیشتر از COUNT کلید برگردانده شود. این رفتار طبیعی است و نباید انتظار دقت داشت.

۲. تضمین نمی‌کند که کلیدی که قبل از شروع SCAN وجود نداشت، در نتایج نیاید یا کلیدی که در حین SCAN اضافه شد، حتماً بیاید. یعنی SCAN یک snapshot نمی‌دهد؛ بلکه روی وضعیت «تقریباً ثابت» کار می‌کند. این رفتار برای اکثر کاربردها کافی است.

۳. ممکن است یک کلید را بیش از یکبار برگرداند. این موضوع در داکیومنت رسمی ذکر شده و کد شما باید آن را مدیریت کند (مثلاً با یک Set برای ردیابی).

برای ساختارهای داده‌ی دیگر، دستورات مشابهی وجود دارد: HSCAN برای Hash، SSCAN برای Set، ZSCAN برای Sorted Set. این دستورات برای تکرار روی مجموعه‌های بزرگ بدون مسدود کردن سرور استفاده می‌شوند:

bash
# HSCAN برای تکرار روی فیلدهای یک Hash
127.0.0.1:6379> HSCAN myhash 0 COUNT 100
1) "0"
2)  1) "field1"
    2) "value1"
    3) "field2"
    4) "value2"
    ...

# SSCAN برای تکرار روی اعضای Set
127.0.0.1:6379> SSCAN myset 0 COUNT 100
1) "0"
2) 1) "member1"
   2) "member2"

# ZSCAN برای تکرار روی اعضای Sorted Set (با score)
127.0.0.1:6379> ZSCAN myzset 0 COUNT 100
1) "0"
2) 1) "member1"
   2) "100.5"
   3) "member2"
   4) "200.0"

مثال: ساخت اسکریپت پیمایش کامل

در ادامه، یک اسکریپت Bash می‌نویسیم که با استفاده از SCAN، تمام کلیدهای مطابق یک الگو را پیدا کرده و تعداد آن‌ها را می‌شمارد:

bash
#!/bin/bash
# count_keys.sh - شمارش کلیدهای مطابق الگو با SCAN

PATTERN="${1:-*}"
CURSOR=0
COUNT=0

while true; do
  # اجرای SCAN و تجزیه‌ی خروجی
  REPLY=$(redis-cli SCAN $CURSOR MATCH "$PATTERN" COUNT 1000)

  # خط اول cursor بعدی است
  CURSOR=$(echo "$REPLY" | head -n 1)

  # خطوط بعدی کلیدها هستند
  KEYS=$(echo "$REPLY" | tail -n +3)
  KEY_COUNT=$(echo "$KEYS" | grep -c .)
  COUNT=$((COUNT + KEY_COUNT))

  # نمایش کلیدها (اختیاری)
  echo "$KEYS"

  # اگر cursor به 0 برگشت، تمام شد
  if [ "$CURSOR" = "0" ]; then
    break
  fi
done

echo ""
echo "Total keys matching '$PATTERN': $COUNT"

این اسکریپت می‌تواند میلیون‌ها کلید را بدون تأثیر بر عملکرد سرور پیمایش کند. در Python با redis-py می‌توان این کار را با scan_iter خیلی ساده‌تر انجام داد:

python
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# پیمایش تمام کلیدهای مطابق الگو
count = 0
for key in r.scan_iter(match='user:*', count=1000):
    count += 1
    # پردازش کلید...

print(f"Total keys: {count}")

# پیمایش فیلدهای یک Hash بزرگ
for field, value in r.hscan_iter('myhash'):
    print(f"{field} = {value}")

عمر کلید (TTL) و رفتار انقضا

Redis یکی از قوی‌ترین مکانیزم‌های TTL را در میان پایگاه‌های داده دارد. هر کلید می‌تواند یک زمان انقضای مطلق یا نسبی داشته باشد که توسط یک تایمر داخلی (به نام active expiry cycle) به‌صورت دوره‌ای بررسی می‌شود. این مکانیزم به‌گونه‌ای بهینه شده که صدها هزار کلید با TTL را بدون تأثیر بر عملکرد مدیریت کند:

graph LR A[SET key value EX 60] --> B[key در keyspace] B --> C[TTL = 60s] C --> D[Active Expire Cycle
هر ۱۰۰ms] D --> E{TTL منقضی شده?} E -- بله --> F[حذف کلید] E -- خیر --> G[صبر کن] G --> D style D fill:#313244,stroke:#c084fc,color:#cdd6f4 style F fill:#45475a,stroke:#f38ba8,color:#cdd6f4
// active-expire-cycle

نکات مهم درباره‌ی TTL:

۱. TTL در زمان RENAME حفظ نمی‌شود (به‌طور پیش‌فرض). اگر کلیدی با TTL را RENAME کنید، کلید جدید بدون TTL خواهد بود. برای حفظ TTL، ابتدا TTL کلید قدیمی را بخوانید، RENAME کنید و سپس TTL را روی کلید جدید تنظیم کنید.

۲. TTL در زمان COPY قابل انتقال است. اگر از COPY source dest استفاده کنید، کلید جدید بدون TTL است؛ اما با فلگ REPLACE و دستی تنظیم TTL می‌توان آن را مدیریت کرد.

۳. TTL بر حسب ثانیه یا میلی‌ثانیه. دستورات EXPIRE، EXPIREAT بر حسب ثانیه و PEXPIRE، PEXPIREAT بر حسب میلی‌ثانیه هستند. برای دقت بالا، از نسخه‌های P استفاده کنید.

۴. SET با KEEPTTL. اگر می‌خواهید مقدار یک کلید را به‌روزرسانی کنید بدون اینکه TTL آن را از دست بدهید، از SET key value KEEPTTL استفاده کنید (نسخه ۶ به بعد).

💡 الگوی مفید: Distributed Lock با TTL

یکی از رایج‌ترین کاربردهای TTL، پیاده‌سازی قفل توزیع‌شده است. کلیدی مثل lock:resource:1 با SET ... NX EX 10 ساخته می‌شود: اگر کلید ساخته شد (پاسخ OK)، شما قفل را گرفته‌اید؛ اگر nil گرفتید، کس دیگری قفل را دارد. TTL ۱۰ ثانیه اطمینان می‌دهد که حتی اگر کلاینت شما crash کند، قفل به‌طور خودکار آزاد شود. در فصل ۱۷ (Lua Scripting) یک پیاده‌سازی کامل Redlock را بررسی می‌کنیم.

انتخاب نوع داده‌ی مناسب — چک‌لیست

انتخاب درست نوع داده، تأثیر بزرگی بر عملکرد و مصرف حافظه دارد. در ادامه، یک چک‌لیست ساده برای تصمیم‌گیری ارائه می‌کنیم:

نیاز شما نوع پیشنهادی دلیل
ذخیره یک مقدار ساده (رشته، عدد، JSON)Stringساده‌ترین، کم‌هزینه‌ترین
شمارنده (views، likes، downloads)String + INCRعملیات اتمیک و سریع
ذخیره شیء با چند فیلد (کاربر، محصول)Hashدسترسی به فیلد خاص بدون خواندن کل شیء
صف FIFO (job queue)List (LPUSH + RPOP)عملیات O(1) از هر دو سر
صف اولویت‌دارSorted Setترتیب بر اساس score (اولویت)
مجموعه‌ی یکتا (تگ‌ها، دسته‌ها)Setحذف تکراری به‌صورت خودکار
رتبه‌بندی (leaderboard)Sorted Setترتیب خودکار + دسترسی به رتبه
تیک‌زدن کاربران فعال روزانهBitmapیک بیت به ازای هر کاربر
شمارش کاربران یکتا (تقریبی)HyperLogLogفقط 12KB به ازای میلیاردها کلید
صف پیام با ack و replayStreamConsumer Group، persistence
اعلان لحظه‌ای (fire-and-forget)Pub/Subبدون نیاز به persistence
نزدیک‌ترین مکان (GPS)Geo (Sorted Set با GeoHash)عملیات شعاعی O(log N + M)
📝 خلاصه فصل

در این فصل، با نمای کلی ساختارهای داده‌ی Redis، مفهوم keyspace، قوانین نام‌گذاری، encoding داخلی، عملیات عمومی، SCAN و TTL آشنا شدید. این مفاهیم پایه‌ای، برای درک فصل‌های بعدی ضروری هستند. در فصل بعدی، با String — ساده‌ترین اما پرکاربردترین نوع داده — به‌صورت مفصل کار می‌کنیم.

Chapter 05

String (رشته) #

ساختار String پایه‌ای‌ترین و در عین حال پرکاربردترین نوع داده‌ی Redis است. برخلاف نام آن، String در Redis فقط رشته‌ی متنی نیست؛ بلکه یک دنباله‌ی بایت‌های arbitrary با حداکثر طول ۵۱۲ مگابایت است که می‌تواند شامل متن، JSON، HTML، تصاویر باینری، داده‌ی serialize شده یا اعداد صحیح باشد. این انعطاف‌پذیری باعث می‌شود String بیش از ۸۰٪ کاربردهای واقعی Redis را پوشش دهد: cache، session، counter، rate limiter و distributed lock همگی با String پیاده‌سازی می‌شوند. در این فصل، تمام دستورات مهم String را با مثال‌های عملی بررسی می‌کنیم.

دستور SET و GET — پایه‌ی کار

دستور SET key value یک کلید با مقدار مشخص می‌سازد (یا مقدار قبلی را بازنویسی می‌کند). GET key مقدار را برمی‌گرداند. در ساده‌ترین حالت، این دو دستور تمام چیزی هستند که برای استفاده از Redis به‌عنوان cache نیاز دارید:

bash
# مقداردهی ساده
127.0.0.1:6379> SET username "alice"
OK
127.0.0.1:6379> GET username
"alice"

# ذخیره JSON (به‌صورت String)
127.0.0.1:6379> SET user:1001 '{"name":"Alice","age":30,"email":"alice@example.com"}'
OK
127.0.0.1:6379> GET user:1001
"{\"name\":\"Alice\",\"age\":30,\"email\":\"alice@example.com\"}"

# ذخیره عدد به‌صورت String
127.0.0.1:6379> SET counter 100
OK
127.0.0.1:6379> GET counter
"100"

# اگر کلید وجود نداشته باشد، GET پاسخ nil می‌دهد
127.0.0.1:6379> GET nonexistent
(nil)

# SET با فلگ‌های متعدد (نسخه ۶.۰ به بعد)
127.0.0.1:6379> SET user:1002 "Bob" EX 3600 NX
OK  # فقط اگر وجود نداشته باشد، با انقضای ۱ ساعت

# GETDEL - خواندن و حذف همزمان (نسخه ۶.۲ به بعد)
127.0.0.1:6379> GETDEL username
"alice"
127.0.0.1:6379> GET username
(nil)

# GETEX - خواندن و تنظیم TTL (نسخه ۶.۲ به بعد)
127.0.0.1:6379> SET temp "data"
OK
127.0.0.1:6379> GETEX temp EX 60
"data"
127.0.0.1:6379> TTL temp
(integer) 60

# GETSET - خواندن مقدار قبلی و نوشتن مقدار جدید (قدیمی، جایگزین با SET ... GET)
127.0.0.1:6379> GETSET counter 0
"100"
127.0.0.1:6379> GET counter
"0"

عملیات اتمیک روی اعداد: INCR، DECR، INCRBY

یکی از قدرتمندترین ویژگی‌های String در Redis، پشتیبانی از عملیات اتمیک افزایش و کاهش است. اگر مقدار یک کلید عدد صحیح باشد، می‌توانید با INCR، DECR، INCRBY، DECRBY و INCRBYFLOAT آن را به‌صورت اتمیک تغییر دهید. این عملیات در محیط چندنخی کاملاً امن هستند و race condition ایجاد نمی‌کنند — چیزی که با GET و SET ساده نمی‌توان به‌سادگی به آن رسید:

bash
# INCR - افزایش واحد
127.0.0.1:6379> SET counter 10
OK
127.0.0.1:6379> INCR counter
(integer) 11
127.0.0.1:6379> INCR counter
(integer) 12

# DECR - کاهش واحد
127.0.0.1:6379> DECR counter
(integer) 11

# INCRBY - افزایش با مقدار دلخواه (صحیح)
127.0.0.1:6379> INCRBY counter 5
(integer) 16

# DECRBY - کاهش با مقدار دلخواه
127.0.0.1:6379> DECRBY counter 3
(integer) 13

# INCRBYFLOAT - افزایش اعشاری
127.0.0.1:6379> SET price 10.50
OK
127.0.0.1:6379> INCRBYFLOAT price 0.25
"10.75"

# اگر کلید وجود نداشته باشد، INCR آن را با 0 می‌سازد و سپس افزایش می‌دهد
127.0.0.1:6379> DEL new_counter
(integer) 1
127.0.0.1:6379> INCR new_counter
(integer) 1

# اگر مقدار عدد نباشد، خطا می‌گیرید
127.0.0.1:6379> SET name "Alice"
OK
127.0.0.1:6379> INCR name
(error) ERR value is not an integer or out of range
💡 چرا INCR اتمیک مهم است؟

فرض کنید دو کاربر همزمان روی یک دکمه‌ی «لایک» کلیک می‌کنند. اگر از GET counter و سپس SET counter + 1 استفاده کنید، هر دو کلاینت ممکن است مقدار ۱۰ را بخوانند و سپس ۱۱ را بنویسند — در نتیجه یک لایک از دست می‌رود. اما INCR counter به‌صورت اتمیک اجرا می‌شود و هر دو افزایش به‌درستی ثبت می‌شوند (نتیجه: ۱۲). این الگو برای شمارنده‌های views، likes، downloads، inventory و rate limiting حیاتی است.

الگوی Rate Limiter با INCR

یکی از کاربردهای کلاسیک INCR، پیاده‌سازی rate limiter است. در اینجا می‌خواهیم کاربری را محدود کنیم که نتواند بیش از ۱۰۰ درخواست در دقیقه ارسال کند:

python
import redis
import time

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

def rate_limit(user_id, limit=100, window=60):
    """
    اگر کاربر از limit مجاز شده باشد، True برمی‌گرداند.
    window به ثانیه است (پیش‌فرض: 60 ثانیه = 1 دقیقه)
    """
    # کلید منحصربه‌فرد برای هر کاربر در هر پنجره زمانی
    now = int(time.time())
    window_key = f"rate_limit:{user_id}:{now // window}"

    # استفاده از pipeline برای اجرای چند دستور اتمیک
    pipe = r.pipeline()
    pipe.incr(window_key)              # افزایش شمارنده
    pipe.expire(window_key, window)    # تنظیم TTL برای پاک‌سازی خودکار
    results = pipe.execute()

    current_count = results[0]
    if current_count > limit:
        return False  # محدود شده
    return True  # مجاز

# استفاده
for _ in range(105):
    user_id = "user_123"
    allowed = rate_limit(user_id)
    print(f"Request {'allowed' if allowed else 'BLOCKED'}")
# خروجی: ۱۰۰ تا allowed، ۵ تا BLOCKED

این الگو بسیار ساده و کارآمد است: به‌ازای هر پنجره‌ی زمانی (مثلاً هر دقیقه)، یک کلید ساخته می‌شود. کلیدها با TTL خودکار پاک می‌شوند و فضای Redis تمیز می‌ماند. برای دقت بیشتر، می‌توان از Sliding Window با Sorted Set استفاده کرد که در فصل ۹ بررسی می‌کنیم.

عملیات روی رشته‌ها: APPEND، STRLEN، SUBSTR

Redis چند دستور برای کار با رشته‌ها به‌عنوان متن ارائه می‌دهد. این دستورات برای ساختن log‌های متنی یا داده‌هایAppend-based مفید هستند:

bash
# APPEND - اضافه کردن به انتهای رشته
127.0.0.1:6379> SET greeting "Hello"
OK
127.0.0.1:6379> APPEND greeting ", World!"
(integer) 13  # طول جدید رشته
127.0.0.1:6379> GET greeting
"Hello, World!"

# اگر کلید وجود نداشته باشد، APPEND آن را می‌سازد
127.0.0.1:6379> APPEND newkey "First"
(integer) 5
127.0.0.1:6379> GET newkey
"First"

# STRLEN - طول رشته (به بایت)
127.0.0.1:6379> STRLEN greeting
(integer) 13

# برای رشته‌های فارسی، طول بر حسب بایت است نه کاراکتر
127.0.0.1:6379> SET fa "سلام"
OK
127.0.0.1:6379> STRLEN fa
(integer) 8  # هر کاراکتر فارسی UTF-8 دو بایت occupies

# GETRANGE - زیررشته (شامل هر دو انتها، اندیس از 0)
127.0.0.1:6379> GETRANGE greeting 0 4
"Hello"
127.0.0.1:6379> GETRANGE greeting 7 12
"World!"
127.0.0.1:6379> GETRANGE greeting -6 -1
"World!"  # اندیس منفی از انتها

# SETRANGE - جایگزینی بخشی از رشته
127.0.0.1:6379> SETRANGE greeting 7 "Redis"
(integer) 13
127.0.0.1:6379> GET greeting
"Hello, Redis!"

# اگر کلید وجود نداشته باشد، SETRANGE آن را با null byte پر می‌کند
127.0.0.1:6379> SETRANGE newstr 5 "X"
(integer) 6
127.0.0.1:6379> GET newstr
"\x00\x00\x00\x00\x00X"

# MSET - تنظیم چند کلید همزمان
127.0.0.1:6379> MSET key1 "val1" key2 "val2" key3 "val3"
OK

# MGET - خواندن چند کلید همزمان
127.0.0.1:6379> MGET key1 key2 key3
1) "val1"
2) "val2"
3) "val3"

# MSETNX - فقط اگر هیچ‌کدام از کلیدها وجود نداشته باشند
127.0.0.1:6379> MSETNX key1 "new1" key4 "new4"
(integer) 0  # چون key1 وجود داشت، هیچ‌کدام set نشدند

عملیات بیتی: SETBIT، GETBIT، BITCOUNT

Redis چند دستور برای کار با String به‌عنوان آرایه‌ای از بیت‌ها دارد. این دستورات پایه‌ی ساختار Bitmap هستند که در فصل ۱۰ به‌طور مفصل بررسی می‌کنیم، اما در اینجا دستورات پایه را معرفی می‌کنیم:

bash
# SETBIT - تنظیم یک بیت در موقعیت مشخص
# فرض می‌خواهیم user:1001 را در روز 7 "فعال" علامت بزنیم
127.0.0.1:6379> SETBIT active:users:day:7 1001 1
(integer) 0  # مقدار قبلی بیت

# GETBIT - خواندن یک بیت
127.0.0.1:6379> GETBIT active:users:day:7 1001
(integer) 1

127.0.0.1:6379> GETBIT active:users:day:7 9999
(integer) 0  # این بیت set نشده

# BITCOUNT - تعداد بیت‌های 1 در رشته
127.0.0.1:6379> BITCOUNT active:users:day:7
(integer) 1  # فقط یک بیت set شده

# BITCOUNT با محدوده
127.0.0.1:6379> BITCOUNT active:users:day:7 0 1000 BYTE
(integer) 1

# BITOP - عملیات بیتی بین چند کلید
127.0.0.1:6379> SETBIT active:users:day:8 1001 1
127.0.0.1:6379> SETBIT active:users:day:8 1002 1
127.0.0.1:6379> BITOP AND result active:users:day:7 active:users:day:8
(integer) 128  # طول نتیجه بر حسب بایت
# result شامل کاربرانی است که در هر دو روز فعال بوده‌اند

# BITOP OR - اجتماع
127.0.0.1:6379> BITOP OR result active:users:day:7 active:users:day:8
# کاربرانی که در حداقل یک روز فعال بوده‌اند

# BITOP XOR - XOR
127.0.0.1:6379> BITOP XOR result active:users:day:7 active:users:day:8
# کاربرانی که فقط در یک روز فعال بوده‌اند (نه هر دو)

# BITOP NOT - معکوس
127.0.0.1:6379> BITOP NOT result active:users:day:7

الگوی Cache با JSON

یکی از رایج‌ترین کاربردهای String، cache کردن پاسخ API یا نتایج کوئری به‌صورت JSON است. این الگو به‌قدری پرکاربرد است که تقریباً هر پروژه‌ای از Redis استفاده می‌کند، آن را پیاده‌سازی می‌کند:

python
import redis
import json
import time

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

def get_user(user_id):
    """دریافت اطلاعات کاربر با cache-aside pattern"""
    cache_key = f"user:{user_id}"

    # 1. ابتدا در cache بگرد
    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)

    # 2. اگر نبود، از دیتابیس بخوان
    user = fetch_user_from_db(user_id)  # تابع فرضی
    if user is None:
        return None

    # 3. در cache ذخیره کن (با TTL)
    r.set(cache_key, json.dumps(user), ex=3600)  # انقضا در ۱ ساعت

    return user

def update_user(user_id, data):
    """به‌روزرسانی کاربر و invalidation cache"""
    # 1. دیتابیس را به‌روز کن
    update_user_in_db(user_id, data)

    # 2. cache را پاک کن تا در درخواست بعدی از نو ساخته شود
    r.delete(f"user:{user_id}")

    # یا: cache را به‌روز کن
    # r.set(f"user:{user_id}", json.dumps(data), ex=3600)
⚠️ نکته مهم در مورد JSON

ذخیره JSON به‌صورت String ساده است؛ اما برای به‌روزرسانی یک فیلد خاص، باید کل JSON را بخوانید، parse کنید، فیلد را تغییر دهید و دوباره بنویسید. این کار برای داده‌های بزرگ ناکارآمد است. اگر اشیاء شما big هستند و به‌روزرسانی فیلد به فیلد نیاز دارید، از Hash (فصل ۷) یا ماژول RedisJSON (فصل ۲۲) استفاده کنید.

الگوی Session Store

ذخیره session کاربر یکی از بهترین کاربردهای Redis است؛ زیرا sessionها موقت، کوچک و نیازمند دسترسی سریع هستند. در ادامه، یک پیاده‌سازی ساده اما production-ready می‌بینیم:

python
import redis
import json
import secrets
import time

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class SessionStore:
    def __init__(self, ttl=86400):  # پیش‌فرض: ۲۴ ساعت
        self.ttl = ttl

    def create_session(self, user_id, user_data):
        """ساختن session جدید برای کاربر"""
        session_id = secrets.token_urlsafe(32)  # شناسه‌ی امن ۲۵۶-bit
        session_key = f"session:{session_id}"

        session_data = {
            'user_id': user_id,
            'data': user_data,
            'created_at': int(time.time()),
            'last_access': int(time.time()),
        }

        # ذخیره با TTL
        r.set(session_key, json.dumps(session_data), ex=self.ttl)

        # نگاشت user_id به session_id برای جستجوی سریع
        r.set(f"user_session:{user_id}", session_id, ex=self.ttl)

        return session_id

    def get_session(self, session_id):
        """خواندن session"""
        session_key = f"session:{session_id}"
        data = r.get(session_key)
        if not data:
            return None

        session = json.loads(data)
        # به‌روزرسانی last_access
        session['last_access'] = int(time.time())
        r.set(session_key, json.dumps(session), ex=self.ttl)

        return session

    def destroy_session(self, session_id):
        """پاک کردن session"""
        session_key = f"session:{session_id}"
        data = r.get(session_key)
        if data:
            session = json.loads(data)
            user_id = session.get('user_id')
            r.delete(session_key)
            if user_id:
                r.delete(f"user_session:{user_id}")

    def refresh_session(self, session_id):
        """تمدید TTL session (برای activity-based expiry)"""
        r.expire(f"session:{session_id}", self.ttl)

الگوی Distributed Lock با SET NX

یکی دیگر از کاربردهای مهم String، پیاده‌سازی قفل توزیع‌شده است. این الگو برای هماهنگ‌سازی چند پردازش یا سرور که به یک منبع مشترک دسترسی دارند، ضروری است:

python
import redis
import uuid
import time

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class DistributedLock:
    def __init__(self, lock_name, ttl=10, retry_count=3, retry_delay=0.1):
        self.lock_key = f"lock:{lock_name}"
        self.ttl = ttl
        self.retry_count = retry_count
        self.retry_delay = retry_delay
        self.lock_value = str(uuid.uuid4())  # مقدار منحصربه‌فرد برای ایمنی

    def acquire(self):
        """تلاش برای گرفتن قفل"""
        for _ in range(self.retry_count):
            # SET with NX + EX = atomic
            if r.set(self.lock_key, self.lock_value, nx=True, ex=self.ttl):
                return True
            time.sleep(self.retry_delay)
        return False

    def release(self):
        """آزاد کردن قفل (فقط اگر مال ما باشد)"""
        # نیاز به Lua script برای اتمیک بودن
        # این در فصل ۱۷ مفصل بررسی می‌شود
        script = """
        if redis.call("get", KEYS[1]) == ARGV[1] then
            return redis.call("del", KEYS[1])
        else
            return 0
        end
        """
        r.eval(script, 1, self.lock_key, self.lock_value)

    def __enter__(self):
        if not self.acquire():
            raise RuntimeError("Could not acquire lock")
        return self

    def __exit__(self, *args):
        self.release()

# استفاده
with DistributedLock("update_inventory:product_42", ttl=10) as lock:
    # در این بخش، فقط یک پردازش می‌تواند باشد
    update_inventory_safely()

توجه کنید که release از Lua Script استفاده می‌کند تا اتمیک باشد. دلیل این موضوع این است که اگر ابتدا GET کنیم و بعد DEL، بین این دو عملیات، قفل ممکن است منقضی شود و کس دیگری آن را گرفته باشد؛ سپس DEL ما قفل او را پاک می‌کند! Lua Script این مشکل را با اجرای اتمیک GET و DEL حل می‌کند. در فصل ۱۷ به‌طور مفصل به این موضوع می‌پردازیم.

Encoding داخلی String

Redis برای String از سه encoding متفاوت استفاده می‌کند که بر اساس محتوای value به‌صورت خودکار انتخاب می‌شوند:

Encoding شرایط استفاده مزیت
intاگر مقدار عدد صحیح در بازه‌ی ۲^63 باشدفقط ۸ بایت حافظه (به‌جای ذخیره‌ی رشته)
embstrاگر طول رشته ≤ 44 بایت باشدیک allocation پیوسته (سریع‌تر)
rawاگر طول رشته > 44 بایت باشددو allocation جداگانه
bash
# عدد صحیح -> int
127.0.0.1:6379> SET counter 100
OK
127.0.0.1:6379> OBJECT ENCODING counter
"int"

# رشته کوتاه -> embstr
127.0.0.1:6379> SET short "Hello"
OK
127.0.0.1:6379> OBJECT ENCODING short
"embstr"

# رشته بلند -> raw
127.0.0.1:6379> SET long "This is a very long string that exceeds the 44 byte threshold limit"
OK
127.0.0.1:6379> OBJECT ENCODING long
"raw"

# نکته: embstr به raw تبدیل می‌شود بعد از هر عملیات modify
127.0.0.1:6379> APPEND short "!"
127.0.0.1:6379> OBJECT ENCODING short
"raw"  # حالا raw شده

این encoding هوشمند، یکی از دلایل مصرف بهینه‌ی حافظه در Redis است. اعدادی که می‌توانند به‌صورت int ذخیره شوند، فقط ۸ بایت occupy می‌کنند (به‌جای ۲ تا ۲۰ بایت برای رشته‌ی عددی). این موضوع برای شمارنده‌ها و ID‌ها که میلیون‌ها کلید دارند، صرفه‌جویی بزرگی در حافظه است.

محدودیت‌ها و نکات

هرچند String بسیار قدرتمند است، محدودیت‌هایی دارد که باید بدانید:

۱. حداکثر طول یک مقدار String ۵۱۲ مگابایت است. اگر فایل بزرگ‌تری دارید، باید آن را به چند کلید تقسیم کنید یا از ساختارهای دیگر (مثل List) استفاده کنید.

۲. SET بدون TTL برای کلیدهایی که با حجم زیاد ساخته می‌شوند، می‌تواند حافظه را پر کند. همیشه TTL تعیین کنید یا از maxmemory-policy استفاده کنید.

۳. عملیات modify روی رشته‌ی بلند (SETRANGE، APPEND) می‌تواند کند باشد. Redis باید کل رشته را در حافظه کپی کند. برای log‌های متنی با حجم بالا، از List با LPUSH + LTRIM استفاده کنید.

۴. ذخیره JSON بزرگ به‌صورت String، عملیات خواندن/نوشتن کل را کند می‌کند. اگر فقط به چند فیلد نیاز دارید، از Hash استفاده کنید.

📝 خلاصه فصل

در این فصل با String آشنا شدید: دستورات SET/GET با فلگ‌های مختلف، عملیات اتمیک INCR/DECR برای شمارنده‌ها، عملیات بیتی برای Bitmap، الگوهای cache، session، rate limiter و distributed lock. String ساده‌ترین اما انعطاف‌پذیرترین نوع داده‌ی Redis است و در ۸۰٪ موارد کافی است. در فصل بعدی، با List کار می‌کنیم که برای صف‌ها و timeline‌ها طراحی شده است.

Chapter 06

List (لیست) #

ساختار List در Redis یک لیست پیوندی دو طرفه (doubly linked list) از رشته‌هاست که اجازه می‌دهد به‌صورت کارآمد از هر دو سر عناصر اضافه و حذف کنید — عملیاتی که هر کدام O(1) هستند. این ویژگی، List را به ابزار ایده‌آلی برای پیاده‌سازی صف (queue)، پشته (stack)، timeline و circular buffer تبدیل می‌کند. در این فصل، تمام دستورات مهم List را با مثال‌های کاربردی بررسی می‌کنیم و چند الگوی مهم مانند Reliable Queue و Producer-Consumer را پیاده‌سازی می‌کنیم.

دستورات پایه: LPUSH، RPUSH، LRANGE، LPOP

LPUSH یک عنصر به ابتدای لیست (سر چپ) اضافه می‌کند و RPUSH آن را به انتها (سر راست) اضافه می‌کند. LRANGE یک زیرمجموعه از لیست را برمی‌گرداند و LPOP/RPOP عنصر اول یا آخر را حذف و برمی‌گردانند:

bash
# RPUSH - اضافه کردن به انتها (راست)
127.0.0.1:6379> RPUSH tasks "task1" "task2" "task3"
(integer) 3
# لیست اکنون: [task1, task2, task3]

# LPUSH - اضافه کردن به ابتدا (چپ)
127.0.0.1:6379> LPUSH tasks "task0"
(integer) 4
# لیست اکنون: [task0, task1, task2, task3]

# LRANGE - خواندن محدوده (شامل هر دو انتها، اندیس از 0)
127.0.0.1:6379> LRANGE tasks 0 -1   # تمام لیست
1) "task0"
2) "task1"
3) "task2"
4) "task3"

127.0.0.1:6379> LRANGE tasks 0 1    # دو عنصر اول
1) "task0"
2) "task1"

127.0.0.1:6379> LRANGE tasks -2 -1  # دو عنصر آخر
1) "task2"
2) "task3"

# LLEN - طول لیست
127.0.0.1:6379> LLEN tasks
(integer) 4

# LPOP - حذف و برگرداندن عنصر اول
127.0.0.1:6379> LPOP tasks
"task0"
# لیست اکنون: [task1, task2, task3]

# RPOP - حذف و برگرداندن عنصر آخر
127.0.0.1:6379> RPOP tasks
"task3"
# لیست اکنون: [task1, task2]

# LPOP با count (نسخه ۶.۲ به بعد)
127.0.0.1:6379> LPOP tasks 2
1) "task1"
2) "task2"
# لیست اکنون خالی است

127.0.0.1:6379> LLEN tasks
(integer) 0

الگوی Stack (LIFO)

با استفاده از LPUSH و LPOP می‌توان یک پشته (stack) با رفتار LIFO (Last In, First Out) پیاده‌سازی کرد. هر دو عمل O(1) هستند:

bash
# Push (افزودن به پشته)
127.0.0.1:6379> LPUSH mystack "item1" "item2" "item3"
(integer) 3
# لیست: [item3, item2, item1]

# Pop (برداشتن از پشته - آخرینpushشده اول برداشته می‌شود)
127.0.0.1:6379> LPOP mystack
"item3"
127.0.0.1:6379> LPOP mystack
"item2"
127.0.0.1:6379> LPOP mystack
"item1"
127.0.0.1:6379> LPOP mystack
(nil)  # پشته خالی

الگوی Queue (FIFO)

با استفاده از RPUSH و LPOP می‌توان یک صف (queue) با رفتار FIFO (First In, First Out) ساخت. این الگوی پایه‌ای برای job queue‌ها است:

bash
# Enqueue (افزودن به انتها)
127.0.0.1:6379> RPUSH jobqueue "job1" "job2" "job3"
(integer) 3
# لیست: [job1, job2, job3]

# Dequeue (برداشتن از ابتدا)
127.0.0.1:6379> LPOP jobqueue
"job1"
127.0.0.1:6379> LPOP jobqueue
"job2"
127.0.0.1:6379> LPOP jobqueue
"job3"

# یا برعکس: LPUSH و RPOP
# هر دو الگو معادل هستند و FIFO می‌دهند

صف مسدودکننده: BLPOP و BRPOP

یکی از قدرتمندترین ویژگی‌های List، دستورات blocking هستند. BLPOP (Blocking LPOP) و BRPOP (Blocking RPOP) اگر لیست خالی باشد، کلاینت را به‌جای برگرداندن nil، تا زمانی که عنصری اضافه شود (یا timeout شود) مسدود می‌کنند. این قابلیت برای ساخت message queue بدون polling بی‌نهایت ایده‌آل است:

bash
# در ترمینال ۱ (consumer): BLPOP با timeout 0 (بی‌نهایت)
127.0.0.1:6379> BLPOP myqueue 0
# (مسدود می‌شود و منتظر می‌ماند...)

# در ترمینال ۲ (producer): اضافه کردن به صف
127.0.0.1:6379> LPUSH myqueue "hello"

# در ترمینال ۱: پاسخ دریافت می‌شود
1) "myqueue"     # نام لیست
2) "hello"       # مقدار
# (مدت زمان مسدود شدن: 5.123s)

# BLPOP با چندین لیست (اولین لیست غیرخالی برمی‌گردد)
127.0.0.1:6379> BLPOP high_priority low_priority 30
# اگر high_priority خالی باشد، low_priority چک می‌شود
# اگر هر دو خالی باشند، تا ۳۰ ثانیه منتظر می‌ماند

# timeout به ثانیه (می‌توان اعشاری هم داد)
127.0.0.1:6379> BLPOP myqueue 0.5  # نیم ثانیه
127.0.0.1:6379> BLPOP myqueue 0    # بی‌نهایت
💡 چرا BLPOP بهتر از polling است؟

اگر از LPOP در یک حلقه استفاده کنید، یا CPU کلاینت را اشغال می‌کنید (polling سریع) یا تأخیر اضافه ایجاد می‌کنید (polling کند). BLPOP این مشکل را حل می‌کند: کلاینت مسدود می‌شود و در سرور، هیچ CPU مصرف نمی‌کند. وقتی پیام جدیدی برسد، فوراً به کلاینت خبر داده می‌شود. این الگو تا حد زیادی می‌تواند جایگزین RabbitMQ یا Kafka در سناریوهای ساده باشد.

دستورات پیشرفته: LREM، LINSERT، LSET، LTRIM

علاوه بر عملیات پایه، Redis دستوراتی برای تغییر محتوای لیست در میانه ارائه می‌دهد:

bash
# ایجاد لیست برای تست
127.0.0.1:6379> RPUSH mylist "a" "b" "c" "b" "d" "b" "e"
(integer) 7

# LINDEX - دسترسی به عنصر با اندیس
127.0.0.1:6379> LINDEX mylist 0
"a"
127.0.0.1:6379> LINDEX mylist -1
"e"

# LSET - جایگزینی عنصر با اندیس
127.0.0.1:6379> LSET mylist 0 "AAA"
OK
# لیست اکنون: [AAA, b, c, b, d, b, e]

# LREM - حذف عنصر با مقدار
# LREM key count value
# count > 0: از ابتدا به انتها، count عنصر حذف کن
# count < 0: از انتها به ابتدا، |count| عنصر حذف کن
# count = 0: تمام تکرارها را حذف کن
127.0.0.1:6379> LREM mylist 2 "b"
(integer) 2
# لیست اکنون: [AAA, c, d, b, e]

127.0.0.1:6379> LREM mylist -1 "b"
(integer) 1
# لیست اکنون: [AAA, c, d, e]

127.0.0.1:6379> LREM mylist 0 "x"
(integer) 0  # "x" وجود نداشت

# LINSERT - درج قبل یا بعد یک عنصر
127.0.0.1:6379> LINSERT mylist BEFORE "c" "before_c"
(integer) 5
# لیست اکنون: [AAA, before_c, c, d, e]

127.0.0.1:6379> LINSERT mylist AFTER "d" "after_d"
(integer) 6
# لیست اکنون: [AAA, before_c, c, d, after_d, e]

# LTRIM - نگه‌داشتن فقط یک محدوده (حذف بقیه)
# این دستور برای ساخت circular buffer عالی است
127.0.0.1:6379> LTRIM mylist 0 3
OK
# لیست اکنون: [AAA, before_c, c, d]

# LRANGE برای تأیید
127.0.0.1:6379> LRANGE mylist 0 -1
1) "AAA"
2) "before_c"
3) "c"
4) "d"

الگوی Circular Buffer با LPUSH + LTRIM

یکی از کاربردهای جذاب List، ساخت circular buffer برای نگه‌داشتن N آخرین رویداد است. این الگو برای log‌های اخیر، recent activity، یا N آخرین پیام در یک چت استفاده می‌شود:

python
import redis
import time

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

MAX_LOGS = 1000  # فقط ۱۰۰۰ لاگ اخیر نگه دار

def add_log(level, message):
    """افزودن log به circular buffer"""
    log_entry = f"{time.strftime('%Y-%m-%d %H:%M:%S')} [{level}] {message}"
    key = "logs:recent"

    # Pipeline برای اجرای اتمیک LPUSH + LTRIM
    pipe = r.pipeline()
    pipe.lpush(key, log_entry)      # اضافه کردن به ابتدا
    pipe.ltrim(key, 0, MAX_LOGS - 1)  # نگه‌داشتن فقط N عنصر اول
    pipe.execute()

def get_recent_logs(count=50):
    """خواندن logs اخیر"""
    return r.lrange("logs:recent", 0, count - 1)

# استفاده
for i in range(1050):
    add_log("INFO", f"Event number {i}")

logs = get_recent_logs(10)
for log in logs:
    print(log)
# فقط ۱۰۰۰ لاگ اخیر نگه داشته شده، ۵۰ تا قدیمی حذف شده‌اند

الگوی Reliable Queue با RPOPLPUSH

یک مشکل با LPOP این است که اگر consumer پس از گرفتن پیام، قبل از پردازش crash کند، پیام از دست می‌رود. برای حل این مشکل، از الگوی Reliable Queue استفاده می‌کنیم که پیام را از صف اصلی به صف "processing" منتقل می‌کند. اگر پردازش موفق باشد، از صف processing حذف می‌شود؛ اگر نه، در صف processing باقی می‌ماند و می‌توان بعداً retry کرد:

bash
# RPOPLPUSH source destination
# اتمیک: آخرین عنصر از source را POP می‌کند و به ابتدای destination PUSH می‌کند

# producer: اضافه کردن کار به صف اصلی
127.0.0.1:6379> LPUSH main_queue "job1" "job2" "job3"

# consumer: انتقال اتمیک از main_queue به processing_queue
127.0.0.1:6379> RPOPLPUSH main_queue processing_queue
"job1"
# اکنون job1 در processing_queue است و از main_queue حذف شده

# پس از پردازش موفق، از processing_queue حذف کن
127.0.0.1:6379> LREM processing_queue 1 "job1"

# نسخه blocking: BRPOPLPUSH (یا LMOVE در نسخه ۶.۲ به بعد)
127.0.0.1:6379> BRPOPLPUSH main_queue processing_queue 30
# اگر main_queue خالی باشد، تا ۳۰ ثانیه منتظر می‌ماند

# LMOVE و BLMOVE (نسخه ۶.۲ به بعد - جایگزین RPOPLPUSH)
127.0.0.1:6379> BLMOVE main_queue processing_queue RIGHT LEFT 30
# آرگومان‌ها: source destination from to timeout
# from/to می‌توانند LEFT یا RIGHT باشند
graph LR P[Producer] -->|LPUSH| M[(main_queue)] M -->|BRPOPLPUSH atomically| C[Consumer] C -->|push to| PR[(processing_queue)] PR -->|process| W{Success?} W -- Yes --> R[LREM from processing] W -- No / Crash --> PR R -.retry.-> M style M fill:#313244,stroke:#c084fc,color:#cdd6f4 style PR fill:#313244,stroke:#f9e2af,color:#cdd6f4
// reliable-queue-with-rpoplpush

الگوی Timeline (آخرین فعالیت‌های کاربر)

یکی از کاربردهای کلاسیک List، ساخت timeline برای شبکه‌های اجتماعی است. وقتی کاربر پست جدیدی منتشر می‌کند، به timeline تمام دنبال‌کنندگانش اضافه می‌شود:

python
import redis
import json
import time

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

MAX_TIMELINE = 1000  # حداکثر تعداد پست در timeline هر کاربر

def publish_post(user_id, post_id, content):
    """انتشار پست و اضافه کردن به timeline دنبال‌کنندگان"""
    # دریافت لیست دنبال‌کنندگان (با فرض استفاده از Set)
    followers = r.smembers(f"followers:{user_id}")
    followers.add(str(user_id))  # پست به timeline خود کاربر هم اضافه می‌شود

    post_data = json.dumps({
        'post_id': post_id,
        'user_id': user_id,
        'content': content,
        'timestamp': int(time.time()),
    })

    # Pipeline برای اجرای سریع روی چندین کاربر
    pipe = r.pipeline()
    for follower_id in followers:
        timeline_key = f"timeline:{follower_id}"
        pipe.lpush(timeline_key, post_data)
        pipe.ltrim(timeline_key, 0, MAX_TIMELINE - 1)  # محدود به N پست اخیر
    pipe.execute()

def get_timeline(user_id, page=1, per_page=20):
    """خواندن timeline کاربر با pagination"""
    start = (page - 1) * per_page
    end = start + per_page - 1
    posts = r.lrange(f"timeline:{user_id}", start, end)
    return [json.loads(p) for p in posts]

# استفاده
publish_post("user_1", "post_42", "Hello, world!")
publish_post("user_1", "post_43", "Another post")

# صفحه اول timeline
timeline = get_timeline("user_1", page=1, per_page=10)
for post in timeline:
    print(f"{post['user_id']}: {post['content']}")

این الگو به fan-out on write معروف است و برای سیستم‌هایی با تعداد دنبال‌کننده‌ی محدود (تا چند هزار) عالی کار می‌کند. برای شبکه‌های بزرگ‌تر (مثل Twitter با میلیون‌ها دنبال‌کننده)، از الگوی fan-out on read یا ترکیبی استفاده می‌شود که در فصل ۲۵ بحث می‌کنیم.

Encoding داخلی List

List در Redis از دو encoding متفاوت استفاده می‌کند:

Encoding توضیح مزیت
listpackلیست پیوندی فشرده در یک block حافظه پیوستهکم‌مصرف، cache-friendly
quicklistلیست پیوندی از چند listpackبرای لیست‌های بزرگ، تعادل سرعت و حافظه
bash
# لیست کوچک: listpack
127.0.0.1:6379> RPUSH small "a" "b" "c"
127.0.0.1:6379> OBJECT ENCODING small
"listpack"

# لیست بزرگ: quicklist
127.0.0.1:6379> RPUSH big "elem1" "elem2" ... "elem500"
127.0.0.1:6379> OBJECT ENCODING big
"quicklist"

تنظیمات آستانه‌ی تبدیل در redis.conf با list-max-listpack-size (پیش‌فرض -2 یعنی حداکثر ۸ کیلوبایت هر listpack) و list-compress-depth (پیش‌فرض 0 یعنی بدون فشرده‌سازی) کنترل می‌شود.

الگوی Producer-Consumer با BLPOP

در ادامه، یک پیاده‌سازی کامل Producer-Consumer با BLPOP می‌بینیم. این الگو می‌تواند پایه‌ی یک سیستم job queue ساده اما قدرتمند باشد:

python
# producer.py
import redis
import json
import time

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

def send_task(queue_name, task_data):
    """ارسال task به صف"""
    message = json.dumps(task_data)
    r.lpush(queue_name, message)
    print(f"Sent: {task_data}")

# ارسال ۱۰ task
for i in range(10):
    send_task("email_queue", {
        'task_id': i,
        'to': f'user{i}@example.com',
        'subject': f'Test email {i}',
    })
    time.sleep(0.5)

# consumer.py
import redis
import json
import time

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

def process_email(task):
    """پردازش یک task ایمیل"""
    print(f"Sending email to {task['to']} with subject '{task['subject']}'")
    time.sleep(1)  # شبیه‌سازی ارسال ایمیل
    print(f"  -> Done: {task['task_id']}")

def worker(queue_name="email_queue"):
    """consumer که دائماً از صف می‌خواند"""
    print(f"Worker started, listening on '{queue_name}'...")
    while True:
        try:
            # BLPOP با timeout ۵ ثانیه (برای امکان graceful shutdown)
            result = r.blpop(queue_name, timeout=5)
            if result is None:
                continue  # timeout، دوباره تلاش کن

            queue, message = result
            task = json.loads(message)
            process_email(task)

        except KeyboardInterrupt:
            print("Worker shutting down...")
            break
        except Exception as e:
            print(f"Error: {e}")

# اجرای worker
worker()

این الگو برای سیستم‌های ساده بسیار کارآمد است؛ اما توجه داشته باشید که برای کاربردهای پیچیده‌تر (با نیاز به ack، retry policy، dead-letter queue، و ordering guarantees) بهتر است از Redis Streams (فصل ۱۲) استفاده کنید که در نسخه‌ی ۵.۰ اضافه شد و ویژگی‌های بسیار بیشتری برای message queue دارد.

الگوی Rate Limiter با List (Sliding Window)

در فصل ۵ یک rate limiter ساده با INCR دیدید؛ اما آن الگو یک «پنجره‌ی ثابت» است که در مرز پنجره‌ها مشکل دارد (مثلاً ۹۹ درخواست در ثانیه ۵۹:۵۹ و ۹۹ درخواست در ثانیه ۰۰:۰۰ = ۱۹۸ درخواست در ۲ ثانیه). با List می‌توان یک sliding window دقیق‌تر ساخت:

python
import redis
import time

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

def sliding_window_rate_limit(user_id, limit=100, window=60):
    """Rate limiter با sliding window دقیق"""
    key = f"ratelimit:{user_id}"
    now = time.time()
    window_start = now - window

    # Pipeline برای اجرای اتمیک چند عمل
    pipe = r.pipeline()
    # 1. حذف درخواست‌های قدیمی‌تر از window
    pipe.zremrangebyscore(key, 0, window_start)
    # 2. شمارش درخواست‌های باقیمانده
    pipe.zcard(key)
    # 3. اضافه کردن درخواست فعلی (با timestamp به‌عنوان score)
    pipe.zadd(key, {str(now): now})
    # 4. تنظیم TTL برای پاک‌سازی خودکار
    pipe.expire(key, window)
    results = pipe.execute()

    current_count = results[1]  # قبل از اضافه کردن درخواست فعلی
    if current_count >= limit:
        return False  # محدود شده
    return True  # مجاز

# اینجا از Sorted Set استفاده کردیم چون List برای این کار بهینه نیست
# اما الگوی کلی با LRANGE + LTRIM هم قابل پیاده‌سازی است
📝 نکته

برای sliding window دقیق، Sorted Set بهتر از List است (همانطور که در مثال بالا دیدید). List برای صف‌ها و timeline بهینه است؛ اما برای جستجوی بر اساس زمان، Sorted Set عملکرد بهتری دارد.

محدودیت‌های List

با وجود کاربردهای فراوان، List محدودیت‌هایی دارد که باید در طراحی مد نظر قرار دهید:

۱. دسترسی با اندیس O(N) است. LINDEX mylist 1000000 باید از ابتدا تا عنصر یک‌میلیون پیمایش کند. اگر نیاز به دسترسی تصادفی با O(1) دارید، از Sorted Set یا Hash استفاده کنید.

۲. جستجوی مقدار O(N) است. LPOS کل لیست را برای یافتن یک مقدار پیمایش می‌کند. برای جستجوی سریع، از Set استفاده کنید.

۳. حداقل یک عضو در لیست باید وجود داشته باشد تا List وجود داشته باشد. وقتی آخرین عضو را POP می‌کنید، کلید حذف می‌شود.

۴. حداکثر طول یک عنصر ۵۱۲ مگابایت است (مانند همه‌ی Stringها).

📝 خلاصه فصل

در این فصل با List آشنا شدید: دستورات پایه، الگوهای stack و queue، blocking با BLPOP، reliable queue با RPOPLPUSH، circular buffer با LTRIM، و timeline. List برای صف‌ها و دنباله‌های مرتب عالی است؛ اما برای کاربردهای پیچیده‌تر message queue، Redis Streams (فصل ۱۲) گزینه‌ی بهتری است.

Chapter 07

Hash (هش) #

ساختار Hash در Redis یک نگاشت (map) از فیلد به مقدار است — دقیقاً مانند یک Object در جاوااسکریپت، یک dict در پایتون، یا یک record در پایگاه داده‌ی رابطه‌ای. Hash برای ذخیره‌ی اشیاء با چندین ویژگی (مثل کاربر، محصول، پست) ایده‌آل است؛ زیرا به شما اجازه می‌دهد هر فیلد را به‌صورت جداگانه بخوانید یا به‌روز کنید، بدون اینکه کل شیء را بخوانید و serialize/deserialize کنید. این قابلیت هم سرعت را بالا می‌برد و هم مصرف حافظه را کاهش می‌دهد. در این فصل، تمام دستورات Hash را با مثال‌های عملی و الگوهای کاربردی بررسی می‌کنیم.

دستورات پایه: HSET، HGET، HGETALL

HSET یک فیلد را در Hash تنظیم می‌کند (می‌تواند چند فیلد همزمان تنظیم کند). HGET مقدار یک فیلد را برمی‌گرداند و HGETALL تمام فیلدها و مقادیر را برمی‌گرداند:

bash
# HSET - تنظیم یک فیلد
127.0.0.1:6379> HSET user:1001 name "Alice"
(integer) 1  # تعداد فیلدهای جدید اضافه شده

# HSET با چند فیلد همزمان (نسخه ۴ به بعد)
127.0.0.1:6379> HSET user:1001 name "Alice" age 30 email "alice@example.com" city "Tehran"
(integer) 3  # ۳ فیلد جدید

# HGET - خواندن یک فیلد
127.0.0.1:6379> HGET user:1001 name
"Alice"
127.0.0.1:6379> HGET user:1001 age
"30"

# اگر فیلد وجود نداشته باشد، nil برمی‌گردد
127.0.0.1:6379> HGET user:1001 phone
(nil)

# HMSET (قدیمی، با HSET جایگزین شده)
# در نسخه‌های قدیمی، HMSET برای چند فیلد استفاده می‌شد

# HGETALL - خواندن تمام فیلدها
127.0.0.1:6379> HGETALL user:1001
1) "name"
2) "Alice"
3) "age"
4) "30"
5) "email"
6) "alice@example.com"
7) "city"
8) "Tehran"

# HEXISTS - بررسی وجود فیلد
127.0.0.1:6379> HEXISTS user:1001 name
(integer) 1
127.0.0.1:6379> HEXISTS user:1001 phone
(integer) 0

# HDEL - حذف فیلد
127.0.0.1:6379> HDEL user:1001 city
(integer) 1
127.0.0.1:6379> HGET user:1001 city
(nil)

# HLEN - تعداد فیلدها
127.0.0.1:6379> HLEN user:1001
(integer) 3

# HSTRLEN - طول مقدار یک فیلد
127.0.0.1:6379> HSTRLEN user:1001 name
(integer) 5  # "Alice" = 5 بایت

# HMGET - خواندن چند فیلد همزمان
127.0.0.1:6379> HMGET user:1001 name age phone
1) "Alice"
2) "30"
3) (nil)  # phone وجود ندارد

# HKEYS - فقط نام فیلدها
127.0.0.1:6379> HKEYS user:1001
1) "name"
2) "age"
3) "email"

# HVALS - فقط مقادیر
127.0.0.1:6379> HVALS user:1001
1) "Alice"
2) "30"
3) "alice@example.com"

عملیات اتمیک روی فیلدهای عددی: HINCRBY، HINCRBYFLOAT

مانند String، Hash هم از عملیات اتمیک افزایش/کاهش پشتیبانی می‌کند. این قابلیت برای شمارنده‌های مرتبط با یک شیء (مثل بازدید پروفایل، تعداد لایک پست) بسیار مفید است:

bash
# HINCRBY - افزایش صحیح
127.0.0.1:6379> HSET user:1001 views 0 login_count 0
(integer) 2

127.0.0.1:6379> HINCRBY user:1001 views 1
(integer) 1
127.0.0.1:6379> HINCRBY user:1001 views 1
(integer) 2
127.0.0.1:6379> HINCRBY user:1001 login_count 1
(integer) 1

# HINCRBY می‌تواند منفی هم باشد (کاهش)
127.0.0.1:6379> HINCRBY user:1001 views -1
(integer) 1

# HINCRBYFLOAT - افزایش اعشاری
127.0.0.1:6379> HSET product:42 price 100.00 stock_count 50
127.0.0.1:6379> HINCRBYFLOAT product:42 price 10.50
"110.50"

# اگر فیلد وجود نداشته باشد، از 0 شروع می‌کند
127.0.0.1:6379> HINCRBY user:1001 new_counter 5
(integer) 5

الگوی ذخیره‌ی اشیاء با Hash

یکی از بهترین کاربردهای Hash، ذخیره‌ی اشیاء (مثل کاربر، محصول، پست) است. در ادامه، یک مقایسه‌ی عملی بین ذخیره‌ی شیء به‌صورت JSON (String) و Hash می‌بینیم:

ویژگی String با JSON Hash
به‌روزرسانی یک فیلدGET → parse → modify → SETHSET key field value
خواندن یک فیلدGET → parse → fieldHGET key field
حافظه برای اشیای کوچکبیشتر (overhead JSON)کمتر (listpack فشرده)
انقضای فیلد خاصنه (فقط کلید)نه (فقط کلید) - محدودیت
پیمایش فیلدهاparse بعد از GETHSCAN
تایپ‌های دادههمه رشته‌ایهمه رشته‌ای (عدد با HINCRBY)
نested objectsآسانسخت (نیاز به کلید تو در تو)
python
import redis
import json

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# روش ۱: ذخیره به‌صورت JSON در String
def save_user_json(user_id, user_data):
    r.set(f"user:{user_id}", json.dumps(user_data))

def get_user_field_json(user_id, field):
    user = json.loads(r.get(f"user:{user_id}"))
    return user.get(field)

def update_user_field_json(user_id, field, value):
    user = json.loads(r.get(f"user:{user_id}"))
    user[field] = value
    r.set(f"user:{user_id}", json.dumps(user))

# روش ۲: ذخیره به‌صورت Hash
def save_user_hash(user_id, user_data):
    r.hset(f"user:{user_id}", mapping=user_data)

def get_user_field_hash(user_id, field):
    return r.hget(f"user:{user_id}", field)

def update_user_field_hash(user_id, field, value):
    r.hset(f"user:{user_id}", field, value)

def increment_user_field(user_id, field, by=1):
    """افزایش اتمیک یک فیلد عددی - با Hash خیلی ساده‌تر است"""
    return r.hincrby(f"user:{user_id}", field, by)

# Hash به‌طور قابل توجهی بهتر است برای:
# - اشیاء با فیلدهای مستقل
# - وقتی نیاز به به‌روزرسانی یک فیلد دارید
# - شمارنده‌های مرتبط با شیء

# JSON بهتر است برای:
# - اشیاء با ساختار تودرتو (nested)
# - آرایه‌ها و لیست‌ها درون شیء
# - وقتی شیء همیشه به‌صورت کامل خوانده می‌شود

HSCAN — پیمایش امن Hashهای بزرگ

برای Hashهای بزرگ (هزاران فیلد)، HGETALL می‌تواند کند باشد و حافظه‌ی زیادی مصرف کند (چون تمام فیلدها را در یک پاسخ برمی‌گرداند). در این موارد، از HSCAN استفاده کنید:

bash
# HSCAN key cursor [MATCH pattern] [COUNT count]

# پیمایش تمام فیلدها
127.0.0.1:6379> HSCAN user:1001 0
1) "0"   # cursor (0 = تمام شد)
2)  1) "name"
    2) "Alice"
    3) "age"
    4) "30"
    5) "email"
    6) "alice@example.com"

# پیمایش با الگو
127.0.0.1:6379> HSCAN user:1001 0 MATCH "na*"
1) "0"
2) 1) "name"
   2) "Alice"

# پیمایش با COUNT (پیشنهادی برای Hashهای بزرگ)
127.0.0.1:6379> HSCAN big_hash 0 COUNT 100
1) "42"
2) 1) "field_1"
   2) "value_1"
   ...
   199) "field_100"
   200) "value_100"
python
# در Python با redis-py
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# پیمایش تمام فیلدهای یک Hash بزرگ
for field, value in r.hscan_iter("big_hash", match="user_*", count=100):
    print(f"{field} = {value}")

الگوی ذخیره‌ی تنظیمات کاربر

Hash برای ذخیره‌ی تنظیمات (settings) کاربر عالی است. هر کاربر می‌تواند یک Hash با تنظیمات مختلف داشته باشد و هر تنظیم را می‌توان به‌صورت مستقل خواند یا تغییر داد:

python
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class UserSettings:
    def __init__(self, user_id):
        self.key = f"settings:{user_id}"

    def get(self, setting_name, default=None):
        """خواندن یک تنظیم"""
        value = r.hget(self.key, setting_name)
        return value if value is not None else default

    def set(self, setting_name, value):
        """تنظیم یک مقدار"""
        r.hset(self.key, setting_name, value)

    def get_all(self):
        """خواندن تمام تنظیمات"""
        return r.hgetall(self.key)

    def update_multiple(self, settings_dict):
        """به‌روزرسانی چند تنظیم همزمان"""
        r.hset(self.key, mapping=settings_dict)

    def delete(self, *setting_names):
        """حذف یک یا چند تنظیم"""
        r.hdelete(self.key, *setting_names)

# استفاده
settings = UserSettings("user_1001")

# تنظیم مقادیر پیش‌فرض
settings.update_multiple({
    'theme': 'dark',
    'language': 'fa',
    'notifications_email': 'true',
    'notifications_push': 'true',
    'timezone': 'Asia/Tehran',
    'items_per_page': '20',
})

# خواندن یک تنظیم
theme = settings.get('theme', default='light')
print(f"Theme: {theme}")

# تغییر یک تنظیم
settings.set('theme', 'light')

# خواندن همه
all_settings = settings.get_all()
print(all_settings)

الگوی شمارش‌های مرتبط با شیء

Hash برای نگه‌داشتن چندین شمارنده‌ی مرتبط با یک شیء (مثل آمار پست) بسیار مفید است. همه‌ی شمارنده‌ها در یک کلید و به‌صورت اتمیک قابل به‌روزرسانی هستند:

python
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class PostStats:
    def __init__(self, post_id):
        self.key = f"stats:post:{post_id}"

    def record_view(self):
        """ثبت یک بازدید"""
        r.hincrby(self.key, 'views', 1)

    def record_like(self):
        """ثبت یک لایک"""
        r.hincrby(self.key, 'likes', 1)

    def record_share(self):
        """ثبت یک اشتراک‌گذاری"""
        r.hincrby(self.key, 'shares', 1)

    def record_comment(self):
        """ثبت یک کامنت"""
        r.hincrby(self.key, 'comments', 1)

    def get_stats(self):
        """خواندن تمام آمار"""
        stats = r.hgetall(self.key)
        return {k: int(v) for k, v in stats.items()}

    def increment_engagement(self, **counts):
        """افزایش چند شمارنده همزمان"""
        # استفاده از pipeline برای اجرای اتمیک
        pipe = r.pipeline()
        for metric, count in counts.items():
            pipe.hincrby(self.key, metric, count)
        pipe.execute()

# استفاده
stats = PostStats("post_42")

# ثبت فعالیت‌ها
for _ in range(100):
    stats.record_view()
stats.record_like()
stats.record_like()
stats.record_share()

# خواندن آمار
print(stats.get_stats())
# {'views': 100, 'likes': 2, 'shares': 1, 'comments': 0}

# افزایش چند شمارنده همزمان
stats.increment_engagement(views=50, likes=10, comments=5)
print(stats.get_stats())

HRANDFIELD — انتخاب فیلد تصادفی

از نسخه‌ی ۶.۲ به بعد، دستور HRANDFIELD برای انتخاب یک یا چند فیلد تصادفی اضافه شده است. این دستور برای sampling، A/B testing یا نمایش محتوای تصادفی مفید است:

bash
# یک فیلد تصادفی (فقط نام فیلد)
127.0.0.1:6379> HRANDFIELD user:1001
"age"

# چند فیلد تصادفی (می‌تواند تکراری باشد)
127.0.0.1:6379> HRANDFIELD user:1001 3
1) "name"
2) "age"
3) "name"  # تکراری مجاز است

# چند فیلد تصادفی یکتا
127.0.0.1:6379> HRANDFIELD user:1001 3 WITHVALUES
1) "name"
2) "Alice"
3) "age"
4) "30"
5) "email"
6) "alice@example.com"

# تعداد منفی = تکراری مجاز
127.0.0.1:6379> HRANDFIELD user:1001 -5
1) "name"
2) "age"
3) "name"
4) "email"
5) "name"

Encoding داخلی Hash

Hash از دو encoding استفاده می‌کند:

Encoding شرایط استفاده مزیت
listpack≤ 128 فیلد و هر مقدار ≤ 64 بایت (پیش‌فرض)کم‌مصرف، cache-friendly
hashtableبزرگ‌تر از آستانهO(1) برای تمام عملیات
bash
# Hash کوچک: listpack
127.0.0.1:6379> HSET small f1 v1 f2 v2
127.0.0.1:6379> OBJECT ENCODING small
"listpack"

# Hash بزرگ: hashtable
127.0.0.1:6379> HSET big f1 v1 f2 v2 ... f129 v129
127.0.0.1:6379> OBJECT ENCODING big
"hashtable"

تنظیمات آستانه با hash-max-listpack-entries (پیش‌فرض ۱۲۸) و hash-max-listpack-value (پیش‌فرض ۶۴) در redis.conf کنترل می‌شود. اگر می‌خواهید Hash همیشه با listpack باشد (صرفه‌جویی در حافظه در ازای سرعت کمی پایین‌تر در جستجوی خطی)، می‌توانید این مقادیر را افزایش دهید.

الگوی ذخیره‌ی Inventory (موجودی انبار)

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

python
import redis
import json

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class Inventory:
    def __init__(self, product_id):
        self.key = f"inventory:{product_id}"

    def set_stock(self, count):
        r.hset(self.key, 'stock', count)

    def reserve(self, count):
        """رزرو کردن تعدادی از محصول (کاهش از موجودی)"""
        # استفاده از Lua Script برای اتمیک بودن
        script = """
        local current = tonumber(redis.call('HGET', KEYS[1], 'stock'))
        if current == nil then
            return -1  -- محصول وجود ندارد
        end
        local requested = tonumber(ARGV[1])
        if current >= requested then
            redis.call('HINCRBY', KEYS[1], 'stock', -requested)
            redis.call('HINCRBY', KEYS[1], 'reserved', requested)
            return current - requested  -- موجودی باقیمانده
        else
            return -2  -- موجودی کافی نیست
        end
        """
        return r.eval(script, 1, self.key, count)

    def release(self, count):
        """آزاد کردن رزرو (برای cancel سفارش)"""
        pipe = r.pipeline()
        pipe.hincrby(self.key, 'stock', count)
        pipe.hincrby(self.key, 'reserved', -count)
        pipe.execute()

    def get_status(self):
        """گزارش وضعیت موجودی"""
        return r.hgetall(self.key)

# استفاده
inv = Inventory("product_42")
inv.set_stock(100)

# رزرو ۵ تا
result = inv.reserve(5)
print(f"Remaining: {result}")  # 95

# رزرو ۱۰۰۰ تا (بیشتر از موجودی)
result = inv.reserve(1000)
print(f"Result: {result}")  # -2 (موجودی کافی نیست)

# وضعیت نهایی
print(inv.get_status())  # {'stock': '95', 'reserved': '5'}
💡 Lua Script برای اتمیک بودن

در مثال بالا از Lua Script استفاده کردیم تا مطمئن شویم خواندن موجودی و کاهش آن به‌صورت اتمیک انجام می‌شود. اگر این کار را با GET و سپس SET انجام دهید، در محیط همزمان ممکن است race condition رخ دهد. در فصل ۱۷ (Lua Scripting) به‌طور مفصل به این موضوع می‌پردازیم.

Hash برای session store (جایگزین String)

در فصل ۵ یک session store با String دیدید. Hash می‌تواند جایگزین بهتری باشد؛ زیرا هر فیلد session به‌صورت مستقل قابل دسترسی است و می‌توان فیلدهای خاص (مثل last_access) را بدون خواندن کل session به‌روز کرد:

python
import redis
import secrets
import time
import json

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class HashSessionStore:
    SESSION_TTL = 86400  # ۲۴ ساعت

    def create_session(self, user_id, user_data):
        session_id = secrets.token_urlsafe(32)
        key = f"session:{session_id}"

        # ذخیره به‌صورت Hash
        r.hset(key, mapping={
            'user_id': user_id,
            'data': json.dumps(user_data),
            'created_at': int(time.time()),
            'last_access': int(time.time()),
        })
        r.expire(key, self.SESSION_TTL)

        # نگاشت user_id به session_id
        r.set(f"user_session:{user_id}", session_id, ex=self.SESSION_TTL)
        return session_id

    def get_user_id(self, session_id):
        """فقط user_id را بخوان (بدون خواندن کل session)"""
        return r.hget(f"session:{session_id}", 'user_id')

    def touch_session(self, session_id):
        """به‌روزرسانی last_access بدون خواندن کل داده"""
        r.hset(f"session:{session_id}", 'last_access', int(time.time()))
        r.expire(f"session:{session_id}", self.SESSION_TTL)

    def get_all(self, session_id):
        """خواندن کامل session"""
        data = r.hgetall(f"session:{session_id}")
        if not data:
            return None
        data['data'] = json.loads(data.get('data', '{}'))
        return data

مدیریت حافظه با Hash

یکی از نکات مهم در استفاده از Hash، صرفه‌جویی در حافظه است. اگر ۱ میلیون کاربر دارید و هر کدام ۱۰ فیلد دارد، ذخیره به‌صورت ۱۰ کلید String جداگانه به‌مراتب بیش از ذخیره به‌صورت ۱۰۰ هزار Hash (هر کدام ۱۰ فیلد) حافظه مصرف می‌کند. دلیل این موضوع این است که هر کلید در Redis حدود ۶۰ بایت overhead دارد (برای dictEntry، sds، و...). با Hash، این overhead فقط یک بار برای کل کلید پرداخت می‌شود و سپس فیلدها در یک listpack فشرده ذخیره می‌شوند.

⚠️ محدودیت Hash

در Redis، نمی‌توان TTL جداگانه برای فیلدهای Hash تعیین کرد. TTL فقط روی کل کلید Hash اعمال می‌شود. اگر به TTL فیلد به فیلد نیاز دارید، باید هر فیلد را به‌صورت یک کلید String جداگانه نگه دارید (با پیشوند یکسان). این محدودیت برای List، Set، Sorted Set و Stream هم وجود دارد.

📝 خلاصه فصل

در این فصل با Hash آشنا شدید: دستورات HSET/HGET/HGETALL، عملیات اتمیک HINCRBY، الگوهای ذخیره‌ی اشیاء، تنظیمات، شمارنده‌های مرتبط، inventory و session store. Hash برای ذخیره‌ی اشیاء با فیلدهای مستقل بهترین انتخاب است و معمولاً بهتر از String+JSON عمل می‌کند. در فصل بعدی، با Set کار می‌کنیم که برای مجموعه‌های یکتا و عملیات مجموعه‌ای طراحی شده است.

Chapter 08

Set (مجموعه) #

ساختار Set در Redis یک مجموعه‌ی نامرتب از رشته‌های یکتا است. این ساختار برای ذخیره‌ی داده‌هایی که هر عضو فقط یک بار می‌تواند appear شود، ایده‌آل است: تگ‌ها، دسته‌بندی، روابط (مثل دنبال‌کنندگان)، نقش‌های کاربر، یا لیست wishlist. مهم‌تر از همه، Set از عملیات مجموعه‌ای قدرتمندی پشتیبانی می‌کند: اجتماع (Union)، اشتراک (Intersection)، و تفاضل (Difference). این عملیات روی چندین Set به‌صورت اتمیک و در سرور انجام می‌شوند که می‌تواند به‌جای انتقال حجم زیادی از داده به کلاینت، فقط نتیجه‌ی نهایی را برگرداند.

دستورات پایه: SADD، SMEMBERS، SISMEMBER

SADD یک یا چند عضو به Set اضافه می‌کند (تنها اعضای جدید شمارده می‌شوند). SMEMBERS تمام اعضا را برمی‌گرداند و SISMEMBER بررسی می‌کند که آیا یک عضو در Set وجود دارد یا خیر:

bash
# SADD - اضافه کردن اعضا
127.0.0.1:6379> SADD tags:post:1 "redis" "database" "nosql"
(integer) 3
127.0.0.1:6379> SADD tags:post:1 "redis"  # تکراری، اضافه نمی‌شود
(integer) 0
127.0.0.1:6379> SADD tags:post:1 "tutorial" "beginner"
(integer) 2

# SMEMBERS - تمام اعضا (نامرتب!)
127.0.0.1:6379> SMEMBERS tags:post:1
1) "redis"
2) "nosql"
3) "tutorial"
4) "database"
5) "beginner"

# SISMEMBER - بررسی عضویت (O(1))
127.0.0.1:6379> SISMEMBER tags:post:1 "redis"
(integer) 1  # بله، عضو است
127.0.0.1:6379> SISMEMBER tags:post:1 "python"
(integer) 0  # خیر، عضو نیست

# SCARD - تعداد اعضا (cardinality)
127.0.0.1:6379> SCARD tags:post:1
(integer) 5

# SREM - حذف اعضا
127.0.0.1:6379> SREM tags:post:1 "beginner"
(integer) 1
127.0.0.1:6379> SCARD tags:post:1
(integer) 4

# SPOP - حذف و برگرداندن عضو تصادفی
127.0.0.1:6379> SPOP tags:post:1
"nosql"

# SPOP با count
127.0.0.1:6379> SPOP tags:post:1 2
1) "redis"
2) "tutorial"

عملیات مجموعه‌ای: SINTER، SUNION، SDIFF

قدرت اصلی Set در عملیات مجموعه‌ای نهفته است. این عملیات می‌توانند نتیجه را یا به‌صورت مستقیم برگردانند یا در یک Set جدید ذخیره کنند (با پیشوند ...STORE):

bash
# ساخت دو Set برای تست
127.0.0.1:6379> SADD skills:alice "python" "redis" "linux" "docker" "git"
127.0.0.1:6379> SADD skills:bob "python" "java" "redis" "kubernetes"

# SINTER - اشتراک (مواردی که در هر دو هست)
127.0.0.1:6379> SINTER skills:alice skills:bob
1) "python"
2) "redis"

# SUNION - اجتماع (مواردی که در حداقل یکی هست)
127.0.0.1:6379> SUNION skills:alice skills:bob
1) "python"
2) "redis"
3) "linux"
4) "docker"
5) "git"
6) "java"
7) "kubernetes"

# SDIFF - تفاضل (در اولی هست، در دومی نیست)
127.0.0.1:6379> SDIFF skills:alice skills:bob
1) "linux"
2) "docker"
3) "git"

127.0.0.1:6379> SDIFF skills:bob skills:alice
1) "java"
2) "kubernetes"

# SINTERSTORE - ذخیره‌ی نتیجه در Set جدید
127.0.0.1:6379> SINTERSTORE common_skills skills:alice skills:bob
(integer) 2  # تعداد اعضای Set جدید

# SUNIONSTORE - ذخیره‌ی اجتماع
127.0.0.1:6379> SUNIONSTORE all_skills skills:alice skills:bob
(integer) 7

# SDIFFSTORE - ذخیره‌ی تفاضل
127.0.0.1:6379> SDIFFSTORE alice_only skills:alice skills:bob
(integer) 3

# SINTERCARD - فقط تعداد اشتراک (بدون برگرداندن اعضا، نسخه ۷ به بعد)
127.0.0.1:6379> SINTERCARD 2 skills:alice skills:bob
(integer) 2

# SINTERCARD با محدودیت (LIMIT)
127.0.0.1:6379> SINTERCARD 2 skills:alice skills:bob LIMIT 1
(integer) 1  # بعد از ۱ تطابق متوقف می‌شود
💡 کاربرد عملیات مجموعه‌ای

فرض کنید می‌خواهید کاربرانی را پیدا کنید که هم فالوور user:1 هستند و هم فالوور user:2 (مشترکات). کافی است SINTER followers:user:1 followers:user:2 را اجرا کنید. این کار در سرور انجام می‌شود و فقط نتیجه به کلاینت منتقل می‌شود — صدها هزار follower بدون نیاز به انتقال به کلاینت پردازش می‌شوند.

الگوی دنبال‌کنندگان (Followers/Following)

یکی از رایج‌ترین کاربردهای Set، پیاده‌سازی روابط social است: followers (دنبال‌کنندگان) و following (دنبال‌شده‌ها). هر کاربر دو Set دارد:

python
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class SocialGraph:
    def follow(self, follower_id, followee_id):
        """user_id پیگیری followee_id را شروع می‌کند"""
        if follower_id == followee_id:
            return False

        # افزودن به setهای دو طرف
        pipe = r.pipeline()
        pipe.sadd(f"following:{follower_id}", followee_id)
        pipe.sadd(f"followers:{followee_id}", follower_id)
        pipe.execute()
        return True

    def unfollow(self, follower_id, followee_id):
        """لغو پیگیری"""
        pipe = r.pipeline()
        pipe.srem(f"following:{follower_id}", followee_id)
        pipe.srem(f"followers:{followee_id}", follower_id)
        pipe.execute()

    def is_following(self, follower_id, followee_id):
        """آیا follower_id پیگیر followee_id است؟"""
        return r.sismember(f"following:{follower_id}", followee_id)

    def get_followers(self, user_id):
        """لیست دنبال‌کنندگان"""
        return r.smembers(f"followers:{user_id}")

    def get_following(self, user_id):
        """لیست دنبال‌شده‌ها"""
        return r.smembers(f"following:{user_id}")

    def get_follower_count(self, user_id):
        """تعداد دنبال‌کنندگان"""
        return r.scard(f"followers:{user_id}")

    def get_mutual_followers(self, user_a, user_b):
        """دنبال‌کنندگان مشترک دو کاربر"""
        return r.sinter(f"followers:{user_a}", f"followers:{user_b}")

    def get_friends(self, user_id):
        """دوستان (دوطرفه) - کسانی که هم.followed و هم.follower"""
        return r.sinter(f"following:{user_id}", f"followers:{user_id}")

# استفاده
social = SocialGraph()
social.follow("alice", "bob")
social.follow("alice", "carol")
social.follow("bob", "alice")

print(social.get_friends("alice"))  # {'bob'} - فقط bob دوست است
print(social.get_mutual_followers("alice", "bob"))  # {'alice', 'bob'}

الگوی تگ‌گذاری (Tagging)

Set برای تگ‌گذاری محتوا عالی است. هر تگ یک Set از IDهای محتواست و هر محتوا هم Set از تگ‌های خودش را دارد. این طراحی دو طرفه امکان کوئری‌های قدرتمند را فراهم می‌کند:

python
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class TaggingSystem:
    def add_tags(self, item_id, tags):
        """افزودن تگ‌ها به یک آیتم"""
        pipe = r.pipeline()
        # 1. ذخیره تگ‌ها برای آیتم
        pipe.sadd(f"item_tags:{item_id}", *tags)
        # 2. افزودن آیتم به Set هر تگ
        for tag in tags:
            pipe.sadd(f"tag:{tag}", item_id)
        pipe.execute()

    def remove_tags(self, item_id, tags):
        """حذف تگ‌ها از آیتم"""
        pipe = r.pipeline()
        pipe.srem(f"item_tags:{item_id}", *tags)
        for tag in tags:
            pipe.srem(f"tag:{tag}", item_id)
        pipe.execute()

    def get_item_tags(self, item_id):
        """تمام تگ‌های یک آیتم"""
        return r.smembers(f"item_tags:{item_id}")

    def get_items_with_tag(self, tag):
        """تمام آیتم‌هایی که این تگ را دارند"""
        return r.smembers(f"tag:{tag}")

    def get_items_with_all_tags(self, *tags):
        """آیتم‌هایی که همه‌ی تگ‌ها را دارند (AND)"""
        keys = [f"tag:{t}" for t in tags]
        return r.sinter(*keys)

    def get_items_with_any_tag(self, *tags):
        """آیتم‌هایی که حداقل یکی از تگ‌ها را دارند (OR)"""
        keys = [f"tag:{t}" for t in tags]
        return r.sunion(*keys)

    def get_items_with_first_not_second(self, tag_a, tag_b):
        """آیتم‌هایی که tag_a دارند ولی tag_b ندارند"""
        return r.sdiff(f"tag:{tag_a}", f"tag:{tag_b}")

# استفاده
tags = TaggingSystem()
tags.add_tags("post:1", ["redis", "database", "tutorial"])
tags.add_tags("post:2", ["redis", "performance", "tutorial"])
tags.add_tags("post:3", ["python", "database"])

# کوئری‌ها
print(tags.get_items_with_all_tags("redis", "tutorial"))  # {post:1, post:2}
print(tags.get_items_with_any_tag("redis", "python"))      # {post:1, post:2, post:3}
print(tags.get_items_with_first_not_second("redis", "python"))  # {post:1, post:2}

SRANDMEMBER — انتخاب عضو تصادفی

SRANDMEMBER یک یا چند عضو تصادفی را بدون حذف برمی‌گرداند. این دستور برای sampling، نمایش محتوای تصادفی یا A/B testing مفید است:

bash
# یک عضو تصادفی
127.0.0.1:6379> SRANDMEMBER tags:post:1
"redis"

# چند عضو تصادفی یکتا
127.0.0.1:6379> SRANDMEMBER tags:post:1 2
1) "redis"
2) "tutorial"

# چند عضو تصادفی با تکرار مجاز
127.0.0.1:6379> SRANDMEMBER tags:post:1 -5
1) "redis"
2) "redis"  # تکرار مجاز است
3) "tutorial"
4) "redis"
5) "database"

# تفاوت با SPOP:
# SPOP = حذف می‌کند
# SRANDMEMBER = فقط می‌خواند

SSCAN — پیمایش امن Setهای بزرگ

مانند سایر ساختارها، SMEMBERS برای Setهای بزرگ خطرناک است. از SSCAN استفاده کنید:

bash
# SSCAN key cursor [MATCH pattern] [COUNT count]

127.0.0.1:6379> SSCAN big_set 0 MATCH "user_*" COUNT 100
1) "42"
2) 1) "user_1"
   2) "user_15"
   3) "user_42"
   ...

SMOVE — انتقال عضو بین Setها

SMOVE یک عضو را از یک Set به Set دیگر اتمیک انتقال می‌دهد. این دستور برای workflows که یک آیتم از یک وضعیت به وضعیت دیگر می‌رود (مثلاً از «در انتظار» به «در حال پردازش») مفید است:

bash
# ساخت Setهای مختلف برای وضعیت سفارش
127.0.0.1:6379> SADD orders:pending "order_1" "order_2" "order_3"
127.0.0.1:6379> SADD orders:processing
127.0.0.1:6379> SADD orders:shipped

# انتقال سفارش از pending به processing
127.0.0.1:6379> SMOVE orders:pending orders:processing "order_1"
(integer) 1

127.0.0.1:6379> SMEMBERS orders:pending
1) "order_2"
2) "order_3"

127.0.0.1:6379> SMEMBERS orders:processing
1) "order_1"

# انتقال از processing به shipped
127.0.0.1:6379> SMOVE orders:processing orders:shipped "order_1"
(integer) 1

Encoding داخلی Set

Set از سه encoding متفاوت استفاده می‌کند که بر اساس محتوا انتخاب می‌شوند:

Encoding شرایط استفاده مزیت
intsetتمام اعضا عدد صحیح باشند و ≤ 512 عضوبسیار کم‌مصرف (بهینه‌سازی شده برای اعداد)
listpack≤ 128 عضو و هر مقدار ≤ 64 بایتکم‌مصرف، cache-friendly
hashtableبزرگ‌تر از آستانه‌هاO(1) برای تمام عملیات
bash
# Set فقط اعداد صحیح: intset
127.0.0.1:6379> SADD numbers 1 2 3 4 5
127.0.0.1:6379> OBJECT ENCODING numbers
"intset"

# Set با اعداد و رشته: listpack
127.0.0.1:6379> SADD mixed 1 "a" "b"
127.0.0.1:6379> OBJECT ENCODING mixed
"listpack"

# Set بزرگ: hashtable
127.0.0.1:6379> SADD big "m1" "m2" ... "m129"
127.0.0.1:6379> OBJECT ENCODING big
"hashtable"

تنظیمات آستانه با set-max-intset-entries (پیش‌فرض ۵۱۲) و set-max-listpack-entries (پیش‌فرض ۱۲۸) و set-max-listpack-value (پیش‌فرض ۶۴) در redis.conf کنترل می‌شود. intset به‌طور خاص برای Setهایی که فقط ID عددی دارند (مثل IDs of followers) بسیار بهینه است و صرفه‌جویی قابل توجهی در حافظه ایجاد می‌کند.

الگوی نقش‌های کاربر (RBAC)

Set برای ذخیره‌ی نقش‌های کاربر و بررسی دسترسی‌ها عالی است. هر کاربر یک Set از نقش‌ها دارد و بررسی دسترسی به یک صفحه با SISMEMBER به‌صورت O(1) انجام می‌شود:

python
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class RBAC:
    def assign_role(self, user_id, role):
        r.sadd(f"roles:{user_id}", role)

    def revoke_role(self, user_id, role):
        r.srem(f"roles:{user_id}", role)

    def get_user_roles(self, user_id):
        return r.smembers(f"roles:{user_id}")

    def has_role(self, user_id, role):
        """بررسی اینکه آیا کاربر نقش خاص دارد (O(1))"""
        return r.sismember(f"roles:{user_id}", role)

    def has_any_role(self, user_id, *roles):
        """آیا کاربر حداقل یکی از نقش‌ها را دارد؟"""
        # SINTER کار می‌کند ولی بهتر است MEMBER یکی یکی چک شود
        user_roles = r.smembers(f"roles:{user_id}")
        return any(role in user_roles for role in roles)

    def has_all_roles(self, user_id, *roles):
        """آیا کاربر همه‌ی نقش‌ها را دارد؟"""
        user_roles = r.smembers(f"roles:{user_id}")
        return all(role in user_roles for role in roles)

    def get_users_with_role(self, role):
        """تمام کاربرانی که این نقش را دارند"""
        return r.smembers(f"role_users:{role}")

    def assign_role_with_index(self, user_id, role):
        """افزودن نقش و به‌روزرسانی ایندکس معکوس"""
        pipe = r.pipeline()
        pipe.sadd(f"roles:{user_id}", role)
        pipe.sadd(f"role_users:{role}", user_id)
        pipe.execute()

# استفاده
rbac = RBAC()
rbac.assign_role_with_index("user_1", "admin")
rbac.assign_role_with_index("user_1", "editor")
rbac.assign_role_with_index("user_2", "editor")

print(rbac.has_role("user_1", "admin"))  # True
print(rbac.has_role("user_2", "admin"))  # False
print(rbac.get_users_with_role("editor"))  # {'user_1', 'user_2'}

الگوی Unique Visitors روزانه

Set می‌تواند برای شمارش visitors یکتای روزانه استفاده شود. هر روز یک Set با ID کاربران دارد:

python
import redis
from datetime import datetime

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class UniqueVisitors:
    def record_visit(self, user_id, date=None):
        """ثبت بازدید کاربر در یک روز"""
        if date is None:
            date = datetime.now().strftime("%Y-%m-%d")
        key = f"visitors:daily:{date}"
        r.sadd(key, user_id)
        # TTL ۳۲ روز برای پاک‌سازی خودکار
        r.expire(key, 32 * 86400)

    def get_daily_count(self, date):
        """تعداد visitors یکتای یک روز"""
        return r.scard(f"visitors:daily:{date}")

    def get_weekly_unique(self, start_date):
        """تعداد visitors یکتای هفته (با SINTERSTORE و SSCAN)"""
        from datetime import timedelta
        dates = []
        for i in range(7):
            d = (datetime.strptime(start_date, "%Y-%m-%d") + timedelta(days=i)).strftime("%Y-%m-%d")
            dates.append(f"visitors:daily:{d}")

        # استفاده از SUNIONSTORE برای اتحاد هفتگی
        temp_key = f"visitors:weekly:{start_date}"
        r.sunionstore(temp_key, *dates)
        r.expire(temp_key, 86400)  # TTL موقت
        return r.scard(temp_key)

# نکته: برای حجم بسیار زیاد (میلیون‌ها visitor در روز)، از HyperLogLog استفاده کنید
# که در فصل ۱۱ بررسی می‌شود
⚠️ هشدار حافظه

اگر سایت شما روزانه میلیون‌ها کاربر یکتا دارد، Set برای ذخیره‌ی visitorهای یکتا بسیار پرهزینه است. هر عضو در Set حدود ۶۰ بایت overhead دارد؛ بنابراین ۱ میلیون کاربر یکتا حدود ۶۰ مگابایت به‌ازای هر روز اشغال می‌کند. در این موارد، از HyperLogLog (فصل ۱۱) استفاده کنید که فقط ۱۲ کیلوبایت به ازای میلیاردها کلید نیاز دارد — البته با دقت تقریبی (خطای ~۰.۸٪).

الگوی بازیابی اطلاعات با SINTER

یکی از کاربردهای جذاب Set، فیلتر کردن داده‌ها بر اساس چندین شرط است. هر شرط یک Set از IDهای مطابق است و SINTER نتیجه‌ی نهایی را می‌دهد:

python
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# فرض کنید سایت فروش خانه داریم
# کاربر می‌خواهد: خانه‌های ۳ خوابه، در تهران، با قیمت زیر ۵ میلیارد

# هر فیلتر یک Set از IDهای خانه‌های مطابق
def add_house(house_id, bedrooms, city, price):
    pipe = r.pipeline()
    pipe.sadd(f"filter:bedrooms:{bedrooms}", house_id)
    pipe.sadd(f"filter:city:{city}", house_id)
    # قیمت را در دسته‌های مختلف قرار می‌دهیم
    if price < 1_000_000_000:
        pipe.sadd("filter:price:under_1b", house_id)
    elif price < 5_000_000_000:
        pipe.sadd("filter:price:1b_to_5b", house_id)
    else:
        pipe.sadd("filter:price:over_5b", house_id)
    pipe.execute()

def search(bedrooms, city, price_range):
    """جستجوی چندشرطی"""
    keys = [
        f"filter:bedrooms:{bedrooms}",
        f"filter:city:{city}",
        f"filter:price:{price_range}",
    ]
    return r.sinter(*keys)

# افزودن خانه‌ها
add_house("h1", 3, "tehran", 3_000_000_000)
add_house("h2", 3, "tehran", 4_500_000_000)
add_house("h3", 2, "tehran", 2_000_000_000)
add_house("h4", 3, "isfahan", 3_000_000_000)

# جستجو
results = search(3, "tehran", "1b_to_5b")
print(results)  # {'h1', 'h2'}

این الگو به inverted index معروف است و در موتورهای جستجو، e-commerce و سیستم‌های recommendation به‌طور گسترده استفاده می‌شود. برای جستجوی متن کامل (full-text search)، ماژول RediSearch (فصل ۲۲) گزینه‌ی قدرتمندتری است.

📝 خلاصه فصل

در این فصل با Set آشنا شدید: دستورات پایه SADD/SMEMBERS/SISMEMBER، عملیات مجموعه‌ای قدرتمند SINTER/SUNION/SDIFF، الگوهای followers، tagging، RBAC، unique visitors و inverted index. Set برای داده‌های یکتا و روابط بین موجودیت‌ها عالی است. در فصل بعدی، با Sorted Set کار می‌کنیم که Set را با قابلیت مرتب‌سازی بر اساس score ترکیب می‌کند — ابزار اصلی برای leaderboard و رتبه‌بندی.

Chapter 09

Sorted Set (مجموعه مرتب) #

Sorted Set (یا ZSet) یکی از قدرتمندترین و متمایزکننده‌ترین ساختارهای داده‌ی Redis است. این ساختار مانند Set، مجموعه‌ای از اعضای یکتا است؛ اما هر عضو یک score (امتیاز عددی) دارد که مجموعه بر اساس آن به‌صورت خودکار مرتب می‌شود. این ویژگی، Sorted Set را به ابزاری ایده‌آل برای leaderboard، رتبه‌بندی، صف اولویت‌دار، sliding window rate limiter و time-series data تبدیل می‌کند. در این فصل، تمام دستورات مهم و چند الگوی کاربردی را بررسی می‌کنیم.

دستورات پایه: ZADD، ZRANGE، ZRANK

ZADD یک عضو با score به Sorted Set اضافه می‌کند. ZRANGE اعضا را در محدوده‌ی مشخص برمی‌گرداند و ZRANK رتبه‌ی یک عضو را (از کمترین score) برمی‌گرداند:

bash
# ZADD - افزودن عضو با score
127.0.0.1:6379> ZADD leaderboard 100 "alice" 250 "bob" 175 "carol" 320 "dave"
(integer) 4

# ZRANGE - اعضا در محدوده (مرتب صعودی پیش‌فرض)
127.0.0.1:6379> ZRANGE leaderboard 0 -1
1) "alice"     # 100
2) "carol"     # 175
3) "bob"       # 250
4) "dave"      # 320

# ZRANGE با score (نسخه ۶.۲ به بعد)
127.0.0.1:6379> ZRANGE leaderboard 0 -1 WITHSCORES
1) "alice"
2) "100"
3) "carol"
4) "175"
5) "bob"
6) "250"
7) "dave"
8) "320"

# ZREVRANGE - نزولی (نسخه قدیمی، با ZRANGE هم می‌توان)
127.0.0.1:6379> ZREVRANGE leaderboard 0 -1 WITHSCORES
1) "dave"
2) "320"
3) "bob"
4) "250"
5) "carol"
6) "175"
7) "alice"
8) "100"

# ZRANGE با REV (نسخه ۶.۲ به بعد)
127.0.0.1:6379> ZRANGE leaderboard 0 -1 REV WITHSCORES

# ZRANK - رتبه (اندیس از 0، صعودی)
127.0.0.1:6379> ZRANK leaderboard "bob"
(integer) 2  # سومین نفر (0-based)

# ZREVRANK - رتبه نزولی (بالاترین امتیاز = رتبه 0)
127.0.0.1:6379> ZREVRANK leaderboard "bob"
(integer) 1  # دومین نفر از بالا

# ZSCORE - score یک عضو
127.0.0.1:6379> ZSCORE leaderboard "alice"
"100"

# ZMSCORE - score چند عضو (نسخه ۶.۲ به بعد)
127.0.0.1:6379> ZMSCORE leaderboard "alice" "bob" "nobody"
1) "100"
2) "250"
3) (nil)

# ZCARD - تعداد اعضا
127.0.0.1:6379> ZCARD leaderboard
(integer) 4

# ZREM - حذف عضو
127.0.0.1:6379> ZREM leaderboard "carol"
(integer) 1

فلگ‌های ZADD

ZADD چندین فلگ مفید دارد که رفتار آن را کنترل می‌کنند:

bash
# NX - فقط اضافه کن، به‌روزرسانی نکن
127.0.0.1:6379> ZADD leaderboard NX 200 "alice"
(integer) 0  # alice از قبل وجود داشت، به‌روز نشد

# XX - فقط به‌روزرسانی کن، اضافه نکن
127.0.0.1:6379> ZADD leaderboard XX 150 "newuser"
(integer) 0  # newuser وجود نداشت، اضافه نشد

# GT - فقط به‌روزرسانی اگر score جدید بزرگ‌تر باشد
127.0.0.1:6379> ZADD leaderboard GT 250 "alice"
(integer) 0  # 250 > 100، پس به‌روز شد
127.0.0.1:6379> ZADD leaderboard GT 50 "alice"
(integer) 0  # 50 < 250، پس به‌روز نشد

# LT - فقط به‌روزرسانی اگر score جدید کوچک‌تر باشد
127.0.0.1:6379> ZADD leaderboard LT 100 "alice"
(integer) 0  # 100 < 250، پس به‌روز شد

# CH - برگرداندن تعداد اعضای تغییریافته (نه فقط اضافه شده)
127.0.0.1:6379> ZADD leaderboard CH 200 "alice" 999 "newuser"
(integer) 2  # alice به‌روز شد، newuser اضافه شد

# INCR - افزایش score به‌جای جایگزینی
127.0.0.1:6379> ZADD leaderboard INCR 50 "alice"
"250"  # مقدار جدید

الگوی Leaderboard

رایج‌ترین کاربرد Sorted Set، ساخت leaderboard برای بازی‌ها، مسابقات یا سیستم‌های رتبه‌بندی است. در ادامه، یک پیاده‌سازی کامل می‌بینیم:

python
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class Leaderboard:
    def __init__(self, name):
        self.key = f"leaderboard:{name}"

    def add_score(self, user, score):
        """افزودن یا به‌روزرسانی امتیاز کاربر"""
        r.zadd(self.key, {user: score})

    def increment_score(self, user, points=1):
        """افزایش امتیاز کاربر به‌صورت اتمیک"""
        return r.zincrby(self.key, points, user)

    def get_top_n(self, n=10):
        """N نفر برتر (از بیشترین امتیاز)"""
        # zrevrange با scores
        return r.zrevrange(self.key, 0, n - 1, withscores=True)

    def get_rank(self, user):
        """رتبه‌ی کاربر (1-based)"""
        rank = r.zrevrank(self.key, user)
        return rank + 1 if rank is not None else None

    def get_score(self, user):
        """امتیاز کاربر"""
        return r.zscore(self.key, user)

    def get_around_user(self, user, count=5):
        """کاربران اطراف یک کاربر خاص (برای نمایش رتبه‌بندی محلی)"""
        rank = r.zrevrank(self.key, user)
        if rank is None:
            return []

        start = max(0, rank - count)
        end = rank + count
        return r.zrevrange(self.key, start, end, withscores=True)

    def remove_user(self, user):
        r.zrem(self.key, user)

    def get_total_users(self):
        return r.zcard(self.key)

# استفاده
lb = Leaderboard("weekly_top")

# افزودن امتیازها
lb.add_score("alice", 100)
lb.add_score("bob", 250)
lb.add_score("carol", 175)
lb.increment_score("alice", 50)  # alice: 150

# نمایش ۱۰ نفر برتر
top_10 = lb.get_top_n(10)
for rank, (user, score) in enumerate(top_10, 1):
    print(f"{rank}. {user}: {score}")

# رتبه‌ی یک کاربر
print(f"Alice's rank: {lb.get_rank('alice')}")  # 3
print(f"Alice's score: {lb.get_score('alice')}")  # 150.0

# کاربران اطراف alice
around = lb.get_around_user("alice", count=2)
print("Around Alice:", around)

عملیات بر اساس محدوده‌ی Score: ZRANGEBYSCORE

یکی از قابلیت‌های مهم Sorted Set، توانایی جستجوی اعضا بر اساس محدوده‌ی score است. این قابلیت برای فیلتر کردن داده‌ها بر اساس مقدار عددی (مثل قیمت، تاریخ، وزن) بسیار مفید است:

bash
# ایجاد داده‌ی تست
127.0.0.1:6379> ZADD products 99.99 "item1" 149.99 "item2" 199.99 "item3" 299.99 "item4" 499.99 "item5"

# ZRANGEBYSCORE (قدیمی)
127.0.0.1:6379> ZRANGEBYSCORE products 100 300
1) "item2"
2) "item3"
3) "item4"

# با WITHSCORES
127.0.0.1:6379> ZRANGEBYSCORE products 100 300 WITHSCORES
1) "item2"
2) "149.99"
3) "item3"
4) "199.99"
5) "item4"
6) "299.99"

# با LIMIT (مثل SQL offset, count)
127.0.0.1:6379> ZRANGEBYSCORE products 100 500 LIMIT 1 2
1) "item3"
2) "item4"

# با (-inf, +inf) برای تمام اعضا
127.0.0.1:6379> ZRANGEBYSCORE products -inf +inf
1) "item1"
2) "item2"
3) "item3"
4) "item4"
5) "item5"

# با ( برای exclusive
127.0.0.1:6379> ZRANGEBYSCORE products (100 300
1) "item2"
2) "item3"
3) "item4"

# روش جدید (نسخه ۶.۲ به بعد): ZRANGE با BYSCORE
127.0.0.1:6379> ZRANGE products 100 300 BYSCORE
127.0.0.1:6379> ZRANGE products 100 300 BYSCORE REV LIMIT 0 2

# ZREVRANGEBYSCORE (قدیمی، نزولی)
127.0.0.1:6379> ZREVRANGEBYSCORE products 300 100

# ZCOUNT - تعداد اعضا در محدوده‌ی score
127.0.0.1:6379> ZCOUNT products 100 300
(integer) 3

# ZRANGESTORE - ذخیره‌ی محدوده در کلید جدید
127.0.0.1:6379> ZRANGESTORE filtered products 100 300 BYSCORE
(integer) 3

الگوی Sliding Window Rate Limiter (دقیق)

در فصل ۶ یک rate limiter با Sorted Set معرفی کردیم. در اینجا پیاده‌سازی کامل آن را می‌بینیم که یک sliding window دقیق ارائه می‌دهد — برخلاف الگوی fixed window که در فصل ۵ دیدیم:

python
import redis
import time
import uuid

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class SlidingWindowRateLimiter:
    def __init__(self, limit=100, window=60):
        self.limit = limit
        self.window = window  # ثانیه

    def is_allowed(self, user_id):
        """بررسی اینکه آیا درخواست مجاز است"""
        key = f"ratelimit:{user_id}"
        now = time.time()
        window_start = now - self.window

        # هر درخواست یک ID منحصربه‌فرد دارد
        request_id = str(uuid.uuid4())

        # Lua Script برای اتمیک بودن
        script = """
        local key = KEYS[1]
        local now = tonumber(ARGV[1])
        local window_start = tonumber(ARGV[2])
        local request_id = ARGV[3]
        local limit = tonumber(ARGV[4])
        local window = tonumber(ARGV[5])

        -- 1. حذف درخواست‌های قدیمی‌تر از window
        redis.call('ZREMRANGEBYSCORE', key, 0, window_start)

        -- 2. شمارش درخواست‌های فعلی
        local current = redis.call('ZCARD', key)

        -- 3. اگر کمتر از limit است، اجازه بده
        if current < limit then
            redis.call('ZADD', key, now, request_id)
            redis.call('EXPIRE', key, window)
            return 1  -- مجاز
        else
            return 0  -- محدود
        end
        """

        result = r.eval(script, 1, key, now, window_start, request_id,
                       self.limit, self.window)
        return bool(result)

# استفاده
limiter = SlidingWindowRateLimiter(limit=10, window=60)

# شبیه‌سازی ۱۵ درخواست
for i in range(15):
    allowed = limiter.is_allowed("user_123")
    print(f"Request {i+1}: {'✓' if allowed else '✗ BLOCKED'}")

این پیاده‌سازی بسیار دقیق است: هر درخواست با timestamp خود در Sorted Set ثبت می‌شود، درخواست‌های قدیمی به‌صورت خودکار حذف می‌شوند و شمارش تعداد درخواست‌های درون پنجره دقیق است. Lua Script اتمیک بودن را تضمین می‌کند.

الگوی Delayed Job Queue

Sorted Set برای صف‌های jobs با تأخیر ایده‌آل است. score نشان‌دهنده‌ی زمان اجرای job است و consumer با ZRANGEBYSCORE jobs که زمانشان رسیده را پیدا می‌کند:

python
import redis
import json
import time
import uuid

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class DelayedJobQueue:
    def __init__(self, name="delayed_jobs"):
        self.key = name
        self.processing_key = f"{name}:processing"

    def schedule(self, job_data, delay_seconds=0):
        """زمان‌بندی یک job با تأخیر"""
        run_at = time.time() + delay_seconds
        job_id = str(uuid.uuid4())
        job = {
            'id': job_id,
            'data': job_data,
            'scheduled_at': run_at,
        }
        r.zadd(self.key, {json.dumps(job): run_at})
        return job_id

    def fetch_ready_jobs(self, count=10):
        """گرفتن jobهای آماده اجرا"""
        now = time.time()
        # Lua Script برای اتمیک بودن fetch + transfer to processing
        script = """
        local queue_key = KEYS[1]
        local processing_key = KEYS[2]
        local now = tonumber(ARGV[1])
        local count = tonumber(ARGV[2])

        -- 1. یافتن jobهای آماده
        local jobs = redis.call('ZRANGEBYSCORE', queue_key, 0, now, 'LIMIT', 0, count)
        if #jobs == 0 then
            return {}
        end

        -- 2. انتقال به processing queue
        for _, job in ipairs(jobs) do
            redis.call('ZREM', queue_key, job)
            redis.call('ZADD', processing_key, now, job)
        end

        return jobs
        """
        return r.eval(script, 2, self.key, self.processing_key, now, count)

    def complete_job(self, job_data):
        """حذف job از processing queue پس از اجرای موفق"""
        r.zrem(self.processing_key, job_data)

    def requeue_stale_jobs(self, timeout=300):
        """برگشت jobهای stale (که timeout شده‌اند) به صف اصلی"""
        now = time.time()
        cutoff = now - timeout
        # یافتن jobهایی که بیش از timeout در processing بوده‌اند
        stale = r.zrangebyscore(self.processing_key, 0, cutoff)
        for job in stale:
            r.zrem(self.processing_key, job)
            r.zadd(self.key, {job: now})  # اکنون اجرا شوند

# استفاده
queue = DelayedJobQueue()

# زمان‌بندی jobs
queue.schedule("send_email:user_1", delay_seconds=60)
queue.schedule("send_email:user_2", delay_seconds=120)
queue.schedule("cleanup_temp_files", delay_seconds=3600)

# worker loop (در یک process جداگانه)
def worker():
    while True:
        jobs = queue.fetch_ready_jobs(count=5)
        for job_str in jobs:
            job = json.loads(job_str)
            try:
                print(f"Processing: {job['data']}")
                # انجام کار...
                queue.complete_job(job_str)
            except Exception as e:
                print(f"Failed: {e}")
        time.sleep(1)  # صبر قبل از تلاش مجدد

عملیات مجموعه‌ای روی Sorted Set

مانند Set، Sorted Set هم از عملیات مجموعه‌ای پشتیبانی می‌کند، اما با قابلیت ترکیب scores:

bash
# ساخت دو Sorted Set
127.0.0.1:6379> ZADD math 90 "alice" 85 "bob" 70 "carol"
127.0.0.1:6379> ZADD physics 95 "alice" 80 "bob" 75 "dave"

# ZUNIONSTORE - اجتماع (با ترکیب scores)
# AGGREGATE می‌تواند SUM (پیش‌فرض)، MIN یا MAX باشد
127.0.0.1:6379> ZUNIONSTORE total 2 math physics
(integer) 4
127.0.0.1:6379> ZRANGE total 0 -1 WITHSCORES
1) "carol"   # 70 + 0 = 70
2) "70"
3) "dave"    # 0 + 75 = 75
4) "75"
5) "bob"     # 85 + 80 = 165
6) "165"
7) "alice"   # 90 + 95 = 185
8) "185"

# با AGGREGATE MIN (حداقل)
127.0.0.1:6379> ZUNIONSTORE min_score 2 math physics AGGREGATE MIN
127.0.0.1:6379> ZRANGE min_score 0 -1 WITHSCORES
1) "carol"
2) "70"
3) "dave"
4) "75"
5) "bob"
6) "80"
7) "alice"
8) "90"

# با WEIGHTS (وزن‌دهی)
127.0.0.1:6379> ZUNIONSTORE weighted 2 math physics WEIGHTS 1 2
# math با ضریب 1، physics با ضریب 2
# alice: 90*1 + 95*2 = 280
# bob: 85*1 + 80*2 = 245

# ZINTERSTORE - اشتراک (فقط اعضایی که در همه هستن)
127.0.0.1:6379> ZINTERSTORE both 2 math physics
(integer) 2  # فقط alice و bob در هر دو هستند

# ZDIFFSTORE - تفاضل
127.0.0.1:6379> ZDIFFSTORE math_only 2 math physics
127.0.0.1:6379> ZRANGE math_only 0 -1
1) "carol"  # فقط در math هست

الگوی Time-Series با Sorted Set

Sorted Set برای ذخیره‌ی داده‌های time-series (مانند متریک‌ها) عالی است. score نشان‌دهنده‌ی timestamp است و می‌توان با ZRANGEBYSCORE داده‌های یک بازه‌ی زمانی را گرفت:

python
import redis
import time
import json

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class TimeSeries:
    def __init__(self, name):
        self.key = f"ts:{name}"

    def add_point(self, value, timestamp=None):
        """افزودن یک نقطه داده"""
        if timestamp is None:
            timestamp = time.time()
        # اگر چند نقطه در همان timestamp ممکن است، از UUID استفاده کنید
        member = f"{timestamp}:{value}"
        r.zadd(self.key, {member: timestamp})

    def get_range(self, start_ts, end_ts):
        """دریافت نقاط در بازه‌ی زمانی"""
        results = r.zrangebyscore(self.key, start_ts, end_ts, withscores=True)
        return [(score, member.split(':')[1]) for member, score in results]

    def get_last_n(self, n=10):
        """N نقطه‌ی اخیر"""
        results = r.zrange(self.key, -n, -1, withscores=True)
        return [(score, member.split(':')[1]) for member, score in results]

    def cleanup_old(self, max_age_seconds):
        """حذف داده‌های قدیمی‌تر از max_age"""
        cutoff = time.time() - max_age_seconds
        r.zremrangebyscore(self.key, 0, cutoff)

# استفاده
ts = TimeSeries("cpu_usage")

# شبیه‌سازی ثبت متریک
for i in range(100):
    ts.add_point(50 + i * 0.5)
    time.sleep(0.1)

# خواندن داده‌های ۱۰ ثانیه اخیر
now = time.time()
recent = ts.get_range(now - 10, now)
for timestamp, value in recent:
    print(f"{timestamp}: {value}%")

# پاک‌سازی داده‌های قدیمی‌تر از ۱ ساعت
ts.cleanup_old(3600)
💡 برای time-series واقعی

برای کاربردهای جدی time-series (مانند IoT یا مانیتورینگ)، ماژول RedisTimeSeries (فصل ۲۲) گزینه‌ی بسیار بهتری است. این ماژول aggregation، downsampling و紧凑 compression را به‌صورت بومی ارائه می‌دهد. Sorted Set برای داده‌های time-series ساده یا موقت مناسب است.

الگوی Pagination با Sorted Set

Sorted Set برای pagination بر اساس زمان یا امتیاز عالی است. برخلاف pagination با LIMIT در SQL که با offset بالا کند می‌شود، Sorted Set با cursor-based pagination همیشه O(log N + M) است:

python
import redis
import time
import uuid

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class NewsFeed:
    """فید اخبار با cursor-based pagination"""
    def __init__(self, name="news"):
        self.key = f"feed:{name}"

    def add_post(self, post_id, content, timestamp=None):
        if timestamp is None:
            timestamp = time.time()
        # post_id را به‌عنوان member و timestamp را به‌عنوان score استفاده می‌کنیم
        r.zadd(self.key, {post_id: timestamp})

    def get_page(self, cursor=None, page_size=20):
        """
        cursor: score آخرین پست صفحه‌ی قبلی
        صفحه‌ی اول: cursor=None
        """
        if cursor is None:
            # صفحه‌ی اول: جدیدترین پست‌ها
            posts = r.zrevrange(self.key, 0, page_size - 1, withscores=True)
        else:
            # صفحات بعدی: پست‌های قدیمی‌تر از cursor
            # از ZRANGEBYSCORE با REV استفاده می‌کنیم
            posts = r.zrevrangebyscore(self.key, f"({cursor}", "-inf",
                                       start=0, num=page_size)

        if not posts:
            return [], None  # صفحه خالی

        # cursor برای صفحه‌ی بعد = score آخرین پست
        last_score = posts[-1][1] if isinstance(posts[-1], tuple) else posts[-1]
        if isinstance(last_score, tuple):
            last_score = last_score[1]

        return posts, last_score

# استفاده
feed = NewsFeed()
for i in range(100):
    feed.add_post(f"post_{i}", f"Content {i}")

# صفحه‌بندی
page = 1
cursor = None
while True:
    posts, cursor = feed.get_page(cursor=cursor, page_size=10)
    if not posts:
        break
    print(f"--- Page {page} ---")
    for post_id, score in posts:
        print(f"  {post_id} at {score}")
    page += 1
    if page > 10:
        break

ZPOPMIN و ZPOPMAX

دستورات ZPOPMIN و ZPOPMAX عضو با کمترین/بیشترین score را حذف و برمی‌گردانند. این دستورات پایه‌ی صف‌های اولویت‌دار هستند:

bash
# ZPOPMIN - حذف و برگرداندن کمترین score
127.0.0.1:6379> ZPOPMIN leaderboard
1) "alice"
2) "100"

# ZPOPMAX - حذف و برگرداندن بیشترین score
127.0.0.1:6379> ZPOPMAX leaderboard
1) "dave"
2) "320"

# با count
127.0.0.1:6379> ZPOPMIN leaderboard 2
1) "carol"
2) "175"
3) "bob"
4) "250"

# BZPOPMIN و BZPOPMAX - نسخه‌های blocking
127.0.0.1:6379> BZPOPMIN leaderboard 30
# اگر خالی باشد، تا ۳۰ ثانیه منتظر می‌ماند

# چند کلید همزمان
127.0.0.1:6379> BZPOPMIN queue_high queue_normal queue_low 30

Encoding داخلی Sorted Set

Sorted Set از دو encoding استفاده می‌کند:

Encoding شرایط استفاده ساختار داخلی
listpack≤ 128 عضو و هر مقدار ≤ 64 بایتلیست فشرده
skiplist + hashtableبزرگ‌تر از آستانهSkipList برای مرتب‌سازی + Hash برای lookup O(1)

ترکیب SkipList و Hash بسیار هوشمندانه است: SkipList اجازه می‌دهد عملیات مرتب‌سازی (ZRANGE، ZRANK) به‌صورت O(log N + M) انجام شوند، در حالی که Hash دسترسی به score یک عضو خاص را به‌صورت O(1) ممکن می‌کند. این طراحی دو ساختار داده را در عوض یک ساختار استفاده می‌کند، اما به‌جای ۲ برابر حافظه، فقط حدود ۳۰٪ بیشتر از یک SkipList تنها مصرف می‌کند.

📝 خلاصه فصل

در این فصل با Sorted Set آشنا شدید: دستورات ZADD/ZRANGE/ZRANK، فلگ‌های ZADD، عملیات مجموعه‌ای، و الگوهای leaderboard، sliding window rate limiter، delayed job queue، time-series و cursor-based pagination. Sorted Set یکی از قدرتمندترین و متمایزکننده‌ترین ساختارهای Redis است و در بسیاری از کاربردهای واقعی استفاده می‌شود. در فصل بعدی، با Bitmap و Bitfield کار می‌کنیم که برای ذخیره‌ی فشرده‌ی داده‌های بیتی طراحی شده‌اند.

Chapter 10

Bitmap و Bitfield #

Bitmap و Bitfield دو ساختار داده‌ی مبتنی بر بیت در Redis هستند که به شما اجازه می‌دهند اطلاعات را در سطح بیت ذخیره و دستکاری کنید. این ساختارها به‌لطف فشردگی فوق‌العاده‌ی خود — یک بیت به ازای هر کاربر یا هر رویداد — برای کاربردهایی مانند ردیابی کاربران فعال روزانه، فیلتر Bloom، شمارنده‌های فشرده و feature flags ایده‌آل هستند. در یک سناریوی واقعی، ردیابی فعالیت ۱۰۰ میلیون کاربر در طول یک سال با Bitmap فقط حدود ۴ گیگابایت حافظه نیاز دارد — چیزی که با Set یا Sorted Set عملاً غیرممکن است.

مفهوم Bitmap

Bitmap در واقع یک String است که به‌عنوان یک آرایه‌ی بیتی در نظر گرفته می‌شود. هر بیت در موقعیت (offset) مشخص می‌تواند 0 یا 1 باشد. چون هر بیت فقط یک بیت حافظه اشغال می‌کند، می‌توان با یک String به طول ۱ مگابایت، ۸ میلیون وضعیت باینری را ذخیره کرد. این فشردگی عالی برای داده‌هایی است که فقط دو حالت دارند: «بله/خیر»، «فعال/غیرفعال»، «حاضر/غایب».

graph LR subgraph "Bitmap Concept" A[String: 'hello'] --> B[Bytes: 0x68 0x65 0x6C 0x6C 0x6F] B --> C[Bits: 01101000 01100101 01101100 01101100 01101111] end C --> D[Offset 0: 0] C --> E[Offset 1: 1] C --> F[Offset 2: 1] C --> G[Offset 3: 0] C --> H[Offset 4: 1] C --> I[...] style C fill:#313244,stroke:#c084fc,color:#cdd6f4
// bitmap-as-bit-array

دستورات پایه: SETBIT، GETBIT، BITCOUNT

SETBIT یک بیت در موقعیت مشخص را به 0 یا 1 تنظیم می‌کند. GETBIT مقدار یک بیت را می‌خواند و BITCOUNT تعداد بیت‌های 1 را می‌شمارد:

bash
# SETBIT key offset value
# علامت‌گذاری user_id=1001 به‌عنوان فعال در روز 7
127.0.0.1:6379> SETBIT active:users:2024-01-07 1001 1
(integer) 0  # مقدار قبلی بیت

127.0.0.1:6379> SETBIT active:users:2024-01-07 1002 1
(integer) 0

127.0.0.1:6379> SETBIT active:users:2024-01-07 1050 1
(integer) 0

# GETBIT - بررسی فعال بودن یک کاربر
127.0.0.1:6379> GETBIT active:users:2024-01-07 1001
(integer) 1  # فعال

127.0.0.1:6379> GETBIT active:users:2024-01-07 9999
(integer) 0  # غیرفعال

# BITCOUNT - تعداد کل کاربران فعال
127.0.0.1:6379> BITCOUNT active:users:2024-01-07
(integer) 3

# BITCOUNT با محدوده (به بایت یا بیت)
127.0.0.1:6379> BITCOUNT active:users:2024-01-07 0 1000 BYTE
(integer) 3  # در ۱۰۰۰ بایت اول
127.0.0.1:6379> BITCOUNT active:users:2024-01-07 0 8000 BIT
(integer) 3  # در ۸۰۰۰ بیت اول

الگوی ردیابی کاربران فعال روزانه (DAU)

یکی از بهترین کاربردهای Bitmap، ردیابی Daily Active Users (DAU) است. هر روز یک Bitmap ساخته می‌شود که هر بیت نشان‌دهنده‌ی یک user_id است. با BITCOUNT می‌توان تعداد کل کاربران فعال را در O(N) (نسبت به طول bitmap) گرفت:

python
import redis
from datetime import datetime

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class ActiveUsersTracker:
    def record_activity(self, user_id, date=None):
        """ثبت فعالیت کاربر در یک روز"""
        if date is None:
            date = datetime.now().strftime("%Y-%m-%d")
        key = f"active:daily:{date}"
        r.setbit(key, user_id, 1)
        # TTL ۱۲ ماه برای پاک‌سازی خودکار
        r.expire(key, 365 * 86400)

    def is_active_on(self, user_id, date):
        """بررسی فعال بودن کاربر در یک روز"""
        return bool(r.getbit(f"active:daily:{date}", user_id))

    def get_daily_active_count(self, date):
        """تعداد کاربران فعال در یک روز"""
        return r.bitcount(f"active:daily:{date}")

    def get_weekly_active_count(self, end_date):
        """تعداد کاربران فعال در هفته (اجتماع ۷ روز)"""
        from datetime import timedelta
        end = datetime.strptime(end_date, "%Y-%m-%d")
        keys = []
        for i in range(7):
            d = (end - timedelta(days=i)).strftime("%Y-%m-%d")
            keys.append(f"active:daily:{d}")

        # BITOP OR برای اجتماع
        result_key = f"active:weekly:{end_date}"
        r.bitop("OR", result_key, *keys)
        count = r.bitcount(result_key)
        r.delete(result_key)  # پاک‌سازی موقت
        return count

    def get_monthly_active_count(self, year, month):
        """تعداد کاربران فعال در یک ماه"""
        # شبیه weekly اما با ۳۰ روز
        # برای سادگی، فقط کلیدها را با OR می‌گیریم
        keys = []
        for day in range(1, 32):
            date = f"{year}-{month:02d}-{day:02d}"
            keys.append(f"active:daily:{date}")

        result_key = f"active:monthly:{year}-{month:02d}"
        r.bitop("OR", result_key, *keys)
        count = r.bitcount(result_key)
        r.delete(result_key)
        return count

    def get_retention(self, start_date, end_date):
        """نرخ retention: کاربرانی که هم در start و هم در end فعال بودند"""
        start_key = f"active:daily:{start_date}"
        end_key = f"active:daily:{end_date}"

        # BITOP AND برای اشتراک
        result_key = f"retention:{start_date}_{end_date}"
        r.bitop("AND", result_key, start_key, end_key)
        retention_count = r.bitcount(result_key)
        start_count = r.bitcount(start_key)
        r.delete(result_key)

        return retention_count / start_count if start_count > 0 else 0

# استفاده
tracker = ActiveUsersTracker()

# شبیه‌سازی فعالیت کاربران در روز 2024-01-07
for user_id in [1, 5, 10, 100, 500, 1000, 5000]:
    tracker.record_activity(user_id, "2024-01-07")

print(f"DAU on 2024-01-07: {tracker.get_daily_active_count('2024-01-07')}")

عملیات بیتی: BITOP

BITOP امکان انجام عملیات بیتی AND، OR، XOR و NOT روی چند Bitmap را فراهم می‌کند. این عملیات برای تحلیل ترکیبی کاربران بسیار مفید است:

bash
# فرض کنیم دو روز داریم
127.0.0.1:6379> SETBIT day1 1 1
127.0.0.1:6379> SETBIT day1 2 1
127.0.0.1:6379> SETBIT day1 3 1

127.0.0.1:6379> SETBIT day2 2 1
127.0.0.1:6379> SETBIT day2 3 1
127.0.0.1:6379> SETBIT day2 4 1

# AND - کاربرانی که در هر دو روز فعال بودند (retention)
127.0.0.1:6379> BITOP AND both_days day1 day2
127.0.0.1:6379> BITCOUNT both_days
(integer) 2  # users 2 و 3

# OR - کاربرانی که در حداقل یک روز فعال بودند
127.0.0.1:6379> BITOP OR any_day day1 day2
127.0.0.1:6379> BITCOUNT any_day
(integer) 4  # users 1, 2, 3, 4

# XOR - کاربرانی که فقط در یک روز فعال بودند
127.0.0.1:6379> BITOP XOR only_one day1 day2
127.0.0.1:6379> BITCOUNT only_one
(integer) 2  # users 1 و 4

# NOT - معکوس (تمام بیت‌های NOT شده)
127.0.0.1:6379> BITOP NOT not_day1 day1
# (توجه: NOT روی تمام بیت‌های تا بیشترین بیت set شده اعمال می‌شود)

BITPOS — یافتن اولین بیت با مقدار مشخص

BITPOS موقعیت اولین بیت 0 یا 1 را برمی‌گرداند. این دستور برای یافتن اولین user_id خالی یا اولین کاربر فعال مفید است:

bash
# BITPOS key bit [start [end [BYTE|BIT]]]

# اولین بیت 1
127.0.0.1:6379> BITPOS active:users:2024-01-07 1
(integer) 1001  # اولین کاربر فعال

# اولین بیت 0
127.0.0.1:6379> BITPOS active:users:2024-01-07 0
(integer) 0  # بیت 0 خالی است

# با محدوده
127.0.0.1:6379> BITPOS active:users:2024-01-07 1 0 1000 BYTE
(integer) 1001

Bitfield — اعداد صحیح درون بیت‌ها

علاوه بر Bitmap که فقط بیت‌های 0/1 را پشتیبانی می‌کند، Redis از Bitfield هم پشتیبانی می‌کند که اجازه می‌دهد اعداد صحیح با عرض مشخص (مثلاً ۸ بیت، ۱۶ بیت، ۳۲ بیت) را درون یک String ذخیره کنید. این قابلیت برای شمارنده‌های فشرده بسیار مفید است:

bash
# BITFIELD key [GET type offset | SET type offset value |
#               INCRBY type offset increment | OVERFLOW WRAP|SAT|FAIL]

# type می‌تواند: u8, u16, u32, u64 (unsigned) یا i8, i16, i32, i64 (signed)

# SET: تنظیم عدد 8 بیتی در offset 0
127.0.0.1:6379> BITFIELD counters SET u8 0 100
1) (integer) 0  # مقدار قبلی

# SET عدد 16 بیتی در offset 8
127.0.0.1:6379> BITFIELD counters SET u16 8 50000
1) (integer) 0

# GET
127.0.0.1:6379> BITFIELD counters GET u8 0 GET u16 8
1) (integer) 100
2) (integer) 50000

# INCRBY - افزایش اتمیک
127.0.0.1:6379> BITFIELD counters INCRBY u8 0 5
1) (integer) 105

# چند عمل در یک دستور
127.0.0.1:6379> BITFIELD counters \
    SET u8 0 200 \
    INCRBY u16 8 1000 \
    GET u8 0
1) (integer) 105   # مقدار قبلی SET
2) (integer) 51000 # نتیجه INCRBY
3) (integer) 200   # نتیجه GET

# OVERFLOW - رفتار در سرریز
# WRAP (پیش‌فرض): دور می‌زند
127.0.0.1:6379> BITFIELD counters SET u8 16 250 OVERFLOW WRAP INCRBY u8 16 10
1) (integer) 250
2) (integer) 4  # 250 + 10 = 260، اما در u8 دور می‌زند به 4

# SAT: در مقدار max متوقف می‌شود
127.0.0.1:6379> BITFIELD counters OVERFLOW SAT INCRBY u8 16 100
1) (integer) 255  # max u8

# FAIL: در سرریز، nil برمی‌گرداند
127.0.0.1:6379> BITFIELD counters OVERFLOW FAIL INCRBY u8 16 100
1) (nil)

الگوی شمارنده‌های فشرده با Bitfield

Bitfield برای ذخیره‌ی شمارنده‌های متعدد به‌صورت فشرده عالی است. مثلاً اگر بخواهید تعداد بازدید ۱۰۰۰ صفحه وب را در روز ردیابی کنید، با شمارنده‌ی ۳۲ بیتی فقط ۴ کیلوبایت نیاز دارید (۱۰۰۰ × ۳۲ بیت = ۴ کیلوبایت):

python
import redis
from datetime import datetime

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class CompactPageViews:
    """شمارنده‌ی بازدید صفحه‌ها با Bitfield (فشرده)"""
    NUM_PAGES = 1000
    BITS_PER_COUNTER = 32  # u32 کافی برای ۴ میلیارد بازدید
    COUNTER_TYPE = 'u32'

    def __init__(self, date=None):
        if date is None:
            date = datetime.now().strftime("%Y-%m-%d")
        self.key = f"pageviews:{date}"

    def record_view(self, page_id):
        """ثبت یک بازدید برای صفحه"""
        offset = page_id * self.BITS_PER_COUNTER
        r.bitfield(self.key, f'INCRBY {self.COUNTER_TYPE} {offset} 1')

    def get_views(self, page_id):
        """خواندن تعداد بازدید یک صفحه"""
        offset = page_id * self.BITS_PER_COUNTER
        result = r.bitfield(self.key, f'GET {self.COUNTER_TYPE} {offset}')
        return result[0] if result else 0

    def get_top_pages(self, n=10):
        """یافتن N صفحه‌ی پربازدید (نیاز به خواندن همه)"""
        # Bitfield مستقیماً top-N نمی‌دهد، باید با یک حلقه بخوانیم
        # برای بهینه‌سازی، می‌توان از Sorted Set موازی استفاده کرد
        views = []
        for page_id in range(self.NUM_PAGES):
            count = self.get_views(page_id)
            if count > 0:
                views.append((page_id, count))
        views.sort(key=lambda x: -x[1])
        return views[:n]

# مقایسه‌ی حافظه:
# روش سنتی (String یا Hash برای هر صفحه):
#   1000 کلید × ~70 بایت overhead = 70 کیلوبایت
# روش Bitfield:
#   1000 × 32 بیت = 4 کیلوبایت (۱۷ برابر کمتر!)

tracker = CompactPageViews()
for _ in range(100):
    tracker.record_view(42)
for _ in range(50):
    tracker.record_view(10)

print(f"Page 42 views: {tracker.get_views(42)}")  # 100
print(f"Page 10 views: {tracker.get_views(10)}")  # 50

الگوی Bloom Filter با Bitmap

یک کاربرد جالب Bitmap، ساخت فیلتر Bloom است — یک ساختار داده‌ی احتمالی که برای تست عضویت سریع (با احتمال false positive اما بدون false negative) استفاده می‌شود. Redis ماژول RedisBloom دارد که فیلتر Bloom بومی ارائه می‌دهد، اما می‌توان با Bitmap هم نسخه‌ی ساده‌ای ساخت:

python
import redis
import hashlib

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class SimpleBloomFilter:
    def __init__(self, name, expected_items=100000, false_positive_rate=0.01):
        self.key = f"bloom:{name}"
        # محاسبه‌ی اندازه‌ی bitmap و تعداد hashها
        import math
        m = int(-expected_items * math.log(false_positive_rate) / (math.log(2) ** 2))
        k = int(m / expected_items * math.log(2))
        self.size = m
        self.num_hashes = k

    def _get_hashes(self, item):
        """تولید k hash برای یک آیتم با double hashing"""
        h1 = int(hashlib.md5(item.encode()).hexdigest(), 16) % self.size
        h2 = int(hashlib.sha1(item.encode()).hexdigest(), 16) % self.size
        for i in range(self.num_hashes):
            yield (h1 + i * h2) % self.size

    def add(self, item):
        """افزودن آیتم به فیلتر"""
        pipe = r.pipeline()
        for offset in self._get_hashes(item):
            pipe.setbit(self.key, offset, 1)
        pipe.execute()

    def might_contain(self, item):
        """بررسی احتمالی عضویت (ممکن است false positive بدهد)"""
        pipe = r.pipeline()
        for offset in self._get_hashes(item):
            pipe.getbit(self.key, offset)
        results = pipe.execute()
        return all(results)  # اگر همه 1 بودند، احتمالاً عضو است

# استفاده
bf = SimpleBloomFilter("emails", expected_items=1000000)

# افزودن ایمیل‌های موجود
bf.add("user1@example.com")
bf.add("user2@example.com")

# بررسی
print(bf.might_contain("user1@example.com"))  # True (یقیناً)
print(bf.might_contain("user3@example.com"))  # False یا True (احتمالاً False)
# نکته: false positive ممکن است، false negative هرگز
💡 برای تولید واقعی

این پیاده‌سازی ساده است؛ برای محیط تولید، از ماژول RedisBloom (فصل ۲۲) استفاده کنید که فیلترهای Bloom مقیاس‌پذیر، Cuckoo filters، Top-K و Count-Min Sketch را به‌صورت بومی ارائه می‌دهد.

الگوی Feature Flags با Bitmap

Bitmap برای feature flags (فعال/غیرفعال کردن ویژگی برای کاربران خاص) عالی است. هر feature یک Bitmap دارد که هر بیت نشان‌دهنده‌ی یک user_id است:

python
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class FeatureFlags:
    def enable_for_user(self, feature, user_id):
        """فعال‌سازی feature برای یک کاربر"""
        r.setbit(f"feature:{feature}", user_id, 1)

    def disable_for_user(self, feature, user_id):
        """غیرفعال‌سازی"""
        r.setbit(f"feature:{feature}", user_id, 0)

    def is_enabled(self, feature, user_id):
        """بررسی فعال بودن feature برای کاربر"""
        return bool(r.getbit(f"feature:{feature}", user_id))

    def enable_for_percentage(self, feature, percentage):
        """فعال‌سازی برای درصدی از کاربران (A/B testing)"""
        # هر percentage کاربر را فعال کن
        # یک الگوی ساده: فعال‌سازی کاربرانی که user_id % 100 < percentage
        # برای پیاده‌سازی صحیح، باید روی یک بازه‌ی بزرگ کار کنیم
        # یا از Hash با seed استفاده کنیم
        pass

    def get_enabled_count(self, feature):
        """تعداد کاربرانی که feature برایشان فعال است"""
        return r.bitcount(f"feature:{feature}")

# استفاده
flags = FeatureFlags()

# فعال‌سازی feature جدید برای ۱۰۰ کاربر اول
for user_id in range(1, 101):
    flags.enable_for_user("new_dashboard", user_id)

# بررسی
print(flags.is_enabled("new_dashboard", 50))  # True
print(flags.is_enabled("new_dashboard", 200))  # False
print(f"Total enabled: {flags.get_enabled_count('new_dashboard')}")  # 100

محاسبه‌ی حافظه

یکی از جذاب‌ترین ویژگی‌های Bitmap، صرفه‌جویی فوق‌العاده در حافظه است. در جدول زیر، مقایسه‌ی حافظه برای ردیابی ۱۰ میلیون کاربر را می‌بینید:

روش حافظه برای ۱۰M کاربر نسبت
Bitmap (1 بیت/کاربر)~1.25 MB1x
Set (هر ID ~60 بایت)~600 MB480x
Hash (هر فیلد)~600 MB480x
String+JSON~100 MB80x
⚠️ نکته مهم در مورد user_id

Bitmap با user_id های متوالی (مثل 1، 2، 3، ...) بسیار بهینه است. اما اگر user_id های تصادفی یا بسیار بزرگ (مثل UUID) داشته باشید، Bitmap کارایی ندارد — مثلاً یک UUID تولید bitmap به طول ۱۲۸ بیت برای هر کاربر می‌کند، که با ۱۰ میلیون کاربر ۱۵۰ ترابایت نیاز دارد! در این موارد، از Set یا HyperLogLog (فصل بعد) استفاده کنید.

محدودیت‌های Bitmap

با وجود مزایای فراوان، Bitmap محدودیت‌هایی دارد که باید مد نظر قرار گیرد:

۱. فقط دو حالت دارد. هر بیت فقط 0 یا 1 می‌تواند باشد. اگر نیاز به ذخیره‌ی مقدار بیشتر دارید، از Bitfield یا Hash استفاده کنید.

۲. به user_idهای متوالی وابسته است. اگر user_id های تصادفی یا بسیار بزرگ دارید، Bitmap حافظه‌ی زیادی اشغال می‌کند. در این موارد، می‌توان از یک hash function برای نگاشت user_id به offset کوچک‌تر استفاده کرد (با ریسک collision).

۳. BITCOUNT به طول bitmap وابسته است. شمارش بیت‌های 1 در یک bitmap به طول ۱ گیگابایت می‌تواند چند صد میلی‌ثانیه طول بکشد. برای bitmapهای بسیار بزرگ، می‌توان با BITCOUNT key start end و یک محدوده‌ی کوچک‌تر، عملیات را محدود کرد.

۴. پیمایش اعضای فعال کند است. اگر بخواهید لیست تمام user_id های فعال را بگیرید، باید کل bitmap را با GETBIT پیمایش کنید. در این موارد، بهتر است یک Set موازی نگه دارید.

📝 خلاصه فصل

در این فصل با Bitmap و Bitfield آشنا شدید: دستورات SETBIT/GETBIT/BITCOUNT/BITOP، الگوهای ردیابی DAU، retention، شمارنده‌های فشرده، فیلتر Bloom و feature flags. Bitmap برای داده‌های باینری با حجم بزرگ بهترین انتخاب است؛ اما به user_id های متوالی وابسته است. در فصل بعدی، با HyperLogLog کار می‌کنیم که برای شمارش کارتینالیتی (تعداد عناصر یکتا) با حافظه‌ی ثابت ۱۲ کیلوبایت طراحی شده است.

Chapter 11

HyperLogLog #

HyperLogLog یک ساختار داده‌ی احتمالی است که برای شمارش تقریبی تعداد عناصر یکتا (cardinality) طراحی شده است. این ساختار می‌تواند میلیون‌ها یا حتی میلیاردها عنصر یکتا را فقط با ۱۲ کیلوبایت حافظه بشمارد — با خطای استاندارد حدود ۰.۸۱٪. این در حالی است که ذخیره‌ی همان داده‌ها در Set، صدها مگابایت تا چند گیگابایت حافظه نیاز دارد. HyperLogLog برای کاربردهایی مانند شمارش Unique Visitors روزانه، تعداد کاربران یکتای دیده‌ی یک تبلیغ، یا تعداد آی‌پی‌های یکتای دسترسی به یک منبع ایده‌آل است.

مفهوم HyperLogLog

HyperLogLog بر پایه‌ی مشاهده‌ی ساده اما زیرکانه‌ای بنا شده است: اگر اعداد تصادفی را با توزیع یکنواخت تولید کنیم، میانگین طول دنباله‌ی صفرهای ابتدایی به‌طور لگاریتمی با تعداد اعداد مرتبط است. به‌عبارت دیگر، اگر بیشترین تعداد صفرهای پشت‌سرهم در ابتدای اعداد را نگه داریم، می‌توانیم تخمین بزنیم که چند عدد تصادفی دیده‌ایم. HyperLogLog این ایده را با استفاده از چندین زیر-جریان (m = 2^14 = 16384 register) و میانگیری هارمونیک بهبود می‌دهد تا دقت قابل قبولی به‌دست آورد.

graph TB A[Element to count] --> B[Hash Function] B --> C[64-bit hash] C --> D[First 14 bits: register index] C --> E[Remaining 50 bits: count leading zeros + 1] D --> F[Register 0..16383] E --> F F --> G[Maximum across registers] G --> H[Harmonic Mean Estimate] H --> I[Approximate Cardinality] style F fill:#313244,stroke:#c084fc,color:#cdd6f4 style H fill:#45475a,stroke:#2dd4bf,color:#cdd6f4
// hyperloglog-internal-concept

دستورات HyperLogLog: PFADD، PFCOUNT، PFMERGE

سه دستور اصلی HyperLogLog وجود دارد. PFADD یک یا چند عنصر را به HyperLogLog اضافه می‌کند. PFCOUNT تخمین تعداد عناصر یکتا را برمی‌گرداند و PFMERGE چندین HyperLogLog را در یکی ترکیب می‌کند:

bash
# PFADD - افزودن عناصر
127.0.0.1:6379> PFADD unique_visitors:2024-01-07 "user1" "user2" "user3" "user4"
(integer) 1  # ساختار تغییر کرد

# افزودن کاربر تکراری
127.0.0.1:6379> PFADD unique_visitors:2024-01-07 "user1"
(integer) 0  # تغییری نکرد (قبلاً اضافه شده بود)

# PFCOUNT - تخمین تعداد عناصر یکتا
127.0.0.1:6379> PFCOUNT unique_visitors:2024-01-07
(integer) 4

# افزودن کاربران بیشتر
127.0.0.1:6379> PFADD unique_visitors:2024-01-07 "user5" "user6" "user7" "user8"
127.0.0.1:6379> PFCOUNT unique_visitors:2024-01-07
(integer) 8

# PFMERGE - ترکیب چند HLL
127.0.0.1:6379> PFADD unique_visitors:2024-01-08 "user2" "user3" "user9" "user10"
127.0.0.1:6379> PFMERGE unique_visitors:2days unique_visitors:2024-01-07 unique_visitors:2024-01-08
OK
127.0.0.1:6379> PFCOUNT unique_visitors:2days
(integer) 10  # users 1-10, با user2 و user3 که در هر دو روز بودند فقط یک بار شمرده شدند
ℹ️ چرا PF؟

پیشوند PF در دستورات HyperLogLog به افتخار Philippe Flajolet، ریاضیدان فرانسوی که یکی از مخترعان اصلی این الگوریتم بود، انتخاب شده است. او در سال ۲۰۰۷ نسخه‌ی اصلی HyperLogLog را همراه با Marckou و Toumit منتشر کرد. این یکی از معدود مواردی است که در Redis نام یک شخص به‌عنوان پیشوند دستور استفاده شده است.

الگوی شمارش Unique Visitors روزانه

رایج‌ترین کاربرد HyperLogLog، شمارش Unique Visitors روزانه (UV) است. در ادامه، یک پیاده‌سازی کامل را می‌بینیم که هم شمارش روزانه و هم شمارش هفتگی و ماهانه را با PFMERGE ارائه می‌دهد:

python
import redis
from datetime import datetime, timedelta

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class UniqueVisitorsHLL:
    def record_visit(self, user_id, date=None):
        """ثبت بازدید کاربر در یک روز"""
        if date is None:
            date = datetime.now().strftime("%Y-%m-%d")
        key = f"uv:daily:{date}"
        r.pfadd(key, str(user_id))
        # TTL ۴۰۰ روز برای پاک‌سازی خودکار
        r.expire(key, 400 * 86400)

    def get_daily_uv(self, date):
        """تعداد UV یک روز"""
        return r.pfcount(f"uv:daily:{date}")

    def get_weekly_uv(self, end_date):
        """تعداد UV هفته‌ی پایان‌یافته در end_date"""
        end = datetime.strptime(end_date, "%Y-%m-%d")
        keys = []
        for i in range(7):
            d = (end - timedelta(days=i)).strftime("%Y-%m-%d")
            keys.append(f"uv:daily:{d}")

        merged_key = f"uv:weekly:{end_date}"
        r.pfmerge(merged_key, *keys)
        r.expire(merged_key, 86400)  # نتیجه‌ی موقت
        return r.pfcount(merged_key)

    def get_monthly_uv(self, year, month):
        """تعداد UV یک ماه"""
        keys = []
        for day in range(1, 32):
            date = f"{year}-{month:02d}-{day:02d}"
            keys.append(f"uv:daily:{date}")

        merged_key = f"uv:monthly:{year}-{month:02d}"
        r.pfmerge(merged_key, *keys)
        return r.pfcount(merged_key)

    def get_realtime_uv(self, minutes=5):
        """UV در ۵ دقیقه‌ی اخیر (با کلیدهای دقیقه‌ای)"""
        now = datetime.now()
        keys = []
        for i in range(minutes):
            d = (now - timedelta(minutes=i)).strftime("%Y-%m-%d-%H-%M")
            keys.append(f"uv:minute:{d}")

        merged_key = f"uv:realtime:{now.strftime('%Y-%m-%d-%H-%M')}"
        r.pfmerge(merged_key, *keys)
        r.expire(merged_key, 600)  # ۱۰ دقیقه TTL
        return r.pfcount(merged_key)

# استفاده
uv = UniqueVisitorsHLL()

# شبیه‌سازی بازدید ۱۰۰۰۰ کاربر در یک روز
import random
for _ in range(10000):
    user_id = random.randint(1, 50000)
    uv.record_visit(user_id, "2024-01-07")

print(f"Daily UV: {uv.get_daily_uv('2024-01-07')}")
# خروجی تقریبی: ۹۹۲۰ (خطای ~۰.۸٪)
💡 مقایسه‌ی حافظه

برای شمارش ۱۰ میلیون UV روزانه:

Set: ~600 مگابایت (هر عضو ~60 بایت overhead)

HyperLogLog: ۱۲ کیلوبایت (ثابت!)

این یعنی ۵۰۰۰۰ برابر صرفه‌جویی در حافظه. با این قیمت، خطای ۰.۸٪ برای اکثر کاربردهای آماری کاملاً قابل قبول است.

الگوی شمارش نمایش‌های تبلیغ

در صنعت تبلیغات آنلاین، می‌خواهیم بدانیم یک تبلیغ چند کاربر یکتا دیده‌اند. این کار با Set هم ممکن است؛ اما اگر میلیون‌ها کاربر داشته باشیم، HyperLogLog بسیار بهتر است:

python
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class AdViewCounter:
    def record_view(self, ad_id, user_id, date):
        """ثبت مشاهده‌ی تبلیغ توسط کاربر"""
        key = f"ad:{ad_id}:views:{date}"
        r.pfadd(key, str(user_id))
        r.expire(key, 90 * 86400)  # نگه‌داری ۹۰ روز

    def get_unique_viewers(self, ad_id, date):
        """تعداد بیننده‌ی یکتای تبلیغ در یک روز"""
        return r.pfcount(f"ad:{ad_id}:views:{date}")

    def get_campaign_reach(self, campaign_id, date):
        """تعداد کل بیننده‌ی یکتای یک کمپین (چند تبلیغ)"""
        # فرض می‌کنیم لیست تبلیغات کمپین را داریم
        ad_ids = r.smembers(f"campaign:{campaign_id}:ads")
        keys = [f"ad:{ad_id}:views:{date}" for ad_id in ad_ids]

        merged_key = f"campaign:{campaign_id}:reach:{date}"
        r.pfmerge(merged_key, *keys)
        r.expire(merged_key, 86400)
        return r.pfcount(merged_key)

    def get_total_reach(self, ad_id, start_date, end_date):
        """تعداد بیننده‌ی یکتا در یک بازه‌ی چند روزه"""
        from datetime import datetime, timedelta
        start = datetime.strptime(start_date, "%Y-%m-%d")
        end = datetime.strptime(end_date, "%Y-%m-%d")

        keys = []
        current = start
        while current <= end:
            date_str = current.strftime("%Y-%m-%d")
            keys.append(f"ad:{ad_id}:views:{date_str}")
            current += timedelta(days=1)

        merged_key = f"ad:{ad_id}:total:{start_date}_{end_date}"
        r.pfmerge(merged_key, *keys)
        return r.pfcount(merged_key)

# استفاده
counter = AdViewCounter()
# شبیه‌سازی: تبلیغ 42 توسط ۱۰۰۰۰ کاربر دیده می‌شود
for _ in range(10000):
    counter.record_view("42", random.randint(1, 100000), "2024-01-07")

print(f"Unique viewers: {counter.get_unique_viewers('42', '2024-01-07')}")

دقت HyperLogLog

HyperLogLog در Redis با m = 16384 register (۱۴ بیت برای index) پیکربندی شده است. این مقدار به‌طور پیش‌فرض خطای استاندارد 0.81% می‌دهد. این یعنی اگر واقعاً ۱۰۰٬۰۰۰ کاربر یکتا داشته باشید، PFCOUNT ممکن است بین ۹۹٬۲۰۰ و ۱۰۰٬۸۰۰ بدهد — اما هرگز به‌طور سیستماتیک بزرگ‌تر یا کوچک‌تر از واقعیت نیست. این دقت برای اکثر کاربردهای آماری (پنل‌ها، داشبوردها، گزارش‌ها) کاملاً کافی است؛ اما برای مواردی که به دقت ۱۰۰٪ نیاز دارید (مثلاً گزارش مالی)، باید از Set استفاده کنید.

تعداد واقعی تخمین HLL (میانگین) خطا
1,0001,0070.7%
10,0009,9470.5%
100,000100,6120.6%
1,000,0001,005,2340.5%
10,000,0009,983,4210.2%
100,000,000100,234,5670.2%

مزایا و معایب HyperLogLog

مزایا:

۱. حافظه‌ی ثابت: ۱۲ کیلوبایت برای هر HLL، صرف‌نظر از تعداد عناصر. این بزرگترین مزیت است.

۲. سرعت بالا: PFADD و PFCOUNT در O(1) با ثابت کوچک اجرا می‌شوند (~1ms برای هر عمل).

۳. قابلیت merge: با PFMERGE می‌توان چندین HLL را بدون از دست رفتن دقت ترکیب کرد. این برای گزارش‌های هفتگی/ماهانه از داده‌های روزانه عالی است.

۴. تخمین کاردینالیتی بسیار بزرگ: می‌تواند تا ۲^64 عنصر را بشمارد — عددی که عملاً غیرممکن است با Set ذخیره شود.

معایب:

۱. تقریبی است: خطای ۰.۸۱٪ برای اکثر کاربردها قابل قبول است؛ اما برای مواردی که نیاز به دقت ۱۰۰٪ دارید، مناسب نیست.

۲. نمی‌تواند عناصر را برگرداند. HLL فقط تعداد را می‌شمارد؛ نمی‌توانید بپرسید «آیا کاربر X در این HLL است؟». برای این کار، Set یا Bitmap لازم است.

۳. حذف عنصر مجاز نیست. وقتی عنصری به HLL اضافه شد، نمی‌توان آن را حذف کرد. این برای کاربردهایی که نیاز به «حذف کاربر از لیست فعال» دارند، مشکل‌ساز است.

۴. برای مقادیر کوچک بی‌معنی است. اگر فقط چند هزار کاربر یکتا دارید، Set بهتر است — هم دقیق است و هم حافظه‌ی کمتری مصرف می‌کند.

مقایسه‌ی HyperLogLog با Set

ویژگی Set HyperLogLog
دقت۱۰۰٪ دقیقتقریبی (خطای ~۰.۸٪)
حافظه برای ۱M کاربر~60 MB12 KB (ثابت)
حافظه برای ۱B کاربر~60 GB12 KB (ثابت)
بررسی عضویتبله (SISMEMBER)خیر
حذف عضوبله (SREM)خیر
ترکیب چند مجموعهSUNIONSTOREPFMERGE (بهتر)
سرعت PFADD/SADDO(1)O(1)
مناسب برایتا چند صد هزار کاربر، نیاز به دقتمیلیون‌ها کاربر، آمار و گزارش

الگوی Real-time Dashboard

یکی از کاربردهای جذاب HyperLogLog، داشبوردهای real-time است که می‌خواهند تعداد کاربران فعال لحظه‌ای را نشان دهند. می‌توان با کلیدهای دقیقه‌ای یا حتی ثانیه‌ای این کار را انجام داد:

python
import redis
import time
from datetime import datetime

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class RealtimeDashboard:
    """داشبورد real-time با HyperLogLog"""
    def record_active_user(self, user_id):
        """ثبت فعالیت کاربر در دقیقه‌ی فعلی"""
        now = datetime.now()
        minute_key = now.strftime("%Y-%m-%d-%H-%M")
        r.pfadd(f"active:minute:{minute_key}", str(user_id))
        r.expire(f"active:minute:{minute_key}", 3600)  # نگه‌داری ۱ ساعت

        # ساعت فعلی هم
        hour_key = now.strftime("%Y-%m-%d-%H")
        r.pfadd(f"active:hour:{hour_key}", str(user_id))
        r.expire(f"active:hour:{hour_key}", 86400 * 7)  # ۷ روز

    def get_active_users_last_n_minutes(self, n=5):
        """تعداد کاربران فعال در N دقیقه‌ی اخیر"""
        now = datetime.now()
        keys = []
        for i in range(n):
            minute = (now.replace(second=0, microsecond=0)
                      .timestamp() - i * 60)
            from datetime import datetime as dt
            minute_str = dt.fromtimestamp(minute).strftime("%Y-%m-%d-%H-%M")
            keys.append(f"active:minute:{minute_str}")

        merged_key = f"active:last_{n}min:{int(time.time())}"
        r.pfmerge(merged_key, *keys)
        r.expire(merged_key, 60)  # نتیجه موقت
        return r.pfcount(merged_key)

    def get_active_users_today(self):
        """تعداد کاربران فعال امروز"""
        today = datetime.now().strftime("%Y-%m-%d")
        # فرض: داده‌های ساعت‌های امروز را داریم
        keys = []
        for hour in range(24):
            keys.append(f"active:hour:{today}-{hour:02d}")
        merged_key = f"active:today:{today}"
        r.pfmerge(merged_key, *keys)
        return r.pfcount(merged_key)

مثال: شمارش IPهای یکتا

یکی دیگر از کاربردهای رایج، شمارش IPهای یکتای دسترسی به یک منبع است. این کار برای تحلیل ترافیک و شناسایی bot‌ها مفید است:

python
import redis
from datetime import datetime

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class IPCounter:
    def record_access(self, ip_address, endpoint, date=None):
        """ثبت دسترسی یک IP به یک endpoint"""
        if date is None:
            date = datetime.now().strftime("%Y-%m-%d")
        # هر endpoint یک HLL جداگانه دارد
        key = f"ips:{endpoint}:{date}"
        r.pfadd(key, ip_address)
        r.expire(key, 30 * 86400)

    def get_unique_ips(self, endpoint, date):
        """تعداد IPهای یکتای دسترسی به endpoint در یک روز"""
        return r.pfcount(f"ips:{endpoint}:{date}")

    def get_unique_ips_range(self, endpoint, start_date, end_date):
        """تعداد IPهای یکتا در یک بازه‌ی چند روزه"""
        from datetime import datetime, timedelta
        start = datetime.strptime(start_date, "%Y-%m-%d")
        end = datetime.strptime(end_date, "%Y-%m-%d")
        keys = []
        current = start
        while current <= end:
            keys.append(f"ips:{endpoint}:{current.strftime('%Y-%m-%d')}")
            current += timedelta(days=1)
        merged_key = f"ips:{endpoint}:{start_date}_{end_date}"
        r.pfmerge(merged_key, *keys)
        return r.pfcount(merged_key)

ترکیب HyperLogLog با Set — هوشمندانه

گاهی اوقات می‌توان از هر دو ساختار به‌صورت ترکیبی استفاده کرد: Set برای داده‌های اخیر (با قابلیت بررسی عضویت) و HyperLogLog برای داده‌های تاریخی (با صرفه‌جویی حافظه). این الگو به HLL + Set hybrid معروف است:

python
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class HybridUniqueCounter:
    """ترکیب Set (دقیق، برای داده‌های اخیر) و HLL (تقریبی، برای داده‌های قدیمی)"""
    SET_THRESHOLD = 100000  # وقتی Set از این تعداد بیشتر شد، به HLL منتقل کن

    def record_view(self, key, user_id):
        """ثبت بازدید"""
        # ابتدا در Set اضافه کن
        r.sadd(f"{key}:recent", user_id)

        # اگر Set خیلی بزرگ شد، آن را به HLL منتقل کن
        if r.scard(f"{key}:recent") >= self.SET_THRESHOLD:
            self._migrate_to_hll(key)

    def _migrate_to_hll(self, key):
        """انتقال Set به HLL برای صرفه‌جویی در حافظه"""
        # تمام اعضای Set را به HLL اضافه کن
        members = r.smembers(f"{key}:recent")
        if members:
            r.pfadd(f"{key}:hll", *members)
        # Set را پاک کن
        r.delete(f"{key}:recent")

    def get_count(self, key):
        """تعداد یکتا"""
        set_count = r.scard(f"{key}:recent")
        hll_count = r.pfcount(f"{key}:hll") if r.exists(f"{key}:hll") else 0
        # نکته: این مجموع واقعی نیست چون ممکن است هم‌پوشانی باشد
        # برای دقت بیشتر، می‌توان Set را هم به HLL موقتاً merge کرد
        if hll_count == 0:
            return set_count
        # تخمین ترکیبی: HLL + تعداد Set (تقریبی)
        return hll_count + set_count
📝 خلاصه فصل

در این فصل با HyperLogLog آشنا شدید: دستورات PFADD/PFCOUNT/PFMERGE، الگوهای شمارش UV، نمایش تبلیغ، داشبورد real-time و شمارش IP. HyperLogLog برای شمارش approximate cardinality با حافظه‌ی ثابت ۱۲KB بهترین انتخاب است — به‌ویژه وقتی تعداد عناصر بسیار بزرگ (میلیون‌ها یا میلیاردها) است. در فصل بعدی، با Redis Streams کار می‌کنیم که یک سیستم قدرتمند message queue با consumer groups است.

Chapter 12

Stream (جریان داده) #

Redis Stream در نسخه‌ی ۵.۰ معرفی شد و یکی از قدرتمندترین ساختارهای داده‌ی Redis است. Stream یک log append-only است که برای ذخیره و پردازش رویدادها به‌صورت زمان‌مند طراحی شده است. این ساختار بسیاری از ویژگی‌های یک message queue حرفه‌ای را ارائه می‌دهد: Consumer Groups، acknowledgment، persistence، replay و ordering guarantees. Stream می‌تواند جایگزین Kafka یا RabbitMQ در سناریوهای متوسط باشد و مزیت اصلی‌اش این است که نیاز به یک ابزار جداگانه را از بین می‌برد — همه‌چیز در همان Redis است.

تفاوت Stream با List و Pub/Sub

پیش از ورود به جزئیات، بیایید ببینیم Stream چه مشکلاتی از List و Pub/Sub را حل می‌کند:

ویژگی List (Queue) Pub/Sub Stream
Persistenceبلهخیر (fire-and-forget)بله
Consumer Groupsخیرخیربله
Acknowledgmentبا RPOPLPSH دستیخیربله (XPENDING/XACK)
Replay (خواندن قدیمی)خیر (POP شده حذف می‌شود)خیربله
Multiple Consumersبا تقسیم دستیبله (هر یکی می‌گیرد)بله (هر یکی می‌گیرد یا تقسیم)
Orderingبله (به ترتیب ورود)بلهبله (با ID یکتا)
TTL خودکاربا LTRIMخیربله (MAXLEN)
Blocking ReadBLPOPSUBSCRIBEXREAD (BLOCK)

ساخت Stream و افزودن پیام: XADD

XADD یک پیام جدید به Stream اضافه می‌کند. هر پیام شامل چند فیلد-مقدار است (شبیه Hash). هر پیام یک ID خودکار یا دلخواه می‌گیرد:

bash
# XADD key [NOMKSTREAM] [MAXLEN|MINID [=|~] threshold [LIMIT count]] *|ID field value [field value ...]

# افزودن پیام با ID خودکار
127.0.0.1:6379> XADD orders * type "purchase" user_id 1001 amount 99.99
"1709827200000-0"  # ID تولید شده: timestamp-sequencenumber

127.0.0.1:6379> XADD orders * type "refund" user_id 1002 amount 25.50
"1709827200001-0"

127.0.0.1:6379> XADD orders * type "purchase" user_id 1003 amount 150.00
"1709827200002-0"

# افزودن با MAXLEN (حذف پیام‌های قدیمی)
127.0.0.1:6379> XADD orders MAXLEN 1000 * type "purchase" user_id 1004 amount 200
# فقط ۱۰۰۰ پیام اخیر نگه می‌دارد

# MAXLEN با ~ (تقریبی، بهینه‌تر)
127.0.0.1:6379> XADD orders MAXLEN ~ 1000 * type "purchase" user_id 1005 amount 300
# اجازه می‌دهد کمی بیشتر از ۱۰۰۰ نگه دارد ولی بهینه‌تر حذف می‌کند

# افزودن با ID دلخواه
127.0.0.1:6379> XADD orders 1234567890-0 type "manual" user_id 9999 amount 0
"1234567890-0"

# NOMKSTREAM - اگر Stream وجود نداشت، آن را نساز
127.0.0.1:6379> XADD NOMKSTREAM nonexistent * field value
(nil)  # Stream ساخته نشد

خواندن پیام: XREAD و XRANGE

XRANGE پیام‌های یک Stream را در محدوده‌ی ID مشخص برمی‌گرداند. XREAD امکان خواندن از یک یا چند Stream را با cursor-based می‌دهد و می‌تواند blocking باشد:

bash
# XRANGE - تمام پیام‌ها
127.0.0.1:6379> XRANGE orders - +
1) 1) "1709827200000-0"
   2) 1) "type"
      2) "purchase"
      3) "user_id"
      4) "1001"
      5) "amount"
      6) "99.99"
2) 1) "1709827200001-0"
   2) 1) "type"
      2) "refund"
      ...

# XRANGE با محدوده
127.0.0.1:6379> XRANGE orders 1709827200000-0 1709827200001-0

# XRANGE با COUNT
127.0.0.1:6379> XRANGE orders - + COUNT 2

# XREVRANGE - نزولی (از جدید به قدیم)
127.0.0.1:6379> XREVRANGE orders + - COUNT 5

# XLEN - تعداد پیام‌ها
127.0.0.1:6379> XLEN orders
(integer) 5

# XREAD - خواندن از یک یا چند Stream
# شروع از ابتدا
127.0.0.1:6379> XREAD COUNT 10 STREAMS orders 0
1) 1) "orders"
   2) 1) 1) "1709827200000-0"
         2) 1) "type"
            2) "purchase"
            ...

# شروع از ID مشخص (فقط پیام‌های جدیدتر)
127.0.0.1:6379> XREAD COUNT 10 STREAMS orders 1709827200001-0

# BLOCK - blocking read (منتظر پیام جدید)
127.0.0.1:6379> XREAD BLOCK 30000 STREAMS orders $
# $ یعنی از آخرین پیام موجود شروع کن
# اگر پیام جدیدی نیاید، تا ۳۰ ثانیه مسدود می‌شود

# BLOCK بی‌نهایت
127.0.0.1:6379> XREAD BLOCK 0 STREAMS orders $

Consumer Groups — پردازش توزیع‌شده

قوی‌ترین ویژگی Stream، Consumer Groups است. یک Consumer Group گروهی از consumerها است که پیام‌های یک Stream را به‌صورت تقسیم‌شده پردازش می‌کنند — هر پیام فقط توسط یک consumer در گروه پردازش می‌شود. این الگو برای مقیاس‌پذیری افقی workerها ایده‌آل است. علاوه بر این، Group وضعیت پردازش هر پیام را ردیابی می‌کند و اگر consumer‌ای crash کند، می‌توان پیام‌های پردازش‌نشده را به consumer دیگری منتقل کرد:

bash
# XGROUP CREATE key group $|id [MKSTREAM]
# ایجاد consumer group از ابتدا
127.0.0.1:6379> XGROUP CREATE orders email_workers $ MKSTREAM
OK

# از ابتدا (ID = 0)
127.0.0.1:6379> XGROUP CREATE orders email_workers 0
OK

# XREADGROUP - خواندن پیام‌ها به‌عنوان consumer در گروه
# > یعنی "پیام‌های جدیدی که هنوز توسط این گروه خوانده نشده‌اند"
127.0.0.1:6379> XREADGROUP GROUP email_workers worker-1 COUNT 10 STREAMS orders >
1) 1) "orders"
   2) 1) 1) "1709827200000-0"
         2) 1) "type"
            2) "purchase"
            3) "user_id"
            4) "1001"
            ...

# XACK - تأیید پردازش موفق
127.0.0.1:6379> XACK orders email_workers 1709827200000-0
(integer) 1

# XPENDING - مشاهده‌ی پیام‌های در انتظار ack
127.0.0.1:6379> XPENDING orders email_workers
1) (integer) 2  # تعداد پیام‌های pending
2) "1709827200001-0"  # اولین pending ID
3) "1709827200002-0"  # آخرین pending ID
4) 1) 1) "worker-1"
      2) "1"
   2) 1) "worker-2"
      2) "1"

# XPENDING با جزئیات
127.0.0.1:6379> XPENDING orders email_workers - + 10
1) 1) "1709827200001-0"
   2) "worker-1"
   3) (integer) 60000  # ms since delivery
   4) (integer) 1      # تعداد دفعات delivery

# XCLAIM - گرفتن مالکیت یک پیام (برای stale messages)
127.0.0.1:6379> XCLAIM orders email_workers worker-2 60000 1709827200001-0
1) 1) "1709827200001-0"
   2) ...

# XAUTOCLAIM - اتوماتیک claim پیام‌های stale (نسخه ۶.۲ به بعد)
127.0.0.1:6379> XAUTOCLAIM orders email_workers worker-2 60000 0 COUNT 10

# XINFO - اطلاعات group یا stream
127.0.0.1:6379> XINFO STREAM orders
127.0.0.1:6379> XINFO GROUPS orders
127.0.0.1:6379> XINFO CONSUMERS orders email_workers
graph TB P[Producer] -->|XADD| S[(Stream: orders)] S --> G[Consumer Group: email_workers] G --> W1[Worker 1] G --> W2[Worker 2] G --> W3[Worker 3] W1 -->|XACK on success| S W2 -->|crash without ACK| PEL[Pending Entries List] PEL -->|XAUTOCLAIM after timeout| W3 W3 -->|XACK| S style S fill:#313244,stroke:#c084fc,color:#cdd6f4 style G fill:#45475a,stroke:#2dd4bf,color:#cdd6f4 style PEL fill:#45475a,stroke:#f9e2af,color:#cdd6f4
// stream-consumer-group-architecture

الگوی Producer-Consumer کامل

در ادامه، یک پیاده‌سازی کامل Producer-Consumer با Stream را می‌بینید. این پیاده‌سازی شامل consumer group، acknowledgment، و retry برای پیام‌های stale است:

python
import redis
import json
import time
import threading
import signal

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

STREAM_NAME = "orders"
GROUP_NAME = "email_workers"
CONSUMER_NAME = f"worker-{threading.get_ident()}"

def ensure_group():
    """اطمینان از وجود consumer group"""
    try:
        r.xgroup_create(STREAM_NAME, GROUP_NAME, id="0", mkstream=True)
        print(f"Created group '{GROUP_NAME}'")
    except redis.exceptions.ResponseError as e:
        if "BUSYGROUP" in str(e):
            print(f"Group '{GROUP_NAME}' already exists")
        else:
            raise

def producer():
    """ارسال سفارش‌ها به stream"""
    for i in range(100):
        order = {
            'order_id': str(i),
            'user_id': str(1000 + i),
            'amount': f"{100 + i * 0.5:.2f}",
            'email': f'user{i}@example.com',
        }
        # XADD با MAXLEN برای جلوگیری از رشد بی‌نهایت
        message_id = r.xadd(STREAM_NAME, order, maxlen=10000, approximate=True)
        print(f"Produced order {i}: {message_id}")
        time.sleep(0.1)

def consumer():
    """پردازش سفارش‌ها به‌عنوان consumer"""
    print(f"Consumer {CONSUMER_NAME} started")

    while True:
        try:
            # 1. خواندن پیام‌های جدید
            messages = r.xreadgroup(
                groupname=GROUP_NAME,
                consumername=CONSUMER_NAME,
                streams={STREAM_NAME: '>'},
                count=10,
                block=5000  # ۵ ثانیه block
            )

            if not messages:
                # پیام جدیدی نبود، چک کن پیام stale هست
                reclaim_stale_messages()
                continue

            # 2. پردازش هر پیام
            for stream, msg_list in messages:
                for msg_id, fields in msg_list:
                    try:
                        process_order(fields)
                        # 3. ACK پس از پردازش موفق
                        r.xack(STREAM_NAME, GROUP_NAME, msg_id)
                        print(f"{CONSUMER_NAME} processed & acked {msg_id}")
                    except Exception as e:
                        print(f"Error processing {msg_id}: {e}")
                        # ACK نمی‌کنیم تا بعداً retry شود

        except KeyboardInterrupt:
            print(f"Consumer {CONSUMER_NAME} shutting down")
            break
        except Exception as e:
            print(f"Consumer error: {e}")
            time.sleep(1)

def reclaim_stale_messages(timeout_ms=30000):
    """گرفتن پیام‌های stale (پردازش نشده برای timeout)"""
    # یافتن پیام‌های pending که بیشتر از timeout شده‌اند
    result = r.xautoclaim(
        STREAM_NAME, GROUP_NAME, CONSUMER_NAME,
        min_idle_time=timeout_ms,
        start_id="0-0",
        count=10
    )
    if result and result[1]:
        for msg_id, fields in result[1]:
            print(f"Reclaimed stale message: {msg_id}")
            try:
                process_order(fields)
                r.xack(STREAM_NAME, GROUP_NAME, msg_id)
            except Exception as e:
                print(f"Failed to process reclaimed {msg_id}: {e}")

def process_order(order_data):
    """پردازش یک سفارش (ارسال ایمیل تأیید)"""
    print(f"  Sending email to {order_data['email']} for order {order_data['order_id']}")
    # شبیه‌سازی زمان پردازش
    time.sleep(0.5)

# اجرای چند consumer موازی
if __name__ == "__main__":
    ensure_group()

    # شروع producer
    p = threading.Thread(target=producer)
    p.start()

    # شروع ۳ consumer موازی
    consumers = []
    for i in range(3):
        c = threading.Thread(target=consumer)
        c.start()
        consumers.append(c)

    p.join()
    # consumers در حالت واقعی باید graceful shutdown داشته باشند

الگوی Multiple Consumer Groups

یکی از قابلیت‌های جذاب Stream، این است که چندین Consumer Group می‌توانند روی همان Stream به‌صورت مستقل کار کنند. هر group پیام‌ها را از ابتدا می‌بیند و موقعیت خود را مستقل نگه می‌دارد. این الگو برای fan-out عالی است:

python
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# ایجاد چند consumer group برای کاربردهای مختلف
def setup_groups():
    """هر group یک کار متفاوت انجام می‌دهد"""
    groups = [
        'email_workers',     # ارسال ایمیل تأیید
        'inventory_workers', # کاهش موجودی انبار
        'analytics_workers', # ثبت در سیستم analytics
        'notification_workers', # push notification
    ]
    for g in groups:
        try:
            r.xgroup_create('orders', g, id='0', mkstream=True)
        except redis.exceptions.ResponseError as e:
            if "BUSYGROUP" not in str(e):
                raise

# وقتی یک سفارش به stream اضافه می‌شود، هر ۴ group آن را می‌بینند
# و مستقل پردازش می‌کنند
# این یعنی یک سفارش می‌تواند همزمان:
# - ایمیل تأیید ارسال کند
# - موجودی انبار را به‌روزرسانی کند
# - در سیستم analytics ثبت شود
# - push notification بفرستد

مدیریت طول Stream با MAXLEN

اگر بدون کنترل به Stream پیام اضافه کنید، حافظه‌ی سرور پر می‌شود. دو راه برای محدود کردن طول Stream وجود دارد:

bash
# روش ۱: MAXLEN در XADD
127.0.0.1:6379> XADD orders MAXLEN 1000 * field value
# دقیقاً ۱۰۰۰ پیام نگه می‌دارد (کندتر)

127.0.0.1:6379> XADD orders MAXLEN ~ 1000 * field value
# تقریبی (~)، اجازه می‌دهد کمی بیشتر بمانند ولی سریع‌تر (توصیه می‌شود)

# روش ۲: XTRIM جداگانه
127.0.0.1:6379> XTRIM orders MAXLEN 1000
127.0.0.1:6379> XTRIM orders MAXLEN ~ 1000

# روش ۳: MINID - حذف پیام‌های قدیمی‌تر از ID
127.0.0.1:6379> XTRIM orders MINID 1709827200000-0
# تمام پیام‌های قبل از این ID حذف می‌شوند

127.0.0.1:6379> XTRIM orders MINID ~ 1709827200000-0
💡 چرا از ~ استفاده کنیم؟

MAXLEN ~ N به Redis اجازه می‌دهد به‌جای حذف دقیق، با macro nodes کار کند. این یعنی ممکن است چند پیام اضافه‌تر از N باقی بماند (معمولاً ۱۰۰-۱۰۰۰ پیام)، اما عملکرد بسیار بهتر است — زیرا Redis می‌تواند کل radix tree node را به‌جای هر entry جداگانه حذف کند. برای اکثر کاربردها، این مقدار تقریبی کاملاً قابل قبول است.

خواندن به‌صورت Real-time با XREAD BLOCK

XREAD با BLOCK می‌تواند مانند Pub/Sub عمل کند — کلاینت را تا رسیدن پیام جدید مسدود می‌کند. اما برخلاف Pub/Sub، اگر کلاینت قطع شود و دوباره وصل شود، می‌تواند پیام‌های از دست رفته را با ID آخرین خوانده شده بگیرد:

python
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# نگه‌داشتن آخرین ID خوانده شده (در یک فایل یا Redis)
last_id = "0-0"  # شروع از ابتدا

def listen_to_stream():
    """گوش دادن به stream به‌صورت real-time"""
    global last_id
    print(f"Listening from ID: {last_id}")

    while True:
        # BLOCK 0 = بی‌نهایت
        result = r.xread({"orders": last_id}, block=0, count=10)
        if result:
            for stream, messages in result:
                for msg_id, fields in messages:
                    print(f"Received: {msg_id} - {fields}")
                    last_id = msg_id  # به‌روزرسانی آخرین ID
                    # ذخیره last_id برای recovery
                    r.set("stream:orders:last_read", last_id)

# Recovery: در startup، آخرین ID خوانده شده را از Redis بخوان
saved = r.get("stream:orders:last_read")
if saved:
    last_id = saved

الگوی Dead Letter Queue

اگر یک پیام بارها fail شود، بهتر است به یک Stream جداگانه (Dead Letter Queue) منتقل شود تا پردازش اصلی متوقف نشود. در ادامه، یک الگوی ساده برای این کار می‌بینید:

python
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

MAX_RETRIES = 3

def process_with_dlq(stream_name, group_name, consumer_name):
    """پردازش با انتقال پیام‌های fail شده به DLQ"""
    while True:
        # ابتدا پیام‌های stale را بررسی کن
        stale = r.xautoclaim(
            stream_name, group_name, consumer_name,
            min_idle_time=60000,  # ۱ دقیقه
            start_id="0-0",
            count=10
        )

        if stale and stale[1]:
            for msg_id, fields in stale[1]:
                # بررسی تعداد retry
                delivery_count = get_delivery_count(stream_name, group_name, msg_id)
                if delivery_count >= MAX_RETRIES:
                    # انتقال به DLQ
                    r.xadd(f"{stream_name}:dlq", {
                        'original_id': msg_id,
                        **fields,
                        'failure_reason': 'max_retries_exceeded',
                    })
                    r.xack(stream_name, group_name, msg_id)
                    print(f"Moved {msg_id} to DLQ after {delivery_count} retries")
                else:
                    # retry
                    try:
                        process_message(fields)
                        r.xack(stream_name, group_name, msg_id)
                    except Exception as e:
                        print(f"Retry failed for {msg_id}: {e}")

        # سپس پیام‌های جدید
        messages = r.xreadgroup(
            group_name, consumer_name,
            {stream_name: '>'},
            count=10, block=1000
        )
        if messages:
            for stream, msg_list in messages:
                for msg_id, fields in msg_list:
                    try:
                        process_message(fields)
                        r.xack(stream_name, group_name, msg_id)
                    except Exception as e:
                        print(f"Failed {msg_id}: {e}")

def get_delivery_count(stream_name, group_name, msg_id):
    """دریافت تعداد دفعات delivery از XPENDING"""
    pending = r.xpending_range(
        stream_name, group_name, msg_id, msg_id, count=1
    )
    if pending:
        return pending[0]['times_delivered']
    return 0

XINFO — مانیتورینگ Stream

چند دستور برای مانیتورینگ و عیب‌یابی Stream وجود دارند:

bash
# اطلاعات کلی Stream
127.0.0.1:6379> XINFO STREAM orders
1) "length"
2) (integer) 1000
3) "radix-tree-keys"
4) (integer) 12
5) "radix-tree-nodes"
6) (integer) 23
7) "groups"
8) (integer) 2  # تعداد consumer groups
9) "last-generated-id"
10) "1709827200002-0"
11) "max-deleted-entry-id"
12) "0-0"
13) "entries-added"
14) (integer) 1005
15) "recorded-first-entry-id"
16) "1709827200000-0"
17) "entries-read"
18) (integer) 1000
19) "first-entry"
20) 1) "1709827200000-0"
    2) 1) "type"
       2) "purchase"
       ...
21) "last-entry"
22) 1) "1709827200002-0"
    2) ...

# لیست consumer groups
127.0.0.1:6379> XINFO GROUPS orders
1)  1) "name"
    2) "email_workers"
    3) "consumers"
    4) (integer) 3
    5) "pending"
    6) (integer) 5
    7) "last-delivered-id"
    8) "1709827200002-0"
2)  1) "name"
    2) "analytics_workers"
    ...

# لیست consumers در یک group
127.0.0.1:6379> XINFO CONSUMERS orders email_workers
1) 1) "name"
   2) "worker-1"
   3) "pending"
   4) (integer) 2
   5) "idle"
   6) (integer) 1234  # ms since last activity
   7) "inactive"
   8) (integer) 5678
2) 1) "name"
   2) "worker-2"
   ...

# پاک کردن consumer از group
127.0.0.1:6379> XGROUP DELCONSUMER orders email_workers worker-1
(integer) 2  # تعداد pending messages که داشت

# حذف کامل group
127.0.0.1:6379> XGROUP DESTROY orders email_workers
(integer) 1

# تنظیم ID گروه (مثلاً برای reset)
127.0.0.1:6379> XGROUP SETID orders email_workers 0
OK

الگوی Event Sourcing

Stream به‌خوبی برای Event Sourcing مناسب است — جایی که تمام تغییرات state اپلیکیشن به‌صورت رویدادها ذخیره می‌شوند و state فعلی از replay این رویدادها به‌دست می‌آید:

python
import redis
import json

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class EventStore:
    """Event Sourcing با Redis Stream"""
    def append_event(self, aggregate_id, event_type, payload):
        """افزودن یک رویداد به aggregate"""
        stream = f"events:{aggregate_id}"
        event = {
            'event_type': event_type,
            'payload': json.dumps(payload),
            'timestamp': str(int(time.time() * 1000)),
        }
        return r.xadd(stream, event, maxlen=10000, approximate=True)

    def get_events(self, aggregate_id, from_id="0-0"):
        """دریافت تمام رویدادهای یک aggregate"""
        stream = f"events:{aggregate_id}"
        return r.xrange(stream, from_id, "+")

    def replay_to_state(self, aggregate_id):
        """بازسازی state فعلی از replay رویدادها"""
        events = self.get_events(aggregate_id)
        state = {}
        for event_id, fields in events:
            event_type = fields['event_type']
            payload = json.loads(fields['payload'])

            # اعمال هر رویداد
            if event_type == 'created':
                state.update(payload)
            elif event_type == 'updated':
                state.update(payload)
            elif event_type == 'deleted':
                for key in payload.get('deleted_fields', []):
                    state.pop(key, None)

        return state
📝 خلاصه فصل

در این فصل با Redis Streams آشنا شدید: XADD برای افزودن، XREAD/XRANGE برای خواندن، Consumer Groups برای پردازش توزیع‌شده، XPENDING/XACK برای acknowledgment، XCLAIM/XAUTOCLAIM برای retry پیام‌های stale، و الگوهای producer-consumer، DLQ و event sourcing. Streams ابزار اصلی Redis برای message queue و event-driven architecture است و می‌تواند جایگزین Kafka در بسیاری از سناریوها باشد. در فصل بعدی، با Pub/Sub کار می‌کنیم که برای fire-and-forget messaging طراحی شده است.

Chapter 13

Pub/Sub (انتشار/اشتراک) #

Pub/Sub (Publish/Subscribe) یکی از قدیمی‌ترین ویژگی‌های Redis است که برای پیام‌رسانی fire-and-forget طراحی شده است. در این مدل، publisherها پیام‌ها را به کانال‌ها (channels) می‌فرستند و subscriberها به کانال‌هایی که به آن‌ها علاقه دارند گوش می‌دهند. برخلاف Stream، Pub/Sub پیام‌ها را ذخیره نمی‌کند: اگر هیچ subscriberی متصل نباشد، پیام از دست می‌رود. این ویژگی، Pub/Sub را برای کاربردهای real-time مانند چت، اعلان لحظه‌ای، و live updates عالی می‌کند؛ اما برای سیستم‌هایی که نیاز به reliability دارند، مناسب نیست.

مدل Pub/Sub در Redis

مدل Pub/Sub در Redis ساده و مستقیم است. یک publisher با دستور PUBLISH پیامی را به یک کانال می‌فرستد. یک یا چند subscriber با SUBSCRIBE به کانال گوش می‌دهند. Redis پیام را به تمام subscriberهای فعال در آن لحظه می‌رساند. اگر subscriber در آن لحظه قطع باشد، پیام را از دست می‌دهد. علاوه بر subscribe مستقیم به کانال، می‌توان با PSUBSCRIBE به الگوها (patterns) گوش داد:

graph LR P1[Publisher 1] -->|PUBLISH chat:room1 hello| R[(Redis)] P2[Publisher 2] -->|PUBLISH chat:room1 hi| R R --> S1[Subscriber 1
SUBSCRIBE chat:room1] R --> S2[Subscriber 2
SUBSCRIBE chat:room1] R --> S3[Subscriber 3
PSUBSCRIBE chat:*] style R fill:#313244,stroke:#c084fc,color:#cdd6f4
// pubsub-model

دستورات پایه: SUBSCRIBE، PUBLISH

SUBSCRIBE کلاینت را در حالت subscribe قرار می‌دهد. در این حالت، کلاینت فقط می‌تواند دستورات SUBSCRIBE، UNSUBSCRIBE، PSUBSCRIBE و PUNSUBSCRIBE را اجرا کند. PUBLISH یک پیام را به کانال می‌فرستد و تعداد subscriberهایی که پیام را دریافت کردند را برمی‌گرداند:

bash
# در ترمینال ۱: subscribe به یک کانال
127.0.0.1:6379> SUBSCRIBE news:tech
Reading messages... (press Ctrl-C to quit)
1) "subscribe"
2) "news:tech"
3) (integer) 1  # تعداد کانال‌هایی که به آن subscribe شده

# (مسدود می‌شود و منتظر پیام می‌ماند)

# در ترمینال ۲: publish یک پیام
127.0.0.1:6379> PUBLISH news:tech "Redis 7.4 released!"
(integer) 1  # ۱ subscriber پیام را گرفت

# در ترمینال ۱: پیام دریافت می‌شود
1) "message"
2) "news:tech"   # نام کانال
3) "Redis 7.4 released!"  # پیام

# subscribe به چند کانال همزمان
127.0.0.1:6379> SUBSCRIBE news:tech news:sports news:weather

# UNSUBSCRIBE - لغو subscribe
127.0.0.1:6379> UNSUBSCRIBE news:tech
# بدون آرگومان = unsubscribe از تمام کانال‌ها
127.0.0.1:6379> UNSUBSCRIBE

Pattern Subscription: PSUBSCRIBE

PSUBSCRIBE اجازه می‌دهد به جای subscribe به کانال مشخص، به یک الگو (pattern) گوش دهید. کاراکتر * به‌عنوان wildcard عمل می‌کند. این قابلیت برای ساخت سیستم‌های انعطاف‌پذیر مانند routing یا filtering مفید است:

bash
# Subscribe به تمام کانال‌هایی که با news: شروع می‌شوند
127.0.0.1:6379> PSUBSCRIBE news:*
Reading messages... (press Ctrl-C to quit)
1) "psubscribe"
2) "news:*"
3) (integer) 1

# وقتی پیامی به news:tech فرستاده شود
127.0.0.1:6379> PUBLISH news:tech "Tech news"
# در سابسکرایبر:
1) "pmessage"
2) "news:*"      # الگویی که تطبیق داده شد
3) "news:tech"   # کانال واقعی
4) "Tech news"   # پیام

# چند الگو همزمان
127.0.0.1:6379> PSUBSCRIBE news:* chat:* alerts:*

# PUNSUBSCRIBE - لغو pattern subscribe
127.0.0.1:6379> PUNSUBSCRIBE news:*
127.0.0.1:6379> PUNSUBSCRIBE  # لغو تمام الگوها

# الگوهای پیچیده‌تر
# user:*:events - مطابق user:123:events و user:456:events
# log:*:error - مطابق log:app:error و log:db:error
127.0.0.1:6379> PSUBSCRIBE user:*:events log:*:error

اتصال با redis-py

در Python، باید دو connection جداگانه داشته باشید: یکی برای subscribe (که مسدود می‌شود) و یکی برای publish. این به این دلیل است که connection در حالت subscribe نمی‌تواند دستورات دیگر را اجرا کند:

python
import redis
import threading
import json

# دو connection جداگانه
r_pub = redis.Redis(host='localhost', port=6379, decode_responses=True)
r_sub = redis.Redis(host='localhost', port=6379, decode_responses=True)

def subscriber():
    """گوش دادن به کانال"""
    pubsub = r_sub.pubsub()
    pubsub.subscribe('news:tech', 'news:sports')
    pubsub.psubscribe('alerts:*')

    print("Subscriber started, listening...")

    for message in pubsub.listen():
        if message['type'] == 'subscribe':
            print(f"Subscribed to: {message['channel']}")
        elif message['type'] == 'psubscribe':
            print(f"Pattern subscribed: {message['pattern']}")
        elif message['type'] == 'message':
            print(f"[{message['channel']}] {message['data']}")
        elif message['type'] == 'pmessage':
            print(f"[{message['channel']}] (matched {message['pattern']}) {message['data']}")

def publisher():
    """ارسال پیام‌ها"""
    import time
    time.sleep(1)  # صبر برای subscribe

    r_pub.publish('news:tech', 'Redis 7.4 is out!')
    r_pub.publish('news:sports', 'Iran won the match!')
    r_pub.publish('alerts:critical', 'Server CPU at 95%')
    r_pub.publish('alerts:warning', 'Disk space low')

# اجرای subscriber در thread جداگانه
sub_thread = threading.Thread(target=subscriber, daemon=True)
sub_thread.start()

# اجرای publisher در thread اصلی
publisher()

# صبر برای دریافت پیام‌ها
import time
time.sleep(2)

الگوی Chat Room

یکی از کلاسیک‌ترین کاربردهای Pub/Sub، پیاده‌سازی chat room است. هر room یک کانال است و کاربرانی که به room می‌پیوندند، به کانال subscribe می‌کنند. وقتی کسی پیام می‌فرستد، آن را به کانال publish می‌کند:

python
import redis
import json
import threading
import time

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class ChatRoom:
    def __init__(self, room_name, username):
        self.room_name = room_name
        self.username = username
        self.channel = f"chat:room:{room_name}"
        self.pubsub = r.pubsub()
        self.running = False

    def join(self):
        """پیوستن به room"""
        # ثبت حضور کاربر
        r.sadd(f"chat:users:{self.room_name}", self.username)
        # subscribe به کانال room
        self.pubsub.subscribe(self.channel)
        self.running = True
        # شروع thread برای گوش دادن
        self.thread = threading.Thread(target=self._listen, daemon=True)
        self.thread.start()
        # اعلام ورود
        self.send_system_message(f"{self.username} joined the room")

    def leave(self):
        """خروج از room"""
        self.send_system_message(f"{self.username} left the room")
        r.srem(f"chat:users:{self.room_name}", self.username)
        self.running = False
        self.pubsub.unsubscribe(self.channel)
        self.pubsub.close()

    def send_message(self, text):
        """ارسال پیام"""
        message = {
            'type': 'message',
            'user': self.username,
            'text': text,
            'timestamp': int(time.time()),
        }
        r.publish(self.channel, json.dumps(message))

    def send_system_message(self, text):
        """ارسال پیام سیستمی"""
        message = {
            'type': 'system',
            'text': text,
            'timestamp': int(time.time()),
        }
        r.publish(self.channel, json.dumps(message))

    def _listen(self):
        """گوش دادن به پیام‌ها"""
        for message in self.pubsub.listen():
            if not self.running:
                break
            if message['type'] != 'message':
                continue
            try:
                data = json.loads(message['data'])
                if data['type'] == 'message':
                    print(f"[{data['user']}] {data['text']}")
                elif data['type'] == 'system':
                    print(f"--- {data['text']} ---")
            except json.JSONDecodeError:
                continue

    def get_online_users(self):
        """لیست کاربران آنلاین"""
        return r.smembers(f"chat:users:{self.room_name}")

# استفاده
# در یک process:
alice = ChatRoom("general", "Alice")
alice.join()
alice.send_message("Hello everyone!")

# در process دیگر:
bob = ChatRoom("general", "Bob")
bob.join()
bob.send_message("Hi Alice!")

# هنگام خروج
alice.leave()
bob.leave()

الگوی Real-time Notifications

یکی از بهترین کاربردهای Pub/Sub، ارسال اعلان real-time به کاربر است. هر کاربر یک کانال اختصاصی دارد و وقتی رویدادی رخ می‌دهد (مثلاً پیام جدید، لایک، کامنت)، اعلان به کانال کاربر publish می‌شود:

python
import redis
import json
import time

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class NotificationService:
    def send_notification(self, user_id, notification_type, data):
        """ارسال اعلان به یک کاربر"""
        channel = f"notifications:user:{user_id}"
        notification = {
            'type': notification_type,
            'data': data,
            'timestamp': int(time.time()),
        }
        r.publish(channel, json.dumps(notification))
        # ذخیره در Stream برای replay (در صورت نیاز)
        r.xadd(f"notifications:history:{user_id}",
               notification, maxlen=100, approximate=True)

    def send_to_role(self, role, notification_type, data):
        """ارسال اعلان به تمام کاربرانی که نقش خاصی دارند"""
        # دریافت تمام user_id های با این نقش (از Set)
        user_ids = r.smembers(f"role_users:{role}")
        for user_id in user_ids:
            self.send_notification(user_id, notification_type, data)

    def broadcast(self, notification_type, data):
        """اعلان همگانی"""
        channel = "notifications:broadcast"
        r.publish(channel, json.dumps({
            'type': notification_type,
            'data': data,
            'timestamp': int(time.time()),
        }))

class UserNotificationListener:
    def __init__(self, user_id):
        self.user_id = user_id
        self.channel = f"notifications:user:{user_id}"
        self.pubsub = r.pubsub()
        self.running = False

    def start(self):
        """شروع گوش دادن"""
        self.pubsub.subscribe(self.channel, "notifications:broadcast")
        self.running = True
        import threading
        self.thread = threading.Thread(target=self._listen, daemon=True)
        self.thread.start()

    def _listen(self):
        for message in self.pubsub.listen():
            if not self.running:
                break
            if message['type'] != 'message':
                continue
            try:
                notification = json.loads(message['data'])
                self._display(notification)
            except:
                continue

    def _display(self, notification):
        """نمایش اعلان (در عمل: WebSocket، push notification و...)"""
        ntype = notification['type']
        if ntype == 'like':
            print(f"❤️  {notification['data']['user']} liked your post")
        elif ntype == 'comment':
            print(f"💬 {notification['data']['user']} commented: {notification['data']['text']}")
        elif ntype == 'message':
            print(f"✉️  New message from {notification['data']['from']}")

    def stop(self):
        self.running = False
        self.pubsub.unsubscribe()
        self.pubsub.close()

# استفاده
notifier = NotificationService()
listener = UserNotificationListener("user_1001")
listener.start()

# شبیه‌سازی رویدادها
import time
time.sleep(0.5)
notifier.send_notification("user_1001", "like", {'user': 'Alice', 'post_id': 42})
time.sleep(0.5)
notifier.send_notification("user_1001", "comment",
    {'user': 'Bob', 'post_id': 42, 'text': 'Great post!'})
time.sleep(0.5)
listener.stop()

Sharded Pub/Sub (نسخه ۷ به بعد)

در Redis Cluster، Pub/Sub سنتی یک مشکل دارد: پیام‌ها به تمام node‌های cluster broadcast می‌شوند، که این پهنای باند زیادی مصرف می‌کند. در نسخه‌ی ۷، Sharded Pub/Sub معرفی شد که پیام‌ها فقط به node‌ای که صاحب کانال است می‌رود. این قابلیت با SPUBLISH و SSUBSCRIBE کار می‌کند:

bash
# SSUBSCRIBE - شرد شده (نسخه ۷ به بعد)
127.0.0.1:6379> SSUBSCRIBE events:user:1001
# پیام‌ها فقط در node‌ای که کانال به آن شرد شده، منتشر می‌شوند

# SPUBLISH - انتشار شرد شده
127.0.0.1:6379> SPUBLISH events:user:1001 "Hello"
(integer) 1  # تعداد subscriberهایی که در این node دریافت کردند

# تفاوت کلیدی:
# PUBLISH: تمام node‌های cluster پیام را دریافت می‌کنند (پهنای باند زیاد)
# SPUBLISH: فقط node صاحب کانال پیام را پردازش می‌کند (بهینه)
💡 کی از Sharded Pub/Sub استفاده کنیم؟

اگر در محیط Redis Cluster هستید و تعداد زیادی کانال دارید، Sharded Pub/Sub می‌تواند به‌طور قابل توجهی پهنای باند شبکه را کاهش دهد. اما توجه داشته باشید که SSUBSCRIBE و SUBSCRIBE قابل تعویض نیستند — یک subscriber باید بداند کانال شرد شده است یا نه.

PUBSUB — دستورات مانیتورینگ

چند دستور برای مانیتورینگ و عیب‌یابی Pub/Sub وجود دارند:

bash
# PUBSUB CHANNELS - لیست کانال‌های فعال با subscriber
127.0.0.1:6379> PUBSUB CHANNELS
1) "news:tech"
2) "chat:room:general"
3) "notifications:user:1001"

# با الگو
127.0.0.1:6379> PUBSUB CHANNELS news:*
1) "news:tech"
2) "news:sports"

# PUBSUB NUMSUB - تعداد subscriberهای یک کانال
127.0.0.1:6379> PUBSUB NUMSUB news:tech
1) "news:tech"
2) (integer) 3  # ۳ subscriber

# PUBSUB NUMPAT - تعداد کل pattern subscriptions
127.0.0.1:6379> PUBSUB NUMPAT
(integer) 5

# PUBSUB SHARDCHANNELS - کانال‌های شرد شده فعال (نسخه ۷)
127.0.0.1:6379> PUBSUB SHARDCHANNELS events:*

محدودیت‌های Pub/Sub

با وجود سادگی و کاربرد فراوان، Pub/Sub محدودیت‌های مهمی دارد که باید در طراحی مد نظر قرار دهید:

۱. بدون persistence: پیام‌ها ذخیره نمی‌شوند. اگر subscriber در لحظه‌ی publish قطع باشد، پیام را برای همیشه از دست می‌دهد. برای کاربردهایی که نیاز به reliability دارند، از Stream استفاده کنید.

۲. بدون acknowledgment: publisher نمی‌داند آیا subscriber پیام را پردازش کرده یا نه. فقط تعداد subscriberهایی که پیام را دریافت کردند را می‌داند.

۳. بدون replay: نمی‌توان پیام‌های گذشته را دوباره خواند. اگر کلاینت دیر وصل شود، پیام‌های قبلی را از دست داده است.

۴. محدودیت throughput: در Redis Cluster، Pub/Sub سنتی پیام‌ها را به تمام node‌ها broadcast می‌کند که می‌تواند پهنای باند شبکه را اشغال کند. برای حجم بالا، از Sharded Pub/Sub یا Stream استفاده کنید.

۵. consumer نمی‌تواند پردازش کند: در حالت subscribe، کلاینت فقط می‌تواند دستورات subscribe/unsubscribe اجرا کند. برای انجام کار دیگر، باید connection دیگری باز کند.

مقایسه‌ی Pub/Sub با Stream

ویژگی Pub/Sub Stream
Persistenceخیربله
Acknowledgmentخیربله
Replayخیربله
Consumer Groupsخیر (هر subscriber همه را می‌گیرد)بله
Latencyبسیار پایینپایین
Throughputبالا (ولی broadcast در cluster)بالا
Pattern matchingبله (PSUBSCRIBE)خیر
مناسب برایChat، real-time notifications، fan-outJob queue، event log، reliable messaging

الگوی WebSocket + Pub/Sub

یکی از رایج‌ترین کاربردهای Pub/Sub، ترکیب آن با WebSocket برای ارسال real-time updates به مرورگر کاربر است. در ادامه، یک نمونه‌ی ساده با Python و Flask-SocketIO می‌بینید (کد خلاصه شده):

python
# server.py
from flask import Flask
from flask_socketio import SocketIO
import redis
import threading
import json

app = Flask(__name__)
socketio = SocketIO(app, cors_allowed_origins="*")

# Redis connection
r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# نگاشت user_id به WebSocket sid
user_sessions = {}

@socketio.on('connect')
def handle_connect(auth):
    print(f"Client connected: {auth}")

@socketio.on('register')
def handle_register(data):
    """ثبت user_id هنگام اتصال WebSocket"""
    user_id = data['user_id']
    user_sessions[user_id] = request.sid
    # subscribe به کانال اعلان‌های کاربر
    start_listener(user_id)

def start_listener(user_id):
    """گوش دادن به Redis Pub/Sub برای user و forward به WebSocket"""
    def listen():
        pubsub = r.pubsub()
        channel = f"notifications:user:{user_id}"
        pubsub.subscribe(channel)

        for message in pubsub.listen():
            if message['type'] == 'message':
                # forward به WebSocket client
                socketio.emit('notification', message['data'], room=user_sessions[user_id])

    thread = threading.Thread(target=listen, daemon=True)
    thread.start()

# هر جا در اپلیکیشن، می‌توان اعلان فرستاد:
def notify_user(user_id, message):
    r.publish(f"notifications:user:{user_id}", message)

if __name__ == '__main__':
    socketio.run(app, port=5000)

# client.js (مرورگر)
# const socket = io('http://localhost:5000');
# socket.on('connect', () => {
#     socket.emit('register', { user_id: 'user_1001' });
# });
# socket.on('notification', (data) => {
#     console.log('Got notification:', data);
#     // نمایش به کاربر
# });
📝 خلاصه فصل

در این فصل با Pub/Sub آشنا شدید: دستورات SUBSCRIBE/PUBLISH/PSUBSCRIBE، الگوهای chat room، real-time notifications، و WebSocket integration. Pub/Sub برای پیام‌رسانی fire-and-forget real-time عالی است؛ اما بدون persistence و ack است. برای سیستم‌های reliable، از Stream (فصل ۱۲) استفاده کنید. در فصل بعدی، با تراکنش‌ها و Pipeline کار می‌کنیم که برای بهینه‌سازی عملکرد بسیار مهم هستند.

Chapter 14

تراکنش‌ها و Pipeline #

در این فصل، دو ویژگی مهم Redis را بررسی می‌کنیم: Transactions (تراکنش‌ها) برای اجرای اتمیک چند دستور، و Pipeline برای بهینه‌سازی عملکرد با کاهش round-trip time. این دو مفهوم در ظاهر شبیه به هم هستند — هر دو اجازه می‌دهند چند دستور را در یک درخواست بفرستید — اما از نظر معنایی و رفتار کاملاً متفاوت‌اند. درک تفاوت آن‌ها برای طراحی سیستم‌های کارآمد ضروری است. همچنین با OPTIMISTIC LOCKING با WATCH آشنا می‌شویم که یکی از قدرتمندترین الگوهای Redis است.

پایپ‌لاین (Pipeline) — چیست و چه فرقی با Transaction دارد؟

Pipeline یک ویژگی سمت کلاینت است: شما چند دستور را در یک packet شبکه می‌فرستید و Redis هم چند پاسخ را در یک packet برمی‌گرداند. این کار تعداد round-trip‌ها را کاهش می‌دهد و می‌تواند عملکرد را ۵ تا ۵۰ برابر افزایش دهد. اما Pipeline اتمیک نیست: دستورات در میان دستورات دیگر کلاینت‌ها اجرا می‌شوند و ممکن است توسط کلاینت دیگری قطع شوند. در مقابل، Transaction تضمین می‌کند که دستورات به‌صورت پیوسته و بدون قطع اجرا شوند.

sequenceDiagram participant C as Client participant R as Redis Note over C,R: بدون Pipeline (5 round-trips) C->>R: SET k1 v1 R-->>C: OK C->>R: SET k2 v2 R-->>C: OK C->>R: SET k3 v3 R-->>C: OK Note over C,R: با Pipeline (1 round-trip) C->>R: SET k1 v1\nSET k2 v2\nSET k3 v3 R-->>C: OK\nOK\nOK
// pipeline-vs-without

استفاده از Pipeline در redis-py

در کتابخانه‌ی redis-py، Pipeline با متد pipeline() در دسترس است. دستورات به buffer اضافه می‌شوند و با execute() به‌صورت یکجا به سرور فرستاده می‌شوند:

python
import redis
import time

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# روش ۱: بدون Pipeline (کند)
def without_pipeline(n=10000):
    start = time.time()
    for i in range(n):
        r.set(f"key:{i}", f"value:{i}")
    return time.time() - start

# روش ۲: با Pipeline (سریع‌تر)
def with_pipeline(n=10000, batch_size=1000):
    start = time.time()
    pipe = r.pipeline()
    for i in range(n):
        pipe.set(f"key:{i}", f"value:{i}")
        # اجرای batch هر batch_size دستور
        if (i + 1) % batch_size == 0:
            pipe.execute()
            pipe = r.pipeline()
    # اجرای باقیمانده
    if pipe:
        pipe.execute()
    return time.time() - start

# مقایسه
t1 = without_pipeline(10000)
t2 = with_pipeline(10000)
print(f"Without pipeline: {t1:.2f}s")
print(f"With pipeline: {t2:.2f}s")
print(f"Speedup: {t1/t2:.1f}x")
# خروجی معمول:
# Without pipeline: 12.50s
# With pipeline: 0.85s
# Speedup: 14.7x
💡 نکته مهم درباره‌ی اندازه‌ی batch

در Pipeline، اندازه‌ی batch مهم است. اگر batch خیلی کوچک باشد (مثلاً ۱۰)، بهینه‌سازی کم است. اگر خیلی بزرگ باشد (مثلاً ۱۰۰٬۰۰۰)، حافظه‌ی کلاینت و سرور تحت فشار قرار می‌گیرد و در صورت خطا، باید همه از نو ارسال شوند. معمولاً batch با ۵۰۰ تا ۵۰۰۰ دستور بهترین تعادل را ارائه می‌دهد.

دستورات Transaction: MULTI، EXEC، DISCARD

Redis تراکنش‌ها را با سه دستور پیاده می‌کند:

MULTI: شروع تراکنش — دستورات بعدی به‌جای اجرای فوری، در صف قرار می‌گیرند.

EXEC: اجرای تمام دستورات صف به‌صورت اتمیک — بین اجرای این دستورات، هیچ دستور دیگری از کلاینت دیگر اجرا نمی‌شود.

DISCARD: لغو تراکنش — صف پاک می‌شود و خارج از حالت transaction می‌رویم.

bash
# شروع تراکنش
127.0.0.1:6379> MULTI
OK

# دستورات در صف قرار می‌گیرند (QUEUED برمی‌گردد)
127.0.0.1:6379> SET user:1:name "Alice"
QUEUED
127.0.0.1:6379> SET user:1:age 30
QUEUED
127.0.0.1:6379> INCR counter
QUEUED

# اجرای تراکنش (تمام دستورات اتمیک اجرا می‌شوند)
127.0.0.1:6379> EXEC
1) OK
2) OK
3) (integer) 1  # نتیجه INCR

# لغو تراکنش
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET key1 "value1"
QUEUED
127.0.0.1:6379> DISCARD
OK

127.0.0.1:6379> GET key1
(nil)  # اجرا نشد

خطا در Transaction

خطاها در تراکنش‌های Redis به دو دسته تقسیم می‌شوند و رفتار متفاوتی دارند:

۱. خطای syntax در زمان QUEUE: اگر دستور از نظر syntax اشتباه باشد یا تعداد آرگومان‌ها غلط باشد، Redis قبل از EXEC خطا می‌دهد و کل تراکنش لغو می‌شود:

bash
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET key value
QUEUED
127.0.0.1:6379> WRONGCOMMAND arg
(error) ERR unknown command 'WRONGCOMMAND'
127.0.0.1:6379> SET
(error) ERR wrong number of arguments for 'set' command
127.0.0.1:6379> EXEC
(error) EXECABORT Transaction discarded because of previous errors.
# کل تراکنش لغو شد

۲. خطای runtime در زمان EXEC: اگر دستور از نظر syntax درست باشد اما در زمان اجرا خطا بدهد (مثلاً INCR روی یک String غیر عددی)، بقیه دستورات همچنان اجرا می‌شوند — برخلاف پایگاه‌های داده‌ی رابطه‌ای که کل تراکنش rollback می‌شود:

bash
127.0.0.1:6379> SET mykey "not_a_number"
OK
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> INCR mykey
QUEUED
127.0.0.1:6379> SET other_key "value"
QUEUED
127.0.0.1:6379> EXEC
1) (error) ERR value is not an integer or out of range  # INCR خطا داد
2) OK  # ولی SET همچنان اجرا شد!
127.0.0.1:6379> GET other_key
"value"  # وجود دارد
⚠️ Redis rollback ندارد!

برخلاف PostgreSQL یا MySQL، Redis rollback پشتیبانی نمی‌کند. دلیل این طراحی (طبق گفته‌ی سازنده‌ی Redis) این است که خطاهای runtime معمولاً ناشی از باگ در برنامه هستند و باید در توسعه کشف شوند، نه در تولید. اگر rollback می‌خواهید، باید خودتان با Lua Script یا الگوهای دیگری آن را پیاده کنید.

OPTIMISTIC LOCKING با WATCH

یکی از قدرتمندترین الگوهای Redis، WATCH است. این دستور optimistic concurrency control را پیاده می‌کند: شما یک یا چند کلید را watch می‌کنید، سپس MULTI می‌کنید و دستورات را می‌فرستید. اگر بین WATCH و EXEC، کلیدهای watch شده توسط کلاینت دیگری تغییر کرده باشند، EXEC اجرا نمی‌شود و nil برمی‌گرداند. این الگو برای جلوگیری از lost update در محیط همزمان عالی است:

sequenceDiagram participant A as Client A participant R as Redis participant B as Client B A->>R: WATCH counter A->>R: GET counter (returns 10) Note over A: Local: 10 + 1 = 11 B->>R: SET counter 20 Note over R: counter changed! A->>R: MULTI A->>R: SET counter 11 (QUEUED) A->>R: EXEC R-->>A: (nil) - transaction aborted Note over A: باید retry کنی!
// optimistic-locking-with-watch
bash
# ترمینال ۱
127.0.0.1:6379> SET counter 10
OK
127.0.0.1:6379> WATCH counter
OK
127.0.0.1:6379> GET counter
"10"
# اکنون MULTI و SET می‌خواهیم
127.0.0.1:6379> MULTI
OK
127.0.0.1:6379> SET counter 11
QUEUED

# در ترمینال ۲: تغییر counter
127.0.0.1:6379> SET counter 99
OK

# در ترمینال ۱: EXEC
127.0.0.1:6379> EXEC
(nil)  # تراکنش لغو شد چون counter تغییر کرد!

127.0.0.1:6379> GET counter
"99"  # مقدار ترمینال ۲ باقی مانده

# UNWATCH - لغو watch (اگر EXEC یا DISCARD اجرا شود، خودکار است)
127.0.0.1:6379> UNWATCH
OK

الگوی Atomic Counter Increment با WATCH

یکی از کلاسیک‌ترین الگوها، خواندن یک مقدار، محاسبه روی آن، و نوشتن نتیجه به‌صورت اتمیک است. این کار با WATCH و MULTI می‌تواند به‌صورت optimistic انجام شود:

python
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

def atomic_increment(key, max_value):
    """افزایش اتمیک تا max_value، با WATCH برای optimistic locking"""
    with r.pipeline() as pipe:
        while True:
            try:
                # 1. WATCH کلید
                pipe.watch(key)
                # 2. خواندن مقدار فعلی
                current_value = int(pipe.get(key) or 0)
                # 3. بررسی شرط
                if current_value >= max_value:
                    pipe.unwatch()
                    return False  # قابل افزایش نیست
                # 4. شروع تراکنش
                pipe.multi()
                pipe.set(key, current_value + 1)
                # 5. EXEC - اگر کلید تغییر کرده باشد، WatchError پرتاب می‌شود
                pipe.execute()
                return True
            except redis.WatchError:
                # کلید توسط کلاینت دیگری تغییر کرد، retry
                continue

# استفاده
r.set("inventory:product_42", 5)
for _ in range(10):
    success = atomic_increment("inventory:product_42", max_value=8)
    print(f"Increment: {'✓' if success else '✗'}")

# نتیجه:
# Increment: ✓ (5 -> 6)
# Increment: ✓ (6 -> 7)
# Increment: ✓ (7 -> 8)
# Increment: ✗ (در حد Max)
# ...

این الگو به Compare-And-Swap (CAS) معروف است و پایه‌ی بسیاری از الگوریتم‌های lock-free است. در فصل ۱۷ (Lua Scripting) می‌بینیم که Lua Script می‌تواند همین کار را به‌صورت اتمیک‌تر و کارآمدتر انجام دهد — زیرا کل اسکریپت به‌عنوان یک عمل اتمیک اجرا می‌شود.

Transaction در redis-py

کتابخانه‌ی redis-py به‌طور خودکار MULTI/EXEC را با pipeline(transaction=True) (پیش‌فرض) پیاده می‌کند. این یعنی وقتی pipe.multi() صدا می‌زنید یا transaction=True تنظیم می‌کنید، کتابخانه به‌طور خودکار MULTI را می‌فرستد، دستورات را در صف می‌گذارد، و سپس EXEC را می‌فرستد:

python
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# روش ۱: pipeline با transaction (پیش‌فرض)
pipe = r.pipeline()  # transaction=True پیش‌فرض است
pipe.set("key1", "value1")
pipe.set("key2", "value2")
pipe.incr("counter")
results = pipe.execute()
print(results)  # [True, True, 1]

# روش ۲: pipeline بدون transaction (فقط batching)
pipe = r.pipeline(transaction=False)
pipe.set("key1", "value1")
pipe.set("key2", "value2")
results = pipe.execute()

# روش ۳: با WATCH
def transfer_funds(from_key, to_key, amount):
    """انتقال مبلغ از یک حساب به دیگر (atomic)"""
    with r.pipeline() as pipe:
        while True:
            try:
                pipe.watch(from_key, to_key)
                balance_from = int(pipe.get(from_key) or 0)
                balance_to = int(pipe.get(to_key) or 0)

                if balance_from < amount:
                    pipe.unwatch()
                    return False, "Insufficient funds"

                pipe.multi()
                pipe.set(from_key, balance_from - amount)
                pipe.set(to_key, balance_to + amount)
                pipe.execute()
                return True, "Transfer successful"

            except redis.WatchError:
                continue  # retry

# استفاده
r.set("account:alice", 1000)
r.set("account:bob", 500)
success, msg = transfer_funds("account:alice", "account:bob", 200)
print(msg)  # Transfer successful

تفاوت Pipeline و Transaction — جمع‌بندی

ویژگی Pipeline (transaction=False) Transaction (MULTI/EXEC)
اتمیک بودنخیربله
کاهش round-tripبلهبله
interruptibleبله (توسط کلاینت دیگر)خیر
WATCH پشتیبانیخیربله
سربارکمکمی بیشتر (queue management)
مناسب برایbulk operations (SET چندین کلید)operations با correctness constraint

چه زمان از چه چیزی استفاده کنیم؟

این سوال مهمی است. در اینجا چند راهنمای عملی ارائه می‌دهم:

از Pipeline (بدون transaction) استفاده کنید وقتی:

• می‌خواهید چندین SET/GET مستقل انجام دهید (مثلاً پر کردن cache با ۱۰۰۰ کلید).

• ترتیب اجرای دستورات مهم نیست (هر کدام مستقل هستند).

• حداکثر throughput را می‌خواهید (Pipeline کمی سریع‌تر از Transaction است).

از Transaction (MULTI/EXEC) استفاده کنید وقتی:

• چند دستور باید اتمیک اجرا شوند (مثلاً انتقال وجه: کم از A، اضافه به B).

• می‌خواهید بین دستورات، کلاینت دیگری تغییری ایجاد نکند.

• با WATCH برای optimistic locking کار می‌کنید.

از Lua Script استفاده کنید وقتی:

• منطق پیچیده‌ای دارید که نیاز به if/else دارد.

• چندین کلید را می‌خوانید و بر اساس مقادیر، تصمیم می‌گیرید.

• می‌خواهید یک عملیات پیچیده را به‌صورت اتمیک انجام دهید (در فصل ۱۷).

الگوی Bulk Import با Pipeline

یکی از بهترین کاربردهای Pipeline، import داده‌های زیاد است. در ادامه، یک اسکریپت برای import ۱۰۰٬۰۰۰ کاربر از CSV به Redis می‌بینید:

python
import redis
import csv
import time

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

def import_users_from_csv(csv_path, batch_size=1000):
    """Import کاربران از CSV با Pipeline"""
    start = time.time()
    count = 0
    pipe = r.pipeline()

    with open(csv_path, 'r') as f:
        reader = csv.DictReader(f)
        for row in reader:
            user_id = row['id']
            # ذخیره به‌صورت Hash
            pipe.hset(f"user:{user_id}", mapping={
                'name': row['name'],
                'email': row['email'],
                'age': row['age'],
            })
            # ایندکس برای جستجوی email
            pipe.set(f"email_index:{row['email']}", user_id)
            count += 1

            if count % batch_size == 0:
                pipe.execute()
                pipe = r.pipeline()
                print(f"Imported {count} users...")

    # اجرای باقیمانده
    if pipe:
        pipe.execute()

    elapsed = time.time() - start
    print(f"\nTotal: {count} users in {elapsed:.2f}s ({count/elapsed:.0f} users/sec)")

# تولید داده‌ی تست
def generate_test_csv(path, n=100000):
    import random
    import string
    with open(path, 'w') as f:
        f.write("id,name,email,age\n")
        for i in range(n):
            name = ''.join(random.choices(string.ascii_letters, k=8))
            email = f"{name}@example.com"
            age = random.randint(18, 80)
            f.write(f"{i},{name},{email},{age}\n")

generate_test_csv('/tmp/users.csv', n=100000)
import_users_from_csv('/tmp/users.csv')

Atomic Swap با MULTI/EXEC

یکی از کاربردهای کلاسیک Transaction، جابه‌جایی اتمیک مقادیر دو کلید است. در اینجا یک پیاده‌سازی ساده می‌بینید:

python
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

def atomic_swap(key_a, key_b):
    """جابه‌جایی اتمیک مقادیر دو کلید با WATCH"""
    with r.pipeline() as pipe:
        while True:
            try:
                pipe.watch(key_a, key_b)
                val_a = pipe.get(key_a)
                val_b = pipe.get(key_b)
                pipe.multi()
                if val_a is not None:
                    pipe.set(key_b, val_a)
                else:
                    pipe.delete(key_b)
                if val_b is not None:
                    pipe.set(key_a, val_b)
                else:
                    pipe.delete(key_a)
                pipe.execute()
                return True
            except redis.WatchError:
                continue

# روش ساده‌تر با Lua Script (در فصل ۱۷)
def atomic_swap_lua(key_a, key_b):
    """جابه‌جایی اتمیک با Lua Script"""
    script = """
    local a = redis.call('GET', KEYS[1])
    local b = redis.call('GET', KEYS[2])
    if a then redis.call('SET', KEYS[2], a) else redis.call('DEL', KEYS[2]) end
    if b then redis.call('SET', KEYS[1], b) else redis.call('DEL', KEYS[1]) end
    return 1
    """
    return r.eval(script, 2, key_a, key_b)

# استفاده
r.set("a", "value_a")
r.set("b", "value_b")
atomic_swap("a", "b")
print(r.get("a"))  # value_b
print(r.get("b"))  # value_a

محدودیت‌های Transaction

تراکنش‌های Redis چند محدودیت مهم دارند که باید بدانید:

۱. بدون ACID کامل: Redis تراکنش‌های atomic و consistent ارائه می‌دهد، اما isolated به معنای کامل نیست (چون no isolation levels) و durable هم به پیکربندی persistence بستگی دارد.

۲. بدون ROLLBACK: اگر یک دستور در زمان EXEC خطا بدهد، بقیه اجرا می‌شوند. این رفتار با پایگاه‌های داده‌ی رابطه‌ای متفاوت است.

۳. فقط روی یک node: در Redis Cluster، یک تراکنش فقط می‌تواند روی کلیدهایی کار کند که در همان node هستند (همان hash slot). اگر کلیدها در node‌های مختلف باشند، خطا می‌گیرید. برای این موارد، از hash tags استفاده کنید (مثلاً {user:1}:profile و {user:1}:settings در همان slot هستند).

۴. بدون nested transactions: نمی‌توانید درون یک MULTI، یک MULTI دیگر اجرا کنید.

📝 خلاصه فصل

در این فصل با Pipeline و Transaction آشنا شدید: Pipeline برای کاهش round-trip، Transaction برای اجرای اتمیک، WATCH برای optimistic locking، و الگوهای CAS، atomic swap و bulk import. در بسیاری موارد، Lua Script (فصل ۱۷) می‌تواند جایگزین بهتری برای Transaction باشد — زیرا کل اسکریپت به‌صورت اتمیک اجرا می‌شود و می‌تواند منطق پیچیده داشته باشد. در فصل بعدی، با مدیریت کلیدها، TTL و eviction policies کار می‌کنیم.

Chapter 15

کلیدها، TTL و Eviction #

در این فصل، با یکی از مهم‌ترین جنبه‌های مدیریت حافظه در Redis آشنا می‌شویم: کلیدها و چرخه‌ی حیات آن‌ها. Redis یک پایگاه داده‌ی in-memory است؛ بنابراین مدیریت هوشمند حافظه حیاتی است. اگر بدون کنترل به Redis داده اضافه کنید، سرور شما از حافظه خارج می‌شود و OOM kill می‌شود. در این فصل، TTL، persistence، eviction policies و best practices برای مدیریت حافظه را بررسی می‌کنیم.

TTL — Time To Live

TTL مکانیزمی است که به هر کلید یک «زمان انقضا» می‌دهد. وقتی TTL یک کلید به صفر برسد، Redis آن کلید را به‌صورت خودکار حذف می‌کند. این قابلیت برای داده‌های موقت مانند session، cache و rate limit counters ضروری است. در فصل ۴ با دستورات اولیه TTL آشنا شدیم؛ در اینجا به‌طور عمیق‌تر بررسی می‌کنیم:

bash
# EXPIRE - تنظیم TTL به ثانیه
127.0.0.1:6379> SET session:abc "user_data"
OK
127.0.0.1:6379> EXPIRE session:abc 3600
(integer) 1  # TTL تنظیم شد

# PEXPIRE - تنظیم TTL به میلی‌ثانیه
127.0.0.1:6379> PEXPIRE session:abc 60000
(integer) 1

# EXPIREAT - انقضا در زمان Unix مشخص (ثانیه)
127.0.0.1:6379> EXPIREAT session:abc 1709827200
(integer) 1

# PEXPIREAT - انقضا در زمان Unix به میلی‌ثانیه
127.0.0.1:6379> PEXPIREAT session:abc 1709827200000
(integer) 1

# TTL - زمان باقیمانده به ثانیه
127.0.0.1:6379> TTL session:abc
(integer) 3500

# PTTL - زمان باقیمانده به میلی‌ثانیه
127.0.0.1:6379> PTTL session:abc
(integer) 3500000

# مقادیر خاص TTL:
# -1: کلید وجود دارد ولی TTL ندارد (دائمی)
# -2: کلید وجود ندارد

127.0.0.1:6379> SET permanent "data"
OK
127.0.0.1:6379> TTL permanent
(integer) -1

127.0.0.1:6379> TTL nonexistent
(integer) -2

# PERSIST - حذف TTL (تبدیل به دائمی)
127.0.0.1:6379> PERSIST session:abc
(integer) 1
127.0.0.1:6379> TTL session:abc
(integer) -1

SET با TTL داخلی

برای String، می‌توان TTL را به‌صورت همزمان با SET تنظیم کرد که از نظر atomic بودن بهتر از دو دستور جداگانه است:

bash
# SET با EX (ثانیه)
127.0.0.1:6379> SET key "value" EX 60

# SET با PX (میلی‌ثانیه)
127.0.0.1:6379> SET key "value" PX 60000

# SET با EXAT (Unix timestamp ثانیه)
127.0.0.1:6379> SET key "value" EXAT 1709827200

# SET با PXAT (Unix timestamp میلی‌ثانیه)
127.0.0.1:6379> SET key "value" PXAT 1709827200000

# SET با KEEPTTL - اگر کلید از قبل TTL داشته باشد، حفظ شود
127.0.0.1:6379> SET key "new_value" KEEPTTL
OK

Active Expiry Cycle — چگونه TTL کار می‌کند؟

Redis کلیدهای منقضی شده را به دو روش پاک می‌کند:

۱. Passive (Lazy Expiration): وقتی کلاینت به یک کلید دسترسی پیدا می‌کند، Redis ابتدا TTL آن را چک می‌کند. اگر منقضی شده باشد، کلید را حذف می‌کند و nil برمی‌گرداند.

۲. Active (Active Expiration Cycle): Redis به‌صورت دوره‌ای (هر ۱۰۰ میلی‌ثانیه) sample می‌گیرد از کلیدهایی که TTL دارند و چک می‌کند که آیا منقضی شده‌اند. اگر بیش از ۲۵٪ نمونه‌ها منقضی شده باشند، دوباره sample می‌گیرد. این کار باعث می‌شود کلیدهای منقضی شده که هرگز خوانده نمی‌شوند هم حذف شوند.

graph TB A[Set TTL on key] --> B[Key in keyspace] B --> C{Access?} C -- Yes --> D[Lazy check TTL] D --> E[TTL expired?] E -- Yes --> F[Delete & return nil] E -- No --> G[Return value] C -- No --> H[Active cycle every 100ms] H --> I[Sample keys with TTL] I --> E style H fill:#313244,stroke:#c084fc,color:#cdd6f4 style F fill:#45475a,stroke:#f38ba8,color:#cdd6f4
// lazy-vs-active-expiration

تنظیمات active expiry cycle در redis.conf با hz (فرکانس، پیش‌فرض ۱۰ یعنی ۱۰ بار در ثانیه) و active-expire-effort (۱ تا ۱۰، پیش‌فرض ۱) کنترل می‌شود. افزایش این مقادیر باعث می‌شود Redis بیشتر تلاش کند کلیدهای منقضی شده را پاک کند، اما CPU بیشتری مصرف می‌کند.

دستورات Bulk روی TTL

متأسفانه، Redis دستوری برای تنظیم TTL روی چندین کلید به‌صورت همزمان ندارد. برای این کار، باید از Pipeline استفاده کنید:

python
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

def set_ttl_bulk(keys, ttl_seconds):
    """تنظیم TTL روی چندین کلید با Pipeline"""
    pipe = r.pipeline()
    for key in keys:
        pipe.expire(key, ttl_seconds)
    pipe.execute()

def set_ttl_by_pattern(pattern, ttl_seconds, batch_size=1000):
    """تنظیم TTL روی تمام کلیدهای مطابق الگو"""
    cursor = 0
    while True:
        cursor, keys = r.scan(cursor, match=pattern, count=batch_size)
        if keys:
            set_ttl_bulk(keys, ttl_seconds)
        if cursor == 0:
            break

# استفاده: تنظیم TTL یک ساعت روی تمام session keys
set_ttl_by_pattern("session:*", 3600)

Eviction Policies — وقتی حافظه پر می‌شود

وقتی Redis به maxmemory می‌رسد، باید تصمیم بگیرد چه کلیدی را حذف کند تا جای کلید جدید باز شود. این تصمیم توسط eviction policy گرفته می‌شود. در redis.conf با maxmemory-policy تنظیم می‌شود:

Policy توضیح مناسب برای
noevictionهیچ کلیدی حذف نمی‌کند؛ خطا برمی‌گرداندپایگاه داده‌ی اصلی (نه cache)
allkeys-lruکمترین اخیراً استفاده‌شده (LRU) از تمام کلیدهاcache خالص
allkeys-lfuکمترین فرکانس استفاده (LFU) از تمام کلیدهاcache با الگوهای دسترسی نابرابر
allkeys-randomتصادفی از تمام کلیدهاcache با دسترسی یکنواخت
volatile-lruLRU فقط از کلیدهای دارای TTLcache + داده‌ی دائمی
volatile-lfuLFU فقط از کلیدهای دارای TTLcache + داده‌ی دائمی
volatile-randomتصادفی از کلیدهای دارای TTLcache + داده‌ی دائمی
volatile-ttlکلید با نزدیک‌ترین TTL به انقضاcache با اولویت TTL
💡 انتخاب Policy

برای اکثر کاربردهای cache، allkeys-lru بهترین انتخاب است. اگر داده‌های دائمی و cache در همان دیتابیس دارید، از volatile-lru استفاده کنید (با تنظیم TTL روی کلیدهای cache). allkeys-lfu (در نسخه ۴ به بعد) برای کاربردهایی که برخی داده‌ها بسیار پربازدیدتر از بقیه هستند، بهتر است.

LRU vs LFU — تفاوت چیست؟

LRU (Least Recently Used) فرض می‌کند کلیدی که اخیراً دسترسی نشده، احتمالاً در آینده هم دسترسی نخواهد شد. این الگوریتم ساده است اما یک ضعف دارد: یک کلید که قبلاً خیلی پربازدید بوده ولی اخیراً چند ثانیه دسترسی نشده، ممکن است حذف شود.

LFU (Least Frequently Used) به‌جای زمان، تعداد دفعات دسترسی را در نظر می‌گیرد. کلیدی که کمتر از همه دسترسی شده، حذف می‌شود. این الگوریتم برای داده‌های با الگوی Pareto (۸۰/۲۰) بهتر است — جایی که ۲۰٪ داده‌ها ۸۰٪ دسترسی‌ها را دارند.

Redis پیاده‌سازی تقریبی LRU/LFU دارد (با sampling) که بسیار کارآمدتر از LRU دقیق است. تنظیم maxmemory-samples (پیش‌فرض ۵) تعداد نمونه‌هایی که در هر بار eviction بررسی می‌شوند را کنترل می‌کند. افزایش این مقدار (مثلاً ۱۰) دقت را بهبود می‌دهد ولی کمی کندتر است.

ini
# redis.conf
maxmemory 1gb
maxmemory-policy allkeys-lru
maxmemory-samples 5

# برای LFU
# maxmemory-policy allkeys-lfu
# lfule-decay-time 1      # هر ۱ دقیقه، فرکانس کاهش می‌یابد
# lfu-log-factor 10       # ضریب رشد فرکانس

مشاهده‌ی آمار Eviction

می‌توانید با دستور INFO ببینید چند کلید evict شده و چند کلید منقضی شده:

bash
127.0.0.1:6379> INFO stats
# Stats
...
expired_keys:1245          # تعداد کلیدهای منقضی شده
evicted_keys:50            # تعداد کلیدهای evict شده به دلیل maxmemory
keyspace_hits:15000        # تعداد GET موفق
keyspace_misses:500        # تعداد GET ناموفق (nil)
latest_fork_usec:250       # زمان آخرین fork (میکروثانیه)
...

اگر evicted_keys به‌سرعت در حال افزایش است، یعنی maxmemory شما کم است یا policy شما مناسب نیست. keyspace_hits / (keyspace_hits + keyspace_misses) هم hit rate شماست که برای cache باید بالای ۹۰٪ باشد.

الگوی Cache with TTL

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

python
import redis
import json
import time

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class TTLCache:
    def __init__(self, default_ttl=3600):
        self.default_ttl = default_ttl

    def get_or_set(self, key, fetch_func, ttl=None):
        """Cache-aside با TTL خودکار"""
        if ttl is None:
            ttl = self.default_ttl

        # 1. cache را چک کن
        cached = r.get(key)
        if cached is not None:
            return json.loads(cached)

        # 2. اگر نبود، از منبع اصلی بخوان
        value = fetch_func()

        # 3. در cache با TTL ذخیره کن
        r.set(key, json.dumps(value), ex=ttl)

        return value

    def invalidate(self, key):
        """پاک کردن cache برای یک کلید"""
        r.delete(key)

# استفاده
cache = TTLCache(default_ttl=300)  # ۵ دقیقه

def fetch_user_from_db(user_id):
    """شبیه‌سازی کوئری دیتابیس"""
    print(f"Fetching user {user_id} from DB...")
    time.sleep(0.5)  # شبیه‌سازی تأخیر DB
    return {'id': user_id, 'name': 'Alice', 'email': 'alice@example.com'}

# بار اول: از DB خوانده می‌شود
user = cache.get_or_set(
    f"user:1001",
    fetch_func=lambda: fetch_user_from_db(1001),
    ttl=300
)
# خروجی: Fetching user 1001 from DB...

# بار دوم: از cache خوانده می‌شود (سریع)
user = cache.get_or_set(
    f"user:1001",
    fetch_func=lambda: fetch_user_from_db(1001),
    ttl=300
)
# (هیچ خروجی از fetch_func، چون از cache آمد)

الگوی Sliding Expiration

گاهی می‌خواهید TTL یک کلید هر بار که خوانده می‌شود، تمدید شود. این الگو به sliding expiration معروف است و برای session‌ها مفید است — وقتی کاربر فعال است، session او نباید منقضی شود:

python
import redis
import json

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class SlidingSession:
    SESSION_TTL = 1800  # ۳۰ دقیقه

    def get(self, session_id):
        """خواندن session و تمدید TTL"""
        key = f"session:{session_id}"
        # Lua Script برای اتمیک بودن GET + EXPIRE
        script = """
        local value = redis.call('GET', KEYS[1])
        if value then
            redis.call('EXPIRE', KEYS[1], ARGV[1])
        end
        return value
        """
        result = r.eval(script, 1, key, self.SESSION_TTL)
        if result:
            return json.loads(result)
        return None

    def set(self, session_id, data):
        """ساختن یا به‌روزرسانی session"""
        key = f"session:{session_id}"
        r.set(key, json.dumps(data), ex=self.SESSION_TTL)

# استفاده
session = SlidingSession()

# وقتی کاربر login می‌کند
session.set("abc123", {'user_id': 1001, 'name': 'Alice'})

# هر بار که کاربر فعالیتی می‌کند (مثلاً در middleware)
data = session.get("abc123")
if data:
    print(f"Hello, {data['name']}!")
    # TTL تمدید شد، ۳۰ دقیقه دیگر از الان
else:
    print("Session expired, please login again")

OBJECT FREQ — مشاهده‌ی فرکانس دسترسی

وقتی از LFU policy استفاده می‌کنید، می‌توانید با OBJECT FREQ فرکانس دسترسی به یک کلید را ببینید:

bash
# ابتدا باید maxmemory-policy به lf* تنظیم شده باشد
127.0.0.1:6379> CONFIG SET maxmemory-policy allkeys-lfu
OK

# ساخت کلید و چند بار دسترسی
127.0.0.1:6379> SET mykey "value"
OK
127.0.0.1:6379> GET mykey
127.0.0.1:6379> GET mykey
127.0.0.1:6379> GET mykey

# مشاهده‌ی فرکانس
127.0.0.1:6379> OBJECT FREQ mykey
(integer) 7  # logarithmic frequency (هر بار دسترسی، کمی زیاد می‌شود)

# OBJECT IDLETIME - مدت زمان idle (فقط با LRU)
127.0.0.1:6379> CONFIG SET maxmemory-policy allkeys-lru
OK
127.0.0.1:6379> OBJECT IDLETIME mykey
(integer) 30  # ۳۰ ثانیه از آخرین دسترسی گذشته

MEMORY USAGE — مشاهده‌ی مصرف حافظه‌ی یک کلید

دستور MEMORY USAGE به شما می‌گوید یک کلید چه مقدار حافظه اشغال می‌کند — شامل کلید، مقدار و overhead:

bash
# MEMORY USAGE key [SAMPLES count]

127.0.0.1:6379> SET mykey "hello"
OK
127.0.0.1:6379> MEMORY USAGE mykey
(integer) 56  # ۵۶ بایت (شامل کلید، مقدار و overhead)

127.0.0.1:6379> HSET myhash f1 v1 f2 v2 f3 v3
127.0.0.1:6379> MEMORY USAGE myhash
(integer) 104

127.0.0.1:6379> LPUSH mylist "a" "b" "c" "d" "e"
127.0.0.1:6379> MEMORY USAGE mylist
(integer) 88

# با SAMPLES (برای ساختارهای بزرگ، نمونه‌گیری می‌کند)
127.0.0.1:6379> MEMORY USAGE big_sorted_set SAMPLES 5

MEMORY STATS — آمار کامل حافظه

MEMORY STATS اطلاعات کاملی از مصرف حافظه ارائه می‌دهد:

bash
127.0.0.1:6379> MEMORY STATS
1) "peak.allocated"
2) (integer) 1048576
3) "total.allocated"
4) (integer) 1048576
5) "startup.allocated"
6) (integer) 524288
7) "replication.backlog"
8) (integer) 0
9) "clients.slaves"
10) (integer) 0
11) "clients.normal"
12) (integer) 1024
13) "aof.buffer"
14) (integer) 0
15) "dataset.bytes"
16) (integer) 524288
17) "dataset.percentage"
18) "50.0"
19) "peak.percentage"
20) "100.0"
21) "allocator.allocated"
22) (integer) 1048576
23) "allocator.active"
24) (integer) 1048576
25) "allocator.resident"
26) (integer) 1048576
27) "allocator-fragmentation.ratio"
28) "1.0"
29) "allocator-fragmentation.bytes"
30) (integer) 0
31) "allocator-rss.ratio"
32) "1.0"
33) "allocator-rss.bytes"
34) (integer) 0
35) "rss-overhead.ratio"
36) "1.0"
37) "rss-overhead.bytes"
38) (integer) 0
39) "fragmentation"  # نسبت fragmentation
40) "1.0"
41) "fragmentation.bytes"
42) (integer) 0
⚠️ Fragmentation

اگر fragmentation (نسبت RSS به allocated) بالای ۱.۵ باشد، یعنی حافظه‌ی فیزیکی بیشتری از آنچه Redis استفاده می‌کند اشغال شده است. این می‌تواند به‌دلیل频繁 allocation/deallocation رخ دهد. راه‌حل: MEMORY PURGE یا فعال‌سازی activedefrag yes در redis.conf.

الگوی Memory-Efficient Key Naming

همانطور که در فصل ۴ گفتیم، طول کلیدها در مصرف حافظه نقش دارد. اگر میلیون‌ها کلید دارید، صرفه‌جویی در چند بایت به‌صورت تجمعی، صدها مگابایت است. در اینجا چند نکته ارائه می‌دهم:

python
# بد: کلیدهای طولانی
r.set("the_user_object_with_id_1001", data)  # 29 بایت فقط برای کلید
r.set("the_user_settings_with_id_1001", settings)  # 33 بایت

# خوب: کلیدهای کوتاه و معنادار
r.set("u:1001", data)  # 7 بایت
r.set("u:1001:settings", settings)  # 16 بایت

# بهتر: استفاده از Hash به‌جای چند کلید جداگانه
r.hset("u:1001", mapping={
    'data': data,
    'settings': settings,
    # ...
})
# فقط یک کلید اصلی + فیلدهای فشرده در listpack

الگوی Memory Budget

برای جلوگیری از رشد بی‌رویه‌ی حافظه، یک «بودجه‌ی حافظه» تعریف کنید: حداکثر تعداد کلیدها و حداکثر اندازه‌ی هر کلید:

python
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class MemoryBudget:
    MAX_LIST_LENGTH = 10000
    MAX_HASH_FIELDS = 1000
    MAX_SET_MEMBERS = 100000

    def safe_lpush(self, key, *values):
        """LPUSH با محدودیت طول"""
        pipe = r.pipeline()
        pipe.lpush(key, *values)
        pipe.ltrim(key, 0, self.MAX_LIST_LENGTH - 1)
        pipe.execute()

    def safe_hset(self, key, mapping):
        """HSET با محدودیت تعداد فیلد"""
        current = r.hlen(key)
        if current + len(mapping) > self.MAX_HASH_FIELDS:
            raise ValueError(f"Hash {key} would exceed max fields")
        r.hset(key, mapping=mapping)

    def safe_sadd(self, key, *members):
        """SADD با محدودیت تعداد عضو"""
        current = r.scard(key)
        if current + len(members) > self.MAX_SET_MEMBERS:
            raise ValueError(f"Set {key} would exceed max members")
        r.sadd(key, *members)

# استفاده
budget = MemoryBudget()

# این کار می‌کند
budget.safe_lpush("logs", "log_entry_1")

# این خطا می‌دهد اگر بیش از حد باشد
for i in range(100000):
    try:
        budget.safe_lpush("logs", f"log_entry_{i}")
    except ValueError as e:
        print(f"Limit reached: {e}")
        break

FLUSHDB و FLUSHALL — با احتیاط!

دو دستور برای پاک کردن داده‌ها وجود دارند که باید با احتیاط بسیار استفاده شوند:

bash
# FLUSHDB - پاک کردن فقط دیتابیس فعلی
127.0.0.1:6379> FLUSHDB
OK
127.0.0.1:6379> FLUSHDB ASYNC  # ناهمزمان (بهتر برای حجم زیاد)

# FLUSHALL - پاک کردن تمام دیتابیس‌ها (!!!)
127.0.0.1:6379> FLUSHALL
OK
127.0.0.1:6379> FLUSHALL ASYNC

# برای جلوگیری از اشتباه، می‌توان این دستورات را در redis.conf غیرفعال کرد:
# rename-command FLUSHDB ""
# rename-command FLUSHALL ""
# rename-command KEYS ""
🚨 هشدار بحرانی

FLUSHALL تمام داده‌های Redis را بدون پرسش حذف می‌کند. همیشه قبل از اجرای آن مطمئن شوید در محیط production نیستید. در محیط production، با rename-command FLUSHALL "" آن را غیرفعال کنید.

📝 خلاصه فصل

در این فصل با TTL، eviction policies، LRU/LFU، active expiration، MEMORY USAGE/STATS و الگوهای cache با TTL آشنا شدید. مدیریت حافظه یکی از مهم‌ترین مهارت‌ها در Redis است و انتخاب درست policy و تنظیم maxmemory می‌تواند تفاوت بین یک سیستم پایدار و یک سرور crash شده باشد. در فصل بعدی، با الگوهای پیشرفته caching کار می‌کنیم.

Chapter 16

الگوهای Caching #

Redis به‌عنوان یکی از محبوب‌ترین cache engine‌های جهان، الگوهای مختلفی برای caching ارائه می‌دهد. در این فصل، پنج الگوی اصلی caching را بررسی می‌کنیم: Cache-Aside، Read-Through، Write-Through، Write-Behind و Refresh-Ahead. هر الگو مزایا و معایب خود را دارد و انتخاب درست آن‌ها به الگوی دسترسی، الزامات consistency و میزان تحمل تأخیر بستگی دارد. در پایان فصل، به مبحث cache invalidation — که به گفته‌ی Phil Karlton یکی از سخت‌ترین مشکلات علوم کامپیوتر است — می‌پردازیم.

الگوی Cache-Aside (Lazy Loading)

Cache-Aside رایج‌ترین الگوی caching است. در این الگو، اپلیکیشن مستقیماً با cache و database کار می‌کند: ابتدا cache را چک می‌کند، اگر داده بود برمی‌گرداند (cache hit)، اگر نبود از database می‌خواند و در cache ذخیره می‌کند (cache miss). این الگو ساده است و با هر cache engine‌ای کار می‌کند.

sequenceDiagram participant A as App participant C as Redis Cache participant D as Database A->>C: GET user:1 C-->>A: nil (miss) A->>D: SELECT * FROM users WHERE id=1 D-->>A: row data A->>C: SET user:1 data EX 3600 A->>A: return data Note over A,C: Next request: cache hit
// cache-aside-pattern
python
import redis
import json
import time

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class CacheAside:
    def __init__(self, ttl=3600):
        self.ttl = ttl

    def get_user(self, user_id):
        """دریافت کاربر با cache-aside"""
        cache_key = f"user:{user_id}"

        # 1. ابتدا cache را چک کن
        cached = r.get(cache_key)
        if cached:
            print(f"Cache HIT for {cache_key}")
            return json.loads(cached)

        # 2. cache miss: از DB بخوان
        print(f"Cache MISS for {cache_key}, fetching from DB...")
        user = self._fetch_user_from_db(user_id)
        if user is None:
            return None

        # 3. در cache ذخیره کن با TTL
        r.set(cache_key, json.dumps(user), ex=self.ttl)

        return user

    def update_user(self, user_id, new_data):
        """به‌روزرسانی کاربر + invalidate cache"""
        # 1. DB را به‌روز کن
        self._update_user_in_db(user_id, new_data)
        # 2. cache را پاک کن (تا در درخواست بعدی از نو ساخته شود)
        r.delete(f"user:{user_id}")

    def _fetch_user_from_db(self, user_id):
        """شبیه‌سازی کوئری DB (کند)"""
        time.sleep(0.5)  # تأخیر شبیه‌سازی شده
        return {'id': user_id, 'name': 'Alice', 'email': 'alice@example.com'}

    def _update_user_in_db(self, user_id, data):
        """شبیه‌سازی به‌روزرسانی DB"""
        time.sleep(0.2)

# استفاده
cache = CacheAside(ttl=300)
user = cache.get_user(1001)  # miss - از DB
user = cache.get_user(1001)  # hit - از cache

مزایا:

• ساده و قابل فهم

• با هر database و cache‌ای کار می‌کند

• در صورت crash کردن cache، سیستم همچنان کار می‌کند (فقط کندتر)

• فقط داده‌ای که واقعاً خوانده می‌شود در cache قرار می‌گیرد

معایب:

• در cache miss، ۳ مرحله انجام می‌شود (GET cache + DB read + SET cache) — تأخیر بیشتر

• امکان stale data: اگر DB به‌روز شود ولی cache invalidate نشود

• اگر چندین درخواست همزمان cache miss بدهند، thundering herd رخ می‌دهد

الگوی Read-Through

Read-Through مشابه Cache-Aside است، اما با یک تفاوت کلیدی: اپلیکیشن فقط با cache کار می‌کند و cache خودش مسئول خواندن از database در صورت miss است. این کار اپلیکیشن را ساده می‌کند و منطق خواندن را در یک جا متمرکز می‌کند. Redis به‌طور بومی از این الگو پشتیبانی نمی‌کند، اما می‌توان آن را در لایه‌ی client پیاده کرد:

python
import redis
import json
from functools import lru_cache

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class ReadThroughCache:
    """Cache که خودش از DB می‌خواند در صورت miss"""
    def __init__(self, ttl=3600):
        self.ttl = ttl
        self._loaders = {}  # registry of loader functions

    def register_loader(self, entity_type, loader_func):
        """ثبت تابع بارگذاری برای هر نوع موجودیت"""
        self._loaders[entity_type] = loader_func

    def get(self, entity_type, entity_id):
        """خواندن با read-through"""
        cache_key = f"{entity_type}:{entity_id}"

        # cache را چک کن
        cached = r.get(cache_key)
        if cached:
            return json.loads(cached)

        # miss: loader را صدا بزن
        loader = self._loaders.get(entity_type)
        if not loader:
            raise ValueError(f"No loader for {entity_type}")

        data = loader(entity_id)
        if data is not None:
            r.set(cache_key, json.dumps(data), ex=self.ttl)

        return data

# استفاده
cache = ReadThroughCache(ttl=300)

# ثبت loader برای هر نوع
def load_user(user_id):
    # شبیه‌سازی DB
    return {'id': user_id, 'name': 'Alice'}

def load_product(product_id):
    return {'id': product_id, 'price': 99.99}

cache.register_loader('user', load_user)
cache.register_loader('product', load_product)

# اپلیکیشن فقط با cache کار می‌کند
user = cache.get('user', 1001)
product = cache.get('product', 42)

الگوی Write-Through

در Write-Through، هر بار که داده‌ای نوشته می‌شود، هم در cache و هم در database به‌صورت همزمان نوشته می‌شود. این الگو consistency بالا را تضمین می‌کند — cache همیشه به‌روز است — اما نوشتن را کندتر می‌کند (چون دو عمل انجام می‌شود):

sequenceDiagram participant A as App participant C as Redis Cache participant D as Database A->>C: SET user:1 data C-->>A: OK A->>D: UPDATE users SET ... WHERE id=1 D-->>A: OK Note over A,C: Cache همیشه به‌روز است
// write-through-pattern
python
import redis
import json

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class WriteThrough:
    def __init__(self, ttl=3600):
        self.ttl = ttl

    def write_user(self, user_id, user_data):
        """نوشتن همزمان در cache و DB"""
        cache_key = f"user:{user_id}"

        # 1. نوشتن در DB
        self._write_to_db(user_id, user_data)

        # 2. نوشتن در cache
        r.set(cache_key, json.dumps(user_data), ex=self.ttl)

    def update_user(self, user_id, updates):
        """به‌روزرسانی همزمان"""
        cache_key = f"user:{user_id}"

        # 1. خواندن داده‌ی فعلی
        current = self.get_user(user_id) or {}
        current.update(updates)

        # 2. نوشتن در DB
        self._write_to_db(user_id, current)

        # 3. به‌روزرسانی cache
        r.set(cache_key, json.dumps(current), ex=self.ttl)

    def get_user(self, user_id):
        """خواندن (همیشه از cache چون به‌روز است)"""
        cached = r.get(f"user:{user_id}")
        return json.loads(cached) if cached else None

    def _write_to_db(self, user_id, data):
        # شبیه‌سازی نوشتن در DB
        pass

الگوی Write-Behind (Write-Back)

Write-Behind برعکس Write-Through است: نوشتن فقط در cache انجام می‌شود و سپس به‌صورت asynchronous در DB نوشته می‌شود. این الگو latency نوشتن را به حداقل می‌رساند، اما ریسک از دست رفتن داده در صورت crash وجود دارد. این الگو با Redis Streams به‌خوبی پیاده می‌شود:

sequenceDiagram participant A as App participant C as Redis Cache participant S as Stream participant D as Database participant W as Worker A->>C: SET user:1 data C-->>A: OK (fast!) A->>S: XADD writes user:1 S-->>A: OK Note over W: After delay... W->>S: XREADGROUP S-->>W: user:1 data W->>D: UPDATE users W->>S: XACK
// write-behind-pattern
python
import redis
import json
import threading
import time

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class WriteBehind:
    STREAM_NAME = "db_writes"
    GROUP_NAME = "db_workers"

    def __init__(self):
        self._ensure_group()

    def _ensure_group(self):
        try:
            r.xgroup_create(self.STREAM_NAME, self.GROUP_NAME, id="0", mkstream=True)
        except redis.exceptions.ResponseError as e:
            if "BUSYGROUP" not in str(e):
                raise

    def write(self, table, key, data):
        """نوشتن سریع در cache + صف برای DB"""
        cache_key = f"{table}:{key}"

        # 1. نوشتن در cache (سریع)
        r.set(cache_key, json.dumps(data))

        # 2. افزودن به stream برای نوشتن در DB
        r.xadd(self.STREAM_NAME, {
            'table': table,
            'key': str(key),
            'data': json.dumps(data),
            'operation': 'upsert',
        })

    def delete(self, table, key):
        """حذف از cache + صف برای DB"""
        cache_key = f"{table}:{key}"
        r.delete(cache_key)
        r.xadd(self.STREAM_NAME, {
            'table': table,
            'key': str(key),
            'operation': 'delete',
        })

    def start_worker(self):
        """Worker که از stream می‌خواند و در DB می‌نویسد"""
        def worker():
            print("Write-behind worker started")
            while True:
                messages = r.xreadgroup(
                    self.GROUP_NAME, "worker-1",
                    {self.STREAM_NAME: '>'},
                    count=10, block=1000
                )
                if not messages:
                    continue

                for stream, msg_list in messages:
                    for msg_id, fields in msg_list:
                        try:
                            self._write_to_db(fields)
                            r.xack(self.STREAM_NAME, self.GROUP_NAME, msg_id)
                        except Exception as e:
                            print(f"DB write failed: {e}")

        thread = threading.Thread(target=worker, daemon=True)
        thread.start()

    def _write_to_db(self, fields):
        """شبیه‌سازی نوشتن در DB"""
        if fields['operation'] == 'upsert':
            data = json.loads(fields['data'])
            print(f"DB: UPSERT {fields['table']} {fields['key']} = {data}")
        elif fields['operation'] == 'delete':
            print(f"DB: DELETE {fields['table']} {fields['key']}")

# استفاده
wb = WriteBehind()
wb.start_worker()

# نوشتن سریع (فقط cache + stream)
wb.write("users", 1001, {'name': 'Alice', 'email': 'alice@example.com'})
# cache فورا به‌روز شد، DB به‌صورت asynchronous به‌روز می‌شود
⚠️ ریسک Write-Behind

اگر Redis قبل از نوشتن در DB crash کند، داده‌ها از دست می‌روند. این الگو فقط برای کاربردهایی مناسب است که می‌توانند از دست رفتن داده را تحمل کنند (مثلاً click counters، analytics events). برای داده‌های حیاتی، از Write-Through استفاده کنید.

الگوی Refresh-Ahead

Refresh-Ahead برای داده‌هایی است که محاسبه‌شان گران است و می‌خواهید همیشه warm نگه داشته شوند. در این الگو، قبل از اینکه TTL منقضی شود، cache به‌صورت پیش‌گیرانه به‌روز می‌شود. این کار cache hit rate را به ۱۰۰٪ نزدیک می‌کند، اما پیچیدگی را افزایش می‌دهد:

python
import redis
import json
import time
import threading

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class RefreshAhead:
    def __init__(self, ttl=3600, refresh_threshold=0.8):
        self.ttl = ttl
        self.refresh_threshold = refresh_threshold  # وقتی ۸۰٪ TTL گذشت

    def get_or_set(self, key, fetch_func):
        """خواندن با refresh-ahead"""
        cached = r.get(key)
        if cached:
            data = json.loads(cached)
            # چک کن آیا زمان refresh رسیده
            ttl_remaining = r.ttl(key)
            if ttl_remaining > 0 and ttl_remaining < self.ttl * (1 - self.refresh_threshold):
                # به‌صورت asynchronous refresh کن
                threading.Thread(
                    target=self._refresh,
                    args=(key, fetch_func),
                    daemon=True
                ).start()
            return data

        # miss: همزمان refresh
        return self._refresh(key, fetch_func)

    def _refresh(self, key, fetch_func):
        """Refresh کردن cache"""
        data = fetch_func()
        if data is not None:
            r.set(key, json.dumps(data), ex=self.ttl)
        return data

# استفاده
def fetch_expensive_report():
    """شبیه‌سازی محاسبه‌ی گران"""
    print("Computing report...")
    time.sleep(2)
    return {'total_users': 10000, 'revenue': 99999.99}

cache = RefreshAhead(ttl=600, refresh_threshold=0.8)

# بار اول: محاسبه می‌شود
report = cache.get_or_set("report:daily", fetch_expensive_report)

# بار‌های بعدی: از cache می‌آید، و اگر TTL کم باشد، refresh می‌شود
for _ in range(5):
    report = cache.get_or_set("report:daily", fetch_expensive_report)
    time.sleep(60)

الگوی Cache Stampede Protection

وقتی یک کلید محبوب از cache حذف می‌شود (مثلاً TTL منقضی می‌شود)، ممکن است صدها درخواست همزمان cache miss بدهند و همگی به DB حمله کنند — این پدیده به cache stampede یا thundering herd معروف است. برای جلوگیری، می‌توان از probabilistic early expiration یا distributed lock استفاده کرد:

python
import redis
import json
import time
import random
import uuid

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class StampedeProtection:
    """جلوگیری از cache stampede با distributed lock"""
    LOCK_TTL = 30  # ۳۰ ثانیه برای جلوگیری از deadlock

    def get_or_set(self, key, fetch_func, ttl=3600):
        # 1. cache را چک کن
        cached = r.get(key)
        if cached:
            return json.loads(cached)

        # 2. miss: سعی کن lock بگیر
        lock_key = f"lock:{key}"
        lock_value = str(uuid.uuid4())

        # SET NX با TTL = acquiring lock atomically
        if r.set(lock_key, lock_value, nx=True, ex=self.LOCK_TTL):
            try:
                # 3. دوباره cache را چک کن (شاید در این مدت کس دیگری پر کرده)
                cached = r.get(key)
                if cached:
                    return json.loads(cached)

                # 4. از DB بخوان
                data = fetch_func()
                r.set(key, json.dumps(data), ex=ttl)
                return data
            finally:
                # 5. lock را آزاد کن (فقط اگر مال ماست)
                self._release_lock(lock_key, lock_value)
        else:
            # کس دیگری در حال fetch است؛ کوتاه صبر کن و دوباره cache را چک کن
            time.sleep(0.1)
            return self.get_or_set(key, fetch_func, ttl)

    def _release_lock(self, lock_key, lock_value):
        """آزاد کردن lock به‌صورت اتمیک"""
        script = """
        if redis.call("get", KEYS[1]) == ARGV[1] then
            return redis.call("del", KEYS[1])
        else
            return 0
        end
        """
        r.eval(script, 1, lock_key, lock_value)

# استفاده
cache = StampedeProtection()

def fetch_user(user_id):
    time.sleep(1)  # شبیه‌سازی DB کند
    return {'id': user_id, 'name': 'Alice'}

# حتی اگر ۱۰۰ درخواست همزمان بیاید، فقط یکی به DB می‌رود
result = cache.get_or_set("user:1001", lambda: fetch_user(1001))

Cache Invalidation — سخت‌ترین مشکل

همانطور که Phil Karlton گفته: «دو مشکل سخت در علوم کامپیوتر وجود دارد: cache invalidation و نام‌گذاری چیزها.» cache invalidation یعنی مطمئن شویم که cache همیشه داده‌ی به‌روز دارد و stale data به کاربر نمی‌دهد. سه استراتژی اصلی وجود دارد:

۱. TTL-based (زمانی): هر کلید یک TTL دارد و بعد از آن خودکار پاک می‌شود. ساده‌ترین روش؛ اما می‌تواند به stale data کوتاه‌مدت منجر شود.

۲. Write-based (رویدادی): هر بار که داده‌ای در DB تغییر می‌کند، cache مربوطه invalidate می‌شود. این کار consistency بالاتری می‌دهد؛ اما پیچیدگی بیشتری دارد.

۳. Hybrid: ترکیب هر دو — TTL برای اطمینان نهایی + write-based برای به‌روزرسانی سریع.

python
import redis
import json

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

class CacheInvalidation:
    """الگوی hybrid cache invalidation"""

    def get_user(self, user_id):
        """خواندن با cache-aside"""
        cached = r.get(f"user:{user_id}")
        if cached:
            return json.loads(cached)
        user = self._fetch_from_db(user_id)
        if user:
            r.set(f"user:{user_id}", json.dumps(user), ex=3600)
        return user

    def update_user(self, user_id, data):
        """به‌روزرسانی با write-based invalidation"""
        # 1. DB را به‌روز کن
        self._update_in_db(user_id, data)
        # 2. cache را پاک کن
        r.delete(f"user:{user_id}")
        # (در درخواست بعدی، cache از نو ساخته می‌شود)

    def update_user_v2(self, user_id, data):
        """به‌روزرسانی با cache update (به‌جای invalidate)"""
        self._update_in_db(user_id, data)
        # cache را با داده‌ی جدید به‌روز کن (بهتر از invalidate چون cache hit باقی می‌ماند)
        r.set(f"user:{user_id}", json.dumps(data), ex=3600)

    def delete_user(self, user_id):
        """حذف کاربر"""
        self._delete_from_db(user_id)
        r.delete(f"user:{user_id}")
        # invalidate لیست‌های مرتبط هم
        r.delete(f"user:{user_id}:posts")
        r.delete(f"user:{user_id}:followers")

    def bulk_invalidate(self, pattern):
        """پاک کردن تمام کلیدهای مطابق الگو (با SCAN)"""
        cursor = 0
        count = 0
        while True:
            cursor, keys = r.scan(cursor, match=pattern, count=1000)
            if keys:
                r.delete(*keys)
                count += len(keys)
            if cursor == 0:
                break
        return count

Client-Side Caching (Tracking)

از Redis ۶ به بعد، یک قابلیت جدید به نام Client-Side Caching معرفی شد. در این الگو، کلاینت می‌تواند در حافظه‌ی خود هم cache کند و Redis به او خبر می‌دهد که آیا داده‌ای که cache کرده تغییر کرده یا نه. این کار با دو مکانیزم کار می‌کند: Tracking و INVALIDATE messages:

bash
# در redis-cli: فعال‌سازی tracking
127.0.0.1:6379> CLIENT TRACKING ON
OK

# حالا هر GET که می‌کنید، Redis یادش می‌ماند
127.0.0.1:6379> GET user:1
"alice"

# در کلاینت دیگر: تغییر کلید
127.0.0.1:6379> SET user:1 "bob"

# در کلاینت اول: پیام invalidate دریافت می‌شود
# (در یک connection جداگانه که برای دریافت پیام‌های invalidation باز می‌شود)
# INVALIDATE user:1
python
import redis
import threading

# Connection اصلی
r = redis.Redis(host='localhost', port=6379, decode_responses=True)
# Connection برای دریافت invalidate messages
r_pubsub = redis.Redis(host='localhost', port=6379, decode_responses=True)

local_cache = {}

def start_tracking():
    """فعال‌سازی client-side caching"""
    # فعال‌سازی tracking در RESP3 mode
    r.execute_command("CLIENT", "TRACKING", "ON",
                      "REDIRECT", r_pubsub.client_id())

def listen_for_invalidations():
    """گوش دادن به invalidate messages"""
    pubsub = r_pubsub.pubsub()
    pubsub.subscribe("__redis__:invalidate")

    for message in pubsub.listen():
        if message['type'] == 'message':
            # کلیدهای invalidate شده
            invalidated_keys = message['data']
            for key in invalidated_keys:
                local_cache.pop(key, None)
                print(f"Invalidated local cache for: {key}")

def get_with_client_cache(key, fetch_func):
    """خواندن با local cache + Redis cache"""
    # 1. local cache
    if key in local_cache:
        return local_cache[key]

    # 2. Redis cache
    value = r.get(key)
    if value is None:
        value = fetch_func()
        r.set(key, value, ex=3600)

    local_cache[key] = value
    return value
💡 مزیت Client-Side Caching

این الگو می‌تواند latency را به‌طور چشمگیری کاهش دهد، زیرا خواندن از حافظه‌ی process خود کلاینت سریع‌تر از هر network call است. برای برنامه‌هایی که بارها همان داده را می‌خوانند (مثلاً در یک request چندین بار به user settings دسترسی پیدا می‌کنند)، این الگو می‌تواند عملکرد را ۵-۱۰ برابر بهبود بخشد.

جمع‌بندی: کدام الگو؟

الگو مناسب برای معایب
Cache-Asideاکثر کاربردها، داده‌ی read-heavyتأخیر در cache miss، ریسک stale
Read-Throughمنطق خواندن متمرکزنیاز به cache library با قابلیت loader
Write-Throughداده‌ی حیاتی، consistency بالاتأخیر نوشتن بیشتر
Write-Behindنوشتن پرحجم، تحمل از دست رفتنریسک از دست رفتن داده
Refresh-Aheadداده‌ی گران‌محاسبه، باید warm بماندپیچیدگی، احتمال داده‌ی اضافی
Stampede Protectionکلیدهای محبوب با cache miss همزمانتأخیر جزئی برای درخواست‌های بعدی
Client-Side Cachingداده‌ی بسیار پربازدید، نیاز به ultra-low latencyپیچیدگی client، حافظه‌ی مضاعف
📝 خلاصه فصل

در این فصل با الگوهای caching آشنا شدید: Cache-Aside، Read-Through، Write-Through، Write-Behind، Refresh-Ahead، Stampede Protection و Client-Side Caching. انتخاب الگوی درست به الزامات consistency، latency و throughput بستگی دارد. همیشه cache invalidation را به‌طور جدی بگیرید — یک cache stale می‌تواند باعث باگ‌های بسیار عجیب شود. در فصل بعدی، با Lua Scripting کار می‌کنیم که ابزاری قدرتمند برای اجرای منطق پیچیده به‌صورت اتمیک است.

Chapter 17

Lua Scripting #

Lua Scripting یکی از قدرتمندترین ویژگی‌های Redis است که از نسخه‌ی ۲.۶ اضافه شد. این قابلیت به شما اجازه می‌دهد منطق پیچیده‌ای را در سرور Redis اجرا کنید — به‌صورت اتمیک و بدون round-trip شبکه‌ای. Lua Script برای کارهایی مانند atomic compare-and-swap، distributed locks، rate limiting پیچیده، و عملیات چندکلیدی atomic ایده‌آل است. در این فصل، با Lua در Redis، دستورات EVAL/EVALSHA، و چند الگوی عملی آشنا می‌شوید.

چرا Lua Script؟

بدون Lua، اگر بخواهید یک منطق پیچیده به‌صورت اتمیک اجرا کنید، باید از MULTI/EXEC با WATCH استفاده کنید که هم پیچیده است و هم در صورت رقابت، retry نیاز دارد. Lua Script این مشکل را حل می‌کند: کل اسکریپت به‌صورت یک عمل اتمیک اجرا می‌شود، بدون امکان قطع توسط کلاینت دیگر. علاوه بر این، اسکریپت فقط یک‌بار به سرور فرستاده می‌شود (با EVALSHA) و سپس می‌توان بارها آن را با SHA hash اجرا کرد — این یعنی کاهش قابل توجه network overhead.

ℹ️ نکته مهم درباره‌ی atomic بودن

در حین اجرای یک Lua Script، Redis blocked است — هیچ دستور دیگری از هیچ کلاینت دیگری اجرا نمی‌شود. این یعنی اسکریپت باید سریع باشد (زیر چند میلی‌ثانیه). اگر اسکریپت طولانی باشد، تمام کلاینت‌ها باید صبر کنند و performance سیستم به‌شدت افت می‌کند.

دستور EVAL — اجرای Lua

EVAL دستور اصلی برای اجرای Lua در Redis است. سینتکس آن: EVAL script numkeys key [key ...] arg [arg ...]. داخل اسکریپت، KEYS[1], KEYS[2], ... به کلیدها و ARGV[1], ARGV[2], ... به آرگومان‌ها دسترسی دارید:

bash
# hello world
127.0.0.1:6379> EVAL "return 'Hello, Redis!'" 0
"Hello, Redis!"

# دسترسی به KEYS و ARGV
127.0.0.1:6379> EVAL "return KEYS[1]" 1 mykey
"mykey"

127.0.0.1:6379> EVAL "return ARGV[1]" 0 hello
"hello"

# ترکیب: GET یک کلید
127.0.0.1:6379> SET mykey "hello"
OK
127.0.0.1:6379> EVAL "return redis.call('GET', KEYS[1])" 1 mykey
"hello"

# شرطی: اگر کلید وجود داشت، مقدار را برگردان، وگرنه default
127.0.0.1:6379> EVAL "local v = redis.call('GET', KEYS[1]); if v then return v else return ARGV[1] end" 1 nonexistent "default_value"
"default_value"

redis.call و redis.pcall

دو تابع برای فراخوانی دستورات Redis از داخل Lua وجود دارد:

redis.call(cmd, ...): اگر دستور خطا بدهد، اجرای اسکریپت متوقف می‌شود و خطا به کلاینت برمی‌گردد.

redis.pcall(cmd, ...): اگر دستور خطا بدهد، خطا به‌عنوان یک Lua table برگردانده می‌شود و اجرای اسکریپت ادامه می‌یابد. این برای error handling مفید است.

bash
# redis.call با خطا
127.0.0.1:6379> SET mystr "not_a_number"
127.0.0.1:6379> EVAL "redis.call('INCR', KEYS[1]); return 'done'" 1 mystr
(error) ERR value is not an integer or out of range
# اسکریپت متوقف شد

# redis.pcall با خطا
127.0.0.1:6379> EVAL "local ok, err = pcall(redis.call, 'INCR', KEYS[1]); if not ok then return 'Error: ' .. err['err'] else return 'Success' end" 1 mystr
"Error: ERR value is not an integer or out of range"
# اسکریپت ادامه داد

تبدیل نوع بین Lua و Redis

Lua و Redis نوع‌های متفاوتی دارند. درک تبدیل نوع برای جلوگیری از باگ مهم است:

Lua نوع Redis تبدیل به توضیح
numberinteger replyاگر اعشاری باشد، رُند می‌شود
stringbulk string reply
boolean trueinteger reply 1
boolean falsenil reply
nilnil reply
table (با اعداد整数 as keys)array reply{1,2,3} تبدیل می‌شود به array
table (با string keys)خطانمی‌تواند تبدیل شود
table با err fielderror replyبرای redis.error_reply
bash
# برگرداندن array
127.0.0.1:6379> EVAL "return {1, 2, 'three', 4}" 0
1) (integer) 1
2) (integer) 2
3) "three"
4) (integer) 4

# برگرداندن boolean
127.0.0.1:6379> EVAL "return true" 0
(integer) 1
127.0.0.1:6379> EVAL "return false" 0
(nil)

# برگرداندن table با کلید string (status reply)
127.0.0.1:6379> EVAL "return {ok = 'success'}" 0
"success"

# برگرداندن error
127.0.0.1:6379> EVAL "return {err = 'something went wrong'}" 0
(error) something went wrong

# توابع helper
127.0.0.1:6379> EVAL "return redis.status_reply('OK')" 0
OK
127.0.0.1:6379> EVAL "return redis.error_reply('my error')" 0
(error) my error

EVALSHA — اجرای اسکریپت کش‌شده

ارسال اسکریپت کامل در هر بار اجرا، پهنای باند زیادی مصرف می‌کند. EVALSHA این مشکل را حل می‌کند: ابتدا اسکریپت را با SCRIPT LOAD در سرور کش می‌کنید و سپس فقط SHA1 hash آن را با EVALSHA اجرا می‌کنید:

bash
# 1. load اسکریپت در سرور
127.0.0.1:6379> SCRIPT LOAD "return redis.call('GET', KEYS[1])"
"a5260dd66ce02462c5b523b050c5e8e6f3f0c34c"  # SHA1 hash

# 2. اجرا با EVALSHA
127.0.0.1:6379> EVALSHA a5260dd66ce02462c5b523b050c5e8e6f3f0c34c 1 mykey
"hello"

# بررسی وجود اسکریپت در کش
127.0.0.1:6379> SCRIPT EXISTS a5260dd66ce02462c5b523b050c5e8e6f3f0c34c
1) (integer) 1

# پاک کردن کش (با احتیاط!)
127.0.0.1:6379> SCRIPT FLUSH
OK

کتابخانه‌های Redis در پایتون، Node.js و سایر زبان‌ها به‌طور خودکار از EVALSHA استفاده می‌کنند: ابتدا سعی می‌کنند با EVALSHA اجرا کنند؛ اگر سرور اسکریپت را نداشته باشد (خطای NOSCRIPT)، اسکریپت را با SCRIPT LOAD دوباره load می‌کنند و سپس EVALSHA می‌کنند. این فرآیند برای کاربر شفاف است.

الگوی Atomic Compare-And-Swap

یکی از بهترین کاربردهای Lua، پیاده‌سازی atomic CAS است. در اینجا یک اسکریپت می‌بینید که یک کلید را می‌خواند، با مقدار مورد انتظار مقایسه می‌کند، و فقط اگر برابر بود، مقدار جدید را می‌نویسد:

lua
-- compare_and_swap.lua
-- KEYS[1] = کلید
-- ARGV[1] = مقدار مورد انتظار (expected)
-- ARGV[2] = مقدار جدید (new)

local current = redis.call('GET', KEYS[1])
if current == ARGV[1] then
    redis.call('SET', KEYS[1], ARGV[2])
    return 1  -- موفق
else
    return 0  -- مقدار فعلی با expected متفاوت است
end
python
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

CAS_SCRIPT = """
local current = redis.call('GET', KEYS[1])
if current == ARGV[1] then
    redis.call('SET', KEYS[1], ARGV[2])
    return 1
else
    return 0
end
"""

# register اسکریپت (در عمل با client.register_script)
def compare_and_swap(key, expected, new_value):
    result = r.eval(CAS_SCRIPT, 1, key, expected, new_value)
    return bool(result)

# استفاده
r.set("counter", "10")
print(compare_and_swap("counter", "10", "11"))  # True - موفق
print(compare_and_swap("counter", "10", "12"))  # False - چون counter اکنون 11 است

الگوی Redlock (Distributed Lock)

یکی از مهم‌ترین الگوهای Lua، پیاده‌سازی distributed lock با تضمین اتمیک بودن acquire و release است. در ادامه، نسخه‌ی پایه Redlock را می‌بینید:

python
import redis
import uuid
import time

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# اسکریپت acquire: SET NX با TTL (atomic)
ACQUIRE_SCRIPT = """
if redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2]) then
    return 1
else
    return 0
end
"""

# اسکریپت release: فقط اگر مال ما باشد (atomic)
RELEASE_SCRIPT = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0
end
"""

# اسکریپت extend: تمدید TTL فقط اگر مال ما باشد
EXTEND_SCRIPT = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('PEXPIRE', KEYS[1], ARGV[2])
else
    return 0
end
"""

class RedisLock:
    def __init__(self, name, ttl_ms=10000, retry_count=3, retry_delay=0.1):
        self.lock_key = f"lock:{name}"
        self.ttl_ms = ttl_ms
        self.retry_count = retry_count
        self.retry_delay = retry_delay
        self.lock_value = str(uuid.uuid4())
        self._acquired = False

    def acquire(self):
        """گرفتن قفل"""
        for _ in range(self.retry_count):
            result = r.eval(ACQUIRE_SCRIPT, 1,
                           self.lock_key, self.lock_value, self.ttl_ms)
            if result:
                self._acquired = True
                return True
            time.sleep(self.retry_delay)
        return False

    def release(self):
        """آزاد کردن قفل (فقط اگر مال ما باشد)"""
        if not self._acquired:
            return False
        result = r.eval(RELEASE_SCRIPT, 1, self.lock_key, self.lock_value)
        self._acquired = not bool(result)
        return bool(result)

    def extend(self, additional_ms):
        """تمدید TTL قفل"""
        result = r.eval(EXTEND_SCRIPT, 1,
                       self.lock_key, self.lock_value, additional_ms)
        return bool(result)

    def __enter__(self):
        if not self.acquire():
            raise RuntimeError("Could not acquire lock")
        return self

    def __exit__(self, *args):
        self.release()

# استفاده
with RedisLock("update_inventory:product_42", ttl_ms=10000) as lock:
    print("Lock acquired, doing work...")
    # کار طولانی...
    time.sleep(2)
    # تمدید اگر کار بیشتر طول کشید
    lock.extend(5000)
# lock به‌طور خودکار آزاد شد
💡 چرا release با Lua؟

اگر release را با GET و سپس DEL انجام دهید، بین این دو عملیات، قفل ممکن است منقضی شود و کس دیگری آن را گرفته باشد. سپس DEL ما قفل او را پاک می‌کند! Lua Script این مشکل را با اجرای atomic GET + conditional DEL حل می‌کند.

الگوی Rate Limiter با Lua

در فصل ۵ و ۹ rate limiter پیاده‌سازی کردیم. در اینجا نسخه‌ی بهینه با Lua Script را می‌بینید که تمام عملیات در یک round-trip و به‌صورت atomic انجام می‌شود:

lua
-- sliding_window_rate_limiter.lua
-- KEYS[1] = rate limit key (e.g., "ratelimit:user:123")
-- ARGV[1] = current timestamp (float)
-- ARGV[2] = window size in seconds (float)
-- ARGV[3] = max requests in window (integer)
-- ARGV[4] = unique request ID (string)

local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local request_id = ARGV[4]

local window_start = now - window

-- 1. حذف درخواست‌های قدیمی‌تر از window
redis.call('ZREMRANGEBYSCORE', key, 0, window_start)

-- 2. شمارش درخواست‌های فعلی
local current = redis.call('ZCARD', key)

-- 3. اگر زیر limit است، اجازه بده
if current < limit then
    redis.call('ZADD', key, now, request_id)
    redis.call('EXPIRE', key, math.ceil(window))
    return {1, current + 1, limit - (current + 1)}  -- allowed, count, remaining
else
    -- محاسبه زمان تا پنجره‌ی بعدی
    local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
    local retry_after = 0
    if oldest[2] then
        retry_after = tonumber(oldest[2]) + window - now
    end
    return {0, current, 0, retry_after}  -- blocked, count, remaining=0, retry_after
end
python
import redis
import time
import uuid

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# بارگذاری اسکریپت (با register_script برای استفاده از EVALSHA خودکار)
rate_limit_script = r.register_script("""
local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local limit = tonumber(ARGV[3])
local request_id = ARGV[4]
local window_start = now - window

redis.call('ZREMRANGEBYSCORE', key, 0, window_start)
local current = redis.call('ZCARD', key)

if current < limit then
    redis.call('ZADD', key, now, request_id)
    redis.call('EXPIRE', key, math.ceil(window))
    return {1, current + 1, limit - (current + 1)}
else
    local oldest = redis.call('ZRANGE', key, 0, 0, 'WITHSCORES')
    local retry_after = 0
    if oldest[2] then
        retry_after = tonumber(oldest[2]) + window - now
    end
    return {0, current, 0, retry_after}
end
""")

def check_rate_limit(user_id, limit=100, window=60):
    """بررسی rate limit با Lua Script"""
    key = f"ratelimit:user:{user_id}"
    request_id = str(uuid.uuid4())
    now = time.time()

    result = rate_limit_script(keys=[key],
                               args=[now, window, limit, request_id])

    allowed = bool(result[0])
    current = result[1]
    remaining = result[2]
    retry_after = result[3] if len(result) > 3 else 0

    return {
        'allowed': allowed,
        'current': current,
        'remaining': remaining,
        'retry_after': retry_after,
    }

# استفاده
for i in range(105):
    result = check_rate_limit("user_123", limit=100, window=60)
    if result['allowed']:
        print(f"Request {i+1}: OK (remaining: {result['remaining']})")
    else:
        print(f"Request {i+1}: BLOCKED (retry in {result['retry_after']:.1f}s)")

الگوی Atomic Inventory Reservation

یکی از کاربردهای واقعی Lua، رزرو موجودی انبار به‌صورت atomic است. این الگو در فروشگاه‌های اینترنتی برای جلوگیری از over-selling حیاتی است:

lua
-- reserve_inventory.lua
-- KEYS[1] = inventory key (e.g., "inventory:product:42")
-- ARGV[1] = quantity to reserve
-- ARGV[2] = order_id (for tracking)

local inventory_key = KEYS[1]
local quantity = tonumber(ARGV[1])
local order_id = ARGV[2]

-- خواندن موجودی فعلی
local current = redis.call('HGET', inventory_key, 'available')
if not current then
    return {-1, "Product not found"}
end

current = tonumber(current)

-- بررسی موجودی کافی
if current < quantity then
    return {-2, "Insufficient stock", current}
end

-- کاهش موجودی و افزایش رزرو شده
local new_available = current - quantity
redis.call('HSET', inventory_key, 'available', new_available)
redis.call('HINCRBY', inventory_key, 'reserved', quantity)

-- ثبت رزرو برای tracking
redis.call('HSET', KEYS[1] .. ':reservations', order_id, quantity)

return {1, "Success", new_available}

مدیریت خطا و Debug

Lua در Redis از چند تابع helper پشتیبانی می‌کند:

bash
# LOG - نوشتن در لاگ Redis
127.0.0.1:6379> EVAL "redis.log(redis.LOG_NOTICE, 'Hello from Lua'); return 1" 0

# LOG_LEVELS:
# redis.LOG_DEBUG
# redis.LOG_VERBOSE
# redis.LOG_NOTICE
# redis.LOG_WARNING

# SHA1_HEX - محاسبه SHA1 (گاهی مفید)
127.0.0.1:6379> EVAL "return redis.sha1hex('hello')" 0
"aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d"

# REPLICATE_COMMANDS - برای replication (نسخه‌های قدیمی)
# (در نسخه‌های جدید به‌طور خودکار)

Lua Functions (نسخه ۷ به بعد)

از نسخه‌ی ۷، Redis Functions را به‌عنوان جایگزین پیشرفته‌ی Scripts معرفی کرد. Functions چند مزیت دارد: ثبت دائمی در سرور (تا SCRIPT FLUSH)، مدیریت library-based، و قابلیت debugging بهتر:

bash
# FUNCTION LOAD - بارگذاری library
127.0.0.1:6379> FUNCTION LOAD "#!lua name=mylib
redis.register_function('myfunc', function(keys, args)
    return redis.call('GET', keys[1])
end)
redis.register_function('setifhigher', function(keys, args)
    local current = tonumber(redis.call('GET', keys[1]) or 0)
    local newval = tonumber(args[1])
    if newval > current then
        redis.call('SET', keys[1], newval)
        return 1
    end
    return 0
end)"

# FCALL - فراخوانی function
127.0.0.1:6379> FCALL myfunc 1 mykey
127.0.0.1:6379> FCALL setifhigher 1 counter 100

# FUNCTION LIST - لیست libraries
127.0.0.1:6379> FUNCTION LIST

# FUNCTION DELETE - حذف library
127.0.0.1:6379> FUNCTION DELETE mylib

# FUNCTION DUMP - backup
127.0.0.1:6379> FUNCTION DUMP

# FUNCTION RESTORE - restore از backup
127.0.0.1:6379> FUNCTION RESTORE <binary_data>

محدودیت‌های Lua Scripting

با وجود قدرت، Lua در Redis محدودیت‌هایی دارد:

۱. بدون I/O خارجی: Lua نمی‌تواند فایل بخواند، HTTP request بفرستد، یا به سیستم‌عامل دسترسی داشته باشد. این برای امنیت و deterministic بودن ضروری است (برای replication).

۲. بدون sleep: os.execute('sleep 1') مجاز نیست. اگر نیاز به delay دارید، باید از یک مکانیزم دیگر استفاده کنید.

۳. محدودیت زمان: اگر اسکریپت خیلی طول بکشد (پیش‌فرض ۵ ثانیه)، Redis آن را متوقف می‌کند. این با lua-time-limit در redis.conf کنترل می‌شود.

۴. random و time: توابعی که non-deterministic هستند (مانند math.random یا os.time) محدود شده‌اند. اگر در اسکریپت از math.random استفاده کنید، باید ابتدا redis.replicate_commands() را صدا بزنید (در نسخه‌های قدیمی).

۵. عدم دسترسی به ماژول‌های Lua: فقط کتابخانه‌ی استاندارد Lua (string، table، math، ...) در دسترس است. ماژول‌های third-party قابل load نیستند.

۶. در Redis Cluster، تمام کلیدها باید در یک hash slot باشند. می‌توان با hash tags این کار را انجام داد: {user}:profile و {user}:settings.

تست و Debug

برای debug اسکریپت‌های Lua، می‌توان از LDEBUG در redis-cli استفاده کرد (محیط تعاملی debug):

bash
# شروع debugger برای یک اسکریپت
127.0.0.1:6379> DEBUG SET-ACTIVE-EXPIRE 0  # اختیاری

# استفاده از redis-cli --ldb
$ redis-cli --ldb --eval /path/to/script.lua key1 key2 , arg1 arg2

# در محیط debug:
# step - اجرای یک خط
# next - اجرای یک خط (بدون ورود به توابع)
# continue - اجرای کامل
# print <var> - نمایش مقدار متغیر
# break <line> - گذاشتن breakpoint
# list - نمایش کد اطراف
# restart - شروع مجدد
📝 خلاصه فصل

در این فصل با Lua Scripting آشنا شدید: دستورات EVAL/EVALSHA/SCRIPT LOAD، redis.call/pcall، تبدیل نوع، و الگوهای atomic CAS، Redlock، rate limiter و inventory reservation. Lua برای منطق پیچیده‌ی atomic بسیار قدرتمند است؛ اما باید سریع باشد (زیر چند ms) تا کلاینت‌های دیگر را مسدود نکند. در نسخه‌ی ۷، Functions جایگزین پیشرفته‌ی Scripts است. در فصل بعدی، با امنیت و ACL کار می‌کنیم.

Chapter 18

امنیت و ACL #

امنیت در Redis اغلب نادیده گرفته می‌شود، که می‌تواند به فاجعه منجر شود. چندین حادثه‌ی معروف وجود داشته که در آن شرکت‌ها به‌خاطر پیکربندی نادرست Redis، داده‌های حساس خود را از دست داده‌اند یا سرورهایشان به botnet تبدیل شده‌اند. در آوریل ۲۰۱۵، بیش از ۱۸٬۰۰۰ سرور Redis بدون رمز عبور در اینترنت در دسترس بودند و با ransomware آلوده شدند. در این فصل، با بهترین روش‌های امن‌سازی Redis، ACL در نسخه ۶ به بعد، TLS/SSL encryption، و best practices آشنا می‌شوید.

مدل امنیتی Redis

Redis به‌طور پیش‌فرض برای محیط توسعه طراحی شده و امنیت پیش‌فرض آن کم است. در نسخه‌های قدیمی، تنها مکانیزم امنیتی، رمز عبور با requirepass بود که همه‌ی کاربران از همان رمز استفاده می‌کردند. از نسخه‌ی ۶، Redis از ACL (Access Control List) پشتیبانی می‌کند که اجازه می‌دهد کاربران مختلف با دسترسی‌های متفاوت تعریف کنید. علاوه بر این، TLS/SSL برای رمزنگاری connection و protected mode برای جلوگیری از دسترسی عمومی اضافه شده‌اند.

Protected Mode

Protected Mode در نسخه‌ی ۳.۲ معرفی شد تا از یک اشتباه رایج جلوگیری کند: راه‌اندازی Redis روی اینترنت بدون رمز عبور. وقتی protected mode فعال است (پیش‌فرض) و Redis روی همه‌ی interface‌ها گوش می‌دهد (bind 0.0.0.0) و رمز عبور تنظیم نشده، Redis فقط از 127.0.0.1 دسترسی می‌پذیرد و به بقیه با خطا پاسخ می‌دهد:

ini
# redis.conf
protected-mode yes  # پیش‌فرض: yes

# اگر می‌خواهید به اینترنت دسترسی بدهید، باید یکی از این‌ها را انجام دهید:
# 1. bind به interface خاص
bind 192.168.1.100
# 2. تنظیم رمز عبور
requirepass your_strong_password
# 3. غیرفعال‌سازی protected mode (به‌هیچ‌وجه توصیه نمی‌شود!)
# protected-mode no
🚨 هرگز protected-mode را غیرفعال نکنید!

اگر protected-mode no تنظیم کنید و bind 0.0.0.0 بدون رمز عبور، تمام اینترنت می‌تواند به Redis شما دسترسی داشته باشد. این یک اشتباه بحرانی است که به leakage داده و ransomware منجر می‌شود. همیشه protected-mode را روشن نگه دارید یا رمز عبور قوی تنظیم کنید.

ACL — Access Control List (نسخه ۶ به بعد)

ACL به شما اجازه می‌دهد چند کاربر با دسترسی‌های مختلف تعریف کنید. هر کاربر می‌تواند به دستورات خاصی، کلیدهای خاصی (با pattern)، یا کانال‌های Pub/Sub خاصی دسترسی داشته باشد. این قابلیت برای محیط‌های production با چندین اپلیکیشن مختلف بسیار مفید است:

bash
# ACL WHOAMI - مشاهده‌ی کاربر فعلی
127.0.0.1:6379> ACL WHOAMI
"default"

# ACL LIST - لیست تمام کاربران (با فرمت قدیمی)
127.0.0.1:6379> ACL LIST
1) "user default on nopass ~* &* +@all"

# ACL USERS - فقط نام کاربران
127.0.0.1:6379> ACL USERS
1) "default"

# ACL SETUSER - ایجاد یا ویرایش کاربر
# سینتکس: ACL SETUSER username [rules]
# rules:
#   on/off - فعال/غیرفعال
#   password >password> - تنظیم رمز عبور
#   resetpass - حذف رمز عبور
#   allcommands/+@all - دسترسی به همه دستورات
#   -command - حذف دسترسی به دستور خاص
#   +category - دسترسی به یک category (read, write, admin, ...)
#   allkeys/~pattern - دسترسی به همه کلیدها / فقط pattern
#   resetkeys - حذف تمام key patterns
#   allchannels/&pattern - دسترسی به همه کانال‌ها / فقط pattern

# ایجاد کاربر با دسترسی فقط خواندن
127.0.0.1:6379> ACL SETUSER readonly on >strong_password ~* +@read
OK

# ایجاد کاربر با دسترسی فقط به cache keys
127.0.0.1:6379> ACL SETUSER cache_app on >cache_pass ~cache:* +@write +@read
OK

# ایجاد کاربر admin با همه دسترسی‌ها
127.0.0.1:6379> ACL SETUSER admin on >admin_pass ~* +@all
OK

# ایجاد کاربر محدود (بدون دسترسی خطرناک)
127.0.0.1:6379> ACL SETUSER restricted on >pass ~user:* +@read -FLUSHALL -FLUSHDB -CONFIG
OK

# ACL GETUSER - مشاهده‌ی جزئیات کاربر
127.0.0.1:6379> ACL GETUSER cache_app
1) "flags"
2) 1) "on"
3) "passwords"
4) 1) "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8"
5) "commands"
6) "+@write +@read"
7) "keys"
8) 1) "cache:*"
9) "channels"
10) 1) "*"
11) "selectors"
12) (empty array)

# ACL DELUSER - حذف کاربر
127.0.0.1:6379> ACL DELUSER readonly
(integer) 1

# ACL CAT - لیست دسته‌بندی دستورات
127.0.0.1:6379> ACL CAT
1) "keyspace"
2) "read"
3) "write"
4) "set"
5) "sortedset"
6) "list"
7) "hash"
8) "string"
9) "bitmap"
10) "hyperloglog"
11) "geo"
12) "stream"
13) "pubsub"
14) "admin"
15) "fast"
16) "slow"
17) "blocking"
18) "dangerous"

# ACL CAT read - لیست دستورات در یک category
127.0.0.1:6379> ACL CAT read
1) "get"
2) "mget"
3) "exists"
4) "strlen"
...

ذخیره‌ی ACL در فایل

تغییرات ACL با ACL SETUSER در حافظه هستند و با restart از بین می‌روند. برای persistence، باید در فایل aclfile ذخیره شوند:

ini
# redis.conf
aclfile /etc/redis/users.acl
bash
# ذخیره‌ی ACL فعلی در فایل
127.0.0.1:6379> ACL SAVE
OK

# فایل users.acl محتوایی شبیه به این دارد:
# user default on nopass ~* &* +@all
# user cache_app on #5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8 ~cache:* &* +@write +@read
# user admin on #2bb80d537b1da3e38bd30361aa855686bde0eacd7162fef6a25fe97bf527a25b ~* &* +@all

# بارگذاری مجدد از فایل
127.0.0.1:6379> ACL LOAD
OK

اتصال با کاربر خاص

با redis-cli یا کتابخانه‌های client می‌توان به‌عنوان کاربر خاص وصل شد:

bash
# با رمز عبور (روش قدیمی)
redis-cli -a strong_password

# با نام کاربری و رمز (ACL)
redis-cli --user cache_app -a cache_pass

# با AUTH command
127.0.0.1:6379> AUTH cache_app cache_pass
OK

# با متغیر محیطی (توصیه شده)
export REDISCLI_AUTH=cache_pass
redis-cli --user cache_app
python
import redis

# اتصال با کاربر خاص
r = redis.Redis(
    host='localhost',
    port=6379,
    username='cache_app',
    password='cache_pass',
    decode_responses=True,
)

# تست دسترسی
try:
    r.set('cache:test', 'hello')  # OK - دسترسی داریم
    print("Set OK")
except redis.exceptions.ResponseError as e:
    print(f"Access denied: {e}")

try:
    r.set('user:1001', 'alice')  # خطا - دسترسی نداریم
except redis.exceptions.ResponseError as e:
    print(f"Expected denial: {e}")  # NOPERM این کلید allowed نیست

الگوی ACL برای محیط Production

در محیط production، بهترین روش این است که برای هر اپلیکیشن یا سرویس یک کاربر جداگانه با حداقل دسترسی لازم تعریف کنید. این کار به principle of least privilege معروف است:

bash
# کاربر برای اپلیکیشن وب (cache + session)
127.0.0.1:6379> ACL SETUSER webapp on >webapp_strong_pass \
    ~session:* ~cache:* \
    +@read +@write +@connection \
    -FLUSHALL -FLUSHDB -CONFIG -DEBUG -SHUTDOWN -SCRIPT

# کاربر برای analytics (فقط خواندن)
127.0.0.1:6379> ACL SETUSER analytics on >analytics_pass \
    ~analytics:* \
    +@read +@connection

# کاربر برای background worker (Streams)
127.0.0.1:6379> ACL SETUSER worker on >worker_pass \
    ~jobs:* ~queue:* \
    +@read +@write +@connection +@stream \
    +XADD +XREADGROUP +XACK +XPENDING +XCLAIM

# کاربر برای مانیتورینگ (فقط INFO)
127.0.0.1:6379> ACL SETUSER monitor on >monitor_pass \
    +INFO +PING +CLIENT +LATENCY +SLOWLOG +MEMORY \
    +@connection

# کاربر admin (فقط از localhost)
127.0.0.1:6379> ACL SETUSER admin on >admin_pass ~* &* +@all

# ذخیره در فایل
127.0.0.1:6379> ACL SAVE

TLS/SSL Encryption

از نسخه‌ی ۶ به بعد، Redis به‌طور بومی از TLS/SSL پشتیبانی می‌کند. این قابلیت برای رمزنگاری connection بین کلاینت و سرور، و همچنین در replication، ضروری است. بدون TLS، تمام داده‌ها (از جمله رمز عبور) به‌صورت plaintext روی شبکه می‌روند:

ini
# redis.conf
# فعال‌سازی TLS
tls-port 6379
port 0  # غیرفعال‌سازی plaintext (یا هر دو فعال باشند)

# گواهی و کلید
tls-cert-file /etc/redis/redis.crt
tls-key-file /etc/redis/redis.key
tls-ca-cert-file /etc/redis/ca.crt

# برای replication
tls-replication yes

# برای cluster
tls-cluster yes

# برای Pub/Sub
tls-channel yes

# حداقل نسخه‌ی TLS (پیش‌فرض TLSv1.2)
tls-protocols "TLSv1.2 TLSv1.3"

# cipher suites (اختیاری)
tls-ciphersuitES "TLS_AES_256_GCM_SHA384"

# verify mode برای کلاینت‌ها
tls-auth-clients no  # کلاینت نیاز به cert ندارد
# tls-auth-clients yes  # کلاینت هم باید cert داشته باشد (mTLS)
bash
# اتصال با TLS در redis-cli
redis-cli --tls \
  --cert /path/to/client.crt \
  --key /path/to/client.key \
  --cacert /path/to/ca.crt \
  -h redis.example.com -p 6379

# اگر فقط CA public cert نیاز است (بدون client cert)
redis-cli --tls --cacert /path/to/ca.crt -h redis.example.com
python
import redis
import ssl

# اتصال با TLS در Python
r = redis.Redis(
    host='redis.example.com',
    port=6379,
    username='webapp',
    password='webapp_strong_pass',
    ssl=True,
    ssl_ca_certs='/path/to/ca.crt',
    # اگر client cert نیاز است:
    # ssl_certfile='/path/to/client.crt',
    # ssl_keyfile='/path/to/client.key',
    # برای تست خود-امضا:
    # ssl_cert_reqs=ssl.CERT_NONE,  # خطرناک! فقط برای توسعه
    decode_responses=True,
)

print(r.ping())  # PONG

تولید گواهی برای تست

برای راه‌اندازی TLS در محیط توسعه، می‌توانید با OpenSSL گواهی self-signed تولید کنید:

bash
# تولید CA
openssl genrsa -out ca.key 4096
openssl req -x509 -new -nodes -key ca.key -sha256 -days 3650 \
    -subj "/CN=Redis Test CA" -out ca.crt

# تولید گواهی سرور
openssl genrsa -out redis.key 2048
openssl req -new -key redis.key -subj "/CN=redis.example.com" -out redis.csr

# ایجاد فایل SAN (Subject Alternative Names)
cat > san.cnf <<EOF
[req]
distinguished_name = req_distinguished_name
req_extensions = v3_req
[req_distinguished_name]
[v3_req]
subjectAltName = @alt_names
[alt_names]
DNS.1 = redis.example.com
DNS.2 = localhost
IP.1 = 127.0.0.1
EOF

openssl x509 -req -in redis.csr -CA ca.crt -CAkey ca.key -CAcreateserial \
    -out redis.crt -days 365 -sha256 -extfile san.cnf -extensions v3_req

# فایل‌ها اکنون آماده‌ی استفاده در redis.conf هستند

rename-command — غیرفعال‌سازی دستورات خطرناک

یکی دیگر از راه‌های امن‌سازی، غیرفعال‌سازی یا تغییر نام دستورات خطرناک است. این کار باعث می‌شود حتی اگر کلاینت مخرب به Redis وصل شود، نتواند کار تخریبی انجام دهد:

ini
# redis.conf
# غیرفعال‌سازی کامل (با مقدار خالی)
rename-command FLUSHDB ""
rename-command FLUSHALL ""
rename-command KEYS ""
rename-command CONFIG ""
rename-command DEBUG ""

# یا تغییر نام (به‌عنوان یک رمز)
rename-command CONFIG "CONFIG_9f8a7b6c5d4e3f2g"
rename-command FLUSHALL "FLUSHALL_secret_password_here"
# حالا برای اجرای CONFIG باید از نام جدید استفاده کنید
⚠️ نکته درباره‌ی rename-command

اگر از Redis Cluster یا Sentinel استفاده می‌کنید، rename-command ممکن است مشکلاتی ایجاد کند زیرا این ابزارها به دستورات خاصی نیاز دارند. همچنین، در نسخه‌ی ۷ به بعد، ACL روش توصیه‌شده‌ی کنترل دسترسی است و rename-command کم‌کم منسوخ می‌شود.

بهترین روش‌های امنیتی

در ادامه، جمع‌بندی بهترین روش‌های امنیتی برای Redis را می‌بینید:

۱. هرگز Redis را مستقیماً روی اینترنت قرار ندهید. همیشه پشت یک firewall قرار دهید و فقط اپلیکیشن‌های مورد اعتماد به آن دسترسی داشته باشند.

۲. همیشه رمز عبور یا ACL تنظیم کنید. حتی در محیط داخلی، یک لایه‌ی اضافی امنیت ارزشش را دارد.

۳. از TLS استفاده کنید. مخصوصاً اگر ترافیک از شبکه‌ی عمومی عبور می‌کند.

۴. principle of least privilege. هر اپلیکیشن فقط به کلیدها و دستوراتی که نیاز دارد دسترسی داشته باشد.

۵. دستورات خطرناک را محدود کنید. FLUSHALL، CONFIG، DEBUG را فقط برای کاربر admin در دسترس بگذارید.

۶. Redis را به‌عنوان کاربر غیر root اجرا کنید. یک کاربر مخصوص redis با حداقل دسترسی سیستم‌عامل بسازید.

۷. فایل redis.conf را قابل خواندن فقط برای redis user کنید. چون ممکن است رمز عبور در آن باشد.

۸. به‌طور منظم Redis را به‌روز کنید. هر نسخه‌ی جدید معمولاً رفع آسیب‌پذیری‌های امنیتی دارد.

عمر رمز عبور با ACL

از نسخه‌ی ۷.۴ به بعد، می‌توان برای هر رمز عبور در ACL، تاریخ انقضا تنظیم کرد. این برای policies مانند «تغییر رمز هر ۹۰ روز» مفید است:

bash
# رمز با انقضای ۹۰ روز (تقریبی)
127.0.0.1:6379> ACL SETUSER webapp on >webapp_pass resetpass >webapp_pass expire 7776000 ~cache:* +@read +@write

# بررسی زمان باقیمانده (به ثانیه)
127.0.0.1:6379> ACL GETUSER webapp

LOG و Audit

برای audit security، می‌توانید لاگ Redis را بررسی کنید. تنظیم loglevel verbose در redis.conf اطلاعات بیشتری ثبت می‌کند، اما حجم لاگ را افزایش می‌دهد:

ini
# redis.conf
loglevel notice  # debug | verbose | notice | warning
logfile /var/log/redis/redis-server.log

# در production، notice یا warning بهتر است
💡 برای audit دقیق‌تر

اگر به audit دقیق‌تری نیاز دارید، می‌توانید از MONITOR استفاده کنید (که تمام دستورات را در real-time نشان می‌دهد) یا ابزارهایی مانند RedisAudit یا Falco را برای audit و anomaly detection به‌کار ببرید. توجه داشته باشید که MONITOR تأثیر زیادی روی performance دارد و هرگز در production استفاده نکنید.

Network Security

علاوه بر امنیت خود Redis، باید شبکه‌ی اطراف آن هم امن باشد:

bash
# فایروال: فقط اجازه دسترسی از اپلیکیشن server
sudo ufw allow from 192.168.1.10 to any port 6379
sudo ufw deny 6379

# در AWS Security Group: inbound rule برای پورت 6379
# فقط از security group اپلیکیشن

# در Kubernetes: NetworkPolicy
# فقط pods با label app=webapp می‌توانند به redis وصل شوند

# SSH Tunneling برای دسترسی از راه دور
ssh -L 6379:localhost:6379 user@redis-server
# حالا در local، با localhost:6379 می‌توانید وصل شوید

عمر session با ACL

یکی دیگر از قابلیت‌های ACL، امکان تعیین حداکثر تعداد کلاینت‌های همزمان برای هر کاربر است:

bash
# کاربر با حداکثر ۱۰ connection همزمان
127.0.0.1:6379> ACL SETUSER webapp on >pass maxconnections 10 ~cache:* +@read +@write

# کاربر بدون محدودیت connection (پیش‌فرض)
127.0.0.1:6379> ACL SETUSER monitor on >pass maxconnections -1 +INFO

# تنظیم global maxclients در redis.conf
# maxclients 10000
📝 خلاصه فصل

در این فصل با امنیت Redis آشنا شدید: protected mode، ACL با کاربران و دسترسی‌های مختلف، TLS/SSL برای رمزنگاری، rename-command برای غیرفعال‌سازی دستورات خطرناک، و بهترین روش‌های امنیتی. امنیت Redis یک مسئولیت مستمر است و باید به‌طور منظم بررسی و به‌روز شود. در فصل بعدی، با پایداری داده (RDB و AOF) کار می‌کنیم که برای جلوگیری از از دست رفتن داده در صورت crash ضروری است.

Chapter 19

پایداری داده (RDB و AOF) #

Redis یک پایگاه داده‌ی in-memory است؛ اما این به‌معنای از دست رفتن داده در صورت crash نیست. Redis دو مکانیزم persistence ارائه می‌دهد: RDB (Redis Database) که snapshot‌های دوره‌ای از داده‌ها می‌گیرد، و AOF (Append-Only File) که هر دستور نوشتن را در یک log ثبت می‌کند. هر کدام مزایا و معایب خود را دارند و می‌توان آن‌ها را به‌صورت ترکیبی هم استفاده کرد. در این فصل، با هر دو مکانیزم، تنظیمات، و best practices آشنا می‌شوید.

RDB — Snapshotting

RDB یک فایل باینری فشرده است که snapshot کاملی از داده‌های Redis در یک لحظه‌ی خاص است. Redis با استفاده از fork() یک process فرزند می‌سازد که داده‌ها را روی دیسک می‌نویسد، در حالی که process اصلی به کار خود ادامه می‌دهد. این مکانیزم بسیار کارآمد است و تأثیر کمی بر عملکرد دارد.

sequenceDiagram participant R as Redis Main participant F as Fork Process participant D as Disk R->>R: BGSAVE trigger R->>F: fork() Note over R: ادامه کار با کلاینت‌ها F->>F: خواندن داده‌ها از memory F->>D: نوشتن temp file F->>D: rename به dump.rdb F-->>R: signal done R->>R: log "BGSAVE finished"
// rdb-bgsave-process

تنظیمات RDB

RDB در redis.conf با چندین تنظیم کنترل می‌شود. مهم‌ترین آن save است که سیاست snapshotگیری را تعیین می‌کند:

ini
# redis.conf
# سیاست‌های save (هر کدام یک trigger)
save 900 1     # اگر ۱ کلید در ۱۵ دقیقه تغییر کرد
save 300 10    # اگر ۱۰ کلید در ۵ دقیقه تغییر کرد
save 60 10000  # اگر ۱۰۰۰۰ کلید در ۱ دقیقه تغییر کرد

# غیرفعال‌سازی RDB (اگر فقط AOF می‌خواهید)
# save ""

# در صورت خطا در BGSAVE، writes را متوقف کن
stop-writes-on-bgsave-error yes

# فشرده‌سازی RDB با LZF (سریع)
rdbcompression yes

# بررسی checksum فایل RDB (کمی overhead)
rdbchecksum yes

# نام فایل
dbfilename dump.rdb

# مسیر ذخیره (پیش‌فرض: current directory)
dir /var/lib/redis

دستورات RDB

bash
# SAVE - snapshot همزمان (سرور را مسدود می‌کند!)
127.0.0.1:6379> SAVE
OK
# هشدار: در production، این کار را نکنید! از BGSAVE استفاده کنید.

# BGSAVE - snapshot ناهمزمان (پیشنهاد شده)
127.0.0.1:6379> BGSAVE
Background saving started

# بررسی آخرین SAVE موفق
127.0.0.1:6379> LASTSAVE
(integer) 1709827200  # Unix timestamp

# بررسی در حال انجام بودن BGSAVE
127.0.0.1:6379> INFO persistence
# Persistence
rdb_bgsave_in_progress:0
rdb_last_save_time:1709827200
rdb_last_bgsave_status:ok
rdb_last_bgsave_time_sec:2
rdb_current_bgsave_time_sec:-1
...

AOF — Append-Only File

AOF یک log از تمام دستورات نوشتن است. هر بار که یک دستور نوشتن (SET، HSET، LPUSH و...) اجرا می‌شود، Redis آن را به فایل AOF اضافه می‌کند. در صورت restart، Redis تمام دستورات AOF را replay می‌کند تا state قبلی بازسازی شود. مزیت اصلی AOF این است که دوره‌ی از دست رفتن داده بسیار کوتاه‌تر است (معمولاً ۱ ثانیه)، در حالی که RDB ممکن است تا چند دقیقه داده‌ی آخر را از دست بدهد.

تنظیمات AOF

ini
# redis.conf
# فعال‌سازی AOF
appendonly yes

# نام فایل
appendfilename "appendonly.aof"

# در نسخه‌ی ۷: multi-part AOF
appenddirname "appendonlydir"

# فرکانس fsync (مهم‌ترین تنظیم AOF)
# always   - هر دستور را fsync کن (کندترین، امن‌ترین)
# everysec - هر ثانیه fsync کن (توصیه شده، تعادل خوب)
# no       - به OS بسپار (سریع‌ترین، ریسک بیشتر)
appendfsync everysec

# در حین AOF rewrite، fsync نکن
no-appendfsync-on-rewrite no

# شرط‌های rewrite خودکار
auto-aof-rewrite-percentage 100  # وقتی حجم ۱۰۰٪ افزایش یافت
auto-aof-rewrite-min-size 64mb   # حداقل حجم برای rewrite
💡 انتخاب appendfsync

برای اکثر کاربردها، appendfsync everysec بهترین انتخاب است. این تنظیم تعادل خوبی بین performance و durability ارائه می‌دهد — حداکثر ۱ ثانیه داده از دست می‌رود در صورت crash. always بسیار کند است (هر write منتظر fsync می‌ماند) و فقط برای داده‌های فوق‌حساس استفاده می‌شود. no هم به OS اجازه می‌دهد fsync را مدیریت کند، که می‌تواند تا ۳۰ ثانیه داده را در صف نگه دارد.

AOF Rewrite

یک مشکل AOF این است که با گذشت زمان، فایل آن بزرگ می‌شود — حتی اگر کلیدها حذف شوند، دستورات حذف در AOF باقی می‌مانند. برای حل این مشکل، Redis به‌صورت دوره‌ای AOFRewrite انجام می‌دهد: یک نسخه‌ی فشرده‌ی AOF می‌سازد که فقط شامل دستورات لازم برای بازسازی state فعلی است (بدون دستورات حذف شده یا بازنویسی شده):

graph LR A[AOF: 100MB
SET k1 v1
SET k1 v2
SET k1 v3
DEL k2] -->|Rewrite| B[AOF: 1KB
SET k1 v3] style A fill:#45475a,stroke:#f38ba8,color:#cdd6f4 style B fill:#313244,stroke:#a6e3a1,color:#cdd6f4
// aof-rewrite-optimization
bash
# BGREWRITEAOF - اجرای دستی rewrite
127.0.0.1:6379> BGREWRITEAOF
Background append only file rewriting started

# بررسی وضعیت rewrite
127.0.0.1:6379> INFO persistence
# Persistence
aof_enabled:1
aof_rewrite_in_progress:0
aof_rewrite_scheduled:0
aof_last_rewrite_time_sec:5
aof_current_rewrite_time_sec:-1
aof_last_bgrewrite_status:ok
aof_last_write_status:ok
...

ترکیب RDB و AOF

می‌توان همزمان از RDB و AOF استفاده کرد. این کار دو مزیت دارد: RDB برای backup سریع و disaster recovery عالی است، و AOF برای حفظ داده‌های اخیر. در صورت restart، Redis ابتدا AOF را replay می‌کند (چون کامل‌تر است). برای فعال‌سازی هر دو، در redis.conf هر دو را فعال کنید:

ini
# فعال‌سازی هر دو
save 900 1
save 300 10
save 60 10000
appendonly yes
appendfsync everysec

# در نسخه‌ی ۷، می‌توان hybrid را فعال کرد
# این یعنی AOF rewrite شامل یک RDB snapshot به‌علاوه‌ی دستورات اخیر است
aof-use-rdb-preamble yes  # پیش‌فرض: yes

Hybrid AOF (پیش‌فرض در نسخه‌ی ۷) بهترین هر دو مکانیزم را ترکیب می‌کند: فایل AOF با یک RDB snapshot شروع می‌شود (بازسازی سریع) و سپس دستورات اخیر به‌صورت AOF اضافه می‌شوند. این یعنی restart سریع‌تر و فایل کوچک‌تر.

بازیابی از RDB و AOF

وقتی Redis شروع به کار می‌کند، به ترتیب زیر فایل‌ها را چک می‌کند:

۱. اگر appendonly.aof وجود دارد و فعال است، Redis آن را replay می‌کند.

۲. در غیر این صورت، اگر dump.rdb وجود دارد، Redis آن را load می‌کند.

۳. اگر هیچ‌کدام نباشد، Redis با یک دیتابیس خالی شروع می‌کند.

می‌توانید Redis را مجبور کنید از یک فایل خاص load کند:

bash
# استارت با فایل RDB خاص
redis-server --dbfilename backup-2024-01-07.rdb --dir /backups

# استارت با فایل AOF خاص
redis-server --appendonly yes --appendfilename backup-2024-01-07.aof --dir /backups

# غیرفعال‌سازی AOF در هنگام استارت (برای restore دستی)
redis-server --appendonly no

# بررسی لاگ Redis در هنگام استارت
sudo journalctl -u redis-server | tail -50
# یا
sudo cat /var/log/redis/redis-server.log

backup و Restore

یک استراتژی backup خوب شامل مراحل زیر است:

bash
#!/bin/bash
# backup_redis.sh - اسکریپت backup روزانه

BACKUP_DIR=/backups/redis
DATE=$(date +%Y-%m-%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/redis-$DATE.rdb"

mkdir -p $BACKUP_DIR

# 1. BGSAVE برای گرفتن snapshot تازه
redis-cli BGSAVE

# 2. صبر تا اتمام BGSAVE
while [ "$(redis-cli INFO persistence | grep rdb_bgsave_in_progress | cut -d: -f2 | tr -d '\r')" != "0" ]; do
    sleep 1
done

# 3. کپی فایل RDB به مسیر backup
cp /var/lib/redis/dump.rdb $BACKUP_FILE

# 4. فشرده‌سازی
gzip $BACKUP_FILE

# 5. پاک کردن backupهای قدیمی‌تر از ۳۰ روز
find $BACKUP_DIR -name "redis-*.rdb.gz" -mtime +30 -delete

echo "Backup completed: $BACKUP_FILE.gz"
bash
# restore_redis.sh - بازیابی از backup

BACKUP_FILE=$1

if [ -z "$BACKUP_FILE" ]; then
    echo "Usage: $0 <backup_file.rdb.gz>"
    exit 1
fi

# 1. توقف Redis
sudo systemctl stop redis-server

# 2. backup فایل فعلی
cp /var/lib/redis/dump.rdb /var/lib/redis/dump.rdb.bak.$(date +%s)

# 3. اکسترکت فایل backup
gunzip -c $BACKUP_FILE > /var/lib/redis/dump.rdb

# 4. تنظیم owner
chown redis:redis /var/lib/redis/dump.rdb

# 5. شروع Redis
sudo systemctl start redis-server

# 6. بررسی
sleep 2
redis-cli ping
redis-cli DBSIZE

echo "Restore completed from $BACKUP_FILE"

Debug فایل AOF خراب

گاهی فایل AOF ممکن است خراب شود (مثلاً به‌خاطر قطع برق در حین نوشتن). Redis ابزاری برای این مورد دارد:

bash
# بررسی فایل AOF
redis-check-aof /var/lib/redis/appendonly.aof

# اگر فایل خراب است، repair کن (آخرین دستور ناقص را cut می‌کند)
redis-check-aof --fix /var/lib/redis/appendonly.aof

# بررسی فایل RDB
redis-check-rdb /var/lib/redis/dump.rdb

مقایسه‌ی RDB و AOF

ویژگی RDB AOF
فرمتباینری فشردهمتن (دستورات)
دوره‌ی از دست رفتن دادهتا چند دقیقه۱ ثانیه (everysec)
سرعت نوشتنسریع (فقط در snapshot)کندتر (هر دستور)
سرعت restartسریع (فقط load)کندتر (replay همه دستورات)
اندازه‌ی فایلکوچکبزرگ‌تر
قابل خواندنخیر (باینری)بله (متن)
backup مناسببله (یک فایل)بله ولی بزرگ‌تر
قابل reproductionسختآسان (replay)
توصیه برایbackup، disaster recoverydurability، 최소 از دست رفتن

چه زمان از چه چیزی استفاده کنیم؟

فقط RDB: اگر داده‌های شما cache است و از دست رفتن چند دقیقه داده قابل قبول است. RDB سبک و سریع است.

فقط AOF: اگر داده‌های حیاتی دارید و نمی‌توانید حتی ۱ ثانیه از دست رفتن داده را تحمل کنید.

هر دو (توصیه شده): برای production. AOF برای durability روزانه، RDB برای backup و disaster recovery.

هیچ‌کدام: اگر Redis را به‌عنوان cache خالص استفاده می‌کنید و داده‌ها از جای دیگری قابل بازسازی هستند. این حالت، عملکرد را به حداکثر می‌رساند.

تأثیر fork روی حافظه

هر دو RDB و AOF rewrite از fork() استفاده می‌کنند. در Linux، fork از copy-on-write استفاده می‌کند: یعنی memory واقعاً کپی نمی‌شود مگر اینکه تغییر کند. اما اگر در حین BGSAVE، تعداد زیادی write انجام شود، memory ممکن است دو برابر شود. این باید در ظرفیت‌سنجی مد نظر قرار گگیرد:

ini
# redis.conf
# تنظیم vm.overcommit_memory = 1 در /etc/sysctl.conf
# برای اجازه دادن به fork حتی اگر memory کم باشد
# sudo sysctl vm.overcommit_memory=1

# فعال‌سازی transparent huge pages (پیشنهاد: disable)
# echo never > /sys/kernel/mm/transparent_hugepage/enabled
⚠️ هشدار حافظه

اگر Redis شما ۱۰ GB memory استفاده می‌کند و write rate بالایی دارید، در حین BGSAVE ممکن است memory به ۱۵-۲۰ GB برسد. باید سرور حداقل ۲ برابر memory Redis را داشته باشد. در غیر این صورت، OOM kill رخ می‌دهد.

تنظیمات بهینه برای production

ini
# توصیه‌های production

# RDB - snapshot هر ساعت در ساعات کم‌باری
save 3600 1     # هر ساعت اگر ۱ کلید تغییر کرد
save 300 100    # هر ۵ دقیقه اگر ۱۰۰ کلید تغییر کرد
save 60 10000   # هر دقیقه اگر ۱۰۰۰۰ کلید تغییر کرد

# AOF - با everysec
appendonly yes
appendfsync everysec
no-appendfsync-on-rewrite yes  # در حین rewrite، fsync نکن (جلوگیری از disk I/O دوچندان)

# AOF rewrite خودکار
auto-aof-rewrite-percentage 100
auto-aof-rewrite-min-size 64mb

# Hybrid (پیش‌فرض در ۷)
aof-use-rdb-preamble yes

# RDB فشرده
rdbcompression yes
rdbchecksum yes

# مسیر ذخیره روی دیسک جداگانه (بهتر برای I/O)
dir /var/lib/redis

مانیتورینگ Persistence

برای اطمینان از کار درست persistence، باید به‌طور منظم این متریک‌ها را چک کنید:

bash
# بررسی وضعیت کامل persistence
127.0.0.1:6379> INFO persistence
# Persistence
loading:0
async_loading:0
current_cow_peak:0
current_cow_size:0
current_fork_perc:0.00
current_fork_time_msec:0
total_forks:15
rdb_changes_since_last_save:0
rdb_bgsave_in_progress:0
rdb_last_save_time:1709827200
rdb_last_bgsave_status:ok
rdb_last_bgsave_time_sec:2
rdb_current_bgsave_time_sec:-1
rdb_last_cow_size:1048576
rdb_last_load_keys_expired:0
rdb_last_load_keys_loaded:1000
aof_enabled:1
aof_rewrite_in_progress:0
aof_rewrite_scheduled:0
aof_last_rewrite_time_sec:5
aof_current_rewrite_time_sec:-1
aof_last_bgrewrite_status:ok
aof_last_write_status:ok
aof_last_cow_size:2097152
module_fork_in_progress:0
module_fork_last_cow_size:0

# بررسی حجم فایل‌ها روی دیسک
ls -lh /var/lib/redis/
# dump.rdb     10MB
# appendonly.aof  50MB

# بررسی لاگ برای خطاها
grep -E "(BGSAVE|AOF|fork)" /var/log/redis/redis-server.log | tail -20
💡 اسکریپت مانیتورینگ ساده

یک اسکریپت cron که هر ۵ دقیقه چک می‌کند rdb_last_bgsave_status و aof_last_write_status برابر ok هستند. اگر نبودند، alert بفرستد. این کار به شما کمک می‌کند زود از مشکلات persistence مطلع شوید.

📝 خلاصه فصل

در این فصل با پایداری داده در Redis آشنا شدید: RDB برای snapshot‌های فشرده و سریع، AOF برای durability بالا، و ترکیب هر دو برای production. همچنین با backup، restore، debug فایل‌های خراب، و تأثیر fork بر memory آشنا شدید. انتخاب درست persistence strategy به الزامات durability، performance، و فضای دیسک شما بستگی دارد. در فصل بعدی، با Replication و Sentinel برای High Availability کار می‌کنیم.

Chapter 20

Replication و Sentinel #

در محیط production، یک سرور Redis تکی یک single point of failure است. اگر سرور crash کند، کل سرویس از دسترس خارج می‌شود. برای حل این مشکل، Redis دو مکانیزم اصلی ارائه می‌دهد: Replication برای داشتن کپی‌های زنده از داده‌ها روی سرورهای دیگر، و Sentinel برای مدیریت خودکار failover و High Availability. در این فصل، با هر دو مکانیزم، نحوه‌ی پیکربندی، و best practices آشنا می‌شوید.

Replication — Master/Replica

در مدل Replication Redis، یک سرور به‌عنوان master عمل می‌کند و می‌پذیرد writes و reads. یک یا چند سرور دیگر به‌عنوان replica (در نسخه‌های قدیمی slave) عمل می‌کنند و داده‌ها را از master کپی می‌کنند. writes فقط روی master انجام می‌شود و سپس به replica‌ها propagate می‌شود. reads می‌تواند از master یا replica انجام شود — این کار load balancing خواندن را ممکن می‌کند.

graph TB C1[Client Write] --> M[Master
127.0.0.1:6379] C2[Client Read] --> R1[Replica 1
127.0.0.1:6380] C3[Client Read] --> R2[Replica 2
127.0.0.1:6381] M -->|replicate async|R1 M -->|replicate async|R2 style M fill:#313244,stroke:#c084fc,color:#cdd6f4 style R1 fill:#313244,stroke:#a6e3a1,color:#cdd6f4 style R2 fill:#313244,stroke:#a6e3a1,color:#cdd6f4
// master-replica-replication

راه‌اندازی Replication

راه‌اندازی replication بسیار ساده است. کافی است در redis.conf سرور replica، replicaof را تنظیم کنید:

ini
# redis.conf برای Replica 1
# master آدرس و پورت
replicaof 127.0.0.1 6379

# اگر master رمز عبور دارد
masterauth your_strong_password

# اگر از ACL استفاده می‌کنید (نسخه ۶ به بعد)
# masteruser replica_user
# masterauth replica_user_password

# در نسخه‌های قدیمی: slaveof 127.0.0.1 6379

# replica فقط خواندنی باشد (پیش‌فرض)
replica-read-only yes

# در صورت قطع موقت master، از data قدیمی استفاده کن
replica-serve-stale-data yes

# اولویت replica برای انتخاب به‌عنوان master در failover
# 0 = هرگز master نشو
replica-priority 100
bash
# یا با خط فرمان (بدون restart)
redis-cli -p 6380
127.0.0.1:6380> REPLICAOF 127.0.0.1 6379
OK

# بررسی وضعیت replication از روی master
127.0.0.1:6379> INFO replication
# Replication
role:master
connected_slaves:2
slave0:ip=127.0.0.1,port=6380,state=online,offset=1024,lag=0
slave1:ip=127.0.0.1,port=6381,state=online,offset=1024,lag=0
master_failover_state:no-failover
master_replid:8abc123def456...
master_replid2:000000000000...
master_repl_offset:1024
second_repl_offset:-1
repl_backlog_active:1
repl_backlog_size:1048576
repl_backlog_first_byte_offset:1
repl_backlog_histlen:1024

# بررسی وضعیت از روی replica
127.0.0.1:6380> INFO replication
# Replication
role:slave
master_host:127.0.0.1
master_port:6379
master_link_status:up
master_last_io_seconds_ago:0
master_sync_in_progress:0
slave_read_repl_offset:1024
slave_repl_offset:1024
slave_priority:100
slave_read_only:1
replica_announced:1
connected_slaves:0
master_failover_state:no-failover
master_replid:8abc123def456...
master_replid2:000000000000...
master_repl_offset:1024
second_repl_offset:-1
repl_backlog_active:1
repl_backlog_size:1048576
...

نحوه‌ی کار Replication

Replication در Redis به دو مرحله تقسیم می‌شود:

۱. Initial Synchronization (Full Sync): وقتی replica برای اولین بار به master وصل می‌شود، master یک BGSAVE انجام می‌دهد و فایل RDB را به replica می‌فرستد. replica این فایل را load می‌کند و سپس دستورات جدیدی که در حین BGSAVE نوشته شده‌اند را از master می‌گیرد و اعمال می‌کند.

۲. Incremental Synchronization (PSYNC): پس از full sync، replica به‌صورت پیوسته دستورات جدید را از master دریافت می‌کند. اگر connection قطع شود و دوباره وصل شود، replica با PSYNC سعی می‌کند فقط بخش از دست رفته را بگیرد (از replication backlog) به‌جای full sync کامل.

sequenceDiagram participant R as Replica participant M as Master R->>M: PSYNC ? -1 (اولین اتصال) M-->>R: +FULLRESYNC <replid> <offset> M->>M: BGSAVE M->>R: $<length>\r\n + RDB file M-->>R: دستورات جدید (از backlog) Note over R,M: حالت پایدار - replication پیوسته R->>M: ACK <offset>
// psync-full-resync

Replication Backlog

Replication backlog یک بافر حلقوی در master است که آخرین دستورات را نگه می‌دارد. اگر replica موقتاً قطع شود و دوباره وصل شود، می‌تواند بخش از دست رفته را از backlog بگیرد به‌جای full sync. اندازه‌ی backlog با repl-backlog-size تنظیم می‌شود:

ini
# redis.conf (روی master)
# اندازه‌ی backlog (پیش‌فرض 1MB)
# برای write rate بالا، بزرگ‌تر تنظیم کنید (مثلاً 100MB)
repl-backlog-size 100mb

# مدت زمان نگه‌داشتن backlog پس از قطع آخرین replica
# 1h = 1 ساعت، 0 = همیشه
repl-backlog-ttl 3600

# timeout برای heartbeat (ثانیه)
repl-timeout 60

# فعال‌سازی TCP_NODELAY (برای latency پایین)
repl-disable-tcp-nodelay no

# در نسخه‌ی ۷: مهاجرت replica به master بدون full sync
# (با رعایت شرایطی)
replica-announced yes
💡 محاسبه‌ی backlog size

اندازه‌ی backlog باید حداقل برابر باشد با: write_rate_per_second × max_disconnect_time. مثلاً اگر write rate شما ۱ مگابایت بر ثانیه است و می‌خواهید قطع تا ۶۰ ثانیه را تحمل کنید، backlog باید حداقل ۶۰ مگابایت باشد.

Read Replicas — Load Balancing خواندن

یکی از کاربردهای اصلی replication، load balancing خواندن است. می‌توان writes را به master و reads را به replica‌ها فرستاد. این کار capacity خواندن را به‌طور خطی افزایش می‌دهد. اما باید توجه داشت که replication در Redis asynchronous است، یعنی ممکن است replica کمی عقب‌تر از master باشد (replication lag):

python
import redis

# Master connection (برای writes)
master = redis.Redis(host='master.redis.local', port=6379, decode_responses=True)

# Replica connections (برای reads)
replicas = [
    redis.Redis(host='replica1.redis.local', port=6379, decode_responses=True),
    redis.Redis(host='replica2.redis.local', port=6379, decode_responses=True),
]

import random

class ReadWriteSplit:
    def __init__(self, master, replicas):
        self.master = master
        self.replicas = replicas
        self._replica_idx = 0

    def write(self, key, value):
        """writes همیشه به master"""
        return self.master.set(key, value)

    def read(self, key):
        """reads به round-robin در replica‌ها"""
        replica = self.replicas[self._replica_idx]
        self._replica_idx = (self._replica_idx + 1) % len(self.replicas)
        return replica.get(key)

    def read_strong(self, key):
        """خواندن strong consistency - مستقیم از master"""
        return self.master.get(key)

# استفاده
rws = ReadWriteSplit(master, replicas)
rws.write("user:1", "Alice")
print(rws.read("user:1"))  # ممکن است در لحظه اول nil بدهد (lag)
⚠️ نکته درباره‌ی Consistency

Replication در Redis asynchronous است. این یعنی بعد از write به master، ممکن است هنوز به replica نرسیده باشد. اگر کاربر بلافاصله بخواند از replica، ممکن است داده‌ی قدیمی ببیند (read-your-writes violation). برای رفع این مشکل، یا از master برای read پس از write استفاده کنید، یا از WAIT command برای صبر تا رسیدن به N replica استفاده کنید.

دستور WAIT — صبر برای Replication

WAIT به شما اجازه می‌دهد بعد از یک write، صبر کنید تا N replica آن را دریافت کنند:

bash
# SET یک مقدار
127.0.0.1:6379> SET important_key "important_value"
OK

# WAIT برای 2 replica تا 1 ثانیه
127.0.0.1:6379> WAIT 2 1000
(integer) 2  # ۲ replica دریافت کردند

# WAIT 0 = بی‌نهایت صبر کن (خطرناک!)
# WAIT numreplicas timeout_ms
# خروجی: تعداد replicaهایی که دریافت کردند

توجه: WAIT فقط تأیید می‌کند که replica‌ها داده را دریافت کرده‌اند، نه اینکه appliance کرده باشند. با این حال، در عمل این تأیید معمولاً کافی است.

Sentinel — High Availability

Redis Sentinel یک سیستم مستقل برای مانیتورینگ و failover خودکار است. Sentinel یک process جداگانه است که master و replica‌ها را نظارت می‌کند و در صورت قطع master، به‌صورت خودکار یکی از replica‌ها را به master تبدیل می‌کند. علاوه بر این، Sentinel به کلاینت‌ها اطلاعات می‌دهد که کدام node master است — این یعنی کلاینت‌ها نیازی ندارند مستقیماً مدیریت failover کنند.

graph TB S1[Sentinel 1] S2[Sentinel 2] S3[Sentinel 3] M[Master] R1[Replica 1] R2[Replica 2] S1 -.monitor.-> M S1 -.monitor.-> R1 S1 -.monitor.-> R2 S2 -.monitor.-> M S2 -.monitor.-> R1 S2 -.monitor.-> R2 S3 -.monitor.-> M S3 -.monitor.-> R1 S3 -.monitor.-> R2 S1 <-.gossip.-> S2 S2 <-.gossip.-> S3 S1 <-.gossip.-> S3 M -->|replicate|R1 M -->|replicate|R2 style M fill:#313244,stroke:#c084fc,color:#cdd6f4 style R1 fill:#313244,stroke:#a6e3a1,color:#cdd6f4 style R2 fill:#313244,stroke:#a6e3a1,color:#cdd6f4 style S1 fill:#45475a,stroke:#f9e2af,color:#cdd6f4 style S2 fill:#45475a,stroke:#f9e2af,color:#cdd6f4 style S3 fill:#45475a,stroke:#f9e2af,color:#cdd6f4
// sentinel-architecture

پیکربندی Sentinel

برای High Availability واقعی، حداقل ۳ Sentinel لازم است (یا هر عدد فرد ≥ ۳). این به این دلیل است که برای failover، اکثریت Sentinel‌ها (quorum) باید موافق باشند که master قطع شده است:

ini
# sentinel.conf (روی هر Sentinel)
port 26379  # پورت Sentinel (پیش‌فرض)

# مانیتور کردن master با نام mymaster
# sentinel monitor <name> <ip> <port> <quorum>
sentinel monitor mymaster 127.0.0.1 6379 2

# اگر master رمز عبور دارد
sentinel auth-pass mymaster your_strong_password

# ACL (نسخه ۶ به بعد)
# sentinel auth-user mymaster sentinel_user

# مدت زمانی که بعد از آن Sentinel master را down می‌داند (ms)
sentinel down-after-milliseconds mymaster 30000  # ۳۰ ثانیه

# مدت زمانی که Sentinel برای failover صبر می‌کند (ms)
sentinel failover-timeout mymaster 180000  # ۳ دقیقه

# همزمان چند replica می‌توانند در حین failover reconfigure شوند
sentinel parallel-syncs mymaster 1

# در صورت قطع موقت Sentinel‌ها از master، داده‌ی قدیمی استفاده کن
sentinel deny-scripts-reconfig yes

# در نسخه‌ی ۷: فعال‌سازی announcements
# sentinel announce-ip <ip>
# sentinel announce-port <port>

اجرای Sentinel

bash
# اجرای Sentinel (روی ۳ سرور جداگانه)
redis-sentinel /etc/redis/sentinel.conf

# یا با redis-server --sentinel
redis-server /etc/redis/sentinel.conf --sentinel

# اتصال به Sentinel با redis-cli
redis-cli -p 26379

# دستورات Sentinel
127.0.0.1:26379> SENTINEL masters
1)  1) "name"
    2) "mymaster"
    3) "ip"
    4) "127.0.0.1"
    5) "port"
    6) "6379"
    7) "runid"
    8) "..."
    9) "flags"
    10) "master"
    ...

# SENTINEL master mymaster - اطلاعات یک master
127.0.0.1:26379> SENTINEL master mymaster

# SENTINEL replicas mymaster - لیست replica‌ها
127.0.0.1:26379> SENTINEL replicas mymaster

# SENTINEL sentinels mymaster - لیست Sentinel‌های دیگر
127.0.0.1:26379> SENTINEL sentinels mymaster

# SENTINEL get-master-addr-by-name mymaster
127.0.0.1:26379> SENTINEL get-master-addr-by-name mymaster
1) "127.0.0.1"
2) "6379"

# SENTINEL failover mymaster - اجبار به failover
127.0.0.1:26379> SENTINEL failover mymaster
OK

# SENTINEL reset mymaster - reset همه‌ی stateها
127.0.0.1:26379> SENTINEL reset mymaster

# SENTINEL ckquorum mymaster - بررسی quorum
127.0.0.1:26379> SENTINEL ckquorum mymaster
OK 2 usable Sentinels. Quorum and failover authorization can be reached

مراحل Failover

وقتی master قطع می‌شود، Sentinel‌ها فرآیند failover زیر را طی می‌کنند:

۱. Detection: هر Sentinel به‌صورت دوره‌ای (هر ۱ ثانیه) PING به master می‌فرستد. اگر پس از down-after-milliseconds پاسخی نیاید، Sentinel آن را subjectively down (SDOWN) اعلام می‌کند.

۲. Quorum: اگر quorum (تعداد آستانه‌ی تنظیم‌شده) Sentinel‌ها همگی master را SDOWN اعلام کنند، به objectively down (ODOWN) تبدیل می‌شود.

۳. Election: Sentinel‌ها با الگوریتم Raft یک Sentinel را به‌عنوان leader انتخاب می‌کنند.

۴. Selection: Leader بهترین replica را برای تبدیل شدن به master انتخاب می‌کند (بر اساس اولویت، replication offset، runid).

۵. Promotion: Leader دستور SLAVEOF NO ONE به replica انتخاب‌شده می‌فرستد تا تبدیل به master شود.

۶. Reconfiguration: Leader به سایر replica‌ها دستور می‌دهد که به master جدید وصل شوند.

۷. Notification: Sentinel‌ها به کلاینت‌ها (از طریق pub/sub) خبر می‌دهند که master جدید کیست.

اتصال کلاینت به Sentinel

کلاینت‌های مدرن Redis (مانند redis-py) به‌طور بومی از Sentinel پشتیبانی می‌کنند. کلاینت به جای اتصال مستقیم به master، به Sentinel‌ها وصل می‌شود و آدرس master را از آن‌ها می‌گیرد:

python
from redis.sentinel import Sentinel

# لیست Sentinel‌ها
sentinel = Sentinel([
    ('sentinel1.redis.local', 26379),
    ('sentinel2.redis.local', 26379),
    ('sentinel3.redis.local', 26379),
], socket_timeout=0.5)

# گرفتن master connection
master = sentinel.master_for('mymaster', socket_timeout=0.5)
# گرفتن replica connection (برای reads)
replica = sentinel.slave_for('mymaster', socket_timeout=0.5)

# writes به master
master.set('hello', 'world')

# reads از replica
print(replica.get('hello'))

# در صورت failover، کتابخانه به‌طور خودکار به master جدید وصل می‌شود

Sentinel در Docker Compose

در اینجا یک پیکربندی کامل Sentinel با Docker Compose می‌بینید:

yaml
version: '3.8'

services:
  redis-master:
    image: redis:7
    container_name: redis-master
    command: redis-server --appendonly yes
    ports:
      - "6379:6379"
    networks:
      - redis-net

  redis-replica1:
    image: redis:7
    container_name: redis-replica1
    command: redis-server --replicaof redis-master 6379 --appendonly yes
    depends_on:
      - redis-master
    ports:
      - "6380:6379"
    networks:
      - redis-net

  redis-replica2:
    image: redis:7
    container_name: redis-replica2
    command: redis-server --replicaof redis-master 6379 --appendonly yes
    depends_on:
      - redis-master
    ports:
      - "6381:6379"
    networks:
      - redis-net

  sentinel1:
    image: redis:7
    container_name: sentinel1
    command: |
      sh -c 'echo "
      port 26379
      sentinel monitor mymaster redis-master 6379 2
      sentinel down-after-milliseconds mymaster 30000
      sentinel failover-timeout mymaster 180000
      sentinel parallel-syncs mymaster 1
      " > /etc/redis/sentinel.conf &&
      redis-server /etc/redis/sentinel.conf --sentinel'
    depends_on:
      - redis-master
    ports:
      - "26379:26379"
    networks:
      - redis-net

  sentinel2:
    image: redis:7
    container_name: sentinel2
    command: |
      sh -c 'echo "
      port 26379
      sentinel monitor mymaster redis-master 6379 2
      sentinel down-after-milliseconds mymaster 30000
      " > /etc/redis/sentinel.conf &&
      redis-server /etc/redis/sentinel.conf --sentinel'
    depends_on:
      - redis-master
    ports:
      - "26380:26379"
    networks:
      - redis-net

  sentinel3:
    image: redis:7
    container_name: sentinel3
    command: |
      sh -c 'echo "
      port 26379
      sentinel monitor mymaster redis-master 6379 2
      sentinel down-after-milliseconds mymaster 30000
      " > /etc/redis/sentinel.conf &&
      redis-server /etc/redis/sentinel.conf --sentinel'
    depends_on:
      - redis-master
    ports:
      - "26381:26379"
    networks:
      - redis-net

networks:
  redis-net:
    driver: bridge

Best Practices برای Sentinel

۱. حداقل ۳ Sentinel. برای تحمل قطع یک Sentinel، باید ۳ Sentinel داشته باشید. برای تحمل قطع دو Sentinel، ۵ Sentinel لازم است.

۲. Sentinel‌ها روی سرورهای جداگانه. اگر Sentinel روی همان سرور master باشد، با crash سرور، Sentinel هم از دسترس خارج می‌شود.

۳. تنطیم درست quorum. پیشنهاد: quorum = ceil(N/2) + 1 برای N Sentinel.

۴. down-after-milliseconds معقول. خیلی کوتاه (مثلاً ۵ ثانیه) باعث failover‌های اشتباه می‌شود. خیلی طولانی (مثلاً ۵ دقیقه) باعث downtime طولانی می‌شود. ۳۰ ثانیه یک نقطه‌ی شروع خوب است.

۵. replica-priority. اگر یک replica ضعیف‌تر دارید (مثلاً روی سرور با CPU کمتر)، replica-priority آن را پایین تنظیم کنید تا در failover آخرین انتخاب شود.

محدودیت‌های Sentinel

با وجود قابلیت‌های فراوان، Sentinel محدودیت‌هایی دارد:

۱. فقط یک master. Sentinel برای یک master و replica‌های آن طراحی شده است. اگر چند master دارید، باید Sentinel‌های جداگانه داشته باشید.

۲. بدون sharding. Sentinel داده‌ها را بین node‌ها تقسیم نمی‌کند. همه‌ی داده‌ها روی master هستند و replica‌ها کپی کامل دارند. برای sharding، از Redis Cluster استفاده کنید.

۳. ممکن است داده از دست برود. اگر master crash کند قبل از اینکه داده‌ها به replica propagate شوند، آن داده‌ها از دست می‌روند. این به تنظیم min-replicas-to-write بستگی دارد.

۴. failover ممکن است زمان‌بر باشد. از detection تا completion، failover ممکن است ۳۰ ثانیه تا چند دقیقه طول بکشد.

Sentinel Notifications با Pub/Sub

Sentinel از طریق Pub/Sub رویدادها را به کلاینت‌ها اطلاع می‌دهد. این کار برای به‌روزرسانی خودکار کلاینت‌ها مفید است:

python
import redis

# اتصال به Sentinel (نه master!)
sentinel = redis.Redis(host='sentinel1.redis.local', port=26379, decode_responses=True)

# subscribe به کانال‌های Sentinel
pubsub = sentinel.pubsub()
pubsub.psubscribe('+sdown', '+odown', '+failover-start', '+failover-end',
                  '+switch-master', '+slave-reconf-sent')

for message in pubsub.listen():
    if message['type'] == 'pmessage':
        print(f"[{message['pattern']}] {message['data']}")

# مثال خروجی:
# [+sdown] master mymaster 127.0.0.1 6379
# [+odown] master mymaster 127.0.0.1 6379 #quorum 2/2
# [+failover-start] Start the failover mymaster.
# [+selected-slave] slave 127.0.0.1:6380 selected as new master.
# [+failover-end] Failover for master mymaster done.
# [+switch-master] mymaster 127.0.0.1 6379 127.0.0.1 6380
📝 خلاصه فصل

در این فصل با Replication و Sentinel آشنا شدید: راه‌اندازی master/replica، PSYNC، replication backlog، read/write splitting، و Sentinel برای HA با failover خودکار. Sentinel راه‌حل اصلی Redis برای High Availability است و توصیه می‌شود در production همیشه از آن استفاده کنید. اما برای مقیاس‌پذیری افقی (sharding)، باید از Redis Cluster استفاده کنید که در فصل بعدی بررسی می‌کنیم.

Chapter 21

Redis Cluster #

Redis Cluster راه‌حل رسمی Redis برای مقیاس‌پذیری افقی (horizontal scaling) و sharding است. در حالی که Sentinel یک master و چند replica را مدیریت می‌کند (با کپی کامل داده‌ها)، Cluster داده‌ها را بین چند master تقسیم می‌کند — هر master فقط بخشی از کل داده‌ها را در اختیار دارد. این یعنی می‌توانید ظرفیت ذخیره‌سازی و throughput را با اضافه کردن node‌های بیشتر افزایش دهید. در این فصل، با معماری Cluster، نحوه‌ی راه‌اندازی، hash slots، و عملیات روزانه آشنا می‌شوید.

معماری Redis Cluster

Redis Cluster از چندین node تشکیل می‌شود که هر کدام نقش master یا replica دارند. داده‌ها به ۱۶۳۸۴ hash slot تقسیم می‌شوند و این slot‌ها بین master‌ها پخش می‌شوند. هر کلید با استفاده از الگوریتم CRC16 به یک slot نگاشت می‌شود. اگر کلاینت به یک node درخواست بفرستد که کلید روی آن نیست، node یک MOVED redirect برمی‌گرداند و کلاینت باید به node درست برود:

graph TB C[Client] -->|GET user:1001| N1[Node 1
slots 0-5460] N1 -.MOVED to Node 2.-> C C -->|GET user:1001| N2[Node 2
slots 5461-10922] N2 -->|return value| C subgraph "Redis Cluster" N1 N2 N3[Node 3
slots 10923-16383] N1R[Replica of N1] N2R[Replica of N2] N3R[Replica of N3] end N1 -.replication.-> N1R N2 -.replication.-> N2R N3 -.replication.-> N3R style N1 fill:#313244,stroke:#c084fc,color:#cdd6f4 style N2 fill:#313244,stroke:#2dd4bf,color:#cdd6f4 style N3 fill:#313244,stroke:#f38ba8,color:#cdd6f4
// redis-cluster-architecture

Hash Slots و Key Tagging

Redis Cluster از ۱۶٬۳۸۴ slot استفاده می‌کند (نه ۶۵٬۵۳۶ که عدد متداول‌تری است). دلیل این عدد خاص به نگاهه‌ای به الگوریتم heartbeat برمی‌گردد: هر node در هر heartbeat پیام، bitmap‌ای از slot‌هایی که دارد را به بقیه node‌ها می‌فرستد. اگر slot‌ها ۱۶٬۳۸۴ باشند، این bitmap فقط ۲ کیلوبایت است و در هر پیام جا می‌شود.

هر کلید با CRC16(key) mod 16384 به یک slot نگاشت می‌شود. این یعنی نمی‌توانید انتظار داشته باشید دو کلید مختلف روی یک node باشند. برای اجرای عملیات چندکلیدی (مانند MGET یا transaction)، باید از hash tags استفاده کنید:

bash
# کلید معمولی - slot بر اساس کل کلید
# user:1001 - slot = CRC16("user:1001") % 16384 = 5061
# user:1001:settings - slot = CRC16("user:1001:settings") % 16384 = 9923
# این دو روی slot‌های مختلف هستند!

# Hash tag با {}
# {user:1001}:profile - slot = CRC16("user:1001") % 16384 = 5061
# {user:1001}:settings - slot = CRC16("user:1001") % 16384 = 5061
# حالا هر دو روی همان slot هستند

# مثال: transaction روی چند کلید هم‌slot
127.0.0.1:6379> MULTI
127.0.0.1:6379> SET {user:1001}:name "Alice"
127.0.0.1:6379> SET {user:1001}:age 30
127.0.0.1:6379> HSET {user:1001}:stats views 0
127.0.0.1:6379> EXEC
💡 الگوی Hash Tag

قانون کلی: اگر چند کلید به‌طور منطقی به یک موجودیت تعلق دارند و ممکن است در یک عملیات استفاده شوند، از hash tag استفاده کنید. مثلاً {user:1001}:profile، {user:1001}:settings، {user:1001}:posts — همه روی یک slot و در نتیجه روی یک node هستند.

راه‌اندازی Redis Cluster

راه‌اندازی Cluster حداقل به ۶ node نیاز دارد (۳ master + ۳ replica). در اینجا با Docker Compose یک Cluster کامل راه‌اندازی می‌کنیم:

yaml
# docker-compose.yml
version: '3.8'

services:
  redis-node-1:
    image: redis:7
    container_name: redis-node-1
    command: redis-server --port 7000 --cluster-enabled yes --cluster-config-file nodes.conf --cluster-node-timeout 5000 --appendonly yes
    ports:
      - "7000:7000"
    networks:
      - redis-cluster

  redis-node-2:
    image: redis:7
    container_name: redis-node-2
    command: redis-server --port 7001 --cluster-enabled yes --cluster-config-file nodes.conf --cluster-node-timeout 5000 --appendonly yes
    ports:
      - "7001:7001"
    networks:
      - redis-cluster

  redis-node-3:
    image: redis:7
    container_name: redis-node-3
    command: redis-server --port 7002 --cluster-enabled yes --cluster-config-file nodes.conf --cluster-node-timeout 5000 --appendonly yes
    ports:
      - "7002:7002"
    networks:
      - redis-cluster

  redis-node-4:
    image: redis:7
    container_name: redis-node-4
    command: redis-server --port 7003 --cluster-enabled yes --cluster-config-file nodes.conf --cluster-node-timeout 5000 --appendonly yes
    ports:
      - "7003:7003"
    networks:
      - redis-cluster

  redis-node-5:
    image: redis:7
    container_name: redis-node-5
    command: redis-server --port 7004 --cluster-enabled yes --cluster-config-file nodes.conf --cluster-node-timeout 5000 --appendonly yes
    ports:
      - "7004:7004"
    networks:
      - redis-cluster

  redis-node-6:
    image: redis:7
    container_name: redis-node-6
    command: redis-server --port 7005 --cluster-enabled yes --cluster-config-file nodes.conf --cluster-node-timeout 5000 --appendonly yes
    ports:
      - "7005:7005"
    networks:
      - redis-cluster

networks:
  redis-cluster:
    driver: bridge
bash
# 1. اجرای همه‌ی node‌ها
docker-compose up -d

# 2. ساخت Cluster با redis-cli
redis-cli --cluster create \
    127.0.0.1:7000 127.0.0.1:7001 127.0.0.1:7002 \
    127.0.0.1:7003 127.0.0.1:7004 127.0.0.1:7005 \
    --cluster-replicas 1

# خروجی:
# >>> Performing hash slots allocation on 6 nodes...
# Master[0] -> Slots 0 - 5460
# Master[1] -> Slots 5461 - 10922
# Master[2] -> Slots 10923 - 16383
# Adding replica 127.0.0.1:7003 to 127.0.0.1:7000
# Adding replica 127.0.0.1:7004 to 127.0.0.1:7001
# Adding replica 127.0.0.1:7005 to 127.0.0.1:7002
# ...
# Can I set the above configuration? (type 'yes' to accept): yes

# 3. بررسی Cluster
redis-cli -p 7000 CLUSTER INFO
# cluster_enabled:1
# cluster_state:ok
# cluster_slots_assigned:16384
# cluster_slots_ok:16384
# cluster_slots_pfail:0
# cluster_slots_fail:0
# cluster_known_nodes:6
# cluster_size:3
# ...

# 4. مشاهده‌ی node‌ها
redis-cli -p 7000 CLUSTER NODES
# id  address  flags  master  ping_sent  pong_recv  epoch  link_status  slots
# abc123... 127.0.0.1:7000@17000 myself,master - 0 0 1 connected 0-5460
# def456... 127.0.0.1:7001@17001 master - 0 0 2 connected 5461-10922
# ghi789... 127.0.0.1:7002@17002 master - 0 0 3 connected 10923-16383
# jkl012... 127.0.0.1:7003@17003 slave abc123... 0 0 1 connected
# ...

# 5. مشاهده‌ی slot‌ها به تفکیک node
redis-cli -p 7000 CLUSTER SLOTS

کار با Cluster

وقتی به یک node از Cluster وصل می‌شوید و دستور می‌زنید، اگر کلید روی آن node نباشد، خطای MOVED می‌گیرید:

bash
# اتصال به node 7000
$ redis-cli -p 7000
127.0.0.1:7000> SET user:1 "Alice"
(error) MOVED 9189 127.0.0.1:7001
# کلید user:1 روی slot 9189 است که در node 7001 است

# روش ۱: اتصال به node درست
$ redis-cli -p 7001
127.0.0.1:7001> SET user:1 "Alice"
OK

# روش ۲: حالت cluster در redis-cli (به‌طور خودکار redirect می‌کند)
$ redis-cli -c -p 7000
127.0.0.1:7000> SET user:1 "Alice"
-> Redirected to slot [9189] located at 127.0.0.1:7001
OK
127.0.0.1:7001>

# بدون -c، خطای MOVED می‌گیرید و خودتان باید هندل کنید

اتصال با redis-py به Cluster

python
from redis.cluster import RedisCluster, ClusterNode

# روش ۱: با startup nodes
startup_nodes = [
    ClusterNode('127.0.0.1', 7000),
    ClusterNode('127.0.0.1', 7001),
    ClusterNode('127.0.0.1', 7002),
]

rc = RedisCluster(startup_nodes=startup_nodes, decode_responses=True)

# عملیات معمولی (به‌طور خودکار به node درست می‌رود)
rc.set('user:1', 'Alice')
print(rc.get('user:1'))  # Alice

# عملیات با hash tag (تضمین همان slot)
rc.set('{user:1}:name', 'Alice')
rc.set('{user:1}:age', 30)
rc.set('{user:1}:email', 'alice@example.com')

# MGET روی کلیدهای هم‌slot
print(rc.mget('{user:1}:name', '{user:1}:age', '{user:1}:email'))
# ['Alice', '30', 'alice@example.com']

# روش ۲: ساده‌تر (با host و port)
rc = RedisCluster(host='127.0.0.1', port=7000, decode_responses=True)

# گرفتن اطلاعات Cluster
info = rc.cluster_info()
print(info)
# {'cluster_state': 'ok', 'cluster_slots_assigned': '16384', ...}

nodes = rc.cluster_nodes()
for node in nodes:
    print(node)

اضافه‌کردن Node جدید به Cluster

یکی از قدرتمندترین ویژگی‌های Cluster، امکان resharding — یعنی حرکت دادن slot‌ها بین node‌ها بدون downtime — است:

bash
# 1. اجرای node جدید (مثلاً روی پورت 7006)
docker run -d --name redis-node-7 --network redis-cluster \
    redis:7 redis-server --port 7006 --cluster-enabled yes \
    --cluster-config-file nodes.conf --cluster-node-timeout 5000

# 2. افزودن node جدید به Cluster به‌عنوان master
redis-cli --cluster add-node 127.0.0.1:7006 127.0.0.1:7000

# 3. resharding - انتقال slot‌ها به node جدید
redis-cli --cluster reshard 127.0.0.1:7000
# How many slots do you want to move? 4096
# What is the receiving node ID? <id of 7006>
# Source node IDs: all (یا IDهای خاصی)

# یا به‌صورت non-interactive:
redis-cli --cluster reshard 127.0.0.1:7000 \
    --cluster-from <node-id> \
    --cluster-to <new-node-id> \
    --cluster-slots 1000 \
    --cluster-yes

# 4. افزودن replica به node جدید
redis-cli --cluster add-node 127.0.0.1:7007 127.0.0.1:7000 \
    --cluster-slave --cluster-master-id <new-node-id>

# 5. balance slot‌ها بین همه‌ی master‌ها
redis-cli --cluster rebalance 127.0.0.1:7000
💡 نکته درباره‌ی Resharding

در حین resharding، Cluster همچنان به کار خود ادامه می‌دهد و کلاینت‌ها می‌توانند بنویسند و بخوانند. در طول انتقال هر slot، آن slot به‌طور موقت قفل می‌شود (SET فقط می‌تواند به node جدید برود، GET ممکن است هر دو node را ببیند). این فرآیند معمولاً به‌صورت عملی شفاف است و کلاینت‌ها حتی متوجه نمی‌شوند.

Failover در Cluster

اگر یک master در Cluster crash کند، Sentinel لازم نیست — خود Cluster به‌صورت خودکار failover انجام می‌دهد:

۱. Detection: بقیه node‌ها به‌صورت دوره‌ای PING به master می‌فرستند. اگر پس از cluster-node-timeout (پیش‌فرض ۵ ثانیه) پاسخی نیاید، آن node را PFAIL (probable failure) اعلام می‌کنند.

۲. Confirmation: اگر اکثریت master‌ها (یعنی بیش از نصف) آن node را PFAIL اعلام کنند، به FAIL تبدیل می‌شود.

۳. Replica Election: Replica‌های آن master شروع به انتخاب می‌کنند (با الگوریتمی شبیه Raft). Replica‌ای که آخرین replication offset را دارد، برنده می‌شود.

۴. Promotion: Replica انتخاب‌شده به master تبدیل می‌شود و بقیه node‌ها از آن مطلع می‌شوند.

۵. Slot Migration: slot‌های مربوط به master قدیمی به master جدید اختصاص می‌یابند.

دستورات مفید Cluster

bash
# CLUSTER INFO - اطلاعات کلی Cluster
127.0.0.1:7000> CLUSTER INFO

# CLUSTER NODES - لیست تمام node‌ها با جزئیات
127.0.0.1:7000> CLUSTER NODES

# CLUSTER SLOTS - mapping slot به node
127.0.0.1:7000> CLUSTER SLOTS

# CLUSTER COUNTKEYSINSLOT slot - تعداد کلیدها در یک slot
127.0.0.1:7000> CLUSTER COUNTKEYSINSLOT 9189
(integer) 5

# CLUSTER GETKEYSINSLOT slot count - کلیدهای یک slot
127.0.0.1:7000> CLUSTER GETKEYSINSLOT 9189 10
1) "user:1"
2) "user:2"
3) "user:3"

# CLUSTER KEYSLOT key - slot یک کلید
127.0.0.1:7000> CLUSTER KEYSLOT user:1
(integer) 9189

# CLUSTER MYID - ID خود node
127.0.0.1:7000> CLUSTER MYID

# CLUSTER RESET HARD/SOFT - reset کامل (با احتیاط!)
127.0.0.1:7000> CLUSTER RESET HARD

# CLUSTER FAILOVER - اجبار به failover (روی replica)
127.0.0.1:7003> CLUSTER FAILOVER

# CLUSTER FAILOVER TAKEOVER - بدون تأیید بقیه (خطرناک!)
127.0.0.1:7003> CLUSTER FAILOVER TAKEOVER

# CLUSTER FORGET node-id - حذف یک node از Cluster
127.0.0.1:7000> CLUSTER FORGET abc123def456...

# CLUSTER MEET ip port - افزودن node جدید
127.0.0.1:7000> CLUSTER MEET 127.0.0.1 7006

# CLUSTER REPLICAS node-id - لیست replica‌های یک master
127.0.0.1:7000> CLUSTER REPLICAS <master-id>

# CLUSTER LINKS - اتصال‌های شبکه
127.0.0.1:7000> CLUSTER LINKS

مدیریت عملیات روزانه

bash
# بررسی سلامت Cluster
redis-cli --cluster check 127.0.0.1:7000

# repair Cluster (در صورت مشکل)
redis-cli --cluster fix 127.0.0.1:7000

# اطلاعات کامل Cluster
redis-cli --cluster info 127.0.0.1:7000

# backup از Cluster (روی هر master جداگانه)
for port in 7000 7001 7002; do
    redis-cli -p $port BGSAVE
done

# آپگرید تدریجی (rolling upgrade)
# 1.Upgrade replicas اول
# 2.Switch master به replica (CLUSTER FAILOVER)
# 3.Upgrade master قدیمی

# حذف یک node از Cluster
# 1. ابتدا slot‌ها را به node دیگر منتقل کن
redis-cli --cluster reshard 127.0.0.1:7000 \
    --cluster-from <node-id-to-remove> \
    --cluster-to <other-node-id> \
    --cluster-slots <count> \
    --cluster-yes

# 2. سپس node را forget کن
redis-cli --cluster del-node 127.0.0.1:7000 <node-id-to-remove>

محدودیت‌های Redis Cluster

Redis Cluster قوی است اما محدودیت‌هایی دارد که باید در طراحی مد نظر قرار دهید:

۱. فقط عملیات تک‌slot: MGET، MSET، transaction، و Lua Script فقط روی کلیدهای هم‌slot کار می‌کنند. اگر کلیدها روی slot‌های مختلف باشند، خطا می‌گیرید. راه‌حل: hash tags.

۲. PUB/SUB در Cluster: در Pub/Sub سنتی، پیام‌ها به تمام node‌ها broadcast می‌شوند که پهنای باند زیادی مصرف می‌کند. در نسخه ۷، Sharded Pub/Sub این مشکل را حل می‌کند.

۳. حداقل ۳ master: برای تحمل قطع یک master، حداقل ۳ master و ۳ replica لازم است (۶ node).

۴. بدون multi-database: در Cluster، فقط SELECT 0 قابل استفاده است. نمی‌توانید از چندین دیتابیس (0-15) استفاده کنید.

۵. پیچیدگی عملیات: راه‌اندازی و مدیریت Cluster پیچیده‌تر از Sentinel است و نیاز به دانش بیشتری دارد.

۶. latency خواندن از replica: به‌طور پیش‌فرض، reads هم از master انجام می‌شوند. برای خواندن از replica، باید READONLY در connection فعال کنید (کتابخانه‌ها این کار را می‌کنند).

Cluster vs Sentinel — کدام؟

ویژگی Sentinel Cluster
Shardingخیربله
High Availabilityبلهبله
حداقل node۱ master + ۱ replica + ۳ Sentinel۳ master + ۳ replica
مقیاس‌پذیریعمودی (RAM بیشتر)افقی (node بیشتر)
پیچیدگیمتوسطبالا
multi-key operationsآزادفقط با hash tags
مناسب برایتا چند GB، HA لازم استبیش از چند GB، sharding لازم است
💡 قانون سرانگشتی

اگر داده‌ی شما زیر ۱۰ گیگابایت است و فقط HA می‌خواهید، Sentinel کافی است. اگر داده‌ی شما بیشتر از ۱۰ گیگابایت است یا throughput بیش از یک سرور را نیاز دارید، Cluster راه‌حل بهتری است. در عمل، بسیاری از پروژه‌ها با Sentinel شروع می‌کنند و وقتی به مرز ظرفیت می‌رسند، به Cluster مهاجرت می‌کنند.

📝 خلاصه فصل

در این فصل با Redis Cluster آشنا شدید: معماری با ۱۶۳۸۴ hash slot، hash tags، راه‌اندازی با Docker، عملیات روزانه، resharding، failover، و مقایسه با Sentinel. Cluster ابزار اصلی Redis برای مقیاس‌پذیری افقی است و وقتی داده‌ها از یک سرور بزرگ‌تر می‌شوند، ضروری می‌شود. در فصل بعدی، با ماژول‌های Redis (RedisJSON، RediSearch و...) آشنا می‌شوید که قابلیت‌های Redis را فراتر از key-value می‌برند.

Chapter 22

ماژول‌های Redis #

یکی از جذاب‌ترین ویژگی‌های Redis، سیستم ماژول آن است که از نسخه‌ی ۴ معرفی شد. ماژول‌ها به شما اجازه می‌دهند قابلیت‌های جدیدی به Redis اضافه کنید — بدون نیاز به تغییر کد اصلی Redis. این ماژول‌ها به‌صورت کتابخانه‌های C کامپایل شده‌اند و در همان process Redis اجرا می‌شوند، که به آن‌ها اجازه می‌دهد به‌سرعت ساختارهای داده‌ی داخلی Redis دسترسی داشته باشند. در این فصل، با مهم‌ترین ماژول‌های رسمی و محبوب آشنا می‌شوید: RedisJSON، RediSearch، RedisGraph، RedisTimeSeries، RedisBloom و RedisAI.

نصب ماژول‌ها

راه‌های مختلفی برای نصب ماژول‌ها وجود دارد. ساده‌ترین راه، استفاده از Redis Stack است — یک بسته‌ی آماده از Redis به‌علاوه‌ی ماژول‌های محبوب. راه دیگر، نصب دستی هر ماژول است:

bash
# روش ۱: استفاده از Redis Stack Docker (ساده‌ترین راه)
docker run -d --name redis-stack -p 6379:6379 -p 8001:8001 \
    redis/redis-stack:latest

# پورت 8001 = RedisInsight (رابط وب)

# روش ۲: نصب با apt (روی Ubuntu)
curl -fsSL https://packages.redis.io/gpg | sudo gpg --dearmor -o /usr/share/keyrings/redis-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/redis-archive-keyring.gpg] https://packages.redis.io/deb $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/redis.list
sudo apt update
sudo apt install redis-stack-server

# روش ۳: دانلود ماژول دستی
# از https://redis.io/modules/ آخرین نسخه را دانلود کنید
# سپس در redis.conf:
# loadmodule /path/to/module.so

# روش ۴: build از source
git clone https://github.com/RedisJSON/RedisJSON.git
cd RedisJSON
make
# فایل build/redisjson.so ایجاد می‌شود
ini
# redis.conf - بارگذاری ماژول‌ها
loadmodule /opt/redis-stack/lib/rejson.so
loadmodule /opt/redis-stack/lib/redisearch.so
loadmodule /opt/redis-stack/lib/redistimeseries.so
loadmodule /opt/redis-stack/lib/redisbloom.so
loadmodule /opt/redis-stack/lib/redisgraph.so
# loadmodule /opt/redis-stack/lib/redisai.so

# بررسی ماژول‌های بارگذاری شده
127.0.0.1:6379> MODULE LIST
1) 1) "name"
   2) "ReJSON"
   3) "ver"
   4) (integer) 20400
2) 1) "name"
   2) "search"
   3) "ver"
   4) (integer) 20400
...

RedisJSON — ذخیره و کوئری JSON

RedisJSON اجازه می‌دهد اشیاء JSON را به‌صورت بومی در Redis ذخیره کنید — نه به‌صورت String، بلکه به‌صورت یک ساختار داده‌ی درختی. این یعنی می‌توانید به فیلدهای تو در تو دسترسی داشته باشید، آن‌ها را به‌روزرسانی کنید، و حتی با RediSearch روی آن‌ها کوئری بزنید — همه بدون serialize/deserialize:

bash
# JSON.SET - تنظیم یک سند JSON
127.0.0.1:6379> JSON.SET user:1001 $ '{"name":"Alice","age":30,"email":"alice@example.com","address":{"city":"Tehran","zip":"12345"},"tags":["admin","editor"]}'
OK

# JSON.GET - خواندن کل سند
127.0.0.1:6379> JSON.GET user:1001 $
"[{\"name\":\"Alice\",\"age\":30,...}]"

# JSON.GET - خواندن یک فیلد
127.0.0.1:6379> JSON.GET user:1001 $.name
"[\"Alice\"]"

# JSON.GET - خواندن فیلد تو در تو
127.0.0.1:6379> JSON.GET user:1001 $.address.city
"[\"Tehran\"]"

# JSON.SET - به‌روزرسانی یک فیلد
127.0.0.1:6379> JSON.SET user:1001 $.age 31
OK

# JSON.SET - به‌روزرسانی فیلد تو در تو
127.0.0.1:6379> JSON.SET user:1001 $.address.city "Shiraz"
OK

# JSON.ARRAPPEND - اضافه کردن به آرایه
127.0.0.1:6379> JSON.ARRAPPEND user:1001 $.tags '"premium"'
(integer) 3  # طول جدید آرایه

# JSON.ARRINDEX - پیدا کردن اندیس یک عضو
127.0.0.1:6379> JSON.ARRINDEX user:1001 $.tags '"admin"'
(integer) 0

# JSON.DEL - حذف یک فیلد
127.0.0.1:6379> JSON.DEL user:1001 $.email
(integer) 1

# JSON.TYPE - نوع یک فیلد
127.0.0.1:6379> JSON.TYPE user:1001 $.name
"string"
127.0.0.1:6379> JSON.TYPE user:1001 $.age
"integer"
127.0.0.1:6379> JSON.TYPE user:1001 $.tags
"array"

# JSON.NUMINCRBY - افزایش عدد
127.0.0.1:6379> JSON.NUMINCRBY user:1001 $.age 1
"[32]"

# JSON.STRAPPEND - اضافه کردن به رشته
127.0.0.1:6379> JSON.STRAPPEND user:1001 $.name '" Smith"'
(integer) 11  # طول جدید
python
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# ذخیره یک کاربر به‌صورت JSON
user = {
    'name': 'Alice',
    'age': 30,
    'email': 'alice@example.com',
    'address': {
        'city': 'Tehran',
        'zip': '12345'
    },
    'tags': ['admin', 'editor']
}

import json
r.execute_command('JSON.SET', 'user:1001', '$', json.dumps(user))

# خواندن یک فیلد خاص
name = r.execute_command('JSON.GET', 'user:1001', '$.name')
print(json.loads(name))  # ['Alice']

# به‌روزرسانی فیلد
r.execute_command('JSON.SET', 'user:1001', '$.age', 31)

# افزایش اتمیک
r.execute_command('JSON.NUMINCRBY', 'user:1001', '$.age', 1)

RediSearch — جستجوی全文 و Indexing

RediSearch به Redis قابلیت full-text search و secondary indexing اضافه می‌کند. می‌توانید روی Hashها یا JSONها index بسازید و سپس با کوئری‌های پیچیده (شامل Boolean، numeric range، geo، و fuzzy search) جستجو کنید:

bash
# FT.CREATE - ساخت index روی Hashها
127.0.0.1:6379> FT.CREATE idx_users ON HASH PREFIX 1 user: SCHEMA
    name TEXT
    age NUMERIC SORTABLE
    email TEXT NOSTEM
    city TAG
    created_at NUMERIC

# FT.CREATE - ساخت index روی JSONها
127.0.0.1:6379> FT.CREATE idx_users_json ON JSON PREFIX 1 user: SCHEMA
    $.name AS name TEXT
    $.age AS age NUMERIC SORTABLE
    $.email AS email TEXT
    $.address.city AS city TAG

# افزودن داده‌ها
127.0.0.1:6379> HSET user:1 name "Alice Smith" age 30 email "alice@example.com" city "Tehran"
127.0.0.1:6379> HSET user:2 name "Bob Jones" age 25 email "bob@example.com" city "Shiraz"
127.0.0.1:6379> HSET user:3 name "Carol White" age 35 email "carol@example.com" city "Tehran"

# FT.SEARCH - جستجوی ساده
127.0.0.1:6379> FT.SEARCH idx_users "Alice"
1) (integer) 1  # تعداد نتایج
2) "user:1"
3) 1) "name"
   2) "Alice Smith"
   3) "age"
   4) "30"
   ...

# جستجوی پیشرفته
127.0.0.1:6379> FT.SEARCH idx_users "@name:Alice @age:[20 35]"
# نام شامل Alice، سن بین ۲۰ تا ۳۵

# جستجوی TAG
127.0.0.1:6379> FT.SEARCH idx_users "@city:{Tehran}"
# کاربران در تهران

# جستجوی ترکیبی با OR
127.0.0.1:6379> FT.SEARCH idx_users "@city:{Tehran|Shiraz}"

# جستجوی prefix
127.0.0.1:6379> FT.SEARCH idx_users "Ali*"

# جستجوی فازی (fuzzy)
127.0.0.1:6379> FT.SEARCH idx_users "%Alice%"

# FT.AGGGREGATE - تجمیع
127.0.0.1:6379> FT.AGGREGATE idx_users "*" GROUPBY 1 @city REDUCE COUNT 0 AS count
# گروه‌بندی بر اساس شهر با شمارش

# FT.INFO - اطلاعات index
127.0.0.1:6379> FT.INFO idx_users

# FT.DROPINDEX - حذف index
127.0.0.1:6379> FT.DROPINDEX idx_users

RedisTimeSeries — داده‌های زمانی

RedisTimeSeries برای ذخیره و کوئلی داده‌های زمانی (مانند IoT، متریک‌ها، داشبوردها) طراحی شده است. این ماژول از aggregation، downsampling و compact storage پشتیبانی می‌کند:

bash
# TS.CREATE - ساخت یک time-series
127.0.0.1:6379> TS.CREATE temperature:room1 RETENTION 86400000 LABELS room "1" type "temperature"
# RETENTION به میلی‌ثانیه (۲۴ ساعت)

# TS.ADD - افزودن یک نقطه
127.0.0.1:6379> TS.ADD temperature:room1 * 25.5
(integer) 1709827200000  # timestamp

# TS.ADD با timestamp خاص
127.0.0.1:6379> TS.ADD temperature:room1 1709827200000 25.5
127.0.0.1:6379> TS.ADD temperature:room1 1709827205000 26.0
127.0.0.1:6379> TS.ADD temperature:room1 1709827210000 26.5

# TS.MADD - افزودن چند نقطه همزمان
127.0.0.1:6379> TS.MADD temperature:room1 * 25.5 temperature:room2 * 22.0

# TS.RANGE - خواندن در محدوده‌ی زمانی
127.0.0.1:6379> TS.RANGE temperature:room1 - +
1) 1) (integer) 1709827200000
   2) "25.5"
2) 1) (integer) 1709827205000
   2) "26.0"
3) 1) (integer) 1709827210000
   2) "26.5"

# TS.RANGE با aggregation
127.0.0.1:6379> TS.RANGE temperature:room1 - + AGGREGATION avg 5000
# میانگین هر ۵ ثانیه

# aggregation types: avg, sum, min, max, range, count, first, last, std.p, std.s, var.p, var.s

# TS.CREATE با compaction rule (downsampling)
127.0.0.1:6379> TS.CREATE temperature:room1:avg_1h RETENTION 31536000000
127.0.0.1:6379> TS.CREATERULE temperature:room1 temperature:room1:avg_1h AGGREGATION avg 3600000
# هر ساعت یک میانگین ذخیره می‌شود

# TS.MRANGE - خواندن چند time-series همزمان
127.0.0.1:6379> TS.MRANGE - + AGGREGATION avg 60000 FILTER room=(1 2)
# تمام room های 1 و 2 را در یک دقیقه میانگین می‌گیرد

# TS.INCRBY / TS.DECRBY - شمارنده‌ها
127.0.0.1:6379> TS.CREATE pageviews:home RETENTION 604800000
127.0.0.1:6379> TS.INCRBY pageviews:home 1
127.0.0.1:6379> TS.INCRBY pageviews:home 1

# TS.INFO - اطلاعات یک series
127.0.0.1:6379> TS.INFO temperature:room1

RedisBloom — فیلترهای احتمالی

RedisBloom مجموعه‌ای از ساختارهای داده‌ی احتمالی را ارائه می‌دهد: Bloom Filter، Cuckoo Filter، Count-Min Sketch، Top-K و TinySketch. این ساختارها با حافظه‌ی بسیار کم، تخمین‌های approximate ارائه می‌دهند:

bash
# ===== Bloom Filter =====
# BF.RESERVE - ساخت bloom filter
127.0.0.1:6379> BF.RESERVE emails 0.01 1000000
# error rate 0.01, capacity 1M

# BF.ADD - افزودن
127.0.0.1:6379> BF.ADD emails "user1@example.com"
127.0.0.1:6379> BF.ADD emails "user2@example.com"

# BF.EXISTS - بررسی
127.0.0.1:6379> BF.EXISTS emails "user1@example.com"
(integer) 1  # احتمالاً عضو است

127.0.0.1:6379> BF.EXISTS emails "user3@example.com"
(integer) 0  # قطعاً عضو نیست

# BF.MADD / BF.MEXISTS - چندتایی
127.0.0.1:6379> BF.MADD emails "a@x.com" "b@x.com" "c@x.com"
127.0.0.1:6379> BF.MEXISTS emails "user1@example.com" "nonexistent@x.com"

# ===== Cuckoo Filter (با قابلیت حذف) =====
127.0.0.1:6379> CF.RESERVE mycuckoo 1000000
127.0.0.1:6379> CF.ADD mycuckoo "item1"
127.0.0.1:6379> CF.DEL mycuckoo "item1"  # قابل حذف!

# ===== Count-Min Sketch (تخمین فرکانس) =====
127.0.0.1:6379> CMS.INITBYPROB cms 0.01 0.01
127.0.0.1:6379> CMS.INCRBY cms "apple" 5
127.0.0.1:6379> CMS.INCRBY cms "banana" 3
127.0.0.1:6379> CMS.QUERY cms "apple"
(integer) 5  # تقریبی

# ===== Top-K =====
127.0.0.1:6379> TOPK.RESERVE topitems 10 50 4 0.9
127.0.0.1:6379> TOPK.INCRBY topitems "item1" 1 "item2" 1 "item1" 1
127.0.0.1:6379> TOPK.LIST topitems
1) "item1"
2) "item2"

RedisGraph — پایگاه داده‌ی گرافی

RedisGraph یک پایگاه داده‌ی گرافی بر مبنای Cypher (زبان کوئلی Neo4j) است. این ماژول برای داده‌های با روابط پیچیده (مثل شبکه‌های اجتماعی، recommendation engine، fraud detection) مفید است. توجه: این ماژول در سال ۲۰۲۴ از Redis Stack حذف شده و پیشنهاد می‌شود از جایگزین‌های آن استفاده کنید:

cypher
# GRAPH.QUERY - اجرای کوئری Cypher
# ساخت گراف با نام "social"
127.0.0.1:6379> GRAPH.QUERY social "CREATE (alice:Person {name: 'Alice', age: 30}), (bob:Person {name: 'Bob', age: 25}), (alice)-[:KNOWS {since: 2020}]->(bob)"

# جستجوی تمام افراد
127.0.0.1:6379> GRAPH.QUERY social "MATCH (p:Person) RETURN p.name, p.age"

# جستجوی روابط
127.0.0.1:6379> GRAPH.QUERY social "MATCH (a:Person)-[:KNOWS]->(b:Person) RETURN a.name, b.name"

# جستجوی با شرط
127.0.0.1:6379> GRAPH.QUERY social "MATCH (p:Person) WHERE p.age > 25 RETURN p.name"

# افزودن رابطه‌ی جدید
127.0.0.1:6379> GRAPH.QUERY social "MATCH (alice:Person {name: 'Alice'}), (carol:Person {name: 'Carol'}) CREATE (alice)-[:KNOWS {since: 2021}]->(carol)"

# shortest path
127.0.0.1:6379> GRAPH.QUERY social "MATCH p=shortestPath((a:Person {name: 'Alice'})-[*]->(b:Person {name: 'Bob'})) RETURN p"
⚠️ توجه: RedisGraph منسوخ شده

در آوریل ۲۰۲۴، Redis Inc. اعلام کرد که RedisGraph دیگر در Redis Stack پشتیبانی نمی‌شود. برای کاربردهای گرافی، پیشنهاد می‌شود از FalkorDB (یک fork از RedisGraph) یا پایگاه‌های داده‌ی گرافی مستقل مانند Neo4j یا ArangoDB استفاده کنید.

RedisAI — استنتاج مدل ML

RedisAI اجازه می‌دهد مدل‌های یادگیری ماشین (TensorFlow، PyTorch، ONNX) را داخل Redis اجرا کنید. این ماژول برای serving مدل‌های ML در real-time مفید است. مانند RedisGraph، این ماژول هم از Redis Stack حذف شده است:

bash
# بارگذاری مدل
127.0.0.1:6379> AI.MODELSET mymodel TF CPU INPUTS 2 a b OUTPUTS 1 c BLOB <model_bytes>

# اجرای مدل
127.0.0.1:6379> AI.TENSORSET a FLOAT 2 VALUES 1.0 2.0
127.0.0.1:6379> AI.TENSORSET b FLOAT 2 VALUES 3.0 4.0
127.0.0.1:6379> AI.MODELRUN mymodel INPUTS a b OUTPUTS c

# خواندن نتیجه
127.0.0.1:6379> AI.TENSORGET c VALUES

ساخت ماژول سفارشی

علاوه بر ماژول‌های رسمی، می‌توانید ماژول خود را با C یا Rust بسازید. Redis SDK رسمی (redismodule-rs برای Rust، RedisModuleSDK برای C) شما را در این مسیر کمک می‌کند. این قابلیت برای کاربردهای بسیار خاص مفید است، مثل ساختن یک ساختار داده‌ی سفارشی یا دسترسی به یک سرویس خارجی از داخل Redis:

c
// hello.c - یک ماژول ساده Redis
#include "redismodule.h"
#include <string.h>

int HelloRedis_RedisCommand(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) {
    if (argc < 2) {
        return RedisModule_WrongArity(ctx);
    }
    RedisModule_AutoMemory(ctx);
    size_t len;
    const char *name = RedisModule_StringPtrLen(argv[1], &len);
    RedisModule_ReplyWithSimpleString(ctx, "Hello, world!");
    return REDISMODULE_OK;
}

int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) {
    if (RedisModule_Init(ctx, "helloworld", 1, REDISMODULE_APIVER_1) == REDISMODULE_ERR) {
        return REDISMODULE_ERR;
    }
    if (RedisModule_CreateCommand(ctx, "hello.redis", HelloRedis_RedisCommand,
                                  "readonly", 1, 1, 1) == REDISMODULE_ERR) {
        return REDISMODULE_ERR;
    }
    return REDISMODULE_OK;
}

// کامپایل:
// gcc -shared -fPIC -o hello.so hello.c -I/path/to/redis/src
// استفاده در redis.conf:
// loadmodule /path/to/hello.so
// سپس: redis-cli> hello.redis "Alice"

مدیریت ماژول‌ها

bash
# MODULE LIST - لیست ماژول‌های بارگذاری شده
127.0.0.1:6379> MODULE LIST

# MODULE LOAD - بارگذاری پویا (بدون restart)
127.0.0.1:6379> MODULE LOAD /path/to/module.so
OK

# MODULE LOAD با پارامتر
127.0.0.1:6379> MODULE LOAD /path/to/module.so param1 param2

# MODULE UNLOAD - حذف ماژول
127.0.0.1:6379> MODULE UNLOAD rejson
OK

انتخاب ماژول‌های مناسب

نیاز ماژول پیشنهادی مزیت
ذخیره اشیاء JSONRedisJSONدسترسی به فیلد بدون serialize
جستجوی full-textRediSearchسرعت بالا، کوئری پیچیده
داده‌های time-seriesRedisTimeSeriesaggregation، downsampling
تست عضویت سریعRedisBloom (Bloom/Cuckoo)حافظه‌ی بسیار کم
تخمین فرکانسRedisBloom (Count-Min Sketch)شمارش approximate
Top-K itemsRedisBloom (Top-K)پیدا کردن محبوب‌ترین‌ها
روابط پیچیده (گراف)FalkorDB یا Neo4jکوئری Cypher
سرو مدل MLOutside Redis (Triton, TF Serving)بهترین عملکرد
💡 نکته عملی

برای شروع، توصیه می‌کنیم از Redis Stack استفاده کنید که شامل RedisJSON، RediSearch، RedisTimeSeries و RedisBloom است. این بسته برای اکثر کاربردهای وب و اپلیکیشن کافی است. اگر فقط به یک ماژول نیاز دارید، می‌توانید فقط آن را نصب کنید تا حافظه و CPU کمتری مصرف شود.

📝 خلاصه فصل

در این فصل با ماژول‌های Redis آشنا شدید: RedisJSON برای JSON، RediSearch برای full-text search، RedisTimeSeries برای داده‌های زمانی، RedisBloom برای فیلترهای احتمالی، RedisGraph (منسوخ) برای گراف، و RedisAI (منسوخ) برای ML. ماژول‌ها قدرت Redis را به‌طور قابل توجهی گسترش می‌دهند و بسیاری از کاربردهایی که قبلاً به چند ابزار نیاز داشت، اکنون در Redis قابل انجام است. در فصل بعدی، با مانیتورینگ و بهینه‌سازی عملکرد کار می‌کنیم.

Chapter 23

مانیتورینگ و Performance #

در محیط production، مانیتورینگ Redis به‌اندازه‌ی خود Redis مهم است. بدون مانیتورینگ، نمی‌توانید بفهمید که آیا Redis شما سالم است، چه مقدار حافظه مصرف می‌کند، کدام کوئری‌ها کند هستند، یا چه زمانی باید ارتقا دهید. در این فصل، با دستورات مانیتورینگ، متریک‌های کلیدی، ابزارهای مانیتورینگ، و الگوهای بهینه‌سازی عملکرد آشنا می‌شوید. این فصل برای هر کسی که Redis را در production اجرا می‌کند، ضروری است.

INFO — شاه‌کلید مانیتورینگ

دستور INFO اطلاعات جامعی از تمام جنبه‌های Redis ارائه می‌دهد. این دستور به چندین section تقسیم می‌شود که هر کدام را می‌توان به‌صورت جداگانه درخواست کرد:

bash
# INFO کامل
127.0.0.1:6379> INFO

# بخش‌های مختلف
127.0.0.1:6379> INFO server       # اطلاعات سرور
127.0.0.1:6379> INFO clients      # اتصال‌های کلاینت
127.0.0.1:6379> INFO memory       # حافظه
127.0.0.1:6379> INFO persistence  # RDB و AOF
127.0.0.1:6379> INFO stats        # آمار عملیات
127.0.0.1:6379> INFO replication  # Replication
127.0.0.1:6379> INFO cpu          # مصرف CPU
127.0.0.1:6379> INFO commandstats # آمار هر دستور
127.0.0.1:6379> INFO latencystats # آمار latency
127.0.0.1:6379> INFO cluster      # Cluster
127.0.0.1:6379> INFO keyspace     # آمار هر دیتابیس

# خروجی JSON (نسخه ۷ به بعد)
127.0.0.1:6379> INFO --json memory | jq .used_memory_human

متریک‌های کلیدی

در ادامه، مهم‌ترین متریک‌هایی که باید مانیتور کنید را با توضیح می‌بینید:

بخش متریک توضیح هشدار
Memoryused_memoryحافظه‌ی مصرف شدهنزدیک به maxmemory
used_memory_peakبیشترین حافظه‌ی مصرف شده تا کنون
mem_fragmentation_ratioنسبت RSS به allocatedبالای ۱.۵ یا زیر ۱.۰
Clientsconnected_clientsتعداد کلاینت‌های متصلنزدیک به maxclients
blocked_clientsکلاینت‌های مسدود (BLPOP و...)افزایش ناگهانی
Statsops_per_secعملیات در ثانیهکاهش ناگهانی
hit_rateدرصد cache hitsزیر ۹۰٪
evicted_keysکلیدهای evict شدهافزایش مداوم
expired_keysکلیدهای منقضی شده
Persistencerdb_last_bgsave_statusوضعیت آخرین BGSAVEerr
aof_last_write_statusوضعیت آخرین AOF writeerr
Replicationmaster_link_statusوضعیت link به masterdown
master_last_io_seconds_agoثانیه از آخرین I/O با masterبیش از ۶۰
CPUused_cpu_sysCPU مصرفی (system)افزایش ناگهانی

محاسبه‌ی Cache Hit Rate

Cache hit rate یکی از مهم‌ترین متریک‌ها برای ارزیابی کارایی cache است. محاسبه‌ی آن ساده است:

bash
# گرفتن keyspace hits و misses
127.0.0.1:6379> INFO stats | grep -E "keyspace_(hits|misses)"
keyspace_hits:15000
keyspace_misses:500

# محاسبه‌ی hit rate
# hit_rate = hits / (hits + misses) * 100
# = 15000 / (15000 + 500) * 100
# = 96.77%

# اسکریپت ساده:
hits=$(redis-cli INFO stats | grep keyspace_hits | cut -d: -f2 | tr -d '\r')
misses=$(redis-cli INFO stats | grep keyspace_misses | cut -d: -f2 | tr -d '\r')
total=$((hits + misses))
if [ $total -gt 0 ]; then
    rate=$(echo "scale=2; $hits * 100 / $total" | bc)
    echo "Cache hit rate: $rate%"
fi
💡 تفسیر Hit Rate

برای cache معمولی، hit rate بالای ۹۰٪ خوب است، بالای ۹۵٪ عالی. اگر زیر ۸۰٪ است، باید بررسی کنید: آیا TTL خیلی کوتاه است؟ آیا eviction policy مناسب است؟ آیا access pattern شما با cache سازگار است؟

MEMORY — تحلیل حافظه‌ی دقیق

دستورات MEMORY برای تحلیل دقیق مصرف حافظه مفید هستند:

bash
# MEMORY USAGE - حافظه‌ی یک کلید
127.0.0.1:6379> MEMORY USAGE user:1001
(integer) 88  # 88 بایت

# با SAMPLES (برای ساختارهای بزرگ)
127.0.0.1:6379> MEMORY USAGE big_sorted_set SAMPLES 5

# MEMORY STATS - آمار کامل
127.0.0.1:6379> MEMORY STATS

# MEMORY DOCTOR - تحلیل خودکار
127.0.0.1:6379> MEMORY DOCTOR
# Sam's observations:
# - data is not fully in memory
# - big fragmentation ratio
# - high allocator fragmentation

# MEMORY PURGE - پاک کردن fragmentation ( jemalloc)
127.0.0.1:6379> MEMORY PURGE

# MEMORY MALLOC-STATS - آمار مفصل allocator
127.0.0.1:6379> MEMORY MALLOC-STATS

SLOWLOG — یافتن کوئری‌های کند

SLOWLOG لیست دستوراتی که بیش از آستانه‌ی مشخص طول کشیده‌اند را نگه می‌دارد. این ابزار برای یافتن گلوگاه‌های عملکرد بسیار مفید است:

bash
# تنظیم آستانه (به میکروثانیه، 10000 = 10ms)
127.0.0.1:6379> CONFIG SET slowlog-log-slower-than 10000
OK

# تنظیم طول slowlog
127.0.0.1:6379> CONFIG SET slowlog-max-len 1024
OK

# SLOWLOG GET - گرفتن N ورودی اخیر
127.0.0.1:6379> SLOWLOG GET 10
1) 1) (integer) 14           # شناسه‌ی یکتا
   2) (integer) 1709827200   # timestamp Unix
   3) (integer) 25000        # مدت اجرا به میکروثانیه (25ms)
   4) 1) "KEYS"              # دستور
      2) "*"                 # آرگومان‌ها
   5) "127.0.0.1:54321"      # آدرس کلاینت
   6) "myworker"             # نام کلاینت
   7) (integer) 1            # date در نسخه ۷

# SLOWLOG LEN - تعداد ورودی‌ها
127.0.0.1:6379> SLOWLOG LEN
(integer) 5

# SLOWLOG RESET - پاک کردن
127.0.0.1:6379> SLOWLOG RESET

LATENCY — تحلیل تأخیر

از نسخه‌ی ۲.۸.۱۳، Redis یک چارچوب latency monitoring دارد که می‌تواند به شما کمک کند بفهمید چرا گاهی تأخیر ناگهانی رخ می‌دهد:

bash
# فعال‌سازی مانیتورینگ برای رویدادهای خاص
127.0.0.1:6379> CONFIG SET latency-monitor-threshold 100
OK  # 100ms threshold

# LATENCY HISTORY - تاریخچه‌ی یک رویداد
127.0.0.1:6379> LATENCY HISTORY command
1) 1) (integer) 1709827200   # timestamp
   2) (integer) 150           # ms latency

# LATENCY LATEST - آخرین رویداد هر نوع
127.0.0.1:6379> LATENCY LATEST
1) 1) "command"
   2) (integer) 1709827200
   3) (integer) 150
   4) (integer) 200  # max latency

# LATENCY GRAPH - نمایش متنی
127.0.0.1:6379> LATENCY GRAPH command

# LATENCY RESET - پاک کردن
127.0.0.1:6379> LATENCY RESET

# انواع رویدادها:
# command, fast-command, fork, rdb-unlink-temp-file, aof-write, aof-fsync-always,
# expire-cycle, eviction-cycle, eviction-del, io-loop-aof

CLIENT — مدیریت کلاینت‌ها

bash
# CLIENT LIST - لیست تمام کلاینت‌ها
127.0.0.1:6379> CLIENT LIST

# CLIENT INFO - نسخه‌ی خلاصه‌تر
127.0.0.1:6379> CLIENT INFO

# CLIENT KILL - قطع یک کلاینت
127.0.0.1:6379> CLIENT KILL ADDR 127.0.0.1:54321
OK

# CLIENT KILL با فیلتر
127.0.0.1:6379> CLIENT KILL TYPE normal
127.0.0.1:6379> CLIENT KILL ID 5
127.0.0.1:6379> CLIENT KILL USER webapp
127.0.0.1:6379> CLIENT KILL SKIPME yes  # همه به‌جز خودم

# CLIENT PAUSE - توقف پردازش کلاینت‌ها (با احتیاط!)
127.0.0.1:6379> CLIENT PAUSE 5000  # 5 ثانیه
127.0.0.1:6379> CLIENT PAUSE 5000 WRITE  # فقط writes

# CLIENT NO-EVICT - این کلاینت در eviction در نظر گرفته نشود
127.0.0.1:6379> CLIENT NO-EVICT ON

# CLIENT NO-TOUCH - LRU را به‌روز نکند
127.0.0.1:6379> CLIENT NO-TOUCH ON

# CLIENT GETNAME / SETNAME - نام‌گذاری کلاینت
127.0.0.1:6379> CLIENT SETNAME "my-app"
OK
127.0.0.1:6379> CLIENT GETNAME
"my-app"

# CLIENT TRACKING - client-side caching
127.0.0.1:6379> CLIENT TRACKING ON

# CLIENT REPLY - کنترل پاسخ سرور
127.0.0.1:6379> CLIENT REPLY OFF  # هیچ پاسخی نده
127.0.0.1:6379> CLIENT REPLY SKIP  # پاسخ بعدی را نده
127.0.0.1:6379> CLIENT REPLY ON

الگوی مانیتورینگ با Prometheus و Grafana

برای مانیتورینگ production، ترکیب redis_exporter + Prometheus + Grafana استاندارد است. در اینجا یک پیکربندی ساده می‌بینید:

yaml
# docker-compose.yml
version: '3.8'

services:
  redis:
    image: redis:7
    container_name: redis
    ports:
      - "6379:6379"

  redis-exporter:
    image: oliver006/redis_exporter
    container_name: redis-exporter
    environment:
      - REDIS_ADDR=redis://redis:6379
      - REDIS_PASSWORD=your_password
    ports:
      - "9121:9121"
    depends_on:
      - redis

  prometheus:
    image: prom/prometheus
    container_name: prometheus
    volumes:
      - ./prometheus.yml:/etc/prometheus/prometheus.yml
    ports:
      - "9090:9090"

  grafana:
    image: grafana/grafana
    container_name: grafana
    ports:
      - "3000:3000"
    environment:
      - GF_SECURITY_ADMIN_PASSWORD=admin
    volumes:
      - grafana-data:/var/lib/grafana

volumes:
  grafana-data:
yaml
# prometheus.yml
global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'redis'
    static_configs:
      - targets: ['redis-exporter:9121']
    # یا چند Redis:
    # - targets: ['redis1-exporter:9121', 'redis2-exporter:9121']

در Grafana، می‌توانید از dashboard آماده‌ی Redis Dashboard for Prometheus (ID: 763) استفاده کنید که شامل تمام متریک‌های مهم است.

RedisInsight — رابط وب رسمی

RedisInsight یک رابط وب رسمی و رایگان از Redis Inc. است که برای تحلیل، دیباگ و بهینه‌سازی Redis طراحی شده است. این ابزار شامل:

Tree View: مرور کلیدها به‌صورت درختی

CLI: اجرای دستورات با syntax highlighting

Profiler: مشاهده‌ی دستورات در حال اجرا

Memory Analysis: تحلیل مصرف حافظه با نمودار

Workbench: آزمایش با RediSearch، RedisJSON و...

bash
# اجرای RedisInsight با Docker
docker run -d --name redisinsight -p 8001:8001 redis/redisinsight:latest

# سپس به http://localhost:8001 در مرورگر بروید

بهینه‌سازی عملکرد

علاوه بر مانیتورینگ، باید بتوانید گلوگاه‌ها را شناسایی و رفع کنید. در ادامه چندین الگوی بهینه‌سازی را مرور می‌کنیم:

۱. استفاده از Pipeline

همانطور که در فصل ۱۴ دیدیم، Pipeline می‌تواند عملکرد را ۵ تا ۵۰ برابر افزایش دهد. همیشه برای عملیات bulk از Pipeline استفاده کنید:

python
# بد - 1000 round-trip
for i in range(1000):
    r.set(f"key:{i}", f"val:{i}")

# خوب - 1 round-trip
pipe = r.pipeline()
for i in range(1000):
    pipe.set(f"key:{i}", f"val:{i}")
pipe.execute()

۲. انتخاب ساختار داده‌ی درست

انتخاب درست ساختار داده می‌تواند تأثیر بزرگی بر عملکرد و حافظه داشته باشد:

python
# بد: ذخیره ۱۰۰۰ فیلد به‌صورت ۱۰۰۰ کلید جداگانه
for i in range(1000):
    r.set(f"user:1:field:{i}", value)

# خوب: ذخیره به‌صورت Hash
r.hset("user:1", mapping={f"field:{i}": value for i in range(1000)})

# بد: ۱۰۰۰۰ آیتم در List با دسترسی با اندیس
r.lpush("mylist", *items)
val = r.lindex("mylist", 5000)  # O(N)

# خوب: استفاده از Hash با اندیس به‌عنوان فیلد
r.hset("myhash", mapping={str(i): val for i, val in enumerate(items)})
val = r.hget("myhash", "5000")  # O(1)

۳. کوچک نگه‌داشتن کلیدها و مقادیر

برای ذخیره‌ی داده‌های بزرگ، چندین کلید کوچک بهتر از یک کلید بزرگ است. مثلاً به‌جای یک List با ۱ میلیون عضو، چند List با ۱۰۰۰ عضو بسازید:

python
# بد: یک Sorted Set با ۱M عضو
r.zadd("big:zset", {f"member:{i}": i for i in range(1_000_000)})

# بهتر: چند Sorted Set با ۱۰۰۰ عضو
for chunk in range(1000):
    start = chunk * 1000
    members = {f"member:{i}": i for i in range(start, start + 1000)}
    r.zadd(f"big:zset:{chunk}", members)

۴. استفاده از UNLINK به‌جای DEL

برای کلیدهای بزرگ، UNLINK به‌جای DEL استفاده کنید (حذف ناهمزمان):

python
# بد - مسدود می‌کند
r.delete("big_sorted_set")  # ممکن است چند ثانیه طول بکشد

# خوب - ناهمزمان
r.unlink("big_sorted_set")  # فوراً برمی‌گردد

۵. فعال‌سازی Active Defragmentation

اگر fragmentation بالا دارید (بیش از ۱.۵)، می‌توانید active defragmentation را فعال کنید:

ini
# redis.conf
activedefrag yes
active-defrag-ignore-bytes 100mb       # حداقل fragmentation برای شروع
active-defrag-threshold-lower 10        # شروع defrag وقتی fragmentation ۱۰٪ است
active-defrag-threshold-upper 100       # تلاش کامل وقتی ۱۰۰٪
active-defrag-cycle-min 1               # حداقل CPU برای defrag (%)
active-defrag-cycle-max 25              # حداکثر CPU برای defrag (%)

۶. استفاده از I/O Threading (در نسخه ۶ به بعد)

اگر تعداد کلاینت‌ها زیاد است یا حجم داده‌های پاسخ بزرگ است، I/O threading می‌تواند کمک کند:

ini
# redis.conf
io-threads 4              # تعداد threadها (معمولاً تعداد CPU cores)
io-threads-do-reads no    # برای reads هم (با احتیاط)

# توجه: I/O threading فقط برای network I/O است،
# پردازش دستورات همچنان single-threaded است

۷. تنظیم Linux Kernel

bash
# /etc/sysctl.conf
# اجازه دادن به fork حتی با memory کم
vm.overcommit_memory = 1

# کاهش swappiness (Redis نباید swap شود)
vm.swappiness = 1

# بهبود شبکه
net.core.somaxconn = 1024
net.ipv4.tcp_max_syn_backlog = 1024

# اعمال
sudo sysctl -p

# disable transparent huge pages (بهبود latency)
echo never | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
echo never | sudo tee /sys/kernel/mm/transparent_hugepage/defrag

# برای persistence: جای دادن dir روی دیسک جداگانه (مثلاً NVMe)
# dir /mnt/nvme/redis

الگوی Health Check

یک اسکریپت health check ساده که می‌توان در cron اجرا کرد:

bash
#!/bin/bash
# health_check_redis.sh

REDIS_HOST="localhost"
REDIS_PORT=6379
REDIS_PASS="your_password"
ALERT_EMAIL="admin@example.com"

# 1. بررسی اتصال
if ! redis-cli -h $REDIS_HOST -p $REDIS_PORT -a $REDIS_PASS PING | grep -q PONG; then
    echo "CRITICAL: Redis not responding to PING"
    # ارسال alert...
    exit 1
fi

# 2. بررسی memory
USED=$(redis-cli -h $REDIS_HOST -p $REDIS_PORT -a $REDIS_PASS INFO memory | grep used_memory: | cut -d: -f2 | tr -d '\r')
MAX=$(redis-cli -h $REDIS_HOST -p $REDIS_PORT -a $REDIS_PASS CONFIG GET maxmemory | tail -1)
if [ $MAX -gt 0 ]; then
    PERCENT=$((USED * 100 / MAX))
    if [ $PERCENT -gt 90 ]; then
        echo "WARNING: Memory usage at ${PERCENT}%"
    fi
fi

# 3. بررسی hit rate
HITS=$(redis-cli -h $REDIS_HOST -p $REDIS_PORT -a $REDIS_PASS INFO stats | grep keyspace_hits: | cut -d: -f2 | tr -d '\r')
MISSES=$(redis-cli -h $REDIS_HOST -p $REDIS_PORT -a $REDIS_PASS INFO stats | grep keyspace_misses: | cut -d: -f2 | tr -d '\r')
TOTAL=$((HITS + MISSES))
if [ $TOTAL -gt 0 ]; then
    HIT_RATE=$((HITS * 100 / TOTAL))
    if [ $HIT_RATE -lt 80 ]; then
        echo "WARNING: Cache hit rate is ${HIT_RATE}%"
    fi
fi

# 4. بررسی replication
ROLE=$(redis-cli -h $REDIS_HOST -p $REDIS_PORT -a $REDIS_PASS INFO replication | grep role: | cut -d: -f2 | tr -d '\r')
if [ "$ROLE" = "slave" ]; then
    LINK=$(redis-cli -h $REDIS_HOST -p $REDIS_PORT -a $REDIS_PASS INFO replication | grep master_link_status: | cut -d: -f2 | tr -d '\r')
    if [ "$LINK" != "up" ]; then
        echo "CRITICAL: Replication link is down"
    fi
fi

# 5. بررسی persistence
RDB_STATUS=$(redis-cli -h $REDIS_HOST -p $REDIS_PORT -a $REDIS_PASS INFO persistence | grep rdb_last_bgsave_status: | cut -d: -f2 | tr -d '\r')
if [ "$RDB_STATUS" != "ok" ]; then
    echo "CRITICAL: Last RDB save failed"
fi

echo "Health check passed"
exit 0

تعمیر و عیب‌یابی

چند مشکل رایج و راه‌حل آن‌ها:

۱. High Memory Usage: بررسی کنید چه کلیدهایی بیشترین حافظه را اشغال می‌کنند. با SCAN و MEMORY USAGE این کار را انجام دهید. شاید نیاز به افزایش maxmemory یا بهینه‌سازی ساختار داده باشد.

۲. High Latency: با SLOWLOG و LATENCY دستورات کند را پیدا کنید. معمولاً KEYS * یا SMEMBERS روی Set بزرگ عامل آن است.

۳. High CPU: معمولاً به‌خاطر دستورات پردازش‌ سنگین یا connection storm است. با INFO commandstats ببینید کدام دستورات CPU مصرف می‌کنند.

۴. Connection Rejected: اگر maxclients رسیده است، کلاینت‌های idle را با CLIENT KILL پاک کنید یا maxclients را افزایش دهید.

۵. Replication Lag: اگر replica عقب است، repl-backlog-size را افزایش دهید یا شبکه را بررسی کنید.

📝 خلاصه فصل

در این فصل با مانیتورینگ و بهینه‌سازی Redis آشنا شدید: INFO، MEMORY، SLOWLOG، LATENCY، CLIENT، RedisInsight، Prometheus+Grafana، و چندین الگوی بهینه‌سازی. مانیتورینگ مداوم کلید production شدن Redis است — بدون آن، در تاریکی هستید و ممکن است تا وقتی متوجه مشکل شوید، دیر شده باشد. در فصل بعدی، با استقرار Redis با Docker و Kubernetes کار می‌کنیم.

Chapter 24

Docker و Kubernetes #

در محیط production مدرن، استقرار Redis با Docker و Kubernetes به استاندارد تبدیل شده است. Docker امکان package کردن Redis با تمام وابستگی‌ها را در یک image فراهم می‌کند، و Kubernetes مدیریت چندین instance، scaling، و failover را خودکار می‌کند. در این فصل، با راه‌اندازی Redis در Docker، Docker Compose برای محیط توسعه، و Kubernetes برای production آشنا می‌شوید. همچنین با Redis Operator که راه‌حل پیشنهادی برای Redis در K8s است، کار می‌کنیم.

اجرای Redis در Docker

ساده‌ترین راه اجرای Redis، استفاده از image رسمی Redis است:

bash
# اجرای ساده Redis
docker run -d --name my-redis -p 6379:6379 redis:7

# با رمز عبور
docker run -d --name my-redis -p 6379:6379 \
    redis:7 redis-server --requirepass "your_password"

# با persistence
docker run -d --name my-redis \
    -p 6379:6379 \
    -v redis-data:/data \
    redis:7 redis-server --appendonly yes --save 60 1

# با redis.conf سفارشی
docker run -d --name my-redis \
    -p 6379:6379 \
    -v /path/to/redis.conf:/usr/local/etc/redis/redis.conf \
    -v redis-data:/data \
    redis:7 redis-server /usr/local/etc/redis/redis.conf

# Redis Stack (شامل ماژول‌ها)
docker run -d --name redis-stack \
    -p 6379:6379 -p 8001:8001 \
    redis/redis-stack:latest

# نسخه‌ی خاص
docker run -d --name my-redis redis:7.2.4
docker run -d --name my-redis redis:7-alpine  # سبک‌تر

# بررسی لاگ
docker logs my-redis

# اجرای دستور در container
docker exec -it my-redis redis-cli
docker exec -it my-redis redis-cli INFO server

Dockerfile سفارشی

اگر نیاز به تنظیمات خاص دارید، می‌توانید Dockerfile خود را بسازید:

dockerfile
# Dockerfile
FROM redis:7

# کپی redis.conf
COPY redis.conf /usr/local/etc/redis/redis.conf

# کپی اسکریپت‌های راه‌اندازی
COPY scripts/ /scripts/
RUN chmod +x /scripts/*.sh

# نصب ابزارهای مفید
RUN apt-get update && apt-get install -y \
    curl \
    vim \
    && rm -rf /var/lib/apt/lists/*

# تنظیم volume
VOLUME /data

# exposE port
EXPOSE 6379

# healthcheck
HEALTHCHECK --interval=30s --timeout=3s --retries=3 \
    CMD redis-cli ping | grep -q PONG || exit 1

# اجرای Redis با conf
CMD ["redis-server", "/usr/local/etc/redis/redis.conf"]

Docker Compose — محیط توسعه

برای محیط توسعه، Docker Compose راه‌حل ایده‌آلی است. در اینجا یک پیکربندی کامل با Redis، Sentinel، و یک اپلیکیشن می‌بینید:

yaml
# docker-compose.yml
version: '3.8'

services:
  # Redis Master
  redis-master:
    image: redis:7
    container_name: redis-master
    command: redis-server --appendonly yes --maxmemory 512mb --maxmemory-policy allkeys-lru
    ports:
      - "6379:6379"
    volumes:
      - redis-master-data:/data
    networks:
      - redis-network
    healthcheck:
      test: ["CMD", "redis-cli", "ping"]
      interval: 10s
      timeout: 3s
      retries: 3

  # Redis Replica
  redis-replica:
    image: redis:7
    container_name: redis-replica
    command: redis-server --replicaof redis-master 6379 --appendonly yes
    depends_on:
      redis-master:
        condition: service_healthy
    ports:
      - "6380:6379"
    volumes:
      - redis-replica-data:/data
    networks:
      - redis-network

  # Redis Insight (رابط وب)
  redis-insight:
    image: redis/redisinsight:latest
    container_name: redis-insight
    ports:
      - "8001:8001"
    depends_on:
      - redis-master
    networks:
      - redis-network

  # اپلیکیشن نمونه
  webapp:
    build: .
    container_name: webapp
    environment:
      - REDIS_URL=redis://redis-master:6379
      - REDIS_REPLICA_URL=redis://redis-replica:6379
    depends_on:
      redis-master:
        condition: service_healthy
    ports:
      - "5000:5000"
    networks:
      - redis-network

volumes:
  redis-master-data:
  redis-replica-data:

networks:
  redis-network:
    driver: bridge

Redis Stack با Docker Compose

yaml
version: '3.8'

services:
  redis-stack:
    image: redis/redis-stack:latest
    container_name: redis-stack
    environment:
      - REDIS_ARGS=--maxmemory 512mb --maxmemory-policy allkeys-lru
      - REDISINSIGHT_ARGS=--port 8001
    ports:
      - "6379:6379"   # Redis
      - "8001:8001"   # RedisInsight
    volumes:
      - redis-stack-data:/data
    restart: unless-stopped

volumes:
  redis-stack-data:

Redis در Kubernetes

برای محیط production، Kubernetes راه‌حل قدرتمندتری است. در ادامه، یک استقرار Redis کامل در K8s می‌بینید:

۱. ConfigMap

yaml
# redis-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: redis-config
  namespace: redis
data:
  redis.conf: |
    bind 0.0.0.0
    protected-mode yes
    port 6379

    # Memory
    maxmemory 1gb
    maxmemory-policy allkeys-lru

    # Persistence
    appendonly yes
    appendfsync everysec
    save 900 1
    save 300 10
    save 60 10000

    # Logging
    loglevel notice
    logfile ""

    # Network
    timeout 300
    tcp-keepalive 300

    # Limits
    maxclients 10000

۲. Persistent Volume

yaml
# redis-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: redis-pvc
  namespace: redis
spec:
  accessModes:
    - ReadWriteOnce
  storageClassName: fast-ssd  # یا storage class موجود
  resources:
    requests:
      storage: 10Gi

۳. Deployment (Single Master)

yaml
# redis-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: redis
  namespace: redis
  labels:
    app: redis
spec:
  replicas: 1
  selector:
    matchLabels:
      app: redis
  template:
    metadata:
      labels:
        app: redis
    spec:
      containers:
      - name: redis
        image: redis:7
        command: ["redis-server", "/etc/redis/redis.conf"]
        ports:
        - containerPort: 6379
          name: redis
        resources:
          requests:
            cpu: 500m
            memory: 1Gi
          limits:
            cpu: 1000m
            memory: 2Gi
        volumeMounts:
        - name: redis-config
          mountPath: /etc/redis
        - name: redis-data
          mountPath: /data
        livenessProbe:
          exec:
            command: ["redis-cli", "ping"]
          initialDelaySeconds: 30
          periodSeconds: 10
          timeoutSeconds: 5
        readinessProbe:
          exec:
            command: ["redis-cli", "ping"]
          initialDelaySeconds: 5
          periodSeconds: 5
      volumes:
      - name: redis-config
        configMap:
          name: redis-config
      - name: redis-data
        persistentVolumeClaim:
          claimName: redis-pvc

۴. Service

yaml
# redis-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: redis
  namespace: redis
spec:
  selector:
    app: redis
  ports:
  - port: 6379
    targetPort: 6379
    name: redis
  type: ClusterIP

۵. Secret (برای رمز عبور)

yaml
# redis-secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: redis-secret
  namespace: redis
type: Opaque
data:
  redis-password: eW91cl9zdHJvbmdfcGFzc3dvcmQ=  # base64 of "your_strong_password"

# استفاده در Deployment:
# env:
# - name: REDIS_PASSWORD
#   valueFrom:
#     secretKeyRef:
#       name: redis-secret
#       key: redis-password

۶. StatefulSet با Replication

برای استقرار Redis با replication در K8s، StatefulSet بهتر از Deployment است:

yaml
# redis-statefulset.yaml
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: redis
  namespace: redis
spec:
  serviceName: redis
  replicas: 3  # 1 master + 2 replica
  selector:
    matchLabels:
      app: redis
  template:
    metadata:
      labels:
        app: redis
    spec:
      containers:
      - name: redis
        image: redis:7
        ports:
        - containerPort: 6379
        volumeMounts:
        - name: redis-data
          mountPath: /data
  volumeClaimTemplates:
  - metadata:
      name: redis-data
    spec:
      accessModes: ["ReadWriteOnce"]
      resources:
        requests:
          storage: 10Gi

Redis Operator — راه‌حل پیشنهادی

برای production واقعی، توصیه می‌شود از Redis Operator استفاده کنید. Operator یک controller برای K8s است که چرخه‌ی حیات Redis (شامل deployment، scaling، failover، backup) را به‌صورت خودکار مدیریت می‌کند. چند Operator محبوب عبارتند از:

Redis Operator (OT Containerized): رایگان و متن‌باز، با پشتیبانی Sentinel

Spotahome Redis Operator: رایگان و ساده

Redis Enterprise Operator: نسخه‌ی رسمی Redis Inc. (تجاری)

Kruise Redis: توسط Alibaba Cloud

OperatorHub.io: جستجوی Operator‌های دیگر

در ادامه، یک نمونه‌ی استفاده از OT Containerized Redis Operator را می‌بینید:

bash
# نصب Operator
kubectl apply -f https://raw.githubusercontent.com/OT-CONTAINER-KIT/redis-operator/master/manifests/operator.yaml

# بررسی نصب
kubectl get pods -n redis-operator
yaml
# redis-cluster.yaml
apiVersion: redis.opstreelabs.in/v1beta1
kind: Redis
metadata:
  name: redis-cluster
  namespace: redis
spec:
  size: 3  # 1 master + 2 replica
  redisExporter:
    enabled: true
    image: quay.io/opstree/redis-exporter:1.0
  storage:
    volumeClaimTemplate:
      spec:
        storageClassName: fast-ssd
        accessModes: ["ReadWriteOnce"]
        resources:
          requests:
            storage: 10Gi
  redisConfig:
    additionalRedisConfig:
      maxmemory-policy: "allkeys-lru"
      appendonly: "yes"
  securityContext:
    runAsUser: 1000
    fsGroup: 1000
  resources:
    requests:
      cpu: 500m
      memory: 1Gi
    limits:
      cpu: 1000m
      memory: 2Gi

Helm Chart — استقرار سریع

برای استقرار سریع‌تر، می‌توانید از Helm Chart‌های آماده استفاده کنید:

bash
# اضافه کردن repository رسمی Bitnami
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update

# نصب Redis ساده
helm install my-redis bitnami/redis

# نصب Redis Cluster
helm install my-cluster bitnami/redis-cluster

# نصب با مقادیر سفارشی
cat >> values.yaml <<EOF
architecture: replication
auth:
  password: "your_strong_password"
master:
  persistence:
    size: 10Gi
  resources:
    requests:
      cpu: 500m
      memory: 1Gi
replica:
  replicaCount: 3
  persistence:
    size: 10Gi
metrics:
  enabled: true
EOF

helm install my-redis bitnami/redis -f values.yaml

# به‌روزرسانی
helm upgrade my-redis bitnami/redis -f values.yaml

# حذف
helm uninstall my-redis

Network Policy

برای امنیت در K8s، باید با NetworkPolicy دسترسی به Redis را محدود کنید:

yaml
# redis-network-policy.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: redis-network-policy
  namespace: redis
spec:
  podSelector:
    matchLabels:
      app: redis
  policyTypes:
  - Ingress
  ingress:
  - from:
    # فقط pods با label app=webapp
    - podSelector:
        matchLabels:
          app: webapp
    # یا namespace خاص
    - namespaceSelector:
        matchLabels:
          name: production
    ports:
    - protocol: TCP
      port: 6379

Horizontal Pod Autoscaler

برای Redis Cluster (نه standalone)، می‌توان از HPA برای autoscaling replica‌ها استفاده کرد:

yaml
# redis-hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: redis-hpa
  namespace: redis
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: StatefulSet
    name: redis
  minReplicas: 3
  maxReplicas: 10
  metrics:
  - type: Resource
    resource:
      name: cpu
      target:
        type: Utilization
        averageUtilization: 70
  - type: Resource
    resource:
      name: memory
      target:
        type: Utilization
        averageUtilization: 80

Backup و Restore در K8s

برای backup خودکار، می‌توان از CronJob استفاده کرد:

yaml
# redis-backup-cronjob.yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: redis-backup
  namespace: redis
spec:
  schedule: "0 2 * * *"  # روزانه ساعت ۲ صبح
  jobTemplate:
    spec:
      template:
        spec:
          containers:
          - name: redis-backup
            image: redis:7
            command:
            - /bin/sh
            - -c
            - |
              redis-cli -h redis -a $REDIS_PASSWORD BGSAVE
              while [ "$(redis-cli -h redis -a $REDIS_PASSWORD INFO persistence | grep rdb_bgsave_in_progress | cut -d: -f2 | tr -d '\r')" != "0" ]; do
                sleep 1
              done
              cp /redis-data/dump.rdb /backups/redis-$(date +%Y%m%d-%H%M%S).rdb
              # پاک کردن backupهای قدیمی‌تر از ۳۰ روز
              find /backups -name "redis-*.rdb" -mtime +30 -delete
            env:
            - name: REDIS_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: redis-secret
                  key: redis-password
            volumeMounts:
            - name: redis-data
              mountPath: /redis-data
              readOnly: true
            - name: backups
              mountPath: /backups
          volumes:
          - name: redis-data
            persistentVolumeClaim:
              claimName: redis-pvc
          - name: backups
            persistentVolumeClaim:
              claimName: redis-backup-pvc
          restartPolicy: OnFailure

بهترین روش‌های تولید

۱. از StatefulSet استفاده کنید، نه Deployment. StatefulSet برای پایگاه داده طراحی شده و identity پایدار، ordered deployment، و persistent storage ارائه می‌دهد.

۲. Resource limits تنظیم کنید. بدون limits، Redis ممکن است تمام node را اشغال کند و روی pods دیگر تأثیر بگذارد.

۳. liveness و readiness probes. این probes به K8s کمک می‌کنند بفهمد Redis سالم است یا نه.

۴. Persistent Volume با storage class سریع. Redis I/O-bound است؛ استفاده از NVMe یا SSD سریع تأثیر زیادی دارد.

۵. Anti-affinity rules. مطمئن شوید replica‌ها روی node‌های مختلف هستند تا در صورت خرابی یک node، کل Cluster از دسترس خارج نشود:

yaml
affinity:
  podAntiAffinity:
    requiredDuringSchedulingIgnoredDuringExecution:
    - labelSelector:
        matchExpressions:
        - key: app
          operator: In
          values:
          - redis
      topologyKey: kubernetes.io/hostname

۶. Monitoring. حتماً redis-exporter را استقرار دهید و متریک‌ها را به Prometheus بفرستید.

۷. به‌روزرسانی تدریجی (Rolling Update). با updateStrategy، می‌توان به‌صورت تدریجی آپگرید کرد:

yaml
updateStrategy:
  type: RollingUpdate
  rollingUpdate:
    partition: 0
podManagementPolicy: OrderedReady  # یا Parallel
📝 خلاصه فصل

در این فصل با استقرار Redis در Docker و Kubernetes آشنا شدید: Docker برای محیط توسعه، Docker Compose برای محیط توسعه‌ی چندسرویسی، و Kubernetes برای production. همچنین با Redis Operator و Helm Charts که استقرار را ساده می‌کنند، کار کردید. برای production واقعی، توصیه می‌شود از Operator یا Helm Chart رسمی استفاده کنید — نه از StatefulSet دستی. در فصل آخر، با یکپارچه‌سازی Redis با Python و Node.js و بهترین روش‌ها کار می‌کنیم.

Chapter 25

Python/Node و Best Practices #

در فصل پایانی این کتاب، با یکپارچه‌سازی Redis در دو زبان محبوب Python و Node.js آشنا می‌شوید و سپس به سراغ Best Practices و الگوهای طراحی می‌رویم که در طول کتاب به آن‌ها اشاره کردیم. این فصل جمع‌بندی تمام آموخته‌های شماست و آماده‌ی استفاده در پروژه‌های واقعی می‌باشد. در پایان، یک Cheat Sheet کوتاه از پرکاربردترین دستورات ارائه می‌کنیم که می‌توانید آن را برای مرجع سریع نگه دارید.

راه‌اندازی در Python با redis-py

redis-py کتابخانه‌ی رسمی Redis برای Python است. نصب آن ساده است:

bash
# نصب
pip install redis

# نصب با قابلیت‌های اضافی
pip install "redis[hiredis]"  # parser سریع‌تر (C-based)
pip install "redis[asyncio]"  # پشتیبانی async

اتصال پایه

python
import redis

# اتصال ساده
r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)

# با رمز عبور
r = redis.Redis(
    host='redis.example.com',
    port=6379,
    password='your_strong_password',
    decode_responses=True,
)

# با ACL (نسخه ۶ به بعد)
r = redis.Redis(
    host='redis.example.com',
    port=6379,
    username='webapp',
    password='webapp_pass',
    decode_responses=True,
)

# با TLS
import ssl
r = redis.Redis(
    host='redis.example.com',
    port=6379,
    ssl=True,
    ssl_ca_certs='/path/to/ca.crt',
    decode_responses=True,
)

# با Connection Pool (برای performance بهتر)
pool = redis.ConnectionPool(
    host='localhost',
    port=6379,
    max_connections=50,
    decode_responses=True,
)
r = redis.Redis(connection_pool=pool)

# تست اتصال
print(r.ping())  # True

# decode_responses=True باعث می‌شود خروجی به‌جای bytes، str باشد
# (پیشنهاد می‌شود مگر اینکه با داده‌ی باینری کار کنید)

عملیات پایه در Python

python
import redis

r = redis.Redis(host='localhost', port=6379, decode_responses=True)

# ===== String =====
r.set('key', 'value')
r.set('key', 'value', ex=3600)  # با TTL
r.set('key', 'value', nx=True)  # فقط اگر وجود نداشته باشد
value = r.get('key')
r.delete('key')

# Counter
r.incr('counter')
r.incrby('counter', 5)
r.decr('counter')

# ===== Hash =====
r.hset('user:1', mapping={'name': 'Alice', 'age': 30})
r.hget('user:1', 'name')
r.hgetall('user:1')
r.hincrby('user:1', 'age', 1)
r.hdel('user:1', 'age')

# ===== List =====
r.lpush('queue', 'task1', 'task2')
r.rpush('queue', 'task3')
r.lpop('queue')
r.rpop('queue')
r.lrange('queue', 0, -1)
r.llen('queue')

# ===== Set =====
r.sadd('tags:post:1', 'redis', 'database', 'nosql')
r.smembers('tags:post:1')
r.sismember('tags:post:1', 'redis')
r.sinter('tags:post:1', 'tags:post:2')
r.sunion('tags:post:1', 'tags:post:2')

# ===== Sorted Set =====
r.zadd('leaderboard', {'alice': 100, 'bob': 250, 'carol': 175})
r.zrevrange('leaderboard', 0, -1, withscores=True)
r.zincrby('leaderboard', 5, 'alice')
r.zrank('leaderboard', 'bob')

# ===== Stream =====
r.xadd('orders', {'user_id': '1001', 'amount': '99.99'})
r.xrange('orders', '-', '+')
r.xread({'orders': '0'}, count=10, block=1000)

# ===== Pub/Sub =====
pubsub = r.pubsub()
pubsub.subscribe('notifications')
for message in pubsub.listen():
    if message['type'] == 'message':
        print(message['data'])

Async Redis در Python

برای برنامه‌های async (مثل FastAPI یا asyncio)، می‌توانید از redis.asyncio استفاده کنید:

python
import asyncio
import redis.asyncio as redis

async def main():
    r = redis.Redis(host='localhost', port=6379, decode_responses=True)

    # عملیات async
    await r.set('key', 'value')
    value = await r.get('key')
    print(value)

    # Pipeline async
    pipe = r.pipeline()
    pipe.set('key1', 'val1')
    pipe.set('key2', 'val2')
    await pipe.execute()

    # Pub/Sub async
    pubsub = r.pubsub()
    await pubsub.subscribe('notifications')
    async for message in pubsub.listen():
        if message['type'] == 'message':
            print(message['data'])
            break

    await r.close()

asyncio.run(main())

# با FastAPI
from fastapi import FastAPI
import redis.asyncio as redis

app = FastAPI()
r = redis.Redis(host='localhost', port=6379, decode_responses=True)

@app.get("/user/{user_id}")
async def get_user(user_id: str):
    # ابتدا cache را چک کن
    cached = await r.get(f"user:{user_id}")
    if cached:
        return {"source": "cache", "data": cached}
    # اگر نبود، از DB بخوان
    user = await fetch_from_db(user_id)
    await r.set(f"user:{user_id}", user, ex=3600)
    return {"source": "db", "data": user}

راه‌اندازی در Node.js با ioredis

دو کتابخانه‌ی محبوب برای Redis در Node.js وجود دارد: node-redis (رسمی) و ioredis (محبوب‌تر، با قابلیت‌های بیشتر). در اینجا از ioredis استفاده می‌کنیم:

bash
# نصب
npm install ioredis

# یا با node-redis (رسمی)
npm install redis
javascript
const Redis = require('ioredis');

// اتصال ساده
const redis = new Redis({
    host: 'localhost',
    port: 6379,
});

// با رمز عبور
const redis = new Redis({
    host: 'redis.example.com',
    port: 6379,
    password: 'your_strong_password',
    username: 'webapp',  // ACL
});

// با TLS
const redis = new Redis({
    host: 'redis.example.com',
    port: 6379,
    tls: {
        ca: fs.readFileSync('/path/to/ca.crt'),
        // cert: fs.readFileSync('/path/to/client.crt'),
        // key: fs.readFileSync('/path/to/client.key'),
    },
});

// تست
redis.ping().then(console.log);  // PONG

// ===== String =====
await redis.set('key', 'value');
await redis.set('key', 'value', 'EX', 3600);  // TTL
await redis.set('key', 'value', 'NX');  // فقط اگر نباشد
const value = await redis.get('key');
await redis.del('key');

// Counter
await redis.incr('counter');
await redis.incrby('counter', 5);

// ===== Hash =====
await redis.hset('user:1', 'name', 'Alice', 'age', 30);
const name = await redis.hget('user:1', 'name');
const all = await redis.hgetall('user:1');
await redis.hincrby('user:1', 'age', 1);

// ===== List =====
await redis.lpush('queue', 'task1', 'task2');
await redis.rpush('queue', 'task3');
const item = await redis.lpop('queue');
const items = await redis.lrange('queue', 0, -1);

// ===== Set =====
await redis.sadd('tags', 'redis', 'database');
const members = await redis.smembers('tags');
const exists = await redis.sismember('tags', 'redis');

// ===== Sorted Set =====
await redis.zadd('leaderboard', 100, 'alice', 250, 'bob');
const top = await redis.zrevrange('leaderboard', 0, -1, 'WITHSCORES');

// ===== Stream =====
await redis.xadd('orders', '*', 'user_id', '1001', 'amount', '99.99');
const messages = await redis.xrange('orders', '-', '+');

// ===== Pipeline (batch) =====
const pipeline = redis.pipeline();
pipeline.set('key1', 'val1');
pipeline.set('key2', 'val2');
pipeline.incr('counter');
const results = await pipeline.exec();
// results = [[err, result], [err, result], ...]

// ===== Transaction =====
const multi = redis.multi();
multi.set('key1', 'val1');
multi.incr('counter');
const results = await multi.exec();

// ===== Pub/Sub =====
const subscriber = new Redis({ host: 'localhost', port: 6379 });
await subscriber.subscribe('notifications');
subscriber.on('message', (channel, message) => {
    console.log(`[${channel}] ${message}`);
});

اتصال به Sentinel و Cluster در Node.js

javascript
const Redis = require('ioredis');

// Sentinel
const redis = new Redis({
    sentinels: [
        { host: 'sentinel1.redis.local', port: 26379 },
        { host: 'sentinel2.redis.local', port: 26379 },
        { host: 'sentinel3.redis.local', port: 26379 },
    ],
    name: 'mymaster',
    password: 'your_password',
    role: 'master',  // یا 'slave' برای reads
});

// Cluster
const cluster = new Redis.Cluster([
    { host: 'node1.redis.local', port: 7000 },
    { host: 'node2.redis.local', port: 7001 },
    { host: 'node3.redis.local', port: 7002 },
], {
    redisOptions: {
        password: 'your_password',
    },
});

// عملیات روی Cluster (به‌طور خودکار routing)
await cluster.set('user:1', 'Alice');
const value = await cluster.get('user:1');

// با hash tag
await cluster.set('{user:1}:name', 'Alice');
await cluster.set('{user:1}:age', 30);
// اکنون می‌توان transaction روی این کلیدها زد

Best Practices — جمع‌بندی

در طول کتاب، به‌طور پراکنده به چندین best practice اشاره کردیم. در اینجا آن‌ها را به‌صورت یکجا جمع‌بندی می‌کنیم:

۱. کلیدها و نام‌گذاری

• از : برای جداسازی بخش‌های کلید استفاده کنید (مثلاً user:1001:profile).

• کلیدها را کوتاه اما معنادار نگه دارید. در دیتابیس بزرگ، صرفه‌جویی در چند بایت، صدها مگابایت می‌شود.

• برای ساختارهای داده‌ی مرتبط، از hash tags در Cluster استفاده کنید: {user:1}:profile.

• از کاراکترهای خاص (فاصله، کوتیشن، یونیکد) در کلیدها پرهیز کنید.

۲. TTL و Eviction

• همیشه TTL تنظیم کنید — مگر برای داده‌های واقعاً دائمی.

maxmemory و maxmemory-policy را در production همیشه تنظیم کنید.

• برای cache خالص، از allkeys-lru استفاده کنید.

• برای cache + داده‌ی دائمی، از volatile-lru با TTL روی کلیدهای cache استفاده کنید.

۳. ساختار داده

• به‌جای چند کلید جداگانه برای فیلدهای یک شیء، از Hash استفاده کنید.

• به‌جای JSON به‌صورت String، اگر به‌روزرسانی فیلد نیاز دارید، از Hash یا RedisJSON استفاده کنید.

• برای صف‌های ساده، List با BLPOP مناسب است؛ برای صف‌های reliable، از Stream استفاده کنید.

• برای شمارش approximate کاربران یکتا، HyperLogLog بهتر از Set است.

• برای داده‌های باینری با user_id متوالی، Bitmap بهترین انتخاب است.

۴. عملکرد

• همیشه از Pipeline برای عملیات bulk استفاده کنید.

• به‌جای KEYS *، از SCAN استفاده کنید.

• به‌جای DEL برای کلیدهای بزرگ، از UNLINK استفاده کنید.

• برای عملیات atomic پیچیده، از Lua Script به‌جای MULTI/EXEC + WATCH استفاده کنید.

• Connection Pool را در کلاینت تنظیم کنید تا overhead اتصال کاهش یابد.

۵. امنیت

• هرگز Redis را مستقیماً روی اینترنت قرار ندهید.

• همیشه ACL یا رمز عبور تنظیم کنید.

• از TLS برای ارتباطات خارجی استفاده کنید.

• دستورات خطرناک (FLUSHALL، CONFIG) را محدود کنید.

• Redis را به‌عنوان کاربر غیر-root اجرا کنید.

۶. پایداری

• در production، هم RDB و هم AOF را فعال کنید.

appendfsync everysec بهترین تعادل است.

• backup روزانه از فایل‌های RDB بگیرید و در جای دیگری ذخیره کنید.

• تست restore را به‌طور منظم انجام دهید تا در زمان بحران آماده باشید.

۷. مقیاس‌پذیری

• برای HA، از Sentinel استفاده کنید.

• برای sharding و مقیاس‌پذیری افقی، از Redis Cluster استفاده کنید.

• در ابتدا با Sentinel شروع کنید و وقتی به مرز ظرفیت رسیدید، به Cluster مهاجرت کنید.

• read replicas را برای load balancing خواندن در نظر بگیرید.

۸. مانیتورینگ

• متریک‌های کلیدی (memory، hit rate، ops/sec، latency) را مانیتور کنید.

• از Prometheus + Grafana یا RedisInsight استفاده کنید.

SLOWLOG را برای یافتن کوئری‌های کند چک کنید.

• alert تنظیم کنید برای حالات بحرانی (memory بالا، replication down، ...).

Cheat Sheet — مرجع سریع

در اینجا یک مرجع سریع از پرکاربردترین دستورات Redis ارائه می‌کنیم:

bash
# ===== String =====
SET key value [EX seconds] [NX|XX]
GET key
DEL key1 key2 ...
INCR key / DECR key / INCRBY key n / INCRBYFLOAT key n
MSET k1 v1 k2 v2 / MGET k1 k2
APPEND key value
STRLEN key
GETRANGE key start end / SETRANGE key offset value

# ===== Hash =====
HSET key field value [field value ...]
HGET key field / HMGET key f1 f2
HGETALL key / HKEYS key / HVALS key
HDEL key f1 f2
HINCRBY key field n
HEXISTS key field / HLEN key
HSCAN key cursor [MATCH pattern] [COUNT n]

# ===== List =====
LPUSH key v1 v2 / RPUSH key v1 v2
LPOP key [n] / RPOP key [n]
LRANGE key start stop / LINDEX key index
LLEN key
LSET key index value
LINSERT key BEFORE|AFTER pivot value
LREM key count value
LTRIM key start stop
BLPOP key [key ...] timeout / BRPOP key [key ...] timeout
RPOPLPUSH source destination / LMOVE src dst from to

# ===== Set =====
SADD key m1 m2 / SREM key m1 m2
SMEMBERS key / SISMEMBER key m / SCARD key
SINTER k1 k2 / SUNION k1 k2 / SDIFF k1 k2
SINTERSTORE dest k1 k2 / SUNIONSTORE dest k1 k2
SPOP key [count] / SRANDMEMBER key [count]
SMOVE source dest member
SSCAN key cursor [MATCH pattern]

# ===== Sorted Set =====
ZADD key [NX|XX|GT|LT] [CH] [INCR] score member [score member ...]
ZRANGE key start stop [WITHSCORES] [REV] [BYSCORE]
ZRANK key member / ZREVRANK key member
ZSCORE key member / ZMSCORE key m1 m2
ZINCRBY key increment member
ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT offset count]
ZPOPMIN key [n] / ZPOPMAX key [n]
BZPOPMIN key [key ...] timeout / BZPOPMAX key [key ...] timeout
ZUNIONSTORE dest numkeys k1 k2 [WEIGHTS w1 w2] [AGGREGATE SUM|MIN|MAX]
ZINTERSTORE dest numkeys k1 k2 [WEIGHTS w1 w2]

# ===== Stream =====
XADD key [MAXLEN [~] n] *|ID field value [field value ...]
XREAD [COUNT n] [BLOCK ms] STREAMS key [key ...] ID [ID ...]
XRANGE key start end [COUNT n]
XLEN key
XGROUP CREATE key group $|id [MKSTREAM]
XREADGROUP GROUP group consumer [COUNT n] [BLOCK ms] STREAMS key [key ...] >|ID [ID ...]
XACK key group id [id ...]
XPENDING key group [start end count] [consumer]
XCLAIM key group consumer min-idle-time id [id ...]
XAUTOCLAIM key group consumer min-idle-time start [COUNT n]
XTRIM key MAXLEN [~] n / XTRIM key MINID [~] id
XINFO STREAM key / XINFO GROUPS key / XINFO CONSUMERS key group

# ===== Pub/Sub =====
PUBLISH channel message
SUBSCRIBE channel [channel ...]
UNSUBSCRIBE [channel ...]
PSUBSCRIBE pattern [pattern ...]
PUNSUBSCRIBE [pattern ...]
PUBSUB CHANNELS [pattern] / PUBSUB NUMSUB [channel ...] / PUBSUB NUMPAT
SPUBLISH channel message / SSUBSCRIBE channel  # Sharded (نسخه ۷)

# ===== Transactions =====
MULTI / EXEC / DISCARD
WATCH key [key ...] / UNWATCH

# ===== Keys و TTL =====
EXPIRE key seconds / PEXPIRE key ms
EXPIREAT key timestamp / PEXPIREAT key timestamp-ms
TTL key / PTTL key
PERSIST key
TYPE key / OBJECT ENCODING key / OBJECT IDLETIME key
EXISTS key / RANDOMKEY / DBSIZE
RENAME key newkey / RENAMENX key newkey
COPY source dest [DB n] [REPLACE]
KEYS pattern (خطرناک!) / SCAN cursor [MATCH pattern] [COUNT n] [TYPE type]
UNLINK key (بهتر از DEL برای کلیدهای بزرگ)

# ===== Bit Operations =====
SETBIT key offset value / GETBIT key offset
BITCOUNT key [start end [BYTE|BIT]]
BITOP AND|OR|XOR|NOT destkey key [key ...]
BITPOS key bit [start [end [BYTE|BIT]]]
BITFIELD key [GET type offset] [SET type offset value] [INCRBY type offset increment] [OVERFLOW WRAP|SAT|FAIL]

# ===== HyperLogLog =====
PFADD key [element [element ...]]
PFCOUNT key [key ...]
PFMERGE destkey key [key ...]

# ===== Geo =====
GEOADD key [NX|XX] [CH] longitude latitude member [longitude latitude member ...]
GEOPOS key member [member ...]
GEODIST key m1 m2 [m|km|mi|ft]
GEOSEARCH key <FROMMEMBER|FROMLONLAT> ... <BYRADIUS|BYBOX> [ASC|DESC] [COUNT n]
GEOSEARCHSTORE dest src ...

# ===== Server / Admin =====
PING / ECHO message
SELECT db / MOVE key db
FLUSHDB [ASYNC] / FLUSHALL [ASYNC] (با احتیاط!)
INFO [section] / DBSIZE
CONFIG GET parameter / CONFIG SET parameter value / CONFIG REWRITE
CLIENT LIST / CLIENT KILL / CLIENT GETNAME / CLIENT SETNAME
CLIENT PAUSE ms / CLIENT TRACKING ON|OFF
SLOWLOG GET [n] / SLOWLOG LEN / SLOWLOG RESET
LATENCY HISTORY event / LATENCY LATEST / LATENCY RESET
MEMORY USAGE key / MEMORY STATS / MEMORY DOCTOR / MEMORY PURGE
OBJECT ENCODING key / OBJECT REFCOUNT key / OBJECT IDLETIME key / OBJECT FREQ key
DEBUG SLEEP seconds / DEBUG OBJECT key
LASTSAVE / BGSAVE / SAVE / BGREWRITEAOF
SHUTDOWN [NOSAVE|SAVE]
MONITOR (با احتیاط!)

# ===== Scripting =====
EVAL script numkeys key [key ...] arg [arg ...]
EVALSHA sha1 numkeys key [key ...] arg [arg ...]
SCRIPT LOAD script / SCRIPT EXISTS sha1 [sha1 ...] / SCRIPT FLUSH
FUNCTION LOAD code / FCALL function numkeys key [key ...] arg [arg ...]

# ===== Replication =====
REPLICAOF host port / REPLICAOF NO ONE
INFO replication
WAIT numreplicas timeout

# ===== Cluster =====
CLUSTER INFO / CLUSTER NODES / CLUSTER SLOTS
CLUSTER MEET ip port / CLUSTER FORGET node-id
CLUSTER KEYSLOT key / CLUSTER COUNTKEYSINSLOT slot
CLUSTER GETKEYSINSLOT slot count
CLUSTER FAILOVER [TAKEOVER|FORCE]

# ===== ACL =====
ACL WHOAMI / ACL LIST / ACL USERS / ACL CAT [category]
ACL SETUSER username [rules]
ACL GETUSER username / ACL DELUSER username [username ...]
ACL SAVE / ACL LOAD

منابع برای ادامه‌ی یادگیری

این کتاب یک نقطه‌ی شروع بود. برای ادامه‌ی یادگیری، منابع زیر را توصیه می‌کنم:

داکیومنت رسمی Redis: https://redis.io/docs/ — منبع کامل و همیشه به‌روز.

Redis Source Code: https://github.com/redis/redis — برای درک عمیق، خواندن کد منبع بسیار مفید است.

Redis Blog: https://redis.com/blog/ — مقالات فنی و case study‌ها.

Redis University: https://university.redis.com/ — دوره‌های رایگان آنلاین.

Redis Stack Docs: https://redis.io/docs/stack/ — مستندات ماژول‌ها.

کتاب "Redis in Action": اثر Josiah Carlson — یک کتاب جامع (البته قدیمی، نسخه ۳).

Community: Stack Overflow، Reddit r/redis، Discord Redis.

سخن پایانی

Redis یکی از قدرتمندترین و انعطاف‌پذیرترین ابزارهای ذخیره‌سازی داده در دنیای امروز است. از یک پروژه‌ی شخصی توسط سالواتوره سانفیلیپو در ۲۰۰۹ شروع شد و امروز به یکی از محبوب‌ترین پایگاه‌های داده‌ی جهان تبدیل شده است. در این کتاب، با تمام جنبه‌های اصلی Redis آشنا شدید — از نصب و راه‌اندازی، تا ساختارهای داده، الگوهای caching، امنیت، پایداری، replication، cluster، ماژول‌ها، و استقرار در production.

کلید موفقیت با Redis، درک عمیق ساختارهای داده و انتخاب درست الگو برای هر کاربرد است. امیدوارم این کتاب به شما کمک کرده باشد تا نه‌تنها یاد بگیرید چگونه از Redis استفاده کنید، بلکه چرا و کجا از آن استفاده کنید را هم درک کنید. موفق باشید!

📝 خلاصه فصل

در این فصل پایانی، با یکپارچه‌سازی Redis در Python (redis-py و async) و Node.js (ioredis) آشنا شدید و سپس به سراغ Best Practices و Cheat Sheet رفتیم. این کتاب یک مرجع کامل برای شروع کار با Redis بود؛ اما یادگیری واقعی با عمل کردن در پروژه‌های واقعی اتفاق می‌افتد. امیدوارم این کتاب همراه شما در مسیر حرفه‌ای‌تان باشد.

Appendix

واژه‌نامه #

در این واژه‌نامه، مهم‌ترین اصطلاحات Redis و مفاهیم مرتبط را به‌صورت خلاصه توضیح می‌دهیم. این واژه‌نامه می‌تواند برای مرجع سریع در حین مطالعه‌ی کتاب یا کار با Redis مفید باشد. واژه‌ها به ترتیب حروف انگلیسی مرتب شده‌اند تا جستجوی آن‌ها ساده باشد.

اصطلاح توضیح
AOFAppend-Only File — یک فایل log که تمام دستورات نوشتن را ثبت می‌کند و در restart، replay می‌شود.
ACLAccess Control List — سیستم مدیریت کاربران و دسترسی‌ها در Redis (از نسخه ۶ به بعد).
Active Expiryفرآیند دوره‌ای Redis که کلیدهای منقضی‌شده را به‌صورت پیش‌گیرانه پاک می‌کند.
Bitmapیک String که به‌عنوان آرایه‌ی بیتی استفاده می‌شود — هر بیت یک وضعیت باینری نشان می‌دهد.
Bitfieldاعداد صحیح با عرض مشخص درون یک String، برای شمارنده‌های فشرده.
Bloom Filterساختار داده‌ی احتمالی برای تست عضویت سریع با احتمال false positive اما بدون false negative.
Cache-Asideالگوی caching که در آن اپلیکیشن مستقیماً cache و database را مدیریت می‌کند.
Cache Stampedeپدیده‌ای که وقتی یک کلید محبوب از cache حذف می‌شود، صدها درخواست همزمان به DB حمله می‌کنند.
CASCompare-And-Swap — یک الگوی atomic که فقط در صورت تطابق با مقدار مورد انتظار، به‌روزرسانی می‌کند.
Clusterراه‌حل رسمی Redis برای sharding و مقیاس‌پذیری افقی با ۱۶۳۸۴ hash slot.
Connection Poolمجموعه‌ای از اتصال‌های قابل استفاده‌ی مجدد که overhead ساخت اتصال جدید را کاهش می‌دهد.
Consumer Groupگروهی از consumerها که پیام‌های یک Stream را به‌صورت تقسیم‌شده پردازش می‌کنند.
CRC16الگوریتم hash که برای نگاشت کلید به hash slot در Cluster استفاده می‌شود.
Cuckoo Filterنسخه‌ی بهبودیافته‌ی Bloom Filter که قابلیت حذف عضو را دارد.
Distributed Lockقفلی که در محیط توزیع‌شده هماهنگی بین چند process را فراهم می‌کند (معمولاً با SET NX + TTL).
Eviction Policyسیاستی که تعیین می‌کند وقتی حافظه پر می‌شود، کدام کلید حذف شود (LRU، LFU، و...).
Failoverفرآیند خودکار ارتقای یک replica به master وقتی master اصلی قطع می‌شود.
HyperLogLogساختار داده‌ی احتمالی برای شمارش approximate cardinality با حافظه‌ی ثابت ۱۲KB.
Hashساختار داده‌ی field-value، شبیه به dict در Python یا Object در JavaScript.
Hash Slotواحد partitioning در Cluster — ۱۶۳۸۴ slot بین master‌ها تقسیم می‌شوند.
Hash Tagقرارداد {...} در کلیدها برای اطمینان از قرارگیری روی یک hash slot.
Hit Rateدرصد درخواست‌هایی که در cache پاسخ داده می‌شوند — معیار مهم برای کارایی cache.
In-Memoryویژگی Redis که داده‌ها در RAM نگهداری می‌شوند، نه روی دیسک.
Keyspaceفضای نام‌های کلید در Redis — تمام کلیدها در یک فضای مسطح هستند.
LATENCYتأخیر بین ارسال درخواست و دریافت پاسخ — معیار مهم عملکرد.
LFULeast Frequently Used — سیاست eviction که کمترین فرکانس دسترسی را حذف می‌کند.
Listلیست پیوندی دو طرفه از رشته‌ها، با عملیات O(1) از هر دو سر.
LRULeast Recently Used — سیاست eviction که کمترین اخیراً استفاده‌شده را حذف می‌کند.
Lua Scriptاسکریپتی به زبان Lua که در سرور Redis به‌صورت atomic اجرا می‌شود.
MAXLENتنظیم حداکثر طول Stream برای جلوگیری از رشد بی‌نهایت.
maxmemoryتنظیم سقف مصرف حافظه‌ی Redis.
Memory Fragmentationحافظه‌ی تخصیص‌یافته ولی استفاده‌نشده — با activedefrag قابل کاهش است.
MOVEDپاسخ Redis Cluster وقتی کلید روی node دیگری است — کلاینت باید redirect شود.
MULTI/EXECدستورات شروع و پایان transaction در Redis.
Objectدر Redis، هر کلید و مقدارش یک Object داخلی است.
Optimistic Lockingالگوی قفل‌گذاری که با WATCH پیاده می‌شود — در صورت تغییر، retry می‌شود.
Partitioningتقسیم داده‌ها بین چند سرور — در Redis با Cluster پیاده می‌شود.
Persistenceذخیره‌سازی داده‌ها روی دیسک برای جلوگیری از از دست رفتن در crash.
Pipelineارسال چند دستور در یک round-trip شبکه برای کاهش latency.
Pub/Subمدل پیام‌رسانی fire-and-forget که در آن publisher‌ها به کانال‌ها پیام می‌فرستند.
PSUBSCRIBEsubscribe به Pub/Sub با الگو (pattern) به‌جای نام کانال مشخص.
PSYNCدستور هماهنگی replication که در اتصال مجدد، فقط داده‌ی از دست رفته را می‌گیرد.
RDBRedis Database file — snapshot باینری فشرده از داده‌ها.
Read-Throughالگوی caching که در آن cache خودش از DB در صورت miss می‌خواند.
RedisInsightرابط وب رسمی Redis برای مانیتورینگ و دیباگ.
RedisJSONماژول Redis برای ذخیره و کوئلی JSON به‌صورت بومی.
RediSearchماژول Redis برای full-text search و secondary indexing.
Replicationکپی کردن داده‌ها از master به replica برای HA و load balancing.
Replicaسروری که کپی از داده‌های master دارد (در نسخه‌های قدیمی slave).
Sentinelسیستم مانیتورینگ و failover خودکار برای Redis.
SETساختار داده‌ی مجموعه‌ی نامرتب از رشته‌های یکتا.
Shardingتقسیم داده‌ها بین چند سرور برای مقیاس‌پذیری.
SLOWLOGlog دستوراتی که بیش از آستانه‌ی مشخص طول کشیده‌اند.
Sorted SetSet با score که به‌صورت خودکار بر اساس score مرتب می‌شود.
Streamlog append-only با consumer groups و ack — برای message queue.
Stringساده‌ترین ساختار داده‌ی Redis — دنباله‌ای از بایت‌ها تا ۵۱۲ مگابایت.
Throughputتعداد عملیات در واحد زمان (معمولاً ops/sec).
TLSTransport Layer Security — رمزنگاری ارتباطات شبکه.
TTLTime To Live — مدت زمان تا انقضای یک کلید.
UNLINKحذف ناهمزمان یک کلید (بهتر از DEL برای کلیدهای بزرگ).
WATCHدستور optimistic locking که اگر کلید تغییر کرد، EXEC لغو می‌شود.
Write-Behindالگوی caching که در آن نوشتن فقط در cache و سپس asynchronous در DB انجام می‌شود.
Write-Throughالگوی caching که در آن نوشتن همزمان در cache و DB انجام می‌شود.
💡 نکته

برای مرجع کامل دستورات، به داکیومنت رسمی Redis به آدرس https://redis.io/commands/ مراجعه کنید. هر دستور با تمام جزئیات، نسخه‌ی معرفی شده، و مثال‌ها مستند شده است.