Command Palette

Search for a command to run...

[Spring Boot Basics] Logging trong Spring Boot: SLF4J, Logback, log level và ghi log ra file

Mọi application Spring Boot đều ghi log ngay từ giây đầu tiên. Banner vừa trôi qua là Starting DemoApplication xuất hiện, tiếp theo là Tomcat và một dòng Started. Bạn không thêm dependency logging nào, cũng không viết dòng config nào cho chuyện đó: spring-boot-starter-webmvc đã kéo theo nguyên một stack logging, và Boot cấu hình xong trước khi bean đầu tiên của bạn tồn tại.

Bài này tháo rời stack đó. Jar nào là API và jar nào thực sự ghi log ra, log từ những thư viện dùng API khác đi vào cùng một luồng bằng cách nào, log level và cây logger quyết định dòng nào được in, từng phần của dòng log mặc định nghĩa là gì, cách ghi ra file có xoay vòng, và khi nào bạn cần một file logback-spring.xml của riêng mình. Đây là bài cuối của Chương 2.

Ba logging API đổ về một Logback duy nhất, ghi ra console và file log

Mọi thứ bên dưới chạy trên OpenJDK 21.0.6 với Spring Boot 4.1.1 (Spring Framework 7.0.9, Logback 1.5.38, SLF4J 2.0.18) và Gradle 9.7.1, trên project sinh bởi Spring Initializr với dependencies=web. Mỗi lần chạy truyền các property logging.* dưới dạng argument -- trên command line, thứ mà Boot xử lý y hệt như khi đặt trong application.properties, và mọi dòng output đều copy từ chính những lần chạy đó.

SLF4J là API, Logback là thứ ghi log ra

Class của bạn không bao giờ gọi Logback. Chúng gọi SLF4J (Simple Logging Facade for Java), một API nhỏ — Logger, LoggerFactory, MDC — hoàn toàn không chứa logic ghi output. Lúc runtime, SLF4J tìm một provider trên classpath và chuyển mọi lời gọi cho nó. Trong application Spring Boot, provider đó là Logback, và Logback nắm mọi thứ quan trọng: level nào được bật, một dòng log được định dạng ra sao, và nó đi ra console, ra file hay cả hai.

Việc tách đôi này tồn tại vì các thư viện. Tomcat, Hibernate, một SDK thanh toán — thư viện nào cũng cần ghi log, nhưng không thư viện nào biết được application dùng nó đã chọn Logback, Log4j2 hay thứ khác. Một thư viện gọi thẳng Logback sẽ ép Logback lên mọi người dùng và xung đột với application nào chọn khác. Thư viện chỉ compile với facade thì để quyền chọn lại cho application: implementation nào application đặt lên classpath thì implementation đó nhận output.

Code của bạn cũng nên theo đúng quy tắc này. Import org.slf4j.Logger, đừng bao giờ import ch.qos.logback.classic.Logger, và việc thay Logback sau này chỉ là sửa file build chứ không phải sửa code. Gần cuối bài sẽ làm đúng việc đó.

Các jar logging đã có sẵn trên classpath

./gradlew dependencies --configuration runtimeClasspath trên project vừa sinh, cắt còn phần logging của cây:

Text
\--- org.springframework.boot:spring-boot-starter-webmvc -> 4.1.1
     +--- org.springframework.boot:spring-boot-starter:4.1.1
     |    +--- org.springframework.boot:spring-boot-starter-logging:4.1.1
     |    |    +--- ch.qos.logback:logback-classic:1.5.38
     |    |    |    +--- ch.qos.logback:logback-core:1.5.38
     |    |    |    \--- org.slf4j:slf4j-api:2.0.17 -> 2.0.18
     |    |    +--- org.apache.logging.log4j:log4j-to-slf4j:2.25.5
     |    |    |    +--- org.apache.logging.log4j:log4j-api:2.25.5
     |    |    |    \--- org.slf4j:slf4j-api:2.0.17 -> 2.0.18
     |    |    \--- org.slf4j:jul-to-slf4j:2.0.18
     |    |         \--- org.slf4j:slf4j-api:2.0.18
     |    +--- org.springframework.boot:spring-boot-autoconfigure:4.1.1
     |    |    \--- org.springframework.boot:spring-boot:4.1.1
     |    |         +--- org.springframework:spring-core:7.0.9
     |    |         |    +--- commons-logging:commons-logging:1.3.5 -> 1.3.6

Bạn không bao giờ khai báo spring-boot-starter-logging. Nó đến qua spring-boot-starter, thứ mà mọi starter khác đều phụ thuộc. Vai trò của từng jar:

JarVai trò
slf4j-api 2.0.18facade: Logger, LoggerFactory, MDC
logback-classic 1.5.38provider của SLF4J: logger, level, pattern layout
logback-core 1.5.38appender, encoder và rolling policy
jul-to-slf4j 2.0.18bridge từ java.util.logging sang SLF4J
log4j-to-slf4j 2.25.5bridge từ Log4j API sang SLF4J
log4j-api 2.25.5chính Log4j API, để thư viện viết theo nó vẫn chạy được
commons-logging 1.3.6API mà các class của Spring Framework dùng để ghi log

Bridge đưa mọi logging API vào cùng một pipeline

Không phải thư viện nào cũng dùng SLF4J. Có thư viện dùng java.util.logging (JUL) của JDK, có thư viện dùng Log4j API, còn chính Spring Framework ghi log qua Apache Commons Logging. Nếu để mặc, mỗi API sẽ tự cấu hình riêng: format riêng, level riêng, nơi ghi riêng. Các bridge jar ngăn chuyện đó bằng cách implement API ngoại lai và chuyển mọi lời gọi vào SLF4J:

  • log4j-to-slf4j là một provider của Log4j API mà logger bên trong chính là logger của SLF4J.
  • jul-to-slf4j cung cấp SLF4JBridgeHandler, một JUL handler đẩy từng record sang SLF4J. Nó chỉ có tác dụng khi đã được gắn vào root logger của JUL, và Boot gắn nó trong lúc khởi động.
  • Commons Logging 1.3, thứ Spring Framework 7 phụ thuộc trực tiếp, không cần bridge jar. Nó tự phát hiện SLF4J trên classpath và dùng adapter có sẵn bên trong.

Để chứng minh, chỉ cần một ApplicationRunner ghi log qua cả bốn API:

BridgeDemo.java
@Component
class BridgeDemo implements ApplicationRunner {
 
    private static final org.slf4j.Logger slf4j =
            org.slf4j.LoggerFactory.getLogger(BridgeDemo.class);
    private static final java.util.logging.Logger jul =
            java.util.logging.Logger.getLogger("com.legacy.JulClient");
    private static final org.apache.logging.log4j.Logger log4j =
            org.apache.logging.log4j.LogManager.getLogger("com.legacy.Log4jClient");
    private static final org.apache.commons.logging.Log jcl =
            org.apache.commons.logging.LogFactory.getLog("com.legacy.JclClient");
 
    @Override
    public void run(ApplicationArguments args) {
        slf4j.info("hello from the SLF4J API");
        jul.info("hello from java.util.logging");
        log4j.info("hello from the Log4j API");
        jcl.info("hello from Commons Logging");
    }
}
Text
2026-09-11T14:42:37.888+07:00  INFO 46059 --- [demo] [           main] com.example.demo.BridgeDemo              : hello from the SLF4J API
2026-09-11T14:42:37.888+07:00  INFO 46059 --- [demo] [           main] com.legacy.JulClient                     : hello from java.util.logging
2026-09-11T14:42:37.888+07:00  INFO 46059 --- [demo] [           main] com.legacy.Log4jClient                   : hello from the Log4j API
2026-09-11T14:42:37.888+07:00  INFO 46059 --- [demo] [           main] com.legacy.JclClient                     : hello from Commons Logging

Bốn API, một format: cùng kiểu timestamp, cùng cột PID, cùng tên logger được đệm cho thẳng cột. Tự chạy một mình, JUL sẽ in mỗi record thành hai dòng theo một layout khác hẳn. In ra class đứng sau từng API ngay trong runner đó cho thấy ai đang làm việc:

Text
SLF4J ILoggerFactory : ch.qos.logback.classic.LoggerContext
Log4j API logger     : org.apache.logging.slf4j.SLF4JLogger
Commons Logging Log  : org.apache.commons.logging.impl.Slf4jLogFactory$Slf4jLocationAwareLog
JUL root handlers    : [org.slf4j.bridge.SLF4JBridgeHandler@773c0293]

Level cũng đi xuyên qua bridge. Thêm một lời gọi jul.fine(...) và một log4j.debug(...), rồi chạy với logging.level.com.legacy=DEBUG:

