在实际开发项目时,程序并不是总会按照正常的流程去执行,有时候线上可能出现一些无法预知的问题,任何一步操作都有可能发生异常,异常则会导致后续的操作无法完成。此时由于业务逻辑并未正确的完成,所以在之前操作过数据库的动作并不可靠,需要在这种情况下进行数据的回滚,SpringBoot提供了对这种数据回滚操作场景的支持,也就是事务。
如果你是新手,且没看过我之前的一系列SpringBoot文章,建议至少看一下这一篇:
SpringBoot(四)SpringBoot搭建简单服务端_springboot做成服务_heart荼毒的博客-CSDN博客
如果你想从头到尾系统地学习,欢迎关注我的专栏,持续更新:
https://blog.csdn.net/qq_21154101/category_12359403.html
目录
一、什么是事务
二、快速开启事务
三、用户登录场景模拟使用事务
1、创建ActionDao
2、创建ActionRepository
3、action_info表设计
4、创建实现事务的Service类
5、实现登录接口
四、测试事务是否生效
1、正常测试
2、在事务方法中打开异常代码注释
3、在第一个方法中打开异常注释
4、在第二个方法打开异常注释
5、反向测试
五、事务的坑
六、事务的原理
事务,就是一组操作数据库的动作集合。如果一组处理步骤由于其中的一步或多步执行失败,则事务必须回滚到最初的系统状态,也就是一步都不执行。
举例来讲,在某些业务场景下,如果一个请求,需要同时写入多张表的数据。为了避免数据不一致的情况,我们一般都会用到事务。
事务的最大特点就是原子性。整个事务是不可分割的最小工作单位,一个事务中的所有操作要么全部执行成功,要么全部都不执行。其中任何一条语句执行失败,都会导致事务回滚。
那么,如何在SpringBoot项目中开启事务呢?SpringBoot提供了多种开启事务的方式,我们在本篇中使用 @Transactional 注解的方式。至于基于xml配置的方式,我想说的是,这都2032年了,别再去使用一坨一坨的配置了。
在这里顺嘴一提,如果java是你的日常工作语言,那么注解你一定要习惯去使用。因为不仅是java后端开发,Android开发也大量使用注解。
上篇博客,我们实现了用户注册的场景,相信基本的步骤大家都掌握了,接下来的示例我不详细去解释了。本篇我们通过用户登录的场景,来模拟事务的使用。
场景比较简单,如下:
(1)用户登录后,我需要往user_info表更新该用户的登录时间。
(2)同时,用户登录是一种主动的行为,我需要往action_info表添加用户登录的打点。
我的要求如下:
(1)如果更新user_info用户表出现了异常,那么就算是登录失败了,则不能继续执行用户登录的打点。
(2)更新user_info用户表成功执行,但是记录用户登录打点的action_info表执行出现了异常没有成功写入,那么需要回滚user_info表的操作。
上面说到了,需要记录用户的登录行为。在这里因为是demo,我的数据库和字段设计没那么严密,只设计了简单的几个字段。
@Entity @Table(name = "action_info") public class ActionDao { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer actionId; @Column(name = "user_name") private String userName; @Column(name = "action_type") private String actionType; @Column(name = "action_time") private String actionTime; @Column(name = "action_ext") private String actionExt; public Integer getActionId() { return actionId; } public void setActionId(Integer actionId) { this.actionId = actionId; } public String getUserName() { return userName; } public void setUserName(String userName) { this.userName = userName; } public String getActionType() { return actionType; } public void setActionType(String actionType) { this.actionType = actionType; } public String getActionTime() { return actionTime; } public void setActionTime(String actionTime) { this.actionTime = actionTime; } public String getActionExt() { return actionExt; } public void setActionExt(String actionExt) { this.actionExt = actionExt; } }
只自定义一个方法,findByUserName,也就是通过用户名查到Dao对象:
@Repository public interface ActionRepository extends JpaRepository { public ActionDao findByUserName(String userName); }
很简单,就跟ActionDao里面的字段一一对应即可,不过多介绍,上图:
特别需要注意的是,这个类的实现有以下特点:
(1)新起的一个类。
(2)提供了一个开启事务(@Transactional)的public方法updateData。
(3)在这个支持事务的方法里有两个串行的数据库操作的方法。
(4)三个方法的最后我手动抛了一个异常,临时注释掉,打开的话模拟实际的登录异常。
@Service public class LoginService { @Transactional public void updateData(UserRepository userRepository, ActionRepository actionRepository, UserDao userDao, String name) { updateUser(userRepository, userDao); addAction(actionRepository, name); // throw new RuntimeException("更新用户SQL执行异常"); } /** * 更新用户信息 * * @param userDao UserDao */ public void updateUser(UserRepository userRepository, UserDao userDao) { Timestamp timestamp = new Timestamp(new java.util.Date().getTime()); userDao.setUserLastLoginTime(timestamp + ""); userRepository.save(userDao); // throw new RuntimeException("更新用户登录时间SQL执行异常"); } /** * 添加用户行为 * * @param name 用户名 */ private void addAction(ActionRepository actionRepository, String name) { ActionDao actionDao = new ActionDao(); actionDao.setUserName(name); Timestamp timestamp = new Timestamp(new java.util.Date().getTime()); actionDao.setActionTime(timestamp + ""); actionDao.setActionType("login"); actionRepository.save(actionDao); // throw new RuntimeException("添加用户行为SQL执行异常"); } }
通过@RestController注解,实现一个登录接口。我直接把我的代码全部贴上,注意里面的TextIUtils是我自己写的工具类,就是比较两个字符串是否一致,你也可以str1.equals(str2),但是需要注意str1判空:
@RestController public class LoginController { @Autowired private UserRepository userRepository; @Autowired private ActionRepository actionRepository; @Autowired private LoginService loginService; @GetMapping("login") public LoginResult login(String name, String password) { int code = 0; String status = "未知状态"; String msg = "未知信息"; LoginResult loginResult = new LoginResult(); UserDao userDao = userRepository.findByUserName(name); if (userDao == null) { status = "fail"; msg = "用户名不存在"; } else { String localPassword = userDao.getUserPassword(); if (TextUtils.equals(password, localPassword)) { try { loginService.updateData(userRepository, actionRepository, userDao, name); status = "success"; msg = "登录成功"; } catch (Exception e) { status = "fail"; msg = e.getMessage(); } } else { status = "fail"; msg = "密码错误"; } } loginResult.setCode(code); loginResult.setStatus(status); loginResult.setMsg(msg); return loginResult; } }
接下来,我们通过上面的代码来测试下,程序正常执行和分别在三个地方发生异常时,事务能否回滚来规避数据的不一致性。
首先,直接运行上述程序,不产生任何异常。如下是我之前的博客中注册成功的用户,我还是使用这个用户的用户名密码来模拟登录:http://localhost:8080/login?name=zj&password=123456
可以看到,登录成功:
我们看下user_info和action_info两个表是否都更新,时间刚好跟我的测试时间一致:
如下所示,直接把支持事务的方法打开抛出异常代码的注释,手动抛一个异常:
@Transactional public void updateData(UserRepository userRepository, ActionRepository actionRepository, UserDao userDao, String name) { updateUser(userRepository, userDao); addAction(actionRepository, name); throw new RuntimeException("更新用户SQL执行异常"); }
模拟调用登录接口后,登录失败:
看下数据库两个表格是否回滚了,在这里不截图了,跟上面的截图一致,没有任何更新,事务回滚成功。
也就是,在这样的场景下:
/** * 更新用户信息 * * @param userDao UserDao */ public void updateUser(UserRepository userRepository, UserDao userDao) { Timestamp timestamp = new Timestamp(new java.util.Date().getTime()); userDao.setUserLastLoginTime(timestamp + ""); userRepository.save(userDao); throw new RuntimeException("更新用户登录时间SQL执行异常"); }
可以看到,登录失败:
查看数据库的两张表,确实也没有更新,事务回滚成功。
模拟第一个数据操作已经完成,第二个数据操作出现了异常:
/** * 添加用户行为 * * @param name 用户名 */ private void addAction(ActionRepository actionRepository, String name) { ActionDao actionDao = new ActionDao(); actionDao.setUserName(name); Timestamp timestamp = new Timestamp(new java.util.Date().getTime()); actionDao.setActionTime(timestamp + ""); actionDao.setActionType("login"); actionRepository.save(actionDao); throw new RuntimeException("添加用户行为SQL执行异常"); }
毫无疑问,登录失败:
同样的,数据库的两张表没有任何更新。
那么,我们把支持事务的方法的注解去掉会发生什么事情呢?去掉注解,再次运行程序,模拟登录行为:
//@Transactional public void updateData(UserRepository userRepository, ActionRepository actionRepository, UserDao userDao, String name) { updateUser(userRepository, userDao); addAction(actionRepository, name); // throw new RuntimeException("更新用户SQL执行异常"); }
毫无疑问,现象还是跟上面一致,登录失败:
但是,我们看下数据库呢。可以看到,数据库也变了:
这就前后不一致了,程序发生异常了,告诉用户的其实是登录失败了这没问题,但是数据库更新了用户的登录时间,并且增加了用户登录的打点。但实际上,用户本次登录确实失败了。
通过这几个例子可以看出,事务确实很好地帮我们避免了数据不一致的情况或数据库跟前端表现不一致的情况。
事务的使用,其实是有很多坑的。在一开始使用事务的时候,我就说明了我处理事务的类的特点,其实那也是使用事务的几个基本的规范。在这里,我直接引用知乎一个大佬总结的事务使用的坑,我就不一一给大家详解了。
图片出处:聊聊Spring事务失效的12种场景,太坑人了 - 知乎
简单说下事务的原理。如果你对java常用的设计模式有过了解,那么通过其特点,其实可以想到,在这里使用了拦截器模式。
拦截器模式:每个任务都对应一个拦截器,共同组成了一个拦截器链,每个拦截器都是拦截器链的一环。下一个拦截器是否执行,取决于上一个拦截器执行的结果。
在这里只说其实现原理,不去深扒源码看其实现,感兴趣的同学自己去查阅。添加事务注解的方法,其内部的每一个方法都是一个拦截器,多个方法共同组成了拦截器链。当上一个任务执行成功时,下一个任务才会执行。反之,如果上一个任务执行失败了,那么会回滚该方法的执行,并且下一个方法不再执行。以此类推,只有拦截器链的每一个拦截器均执行成功了,那么才算执行成功。
最后,简单的总结下。本篇介绍了SpringBoot事务是什么以及事务的特点,并且通过注解的方式实现了事务的demo,通过模拟登录场景下更新两张数据表,测试了事务是否是符合预期的。最后呢,也简单的对事务的原理进行了介绍。如果有任何问题,欢迎留言或私聊交流。