漫谈数据库事务

438 阅读17分钟

认识事务

事务的概念

数据库事务是访问并可能更新数据库 中各种数据项的一个程序执行单元

事务的组成

一个数据库事务通常包含对数据库进行读或写的一个操作序列

典型的数据库事务语句

BEGIN TRANSACTION
SQL1
SQL2
COMMIT/ROLLBACK

场景举例

  • 有可能出现以下问题

    • 转账操作的第一步执行成功,A账户成功扣除100元.但是第二步执行失败或者未执行便发生系统崩溃.

    导致B账户并没有相应增加100元

    • 转账操作刚完成就发生系统崩溃,系统重启恢复时丢失了崩溃前的转账记录

    • 本次转账的同时又有另一个用户转账给B账户,由于同时对B账户进行操作,导致B账户金额出现异常

  • 解决方式

    对于上面的转账例子,可以将相关操作组成一个事务

    BEGIN TRANSACTION
    A账户扣除100元
    B账户增加100COMMIT
    
    • 当数据库操作失败或者系统出现崩溃,系统能够以事务为边界进行恢复,不会出现A账户金额减少而B商户未增加的情况
    • 当有多个用户同时操作数据库时,数据库能够以事务为单位进行并发控制,使多个用户对B账户的转账操作进行隔离

总结

事务使系统能够更方便的进行故障恢复以及并发控制,从而保证数据库状态的一致性

事务的特性

四大特性

简称 ACID

原子,一致,持久都比较好理解.本文将重点讲解隔离,因为这是事务并发时可能会造成的一些数据异常,而这些异常可以通过设置不同的隔离级别进行解决

事务并发异常分析与隔离级别

事务并发异常(事务的传播行为)

脏读

A事务读取了B事务尚未提交的更改数据,并在这个读取的脏数据上进行操作.如果这时B事务恰巧进行了回滚事务,那么A事务读取的事务是不被承认的.

时间 转账事务A 取款事务B
T1 开始事务
T2 开始事务
T3 查询账户余额为1000元
T4 取出500元,把余额改为500元
T5 查询账户余额为500元(脏读)
T6 撤销事务,余额恢复为1000元
T7 汇入100元,把余额改为600元
T8 提交事务

不可重复读

A事务读取到了B事务已经提交的更改数据,在同个时间内,两次查询的结果不一致

时间 取款事务A 转账事务B
T1 开始事务
T2 开始事务
T3 查询余额为1000元
T4 查询余额为1000元
T5 取出100元,把余额改为900元
T6 提交事务
T7 查询余额为900元(和T4读取的不一致)

幻读

A事务读取到B事务提交的新增数据,幻象读一般发生在数据统计事务中

时间 统计金额事务A 转账事务B
T1 开始事务
T2 开始事务
T3 统计总存款为1000元
T4 新增一账户,存款为100元
T5 提交事务
T6 再次统计总存款为1100元(幻象读)

[^幻象读主要针对于一批数据 而不像不可重复读针对的是一行数据 问题点也在于两次读取的数据不一致]:

丢失更新

如果多个线程操作,基于同一个查询结构对表中的记录进行修改,那么后修改的记录将会覆盖前面修改的记录,前面的修改记录就丢失掉了.丢失更新体现在以下两点

第一类丢失更新:回滚丢失

时间 取款事务A 转账事务B
T1 开始事务
T2 开始事务
T3 查询账户余额为1000元
T4 查询账户余额为1000元
T5 汇入100元把余额改为1100元
T6 提交事务
T7 取出100元把余额改为900元
T8 撤销事务
T9 余额恢复为1000元(丢失更新)

第二类丢失更新:覆盖更新

时间 取款事务A 转账事务B
T1 开始事务
T2 开始事务
T3 查询账户余额为1000元
T4 查询账户余额为1000元
T5 汇入100元把余额改为1100元
T6 提交事务
T7 取出100元把余额改为900元
T8 提交事务

解决办法

为了避免数据混乱,我们可以通过锁的机制保证多线程操作的顺序

悲观锁机制

假定这样的问题是高概率的,最好一开始就锁住,免得更新老是出错

共享锁方式(其他线程能看不能改):

select * from account lock in share mode; 

排他锁方式(其他线程不能看也不能改):

select * from account for update; 

乐观锁机制

假定这样的问题是小概率的,最后一步做更新的时候再锁住,免得锁的时间太长影响其他线程的有关操作

实现思路

在表中新增一个timestamp字段,且设置只要该表进行插入或修改操作时都会更新该字段为最新时间戳,在修改数据时通过检查timestamp字段是否发生改变判断出当前更新基于的查询是否已经是过时版本

