for update 应用场景:对需要读写操作(query data and then insert or update related data within the same transaction)的数据进行加锁,防止其他写操作的影响。把业务并发彻底变为串行。业务操作变安全。
MySQL :: MySQL 5.7 Reference Manual :: 14.7.2.4 Locking Reads
多个session(事务) 在没有修改操作时,都可以对当前的数据加共享锁。
如果有session 有修改操作,那么加锁会阻塞,一直等待操作session commit后,加锁成功,并且返回最新的数据。
这种适用于对加锁数据只读操作的场景。文档Example 以parent /child表 级联操作案例来说明应用场景。
多个session(事务) ,对当前数据,只有一个可以加锁成功,其余等待(包括LOCK IN SHARE MODE)。
这种适用于对加锁数据进行读写操作的场景。文档Example 以 counter 累加的案例说明应用场景。
START TRANSACTION;
-- try lock
SELECT counter_field FROM child_codes FOR UPDATE;
-- modify...
UPDATE child_codes SET counter_field = counter_field + 1;
-- release lock
COMMIT;
START TRANSACTION;
SELECT * FROM child_codes WHERE id = 10567 LOCK IN SHARE MODE;
-- 多个session 在此互相等待,死锁发生
UPDATE child_codes SET name = 'foo' WHERE id = 10567;
COMMIT;
mysql 通过locking reads 机制,解决了并发场景下读写数据不一致的问题。
我们比较常用的是 for update。优点是简单,缺点是tps不高。
还有其他的控制业务并发的方案,比如乐观锁机制(自旋)。需要根据场景去选用合适的技术方案。
如果使用不当,会造成SQL 死锁。又一种死锁的方式😂。
使用MybatisPlus 过程中,发现有逻辑删除的内置功能,比较好奇,引出此次的Code Insight。 注意:逻辑删除是设置针对全局的。
mybatis-plus:
check-config-location: fals
# 这个是 Mybatis 的配置
configuration:
auto-mapping-unknown-column-behavior: partial
cache-enabled: false
call-setters-on-nulls: true
map-underscore-to-camel-case: true
# 这个是MybatisPlus 的配置
global-config:
db-config:
logic-delete-field: isDelete
# 逻辑删除全局值(默认 1、表示已删除)
logic-delete-value: 1
logic-not-delete-value: 0
com.baomidou.mybatisplus.core.config.GlobalConfig 插件的配置项, 对应上述的配置
com.baomidou.mybatisplus.annotation.TableLogic 表字段逻辑处理注解(逻辑删除)
com.baomidou.mybatisplus.core.metadata.TableFieldInfo 数据库表字段信息
initLogicDelete:316, TableFieldInfo initTableFields:319, TableInfoHelper initTableInfo:144, TableInfoHelper inspectInject:53, AbstractSqlInjector parserInjector:133, MybatisMapperAnnotationBuilder parse:123, MybatisMapperAnnotationBuilder addMapper:83, MybatisMapperRegistry bindMapperForNamespace:432, XMLMapperBuilder
/**
* 逻辑删除初始化
*
* @param dbConfig 数据库全局配置
* @param field 字段属性对象
*/
private void initLogicDelete(...) {
/* 获取注解属性,逻辑处理字段 */
TableLogic tableLogic = field.getAnnotation(TableLogic.class);
if (null != tableLogic) {
// ...
} else if (!existTableLogic) {
// 'isDelete'
String deleteField = dbConfig.getLogicDeleteField();
if (StringUtils.isNotBlank(deleteField) && this.property.equals(deleteField)) {
// 0
this.logicNotDeleteValue = dbConfig.getLogicNotDeleteValue();
this.logicDeleteValue = dbConfig.getLogicDeleteValue();
this.logicDelete = true;
}
}
}
/**
* 应用场景
* 根据ID 查询一条数据(MappedStatement 模板)
* 自动追加逻辑删除条件(AND is_delete = 0)
*/
public class SelectById extends AbstractMethod {
@Override
public MappedStatement injectMappedStatement(Class<?> mapperClass, Class<?> modelClass, TableInfo tableInfo) {
// SELECT %s FROM %s WHERE %s=#{%s} %s 最后一个参数:逻辑删除条件
SqlMethod sqlMethod = SqlMethod.SELECT_BY_ID;
SqlSource sqlSource = new RawSqlSource(configuration, String.format(sqlMethod.getSql(),
sqlSelectColumns(tableInfo, false),
tableInfo.getTableName(), tableInfo.getKeyColumn(), tableInfo.getKeyProperty(),
// " AND " + logicDeleteFieldInfo.getColumn() + "=" + logicDeleteFieldInfo.getLogicNotDeleteValue();
tableInfo.getLogicDeleteSql(true, true)), Object.class);
return this.addSelectMappedStatementForTable(mapperClass, getMethod(sqlMethod), sqlSource, tableInfo);
}
}
MybatisPlus 和tk.Mybatis 针对@Table 处理思路一样。在初始化过程中,会缓存表的相关信息,方便后续拼装SQL 使用。logic-delete 就是应用在初始化过程中。
A sequence of elements supporting sequential and parallel aggregate operations.
A stream pipeline consists of a source, zero or more intermediate operations, and a terminal operation
Streams are lazy
了解了上述的概念,要分析 parallel stream 作用原理,就要从terminal operation 源码入手。
/**
* Stream 定义的接口可以作为源码阅读的切入口
*
* Returns whether this stream, if a terminal operation were to be executed,
* would execute in parallel. 注意:terminal operation executed
* @see java.util.stream.BaseStream#isParallel
*/
boolean isParallel();
java.util.stream.AbstractPipeline the core implementations of the Stream interface
java.util.stream.TerminalOp
java.util.stream.ForEachOps.ForEachOp
java.util.stream.ReduceOps.ReduceOp
/**
* 通过TerminalOp 发生真正的调用,是否并行,在此处确定
* Evaluate the pipeline with a terminal operation to produce a result.
*
* @see java.util.stream.AbstractPipeline#evaluate(java.util.stream.TerminalOp<E_OUT,R>)
*/
final <R> R evaluate(TerminalOp<E_OUT, R> terminalOp) {
return isParallel() ? terminalOp.evaluateParallel(...) : terminalOp.evaluateSequential(...);
}
// 这样的Terminal Operation
persons.parallelStream().map(person -> person.name).forEach(System.out::println)
如何实现并发调用,只需要关注evaluateParallel 方法。
new ForEachTask<>(helper, spliterator, helper.wrapSink(this)).invoke();
class ForEachTask<S, T> extends ForkJoinTask<T>
至此,我们就知道并发是依靠ForkJoinTask 实现的。
近期,在使用SpringBoot Test 单测验证service 逻辑过程中,发现service “注入”的mapper 竟然是null, 导致业务方法NPE。
之前的单测一直是这样的,是因为SpringBoot 版本问题,导致注入失败么?
@Slf4j
@RunWith(SpringJUnit4ClassRunner.class)
@SpringBootTest
public class FooServiceProcessTest {
/**
* 诡异的问题就在这里,serviceProcess 注入并没有问题。
* serviceProcess 的属性mapper 是null
*/
@Autowired
private FooServiceProcess serviceProcess;
/**
* 测试更新关联关系操作
* cascadeUpdateFoo 方法中,是通过this.fooMapper.update() 方式编写的。
* 需要测试的方法是private ,为了方便,直接用反射调用的。就是因为这个非常规使用,导致个诡异问题出现
*/
@Test
void cascadeUpdateFoo() throws InvocationTargetException, IllegalAccessException {
Method method = ReflectionUtils.findMethod(serviceProcess.getClass(), "cascadeUpdateFoo", FooParam.class);
ReflectionUtils.makeAccessible(method);
FooParam param = new FooParam();
param.setId(180L);
param.setName("foo");
param.setUpdateUser("foo");
method.invoke(serviceProcess, param);
}
}
从报错日志和直观的判断,mapper 为null,可能的问题是Mybatis 或者Mybatis 插件扫描,加载出了问题。通过查看ApplicationContext, mapper 是存在的,况且注入使用的是@Autowired,如果容器没有mapper,启动就会报错。
然后就是从spring-test 去排查,是否会存在父子容器注入替换的问题。之前在排查SpringMVC 就遇到过这种问题。
参考上述代码,FooServiceProcess 可以正常注入,追查容器的初始化或者注入线索的意义就不太大了。那就从单测代码上追查。
通过debug, 发现真正的问题。serviceProcess 实例并非原始的对象,是经过cglib 动态代理过的proxy。如果是调用private 方法,调用的就是这个proxy 的方法,自然会出现NPE 的问题。
通过上述的排查和分析,这个问题和SpringBoot 并没有关系。
问题出在cglib 动态代理类的调用方式和继承机制上,通过编写测试类HotPot,再按照上述的反射调用private 方法,即可复现。
/**
* private, cglib can't enhance...<br/>
* this 是指proxy, getMaterial() 则调用了目标instance.getMaterial(), 是可以正常返回的。<br/>
* 打印结果:<br/>
* x.y.simple.HotPot$$EnhancerByCGLIB$$23f73db0
* intercept method--class x.y.simple.HotPot.getMaterial
* this material is china., need heat
*/
private void prepare() {
System.out.println(this.getClass().getName());
System.out.println("this material is " + this.getMaterial() + ", need heat");
}
/**
* 同样是无法代理的private, this 是proxy, material 是proxy 实例的属性,所以是null<br/>
* 打印结果:<br/>
* the material null need wash, after used
*/
private void finish () {
System.out.println("the material " + this.material + " need wash, after used");
}
/**
* public 方法,proxy.toString, 拦截后,变为目标instance.toString()<br/>
* 打印结果:<br/>
* x.y.simple.HotPot
* HotPot{temperature=99.9, material='china.'}
*/
@Override
public String toString() {
System.out.println(this.getClass().getName());
return "HotPot{temperature=" + this.temperature + ", material='" + this.material + '\'' + '}';
}
代码库: https://github.com/MrRobot5/sample-base-more
不要使用非常规的写法,不常用的功能或者api,遇到问题的概率会大很多。
这次的问题深入追查后,是cglib 代理的问题,是java 继承理论知识的充分表现。
越是离奇诡异的问题,原因越是简单和直白。但是排查弯路总是要走的,经验就是这么来的。
一次上线完成后,观察线上日志,发现很多死锁异常(org.springframework.dao.DeadlockLoserDataAccessException)。
mysql 提示信息:Deadlock found when trying to get lock; try restarting transaction。
这是一次并发插入/更新引发的死锁案例
| 事务1 | 事务2 | 备注 |
|---|---|---|
INSERT INTO country(countryname, countrycode) VALUES (‘Angola’,’AO’) |
||
INSERT INTO country(countryname, countrycode) VALUES (‘Brazil’,’BR’) |
正常执行,相安无事 | |
UPDATE country SET countryname = ‘Angola’ WHERE countryname = ‘AO’ |
阻塞。事务1等待事务2 释放锁 | |
UPDATE country SET countryname = ‘Brazil’ WHERE countryname = ‘BR’ |
DEADLOCK。事务2 等待事务1 释放锁 |
-- 查询死锁日志
SHOW ENGINE innodb STATUS
------------------------
LATEST DETECTED DEADLOCK
------------------------
2022-06-02 19:37:26 9ec
*** (1) TRANSACTION:
TRANSACTION 11187331, ACTIVE 5 sec fetching rows
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 312, 21 row lock(s), undo log entries 1
UPDATE country SET countryname = 'Angola' WHERE countryname = 'AO'
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 515 page no 3 n bits 88 index `PRIMARY` of table `test`.`country` trx id 11187331 lock_mode X waiting
*** (2) TRANSACTION:
TRANSACTION 11187330, ACTIVE 5 sec starting index read
mysql tables in use 1, locked 1
UPDATE country SET countryname = 'Brazil' WHERE countryname = 'BR'
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 515 page no 3 n bits 88 index `PRIMARY` of table `test`.`country` trx id 11187330 lock_mode X locks rec but not gap
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 515 page no 3 n bits 88 index `PRIMARY` of table `test`.`country` trx id 11187330 lock_mode X waiting
*** WE ROLL BACK TRANSACTION (2)
日志怎么看?
上述的 innodb 日志已经精简过了。
其中列出了死锁相关的两个事务*** (1) TRANSACTION。
引发死锁的事务会列出持有的锁 HOLDS THE LOCK(S) lock_mode X locks rec but not gap。 这对应的就是insert 语句的加锁。
最后,WE ROLL BACK TRANSACTION (2)