天天看點

高并發下實作幂等的幾種方式

作者:飛龍在天808

前言

在我們業務開發過程中,總會遇到這種情況,就是插入了多條重複資料,或者在更新資料的時候出現了資料錯亂,在執行多次的時候,結果總是不一樣的,與我們的預期不符。我們引入一個概念叫做“幂等”,幂等其實是一個數學概念,在程式設計中一個幂等操作的特點是其任意多次執行所産生的影響均與一次執行的影響相同,這也是我們所期望的,那麼下面我們詳細介紹一下幾種實作幂等的方式。

select + insert

先從資料庫查詢記錄是否存在,不存在插入,存在更新。

public Users insert(Users users) {
    LambdaQueryWrapper<Users> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(Users::getUsername, users.getUsername());
    Users usersOrig = usersMapper.selectOne(queryWrapper);
    if (usersOrig == null) {
        usersMapper.insert(users);
    } else {
        users.setId(usersOrig.getId());
        BeanUtils.copyProperties(users, usersOrig);
        usersMapper.update(users);
    }
    return users;
}           

這種方式在并發不高的情況下可以使用,在高并發下兩個線程過來同時查詢,都查不出資料,判斷資料為空,同時插入,出現重複資料。

實驗:

使用jmeter模拟多線程(後面幾種幂等方式驗證也使用此例子)

模拟3個線程同時請求

高并發下實作幂等的幾種方式
高并發下實作幂等的幾種方式

出現重複資料

高并發下實作幂等的幾種方式

高并發場景下不建議單獨使用,但是可以結合分布式鎖使用(在性能要求不是很高,資料要嚴格幂等的話--例如支付,轉賬等,推薦此方式)

2. 資料庫加悲觀鎖

這種方式适合更新帶有計算的幂等,例如:amount為使用者賬戶餘額

update users set amount = amount - 10 where id = 138           

如果不加幂等,高并發場景下很有可能将amount減為負數。

假如場景:使用者A賬戶餘額有20元,現在有3個線程同時進行請求扣減金額10元,正常會有一個線程因餘額不足扣減失敗,最後賬戶餘額為0。

@Transactional
public void updateAmount(Users users) {
    Users usersOrig = usersMapper.queryById(users.getId());
    if (usersOrig != null && usersOrig.getAmount() >= users.getAmount()) {
        // 餘額大于扣減金額
        usersMapper.updateAmount(users);
    } else {
        log.info("賬戶餘額不足");
    }

}           

賬戶餘額變成了-10

高并發下實作幂等的幾種方式

使用資料庫悲觀鎖可以解決這一問題,在查詢餘額時鎖住這一行

select * from users where id = 138 for update           
高并發下實作幂等的幾種方式

最後賬戶餘額為0,成功解決。

注意:必須使用事物,沒有事物鎖會失效,查詢條件ID必須是主鍵或者唯一索引,要不然會鎖整個表。

@Transactional
public void updateAmount(Users users) {
    log.info("目前線程pos1={}", Thread.currentThread().getName());
    Users usersOrig = usersMapper.queryById(users.getId());
    log.info("目前線程pos2={}", Thread.currentThread().getName());
    if (usersOrig != null && usersOrig.getAmount() >= users.getAmount()) {
        // 餘額大于扣減金額
        usersMapper.updateAmount(users);
    } else {
        log.info("賬戶餘額不足");
    }
    log.info("目前線程pos3={}", Thread.currentThread().getName());

}           
高并發下實作幂等的幾種方式

由于悲觀鎖是在事物中鎖住一行資料,就是其他線程要等待正在處理的線程執行完所用事物操作,才會執行(見上圖示例)。也就是說如果整個事物處理的很慢,會有大量的線程出于等待狀态,會嚴重影響接口性能,不建議使用。

3. 資料庫加樂觀鎖

樂觀鎖可以解決悲觀鎖性能問題,即在表中加一個版本号version字段,每次更新對version+1。假如有2個線程同時請求

首先查詢金額時,帶出version

select
  id, amount, version
from users
where id = 138           

更新時,id與version做條件,更新amount,version+1

update users set amount = amount - 10, version = version + 1 where id = 138 and version = 1           

然後判斷本次 update 操作的影響行數,如果大于 0,則說明本次更新成功,如果等于 0,則說明本次更新沒有讓資料變更。

public void updateAmount(Users users) {
    Users usersOrig = usersMapper.queryById(users.getId());
    if (usersOrig != null && usersOrig.getAmount() >= users.getAmount()) {
        // 餘額大于扣減金額
        users.setVersion(usersOrig.getVersion());
        int i = usersMapper.updateAmount(users);
        if (i > 0) {
            log.info(Thread.currentThread().getName() + ":扣減成功");
        }

    } else {
        log.info("賬戶餘額不足");
    }

}           

其實不管是悲觀鎖還是樂觀鎖可以防止多個不同使用者去扣減同一個賬戶餘額,造成多扣餘額變為負數的情況,但是如果是同一個使用者,由于某種原因連續點了多次扣減(有可能前端沒有做防重複送出),比如使用者想扣除10,結果扣了20,與預期不符。像這種情況可以通過下面方式解決:

1. 前端做好防抖處理

2. 後端做好放重複送出

