解放生產力orm並行更新下應該這麼處理求求你別再用UpdateById了

2023-08-22 09:00:28

解放生產力orm並行更新下應該這麼處理求求你別再用UpdateById了

背景

很多時候為了方便我們都採用實體物件進行前後端的資料互動,然後為了便捷開發我們都會採用DTO物件進行轉換為資料庫物件,然後呼叫UpdateById將變更後的資料存入到資料庫內,這樣的一個做法有什麼問題呢,如果你的系統並行量特別少甚至沒有並行量那麼這麼做是沒什麼關係的無可厚非,但是如果你的系統有並行量那麼在某些情況下會有嚴重的問題.

案例1

現在我們有一條待稽核記錄,其中status 0表示待提交, 1表示待稽核

id name status description
1 記錄1 0 我是備註

假設有兩個使用者,A使用者想對當前記錄的description欄位進行修改,B使用者想對當前記錄進行提交

使用者請求

/api/update

  • 使用者A: {"id":1,"name":"記錄1","status":0,"description":"修改後的備註"}
  • 使用者B: {"id":1,"name":"記錄1","status":1,"description":"我是備註 "}

修改介面

A使用者虛擬碼

Entity entity = entityMapper.selectOne(1);//A1
//查詢結果{"id":1,"name":"記錄1","status":0,"description":"我是備註'"}
if(status.待稽核!=entity.status){//A2
  throw new BusinessException("當前記錄無法修改");
}
BeanUtil.copyProperties(request,entity);//A3
entityMapper.updateById(entity);//A4
-- update table set name='記錄1',status=0,description='修改後的備註' where id=1

提交介面

B使用者虛擬碼

Entity entity = entityMapper.selectOne(1);//B1
//查詢結果{"id":1,"name":"記錄1","status":0,"description":"我是備註'"}
if(status.待稽核!=entity.status){//B2
  throw new BusinessException("當前記錄無法提交");
}
entity.status=status.待稽核;//B3
entityMapper.updateById(entity);//B4
-- update table set name='記錄1',status=1,description='我是備註', where id=1

提交請求

A1=>A2=>A3=>B1=>B2=>B3=>B4=>A4
加入並行情況下那麼針對當前記錄我們生成的兩個操作因為沒有考慮並行問題基於上述執行順序,最終資料庫的記錄將會被A4覆蓋也就是提交失敗,那麼如果提交稽核會觸發一些事件那麼就就會有嚴重的問題產生,操作將會變得不是冪等。

解決方案

樂觀鎖

首先我們修改表結構新增版本號欄位

id name status description version
1 記錄1 0 我是備註 1

A4和B4的執行sql改為orm支援的樂觀鎖模式

-- A4
update table set name='記錄1',status=0,description='修改後的備註',version=2 where id=1 and version=1

-- B4
update table set name='記錄1',status=1,description='我是備註',version=2 where id=1 and version=1

因為A4和B4兩條記錄只有一條記錄可以生效,所以另一條語句肯定返回受影響行數為0.對於返回為0的操作可以告知使用者端操作失敗請重試。

這種方式看著看著很美好但是也是有一定的缺點的,就是他是樂觀鎖強序列化,針對一些不必要的欄位其實大部分的時候我們完全可以採取後覆蓋模式比如修改name,修改description,但是因為樂觀鎖的存在導致我們的並行粒度變粗所以是否使用樂觀鎖需要進行一個取捨。

分散式鎖

通過在請求外部也就是A1-A4和B1-B4外部進行lock包裹,讓兩個執行變成序列化,可以用id:1作為分散式鎖的key,加入A先執行那麼B執行後可以提交,加入B先執行那麼A就會報錯,缺點也很明顯需要將對應記錄的任何操作都進行分散式鎖進行處理。需要掌握好鎖的粒度和管理,如果出現其他業務操作中涉及到當前記錄的修改那麼分散式鎖又會遇到很多問題,在單一環境下分散式鎖可以解決,但是大部分情況下並不是用在這個場景下。

以判斷條件為樂觀鎖

既然樂觀鎖有粒度太粗導致並行度太低,那麼可以選擇性不要一刀切,我們以狀態來作為樂觀鎖更新資料

-- A4
update table set name='記錄1',status=0,description='修改後的備註' where id=1 and status=0//status=0是因為我們查到的是0

-- B4
update table set name='記錄1',status=1,description='我是備註' where id=1  and status=0//status=0是因為我們查到的是0

這種方式我們解決了name或者description這些無關順序痛癢的更新粒度,使其更新其餘欄位並行度大大提高,大家可以多個執行緒一起更新name或者description都是不會出現樂觀鎖的錯誤。

