我發現很多程式設計師都不會打日誌。。

程序员鱼皮發表於2024-11-22

大家好,我是程式設計師魚皮。我發現很多程式設計師都不打日誌,有的是 不想 打、有的是 意識不到 要打、還有的是 真不會 打日誌啊!

前段時間的模擬面試中,我問了幾位應屆的 Java 開發同學 “你在專案中是怎麼打日誌的”,得到的答案竟然是 “支支吾吾”、“阿巴阿巴”,更有甚者,竟然表示:直接用 System.out.println() 列印一下吧。。。

我發現很多程式設計師都不會打日誌。。

要知道,日誌是我們系統出現錯誤時,最快速有效的定位工具,沒有日誌給出的錯誤資訊,遇到報錯你就會一臉懵逼;而且日誌還可以用來記錄業務資訊,比如記錄使用者執行的每個操作,不僅可以用於分析改進系統,同時在遇到非法操作時,也能很快找到兇手。

因此,對於程式設計師來說,日誌記錄是重要的基本功。但很多同學並沒有系統學習過日誌操作、缺乏經驗,所以我寫下這篇文章,分享自己在開發專案中記錄日誌的方法和最佳實踐,希望對大家有幫助~

一、日誌記錄的方法

日誌框架選型

有很多 Java 的日誌框架和工具庫,可以幫我們用一行程式碼快速完成日誌記錄。

在學習日誌記錄之前,很多同學應該是透過 System.out.println 輸出資訊來除錯程式的,簡單方便。

但是,System.out.println 存在很嚴重的問題!

我發現很多程式設計師都不會打日誌。。

首先,System.out.println 是一個同步方法,每次呼叫都會導致 I/O 操作,比較耗時,頻繁使用甚至會嚴重影響應用程式的效能,所以不建議在生產環境使用。此外,它只能輸出簡單的資訊到標準控制檯,無法靈活設定日誌級別、格式、輸出位置等。

所以我們一般會選擇專業的 Java 日誌框架或工具庫,比如經典的 Apache Log4j 和它的升級版 Log4j 2,還有 Spring Boot 預設整合的 Logback 庫。不僅可以幫我們用一行程式碼更快地完成日誌記錄,還能靈活調整格式、設定日誌級別、將日誌寫入到檔案中、壓縮日誌等。

可能還有同學聽說過 SLF4J(Simple Logging Facade for Java),看英文名就知道了,這玩意並不是一個具體的日誌實現,而是為各種日誌框架提供簡單統一介面的日誌門面(抽象層)。

啥是門面?

舉個例子,現在我們要記錄日誌了,先聯絡到前臺接待人員 SLF4J,它說必須要讓我們選擇日誌的級別(debug / info / warn / error),然後要提供日誌的內容。確認之後,SLF4J 自己不幹活,屁顛屁顛兒地去找具體的日誌實現框架,比如 Logback,然後由 Logback 進行日誌寫入。

我發現很多程式設計師都不會打日誌。。

這樣做有什麼好處呢?無論我們選擇哪套日誌框架、或者後期要切換日誌框架,呼叫的方法始終是相同的,不用再去更改日誌呼叫程式碼,比如將 log.info 改為 log.printInfo。

既然 SLF4J 只是玩抽象,那麼 Log4j、Log4j 2 和 Logback 應該選擇哪一個呢?

值得一提的是,SLF4J、Log4j 和 Logback 竟然都是同一個作者(俄羅斯程式設計師 Ceki Gülcü)。

首先,Log4j 已經停止維護,直接排除。Log4j 2 和 Logback 基本都能滿足功能需求,那麼就看效能、穩定性和易用性。

  • 從效能來說,Log4j 2 和 Logback 雖然都支援非同步日誌,但是 Log4j 基於 LMAX Disruptor 高效能非同步處理庫實現,效能更高。

  • 從穩定性來說,雖然這些日誌庫都被曝出過漏洞,但 Log4j 2 的漏洞更為致命,姑且算是 Logback 得一分。

  • 從易用性來說,二者差不多,但 Logback 是 SLF4J 的原生實現、Log4j2 需要額外使用 SLF4J 繫結器實現。

