در دنیای پایگاههای داده، یکی از بزرگترین چالشها این است که چطور اجازه دهیم هزاران کاربر همزمان داده بخوانند و بنویسند، بدون اینکه داده خراب شود یا سرعت سیستم پایین بیاید. تصور کنید در یک کتابخانه، چند نفر همزمان بخواهند کتابی را بردارند، یکی برگرداند، دیگری یادداشتبرداری کند. اگر کتابخانه قفلهای فیزیکی بگذارد، هر بار فقط یک نفر میتواند کاری انجام دهد و بقیه باید منتظر بمانند. این روش همان locking بدبینانه است که در پایگاههای داده قدیمی استفاده میشد.
اما PostgreSQL رویکرد دیگری دارد: همه میتوانند همزمان کار کنند، چون هر کسی نسخهٔ خودش از داده را میبیند و هنگام نهایی کردن تغییرات، تصمیم خواهیم گرفت که کدام نسخه ها را نگه داریم. این ایده پایهٔ MVCC است.
وقتی چند تراکنش همزمان روی یک پایگاه داده کار میکنند، چند مشکل رایج پیش میآید. بیایید با مثال سادهای آنها را بشناسیم:
| مشکل | توضیح |
|---|---|
| Dirty Read | تراکنش A دادهای را تغییر میدهد اما هنوز کامیت نکرده. تراکنش B آن داده را میخواند. اگر A لغو شود(rollback)، B دادهٔ نادرست خوانده است. |
| Non‑repeatable Read | تراکنش A یک مقدار را میخواند. تراکنش B آن را تغییر داده و کامیت میکند. A دوباره همان را میخواند و مقدار متفاوت میبیند. یعنی A بر اساس دادهای جلو رفته است که در انتهای کار، آن مقدار تغییر کرده است. |
| Phantom Read | تراکنش A مجموعهای از ردیفها را بر اساس شرطی میخواند (مثلاً COUNT(*) WHERE status='pending'). تراکنش B ردیف جدیدی با همان شرط اضافه کرده و کامیت میکند. A دوباره همان دادهها را با اجرای مجدد همان کوئری میخواند و تعداد رکورد متفاوتی میبیند. انگار یک رکورد شبحوار(فانتوم) ظاهر شده است. |
| Lost Update | دو تراکنش همزمان یک مقدار را میخوانند، سپس هر دو بر اساس مقدار خواندهشده آن را بهروز میکنند. آخرین بهروزرسانی، اثر بهروزرسانی قبلی را از بین میبرد. مثال: T1 و T2 هر دو x=10 را میخوانند. T1: x = x+1 → ۱۱ را مینویسد. T2: x = x+1 → ۱۱ را مینویسد. نتیجه نهایی ۱۱ میشود، در حالی که باید ۱۲ میشد. |
برای کنترل این مشکلات، استاندارد SQL چهار سطح ایزولهسازی (Isolation Level) تعریف کرده است:
| سطح ایزولهسازی | Dirty Read | Non-repeatable Read | Phantom Read | Lost Update |
|---|---|---|---|---|
| Read Uncommitted | جلوگیری میشود (در PostgreSQL معادل Read Committed است) | ممکن است | ممکن است | ممکن است |
| Read Committed (پیشفرض) | جلوگیری میشود | ممکن است | ممکن است | ممکن است (برای جلوگیری از lost-update از LOCK/SELECT FOR UPDATE یا SERIALIZABLE استفاده کنید) |
| Repeatable Read | جلوگیری میشود | جلوگیری میشود | جلوگیری میشود (در PostgreSQL بهدلیل Snapshot Isolation رکوردهای تازهٔ درجشده در snapshot دیده نمیشوند) | ممکن است (Snapshot Isolation میتواند برخی سناریوهای «lost update / write-skew» را اجازه دهد) |
| Serializable | جلوگیری میشود | جلوگیری میشود | جلوگیری میشود | جلوگیری میشود |
نکته: PostgreSQL سطح Read Uncommitted را پیادهسازی نکرده و آن را مانند Read Committed رفتار میکند. همچنین در Repeatable Read، پدیدهٔ Phantom هم رخ نمیدهد چون snapshot isolation قوی دارد.
رداشت دقیق از رفتارهای جدول بالا زمانی کاملاً روشن میشود که سازوکار MVCC در PostgreSQL را بهخوبی بشناسید.
برای جلوگیری قطعی از ناهنجاریهایی مانند lost update یا write-skew، مطمئنترین راه استفاده از سطح ایزولهسازی SERIALIZABLE است. اگر از سطوح پایینتر استفاده میکنید، باید کنترل تعارض را خودتان مدیریت کنید؛ معمولاً با قفلگذاری صریح (مثل SELECT ... FOR UPDATE) یا با طراحی تراکنشها بهگونهای که دسترسیهای متعارض بهصورت کنترلشده و قفلشده انجام شوند.
ایدهٔ مرکزی MVCC در ظاهر بسیار ساده است، اما اثر عمیقی روی نحوهٔ اجرای تراکنشها دارد:
بهجای اینکه یک ردیف هنگام تغییر بازنویسی شود، سیستم یک نسخهٔ جدید از آن ایجاد میکند. نسخهٔ قبلی بلافاصله حذف نمیشود؛ بلکه تا زمانی که تراکنشهایی وجود دارند که باید آن را ببینند، نگه داشته میشود.
نتیجهٔ این رویکرد:
به بیان سادهتر، MVCC با نگهداشتن چند نسخه از دادهها، همزمانی بالا و سازگاری منطقی تراکنشها را بدون قفلگذاری سنگین فراهم میکند.
در PostgreSQL هر تراکنش یک شناسهٔ یکتای عددی دریافت میکند که به آن Transaction ID (XID) گفته میشود. این شمارنده از مقدار ۱ شروع میشود، بهصورت پیوسته افزایش مییابد و در نهایت پس از رسیدن به حدود ۲³² (بهدلیل محدودیت اندازهٔ عددی) دوباره ریست میشود.
برای جلوگیری از مشکلاتی که این «گردش شمارنده» ممکن است در تشخیص نسخههای قدیمی ایجاد کند، مکانیزمی به نام Freezing وجود دارد که بعداً مفصلتر به آن میپردازیم.
از آنجا که شناسهٔ تراکنشها بهترتیب افزایش مییابد، میتوان از روی آنها ترتیب زمانی نسبی اجرای تراکنشها را تشخیص داد.
هر رکورد یا ردیف (tuple) در PostgreSQL نیز اطلاعات تراکنشی خودش را نگه میدارد:
موتور پایگاهداده با استفاده از همین اطلاعات تصمیم میگیرد که کدام نسخه از یک ردیف برای یک تراکنش خاص قابل مشاهده باشد. به بیان دیگر، کنترل دیدپذیری دادهها در MVCC دقیقاً بر اساس همین متادیتای تراکنشی انجام میشود.

