Jdbc的事务隔离级别

数据库事务的基本特性

A 原子性 (atomicity)

事务中的各项操作被看成一个逻辑单元,要么全做,要么全不做,任何一项操作的失败都会导致整个事务的失败。

C 一致性 (consistency)

保证了当事务结束后,系统状态是一致的。

I 隔离性 (isolation)

使得并发执行的事务,彼此无法看到对方的中间状态。保证了并发执行的事务顺序执行,而不会导致系统状态不一致。

D 持久性 (durability)

保证了事务完成后所作的改动都会被持久化,即使是发生灾难性的失败。可恢复性资源保存了一份事务日志,如果资源发生故障,可以通过日志来将数据重建起来。

关于一致性,这里再稍稍做些补充。在ACID中,AID都是数据库的特征,也就是依赖数据库的具体实现。而唯独这个C,实际上它依赖于应用层。
这里的一致性是指系统从一个正确的状态,迁移到另一个正确的状态。什么叫正确的状态呢?就是当前的状态满足预定的约束就叫做正确的状态。

以财务系统为例,A向B转账100元,并且我们预设系统中的总金额不变这一约束,那事务执行前后都满足 A的余额+B的余额=100,那就认为事务满足一致性。
但是如果A的余额只有90元,并且我们给定账户余额这一列的约束是,不能小于0。那么很明显这条事务执行会失败,因为90-100=-10,小于我们给定的约束了。
此时,为了保证事务的一致性,我们需要对失败的事务进行回滚。

参考 知乎问题:如何理解数据库事务中的一致性的概念?

数据库事务的隔离级别

在高并发的情况下,要完全保证其ACID特性是非常困难的,除非把所有的事务串行化执行,但是带来的负面影响将是性能大打折扣。很多时候,我们有些业务对事务的要求是不一样的,所以数据库设计了四种隔离级别,供用户基于业务进行选择。

隔离级别 脏读(Dirty Read) 不可重复读(NonRepeatable Read) 幻读(Phantom Read)
未提交读(Read Uncommitted) 可能 可能 可能
已提交读(Read Committed) 不可能 可能 可能
可重复读(Repeatable Read) 不可能 不可能 可能
可串行化(Serializable) 不可能 不可能 不可能
  • 脏读
    一个事务读取到另一个事务未提交的更新数据,这被称为脏读。
  • 不可重复读
    在同一事务中,多次读取同一数据返回的结果有所不同。换言之,后续读取可以读到另一事务已提交的更新数据。
  • 幻读
    查询表中一条数据如果不存在就插入一条,并发的时候发现,里面居然由相同的数据,这就是幻读的问题。

脏读

一个事务读取到另一个事务未提交的更新数据,这被称为脏读。

首先在数据库中建表

CREATE TABLE `account` (
  `account_name` VARCHAR (100),
  `name` VARCHAR (100),
  `money` DOUBLE
) COMMENT = '用户表';

代码示例:

public class ReadUncommittedTest extends JdbcBase {

    public static void insert(String accountName, String name, Double money) throws Exception {
        Connection conn = openConnection();
        conn.setAutoCommit(false);
        PreparedStatement statement = conn.prepareStatement("INSERT INTO account(account_name, `name`, money) VALUES (?,?,?)");
        statement.setString(1, accountName);
        statement.setString(2, name);
        statement.setDouble(3, money);
        statement.executeUpdate();
        System.out.println("执行插入");
        Thread.sleep(3000);
        conn.close();
    }

    public static void select(String name, Connection conn) throws Exception {
        PreparedStatement statement = conn.prepareStatement("SELECT * FROM account where name=?");
        statement.setString(1, name);
        statement.executeQuery();
        System.out.println("执行查询");
        ResultSet resultSet = statement.getResultSet();
        while (resultSet.next()) {
            for (int i = 1; i < 4; i++) {
                System.out.print(resultSet.getString(i) + ",");
            }
        }
    }

