用Spring Boot + MySQL实战解析四种隔离级别的数据异常现象
在数据库开发中,事务隔离级别是一个既基础又关键的概念。很多开发者虽然能背出四种隔离级别的定义,却对它们在实际应用中的表现缺乏直观感受。本文将带你通过Spring Boot应用和MySQL数据库,亲手"制造"并观察不同隔离级别下的数据异常现象。
1. 环境准备与项目搭建
首先创建一个基础的Spring Boot项目,添加必要的依赖。我们将使用Spring Data JPA与MySQL进行交互,同时用JUnit编写测试用例来模拟并发事务场景。
<!-- pom.xml关键依赖 --> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-jpa</artifactId> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies>配置application.properties文件,设置数据库连接和JPA属性:
spring.datasource.url=jdbc:mysql://localhost:3306/transaction_demo spring.datasource.username=root spring.datasource.password=yourpassword spring.jpa.hibernate.ddl-auto=update spring.jpa.show-sql=true创建一个简单的实体类用于测试:
@Entity public class Account { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private BigDecimal balance; // 省略getter和setter }2. 理解隔离级别与数据异常
在深入代码前,我们需要明确几个关键概念:
- 脏读(Dirty Read):一个事务读取了另一个未提交事务修改过的数据
- 不可重复读(Non-repeatable Read):同一事务内,多次读取同一数据返回不同结果
- 幻读(Phantom Read):同一事务内,相同的查询条件返回不同数量的记录
MySQL支持的四种隔离级别及其可能发生的问题:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| READ UNCOMMITTED | ✓ | ✓ | ✓ |
| READ COMMITTED | × | ✓ | ✓ |
| REPEATABLE READ | × | × | ✓ |
| SERIALIZABLE | × | × | × |
3. 实战演示四种隔离级别
3.1 READ UNCOMMITTED下的脏读现象
在这个隔离级别下,我们将观察到最"宽松"的数据异常现象。
@Test @Transactional(isolation = Isolation.READ_UNCOMMITTED) public void testDirtyRead() throws InterruptedException { // 事务1:修改数据但不提交 new Thread(() -> { transactionTemplate.execute(status -> { Account account = accountRepository.findById(1L).orElseThrow(); account.setBalance(new BigDecimal("1000.00")); accountRepository.save(account); // 故意不提交,模拟长时间运行的事务 try { Thread.sleep(5000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return null; }); }).start(); // 事务2:在事务1提交前读取数据 Thread.sleep(1000); // 确保事务1已经开始 Account account = accountRepository.findById(1L).orElseThrow(); System.out.println("读取到未提交的数据:" + account.getBalance()); }运行这个测试,你会看到事务2读取到了事务1尚未提交的修改,这就是典型的脏读现象。
3.2 READ COMMITTED下的不可重复读
将隔离级别提高到READ COMMITTED,脏读问题解决了,但会出现不可重复读。
@Test public void testNonRepeatableRead() throws InterruptedException { // 初始数据 Account account = new Account(); account.setName("测试账户"); account.setBalance(new BigDecimal("500.00")); accountRepository.save(account); // 事务1:读取数据 CompletableFuture<BigDecimal> firstRead = CompletableFuture.supplyAsync(() -> transactionTemplate.execute(status -> { Account acc = accountRepository.findById(account.getId()).orElseThrow(); return acc.getBalance(); }) ); // 事务2:修改并提交数据 CompletableFuture.runAsync(() -> transactionTemplate.execute(status -> { Account acc = accountRepository.findById(account.getId()).orElseThrow(); acc.setBalance(new BigDecimal("1000.00")); accountRepository.save(acc); return null; }) ); // 事务1:再次读取 CompletableFuture<BigDecimal> secondRead = CompletableFuture.supplyAsync(() -> transactionTemplate.execute(status -> { Account acc = accountRepository.findById(account.getId()).orElseThrow(); return acc.getBalance(); }) ); BigDecimal first = firstRead.get(); secondRead.get(); // 等待事务2完成 BigDecimal second = secondRead.get(); System.out.println("第一次读取:" + first); System.out.println("第二次读取:" + second); }在这个测试中,尽管是在同一个事务内,两次读取的结果却不一致,这就是不可重复读。
3.3 REPEATABLE READ下的幻读现象
MySQL的默认隔离级别是REPEATABLE READ,它解决了不可重复读问题,但仍可能出现幻读。
@Test public void testPhantomRead() throws InterruptedException { // 事务1:查询符合条件的记录数 CompletableFuture<Long> firstCount = CompletableFuture.supplyAsync(() -> transactionTemplate.execute(status -> accountRepository.countByName("测试账户") ) ); // 事务2:插入新记录并提交 CompletableFuture.runAsync(() -> transactionTemplate.execute(status -> { Account newAccount = new Account(); newAccount.setName("测试账户"); newAccount.setBalance(new BigDecimal("200.00")); accountRepository.save(newAccount); return null; }) ); // 事务1:再次查询 CompletableFuture<Long> secondCount = CompletableFuture.supplyAsync(() -> transactionTemplate.execute(status -> accountRepository.countByName("测试账户") ) ); Long first = firstCount.get(); secondCount.get(); // 等待事务2完成 Long second = secondCount.get(); System.out.println("第一次计数:" + first); System.out.println("第二次计数:" + second); }在REPEATABLE READ隔离级别下,同一个事务内两次查询返回的记录数可能不同,这就是幻读现象。
3.4 SERIALIZABLE隔离级别的行为
最高级别的隔离级别SERIALIZABLE解决了所有数据异常问题,但会带来性能开销。
@Test @Transactional(isolation = Isolation.SERIALIZABLE) public void testSerializable() { // 事务1:查询并锁定记录 List<Account> accounts = accountRepository.findByName("测试账户"); // 事务2尝试修改被锁定的记录 CompletableFuture<Void> future = CompletableFuture.runAsync(() -> transactionTemplate.execute(status -> { Account account = accountRepository.findById(1L).orElseThrow(); account.setBalance(new BigDecimal("1500.00")); accountRepository.save(account); return null; }) ); try { future.get(2, TimeUnit.SECONDS); } catch (Exception e) { System.out.println("事务2被阻塞或超时"); } }在这个测试中,你会观察到事务2会被阻塞,直到事务1完成。这是SERIALIZABLE隔离级别的典型行为。
4. 隔离级别的选择与实践建议
在实际开发中,隔离级别的选择需要权衡数据一致性和系统性能:
- 低一致性要求场景:如日志记录、统计分析等,可以考虑READ COMMITTED
- 一般业务场景:MySQL默认的REPEATABLE READ通常是最佳选择
- 高一致性要求场景:如金融交易,可能需要使用SERIALIZABLE
几个实用技巧:
- 在Spring中设置隔离级别:
@Transactional(isolation = Isolation.REPEATABLE_READ) public void businessMethod() { // 业务逻辑 }监控数据库性能,当发现锁争用严重时,考虑调整隔离级别
对于特定操作,可以使用SELECT ... FOR UPDATE显式加锁
合理设计事务边界,避免长事务
在实际项目中,我遇到过因不当使用SERIALIZABLE隔离级别导致的性能问题。通过将这些演示代码应用到真实场景,你能更直观地理解不同隔离级别的行为特征,从而做出更合理的技术决策。