事务重要知识及实现原理

本文详细介绍了数据库事务的基础知识,包括事务的作用、隔离级别和实现原理,如加锁、MVCC和回滚机制。此外,还探讨了Spring事务管理,特别是传播行为和@Translational注解的使用限制及解决办法。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

Table of Contents

事务基础知识

数据库事务的定义

事务的隔离级别

事务是如何在技术层面实现的?

问题:如何对数据库操作加锁?

问题:MVCC在数据库事务当中的如何应用?

问题:事务操作回滚是如何实现的?

问题:数据库如何实现原子性?

Spring事务

事务的传播

Spring里事务的传播

注解@Translational在什么情况下会失效?为什么?

解决办法


事务基础知识

数据库事务的定义

概念上的定义:访问并可能更新数据库中各种数据项的一个程序执行单元

数据库里的定义:为单个逻辑单元执行的一系列操作,即一组原子性的sql查询,或者说一个独立的工作单元。

共同点:这些操作/方法要么全部成功,要么全部失败。

一个事务处理的过程,必须符合SO/IEC所制定的ACID原则。ACID是原子性(atomicity)、一致性(consistency)、隔离性(isolation)、持久性(durability)的缩写,这四种状态的意思是:

原子性一个事务必须视为一个不可分割的最小工作单元,整个事务中的所有操作要么全部提交成功,要么全部失败回滚,对于一个事务来说,不可能只执行其中一部分的操作,这就是事务的原子性。
一致性数据库总是从一个一致性的状态转换到另一个一致性的状态。
隔离性通常来说,一个事务所做的修改在最终提交之前,对于其他事务时不可见的。
持久性一旦事务提交,则其所有的修改会永久保存到数据库中。

事务有什么作用

事务机制它保证了用户的每一次操作都是可靠的,即便出现了异常的访问情况,也不至于破坏后台数据的完整性。最典型的例子莫过于银行取款,通常ATM都可以正常为客户服务,但是也难免遇到操作过程中及其突然出故障的情况,此时,事务就必须确保出故障前对账户的操作不生效,就像用户刚才完全没有使用过ATM机一样,以保证用户和银行的利益都不受损失

事务的隔离级别

要搞清楚事务的隔离级别首先要明白,在数据并发操作中,可能存在的不确定情况,也就是可能出现的问题

  1. 更新丢失。后一条记录更新冲掉了前一条
  2. 脏读。无效数据的读出。a线程修改某值,b线程读取该值,a线程回滚
  3. 不可重复读。一个事务范围内,两个相同查询返回了不同的数据值。这是由于在查询间隔,被另一个事务修改并提交了。
  4. 幻读。是指当事务不是独立执行时发生的一种现象,例如第一个事务对一个表中的数据进行了修改,这种修改涉及到表中的全部数据行。同时,第二个事务也修改这个表中的数据,这种修改是向表中插入一行新数据。那么,以后就会发生操作第一个事务的用户发现表中还有没有修改的数据行,就好象发生了幻觉一样。

为解决上述问题,sql规范定义了4个事务隔离级别。这四个级别可以逐个解决脏读、不可重复读、幻读这几类问题

√: 可能出现    ×: 不会出现

 脏读不可重复读幻读
Read uncommitted
Read committed×
Repeatable read××
Serializable×××

事务隔离级别是通过锁机制实现的。(重要)

事务是如何在技术层面实现的?

数据库事务的实现是一个非常大的话题,实现方式多种多样。关于这个话题写一本书也不为过,并且也确实有相关的书籍,要了解这个问题最好的办法是买一本书。在本篇博客种,只选取最为重要的信息,或者只提供一个思路。

我们已经知道了,数据库事务的定义是指为单个逻辑工作单元执行的一系列操作。除非事务单元内的所有操作都成功,否则不会更新数据库资源。数据库事务这些特性是通过对数据库操作使用读写锁,结合MVCC(Multi-Version Concurrency Control)原理,结合操作回滚实现的,让我们依次分析下面几个问题

问题:如何对数据库操作加锁?

数据库需要锁的原因是为了处理并发问题,数据库是一个多用户共享的资源。数据库锁有很多种,按锁的粒度可以分为行级锁,表级锁,整个db的锁,按对用户的操作而影响可以分为悲观锁和乐观锁,从数据库设计角度可以分为排他锁、共享锁。