select timestamp from account where id=1; // 获取最新时间戳为20200329161851
update account set money=money-100 where id=1 and timestamp=20200329161851;
//如果时间戳不相等将不会更新成功,如果时间戳相等将更新成功且将timestamp字段设置为最新时间戳

锁扩展:

如果表使用的是mysql中的innodb存储引擎,可以通过利用innodb行锁的特性将该行数据锁住进行相应的判断后再决定是否做更新操作

update account set money=money-100 where id=1 and money>=100;
// 通过id主键索引锁住数据后获取money字段最新的值且判断大于等于100,计算money的值再更新money字段
// 如果同时存在多线程操作,将会排队等待一个个进行处理

隔离级别

隔离级别是什么?

事务并发操作同一批数据的时候所导致的问题可以通过设置隔离级别来解决

隔离级别的分类

  • 读未提交(READ UNCOMMITTED)
  • 读已提交(READ COMMITTED) --- Oracle默认级别
  • 可重复读(REPEATABLE READ) --- Mysql 默认级别
  • 串行化(SERIALIZABLE)

隔离级别和事务并发异常的关系

隔离级别 脏读 不可重复读 幻读
读未提交
读已提交 ×
可重复读 × ×
串行化 × × ×

事务隔离级别实战

准备工作

数据库及工具

mysql版本 8.0.19

Navicat for MySQL

涉及相关指令

查看数据库版本

select version(); 

查看当前会话的隔离级别

select @@session.tx_isolation;

设置当前会话的隔离级别

set @@session.tx_isolation = '隔离级别';

隔离级别

READ-UNCOMMITTED -- 读未提交
READ-COMMITTED   -- 读已提交
REPEATABLE-READ  -- 可重复读
SERIALIZABLE     -- 串行化

事务基本语法

start transaction; --开启事务
commit/rollback;   --提交或回滚事务

数据库准备脚本

-- 账户金额表结构
CREATE TABLE `account` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主键',
  `name` varchar(20) NOT NULL COMMENT '姓名',
  `money` decimal(10,2) NOT NULL COMMENT '账户余额',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账户金额表';

-- 初始化数据
INSERT INTO account(name,money) VALUES('张三',1000);
INSERT INTO account(name,money) VALUES('李四',1000);
INSERT INTO account(name,money) VALUES('王五',1000);

Navicat 按F6 打开命令列界面 对应的是一个session会话,打开两个会话 模拟并发场景

设置当前会话的隔离级别为当前需要实战的隔离级别

对于不同的隔离级别可能会发生的传播行为(脏读,不可重复读,幻读)根据场景 进行验证

读未提交

设置session隔离级别为读未提交

1.设置两个session会话的隔离级别为读未提交

set @@session.tx_isolation = 'READ-UNCOMMITTED';

2.查看两个session会话的隔离级别确认为读未提交

select @@session.tx_isolation; --查询结果为READ-UNCOMMITTED

脏读-案例

1.session1 开启事务且查看当前account表中id为1的账户余额

start transaction; 
select money from account where id=1; --查询结果为1000

2.session2 开启事务且查看当前account表中id为1的账户余额

start transaction; 
select money from account where id=1; --查询结果为1000

3.session1 修改id为1的账户余额为500不提交事务

update account set money = 500 where id = 1; --设置余额为500,此时还没提交事务

4.session2 查看id为1的账户余额

select money from account where id=1; --查询结果为500,读取到了session1未提交事务的脏数据

5.session1 回滚事务

rollback;

session2读取到了session1未提交的数据,产生了脏读

不可重复读-案例

1.session1 开启事务且查看当前account表中id为1的账户余额

start transaction; 
select money from account where id=1; --查询结果为1000

2.session2 开启事务且查看当前account表中id为1的账户余额

start transaction; 
select money from account where id=1; --查询结果为1000

3.session1 修改id为1的账户余额为500且提交事务

update account set money = 500 where id = 1; --设置余额为500
commit; --提交事务

4.session2 再次查看id为1的账户余额

select money from account where id=1; --查询结果为500,两次查询读取到不一样的结果

session2读取了两次id为1的数据却得到两个不一样的结果,产生了不可重复读

幻读-案例

1.session1 开启事务且查看当前account表中所有用户的金额总价

start transaction; 
select sum(money) from account; --查询结果为2500

2.session2 开启事务新增一条数据且提交事务

start transaction; 
INSERT INTO account(name,money) VALUES('赵六',1000);
commit;

3.session1再次查询account表中所有用户的金额总价

select sum(money) from account; --查询结果为3500

session1统计查询得到两次不一样的总金额,产生了幻读

读已提交

设置session隔离级别为读已提交

