審計(jì)日志類(lèi)的需求一直都存在痛點(diǎn)月杉,大部分采用與業(yè)務(wù)耦合的方式來(lái)實(shí)現(xiàn)。本文主要描述作者在工作中積累的幾種方案白对,以及最近思考的新的實(shí)現(xiàn)方案,整體梳理成文握联。希望能給你提供思路,并且如果有更優(yōu)雅的方式歡迎交流每瞒。
業(yè)務(wù)上的審計(jì)日志主要解決三個(gè)問(wèn)題:
- 數(shù)據(jù)回溯金闽,例如需要查詢(xún)之前值是什么,由什么修改到了什么剿骨。
- 行為回溯代芜,例如是觸發(fā)了什么操作才導(dǎo)致的數(shù)據(jù)變化。
- 操作人員回溯浓利,例如誰(shuí)修改的挤庇。
以上三個(gè)方面最大的難點(diǎn)在于1钞速,如果只是單純記錄本次修改后的值,實(shí)現(xiàn)方案會(huì)簡(jiǎn)單很多嫡秕。但是如果要記錄修改前的值復(fù)雜度就會(huì)高很多渴语。
首先淘汰的方案
有些同學(xué)可能會(huì)想我可以每次都記錄新的值,那么最終兩次值之間的對(duì)比不就是新老值的不同了么昆咽?這種方案也是一個(gè)思路遵班,但是存在三個(gè)問(wèn)題:
- 如果不是新系統(tǒng),那么日志的功能上線(xiàn)后第一批數(shù)據(jù)就是錯(cuò)的潮改,因?yàn)槔蠑?shù)據(jù)并沒(méi)記錄,存在冷啟動(dòng)的問(wèn)題腹暖。
- 不和老值對(duì)比的情況下汇在,就不知道發(fā)生了變化,就需要都記錄下來(lái)脏答。這樣數(shù)據(jù)增長(zhǎng)會(huì)非掣庋常快。
- 值的對(duì)比在獲取數(shù)據(jù)過(guò)程中做殖告,響應(yīng)時(shí)間沒(méi)辦法保證阿蝶,而且分頁(yè)查詢(xún)會(huì)比較復(fù)雜。
所以每次只記錄新數(shù)據(jù)的方案先排除掉黄绩,不在考慮范圍內(nèi)羡洁,下面都是新老值都記錄的實(shí)現(xiàn)方案。
第二淘汰的方案
最容易想到的方案爽丹,在業(yè)務(wù)代碼中編寫(xiě)日志邏輯筑煮,和業(yè)務(wù)代碼耦合嚴(yán)重。流程如下:
在該方案中有些環(huán)節(jié)可以簡(jiǎn)單處理粤蝎,如果原上下文中存在更新后的值那么就不需要再查詢(xún)真仲,但是需要在方法之間不斷的傳遞;新老值對(duì)比可以抽象工具類(lèi)來(lái)實(shí)現(xiàn)初澎,減少重復(fù)代碼秸应。
總體看還是侵入太大,本身不是業(yè)務(wù)流程碑宴,又是非核心功能软啼,這種實(shí)現(xiàn)方式耦合太強(qiáng)。
第三種淘汰的方案
為了解決業(yè)務(wù)侵入的問(wèn)題延柠,想通過(guò)簡(jiǎn)單的配置來(lái)實(shí)現(xiàn)焰宣,產(chǎn)生了第三種方案。第三種方案采用業(yè)務(wù)集成SDK的方式捕仔,用來(lái)收集數(shù)據(jù)匕积,上報(bào)到server來(lái)進(jìn)行數(shù)據(jù)對(duì)比落庫(kù)的方式實(shí)現(xiàn)盈罐。如下圖:
該方案通過(guò)
mybatis
的攔截器實(shí)現(xiàn)對(duì)update/insert/delete
SQL攔截闪唆,將SQL解析盅粪。
- 如果是insert的那么不需要查詢(xún)?cè)担挥行轮怠?/li>
- 如果是delete/update悄蕾,那么需要解析sql票顾,取where部分的語(yǔ)法節(jié)點(diǎn)來(lái)構(gòu)造select查詢(xún)來(lái)查詢(xún)?cè)怠?/li>
將查詢(xún)到的原值和SQL發(fā)送到server端。server對(duì)數(shù)據(jù)和SQL進(jìn)行解析就能提取新老值了帆调,再進(jìn)行對(duì)比便知道那些變更了奠骄,那些未變更。
這個(gè)方案侵入比較低番刊,自動(dòng)查詢(xún)?cè)岛郏⑶覟榱藴p少計(jì)算負(fù)擔(dān)將數(shù)據(jù)發(fā)送到server進(jìn)行計(jì)算和落地。但是因?yàn)樯婕暗阶詣?dòng)查詢(xún)芹务,這樣就存在一個(gè)比較大的風(fēng)險(xiǎn)蝉绷,如果業(yè)務(wù)做批量或者一次更新(update/delete)數(shù)據(jù)非常多,比如:
update user set status = 1 where city = 110000;
-- Query OK, 1000000 row affected
那么進(jìn)行原值查詢(xún)的時(shí)候就會(huì)爆炸枣抱,應(yīng)用肯定會(huì)變的不可用熔吗,因?yàn)椴樵?xún)出了100W條數(shù)據(jù)在內(nèi)存中。雖然該方案也做了一些防御的手段佳晶,比如查詢(xún)結(jié)果大于100條桅狠,發(fā)生次數(shù)大于2次后日志功能就會(huì)關(guān)閉;并且在執(zhí)行SQL中拼接了MAX_EXECUTION_TIME(例如:SELECT /*+ MAX_EXECUTION_TIME = 200 */ * FROM TABLE)
,如果查詢(xún)?cè)禃r(shí)間超過(guò)200ms就會(huì)停止轿秧,減少服務(wù)本身的影響垂攘。但是種種的防御手段還是不能完全保證問(wèn)題的出現(xiàn)。
之前采用這種方式時(shí)也是在不斷的強(qiáng)調(diào)淤刃,不能批量晒他,不能一次修改太多數(shù)據(jù),由業(yè)務(wù)方自己控制逸贾,自己負(fù)責(zé)陨仅。??
第四種方案
該方案并沒(méi)實(shí)現(xiàn),但是已驗(yàn)證可行铝侵,就是還沒(méi)有把點(diǎn)串成線(xiàn)灼伤。在設(shè)計(jì)方案三時(shí)其實(shí)有想過(guò)這個(gè)方案但是當(dāng)時(shí)問(wèn)DBA告訴沒(méi)有類(lèi)似的辦法,所以就放棄了咪鲜,最近看到一些材料有了新的收獲狐赡,就想起這個(gè)事是可行的。
如果想解決不在業(yè)務(wù)中進(jìn)行大量的查詢(xún)那么就把獲取原值的操作外置吧疟丙。那怎么外置呢颖侄?binlog
是一個(gè)可行的方案鸟雏。那么如何將操作和binlog進(jìn)行串起來(lái)呢?首先想到的是transaction id览祖,但是嘗試了下binlog中找到的id孝鹊,有個(gè)xid但是應(yīng)用作為client拿不到,client可以通過(guò)下面sql獲取當(dāng)前的trx_id展蒂,和xid不是一個(gè)概念又活。
SELECT tx.trx_id
FROM information_schema.innodb_trx tx
WHERE tx.trx_mysql_thread_id = connection_id();
那有沒(méi)有其他ID能夠串起來(lái)呢?GTID能滿(mǎn)足需求锰悼。
GTID是5.6的新特性柳骄,開(kāi)啟GTID模式在主從切換時(shí)會(huì)更加準(zhǔn)確高效。這里不介紹GTID的更多細(xì)節(jié)箕般。
下面如果要串起來(lái)就需要在binlog中有GITD耐薯,并且在應(yīng)用端也可以獲取到GTID。
binlog中有GTID:
這里我放一個(gè)帖子如何開(kāi)啟GTID:
https://www.fordba.com/mysql57replication-mode-change-online-enable-and-disable-gtids-html.html
開(kāi)啟后我們?cè)赽inlog event中查看如下:
應(yīng)用端獲取GTID比較困難
下面幾篇mysql官方文檔證明是可以獲取到的:
- https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_session_track_gtids
- https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_session_track_state_change
- https://dev.mysql.com/doc/refman/8.0/en/session-state-tracking.html
-
https://dev.mysql.com/doc/c-api/8.0/en/mysql-session-track-get-first.html
最后一篇文章的代碼片段中起到了很大的作用:
/* extract any available session state-change information */
enum enum_session_state_type type;
for (type = SESSION_TRACK_BEGIN; type <= SESSION_TRACK_END; type++)
{
################從這####################
const char *data;
size_t length;
if (mysql_session_track_get_first(mysql, type, &data, &length) == 0)
{
/* print info type and initial data */
printf("Type=%d:\n", type);
printf("mysql_session_track_get_first(): length=%d; data=%*.*s\n",
(int) length, (int) length, (int) length, data);
################到這####################
/* check for more data */
while (mysql_session_track_get_next(mysql, type, &data, &length) == 0)
{
printf("mysql_session_track_get_next(): length=%d; data=%*.*s\n",
(int) length, (int) length, (int) length, data);
}
}
}
將mysql的代碼拉下來(lái)后修改client/mysql.cc
隘世,修改后的代碼片段如下:
else if( !batchmode )
sprintf(buff,"Query OK, %lld %s affected",
mysql_affected_rows(&mysql),
mysql_affected_rows(&mysql) == 1LL ? "row" : "rows");
################新增開(kāi)始####################
const char *data;
size_t length;
if(mysql_session_track_get_first(&mysql, SESSION_TRACK_GTIDS, &data, &length) == 0)
{
printf("mysql_session_track_get_first: length=%d;data=%*.*s\n", (int)length, (int)length, (int)length, data);
}
################新增結(jié)束####################
然后再編譯。以上步驟參考:
在mysql客戶(hù)端顯示gtid: https://blog.csdn.net/qq_28074313/article/details/88072410
Mac編譯安裝Mysql5.7.17: http://www.reibang.com/p/1cc29d893cfc
最終實(shí)現(xiàn)結(jié)果鸠踪,commit后GTID就直接輸出了:
以上步驟證明client端可以獲取到GTID丙者。剩下的步驟就是如何解析到GTID了,暫時(shí)沒(méi)找到好辦法营密。根據(jù)mysql官方文檔說(shuō)明:
Controls whether the server returns GTIDs to the client, enabling the client to use them to track the server state. Depending on the variable value, at the end of executing each transaction, the server’s GTIDs are captured and returned to the client as part of the acknowledgement.
GTID作為ack的一部分返回械媒,使用Wireshark也確實(shí)能抓到了,下圖:
但是最后如何解析或者是不是mysql-connector-j已經(jīng)幫我們解析了只是不知如何獲取是下面的難點(diǎn)了评汰,后面的部分待完善纷捞。
這種方案更加優(yōu)雅,更加可以保證服務(wù)的可用性被去,并且在當(dāng)前環(huán)境下公司內(nèi)提供binlog的成熟解析方案已經(jīng)成了標(biāo)配主儡,這樣給該方案的時(shí)間又減少了很大的成本。綜上來(lái)看是當(dāng)前可以想到的審計(jì)日志類(lèi)型的需求最優(yōu)雅解法了惨缆,后面等完全實(shí)現(xiàn)再繼續(xù)補(bǔ)充糜值。