阅读 385

小事务,大学问

       最近在做旧代码重构中经常会遇事务相关的问题,比如事务范围与有效性,事务同步,事务与其他中间件协同,事务粒度控制等问题,本篇系统地梳理下事务相关的知识内容。

1.事务的相关概念

事务的概念来自于两个独立的需求:并发数据库访问,系统错误恢复。在数据库操作中,一个事务可以被看作一个独立操作单元的一系列SQL语句的集合。

1.1 事务的特性(ACID)

  • A (atomacity 原子性) : 事务必须是原子工作单元;对于其数据修改,要么全都执行,要么全都不执行。

  • C (consistency 一致性) : 事务将数据库从一种一致状态转变为下一个一致状态。事务在完成时,必须使所有的数据都保持一致状态。

  • I (isolation 隔离性) : 由并发事务所作的修改必须与任何其它并发事务所作的修改隔离。事务查看数据时数据所处的状态,要么是另一并发事务修改它之前的状态,要么是另一事务修改它之后的状态,事务不会查看中间状态的数据。

  • D (durability 持久性) : 事务完成之后,它对于系统的影响是永久性的。该修改即使出现致命的系统故障也将一直保持。

1.2 事务相关行为

       事务的行为包括开启事务、提交事务和回滚事务。在MySQL的InnoDB存储引擎中所有的用户SQL执行都在事务控制之内。 

  • 在默认情况下,autocommit设置为true,单条SQL执行成功后,MySQL会自动提交事务,或者如果SQL执行出错,则根据异常类型执行事务提交或者回滚。
  • 可以使用START TRANSACTION或者BEGIN开启事务,使用COMMIT和ROLLBACK提交和回滚事务;
  • 当设置autocommit为false时,其后执行的多条SQL语句将在一个事务内,直到执行COMMIT或者ROLLBACK事务才会提交或者回滚。

2.MySQL实现事务原理

在业务中常采用MySQL中间件作为关系型数据库,要实现事务的有效性,就需要解决MySQL解决可靠性和并发性的问题,可靠性是要保证当insert或update操作时抛异常或者数据库crash的时候需要保障数据的操作前后的一致;并发性要保证当有多个事务并发执行时,能避免读到脏数据,需要对事务之间的读写进行隔离。MySQL中使用日志文件(redo log 和 undo log),锁技术以及MVCC等技术来实现事务。

2.1 日志文件

         在业务中常见的数据库MySQL是靠redo和undo来实现事务的重做和恢复。

  • redo log: 用于记录已成功提交事务的修改信息。为了提升性能不会把每次的修改都实时同步到磁盘,而是会先存到Boffer Pool(缓冲池),后台线程去做缓冲池和磁盘之间的同步,所以引入了redo log来记录已成功提交事务的修改信息,会把redo log持久化到磁盘,如系统重启之后会再读取redo log恢复最新数据。
  • undo log: 用于记录数据被修改前的信息。 为了在发生错误时回滚之前的操作,需要将之前的操作都记录下来,然后在发生错误时才可以回滚,undo log不用持久化,在提交完事务后则将删除该事务的undo log。

       如下是一个简单的更新用户账户信息的例子,首先开启一个新的事务,在该事务中先先读取到某个用户的账户信息然后进行修改,包括了两条更新sql语句的执行,可以看到每次执行都会写redo log和undo log,一个记录执行前结果,一个记录执行后结果。当事务提交后, redo log将会同步到磁盘中,而undo log将被删除。

2.2 锁与MVCC

        当有多个请求来读取表中的数据时可以不采取任何操作,但是多个请求里有读请求,又有修改请求时必须有一种措施来进行并发控制。读写锁解决上述问题很简单,只需用下面两种锁的组合来对读写请求进行控制即可。

  • 共享锁(shared lock),又叫做"读锁",读锁是可以共享的,或者说多个读请求可以共享一把锁读数据,不会造成阻塞。
  • 排他锁(exclusive lock),又叫做"写锁",写锁会排斥其他所有获取锁的请求,一直阻塞,直到写入完成释放锁。

        InnoDB存储引擎的MVCC,是通过在每行记录的后面保存两个隐藏的列来实现的。这两个列, 一个保存了行的创建时间,一个保存了行的过期时间, 当然存储的并不是实际的时间值,而是系统版本号,主要实现思想是通过数据多版本来做到读写分离。从而实现不加锁读进而做到读写并行。MVCC在mysql中的实现依赖的是undo log与read view

  • undo log: undo log 中记录某行数据的多个版本的数据。
  • read view : 用来判断当前版本数据的可见性

