曾經,我遭遇了一個荒謬的Bug。那是一個看似平凡的字段,由我和同事共同維護著,如同兩個陌生人在同一片土地上耕作。最終,我們發現這個字段背叛了現實,它所記錄的與真實情況南轅北轍。
生產環境是沉默的,它從不打印SQL語句,就像西西弗斯推石的山坡,永遠不會告訴你石頭為何滾落。我們各自負責的模組如同兩座孤島,彼此的業務邏輯互不相通,因此很難判斷問題的根源在哪裡。
後來,我借助阿里雲的SQL洞察,如同考古學家挖掘古跡一般,獲取到了那條記錄的更新歷史,這才為我洗刷了冤屈。原來是同事後續的操作將字段更新錯誤了。幸運的是,兩個操作之間存在時間差,這成了唯一的線索,讓真相得以浮出水面。
那一刻我想,如果SQL語句也能攜帶TraceId,就像每個人都有自己的身份證一樣,那麼回溯問題將變得簡單而確鑿,鐵證如山。這就是我們今天要探討的——在資料庫的荒原中,為每一條SQL語句刻上它的身份標記。
在微服務的迷宮中,全鏈路追蹤本應是我們的阿里阿德涅之線。我們在應用層、網關層、服務間調用等環節都小心翼翼地添加著TraceId,卻往往忽略了資料庫這個沉默的見證者。為了實現真正意義上的全鏈路追蹤,我們必須將TraceId的印記延伸到SQL語句中,讓每一次資料庫的低語都能被準確地追溯到它的源頭。
筆者最開始想到的辦法是使用 WHERE子句,比如查詢的時候我在WHERE的最後加一個恆等表達式,形如:#{traceId} = #{traceId}
SELECT
id, age, name
FROM
tbl_user
WHERE
id = 1
AND
'004b0307-2d71-466e-aedf-cb8009893881' = '004b0307-2d71-466e-aedf-cb8009893881';
這樣我們就可以把鏈路中的TraceId帶入SQL,可是這麼搞局限性非常大:
綜上所述,筆者最終放棄了這個方案。
我們必須要找到一種足夠簡單,不需要應對各種複雜case,程式碼好寫好維護的方案。於是我想到可以把SQL都帶上註解,然後註解裡帶上Trace資訊。
/*X-ld:0002a4de-400f-40ae-ba46-9402f0eb46f4*/
SELECT
id, age, name
FROM
tbl_user
WHERE
id = 1;
這個方案有很多好處:
使用MyBatis的 Interceptor
攔截 StatementHandler
,修改SQL。MyBatis 允許開發者通過實現 org.apache.ibatis.plugin.Interceptor
接口,攔截四大對象之一:
我們要攔截的是:StatementHandler.prepare()
方法,在這個方法執行之前或之後,可以拿到即將發送到資料庫的原始 SQL,然後我們在 SQL 的頭部或尾部拼接上 /* traceId = xxx */
這種註解(筆者最終選擇頭部,因為一眼就能看到)。
import com.baomidou.mybatisplus.core.toolkit.StringPool;
import org.apache.commons.lang3.StringUtils;
import org.apache.ibatis.executor.statement.StatementHandler;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Signature;
import java.lang.reflect.Field;
import java.sql.Connection;
@Intercepts({
@Signature(
type = StatementHandler.class,
method = "prepare",
args = {Connection.class, Integer.class}
)
})
public class TraceSqlInterceptor implements Interceptor {
/**
* 這裡沒叫traceId是因為阿里雲ARMS已經使用該名詞
* 換成別的以示區分
*/
private static final String TRACE_NAME = "X-Id:";
/**
* @see BoundSql#sql
*/
private static final String SQL = "sql";
@Override
public Object intercept(Invocation invocation) throws Throwable {
Object target = invocation.getTarget();
if (target instanceof StatementHandler) {
String sqlMark = buildMark();
if (StringUtils.isNotBlank(sqlMark)) {
StatementHandler stat = (StatementHandler) target;
BoundSql boundSql = stat.getBoundSql();
/* 原始SQL語句 */
String sql = boundSql.getSql();
setField(boundSql, sqlMark + sql);
}
}
return invocation.proceed();
}
private void setField(BoundSql boundSql, String newSql) {
try {
Field field = BoundSql.class.getDeclaredField(SQL);
field.setAccessible(true);
field.set(boundSql, newSql);
} catch (Exception e) {
/* 忽略,因為失敗不會有任何影響,無非SQL無法成功著色 */
}
}
/**
* 構造標記
*/
private static String buildMark() {
/* 這一行是獲取業務系統的鏈路Id */
String traceId = TraceContext.getTraceId();
if (StringUtils.isBlank(traceId)) {
return StringUtils.EMPTY;
}
return StringPool.SLASH
+ StringPool.ASTERISK
+ StringPool.SPACE
+ TRACE_NAME
+ traceId
+ StringPool.SPACE
+ StringPool.ASTERISK
+ StringPool.SLASH;
}
}
同時不要忘了往Spring容器中注入攔截器實例
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class TraceSqlConfiguration {
@Bean
public TraceSqlInterceptor traceSqlInterceptor() {
return new TraceSqlInterceptor();
}
}
可以看到阿里雲ARMS採集到的SQL已經全部帶上了業務系統的TraceId,目的達成......
後記
有朋友反饋我的文字功底不行,因此開頭的背景介紹是用AI改寫的,有那味兒了......