想必大家都聽說過刪庫跑路吧,我之前一直把它當(dāng)一個(gè)段子來看。
可萬萬沒想到,就在昨天,我們公司的某位員工,竟然寫了一個(gè)比刪庫更可怕的 Bug!
給大家分享一下(不是公開處刑),希望朋友們引以為戒。
一、Bug 起因
事情是這樣的,昨天中午 11 點(diǎn)左右,突然用戶群里的小伙伴反饋:自己直接成為了 魚聰明 AI 網(wǎng)站 的管理員!

接下來,陸續(xù)有更多同學(xué)反饋:大家都成管理員了!

看到這里,我立刻就去查了下數(shù)據(jù)庫,結(jié)果看到的是:

好家伙,早起腦供血不足的我立刻高血壓上來了,怎么所有的用戶都變成管理員了?!
我趕緊問下我所有的員工,這特么是誰干的!!!
然后員工小 A 大叫:“我 X,是我今天執(zhí)行單元測(cè)試更新數(shù)據(jù)的時(shí)候,少加了個(gè) where 條件!”
本來的預(yù)期:update user set userRole = 'admin' where id = 1
實(shí)際上執(zhí)行:update user set userRole = 'admin'
于是導(dǎo)致整個(gè)庫里的所有用戶都變成了管理員,大家可以愉快地薅魚毛了。
二、緊急處理
后來據(jù)這位寫 Bug 的同學(xué)的回憶,由于她之前沒有遇到過類似的情況,第一時(shí)間腦袋是一片空白、頭嗡嗡的,完全不知道接下來要怎么做。
不過我是很冷靜的,因?yàn)橹霸诠咎幚磉^類似的情況,畢竟曾經(jīng)凌晨 4 - 5 點(diǎn)的時(shí)候都被叫起來過。。。
所以立刻就給他發(fā)了一段處理方式:

解釋一下,就跟我們?cè)诼飞峡吹揭黄鸾煌ㄊ鹿室粯樱谝粫r(shí)間要么是保護(hù)現(xiàn)場(chǎng),放一個(gè)小牌牌不讓大家進(jìn)到事故發(fā)生地;要么就是防止擴(kuò)大影響,人工疏導(dǎo)不讓更多人圍觀、阻塞交通。
一般這兩件事情是同時(shí)執(zhí)行的,由于我知道怎么能夠判定哪些用戶本來是 VIP(比如通過 VIP 信息)、而且程序又有詳細(xì)的日志,所以第一時(shí)間是讓員工先把 user 表的所有角色設(shè)置為普通用戶權(quán)限,防止有人繼續(xù)利用管理員權(quán)限去做一些不好的事情。
接下來就是立刻停止了線上的前后端服務(wù),一方面是為了后面好恢復(fù)數(shù)據(jù),另外也是防止一些同學(xué)發(fā)現(xiàn)自己突然從會(huì)員變成了普通用戶,增加大量的人工咨詢成本。
所以當(dāng)時(shí)很多同學(xué)訪問魚聰明時(shí),看到了這樣的截圖:

穩(wěn)定現(xiàn)場(chǎng)后,接下來就是想辦法恢復(fù)數(shù)據(jù)到正常的狀態(tài),好在我給數(shù)據(jù)庫設(shè)置了分鐘級(jí)別的備份,可以直接把數(shù)據(jù)恢復(fù)到事故發(fā)生前的最近正常的時(shí)間點(diǎn)。

有了備份后的老數(shù)據(jù),還要考慮恢復(fù)這個(gè)時(shí)間點(diǎn)后新增的用戶數(shù)據(jù)。
有很多種恢復(fù)策略,我優(yōu)先選擇了邏輯最簡(jiǎn)單的策略:直接更新用戶 updateTime > '2023-07-20 10:00:00' 的數(shù)據(jù),根據(jù) id 點(diǎn)對(duì)點(diǎn)覆蓋除了 userRole 之外的數(shù)據(jù)列;如果沒有對(duì)應(yīng)的 id,新增一條數(shù)據(jù)。也就是使用類似 saveOrUpdate 的方法。
理想很豐滿,現(xiàn)實(shí)很殘酷。萬萬沒想到,由于 updateTime 是一個(gè)發(fā)生數(shù)據(jù)修改時(shí)自動(dòng)更新的字段,導(dǎo)致所有的數(shù)據(jù) updateTime 全是最新的,相當(dāng)于要把數(shù)據(jù)庫全量的數(shù)據(jù)都去比較一遍。
于是我的員工呢,寫了類似下面這樣的程序:

然后就開始執(zhí)行了,結(jié)果執(zhí)行了很久很久,數(shù)據(jù)都沒更新完。
看來單線程還是太慢了,于是我用并發(fā)編程的方式改進(jìn)了同步的過程。先把所有用戶分組,然后多線程同時(shí)執(zhí)行 saveOrUpdateBatch 方法。
示例代碼如下:
void restoreUserTable() {
List<User> userList = userService.list();
List<UserBak> userBakList = userList.stream().map(user -> {
user.setUserRole(null);
UserBak userBak = new UserBak();
BeanUtils.copyProperties(user, userBak);
return userBak;
}).collect(Collectors.toList());
int batchSize = 1000;
// 使用 lambda 表達(dá)式將 userList 每1000個(gè)元素分為一組
List<List<UserBak>> groupedBakUsers = IntStream.range(0, userList.size())
.boxed()
.collect(Collectors.groupingBy(index -> index / batchSize)) // 將索引按組分組
.values()
.stream()
.map(indices -> indices.stream()
.map(userBakList::get) // 根據(jù)索引獲取 User 對(duì)象
.collect(Collectors.toList())) // 每組1000個(gè)元素的列表
.collect(Collectors.toList()); // 所有分組的列表
List<CompletableFuture<Void>> completableFutureList = new ArrayList<>();
int i = 1;
for (List<UserBak> groupedBakUser : groupedBakUsers) {
int finalI = i;
CompletableFuture<Void> completableFuture = CompletableFuture.runAsync(() -> {
boolean b = userBakService.saveOrUpdateBatch(groupedBakUser, batchSize);
});
i++;
completableFutureList.add(completableFuture);
}
CompletableFuture.allOf(completableFutureList.toArray(new CompletableFuture[]{})).join();
}
使用這種方式,很快數(shù)據(jù)就恢復(fù)完成了。
當(dāng)然,還有更簡(jiǎn)單的方式,比如聯(lián)表查詢、對(duì)比哪些數(shù)據(jù)行發(fā)生了變動(dòng),再去做修改。只不過當(dāng)時(shí)情況緊急、再加上數(shù)據(jù)庫量級(jí)可控,我們選擇了相對(duì)理解成本最低的方式。
之后,我這邊又手動(dòng)做了一次全量備份,并且思考了一下還有沒有遺漏的問題,才恢復(fù)上線。
三、事后復(fù)盤
整個(gè)事故時(shí)長(zhǎng)接近 2 個(gè)小時(shí),大致分為:
- 人工發(fā)現(xiàn)事故(30 分鐘后通過用戶反饋才得知)
- 定位問題(5 - 10 分鐘)
- 策略制定和同步(5 - 10 分鐘)
- 數(shù)據(jù)備份恢復(fù)(15 分鐘)
- 增量數(shù)據(jù)同步(40 分鐘)
- 上線前備份(10 分鐘),同時(shí)進(jìn)行其他考慮
從某種意義上來說,這次的事故比直接刪庫更嚴(yán)重!因?yàn)閯h庫了趕緊恢復(fù)就好,但這次不僅出現(xiàn)了 “數(shù)據(jù)污染”,還出現(xiàn)了 “越權(quán)” 的問題,我們網(wǎng)站內(nèi)僅管理員可見的敏感信息會(huì)存在泄露風(fēng)險(xiǎn)。好在我們也沒什么敏感信息哈哈。
還有就是用戶可能會(huì)利用漏洞來薅魚毛(管理員可以大量獲取),但經(jīng)過我們的統(tǒng)計(jì),這段時(shí)間利用漏洞薅魚毛的人數(shù)寥寥無幾,大家都是非常善良的,這才放下心來。
雖然這次的事故帶來的損失不是特別大,但也發(fā)現(xiàn)了我們系統(tǒng)存在的問題。
我也跟這位員工說:出了事情不可怕,可怕的是不知道改正,出現(xiàn)同樣的事情。
那么應(yīng)該如何防止出現(xiàn)類似的事故呢?
1、控制操作權(quán)限
為了防止用戶執(zhí)行 update、delete 操作時(shí)不小心漏掉了 where 條件、直接更新全量數(shù)據(jù),企業(yè)中一般是會(huì)禁止不帶 where 條件的修改操作的。
出現(xiàn)這次的事故后,我也立刻給 MySQL 開啟了 sql_safe_updates 配置:

缺少 where 條件的更新會(huì)直接觸發(fā)下列報(bào)錯(cuò):

之前為什么沒加?主要是因?yàn)橐郧岸际亲约阂粋€(gè)人開發(fā)系統(tǒng),而且會(huì)有需要全量更新的場(chǎng)景,圖省事兒。
2、生產(chǎn)環(huán)境隔離
正常情況下,不應(yīng)該允許直接在本地連接和操作線上數(shù)據(jù)庫的數(shù)據(jù)。而是需要先編寫代碼、提交代碼審核、發(fā)布上線后,再執(zhí)行修改操作。
像這次的事故,如果員工不是本地直接更新數(shù)據(jù)庫,而是提交代碼給我看一下,我大概率就會(huì)發(fā)現(xiàn)他少寫了更新條件,就能防止了。
其實(shí)之前在騰訊的時(shí)候,我都會(huì)嚴(yán)格注意這些事項(xiàng)的。但之所以現(xiàn)在自己公司的項(xiàng)目是允許員工在本地連接線上的,想必大家也能猜到原因 —— 業(yè)務(wù)規(guī)模小、人數(shù)少,直接在同一個(gè)庫開發(fā)會(huì)方便一些。
但如果項(xiàng)目的規(guī)模上來了,一定要做好多套環(huán)境的隔離,本地環(huán)境、測(cè)試環(huán)境、預(yù)發(fā)布環(huán)境、線上環(huán)境都要嚴(yán)格區(qū)分了。
3、SQL 審批
之前在騰訊的時(shí)候,想要修改關(guān)鍵庫的數(shù)據(jù),不能直接執(zhí)行 SQL 語句,而是要先把 SQL 語句提交到審核平臺(tái),等你的領(lǐng)導(dǎo)和數(shù)據(jù)庫運(yùn)維確認(rèn)沒問題后,才能執(zhí)行。這樣每條 SQL 都是至少有 2 個(gè)人看過的,能夠大大增加安全性。
曾經(jīng)我覺得這種機(jī)制很麻煩,但經(jīng)歷過一些血淚教訓(xùn)后,才意識(shí)到這個(gè)環(huán)節(jié)真的是泰褲辣!
4、數(shù)據(jù)庫審計(jì)
數(shù)據(jù)庫審計(jì)是指記錄和監(jiān)控?cái)?shù)據(jù)庫的訪問及 SQL 語句執(zhí)行情況,從而精細(xì)化風(fēng)險(xiǎn)控制,提高數(shù)據(jù)安全性。
可以自己在數(shù)據(jù)庫配置(比如開啟日志、使用審計(jì)插件等),也可以使用第三方云服務(wù)自帶的審計(jì)規(guī)則配置。
5、提升風(fēng)險(xiǎn)意識(shí)
最不需要技術(shù),卻也是最重要的一點(diǎn),那就是要讓團(tuán)隊(duì)的所有同學(xué)意識(shí)到這件事情帶來的風(fēng)險(xiǎn)、問題的嚴(yán)重性。
因?yàn)槟阌肋h(yuǎn)叫不醒一個(gè)裝睡的人,同理,再多的防護(hù)也限制不了本身就想搞事的人。
所以這件事情是我和這位員工共同的責(zé)任,作為懲罰,我們決定請(qǐng)其他同事喝奶茶。就這么愉快地決定了~
不過也有做的好的地方,比如做了完整又靈活的數(shù)據(jù)備份,這是線上項(xiàng)目必備的操作。
以上就是本期分享,希望大家不僅是看個(gè)樂,也能有一些收獲和啟發(fā),不過希望大家都不要遇到這類鬧心的事情。
作者丨程序員魚皮
來源丨公眾號(hào):程序員魚皮(ID:coder_yupi)






