1. 背景
上篇撸了逻辑删除的源码,这次把乐观锁的源码再撸一遍,看看乐观锁是如何实现的,同时根据细节,看能否和逻辑删除相呼应上。
2. 乐观锁工作原理
首先搞清楚乐观锁是什么。
mybatis-plus的乐观锁是这样的,比如在一张表中,增加一个version字段,在执行更新语句时,会set version = #{version} + 1
, 同时会增加条件: where version = #{version}
以此来实现乐观锁的一个基本逻辑。如果当前修改的版本号和数据库中的版本号不一致,则不修改这条数据。
乐观锁和悲观锁的理论这里就不做过多赘述了。
既然了解了mp的基本工作原理,那么就来撸源码
2.1. 基本使用
mybatis-plus的用法很简单,因为乐观锁是一个插件,所以将这个插件注入到mp的全局配置中,就可以开启乐观锁插件了。
@Bean
public MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
// 乐观锁
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
return interceptor;
}
所以,要了解mybatis-plus的乐观锁原理,只需要去撸这个插件的源码即可。
2.2. 乐观锁源码解析
2.2.1. 构造器解析
乐观锁的构造器有两个
public OptimisticLockerInnerInterceptor() {
this(false);
}
public OptimisticLockerInnerInterceptor(boolean wrapperMode) {
this.wrapperMode = wrapperMode;
}
通过构造器,可以看到有一个参数,是构造时需要初始化的。wrapperMode
- 无参构造时,默认为false。
- 有参数构造时,则为传入的值。
这个参数,字面意思为是否为包装类型。具体的含义,后面撸代码看一下。
2.2.2. 乐观锁入口
com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor#beforeUpdate
乐观锁的入口是一个更新前处理器。
留一个点,这里为什么是入口呢?向上追溯一下,现在先继续往下看。【已处理】2.3.2mp插件装载原理
@Override
public void beforeUpdate(Executor executor, MappedStatement ms, Object parameter) throws SQLException {
if (SqlCommandType.UPDATE != ms.getSqlCommandType()) {
return;
}
if (parameter instanceof Map) {
Map<String, Object> map = (Map<String, Object>) parameter;
doOptimisticLocker(map, ms.getId());
}
}
通过代码,这里是只会执行UPDATE类型的。
ms.getSqlCommandType()
这个的含义,在
【撸源码】【mybatis-plus】乐观锁和逻辑删除是如何工作的——上篇https://blog.csdn.net/smile_795/article/details/138602029
这篇文章中,有说明,有兴趣的可以去看下。
这个类型,只有执行更新语句和逻辑删除时,会使用到这个类型。
com.baomidou.mybatisplus.core.injector.AbstractMethod#addUpdateMappedStatement(java.lang.Class<?>, java.lang.Class<?>, java.lang.String, org.apache.ibatis.mapping.SqlSource)
感兴趣的看下这个方法的引用即可明白。这个在上篇也有讲到。
那么就说明,乐观锁的作用范围在更新和逻辑删除时,会用到乐观锁。
这个方法没有什么逻辑,核心就是执行了 com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor#doOptimisticLocker
这个方法。那我们看这个方法,就可以知道乐观锁的核心原理。
2.2.3. 核心原理
protected void doOptimisticLocker(Map<String, Object> map, String msId) {
// updateById(et), update(et, wrapper);
Object et = map.getOrDefault(Constants.ENTITY, null);
if (Objects.nonNull(et)) {
// version field
TableFieldInfo fieldInfo = this.getVersionFieldInfo(et.getClass());
if (null == fieldInfo) {
return;
}
try {
Field versionField = fieldInfo.getField();
// 旧的 version 值
Object originalVersionVal = versionField.get(et);
if (originalVersionVal == null) {
if (null != exception) {
/**
* 自定义异常处理
*/
throw exception;
}
return;
}
String versionColumn = fieldInfo.getColumn();
// 新的 version 值
Object updatedVersionVal = this.getUpdatedVersionVal(fieldInfo.getPropertyType(), originalVersionVal);
String methodName = msId.substring(msId.lastIndexOf(StringPool.DOT) + 1);
if ("update".equals(methodName)) {
AbstractWrapper<?, ?, ?> aw = (AbstractWrapper<?, ?, ?>) map.getOrDefault(Constants.WRAPPER, null);
if (aw == null) {
UpdateWrapper<?> uw = new UpdateWrapper<>();
uw.eq(versionColumn, originalVersionVal);
map.put(Constants.WRAPPER, uw);
} else {
aw.apply(versionColumn + " = {0}", originalVersionVal);
}
} else {
map.put(Constants.MP_OPTLOCK_VERSION_ORIGINAL, originalVersionVal);
}
versionField.set(et, updatedVersionVal);
} catch (IllegalAccessException e) {
throw ExceptionUtils.mpe(e);
}
}
// update(LambdaUpdateWrapper) or update(UpdateWrapper)
else if (wrapperMode && map.entrySet().stream().anyMatch(t -> Objects.equals(t.getKey(), Constants.WRAPPER))) {
setVersionByWrapper(map, msId);
}
}
2.2.3.1. 最外层解读
通过代码,最外层有两块逻辑
- 如果当前的更新入参是Entity,那么就执行第一段逻辑。
- 如果
wrapperMode
为true,并且当前的更新入参是包装类,那么就执行第二段逻辑。
这里就看到,构造器初始化的参数是在这里使用的。
那么就可以理解为,如果开启了这个参数,在更新数据时,使用entity类型的,就会自动携带上乐观锁字段。否则就不会更新乐观锁字段。
这里为一个问题,后需可以通过测试来验证。【已处理】2.4.3关闭包装类型参数,且不使用entity来更新,是否会触发乐观锁。
继续撸代码,先来看第一段逻辑。
2.2.3.2. 第一段逻辑解读
逻辑概要
- 获取当前实体类的类型
- 从表信息中,获取乐观锁字段,判断当前类型中是否包含乐观锁字段。
-
- 这里是如何获取,乐观锁字段又是什么时机加载的,可以参考上篇文章,里面有讲到哦。
- 【撸源码】【mybatis-plus】乐观锁和逻辑删除是如何工作的——上篇https://blog.csdn.net/smile_795/article/details/138602029
- 如果不包含,则直接退出,不再处理乐观锁信息。
- 通过乐观锁字段,获取当前对象中乐观锁字段的值。
- 如果值为空
-
- 判断当前是否设置了异常类信息,如果设置了,就抛出异常。
- 如果没有配置异常,则退出,不再处理乐观锁字段。
这里设置为一个问题,如果更新时,使用entity来更新,但是version字段为null,是不是就不再更新乐观锁了?【已处理】2.4.2version为null,是否会更新乐观锁字段如果装载时,增加了异常选项,version为空时,是否就会抛出异常?【已处理】2.4.1装载乐观锁插件时,放入异常对象,那么在使用乐观锁时没有值是否会抛出异常。
- 通过当前的version值和version类型,从工厂中获取一个新的version值。
-
乐观锁工厂可以详细解读下。【已处理】2.3.1乐观锁获取的工厂逻辑
- 再次判断当前执行方法,是否为更新语句。
-
这里需要细节性分析一下。 【已处理】2.2.3.2.1是否为update细节分析
- 如果是update
-
- 获取当前上下文中的包装类条件参数,如果当前上下文中,已经存在了条件,则会增加一个乐观锁条件。
- 如果当前上下文中,没有条件,则创建一个包装类条件参数,并增加乐观锁条件。
- 如果不是update
-
- 则在全局参数中,添加一个乐观锁的条件。
- 将当前更新对象中的值中,乐观锁字段的值设置为刚才获取的新值。
2.2.3.2.1. 是否为update细节分析
为什么上层逻辑,已经判断了 ms.getSqlCommandType() == UPDATE
,为什么这里要再判断一次?
String methodName = msId.substring(msId.lastIndexOf(StringPool.DOT) + 1);
if ("update".equals(methodName)) {
AbstractWrapper<?, ?, ?> aw = (AbstractWrapper<?, ?, ?>) map.getOrDefault(Constants.WRAPPER, null);
if (aw == null) {
UpdateWrapper<?> uw = new UpdateWrapper<>();
uw.eq(versionColumn, originalVersionVal);
map.put(Constants.WRAPPER, uw);
} else {
aw.apply(versionColumn + " = {0}", originalVersionVal);
}
} else {
map.put(Constants.MP_OPTLOCK_VERSION_ORIGINAL, originalVersionVal);
}
仔细观察,这里的update,匹配的是methodName
再来看methodName是从哪获取到的
String methodName = msId.substring(msId.lastIndexOf(StringPool.DOT) + 1);
这里是根据msId来判断的
从上篇文章可以知道,statement的构造在这里 com.baomidou.mybatisplus.core.injector.methods.Delete#injectMappedStatement
public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
String sql;
SqlMethod sqlMethod = SqlMethod.LOGIC_DELETE;
if (tableInfo.isWithLogicDelete()) {
sql = String.format(sqlMethod.getSql(), tableInfo.getTableName(), sqlLogicSet(tableInfo),
sqlWhereEntityWrapper(true, tableInfo),
sqlComment());
SqlSource sqlSource = super.createSqlSource(configuration, sql, modelClass);
return addUpdateMappedStatement(mapperClass, modelClass, methodName, sqlSource);
} else {
sqlMethod = SqlMethod.DELETE;
sql = String.format(sqlMethod.getSql(), tableInfo.getTableName(),
sqlWhereEntityWrapper(true, tableInfo),
sqlComment());
SqlSource sqlSource = super.createSqlSource(configuration, sql, modelClass);
return this.addDeleteMappedStatement(mapperClass, methodName, sqlSource);
}
}
通过继续往下追溯,发现这里的id = namespace + methodName。中间以 "." 连接。
所以,这里的method,就是statement构建时的method。
而构建时的method
,是com.baomidou.mybatisplus.core.enums.SqlMethod
这个枚举的method
字段。
所以,这里的匹配,就是在看方法名字是不是update,而不是判断是否为更新语句。
com.baomidou.mybatisplus.core.mapper.BaseMapper#update(T, com.baomidou.mybatisplus.core.conditions.Wrapper<T>)
通过代码,查看所有的update方法,全是包装类,那么这里判断upadte的意思,就是看是否为包装类。
另外拓展看了一下,非update方法,全都不是包装类,这就很好区分了,只要是update,就处理包装类的参数。不是update,就处理map即可。
2.2.3.3. 第二段逻辑解读
private void setVersionByWrapper(Map<String, Object> map, String msId) {
Object ew = map.get(Constants.WRAPPER);
if (ew instanceof AbstractWrapper && ew instanceof Update) {
Class<?> entityClass = ENTITY_CLASS_CACHE.get(msId);
if (null == entityClass) {
try {
final String className = msId.substring(0, msId.lastIndexOf('.'));
entityClass = ReflectionKit.getSuperClassGenericType(Class.forName(className), Mapper.class, 0);
ENTITY_CLASS_CACHE.put(msId, entityClass);
} catch (ClassNotFoundException e) {
throw ExceptionUtils.mpe(e);
}
}
final TableFieldInfo versionField = getVersionFieldInfo(entityClass);
if (null == versionField) {
return;
}
final String versionColumn = versionField.getColumn();
final FieldEqFinder fieldEqFinder = new FieldEqFinder(versionColumn, (Wrapper<?>) ew);
if (!fieldEqFinder.isPresent()) {
return;
}
final Map<String, Object> paramNameValuePairs = ((AbstractWrapper<?, ?, ?>) ew).getParamNameValuePairs();
final Object originalVersionValue = paramNameValuePairs.get(fieldEqFinder.valueKey);
if (originalVersionValue == null) {
return;
}
final Object updatedVersionVal = getUpdatedVersionVal(originalVersionValue.getClass(), originalVersionValue);
if (originalVersionValue == updatedVersionVal) {
return;
}
// 拼接新的version值
paramNameValuePairs.put(UPDATED_VERSION_VAL_KEY, updatedVersionVal);
((Update<?, ?>) ew).setSql(String.format("%s = #{%s.%s}", versionColumn, "ew.paramNameValuePairs", UPDATED_VERSION_VAL_KEY));
}
}
逻辑解读
- 从参数中,获取条件
- 条件如果不是UPDATE包装类,则不做任何处理
- 通过statementId来获取实体类对象类型
- 如果获取类型失败,则抛出异常。
- 从表结构信息中,获取当前表的乐观锁字段
- 如果不存在这个字段,就退出,不再处理乐观锁
-
如何寻找乐观锁字段的? 【已处理】2.2.3.3.1如何寻找乐观锁字段
- 从当前条件中,获取乐观锁值
- 如果当前条件中没有乐观锁值,就直接退出,不再处理乐观锁。
- 通过乐观锁处理工厂来获取新的乐观锁值
- 将新的乐观锁值放入当前要更新的字段中
- 将当前更新条件,增加乐观锁的判断条件
这里的逻辑就比较简单,就很正常的一段处理逻辑,从包装类中获取字段,并插入值,插入条件。
和第一段逻辑中的处理有大体的类似。
这里比较有趣的代码,应该就是寻找乐观锁字段的设计了。
2.2.3.3.1. 如何寻找乐观锁字段
final FieldEqFinder fieldEqFinder = new FieldEqFinder(versionColumn, (Wrapper<?>) ew);
写法很简单,只是new出来了一个对象,就能知道这个字段是否存在。
看下他的构造器,原来别有洞天
public FieldEqFinder(String fieldName, Wrapper<?> wrapper) {
this.fieldName = fieldName;
state = State.INIT;
find(wrapper);
}
首先,将要搜索的字段名,设置到当前的属性中。
将状态初始化
接着就开始搜索这个字段了。
private boolean find(Wrapper<?> wrapper) {
Matcher matcher;
final NormalSegmentList segments = wrapper.getExpression().getNormal();
for (ISqlSegment segment : segments) {
// 如果字段已找到并且当前segment为EQ
if (state == State.FIELD_FOUND && segment == SqlKeyword.EQ) {
this.state = State.EQ_FOUND;
// 如果EQ找到并且value已找到
} else if (state == State.EQ_FOUND
&& (matcher = PARAM_PAIRS_RE.matcher(segment.getSqlSegment())).matches()) {
this.valueKey = matcher.group(1);
this.state = State.VERSION_VALUE_PRESENT;
return true;
// 处理嵌套
} else if (segment instanceof Wrapper) {
if (find((Wrapper<?>) segment)) {
return true;
}
// 判断字段是否是要查找字段
} else if (segment.getSqlSegment().equals(this.fieldName)) {
this.state = State.FIELD_FOUND;
}
}
return false;
}
这里的设计非常的精妙,首先他把条件列表,定义为了一个个的Segment
,小的条件片段。
每一个条件,由于实现了ISqlSegment
接口,所以它还是一个枚举接口。
枚举接口,就可以实现和枚举的==比较。
protected Children addCondition(boolean condition, R column, SqlKeyword sqlKeyword, Object val) {
return maybeDo(condition, () -> appendSqlSegments(columnToSqlSegment(column), sqlKeyword,
() -> formatParam(null, val)));
}
看下NormalSegmentList
的构造过程,可以发现,每一个条件,都是三个segment
。
- 第一个:条件名
- 第二个:表达式
- 第三个:值
所以说,这段逻辑,正确的匹配逻辑是
- 先判断第一个,是否为自己需要的字段。如果确定是了,当前状态为字段已找到。第二次循环时,就会到第二个segment
- 第二个判断是否已经找到这个字段了,如果找到了,就判断当时是否为EQ。如果不是EQ,就不是自己需要的。如果是EQ,当前状态就位EQ_FOUND
- 如果当前条件也对,就会来到第三个segment中,此时就判断第三个值是否符合正则。如果符合正则,就会从第三个中获取到拿乐观锁值的字段key,并返回找到。
- 兼容逻辑,如果三个条件都不对,还会判断当前的segment是否会包装类,因为这个类是多个接口嘛,如果这个类是包装类,则通过递归的方式,继续循环处理。
- 如果全部循环完,还没找到,就会标记没找到字段。
这就是整个的寻找过程,通过这个,就可以精准匹配当前的条件中是否包含字段,是否为EQ匹配,并且顺便找到了获取乐观锁字段值的key,因为是包装类嘛,所以乐观锁的值并不是在实体类中,需要通过key来为乐观锁值进行处理。
2.2.4. 总结
至此,乐观锁的核心原理已经全部完成,到这,乐观锁的整体原理逻辑均已清楚。
但是在乐观锁原理解读的过程中,有一些深入的点需要分析,接着我们就来拓展的再深度挖掘下非核心原理,实现的也很精妙,学习意义很大。
2.3. 待处理的逻辑解读
2.3.1. 乐观锁获取的工厂逻辑
// 新的 version 值
Object updatedVersionVal = this.getUpdatedVersionVal(fieldInfo.getPropertyType(), originalVersionVal);
在获取新的version值的时候,调用的是getUpdatedVersionVal
方法,就能获取到一个新的version值
来看下这块代码的逻辑
/**
* This method provides the control for version value.<BR>
* Returned value type must be the same as original one.
*
* @param originalVersionVal ignore
* @return updated version val
*/
protected Object getUpdatedVersionVal(Class<?> clazz, Object originalVersionVal) {
return VersionFactory.getUpdatedVersionVal(clazz, originalVersionVal);
}
这个方法是调用工厂中的version值。
public static Object getUpdatedVersionVal(Class<?> clazz, Object originalVersionVal) {
Function<Object, Object> versionFunction = VERSION_FUNCTION_MAP.get(clazz);
if (versionFunction == null) {
// not supported type, return original val.
return originalVersionVal;
}
return versionFunction.apply(originalVersionVal);
}
逻辑解析
- 根据version的类型,从预置的方法中,获取一个方法,该方法是传入一个object类型的参数,获取一个object类型的值,也就是弱类型的。
- 如果没有获取到,就将原值返回。
- 如果获取到,就执行这个方法,来获取新的version值。
逻辑很简单,就是通过一个类型的映射,获取一个内置函数,通过函数来计算新的值。核心就是这个内置的函数是什么。
/**
* 存放版本号类型与获取更新后版本号的map
*/
private static final Map<Class<?>, Function<Object, Object>> VERSION_FUNCTION_MAP = new HashMap<>();
函数的定义是一个HashMap,key是一个类型,value是一个函数。
static {
VERSION_FUNCTION_MAP.put(long.class, version -> (long) version + 1);
VERSION_FUNCTION_MAP.put(Long.class, version -> (long) version + 1);
VERSION_FUNCTION_MAP.put(int.class, version -> (int) version + 1);
VERSION_FUNCTION_MAP.put(Integer.class, version -> (int) version + 1);
VERSION_FUNCTION_MAP.put(Date.class, version -> new Date());
VERSION_FUNCTION_MAP.put(Timestamp.class, version -> new Timestamp(System.currentTimeMillis()));
VERSION_FUNCTION_MAP.put(LocalDateTime.class, version -> LocalDateTime.now());
VERSION_FUNCTION_MAP.put(Instant.class, version -> Instant.now());
}
这个工厂中有一块静态方法区,在类加载的时候,就会初始化进去一批内置函数。
这也是乐观锁所支持的类型。
- long
- Long
- int
- Integer
- Date
- Timestamp
- LocalDateTime
- Instant
支持这么多种类型,每一种类型后的参数,都是构造出一个新的值。
如果是数字,则自增。如果是时间, 则取当前时间。这就是乐观锁核心的自增逻辑了。
2.3.2. mp插件装载原理
乐观锁的核心原理基本已经全部讲完了,向下已经下钻到底了,这时我们回过头来,再深度解读下,为什么插件能被执行,原理是什么。
首先就是入口 com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor#beforeUpdate
这个方法为什么会被执行。
org.apache.ibatis.plugin.Plugin#invoke
↓
com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor#intercept
↓
com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor#beforeUpdate
到这里,可以看到实际执行的点是因为Plugin来执行的,是Mybatis的执行逻辑。
再往上追溯,就过于散乱了,无法深入追溯,所以就需要用到debug了。
通过debug,可以得到这样一个逻辑
通过上篇文章,我们知道,所有的sql执行时,都会通过org.mybatis.spring.SqlSessionTemplate.SqlSessionInterceptor#invoke
这个方法。
这个方法中,首先会获取一个sqlSession。
SqlSession sqlSession = SqlSessionUtils.getSqlSession(SqlSessionTemplate.this.sqlSessionFactory, SqlSessionTemplate.this.executorType, SqlSessionTemplate.this.exceptionTranslator);
这里继续深入追踪,不讲细节了,最终可以看到在创建session时,会通过配置new出来一个执行器。最终是执行的这个方法
org.apache.ibatis.session.defaults.DefaultSqlSessionFactory#openSessionFromDataSource
private SqlSession openSessionFromDataSource(ExecutorType execType, TransactionIsolationLevel level,
boolean autoCommit) {
Transaction tx = null;
try {
final Environment environment = configuration.getEnvironment();
final TransactionFactory transactionFactory = getTransactionFactoryFromEnvironment(environment);
tx = transactionFactory.newTransaction(environment.getDataSource(), level, autoCommit);
final Executor executor = configuration.newExecutor(tx, execType);
return new DefaultSqlSession(configuration, executor, autoCommit);
} catch (Exception e) {
closeTransaction(tx); // may have fetched a connection so lets call close()
throw ExceptionFactory.wrapException("Error opening session. Cause: " + e, e);
} finally {
ErrorContext.instance().reset();
}
}
核心在于 final Executor executor = configuration.newExecutor(tx, execType);
public Executor newExecutor(Transaction transaction, ExecutorType executorType) {
executorType = executorType == null ? defaultExecutorType : executorType;
Executor executor;
if (ExecutorType.BATCH == executorType) {
executor = new BatchExecutor(this, transaction);
} else if (ExecutorType.REUSE == executorType) {
executor = new ReuseExecutor(this, transaction);
} else {
executor = new SimpleExecutor(this, transaction);
}
if (cacheEnabled) {
executor = new CachingExecutor(executor);
}
return (Executor) interceptorChain.pluginAll(executor);
}
在创建过执行器后,还会再包装一下。
继续下钻,看包装逻辑
public static Object wrap(Object target, Interceptor interceptor) {
Map<Class<?>, Set<Method>> signatureMap = getSignatureMap(interceptor);
Class<?> type = target.getClass();
Class<?>[] interfaces = getAllInterfaces(type, signatureMap);
if (interfaces.length > 0) {
return Proxy.newProxyInstance(type.getClassLoader(), interfaces, new Plugin(target, interceptor, signatureMap));
}
return target;
}
可以看到,执行器全部都包装成了一个Plugin类。
而这个plugin类,就是本小节最开始看到的调用链顶端。
那么到此,就明白了基本的运行原理
- 执行sql,获取sqlSession
- 构建sqlSession时,构建执行器
- 将执行器包装成plugin类
- 通过sqlSession执行
- 调用执行器进行执行
- 执行器被包装到plugin中,先执行plugin的invoke方法
- plugin执行拦截器
- 拦截器执行完后,调用执行器。
plugin执行器执行时,调用的就是Mybatis-plus的拦截器
而我们所有注册的mp插件,全部都注册到了Mybatis-plus的拦截器中,再由mp的拦截器进行顺序执行。
Iterator var8 = this.interceptors.iterator();
while(var8.hasNext()) {
InnerInterceptor update = (InnerInterceptor)var8.next();
if (!update.willDoUpdate(executor, ms, parameter)) {
return -1;
}
update.beforeUpdate(executor, ms, parameter);
}
所有的逻辑全部畅通!
2.4. 待验证的逻辑点
2.4.1. 装载乐观锁插件时,放入异常对象,那么在使用乐观锁时没有值是否会抛出异常。
乐观锁插件注入时,如果配置了异常信息,在更新时,没有乐观锁字段,就会抛出异常。
如果要强制使用乐观锁,可以考虑在注入时加上这个参数。
2.4.2. version为null,是否会更新乐观锁字段
version如果为null,乐观锁字段就不会更新。
2.4.3. 关闭包装类型参数,且不使用entity来更新,是否会触发乐观锁。
如果使用默认的构造器来构造乐观锁插件,使用包装类来更新,则会导致乐观锁失效。
因为此时et为null,第二段逻辑也无法走。
3. 结束
OK,至此Mybatis-plus中,乐观锁和逻辑删除的全部源码逻辑均已撸完。
如果有想看的源码,欢迎评论,我会出文带你一起撸!