总结下,MySQL事务的的原子性是通过 undo log 来实现的,持久性性是通过 redo log 来实现的,隔离性是通过 (读写锁+MVCC)来实现的。

3. Spring框架事务实现原理

3.1 Spring-AOP增强原理

    Spring使用AOP(面向切面编程)来实现声明式事务,后续再讲Spring事务具体实现的时候会详细说明,关于AOP的概念可参考Spring AOP概念理解,这里不再细说。说下动态代理和AOP增强。

 

        动态代理是Spring实现AOP的默认方式,分为两种:JDK动态代理CGLIB动态代理。JDK动态代理面向接口,通过反射生成目标代理接口的匿名实现类;CGLIB动态代理则通过继承,使用字节码增强技术为目标代理类生成代理子类。Spring默认对接口实现使用JDK动态代理,对具体类使用CGLIB,同时也支持配置全局使用CGLIB来生成代理对象。详细实操可参见juejin.cn/post/684790…

       我们在切面配置中会使用到@Aspect注解,这里用到了Aspectj的切面表达式。Aspectj是java语言实现的一个AOP框架,使用静态代理模式,拥有完善的AOP功能,与Spring AOP互为补充。Spring采用了Aspectj强大的切面表达式定义方式,但是默认情况下仍然使用动态代理方式,并未使用Aspectj的编译器和织入器,当然也支持配置使用Aspectj静态代理替代动态代理方式。Aspectj功能更强大,比方说它支持对字段、POJO类进行增强,与之相对,Spring只支持对Bean方法级别进行增强。

Spring对方法的增强有五种方式:

  • 前置增强(org.springframework.aop.BeforeAdvice):在目标方法执行之前进行增强;
  •  后置增强(org.springframework.aop.AfterReturningAdvice):在目标方法执行之后进行增强; 
  • 环绕增强(org.aopalliance.intercept.MethodInterceptor):在目标方法执行前后都执行增强;
  •  异常抛出增强(org.springframework.aop.ThrowsAdvice):在目标方法抛出异常后执行增强; 
  • 引介增强(org.springframework.aop.IntroductionInterceptor):为目标类添加新的方法和属性。

        声明式事务的实现就是通过环绕增强的方式,在目标方法执行之前开启事务,在目标方法执行之后提交或者回滚事务,事务拦截器的继承关系图可以体现这一点:

3.2 spring事务切面实现原理

       Spring事务采用AOP的方式实现,主要通过TransactionAspectSupport类实现,可以看到这个类中关于事务操作的主要实现流程如下所示:

//1. 获取@Transactional注解的相关参数
TransactionAttributeSource tas = getTransactionAttributeSource();

// 2. 获取事务管理器
final TransactionAttribute txAttr = (tas != null ? tas.getTransactionAttribute(method, targetClass) : null);
final PlatformTransactionManager tm = determineTransactionManager(txAttr);
final String joinpointIdentification = methodIdentification(method, targetClass, txAttr);

