新聞動態(tài)
日常開發(fā)中,如何減少bug?
常見問題 發(fā)布者:ou3377 2021-12-10 08:40 訪問量:280
本文將從數(shù)據(jù)庫、代碼層面、緩存使用篇3個大方向,總結(jié)出一共50多個注意點,助大家成為開發(fā)質(zhì)量之星。
慢查詢
數(shù)據(jù)庫篇的話,哪些地方容易導(dǎo)致bug出現(xiàn)呢?我總結(jié)了7個方面:慢查詢、數(shù)據(jù)庫字段注意點、事務(wù)失效的場景、死鎖、主從延遲、新老數(shù)據(jù)兼容、一些SQL經(jīng)典注意點。
提起慢查詢,我們馬上就會想到加索引。如果一條SQL沒加索引,或者沒有命中索引的話,就會產(chǎn)生慢查詢。
索引哪些情況會失效?
單表數(shù)據(jù)量太大,就會影響SQL執(zhí)行性能。我們知道索引數(shù)據(jù)結(jié)構(gòu)一般是B+樹,一棵高度為3的B+樹,大概可以存儲兩千萬的數(shù)據(jù)。超過這個數(shù)的話,B+樹要變高,查詢性能會下降。
因此,數(shù)據(jù)量大的時候,建議分庫分表。分庫分表的中間件有mycat、sharding-jdbc
日常開發(fā)中,筆者見過很多不合理的SQL:比如一個SQL居然用了6個表連接,連表太多會影響查詢性能;再比如一個表,居然加了10個索引等等。索引是會降低了插入和更新SQL性能,所以索引一般不建議太多,一般不能超過五個。
數(shù)據(jù)庫字段這塊內(nèi)容,很容易出bug。比如,你測試環(huán)境修改了表結(jié)構(gòu),加了某個字段,忘記把腳本帶到生產(chǎn)環(huán)境,那發(fā)版肯定有問題了。
假設(shè)你的數(shù)據(jù)庫字段是:
`name` varchar(255) DEFAULT NOT NULL
如果請求參數(shù)來了變量name,字段長度是300,那插入表的時候就報錯了。所以需要校驗參數(shù),防止字段超長。
我們設(shè)計數(shù)據(jù)庫表字段的時候,盡量把字段設(shè)置為not null。
如果數(shù)據(jù)庫字段設(shè)置為NULL
值,容易導(dǎo)致程序空指針;如果數(shù)據(jù)庫字段設(shè)置為NULL
值,需要注意count(具體列) 的使用,會有坑。
我們的日常開發(fā)任務(wù),如果在測試環(huán)境,對表進(jìn)行修改,比如添加了一個新字段,必須要把SQL腳本帶到生產(chǎn)環(huán)境,否則字段缺失,發(fā)版就有問題啦。
如果一個表字段需要支持表情存儲,使用utf8mb4。
如果你要用一個字段存儲文件,考慮存儲文件的路徑,而不是保存整個文件下去。使用text時,涉及查詢條件時,注意創(chuàng)建前綴索引。
@Transactional注解,加在非public修飾的方法上,事務(wù)是不會生效的。spring事務(wù)是借鑒了AOP的思想,也是通過動態(tài)代理來實現(xiàn)的。spring事務(wù)自己在調(diào)用動態(tài)代理之前,已經(jīng)對非public方法過濾了,所以非public方法,事務(wù)不生效。
以下這個場景,@Transactional事務(wù)也是無效的
public class TransactionTest{
public void A(){
//插入一條數(shù)據(jù)
//調(diào)用方法B (本地的類調(diào)用,事務(wù)失效了)
B();
}
@Transactional
public void B(){
//插入數(shù)據(jù)
}
}
@Transactional
public void method(){
try{
//插入一條數(shù)據(jù)
insertA();
//更改一條數(shù)據(jù)
updateB();
}catch(Exception e){
logger.error("異常被捕獲了,那你的事務(wù)就失效咯",e);
}
}
Spring默認(rèn)拋出了未檢查unchecked
異常(繼承自RuntimeException 的異常)或者Error才回滾事務(wù);其他異常不會觸發(fā)回滾事務(wù)。如果在事務(wù)中拋出其他類型的異常,就需要指定rollbackFor
屬性。
MyISAM存儲引擎不支持事務(wù),InnoDb就支持事務(wù)
業(yè)務(wù)代碼要和spring事務(wù)的源碼在同一個線程中,才會受spring事務(wù)的控制。比如下面代碼,方法mothed的子線程,內(nèi)部執(zhí)行的事務(wù)操作,將不受mothed方法上spring事務(wù)的控制,這一點大家要注意。這是因為spring事務(wù)實現(xiàn)中使用了ThreadLocal,實現(xiàn)同一個線程中數(shù)據(jù)共享。
@Transactional
public void mothed() {
new Thread() {
事務(wù)操作
}.start();
}
死鎖是指兩個或多個事務(wù)在同一資源上相互占用,并請求鎖定對方的資源,從而導(dǎo)致惡性循環(huán)的現(xiàn)象。
MySQL內(nèi)部有一套死鎖檢測機制,一旦發(fā)生死鎖會立即回滾一個事務(wù),讓另一個事務(wù)執(zhí)行下去。但死鎖有資源的利用率降低、進(jìn)程得不到正確結(jié)果等危害。
要避免死鎖,需要學(xué)會分析:一條SQL的加鎖是如何進(jìn)行的?一條SQL加鎖,可以分9種情況進(jìn)行探討:
分析解決死鎖的步驟如下:
有興趣的小伙伴,可以看下我之前寫的這篇文章:手把手教你分析Mysql死鎖問題
先插入,接著就去查詢,這類代碼邏輯比較常見,這可能會有問題的。一般數(shù)據(jù)庫都是有主庫,從庫的。寫入的話是寫主庫,讀一般是讀從庫。如果發(fā)生主從延遲,,很可能出現(xiàn)你插入成功了,但是查詢不到的情況。
如果是重要業(yè)務(wù),要求強一致性,考慮直接讀主庫
如果是一般業(yè)務(wù),可以接受短暫的數(shù)據(jù)不一致的話,優(yōu)先考慮讀從庫。因為從庫可以分擔(dān)主庫的讀寫壓力,提高系統(tǒng)吞吐。
我們?nèi)粘i_發(fā)中,隨著業(yè)務(wù)需求變更,經(jīng)常需要給某個數(shù)據(jù)庫表添加個字段。比如在某個APP配置表,需要添加個場景號字段,如scene_type
,它的枚舉值是 01、02、03
,那我們就要跟業(yè)務(wù)對齊,新添加的字段,老數(shù)據(jù)是什么默認(rèn)值,是為空還是默認(rèn)01,如果是為NULL
的話,程序代碼就要做好空指針處理。
如果我們開發(fā)中,需要沿用數(shù)據(jù)庫表的老字段,并且有存量數(shù)據(jù),那就需要考慮老存量數(shù)據(jù)庫的值是否有坑。比如我們表有個user_role_code 的字段,老的數(shù)據(jù)中,它枚舉值是 01:超級管理員 02:管理員 03:一般用戶
。假設(shè)業(yè)務(wù)需求是一般用戶拆分為03查詢用戶和04操作用戶,那我們在開發(fā)中,就要考慮老數(shù)據(jù)值的問題啦。
limit大分頁是一個非常經(jīng)典的SQL問題,我們一般有這3種對應(yīng)的解決方案
方案一: 如果id是連續(xù)的,可以這樣,返回上次查詢的最大記錄(偏移量),再往下limit
select id,name from employee where id>1000000 limit 10.
方案二: 在業(yè)務(wù)允許的情況下限制頁數(shù):
建議跟業(yè)務(wù)討論,有沒有必要查這么后的分頁啦。因為絕大多數(shù)用戶都不會往后翻太多頁。谷歌搜索頁也是限制了頁數(shù),因此不存在limit大分頁問題。
方案三: 利用延遲關(guān)聯(lián)或者子查詢優(yōu)化超多分頁場景。(先快速定位需要獲取的id段,然后再關(guān)聯(lián))
SELECT a.* FROM employee a, (select id from employee where 條件 LIMIT 1000000,10 ) b where a.id=b.id
我們更新或者查詢數(shù)據(jù)庫數(shù)據(jù)時,盡量避免循環(huán)去操作數(shù)據(jù)庫,可以考慮分批進(jìn)行。比如你要插入10萬數(shù)據(jù)的話,可以一次插入500條,執(zhí)行200次。
正例:
remoteBatchQuery(param);
反例:
for(int i=0;i<100000;i++){
remoteSingleQuery(param)
}
代碼層面
我們編碼的時候,需要注意這六種類型的空指針問題
if(object!=null){
String name = object.getName();
}
在高并發(fā)場景下,HashMap
可能會出現(xiàn)死循環(huán)。因為它是非線性安全的,可以考慮使用ConcurrentHashMap
。所以我們使用這些集合的時候,需要注意是不是線性安全的。
日常開發(fā),經(jīng)常需要對日期格式化,但是呢,年份設(shè)置為YYYY大寫的時候,是有坑的哦。
Calendar calendar = Calendar.getInstance();
calendar.set(2019, Calendar.DECEMBER, 31);
Date testDate = calendar.getTime();
SimpleDateFormat dtf = new SimpleDateFormat("YYYY-MM-dd");
System.out.println("2019-12-31 轉(zhuǎn) YYYY-MM-dd 格式后 " + dtf.format(testDate));
運行結(jié)果:
2019-12-31 轉(zhuǎn) YYYY-MM-dd 格式后 2020-12-31
還有金額計算也比較常見,我們要注意精度問題:
public class DoubleTest {
public static void main(String[] args) {
System.out.println(0.1+0.2);
System.out.println(1.0-0.8);
System.out.println(4.015*100);
System.out.println(123.3/100);
double amount1 = 3.15;
double amount2 = 2.10;
if (amount1 - amount2 == 1.05){
System.out.println("OK");
}
}
}
運行結(jié)果:
0.30000000000000004
0.19999999999999996
401.49999999999994
1.2329999999999999
讀取大文件的時候,不要Files.readAllBytes
直接讀到內(nèi)存,會OOM的,建議使用BufferedReader
一行一行來,或者使用NIO
使用try-with-resource,讀寫完文件,需要關(guān)閉流
/*
* 關(guān)注公眾號,撿田螺的小男孩
*/
try (FileInputStream inputStream = new FileInputStream(new File("jay.txt")) {
// use resources
} catch (FileNotFoundException e) {
log.error(e);
} catch (IOException e) {
log.error(e);
}
日常開發(fā)中,這種代碼實現(xiàn)經(jīng)??梢姡合炔樵兪欠裼惺S嗫捎玫钠?,再去更新票余量。
if(selectIsAvailable(ticketId){
1、deleteTicketById(ticketId)
2、給現(xiàn)金增加操作
}else{
return “沒有可用現(xiàn)金券”
}
如果是并發(fā)執(zhí)行,很可能有問題的,應(yīng)該利用數(shù)據(jù)庫更新/刪除的原子性,正解如下:
if(deleteAvailableTicketById(ticketId) == 1){
1、給現(xiàn)金增加操作
}else{
return “沒有可用現(xiàn)金券”
}
我們提供對外的接口,不管是提供給客戶端、還是前端,又或是別的系統(tǒng)調(diào)用,都需要校驗一下入?yún)⒌暮戏ㄐ浴?/p>
★如果你的數(shù)據(jù)庫字段設(shè)置為varchar(16),對方傳了一個32位的字符串過來,你不校驗參數(shù)長度,插入數(shù)據(jù)庫直接異常了。
”
很多bug都是因為修改了對外老接口,但是卻不做兼容導(dǎo)致的。關(guān)鍵這個問題多數(shù)是比較嚴(yán)重的,可能直接導(dǎo)致系統(tǒng)發(fā)版失敗的。新手程序員很容易犯這個錯誤哦~
比如我們有個dubbo的分布式接口,本次你修改了入?yún)?,就需要考慮新老接口兼容。原本是只接收A,B參數(shù),現(xiàn)在你加了一個參數(shù)C,就可以考慮這樣處理。
//老接口
void oldService(A,B){
//兼容新接口,傳個null代替C
newService(A,B,null);
}
//新接口,暫時不能刪掉老接口,需要做兼容。
void newService(A,B,C);
如果瞬間的大流量請求過來,容易壓垮系統(tǒng)。所以為了保護(hù)我們的系統(tǒng),一般要做限流處理。可以使用guava ratelimiter 組件做限流,也可以用阿里開源的Sentinel
我們轉(zhuǎn)賬等類型的接口,一定要注意安全性。一定要鑒權(quán),加簽驗簽,為用戶交易保駕護(hù)航。
接口是需要考慮冪等性的,尤其搶紅包、轉(zhuǎn)賬這些重要接口。最直觀的業(yè)務(wù)場景,就是用戶連著點擊兩次,你的接口有沒有hold住。
★”
冪等(idempotent、idempotence)是一個數(shù)學(xué)與計算機學(xué)概念,常見于抽象代數(shù)中。 在編程中.一個冪等操作的特點是其任意多次執(zhí)行所產(chǎn)生的影響均與一次執(zhí)行的影響相同。冪等函數(shù),或冪等方法,是指可以使用相同參數(shù)重復(fù)執(zhí)行,并能獲得相同結(jié)果的函數(shù)。
一般冪等技術(shù)方案有這幾種:
我們調(diào)用別人的接口,如果超時了怎么辦呢?
★舉個例子,我們調(diào)用一個遠(yuǎn)程轉(zhuǎn)賬接口,A客戶給B客戶轉(zhuǎn)100萬,成功的時候就把本地轉(zhuǎn)賬流水置為成功,失敗的時候就把本地流水置為失敗。如果調(diào)用轉(zhuǎn)賬系統(tǒng)超時了呢,我們怎么處理呢?置為成功還是失敗呢?這個超時處理可要考慮好,要不然就資金損失了。這種場景下,調(diào)接口超時,我們就可以先不更新本地轉(zhuǎn)賬流水狀態(tài),而是重新發(fā)起查詢遠(yuǎn)程轉(zhuǎn)賬請求,查詢到轉(zhuǎn)賬成功的記錄,再更新本地狀態(tài)狀態(tài)
”
如果我們調(diào)用一個遠(yuǎn)程http或者dubbo接口,調(diào)用失敗了,我們可以考慮引入重試機制。有時候網(wǎng)路抖動一下,接口就調(diào)失敗了,引入重試機制可以提高用戶體驗。但是這個重試機制需要評估次數(shù),或者有些接口不支持冪等,就不適合重試的。
假設(shè)我們系統(tǒng)是一個提供注冊的服務(wù):用戶注冊成功之后,調(diào)遠(yuǎn)程A接口發(fā)短信,調(diào)遠(yuǎn)程B接口發(fā)郵件,最后更新注冊狀態(tài)為成功。
如果調(diào)用接口B發(fā)郵件失敗,那用戶就注冊失敗,業(yè)務(wù)可能就不會同意了。這時候我們可以考慮給B接口降級處理,提供有損服務(wù)。也就是說,如果調(diào)用B接口失敗,那先不發(fā)郵件,而是先讓用戶注冊成功,后面搞個定時補發(fā)郵件就好啦。
我還是使用上個小節(jié)的用戶注冊的例子。我們可以開個異步線程去調(diào)A接口發(fā)短信,異步調(diào)B接口發(fā)郵件,那即使A或者B接口調(diào)失敗,我們還是可以保證用戶先注冊成功。
把發(fā)短信這些通知類接口,放到異步線程處理,可以降低接口耗時,提升用戶體驗哦。
如果我們調(diào)用一個遠(yuǎn)程接口,一般需要思考以下:如果別人接口異常,我們要怎么處理,怎么兜底,是重試還是當(dāng)做失敗?怎么保證數(shù)據(jù)的最終一致性等等。
使用緩存,可以降低耗時,提供系統(tǒng)吞吐性能。但是,使用緩存,會存在數(shù)據(jù)一致性的問題。
一般我們使用緩存,都是旁路緩存模式,讀請求流程如下:
旁路緩存模式的寫流程:
我們在操作緩存的時候,到底應(yīng)該刪除緩存還是更新緩存呢?我們先來看個例子:
這時候,緩存保存的是A的數(shù)據(jù)(老數(shù)據(jù)),數(shù)據(jù)庫保存的是B的數(shù)據(jù)(新數(shù)據(jù)),數(shù)據(jù)不一致了,臟數(shù)據(jù)出現(xiàn)啦。如果是刪除緩存取代更新緩存則不會出現(xiàn)這個臟數(shù)據(jù)問題。
雙寫的情況下,先操作數(shù)據(jù)庫還是先操作緩存?我們再來看一個例子:假設(shè)有A、B兩個請求,請求A做更新操作,請求B做查詢讀取操作。
image.png
醬紫就有問題啦,緩存和數(shù)據(jù)庫的數(shù)據(jù)不一致了。緩存保存的是老數(shù)據(jù),數(shù)據(jù)庫保存的是新數(shù)據(jù)。因此,Cache-Aside緩存模式,選擇了先操作數(shù)據(jù)庫而不是先操作緩存。
★緩存穿透:指查詢一個一定不存在的數(shù)據(jù),由于緩存不命中時,需要從數(shù)據(jù)庫查詢,查不到數(shù)據(jù)則不寫入緩存,這將導(dǎo)致這個不存在的數(shù)據(jù)每次請求都要到數(shù)據(jù)庫去查詢,進(jìn)而給數(shù)據(jù)庫帶來壓力。
”
緩存穿透一般都是這幾種情況產(chǎn)生的:業(yè)務(wù)不合理的設(shè)計、業(yè)務(wù)/運維/開發(fā)失誤的操作、黑客非法請求攻擊。如何避免緩存穿透呢?一般有三種方法。
★緩存雪崩:指緩存中數(shù)據(jù)大批量到過期時間,而查詢數(shù)據(jù)量巨大,引起數(shù)據(jù)庫壓力過大甚至down機。
”
★緩存擊穿:指熱點key在某個時間點過期的時候,而恰好在這個時間點對這個Key有大量的并發(fā)請求過來,從而大量的請求打到db。
”
緩存擊穿看著有點像緩存雪崩,其實它兩區(qū)別是,緩存雪奔是指數(shù)據(jù)庫壓力過大甚至down機,緩存擊穿只是大量并發(fā)請求到了DB數(shù)據(jù)庫層面??梢哉J(rèn)為擊穿是緩存雪奔的一個子集吧。有些文章認(rèn)為它倆區(qū)別,是在于擊穿針對某一熱點key緩存,雪奔則是很多key。
解決方案就有兩種:
在Redis中,我們把訪問頻率高的key,稱為熱點key。如果某一熱點key的請求到服務(wù)器主機時,由于請求量特別大,可能會導(dǎo)致主機資源不足,甚至宕機,從而影響正常的服務(wù)。
如何解決熱key問題?
如果我們使用的是Redis,而Redis的內(nèi)存是比較昂貴的,我們不要什么數(shù)據(jù)都往Redis里面塞,一般Redis只緩存查詢比較頻繁的數(shù)據(jù)。同時,我們要合理評估Redis的容量,也避免頻繁set覆蓋,導(dǎo)致設(shè)置了過期時間的key失效。
如果我們使用的是本地緩存,如guava的本地緩存,也要評估下容量。避免容量不夠。
為了避免Redis內(nèi)存不夠用,Redis用8種內(nèi)存淘汰策略保護(hù)自己~
★”
volatile-lru:當(dāng)內(nèi)存不足以容納新寫入數(shù)據(jù)時,從設(shè)置了過期時間的key中使用LRU(最近最少使用)算法進(jìn)行淘汰; allkeys-lru:當(dāng)內(nèi)存不足以容納新寫入數(shù)據(jù)時,從所有key中使用LRU(最近最少使用)算法進(jìn)行淘汰。 volatile-lfu:4.0版本新增,當(dāng)內(nèi)存不足以容納新寫入數(shù)據(jù)時,在過期的key中,使用LFU算法進(jìn)行刪除key。 allkeys-lfu:4.0版本新增,當(dāng)內(nèi)存不足以容納新寫入數(shù)據(jù)時,從所有key中使用LFU算法進(jìn)行淘汰; volatile-random:當(dāng)內(nèi)存不足以容納新寫入數(shù)據(jù)時,從設(shè)置了過期時間的key中,隨機淘汰數(shù)據(jù);。 allkeys-random:當(dāng)內(nèi)存不足以容納新寫入數(shù)據(jù)時,從所有key中隨機淘汰數(shù)據(jù)。 volatile-ttl:當(dāng)內(nèi)存不足以容納新寫入數(shù)據(jù)時,在設(shè)置了過期時間的key中,根據(jù)過期時間進(jìn)行淘汰,越早過期的優(yōu)先被淘汰; noeviction:默認(rèn)策略,當(dāng)內(nèi)存不足以容納新寫入數(shù)據(jù)時,新寫入操作會報錯。
文章源自撿田螺的小男孩
【推薦閱讀】
關(guān)鍵字: BUG 數(shù)據(jù)庫 代碼
文章連接: http://www.hsjyfc.com.cn/cjwt/795.html
版權(quán)聲明:文章由 晨展科技 整理收集,來源于互聯(lián)網(wǎng)或者用戶投稿,如有侵權(quán),請聯(lián)系我們,我們會立即刪除。如轉(zhuǎn)載請保留