事务的那些坑

àì夳堔傛蜴生んèń 2022-06-18 02:22 391阅读 0赞
  1. 总结一下事务在使用过程中的一些坑。
  2. 在介绍之前,先普及一些基础知识

一、基础知识

事务隔离等级

  1. 一般事务的隔离等级有Read uncommittedRead committedRepeatable readSerializable。其中隔离程度越来越严格,到了Serializable已经不支持并发事务了。

Read uncommitted

  1. 会造成脏读,可重复读,幻读。什么是脏读,看一下关键词uncommitted,英文很直白。网上很多抄来抄去的文章很多把脏读和不可重复读混淆。特别是乐观锁的文章。脏读也就是说读取到别人未提交的事务中的改动。举个例子:
  2. 原数据 id=1 value=6
  3. 事务A 事务B
  4. 修改id=1的数据value=8
  5. 读取id=1的数据value=8
  6. commit

Read committed

  1. 会造成可重复读,幻读。read committed字面上已经表明了只能读取到事务提交后的数据,所以避免了脏读。什么是可重复读,可重复读是指在事务中前后读取两次同一数据,由于在此期间,存在其他事务修改该数据并提交,造成前后读取不一致。举个例子
  2. 原数据 id=1 value=6
  3. 事务A 事务B
  4. 读取id=1的数据value=6
  5. 修改id=1的数据value=8
  6. commit
  7. 读取id=1的数据value=8

Repeatable read

  1. 会造成幻读。Repeatable read字面上也表明了可以在事务中重复读取。比如mysql中的实现方式是事务开始后的select将会把结果存在临时数据中。事务中再次读取时直接从临时数据中取值,因此可以重复读取。但是这只保证了update操作,对于insert操作,则会产生幻读。举个例子:
  2. 原数据 id=1 value=6
  3. 事务A 事务B
  4. 读取id=1的数据 value=6
  5. 新增一行数据id=1 value=8
  6. commit
  7. 读取id=1的数据 value=6
  8. value=8
  9. 这样得到了两条数据,则是幻读。

Serializable

  1. 不允许并发事务,不会产生任何并发问题。

乐观锁

  1. 乐观锁和之前的事务隔离等级一样,也是为了解决一些并发问题。乐观锁解决的问题如下:

不可重复读

  1. 从之前的事务隔离等级已经知道Repeatable read则可以解决不可重复读,乐观锁和它之间还是有区别的。
  2. 如果在一个事务中防止读取另一个已提交事务造成的重复读,在这一点上和Repeatable read的区别是,如果是Repeatable read,还是可以获得事务开始时的值,而乐观锁你只知道值变了,无法获得原始值。

更新丢失

  1. 乐观锁的作用范围超过了事务。考虑如下场景,用户A打开了修改界面,修改界面读取当前数据显示。然后用户A去做其他事情了。这时候用户B修改了同样的数据。用户A回来继续修改,提交事务则覆盖了用户B的修改。

只读事务

  1. 在数据库层面,只读事务的作用也是解决不可重复读的问题,并且不允许该事务中存在除了查询以外的操作。

事务传播等级

  1. spring提出的概念,这里只介绍容易混淆的等级
  2. **PROPAGATION\_REQUIRED**:如果当前不存在事务,则新建事务,否则使用当前事务。如果遇到内部事务,把外部事务作为内部事务。
  3. **PROPAGATION\_REQUIRES\_NEW**:不论何时,总是新建事务。如果遇到内部事务,则挂起外部事务,内部事务处理完,继续外部事务。
  4. **PROPAGATION\_NESTED**:嵌套事务,规则是这样的,外部事务在进入内部事务的时候,建立一个save point,并且挂起,如果内部事务回滚,则回滚到save point,然后继续执行外部事务。如果外部事务回滚,则同时回滚内部事务。
  5. 通过举例子的方式来说明 这几个事务传播等级的区别,主要是作为内部事务的情形。
  6. 由于只讨论内部事务,外部事务假设为任何一种等级的事务。例子均用伪码表示。

内部事务为PROPAGATION_REQUIRED

  1. 例子1
  2. @Transactional
  3. public void method(){
  4. doSomeThingInDB();
  5. methodRequired();
  6. throw newException();
  7. }
  8. 方法methodRequired使用propagation\_required事务,并且成功提交。外部事务回滚,methodRequired的操作也回滚。
  9. 原因:内部事务和外部事务是同一个。
  10. 例子2
  11. @Transactional
  12. public void method(){
  13. doSomeThingInDB();
  14. methodRequiredRollBack();
  15. }
  16. 方法methodRequiredRollBack使用propagation\_required事务,并且回滚。外部事务同样回滚。
  17. 原因:内部事务和外部事务是同一个。