if (txAttr == null || !(tm instanceof CallbackPreferringPlatformTransactionManager)) {
    // 3. 获取TransactionInfo,包含了tm和TransactionStatus
    TransactionInfo txInfo = createTransactionIfNecessary(tm, txAttr, joinpointIdentification);
    Object retVal = null;
    try {
         // 4.执行目标方法    
         retVal = invocation.proceedWithInvocation();
    } catch (Throwable ex) {
        //5.出现异常,回滚事务
        completeTransactionAfterThrowing(txInfo, ex);
        throw ex;
    } finally {
        // 6. 清理当前线程的事务相关信息
        cleanupTransactionInfo(txInfo);
    }
   // 7.提交事务
    commitTransactionAfterReturning(txInfo);
    return retVal;
}
复制代码

        TransactionManager保存着当前的数据源连接,对外提供对该数据源的事务提交回滚操作接口,同时实现了事务相关操作的方法。其中DataSource具有的核心方法如下:

  • public commit 提交事务
  • public rollback 回滚事务
  • public getTransaction 获得当前事务状态
  • protected doBegin 开始事务,主要是执行了JDBC的con.setAutoCommit(false)方法。同时处理了很多和数据库连接相关的ThreadLocal变量。
  • protected doSuspend 挂起事务
  • protected doCommit 提交事务
  • protected doRollback 回滚事务
  • protected doGetTransaction() 获取事务信息

3.3 Spring事务传播机制

        Spring事务的传播级别描述的是多个使用了@Transactional注解的方法互相调用时,Spring对事务的处理逻辑,传播级别有:

  • REQUIRED, 如果当前线程已经在一个事务中,则加入该事务,否则新建一个事务。
  • SUPPORT, 如果当前线程已经在一个事务中,则加入该事务,否则不使用事务。
  • MANDATORY(强制的),如果当前线程已经在一个事务中,则加入该事务,否则抛出异常。
  • REQUIRES_NEW,无论如何都会创建一个新的事务,如果当前线程已经在一个事务中,则挂起当前事务,创建一个新的事务。
  • NOT_SUPPORTED,如果当前线程在一个事务中,则挂起事务。
  • NEVER,如果当前线程在一个事务中则抛出异常。
  • NESTED, 执行一个嵌套事务,有点像REQUIRED,但是有些区别,在Mysql中是采用SAVEPOINT来实现的。

        挂起事务,指的是将当前事务的属性如事务名称,隔离级别等属性保存在一个变量中,同时将当前线程中所有和事务相关的ThreadLocal变量设置为从未开启过线程一样。Spring维护着一个当前线程的事务状态,用来判断当前线程是否在一个事务中以及在一个什么样的事务中,挂起事务后,当前线程的事务状态就好像没有事务一样。

4.避坑指南

4.1 事务传播级别

         以下是在业务开发中经常会碰到的一些事务传播级别的情况,特别是在多个事务交织的时候,更是一团乱麻,熟悉传播级别的具体表现是避坑的首要条件。如下是针对与事务之间的REQUIRED/REQUIRED_NEW/NESTED三种传播级别下的不同表现做的测试。

针对REQURED传播级别的测试如下,在外围方法未开启事务的情况下REQUIRED修饰的内部方法会新开启自己的事务,且开启的事务相互独立,互不干扰,在外围方法开启事务的情况下REQUIRED修饰的内部方法会加入到外围方法的事务中,所有REQUIRED修饰的内部方法和外围方法均属于同一事务,只要一个方法回滚,整个事务均回滚。

@Service
public class TestServiceAImpl implements TestServiceA {
    
    /**
     * 外围未开启事务,B和C均在自己的事务中独立运行,
     * 外围方法异常不影响内部插入,B和C的插入都正常
     */
    public void saveApp1() {
        serviceB.saveB_required();
        serviceC.saveC_required();
        throw new RuntimeException("test");
    }
    
    /**
     * 外围未开启事务,B和C均在自己的事务中独立运行,
     * 其中C方法中抛出异常,B正常插入,C回滚
     */
    public void saveApp2() {
        serviceB.saveB_required();
        serviceC.saveC_required_throw_exception();
    }
    
    /**
     * 外围开启事务,B和C都加入到外围事务中,
     * 外围抛异常回滚,则BC内部方法也回滚
     */
    @Transactional(propagation = REQUIRED)
    public void saveApp3() {
        serviceB.saveB_required();
        serviceC.saveC_required();
        throw new RuntimeException("test");
    }
    
    /**
     * 外围开启事务,B和C都加入到外围事务中
     * 当C内部方法抛异常时被外围捕捉,导致整体事务回滚,B也会回滚
     */
    @Transactional(propagation = REQUIRED)
    public void saveApp4() {
        serviceB.saveB_required();
        serviceC.saveC_required_throw_exception();
    }
    
