استریمهای Node.js و باگ خاموش نشتی حافظه
خلاصهٔ کاملتر
یه سرویس export که ردیفهای دیتابیس رو به CSV تبدیل میکنه و روی HTTP response میفرسته — کد تمیزه، تست داره، شش ماهه توی production داره اجرا میشه. تا اینکه یه مشتری جدید اومد که هر روز صبح ۲ میلیون ردیف export میکنه. پادها به مرز حافظه میرسن و توسط Kubernetes کشته میشن. مشکل؟ backpressure هرگز رعایت نشده بود.
backpressure یه پروتکل تعاملیه در Node.js: consumer میتونه به producer بگه «کند شو». ولی این فقط یه پیشنهاده — اگه producer نادیدهاش بگیره، هیچچیزی جلوش رو نمیگیره. Node.js بیصدا داده رو بافر میکنه تا heap پر بشه و پروسه بمیره. استریمها خودشون از نشتی حافظه جلوگیری نمیکنن؛ ابزارش رو میدن که تو ازش استفاده کنی.
مفهوم highWaterMark رو خیلیها اشتباه میفهمن. این یه آستانه مشورتیه، نه سقف سخت حافظه. وقتی بافر داخلی به این مقدار رسید، متد .write() مقدار false برمیگردونه — نه exception، نه pause خودکار. اگه false رو نادیده بگیری و به نوشتن ادامه بدی، Node.js همچنان همه چیز رو در یه صف داخلی ذخیره میکنه که بدون محدودیت رشد میکنه. الگوی شکسته این شکلیه:
// Broken: ignores backpressure entirely
for await (const row of databaseCursor) {
const csv = formatRowAsCSV(row);
writable.write(csv); // return value? what return value?
}و درستش با چهار خط اضافه حل میشه — بررسی مقدار بازگشتی و صبر برای رویداد drain:
import { once } from "node:events";
// Fixed: respects backpressure
for await (const row of databaseCursor) {
const csv = formatRowAsCSV(row);
const ok = writable.write(csv);
if (!ok) await once(writable, "drain");
}یه نکته مهم درباره Node.js 22: مقدار پیشفرض highWaterMark از ۱۶KB به ۶۴KB افزایش پیدا کرده. یعنی قبل از اینکه اولین سیگنال backpressure صادر بشه، ۴ برابر بیشتر داده بافر میشه. توی container های محدود (مثلاً ۲۵۶MB یا ۵۱۲MB) این تفاوت خیلی مهمه.
Transform streams یه دام پنهان دارن: دو طرف مستقل دارن (readable و writable) با highWaterMark جداگانه. وقتی در objectMode کار میکنی، highWaterMark به جای بایت تعداد object رو میشماره — پیشفرضش ۱۶ objectه. اگه هر object 50KB باشه، یعنی ۸۰۰KB بافر قبل از هر سیگنال. راهحل: تنظیم صریح و جداگانه برای هر طرف با writableHighWaterMark و readableHighWaterMark.
درباره .pipe() هم باید صادق بود: این API قدیمی backpressure رو مدیریت میکنه، ولی خطاها رو propagate نمیکنه. اگه یه transform در وسط زنجیر throw کنه، stream های دیگه باز میمونن، file descriptor ها leak میکنن و socket ها آویزون میمونن. جایگزین مدرن pipeline() از node:stream/promises هست که از Node 15 موجوده و در صورت خطا همه stream ها رو destroy میکنه:
import { pipeline } from "node:stream/promises";
// Fixed: all streams destroyed on any failure
await pipeline(readStream, transformStream, writeStream);یه اشتباه رایج دیگه هم اینه که فکر میکنیم async/await جریان داده رو کنترل میکنه. for await...of فقط سرعت خواندن رو تنظیم میکنه، نه نوشتن. اگه writable.write() رو داخل حلقه بدون بررسی false صدا بزنی، همون مشکل بافر بیانتها برمیگرده — فقط اینبار با syntax مدرنتر که گولزنندهتره.
نکات کلیدی:
- backpressure یه پروتکل دستیه، Node.js بهصورت خودکار جلوی سرریز بافر رو نمیگیره
- مقدار بازگشتی writable.write() رو همیشه بررسی کن؛ اگه false بود، منتظر رویداد drain بمون
- highWaterMark سقف حافظه نیست، فقط آستانهایه که سیگنال false رو trigger میکنه
- در Node.js 22 این آستانه از ۱۶KB به ۶۴KB رسیده — توی container های محدود مراقب باش
- در objectMode، highWaterMark بایت نمیشماره؛ object میشماره — پیشفرض ۱۶ objectه
- Transform streams دو طرف مستقل دارن؛ هر دو رو با writableHighWaterMark و readableHighWaterMark تنظیم کن
- بهجای .pipe()، از pipeline() در node:stream/promises استفاده کن
- async/await زمانبندی اجرا رو مدیریت میکنه، نه حجم داده رو؛ این دو مفهوم جداگانهان




