الگوی Multi-step Processes در طراحی سیستم
بررسی فرایندهای چندمرحلهای و الگوی Saga و Choreography و Orchestration و موتورهای Durable Execution مثل Temporal
سیستمهای Production واقعی باید از شکست، Retry و عملیات طولانیمدتی که ساعتها یا روزها طول میکشد زنده بمانند. اغلب به شکل فرایندهای چندمرحلهای یا Saga هستند که هماهنگی چند سرویس و سیستم را شامل میشوند. این منبع مداوم چالشهای عملیاتی و طراحی برای مهندسهاست و راهحلهای متنوعی دارد.
مسئله
ساختن فرایندهای چندمرحلهای قابلاعتماد در سیستمهای توزیعشده بهطرز حیرتآوری سخت است. در حالی که سیستمهای تمیز مثل پایگاههای داده معمولاً با یک «نوشتن» یا «خواندن» سروکار دارند، اپلیکیشنهای واقعی اغلب باید دهها سرویس و سیستم (ناپایدار) را هماهنگ کنند تا خواستهی کاربر را انجام دهند، و انجام این کار سریع و قابلاعتماد چالش رایجی است.
Jimmy Bogard سخنرانی عالی با عنوان «Six Little Lines of Fail» دارد که فرضش این است که سیستمهای توزیعشده حتی دنبالهی سادهای از گامها را بهطرز شگفتآوری سخت میکنند (اگر قبلاً با چنین سیستمهایی سروکار نداشتهاید، تماشایش عالی است). در آن سخنرانی، شش خط کد صفحهی «ثبت سفارش» مرور میشود: گرفتن مشتری از پایگاه داده، ساختن سفارش، فراخوانی Stripe برای پرداخت، فرستادن ایمیل تشکر با SendGrid، انتشار پیام در RabbitMQ برای سیستمهای پاییندست، و ریدایرکت، همه داخل یک تراکنش پایگاه داده. هیچکدام از این شش خط فرض نمیکند شکستی ممکن است رخ دهد، و وقتی هر کدام شکست بخورد، سیستم به وضعیت ناسازگار میرسد؛ مثلاً مشتری پول پرداخته ولی سفارشی در پایگاه داده نیست.
فرایند تکمیل سفارش در تجارت الکترونیک را در نظر بگیرید: دریافت پرداخت، رزرو موجودی، ساخت برچسب ارسال، انتظار برای برداشتن کالا توسط کارگر انبار و فرستادن ایمیل تأیید. هر گام سرویس متفاوتی را صدا میزند یا منتظر انسانها میماند، و هرکدام ممکن است شکست بخورد یا Timeout شود. بعضی گامها از ما میخواهند سیستمهای خارجی (مثل درگاه پرداخت) را صدا بزنیم و منتظر تکمیلشان بمانیم. در طول این هماهنگی، سرورتان ممکن است Crash کند یا روی آن Deploy شود. و شاید بخواهید ترتیب یا ماهیت گامها را عوض کنید! پیچیدگی آشفتهی نیازهای کسبوکار و زیرساخت دنیای واقعی بهسرعت فلوچارت خالص گامهای ما را میشکند.
البته میشود بهطور ارگانیک این فرایندها را وصله کرد. میتوانیم هر سرویس را برای مدیریت شکست مقاوم کنیم، از Delay Queue و Hook برای مدیریت انتظار و کارهای انسانی استفاده کنیم و مانند آن، اما هرکدام از اینها سیستم را پیچیدهتر و شکنندهتر میکند. دغدغههای سطح سیستم (Crash، Retry، شکست) با دغدغههای سطح کسبوکار (اگر کالا را پیدا نکنیم چه میشود؟) در هم تنیده میشوند. طراحی چندان خوبی نیست!
سیستمهای Workflow، Sagaهای رویدادمحور و Durable Execution راهحل این مشکلاند و در بسیاری از مصاحبههای طراحی سیستم ظاهر میشوند، بهخصوص وقتی State زیاد و مدیریت شکست زیاد است. مصاحبههای طراحی سیستم AI حتی بیشتر به این تکیه دارند، چون Pipelineهای Agent زنجیرههای بلندی از همین گامهای ناپایدار و Stateful هستند. مصاحبهکنندهها دوست دارند دربارهاش بپرسند چون برای بسیاری از تیمهای Production بر چرخهی On-call مسلط است و به قلب چیزی میزند که ساخت درست بسیاری از سیستمهای توزیعشده را سخت میکند.
در این مطلب میگوییم چه هستند، چطور کار میکنند و چطور در مصاحبه از آنها استفاده کنید. این الگو در تحلیل مسائل Uber، Payment System و Notification System به کار میرود.
راهحلها
برای ایجاد انگیزه در بحث، رویکردهای مختلف ساخت فرایندهای چندمرحلهای قابلاعتماد را از ساده شروع میکنیم و تا سیستمهای Workflow پیچیده بالا میرویم، با فرایند تکمیل سفارش تجارت الکترونیک بهعنوان مثال ثابت.
ابزارهای سادهی یک سرور (Single Server Primitives)
سادهترین چیزی که میتوانیم بسازیم این است که یک سرور همهچیز را مدیریت کند. سرور API سفارش را از کاربر میگیرد و فقط گامها را به ترتیب طی میکند: پرداخت را بگیر، موجودی را رزرو کن، برچسب ارسال بساز، ایمیل تأیید بفرست. یکی پس از دیگری، از بالا به پایین، بعد پاسخ میدهد.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
async function fulfillOrder(order: Order): Promise<OrderResult> {
try {
await chargePayment(order);
await reserveInventory(order);
// Woops, server crashed here! Sorry, customer!
await createShippingLabel(order);
await sendConfirmationEmail(order);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
}
اگر هرگز لازم نبود به چیز اشتباهی یا مقیاس فکر کنیم، دقیقاً از اینجا شروع میکردیم. اما به محض اینکه تضمین قابلیت اطمینان و هماهنگی پیچیدهتر لازم شود، حداقل دو مشکل جدی دارد:
- Crash و شکست: اگر سرور بعد از گرفتن پرداخت ولی پیش از رزرو موجودی Crash کند چه میشود؟ وقتی سرور Restart شود، هیچ حافظهای از آنچه رخ داد ندارد.
- مسیریابی Callback: به درگاه پرداخت فکر کنید؛ روی همان اتصال جواب نمیدهد. در عوض دقایقی بعد با Webhook ما را صدا میزند، یعنی درخواست HTTP که درگاه به Endpoint ای که ما میزبانی میکنیم میفرستد. اگر به چند سرور API پشت Load Balancer مقیاس داده باشیم، آن Callback میتواند روی میزبان کاملاً متفاوتی از آنی که سفارش را شروع کرد فرود بیاید و آن میزبان نمیداند Callback به کدام سفارش در جریان تعلق دارد. آیا به میزبان واحد بدون راه مقیاسدهی یا Replicate کردن گیر کردهایم؟
واقعاً نه. میتوانید این را وصله کنید: وضعیت سفارش را بعد از هر گام در پایگاه داده ماندگار کنید تا هیچ میزبان واحدی لازم نباشد آن را به خاطر بسپارد، و Callbackها را از طریق لایهی Pub/Sub مسیریابی کنید تا هر سروری که آزاد است سفارش را بارگذاری کند و از جایی که قبلی رها کرد ادامه دهد. این چیزی شبیه این به شما میدهد: سرور API با پایگاه دادهی Order State و Pub/Sub برای Webhook.
اما هنوز مشکلات بیشتری برای حل داریم.
- ماشین حالت دستی: داریم دستی ماشین حالت میسازیم با Checkpointهای دقیق پایگاه داده بعد از هر گام. پایگاه داده پیشرفتمان را به خاطر میسپارد، ولی روی آن عمل نمیکند. اگر سرور وسط سفارش بمیرد، ردیف فقط مینشیند. چیزی باید سفارش متوقفشده را متوجه شود، تصمیم بگیرد کدام گام ایمن است برای Retry و کار را بدون اینکه سرور دوم همزمان بردارد ادعا کند. این یعنی Poller، طرح قفلگذاری و منطق Retry، همه دستی.
- جبران (Compensation): هنوز جبران را حل نکردهایم، یعنی خنثی کردن گامهایی که از قبل Commit شدهاند. اگر رزرو موجودی شکست بخورد، باید پرداخت را Refund کنیم. اگر ارسال شکست بخورد، باید رزرو موجودی را آزاد کنیم.
فهرست بیپایان نشانهی مشکل ساختاری است. معماری ما به آشفتگی مدیریت State، مدیریت خطا و منطق جبران تبدیل میشود که روی سرورهای اپلیکیشن پراکنده است. نمودار واقعاً آنقدر بد نیست، ولی پیادهسازی دنیای واقعی گلولهی درهمپیچیدهی سیمها و کد اسپاگتی است.
اگر تازه دربارهی چنین سیستمهایی میخوانید، این مشکلات شاید آنقدر بد به نظر نرسند. اما اگر یکی را اداره کرده باشید، احتمال خوبی هست که منبع درد مداوم شرکتتان بوده باشد! به همین دلیل این الگو در مصاحبهها ظاهر میشود: علاوه بر دانش حل مسئله، تجربهی نامزد را هم میسنجد.
از اینجا به کجا برویم؟
الگوی Saga
وقتی شروع به انباشت گامهای زیاد و مدیریت شکست جالب میکنیم، معمولاً به Saga نگاه میکنیم. این کلمه یعنی دنبالهی بلند و مفصل رویدادها (به سری فیلم Star Wars فکر کنید)، که دقیقاً چیزی است که این فرایندها میشوند.
در نرمافزار، Saga دنبالهای از گامهای محلی است که هر گام یک اقدام جبرانی متناظر دارد که آن را خنثی میکند. گامها را یکییکی اجرا میکنید و اگر گام بعدی شکست بخورد، به عقب میروید و آن جبرانها را فعال میکنید تا هر چه از قبل رخ داده برگردانده شود.
چرا همهی گامها را در یک تراکنش توزیعشدهی بزرگ نمیپیچیم؟ چون هیچکس نمیتواند قفل را اینقدر طولانی نگه دارد. Two-phase Commit همهی شرکتکنندگان را تا وقتی کندترین تصمیم بگیرد قفل نگه میدارد، Coordinator متوقفشده همه را متوقف میکند و سیستمهای خارجی مثل درگاه پرداخت به هر حال پروتکل تراکنش شما را نمیفهمند. هیچ مقدار زرنگی نمیتواند Atomicity بین سرویسهایی که کنترلشان نداریم بدهد. Sagaها این را میپذیرند و با آن کار میکنند: بهجای تضمین اینکه همهی گامها با هم رخ دهند یا هیچکدام، تضمین میکنند هر چه رخ داده قابل خنثی کردن است.
سفارش ما Saga کتابدرسی است: اگر ارسال شکست بخورد، موجودی را آزاد میکنید و شارژ را Refund میکنید. هر گام جداگانه Commit میشود، پس هنگام انتظار هیچچیز قفلی را بین سرویسها نگه نمیدارد. هزینه، پنجرهی کوتاه ناسازگاری است. درست بعد از شارژ، مشتری پول داده برای سفارشی که ارسال نشده، پس میگذارید سفارش تا وقتی ارسال شود در وضعیت «در انتظار» بنشیند. و جبرانها به همان اندازه گامهای رو به جلو نیاز به بدگمانی دارند: Refund هم میتواند مثل شارژ شکست بخورد، پس Retry و Idempotency خودشان را لازم دارند، با راه فرار انسانی برای آنهایی که هنوز نمیروند.
دو راه برای هماهنگی Saga هست.
اولی Choreography نام دارد: هیچ Coordinatorی در کار نیست. هر Worker مشترک رویدادهایی میشود که به آنها اهمیت میدهد، بخش خودش را انجام میدهد و رویدادهای جدید برای واکنش Worker بعدی منتشر میکند. جریان کلی جایی نوشته نشده؛ از آن واکنشها بیرون میآید، مثل رقصندههایی که هرکدام گامهای خودشان را میدانند. Choreography برای سرویسهای کاملاً مستقل (مثلاً مال تیمهای مستقلی که کاملاً به هم وابسته نیستند) و برای Workflowهای با پیچیدگی متوسط عالی کار میکند.
دیگری Orchestration است، که در آن یک Coordinator واحد مالک جریان است و به Workerها میگوید چه کنند. چون کل دنباله در یک جا زندگی میکند، Orchestration برای Workflowهای بسیار پیچیده که کنترل مرکزی و دید میخواهند بهتر است.
در هر دو حالت، آن هماهنگی باید از Crash زنده بماند. اگر چیزی که Saga شما را اجرا میکند بعد از گرفتن پرداخت ولی پیش از رزرو موجودی بمیرد، باید برگردد و دقیقاً بداند کجا رها کرده. آن حسابداری، State ای که از Crash، Retry و همهی منطق جبران بیشتر دوام بیاورد، بخش سخت است و بخشی است که ترجیح میدهیم دستی نسازیم.
Choreography رویدادمحور
اگر بخواهیم به سمت Choreography برویم، تکامل اصولیتر رویکرد ابتدایی تکسرور ما این است که دیگر State فعلی را ذخیره نکنیم و بهجایش جریان رویدادهایی را ذخیره کنیم که ما را به آنجا رسانده، بعد بگذاریم Workerها به آن رویدادها واکنش نشان دهند تا فرایند را جلو ببرند.
رایجترین راه ذخیرهی رویدادها استفاده از Log ماندگار است و Kafka انتخاب محبوبی است، هرچند Redis Streams میتواند در بعضی جاها کار کند. اگر اصطلاح Event Sourcing را شنیدهاید، پسرعموی نزدیک است: Event Sourcing این Log را منبع حقیقت میداند و State را از آن مشتق میکند، در حالی که اینجا عمدتاً از Log برای هماهنگ کردن کار استفاده میکنیم. اغلب با هم میآیند ولی لازم نیست.
Event Store دو کار میکند: کل تاریخچهی سیستم را نگه میدارد و گامهای بعدی را هدایت میکند. هر وقت چیزی رخ دهد که باید به آن واکنش نشان دهیم، رویدادی در Store مینویسیم. Workerها (پروسسهای سرویس معمولی مشترک Log) رویدادها را مصرف میکنند، کارشان را انجام میدهند و رویدادهای جدید منتشر میکنند.
پس Worker پرداخت OrderPlaced را میبیند و سرویس پرداخت ما را صدا میزند. وقتی سرویس پرداخت بعداً با وضعیت Callback میدهد، Payment Worker PaymentCharged یا PaymentFailed منتشر میکند. Worker موجودی PaymentCharged را میبیند و InventoryReserved یا InventoryFailed منتشر میکند. و همینطور ادامه. جبران به همان شکل ولی برعکس جریان مییابد: Payment Worker مشترک InventoryFailed هم هست و با صدور Refund و انتشار PaymentRefunded واکنش نشان میدهد.
سرویس API ما حالا فقط پوشش نازک شروعکننده دور Event Store است. وقتی درخواست سفارش میرسد، رویداد OrderPlaced منتشر میکنیم و سیستم زنده میشود تا رویداد را در سیستم جلو ببرد. بهجای اینکه سرویسها API در اختیار بگذارند، حالا فقط Workerهایی هستند که رویداد مصرف میکنند.
LinkedIn پستی عالی از ۲۰۱۳ دربارهی معماری حول Logهای ماندگار دارد که ممکن است به فهم شهود پشت استفاده از Logهای ماندگار برای این الگو کمک کند.
این به ما میدهد:
- Fault Tolerance: Workerها Log را بهصورت Consumer Group مصرف میکنند و موقعیتشان را فقط بعد از پردازش کامل رویداد Commit میکنند. اگر Worker بمیرد، Partitionهایش دوباره تخصیص مییابند و Worker بعدی از آخرین موقعیت Commitشده ادامه میدهد، پس چیزی گم نمیشود. بعضی رویدادها ممکن است در تحویل دوبار پردازش شوند، که به همین دلیل Workerها باید Idempotent باشند (موضوعی که مدام برمیگردد).
- Scalability: میتوانیم Worker بیشتر برای بار بالاتر اضافه کنیم، تا تعداد Partitionهای Topic.
- Observability: خود Log ردپای حسابرسی کامل هر چیزی است که رخ داده.
- انعطاف: افزودن واکنش جدید آسان است، چون فقط Worker جدیدی است که رویدادهای موجود را مصرف میکند. درج گام در وسط زنجیره سختتر است: قراردادهای رویداد همسایهها باید عوض شود.
خوب! اما برای کارکردن، زیرساخت قابلتوجهی مثل Event Store، صف پیام و Orchestration مربوط به Worker میسازیم. برای فرایندهای کسبوکار پیچیده، این تبدیل به پروژهی مهندسی سیستم توزیعشدهی خودش میشود.
ردپای حسابرسی واقعی است، ولی بخش سخت فهمیدن معنای آن در مقیاس است. چرا Worker Pool ای نبود که این PaymentFailed خاص را بردارد؟ چه زنجیرهای از رویدادها به این InventoryReserved منجر شد؟ پاسخ دادن به آنها یعنی ساختن ابزار روی Log. هزاران ابزار داخلی اضافه در صنعت ساخته شده تا دقیقاً همین کار را بکنند و مهندسان خوب نسبت به راهحلهای بیشازحد ساده برای این مشکلات شکاکاند.
بهطور کلی، Choreography راهحل خوب برای فرایندهای با پیچیدگی متوسط است. فراتر از آن، جریان ضمنی خودش مشکل میشود: هیچ جای واحدی کل دنباله را نمیداند، پس عوض کردنش یعنی عوض کردن قراردادهای رویداد بین Workerهای زیاد. پیچیدهترین Workflowها Coordinator مرکزی میخواهند، که ما را به Orchestration میرساند.
Workflow Orchestration
برای Orchestration، چیزی که واقعاً میخواهیم Workflow توصیف کنیم است، فرایند طولانی قابلاعتمادی که میتواند از شکست زنده بماند و از جایی که رها کرده ادامه دهد. سیستم ایدهآل ما باید در برابر Crash یا Restart سرور مقاوم باشد، بهجای اینکه همهی پیشرفت را از دست بدهد، و نباید لازم باشد زیرساخت لازم برای کارکردن آن را دستی بسازیم.
سیستمهای Workflow و موتورهای Durable Execution وارد میشوند. اینها مزایای رویدادمحوری که پوشش دادیم بهعلاوهی Coordinator ماندگار Saga را میدهند، بدون اینکه خودتان زیرساخت را بسازید. همانطور که Flink راه سطح بالاتری برای توصیف پردازش رویداد جریانی میدهد، سیستمهای Workflow و موتورهای Durable Execution زبانی میدهند تا جریان سطح بالای سیستمتان را توصیف کنید در حالی که آنها Orchestration را مدیریت میکنند. جایی که دو اردو فرق دارند در نحوهی توصیف و مدیریت آن Workflowهاست.
موتورهای Durable Execution
Durable Execution راهی برای نوشتن کد طولانیمدت است که میتواند بین ماشینها حرکت کند و از شکست و Restart سیستم زنده بماند. بهجای از دست دادن همهی پیشرفت هنگام Crash یا Restart سرور، سیستمهای Durable Execution بهطور خودکار Workflowها را از آخرین گام موفقشان روی میزبان جدید در حال اجرا ادامه میدهند. بیشتر موتورهای Durable Execution برای توصیف Workflow از کد استفاده میکنند. تابعی مینویسید که Workflow را نمایش میدهد و موتور Orchestration آن را مدیریت میکند.
محبوبترین موتور Durable Execution، Temporal است، Fork سال ۲۰۱۹ از Cadence، موتوری که بنیانگذارانش در Uber ساختند پس از کار روی AWS Simple Workflow Service. نسب این را نشان میدهد: بالغ است، متنباز است و شرکتهای بزرگ زیادی استفاده میکنند. (صفحهی تحلیل Uber هم میگوید Uber نویسندهی اصلی پروژهی متنباز Cadence است که Temporal، فریمورک پیشروی Durable Execution، از آن زاده شد.)
مثلاً Workflow ساده در Temporal:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const {
processPayment,
reserveInventory,
shipOrder,
sendConfirmationEmail,
refundPayment
} = proxyActivities<Activities>({
startToCloseTimeout: '5 minute',
retry: {
maximumAttempts: 3,
}
});
async function myWorkflow(input: Order): Promise<OrderResult> {
const paymentResult = await processPayment(input);
if(paymentResult.success) {
const inventoryResult = await reserveInventory(input);
if(inventoryResult.success) {
await shipOrder(input);
await sendConfirmationEmail(input);
return { success: true };
} else {
await refundPayment(input);
return { success: false, error: "Inventory reservation failed" };
}
} else {
return { success: false, error: "Payment failed" };
}
}
اگر این شبیه Orchestration تکسرور قبلی به نظر میرسد، به این دلیل است که هست! تفاوت بزرگ در نحوهی اجرای این کد است و ارزش دارد با کمی جزئیات بفهمیدش چون به فهمیدن اینکه واقعاً اینجا چه میگذرد کمک میکند.
Durable Execution چطور کار میکند؟
تقریباً همهی موتورهای Durable Execution یکجور کار میکنند ولی Temporal را مثال میزنیم. Temporal همهچیز را از دو قطعه میسازد: Workflow جریان سطح بالای سیستم شماست و Activity یک گام در آن جریان. Workflow را دستور پخت (گامهای مرتب و تصمیمهای بینشان) و Activityها را اقدامهای پختن منفرد تصور کنید که واقعاً به دنیای بیرون دست میزنند، مثل صدا زدن سرویس، شارژ کارت یا فرستادن ایمیل.
هر قطعه یک تعهد دارد. کد Workflow باید قطعی (Deterministic) باشد: با ورودیها و تاریخچهی یکسان، همیشه همان تصمیمها را میگیرد. SDK مربوط به TypeScript محیط اجرا را Sandbox میکند تا این را اعمال کند، پس Timestampها و اعداد تصادفی بهصورت یکسان Replay میشوند و هر چیز غیرقطعی، مثل فراخوانی شبکه یا خواندن پایگاه داده، به Activity تعلق دارد. Activityها در مقابل باید Idempotent باشند: امن برای بیش از یک بار تلاش. Temporal Activityی را که موفقیت گزارش داده Retry نمیکند، ولی Crash میتواند موتور را مطمئن نگذارد که یکی تمام شده یا نه، و در آن ناحیهی خاکستری Retry میکند. Idempotency آن Retry را بیضرر میکند.
چرا قطعی بودن؟ چون بازیابی با Replay کار میکند. نتیجهی هر Activity در پایگاه دادهی تاریخچه ثبت میشود و اگر Workflow Runner بیفتد، Runner دیگری تابع Workflow را از بالا دوباره اجرا میکند. هر بار Replay به Activityی میرسد که قبلاً اجرا شده، تاریخچه نتیجهی ثبتشده را بهجای اجرای دوباره برمیگرداند. چون کد قطعی است و هیچ Activityی دوباره فعال نمیشود، Replay بدون عارضهی جانبی است و اجرا دقیقاً در جایی که بود فرود میآید.
سفارش خودمان را از Crash رد کنیم تا ملموس شود:
myWorkflowشروع میشود.processPaymentاجرا میشود و نتیجهاش در تاریخچه مینشیند.reserveInventoryاجرا میشود و نتیجهاش در تاریخچه مینشیند.- سروری که Workflow را اجرا میکند میمیرد.
- Worker جدید Workflow را برمیدارد و
myWorkflowرا از بالا دوباره اجرا میکند. درprocessPaymentتاریخچه از قبل نتیجه دارد، پس موتور آن را بدون صدا زدن دوبارهی سرویس پرداخت برمیگرداند. درreserveInventoryهم همینطور. - Replay به
shipOrderمیرسد، چیزی در تاریخچه نمییابد و اجرا زنده از نقطهی دقیقی که Crash قطعش کرد ادامه مییابد.
مشتری یک بار شارژ شد، موجودی یک بار رزرو شد و هیچکدام از کد کسبوکار لازم نبود بداند Crashی رخ داده.
استقرار معمول Temporal چیزهای مشترک زیادی با Choreography رویدادمحور که پوشش دادیم دارد:
- Temporal Server: حسابدار. Taskها را پخش میکند، Timeoutها را ردیابی میکند و پیشرفت را ثبت میکند. هرگز کد شما را اجرا نمیکند.
- History Database: Log از نوع Append-only از همهی تصمیمهای Workflow و نتایج Activity.
- Worker Poolها: پروسسهایی که اجرا میکنید و کد شما را میزبانی میکنند. Workflow Workerها تابع Workflow را اجرا میکنند تا تصمیم بگیرند بعد چه رخ دهد و Activity Workerها Activityها را اجرا میکنند. (یک پروسس میتواند هر دو را میزبانی کند؛ تیمها اغلب برای ایزولهسازی جدایشان میکنند.)
تقسیم کار افراد را گیج میکند، پس مشخص بگوییم. وقتی myWorkflow به await processPayment(input) میرسد، Workflow Worker خودش سرویس پرداخت را صدا نمیزند. به Temporal Server میگوید «این Activity را زمانبندی کن»، و سرور Task را برای Activity Worker صف میکند، که تماس واقعی را میگیرد و نتیجه را گزارش میدهد. Workflow Worker بعد Workflow را از جایی که تاریخچهی ثبتشده تمام میشود برمیدارد و گام بعدی را تصمیم میگیرد. تفاوت بزرگ با Choreography این است که جریان صریحاً در یک تکه کد زندگی میکند، بهجای اینکه ضمنی در این باشد که کدام Workerها کدام Topicها را مصرف میکنند.
Workflowها همچنین میتوانند از Signal برای انتظار رویدادهای خارجی استفاده کنند. مثلاً اگر منتظر برداشتن سفارش توسط انسان هستید، Workflow شما میتواند پیش از ادامه منتظر Signal شود که انسان سفارش را برداشته. بیشتر موتورهای Durable Execution راهی برای انتظار Signal دارند که کارآمدتر و با Latency کمتر از Polling است. در حین انتظار، Workflow نه Thread نگه میدارد نه CPU میسوزاند. موتور State آن را ماندگار میکند و Worker را برای کار دیگر آزاد میکند، بعد وقتی Signal رسید Workflow را دوباره Rehydrate میکند. به همین ترتیب Workflow میتواند ۳۰ روز برای امضا «منتظر» بماند بیآنکه در آن مدت چیزی برایتان هزینه کند.
سیستمهای Workflow مدیریتشده
سیستمهای Workflow مدیریتشده رویکرد اعلانیتری دارند. بهجای نوشتن کدی که شبیه برنامهنویسی معمولی است، Workflow را بهصورت ماشین حالت یا DAG (گراف جهتدار بدون دور) در JSON، YAML یا DSL تخصصی توصیف میکنید و موتور اجرایش میکند. محبوبترینها AWS Step Functions و Google Cloud Workflows هستند.
همین Workflow تکمیل سفارش در AWS Step Functions:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
{
"Comment": "Order fulfillment workflow",
"StartAt": "ProcessPayment",
"States": {
"ProcessPayment": {
"Type": "Task",
"Resource": "arn:aws:lambda:us-east-1:123456789012:function:processPayment",
"Next": "CheckPaymentResult",
"Catch": [
{
"ErrorEquals": ["States.TaskFailed"],
"Next": "PaymentFailed"
}
]
},
"CheckPaymentResult": {
"Type": "Choice",
"Choices": [
{
"Variable": "$.paymentResult.success",
"BooleanEquals": true,
"Next": "ReserveInventory"
}
],
"Default": "PaymentFailed"
}
}
}
(در متن اصلی بقیهی States با کامنت «More and more lines of ugly, declarative JSON» کوتاه شده است.)
زشت ولی کارا! در عمل، اگر در شرکت AWS-محور هستید بهندرت این را دستی مینویسید: ابزارهایی مثل AWS CDK همان ماشین حالت را از کد واقعی تولید میکنند. پس دربارهی «JSON زشت» سخت نگیرید، بهخصوص در Amazon که مصاحبهکنندهتان احتمالاً CDK مینویسد و ممکن است آن را شکاف در دانش AWS شما بشنود. محدودیت عمیقتر نحو نیست، این است که منطق شما هنوز باید در مدل ماشین حالت بگنجد، هر طور که آن را مینویسید.
زیر کاپوت سیستمهای Workflow مدیریتشده همان کار موتورهای Durable Execution را میکنند: Workflow را Orchestrate میکنند، Activityها را صدا میزنند و پیشرفت را طوری ثبت میکنند که در صورت شکست قابل ازسرگیری باشد.
رویکرد اعلانی مزایای واقعی دارد، که بزرگترینش این است که Workflowها را میتوان بهصورت نمودار نمایش داد و UI بسیار بهتری میدهد. هزینه، قدرت بیان است: ممکن است خودتان را در حال نوشتن کد سفارشی زیاد برای جا دادن منطقتان در مدل اعلانی بیابید.
در نهایت، تصمیم برای استفاده از سیستم Workflow اعلانی در برابر رویکرد امری و کدمحور عمدتاً به ترجیح تیم بستگی دارد و بهندرت نقطهی بحث در مصاحبهی طراحی سیستم است. هر دو را میتوان برای اهداف مشابه کارا کرد.
پیادهسازیها
هر دو رویکرد Durable Execution را میدهند تا State Workflow شما در شکست، Restart و حتی Deploy کد دوام بیاورد. مکانیزمهای بازیابی فرق دارند: موتورهایی مثل Temporal State یک Workflow را با Replay کد شما در برابر تاریخچهی ثبتشده بازسازی میکنند، در حالی که سیستمهای مدیریتشده مثل Step Functions موقعیت ماشین حالت را سمت سرور ردیابی میکنند و فقط از آن ادامه میدهند. در هر دو حال، میتوانید منطقی بسیار شبیه Orchestration تکسرور قبلی بنویسید، ولی با تضمینهای اضافهی Fault Tolerance، Scalability و Observability.
- Temporal قدرتمندترین گزینهی متنباز است. Workflowها میتوانند روزها یا ماهها منتظر بمانند، از Crash زنده بمانند و تاریخچهی کامل هر تصمیمشان را نگه دارند. چون Workflowها فقط کد هستند، حلقه، شرط و انشعاب پیچیده را رایگان میگیرید. نکته این است که باید ادارهاش کنید (یا Temporal Cloud بخرید).
- AWS Step Functions همتای مدیریتشده و Serverless است. Workflowها را بهصورت ماشین حالت در JSON تعریف میکنید، که از کد کمبیانتر است ولی خوشهای برای اجرا ندارد. Workflowهای Standard آن مدت اجرا را به یک سال و Payload بین State ها را به ۲۵۶ کیلوبایت محدود میکنند.
- Durable Functions (Azure) و Google Cloud Workflows گزینههای Cloud-native مشابه میدهند. اجرایشان از Temporal آسانتر ولی انعطافشان کمتر است.
- Apache Airflow در Workflowهای Batch زمانبندیشده میدرخشد ولی برای فرایندهای رویدادمحور طولانیمدت طراحی نشده است. DAGهایش بهجای JSON اعلانی در کد Python تعریف میشوند و برای Pipelineهای داده ETL عالی است، برای Workflowهای رو به کاربر کمتر مناسب.
- ورودیهای جدیدتر مثل DBOS (Durable Execution روی Postgres)، Hatchet و Conductor نتفلیکس (Workflowهای تعریفشده با JSON) گاهی پیش میآیند و اگر پرسیدند نام بردنشان خوب است. Temporal و Step Functions هنوز اکثریت قاطع مصاحبهها را پوشش میدهند.
برای مصاحبهها، انتخاب مشخص کمتر از آنچه فکر میکنید مهم است. این ابزارها در بحث طراحی تقریباً قابلتعویضاند و چیزی که شما را نمرهی منفی میدهد انتخاب نیست، فهم نکردن نحوهی کار آنی است که انتخاب کردهاید. یکی را بردارید، مدل بازیابی و محدودیتهایش را بدانید و آماده باشید بگویید چرا برای مسئلهی شما مناسب است.
کی در مصاحبه استفاده کنیم؟
سیستمهای Workflow در سناریوهای خاص میدرخشند. به زور در هر طراحی نگنجانیدشان؛ تشخیص دهید کی مزایایشان از پیچیدگی میچربد. این موضوع در هر سطحی ظاهر میشود، ولی عمق انتظار با ارشدیت بالا میرود: برای نامزدهای Mid-level، تشخیص اینکه مسئله به موتور Workflow نیاز دارد و نام بردن یکی معمولاً کافی است، در حالی که نامزدهای Senior و Staff باید انتظار داشته باشند از انتخاب دفاع کنند، مدیریت شکست و جبران را توضیح دهند و به Deep Dive های زیر پاسخ دهند.
سناریوهای رایج مصاحبه
Workflowها اغلب وقتی ظاهر میشوند که ماشین حالت یا فرایند Stateful در طراحی وجود دارد. اگر دنبالهای از گامها یافتید که به فلوچارت نیاز دارند، احتمال خوبی هست که باید سیستم را حول سیستم Workflow طراحی کنید.
چند مثال:
- سیستمهای پرداخت: در سیستمهای پرداخت یا سیستمهایی که با آنها تعامل دارند (مثل تجارت الکترونیک)، اغلب State زیاد و نیاز شدید به مدیریت شکست با ظرافت وجود دارد. نمیخواهید کاربر برای محصولی که دریافت نکرده شارژ شود!
- Workflowهای انسان در حلقه (Human-in-the-loop): در محصولاتی مثل Uber جاهای زیادی هست که کاربر منتظر انسان برای تکمیل کاری است. مثلاً وقتی کاربر راننده میخواهد، راننده باید سفر را بپذیرد. اینها نامزدهای عالی Workflow هستند. در آن مثال، Workflow در حلقه رانندههای نزدیک را میگیرد و سفر را پیشنهاد میدهد؛ اگر راننده بپذیرد به مسافر اطلاع میدهد و اگر رد کند یا Timeout شود به رانندهی بعدی میرود.
در مصاحبهتان به عبارتهایی مثل «اگر گام X شکست بخورد باید گام Y را خنثی کنیم» یا «باید مطمئن شویم همهی گامها کامل شوند یا هیچکدام» گوش دهید. این سیگنال روشن Workflow است.
کی در مصاحبه استفاده نکنیم؟
بیشتر عملیات CRUD، API های درخواست/پاسخ ساده و عملیات تکسرویسی به Workflow نیاز ندارند. پیچیدهاش نکنید:
- پردازش Async ساده: اگر فقط باید تصویر را Resize کنید یا ایمیل بفرستید، از صف پیام استفاده کنید. Workflow برای عملیات تکمرحلهای زیاد است.
- عملیات همگام: اگر کلاینت منتظر پاسخ است، یا حساسیت زیادی به Latency هست، احتمالاً به Workflow نیاز ندارید (یا نمیتوانید استفاده کنید). آنها را برای فرایندهای Async چندمرحلهای نگه دارید.
- عملیات پرتکرار با ارزش کم: Workflowها سربار اضافه میکنند. هر Activity رفتوبرگشتهایی از موتور و چند نوشتن تاریخچه هزینه دارد، پس برای میلیونها عملیات ساده هزینه و پیچیدگی توجیه نمیشود.
در مصاحبهها با ساده شروع کردن بلوغ نشان دهید. فقط وقتی Workflow معرفی کنید که مشکلات مشخصی را شناسایی کردهاید که حل میکنند: مدیریت شکست جزئی، فرایندهای طولانیمدت، Orchestration پیچیده یا نیازمندیهای حسابرسی. نشان دهید Trade-offها را میفهمید.
Deep Dive های رایج
مصاحبهکنندهها اغلب چالشهای مشخص سیستمهای Workflow را میکاوند و چند تا مدام پیش میآیند.
«اگر پروسسی که Saga شما را اجرا میکند وسط کار Crash کند چه میشود؟»
این حالت شکستی است که Saga باید درست مدیریت کند. فرض کنید پرداخت را گرفتهاید و موجودی را رزرو کردهاید و پروسس پیش از ارسال میمیرد. وقتی برمیگردد، واقعاً چه میداند؟ اگر پیشرفت را فقط در حافظه ردیابی میکردید، جواب هیچ است و حالا نمیتوانید بگویید جلو برویم، آخرین گام را Retry کنیم یا جبرانها را اجرا کنیم تا چیزی که از قبل رخ داده خنثی شود.
راهحل پیشرفت ماندگار است. ثبت میکنید کدام گامها در Store ای کامل شدهاند که از Crash زنده میماند، پس هنگام Restart، Coordinator آن رکورد را میخواند و دقیقاً میداند کجا رها کرده. از آنجا یا رو به جلو ادامه میدهد یا جبرانها را فعال میکند تا برگرداند. چون Coordinator بازیابیشونده ممکن است گامی را دوباره اجرا کند که مطمئن نیست تمام شده، آن گامها و جبرانهایشان باید Idempotent باشند (در Deep Dive مربوط به Exactly-once پایین بیشتر).
این حسابداری ماندگار دقیقاً چیزی است که موتور Workflow رایگان میدهد. هر گام کاملشده را در تاریخچه ثبت میکند و Saga را بعد از Crash روی Worker جدید ادامه میدهد، پس پیشرفت ایمن در برابر Crash میگیرید بدون اینکه Checkpoint را دستی بسازید.
«بهروزرسانیهای Workflow را چطور مدیریت میکنید؟»
مصاحبهکننده میپرسد: «۱۰ هزار Workflow در حال اجرا برای تأیید وام دارید. باید بررسی Compliance جدیدی اضافه کنید. Workflow را چطور بهروز میکنید بدون شکستن اجراهای موجود؟»
چالش این است که Workflowها میتوانند روزها یا هفتهها اجرا شوند. نمیتوانید فقط کد جدید را Deploy کنید و انتظار داشته باشید Workflowهای در حال اجرا درستش مدیریت کنند. اگر Workflow با ۵ گام شروع شد و نسخهای با ۶ گام Deploy کنید، وقتی ازسرگرفته شود چه میشود؟
Workflow Versioning و Workflow Migration دو رویکرد اصلی برای این هستند.
Workflow Versioning: در Versioning، فقط نسخهی جدیدی از کد Workflow میسازیم و جدا Deploy میکنیم. Workflowهای قدیمی با نسخهی قدیمی کد ادامه میدهند و Workflowهای جدید با نسخهی جدید. این سادهترین رویکرد است ولی همیشه شدنی نیست. اگر نیاز دارید تغییر فوراً اعمال شود، نمیتوانید منتظر تمام شدن همهی Workflowهای قدیمی بمانید.
Workflow Migration: رویکرد پیچیدهتر است: تعریف Workflow را در محل بهروز میکنید تا اجراهای در حال اجرا بتوانند تغییر را بردارند. سیستمهای Workflow اعلانی Migration کمتری میخواهند ولی کنترل کمتری هم میدهند. در AWS Step Functions، اجرای در حال اجرا به تعریفی که با آن شروع کرد سنجاق شده است، پس بهروزرسانی ماشین حالت فقط اجراهای جدید را تحت تأثیر میگذارد. اگر اجراهای در جریان به گام جدید نیاز دارند، آنها را متوقف و دوباره شروع میکنید یا انشعاب را از قبل در تعریف مدل میکنید.
با موتورهای Durable Execution، اغلب از «Patch» برای تصمیمگیری قطعی دربارهی اینکه Workflow مشخص کدام مسیر را برود استفاده میکنید. فراخوانی patched() برای اجراهای جدید (و برای اجراهای در جریان که اولین بار به این نقطه میرسند) true برمیگرداند، پس رفتار جدید را اجرا میکنند. اجراهایی که تاریخچهی ثبتشدهشان از قبل از اجرای کد قدیمی فراتر از این نقطه رفته false برمیگردانند و روی رفتار قدیمی میمانند. به این ترتیب یک Deploy میتواند هم Workflowهای قدیمی و هم جدید را بدون شکستن Replay سرویس دهد.
1
2
3
4
5
if (workflow.patched("change-behavior")) {
await a.newBehavior(); // new executions take this path
} else {
await a.legacyBehavior(); // workflows that ran before the patch stay here
}
«چطور اندازهی State مربوط به Workflow را تحت کنترل نگه داریم؟»
هنگام استفاده از موتور Durable Execution مثل Temporal، کل تاریخچهی اجرای Workflow باید ماندگار شود. وقتی Worker میافتد، Workflow از ابتدا Replay میشود و بهجای اجرای دوبارهی Activityها از نتایج تاریخچهی فراخوانیهای قبلی Activity استفاده میکند. یعنی State مربوط به Workflow میتواند خیلی سریع خیلی بزرگ شود و بعضی مصاحبهکنندهها دوست دارند روی این فشار بیاورند.
چند وجه برای راهحل هست: اول، باید تلاش کنیم اندازهی ورودی و نتایج Activity را کم کنیم. اگر میتوانید بهجای Payload عظیم، شناسهای بدهید که در پایگاه داده یا سیستم خارجی جستجو شود، این کار را بکنید.
دوم، میتوانیم بهصورت دورهای از Workflow طولانیمدت Snapshot بگیریم و به نسخهی تازهای از خودش تحویل دهیم. Temporal به این «Continue-as-New» میگوید. بهجای کشیدن کل تاریخچهی رویداد، State فعلی Workflow را میگیرید، Run جدید با تاریخچهی خالی شروع میکنید و فقط با آن State بذرش میکنید تا درست از جایی که قدیمی بود ادامه دهد. Run جدید از موقعیت فعلی شروع میکند، نه برگشت به گام ۱. این راهحل استاندارد Workflowهایی است که مدت طولانی حلقه میزنند یا تاریخچههای عظیم جمع میکنند، مثل Workflow برای هر کاربر که ماهها پردازش رویداد را ادامه میدهد.
«رویدادهای خارجی را چطور مدیریت کنیم؟»
مصاحبهکننده میپرسد: «Workflow شما باید منتظر امضای اسناد توسط مشتری بماند. ممکن است ۵ دقیقه یا ۵ روز طول بکشد. چطور کارا مدیریتش میکنید؟»
Workflowها در انتظار بدون مصرف منابع با استفاده از Signal برای رویدادهای خارجی عالیاند. جریان امضای سند در SDK مربوط به TypeScript در Temporal:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
const documentSigned = defineSignal<[SignatureData]>('documentSigned');
// sendForSignature, sendReminder, etc. are proxied activities
async function documentSigningWorkflow(docId: string) {
let signature: SignatureData | undefined;
setHandler(documentSigned, (data) => { signature = data; });
await sendForSignature(docId);
// Wait up to 30 days for the signature
if (!(await condition(() => signature !== undefined, '30 days'))) {
await sendReminder(docId);
// One more week, then give up
if (!(await condition(() => signature !== undefined, '7 days'))) {
await cancelDocument(docId);
return;
}
}
await processSignature(signature);
}
سیستمهای خارجی Signal را از طریق API موتور Workflow تحویل میدهند. وقتی Webhook مربوط به سرویس امضا فعال میشود، Handler موتور را با شناسهی Workflow صدا میزند و موتور اجرای درست را بیدار میکند. در این میان Workflow منتظر نه Thread دارد و نه حافظهی Worker، فقط State ماندگار و Timer ماندگار. این الگو کارهای انسانی، Callbackهای Webhook و یکپارچهسازی با سیستمهای خارجی را مدیریت میکند.
«چطور تضمین کنیم گام X دقیقاً یک بار اجرا شود؟»
بیشتر سیستمهای Workflow راهی میدهند تا Activity دقیقاً یک بار اجرا شود… برای تعریف بسیار مشخصی از «اجرا». اگر Activity با موفقیت تمام شود ولی گزارش موفقیتش هرگز به موتور Workflow نرسد، موتور Activity را Retry میکند. اگر Activity Refund یا ارسال ایمیل باشد ممکن است بد باشد.
راهحل Idempotent کردن Activity است. یعنی Activity بتواند با ورودیهای یکسان چند بار صدا زده شود و همان نتیجه را بدهد. ذخیرهی کلید در پایگاه داده (مثلاً Idempotency Key ایمیل) و بعد بررسی اینکه آیا وجود دارد پیش از انجام عمل برگشتناپذیر الگوی رایج برای این کار است.
خوانندگان دقیق متوجه میشوند بررسی و عمل هنوز Atomic نیستند: پروسس میتواند بعد از فرستادن ایمیل ولی پیش از ثبت کلید بمیرد. نمیتوانید این پنجره را کاملاً ببندید، ولی میتوانید با ثبت State در دو طرف عمل کوچکش کنید: کلید را پیش از عمل IN_PROGRESS و بعد از آن COMPLETED علامت بزنید. Retryی که COMPLETED بیابد از عمل رد میشود، Retryی که چیزی نیابد ادامه میدهد و حالت نادر IN_PROGRESS تنها حالتی است که مراقبت واقعی لازم دارد: آن را برای تطبیق کنار بگذارید (مثلاً از Provider ایمیل بپرسید چه دریافت کرده) بهجای اینکه کورکورانه دوباره شلیک کنید.
نسخهی دقیق تضمین این است که تلاشها At-least-once هستند و اثر است که Exactly-once است، چون بررسی Idempotency تلاشهای تکراری را بیضرر میکند. (Exactly-once واقعی روی شبکه ناممکن است، که دقیقاً به همین دلیل به Idempotency تکیه میکنیم.) هیچکدام از اینها به این معنا نیست که اثرات جانبی را از Activityها بیرون نگه دارید. شارژ کارت یا فرستادن ایمیل Activity کاملاً خوبی است و Idempotency آن اثرات جانبی را برای Retry ایمن میکند.
نتیجهگیری
سیستمهای Workflow برای ماشینهای حالت پیچیده که درست انجام دادنشان سخت است عالیاند. با متمرکز کردن مدیریت State، منطق Retry و مدیریت خطا در موتور اختصاصی، ساخت سیستمهای توزیعشدهی قابلاعتماد را ممکن میکنند. این اجازه میدهد منطق کسبوکاری بنویسیم که مثل نیازمندیهای کسبوکار خوانده میشود، نه ژیمناستیک زیرساختی.
بیشتر مهارت اینجا تشخیص این است که کی دارید دستی چیزی را میسازید که موتور Workflow فراهم میکند: ماندگاری State در شکست، Orchestration چند سرویس، مدیریت فرایندهای طولانیمدت و Retry خودکار همراه جبران. اگر خودتان را در حال پیادهسازی دستی Sagaهای توزیعشده یا ساختن ماشین حالت در Redis یافتید، وقت آن است که سیستم Workflow را در نظر بگیرید.
آماده باشید دربارهی Trade-offها بحث کنید. بله، پیچیدگی عملیاتی با سیستم توزیعشدهی دیگری که باید مدیریت کنید اضافه میکنید. بله، منحنی یادگیری برای توسعهدهندهها هست. اما برای مسائل درست، تفاوت بین چرخهی On-call که از سفارشهای گیرکرده میترسد و چرخهای است که تاریخچهی Workflow را میخواند و ادامه میدهد.