Spring 并发事务的探究
- 前言
在目前的软件架构中,不仅存在单独的数据库操作(一条SQL以内,还存在逻辑性的一组操作。而互联网软件系统最少不了的就是对共享资源的操作。比如热闹的集市,抢购的人群对同见商品的抢购由一位售货员来处理,这样虽然能保证买卖的正确进行,但是牺牲了效率,饱和的销售过程并不能高效处理所有的购买请求,最后打烊了部分顾客悻悻而归。而电脑的发明是让人类解放于这种低效的工作中,提高销售性能,比如抢购系统,秒杀系统等。而这种销售过程必然包含了检查库存、秒杀排队、校对商品信息、下单等一系列的组合操作,而一个交易过程再怎么解耦,仍然无法做到单条数据操作达到最终数据一致性,因为在比如抢购和库存-1这种操作中,必然要使得其逻辑一致。
我认为,世界上只有两种资源:一种是皇上享有的资源,一种是大众享有的资源。如果不能确定这个资源只有一个用户的话,那就必然涉及到竞争。而多元问题只需要研究二元模型就可以。比如相互独立事件P(X,Y) = P(X)*P(Y),进而两两独立的事件P(X,Y,Z) = P(X)*P(Y)*P(Z)一样,只需要研究两个用户会产生什么样的行为就可以对业务进行精确的设计了。而数据库有一种处理并发操作的设计:数据库事务。
这次就来总结一下本人最近探究的数据库事务的并发模型以及模拟一些会发生的情况,由于缺少大并发的经验,只能立足于书本了。本次的环境是基于上篇搭建的maven项目以及使用Spring事务。
- 基本知识
Mysql数据库,Mysql事务,Spring事务管理。
首先啰嗦一下,对于Maven项目的编译配置,上篇博客中漏掉了编译打包的时候带上properties文件,导致弄了一下午不知道为什么起不来,在此记录更正一下。主要是tomcat的报错太过隐秘,导致我看不到它的编译错误。
为了方便,配置了虚拟映射路径,配置方法是打开tomcat/conf/server.xml,找到<Host>标签,添加Context。
<Context path="/" docBase="/Users/MacBook/Documents/test1/target/test1" reloadable="true" debug="0" />
docBase写项目路径,一般maven项目都会有个target。这样访问项目的时候就是http://localhost:8080/,就可以访问到你的目录了。每次启动tomcat都是一组sh,所以写了个脚本,顺便看看日志。
#!/bin/sh killall -9 java cd /Users/MacBook/Documents/test1 mvn package sh /Users/MacBook/Documents/tomcat7/bin/startup.sh tail -f /Users/MacBook/Documents/tomcat7/logs/catalina.out
maven的pom.xml中要加入编译插件更正的部分,否则打包后丢失properties文件。
<build> <finalName>test1</finalName> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <configuration> <source>1.8</source> <target>1.8</target> <encoding>UTF-8</encoding> </configuration> </plugin> </plugins> <!-- 解决Maven项目编译后classes文件中没有.xml问题 --> <resources> <resource> <directory>src/main/java</directory> <includes> <include>**/*.xml</include> <include>**/*.properties</include> </includes> <filtering>true</filtering> </resource> <resource> <directory>src/main/java/resources</directory> </resource> </resources> </build>
好,接下来进入正题了。数据库事务通常存在四种特性,概念在很早之前已经总结过了,ACID。而利用隔离性来控制并发事务并保证数据一致性,是根据情况来的。什么是根据情况呢?就是业务上,如果这个数据出现这种情况是合法的,那么尽量牺牲隔离性换取性能,如果数据是强一致的,那就牺牲性能换一致性。
Spring的事务中存在几种隔离级别,都是世界公理了,只需要在@Transactional注释里配置一下就好了。
@Transactional(rollbackFor=Exception.class,readOnly = false, propagation = Propagation.REQUIRED,isolation = Isolation.READ_UNCOMMITTED)
进入源码中查看隔离级别的种类。它是个枚举类型,隔离性的英文和数据库的隔离是一样的。
// // Source code recreated from a .class file by IntelliJ IDEA // (powered by Fernflower decompiler) // package org.springframework.transaction.annotation; public enum Isolation { DEFAULT(-1), READ_UNCOMMITTED(1), READ_COMMITTED(2), REPEATABLE_READ(4), SERIALIZABLE(8); private final int value; private Isolation(int value) { this.value = value; } public int value() { return this.value; } }
在执行事务之前,我先总结一下事务的必要条件。基于MySQL的事务,首先表的类型要是Innodb,有次实验一直不触发回滚,后来发现表的类型是Myisam。
事务的原理基于数据库的begin,commit。在这两个命令之间的数据库操作,如果事务中夹杂着缓存操作,那是回滚不了的只能显示回滚了。还有Spring事务的异常回滚,是基于动态代理技术,如果不抛出异常,在Dao层把异常生吞了,后续也没抛出异常,那是回滚不了的了。异常必须是在@Transactional标注的那个函数层被识别,这样才有回滚的余地。
现在进入本次研究的正题,并发事务。并发事务在不同的隔离级别下会产生的异常在资料中存在:脏读、幻读、不可重复读。
概念的表述如下
1.脏读:一个事务读取到另一个事务未提交的数据。也就是begin之后update了一条事务,但是没有commit,另一个事务读取相同数据发现是它修改但是未提交的数据。
2.幻读:一个事务在两次查询相同条件的时候,另一个事务执行插入事务,导致前一个事务在两次查询中返回了不同的结果,宛如产生了幻觉一般。
3.不可重复读:一个事务读取到另一个事务更新后的数据。前一个事务两次查询,出现不一致的结果,后一个事务在两次查询中修改了这个数据的内容并提交。
发生脏读的隔离级别是最低的,使用READ_UNCOMMITTED隔离级别就可以模拟出来了。
首先编写同一个数据库接口。
package Dao; import org.springframework.jdbc.core.JdbcTemplate; import java.util.Map; /** * Created by MacBook on 2017/11/18. */ public class TxAddressDao { private JdbcTemplate jdbcTemplate; public void setJdbcTemplate(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } //写入 public void insertCol(String address,String remark){ String sql = "insert into address(address,remark) values(?,?)"; Object[] args = {address,remark}; try{ jdbcTemplate.update(sql,args); }catch (Exception e){ throw new RuntimeException(); } } //更新 public void updateCol(long id,String address,String remark){ String sql = "update address set address = ?,remark = ? where id= ?"; Object[] args = {address,remark,id}; try{ jdbcTemplate.update(sql,args); }catch (Exception e){ throw new RuntimeException(); } } //查找 public Map<String,Object> selectCol(long id){ String sql = "select address,remark from address where id = ?"; try{ return jdbcTemplate.queryForMap(sql,id); }catch (Exception e){ throw new RuntimeException(); } } //查询数量 public int selectFromTo(long idFrom,long idTo){ String sql = "select count(*) from address where id > ? and id < ?"; Object[] args = {idFrom,idTo}; try{ return jdbcTemplate.queryForInt(sql,args); }catch (Exception e){ throw new RuntimeException(); } } }
- 脏读模拟
编写一个脏读业务和一个更新业务。
@Transactional(rollbackFor=Exception.class,readOnly = false, propagation = Propagation.REQUIRED,isolation = Isolation.READ_UNCOMMITTED) public Map<String,Object> dirtyRead(long id){ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); System.out.println("脏读事务开始:"+sdf.format(new Date())); Map<String,Object> data = txAddressDao.selectCol(id); System.out.println("脏读事务结束:"+sdf.format(new Date())); return data; }
@Transactional(rollbackFor=Exception.class,readOnly = false, propagation = Propagation.REQUIRED,isolation = Isolation.READ_UNCOMMITTED) public void updateData(long id,String address){ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); System.out.println("更新事务开始:"+sdf.format(new Date())); Date d = new Date(); String time = sdf.format(d); txAddressDao.updateCol(id,address,time); try{ Thread.sleep(10000);//10秒 }catch (Exception e){} System.out.println("更新事务j结束:"+sdf.format(new Date())); }
这里在更新后睡眠十秒钟,在此过程内是不会提交事务的。使用postman模拟请求,轻松实现脏读现象。
在这个接口还在pedding的时候,调用另外一个方法读取,发现已经读到了更新的数据了。
在时间上,脏读事务处于更新事务区间内,模拟了一次脏读,如果把隔离级别提升,则这个现象将会消失。
提升了隔离级别之后,再次模拟。
- 不可重复读模拟
在READ_COMMITTED中,会发生不可重复读,即两次select会产生不一样的结果。
//不可重复读模拟 @Transactional(rollbackFor=Exception.class,readOnly = false, propagation = Propagation.REQUIRED,isolation = Isolation.READ_COMMITTED) public Map<String,Object> unrepeatableRead(long id){ Map<String,Object> data = txAddressDao.selectCol(id); System.out.println("第一次读取"); for(String key:data.keySet()){ System.out.println("key :"+key+" value :"+data.get(key)); } try{ Thread.sleep(5000);//5秒 }catch (Exception e){} data = txAddressDao.selectCol(id); System.out.println("第二次读取"); for(String key:data.keySet()){ System.out.println("key :"+key+" value :"+data.get(key)); } return data; }
命令行模拟打印出了不可重复读的模拟结果。在一次事务中读到了不同结果,在实际业务中会产生数据不一致的问题。
- 幻读模拟
幻读会发生在REPEATABLE_READ以下的隔离级别,首先新建一个事务,检查这个id是否有插入过,然后插入这条数据,在这个事务执行过程中另一个事务插入了这个id为主键的数据,最终导致第一个事务失败,这个id不存在的查询宛如幻觉一般。
//幻读模拟 @Transactional(rollbackFor=Exception.class,readOnly = false, propagation = Propagation.REQUIRED,isolation = Isolation.REPEATABLE_READ) public void phantomRead(long id,String address){ SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); int col = txAddressDao.idExist(id); System.out.println("第一次读取区间内数据:"+col); try{ Thread.sleep(5000);//10秒 }catch (Exception e){} if(col == 0){ Date d = new Date(); String time = sdf.format(d); txAddressDao.insertCol(id,address,time); } }
- 项目的坑
(12月16日添加)在实际项目中,对事务的把控有些许偏差踩到的一个大坑,如众多技术人员所知,jdbcTemplate可以把创建的数据的主键返回,这是一项非常实用的功能,不必再回数据库中查询一遍,并且写法也很简洁。
int col = 0; KeyHolder keyHolder = new GeneratedKeyHolder(); jdbcTemplate.update(new PreparedStatementCreator() { public PreparedStatement createPreparedStatement(Connection con) throws SQLException { PreparedStatement ps = con.prepareStatement(sql,PreparedStatement.RETURN_GENERATED_KEYS); for(int i=0;i<args.length;i++){ ps.setObject(i+1,args[i]); } return ps; } }, keyHolder); col = keyHolder.getKey().intValue();
这个col就是生成的主键了,但是如果这个dao方法被service所调用,而事务是在service层开启的话,这么就会有坑了,如果在service中出现了异常,则这个插入的数据是不会回滚的,从而生成脏数据,初步猜测是因为重写这个接口之后新开了一个session,与事务不是同一个session。
我使用了Spring Boot项目进行测试,现象依然存在。下面是我模拟环境写的一个Service类。
@Repository public class CombineOptService { @Autowired private TestDao testDao; @Transactional(rollbackFor = Exception.class,propagation = Propagation.REQUIRED,isolation = Isolation.REPEATABLE_READ) public int generateData(String address,String remark) { int code = -1; try{ code = testDao.insert(address,remark); }catch (Exception e){ e.printStackTrace(); } return code; } }
接着编写Controller。
//插入数据 @RequestMapping(value="/addRecord",method = RequestMethod.POST) public Map<String,Object> addRecord(String address,String remark){ Map<String,Object> result = new HashMap<>(); int code = combineOptService.generateData(address,remark); result.put("code",code); return result; }
原数据库数据图
使用postman生成数据。
抛出了RuntimeException,理应是回滚的。但是这时候数据库却出现了数据。
test g就是刚才提交的数据。如今笔者还未找到妥善的处理方案。(待我感冒好先)
- 结语
以往只是探索了概念,本次亲自做了一下模拟,对性能有了更深刻的感知,要应用带工作中的技术不能一知半解,必须知道如何控制它,让它朝着你预想的方向走。比如我保证事务中的串行执行,只要有一个环节出现超乎预想就要回滚,如何回滚。或者某些异常回滚,某些不回滚。还有哪种隔离级别适合哪种场景。以及初步了解数据库事务对哪方面的操作是可以回滚的、Spring事务是运用了什么思路设计的。
最后,倒腾了一天主要耗时不在事务研究,而在服务器配置maven工程配置之类的。