雖然我們解決了普通欄位的更新修改但是針對部分關鍵欄位的更新如果是整個物件更新依然會有問題,那麼又回到了樂觀鎖是一個比較好的處理方式,比如stock_num欄位

easy-query

我們來看看如果在easy-query下我們分別如何實現上述功能,首先我們還是在之前的solon專案中進行程式碼新增,

@Data
@Table("test_update")
public class TestUpdateEntity {
    @Column(primaryKey = true)
    private String id;
    private String name;
    private Integer status;
    private String description;
}

//新增測試資料

  TestUpdateEntity testUpdateEntity = new TestUpdateEntity();
  testUpdateEntity.setId("1");
  testUpdateEntity.setName("測試1");
  testUpdateEntity.setStatus(0);
  testUpdateEntity.setDescription("描述資訊");
  easyQuery.insertable(testUpdateEntity).executeRows();
  return "ok";

稽核普通更新

一般而言我們會先選擇查詢物件,然後判斷狀態然後將dto請求賦值給物件,之後更新物件


    @Mapping(value = "/testUpdate2",method = MethodType.POST)
    public String testUpdate2(@Validated TestUpdate2Rquest request){
        TestUpdateEntity testUpdateEntity = easyQuery.queryable(TestUpdateEntity.class)
                .whereById(request.getId()).firstNotNull("未找到對應的記錄");
        if(!testUpdateEntity.getStatus().equals(0)){
            return "當前狀態不是0";
        }
        BeanUtil.copyProperties(request,testUpdateEntity);
        testUpdateEntity.setStatus(1);
        easyQuery.updatable(testUpdateEntity).executeRows();
        return "ok";
    }

==> Preparing: SELECT `id`,`name`,`status`,`description` FROM `test_update` WHERE `id` = ? LIMIT 1
==> Parameters: 1(String)
<== Time Elapsed: 22(ms)
<== Total: 1

==> Preparing: UPDATE `test_update` SET `name` = ?,`status` = ?,`description` = ? WHERE `id` = ?
==> Parameters: 測試1(String),1(Integer),123(String),1(String)
<== Total: 1

我們看到這邊更新將status由0改成了1,雖然我們中間做了一次是否為0的判斷,但是在並行環境下這麼更新是有問題的,而且這邊我們僅更新了descriptionstatus欄位缺把name欄位也更新了

稽核並行更新

首先我們改造一下程式碼,在請求方法上新增了對應的註解@EasyQueryTrack又因為我們設定了預設開啟追蹤所以僅需要查詢資料庫物件既可以追蹤資料


    //自動追蹤差異更新 需要開啟default-track: true如果沒開啟那麼就使用`asTracking`啟用追蹤
    @EasyQueryTrack 
    @Mapping(value = "/testUpdate3",method = MethodType.POST)
    public String testUpdate3(@Validated TestUpdate2Rquest request){
        TestUpdateEntity testUpdateEntity = easyQuery.queryable(TestUpdateEntity.class)
                //.asTracking() //如果組態檔預設選擇追蹤那麼只需要新增 @EasyQueryTrack 註解
                .whereById(request.getId())
                .firstNotNull("未找到對應的記錄");
        if(!testUpdateEntity.getStatus().equals(0)){
            return "當前狀態不是0";
        }
        BeanUtil.copyProperties(request,testUpdateEntity);
        testUpdateEntity.setStatus(1);
        easyQuery.updatable(testUpdateEntity)
                //指定更新條件為主鍵和status欄位
                .whereColumns(o->o.columnKeys().column(TestUpdateEntity::getStatus))
                .executeRows(1,"當前狀態不是0");//如果更新返回的受影響函數不是1,那麼就丟擲錯誤,當然你也可以獲取返回結果自行處理
        return "ok";
    }

==> Preparing: SELECT `id`,`name`,`status`,`description` FROM `test_update` WHERE `id` = ? LIMIT 1
==> Parameters: 1(String)
<== Time Elapsed: 23(ms)
<== Total: 1

==> Preparing: UPDATE `test_update` SET `status` = ?,`description` = ? WHERE `id` = ? AND `status` = ?
==> Parameters: 1(Integer),123(String),1(String),0(Integer)
<== Total: 1

更新條件自動感知需要更新的列,不會無腦全更新,並且支援簡單的設定支援當前status並行更新,會自動在where上帶上原來的值,並且在set處更新為新值,整個更新條件對於並行情況下的處理變得非常簡單