内部事务为PROPAGATION_REQUIRED_NEW

  1. 例子1
  2. @Transactional
  3. public void method(){
  4. doSomeThingInDB();
  5. methodRequiredNew();
  6. throw new Exception();
  7. }
  8. 方法methodRequiredNew使用propagation\_required\_new事务,外部事务回滚,不影响内部事务。
  9. 原因:内部事务和外部事务是不同的事务。
  10. 例子2
  11. @Transactional
  12. public void method(){
  13. doSomeThingInDB();
  14. methodRequiredNewRollBack();
  15. }
  16. 方法methodRequiredNewRollBack使用propagation\_required\_new事务,并且回滚,不影响外部事务。
  17. 原因:内部事务和外部事务是不同的事务。

内部事务为PROPAGATION_NESTED

  1. 例子1
  2. @Transactional
  3. public void method(){
  4. doSomeThingInDB();
  5. methodNested();
  6. throw new Exception();
  7. }
  8. 方法methodNested使用propagation\_nested事务,外部事务回滚,内部事务也回滚。
  9. 原因:嵌套事务外部事务回滚,内部事务也回滚。
  10. 例子2
  11. @Transactional
  12. public void method(){
  13. doSomeThingInDB();
  14. try{
  15. methodNestedRollBack();
  16. }catch(Exception e){
  17. doSomeThingInDB();
  18. }
  19. }
  20. 方法methodNestedRollBack使用propagation\_nested事务,并且回滚,此时外部事务可以继续catch中的操作,如果没有抛出,可正常提交外部事务。
  21. @Transactional
  22. public void method(){
  23. doSomeThingInDB();
  24. try{
  25. methodNestedRollBack();
  26. }catch(Exception e){
  27. doSomeThingInDB();
  28. throw new Exception();
  29. }
  30. }
  31. 方法methodNestedRollBack使用propagation\_nested事务,并且回滚,外部事务在catch中抛错回滚。此时,内部事务和外部事务均回滚。
  32. 原因:嵌套事务中,如果内部事务回滚,不影响外部事务。但是外部事务回滚,内部事务也会回滚。

二、事务中的坑

乐观锁与可重复读

  1. 乐观锁其实在更大范围确保了可重复读的问题,如果你的事务隔离等级为可重复读,mysql的默认等级,你又使用了乐观锁的话,你一定要小心谨慎,不然很可能出错。举个很经典的例子,死循环。
  2. public void update(String id){
  3. DataVo vo=mapper.getById(id);
  4. while(mapper.update(vo)==0){
  5. vo=mapper.getById(id)
  6. }
  7. }
  8. 其中vo中包含版本号或时间戳。死循环的过程如下:
  9. 事务A 事务B
  10. 执行update方法
  11. mapper.getById(1)获得版本号为1
  12. 修改id=1的数据,版本号变为2
  13. commit
  14. 由于版本号为2,没有数据被更新,返回0
  15. 执行循环中的mapper.getById(1)获得版本号为1
  16. 由于版本号为2,没有数据被更新,返回0
  17. 执行循环中的mapper.getById(1)获得版本号为1
  18. 无限循环这两个步骤