本篇博客不会去讲数据库加锁如何在底层实现的,我们只需知道的是,我们可以通过对数据库进行加锁实现并发控制即可。下面将简要介绍一下从数据库设计角度出发的这几种锁:

排他锁Exclusive Lock。排他锁又叫写锁,如果事务T对A加上排它锁,则其他事务都不能对A加任何类型的锁。获准排它锁的事务既能读数据,又能写数据。只要执行写操作(INSERT,UPDATE和DELETE),就会在资源上放置排他锁。一次只能在资源上放置一个排他锁。获得排他锁的第一个用户将继续拥有该资源的唯一所有权,并且没有其他用户可以获得该资源的排他锁.

共享锁Shared lock。共享锁又叫读锁,如果事务T对A加上共享锁,则其他事务只能对A再加共享锁,不能加其他锁。共享锁的事务只能读数据,不能写数据。只要执行读操作,就会将共享锁置于资源上。可以在资源上同时设置多个共享锁。也就是会阻塞写操作,但不会阻塞读操作。

问题:MVCC在数据库事务当中的如何应用?

MVCC即多版本并发控制的意思,它也是一种数据库并发处理的思想。在并发控制理论中,有两种方法可以处理并发冲突:

  • 可以通过采用悲观锁定机制(读/写锁)来进行处理
  • 可以允许发生冲突,但需要使用乐观锁定机制(MVCC)检测它们

什么是悲观锁?

悲观锁在操作时很悲观,生怕数据被其他人更新掉,我就先将其先锁住,让别人用不了,我操作完成后再释放掉。
悲观锁需要数据库级别上的的实现,程序中是做不到的,如果在长事务环境中,数据会一直被锁住,导致并发性能大大地降低。

 

什么是乐观锁?

乐观锁思想认为,数据一般是不会造成冲突的。只有在提交数据的时候,才会对数据的冲突进行检测。当发现冲突的时候,返回错误的信息,让用户决定如何去做。
乐观锁不会使用数据库提供的锁机制,一般的实现方式就是记录数据版本。

乐观锁的实现不需要借助于数据库锁机制,只要就是两个步骤:冲突检测和数据更新,其中一种典型的是实现方法就是CAS(Compare and Swap)

关于乐观锁的实现机制可以参考博客:[java] Spring Data JPA注解@Version乐观锁是如何实现的

 

因此,MVCC可以看做是数据库锁的一种补充。它的目标是视图将数据库的锁定程度降到最低,以此来提升数据库操作的效率。期望实现以下的效果

  • 读不阻塞写操作
  • 写操作不阻塞读操作

在实现这样的效果之后,唯一还会发生操作冲突的就只剩下了一种情况,两个操作同时尝试写一条数据。为了实现读/写器不锁定,并发控制机制必须在同一记录的多个版本上运行,因此这种机制称为多版本并发控制(MVCC)。由于没有标准的MVCC实现,每个数据库采用略有不同的方法,但是其本质思想是一样的。以postgreSql为例:

postgreSql为每行存入的数据加入两个额外的列:

  • t_min  - 定义插入记录的事务ID
  • t_max  - 定义删除行的事务ID

postgreSql提供了专门的方法用于获取这两个id,在进行更新,删除,插入操作时,只需要判断相应的id即可,而无需加锁。具体的实现不在这里展开。

问题:事务操作回滚是如何实现的?

首先要明确的是回滚必须按照顺序进行,否则会出现不符合预期的情况。

这个很容易理解,如果两条update语句按照不同的顺序执行,那么其结果肯定不一致,同理,如果回滚时,不按照执行顺序的反序执行,那么回滚的结果也肯定不一致。所以我们必须让回滚本身按照执行顺序的反序执行。一般而言,实现方式就是把数据按照顺序记录到文件里,然后将这个文件按照FILO(先入后出)的方式读取出来,这样可以保证按照执行序列的反序来回滚了

除了记录中间状态的数据外,回滚还要考虑的一个重要因素:并发。

如果数据库系统将所有针对它的读写请求都按照顺序执行,那么完全不用考虑并发因素,回滚也可以很简单地实现。但系统需要更高效地利用CPU和各种物理资源,且很多数据在物理上就是需要被共享的。所以,处理并发和同步就成了一个数据库系统必须面对的实际问题。