    /**
     * 外围开启事务,B和C都加入到外围事务中,
     * 当C内部方法抛异常时已被捕获,即使外围感知不到,
     * 也会造成B和C回滚
     */
    @Transactional(propagation = REQUIRED)
    public void saveApp5() {
        serviceB.saveB_required();
        try {
            serviceC.saveC_required_throw_exception();
        } catch (Exception ex) {
        
        }
    }
}
复制代码

       针对于REQUIRED_NEW传播级别的测试如下,在外围方法未开启事务的情况下REQUIRES_NEW修饰的内部方法会新开启自己的事务,且开启的事务相互独立,互不干扰,在外围方法开启事务的情况下REQUIRES_NEW修饰的内部方法依然会单独开启独立事务,且与外部方法事务也独立,内部方法之间、内部方法和外部方法事务均相互独立,互不干扰。

@Service
public class TestServiceAImpl implements TestServiceA {
    
    /**
     * 外围未开启事务,B和C均在自己的事务中独立运行
     * 外围方法异常不影响内部插入,B和C的插入都正常
     */
    public void saveApp6() {
        serviceB.saveB_new();
        serviceC.saveC_new();
        throw new RuntimeException("test");
    }
    
    /**
     * 外围未开启事务,B和C均在自己的事务中独立运行,
     * 其中C方法中抛出异常后回滚,B正常插入
     */
    public void saveApp7() {
        serviceB.saveB_new();
        serviceC.saveC_new_throw_exception();
    }
    
    /**
     * 外围开启事务,B_required跟外围事务属于同一个,
     * 外围会受到抛出异常影响造成回滚,则B_required也回滚的,
     * B_new和C_new则是两个新的事务不受影响,正常插入
     */
    @Transactional(propagation = REQUIRED)
    public void saveApp8() {
        serviceB.saveB_required();
        serviceB.saveB_new();
        serviceC.saveC_new();
        throw new RuntimeException("test");
    
    }
    
    /**
     * 外围开启事务,B_required跟外围事务属于同一个,
     * C_new开启新的事务抛了异常后回滚,并的影响到了外围事务造成了B_required的回滚,
     * B_new是新的事务,插入不受影响,
     */
    @Transactional(propagation = REQUIRED)
    public void saveApp9() {
        serviceB.saveB_required();
        serviceB.saveB_new();
        serviceC.saveC_new_throw_exception();
    }
    
    /**
     * 外围开启事务,B_required跟外围事务属于同一个,
     * 虽然C_new开启新的事务但抛了异常,被catch到了,并不会影响到外围事务,
     * 所以B_required和C_new插入正常,C_new_throw_exception回滚
     */
    @Transactional(propagation = REQUIRED)
    public void saveApp10() {
        serviceB.saveB_required();
        serviceC.saveC_new();
        
        try {
            serviceC.saveC_new_throw_exception();
        } catch (Exception ex) {
        
        }
    }
}
复制代码

       针对NESTED传播级别测试如下,在外围方法未开启事务的情况下NESTED和REQUIRED作用相同,修饰的内部方法都会新开启自己的事务,且开启的事务相互独立,互不干扰。在外围方法开启事务的情况下NESTED修饰的内部方法属于外部事务的子事务,外围主事务回滚,子事务一定回滚,而内部子事务可以单独回滚而不影响外围主事务和其他子事务。

@Service
public class TestServiceAImpl implements TestServiceA {
    
    /**
     * 外围未开启事务,B和C均在自己的事务中独立运行,
     * 外围方法异常不影响独立事务,B和C的插入都正常
     */
    public void saveApp11() {
        serviceB.saveB_nest();
        serviceC.saveC_nest();
        throw new RuntimeException("test");
    }
    
    /**
     * 外围开启事务,B和C均在自己的事务中独立运行,
     * B正常插入,C回滚
     */
    public void saveApp12() {
        serviceB.saveB_nest();
        serviceC.saveC_nest_throw_exception();
    }
    