Text
2026-09-11T14:42:38.629+07:00 DEBUG 46063 --- [demo] [           main] com.legacy.JulClient                     : JUL fine
2026-09-11T14:42:38.629+07:00 DEBUG 46063 --- [demo] [           main] com.legacy.Log4jClient                   : Log4j debug

FINE của JUL đến nơi dưới dạng DEBUG, và jul.isLoggable(Level.FINE) đổi từ false ở lần chạy mặc định sang true ở lần này. Level bạn đặt qua Boot có hiệu lực ngay bên trong JUL, nên một lời gọi JUL đang bị tắt sẽ bị loại trước khi chạm tới bridge.

Code của bạn, Spring Framework và hai thư viện gọi bốn logging API, đi qua bridge vào slf4j-api và Logback, rồi ghi ra console và rolling file

Tạo logger trong class Spring Boot

Hai service dưới đây được dùng trong mọi demo từ đây trở đi. Logger của chúng viết theo dạng SLF4J thông thường:

src/main/java/com/example/demo/orders/OrderService.java
package com.example.demo.orders;
 
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
 
@Service
public class OrderService {
 
    private static final Logger log = LoggerFactory.getLogger(OrderService.class);
 
    public void place(String orderId) {
        log.trace("Validating order {}", orderId);
        log.debug("Reserving stock for order {}", orderId);
        log.info("Order {} placed", orderId);
    }
}
src/main/java/com/example/demo/billing/BillingService.java
package com.example.demo.billing;
 
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
 
@Service
public class BillingService {
 
    private static final Logger log = LoggerFactory.getLogger(BillingService.class);
 
    public void charge(String orderId, long amount) {
        log.debug("Calling payment gateway for order {}", orderId);
        log.info("Charged {} VND for order {}", amount, orderId);
        if (amount > 1_000_000) {
            log.warn("Large payment of {} VND for order {}", amount, orderId);
        }
    }
}

Có ba chi tiết đáng nắm:

  • Tên logger là tên đầy đủ của class. LoggerFactory.getLogger(BridgeDemo.class).getName() trả về com.example.demo.BridgeDemo ở lần chạy phía trên. Tên này không phải nhãn trang trí — nó là key mà mọi thiết lập level trong phần còn lại của bài sẽ so khớp.
  • private static final: mỗi class một logger, tạo ra khi class được load. Logger không phải Spring bean và không cần inject; LoggerFactory trả về đúng logger cũ cho cùng một tên.
  • Import từ org.slf4j. Auto-import của IDE vẫn sẵn lòng gợi ý java.util.logging.Logger hay org.apache.logging.log4j.Logger. Nhờ bridge chúng vẫn chạy, nhưng lại buộc class vào một API khác.

Annotation @Slf4j của Lombok sinh ra đúng field log này cho bạn; bản thân Lombok sẽ được nói ở Chương 7.

Parameterised logging thay cho nối chuỗi

Các method của SLF4J nhận message có placeholder {} và các giá trị truyền riêng. Khác biệt so với nối chuỗi không nằm ở phong cách, và đo được rất dễ. Cart bên dưới đếm số lần toString() bị gọi, còn checksum() đếm số lần chính nó chạy:

Java
static class Cart {
    static int toStringCalls = 0;
 
    @Override
    public String toString() {
        toStringCalls++;
        return "Cart[items=3, total=450000]";
    }
}
 
static int checksumCalls = 0;
 
static String checksum(Cart cart) {
    checksumCalls++;
    return "9f2c1a";
}
 
@Override
public void run(ApplicationArguments args) {
    Cart cart = new Cart();
    System.out.println("isDebugEnabled() = " + log.isDebugEnabled());
 
    log.debug("Cart contents: " + cart);
    System.out.println("concatenation      -> toString() calls: " + Cart.toStringCalls);
 
    log.debug("Cart contents: {}", cart);
    System.out.println("{} placeholder     -> toString() calls: " + Cart.toStringCalls);
 
    log.atDebug().setMessage("Cart contents: {}").addArgument(cart).log();
    System.out.println("fluent API         -> toString() calls: " + Cart.toStringCalls);
 
    log.debug("Cart checksum: {}", checksum(cart));
    System.out.println("method as argument -> checksum() calls: " + checksumCalls);
 
    log.atDebug().setMessage("Cart checksum: {}").addArgument(() -> checksum(cart)).log();
    System.out.println("supplier argument  -> checksum() calls: " + checksumCalls);
}

Với cấu hình mặc định của Boot, DEBUG bị tắt, nên cả năm lời gọi debug đều không in gì:

Text
isDebugEnabled() = false
concatenation      -> toString() calls: 1
{} placeholder     -> toString() calls: 1
fluent API         -> toString() calls: 1
method as argument -> checksum() calls: 1
supplier argument  -> checksum() calls: 1

Đọc từng dòng bộ đếm:

  • Nối chuỗi đã gọi toString() dù không có gì được ghi. Java tính "Cart contents: " + cart trước khi debug() kịp được gọi, nên chuỗi được dựng lên rồi vứt đi.
  • Bản dùng {} thì không. SLF4J nhận pattern và object, kiểm tra level trước, và chỉ định dạng khi dòng log thực sự được ghi. Bộ đếm vẫn đứng ở 1.
  • {} hoãn việc định dạng, không hoãn việc tính argument. log.debug("Cart checksum: {}", checksum(cart)) vẫn chạy checksum(), vì Java tính argument của method trước khi gọi. Placeholder không giúp được ở đây.
  • Supplier thì giúp được. Lời gọi cuối truyền () -> checksum(cart) và bộ đếm không nhúc nhích.

Một lời gọi method trong câu debug đang tắt thì rẻ, cho đến khi nó là một query database, một lần serialize JSON hay một vòng lặp qua mười nghìn phần tử. Trên hot path, việc đó chạy ở mọi request mà output thì chẳng ai thấy.

Fluent API của SLF4J 2 và argument lười

SLF4J 2.0 thêm một API kiểu builder: atTrace(), atDebug(), atInfo(), atWarn(), atError() trả về một LoggingEventBuilder, và event chỉ được phát ra khi bạn gọi log(). Khi level đang tắt, builder không làm gì cả, đó là lý do addArgument(cart) dạng fluent cũng không gọi toString().

Điểm mạnh thật sự của nó là addArgument(Supplier<?>). Lambda chỉ được gọi nếu dòng log được ghi. Chạy lại cùng đoạn code với logging.level.com.example=DEBUG cho thấy cả hai phía:

Text
isDebugEnabled() = true
2026-09-11T14:42:41.781+07:00 DEBUG 46075 --- [demo] [           main] com.example.demo.ParamsDemo              : Cart contents: Cart[items=3, total=450000]
concatenation      -> toString() calls: 1
2026-09-11T14:42:41.781+07:00 DEBUG 46075 --- [demo] [           main] com.example.demo.ParamsDemo              : Cart contents: Cart[items=3, total=450000]
{} placeholder     -> toString() calls: 2
2026-09-11T14:42:41.782+07:00 DEBUG 46075 --- [demo] [           main] com.example.demo.ParamsDemo              : Cart contents: Cart[items=3, total=450000]
fluent API         -> toString() calls: 3
2026-09-11T14:42:41.782+07:00 DEBUG 46075 --- [demo] [           main] com.example.demo.ParamsDemo              : Cart checksum: 9f2c1a
method as argument -> checksum() calls: 1
2026-09-11T14:42:41.782+07:00 DEBUG 46075 --- [demo] [           main] com.example.demo.ParamsDemo              : Cart checksum: 9f2c1a
supplier argument  -> checksum() calls: 2

Khi dòng log được ghi, mọi dạng đều làm việc đúng một lần. Khác biệt chỉ nằm ở trường hợp nó không được ghi:

Lời gọiCó tốn công khi DEBUG tắt không
log.debug("Cart: " + cart)có — chuỗi được dựng rồi bỏ đi
log.debug("Cart: {}", cart)không — toString() không bao giờ bị gọi
log.debug("Checksum: {}", checksum(cart))có — checksum() vẫn chạy
log.atDebug().setMessage("Checksum: {}").addArgument(() -> checksum(cart)).log()không — supplier không bao giờ bị gọi
if (log.isDebugEnabled()) { ... }không — cả block bị bỏ qua

Dùng {} ở mọi nơi. Chỉ dùng supplier hoặc guard isDebugEnabled() khi bản thân việc tạo ra argument tốn kém; guard hợp hơn khi chuẩn bị argument cần vài statement.

Ghi log exception kèm stack trace

SLF4J có đúng một quy tắc đặc biệt cho exception: nếu argument cuối cùng là một Throwable, nó được ghi như exception, kèm stack trace. Ba cách ghi log cho cùng một NumberFormatException:

