عکسی که عوض شد ولی هیچکس عکس جدید را ندید
تصویر محصول را با همان نام فایل عوض کردیم، اما کش یکساله و immutable عکس قدیمی را نگه داشت. علت، قرارداد immutable و راهحل نامگذاری با hash.
برای سریعتر شدن فروشگاه، فایلهای تصویر محصول را با Cache-Control: immutable و عمر یکساله سرو کردیم. تا اینجا تصمیم درستی بود. اشتباه ما این بود که بعداً عکس یکی از محصولات را عوض کردیم و نام فایل را همانطور نگه داشتیم. فایل روی سرور تازه بود، ولی بینندهها هنوز عکس قدیمی را میدیدند. در این یادداشت مینویسم چه دیدیم، چرا پاکسازی کش CDN کافی نبود و چرا مشکل از جنس همان بهینهسازی بود که خودمان اضافه کرده بودیم.
ماجرا و علامتها
تصاویر محصول را برای بهبود سرعت با سربرگ Cache-Control و مقدار immutable و max-age یکساله سرو میکردیم. سرعت فروشگاه و کمکردن درخواستهای تکراری هدف بود و از این نظر هم جواب داد: مرورگرها و CDN تصویر را نگه میداشتند و به سرور مبدأ سر نمیزدند.
بعد عکس یکی از محصولات را عوض کردیم. فایل جدید را با همان نام فایل قبلی جایگزین کردیم، چون در فروشگاه اینترنتی آدرس تصویر در دادهی محصول ثبت شده بود و عوضکردن فقط محتوای فایل سادهترین کار به نظر میرسید.
علامت مشکل این بود که فایل روی سرور درست و جدید بود، اما CDN و مرورگرها تا مدتها همان عکس قدیمی را نشان میدادند. یعنی «سرور چیز درستی دارد» و «کاربر چیز درستی میبیند» دو جملهی جدا شده بودند. نکتهی بدتر این بود که هر بینندهای بسته به اینکه قبلاً آن عکس را دیده بود یا نه، نتیجهی متفاوتی میدید؛ کسی که قبلاً صفحه را باز نکرده بود عکس جدید را میدید و کسی که کرده بود، عکس قدیمی را.
نکات کلیدی
- فایل روی سرور مبدأ درست بود ولی کاربران عکس قدیمی را میدیدند
- نام فایل ثابت ماند و فقط محتوا عوض شد
- نتیجه برای هر بیننده بسته به سابقهی کش مرورگرش فرق میکرد
ردگیری علت
اولین مظنون طبیعی CDN بود: لایهای که بین سرور و کاربر نسخهی قدیمی را نگه میداشت. پاکسازی کش CDN کار درست و لازمی بود، ولی مشکل را برای کسانی که قبلاً عکس را دیده بودند حل نمیکرد.
دلیلش این است که پاکسازی کش CDN فقط نسخهی ذخیرهشده روی خود CDN را حذف میکند. نسخهای که در مرورگر کاربر ذخیره شده، از دید ما قابل دسترسی نیست. هیچ فرمانی از سمت سرور نمیتواند کش مرورگرِ بینندههای ناشناس را پاک کند. مستندات MDN هم صریحاً میگوید راهی برای حذف پاسخهایی که با max-age طولانی روی سرورهای میانی یا کلاینت ذخیره شدهاند وجود ندارد.
پس بعد از پاکسازی CDN، سرور و CDN هر دو نسخهی تازه را داشتند، اما مرورگرهایی که فایل قدیمی را نگه داشته بودند اصلاً درخواستی نمیفرستادند تا نسخهی تازه را بگیرند. وقتی این را کنار سربرگهایی که خودمان گذاشته بودیم قرار دادیم، تصویر روشن شد: مشکل باگ نبود، همان رفتاری بود که از مرورگر خواسته بودیم.
نکات کلیدی
- پاکسازی کش CDN فقط لایهی CDN را تازه میکند
- کش مرورگر کاربر از سمت سرور قابل پاککردن نیست
علت اصلی: immutable یک قرارداد است
طبق RFC 8246، دایرکتیو immutable به کلاینت میگوید سرور محتوای این پاسخ را در طول دورهی تازگی (freshness lifetime) تغییر نمیدهد. به همین دلیل کلاینت نباید حتی هنگام reload معمولی درخواست شرطی (conditional) بفرستد. مثال خود RFC همین است: max-age=31536000 همراه با immutable.
بدون immutable، مرورگرها معمولاً هنگام reload منبع تازه را هم دوباره با سرور چک میکردند (If-None-Match و If-Modified-Since) و اگر چیزی عوض نشده بود، پاسخ ۳۰۴ میگرفتند. همین چک ارزان، در عمل یک شبکهی ایمنی بود: اگر فایل عوض شده بود، reload آن را میگرفت. immutable دقیقاً این شبکه را برمیدارد. طبق همان RFC فقط بازنگری اجباری کاربر (force reload) میتواند آن را رد کند، و آن را هم نمیتوان از کاربران عادی انتظار داشت.
پس بحث بر سر یک قرارداد است. با نوشتن immutable و max-age یکساله، به تمام لایههای کش قول دادهایم که محتوای این آدرس تا یک سال عوض نمیشود. ما با تعویض عکسِ همان آدرس، به این قول عمل نکردیم. کشها هم کاری جز اعتماد به حرف ما نکرده بودند.
در مدل صحیح، محتوا و آدرس با هم تغییر میکنند. این همان الگوی «versioned URL» یا cache busting است که هم RFC 8246 و هم راهنمای کشینگ MDN به آن اشاره میکنند: هر وقت محتوا عوض شد، آدرس هم عوض شود تا لازم نباشد کسی کش را پاک کند. نکتهی دیگر از RFC 9111: تازگی فقط به کار کش میآید و نمیتواند کاربر را مجبور به بارگذاری مجدد کند. سرور بعد از صدور پاسخ دیگر اختیاری روی آن ندارد.
نکات کلیدی
- immutable یعنی «این آدرس تا پایان عمر کش عوض نمیشود»
- با immutable، reload معمولی هم درخواست شرطی نمیفرستد
- سرور بعد از صدور پاسخ نمیتواند کش مرورگر را باطل کند
راهحل: نام فایل وابسته به محتوا
راهحل ما دو قاعده شد: هر تغییر در تصویر یعنی نام فایل جدید (با hash محتوا یا شمارهی نسخه در نام)، و immutable فقط برای فایلهایی که نامشان به محتوا وابسته است.
در عمل هنگام ذخیرهی تصویر، یک hash کوتاه از محتوای فایل ساخته و در نام میگذاریم. اگر عکس عوض شود، hash عوض میشود و آدرس جدید هیچ سابقهای در هیچ کشی ندارد؛ بدون هیچ پاکسازی. آدرس قدیمی هم میتواند تا هر زمانی در کش بماند و ضرری ندارد. این الگو را میشود بهراحتی به پردازش دستهای تصاویر محصول هم وصل کرد، چون همانجا فایل خروجی ساخته میشود.
نمونهی ساده (عمومی) از ساخت نام با hash در Node.js:
import { createHash } from 'node:crypto';
import { readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
export async function saveImage(srcPath, outDir) {
const buf = await readFile(srcPath);
const hash = createHash('sha256').update(buf).digest('hex').slice(0, 10);
const { name, ext } = path.parse(srcPath);
const fileName = `${name}.${hash}${ext}`; // frame-01.a1b2c3d4e5.webp
await writeFile(path.join(outDir, fileName), buf);
return fileName; // این نام را در دیتابیس ذخیره کنید
}
/*
nginx — نمونهی عمومی
location /media/ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
*/نکات کلیدی
- تغییر تصویر = نام فایل جدید، نه جایگزینی فایل قدیمی
- immutable فقط روی نامهای وابسته به محتوا
- پس از تغییر، آدرس جدید را در دادهی محصول ذخیره کنید
درسها و چکلیست
نتیجهی اصلی این است که immutable یک بهینهسازی بیخطر نیست؛ تعهدی است که به طراحی آدرسها برمیگردد. اگر سیاست کش در لایهی وبسرور و سیاست نامگذاری فایلها در لایهی اپلیکیشن جدا از هم تصمیم گرفته شوند، قرارداد بین این دو لایه خودش شکسته میشود. به همین دلیل در طراحی فروشگاه اینترنتی نامگذاری مدیا را جزو معماری حساب میکنیم، نه جزئیات پیادهسازی. برای نگاه کلیتر به کش در لایههای مختلف هم کش دولایه با Nginx و Redis را ببینید.
چکلیستی که از این ماجرا برای خودمان نگه داشتیم:
۱. قبل از گذاشتن immutable بپرسید: آیا نام این فایل واقعاً با محتوایش تغییر میکند؟ اگر نه، immutable نگذارید.
۲. برای فایلهایی که نامشان ثابت است (مثلاً صفحهی HTML)، از no-cache بههمراه validator استفاده کنید تا هر بار چک شوند.
۳. هیچوقت فایل منتشرشده با immutable را دوباره با همان نام ننویسید. حتی اگر «فقط یک اصلاح کوچک» باشد.
۴. پاکسازی کش CDN را راهحل نهایی ندانید؛ فقط یک لایه را تازه میکند.
۵. بهروزرسانی عکس باید فرایندی باشد که نام جدید میسازد و مرجع در دیتابیس را هم عوض میکند؛ نه کپیکردن دستی روی فایل قبلی.
۶. اگر سیستم شما تصویر را در جاهای دیگری هم ارجاع میدهد (مثلاً در خروجیها یا نقشهی سایت)، آن مراجع را هم با نام جدید بهروز کنید.
نکات کلیدی
- immutable فقط برای نامهای وابسته به محتوا
- تغییر تصویر همیشه با نام جدید
- پاکسازی CDN جای نامگذاری درست را نمیگیرد
جمعبندی
immutable قرارداد است: با آن به همهی کشها میگویید محتوای این آدرس عوض نمیشود. اگر نام فایل ثابت بماند و محتوا عوض شود، آن قرارداد را شکستهاید و هیچ پاکسازیای کش مرورگر کاربران را درست نمیکند. راه امن این است که هر تغییر تصویر، نام فایل جدید بسازد، و immutable فقط برای فایلهایی باشد که نامشان به محتوا وابسته است.
منابع
- RFC 8246: HTTP Immutable Responsesdatatracker.ietf.org
- RFC 9111: HTTP Cachingrfc-editor.org
- Using Immutable Caching To Speed Up The Web (Mozilla Hacks)hacks.mozilla.org
- HTTP caching (MDN)developer.mozilla.org