并发创建或更新具备唯一约束特征数据的方案简析

一、并发创建或更新场景描述

  1. 在业务执行过程中,需要创建具有唯一约束(业务主键)的行记录;
  2. 在web入口处无法很好的用切面控制并发,需要执行到业务深处才能拿到业务主键信息;
  3. 基于createWhenMiss写入记录,不是每一次都需要执行创建;
  4. 可能有更新的需求,当更新的项不存在时需要创建。

二、redis + 新事务方案

下文简称方案A/redis方案

1、方案流程

使用redis锁锁定唯一key,利用spring传播特性和隔离级别,开启新事务创建记录。

伪代码

// 定义 createWhenMiss 业务方法,使用RC,读未命中时进行创建。注意,这里使用了redis锁控制并发
@RedisLock(prefix = "resourceKey", key = "#businessUnikeyFromParams")
@Transactional(isolation = Isolation.READ_COMMITTED, propagation = Propagation.REQUIRES_NEW)
// 需要使用新事务进行读写(这里的RC防止不必要的间隙锁)
public Resource createResourceWhenMiss(String paramsContainRequiredBusinessInfo...) {
    // 尝试读取,一旦命中就返回。当并发创建时,后续线程无需重复创建即可拿到信息。
  return resourceRepository.findByBusinessUniKey(paramsContainsRequiredBusinessInfo...)
  .orElseGet(() -> {
      // 未命中时需要创建
    Resource resource = buildEntityFromParams(paramsContainRequiredBusinessInfo...);
    resourceRepository.create(resource);
    return resource;
  });
}

// 外部业务,参数列表的信息无法锁定内部唯一性数据,且业务必要锁定,因为一旦创建过一次,后续可能只需要读取而不需要修改。
// 需要使用RC新事务进行读写(这里的RC为了读到其他线程在 createResourceWhenMiss 方法中创建的记录)
@Transactional(isolation = Isolation.READ_COMMITTED)
public void doSth(String paramsMaybeNotContainRequiredBusinessInfo...) {
  // 外部业务方法调用其他方法,过程中组装好了必要参数
  paramsContainRequiredBusinessInfo = someMethodsInvokes();

  // 外部需要用到 Resource,并尝试查询,未命中时调用了 createResourceWhenMiss进行创建。
  Resource resource = resourceRepository.findByBusinessUniKey(paramsContainRequiredBusinessInfo...)
  .orElseGet(() -> {
    return createResourceWhenMiss(paramsContainsRequiredBusinessInfo);
  });
  // 用拿到的resource执行后续流程
  useResource(resource);
}

2、缺陷

  1. 业务执行过程中开启redis锁和新事务,性能较差;
  2. 对于批量执行的任务(定时任务),由于并发控制基于行(不支持批量),大量循环内的IO交互(redis与DB读写)拉低整体吞吐
  3. 对于实现createWhenMiss时,未返回创建结果的情形,如果当前事务需要使用创建的数据,要求事务的隔离级别是RC,但可能当前业务不适用RC;或者即使当前业务可以是RC,这里会再次多出一次查询;因此,createWhenMiss的实现如果是不可变数据,必须返回创建好的数据,以防止外部再去查询。
  4. 业务侧需要考虑创建的数据项不会对业务数据有实质的影响。如创建初始余额为0的账户,创建订单配置的快照等。因为新事务提交后无法因当前事务的异常而回滚(即使业务侧实现手动回滚,也无法避免其他事务已经读取该数据并进行了计算)。
  5. (不算缺陷,并发创建都需要这种机制,提供更好的读并发性能)未防止不必要的创建,调用createWhenMiss前(开启新事务创建前),会在当前事务查询一次确认是否存在,以防止不必要的IO交互和阻塞点;对于createWhenMiss内部,虽然开启redis锁,但为了防止内部重复创建,会使用RC读,仍未命中时才创建;因此,在真的需要进行创建时一共至少两次读取,会拉低性能

3、优点

  1. 不需要业务侧在表上真正的建立唯一约束,利用redis就可以控制并发;同时可以很好的适配无法建立唯一约束的表,如具备逻辑删除字段的表;
  2. 在并发锁冲突的情形下,阻塞时间相对较短,允许将阻塞点控制在createWhenMiss方法上,并在createWhenMiss结束后解除阻塞(因为redis和db锁跟随着事务和切面的结束而释放)。