اگر بخواهید شناسهٔ تراکنش جاری را ببینید، میتوانید از تابع زیر استفاده کنید:
BEGIN;
SELECT txid_current(); -- مثلاً ۱۰۰۰
COMMIT;
این تابع شناسهٔ تراکنش فعلی را برمیگرداند و در صورت نیاز یک XID جدید برای آن تخصیص میدهد (بنابراین عملاً شمارنده را جلو میبرد).
یک Snapshot نمایی ثابت و سازگار از وضعیت تراکنشها در یک لحظهٔ مشخص است. هر snapshot با سه مؤلفهٔ اصلی تعریف میشود:
شکل زیر وضعیت سیستم را از لحاظ تراکنش های در حال اجرا در زمان شروع تراکنش ۱۰۱ نشان میدهد.

یک ردیف فقط زمانی برای تراکنش قابل مشاهده است که:
با داشتن xmin، xmax و xip، PostgreSQL هر تراکنش را به یکی از گروههای زیر تقسیم میکند:
این دستهبندی به PostgreSQL اجازه میدهد قابلیت مشاهده هر ردیف را دقیقاً کنترل کند و نمایی پایدار (snapshot-consistent) ارائه دهد.
Snapshot مثل یک عکس ثابت از پایگاهداده است.
xmin، xmax و xip مشخص میکنند کدام تغییرات قبل از snapshot بوده و کدامها بعد از آن؛ بنابراین هر تراکنش فقط نسخههای «معتبر» را میبیند و خواندنها تحت تأثیر commitهای بعدی قرار نمیگیرد.
در PostgreSQL، هر رکورد یک tuple نامیده میشود. هر نسخه از یک رکورد علاوه بر دادههای اصلی، چند فیلد سیستمی دارد که تاریخچه و وضعیت تراکنش آن را مشخص میکنند:
| فیلد | معنا |
|---|---|
| xmin | شناسهٔ تراکنشی که این نسخه از رکورد را ایجاد کرده است. |
| xmax | شناسهٔ تراکنشی که این نسخه را حذف یا بازنویسی کرده است. اگر صفر باشد، یعنی هنوز حذف نشده است. |
| ctid | آدرس فیزیکی این نسخه در صفحهٔ داده (برای یافتن سریع رکورد). |
به بیان ساده، این فیلدهای سیستمی به PostgreSQL اجازه میدهند تاریخچهٔ تغییرات هر رکورد را دنبال کند و قابلیت مشاهدهٔ آن را برای تراکنشها بر اساس MVCC مدیریت کند
بیایید یک جدول ساده بسازیم و autovacuum را موقتاً خاموش کنیم تا خودمان مدیریت نسخهها و پاکسازی را مشاهده کنیم:
CREATE TABLE mvcctest (
id INTEGER,
val TEXT
) WITH (autovacuum_enabled = off);INSERT INTO mvcctest VALUES (1, 'hello'), (2, 'world');
حالا دو Session باز میکنیم (مثلاً دو پنجرهٔ psql) تا تراکنشها همزمان اجرا شوند.
BEGIN;
UPDATE mvcctest SET val = 'HELLO' WHERE id = 1;
id=1 ایجاد شده است با xmin برابر با شمارهٔ تراکنش فعلی.'hello') هنوز روی دیسک موجود است، اما xmax آن برابر با شمارهٔ تراکنش فعلی شده است؛ این نشان میدهد که این نسخه منسوخ شده است.SELECT * FROM mvcctest WHERE id = 1;
(۱, 'hello')میتوانیم فیلدهای سیستمی هر رکورد را هم ببینیم:
SELECT xmin, xmax, ctid, id, val FROM mvcctest;
احتمالاً چیزی شبیه این مشاهده میکنیم (اعداد فرضی هستند):
xmin | xmax | ctid | id | val
------+-----+-------+----+-------
1235 | ۱۲۳۷ | (۰,۱) | ۱ | hello
1236 | ۰ | (۰,۲) | ۲ | world
COMMIT;
SELECT * FROM mvcctest WHERE id = 1;
(۱, 'HELLO')'hello') هنوز روی دیسک موجود است، اما اکنون به یک dead tuple تبدیل شده است و منتظر vacuum است تا پاک شود.این آزمایش نشان میدهد که:
✅ خواندنها تحت تأثیر تراکنشهای همزمان قرار نمیگیرند.
✅ هر تراکنش یک نمای ثابت از دادهها (snapshot) دارد.
✅ PostgreSQL با MVCC بدون قفلگذاری سنگین، همزمانی بالا را مدیریت میکند.
وقتی رکوردها را مکرراً بهروزرسانی یا حذف میکنیم، نسخههای قدیمی (dead tuples) روی دیسک باقی میمانند. این موضوع دو مشکل ایجاد میکند:
مثلاً بعد از چند بهروزرسانی، میتوان وضعیت جدول را بررسی کرد:
SELECT relname, n_live_tup, n_dead_tup
FROM pg_stat_user_tables
WHERE relname = 'mvcctest';
VACUUM فرایندی است که:
VACUUM mvcctest;
n_dead_tup را بررسی میکنیم:SELECT n_dead_tup FROM pg_stat_user_tables WHERE relname = 'mvcctest';
اگر بخواهیم واقعاً فضای دیسک را به سیستمعامل برگردانیم:
VACUUM FULL mvcctest;