    /**
     * 外围开启事务,内部事务均为外围事务的子事务,
     * 外围方法回滚,子事务也要回滚,B和C均插入失败
     */
    @Transactional(propagation = REQUIRED)
    public void saveApp13() {
        serviceB.saveB_nest();
        serviceC.saveC_nest();
        throw new RuntimeException("test");
    }
    
    /**
     * 外围开启事务,内部事务为子事务,
     * 内部事务抛出异常后,外围方法感知导致整体事务回滚,B和C均插入失败
     */
    @Transactional(propagation = REQUIRED)
    public void saveApp14() {
        serviceB.saveB_nest();
        serviceC.saveC_nest_throw_exception();
    }
    
    /**
     * 外围开启事务,内部事务为子事务,
     * C插入抛出异常,则单独对C子事务回滚,
     * 但异常已被捕获,B子事务则插入成功
     */
    @Transactional(propagation = REQUIRED)
    public void saveApp15() {
        serviceB.saveB_nest();
        try {
            serviceC.saveC_nest_throw_exception();
        } catch (Exception ex) {
        
        }
    }
}
复制代码

     总结下,NESTED、REQUIRED、REQUIRED_NEW三种事务隔离级别的相关特性表现如下:

  • NESTED和REQUIRED修饰的内部方法都属于外围方法事务,如果外围方法抛出异常,这两种方法的事务都会被回滚。
  • REQUIRED加入外围方法事务,所以和外围事务同属于一个事务,一旦REQUIRED事务抛出异常被回滚,外围方法事务也将被回滚。
  • NESTED是外围方法的子事务,有单独的保存点,所以NESTED方法抛出异常被回滚,不会影响到外围方法的事务。
  • NESTED和REQUIRES_NEW都可以做到内部方法事务回滚而不影响外围方法事务。但是因为NESTED是嵌套事务,所以外围方法回滚之后,作为外围方法事务的子事务也被回滚。
  • 而REQUIRES_NEW是通过开启新的事务实现的,内部事务和外围事务是两个事务,外围事务回滚不会影响内部事务。

4.2 事务有效性

         在业务开发中会经常遇到事务失效的情况,其中要考虑到数据库、aop技术、spring的@Transactional注解、应用的数据源配置等功能特性,以下是总结的一些踩坑经验:

  • 在MySQL数据库中Innodb存储引擎才支持事务,而MyISAM存储引擎是不支持事务的;
  • 没有指定rollbackFor参数,默认捕捉抛出来的Throwable类型包括了Exception和Error两个子类型,
  • 在有多个数据源的时候,比如做分库分表/读写分离的时候会经常遇到一个应用里面配置了多个数据源,没有指定transactionManager参数,默认的transactionManager可能就不是
  • 如果AOP使用了JDK动态代理,对象内部方法互相调用不会被Spring的AOP拦截,@Transactional注解无效。
  • 如果AOP使用了CGLIB代理,事务方法或者类不是public,无法被外部包访问到,或者是final无法继承,@Transactional注解无效。

4.3 事务的粒度控制

       在平时业务开发中,经常遇到在一个很大的方法上加了@Transactional注解,从调用方法开始就一直打开并占用了一条连接资源,在这个大方法中调用了很多外部接口和逻辑,里面会消耗大量的时间,等到这些都做完了才会去执行最后一步的入库操作,这样极大地影响了数据库的吞吐量等性能表现,通常表现为数据库连接不够用。可通过抓MySQL数据库服务端与应用端的通信包分析可得到。整个事务范围过大,可视情况而定将事务范围适当所小,到使用的时候才去获取连接资源,这样使用更加合理,我们团队中就有事务范围过大的血的教训,

@Transactional
public void big_transaction() {
    //调用外部接口获取用户列表
    List<User> users = findUser(classUid);
    //调用外部接口获取班级信息
    Map<String, String> classNameMap = findName(classUid);
    //检查用户列表是否正确
    check(users);    
    ...... 
    //真正执行sql入库操作
    saveUser(users);
}
复制代码

4.4 与不同中间件的事务同步