Java
String raw = "12x";
try {
    parseQuantity(raw);                                   // Integer.parseInt(raw)
} catch (NumberFormatException e) {
    log.error("Failed to parse quantity '{}'", raw, e);   // A
    log.error("Failed to parse quantity: " + e);          // B
    log.error("Failed to parse quantity: {}", e);         // C
}

A — exception là argument cuối, đứng sau các giá trị của placeholder:

Text
2026-09-11T14:42:42.454+07:00 ERROR 46077 --- [demo] [           main] com.example.demo.ExceptionDemo           : Failed to parse quantity '12x'
 
java.lang.NumberFormatException: For input string: "12x"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67) ~[na:na]
	at java.base/java.lang.Integer.parseInt(Integer.java:662) ~[na:na]
	at java.base/java.lang.Integer.parseInt(Integer.java:778) ~[na:na]
	at com.example.demo.ExceptionDemo.parseQuantity(ExceptionDemo.java:33) ~[!/:0.0.1-SNAPSHOT]
	at com.example.demo.ExceptionDemo.run(ExceptionDemo.java:20) ~[!/:0.0.1-SNAPSHOT]

Ở đây chỉ trích năm frame đầu; output thật còn 25 frame nữa, toàn là runner của Spring Boot và frame của JDK. Hai dòng trống bao quanh stack trace đến từ converter %wEx của Boot, còn phần trong ngoặc sau mỗi frame cho biết class được load từ jar nào, version nào (na với class của JDK).

B — nối chuỗi:

Text
2026-09-11T14:42:42.455+07:00 ERROR 46077 --- [demo] [           main] com.example.demo.ExceptionDemo           : Failed to parse quantity: java.lang.NumberFormatException: For input string: "12x"

Một dòng. Bạn có type và message của exception, nhưng không biết nó bị ném ra ở đâu hay nguyên nhân gốc là gì.

C — cho exception một placeholder riêng:

Text
2026-09-11T14:42:42.455+07:00 ERROR 46077 --- [demo] [           main] com.example.demo.ExceptionDemo           : Failed to parse quantity: {}
 
java.lang.NumberFormatException: For input string: "12x"
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67) ~[na:na]

Stack trace quay lại, nhưng {} bị in nguyên văn: SLF4J lấy Throwable đứng cuối làm exception kể cả khi có placeholder đang chờ nó. Quy tắc tránh được cả ba bất ngờ rất đơn giản — mỗi giá trị thường một {}, exception đứng cuối và không có placeholder.

⚠️ log.error("failed: " + e) compile được, in ra một dòng trông hợp lý và dễ dàng lọt qua code review. Nó cũng là lý do một sự cố production phải điều tra mà không có stack trace. Hãy truyền e làm argument cuối cùng.

Log level trong Spring Boot

SLF4J có năm level. Một logger được đặt ở level nào thì in level đó và mọi level nghiêm trọng hơn, bỏ qua mọi level thấp hơn:

LevelDùng cho
TRACEchi tiết từng bước; mặc định tắt ở mọi nơi
DEBUGthông tin chẩn đoán cho developer
INFOcác mốc bình thường: đã khởi động, đã đặt đơn
WARNđiều bất thường nhưng application đã xử lý được
ERRORmột thao tác đã thất bại
OFFkhông phải level của message; đặt cho logger để tắt hẳn logger đó

Root level mặc định của Spring Boot là INFO. Đọc root logger từ LoggingSystem của Boot trong application đang chạy cho thấy INFO vừa là level được cấu hình vừa là level hiệu lực. Ngoài ra, defaults.xml của Boot đặt sẵn level cho vài logger bên thứ ba hay ồn, ví dụ org.apache.catalina.startup.DigesterFactory ở ERROR và org.apache.coyote.http11.Http11NioProtocol ở WARN.

Trong các demo bên dưới, một ApplicationRunner gọi orderService.place("A-1001")billingService.charge("A-1001", 2_500_000) một lần lúc khởi động. Không đặt level gì cả, hai service in ra:

Text
2026-09-11T14:42:52.189+07:00  INFO 46249 --- [demo] [           main] com.example.demo.orders.OrderService     : Order A-1001 placed
2026-09-11T14:42:52.189+07:00  INFO 46249 --- [demo] [           main] c.example.demo.billing.BillingService    : Charged 2500000 VND for order A-1001
2026-09-11T14:42:52.189+07:00  WARN 46249 --- [demo] [           main] c.example.demo.billing.BillingService    : Large payment of 2500000 VND for order A-1001

logging.level.<tên logger> đổi level của một logger và mọi thứ nằm dưới nó. root là tên đặc biệt cho gốc của cây:

application.properties
logging.level.root=WARN

Lần chạy đó in ra đúng một dòng log từ đầu đến cuối — dòng WARN. Các dòng khởi động của Boot đều là INFO nên cũng biến mất:

Text
2026-09-11T14:42:52.840+07:00  WARN 46251 --- [demo] [           main] c.example.demo.billing.BillingService    : Large payment of 2500000 VND for order A-1001

Theo chiều ngược lại, chỉ cho các package của bạn:

application.properties
logging.level.com.example=DEBUG
Text
2026-09-11T14:42:53.436+07:00 DEBUG 46257 --- [demo] [           main] com.example.demo.orders.OrderService     : Reserving stock for order A-1001
2026-09-11T14:42:53.437+07:00  INFO 46257 --- [demo] [           main] com.example.demo.orders.OrderService     : Order A-1001 placed
2026-09-11T14:42:53.437+07:00 DEBUG 46257 --- [demo] [           main] c.example.demo.billing.BillingService    : Calling payment gateway for order A-1001
2026-09-11T14:42:53.437+07:00  INFO 46257 --- [demo] [           main] c.example.demo.billing.BillingService    : Charged 2500000 VND for order A-1001
2026-09-11T14:42:53.437+07:00  WARN 46257 --- [demo] [           main] c.example.demo.billing.BillingService    : Large payment of 2500000 VND for order A-1001

Cả hai dòng DEBUG xuất hiện; dòng TRACE trong OrderService thì không, vì TRACE thấp hơn DEBUG. Cùng thiết lập đó còn khiến Boot in Running with Spring Boot v4.1.1, Spring v7.0.9 lúc khởi động, vì dòng này được ghi ở DEBUG qua logger của chính class application.

Thiết lập framework hữu ích nhất khi đang xây API là org.springframework.web. Với logging.level.org.springframework.web=DEBUG và một request tới GET /orders/A-1001:

Text
2026-09-11T14:47:18.128+07:00 DEBUG 48619 --- [demo] [nio-8094-exec-1] o.s.web.servlet.DispatcherServlet        : GET "/orders/A-1001", parameters={}
2026-09-11T14:47:18.133+07:00 DEBUG 48619 --- [demo] [nio-8094-exec-1] s.w.s.m.m.a.RequestMappingHandlerMapping : Mapped to com.example.demo.web.OrderController#place(String)
2026-09-11T14:47:18.140+07:00  INFO 48619 --- [demo] [nio-8094-exec-1] com.example.demo.orders.OrderService     : Order A-1001 placed
2026-09-11T14:47:18.141+07:00  INFO 48619 --- [demo] [nio-8094-exec-1] c.example.demo.billing.BillingService    : Charged 450000 VND for order A-1001
2026-09-11T14:47:18.144+07:00 DEBUG 48619 --- [demo] [nio-8094-exec-1] m.m.a.RequestResponseBodyMethodProcessor : Using 'text/plain', given [*/*] and supported [text/plain, */*, application/json, application/*+json]
2026-09-11T14:47:18.144+07:00 DEBUG 48619 --- [demo] [nio-8094-exec-1] m.m.a.RequestResponseBodyMethodProcessor : Writing ["placed A-1001<EOL>"]
2026-09-11T14:47:18.147+07:00 DEBUG 48619 --- [demo] [nio-8094-exec-1] o.s.web.servlet.DispatcherServlet        : Completed 200 OK

Request, handler method mà nó được map tới, content type được chọn cho response, và status — không cần debugger. Lần chạy đó có tổng cộng 14 dòng DEBUG: năm dòng này cho request, năm dòng khi DispatcherServlet khởi tạo ở request đầu tiên, và bốn dòng lúc khởi động tóm tắt các handler mapping.

Cây logger kế thừa level như thế nào

Logback sắp các logger thành một cây dựa trên dấu chấm trong tên. com.example.demo.billing.BillingService là con của com.example.demo.billing, logger này lại là con của com.example.demo, và cứ thế lên tới ROOT. Logger nào không có level riêng thì dùng level của tổ tiên gần nhất có level.

Đó là lý do một property phủ được cả hai service ở trên, và cũng là thứ cho phép một thiết lập cụ thể hơn đè lên thiết lập chung:

application.properties
logging.level.com.example.demo=DEBUG
logging.level.com.example.demo.billing=WARN
Text
2026-09-11T14:56:40.478+07:00 DEBUG 53690 --- [demo] [           main] com.example.demo.orders.OrderService     : Reserving stock for order A-1001
2026-09-11T14:56:40.479+07:00  INFO 53690 --- [demo] [           main] com.example.demo.orders.OrderService     : Order A-1001 placed
2026-09-11T14:56:40.479+07:00  WARN 53690 --- [demo] [           main] c.example.demo.billing.BillingService    : Large payment of 2500000 VND for order A-1001

OrderService không có level riêng, nên kế thừa DEBUG từ com.example.demo và in dòng DEBUG của nó. BillingService cũng không có level riêng, nhưng tổ tiên gần nhất có cấu hình của nó là com.example.demo.billing, đặt ở WARN — nên cả dòng DEBUG lẫn INFO đều mất, chỉ còn cảnh báo. Hai thiết lập nằm trên hai logger khác nhau, nên viết theo thứ tự nào cũng không quan trọng.

OFF hoạt động y hệt. logging.level.com.example.demo.billing=OFF xóa luôn cả dòng WARN, chỉ còn lại Order A-1001 placed.

Các logger sắp theo package, level kế thừa từ logger cha gần nhất có cấu hình; một lời gọi DEBUG đi qua ở OrderService và bị lọc ở BillingService

Log group: một property cho nhiều logger

Khi các logger bạn quan tâm nằm ở những package không liên quan, một group đặt tên cho chúng một lần:

application.properties
logging.group.app=com.example.demo.orders,com.example.demo.billing
logging.level.app=DEBUG

Lần chạy đó in ra đúng năm dòng giống logging.level.com.example=DEBUG ở trên — hai dòng DEBUG, hai dòng INFO và dòng cảnh báo.

Boot tự định nghĩa hai group. Thành viên của chúng trong 4.1.1, đọc từ LoggingApplicationListener và xác nhận bằng cách liệt kê bean LoggerGroups trong application đang chạy:

GroupCác logger
weborg.springframework.core.codec, org.springframework.http, org.springframework.web, org.springframework.boot.actuate.endpoint.web, org.springframework.boot.web.servlet.ServletContextInitializerBeans
sqlorg.springframework.jdbc.core, org.hibernate.SQL, org.jooq.tools.LoggerListener

Vì vậy logging.level.web=DEBUG rộng hơn một chút so với logging.level.org.springframework.web=DEBUG. Cùng một request cho ra 16 dòng DEBUG thay vì 14, và phần chênh là hai dòng lúc khởi động từ ServletContextInitializerBeans, liệt kê mọi filter và servlet đã đăng ký:

Text
2026-09-11T14:47:19.528+07:00 DEBUG 48647 --- [demo] [           main] o.s.b.w.s.ServletContextInitializerBeans : Mapping filters: characterEncodingFilter urls=[/*] order=-2147483648, formContentFilter urls=[/*] order=-9900, requestContextFilter urls=[/*] order=-105, requestIdFilter urls=[/*] order=2147483647
2026-09-11T14:47:19.528+07:00 DEBUG 48647 --- [demo] [           main] o.s.b.w.s.ServletContextInitializerBeans : Mapping servlets: dispatcherServlet urls=[/]

(requestIdFilter là filter trong phần MDC phía sau.) Group sql sẽ có ích ở Chương 4, khi application đã có database để làm việc.

--debug và --trace khác nhau ở đâu

Bài 10 đã dùng --debug để in báo cáo conditions evaluation. Cả hai flag còn đổi log level — nhưng chỉ cho một danh sách logger framework cố định, không phải cho application của bạn. Danh sách đó là một hằng số trong LoggingApplicationListener, và level hiệu lực bên dưới được đọc lại từ application đang chạy:

FlagLogger bị thay đổiOutput của web app, khởi động cộng 1,5 giây nghỉ
không có8 dòng
--debug hoặc debug=truegroup websql, cùng org.springframework.boot → DEBUG424 dòng, 21 dòng DEBUG, cộng báo cáo conditions
--trace hoặc trace=trueorg.springframework, org.apache.tomcat, org.apache.catalina, org.eclipse.jetty, org.hibernate.tool.hbm2ddl → TRACE2.392 dòng: 1.539 TRACE và 287 DEBUG, cộng báo cáo conditions

Từ bảng đó rút ra hai điều:

  • Không flag nào đụng tới logger trong code của bạn. com.example.demo vẫn ở INFO trong cả hai lần chạy, và dòng DEBUG Running with Spring Boot v4.1.1, Spring v7.0.9 — xuất hiện ngay khi bạn đặt logging.level.com.example=DEBUG — không có mặt ở lần nào. Nếu log.debug(...) của chính bạn không in ra, --debug sẽ không cứu được.
  • --trace là vòi rồng. 1.174 dòng trong số đó đến từ đúng một logger, DefaultListableBeanFactory, tường thuật từng bean nó tạo. Nó có ích khi bạn đang truy một lỗi tạo bean, và vô dụng nếu bật làm mặc định.

ROOT vẫn ở INFO dưới cả hai flag, nên các thư viện bên thứ ba ngoài danh sách đó không bị ảnh hưởng.

Giải phẫu một dòng log mặc định của Spring Boot

Pattern console của Boot được định nghĩa trong org/springframework/boot/logging/logback/defaults.xml bên trong spring-boot-4.1.1.jar:

XML
<property name="CONSOLE_LOG_PATTERN" value="${CONSOLE_LOG_PATTERN:-%clr(%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd'T'HH:mm:ss.SSSXXX}}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}){} %clr(${PID:-}){magenta} %clr(--- %esb(){APPLICATION_NAME}%esb{APPLICATION_GROUP}[%15.15t] ${LOG_CORRELATION_PATTERN:-}){faint}%clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}"/>

Và đây là một dòng nó tạo ra, từ request đầu tiên tới web application:

Text
2026-09-11T14:45:17.515+07:00  INFO 47420 --- [demo] [nio-8094-exec-1] o.s.web.servlet.DispatcherServlet        : Initializing Servlet 'dispatcherServlet'

Một dòng log console thật của Spring Boot 4.1.1 được tách thành tám field đánh số, mỗi field kèm pattern token tạo ra nó

Phần lớn token tự giải thích khi hình đã ghép cặp chúng. Những token đáng xem kỹ hơn:

  • ${LOG_DATEFORMAT_PATTERN:-...}${LOG_LEVEL_PATTERN:-%5p} là chỗ móc của logging.pattern.dateformatlogging.pattern.level. Chúng cho phép đổi một phần mà không phải viết lại phần còn lại.
  • %esb(){APPLICATION_NAME} in spring.application.name trong ngoặc vuông kèm một dấu cách phía sau. application.properties do Initializr sinh ra có spring.application.name=demo, nên mới có [demo]. Chạy với một file config rỗng thay vào, field này biến mất hẳn và dòng log thành --- [ main]. %esb{APPLICATION_GROUP} làm tương tự cho spring.application.group, ở đây chưa set.
  • [%15.15t] đệm tên thread cho đủ 15 ký tự và cắt từ bên trái: http-nio-8094-exec-1 của Tomcat thành nio-8094-exec-1, còn main thành mười một dấu cách cộng main.
  • %-40.40logger{39} rút gọn tên logger rồi đệm cho đủ 40 cột. Việc rút gọn viết tắt package từ bên trái và không bao giờ đụng tới tên class. Project này cho thấy đúng ranh giới: com.example.demo.orders.OrderService (36 ký tự) được in đầy đủ, còn com.example.demo.billing.BillingService (39 ký tự) thành c.example.demo.billing.BillingService.
  • ${LOG_CORRELATION_PATTERN:-} rỗng, trừ khi có tracing cung cấp trace id và span id — phần đó thuộc về khóa Advanced.
  • %clr(...) thêm màu, còn %wEx in stack trace với một dòng trống mỗi bên, như đã thấy ở phần exception.

Pattern cho file nằm ngay cạnh trong defaults.xml giống hệt nhưng không có %clr và dùng [%t] thay vì [%15.15t], nên dòng log trong file mang tên thread đầy đủ, không đệm.

Tùy biến pattern với logging.pattern.console

logging.pattern.console thay thế toàn bộ pattern console. Thứ gì bạn bỏ ra — PID, thread, tên application — đơn giản là mất:

application.properties
logging.pattern.console=%d{HH:mm:ss.SSS} %-5level %logger{20} - %msg%n
Text
14:45:29.431 INFO  c.e.d.o.OrderService - Order A-1001 placed
14:45:29.431 INFO  c.e.d.b.BillingService - Charged 2500000 VND for order A-1001
14:45:29.431 WARN  c.e.d.b.BillingService - Large payment of 2500000 VND for order A-1001

%logger{20} để rất ít chỗ cho việc rút gọn, nên mọi package bị thu về chữ cái đầu.

Khi chỉ muốn đổi phần ngày giờ, logging.pattern.dateformat thay đúng phần đó và giữ nguyên layout của Boot:

application.properties
logging.pattern.dateformat=yyyy-MM-dd HH:mm:ss.SSS
Text
2026-09-11 14:45:30.144  INFO 47544 --- [demo] [           main] com.example.demo.orders.OrderService     : Order A-1001 placed
2026-09-11 14:45:30.144  INFO 47544 --- [demo] [           main] c.example.demo.billing.BillingService    : Charged 2500000 VND for order A-1001
2026-09-11 14:45:30.144  WARN 47544 --- [demo] [           main] c.example.demo.billing.BillingService    : Large payment of 2500000 VND for order A-1001

Log có màu với spring.output.ansi.enabled

Các wrapper %clr chỉ phát mã màu khi ANSI output được bật. spring.output.ansi.enabled nhận detect (mặc định), always hoặc never. Với detect, Boot tô màu khi console là terminal; pipe cùng application đó qua cat -v cho ra chữ trơn. Ép bật rồi pipe qua cat -v sẽ thấy rõ các escape code:

application.properties
spring.output.ansi.enabled=always
Text
^[[2m2026-09-11T14:45:30.684+07:00^[[0;39m ^[[33m WARN^[[0;39m ^[[35m47546^[[0;39m ^[[2m--- [demo] [           main] ^[[0;39m^[[36mc.example.demo.billing.BillingService   ^[[0;39m ^[[2m:^[[0;39m Large payment of 2500000 VND for order A-1001

2m là mờ, 33m vàng cho WARN (các dòng INFO nhận 32m, xanh lá), 35m tím cho PID và 36m xanh lơ cho logger. always có ích khi trình xem log của CI hiểu màu ANSI; never là thứ bạn cần khi output bị ghi vào file hoặc gửi tới một log collector, nơi escape code chỉ là rác.

Ghi log ra file

Mặc định Spring Boot chỉ ghi log ra console. Một property là có thêm file:

application.properties
logging.file.name=logs/app.log

Console vẫn in y như trước, và một file mới xuất hiện, tính tương đối theo thư mục làm việc:

Text
./logs/app.log
Text
2026-09-11T14:45:27.519+07:00  INFO 47461 --- [demo] [main] com.example.demo.orders.OrderService     : Order A-1001 placed

Dòng này dùng pattern của file: không màu, và [main] thay cho tên thread được đệm.

Property còn lại nhận một thư mục và luôn dùng tên file spring.log:

application.properties
logging.file.path=logs

Lần chạy đó tạo ra ./logs/spring.log. Đặt cả hai property cùng lúc — logging.file.name=logs/app.loglogging.file.path trỏ tới thư mục var-log — thì chỉ có logs/app.log, và không hề có thư mục var-log nào. LogFile của Boot dùng name bất cứ khi nào nó được set, và chỉ dùng tới path khi name trống:

Property được setFile được ghi
logging.file.name=logs/app.loglogs/app.log
logging.file.path=logslogs/spring.log
cả hailogs/app.log; logging.file.path bị bỏ qua
không có cái nàokhông có file, chỉ console

Xoay vòng file log: size, history và total size cap

Một file log chỉ biết lớn lên thì sớm muộn cũng làm đầy ổ đĩa. File appender của Boot dùng SizeAndTimeBasedRollingPolicy của Logback, và file-appender.xml trong jar của Boot — bản XML tương đương với những gì Boot cấu hình bằng code — cho thấy các giá trị mặc định:

XML
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
    <fileNamePattern>${LOGBACK_ROLLINGPOLICY_FILE_NAME_PATTERN:-${LOG_FILE}.%d{yyyy-MM-dd}.%i.gz}</fileNamePattern>
    <cleanHistoryOnStart>${LOGBACK_ROLLINGPOLICY_CLEAN_HISTORY_ON_START:-false}</cleanHistoryOnStart>
    <maxFileSize>${LOGBACK_ROLLINGPOLICY_MAX_FILE_SIZE:-10MB}</maxFileSize>
    <totalSizeCap>${LOGBACK_ROLLINGPOLICY_TOTAL_SIZE_CAP:-0}</totalSizeCap>
    <maxHistory>${LOGBACK_ROLLINGPOLICY_MAX_HISTORY:-7}</maxHistory>
</rollingPolicy>

Trong pattern tên file, %d là ngày mà archive thuộc về, %i đếm số archive trong ngày đó, còn đuôi .gz bảo Logback nén lại. Để thấy nó chạy mà không phải ghi đủ 10 MB, hãy thu nhỏ giới hạn và ghi 1.000 dòng log trong một vòng lặp:

application.properties
logging.file.name=logs/app.log
logging.logback.rollingpolicy.max-file-size=10KB

ls -lgo logs sau đó:

Text
total 128
-rw-r--r--@ 1   2341 Sep 11 14:45 app.log
-rw-r--r--@ 1    852 Sep 11 14:45 app.log.2026-09-11.0.gz
-rw-r--r--@ 1    555 Sep 11 14:45 app.log.2026-09-11.1.gz
-rw-r--r--@ 1    546 Sep 11 14:45 app.log.2026-09-11.10.gz
-rw-r--r--@ 1    544 Sep 11 14:45 app.log.2026-09-11.11.gz
-rw-r--r--@ 1    540 Sep 11 14:45 app.log.2026-09-11.12.gz
-rw-r--r--@ 1    545 Sep 11 14:45 app.log.2026-09-11.13.gz
-rw-r--r--@ 1    528 Sep 11 14:45 app.log.2026-09-11.14.gz
-rw-r--r--@ 1    559 Sep 11 14:45 app.log.2026-09-11.2.gz
-rw-r--r--@ 1    555 Sep 11 14:45 app.log.2026-09-11.3.gz
-rw-r--r--@ 1    537 Sep 11 14:45 app.log.2026-09-11.4.gz
-rw-r--r--@ 1    550 Sep 11 14:45 app.log.2026-09-11.5.gz
-rw-r--r--@ 1    552 Sep 11 14:45 app.log.2026-09-11.6.gz
-rw-r--r--@ 1    539 Sep 11 14:45 app.log.2026-09-11.7.gz
-rw-r--r--@ 1    551 Sep 11 14:45 app.log.2026-09-11.8.gz
-rw-r--r--@ 1    558 Sep 11 14:45 app.log.2026-09-11.9.gz

Mười lăm archive, từ .0 tới .14 (ls xếp .10 đứng trước .2), cộng file app.log đang ghi. Mỗi archive khoảng 550 byte gzip; giải nén app.log.2026-09-11.3.gz được 66 dòng và 10.286 byte — vừa vượt giới hạn 10KB. Archive .0 lớn hơn vì còn chứa các dòng khởi động.

total-size-cap đặt trần cho tổng dung lượng của mọi archive. Bỏ .gz khỏi pattern để size dễ đọc:

application.properties
logging.logback.rollingpolicy.file-name-pattern=logs/app-%d{yyyy-MM-dd}.%i.log
logging.logback.rollingpolicy.total-size-cap=40KB
Text
total 80
-rw-r--r--@ 1   10296 Sep 11 14:45 app-2026-09-11.12.log
-rw-r--r--@ 1   10286 Sep 11 14:45 app-2026-09-11.13.log
-rw-r--r--@ 1   10286 Sep 11 14:45 app-2026-09-11.14.log
-rw-r--r--@ 1    2341 Sep 11 14:45 app.log

Lần chạy này sinh ra đúng mười lăm archive như trước, và Logback đã xóa mười hai cái cũ nhất: bốn archive khoảng 10KB sẽ vượt 40KB, nên chỉ ba cái sống sót.

max-history là property bị hiểu sai nhiều nhất. Mô tả của chính Boot gọi nó là "maximum number of archive log files to keep", nhưng thứ nó đếm là số chu kỳ của pattern %d, không phải số file. Một pattern xoay vòng mỗi giây làm lộ rõ điều đó:

application.properties
logging.logback.rollingpolicy.file-name-pattern=logs/app-%d{yyyy-MM-dd_HH-mm-ss}.%i.log
logging.logback.rollingpolicy.max-file-size=100KB
logging.logback.rollingpolicy.max-history=2
Text
total 1608
-rw-r--r--@ 1   102451 Sep 11 14:47 app-2026-09-11_14-47-34.0.log
-rw-r--r--@ 1   102453 Sep 11 14:47 app-2026-09-11_14-47-34.1.log
-rw-r--r--@ 1   101674 Sep 11 14:47 app-2026-09-11_14-47-34.2.log
-rw-r--r--@ 1   102451 Sep 11 14:47 app-2026-09-11_14-47-35.0.log
-rw-r--r--@ 1   102500 Sep 11 14:47 app-2026-09-11_14-47-35.1.log
-rw-r--r--@ 1   102482 Sep 11 14:47 app-2026-09-11_14-47-35.2.log
-rw-r--r--@ 1     7258 Sep 11 14:47 app-2026-09-11_14-47-35.3.log
-rw-r--r--@ 1   102475 Sep 11 14:47 app-2026-09-11_14-47-36.0.log
-rw-r--r--@ 1    71215 Sep 11 14:47 app.log

max-history=2 để lại tám archive thuộc ba giây liên tiếp; mọi thứ cũ hơn đã bị xóa. Cùng lần chạy đó với max-history=100 giữ đủ 20 archive sinh ra, trải trên bảy giây. Với pattern mặc định theo ngày của Boot, giá trị mặc định 7 vì thế có nghĩa là khoảng một tuần archive, bất kể mỗi ngày sinh ra bao nhiêu file — nên với một service bận rộn, total-size-cap mới là thiết lập thực sự bảo vệ ổ đĩa.

Việc xóa bình thường chỉ diễn ra khi có rollover. clean-history-on-start=true cho nó chạy thêm lúc khởi động. Restart ngay trên 20 archive của lần chạy max-history=100, với max-history=2 và logging bị tắt để không thể có rollover nào, thì cả 20 archive vẫn nằm nguyên; cùng lần restart đó với clean-history-on-start=true thì xóa sạch cả 20.

Năm thời điểm trong vòng đời của app.log: đang ghi, chạm max-file-size, rollover thành archive gzip, sang ngày mới, và archive cũ bị xóa

Cấu hình riêng với logback-spring.xml

Các property logging.* đáp ứng phần lớn nhu cầu. Khi cần nhiều hơn — appender khác nhau theo môi trường, một appender mà Boot không có property tương ứng, level khác nhau theo profile — bạn tự viết XML của Logback. Đặt nó ở src/main/resources/logback-spring.xml:

src/main/resources/logback-spring.xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
 
    <springProperty name="APP_NAME" source="spring.application.name" defaultValue="app"/>
 
    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{HH:mm:ss.SSS} %-5level [${APP_NAME}] %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>
 
    <springProfile name="dev">
        <logger name="com.example.demo" level="DEBUG"/>
        <root level="INFO">
            <appender-ref ref="CONSOLE"/>
        </root>
    </springProfile>
 
    <springProfile name="!dev">
        <appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
            <file>logs/${APP_NAME}.log</file>
            <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
                <fileNamePattern>logs/${APP_NAME}.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
                <maxFileSize>10MB</maxFileSize>
                <maxHistory>14</maxHistory>
                <totalSizeCap>1GB</totalSizeCap>
            </rollingPolicy>
            <encoder>
                <pattern>%d{yyyy-MM-dd'T'HH:mm:ss.SSSXXX} %-5level [%thread] %logger : %msg%n</pattern>
            </encoder>
        </appender>
        <root level="WARN">
            <appender-ref ref="CONSOLE"/>
            <appender-ref ref="FILE"/>
        </root>
    </springProfile>
 
</configuration>

Hai element bắt đầu bằng spring là extension của Boot, không phải của Logback:

  • <springProperty> chép một giá trị từ Environment của Boot sang một variable của Logback. application.propertiesspring.application.name=demo, nên ${APP_NAME} thành demo cả trong pattern console lẫn trong tên file.
  • <springProfile> chỉ đưa phần bên trong vào khi biểu thức profile khớp. dev là cấu hình máy local: DEBUG cho application, chỉ console. !dev bao mọi profile khác, kể cả khi không có profile nào: WARN, console và một rolling file.

Appender FILE được đặt bên trong block !dev là có chủ ý. Ở một phiên bản trước, nó nằm ở cấp cao nhất, và một lần chạy dev in ra Appender named [FILE] not referenced. Skipping further processing. lúc khởi động — Logback 1.5.38 bỏ qua appender không được logger nào dùng tới, và nói ra điều đó.

Chạy với --spring.profiles.active=dev:

Text
14:47:56.464 DEBUG [demo] com.example.demo.DemoApplication - Running with Spring Boot v4.1.1, Spring v7.0.9
14:47:56.464 INFO  [demo] com.example.demo.DemoApplication - The following 1 profile is active: "dev"
14:47:56.661 INFO  [demo] com.example.demo.DemoApplication - Started DemoApplication in 0.377 seconds (process running for 0.553)
14:47:56.662 DEBUG [demo] c.example.demo.orders.OrderService - Reserving stock for order A-1001
14:47:56.663 INFO  [demo] c.example.demo.orders.OrderService - Order A-1001 placed
14:47:56.663 DEBUG [demo] c.e.demo.billing.BillingService - Calling payment gateway for order A-1001
14:47:56.663 INFO  [demo] c.e.demo.billing.BillingService - Charged 2500000 VND for order A-1001
14:47:56.663 WARN  [demo] c.e.demo.billing.BillingService - Large payment of 2500000 VND for order A-1001

Pattern tự viết với [demo] lấy từ <springProperty>, các dòng DEBUG của application, và không có thư mục logs nào được tạo. Cùng jar đó với --spring.profiles.active=prod chỉ in đúng một dòng ra console:

Text
14:47:57.253 WARN  [demo] c.e.demo.billing.BillingService - Large payment of 2500000 VND for order A-1001

và ghi cùng event đó vào logs/demo.log theo pattern của file:

Text
2026-09-11T14:47:57.253+07:00 WARN  [main] com.example.demo.billing.BillingService : Large payment of 2500000 VND for order A-1001

Có hai điểm tương tác với các property ở những phần trước, cả hai đều kiểm tra với profile prod. logging.level.* vẫn áp lên trên XML của bạn: thêm --logging.level.com.example.demo.orders=DEBUG làm dòng DEBUG Reserving stock quay lại dù root đang là WARN. logging.file.name thì không: --logging.file.name=other.log không tạo ra other.log nào, vì XML này không hề dùng tới variable ${LOG_FILE} của Boot.

Vì sao logback.xml thường được nạp quá sớm

Logback tự tìm logback.xml trên classpath ngay khi logger đầu tiên được tạo — trước khi Spring Boot đọc application.properties hay quyết định profile nào đang active. Ở thời điểm đó chưa ai dạy Logback <springProfile> nghĩa là gì. Chép nguyên file ở trên thành logback.xml rồi chạy với dev thì Logback in status report trước mọi thứ khác, trong đó có:

Text
14:47:58,421 |-WARN in ch.qos.logback.core.model.processor.ImplicitModelHandler - Ignoring unknown property [springProperty] in [ch.qos.logback.classic.LoggerContext]
14:47:58,421 |-WARN in ch.qos.logback.core.model.processor.ImplicitModelHandler - Ignoring unknown property [springProfile] in [ch.qos.logback.classic.LoggerContext]
14:47:58,421 |-WARN in ch.qos.logback.core.model.processor.ImplicitModelHandler - Ignoring unknown property [springProfile] in [ch.qos.logback.classic.LoggerContext]
14:47:58,423 |-INFO in ch.qos.logback.core.model.processor.ModelInterpretationContext@47af7f3d - value "logs/APP_NAME_IS_UNDEFINED.log" substituted for "logs/${APP_NAME}.log"
14:47:58,424 |-WARN in ch.qos.logback.core.model.processor.AppenderModelHandler - Appender named [CONSOLE] not referenced. Skipping further processing.

Cả hai block springProfile bị bỏ qua, APP_NAME không được định nghĩa, và lần cấu hình đầu tiên không có root appender nào. Sau đó Boot nạp lại chính file này lần thứ hai với đầy đủ extension, nên phần output còn lại khớp với logback-spring.xml. Đống cảnh báo, khoảng thời gian chưa được cấu hình lúc khởi động, và việc phải trông vào lần nạp thứ hai — đó chính là những gì cái tên -spring giúp tránh: Boot tự tìm logback-spring.xml, còn Logback không bao giờ tự mình đụng tới nó.

MDC: gắn request id vào mọi dòng log

Khi hai mươi request chạy cùng lúc, các dòng log của chúng đan xen vào nhau, và một dòng từ BillingService không còn cho biết nó thuộc request nào. MDC (Mapped Diagnostic Context) của SLF4J là một map giá trị gắn theo từng thread mà pattern có thể in ra ở mọi dòng. Một servlet filter là chỗ tự nhiên để điền vào nó:

RequestIdFilter.java
@Component
public class RequestIdFilter extends OncePerRequestFilter {
 
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
            FilterChain chain) throws ServletException, IOException {
        String requestId = UUID.randomUUID().toString().substring(0, 8);
        MDC.put("requestId", requestId);
        try {
            chain.doFilter(request, response);
        } finally {
            MDC.remove("requestId");
        }
    }
}

finally không phải tùy chọn. Tomcat phục vụ request bằng một pool thread — chính là nio-8094-exec-1 trong các dòng log — và giá trị MDC thuộc về thread, nên một giá trị không bao giờ bị xóa sẽ vẫn còn đó khi cùng thread nhận request tiếp theo. %X{requestId} in giá trị ra:

application.properties
logging.pattern.console=%d{HH:mm:ss.SSS} %5p [%X{requestId}] %-40.40logger{39} : %m%n

Hai request, tới /orders/A-1001/orders/A-1002, trong đó controller gọi cả hai service:

Text
14:47:21.222  INFO [] com.example.demo.DemoApplication         : Started DemoApplication in 0.527 seconds (process running for 0.705)
14:47:21.326  INFO [fbd2ced0] com.example.demo.orders.OrderService     : Order A-1001 placed
14:47:21.326  INFO [fbd2ced0] c.example.demo.billing.BillingService    : Charged 450000 VND for order A-1001
14:47:21.338  INFO [afd9dadb] com.example.demo.orders.OrderService     : Order A-1002 placed
14:47:21.338  INFO [afd9dadb] c.example.demo.billing.BillingService    : Charged 450000 VND for order A-1002

Ngoài request thì cặp ngoặc rỗng. Nếu muốn giữ layout của Boot, logging.pattern.level=%5p [%X{requestId}] chèn giá trị vào ngay cạnh level:

Text
2026-09-11T14:47:22.922+07:00  INFO [e53e7983] 48689 --- [demo] [nio-8094-exec-1] com.example.demo.orders.OrderService     : Order A-1003 placed

Đây là MDC ở mức đơn giản nhất. Trace id và span id đi theo một request xuyên qua nhiều service là việc của Micrometer Tracing, được nói trong khóa Advanced.

Chuyển từ Logback sang Log4j2

Vì code của bạn chỉ nói chuyện với SLF4J, thay Logback bằng Log4j2 chỉ là sửa build.gradle:

build.gradle
configurations {                                                                          
    all {                                                                                 
        exclude group: 'org.springframework.boot', module: 'spring-boot-starter-logging'
    }                                                                                     
}                                                                                         
 
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-webmvc'
    implementation 'org.springframework.boot:spring-boot-starter-log4j2'
    testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

Exclusion đặt trên configurations.all chứ không đặt trên một dependency, vì spring-boot-starter-logging đến bằng nhiều đường. gradle dependencyInsight trên test classpath cho thấy nó vào project qua spring-boot-starter-webmvc, spring-boot-starter-jackson, spring-boot-starter-tomcatspring-boot-starter-test; chỉ exclude khỏi web starter thì nó vẫn nằm lại trên test classpath.

Sau khi sửa, cây runtime không còn logback, jul-to-slf4j hay log4j-to-slf4j, và starter mới mang theo:

Text
\--- org.springframework.boot:spring-boot-starter-log4j2 -> 4.1.1
     +--- org.apache.logging.log4j:log4j-slf4j2-impl:2.25.5
     |    +--- org.apache.logging.log4j:log4j-api:2.25.5
     |    +--- org.slf4j:slf4j-api:2.0.17 -> 2.0.18
     |    \--- org.apache.logging.log4j:log4j-core:2.25.5
     |         \--- org.apache.logging.log4j:log4j-api:2.25.5
     +--- org.apache.logging.log4j:log4j-core:2.25.5 (*)
     \--- org.apache.logging.log4j:log4j-jul:2.25.5
          \--- org.apache.logging.log4j:log4j-api:2.25.5

Chiều đi đã đảo ngược: log4j-slf4j2-impl đưa lời gọi SLF4J vào Log4j2, và log4j-jul làm tương tự cho JUL. BridgeDemo không sửa gì giờ in ra:

Text
2026-09-11T14:46:02.829+07:00  INFO 47869 --- [demo] [           main] c.e.d.BridgeDemo                         : hello from the SLF4J API
2026-09-11T14:46:02.830+07:00  INFO 47869 --- [demo] [           main] c.l.JulClient                            : hello from java.util.logging
2026-09-11T14:46:02.830+07:00  INFO 47869 --- [demo] [           main] c.l.Log4jClient                          : hello from the Log4j API
2026-09-11T14:46:02.830+07:00  INFO 47869 --- [demo] [           main] c.l.JclClient                            : hello from Commons Logging
SLF4J ILoggerFactory : org.apache.logging.slf4j.Log4jLoggerFactory
Log4j API logger     : org.apache.logging.log4j.core.Logger
Commons Logging Log  : org.apache.commons.logging.impl.Log4jApiLogFactory$Log4j2Log
JUL root handlers    : [org.apache.logging.log4j.jul.Log4jBridgeHandler@537c8c7e]

Bean LoggingSystem của Boot giờ là Log4J2LoggingSystem. Khác biệt nhìn thấy được nằm ở cột logger: log4j2.xml của Boot dùng %-40.40c{1.}, rút mọi package về một chữ cái. logging.level.* vẫn hoạt động — logging.level.com.example.demo.billing=DEBUG đã in ra dòng Calling payment gateway dưới Log4j2 — còn các property rotation chuyển sang tiền tố logging.log4j2.rollingpolicy.*.

Thêm Log4j2 mà không exclude Logback

Thêm spring-boot-starter-log4j2 mà quên block configurations thì cả hai stack cùng nằm trong jar: unzip -l liệt kê logback-classic-1.5.38.jar, log4j-to-slf4j-2.25.5.jarlog4j-slf4j2-impl-2.25.5.jar cạnh nhau trong BOOT-INF/lib. Application không khởi động được:

Text
SLF4J(W): Class path contains multiple SLF4J providers.
SLF4J(W): Found provider [org.apache.logging.slf4j.SLF4JServiceProvider@2b552920]
SLF4J(W): Found provider [ch.qos.logback.classic.spi.LogbackServiceProvider@2758fe70]
SLF4J(W): See https://www.slf4j.org/codes.html#multiple_bindings for an explanation.
SLF4J(I): Actual provider is of type [org.apache.logging.slf4j.SLF4JServiceProvider@2b552920]

tiếp theo, ở cuối chuỗi exception, là:

Text
Caused by: org.apache.logging.log4j.LoggingException: log4j-slf4j2-impl cannot be present with log4j-to-slf4j
	at org.apache.logging.slf4j.Log4jLoggerFactory.validateContext(Log4jLoggerFactory.java:67)
	at org.apache.logging.slf4j.Log4jLoggerFactory.newLogger(Log4jLoggerFactory.java:49)
	at org.apache.logging.slf4j.Log4jLoggerFactory.newLogger(Log4jLoggerFactory.java:32)
	at org.apache.logging.log4j.spi.AbstractLoggerAdapter.getLogger(AbstractLoggerAdapter.java:52)
	at org.apache.logging.slf4j.Log4jLoggerFactory.getLogger(Log4jLoggerFactory.java:32)
	at org.slf4j.LoggerFactory.getLogger(LoggerFactory.java:447)
	at org.apache.commons.logging.impl.Slf4jLogFactory.lambda$getInstance$0(Slf4jLogFactory.java:294)
	at java.base/java.util.concurrent.ConcurrentHashMap.computeIfAbsent(ConcurrentHashMap.java:1708)
	at org.apache.commons.logging.impl.Slf4jLogFactory.getInstance(Slf4jLogFactory.java:293)
	at org.apache.commons.logging.impl.Slf4jLogFactory.getInstance(Slf4jLogFactory.java:288)
	at org.apache.commons.logging.LogFactory.getLog(LogFactory.java:921)
	at org.springframework.boot.SpringApplication.<clinit>(SpringApplication.java:205)
	... 6 more

Đọc nó thành hai phần. Thứ nhất, SLF4J tìm thấy hai provider và cứ thế lấy cái đứng đầu classpath — ở đây là của Log4j, một sự tình cờ về thứ tự chứ không phải một lựa chọn. Thứ hai, log4j-to-slf4j chuyển lời gọi Log4j API sang SLF4J trong khi log4j-slf4j2-impl chuyển lời gọi SLF4J sang Log4j API; đặt cạnh nhau, chúng sẽ chuyền mọi event thành một vòng tròn, nên Log4j từ chối khởi động. Lỗi xảy ra ngay trong static initializer của SpringApplication, trước khi Boot có logging system nào để báo cáo. Khi thấy multiple SLF4J providers, hãy chạy gradle dependencies và gỡ một bên đi.

Structured logging dạng JSON

Boot 4.1.1 còn có thể ghi mỗi event thành một object JSON duy nhất thay cho một dòng đã định dạng, đúng thứ mà log collector muốn parse. logging.structured.format.console (và logging.structured.format.file) nhận ecs, gelf hoặc logstash; với ecs, mỗi dòng thành một object có các field @timestamp, log.level, log.logger, process.pid, service.namemessage. Chọn format, thêm field và gửi output đi là phần của chương observability trong khóa Advanced.

Các property logging.* dùng trong bài

PropertyTác dụngMặc định
logging.level.<logger>level cho một logger và mọi logger bên dưới; root cho gốc của câyroot INFO
logging.group.<name>định nghĩa một group logger để logging.level.<name> đặt level cùng lúccó sẵn websql
logging.pattern.consolethay toàn bộ pattern consoleCONSOLE_LOG_PATTERN của Boot
logging.pattern.dateformatchỉ phần ngày giờ trong pattern của Bootyyyy-MM-dd'T'HH:mm:ss.SSSXXX
logging.pattern.levelchỉ phần level trong pattern của Boot%5p
logging.file.nameghi vào file này, tính theo thư mục làm việckhông có
logging.file.pathghi spring.log trong thư mục này; bị bỏ qua khi đã set logging.file.namekhông có
logging.logback.rollingpolicy.max-file-sizesize mà file đang ghi sẽ được rollover10MB
logging.logback.rollingpolicy.total-size-captổng size các archive trước khi file cũ nhất bị xóa0B, không giới hạn
logging.logback.rollingpolicy.max-historysố chu kỳ %d của archive được giữ, không phải số file7
logging.logback.rollingpolicy.file-name-patterntên archive: %d quyết định chu kỳ, %i là số thứ tự, .gz bật nén${LOG_FILE}.%d{yyyy-MM-dd}.%i.gz
logging.logback.rollingpolicy.clean-history-on-startxóa thêm các archive hết hạn lúc khởi độngfalse
logging.structured.format.consoleoutput JSON cho console: ecs, gelf hoặc logstashkhông có
spring.output.ansi.enabledmã màu: detect, always hoặc neverdetect
debug / tracecông tắc --debug--trace ở dạng propertyfalse

FAQ

Mặc định Spring Boot ghi log ra đâu?

Chỉ ra console, root logger ở INFO, theo pattern trong defaults.xml. Không có file nào được ghi cho tới khi bạn set logging.file.name hoặc logging.file.path. Khi đã set, console vẫn tiếp tục in và file nhận đúng các event đó theo pattern của file.

SLF4J và Logback khác nhau thế nào?

SLF4J là API mà code của bạn gọi: Logger, LoggerFactory, MDC. Logback là implementation đứng sau, kiểm tra level, định dạng dòng log và ghi chúng ra appender. Thư viện phụ thuộc vào SLF4J để không bao giờ ép implementation lên bạn, và implementation mặc định của Spring Boot là Logback 1.5.38.

Vì sao log.debug() không in ra trong Spring Boot?

Vì root level là INFO và package của bạn kế thừa nó. Hãy đặt logging.level.com.yourcompany=DEBUG, hoặc một package cụ thể hơn. --debug không giúp được: nó chỉ nâng một nhóm logger framework cố định, và com.example.demo vẫn ở INFO khi đo ở phần trên. Nếu một logger cụ thể hơn được đặt cao hơn, ví dụ logging.level.com.yourcompany.billing=WARN, thiết lập đó thắng trong nhánh của nó.

Nên dùng logback.xml hay logback-spring.xml?

logback-spring.xml. Logback tự đọc logback.xml trước khi Spring Boot sẵn sàng, nên <springProfile><springProperty> bị bỏ qua ở lần đọc đầu, kèm cảnh báo. Boot tự nạp logback-spring.xml sau khi environment và profile đã được xác định, nên các extension của nó hoạt động.

Vì sao max-history không giới hạn số file log?

Nó đếm số chu kỳ của pattern ngày %d, không đếm file. Với pattern theo ngày của Boot, max-history=7 giữ khoảng một tuần archive, bất kể mỗi ngày có bao nhiêu file; ở bài test theo từng giây phía trên, max-history=2 để lại tám file. Muốn giới hạn dung lượng ổ đĩa, hãy đặt logging.logback.rollingpolicy.total-size-cap.

Có đổi log level mà không cần restart application được không?

Không bằng logging.level.*, vì property này chỉ được áp lúc khởi động. Spring Boot Actuator có endpoint loggers để đọc và đổi level trong application đang chạy; Actuator sẽ được nói ở Chương 7.

Kết luận

Logging trong Spring Boot là một pipeline với sự phân công rõ ràng. Code và các thư viện của bạn gọi một API — gọi thẳng SLF4J, hoặc JUL, Log4j API và Commons Logging thông qua bridge và adapter — còn Logback quyết định dòng nào được in và in ra đâu. Level được kế thừa xuống theo cây tên logger, nên logging.level.<package> và log group điều khiển cả một vùng cùng lúc, trong khi --debug--trace chỉ đụng tới logger của framework. Dòng log mặc định gói timestamp, level, PID, tên application, thread và tên logger rút gọn vào một pattern mà bạn có thể thay từng phần. logging.file.name thêm một file được xoay vòng theo size và ngày, trong đó max-history đếm chu kỳ còn total-size-cap mới thực sự giới hạn ổ đĩa. Khi property không đủ, logback-spring.xml mang lại cấu hình biết profile, và vì code chỉ biết SLF4J, đổi sang cả Log4j2 cũng chỉ cách một lần sửa file build — miễn là Logback rời khỏi classpath.

Vậy là khép lại Chương 2. Chương 3 chuyển từ chuyện application chạy thế nào sang chuyện nó cung cấp gì cho thế giới bên ngoài, và bắt đầu bằng những quy tắc nền mà mọi API đều dựa vào: HTTP và REST căn bản — method, status code, thiết kế URL chuẩn REST.

Bài viết liên quan

[Spring Boot Basics] Validation trong Spring Boot: Bean Validation, @Valid và custom validator

Bean Validation trong Spring Boot 4.1.1 với Hibernate Validator 9.1.3, kiểm chứng bằng các lần chạy thật: spring-boot-starter-validation, @NotNull, @NotEmpty và @NotBlank khác nhau ra sao, @Size, @DecimalMin, @Digits, @Email và @Pattern trên DTO record, @Valid với @RequestBody và response 400 mặc định, object lồng nhau và list, validate @PathVariable và @RequestParam cùng cái bẫy 500 của @Validated, validation group, ValidationMessages.properties và Accept-Language, custom ConstraintValidator và constraint liên quan nhiều field, và validation ở service layer.

[Spring Boot Basics] Tài liệu API trong Spring Boot với springdoc-openapi và Swagger UI

springdoc-openapi 3.1.1 trên Spring Boot 4.1.1, kiểm chứng trên jar đang chạy: document OpenAPI 3.1 ở /v3/api-docs, Swagger UI và Try it out, những gì springdoc suy ra từ controller, DTO record và Bean Validation constraint, response nào của @RestControllerAdvice được thêm vào, @Tag, @Operation, @ApiResponse, @Parameter và @Schema trên record, bean OpenAPI và customizer toàn cục, GroupedOpenApi, property của springdoc và tắt tài liệu trong profile prod.

[Spring Boot Basics] Cấu trúc project Spring Boot: Gradle và Maven, @SpringBootApplication và file JAR

Toàn bộ file mà Spring Initializr sinh ra trên Spring Boot 4.1.1 và Java 21: cấu trúc thư mục, wrapper của Gradle và Maven, build.gradle đọc từng dòng đặt cạnh pom.xml tương ứng, so sánh Maven và Gradle công bằng, BOM giúp bỏ version number, @SpringBootApplication tách thành ba meta-annotation kèm lỗi 404 do sai base package, và giải phẫu file JAR chạy được.

[Spring Boot Basics] Cài đặt Spring Boot: JDK, IDE, Spring Initializr và ứng dụng đầu tiên

Cài JDK 21 trên macOS, Windows và Linux, sửa JAVA_HOME trỏ nhầm JDK, so sánh IntelliJ IDEA với VS Code, tạo project Spring Boot 4.1.1 bằng Spring Initializr hoặc một câu lệnh curl, chạy bằng Gradle wrapper, đọc log khởi động từng dòng, viết @RestController trả về JSON, đổi server.port và xử lý năm lỗi mà người mới nào cũng gặp.