PostgreSQL دارای یک فرایند پسزمینه به نام autovacuum است که بهطور خودکار جداول را نظارت و در صورت نیاز VACUUM میکند.
ALTER TABLE mvcctest SET (autovacuum_vacuum_scale_factor = 0.05);
شناسهٔ تراکنش (XID) در PostgreSQL یک عدد ۳۲ بیتی است، یعنی حداکثر حدود ۴ میلیارد تراکنش قابل شمارش است.
برای بررسی سن تراکنشها در پایگاه داده:
SELECT datname, age(datfrozenxid) FROM pg_database;
میتوان تمام مراحل حیات یک رکورد در PostgreSQL را به ترتیب دید:
xmin = XID تراکنش جاری و xmax = 0 ایجاد میشود.xmin جدید ایجاد میشود.xmax = XID تراکنش جاری میگیرد و به dead tuple تبدیل میشود.xmax = XID تراکنش جاری میگیرد و dead tuple میشود.xmin به مقدار ثابت ۲ (Frozen XID) تغییر میکند.وقتی یک رکورد در PostgreSQL آپدیت میشود، مراحل زیر اتفاق میافتد:
این معماری باعث میشود PostgreSQL همزمانی بالا (MVCC) را حفظ کند، اما در عین حال نیاز به مدیریت منظم جدولها و ایندکسها برای جلوگیری از Bloat و I/O اضافی دارد.
MVCC قلب تپندهٔ PostgreSQL است:
VACUUM همتای ضروری MVCC است:
pg_stat_user_tables بر dead tuples نظارت کنید.datfrozenxid را چک کرده و در صورت نیاز VACUUM FREEZE دستی انجام دهید.MVCC و VACUUM دو روی یک سکه هستند:
درک درست این دو مفهوم، شما را به یک مدیر حرفهای PostgreSQL تبدیل میکند.
فیلم آموزشی مرتبط با این موضوع در قسمت زیر قابل مشاهده است. در این کارگاه عملی میتوانید بهصورت زنده و قدمبهقدم نحوهٔ عملکرد MVCC و VACUUM را در PostgreSQL ببینید، از ایجاد نسخههای جدید رکورد گرفته تا dead tuples و اجرای VACUUM و VACUUM FULL. این ویدئو کمک میکند تا مفاهیم تئوری به شکل عملی و ملموس درک شوند.