3. 極端情況下同一使用者,同時送出2個相同的請求

分兩種情況解決:

  • 使用者是操作自己的賬戶

前端先查詢本賬戶的版本号version,然後調扣減操作時将此版本号傳給背景(查詢version和扣減操作要在一個按鈕操作下),如果此版本号與背景資料庫中版本号對比,如果相同則進行扣減操作。

  • 使用者是操作的公共賬戶

如果使用者是操作的公共賬戶,那就有可能别人再操作,那用版本号這種方式就有問題了,可能有人就沒有扣減成功。可以使用下面的第5種方式,建防重表,如果業務中有類似功能的表可以不用另建,如果插入防重表成功,則請求成功,插入失敗,請求無效。

4. 加唯一索引

對于防止有重複記錄,使用這種方式最簡單,比如在users表中的username字段加唯一索引,即使有多個相同的請求過來,也會隻存一條記錄,其它做好異常處理就好。

5. 建防重表

如果業務表中不具有加唯一索引的條件,可以額外建立一個防重表,專門來建立一個唯一索引,且表中隻包含主鍵和唯一索引。如果插入防重表成功則可繼續執行下面的業務操作。

6. 根據業務表中某個狀态

比如訂單狀态有1-待支付、2-待發貨、3-待收貨、4-已完成,這些狀态是順序改變的,如果目前訂單狀态是待支付,這時使用者支付,則要把訂單改為待發貨

update order_info set order_status = 2 where order_id = 123 and order_status = 1           

當第一個請求過來,将訂單狀态由2更新為1,第二個請求再執行,訂單狀态已經變為2了,再執行相同的sql,則影響的行數為0了,更新失敗。類似于樂觀鎖方式。

7. 分布式鎖

加唯一索引和防重表本質上也是分布式鎖,隻不過是資料庫層面的,并發性能不高,可以采用redis作為分布式鎖,性能會更高。

采用redis中setnx指令或者直接使用redisson分布式鎖架構

setnx指令方式:

public void insertUsers(Users users) {
    // 聲明一個線程ID,用于後面判斷是否是該線程持有鎖
    String threadId = UUID.randomUUID().toString();
    log.info("線程{}執行插入操作", threadId);
    // 設定鎖,注意一定要設定一個逾時時間,否則如果服務挂掉或重新開機,鎖将永遠存在
    try {
        Boolean aBoolean = redisTemplate.opsForValue().setIfAbsent("user:clock:" + users.getUsername(), threadId, 60, TimeUnit.SECONDS);
        if (aBoolean) {
            // 加鎖成功,儲存使用者
            insert(users);
        }
    } finally {
        if (threadId.equals(redisTemplate.opsForValue().get("user:clock:" + users.getUsername()))) {
            // 判斷是目前線程持有的鎖,則進行鎖釋放
            redisTemplate.delete("user:clock:" + users.getUsername());
        }
    }

}
public Users insert(Users users) {
    LambdaQueryWrapper<Users> queryWrapper = new LambdaQueryWrapper<>();
    queryWrapper.eq(Users::getUsername, users.getUsername());
    Users usersOrig = usersMapper.selectOne(queryWrapper);
    if (usersOrig == null) {
        usersMapper.insert(users);
    } else {
        users.setId(usersOrig.getId());
        BeanUtils.copyProperties(users, usersOrig);
        usersMapper.update(users);
    }
    return users;
}           

成熟架構redisson:

public void insertUsers(Users users) {
    String lockKey = "lockKey";
    RLock rLock = redisson.getLock(lockKey);
    try {
        rLock.lock();
        // 加鎖成功,儲存使用者
        insert(users);

    } finally {
        rLock.unlock();
    }
}           

可以看出redisson非常簡潔完成分布式鎖

8. redis+token機制(不推薦)

需要2次請求才能完成一次操作

  • 第一次請求去背景拿token
  • 背景生成token(全局唯一),存到redis中,注意要設定過期時間
  • 前端拿到token,通過第二次請求(實際業務)傳給背景,一般通過head傳遞

背景通過傳過來的token驗證是否存在,存在說明是第一次,執行成功,然後删除token,如果同時有另外一個相同的請求過來,token為空,判斷執行失敗,為無效操作,實作幂等。

因為redis+token機制需要執行2兩步請求,而且如果處理不好,兩次操作都同時執行了這2步,兩次一樣都可以成功,是以不建議使用這種方式。

總結

在實際業務場景中,基本上就3種情況的幂等:

1. 重複記錄,比如同時插入兩條相同的訂單記錄。

2. 更新資料出現多更或者少更,比如扣減商品庫存,高并發下多個線程同時扣減造成多扣庫存。

3. 隻有一方對自己的資料進行更新操作,比如扣減自己賬戶餘額,造成重複扣錢。

首先前後端都要做好防抖處理,再做好防抖處理的同時

針對第一種

  • 添加唯一索引,這是最簡單的方式
  • 如果不唯一索引不友善添加,使用防重表
  • 分布式鎖搭配select+insert方式,如上面第7個案例

針對第二種

  • 防重表(如果有類似業務表也可以使用)+ 使用分布式鎖redisson

針對第三種

  • 防重表(如果有類似業務表也可以使用)
  • 如上面第2個案例中使用version

繼續閱讀