再加上 Spring Boot 預設整合了 Logback,如果沒有特殊的效能需求,我會更推薦初學者選擇 Logback,都不用引入額外的庫了~

使用日誌框架

日誌框架的使用非常簡單,一般需要先獲取到 Logger 日誌物件,然後呼叫 logger.xxx(比如 logger.info)就能輸出日誌了。

最傳統的方法就是透過 LoggerFactory 手動獲取 Logger,示例程式碼如下:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class MyService {
private static final Logger logger = LoggerFactory.getLogger(MyService.class);

public void doSomething() {
logger.info("執行了一些操作");
}
}

上述程式碼中,我們透過呼叫日誌工廠並傳入當前類,建立了一個 logger。但由於每個類的類名都不同,我們又經常複製這行程式碼到不同的類中,就很容易忘記修改類名。

所以我們可以使用 this.getClass 動態獲取當前類的例項,來建立 Logger 物件:

public class MyService {
private final Logger logger = LoggerFactory.getLogger(this.getClass());

public void doSomething() {
logger.info("執行了一些操作");
}
}

給每個類都複製一遍這行程式碼,就能愉快地打日誌了。

但我覺得這樣做還是有點麻煩,我連複製貼上都懶得做,怎麼辦?

還有更簡單的方式,使用 Lombok 工具庫提供的 @Slf4j 註解,可以自動為當前類生成一個名為 log 的 SLF4J Logger 物件,簡化了 Logger 的定義過程。示例程式碼如下:

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class MyService {
public void doSomething() {
log.info("執行了一些操作");
}
}

這也是我比較推薦的方式,效率槓槓的。

我發現很多程式設計師都不會打日誌。。

此外,你可以透過修改日誌配置檔案(比如 logback.xmllogback-spring.xml)來設定日誌輸出的格式、級別、輸出路徑等。日誌配置檔案比較複雜,不建議大家去記憶語法,隨用隨查即可。

我發現很多程式設計師都不會打日誌。。

二、日誌記錄的最佳實踐

學習完日誌記錄的方法後,再分享一些我個人記錄日誌的經驗。內容較多,大家可以先了解一下,實際開發中按需運用。

1、合理選擇日誌級別

日誌級別的作用是標識日誌的重要程度,常見的級別有:

  • TRACE:最細粒度的資訊,通常只在開發過程中使用,用於跟蹤程式的執行路徑。

  • DEBUG:除錯資訊,記錄程式執行時的內部狀態和變數值。

  • INFO:一般資訊,記錄系統的關鍵執行狀態和業務流程。

  • WARN:警告資訊,表示可能存在潛在問題,但系統仍可繼續執行。

  • ERROR:錯誤資訊,表示出現了影響系統功能的問題,需要及時處理。

  • FATAL:致命錯誤,表示系統可能無法繼續執行,需要立即關注。

其中,用的最多的當屬 DEBUG、INFO、WARN 和 ERROR 了。

建議在開發環境使用低階別日誌(比如 DEBUG),以獲取詳細的資訊;生產環境使用高階別日誌(比如 INFO 或 WARN),減少日誌量,降低效能開銷的同時,防止重要資訊被無用日誌淹沒。

注意一點,日誌級別未必是一成不變的,假如有一天你的程式出錯了,但是看日誌找不到任何有效資訊,可能就需要降低下日誌輸出級別了。

2、正確記錄日誌資訊

當要輸出的日誌內容中存在變數時,建議使用引數化日誌,也就是在日誌資訊中使用佔位符(比如 {}),由日誌框架在執行時替換為實際引數值。

比如輸出一行使用者登入日誌:

// 不推薦
logger.debug("使用者ID:" + userId + " 登入成功。");