在实际业务开发中,我们经常会有一些比较重要的操作需要在DB事务提交之后在再执行操作的, 比如发送MQ或清理缓存。举个实际的业务场景:更新用户信息,与之相关的是两个接口

  • 查询用户信息:从缓存里面查询用户信息,如果查不到,则从DB加载,再放到缓存中
  • 更新用户信息:更新db信息、删除缓存

比如某个事务内,代码顺序是 A - B - C - D,我们指定B和D是在事务提交之后再执行的。

  • 事务提交成功,执行顺序是:A - C - 事务提交 - B - D
  • 事务回滚,执行顺序是:A - C - 事务回滚

      我们团队对此情况下进行了事务的增强,在同一个事务中既更新了数据库记录,还清空了缓存,发送了消息。如果不使用增强组件的话,整个事务过程会非常长,而且会受到缓存连接处理与发送消息处理等影响,影响了数据库的吞吐量。在使用组件之后就会控制事务的提交和回滚。

@Service
@InvokeAfterCommitted
public class UserServiceAfterImpl {
    /**
     * 作息相关更新MQ
     */
    public void after() {
        //清除缓存
        clearCache();
        //发送mq
        sendMq();
    }
}

@Service
public clsss ScheduleServiceImpl {

    @Autowired
    privated UserServiceAfterImpl afterInvokerImpl;
    @Transactional
    @Ovveride   
    public void updateSchedule() {
        //执行update sql
        scheduleManager.udpate();
        //入库后发送更新MQ,删除缓存
        afterInvokerImpl.after();
}
复制代码

    如下所示,是实现上述功能的部分代码。

public class CustomTransactionManager extends DataSourceTransactionManager {

    @Override
    protected DefaultTransactionStatus newTransactionStatus(TransactionDefinition definition, Object transaction, boolean newTransaction, boolean newSynchronization, boolean debug, Object suspendedResources) {
        DefaultTransactionStatus transactionStatus = 
            super.newTransactionStatus(definition, transaction, 
                newTransaction, newSynchronization, debug, suspendedResources);
        return DefaultTransactionStatusProxy.proxyTransaction(transactionStatus, 
            transaction, newTransaction, transactionStatus.isNewSynchronization(),
                definition.isReadOnly(), debug, suspendedResources);
    }

    @Override
    protected void doBegin(Object transaction, TransactionDefinition definition) {
        super.doBegin(transaction, definition);
        InvokerHolder.init();
    }

    @Override
    protected void doResume(Object transaction, Object suspendedResources) {
        super.doResume(transaction, suspendedResources);
        Object messageTxHolderObj = TransactionSynchronizationManager.unbindResource(suspendedResources);
        if (messageTxHolderObj instanceof InvokerHolder) {
            InvokerHolder.resume((InvokerHolder) messageTxHolderObj);
        } else {
            ...
        }
    }

    @Override
    protected void doCommit(DefaultTransactionStatus status) {
        super.doCommit(status);
        List<Invoker> logicList = InvokerHolder.clear();
        for (Invoker logic : logicList) {
            try {
                logic.doInvoke();
            } catch (Exception e) {
                ...
            }
        }

    }

    @Override
    protected void doRollback(DefaultTransactionStatus status) {
        super.doRollback(status);
        List<Invoker> logicList = InvokerHolder.clear();
        for (Invoker logic : logicList) {
            ...
        }
    }
}
复制代码

5.总结

         本篇主要围绕日常业务开发中事务的使用展开,首先介绍了事务的基本知识,并介绍了MySQL和Spring实现事务的一些技术原理,并总结了我们团队日常开发中发现到的一些问题与相应的实践,希望能给大家带来一些收获!

参考文献

cloud.tencent.com/developer/a… mysql事务实现原理

www.cnblogs.com/kismetv/p/1…

blog.csdn.net/weixin\_443…

segmentfault.com/a/119000001… spring事务传播行为详解

blog.csdn.net/qq\_3852657… spring传播行为

-----------------------------------------------------------------------------------------

如果觉得这个文章写得还不错,点个赞呗!

求点赞👍 求关注❤️ 求分享👥

文章分类
后端
文章标签