聊聊JDBC事务隔离级别
LydaAXRU
8年前
<h2>摘要</h2> <p>事务在日常开发中是不可避免碰到的问题,JDBC中的事务隔离级别到底会如何影响事务的并发,脏读(dirty reads), 不可重复读(non-repeatable reads),幻读(phantom reads)到底是什么概念</p> <h2>事务</h2> <ol> <li> <p>原子性(atomicity) 事务是数据库的逻辑工作单位,而且是必须是原子工作单位,对于其数据修改,要么全部执行,要么全部不执行。</p> </li> <li> <p>一致性(consistency) 事务在完成时,必须是所有的数据都保持一致状态。在相关数据库中,所有规则都必须应用于事务的修改,以保持所有数据的完整性。</p> </li> <li> <p>隔离性(isolation) 一个事务的执行不能被其他事务所影响。</p> </li> <li> <p>持久性(durability) 一个事务一旦提交,事物的操作便永久性的保存在数据库中,即使此时再执行回滚操作也不能撤消所做的更改。</p> </li> </ol> <h3>隔离性</h3> <p>以上是数据库事务-ACID原则,在JDBC的事务编程中已经为了我们解决了原子性,持久性的问题,唯一可配置的选项是事务隔离级别,根据com.mysql.jdbc.Connection的定义有5个级别:</p> <ol> <li> <p>TRANSACTION_NONE(不支持事务)</p> </li> <li> <p>TRANSACTION_READ_UNCOMMITTED</p> </li> <li> <p>TRANSACTION_READ_COMMITTED</p> </li> <li> <p>TRANSACTION_REPEATABLE_READ</p> </li> <li> <p>TRANSACTION_SERIALIZABLE</p> </li> </ol> <p>读不提交(TRANSACTION_READ_UNCOMMITTED)</p> <p>不能避免dirty reads,non-repeatable reads,phantom reads</p> <p>读提交(TRANSACTION_READ_COMMITTED)</p> <p>可以避免dirty reads,但是不能避免non-repeatable reads,phantom reads</p> <p>重复读(TRANSACTION_REPEATABLE_READ)</p> <p>可以避免dirty reads,non-repeatable reads,但不能避免phantom reads</p> <p>序列化(TRANSACTION_SERIALIZABLE)</p> <p>可以避免dirty reads,non-repeatable reads,phantom reads</p> <p>创建一个简单的表来测试一下隔离性对事务的影响</p> <pre> <code class="language-java">CREATE TABLE `account` ( `id` int(11) NOT NULL AUTO_INCREMENT, `user_id` int(11) DEFAULT NULL, `balance` int(11) DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8;</code></pre> <h3>脏读(dirty reads)</h3> <p>事务A修改了一个数据,但未提交,事务B读到了事务A未提交的更新结果,如果事务A提交失败,事务B读到的就是脏数据。</p> <p>TEST:</p> <p>事务A: update account += 1000, 然后回滚</p> <p>事务B: 尝试读取 account 的值</p> <p>期望结果:</p> <p>当设置隔离级别为TRANSACTION_READ_UNCOMMITTED时,事务B读取到的值不一致</p> <p>当设置隔离级别大于TRANSACTION_READ_UNCOMMITTED时,事务B读取到的值一致</p> <p>先创建一个read任务</p> <pre> <code class="language-java">class ReadTask implements Runnable { int level = 0; public ReadTask(int level) { super(); this.level = level; } @Override public void run() { Db.tx(level, new IAtom() { @Override public boolean run() throws SQLException { AccountService service = new AccountService(); System.out.println(Thread.currentThread().getId() + ":" + service.audit()); return true; } }); } }</code></pre> <p>其中AccountService代码(提供了读和写balance的方法)</p> <pre> <code class="language-java">public class AccountService { // 貌似这个方法有执行了行锁 public void deposit(int num) throws Exception { int index = Db.update("update account set balance = balance + " + num + " where user_id = 1"); if(index != 1) throw new Exception("Oop! deposit fail."); } public int audit() { return Db.findFirst("select balance from account where user_id = 1").getInt("balance"); } }</code></pre> <p>PS: 上述代码所使用的框架为 <a href="/misc/goto?guid=4959617108994835220" rel="nofollow,noindex">JFinal(非常优秀的国产开源框架)</a></p> <p>对于Db.findFirst和Db.update这2个方法就是对JDBC操作的一个简单的封装</p> <p>然后再创建一个writer任务</p> <pre> <code class="language-java">class WriterTask implements Runnable { int level = 0; public WriterTask(int level) { super(); this.level = level; } @Override public void run() { Db.tx(level, new IAtom() { @Override public boolean run() throws SQLException { AccountService service = new AccountService(); try { service.deposit(1000); System.out.println("Writer 1000."); Thread.sleep(1000); System.out.println("Writer complete."); } catch (Exception e) { e.printStackTrace(); } return true; } }); } }</code></pre> <p>然后执行主线程</p> <pre> <code class="language-java">public static void main(String[] args) throws Exception { int level = Connection.TRANSACTION_READ_UNCOMMITTED; for(int j = 0; j < 10; j++) { if(j == 5) new Thread(new WriterTask(level)).start(); try { Thread.sleep(200); } catch (InterruptedException e) { e.printStackTrace(); } new Thread(new ReadTask(level)).start(); } }</code></pre> <p>上诉代码开启ReadTask和WriterTask对balance的值进行并发的写入和读取</p> <p>当隔离级别为TRANSACTION_READ_UNCOMMITTED时,发现在WriterTask-commit事务前后读取到的值不一样</p> <pre> <code class="language-java">10:14000 12:14000 11:14000 15:14000 16:14000 Writer 1000. 18:15000 19:15000 20:15000 21:15000 22:15000 Writer complete.</code></pre> <p>然后修改代码的隔离级别为TRANSACTION_READ_COMMITTED,发现前后读取的值一致,但是这里有一个问题,在数据库中已经被更新为1600,但是2次读取的值是1500,就是WriterTask事务未提交之前的值,说明TRANSACTION_READ_COMMITTED虽然可以避免脏读,但是却不能获取到数据的强一致性,这里是需要注意的一个点,假如有需求实时的获取到balance的最新值,那么WriterTask很显然就需要lock来控制了</p> <pre> <code class="language-java">11:15000 10:15000 12:15000 15:15000 16:15000 Writer 1000. 18:15000 19:15000 20:15000 21:15000 22:15000 Writer complete.</code></pre> <h3>不可重复读(non-repeatable reads)</h3> <p>在同一个事务中,对于同一份数据读取到的结果不一致。比如,事务B在事务A提交前读到的结果,和提交后读到的结果可能不同。</p> <p>TEST:</p> <p>事务A: update account += 1000, 然后commit</p> <p>事务B: 尝试读取 account 的值(间隔2秒),再次尝试读取</p> <p>为了满足不可重复读的测试对ReadTask作一些小改动</p> <pre> <code class="language-java">class ReadTask2 implements Runnable { int level = 0; public ReadTask2(int level) { super(); this.level = level; } @Override public void run() { Db.tx(level, new IAtom() { @Override public boolean run() throws SQLException { AccountService service = new AccountService(); System.out.println(Thread.currentThread().getId() + ":" + service.audit()); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getId() + ":" + service.audit()); return true; } }); } }</code></pre> <p>在代码中间隔2s,然后重复访问同一个balance字段</p> <p>主线程代码</p> <pre> <code class="language-java">public static void main(String[] args) throws Exception { int level = Connection.TRANSACTION_REPEATABLE_READ; new Thread(new ReadTask2(level)).start(); Thread.sleep(1500); new Thread(new WriterTask2(level)).start(); }</code></pre> <p>设置隔离界别为TRANSACTION_READ_UNCOMMITTED</p> <pre> <code class="language-java">10:17000 Writer 1000. 10:18000</code></pre> <p>设置隔离界别为TRANSACTION_REPEATABLE_READ</p> <pre> <code class="language-java">10:18000 Writer 1000. 10:18000</code></pre> <p>和脏读一样读取到的1800是WriterTask事务未提交之前的值,假如要实时的获取balance的最新值,WriterTask很显然还是需要加lock</p> <h3>幻读(phantom reads)</h3> <p>在同一个事务中,同一个查询多次返回的结果不一致。</p> <p>ReadTask和WriterTask分别进行insert的sql与select的操作(select count(*) from account)</p> <p>TEST:</p> <p>事务A: insert account 然后commit</p> <p>事务B: 尝试读取 account 的数量(间隔2秒),再次尝试读取</p> <p>设置隔离界别为TRANSACTION_REPEATABLE_READ</p> <pre> <code class="language-java">12:1 create account. 12:1</code></pre> <p>设置隔离界别为TRANSACTION_SERIALIZABLE</p> <pre> <code class="language-java">12:2 12:2 create account.</code></pre> <p>关于最高级别序列化是只有当一个事务完成后才会执行下一个事务,但是这里我测试使用TRANSACTION_REPEATABLE_READ级别是还是避免了幻读,不知道是程序的问题还是JDBC的问题,这里我可能还需要进一步的测试和研究,但是根据官方对TRANSACTION_REPEATABLE_READ的说明</p> <p>A constant indicating that dirty reads, non-repeatable reads and phantom reads are prevented. This level includes the prohibitions in TRANSACTION_REPEATABLE_READ and further prohibits the situation where one transaction reads all rows that satisfy a WHERE condition, a second transaction inserts a row that satisfies that WHERE condition, and the first transaction rereads for the same condition, retrieving the additional "phantom" row in the second read.</p> <p>表示幻读的定义是在同一个事务中,读取2次的值是不一样的,因为有其他事务添加了一行,并且这行数据是满足第一个事务的where查询条件的数据</p> <h3>总结</h3> <p>本次测试使用JFinal框架(它对JDBC进行了很简易的封装),使用不同的隔离级别对3种并发情况进行测试,但是在幻读的测试中TRANSACTION_REPEATABLE_READ级别同样也避免了幻读的情况,这个有待进一步测试和研究</p> <h3>补充说明</h3> <ol> <li> <p>同一个事务: 在JDBC编程中同一个事务意味着拥有相同的Connection,也就是说如果想保证事务的原子性所有的执行必须使用同一个Connection,事务的代表就是Connection</p> </li> <li> <p>commit和rollback:在JDBC编程中一旦代码commit成功就无法rollback,所有一般rollback是发生在commit出现异常的情况下</p> </li> </ol> <p> </p> <p>来自:https://segmentfault.com/a/1190000006769883</p> <p> </p>