یک فایل تنظیمات که تا پنج ثانیه پیش درست کار میکرد، حالا به یک قطعه ۳۴۷ بایتی از JSON خراب تبدیل شده است. ابزار CLI شما اجرا نخواهد شد. کاربری که صرفاً چون بهروزرسانی بیش از حد انتظار طول کشیده بود، کلید Ctrl-C را فشار داده، حالا با یک stack trace خطا مواجه شده که اصلاً انتظارش را نداشت. آن دو کیلوبایت تنظیمات معتبری که قبل از عملیات نوشتن وجود داشت، از بین رفته و جای خود را به معادل دیجیتالی یک رسید نیمهچاپشده داده است.
این اتفاق به این دلیل میافتد که writeFile اتمیک (atomic) نیست. این متد مسیر موجود را باز میکند، آن را کوتاه (truncate) میکند، دادهها را از Node به page cache هسته (kernel) میفرستد و در نهایت file descriptor را میبندد. لحظهای که عملیات truncate انجام میشود، محتوای قدیمی از قبل از بین رفته است. هر چیزی که بین آن truncate و close نهایی قرار دارد، یک پنجره آسیبپذیری است. یک سیگنال SIGINT، قطع برق، یا حتی بسته شدن ناگهانی درِ لپتاپ در طول این پنجره، باعث میشود سیستم فایل با یک آشفتگی ناقص (truncated mess) رها شود. حتی اگر Node گزارش دهد که Promise حل (resolved) شده است، ممکن است سیستمعامل همچنان در حال بافر کردن نوشتهها در حافظه باشد. متدهای آماده (convenience methods) این شکاف را پنهان میکنند، اما آن را از بین نمیبرند.
راه حل این نیست که مستقیماً روی فایل اصلی بنویسید. راه حل این است که عمل نوشتن را از عمل انتشار (publishing) جدا کنید.
نوشتن در یک فایل همرده، سپس جایگزینی
این الگوی قابل اعتماد دارای پنج مرحله است. هیچکدام پیچیده نیستند، اما در کنار هم، پنجره شکست (failure window) را از کل فرآیند نوشتن جریانی (streaming write) به یک عملیات واحد در متادیتای سیستم فایل کاهش میدهند.
اول، کل محتوا (payload) را در حافظه سریالایز کنید. این کار را قبل از ایجاد هرگونه فایل موقت انجام دهید. اگر JSON.stringify به دلیل ارسال یک شیء حلقوی (circular object) خطا داد، میخواهید آن استثنا (exception) قبل از اینکه با دیسک درگیر شوید، به بالا منتقل شود.
دوم، دادههای سریالایز شده را در یک فایل موقت که در همان دایرکتوری هدف قرار دارد، بنویسید. از یک نام تصادفی استفاده کنید تا دو اجرای همزمان با هم تداخل نداشته باشند. نگه داشتن فایل موقت در همان دایرکتوری اهمیت دارد، زیرا عملیات rename تنها در یک سیستم فایل واحد اتمیک است. اگر فایل موقت شما در پارتیشن دیگری باشد، سیستمعامل به سراغ توالی «کپی و حذف» میرود که خود باعث ایجاد حالتهای شکست جدید میشود و دیگر اتمیک نخواهد بود.
سوم، از هسته (kernel) بخواهید آن فایل موقت را در حافظه فیزیکی ذخیره (flush) کند. متد fsync در Node، که در اینجا به عنوان متد sync روی یک filehandle ارائه شده است، تا زمانی که بافرها روی سختافزار نوشته نشوند، برنامه را متوقف (block) میکند. این کار کند است، اما نوشتن تنظیمات آنقدر کم اتفاق میافتد که پایداری (durability) حاصل، ارزش این چند میلیثانیه را دارد.
چهارم، فایل موقت را با نام مسیر اصلی تغییر نام دهید (rename). هم در سیستمهای POSIX و هم در Windows، این نقطه نهایی (commit point) است. خوانندگانی که مسیر اصلی را باز میکنند، یا فایل قدیمی کامل را میبینند یا فایل جدید کامل را. هیچ لحظهای وجود ندارد که یک خواننده بتواند مسیر را باز کند و با یک بافر نیمهنوشته مواجه شود.
پنجم، دایرکتوری والد را همگامسازی (sync) کنید. این کار یک مورد خاص و ظریف را پوشش میدهد. عملیات rename ورودی دایرکتوری را بهروز میکند، اما خودِ متادیتای دایرکتوری ممکن است در page cache هسته باقی مانده باشد. قطع ناگهانی برق پس از یک rename موفق، گاهی اوقات میتواند سیستم فایل را در حالتی رها کند که ارجاع inode جدید هرگز بهطور پایدار ثبت نشده باشد. همگامسازی دایرکتوری، آن بهروزرسانی متادیتا را به دیسک میفرستد و تراکنش را نهایی میکند.
یک پیادهسازی عینی در Node.js
در اینجا نحوه پیادهسازی این الگو در عمل، تنها با استفاده از کتابخانه استاندارد Node.js آمده است:
import { open, rename, rm } from "node:fs/promises";
import { dirname, basename, join } from "node:path";
import { randomUUID } from "node:crypto";
export async function writeJsonAtomic(path, value) {
const directory = dirname(path);
const temporary = join(directory, `.${basename(path)}.${randomUUID()}.tmp`);
const body = `${JSON.stringify(value, null, 2)}\n`;
let handle;
try {
handle = await open(temporary, "wx", 0o600);
await handle.writeFile(body, "utf8");
await handle.sync();
await handle.close();
handle = undefined;
await rename(temporary, path);
const directoryHandle = await open(directory, "r");
try {
await directoryHandle.sync();
} finally {
await directoryHandle.close();
}
} catch (error) {
if (handle) await handle.close().catch(() => {});
await rm(temporary, { force: true }).catch(() => {});
throw error;
}
}
چند نکته در این کد شایان توجه است.
پرچم wx به معنای «نوشتن، اما اگر فایل از قبل وجود داشت، خطا بده» است. این کار از تداخل UUID یا فایلهای موقت رها شده از فرآیندهای کرشکرده قبلی جلوگیری میکند. اگر کسی یک فایل مخرب را در جایی که فایل موقت شما باید باشد قرار داده باشد، شما بلافاصله از آن مطلع میشوید، به جای اینکه هر چیزی که آنجاست را بازنویسی کنید.
ماسک مجوز 0o600 فایل موقت را فقط با دسترسی خواندن و نوشتن برای مالک ایجاد میکند. فایلهای تنظیمات اغلب حاوی اسرار، توکنهای API یا URLهای مخازن خصوصی هستند. دلیلی ندارد که اجازه دهید سایر کاربران سیستم در حین آمادهسازی فایل موقت، نگاهی به آن بیندازند.
به فراخوانیهای جداگانه sync روی فایل و سپس روی دایرکتوری دقت کنید. بسیاری از توسعهدهندگان از همگامسازی دایرکتوری صرفنظر میکنند چون آن را زائد میدانند. اما اینطور نیست. سیستمهای Ext4، APFS و NTFS همگی بهروزرسانیهای دایرکتوری را متفاوت مدیریت میکنند، اما همگی عادت مشترکی در دستهای (batching) نوشتن متادیتا برای بهبود عملکرد دارند. اگر برای شما مهم است که در برابر قطع برق مقاوم باشید، همگامسازی دایرکتوری مهر نهایی تأیید است.
عملیات پاکسازی در بلاک catch به صورت عمدی دفاعی طراحی شده است. اگر پس از باز شدن هندل فایل، خطایی رخ دهد، کد تلاش میکند هندل را ببندد و فایل موقت را حذف کند و هرگونه خطای ثانویه را نادیده میگیرد تا خطای اصلی به درستی منتشر شود. شما نمیخواهید یک خطای دسترسی (permission error) در حین پاکسازی، باگ واقعی که باعث بروز خطا شده را پنهان کند.
جایی که این الگو دیگر کمکی نمیکند
جایگزینی اتمیک فایل از «نوشتنهای ناقص» (torn writes) جلوگیری میکند، اما از «آپدیتهای از دست رفته» (lost updates) جلوگیری نمیکند. اگر دو نمونه از CLI شما بهطور همزمان یک فایل تنظیمات (config) یکسان را بخوانند، هر دو ویرایشهای خود را در حافظه انجام دهند، هر دو فایلهای موقت جدیدی بنویسند و هر دو عملیات تغییر نام (rename) خود را اجرا کنند، تغییر نام دوم پیروز خواهد شد. فرآیند اول متوجه تغییرات فرآیند دوم نشده است. بسته به نوع اپلیکیشن شما، این میتواند به این معنا باشد که یک کاربر در یک ترمینال تنظیماتی را اضافه میکند و کاربر دیگری در ترمینالی دیگر آن را حذف میکند، در حالی که فایل نهایی فقط تغییرات آخرین نویسنده را منعکس میکند.
اگر ابزار شما نیاز به پشتیبانی از تغییردهندههای همزمان (concurrent mutators) دارد، باید یک مکانیزم هماهنگی فراتر از نوشتن اتمیک داشته باشید. یک فایل قفل مشورتی (advisory lock file) برای موارد ساده پاسخگو است. استفاده از بردارهای نسخه (version vectors) یا یک شماره بازبینی یکنواخت (monotonic revision number) در خودِ فایل تنظیمات میتواند به تشخیص تداخلها کمک کند تا نویسنده دوم بتواند دوباره تلاش کند. این موارد باعث پیچیدگی میشوند و پیچیدگی همان جایی است که باگها در آن پنهان میشوند.
به همین دلیل است که تعیین مرز اهمیت دارد. یک بلاک JSON واحد که یک فرآیند گهگاه آن را بهروزرسانی میکند، کاندیدای مناسبی برای نوشتن اتمیک فایل است. اما زمانی که خود را درگیر مدیریت چندین رکورد، اعمال طرحوارهها (schemas) یا نگرانی در مورد تغییرات همزمان میبینید، یعنی از محدودیتهای سیستم فایل فراتر رفتهاید. SQLite دقیقاً به همین دلیل وجود دارد. این ابزار تراکنشهای اتمیک، ژورنالهای بازگشت (rollback journals) و مدیریت صحیح خوانندگان و نویسندگان همزمان را، همگی در قالب یک فایل محلی واحد در اختیار شما قرار میدهد. یک پروتکل فایل هوشمند، پایگاه داده نیست و نباید بودجه نگهداری خود را صرف تظاهر به خلاف آن کنید.
نکته اصلی
دفعه بعد که در یک ابزار CLI سراغ استفاده از writeFile رفتید، کمی مکث کنید. بخش سخت کار، سریالسازی (serialization) نیست؛ بلکه ماندگاری (durability) است. فایلهای تنظیمات برای استریم کردن بسیار کوچک و برای کوتاه شدن (truncate) بسیار مهم هستند. کل محتوا را در یک فایل همردهی مخفی بنویسید، آن را flush کنید، با یک rename تثبیت (commit) کنید و به دایرکتوری اطلاع دهید. کاربران شما ممکن است Ctrl-C را فشار دهند، کابل برق را بکشند یا در لپتاپ خود را ببندند. وقتی دستگاه دوباره روشن شود، فایل یا شامل دنیای قدیمی خواهد بود یا دنیای جدید. هیچ حالت میانی وجود نخواهد داشت.