readonly是否为只读事务

  1. 从数据库的层面我们知道只读事务可以解决重复读的问题,并且防止该事务中的任何非查询操作。但是我们在数据库驱动中设置的readonly属性真的就是数据库层面的只读事务吗。
  2. 这个问题没有确切的答案,需要看具体的驱动实现。比如:在老版本的oralce驱动中确实是设置了只读事务,而在新驱动中则只设置连接为只读。而且甚至在事务中存在非查询操作也不会报错。
  3. 下面来看看mysql中的情况,驱动为mysql-connector-java-5.1.38.jar
  4. 看一下源码,exec方法
  5. if(!checkReadOnlySafeStatement()) {
  6. throw SQLError.createSQLException(Messages.getString("PreparedStatement.20") //$NON-NLS-1$
  7. + Messages.getString("PreparedStatement.21"), //$NON-NLS-1$
  8. SQLError.SQL_STATE_ILLEGAL_ARGUMENT, getExceptionInterceptor());
  9. }
  10. 其中checkReadOnlySafeStatement的源码
  11. protected boolean checkReadOnlySafeStatement() throws SQLException {
  12. synchronized (checkClosed()) {
  13. return ((!this.connection.isReadOnly()) || (this.firstCharOfStmt == 'S'));
  14. }
  15. }
  16. 我们看到这里的readonly仅仅是connectionreadonly,并不是事务的readonly。因此,基于驱动的spring中的@Transactional(readOnly=true)自然也不是只读事务。不过它还是会对非查询操作回滚,比oralce的某些驱动好些。
  17. 我们可以做个试验
  18. 1、事务隔离级别设为READ\_COMMITTEDreadOnly=true,中途修改数据
  19. 在获取了用户以后线程sleep3秒,在这个时候修改数据库,把该用户的loginname进行修改
  20. @Override
  21. @Transactional(isolation=Isolation.READ_COMMITTED,readOnly=true)
  22. public DataGrid<UserVo> auditor(UserVo vo) throws Exception {
  23. UserVo v=userMapper.findUserById("3af99f64-df20-4ffe-95df-a1d7ac5e84e8");
  24. System.out.println(v.getLoginname());
  25. TimeUnit.SECONDS.sleep(3);
  26. v=userMapper.findUserById("3af99f64-df20-4ffe-95df-a1d7ac5e84e8");
  27. System.out.println(v.getLoginname());
  28. DataGrid<UserVo> vos=datagridMap(vo, userMapper, DBTYPE_MYSQL, "findCurrentAuditor","foundCount");
  29. return vos;
  30. }
  31. 结果如下:

Center

  1. 结论,事务隔离等级比Repeatable read低的情况下,会造成不可重复读,readOnly并不是只读事务。
  2. 2readOnly=true,其中有删除操作
  3. @Override
  4. @Transactional(readOnly=true)
  5. public DataGrid<UserVo> auditor(UserVo vo) throws Exception {
  6. Map<String,Object> params=new HashMap<>();
  7. params.put("id", "1");
  8. userMapper.deleteRoles(params);
  9. DataGrid<UserVo> vos=datagridMap(vo, userMapper, DBTYPE_MYSQL, "findCurrentAuditor","foundCount");
  10. return vos;
  11. }
  12. 其中存在删除操作,会抛出异常
  13. 结论,虽然不是只读事务,但是任然不允许处查询操作之外的操作
  14. 3readOnly=true,或不设置readOnly=true,中途修改数据
  15. @Transactional(readOnly=true)
  16. public DataGrid<UserVo> auditor(UserVo vo) throws Exception {
  17. UserVo v=userMapper.findUserById("3af99f64-df20-4ffe-95df-a1d7ac5e84e8");
  18. System.out.println(v.getLoginname());
  19. TimeUnit.SECONDS.sleep(3);
  20. v=userMapper.findUserById("3af99f64-df20-4ffe-95df-a1d7ac5e84e8");
  21. System.out.println(v.getLoginname());
  22. DataGrid<UserVo> vos=datagridMap(vo, userMapper, DBTYPE_MYSQL, "findCurrentAuditor","foundCount");
  23. return vos;
  24. }
  25. @Transactional
  26. public DataGrid<UserVo> auditor(UserVo vo) throws Exception {
  27. UserVo v=userMapper.findUserById("3af99f64-df20-4ffe-95df-a1d7ac5e84e8");
  28. System.out.println(v.getLoginname());
  29. TimeUnit.SECONDS.sleep(3);
  30. v=userMapper.findUserById("3af99f64-df20-4ffe-95df-a1d7ac5e84e8");
  31. System.out.println(v.getLoginname());
  32. DataGrid<UserVo> vos=datagridMap(vo, userMapper, DBTYPE_MYSQL, "findCurrentAuditor","foundCount");
  33. return vos;
  34. }
  35. 在获取了用户以后线程sleep3秒,在这个时候修改数据库,把该用户的loginname进行修改
  36. 两者结果如下:

Center 1

  1. 结论:默认事务隔离等级Repeatable read确保了可重复读

