前言:
Thrift作為Facebook開源的RPC框架, 通過IDL中間語言, 並借助代碼生成引擎生成各種主流語言的rpc框架服務端/客戶端代碼. 不過Thrift的實現, 簡單使用離實際生產環境還是有一定距離, 本系列將對Thrift作代碼解讀和框架擴充, 使得它更加貼近生產環境. 本文講述RPC服務框架中, 日志的重要性, 以及logid的引入. 日志不僅包含豐富的數據(就看是否會挖掘), 而且還是線上服務問題追蹤和排查錯誤最好的方式.
日志級別
采用大家喜聞樂見的log4j作為該RPC服務框架首選的日志庫. 其對日志的級別有如下幾種:
1). TRACE 最細粒度級別日志級別
2). DEBUG 對調試應用程序有幫助的日志級別
3). INFO 粗粒度級別突出強調應用程序的運行
4). WARN 表明潛在錯誤的情形
5). ERROR 明確發生錯誤, 但不影響系統繼續運行
6). FATAL 嚴重的錯誤, 會導致應用工作不正常
日志級別等級順序如下: TRACE < DEBUG < INFO < WARN < ERROR < FATAL
而應用具體的輸出取決於日志級別的設置(包含及以上才會輸出), 往往項目該上線采用DEBUG級別(日志量大, 容易寫滿磁盤), 等系統穩定后采用INFO級別
RPC服務日志需求
上述的日志需求雖然能定位問題, 但往往存在如下問題:
1). 很多日志只是簡單了記錄該點(代碼行)運行過, 或是運行到該點的數據快照.
2). 服務由多種模塊(每個模塊由有多個節點構成)組成, 之間的日志串聯不起來.
而好的日志設計, 必須能滿足
1). 以完整的一次RPC調用作為單位(不是某個執行點快照, 而是完整的RPC callback過程), 並輸出完整的一行日志記錄, 包括(時間點, 來源, 輸入參數, 輸出參數, 中間經歷的子過程, 消耗時間).
2). 引入logid, 作為多個模塊之間串聯的依據.
RPC級別的日志解決方案
嘗試如下navie的方式去實現
public String echo(String msg) { StringBuilder sb = new StringBuilder(); // *) 記錄輸入參數 sb.append("[request: {msg: msg}]"); // *) 訪問緩存服務 sb.append("[action: access redis, consume 100ms]"); // *). 訪問后端數據庫 sb.append("[action: dao, consume 100ms]"); // *). 記錄返回結果 sb.append("[response: {msg}]"); logger.info(sb.toString()); return msg; }
評注: 這邊的echo函數代表了一個rpc服務調用接口, 且簡化了各個組件的交互. 同時引入StringBuilder, 記錄各個交互的過程和時間消耗, 最后統一由函數出口前使用logger進行日志的統一輸入.
但是這種方式弊端非常的明顯:
1. 假設該rpc服務的函數, 存在多個出口
2. 函數存在嵌套調用, 需要嵌套子函數的過程信息
如下面的代碼片段, 可參考:
public boolean verifySession() { // ***********我要記錄日志(*^__^*) *************** } public String echo(String msg) { StringBuilder sb = new StringBuilder(); // *) 調用子過程 verifySession(); // *) 記錄輸入參數 sb.append("[request: {msg: msg}]"); // *) 訪問緩存服務 if ( KeyValueEngine Access Fail ) { // *********日志記錄在那里*********** throw new Exception(); } sb.append("[action: access redis, consume 100ms]"); // *). 訪問后端數據庫 if ( Database Access Fail ) { // *********日志記錄在那里*********** throw new Exception(); } sb.append("[action: dao, consume 100ms]"); // *). 記錄返回結果 sb.append("[response: {msg}]"); logger.info(sb.toString()); return msg; }
評注: 子函數verifySession的調用, 需要把StringBuilder對象往里傳, 才能記錄相關的信息, 而多個異常出口, 需要把日志輸入往里添加(這個繁瑣且容易忘記). 這種方案只能說容易想到, 但不是最佳的方案.
有一點不可否認, rpc調用始終在同一個線程中. 聰明的讀者是否猜到了最佳的解決方案.
對, 就是大殺器ThreadLocal,其能解決子函數調用的問題, 那多出口問題呢? 讓rpc服務框架去處理, 其作為具體rpc調用的最外層.
采用動態代理類, 去攔截rpc的handler接口調用.
public class LogProxyHandler<T> implements InvocationHandler { private T instance; public LogProxyHandler(T instance) { this.instance = instance; } public Object createProxy() { return Proxy.newProxyInstance(instance.getClass().getClassLoader(), instance.getClass().getInterfaces(), this); } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // *) 函數調用前, 攔截處理, 作ThreadLocal的初始化工作 LoggerUtility.beforeInvoke(); // -----(1) try { Object res = method.invoke(instance, args); // *) 函數成功返回后, 攔截處理, 進行日志的集中輸出 LoggerUtility.returnInvoke(); // -----(2) return res; } catch (Throwable e) { // *) 出現異常后, 攔截處理, 進行日志集中輸入 // -----(3) LoggerUtility.throwableInvode("[result = exception: {%s}]", e.getMessage()); throw e; } } }
代碼評注:
(1). 攔截點beforeInvoke用於ThreadLocal的初始話工作, 日志緩存的清空
(2). 攔截點returnInvoke用於函數成功返回后, 進行日志集中輸出
(3). 攔截點throwableInvoke用於出現異常后, 進行日志的集中輸出
同時在rpc服務調用中, 才用LoggerUtility的noticeLog靜態函數(簡單緩存中間日志過程)代替之前的StringBuilder.append來記錄中間子過程
LoggerUtility的代碼如下所示:
public class LoggerUtility { private static final Logger rpcLogger = LoggerFactory.getLogger("rpc"); public static final ThreadLocal<StringBuilder> threadLocals = new ThreadLocal<StringBuilder>(); public static void beforeInvoke() { StringBuilder sb = threadLocals.get(); if ( sb == null ) { sb = new StringBuilder(); threadLocals.set(sb); } sb.delete(0, sb.length()); } public static void returnInvoke() { StringBuilder sb = threadLocals.get(); if ( sb != null ) { rpcLogger.info(sb.toString()); } } public static void throwableInvode(String fmt, Object... args) { StringBuilder sb = threadLocals.get(); if ( sb != null ) { rpcLogger.info(sb.toString() + " " + String.format(fmt, args)); } } public static void noticeLog(String fmt, Object... args) { StringBuilder sb = threadLocals.get(); if ( sb != null ) { sb.append(String.format(fmt, args)); } } }
兩者的結合完美的解決了上述RPC的日志問題, 是不是很贊.
Logid的日志解決方案
Thrift框架本身是沒有logid的概念的, 我們很難去改動thrift的rpc協議, 去添加它(比如大百度的做法是把logid作為rpc協議本身一部分). 這邊的解決方案是基於約定. 我們采用如下約定, 所有的rpc請求參數都封裝為一個具體Request對象, 所有的返回結構都封裝為一個具體的Response對象, 而每個Request對象首個屬性是logid.
比如如下的結構定義:
struct EchoRequest { 1: required i64 logid = 1001, 2: required string msg } struct EchoResponse { 1: required i32 status, 2: optional string msg } service EchoService { EchoResponse echo(1: EchoRequest req); }
評注: Request結構中logid, 就是約定的需要加到rpc的請求結構里去的.
我一直覺得: 約定優於配置, 約定優於框架.
后續
中間插入日志處理這塊, 后續講述之前計划的服務發布/訂閱化, 借助zookeeper來構建一個簡單的系統, 敬請期待.