文章目录
一、并发创建或更新场景描述
- 在业务执行过程中,需要创建具有唯一约束(业务主键)的行记录;
- 在web入口处无法很好的用切面控制并发,需要执行到业务深处才能拿到业务主键信息;
- 基于createWhenMiss写入记录,不是每一次都需要执行创建;
- 可能有更新的需求,当更新的项不存在时需要创建。
二、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、缺陷
- 业务执行过程中开启redis锁和新事务,性能较差;
- 对于批量执行的任务(定时任务),由于并发控制基于行(不支持批量),大量循环内的IO交互(redis与DB读写)拉低整体吞吐;
- 对于实现createWhenMiss时,未返回创建结果的情形,如果当前事务需要使用创建的数据,要求事务的隔离级别是RC,但可能当前业务不适用RC;或者即使当前业务可以是RC,这里会再次多出一次查询;因此,createWhenMiss的实现如果是不可变数据,必须返回创建好的数据,以防止外部再去查询。
- 业务侧需要考虑创建的数据项不会对业务数据有实质的影响。如创建初始余额为0的账户,创建订单配置的快照等。因为新事务提交后无法因当前事务的异常而回滚(即使业务侧实现手动回滚,也无法避免其他事务已经读取该数据并进行了计算)。
(不算缺陷,并发创建都需要这种机制,提供更好的读并发性能)未防止不必要的创建,调用createWhenMiss前(开启新事务创建前),会在当前事务查询一次确认是否存在,以防止不必要的IO交互和阻塞点;对于createWhenMiss内部,虽然开启redis锁,但为了防止内部重复创建,会使用RC读,仍未命中时才创建;因此,在真的需要进行创建时一共至少两次读取,会拉低性能;
3、优点
- 不需要业务侧在表上真正的建立唯一约束,利用redis就可以控制并发;同时可以很好的适配无法建立唯一约束的表,如具备逻辑删除字段的表;
- 在并发锁冲突的情形下,阻塞时间相对较短,允许将阻塞点控制在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、缺陷
- 行锁持有时间远长于方案A。upsertResource在需要创建时会持有对应记录的行锁,直到整个事务结束,如果在创建点有更多的并发冲突,将导致业务阻塞过多;
- 需要业务表除了主键ID外,额外建立业务层面的唯一主键;
- upsert语法特别注意
on duplicated key update
的负面影响(请自行google),避免错误的更新数据。因此主键一般如果能够拿到,就直接赋值,如果拿不到,就需要随机生成一个合规未使用过的id,因为一是避免id冲突错误的更新数据,二是真正需要插入时,id合法; - 基于2,无法适配具备逻辑删除字段的表;
- 需要考虑ORM框架对语句的处理变更,如mybatis/plus,需要通过合理的配置绕过对语句的额外处理;
3、优点
- 免去使用redis锁,减少了对redis组件的依赖和IO交互;
- 基于行锁,结合RC避免间隙锁,并发度和方案A相同;
- 创建基于当前事务,不需要开启新事务,不需要考虑新建数据对于其他事务在数据层面的影响。
- upsert语义兼顾更新职能,在
on duplicated key update
中直接给出更新的逻辑,不需要在业务中执行查询+计算+写入的流程,较少交互,避免一定的写偏差;尤其在增量计数场景表现优异,因为可能不需要返回当前最新的记录,只需要累计增量(注意使用其他方式保证幂等,这在方案A中也同样需要考虑); - 支持批量执行,较少单次循环的IO交互,效率更高。
四、附录
@RedisLock开源项目
<dependency>
<groupId>xyz.mydev</groupId>
<artifactId>redis-lock-starter</artifactId>
<version>1.3.0</version>
</dependency>