同类中的事务传播等级

  1. 同一个类中的方法之间调用,事务传播等级会失效。原因是事务是通过动态代理实现的,同类中的方法调用不是代理类。
  2. 解决办法是引入aspectjweaver.jar,然后开启暴露aop代理给ThreadLocal,比如xml的配置如下:
  3. <aop:aspectj-autoproxy proxy-target-class="true" expose-proxy="true"/>
  4. 代码中如下调用:
  5. ((Service) AopContext).method();

事务与缓存可重复读

  1. 隔离等级为Repeatable read的事务可以重复读取同一数据,然而同时使用缓存的时候要格外小心,因为缓存很可能会破坏可重复读。
  2. 举个例子:
  3. @Override
  4. @Transactional
  5. @CacheEvict(value="user",allEntries=true)
  6. public void updateTest(String username) throws Exception{
  7. UserVo vo=new UserVo();
  8. vo.setLoginname("super");
  9. vo.setUsername(username);
  10. vo.setDeptId("c5145caa-fc06-11e5-8d93-1d3ee1acb99b");
  11. vo.setPhone("test");
  12. vo.setUserRole("0");
  13. vo.setId("1");
  14. userMapper.update(vo);
  15. }
  16. updateTest方法修改数据库的同时,会清空所有的缓存
  17. @Cacheable(value="user",keyGenerator="baseCacheKeyGenerator")
  18. public Json getUser(String userId){
  19. System.out.println("no cache");
  20. Optional<Json> json=Optional.of(new Json());
  21. UserVo vo=userMapper.findUserById(userId);
  22. Optional.ofNullable(vo).map(v -> {
  23. v.setId(UUID.randomUUID().toString());
  24. return v;
  25. });
  26. Optional.ofNullable(vo).ifPresent(json.get() :: setObj);
  27. return json.get();
  28. }
  29. getUser会把相同userId的缓存起来。其中baseCacheKeyGenerator的策略为类,方法名,参数hash code值均相等才被视为同一查询。
  30. @Override
  31. @Transactional
  32. public Json doSomething(UserVo vo) throws Exception {
  33. Json json=userService.getUser("1");
  34. Optional.ofNullable(json).ifPresent(j -> {
  35. Optional.ofNullable((UserVo) j.getObj()).ifPresent(v -> {
  36. System.out.println(v.getUsername());
  37. });
  38. });
  39. java.util.concurrent.TimeUnit.SECONDS.sleep(10);
  40. json=userService.getUser("1");
  41. Optional.ofNullable(json).ifPresent(j -> {
  42. Optional.ofNullable((UserVo) j.getObj()).ifPresent(v -> {
  43. System.out.println(v.getUsername());
  44. });
  45. });
  46. return new Json();
  47. }
  48. doSomething方法会调用getUser两次,其中间隔10秒,在10秒之中,另外一个事务开启,调用updateTest方法,将该用户的username改为test
  49. getUser没有缓存id1的用户的时候,结果如下:

Center 2

  1. 发现两次都没有走缓存,获得的结果一致,不存在不可重复读问题。
  2. 原因:第一次查询由于没有缓存,所有从数据库查询。接着该用户名被另一事务修改,并且清空缓存。第二次查询时由于缓存被清空,所以同样通过数据库查询,这时候事务的隔离等级Repeatable read起作用,还是获得事务开始前的用户名。
  3. 这个时候,如果我们再一次调用doSomething方法,并在第一次查询结束的10秒类,通过另一个事务调用updateTest方法,将该用户的username改为super
  4. 结果如下:

Center 3

  1. 可以看到,第一次查询因为有缓存,所以直接从缓存中取,获得用户名为super,这个时候由于没有从数据库查询,所以不属于可重复读的第一次查询。然后修改usernametest,并且清空了缓存。再次查询的时候,由于没有缓存,所以通过数据库第一次查询用户名为test。这样就造成了可重复读的破坏。可以推导出,如果这个时候再次修改用户为super,然后在之前的事务再查询用户名,这个时候事务的可重复读才会生效,因为这里的第三次查询被认为了第二次查询,将返回用户名为test
  2. 以上就是这次总结中事务的全部内容,对于存在坑的地方,使用一定要小心。

发表评论

表情:
评论列表 (有 0 条评论,391人围观)

还没有评论,来说两句吧...

相关阅读

    相关 Hibernate 那些

    昨天下午,同事在开发的过程中遇到一个奇怪的问题:在控制器方法中查询出了一个对象,然后把这个对象传递到 service 层中,修改这个对象最后保存,但保存之后在数据库中相应的记录