// 推薦
logger.debug("使用者ID:{} 登入成功。", userId);

這樣做不僅讓日誌清晰易讀;而且在日誌級別低於當前記錄級別時,不會執行字串拼接,從而避免了字串拼接帶來的效能開銷、以及潛在的 NullPointerException 問題。所以建議在所有日誌記錄中,使用引數化的方式替代字串拼接。

此外,在輸出異常資訊時,建議同時記錄上下文資訊、以及完整的異常堆疊資訊,便於排查問題:

try {
// 業務邏輯
} catch (Exception e) {
logger.error("處理使用者ID:{} 時發生異常:", userId, e);
}

3、控制日誌輸出量

過多的日誌不僅會佔用更多的磁碟空間,還會增加系統的 I/O 負擔,影響系統效能。

因此,除了根據環境設定合適的日誌級別外,還要儘量避免在迴圈中輸出日誌。

可以新增條件來控制,比如在批次處理時,每處理 1000 條資料時才記錄一次:

if (index % 1000 == 0) {
logger.info("已處理 {} 條記錄", index);
}

或者在迴圈中利用 StringBuilder 進行字串拼接,迴圈結束後統一輸出:

StringBuilder logBuilder = new StringBuilder("處理結果:");
for (Item item : items) {
try {
processItem(item);
logBuilder.append(String.format("成功[ID=%s], ", item.getId()));
} catch (Exception e) {
logBuilder.append(String.format("失敗[ID=%s, 原因=%s], ", item.getId(), e.getMessage()));
}
}
logger.info(logBuilder.toString());

如果引數的計算開銷較大,且當前日誌級別不需要輸出,應該在記錄前進行級別檢查,從而避免多餘的引數計算:

if (logger.isDebugEnabled()) {
logger.debug("複雜物件資訊:{}", expensiveToComputeObject());
}

此外,還可以透過更改日誌配置檔案整體過濾掉特定級別的日誌,來防止日誌刷屏:

<!-- Logback 示例 -->
<appender name="LIMITED" class="ch.qos.logback.classic.AsyncAppender">
<!-- 只允許 INFO 級別及以上的日誌透過 -->
<filter class="ch.qos.logback.classic.filter.ThresholdFilter">
<level>INFO</level>
</filter>
<!-- 配置其他屬性 -->
</appender>

4、把控時機和內容

很多開發者(尤其是線上經驗不豐富的開發者)並沒有養成記錄日誌的習慣,覺得記錄日誌不重要,等到出了問題無法排查的時候才追悔莫及。

一般情況下,需要在系統的關鍵流程和重要業務節點記錄日誌,比如使用者登入、訂單處理、支付等都是關鍵業務,建議多記錄日誌。

對於重要的方法,建議在入口和出口記錄重要的引數和返回值,便於快速還原現場、復現問題。

對於呼叫鏈較長的操作,確保在每個環節都有日誌,以便追蹤到問題所在的環節。

如果你不想區分上面這些情況,我的建議是儘量在前期多記錄一些日誌,後面再慢慢移除掉不需要的日誌。比如可以利用 AOP 切面程式設計在每個業務方法執行前輸出執行資訊:

@Aspect
@Component
public class LoggingAspect {

@Before("execution(* com.example.service..*(..))")
public void logBeforeMethod(JoinPoint joinPoint) {
Logger logger = LoggerFactory.getLogger(joinPoint.getTarget().getClass());
logger.info("方法 {} 開始執行", joinPoint.getSignature().getName());
}
}

利用 AOP,還可以自動列印每個 Controller 介面的請求引數和返回值,這樣就不會錯過任何一次呼叫資訊了。

不過這樣做也有一個很重要的點,注意不要在日誌中記錄了敏感資訊,比如使用者密碼。萬一你的日誌不小心洩露出去,就相當於洩露了大量使用者的資訊。

我發現很多程式設計師都不會打日誌。。

