在前面的文章中,我们学习了如何执行增删改查操作,也学习了如何来调用存储过程。今天我们就来学习下如何在JDBC中使用事务。

在某些情况下,某个任务执行成功的必要条件为多个子任务全部执行成功,那我们我们就可以称这多个子任务为一个事务。我们以转账为例:比如用户A要从自己的账户中转出1000元到用户B的账户中,我们来看看这个转账操作需要执行什么流程?


JDBC之事务详解_sql


如上图,当图中的1、2、3、4和其他省略的操作全部都完成后,这次转账操作就成功了,A、B的账户有对应的金额变更和流水记录。但是如果,在执行的过成中,A用户这边已经扣减了金额,但是由于网络原因或者异常等原因B账户金额并没能增加成功,那我们就需要将A、B账户还原为此次转账操作前的状态,这样要怎么处理?如果在A账户上的操作已经提交数据库了,即数据库中的账户信息已经update了,那么这时就需要手动的再将金额变回来,然后在处理一堆的流水、冲正、回退等等等。那这样子搞的话,想想异常处理中要回退的逻辑,简直是要疯的节奏。


JDBC之事务详解_数据库_02


如果所有的异常导致的数据库中数据不一致都需要自己动手写回退代码的话,我想不用到30岁,我就有了大佬的发型。幸好,我们有保证数据一致(和分布式没半毛钱关系,不要误解)的神兵利器-----事务

1.事务的概念

事务(Transaction),字面理解,一般是指要做的或所做的事情。

在计算机术语中是指访问并可能更新数据库中各种数据项的一个程序执行单元(unit)。事务通常由​​高级数据库​​​操纵语言或编程语言(如SQL,C++或Java)书写的​​用户程序​​的执行所引起,并用形如begin transactionend transaction语句(或​​函数调用​​)来界定。事务由事务开始(begin transaction)和事务结束(end transaction)之间执行的全体操作组成。

上述解释来自百度百科,如果你学过操作系统,可以将事务理解成原语,事务中的每件不可分割的子任务可以视为一条指令

在我们今天要讲的数据库中的事务,就可以理解为一条或一组SQL语句,这个事务成功的执行的必要条件就是其中的SQL全部执行成功,如果其中的任一SQL执行失败(没有达到预期效果),则此次事务执行失败,所有的SQL都不会对数据库中的数据产生变更。

事务具有4个属性:原子性、一致性、隔离性、持久性。这四个属性通常称为ACID特性


  • 原子性(Atomicity):一个事务是一个不可分割的工作单位,事务中包括的操作要么都做,要么都不做;
  • 一致性(Consistency):事务必须是使数据库从一个一致性状态变到另一个一致性状态。一致性与原子性是密切相关的;
  • 隔离性(Isolation):一个事务的执行不能被其他事务干扰。即一个事务内部的操作及使用的数据对并发的其他事务是隔离的,并发执行的各个事务之间不能互相干扰;
  • 持久性(Durability):持久性也称永久性(Permanence),指一个事务一旦提交,它对数据库中数据的改变就应该是永久性的。

​ 对于其中的隔离性,数据库服务器有时会为了提供更好的处理并发能力,会牺牲一定的隔离性。这也是正确性和性能之间的对抗。如MySQL默认的隔离级别为READ COMMITTED,即读提交,可以读取其他事务已经提交的内容,可以避免脏读,但是无法避免​​重复度和幻读​​。

2.JDBC中事务的相关方法

下面我们来看下,当使用JDBC操作数据库时,要如何使用事务。这里,我们要新介绍几个Connection接口中的几个方法,如下表所示:

方法名

功能描述

void setAutoCommit(boolean autoCommit)

设置此连接的自动提交模式;默认为自动提交

void commit()

使自上一次提交/回退以来的所有更改永久生效,并释放此Connection对象当前持有的所有数据库锁。

void rollback()

撤消在当前事务中所做的所有更改,并释放此Connection对象当前持有的所有数据库锁

void rollback(Savepoint savepoint)

撤消设置给定的Savepoint对象之后所做的所有更改

当Connection自动提交模式为true的时候,即默认值,其所有SQL语句将作为单个事务执行并提交,每次SQL执行时会默认提交,不需手动触发;当自动提交模式为false时,其SQL语句会分组为事务(commit与commit之间的SQL或commit与rollback之间的SQL),这些事务通过调用​​commit​​方法提交,或调用​​rollback​​方法来进行回滚。

3.一个简单的示例

​ 我们在JDBC中使用事务可以参考如下模板,其中的JDBCUtil代码参考​​上文​​:

public void transactionTemplate() throws ClassNotFoundException, SQLException {
Connection connection = null;
Statement statement = null;
ResultSet resultSet = null;
try {
// 获取数据库连接
connection = JDBCUtil.getConnection();
// 设置自动提交的模式为false
connection.setAutoCommit(false);
// 创建Statement对象 或 prepareStatement对象
statement = connection.createStatement();
statement.execute("sql 1");
// ...
statement.execute("sql n");
//提交上面sql 1-sql n所有的SQL语句
connection.commit();
} catch (ClassNotFoundException e) {
e.printStackTrace();
throw e;
} catch (SQLException e) {
e.printStackTrace();
//SQL执行异常,撤销此次事务中的所有SQL
connection.rollback();
throw e;
} finally {
// 如果此连接不释放的话,则将自动提交模式改回true
connection.setAutoCommit(true);
JDBCUtil.release(resultSet, statement, connection);
}
}

