Skip to content

Exception & Logging — Bộ “Sinh Tồn” Cho Junior Developer

By Nhân Nguyễn on Apr 28, 2026

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)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:

  1. Nuốt exception (Swallow exception): Khối catch rỗng bắt mọi Exception như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.
  2. Bắt quá rộng: Bắt Exception là 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.
  3. 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ì.
  4. 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: withdraw thành công nhưng deposit lỗi, tiền đã trừ khỏi tài khoản A nhưng chưa vào B — tiền biến mất.
  5. 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ĩaCách dùng
ErrorVấn đề của JVMKhô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ìnhDùng cho các custom exception liên quan đến business rule, validation
Checked ExceptionLỗi có thể khôi phục đượcI/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:

  1. Kế thừa RuntimeException trừ khi caller thực sự cần phải handle theo kiểu checked.
  2. Luôn có errorCode dạng machine-readable (VD: "ACC_NOT_FOUND") để client hoặc hệ thống giám sát dễ dàng phân loại.
  3. Giữ nguyên nhân gốc (cause): Constructor phải có tham số Throwable cause để không làm mất stack trace.
  4. Thông điệp phải chứa ngữ cảnh: ID, giá trị, expected vs actual.
  5. 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:

  1. 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.
  2. Làm mất cause: throw new RuntimeException(e.getMessage()); – Luôn dùng constructor có tham số Throwable.
  3. Bắt quá rộng: catch (Exception e) để che giấu NullPointerException – Hãy bắt cụ thể (ví dụ: catch (TimeoutException e) cho retry logic).
  4. Dùng e.printStackTrace(): Thông tin in ra System.err, không vào file log, vô dụng trên production. Luôn dùng log.error().
  5. 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ì.
  6. 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.
  7. 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ùng Optional hoặ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.

LevelMục đích sử dụngGhi log trên Production?
ERRORLỗi không mong đợi, cần người trực xử lý ngayLuôn luôn
WARNTì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áiLuôn luôn
INFOSự kiện nghiệp vụ quan trọng (tạo đơn, đăng nhập, chuyển tiền)Luôn luôn
DEBUGThông tin chi tiết phục vụ developer debug❌ Chỉ bật khi cần
TRACEChi 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ị traceId từ 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)

  1. 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.

  2. 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 log INFO vô giá trị trên production. Giải pháp: log tổng quan ở đầu/cuối và chỉ log chi tiết ở mức DEBUG.

  3. 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).

  4. Dùng ERROR cho 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ùng WARN để tránh làm hệ thống giám sát của bạn báo động giả.

  5. Dùng System.out.printlnprintStackTrace(): 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.

  6. 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:

  1. Kiểm tra: Có ai đang log password hoặc PII không?
  2. Nâng cấp: Thêm MDC Filter cho traceId.
  3. Cấu trúc: Thiết lập JSON logging và một @ControllerAdvice toàn cục.
  4. 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.

Hãy kết nối

Nếu bạn quan tâm tới việc hợp tác, có câu hỏi về bài viết, hay chỉ đơn giản muốn chuyện trò về backend — cứ ping mình nhé.