1.设置两个session会话的隔离级别为读已提交

set @@session.tx_isolation = 'READ-COMMITTED';

2.查看两个session会话的隔离级别确认为读已提交

select @@session.tx_isolation; --查询结果为READ-COMMITTED

脏读-案例

1.session1 开启事务且查看当前account表中id为1的账户余额

start transaction; 
select money from account where id=1; --查询结果为500

2.session2 开启事务且查看当前account表中id为1的账户余额

start transaction; 
select money from account where id=1; --查询结果为500

3.session1 修改id为1的账户余额为1000不提交事务

update account set money = 1000 where id = 1; --设置余额为1000,此时还没提交事务

4.session2 查看id为1的账户余额

select money from account where id=1; --查询结果为500,没有读取到session1没提交事务的数据

5.session1 提交事务

commit;

session2没有读取到了session1未提交的数据,没有产生脏读

不可重复读-案例

1.session1 开启事务且查看当前account表中id为1的账户余额

start transaction; 
select money from account where id=1; --查询结果为1000

2.session2 开启事务且查看当前account表中id为1的账户余额

start transaction; 
select money from account where id=1; --查询结果为1000

3.session1 修改id为1的账户余额为500且提交事务

update account set money = 500 where id = 1; --设置余额为500
commit; --提交事务

4.session2 再次查看id为1的账户余额

select money from account where id=1; --查询结果为500,两次查询读取到不一样的结果

session2读取了两次id为1的数据却得到两个不一样的结果,产生了不可重复读

幻读-案例

1.session1 开启事务且查看当前account表中所有用户的金额总价

start transaction; 
select sum(money) from account; --查询结果为3500

2.session2 开启事务新增一条数据且提交事务

start transaction; 
INSERT INTO account(name,money) VALUES('赵七',1000);
commit;

3.session1再次查询account表中所有用户的金额总价

select sum(money) from account; --查询结果为4500

session1统计查询得到两次不一样的总金额,产生了幻读

可重复读

设置session隔离级别为可重复读

1.设置两个session会话的隔离级别为可重复读

set @@session.tx_isolation = 'REPEATABLE-READ';

2.查看两个session会话的隔离级别确认为可重复读

select @@session.tx_isolation; --查询结果为REPEATABLE-READ

脏读-案例

1.session1 开启事务且查看当前account表中id为1的账户余额

start transaction; 
select money from account where id=1; --查询结果为500

2.session2 开启事务且查看当前account表中id为1的账户余额

start transaction; 
select money from account where id=1; --查询结果为500

3.session1 修改id为1的账户余额为1000不提交事务

update account set money = 1000 where id = 1; --设置余额为1000,此时还没提交事务

4.session2 查看id为1的账户余额

select money from account where id=1; --查询结果为500,没有读取到session1没提交事务的数据

5.session1 提交事务

commit;

session2没有读取到了session1未提交的数据,没有产生脏读

不可重复读-案例

1.session1 开启事务且查看当前account表中id为1的账户余额

start transaction; 
select money from account where id=1; --查询结果为1000

2.session2 开启事务且查看当前account表中id为1的账户余额

start transaction; 
select money from account where id=1; --查询结果为1000

3.session1 修改id为1的账户余额为500且提交事务

update account set money = 500 where id = 1; --设置余额为500
commit; --提交事务

4.session2 再次查看id为1的账户余额

select money from account where id=1; --查询结果为1000,两次查询读取到一样的结果

session2读取了两次id为1的数据得到两个不一样的结果,没有产生不可重复读

幻读-案例

1.session1 开启事务且查看当前account表中所有用户的金额总价

start transaction; 
select sum(money) from account; --查询结果为4500

2.session2 开启事务新增一条数据且提交事务

start transaction; 
INSERT INTO account(name,money) VALUES('赵八',1000);
commit;

3.session1再次查询account表中所有用户的金额总价

select sum(money) from account; --查询结果为5500

session1统计查询得到两次不一样的总金额,产生了幻读

串行化-案例

设置session隔离级别为串行化

1.设置两个session会话的隔离级别为串行化

set @@session.tx_isolation = 'SERIALIZABLE';

2.查看两个session会话的隔离级别确认为串行化

select @@session.tx_isolation; --查询结果为SERIALIZABLE

脏读-案例

1.session1 开启事务且查看当前account表中id为1的账户余额

start transaction; 
select money from account where id=1; --查询结果为500

2.session2 开启事务且查看当前account表中id为1的账户余额

start transaction; 
select money from account where id=1; --查询结果为500

3.session1 修改id为1的账户余额为1000不提交事务

update account set money = 1000 where id = 1; --设置余额为1000,此时还没提交事务