如果进行不恰当的并发处理,那么多线程执行回滚操作会导致最终数据出现错乱,比如A进程优先进行了两个操作并记录了回滚段,B进程紧接着进行了一个操作并记录了回滚段,这时候A进程要回滚,那么他用自己记录的回滚中间状态恢复了数据,然而B也要进行回滚,就会发现数据本身已经无法回滚到最初的状态去了。

如果要切实的解决这个问题,我们只能把每个事务所影响的数据全部都加上锁,这样,在这个事务没有完成之前,其它进程不能进入到这些加锁的数据中对这个数据进行修改。从而保证了尽可能细颗粒度的并发控制,同时也解决了回滚中会出现的回滚时序冲突问题。

当然这种方式的代价就是回滚隐含了对事务锁的要求,而事务只要加锁,就存在对加锁数据的读写请求,就需要等待,进而降低并发性能。

问题:数据库如何实现原子性?

实现原子性的核心是要记录下每一个变更的中间状态或者是记录变更的具体过程。这样我们就可以在发现问题时,直接把老数据替换回去,从而实现回滚操作,保证原子性。

以转账的实例进行细节分析,在执行A之前,数据库中的数据大概是这样(version1):

执行A操作:检查Bob账户是否有100块,执行的SQL:select money from T where pk=1,一个简单的查询操作并不涉及对数据的修改,因此不会记录变更数据。

执行B操作:Bob账户减去100块,执行SQL:update T set money=money-100 where pk=1,执行完这个操作,数据库应该是这样(version2)。

注:除了当前运行事务的这个进程,其它的进程只要不是在读未提交状态,完全看不见这些中间状态。

接着执行C操作,突然发现Smith的账户出现未知异常,导致加款的操作无法进行,那么整个事务单元执行失败,需要回滚前面的操作,由于A操作不涉及数据的修改,因此只需要回滚B操作。要回滚B操作,就需要知道PK=1这一行在version1版本时的数据:money=100,在回滚时用version1版本的记录替换当前版本(version2)据即可。

总结:CAS结合version乐观锁机制实现

 

Spring事务

spring的事务本质上是基于数据库事务实现的,没有数据库的事务支持,spring是无法提供事务功能的。对于纯JDBC操作数据库,可以按照以下步骤进行获取事务功能:

  1. 获取连接 Connection con = DriverManager.getConnection()

  2. 开启事务con.setAutoCommit(true/false);

  3. 执行CRUD操作

  4. 提交事务/回滚事务 con.commit() / con.rollback();

  5. 关闭连接 conn.close();

那么Spring是如何在我们书写的 CRUD 之前和之后开启事务和关闭事务的呢?解决这个问题,也就可以从整体上理解Spring的事务管理实现原理了。当我们在一个方法前面加上注解@Translational之后,spring在启动的时候会去解析生成相关的bean,即对象,这时候也会解析拥有相应@Translational注解方法,并且会通过aop为这些方法生成代理,并根据@Transaction的相关参数进行相关配置注入,这样就在代理中为我们把相关的事务处理掉了(开启正常提交事务,异常回滚事务).

即spring是通过aop为对于方法创建代理类以及代理方法,然后在里面通过在调用方法前后引入事务(datbase 支持的事务),从而实现了事务功能。因此spring事务只是进行了spring的一些使用方式上的包装,本质是借助数据库事务实现的。

事务的传播

所谓事务传播行为就是多个事务方法相互调用时,事务如何在这些方法间传播

代码如下:

Class A{
    B b = new B();
    public void methodA(){
        b.methodB();
        //doSomething
     }
 
}
Class B{
     @Transaction(Propagation=XXX)
     public void methodB(){
        //doSomething
     }
}

一定要注意,方法A和方法B一定是在不同的类中的,否则注解会失效,在最后一节我们会单独讲这个问题。代码中methodA()方法嵌套调用了methodB()方法,methodB()的事务传播行为由@Transaction(Propagation=XXX)设置决定。这里需要注意的是methodA()并没有开启事务,某一个事务传播行为修饰的方法并不是必须要在开启事务的外围方法中调用。

另外,这里A方法调用了B方法,称为A方法执行过程中传播到了B。

Spring里事务的传播

spring中定义了七种事务的传播行为:

public enum Propagation {
    REQUIRED(0),
    SUPPORTS(1),
    MANDATORY(2),
    REQUIRES_NEW(3),
    NOT_SUPPORTED(4),
    NEVER(5),
    NESTED(6);
}

对应的解释也非常容易理解

REQUIRED如果当前没有事务,就新建一个事务,如果已经存在一个事务中,加入到这个事务中。(方法B看到自己已经运行在 方法A的事务内部,就不再起新的事务,直接加入方法A)此用法最多
SUPPORTS当前有事务则加入,如果当前没有事务,就以非事务方式执行。(方法B看到自己已经运行在 方法A的事务内部,就不再起新的事务,直接加入方法A)
MANDATORY永远加入当前的事务,如果当前没有事务,就抛出异常
REQUIRES_NEW永远新建事务,如果当前存在事务,把当前事务挂起。(方法A所在的事务就会挂起,方法B会起一个新的事务,等待方法B的事务完成以后,方法A才继续执行)
NOT_SUPPORTED以非事务方式执行,如果当前存在事务,就把当前事务挂起。(方法A所在的事务就会挂起,而方法B以非事务的状态运行完,再继续方法A的事务)
NEVER以非事务方式执行,如果当前存在事务,则抛出异常
NESTED如果当前存在事务,则在嵌套事务内执行,所谓嵌套事务可理解为当前事务的一个子事务。如果当前没有事务,执行REQUIRED。 如果外部事务commit, 嵌套事务也会被commit, 这个规则同样适用于roll back
  

其中标红的几种是最常用的。

声明式事务和编程式事务

Spring的事务管理从实现方式和用法上可以分成两类:

  1. 编程式事务管理(手动编写代码完成事务管理)。使用TransactionTemplate或者直接使用底层的PlatformTransactionManager。对于编程式事务管理,spring推荐使用TransactionTemplate。
  2. 声明式事务管理(不需要手动编写代码,需要配置)。声明式事务是建立在AOP之上的。其本质是对方法前后进行拦截,然后在目标方法开始之前创建或者加入一个事务,在执行完目标方法之后根据执行情况提交或者回滚事务。声明式事务最大的优点就是不需要通过编程的方式管理事务,这样就不需要在业务逻辑代码中掺杂事务管理的代码,只需在配置文件中做相关的事务规则声明(或通过基于@Transactional注解的方式),便可以将事务规则应用到业务逻辑中

注解@Translational在什么情况下会失效?为什么?

@Transactional注解只对代理类时的public方法有效,被protected、private、package-visible修饰的方法使用@Transactional注解无效,对这类方法使用事务注解,推荐使用AspectJ进行事务管理。

除此之外,更重要的是:通过spring注入对象方式调用方法时,对于同一个类中的两个方法A,B. 当调用的第一个方法是没有事务注解的methodA时。此时若methodA调用同一个类中的methodB,即便methodB方法上加了事务注解,methodB中的事务仍然不生效。

下列代码别展示了上述场景:

public class UserService{
    public void A(){
         //在同一个类中的方法,再调用AOP注解(@Transactional注解也是AOP注解)的方法,会使AOP注解失效。此时如果B()中有插入数据库操作失败抛出异常,B动作不会回滚,数据仍旧存入数据库
            B();
    }
 
    @Transactional
    public void B(){
       
    }
}

这个现象的根本原因是: spring的注解本质上也是通过aop实现的。aop的实现有两种:jdk动态代理和cglib动态代理,无论是哪种,最后都会生成一个代理类类执行被代理对象的被代理方法。然而,在一个方法内部调用另一个方法,是直接通过this关键字调用的,并不会走到代理去调用。同一个对象中的方法调用,不会调用spring代理方法,而是直接掉用原始方法。因此,不只是@Translational注解在这种情况下会失效,而是所有的基于Aop实现的注解,在这样的情况都会失效

然而若只有一个方法,这个方法有事务注解,不调用其它方法,那么这个方法的事务注解会生效。

解决办法

通过AopContext.currentProxy获取当前代理对象,通过代理对象调用方法。最好的方法是避免在方法内部调用。可开启cglib动态代理并通过xml配置。如下:

<aop:aspectj-autoproxy proxy-target-class="true" expose-proxy="true"/>

也可以通过更改代码,通过实现ApplicationContext获取代理对象去调用另一个方法。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值