三、mysql upsert方案

简称方案B/upsert方案

1、upsert方案流程

方案A中createWhenMiss方法替换为upsert方法,去除redis锁,无需开启新事务;当需要控制创建本身的阻塞点时,可以考虑开启新事务,但需要注意数据特征对其他并发读写事务的影响。

伪代码

// 定义 upsert 业务方法。RC为了避免不必要的间隙锁。可以不加,跟随外部事务隔离级别。
@Transactional(isolation = Isolation.READ_COMMITTED)
public Resource upsertResource(String paramsContainRequiredBusinessUniKey...) {
   // 根据业务主键upsert
   resourceRepository.upsertResource(paramsContainsRequiredBusinessInfo...);
   // 返回当前最新的记录
   return resourceRepository.findByResourceUniKey(paramsContainRequiredBusinessUniKey);
}


// 外部业务(这里的RC为了使同事务内的upsertResource方式也使用RC隔离别,避免不要的间隙锁)
@Transactional(isolation = Isolation.READ_COMMITTED)
public void doSth(String paramsMaybeNotContainRequiredBusinessInfo...) {
  // 外部业务方法调用其他方法,过程中组装好了必要参数
  paramsContainRequiredBusinessInfo = someMethodsInvokes();
  // 外部需要用到 Resource,并尝试查询,未命中时调用了 upsertResource 进行创建。
  Resource resource = resourceRepository.findByBusinessUniKey(paramsContainRequiredBusinessInfo...)
  .orElseGet(() -> {
    return upsertResource(paramsContainsRequiredBusinessInfo);
  });
  // 用拿到的resource执行后续流程
  useResource(resource);
}
-- upsert 插入或更新 用法
insert into resource(id, city_id, classify_id, course_size, teacher_size, org_size, created_at,
                        updated_at)
values (13, 10, 3, 4, 0, 0, now(), now())
     , (11, 5, 4, 1, 0, 0, now(), now())
on duplicate key update course_size  = course_size + values(course_size),
                        teacher_size = teacher_size + values(teacher_size),
                        org_size     = org_size + values(org_size)
;

-- 忽略插入冲突 用法
insert ignore into resource(id, city_id, classify_id, course_size, teacher_size, org_size, created_at,
                        updated_at)
values (13, 10, 3, 4, 0, 0, now(), now())
     , (11, 5, 4, 1, 0, 0, now(), now())
;

2、缺陷

  1. 行锁持有时间远长于方案A。upsertResource在需要创建时会持有对应记录的行锁,直到整个事务结束,如果在创建点有更多的并发冲突,将导致业务阻塞过多;
  2. 需要业务表除了主键ID外,额外建立业务层面的唯一主键;
  3. upsert语法特别注意on duplicated key update的负面影响(请自行google),避免错误的更新数据。因此主键一般如果能够拿到,就直接赋值,如果拿不到,就需要随机生成一个合规未使用过的id,因为一是避免id冲突错误的更新数据,二是真正需要插入时,id合法;
  4. 基于2,无法适配具备逻辑删除字段的表
  5. 需要考虑ORM框架对语句的处理变更,如mybatis/plus,需要通过合理的配置绕过对语句的额外处理;

3、优点

  1. 免去使用redis锁,减少了对redis组件的依赖和IO交互;
  2. 基于行锁,结合RC避免间隙锁,并发度和方案A相同;
  3. 创建基于当前事务,不需要开启新事务,不需要考虑新建数据对于其他事务在数据层面的影响。
  4. upsert语义兼顾更新职能,在on duplicated key update中直接给出更新的逻辑,不需要在业务中执行查询+计算+写入的流程,较少交互,避免一定的写偏差;尤其在增量计数场景表现优异,因为可能不需要返回当前最新的记录,只需要累计增量(注意使用其他方式保证幂等,这在方案A中也同样需要考虑);
  5. 支持批量执行,较少单次循环的IO交互,效率更高。

四、附录

@RedisLock开源项目

github
gitee
mvn 坐标

<dependency>
  <groupId>xyz.mydev</groupId>
  <artifactId>redis-lock-starter</artifactId>
  <version>1.3.0</version>
</dependency>
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值