Exception & Logging — Bộ “Sinh Tồn” Cho Junior Developer
Exception & Logging — Bộ “Sinh Tồn” Cho Junior Developer
Xin chào các bạn! Trong bài viết này, chúng ta sẽ cùng đào sâu vào hai chủ đề tưởng chừng đơn giản nhưng lại là nguyên nhân của 80% thời gian debug trên production: Xử lý ngoại lệ (Exception Handling) và Ghi log (Logging).
Đây không phải là một bài lý thuyết khô khan. Toàn bộ nội dung được đúc kết từ những câu chuyện thật, những lỗi sai “cười ra nước mắt” của các bạn junior mà tôi đã từng chứng kiến, cùng với những pattern chuẩn mực để giúp bạn tránh khỏi những cơn ác mộng lúc 3 giờ sáng.
Nếu bạn đã sẵn sàng, hãy bắt đầu.
Ba Câu Chuyện Cảnh Tỉnh
Tôi muốn mở đầu bằng ba tình huống có thật trong nghề. Chúng sẽ cho bạn thấy tại sao việc hiểu đúng về exception và logging lại quan trọng đến vậy.
Chuyện 1: Một bạn junior viết catch (Exception e) rồi ghi log log.error("error") — không hề có stack trace. Production gặp lỗi, trong file log chỉ vỏn vẹn một dòng chữ “error”. Sếp gọi lúc 2 giờ sáng, cả team mất 8 tiếng chỉ để tái hiện lại lỗi vì không có bất kỳ manh mối nào.
Chuyện 2: Một bạn khác ghi log thế này để debug: log.info("User password: " + user.getPassword()). Code lên production, file log có 50.000 dòng chứa mật khẩu của khách hàng. Bộ phận kiểm toán phát hiện, công ty bị phạt nặng, bạn junior bị kỷ luật.
Chuyện 3: Một bạn viết try { ... } catch (Exception e) { e.printStackTrace(); }. Stack trace được in ra System.err, không hề đi vào file log. Khi production gặp lỗi, container đã chết, chúng tôi SSH vào cũng vô vọng vì log không có gì.
Ba câu chuyện trên đều bắt nguồn từ một gốc rễ chung: thiếu một mô hình tinh thần (mental model) đúng đắn về exception và logging. Bài viết này sẽ giúp bạn lấp đầy khoảng trống đó.
Phần A – Xử Lý Ngoại Lệ Đúng Cách
1. “Code Chạy Được” Nhưng Production Vẫn Chết
Hãy nhìn vào một đoạn code junior thường viết — nó chạy được trên local, nhưng sẽ là thảm họa trên production:
// ❌ Code junior — tưởng ổn nhưng đầy lỗ hổng
public class TransferService {
public void transfer(String fromId, String toId, BigDecimal amount) {
try {
Account from = accountRepo.findById(fromId);
Account to = accountRepo.findById(toId);
from.withdraw(amount);
to.deposit(amount);
accountRepo.save(from);
accountRepo.save(to);
} catch (Exception e) {
// Im lặng, chẳng ai biết chuyện gì đã xảy ra
}
}
}
Nhìn qua thì có vẻ có try-catch là an toàn. Nhưng một senior sẽ thấy ngay 5 vấn đề chí mạng:
- Nuốt exception (Swallow exception): Khối catch rỗng bắt mọi
Exceptionnhưng không làm gì cả. Lỗi biến mất hoàn toàn, caller tưởng mọi thứ thành công. - Bắt quá rộng: Bắt
Exceptionlà một anti-pattern vì nó che giấu cả những lỗi logic nhưNullPointerException. Tệ hơn, nếu bắt cảThrowable, bạn còn động vào các lỗi JVM (Error) mà đúng ra không nên đụng tới. - Không có log: Ngay cả khi có log, người đến sau không cách nào biết ngoại lệ thực sự là gì.
- Không ném lại (no re-throw): Caller không biết giao dịch thất bại. Trường hợp xấu nhất:
withdrawthành công nhưngdepositlỗi, tiền đã trừ khỏi tài khoản A nhưng chưa vào B — tiền biến mất. - Không có transaction boundary: Nếu
save(to)thất bại,save(from)đã được commit trước đó (vì không có@Transactional), tiền thực sự biến mất khỏi hệ thống.
Phiên bản refactor chuẩn mực:
@Service
public class TransferService {
private static final Logger log = LoggerFactory.getLogger(TransferService.class);
private final AccountRepository accountRepo;
public TransferService(AccountRepository accountRepo) {
this.accountRepo = accountRepo;
}
@Transactional
public TransferResult transfer(String fromId, String toId, BigDecimal amount)
throws AccountNotFoundException, InsufficientFundsException {
Account from = accountRepo.findById(fromId)
.orElseThrow(() -> new AccountNotFoundException(fromId));
Account to = accountRepo.findById(toId)
.orElseThrow(() -> new AccountNotFoundException(toId));
from.withdraw(amount); // Tự ném InsufficientFundsException nếu không đủ
to.deposit(amount);
accountRepo.save(from);
accountRepo.save(to);
log.info("Transfer success: from={}, to={}, amount={}", fromId, toId, amount);
return new TransferResult(from.getId(), to.getId(), amount);
}
}
Điểm khác biệt:
- Sử dụng checked exception cụ thể (
AccountNotFoundException,InsufficientFundsException) để caller biết chính xác lỗi gì. @Transactionalđảm bảo tính nguyên tử của giao dịch.- Log thành công với đầy đủ ngữ cảnh (
from,to,amount). - Không cố gắng catch trong service — hãy để ngoại lệ “nổi bong bóng” lên tầng xử lý chung (
@ControllerAdvice).
2. Mô Hình Tinh Thần: Phân Cấp Exception
Hiểu rõ hệ thống phân cấp exception trong Java là bước đầu tiên để xử lý chúng đúng đắn.
Throwable
├── Error ← Đừng bao giờ cố gắng bắt
│ ├── OutOfMemoryError ← JVM hết bộ nhớ
│ ├── StackOverflowError ← Đệ quy vô hạn
│ └── NoClassDefFoundError ← Không thể load class
│
└── Exception
├── RuntimeException ← UNCHECKED – không bắt buộc phải handle
│ ├── NullPointerException (NPE – bug logic)
│ ├── IllegalArgumentException (lỗi validation)
│ ├── IllegalStateException (đối tượng ở trạng thái không hợp lệ)
│ └── ... (90% custom exception nên extend class này)
│
└── (Checked Exception) ← CHECKED – bắt buộc phải handle hoặc throws
├── IOException (thao tác file, mạng)
├── SQLException (cơ sở dữ liệu)
└── InterruptedException (đa luồng)
Nguyên tắc vàng:
| Loại | Ý nghĩa | Cách dùng |
|---|---|---|
Error | Vấn đề của JVM | Không catch (trừ khi bạn thực sự biết mình đang làm gì) |
RuntimeException (unchecked) | Bug logic, lỗi lập trình | Dùng cho các custom exception liên quan đến business rule, validation |
| Checked Exception | Lỗi có thể khôi phục được | I/O, DB, external service – caller có thể chọn cách xử lý |
Checked vs Unchecked – Cuộc tranh luận muôn thuở
Trường phái Checked (truyền thống) cho rằng việc ép buộc caller xử lý lỗi giúp code an toàn và tự mô tả. Nhưng trong các hệ thống hiện đại (đặc biệt là Spring), unchecked exception chiếm ưu thế vì:
- Checked exception khiến signature của phương thức trở nên cồng kềnh (
throws A, B, Cở khắp nơi). - Caller thường bị buộc phải bắt và wrap lại thành RuntimeException, tạo ra code nhiễu.
Quy tắc thực tế:
- Custom business exception → unchecked (extends
RuntimeException). - Các lỗi I/O tầng thấp → có thể giữ checked (như
IOException). - Tại tầng ứng dụng, bắt checked rồi wrap thành domain exception unchecked của bạn.
// ✅ Pattern: Bắt checked ở tầng thấp, ném domain exception unchecked cho tầng trên
public class FileStorageService {
public byte[] read(String path) {
try {
return Files.readAllBytes(Path.of(path));
} catch (IOException e) {
throw new StorageException("Cannot read file: " + path, e); // Giữ cause!
}
}
}
public class StorageException extends RuntimeException {
public StorageException(String message, Throwable cause) {
super(message, cause); // ← không được quên cause
}
}
3. Try-Catch-Finally và Try-With-Resources
Cách cũ – verbose và dễ lỗi:
FileInputStream fis = null;
try {
fis = new FileInputStream("file.txt");
// xử lý
} catch (FileNotFoundException e) {
log.error("File not found", e);
throw new ApplicationException(e);
} finally {
if (fis != null) {
try { fis.close(); } catch (IOException e) { log.warn("Failed to close", e); }
}
}
Try-With-Resources (Java 7+) – Giải pháp thanh lịch:
Mọi đối tượng implement interface AutoCloseable đều có thể được khai báo trong try(...). JVM sẽ tự động gọi close() cho bạn, kể cả khi có exception, và theo đúng thứ tự ngược lại khi có nhiều resource.
try (FileInputStream fis = new FileInputStream("file.txt");
BufferedReader reader = new BufferedReader(new InputStreamReader(fis))) {
String line;
while ((line = reader.readLine()) != null) {
process(line);
}
} catch (IOException e) {
log.error("File processing failed", e);
throw new ProcessingException("Failed to process file.txt", e);
}
// fis và reader được đóng tự động, không cần finally
Multi-catch (Java 7+) – Gọn gàng hơn:
try {
riskyOperation();
} catch (IOException | SQLException e) { // Một khối cho nhiều loại
log.error("External resource failed", e);
throw new RuntimeException(e);
}
Cạm bẫy finally: Tuyệt đối không đặt return trong finally. Nó sẽ ghi đè lên giá trị return của try hoặc catch, gây ra lỗi logic khó phát hiện. finally chỉ nên dùng cho việc dọn dẹp (cleanup).
4. Thiết Kế Custom Exception Chuẩn Mực
Một hệ thống tốt cần có một hệ thống phân cấp exception rõ ràng. Ví dụ trong domain ngân hàng:
// Lớp cơ sở cho tất cả exception của domain
public abstract class BankingException extends RuntimeException {
private final String errorCode;
protected BankingException(String errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
protected BankingException(String errorCode, String message, Throwable cause) {
super(message, cause); // Luôn truyền cause để giữ stack trace gốc
this.errorCode = errorCode;
}
public String getErrorCode() { return errorCode; }
}
// Các exception cụ thể
public class AccountNotFoundException extends BankingException {
public AccountNotFoundException(String accountId) {
super("ACC_NOT_FOUND", "Account not found: " + accountId);
}
}
public class InsufficientFundsException extends BankingException {
private final BigDecimal required;
private final BigDecimal available;
public InsufficientFundsException(String accountId, BigDecimal required, BigDecimal available) {
super("INSUFFICIENT_FUNDS",
String.format("Account %s: required %s, available %s", accountId, required, available));
this.required = required;
this.available = available;
}
public BigDecimal getRequired() { return required; }
public BigDecimal getAvailable() { return available; }
}
Quy tắc khi viết custom exception:
- Kế thừa
RuntimeExceptiontrừ khi caller thực sự cần phải handle theo kiểu checked. - Luôn có
errorCodedạng machine-readable (VD:"ACC_NOT_FOUND") để client hoặc hệ thống giám sát dễ dàng phân loại. - Giữ nguyên nhân gốc (cause): Constructor phải có tham số
Throwable causeđể không làm mất stack trace. - Thông điệp phải chứa ngữ cảnh: ID, giá trị, expected vs actual.
- Bổ sung trường dữ liệu nếu cần để caller có thể truy xuất chi tiết mà không cần parse message.
Dùng với Spring @ControllerAdvice:
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(AccountNotFoundException.class)
public ResponseEntity<ErrorResponse> handleAccountNotFound(AccountNotFoundException e) {
log.warn("Account not found: {}", e.getMessage()); // WARN vì đây là lỗi từ client
return ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse(e.getErrorCode(), e.getMessage()));
}
@ExceptionHandler(InsufficientFundsException.class)
public ResponseEntity<ErrorResponse> handleInsufficientFunds(InsufficientFundsException e) {
log.warn("Insufficient funds: {}", e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_REQUEST)
.body(new ErrorResponse(e.getErrorCode(), e.getMessage()));
}
@ExceptionHandler(Exception.class) // Lưới bắt tất cả các lỗi còn lại
public ResponseEntity<ErrorResponse> handleUnexpected(Exception e) {
log.error("Unexpected error", e); // Log full stack trace cho team dev
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(new ErrorResponse("INTERNAL_ERROR", "Internal server error"));
// ⚠️ TUYỆT ĐỐI không trả stack trace ra ngoài response – lỗ hổng bảo mật
}
}
5. 7 Sai Lầm Chết Người Của Junior (Và Cách Sửa)
Đây là “bộ sưu tập” những anti-pattern phổ biến nhất mà tôi từng thấy trong code review:
- Nuốt exception:
catch (Exception e) {}– Luôn luôn phải làm gì đó: log và/hoặc ném lại một ngoại lệ có ý nghĩa. - Làm mất cause:
throw new RuntimeException(e.getMessage());– Luôn dùng constructor có tham sốThrowable. - Bắt quá rộng:
catch (Exception e)để che giấuNullPointerException– Hãy bắt cụ thể (ví dụ:catch (TimeoutException e)cho retry logic). - Dùng
e.printStackTrace(): Thông tin in raSystem.err, không vào file log, vô dụng trên production. Luôn dùnglog.error(). - Khai báo
throws Exception: Lười biếng và vô trách nhiệm, caller không biết phải chuẩn bị cho lỗi gì. - Ném lại RuntimeException mà không thêm ngữ cảnh:
throw new RuntimeException(e)không giúp ích gì cho việc debug. Hãy thêm thông tin:"Failed to update account " + accountId. - Dùng exception cho luồng điều khiển:
try { repo.findById(id).get(); } catch(...) { return false; }– vừa chậm (vì việc tạo stack trace rất đắt đỏ) vừa sai về mặt ngữ nghĩa. Hãy dùngOptionalhoặc kiểm tra boolean.
Phần B – Logging Không Chỉ Để “Xem Cho Vui”
1. Tại Sao Một Dòng Log Lại Quan Trọng?
Hãy tưởng tượng 3 giờ sáng, PagerDuty réo vang: “Payment service độ trễ P99 lên 8 giây”. Bạn SSH vào server và thấy:
ERROR
ERROR
process failed
something went wrong
Vô vọng! Bạn không biết lỗi gì, của ai, ở transaction nào. Ngược lại, một file log đúng chuẩn sẽ như thế này:
2026-04-25T03:00:12.453Z INFO [thread-pool-3] [traceId=abc123] [userId=USER_5523]
TransferService.transfer: Initiating transfer from=ACC_001 to=ACC_009 amount=500000
2026-04-25T03:00:12.523Z ERROR [thread-pool-3] [traceId=abc123] [userId=USER_5523]
TransferService.transfer: Transfer failed
com.bank.exceptions.InsufficientFundsException: Account ACC_001: required 500000, available 200000
at com.bank.services.TransferService.transfer(TransferService.java:45)
...
Có timestamp, thread, traceId, userId, class, message và stack trace đầy đủ – bạn có thể truy vết toàn bộ hành trình của một yêu cầu.
2. 5 Cấp Độ Log Bạn Phải Thuộc Lòng
Việc chọn đúng level cho mỗi dòng log là kỹ năng sống còn.
| Level | Mục đích sử dụng | Ghi log trên Production? |
|---|---|---|
ERROR | Lỗi không mong đợi, cần người trực xử lý ngay | ✅ Luôn luôn |
WARN | Tình huống bất thường nhưng có thể khôi phục (retry, fallback) hoặc cảnh báo suy thoái | ✅ Luôn luôn |
INFO | Sự kiện nghiệp vụ quan trọng (tạo đơn, đăng nhập, chuyển tiền) | ✅ Luôn luôn |
DEBUG | Thông tin chi tiết phục vụ developer debug | ❌ Chỉ bật khi cần |
TRACE | Chi tiết đến từng bước nhỏ, hiếm khi dùng | ❌ Không |
Lỗi phổ biến của junior: Dùng INFO cho mọi thứ. Hậu quả: file log phình to, những lỗi ERROR bị chìm trong biển nhiễu, không ai phát hiện ra.
3. SLF4J + Logback: Bộ Đôi Quyền Lực
SLF4J là một logging facade, Logback là implementation mặc định trong Spring Boot. Chúng cho phép bạn viết code logging mà không phụ thuộc vào thư viện cụ thể nào.
Cú pháp đúng – Parameterized Logging:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class TransferService {
private static final Logger log = LoggerFactory.getLogger(TransferService.class);
public void transfer(String fromId, String toId, BigDecimal amount) {
// ✅ Parameterized: chỉ build string nếu INFO level được bật
log.info("Transfer initiated: from={}, to={}, amount={}", fromId, toId, amount);
try {
// logic
log.info("Transfer success: txId={}", txId);
} catch (Exception e) {
// ✅ Truyền exception vào tham số cuối cùng để log toàn bộ stack trace
log.error("Transfer failed unexpectedly: from={}, to={}", fromId, toId, e);
throw e;
}
}
}
Quy tắc cú pháp:
- KHÔNG dùng phép nối chuỗi:
log.debug("User: " + user.getName())– việc nối chuỗi luôn xảy ra ngay cả khi DEBUG bị tắt, gây lãng phí. - Dùng placeholder
{}: Chỉ format khi level được bật. - Luôn đặt exception ở cuối:
log.error("message", e)– SLF4J sẽ tự động in stack trace của nó.
Mẹo Lombok @Slf4j:
@Slf4j // Tự động tạo `private static final Logger log = ...`
@Service
public class TransferService {
public void transfer() {
log.info("Working...");
}
}
4. Cấu Hình logback-spring.xml Chuẩn Cho Mọi Môi Trường
Đây là một file cấu hình mẫu dùng cho cả dev và production.
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- Appender cho Console (dev) -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-}] %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- Appender cho File, tự động roll theo ngày (prod) -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/app.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>30</maxHistory>
<totalSizeCap>10GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-}] [%X{userId:-}] %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- Appender JSON cho ELK/Splunk (Production-Grade) -->
<appender name="JSON" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/app-json.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/app-json.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
</rollingPolicy>
<encoder class="net.logstash.logback.encoder.LogstashEncoder">
<includeMdcKeyName>traceId</includeMdcKeyName>
<includeMdcKeyName>userId</includeMdcKeyName>
</encoder>
</appender>
<!-- Cấu hình theo profile Spring -->
<springProfile name="dev">
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
<logger name="com.mybank" level="DEBUG"/>
</springProfile>
<springProfile name="prod">
<root level="INFO">
<appender-ref ref="FILE"/>
<appender-ref ref="JSON"/>
</root>
<logger name="com.mybank" level="INFO"/>
<logger name="org.hibernate.SQL" level="WARN"/> <!-- Tránh log SQL trong production -->
</springProfile>
</configuration>
Giải thích ý nghĩa các thành phần trong pattern:
%d{...}– Timestamp.[%thread]– Tên thread.%-5level– Log level, canh trái 5 ký tự.[%X{traceId:-}]– Lấy giá trịtraceIdtừ MDC (sẽ nói ngay bên dưới). Dấu:-nghĩa là hiển thị ’-’ nếu không có.%logger{36}– Tên class, tối đa 36 ký tự.%msg– Nội dung log.%n– Ký tự xuống dòng.
5. MDC: “Vũ Khí Tối Thượng” Để Truy Vết Request
Mapped Diagnostic Context (MDC) cho phép bạn gắn các thông tin ngữ cảnh (như traceId, userId) vào tất cả các dòng log trong cùng một request mà không cần truyền tham số qua từng phương thức. Đây là tính năng đơn giản nhưng thay đổi cuộc chơi.
Cách hoạt động: MDC hoạt động như một ThreadLocal Map. Khi một HTTP request đến, bạn đặt thông tin vào MDC. Mọi dòng log trong thread đó đều tự động có các thông tin đó.
Triển khai qua một Filter:
@Component
public class RequestLoggingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
String traceId = req.getHeader("X-Trace-Id");
if (traceId == null) traceId = UUID.randomUUID().toString();
String userId = extractUserId(req); // Lấy từ JWT/Session
try {
MDC.put("traceId", traceId);
MDC.put("userId", userId);
chain.doFilter(req, res);
} finally {
MDC.clear(); // QUAN TRỌNG: Xóa để tránh rò rỉ dữ liệu sang request khác
}
}
}
Ở bất kỳ đâu trong code, bạn chỉ cần log bình thường:
log.info("Transfer initiated");
// Output: 2026-04-25T03:00:12 INFO [traceId=abc123] [userId=USER_5523] ... Transfer initiated
Trên Kibana/Splunk, bạn chỉ cần search traceId:abc123 là thấy toàn bộ hành trình của request, từ lúc vào filter, qua service, tới khi phản hồi.
Cạm bẫy với Thread Pool: Vì MDC dùng ThreadLocal, khi bạn submit task sang một thread khác (ví dụ CompletableFuture.runAsync(...)), ngữ cảnh MDC sẽ không tự động truyền theo. Bạn cần làm thủ công:
Map<String, String> contextMap = MDC.getCopyOfContextMap();
executor.submit(() -> {
MDC.setContextMap(contextMap);
try {
doWork();
} finally {
MDC.clear();
}
});
6. Những “Tử Huyệt” Khi Logging (Và Luật Pháp)
-
Log dữ liệu nhạy cảm (PII):
// ❌ ĐỪNG BAO GIỜ! log.info("User login: user={}, password={}", username, password); log.debug("User details: {}", user); // user.toString() có thể chứa mật khẩu, số thẻ, số CCCD...Tối kỵ với dữ liệu ngân hàng: Mật khẩu, mã PIN, OTP, số thẻ tín dụng đầy đủ, số CCCD, số dư tài khoản. Hậu quả vi phạm: phạt GDPR lên tới 20 triệu Euro hoặc 4% doanh thu toàn cầu, mất chứng nhận PCI-DSS. Luôn luôn phải làm sạch hoặc che dấu dữ liệu trước khi ghi log.
-
Log trong vòng lặp:
log.info("Processing item " + i)trong vòng lặp 1 triệu phần tử sẽ tạo ra 1 triệu dòng logINFOvô giá trị trên production. Giải pháp: log tổng quan ở đầu/cuối và chỉ log chi tiết ở mứcDEBUG. -
Log một exception nhiều lần: Bắt và log ở Repository, rồi lại log ở Service, rồi lại log ở Controller. Kết quả là một lỗi tạo ra 3 stack trace. Nguyên tắc: Chỉ log exception ở tầng cao nhất, nơi bạn xử lý nó (thường là
@ControllerAdvice). -
Dùng
ERRORcho lỗi từ client: Validation fail, account not found là các lỗi thuộc về phía client (HTTP 4xx). Hãy dùngWARNđể tránh làm hệ thống giám sát của bạn báo động giả. -
Dùng
System.out.printlnvàprintStackTrace(): Mọi thứ đều nên đi qua hệ thống logging chính thức để được quản lý, lọc và lưu trữ tập trung. -
Log thiếu stack trace:
log.error("Error: " + e.getMessage())làm mất toàn bộ thông tin quý giá nhất. Luôn luôn làlog.error("Context message", e).
Nâng Cấp: Structured Logging Cho Production
Để các nền tảng như ELK (Elasticsearch, Logstash, Kibana) hay Splunk có thể phân tích log một cách thông minh, bạn nên ghi log dưới định dạng JSON thay vì text thuần.
Thêm dependency logstash-logback-encoder và cấu hình appender. Đầu ra JSON sẽ như thế này:
{
"@timestamp": "2026-04-25T03:00:12.453Z",
"level": "ERROR",
"logger_name": "com.bank.TransferService",
"thread_name": "http-nio-8080-exec-1",
"message": "Transfer failed",
"traceId": "abc123",
"userId": "USER_5523",
"stack_trace": "..."
}
Bây giờ, một câu query trên Kibana như level:ERROR AND traceId:abc123 sẽ trả về kết quả chính xác trong tích tắc.
Lời Kết và Hành Trang Tiếp Theo
Exception và Logging không chỉ là những dòng code. Chúng là hệ thống thần kinh của ứng dụng. Nếu bạn viết sai, bạn sẽ mù mờ khi ứng dụng gặp vấn đề trên môi trường thực. Nếu bạn viết đúng, bạn sẽ có một debugger vô hình, mạnh mẽ và luôn sẵn sàng.
Hãy bắt đầu thực hành ngay trên dự án của bạn:
- Kiểm tra: Có ai đang log password hoặc PII không?
- Nâng cấp: Thêm MDC Filter cho
traceId. - Cấu trúc: Thiết lập JSON logging và một
@ControllerAdvicetoàn cục. - Dọn dẹp: Săn lùng và refactor 7 anti-pattern đã liệt kê.
Hy vọng bài viết này đã cung cấp cho bạn một nền tảng vững chắc. Hãy nhớ, mỗi dòng log là một câu chuyện – hãy viết nó sao cho người trực ca đêm sau này (có thể là chính bạn) phải cảm ơn bạn.