JAVA處理數據不存在插入存在更新

最近在做項目的時候碰到這樣一個問題,做一個用戶餘額的需求。具體如下:
類似這樣一張表:

CREATE TABLE `test_insert` (
  `id` int(10) unsigned NOT NULL AUTO_INCREMENT,
  `token` varchar(10) NOT NULL DEFAULT '0' COMMENT '用戶標誌-唯一索引',
  `remark` varchar(20) NOT NULL DEFAULT '' COMMENT '備註描述',
  `balance` int(10) NOT NULL DEFAULT '0' COMMENT '餘額',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '創建時間',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '修改時間',
  PRIMARY KEY (`id`),
  UNIQUE KEY `idx_a` (`token`)
)

用戶每次下單需要根據token查看用戶在該表中是否有數據,若果有,就把它的balance增加相應的金額amount;如果沒有就要在該表新增一條該用戶的數據添加相應的餘額。
但是在編碼的時候注意到一個問題,由於我們系統中的併發是相當大的(日均達到近千萬筆交易),對於一個用戶也有比較高的併發情況。假設某個用戶在表中沒有記錄,首筆交易還是並發過來的,都去進行insert操作會出問題。爲防止這種情況,最開始的編碼如下:

@Service
public class TestServiceImpl implements TestService {
    private static final String REDISKEY = "balance.insert.";
    @Autowired
    private StringRedisTemplate stringRedisTemplate;
    @Autowired
    private TestInsertDAO testInsertDAO;

    @Override
    public boolean addBalance(String token, Integer amount) {
        TestInsertDO testInsertDO = testInsertDAO.selectByToken(token);
        Long update = 0L;
        if (testInsertDO == null) {
            Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(REDISKEY + token, "1");
            stringRedisTemplate.expire(REDISKEY + token, 5, TimeUnit.SECONDS);
            if (aBoolean) {
                testInsertDO = new TestInsertDO();
                testInsertDO.setToken(token);
                testInsertDO.setRemark("111");
                testInsertDO.setBalance(amount);
                update = testInsertDAO.insert(testInsertDO);     //不存在就插入,balance是該筆訂單的amount
                stringRedisTemplate.delete(REDISKEY + token);
            } else {
                update = testInsertDAO.updateByToken(token, amount);   //拿不到鎖就直接更新
            }
        } else {
            update = testInsertDAO.updateByToken(token, amount);    //存在就直接更新
        }
        return update > 0;
    }
}

注意這裏的updateByToken方法是在sql上做加操作:

    <operation name="updateByToken" paramtype="primitive" >
        UPDATE
        test_insert
        SET
        BALANCE = BALANCE + #{amount}
        WHERE
        token = #{token,jdbcType=VARCHAR}
    </operation>

這種方式只是利用了一下redis的分佈式鎖NX鎖
Boolean aBoolean = stringRedisTemplate.opsForValue().setIfAbsent(REDISKEY + token, “1”);
來防止有兩個線程同時去做插入操作,但是這個寫法問題非常多:

  • 這個鎖不是阻塞的,併發時有一個拿到了鎖去走insert,另一個沒拿到走了update,如果update走的比insert快就會造成update操作更新不到數據
  • stringRedisTemplate.delete(REDISKEY + token);這個解鎖操作。如果解鎖了後,正巧有個線程這個時候去拿鎖,那它拿到了鎖後又去執行insert操作,由於token有唯一索引會報錯!

併發10筆的情況下,我們來看一下併發執行結果:
在這裏插入圖片描述在這裏插入圖片描述
數據庫只加了9次,並且代碼有一次因爲唯一索引重複插入問題的報錯!

對於這種方式,想了不少方法直接來改進但是都不是太好的方式,需要線程休眠或者其他的鎖類型!

下面介紹幾種方式來做優化:

1、利用mysql的on duplicate key update

網上百度了一下差不多都在推薦這種方式,只需要把insert語句改一下

    <operation name="insert" paramtype="object" remark="insert:TEST_INSERT">
        INSERT INTO TEST_INSERT(
        TOKEN
        ,REMARK
        ,BALANCE
        )VALUES(
        #{token,jdbcType=INTEGER}
        , #{remark,jdbcType=VARCHAR}
        , #{balance,jdbcType=INTEGER}
        )on duplicate key update BALANCE = BALANCE + #{amount}
    </operation>

這種方式的確最簡單,但是在實踐開發中並不建議使用這樣的sql語句,首先這個是mysql特有的一個語句,可能有的持久層框架並不支持,另外這樣的語句並不適合DBA來維護,其次,這種寫法也有出現死鎖的風險,詳情參見:https://blog.csdn.net/pml18710973036/article/details/78452688

2、利用成熟的阻塞鎖,比如redisson的

上面利用Jedis的分佈式鎖做的併發處理,其實最大的問題就是Jedis的NX鎖是非阻塞的。利用redisson的阻塞鎖可以更好地解決這個問題,編碼如下:

public boolean addBalance(String token, Integer amount) {
        TestInsertDO testInsertDO = testInsertDAO.selectByToken(token);
        Long update = 0L;
        if (testInsertDO == null) {
            RLock lock = redisson.getLock("balanceinsert" + token);
            lock.lock(5, TimeUnit.SECONDS);
            testInsertDO = testInsertDAO.selectByToken(token); //雙重檢查;防止加鎖過程中被插入數據
            if (testInsertDO == null) {
                testInsertDO = new TestInsertDO();
                testInsertDO.setToken(token);
                testInsertDO.setRemark("加餘額");
                testInsertDO.setBalance(amount);
                update = testInsertDAO.insert(testInsertDO);     //不存在就插入,balance是該筆訂單的amount
            } else {
                update = testInsertDAO.updateByToken(token, amount);  //有數據了就更新
            }
            lock.unlock();
        } else {
            update = testInsertDAO.updateByToken(token, amount);    //存在就直接更新
        }
        return update > 0;
    }

這裏在獲得鎖之後再去檢查一遍數據庫的信息,防止在拿鎖的過程中有信息被插入了!
關於redisson的分佈式鎖可以這個:https://github.com/redisson/redisson/wiki/8.-distributed-locks-and-synchronizers

3、利用唯一索引先插入默認值

這種方式不需要額外的工具來處理。上面的inert的時候把當前訂單的餘額一起插入了。現在我們分兩步,先insert一個默認值,然後再update上該筆訂單的amount,把insert異常捕捉,那麼在併發時由於唯一索引的存在,一定是隻有一條插入成功,其他的報錯,捕捉異常後繼續走update。

    public boolean addBalance(String token, Integer amount) {
        TestInsertDO testInsertDO = testInsertDAO.selectByToken(token);
        Long update = 0L;
        if (testInsertDO == null) {
            try {
                testInsertDO = new TestInsertDO();
                testInsertDO.setToken(token);
                testInsertDO.setBalance(0);
                testInsertDO.setRemark("加餘額");
                update = testInsertDAO.insert(testInsertDO);     //不存在就插入,balance是該筆訂單的amount
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
        update = testInsertDAO.updateByToken(token, amount);    //存在就直接更新

        return update > 0;
    }

併發10條執行結果:
在這裏插入圖片描述
其實這種方式最簡單易行,我們最後也採用的這種方式!其實就是把一步拆爲2步。像這種併發的解決方案其實經常會遇到,多思考一下方案,跟同事多交流一下,發現代碼世界還是無窮無盡的!

發佈了5 篇原創文章 · 獲贊 4 · 訪問量 2257
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章