فصل اول از کتاب I ♥ Logs
این کتاب دربارهی Logها است. چرا یک کتاب دربارهی لاگها نوشته شده است؟ لاگ یک مفهوم است که در قلب بسیاری از سیستمها وجود دارد؛ از دیتابیسهای NoSQL تا کریپتوکارنسی. در این مطلب میخواهیم ببینیم لاگها در یک سیستم توزیع یافته، چگونه کار میکنند و سپس چند مثال عملی از این مفهوم را بررسی کنیم: وارد کردن داده، معماری پروژه، پردازش دادهی لحظهای و طراحی سیستم دادهای.
بیشتر مردم وقتی کلمهی لاگ را میشنوند، یاد چیزی مانند شکل زیر میافتند. بیشتر برنامهنویسها با چنین لاگی آشنا هستند؛ یک سری از درخواستها، ارورها و دیگر پیامها که در یک فایل آمدهاند.
این نوع از لاگ با لاگی که ما میخواهیم بررسی کنیم متفاوت است. بزرگترین تفاوت اینجاست که لاگی که در شکل آمده بیشتر برای این است که انسان بتواند آن را بخواند اما لاگی که ما میخواهیم بررسی کنیم، در برنامهنویسی هم میتوان به آن دسترسی پیدا کرد.
لاگی که ما بررسی خواهیم کرد کمی عمومیتر و نزدیکتر به چیزی است که دیتابیسها یا سیستمها از آن به عنوان Commit یا Journal یاد میکنند. آنها یک فایل Append-Only از رکوردها هستند که به ترتیب زمان آمدهاند؛ مانند شکل زیر
هر مستطیل نشاندهندهی یک رکورد است که به لاگ Append شده است. رکوردها به ترتیبی که Append شدهاند، ذخیره شدهاند. هر ورودیای که به فایل Append شدهاست، یک لاگ نامبر منحصر به فرد و ترتیبی دارد که به عنوان Unique Key آن عمل میکند. محتویات و فرمت رکوردها در بحث ما اهمیتی ندارد. برای مثال، میتوانیم فرض کنیم که هر رکورد یک JSON است.
ترتیب رکوردها یک ادراک از زمان به ما میدهند چون ورودیهای سمت چپ قدیمیتر از ورودیهای سمت راست هستند. لاگ نامبر میتواند به عنوان یک Timestamp ورودی در نظر گرفته شود.
لاگ تفاوت زیادی با یک فایل یا یک Table ندارد. یک فایل، آرایهای از بایتها است، یک Table آرایهای از رکوردها است و یک لاگ نوعی از Table یا فایل است که در آن رکوردها به ترتیب زمان سورت شدهاند.
اینجا ممکن است سوال پیش بیاید که "چرا باید دربارهی چیزی به این سادگی صحبت کنیم؟ یک دنباله از رکوردها چگونه به یک سیستم داده ربط دارد؟" جواب این است که لاگ یک هدف مخصوص دارد: آنها اتفاقها و زمان اتفاق افتادنها را ذخیره میکنند. برای سیستمهای توزیع یافته، این قلب مسئله است.
ما نمیدانیم که مفهوم لاگ از کجا نشات میگیرد؛ احتمالا جزء آن چیزهای خیلی سادهای باشد که سازندهی آن اصلا نفهمیده که یک اختراع است. کاربرد آن در دیتابیسها، همگام سازی انواع ساختمان دادهها و ایندکسها، به هنگام خرابی است. برای پایدار و اَتومیک کردن آن، یک دیتابیس قبل از اعمال تغییرات در تمام ساختمان دادههایی که ذخیره میکند، از لاگ استفاده میکند تا اطلاعاتی دربارهی رکوردهایی که میخواهد تغییر دهد، بنویسد. لاگ، یک رکورد از چیزی است که اتفاق افتاد و هر Table یا ایندکس، یک تصویر از این تاریخچه روی ساختمان دادهها یا ایندکس است. از آنجایی که لاگ در لحظه ذخیره میشود، به عنوان یک منبع معتبر در بازگردانی ساختمان دادهها به هنگام خرابی استفاده میشود.
در طول زمان، کاربرد لاگ از یک پیادهسازی برای جزئیات اطلاعات یک دیتابیس ACID، به یک روش برای Replicate کردن داده بین دیتابیسها رشد پیدا کرد. معلوم شد که دنبالهای از تغییرات که روی یک دیتابیس اتفاق میافتد، دقیقا همان چیزی است که برای همگامسازی یک دیتابیس Replica نیاز است. Oracle MySQL، PostgreSQL و MongoDB شامل پروتکلهای جابجایی لاگ هستند تا دستهای از لاگها را به دیتابیسهای Replica ارسال کنند تا این دیتابیسها به عنوان Slave عمل کنند. سپس Slaveها میتوانند اطلاعات درون لاگها را درون ساختمان دادههای خود اعمال کنند تا با Master همگام شوند.
کاربرد لاگ در ادامهی این کتاب، شامل دو نوع از کاربردها است که در دیتابیسها استفاده میشود:
مشکلی که دیتابیسها با استفاده از لاگها حل میکنند (مانند توزیع کردن داده در Replicaها) جزء اساسیترین مشکلها برای سیستمهای توزیع یافته است.
کتاب در اینجا یک مشاهدهای را تحت عنوان قاعدهی State Machine Replication آورده است:
اگر دو پردازش ِ یکسان ِ قطعی (Deterministic) در یک وضعیت (State) یکسان آغاز شوند و ورودیهای یکسانی را با ترتیب یکسان دریافت کنند، خروجی یکسانی را تولید خواهند کرد و در یک وضعیت یکسان پایان خواهند یافت
ممکن است کمی فهمیدن آن سخت باشد، کمی بیشتر توضیح میدهیم.
قطعی (Deterministic) بودن یعنی پردازش به زمان بستگی ندارد و نمیگذارد ورودی دیگری بر روی نتیجهی آن تاثیر بگذارد. برای Non Deterministic میتوان از این مثال استفاده کرد: برنامهای که بر اساس نتیجهای که فراخوانی تابع getTimeOfDay() بازمیگرداند، تصمیم میگیرد.
وضعیت(State) یک پردازش، دادهای است که بعد از پردازش ما، بر روی هارد یا حافظهی ماشین باقی میماند.
قسمت ورودی یکسان در زمان یکسان هم ما را یاد لاگ میاندازد.
حالت شهودی آن این است: اگر به یک تکه کد قطعی، لاگ ورودی یکسانی را بدهید، خروجی یکسانی را با ترتیب یکسان تولید میکنند.
کاربرد آن در پردازش توزیع یافته مشخص است. میتوان مسئلهی "ساختن چند ماشین برای انجام یک کار یکسان" را به مسئلهی "پیادهسازی یک لاگ پایدار برای خوراندن ورودی به این پردازشها" کاهش داد. در اینجا هدف لاگ این است که تمام Non Deterministic بودن را از جریان ورودی حذف کند تا مطمئن باشد هر Replica که دارد ورودی را پردازش میکند، همگام میماند.
یکی از چیزهای خوب دربارهی لاگ این است که اعداد گسستهی لاگ نامبرها به عنوان یک ساعت برای Stateهای Replicaها عمل میکنند؛ میتوان State هر Replica را با یک عدد توصیف کرد: Timestamp برای آخرین ورودی لاگ که پردازش کرده است. دو Replica در یک زمان یکسان State یکسانی خواهند داشت. به همین خاطر، این Timestamp به همراه لاگ، وضعیت Replica را ذخیره خواهد کرد. این یک ادراک گسسته و متکی بر ایونت به ما میدهد که بر خلاف ساعت محلی ماشینها [که ممکن است متفاوت باشند]، میتوان آنها را به راحتی در ماشینهای مختلف مقایسه کرد.
انواع مختلفی برای اعمال این اصل وجود دارد. این بستگی به این دارد که چه چیزی قرار است در لاگ قرار بگیرد. برای مثال، میتوانیم درخواستهای ورودی به یک سرویس را بنویسیم تا هر Replica آنها را مستقل از هم پردازش کند. یا میتوانیم یک درخواست پردازش داشته باشیم و تغییرات وضعیتها را که در پاسخ به درخواست رخ میدهند، در لاگ بنویسیم.
کامیونیتیهای مختلف، پترنهای مختلف را به شکل متفاوت توصیف میکنند. افراد دیتابیسی، معمولا بین Physical Logging و Logical Logging تفاوت قائل میشوند. Physical یا Row-Based Logging یعنی لاگ کردن محتویات هر ردیفی که تغییر کرده است. Logical یا Statement Logging یعنی ردیفهای تغییر یافته را لاگ نکنیم، بلکه دستورهای SQLای را که منجر به آن تغییرات در ردیفها شده است، لاگ کنیم.
ادبیات سیستمهای توزیع یافته، معمولا بین دو شیوه برای پردازش و Replication تفاوت قائل میشود. State Machine Model معمولا به یک مدل Active-Active اشاره دارد، که در آن ما یک لاگ از درخواستهای ورودی نگه میداریم و هر Replica هر درخواست را به ترتیب لاگ، پردازش میکند. یک نسخهی تغییر یافتهی این مدل، Primary-Backup Model است که در آن یک Replica به عنوان Leader انتخاب میشود. این لیدر درخواستها را به ترتیبی که آمدهاند، پردازش میکند و تغییرات مربوط به حالت خود را که در نتیجهی پردازش درخواستها رخ میدهد، ثبت میکند. بقیهی Replicaها تغییراتی که لیدر ایجاد میکند، اعمال میکنند تا همگام شوند. با این کار به هنگام از کار افتادن لیدر، میتوانند جای آن را بگیرند.
همانطور که در شکل زیر مشخص است، در Primary Backup Model یک Master Node انتخاب شده است تا تمام خواندنها و نوشتنها را مدیریت کند. هر نوشتن درون لاگ ذخیره میشود. Slaveها در لاگ مشترک میشوند و تغییراتی را که Master اجرا کرده است، در وضعیت محلی خود اعمال میکنند. اگر Master از کار بیفتد، یک Master جدید از بین Slaveها انتخاب خواهد شد. در State Machine Replication Model تمام Nodeها یکسان هستند. نوشتنها ابتدا به لاگ میروند و تمام Nodeها نوشتن را به ترتیبی که توسط لاگ مشخص شده است، اجرا میکنند.
برای فهمیدن روشهای متفاوت برای ساخت یک سیستم با استفاده از لاگ، یک مسئله را در نظر میگیریم. فرض کنید میخواهیم یک سرویس حساب Replicated ایجاد کنیم که یک سری متغیر را نگهداری میکند و یک سری عملیات ریاضی روی آنها اعمال میکند. سرویس ما به دستورهای زیر پاسخ خواهد داد:
x? // get the current value of x x+=5 // add 5 to x x-=2 // subtract 2 from x y*=2 // double y
فرض کنیم این به عنوان یک ریموت وب سرویس اجرا خواهد شد که درخواستها و پاسخها با استفاده از HTTP ارسال میشوند.
اگر فقط یک سرور داشتیم، پیادهسازی آن آسان خواهد بود. میتواند متغیرها را در حافظه یا دیسک ذخیره کند و آنها را با هر ترتیبی که اتفاق میافتند، آپدیت کند. اما چون فقط یک سرور داریم، Fault Tolerance را از دست میدهیم و به هیچ وجه نمیتوانیم مقیاس را بالا ببریم.
اینها را میتوانیم با اضافه کردن سرورهایی که این وضعیت را Replicate میکند و دستورها را پردازش میکنند، حل کرد. اما این یک مشکل جدید ایجاد میکند: سرور ممکن است از همگامسازی خارج شود. راههای مختلفی برای اتفاق افتادن این مورد وجود دارد. مثلا سرورها دستورهای آپدیت را در ترتیبهای متفاوت دریافت میکنند یا یک سرور از کار افتاده و آپدیتها را از دست میدهد.
اگرچه در عمل، بیشتر افراد کوئریها و آپدیتها را به یک دیتابیس ریموت ارسال میکنند. با استفاده از این روش مشکل از برنامهی ما خارج میشود اما واقعا حل نمیشود؛ بالاخره ما حالا میخواهیم مشکل Fault Tolerance را در دیتابیس حل کنیم. پس برای اینکه به مثال پایبند باشیم، بیایید کاربرد لاگ را در برنامهمان بررسی کنیم.
راههای مختلفی برای حل این مشکل با استفاده از لاگ وجود دارد. State Machine Replication در ابتدا عملیاتی را که قرار است اجرا شود، در لاگ مینویسد و سپس هر Replica عملیات موجود در لاگ را به ترتیب اجرا میکند. در این حالت، لاگ حاوی دنبالهای از دستورها مانند 5=+x یا 2=*y است.
روش Primary Backup هم امکان پذیر است. در این طراحی، یکی از Replicaها را به عنوان Leader انتخاب میکنیم. این لیدر هر دستوری را که دریافت کند اجرا میکند و مقادیر متغیرها را که خروجی اجرای هر دستور است، در لاگ مینویسد. در این طراحی، لاگ فقط حاوی متغیرهای نهایی مانند 1=x یا 6=y است، نه دستورهایی که منجر به این نتایج میشود. Replicaها مانند بکاپ عمل خواهند کرد؛ آنها در این لاگ مشترک میشوند و این متغیرهای جدید را به ساختمان دادههای محلی خود اضافه میکنند. اگر لیدر از کار بیفتد، یک لیدر جدید از بین این Replicaها انتخاب خواهد شد.
این مثال همچنین نشان میدهد که چرا ترتیب، یک عنصر مهم برای تضمین پایداری میان Replicaها است، جابجایی ترتیب عمل ضرب و جمع، منجر به یک نتیجهی متفاوت میشود. همچنین جابجایی ترتیب اعمال آپدیتها برای یک متغیر هم منجر به نتیجهی متفاوت میشود.
برگردیم به دیتابیسها. همزادی خوبی بین لاگ تغییرات و Table وجود دارد. لاگ شبیه به یک لیست از تمام وامها و بدهیهایی است که یک بانک دسترسی دارد. Table تمام موجودی اکانتهای فعلی است. اگر یک لاگ از تغییرات داشته باشید، میتوانید این تغییرات را اعمال کنید تا Table را بسازید و وضعیت نهایی را به دست بیاورید. این Table آخرین وضعیت برای هر Key را ذخیره خواهد کرد. لاگ نه تنها میتواند Table اصلی را بسازد، بلکه میتوان آن را طوری تغییر داد تا Tableهای نشأت گرفته از Table اصلی را هم بسازد. (در دیتابیسهای Non-Reltaional، تیبل همان Keyed Data Store است)
این فرآیند را معکوس هم میتوان انجام داد: اگر یک Table داریم که آپدیتهایی را دریافت میکند، میتوان این تغییرات را ذخیره کرد و یک Changelog از تمام آپدیتها منتشر کرد. این Changelog دقیقا همان چیزی است که برای پشتیبانی از Near-Real-Time Replicaها نیاز داریم. در این صورت، میتوان ایونتها و تیبلها را به عنوان یک دوگانه دید: تیبلها از دادهی ایستا پشتیبانی میکنند و لاگها تغییرات را ذخیره میکنند. جادوی لاگ اینجاست که اگر یک Complete Log از تغییرات باشد، نه تنها آخرین نسخهی محتویات تیبل را ذخیره میکند بلکه میتواند تمام ورژنهای قبلی را هم دوباره بسازد. در واقع به نوعی یک بکاپ از تمام وضعیتهای قبلی تیبل است.
این ممکن است شما را یاد کنترل ورژن سورس کد بیندازد. یک رابطهی نزدیک بین کنترل سورس کد و دیتابیس وجود دارد. ورژن کنترل یک مشکلی را حل میکند که شباهت زیادی به مشکلی دارد که سیستمهای دادهی توزیع یافته باید حل کند: مدیریت تغییرهای توزیعیافتهی همزمان در وضعیت.