4.session2 查看id为1的账户余额

select money from account where id=1; --查询结果为500,没有读取到session1没提交事务的数据

5.session1 提交事务

commit;

session2没有读取到了session1未提交的数据,没有产生脏读

不可重复读-案例

1.session1 开启事务且查看当前account表中id为1的账户余额

start transaction; 
select money from account where id=1; --查询结果为1000

2.session2 开启事务且查看当前account表中id为1的账户余额

start transaction; 
select money from account where id=1; --查询结果为1000

3.session1 修改id为1的账户余额为500且提交事务

update account set money = 500 where id = 1; --设置余额为500
commit; --提交事务

4.session2 再次查看id为1的账户余额

select money from account where id=1; --查询结果为1000,两次查询读取到一样的结果

session2读取了两次id为1的数据得到两个不一样的结果,没有产生不可重复读

幻读-案例

1.session1 开启事务且查看当前account表中所有用户的金额总价

start transaction; 
select sum(money) from account; --查询结果为5500

2.session2 开启事务新增一条数据且提交事务

start transaction; 
INSERT INTO account(name,money) VALUES('赵九',1000);
commit;

3.session1再次查询account表中所有用户的金额总价

select sum(money) from account; --查询结果为5500

session1统计查询得到两次一样的总金额,没有产生了幻读

Spring 事务管理

原理

Spring事务管理是通过ThreadLocal 给每个线程分配一个数据库连接对象,然后通过AOP切面的方式拦截

被@Transactional修饰的类或方法,从而达到统一管理事务的目的.伪代码如下

@Aspect
@Component
@Slf4j
public class TransactionManagerAop {

    /**
     * 线程隔离
     */
    private ThreadLocal<Connection> threadLocal = new ThreadLocal();

    /**
     * 数据源对象
     */
    @Resource
    private DataSource dataSource;

    /**
     * 拦截Transactional标注的方法
     */
    @Around("@annotation(org.springframework.transaction.annotation.Transactional)")
    public Object transactionAround(ProceedingJoinPoint pjp) throws Throwable {
        // 获取当前线程的数据库连接对象
        Connection connection = threadLocal.get();

        // 如果没有给该线程分配一个连接池对象
        if (connection == null) {
            connection = dataSource.getConnection();
            threadLocal.set(connection);
        }

        // 获取目标方法上的注解
        Transactional transactional = ((MethodSignature) pjp.getSignature())
                .getMethod().getAnnotation(Transactional.class);

        try {
            // 开启手动提交事务
            connection.setAutoCommit(false);

            // 调用目标方法
            Object proceed = pjp.proceed();

            // 提交事务
            connection.commit();
            return proceed;
        } catch (Throwable throwable) {

            // 查看目标方法上碰到什么异常的时候 需要回滚,没写默认有异常就回滚

            Class<? extends Throwable>[] classes = transactional.rollbackFor();
            if (classes.length == 0) {
                // 事务回滚
                connection.rollback();
            } else {
                // 筛选一下,如果存在该异常便回滚
                for (Class<? extends Throwable> aClass : classes) {
                    if (throwable.getClass() == aClass) {
                        // 事务回滚
                        connection.rollback();
                        break;
                    }
                }
            }
        } finally {
            // 释放当前线程
            threadLocal.remove();
        }
        return null;
    }
}

从伪代码中可以看出Spring帮我们封装了公共的事务开启,提交,回滚的操作

@Transactional

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface Transactional {
    @AliasFor("transactionManager")
    String value() default "";

    @AliasFor("value")
    String transactionManager() default "";

    Propagation propagation() default Propagation.REQUIRED;

    Isolation isolation() default Isolation.DEFAULT;

    int timeout() default -1;

    boolean readOnly() default false;

    Class<? extends Throwable>[] rollbackFor() default {};

    String[] rollbackForClassName() default {};

    Class<? extends Throwable>[] noRollbackFor() default {};

    String[] noRollbackForClassName() default {};
}
  • readOnly 设置只读,如果为true可提高读的性能

  • timeout 设置超时, 到了设定的超时时间后会回滚事务且抛出超时异常

  • rollbackFor 设置需要事务回滚的异常class

  • propagation 设置事务

    propagation.REQUIRED //有事务加入事务,没有事务新建事务
        
    propagation.NOT_SUPPORTED //不开启事务
    
    propagation.NEVER //不在事务中执行
        
    propagation.REQUIRES_NEW //不管是否存在事务,都新建一个事务,挂起原来事务
        
    propagation.MANDATORY //必须在一个事务中执行
        
    propagation.SUPPORTS //如果其他bean中调用次方法,在其他bean中声明事务就用事务,没有声明就不用