樂觀鎖

@Data
@Table("test_update_version")
public class TestUpdateVersionEntity {
    @Column(primaryKey = true)
    private String id;
    private String name;
    private Integer status;
    private String description;
    @Version(strategy = VersionUUIDStrategy.class)
    private String version;
}

//初始化資料
  TestUpdateVersionEntity testUpdateVersionEntity = new TestUpdateVersionEntity();
  testUpdateVersionEntity.setId("1");
  testUpdateVersionEntity.setName("測試1");
  testUpdateVersionEntity.setStatus(0);
  testUpdateVersionEntity.setDescription("描述資訊");
  testUpdateVersionEntity.setVersion(UUID.randomUUID().toString().replaceAll("-",""));
  easyQuery.insertable(testUpdateVersionEntity).executeRows();



==> Preparing: INSERT INTO `test_update_version` (`id`,`name`,`status`,`description`,`version`) VALUES (?,?,?,?,?)
==> Parameters: 1(String),測試1(String),0(Integer),描述資訊(String),0603b2e00a1d4b869d13cf974a5cc885(String)
<== Total: 1

稽核樂觀鎖


    @Mapping(value = "/testUpdate2",method = MethodType.POST)
    public String testUpdate2(@Validated TestUpdate2Rquest request){
        TestUpdateVersionEntity testUpdateVersionEntity = easyQuery.queryable(TestUpdateVersionEntity.class)
                .whereById(request.getId()).firstNotNull("未找到對應的記錄");
        if(!testUpdateVersionEntity.getStatus().equals(0)){
            return "當前狀態不是0";
        }
        BeanUtil.copyProperties(request,testUpdateVersionEntity);
        testUpdateVersionEntity.setStatus(1);
        easyQuery.updatable(testUpdateVersionEntity).executeRows();
        return "ok";
    }


==> Preparing: SELECT `id`,`name`,`status`,`description`,`version` FROM `test_update_version` WHERE `id` = ? LIMIT 1
==> Parameters: 1(String)
<== Time Elapsed: 16(ms)
<== Total: 1


==> Preparing: UPDATE `test_update_version` SET `name` = ?,`status` = ?,`description` = ?,`version` = ? WHERE `version` = ? AND `id` = ?
==> Parameters: 測試1(String),1(Integer),123(String),cf6c2f3106b24aba965bb4cc54235076(String),0603b2e00a1d4b869d13cf974a5cc885(String),1(String)
<== Total: 1

雖然我們採用了樂觀鎖但是還是會出現全欄位更新的情況,所以這邊再次使用差異更新來實現


    @EasyQueryTrack
    @Mapping(value = "/testUpdate3",method = MethodType.POST)
    public String testUpdate3(@Validated TestUpdate2Rquest request){
        TestUpdateVersionEntity testUpdateVersionEntity = easyQuery.queryable(TestUpdateVersionEntity.class)
                .whereById(request.getId()).firstNotNull("未找到對應的記錄");
        if(!testUpdateVersionEntity.getStatus().equals(0)){
            return "當前狀態不是0";
        }
        BeanUtil.copyProperties(request,testUpdateVersionEntity);
        testUpdateVersionEntity.setStatus(1);
        easyQuery.updatable(testUpdateVersionEntity).executeRows();
        return "ok";
    }


==> Preparing: UPDATE `test_update_version` SET `status` = ?,`description` = ?,`version` = ? WHERE `version` = ? AND `id` = ?
==> Parameters: 1(Integer),1234(String),7e96f217bc13451c9d10a8fba50780a6(String),cf6c2f3106b24aba965bb4cc54235076(String),1(String)
<== Total: 1

使用追蹤查詢僅更新我們需要更新的欄位easy-query一款為開發者而生的orm框架,擁有非常完善的功能且支援非常易用的功能,讓你在編寫業務時可以非常輕鬆的實現並行操作,哪怕沒有樂觀鎖。

最後

看到這邊您應該已經知道了solon國產框架的簡潔和easy-query的便捷,如果本篇文章對您有幫助或者您覺得還行請給我一個星星表示支援謝謝
當前專案地址demo https://gitee.com/xuejm/solon-encrypt

easy-qeury

檔案地址 https://xuejm.gitee.io/easy-query-doc/

GITHUB地址 https://github.com/xuejmnet/easy-query

GITEE地址 https://gitee.com/xuejm/easy-query

solon

檔案地址 https://xuejm.gitee.io/easy-query-doc/

GITHUB地址 https://github.com/noear/solon

GITEE地址 https://gitee.com/noear/solon