解放生产力orm并发更新下应该这么处理求求你别再用UpdateById了

背景

很多时候为了方便我们都采用实体对象进行前后端的数据交互,然后为了便捷开发我们都会采用DTO对象进行转换为数据库对象,然后调用UpdateById将变更后的数据存入到数据库内,这样的一个做法有什么问题呢,如果你的系统并发量特别少甚至没有并发量那么这么做是没什么关系的无可厚非,但是如果你的系统有并发量那么在某些情况下会有严重的问题.

案例1

现在我们有一条待审核记录,其中status 0表示待提交, 1表示待审核

假设有两个用户,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覆盖也就是提交失败,那么如果提交审核会触发一些事件那么就就会有严重的问题产生,操作将会变得不是幂等。

解决方案

乐观锁

首先我们修改表结构添加版本号字段

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";
    }

解放生产力orm并发更新下应该这么处理求求你别再用UpdateById了-LMLPHP

==> 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";
    }

解放生产力orm并发更新下应该这么处理求求你别再用UpdateById了-LMLPHP

==> 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";
    }

解放生产力orm并发更新下应该这么处理求求你别再用UpdateById了-LMLPHP


==> 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";
    }

解放生产力orm并发更新下应该这么处理求求你别再用UpdateById了-LMLPHP


==> 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-query

文档地址 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

08-22 23:20