5、日誌管理

隨著日誌檔案的持續增長,會導致磁碟空間耗盡,影響系統正常執行,所以我們需要一些策略來對日誌進行管理。

首先是設定日誌的滾動策略,可以根據檔案大小或日期,自動對日誌檔案進行切分。比如按檔案大小滾動:

<!-- 按大小滾動 -->
<rollingPolicy class="ch.qos.logback.core.rolling.SizeBasedRollingPolicy">
<maxFileSize>10MB</maxFileSize>
</rollingPolicy>

如果日誌檔案大小達到 10MB,Logback 會將當前日誌檔案重新命名為 app.log.1 或其他命名模式(具體由檔名模式決定),然後建立新的 app.log 檔案繼續寫入日誌。

還有按照時間日期滾動:

<!-- 按時間滾動 -->
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/app-%d{yyyy-MM-dd}.log</fileNamePattern>
</rollingPolicy>

上述配置表示每天建立一個新的日誌檔案,%d{yyyy-MM-dd} 表示按照日期命名日誌檔案,例如 app-2024-11-21.log

還可以透過 maxHistory 屬性,限制保留的歷史日誌檔案數量或天數:

<maxHistory>30</maxHistory>

這樣一來,我們就可以按照天數檢視指定的日誌,單個日誌檔案也不會很大,提高了日誌檢索效率。

對於使用者較多的企業級專案,日誌的增長是飛快的,因此建議開啟日誌壓縮功能,節省磁碟空間。

<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
<fileNamePattern>logs/app-%d{yyyy-MM-dd}.log.gz</fileNamePattern>
</rollingPolicy>

上述配置表示:每天生成一個新的日誌檔案,舊的日誌檔案會被壓縮儲存。

除了配置日誌切分和壓縮外,我們還需要定期審查日誌,檢視日誌的有效性和空間佔用情況,從日誌中發現系統的問題、清理無用的日誌資訊等。

如果你想偷懶,也可以寫個自動化清理指令碼,定期清理過期的日誌檔案,釋放磁碟空間。比如:

# 每月清理一次超過 90 天的日誌檔案
find /var/log/myapp/ -type f -mtime +90 -exec rm {} \;

6、統一日誌格式

統一的日誌格式有助於日誌的解析、搜尋和分析,特別是在分散式系統中。

我舉個例子大家就能感受到這麼做的重要性了。

統一的日誌格式:

2024-11-21 14:30:15.123 [main] INFO com.example.service.UserService - 使用者ID:12345 登入成功
2024-11-21 14:30:16.789 [main] ERROR com.example.service.UserService - 使用者ID:12345 登入失敗,原因:密碼錯誤
2024-11-21 14:30:17.456 [main] DEBUG com.example.dao.UserDao - 執行SQL:[SELECT * FROM users WHERE id=12345]
2024-11-21 14:30:18.654 [main] WARN com.example.config.AppConfig - 配置項 `timeout` 使用預設值:3000ms
2024-11-21 14:30:19.001 [main] INFO com.example.Main - 應用啟動成功,耗時:2.34秒

這段日誌整齊清晰,支援按照時間、執行緒、級別、類名和內容搜尋。

不統一的日誌格式:

2024/11/21 14:30 登入成功 使用者ID: 12345
2024-11-21 14:30:16 錯誤 使用者12345登入失敗!密碼不對
DEBUG 執行SQL SELECT * FROM users WHERE id=12345
Timeout = default
應用啟動成功

emm,看到這種日誌我直接原地爆炸!

我發現很多程式設計師都不會打日誌。。

建議每個專案都要明確約定和配置一套日誌輸出規範,確保日誌中包含時間戳、日誌級別、執行緒、類名、方法名、訊息等關鍵資訊。

<!-- 控制檯日誌輸出 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<!-- 日誌格式 -->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>

也可以直接使用標準化格式,比如 JSON,確保所有日誌遵循相同的結構,便於後續對日誌進行分析處理:

<encoder class="net.logstash.logback.encoder.LoggingEventCompositeJsonEncoder">
<!-- 配置 JSON 編碼器 -->
</encoder>

此外,你還可以透過 MDC(Mapped Diagnostic Context)給日誌新增額外的上下文資訊,比如使用者 ID、請求 ID 等,方便追蹤。在 Java 程式碼中,可以為 MDC 變數設定值:

MDC.put("requestId", "666");
MDC.put("userId", "yupi");
logger.info("使用者請求處理完成");
MDC.clear();

對應的日誌配置如下:

<!-- 檔案日誌配置 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<encoder>
<!-- 包含 MDC 資訊 -->
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - [%X{requestId}] [%X{userId}] %msg%n</pattern>
</encoder>
</appender>

這樣,每個請求、每個使用者的操作一目瞭然。

7、使用非同步日誌

對於追求效能的操作,可以使用非同步日誌,將日誌的寫入操作放在單獨的執行緒中,減少對主執行緒的阻塞,從而提升系統效能。

除了自己開執行緒去執行 log 操作之外,還可以直接修改配置來開啟 Logback 的非同步日誌功能:

<!-- 非同步 Appender -->
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>500</queueSize> <!-- 佇列大小 -->
<discardingThreshold>0</discardingThreshold> <!-- 丟棄閾值,0 表示不丟棄 -->
<neverBlock>true</neverBlock> <!-- 佇列滿時是否阻塞主執行緒,true 表示不阻塞 -->
<appender-ref ref="CONSOLE" /> <!-- 生效的日誌目標 -->
<appender-ref ref="FILE" />
</appender>

上述配置的關鍵是配置緩衝佇列,要設定合適的佇列大小和丟棄策略,防止日誌積壓或丟失。

8、整合日誌收集系統

在比較成熟的公司中,我們可能會使用更專業的日誌管理和分析系統,比如 ELK(Elasticsearch、Logstash、Kibana)。不僅不用每次都登入到伺服器上檢視日誌檔案,還可以更靈活地搜尋日誌。

但是搭建和運維 ELK 的成本還是比較大的,對於小團隊,我的建議是不要急著搞這一套。


OK,就分享到這裡,洋洋灑灑 4000 多字,希望這篇文章能幫助大家意識到日誌記錄的重要性,並養成良好的日誌記錄習慣。學會的話給魚皮點個贊吧~

日誌不是寫給機器看的,是寫給未來的你和你的隊友看的!

更多程式設計學習資源

  • Java前端程式設計師必做專案實戰教程+畢設網站

  • 程式設計師免費程式設計學習交流社群(自學必備)

  • 程式設計師保姆級求職寫簡歷指南(找工作必備)

  • 程式設計師免費面試刷題網站工具(找工作必備)

  • 最新Java零基礎入門學習路線 + Java教程

  • 最新Python零基礎入門學習路線 + Python教程

  • 最新前端零基礎入門學習路線 + 前端教程

  • 最新資料結構和演算法零基礎入門學習路線 + 演算法教程

  • 最新C++零基礎入門學習路線、C++教程

  • 最新資料庫零基礎入門學習路線 + 資料庫教程

  • 最新Redis零基礎入門學習路線 + Redis教程

  • 最新計算機基礎入門學習路線 + 計算機基礎教程

  • 最新小程式入門學習路線 + 小程式開發教程

  • 最新SQL零基礎入門學習路線 + SQL教程

  • 最新Linux零基礎入門學習路線 + Linux教程

  • 最新Git/GitHub零基礎入門學習路線 + Git教程

  • 最新作業系統零基礎入門學習路線 + 作業系統教程

  • 最新計算機網路零基礎入門學習路線 + 計算機網路教程

  • 最新設計模式零基礎入門學習路線 + 設計模式教程

  • 最新軟體工程零基礎入門學習路線 + 軟體工程教程

相關文章