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.
![]()
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:
\--- 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.6Bạ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:
| Jar | Vai trò |
|---|---|
slf4j-api 2.0.18 | facade: Logger, LoggerFactory, MDC |
logback-classic 1.5.38 | provider của SLF4J: logger, level, pattern layout |
logback-core 1.5.38 | appender, encoder và rolling policy |
jul-to-slf4j 2.0.18 | bridge từ java.util.logging sang SLF4J |
log4j-to-slf4j 2.25.5 | bridge từ Log4j API sang SLF4J |
log4j-api 2.25.5 | chính Log4j API, để thư viện viết theo nó vẫn chạy được |
commons-logging 1.3.6 | API 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-slf4jlà một provider của Log4j API mà logger bên trong chính là logger của SLF4J.jul-to-slf4jcung cấpSLF4JBridgeHandler, 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:
@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");
}
}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 LoggingBố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:
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:
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 debugFINE 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.

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:
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);
}
}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;LoggerFactorytrả 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.Loggerhayorg.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:
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ì:
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: " + carttrước khidebug()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ạychecksum(), 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:
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: 2Khi 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ọi | Có 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:
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:
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:
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:
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ềnelà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:
| Level | Dùng cho |
|---|---|
TRACE | chi tiết từng bước; mặc định tắt ở mọi nơi |
DEBUG | thông tin chẩn đoán cho developer |
INFO | cá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 |
ERROR | một thao tác đã thất bại |
OFF | khô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") và 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:
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-1001logging.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:
logging.level.root=WARNLầ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:
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-1001Theo chiều ngược lại, chỉ cho các package của bạn:
logging.level.com.example=DEBUG2026-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-1001Cả 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:
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 OKRequest, 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:
logging.level.com.example.demo=DEBUG
logging.level.com.example.demo.billing=WARN2026-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-1001OrderService 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.

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:
logging.group.app=com.example.demo.orders,com.example.demo.billing
logging.level.app=DEBUGLầ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:
| Group | Các logger |
|---|---|
web | org.springframework.core.codec, org.springframework.http, org.springframework.web, org.springframework.boot.actuate.endpoint.web, org.springframework.boot.web.servlet.ServletContextInitializerBeans |
sql | org.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ý:
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:
| Flag | Logger bị thay đổi | Output của web app, khởi động cộng 1,5 giây nghỉ |
|---|---|---|
| không có | — | 8 dòng |
--debug hoặc debug=true | group web và sql, cùng org.springframework.boot → DEBUG | 424 dòng, 21 dòng DEBUG, cộng báo cáo conditions |
--trace hoặc trace=true | org.springframework, org.apache.tomcat, org.apache.catalina, org.eclipse.jetty, org.hibernate.tool.hbm2ddl → TRACE | 2.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.demovẫn ở INFO trong cả hai lần chạy, và dòng DEBUGRunning with Spring Boot v4.1.1, Spring v7.0.9— xuất hiện ngay khi bạn đặtlogging.level.com.example=DEBUG— không có mặt ở lần nào. Nếulog.debug(...)của chính bạn không in ra,--debugsẽ không cứu được. --tracelà 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:
<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:
2026-09-11T14:45:17.515+07:00 INFO 47420 --- [demo] [nio-8094-exec-1] o.s.web.servlet.DispatcherServlet : Initializing Servlet 'dispatcherServlet'
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:-...}và${LOG_LEVEL_PATTERN:-%5p}là chỗ móc củalogging.pattern.dateformatvàlogging.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}inspring.application.nametrong ngoặc vuông kèm một dấu cách phía sau.application.propertiesdo 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ự chospring.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-1của Tomcat thànhnio-8094-exec-1, cònmainthành mười một dấu cách cộngmain.%-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òncom.example.demo.billing.BillingService(39 ký tự) thànhc.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%wExin 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:
logging.pattern.console=%d{HH:mm:ss.SSS} %-5level %logger{20} - %msg%n14: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:
logging.pattern.dateformat=yyyy-MM-dd HH:mm:ss.SSS2026-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-1001Log 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:
spring.output.ansi.enabled=always^[[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-10012m 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:
logging.file.name=logs/app.logConsole 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:
./logs/app.log2026-09-11T14:45:27.519+07:00 INFO 47461 --- [demo] [main] com.example.demo.orders.OrderService : Order A-1001 placedDò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:
logging.file.path=logsLần chạy đó tạo ra ./logs/spring.log. Đặt cả hai property cùng lúc — logging.file.name=logs/app.log và logging.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 set | File được ghi |
|---|---|
logging.file.name=logs/app.log | logs/app.log |
logging.file.path=logs | logs/spring.log |
| cả hai | logs/app.log; logging.file.path bị bỏ qua |
| không có cái nào | khô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:
<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:
logging.file.name=logs/app.log
logging.logback.rollingpolicy.max-file-size=10KBls -lgo logs sau đó:
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.gzMườ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:
logging.logback.rollingpolicy.file-name-pattern=logs/app-%d{yyyy-MM-dd}.%i.log
logging.logback.rollingpolicy.total-size-cap=40KBtotal 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.logLầ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 đó:
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=2total 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.logmax-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.

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:
<?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ừEnvironmentcủa Boot sang một variable của Logback.application.propertiescóspring.application.name=demo, nên${APP_NAME}thànhdemocả 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.devlà cấu hình máy local: DEBUG cho application, chỉ console.!devbao 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:
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-1001Pattern 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:
14:47:57.253 WARN [demo] c.e.demo.billing.BillingService - Large payment of 2500000 VND for order A-1001và ghi cùng event đó vào logs/demo.log theo pattern của file:
2026-09-11T14:47:57.253+07:00 WARN [main] com.example.demo.billing.BillingService : Large payment of 2500000 VND for order A-1001Có 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ó:
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ó:
@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:
logging.pattern.console=%d{HH:mm:ss.SSS} %5p [%X{requestId}] %-40.40logger{39} : %m%nHai request, tới /orders/A-1001 và /orders/A-1002, trong đó controller gọi cả hai service:
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-1002Ngoà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:
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:
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-tomcat và spring-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:
\--- 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.5Chiề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:
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.jar và log4j-slf4j2-impl-2.25.5.jar cạnh nhau trong BOOT-INF/lib. Application không khởi động được:
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à:
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.name và message. 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
| Property | Tác dụng | Mặ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ây | root INFO |
logging.group.<name> | định nghĩa một group logger để logging.level.<name> đặt level cùng lúc | có sẵn web và sql |
logging.pattern.console | thay toàn bộ pattern console | CONSOLE_LOG_PATTERN của Boot |
logging.pattern.dateformat | chỉ phần ngày giờ trong pattern của Boot | yyyy-MM-dd'T'HH:mm:ss.SSSXXX |
logging.pattern.level | chỉ phần level trong pattern của Boot | %5p |
logging.file.name | ghi vào file này, tính theo thư mục làm việc | không có |
logging.file.path | ghi spring.log trong thư mục này; bị bỏ qua khi đã set logging.file.name | không có |
logging.logback.rollingpolicy.max-file-size | size mà file đang ghi sẽ được rollover | 10MB |
logging.logback.rollingpolicy.total-size-cap | tổng size các archive trước khi file cũ nhất bị xóa | 0B, không giới hạn |
logging.logback.rollingpolicy.max-history | số chu kỳ %d của archive được giữ, không phải số file | 7 |
logging.logback.rollingpolicy.file-name-pattern | tê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-start | xóa thêm các archive hết hạn lúc khởi động | false |
logging.structured.format.console | output JSON cho console: ecs, gelf hoặc logstash | không có |
spring.output.ansi.enabled | mã màu: detect, always hoặc never | detect |
debug / trace | công tắc --debug và --trace ở dạng property | false |
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> và <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 và --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.