Java工程如何打印程序日志過程解析
很久之前,有個(gè)朋友問我,如果一個(gè)老項(xiàng)目讓你接手去進(jìn)行后續(xù)維護(hù),你會(huì)先從哪里入手、讓自己更快地上手項(xiàng)目?當(dāng)時(shí)我沒有特別正面去回答這個(gè)朋友的問題,我說:一個(gè)老項(xiàng)目是否容易上手,一個(gè)非常關(guān)鍵的地方就是這個(gè)項(xiàng)目的日志是否打得足夠好。因?yàn)橥ǔ碚f,一個(gè)老項(xiàng)目相對(duì)比較穩(wěn)定了,后續(xù)大概率不會(huì)有比較大的變更和改動(dòng),那么對(duì)于這樣的項(xiàng)目,核心就是“維穩(wěn)”。但是任何人都無法保證項(xiàng)目在線上運(yùn)行時(shí)不會(huì)出線上故障,在出現(xiàn)線上問題或者故障時(shí),如何快速止損就是第一要義,而日志在止損過程中就扮演著非常重要的角色。日志打的足夠明了清晰,可以幫助開發(fā)和運(yùn)維人員快速定位問題,繼而決定采取何種方案進(jìn)行止損。
今天就讓我們一起來聊一聊如何把項(xiàng)目程序日志打“好”。
一.為何需要規(guī)范地打印程序日志?
我們平時(shí)在寫程序代碼過程中,一般會(huì)把主要精力集中在功能實(shí)現(xiàn)上,往往會(huì)忽視日志的重要性,然而日志在系統(tǒng)上線后是極其重要的,因?yàn)橄到y(tǒng)上線后,只有通過日志才能了解當(dāng)前系統(tǒng)的運(yùn)行狀態(tài),在出現(xiàn)線上故障時(shí),日志是否足夠清晰明了決定了是否能夠快速找到止損方案。我們可以看一下下面這段代碼:
public class HttpClient { private static final Logger LOG = LoggerFactory.getLogger(HttpClient.class); private static int CONNECT_TIMEOUT = 5000; // unit ms private static int READ_TIMEOUT = 10000; // unit ms public static String sendPost(String url, String param) { OutputStream out = null; BufferedReader in = null; String result = ""; try { URL realUrl = new URL(url); URLConnection conn = realUrl.openConnection(); conn.setDoInput(true); conn.setDoOutput(true); conn.setConnectTimeout(CONNECT_TIMEOUT); conn.setReadTimeout(READ_TIMEOUT); conn.setRequestProperty("charset", "UTF-8"); out = new PrintWriter(conn.getOutputStream()); out.print(parm); out.flush(); in = new BufferedReader(new InputStreamReader(conn.getInputStream())); String line; while ((line = in.readLine()) != null) { result += line; } } catch (Exception ex) { LOG.error("post request error!!!"); } finally { try { if (out != null) { out.close(); } if (in != null) { in.close(); } } catch (IOException ex) { LOG.error("close stream error!!!"); } return result; } } }
某一天線上突然大量http請(qǐng)求失敗,然后查看日志,發(fā)現(xiàn)了大量的“post request error!!!”錯(cuò)誤,此時(shí)假如看到這樣的日志你可能完全不知道究竟是什么原因?qū)е碌?,還得繼續(xù)通過一些其他的手段來定位具體原因。
假如打印的錯(cuò)誤日志是這樣的:
post request error!!!, url:[http://www.123.test.com], param:[name=jack]
java.net.ConnectException: Connection refused
at java.net.PlainSocketImpl.socketConnect(Native Method)
at java.net.AbstractPlainSocketImpl.doConnect(AbstractPlainSocketImpl.java:339)
at java.net.AbstractPlainSocketImpl.connectToAddress(AbstractPlainSocketImpl.java:200)
at java.net.AbstractPlainSocketImpl.connect(AbstractPlainSocketImpl.java:182)
at java.net.SocksSocketImpl.connect(SocksSocketImpl.java:392)
at java.net.Socket.connect(Socket.java:579)
那么便能很快地?cái)喽ㄊ窍掠蝖ttp服務(wù)問題導(dǎo)致的,且下游http服務(wù)域名為www.123.test.com(Connection refused通常是由于下游服務(wù)端口未啟動(dòng)引起的),可以迅速找相應(yīng)的人員進(jìn)行止損,避免在故障定位階段耗費(fèi)大量的時(shí)間。
上面舉的例子只是一個(gè)非常小的例子,實(shí)際日常開發(fā)中可能碰到的線上問題比這個(gè)更加復(fù)雜和棘手,總結(jié)來看,日志的主要作用有以下幾點(diǎn):
1)日志是系統(tǒng)運(yùn)行的“照妖鏡”,通過它能夠?qū)崟r(shí)反映系統(tǒng)的運(yùn)行狀態(tài);
如上圖所示,系統(tǒng)A中producer不斷產(chǎn)生數(shù)據(jù)放入到data queue中,sender不斷從data queue中取數(shù)據(jù)發(fā)送給下游系統(tǒng)B的receiver,那么對(duì)于系統(tǒng)A來說,data queue中的待發(fā)送數(shù)據(jù)量便是一個(gè)非常關(guān)鍵的指標(biāo),它能夠從側(cè)面真實(shí)反應(yīng)當(dāng)前系統(tǒng)的運(yùn)行狀況,如果data queue中的element個(gè)數(shù)超過容量的90%了,那么標(biāo)志著此時(shí)系統(tǒng)可能運(yùn)行不正常了,會(huì)有隊(duì)列堵塞的風(fēng)險(xiǎn);如果data queue中的element個(gè)數(shù)不到容量的10%,那么標(biāo)志著此時(shí)系統(tǒng)運(yùn)行比較正常,出現(xiàn)隊(duì)列堵塞的風(fēng)險(xiǎn)較低。
如果這個(gè)指標(biāo)沒有輸出到日志中,開發(fā)和運(yùn)維人員是無法確切知道當(dāng)前系統(tǒng)A的運(yùn)行狀態(tài)的(當(dāng)然也有其他的方式來獲取這個(gè)指標(biāo),比如通過http接口暴露出來也是一種方式之一)。
2)良好的日志便于后期運(yùn)維和開發(fā)人員迅速定位線上問題,加快止損速度,減少系統(tǒng)故障帶來的損失;
3)日志還有另外一個(gè)作用便是能夠無縫與監(jiān)控系統(tǒng)結(jié)合,通過監(jiān)控系統(tǒng)進(jìn)行日志采集,拿到系統(tǒng)運(yùn)行的相關(guān)性能指標(biāo),有利于分析系統(tǒng)的性能瓶頸、提前規(guī)避風(fēng)險(xiǎn);
舉例說明:
假如有一個(gè)商城系統(tǒng),在初期,數(shù)據(jù)庫(kù)通過2臺(tái)服務(wù)器提供服務(wù)(1臺(tái)master,1臺(tái)slave),此時(shí)大部分接口能在秒級(jí)內(nèi)響應(yīng)用戶請(qǐng)求。隨著時(shí)間的推移,商城系統(tǒng)的用戶量逐漸增多,并發(fā)查詢和寫入量都出現(xiàn)了一定的增長(zhǎng),數(shù)據(jù)庫(kù)中的數(shù)據(jù)量也慢慢增多,導(dǎo)致部分sql語(yǔ)句查詢?cè)絹碓铰蝗挥幸惶?,?shù)據(jù)庫(kù)的slave機(jī)器由于過多的慢查詢導(dǎo)致被拖垮,徹底宕機(jī)了,導(dǎo)致商城服務(wù)不可用。
如果商城系統(tǒng)在日志中記錄了每個(gè)http請(qǐng)求的耗時(shí)情況,通過監(jiān)控系統(tǒng)配置日志采集,同時(shí)配置相應(yīng)的報(bào)警,那么便能提前發(fā)現(xiàn)由于業(yè)務(wù)增長(zhǎng)帶來的系統(tǒng)性能瓶頸,提前進(jìn)行系統(tǒng)優(yōu)化(如機(jī)器擴(kuò)容、sql語(yǔ)句優(yōu)化、分庫(kù)分表等),規(guī)避風(fēng)險(xiǎn)。
4)便于統(tǒng)計(jì)與業(yè)務(wù)相關(guān)的指標(biāo)數(shù)據(jù),進(jìn)行相關(guān)業(yè)務(wù)分析和功能優(yōu)化。
舉例說明:
比如一個(gè)搜索系統(tǒng),想統(tǒng)計(jì)過去一周不同地域(如南北地域)的搜索使用占比,如果日志中本身打印了每個(gè)搜索query請(qǐng)求的ip,則很容易統(tǒng)計(jì),否則需要重新上線加日志才能統(tǒng)計(jì)。
因此,大家在日常編寫代碼過程中要注重日志書寫的規(guī)范性,讓它發(fā)揮出它應(yīng)有的價(jià)值,在輔助保障我們服務(wù)穩(wěn)定運(yùn)行的同時(shí),能夠有效提升后期系統(tǒng)維護(hù)效率。
二.如何規(guī)范地打印程序日志?
接下來,我們從以下幾個(gè)方面來談?wù)勅绾我?guī)范地打印日志。
2.1 日志文件命名
通常來說日志文件的命名可包括以下幾個(gè)關(guān)鍵信息:
- 類型標(biāo)識(shí)(logTypeName)
- 日志級(jí)別(logLevel)
- 日志生成時(shí)間(logCreateTime)
- 日志備份編號(hào)(logBackupNum)
類型標(biāo)識(shí):指此日志文件的功能或者用途,比如一個(gè)web服務(wù),記錄http請(qǐng)求的日志通常命名為request.log或者access.log,request、access就是類型標(biāo)識(shí),而java的gc日志通常命名為gc.log,這樣看一目了然;而通常用來記錄服務(wù)的整體運(yùn)行的日志一般用服務(wù)名稱(serviceName、appKey)或者機(jī)器名(hostName)來命名,如 nginx.log;
日志級(jí)別:打印日志的時(shí)候直接通過文件來區(qū)分級(jí)別是一種比較推薦的方式,如果把所有級(jí)別的日志打到同一個(gè)日志文件中,在定位問題時(shí),還需要去文件中進(jìn)行查找操作,相對(duì)繁瑣。日志級(jí)別一般包括DEBUG、INFO、WARN、ERROR、FATAL這五個(gè)級(jí)別,在實(shí)際編寫代碼中,可以采取嚴(yán)格匹配模式或者非嚴(yán)格匹配模式,嚴(yán)格匹配模式即INFO日志文件中只打印INFO日志,ERROR日志文件只打印ERROR日志;非嚴(yán)格匹配模式即INFO日志文件可以打印INFO日志、WARN日志、ERROR日志、FATAL日志,WARN日志文件可以打印WARN日志、ERROR日志、FATAL日志,以此類推。
日志生成時(shí)間:即在日志文件名稱中附帶上日志文件創(chuàng)建的時(shí)間,方便在查找日志文件時(shí)進(jìn)行排序;
日志備份編號(hào):當(dāng)進(jìn)行日志切割時(shí),如果是以文件大小進(jìn)行滾動(dòng),此時(shí)可以在日志文件名稱末尾加上編號(hào);
2.2 日志滾動(dòng)
雖然日志中能夠保存系統(tǒng)運(yùn)行時(shí)的關(guān)鍵信息,但是由于磁盤空間有限,所以我們不能無限制地保留日志,因此必須有日志滾動(dòng)策略。日志滾動(dòng)通常有以下幾種模式:
第一種:按照時(shí)間滾動(dòng)
第二種:按照單個(gè)日志文件大小滾動(dòng)
第三種:同時(shí)按照時(shí)間和單個(gè)日志文件大小滾動(dòng)。
按照時(shí)間滾動(dòng),即每隔一定的時(shí)間建立一個(gè)新的日志文件,通常可以按照小時(shí)級(jí)別滾動(dòng)或者天級(jí)別滾動(dòng),具體采取哪種方式取決于系統(tǒng)日志的打印量。如果系統(tǒng)日志比較少,可以采取天級(jí)別滾動(dòng);而如果系統(tǒng)日常量比較大,則建議采取小時(shí)級(jí)別滾動(dòng)。
按照單個(gè)日志文件大小滾動(dòng),即每當(dāng)日志文件達(dá)到一定大小則建立一個(gè)新的日志文件,通常建議單個(gè)日志文件大小不要超過500M,日志文件過大的話,對(duì)于日志監(jiān)控或者問題定位排查都可能會(huì)造成一定影響。
按照時(shí)間和單個(gè)日志文件大小滾動(dòng),這種模式通常適用于希望保留一定時(shí)間的日志,但是又不希望單個(gè)日志文件過大的場(chǎng)景。比如logback就提供了這種配置模式,可參考:https://logback.qos.ch/manual/appenders.html#SizeAndTimeBasedFNATP
對(duì)于日志滾動(dòng)策略來說,有2個(gè)比較關(guān)鍵的參數(shù):最大保留日志數(shù)量和最大磁盤占用空間。這2個(gè)參數(shù)切記一定要設(shè)置,如果沒有設(shè)置,則很有可能會(huì)出現(xiàn)把線上機(jī)器磁盤打滿的情況。
2.3 日志級(jí)別
日志的級(jí)別通常有以下幾種:
debug/trace、info、warning、error、fatal
這幾種日志級(jí)別的嚴(yán)重程序依次遞增:
debug/trace:debug和trace級(jí)別的日志由于打印內(nèi)容較多,所以通常情況下不適用于線上生產(chǎn)環(huán)境使用,一般使用于前期線下環(huán)境調(diào)試。即使線上環(huán)境要使用,也需要通過開關(guān)來控制,只在定位追蹤線上問題時(shí)才開啟;
info:info日志一般用來記錄系統(tǒng)運(yùn)行的關(guān)鍵狀態(tài)、關(guān)鍵業(yè)務(wù)邏輯或者關(guān)鍵執(zhí)行節(jié)點(diǎn)。但切記一點(diǎn),info日志絕不可濫用,如果info日志濫用,則和debug/trace日志沒有太大區(qū)別了。
warning:warning日志一般用來記錄系統(tǒng)運(yùn)行時(shí)的一些非預(yù)期情況,顧名思義,是作為一種警示,提醒開發(fā)和運(yùn)維人員需要關(guān)注,但是不用人為介入立刻去處理的。
error:error日志一般用來記錄系統(tǒng)運(yùn)行時(shí)的一些普通錯(cuò)誤,這些錯(cuò)誤一旦出現(xiàn),則表示已經(jīng)影響了用戶的正常訪問或者使用,通常意味著需要人為介入處理。但很多時(shí)候在生產(chǎn)環(huán)境中,也不一定是出現(xiàn)error日志就需要人工立即介入處理的,通常會(huì)結(jié)合error日志的數(shù)量以及持續(xù)時(shí)間來進(jìn)行綜合判斷。
fatal:屬于系統(tǒng)致命錯(cuò)誤,一般出現(xiàn)意味著系統(tǒng)基本等于掛掉了,需要人工立即介入處理。
下面舉個(gè)簡(jiǎn)單的例子來說明,假如我們有這樣一個(gè)場(chǎng)景,我們有一個(gè)工資計(jì)算系統(tǒng),每隔月1號(hào)需要從員工考勤系統(tǒng)獲取公司所有員工的考勤數(shù)據(jù),然后根據(jù)考勤數(shù)據(jù)來計(jì)算上月應(yīng)發(fā)工資,那么需要有一個(gè)函數(shù)從考勤系統(tǒng)獲取員工考勤數(shù)據(jù):
public Map<Long, Double> getEmployeeWorkDaysFromAttendance(int year, int month, Set<Long> employeeList) throws BusiessException { // 入口關(guān)鍵日志,需要打印關(guān)鍵的參數(shù),因?yàn)閑mployeeList可能數(shù)量較大,所以次數(shù)沒有直接打印employeeList列表內(nèi)容,只打印了size logger.info("get employee work days, year:{}, month:{}, employeeList.size:{}", year, month, employeeList.size()); // 如果需要臨時(shí)檢驗(yàn)員工列表,可以把debug日志開關(guān)打開 if (debugOpen()) { logger.debug("employ list content:{}", JSON.toJsonString(employeeList)); } int retry = 1; while (retry <= MAX_RETRY_TIMES) { try { Map<Long, Double> employeeWorkDays = employeeAttendanceRPC.getEmployeeWorkDays(year, month, employeeList); logger.info("get employee work days success, year:{}, month:{}, employeeList.size:{}, employeeWorkDays.size:{}", year, month, employeeList.size(), employeeWorkDays.size()); return employeeWorkDays; } catch (Exception ex) { logger.warning("rpc invoke failed(employeeAttendanceRPC.getEmployeeWorkDays), retry times:{}, year:{}, month:{}, employeeList.size:{}", retry, year, month, employeeList.size(), ex); // 連續(xù)重試失敗之后,向上跑出異常 // 對(duì)于沒有異常機(jī)制的語(yǔ)言,此處應(yīng)該打印error日志 if (retry == MAX_RETRY_TIMES) { throw new BusiessException(ex, "rpc invoke failed(employeeAttendanceRPC.getEmployeeWorkDays)"); } } retry++; } }
2.4 日志打印時(shí)機(jī)的選擇
由于日志是為了方便我們了解系統(tǒng)當(dāng)前的運(yùn)行狀況以及定位線上問題,所以日志打印的時(shí)機(jī)非常重要,如果濫用日志,則會(huì)導(dǎo)致日志內(nèi)容過多,影響問題定位的效率;如果日志打印過少,則容易導(dǎo)致缺少關(guān)鍵日志,導(dǎo)致在線上定位問題時(shí)找不到問題根音。因此把握日志打印的時(shí)機(jī)至關(guān)重要,以下是常見的適合打印日志的時(shí)機(jī):
1)http調(diào)用或者rpc接口調(diào)用
在程序調(diào)用其他服務(wù)或者系統(tǒng)的時(shí)候,需要打印接口調(diào)用參數(shù)和調(diào)用結(jié)果(成功/失敗)。
2)程序異常
在程序出現(xiàn)exception的時(shí)候,要么選擇向上拋出異常,要么必須在catch塊中打印異常堆棧信息。不過需要注意的是,最好不要重復(fù)打印異常日志,比如在catch塊里既向上拋出了異常,又去打印錯(cuò)誤日志(對(duì)外rpc接口函數(shù)入口處除外)。
3)特殊的條件分支
程序進(jìn)入到一些特殊的條件分支時(shí),比如特殊的else或者switch分支。比如我們根據(jù)工齡計(jì)算薪資:
public double calSalaryByWorkingAge(int age) { if (age < 0) { logger.error("wrong age value, age:{}", age); return 0; } // .. }
理論上工齡不可能小于0,所以需要打印出這種非預(yù)期情況,當(dāng)然通過拋出異常的方式也是可行的。
4)關(guān)鍵執(zhí)行路徑及中間狀態(tài)
在一些關(guān)鍵的執(zhí)行路徑以及中間狀態(tài)也需要記錄下關(guān)鍵日志信息,比如一個(gè)算法可能分為很多步驟,每隔步驟的中間輸出結(jié)果是什么,需要記錄下來,以方便后續(xù)定位跟蹤算法執(zhí)行狀態(tài)。
5)請(qǐng)求入口和出口
在函數(shù)或者對(duì)外接口的入口/出口處需要打印入口/出口日志,一來方便后續(xù)進(jìn)行日志統(tǒng)計(jì),同時(shí)也更加方便進(jìn)行系統(tǒng)運(yùn)行狀態(tài)的監(jiān)控。
2.5 日志內(nèi)容與格式
日志打印時(shí)機(jī)決定了能夠根據(jù)日志去進(jìn)行問題定位,而日志的內(nèi)容決定了是否能夠根據(jù)日志快速找出問題原因,因此日志內(nèi)容也是至關(guān)重要的。通常來說,一行日志應(yīng)該至少包括以下幾個(gè)組成部分:
logTag、param、exceptionStacktrace
logTag為日志標(biāo)識(shí),用來標(biāo)識(shí)此日志輸出的場(chǎng)景或者原因,param為函數(shù)調(diào)用參數(shù),exceptionStacktrace為異常堆棧。舉例說明:
good case
public class HttpClient { private static final Logger LOG = LoggerFactory.getLogger(HttpClient.class); private static int CONNECT_TIMEOUT = 5000; // unit ms private static int READ_TIMEOUT = 10000; // unit ms public static String sendPost(String url, String param) { OutputStream out = null; BufferedReader in = null; String result = ""; try { URL realUrl = new URL(url); URLConnection conn = realUrl.openConnection(); conn.setDoInput(true); conn.setDoOutput(true); conn.setConnectTimeout(CONNECT_TIMEOUT); conn.setReadTimeout(READ_TIMEOUT); conn.setRequestProperty("charset", "UTF-8"); out = new PrintWriter(conn.getOutputStream()); out.print(parm); out.flush(); in = new BufferedReader(new InputStreamReader(conn.getInputStream())); String line; while ((line = in.readLine()) != null) { result += line; } } catch (Exception ex) { // 有關(guān)鍵logTag,有參數(shù)信息,有錯(cuò)誤堆棧 LOG.error("post request error!!!, url:[[}], param:[{}]", url, param, ex); } finally { try { if (out != null) { out.close(); } if (in != null) { in.close(); } } catch (IOException ex) { LOG.error("close stream error!!!, url:[[}], param:[{}]", url, param, ex); } return result; } } }
bad case
public class HttpClient { private static final Logger LOG = LoggerFactory.getLogger(HttpClient.class); private static int CONNECT_TIMEOUT = 5000; // unit ms private static int READ_TIMEOUT = 10000; // unit ms public static String sendPost(String url, String param) { OutputStream out = null; BufferedReader in = null; String result = ""; try { URL realUrl = new URL(url); URLConnection conn = realUrl.openConnection(); conn.setDoInput(true); conn.setDoOutput(true); conn.setConnectTimeout(CONNECT_TIMEOUT); conn.setReadTimeout(READ_TIMEOUT); conn.setRequestProperty("charset", "UTF-8"); out = new PrintWriter(conn.getOutputStream()); out.print(parm); out.flush(); in = new BufferedReader(new InputStreamReader(conn.getInputStream())); String line; while ((line = in.readLine()) != null) { result += line; } } catch (Exception ex) { // 沒有任何錯(cuò)誤信息 LOG.error("post request error!!!"); } finally { try { if (out != null) { out.close(); } if (in != null) { in.close(); } } catch (IOException ex) { LOG.error("close stream error!!!"); } return result; } } }
另外,對(duì)于對(duì)外http接口或者rpc接口,最好對(duì)于每個(gè)請(qǐng)求都有requestId,以便跟蹤每個(gè)請(qǐng)求后續(xù)所有的執(zhí)行路徑。
以上就是本文的全部?jī)?nèi)容,希望對(duì)大家的學(xué)習(xí)有所幫助,也希望大家多多支持腳本之家。
相關(guān)文章
SpringBoot定時(shí)任務(wù)實(shí)現(xiàn)數(shù)據(jù)同步的方法
業(yè)務(wù)需求是,通過中臺(tái)調(diào)用api接口獲得,設(shè)備數(shù)據(jù),要求現(xiàn)實(shí)設(shè)備數(shù)據(jù)的同步,這篇文章主要介紹了SpringBoot定時(shí)任務(wù)實(shí)現(xiàn)數(shù)據(jù)同步的方法,文中示例代碼介紹的非常詳細(xì),具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2022-08-08Mybatis中where標(biāo)簽與if標(biāo)簽結(jié)合使用詳細(xì)說明
mybatis中if和where用于動(dòng)態(tài)sql的條件拼接,在查詢語(yǔ)句中如果缺失某個(gè)條件,通過if和where標(biāo)簽可以動(dòng)態(tài)的改變查詢條件,下面這篇文章主要給大家介紹了關(guān)于Mybatis中where標(biāo)簽與if標(biāo)簽結(jié)合使用的詳細(xì)說明,需要的朋友可以參考下2023-03-03ActiveMQ基于zookeeper的主從(levelDB Master/Slave)搭建
這篇文章主要介紹了ActiveMQ基于zookeeper的主從levelDB Master/Slave搭建,以及Spring-boot下的使用方法,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-08-08JAVA?IDEA項(xiàng)目打包為jar包的步驟詳解
在Java開發(fā)中我們通常會(huì)將我們的項(xiàng)目打包成可執(zhí)行的Jar包,以便于在其他環(huán)境中部署和運(yùn)行,下面這篇文章主要給大家介紹了關(guān)于JAVA?IDEA項(xiàng)目打包為jar包的相關(guān)資料,需要的朋友可以參考下2024-08-08