یکی از ابزارهایی که همیشه در پروژه های متوسط و بزرگ نقش ایفا میکنه، ابزار مدیریت task های (یا Job های) سیستم هست که کمک میکنه حجم زیادی از کارهای پیچیده و طولانی رو بشه به صورت غیرهمزمان و مطمئن توی یک سرویس دیگه اجرا کرد. این سرویس ها که معمولا به شکل یک صف (Queue) از تسک ها پیاده میشن، میتونن تسکی رو که بهشون داده شده رو با توجه به اولویتبندیهایی که دارن تحویل ورکرهای مناسب بدن که تسک رو ببره و اجرا کنه.
با اینکه این معماری ها خیلی کارآمد هستن و بخش بزرگی از پیچیدگی فرآیند توزیع تسک رو از سر راه ما برمیدارن، گاهی با افزایش پیچیدگی پروژه دیگه نمی تونن همه انواع تسکهای بکگراند رو برای ما مدیریت کنن. خیلی از ما تو پروژههامون نیاز داشتیم و به سراغ بعضی از این معماریهای جایگزین رفتیم (که در ادامه بهشون اشاره خواهم کرد).

این پست سعی داره بگه چطوری میشه یک چارچوب کلی برای دسته بندی همه معماریهای اجرای تسک ارائه کرد که تا جای ممکن بتونه همه ابزارهای موجود رو دسته بندی بده. البته این به معنی کامل بودن دسته بندی معرفی شده در اینجا نیست. به نظر من یک دسته بندی خیلی کامل برای این ابزارها وجود نداره و این ابزارها و نحوه رفتارشون همیشه بنا به نیازهای موجود در زمان طراحی اون نرمافزار، ابزارها، امکانات و محدودیتهای موجود کاملا متفاوت هست. ولی آشنایی با این دسته بندی میتونه بهمون کمک کنه که چطور باید به این دسته از مسائل نگاه کنیم.
بیاید بحث رو با آشناترین معماری شروع کنیم:
سادهترین و پراستفادهترین روش برای توزیع کار، صفها هستند. ابزارهایی مثل BullMQ, RQ, Celery و ... جزو این دسته هستند. این سیستمها حداقل دو تا بازیگر اصلی دارن:
تولید کننده (Producer) که اجرا شدن تسک رو درخواست میکنه (مینویسه توی صف)
مصرف کننده ها (Consumers) که دارن به صف گوش میدن، تسک های جدیدی که توی صف گذاشته میشه رو نفر اولی که میتونه برمیداره، پردازش میکنه و تکمیل شدن تسک رو تأیید (ACK) میکنه. بعد از تایید، تسک برای همیشه از صف حذف میشه.
داده مرتبط با صف و مکانیزمهای اون میتونن توی ساختارهای مختلفی ذخیره بشن، از Redis و RabbitMQ گرفته تا Kafka و PostgresQL و باقی ابزارهای ذخیرهسازی.
یک نکته همیشه در معماریهای صف مطرحه: همیشه احتمال fail شدن تسک و در نتیجه نیاز به اجرای دوباره اون هست. اکثر فریمورکها، مدیریت صف رو به شکل At Least Once اجرا میکنن (تعدادی هم At Most Once هستن). در روش At Least Once تضمین میشه که تسک شما (حداقل) یکبار تا آخر (یا شرط توقف) اجرا بشه، ولی برای مطمئن بودن از نتیجه ممکنه فریمورک تصمیم بگیره کد شما رو دو بار اجرا کنه. در مدیریت شکست به روش At Least Once یک مفهوم خیلی مهم در تسکها مطرح میشه که در این مطلب چندین بار تکرار خواهد شد: کد اجرا شده در یک تسک باید Idempotent باشه، یعنی خروجی یک بار اجرای تسک و چندین بار تکرار شدن همون تسک باید یکسان باشه.
یک مفهوم دیگه در صف تسکها که ما اینجا باهاش کار داریم، نحوه توزیع تسک هست. وقتی Producer یک تسک رو به صف میسپره تا اجرا بشه، لزوما نمیدونه که تسک چه زمانی قراره اجرا بشه (ممکنه مدتی در صف بمونه)، و نمیدونه این تسک لزوما قراره از کدوم ورکر (یا همون Consumer) سر در بیاره و اجرا بشه ( این تسک ممکنه نصیب هر Consumer ای بشه که داره تسک ها رو از صف برمیداره و انجام میده). Consumer ها هم طبق اصول این معماری، بیحافظه هستند و حین اجرای یک تسک، نمیدونن تسکهای قبلی که اجرا کردن چه تسکی و چه زمانی بوده.
قبل از ادامه، یک توضیح کوتاه لازمه. ما ابزارهایی رو اینجا بررسی میکنیم که کارشون توزیع تسکها بین ماشینها هست. به عبارتی، وظیفه شون هماهنگی کارها و جریان رویدادها بین سرویسهاست. در واقع وظیفه اینها اینه که به این سؤال جواب بدن: چطور یک تسک رو به جای درستی برسونیم و مطمئن باشیم تسک به درستی اجرا میشه.
یک دسته دیگه از چارچوب های توزیع شده هستن که کارشون بر پایه توزیع داده هست. یعنی یک تسک خیلی بزرگ به سیستم سپرده میشه که باید چند ماشین در کنار هم روی اون کار کنند. ابزارهای زیادی از جمله Spark و Flink و Iceberg توی این دسته قرار میگرن. هدف این ابزارها اینه که به این سوال جواب بدن: یک تسک محاسباتی بزرگ که توی یک ماشین جا نمیشه رو چطوری به صورت سریع و بهینه روی چندین ماشین انجام بدیم.
توی این پست من فقط دسته اول رو برررسی میکنم و سعی میکنم وارد دسته دوم نشم، چون این روزها تنوع دنیای دسته دوم خیلی بیشتر از دسته اوله. با این خلاصه، بیایید معماریهایی را که در سناریوهای مختلف میتونن استفاده بشن رو بررسی کنیم.
سیستمهای Publish/Subscribe یا پابسابها یک مدل موفق توزیع message یا event هستند که در اون پابلیشر بدون اینکه بدونه مخاطب های یک مسیج چه کسانی باید باشند، ایونت خودش رو منتشر میکنه و این وظیفه سابسکرایبرها هست که به ابزار پابساب بگن چه پیامهایی رو میخوان دریافت کنن و این سیستم اطلاعات رو به درستی به دست سابسکرایبرها میرسونه.
یک تفاوت عمده این دسته با صف ها اینه که توی صف، هر تسک فقط به یک ورکر (یا consumer) داده میشه، ولی در پابساب، این اطلاعات به همه سابسکرایبرهایی که دارن به اون نوع پیامها گوش میدن ارسال میشه (اصطلاحا Fan-out میشه).
مزیت بزرگ این ابزارها چیه؟ فرض کنید یک event خیلی ساده در یک سیستم بزرگ اتفاق میافته: یوزر عکس پروفایل جدیدی برای خودش آپلود میکنه. عکس ذخیره میشه و:
سرویس بهینهسازی حجم عکس توی بک گراند تصویر رو بهینه میکنه
سرویس Audit لاگ مربوط به این ایونت رو ثبت میکنه
سرویس CDN خودش رو sync میکنه
سرویس جستجو اطلاعات خودش رو آپدیت میکنه
سرویس اطلاع رسانی ایمیلی برای یوزر میفرسته که به وزر میگه عکس پروفایل شما با موفقیت آپدیت شده
به نظرتون سرویسی که پروفایل کاربر رو مدیریت میکنه باید عوض شدن تصویر پروفایل رو به همه این سرویسها اطلاع بده و برای هر کدوم یک تسک مجزا بسازه؟ اصلا لزومی داره که این سرویس از وجود این همه بخش مختلف در سیستم آگاه باشه؟ پس شاید راحتتر باشه هر کسی به این ایونتها نیاز داره، خودش اعلام کنه. سرویس پروفایل کاربر به سک سیستم مرکزی اطلاع میده آپدیت اتفاق افتاده و این سیستم مرکزی به هر بخشی که برای این نوع ایونت رجیستر کرده، آپدیت رو بفرسته.
برای این جنس تسک ها ابزارهای مختلفی استفاده میشه که در مرکز اونها ابزارهای مثل RabbitMQ، Apache Kafka و Apache Pulsar پر طرفدارترین گزینهها هستند.
این پلتفرمها معمولا یک تفاوت عمده با پابساب دارن: اونها اجازه برگشتن به عقب در تاریخچه اتفاقات رو هم به شما میدن. در پابساب، سابسکرایبر فقط ایوینتهایی که تو اون زمان تولید میشن رو میبینه، ولی این پلتفرمها یه کم کنترل بیشتری روی جریان داده ایونتها به شما میدن: شما میتونید رو با تغییر مقدار offset به عقب و جلو ببرید. ساختار دادهای که اینجا استفاده میشه شبیه به یک لیست هست که فقط اجازه دارید به تهش چیزی اضافه کنید، نه میتونید چیزی رو از وسطش حذف کنید و نه میتونید چیزی که اضافه شده رو تغییر بدید. یک چیز خیلی شبیه به log file که فقط میتونید بهش append کنید.
اینجا دیگه ابزاری نیاز دارید که سریع باشه، هزینه ذخیرهسازی اطلاعاتاش پایین باشه و بتونه رو چندین ماشین توزیع بشه. مثل کدوم ابزار؟ بله Kafkaی فقید. ابزاری که ما تو این پست خیلی باهاش کار داریم. به قول یک میم اینترنتی:

بیاید از یک جنبه دیگه به Task Queue نگاه کنیم: ورکرها اصولا باید stateless باشن، یعنی وقتی ورکر داره روی یک تسک کار میکنه، نباید حافظهای داشته باشه که به کمک این حافظه به بخشی از اطلاعات تسکهای قبلی که روی اونها کار کرده چی هستن (مگر اینکه state رو توی جایی مثل دیتابیس نگه دارید!)
فرض کنید میخواید ورکری داشته باشید که چکیدهای از اطلاعات تجمیعی تسک های قبلی (مثلا آپدیت نگه داشتن آماری از ایونتهای دریافت شده) رو توی حافظه خودش نگه داره و شما بتونید با ارسال تسکهای مشابه به همون ورکر، این اطلاعات رو تکمیل کنید. حالا چطور این کار رو انجام میدید؟ هر بار به ازای هر ایونت جدید، اون اطلاعات از دیتابیس بازخوانی میکنید و بعد از اتمام دوباره توی دیتابیس مینویسید؟
بیاید یک چیز دیگه رو تصور کنیم، شما تعدادی مدل هوش مصنوعی دارید. برای هر نوع تسک و مسیجی که بهتون میرسه هم باید یکی از این مدلها رو استفاده کنید. در نتیجه مجبورید این مدل رو هر بار از دیسک بخونید، روی تسک دریافت شده اعمال کنید و نتیجه رو برگردونید. خوندنهای متوالی حجم زیادی از اطلاعات از دیسک اصلا به صرفه نیست(مدلهای هوش مصنوعی معمولا حجم زیادی دارن). اون وقت چیکار میکنید؟ بهتر نبود بتونید هر بار تا وقتی تسکهای مشابه وجود دارن، یک ورکر رو به اون نوع تسکها اختصاص بدید؟ ولی Producer و Consumer انتخاب نمیکنن هر تسکی کجا اجرا بشه.
یک گزینه خیلی کارآمد توی این جنس تسکها، فریمورکهایی هستند که به صورت اکتور-مدل کار میکنند. اکتورها یک مدل پایه از پردازش همزمان هستند که هر اکتور یک state یا وضعیت داخلی داره و با بقیه اکتورها از طریق ارسال message ارتباط برقرار میکنه. پلتفرم های زیادی هستند که امکان توزیع تسک با این معماری رو فراهم میکنند و معمولا معماریهای متنوعی هم دارن. از Akka و Orleans و Ray تا ابزارهای دیگه مثل Faust و Dask.
البته لازم به ذکره که اکتور-مدل یک مدل بسیار قدیمی از پردازش همزمانه و یکی از بنیانهای ایدههای اولیه Object Oriented Programming (تقریبا شبیه به چیزی که Ruby داره) هست. زبانهایی مثل Erlang، Elixir و Scala هم به صورت خیلی جدی از قدرت این معماری در پردازش همزمان خودشون استفاده میکنن که قدرت بالایی بهشون میده.
بعضی وقتها ما تسکهایی داریم که خیلی پیچیده هستند، زمان زیادی برای اجرا نیاز دارند و مدام باید با سرویسهای دیگه ارتباط برقرار کنن. یک نمونه از این تسکها، پایپ لاینهای ETL هست که کارشون استخراج (Extract) کردن دیتا از یک یا چند سورس مختلف، انجام یک دسته محاسبات (Transform) روی اونها و بارگزاری (Load) اونها توی یک دیتابیس دیگه هست. معمولا ما باید همه این بخشها رو به صورت یک تسک یکجا پیادهسازی کنیم. تسکی که خودش شامل تسک های کوچکتر هست که مستقیما با هم ارتباط دارن. یک راه خوب برای نشون دادن این Data flow به صورت DAG (Direct Acyclic Graph) هست که توی ابزارهایی مثل Apache Airflow استفاده میشه.

