در دیتابیسهای قدیمیتر، مدیریت همزمانی (Concurrency) بر پایه قفلگذاری (Locking) استوار بود. یعنی وقتی یک تراکنش در حال خواندن یک ردیف بود و تراکنش دیگری میخواست همان ردیف را آپدیت کند، دیتابیس آن ردیف را قفل میکرد و یکی از تراکنشها باید منتظر دیگری میماند. این گلوگاه در سیستمهای با تراکنش بالا به شدت مشکلساز است.
پستگرس برای حل این مشکل از معماری MVCC (Multi-Version Concurrency Control) استفاده میکند. در ادامه مکانیزم این معماری و مفاهیم مرتبط با آن را به صورت فنی بررسی میکنیم.
قانون طلایی MVCC در این جمله خلاصه میشود: «خواندن، نوشتن را مسدود نمیکند و نوشتن، خواندن را مسدود نمیکند.»
وقتی شما در حال آپدیت یک دیتا هستید، کاربر دیگری که کوئری SELECT میزند، نسخه قبلی و پایدار دیتا را میبیند. این کار تا زمانی که تراکنش شما کامیت (Commit) شود ادامه دارد. در واقع، دیتابیس نسخههای متعددی از یک داده را در یک زمان واحد نگهداری میکند تا ایزولهسازی تراکنشها (Isolation) به درستی پیاده شود.
مشکل اساسی: چرا اصلاً به MVCC نیاز داریم؟
در سیستمهای سنتی (مثلاً مدل Two-Phase Locking یا 2PL)، تضمین یکپارچگی دادهها (Consistency) با قفل کردن (Locking) انجام میشد:
اگر من دارم میخوانم (Shared Lock)، تو نمیتوانی بنویسی (آپدیت/دلیت کنی). باید صبر کنی.
اگر من دارم مینویسم (Exclusive Lock)، تو حتی نمیتوانی بخوانی. باید صبر کنی تا کار من تمام شود.
در سیستمهایی با ترافیک بالا (High Concurrency)، این مدل باعث ایجاد صفهای طولانی و افت شدید پرفورمنس میشود. MVCC خلق شد تا نیاز به قفلگذاری برای خواندن (Read Locking) را به طور کامل از بین ببرد.
برای پیادهسازی این «چند نسخهای» بودن، پستگرس دیتاها را درجا (In-place) تغییر نمیدهد. هر ردیف در جدولهای پستگرس یک Tuple نامیده میشود. پستگرس در سطح دیسک، به هر Tuple چندین هدر (Header) و ستون سیستمی مخفی اضافه میکند که مهمترین آنها دو مورد زیر هستند:
ستون xmin: شناسه تراکنشی (TXID) که این رکورد را ایجاد (INSERT) کرده است.
ستون xmax: شناسه تراکنشی (TXID) که این رکورد را حذف (DELETE) یا آپدیت کرده است. (اگر رکورد هنوز معتبر باشد و حذف نشده باشد، مقدار این ستون 0 است).
پستگرس با استفاده از این دو فیلد و مقایسه آنها با TXID فعلی، تصمیم میگیرد که آیا یک رکورد برای تراکنش جاری قابل رؤیت (Visible) است یا خیر.
عملیات INSERT: یک Tuple جدید ساخته میشود. مقدار xmin برابر با شناسه تراکنش شما (TXID) میشود و مقدار xmax برابر با 0 خواهد بود (چون هنوز حذف نشده است).
عملیات DELETE: رکورد اصلاً از روی هارد دیسک پاک نمیشود! پستگرس فقط مقدار xmax آن رکورد را برابر با TXID تراکنش شما قرار میدهد. این یعنی این رکورد از این لحظه به بعد منقضی شده است.
عملیات UPDATE: در پستگرس چیزی به اسم آپدیت درجا (In-place Update) وجود ندارد. یک UPDATE در واقع ترکیبی از یک DELETE و یک INSERT است. یعنی Tuple قدیمی مقدار xmax میگیرد (منقضی میشود) و یک Tuple کاملاً جدید با دیتای جدید، در یک بلاک جدید روی دیسک با xmin جدید نوشته میشود.
با توجه به مکانیزم بالا، متوجه میشویم که هر بار که دیتایی را UPDATE یا DELETE میکنیم، نسخه قدیمی آن دیتا همچنان روی هارد دیسک (و در حافظه RAM هنگام کش شدن) باقی میماند.
به این نسخههای قدیمی که مقدار xmax دارند و آن تراکنش پایان یافته است، Dead Tuples گفته میشود. این رکوردها دیگر برای هیچ تراکنش فعالی در دیتابیس قابل خواندن نیستند.
مشکل Dead Tuples چیست؟
اگر دیتابیس شما عملیات UPDATE و DELETE زیادی داشته باشد، تعداد Dead Tuples به شدت بالا میرود. این پدیده باعث Table Bloat (تورم جدول) میشود. تورم نهتنها حجم دیسک را هدر میدهد، بلکه مستقیماً روی پرفورمنس تأثیر میگذارد.
مثلاً اگر جدول شما ۱ میلیون رکورد زنده و ۵ میلیون Dead Tuple داشته باشد، هنگام اجرای یک کوئری (مخصوصاً Sequential Scan)، پستگرس مجبور است تمام آن ۶ میلیون رکورد را از روی دیسک بخواند و پردازنده باید برای تکتک آنها قوانین رؤیتپذیری (xmin و xmax) را چک کند. این کار باعث تحمیل بار سنگینی به I/O و CPU میشود.
برای حل مشکل Dead Tuples و جلوگیری از تورم بینهایت، پستگرس فرآیندی به نام VACUUM دارد. VACUUM مانند یک فرآیند زبالهروب (Garbage Collector) است که به صورت دورهای جداول را اسکن و تمیز میکند.
وظایف اصلی VACUUM:
آزاد کردن فضا (Space Reclamation): وکیوم میگردد و تمام Dead Tuples را پیدا میکند. اما آنها را فیزیکی حذف نمیکند؛ بلکه فضایی که آنها اشغال کردهاند را در ساختاری به نام FSM (Free Space Map) علامتگذاری میکند. از این پس، عملیات INSERT یا UPDATEهای بعدی میدانند که میتوانند دیتای خود را در این فضاهای علامتگذاری شده بنویسند.
نکته فنی: دستور VACUUM معمولی حجم فایل دیتابیس در هارد دیسک را کم نمیکند (سیستمعامل فضای خالی شده را نمیبیند)، فقط فضا را در داخل فایل بازیافت میکند. برای بازگرداندن فضا به سیستمعامل باید از VACUUM FULL استفاده کرد که جدول را کاملاً قفل (Exclusive Lock) میکند و یک کپی جدید از دیتای زنده میسازد.
جلوگیری از فاجعه Transaction ID Wraparound: شناسههای تراکنش (TXIDTXIDTXID) در پستگرس یک عدد صحیح ۳۲ بیتی هستند (یعنی ظرفیت حدود ۲ ضرب در ۱۰ به توان ۹ که معادل ۲ میلیارد تراکنش ). وقتی دیتابیس به این مرز برسد، اعداد دوباره از صفر شروع میشوند (Wraparound). اگر این اتفاق بیفتد، دیتابیس ناگهان تراکنشهای گذشته را به عنوان تراکنشهای آینده میبیند و کل دیتاها ناپدید میشوند! VACUUM با بررسی رکوردهای خیلی قدیمی، فیلد xmin آنها را به یک مقدار ویژه به نام FrozenXID تغییر میدهد (فرآیند Freezing). این کار به پستگرس میفهماند که این رکوردها آنقدر قدیمیاند که برای همه تراکنشها در همه زمانها معتبرند.
آپدیت کردن آمار و Visibility Map: وکیوم ساختاری به نام VM (Visibility Map) را آپدیت میکند که نشان میدهد کدام بلاکهای دیسک فقط شامل رکوردهای زنده هستند. این کار باعث تسریع فوقالعاده Index-Only Scans میشود. همچنین معمولاً VACUUM با دستور ANALYZE همراه میشود تا Query Planner پستگرس آمار دقیقی از توزیع دادهها داشته باشد و بهترین Execution Plan را انتخاب کند.
اتووکیوم (AutoVacuum):
مدیریت دستی وکیوم کار دشواری است. پستگرس یک پروسه بکگراند (Daemon) به نام autovacuum دارد که مدام آمار تغییرات جداول را مانیتور میکند. اگر متوجه شود تعداد Dead Tuples در یک جدول از یک آستانه مشخص (معمولاً حدود 20%20\%20%) بالاتر رفته است، به صورت خودکار عملیات پاکسازی را در پسزمینه روی آن جدول اجرا میکند. خاموش کردن autovacuum در محیط پروداکشن یکی از بزرگترین اشتباهاتی است که منجر به توقف دیتابیس (به دلیل Wraparound) یا افت شدید پرفورمنس خواهد شد.
هیچ معماریای بینقص نیست. پستگرس برای این سرعت بالا و قفل نشدن دیتا، هزینههای زیر را میپردازد:
بزرگ شدن سایز رکوردها: به هر ردیف حداقل ۲۳ بایت هدر (شامل همین xminxminxmin و xmaxxmaxxmax و موارد کنترلی دیگر) اضافه میشود.
نوشتن مضاعف (Write Amplification): یک آپدیت ساده، یک ردیف کامل جدید مینویسد و روی ایندکسها هم تاثیر میگذارد.
تولید زباله (Dead Tuples): رکوردهایی که از فیلتر قوانین رویتپذیری رد نمیشوند، روی دیسک میمانند و این ما را به همان بحث Vacuum میرساند.
قبل از اینکه بریم سراغ نحوه تصمیم گیری پستگرس باید با چند مفهوم آشنا بشیم
قلب تپندهی MVCC در پستگرس، مفهومی به نام Snapshot است.
وقتی شما یک تراکنش (Transaction) را شروع میکنید، پستگرس یک “عکس” از وضعیت فعلی تمام تراکنشهای دیتابیس میگیرد. اما این عکس، کپی کردن دیتا نیست (چون وحشتناک کند میشود)، بلکه ثبت وضعیت شناسههای تراکنش (TXID) است.
یک Snapshot شامل سه جزء اصلی ریاضی است:
xmin (در سطح اسنپشات): قدیمیترین تراکنشی که هنوز در حال اجراست (Active). هر تراکنشی که شناسهاش کوچکتر از این عدد باشد، قطعاً تمام شده (کامیت شده) و دیتای آن برای ما قابل دیدن است.
xmax (در سطح اسنپشات): اولین TXID که هنوز به هیچ تراکنشی اختصاص داده نشده است (تراکنشهای آینده). هر تراکنشی که شناسهاش بزرگتر یا مساوی این عدد باشد، مربوط به آینده است و ما نباید دیتای آن را ببینیم.
xip_list: لیستی از شناسههای تراکنشهایی که بین xmin و xmax قرار دارند و همین الان در حال اجرا هستند. کارهایی که اینها میکنند هنوز کامیت نشده، پس برای ما نامرئی است.
حالا فرض کن کوئری SELECT * FROM users را اجرا میکنی. پستگرس شروع میکند به خواندن ردیفها (Tuples) از روی هارد دیسک.
همانطور که در قبلا گفتم، هر Tuple روی دیسک دو هدر دارد: tuple_xmin (سازنده) و tuple_xmax (حذفکننده).
پستگرس برای هر تکتک رکوردهایی که میخواند، قوانین رویتپذیری (Visibility Rules) زیر را با استفاده از Snapshot شما چک میکند:
مرحله اول: آیا این رکورد اصلاً برای من خلق شده است؟ (بررسی سازنده - tuple_xmin)
اگر tuple_xmin ≥ snapshot_xmax باشد: این رکورد توسط تراکنشی در آینده ساخته شده. نامرئی (رد میشود).
اگر tuple_xmin داخل لیست xip_list باشد: این رکورد توسط تراکنشی ساخته شده که هنوز تمام نشده (کامیت نشده). → نامرئی (رد میشود).
اگر هیچکدام از بالا نبود، یعنی رکورد در گذشتهی معتبر کامیت شده است. حالا میرویم مرحله دوم.
مرحله دوم: آیا این رکورد هنوز زنده است یا پاک/آپدیت شده؟ (بررسی حذفکننده - tuple_xmax)
اگر tuple_xmax=0 باشد: رکورد کاملاً زنده است. → مرئی (به شما نشان داده میشود).
اگر tuple_xmax ≥ snapshot_xmax باشد: رکورد توسط تراکنشی در آینده پاک شده است. پس در زمان (اسنپشات) ما، هنوز زنده است! → مرئی (به شما نشان داده میشود).
اگر tuple_xmax داخل لیست xip_list باشد: یک تراکنش دیگر همین الان در حال پاک کردن یا آپدیت این رکورد است، اما هنوز کامیت نکرده. پس برای ما هنوز معتبر است. → مرئی (به شما نشان داده میشود).
اگر هیچکدام نبود، یعنی رکورد در گذشتهی معتبر پاک شده است. →نامرئی (رد میشود - این همان Dead Tuple است!).
معماری MVCC در پستگرس یک شاهکار مهندسی برای مدیریت همزمانی بالا (High Concurrency) است. با حذف نیاز به قفلگذاری برای عملیات خواندن، دیتابیس میتواند هزاران درخواست همزمان را بدون ایجاد گلوگاه پردازش کند.
اما همانطور که دیدیم، این معماری بدون هزینه نیست. حفظ نسخههای متعدد از دادهها منجر به تولید رکوردهای مرده (Dead Tuples) میشود. درک عمیق نحوه کار ستونهای سیستمی مانند xmin و xmax، و نحوه تصمیمگیری موتور دیتابیس بر اساس Snapshot، به ما نشان میدهد که چرا پروسههایی مانند AutoVacuum فقط یک «ابزار جانبی» نیستند، بلکه قلب تپندهی نگهداری از سلامت، سرعت و پایداری پستگرس محسوب میشوند. به عنوان یک توسعهدهنده یا DBA، شناخت این لایههای زیرین به ما کمک میکند تا کوئریهای بهتری بنویسیم و دیتابیس را برای بارهای کاری سنگین بهینهتر پیکربندی کنیم.
برای اینکه دید بازتری نسبت به عملکرد موتور پستگرس داشته باشید، بد نیست به این ۳ نکته حیاتی نیز توجه کنید:
۱. مراقب تراکنشهای طولانی (Long-Running Transactions) باشید:
اگر یک کوئری SELECT یا یک تراکنش باز، ساعتها طول بکشد، Snapshot آن همچنان قدیمیترین xmin فعال را در سیستم نگه میدارد. این یعنی پروسه VACUUM در این مدت نمیتواند هیچ Dead Tuple ای را که تراکنشهای دیگر تولید کردهاند پاک کند (چون پیش خود میگوید شاید این تراکنش طولانی به دیدن آنها نیاز داشته باشد). نتیجهی این اتفاق، تورم ناگهانی و شدید جداول (TableBloat) است.
۲. معجزه آپدیتهای HOT (Heap−OnlyTuples):
در بخش Trade-offs گفتیم که آپدیتها در پستگرس باعث WriteAmplification (نوشتن مضاعف در جداول و ایندکسها) میشوند. پستگرس برای کاهش این هزینه، مکانیزم هوشمندانهای به نام HOT دارد. اگر شما ستونی را UPDATE کنید که روی آن هیچ Index ای تعریف نشده باشد، و در همان بلاکِ دیسک فضای خالی وجود داشته باشد، پستگرس ایندکسها را درگیر نمیکند و رکورد جدید را در همان بلاک مینویسد. این کار هزینه آپدیت را به طرز چشمگیری کاهش میدهد.
۳. تیونینگ (Tuning) تنظیمات AutoVacuum برای جداول بزرگ:
تنظیمات پیشفرض autovacuum برای جداول کوچک تا متوسط عالی است (مقدار پیشفرض پارامتر autovacuum_vacuum_scale_factor=0.2 است، یعنی وقتی 20% رکوردها تغییر کردند وکیوم اجرا شود). اما اگر جدولی 100 میلیون رکورد داشته باشد، پستگرس صبر میکند تا 20 میلیون Dead Tuple ایجاد شود و بعد وکیوم را اجرا کند که این فاجعه است! در دیتابیسهای بزرگ، حتماً باید این درصد را برای جداول حجیم کاهش دهید تا وکیومها کوچکتر، سریعتر و مکرر انجام شوند.