    public static void main(String[] args) throws Exception {
        Thread t1 = run(() -> {
            try {
                insert("1234", "kendoziyu", 100d);
            } catch (Exception e) {
                e.printStackTrace();
            }
        });

        run(() -> {
            try {
                Thread.sleep(1000);
                Connection connection = openConnection();
                // 设置隔离级别未提交读 
                connection.setTransactionIsolation(Connection.TRANSACTION_READ_UNCOMMITTED);
                select("kendoziyu", connection);
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
        t1.join();
    }
}

JdbcBase 代码见最后一节代码附录,执行结果如下图

如果提升一下事务隔离级别,那么就可以解决脏读问题。

// connection.setTransactionIsolation(Connection.TRANSACTION_READ_UNCOMMITTED);
// 修改事务隔离级别为已提交读
connection.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);

不可重复读

在同一事务中,多次读取同一数据返回的结果有所不同。换言之,后续读取可以读到另一事务已提交的更新数据。如下图所示:

接着上代码:

public class ReadCommittedTest extends JdbcBase {
    public static void insert(String accountName, String name, Double money) throws Exception {
        Connection conn = openConnection();
        conn.setAutoCommit(true);
        PreparedStatement statement = conn.prepareStatement("INSERT INTO account(account_name, `name`, money) VALUES (?,?,?)");
        statement.setString(1, accountName);
        statement.setString(2, name);
        statement.setDouble(3, money);
        statement.executeUpdate();
        System.out.println("执行插入成功");
        conn.close();
    }

    public static void select(String name, Connection conn) throws Exception {
        PreparedStatement statement = conn.prepareStatement("SELECT * FROM account where name=?");
        statement.setString(1, name);
        statement.executeQuery();
        System.out.println("执行查询");
        ResultSet resultSet = statement.getResultSet();
        while (resultSet.next()) {
            for (int i = 1; i < 4; i++) {
                System.out.print(resultSet.getString(i) + ",");
            }
        }
    }

    public static void main(String[] args) throws Exception {
        final Object afterFirstQuery = new Object();
        final Object afterInsert = new Object();
        Thread t1 = run(() -> {
            try {
                synchronized (afterFirstQuery) {
                    afterFirstQuery.wait(); // 插入要等待查询1结束之后
                }
                insert("1234", "kendoziyu", 100d);
                synchronized (afterInsert) {
                    afterInsert.notify();
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        });

        run(() -> {
            try {
                Connection connection = openConnection();
                connection.setAutoCommit(false);// 设置事务非自动提交,保证查询1和查询2是同一个事务
                connection.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
                select("kendoziyu", connection); // 查询1
                synchronized (afterFirstQuery) {
                    afterFirstQuery.notify();
                }
                synchronized (afterInsert) {
                    afterInsert.wait();// 查询2要等插入成功之后
                }
                select("kendoziyu", connection); // 查询2
                connection.commit(); // 手动提交事务
            } catch (Exception e) {
                e.printStackTrace();
            }
        });
        t1.join();
    }
}

接着来看一下结果:

如果提升一下事务隔离级别,那么就可以解决脏读问题。

// connection.setTransactionIsolation(Connection.TRANSACTION_READ_COMMITTED);
// 修改事务隔离级别为可重复读
connection.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);

这里我们还需要先清空一下数据 delete from account;

"可重复读"保证了在同一事务中多次读取数据时,能够保证所读数据一样。换句话说,后续读取不可能读到另一事物已提交的更新数据。

幻读

查询表中一条数据如果不存在就插入一条,并发的时候发现,里面居然由相同的数据,这就是幻读的问题。

举例子,比如要注册手机号,手机号不能重复。

在开始实验前,我给 account 表加了一个普通索引 KEY index_account_name (account_name)

public class RepeatableReadTest extends JdbcBase {

    public static void insert(String accountName, String name, Double money) throws Exception {
        Connection conn = openConnection();
        conn.setAutoCommit(false);
        conn.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);
        boolean exist = selectExist(accountName, conn);
        if (exist) {
            conn.commit();
            conn.close();
            return;
        }
        PreparedStatement statement = conn.prepareStatement("INSERT INTO account(account_name, `name`, money) VALUES (?,?,?)");
        statement.setString(1, accountName);
        statement.setString(2, name);
        statement.setDouble(3, money);
        statement.executeUpdate();
        conn.commit();
        System.out.println("插入成功");
        conn.close();
    }

    public static boolean selectExist(String accountName, Connection conn) throws Exception {
        PreparedStatement statement = conn.prepareStatement("SELECT * FROM account where account_name=?");
        statement.setString(1, accountName);
        statement.executeQuery();
        ResultSet resultSet = statement.getResultSet();
        return resultSet.next();
    }
    public static void main(String[] args) {
        for (int i = 0; i < 2; i++) {
            run(() -> {
                try {
                    insert("123456", "kendoziyu", 134d);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            });
        }
    }
}

测试结果:

这显然不符合我们的期望,我们希望通过我们的判断,保证账号的唯一性,结果出现了多条。
我们试着提升隔离级别

// conn.setTransactionIsolation(Connection.TRANSACTION_REPEATABLE_READ);
conn.setTransactionIsolation(Connection.TRANSACTION_SERIALIZABLE);


提升事务隔离级别为 TRANSACTION_SERIALIZABLE 之后,确实只成功插入了一条记录,但是其中一个线程在执行 statement.executeUpdate(); 时抛出了异常。

我们可以通过 show engine innodb status 查看一下日志,我截取了一部分日志,是跟死锁相关的。

------------------------
LATEST DETECTED DEADLOCK
------------------------
2020-08-29 15:37:04 0xbf8
*** (1) TRANSACTION:
TRANSACTION 152969, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 4 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1
MySQL thread id 31, OS thread handle 2196, query id 869 localhost 127.0.0.1 root update
INSERT INTO account(account_name, `name`, money) VALUES ('123456','kendoziyu',134.0)
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 358 page no 4 n bits 72 index index_account_name of table `test`.`account` trx id 152969 lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
 0: len 8; hex 73757072656d756d; asc supremum;;

*** (2) TRANSACTION:
TRANSACTION 152968, ACTIVE 0 sec inserting
mysql tables in use 1, locked 1
4 lock struct(s), heap size 1136, 2 row lock(s), undo log entries 1
MySQL thread id 30, OS thread handle 3064, query id 870 localhost 127.0.0.1 root update
INSERT INTO account(account_name, `name`, money) VALUES ('123456','kendoziyu',134.0)
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 358 page no 4 n bits 72 index index_account_name of table `test`.`account` trx id 152968 lock mode S
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
 0: len 8; hex 73757072656d756d; asc supremum;;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 358 page no 4 n bits 72 index index_account_name of table `test`.`account` trx id 152968 lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
 0: len 8; hex 73757072656d756d; asc supremum;;

*** WE ROLL BACK TRANSACTION (2)

参考这篇文章解决一次mysql死锁问题,我分析了一下现在的情况:
事务2持有共享型(lock mode S)的 Record lock,事务1和事务2在请求排他型(lock mode X)的 insert intention lock 插入意向锁时,因为等待 Record lock 释放而阻塞。
Record Lock 是为了避免幻读而加上的,事务不结束就不会释放。然后这俩事务获取 insert intention lock 又都需要这个 Record Lock,得不到就只能阻塞了。然后就被检查出死锁了!

最终,innodb引擎裁定,让事务1执行成功,回滚事务2。虽然我们保证了 account_name 的唯一性,但是让数据库线程一直阻塞,势必影响性能啊。

小结一下:
Record lock 是死锁异常的引起者。解决办法就是,从业务层面,将并发处理改变为同步处理。比如如果是单机环境而非分布式环境:

// public static void insert(String accountName, String name, Double money) throws Exception
public synchronized static void insert(String accountName, String name, Double money) throws Exception

给这个 insert 方法加上 synchronized 关键字可以解决。

解决幻读

虽然提升事务隔离级别为 TRANSACTION_SERIALIZABLE 可以解决幻读的问题,但是这样会大大影响性能。
所以我们采用别的方法来解决这个问题:

  • for update 行级锁
  • redis 锁
  • zookeeper 锁

总结一下

数据库事务的四个特性:ACID,即原子性,一致性,隔离性,持久性
数据库事务有四个隔离级别:未提交读,已提交读,可重复读,串行化。
每一次提升隔离级别,对应解决的问题有脏读,不可重复读,幻读。

代码附录

// JdbcBase.class
public class JdbcBase {
    private static final String URL = "jdbc:mysql://localhost:3306/test?serverTimezone=UTC";
    private static final String USERNAME = "root";
    private static final String PASSWORD = "password";

    protected static Connection openConnection() throws SQLException {
        return DriverManager.getConnection(URL, USERNAME, PASSWORD);
    }

    protected static Thread run(Runnable runnable) {
        Thread thread = new Thread(runnable);
        thread.start();
        return thread;
    }
}
// pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>org.coderead</groupId>
    <artifactId>jdbc-transaction</artifactId>
    <version>1.0-SNAPSHOT</version>

    <dependencies>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
            <version>8.0.17</version>
            <scope>runtime</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <!--compiler插件-->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <configuration>
                    <source>8</source>
                    <target>8</target>
                    <encoding>utf-8</encoding>
                </configuration>
            </plugin>
        </plugins>
    </build>


</project>

pom 中设置 maven-compiler-plugin 并且指定 jdk 版本,主要是为了规避以下错误:

Information:java: javacTask: 源发行版 8 需要目标发行版 1.8
Information:java: Errors occurred while compiling module 'miaosha'
Information:javac 1.8.0_201 was used to compile java sources
Information:2019/4/25 10:36 - Compilation completed with 1 error and 0 warnings in 803 ms
Error:java: Compilation failed: internal java compiler error

这个问题,我一直在Idea里面调设置很麻烦,代码解决更便捷,参考了这篇博客《idea编译报错 javacTask:源发行版1.8 需要目标发行版1.8》

原文地址:https://www.cnblogs.com/kendoziyu/p/jdbc-transaction-isolation.html