ابزارهایی از این دست که میشه از اسم Workflow Orchestration براشون استفاده کرد، بخش بزرگی از مدیریت تسکهای مهندسی داده رو بر عهده دارن. همه ابزارهای این دسته لزوما ورکفلو رو به صورت گراف DAG نشون نمیدن و ابزارهایی مثل Prefect هم پیدا میشن که سعی میکنن مفهوم یک ورکفلو بزرگتر از اتصال بین گرههای گراف جهتدار ببینن، ولی در نهایت یک ورکفلو دارن که از یک نقطه شروع میشه و جریان داده رو به سمت مشخص هدایت میکنه.
بخش دیگهای از کاربردهای این مدیران جریان کار، اونهایی هستند که شاید روی استریم بزرگی از داده کار نکنند، ولی ورکفلو پیچیدهای دارن که باید با جاهای مختلف ارتباط برقرار کنه و گاهی باید منتظر وقوع Event های بخشهای دیگه بمونه تا اجراش بتونه ادامه پیدا کن. اجرای کل ورکفلوی یک تسک ممکنه روزها یا ماهها طول بکشه. برای این مواقع هم معمولا ابزارهایی استفاده میشه که بتونن تعداد زیادی تسک طولانی مدت رو همزمان اجرا کنند. بیاید این ابزارها رو هم Workflow Engine ها صدا بزنیم.
فرض کنید یک ورکفلو مدیریت سفارش، این قدمها اتفاق میافته:
۱- وقتی پرداخت انجام شد، کالای داخل انبار برای سفارش رزرو میشه و ارسال اونها به مرکز توزیع شروع میشه.
۲- اگر کالاها به موقع از انبار ارسال شدند و به مرکز توزیع رسیدند، فرآیند بسته بندی اونها انجام میشه.
۳- اگر تا یک روز قبل از زمان تحویل کالاها آماده نشدند، تیکت پشتیبانی ساخته میشه که پشتیبان با مشتری تماس بگیره و تصمیم مشتری برای ادامه روند تحویل سبد ناقص، تغییر زمان تحویل برای تامین کالا یا کنسل کردن سفارش رو در سیستم ثبت کنه.
۴- اگر نظر مشتری ثبت شد و نظر بر این بود که تحویل ادامه پیدا کنه، سبد آپدیت بشه، پول اضافه به کیف پول مشتری برگرده و سبد آپدیت شده آماده تحویل به لجستیک بشه.
۵- اگر تصمیم مشتری کنسل کردن سفارش بود، سفارش آپدیت بشه، کالاها به انبار برگشت بخورند و کیف پول هم آپدیت بشه.
و ....
پیاده کردن این ورکفلو در قالب یک تسک یکپارچه توی هیچ کدوم از ابزارهای قبلی ممکن نیست و ما نیاز به یک ابزار جدید داریم که بتونه یک تسک رو برای زمان طولانی در حال انجام نگه داره. حواسش باشه کدوم بخش ها ورکفلو قبلا اجرا شدن و کدومها باید الان اجرا بشن.
اینجاست که ابزارهایی مثل Temporal و Cadence وارد میشن. این معماری توی ابزاری مثل Temporal ورکفلو رو که نمایش دهنده Business Logic های سیستم هست رو به دو قسمت workflow (فلو کلی و سطح بالا) و activity (کارهایی که باید در هر قدم از ورکفلو انجام بشن) تقسیم میکنه و تمام بخشهای دیگه رو خود فریمورک بر عهده میگیره، چیزهایی مثل:
نگهداری state ورکفلو
نگهداری نتیجه هر کدوم از step های ورکفلو (activity ها)
اجرای مجدد تسک fail شده
به خاطر سپردن اینکه در هر قدم از ورکفلو (همون activity)، نتیجه چی بوده (اکتیویتی دوباره اجرا نمیشه)
قطع کردن و ادامه دادن هزاران ورکفلو که دارن همزمان اجرا میشن (تموم شدن هر کدوم از تسکها (ورکفلوها) ممکنه ماهها طول بکشه)
با اینکه activity تضمین At Least Once داره، ولی اگر ورکفلو رو به درستی طراحی کنید، در سایه مدیریت state، ذخیره تاریخچه ایونتها و از همه مهمتر deterministic بودن اجرای ورکفلو، میتونید تضمین Exactly Once رو در اجرای ورکفلو که Business Logic مرتبه بالا رو اجرا میکرد داشته باشید.
این دسته بندی چیزی بود که مدتی بود تو ذهن من شکل گرفته بود و دوست داشتم با شما به اشتراک بزارم. نمیخواستم این مطلب رو به پست چند قسمتی تبدیل کنم و این باعث شد کمی طولانی بشه. امیدوارم براتون مفید بوده باشه.
اگر بخوام این پست رو براتون خلاصه کنم، اینجوری میشه:
نیاز شما >>> این کار پسزمینه را یک بار و مطمئن انجام بده.
بهترین معماری: Task Queue (سادگی و قابلیت اطمینان)
نیاز شما >>> : این رویداد رخ داد؛ همه سرویسهای مربوطه را مطلع کن.
بهترین معماری: Pub/Sub (جداسازی کامل تولیدکننده و مصرفکننده، قابلیت Fan-Out)
نیاز شما >>> یک جریان پیوسته از داده دارم که باید توسط سرویسهای مختلف خوانده و بازخوانی شود.
بهترین معماری: ٍEvent Streaming (قابلیت replay، تحمل خطا، حافظه طولانی با امکان برگشت)
نیاز شما >>> یک سرویس با ترافیک همزمان بالا و state های پیچیده میخوام.
بهترین معماری: Actor Model (حذف همزمانی، مدلسازی نزدیکتر به دنیای ذهنی OOP)
نیاز شما >>> باید یک پایپلاین داده برنامهریزیشده و قابل مانیتورینگ اجرا کنم
بهترین معماری: Workflow Orchestration (مدیریت وابستگیهای پیچیده مراحل دادهای)
نیاز شما >>> باید یک فرآیند کسبوکار بلندمدت و مقاوم در برابر خطا را پیادهسازی کنم
بهترین معماری: Workflow Engine (اجرای پایدار و مدیریت خودکار state)