猿們好,我是honery,今天來給大家嘮一嘮如何避免數據庫報唯一性約束的錯誤。
一、問題的引出
首先拋出一個問題,如何保證數據庫表中的某列的值都不一樣呢?相信大家很容易想到給該列加上唯一性約束,這樣就能保證業務邏輯的正確性了。實際的使用中,尤其高並發場景下,很容易出現插入同一條記錄的情況,該情況下數據庫會報違反唯一性約束的錯誤。總不能讓數據庫一直拋這個錯誤吧。於是我們想到可以在業務代碼中加上該列值是否為空的判斷,判斷為空時再行插入,於是問題就解決了。
問題真的解決了嗎?說是,你就too young too simple了。有沒考慮過高並發場景呢?如果多個線程同時在某次插入前去判空,顯然判斷的結果都是空,那么第一次插入成功后,后續的插入動作都會報違反數據庫唯一性約束的錯誤。總不能讓日志一直報錯吧,該如何解決呢?
二、問題的解決方案
這個問題其實是個典型的問題,可以有很多種解決方案,小編這里就簡單提供三種解決策略。方案很簡單,猿們跟上思路~~
2.1 通過鎖機制,將查詢和插入原子化
相信很多小伙伴很容易就能想到這個方案,通過鎖機制(如內置鎖,synchronized)將記錄是否存在的查詢動作和插入新記錄的動作放在一個同步鎖中,實現的關鍵代碼如下:
@Transactional
public synchronized void insertWhenIdIsEmpty(Qingmj qingmj) {
log.info("進入時間:"+System.currentTimeMillis());
log.info(qingmj.toString());
Qingmj qingmj_old = qingmjMapper.getQingmjById(qingmj.getId());
try {
//等待10s,制造並發場景
Thread.sleep(15000);
} catch (InterruptedException e) {
log.warn("睡眠等待過程異常",e);
}
if(qingmj_old==null) {
log.info("數據准備插入中... ...");
try {
qingmjMapper.insert(qingmj);
log.info("數據插入成功!");
}catch(Exception e) {
log.info("數據插入失敗",e);
}
}else {
log.info("約束鍵已存在,不再插入");
}
log.info("結束時間:"+System.currentTimeMillis());
}
-
[執行結果]:

-
[結果分析]:
從執行結果可知,通過鎖機制將查詢和插入原子化后,徹底的避免了插入重復數據的問題。但是帶來了一個新問題,同步鎖將兩個業務動作鎖在一起,強制執行過程串行化,會導致系統運行的性能較差,該如何優化呢?
2.2 通過雙重檢查鎖機制,優化串行化帶來的性能問題
所謂的雙重檢查鎖機制,從名字不難看出其邏輯。它主要有兩個技術點:一、將方法鎖細化成代碼塊鎖,盡量減少鎖住的執行邏輯;二、執行兩次判空邏輯,即在同步代碼塊中再加入一次判空檢查。具體的代碼實現如下:
//@Transactional
public void insertWhenIdIsEmpty(Qingmj qingmj) {
log.info("進入時間:"+System.currentTimeMillis());
log.info(qingmj.toString());
Qingmj qingmj_old = qingmjMapper.getQingmjById(qingmj.getId());
try {
//等待10s,制造並發場景
Thread.sleep(15000);
} catch (InterruptedException e) {
log.warn("睡眠等待過程異常",e);
}
if(qingmj_old==null) {
synchronized (this) {
Qingmj qingmj_old_2 = qingmjMapper.getQingmjById(qingmj.getId());
if(qingmj_old_2==null) {
log.info("數據准備插入中... ...");
try {
qingmjMapper.insert(qingmj);
log.info("數據插入成功!");
}catch(Exception e) {
log.info("數據插入失敗",e);
}
}else {
log.info("約束鍵發現變為存在狀態,不再插入");
}
}
}else {
log.info("約束鍵已存在,不再插入");
}
log.info("結束時間:"+System.currentTimeMillis());
}
-
[執行結果]:

-
[結果分析]:
引入雙重檢查鎖機制,有效優化了同步方法鎖性能較差的問題,是一種較為推薦的方法。這也是單例模式中懶漢模式的一種經典實現。代碼中的事務注解不能在此處方法上加上,否則會適得其反,這也是鎖與事務作用范圍的一個角逐。好了,回到正題,解決違反數據庫唯一性約束問題,能不能不加鎖呢?答案是肯定的。
2.3 通過巧用Redis的setnx特性,避免重復數據的插入
我們要解決的問題是業務流程正常,但數據庫會持續的報違反唯一性約束的問題。如何保留數據庫的唯一性約束,維持業務流程正常,但同時不讓數據庫報上述異常呢?順着這個思路我們可以想到能否在數據進入數據庫之前就識別出重復數據的沖突呢?這樣便可以解決上述問題了。有很多第三方的中間件可以幫我們做到這點,其中Redis就是一個例子。
大家都用過Redis,知道Redis的setnx方法有個特點,當第一次插入某個key及其value值時會返回“1”;當后續繼續插入該key的其他value值時會返回“0”,並插入失敗。於是乎,我們在插入數據庫之前,先將數據存入Redis當中,若Redis反饋為第一次插入則落地數據庫中;若Redis反饋非第一次插入則不落地數據庫。如此便巧妙解決了數據庫報唯一性約束的問題了,而且!沒有用到鎖!具體實現如下:
@Transactional
public void insertWhenIdIsEmpty(Qingmj qingmj) {
log.info("進入時間:"+System.currentTimeMillis());
log.info(Thread.currentThread().getName() + " parameters:" + qingmj.toString());
Qingmj qingmj_old = qingmjMapper.getQingmjById(qingmj.getId());
try {
//等待10s,制造並發場景
Thread.sleep(15000);
} catch (InterruptedException e) {
log.warn("睡眠等待過程異常",e);
}
if(qingmj_old==null){
if(redisUtil_test.setnx("constraint_id_"+qingmj.getId(), qingmj.getId()) == 1){//如果數據存在則返回0,不存在返回1
log.info("數據准備插入中... ...");
try {
qingmjMapper.insert(qingmj);
log.info("數據插入成功!");
redisUtil_test.expire("constraint_id_"+qingmj.getId(), 1000);//設失效時間3秒
}catch(Exception e) {
redisUtil_test.del("constraint_id_"+qingmj.getId());//插入出異常則刪除
log.info("數據插入失敗",e);
}
}else{
log.info("並發情形,約束鍵已存在,不再插入");
}
}else{
log.info("約束鍵已存在,不再插入");
}
log.info("結束時間:"+System.currentTimeMillis());
}
-
[執行結果]:

-
[結果分析]:
通過巧用Redis的setnx特性有效解決了數據庫持續報違反唯一性約束的錯誤。同時沒有使用同步鎖,大大提升了系統的性能。這種方法甚至在表字段未加唯一性約束的情況下,也能保證業務邏輯的正確性,非常巧妙且靈活。當然系統也必須要支持Redis才能采用這種方案啦。
三、總結
數據庫違反唯一性約束問題,只是一個典型的小問題,解決方案特別多,大家都可以去借鑒。但是小編文中陳述的三種方案匯聚了解決這類問題的兩個思路。其一是加鎖,鎖性能優化;其二是不加鎖,在落地數據庫之前提前引發數據沖突。方案雖說簡單,但是值得回味的。
