خطای "cannot execute CREATE TABLE in a-read-only transaction" در PostgreSQL اکنون باعث بروز مشکل در فرآیند مهاجرت (migration) بسیاری از تیم‌ها شده است و منجر به اعمال ناقص طرحواره‌ها (schemas) و قفل شدن جداول تاریخچه مهاجرت می‌شود. این خطا معمولاً زمانی رخ می‌دهد که یک عملیات نوشتن (write) به جای گره اصلی (primary)، به یک replica ارسال شود و باعث متوقف شدن هر ابزاری می‌شود که به تغییرات طرحواره وابسته است—مانند Flyway، Liquibase، Django ORM و موارد مشابه.

چرا این خطا رخ می‌دهد

PostgreSQL تمام عملیات‌های نوشتن، تغییرات طرحواره و به‌روزرسانی توالی‌ها (sequences) را زمانی که یک تراکنش به عنوان read-only علامت‌گذاری شده باشد، غیرفعال می‌کند. رایج‌ترین حالت‌هایی که یک مهاجرت به این وضعیت دچار می‌شود عبارتند از:

  • Connection poolerها (مانند PgBouncer) که به‌طور ناخواسته اتصال مهاجرت را به یک replica ارسال می‌کنند.
  • نقش‌ها (Roles) که پارامتر default_transaction_read_only در آن‌ها به‌صورت پیش‌فرض روی on تنظیم شده است.
  • نقاط انتهایی ابری (Cloud endpoints) که آدرس‌های جداگانه‌ای برای خواندن (reader) و نوشتن (writer) ارائه می‌دهند؛ استفاده از آدرس reader (که در AWS RDS یا Aurora رایج است) باعث هدایت عملیات نوشتن به یک replica می‌شود.

وقتی هر یک از این شرایط برقرار باشد، فرآیند مهاجرت ممکن است جداول را ایجاد کند، ستون‌ها را اضافه کند یا توالی‌ها را به‌روزرسانی کند، اما در نهایت با بن‌بست مواجه شده و پایگاه داده را در وضعیت مهاجرتِ نیمه‌تمام رها کند.

راه حل فوری در داخل تراکنش

اگر با این خطا مواجه شده‌اید، می‌توانید پرچم read-only را برای تراکنش فعلی بدون تأثیر بر بقیه نشست (session) تغییر دهید—این کار زمانی که یک connection pool از همان session برای کارهای دیگر استفاده می‌کند، بسیار حیاتی است.

BEGIN;
SET LOCAL default_transaction_read_only = off;
SET TRANSACTION READ WRITE;
CREATE TABLE orders (id SERIAL PRIMARY KEY, total NUMERIC);
COMMIT;

دستور SET LOCAL تنظیمات را فقط برای مدت زمان تراکنش تغییر می‌دهد. استفاده از SET معمولی، تغییر را برای کل session اعمال می‌کند که می‌تواند سایر عملیاتی را که به‌طور قانونی به حالت پیش‌فرض read-only نیاز دارند، مختل کند.

اقدامات پیشگیرانه

۱. تأیید کنید که روی گره اصلی (primary node) هستید

قبل از اجرای هرگونه مهاجرت، یک بررسی سریع اضافه کنید:

SELECT CASE WHEN pg_is_in_recovery() THEN 'REPLICA' ELSE 'PRIMARY' END;

اگر نتیجه REPLICA بود، مهاجرت را متوقف کنید. تابع pg_is_in_recovery() در یک سرور standby مقدار true را برمی‌گرداند که تضمین می‌کند سعی نمی‌کنید روی یک کپیِ فقط خواندنی بنویسید.

۲. استفاده از نقش‌های اختصاصی برای مهاجرت

نقشی ایجاد کنید که حالت پیش‌فرض تراکنش آن قابلیت نوشتن داشته باشد و فقط مجوزهای مورد نیاز را به آن بدهید:

  • CREATE روی پایگاه داده هدف.
  • CONNECT برای اجازه دادن به ابزار مهاجرت جهت باز کردن یک session.

از دادن ویژگی default_transaction_read_only = on به این نقش خودداری کنید؛ ویژگی‌ای که گاهی برای کاربران عمومی تنظیم می‌شود.

۳. هدایت زیرساخت به سمت نقطه انتهایی نویسنده (writer endpoint)

در اسکریپت‌های IaC (مانند Terraform، CloudFormation و غیره)، اجراکننده‌های مهاجرت را طوری پیکربندی کنید که از نقطه انتهایی writer کلاستر استفاده کنند، نه از endpoint reader. نقطه انتهایی writer به گره اصلی اشاره می‌کند، در حالی که endpoint reader به یک replica اشاره می‌کند که عملیات نوشتن را رد خواهد کرد.

۴. افزودن یک مرحله کنترل در CI/CD

یک مرحله shell در خط لوله (pipeline) خود (مانند GitHub Actions، GitLab CI و غیره) اضافه کنید که پرس‌وجوی (query) pg_is_in_recovery() را اجرا کند. اگر مقدار آن true بود، با یک وضعیت غیر صفر (non-zero status) از کار خارج شوید تا استقرار (deployment) زودتر متوقف شود.

if psql $DATABASE_URL -c "SELECT pg_is_in_recovery()" | grep -q t; then
  echo "Connected to replica – aborting migration"
  exit 1
fi

۵. تنظیم مجدد تلاش‌های مجدد (retries) ابزار مهاجرت

ابزارهایی مانند Flyway اغلب به‌طور خودکار برای اتصال مجدد تلاش می‌کنند. برای Flyway نسخه ۹ به بالا، مقدار flyway.connectRetries=0 را تنظیم کنید. این کار از تلاش مکرر ابزار برای اتصال به یک replica جلوگیری می‌کند، که در غیر این صورت می‌تواند باعث افزایش تأخیر در تکثیر (replication lag) و هدر رفتن منابع شود.

موارد بعدی که باید زیر نظر بگیرید

  • معیارهای تأخیر تکثیر (Replication lag metrics): افزایش تأخیر ممکن است نشان‌دهنده این باشد که یک مهاجرت به‌طور ناخواسته یک replica را هدف قرار داده و باعث شده تلاش‌های نوشتن در صف قرار بگیرند.
  • قوانین مسیریابی connection-pooler: اطمینان حاصل کنید که پیکربندی‌های pooler به‌طور صریح ترافیک مهاجرت را به میزبان اصلی (primary host) هدایت می‌کنند.
  • مقادیر پیش‌فرض نقش‌ها پس از ارتقا: ارتقای پایگاه داده گاهی اوقات پارامترهای نقش را بازنشانی می‌کند؛ پس از تغییرات نسخه‌های اصلی، مجدداً default_transaction_read_only را بازبینی کنید.

خلاصه کلام: خطای تراکنش read-only به‌ندرت یک باگ در PostgreSQL است؛ بلکه نشانه‌ای از ارسال ترافیک به گره اشتباه یا پیکربندی نادرست یک نقش است. با بررسی نقش گره، استفاده از حساب‌های اختصاصی مهاجرت و مقاوم‌سازی خط لوله CI/CD، می‌توانید مهاجرت‌ها را به آرامی اجرا کنید و از اعمال ناقص طرحواره‌ها که توسعه مراحل بعدی را مختل می‌کند، جلوگیری کنید.