为了模拟事务操作,我们新建一张账户表,用于存储用户的账户金额(删减版),SQL如下:

CREATE TABLE `account` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(15) NOT NULL COMMENT '账户名',
`balance` bigint(20) DEFAULT '0' COMMENT '账户余额,单位:分',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;

-- insert
INSERT INTO `java_web`.`account`(`id`, `name`, `balance`) VALUES (1, 'A', 2000);
INSERT INTO `java_web`.`account`(`id`, `name`, `balance`) VALUES (2, 'B', 500);

我们在表中插入两条数据来模拟我们在文章开始时的转账操作。对应的Java代码如下:

/**
* 转账操作
* @param from 发起人账户名,扣减账户
* @param to 收款人账户名,增加账户
* @param balance 转账金额,单位分
* @return
* @throws ClassNotFoundException
* @throws SQLException
*/
public boolean transferOperation(String from, String to, int balance) throws ClassNotFoundException, SQLException {
Connection connection = null;
Statement statement = null;
ResultSet resultSet = null;
try {
// 获取数据库连接
connection = JDBCUtil.getConnection();
// 设置自动提交的模式为false
connection.setAutoCommit(false);
// 创建Statement对象 或 prepareStatement对象
statement = connection.createStatement();
//A账户扣减对应金额 需保证账户余额大于扣减金额
int result1 = statement.executeUpdate("update account set balance = balance - " +
balance + " where name = '" + from + "' and balance >= " + balance);
// B账户增加对应金额
int result2 = statement.executeUpdate("update account set balance = balance + " +
balance + " where name = '" + to + "'");
// 判断扣减和增加是否都执行成功
if (result1 == 0 || result2 == 0) {
throw new SQLException("事务执行失败");
}
// 提交上面所有的SQL语句
connection.commit();
return true;
} catch (ClassNotFoundException e) {
e.printStackTrace();
throw e;
} catch (SQLException e) {
e.printStackTrace();
// SQL执行异常,撤销此次事务中的所有SQL
connection.rollback();
throw e;
} finally {
// 如果此连接不释放的话,则将自动提交模式改回true
connection.setAutoCommit(true);
JDBCUtil.release(resultSet, statement, connection);
}
}

​ 执行测试代码​​transferOperation("A", "B", 1000)​​后,数据库中的数据如下图所示:


JDBC之事务详解_数据库_03


​ 当我们再次执行测试代码​​transferOperation("A", "B", 2000)​​​后,我们可以看到控制台抛出如下错误,这里是因为上次转账操作,是的A账户的余额只有1000(单位:分)了,当再次转账2000时,A账户的余额不足了,因此A账户扣减SQL影响的行数为0,而B账户增加操作执行成功了,但是因为这个事务中有SQL执行失败,因此我们将异常抛出,并​​rollback​​此次事务。


JDBC之事务详解_mysql_04


再次去数据中查看account表,发现A、B账户的余额都未发生变化。

注意rollback应当发生在commit操作之前,因为commit之后的,此次事务对数据库的改变是无法被撤销的,也就是事务的持久性。对此,我们可以做一个小的实验,我们将上面的函数做下小小的改动,代码如下所示:

//...
// 提交上面所有的SQL语句
connection.commit();
// 判断扣减和增加是否都执行成功
if (result1 == 0 || result2 == 0) {
throw new SQLException("事务执行失败");
}
//...

​ 执行测试代码​​transferOperation("A", "B", 2000)​​后,控制台还是抛出了异常,这里好像和之前(异常抛出在前)没啥区别哦,先别急,我们在去数据库中确认下,结果如下图所示:


JDBC之事务详解_sql_05


哇????,B账户凭空多了1000哦,如果这种错误发生在生产上,那要恭喜你,你有张机票待签收。


JDBC之事务详解_mysql_06


4.总结

本文是对JDBC中如何使用事务的简单示例,不管是Mybatis还是Hibernate,都是支持事务的。不过事务的执行速度相比较原有SQL会稍慢一些,会产生更多的日志,执行更新时占用更多的内存等。在许多公司中,会使用另一种方式来代替事务的功能,那就是失败补偿机制,当执行过程中发生异常时,会有重试机制将此次调用重新执行,当然这也需要接口保证幂等性。因此事务的使用还是要根据自己公司的要求和具体的业务逻辑。

参考阅读:

  1. ​MySQL的四种事务隔离级别​

又到了分隔线以下,本文到此就结束了,本文内容全部都是由博主自己进行整理并结合自身的理解进行总结,如果有什么错误,还请批评指正。

有任何疑问,可以评论区留言。