spring方法呼叫導致事務失效原因及解決方案
spring方法呼叫導致事務失效原因及解決方案
1、事務失效場景復現
- 背景
我們在平時的工作中寫業務邏輯的時候,有可能會遇到這麼一個場景:在一個迴圈中處理事務問題。在使用宣告式事務的情況下我們有兩種選擇,要麼把@Transanal註解放在整個迴圈的方法上,這樣的話每次迴圈的事務都會被管理到,缺點是使用了長事務,會導致鎖表問題,影響效率。另一種方案是將每一次迴圈抽出一個方法,然後把@Transanal註解加在這個方法上。這樣spring只管理了本次迴圈的事務,解決了長事務問題,但是有事務失效的風險。下面我將會模擬這個場景,這種事務失效也是工作中最常遇到的情況。
- 場景模擬
某公司要給疫情期間不回家過年的員工補貼。每個人發放1000元到員工的賬戶 - 使用技術棧
spring-boot 2.2.2.RELEASE
jpa、mysql、lombok 1.18.10
實體類Employee,代表公司的員工:
import lombok.Data;
import javax.persistence.*;
@Data
@Entity
@Table(name = "employee")
public class Employee {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
/**
* 補貼是否已發放
*/
private Boolean hasGiveOut;
}
實體類EmployeeAccount代表員工的賬戶:
import javax.persistence.*;
import java.math.BigDecimal;
@Data
@Entity
@Table(name = "employee_account")
public class EmployeeAccount {
@Id
@GeneratedValue (strategy = GenerationType.IDENTITY)
private Long id;
private Long employeeId;
@Column(name = "account")
private BigDecimal employeeAccount;
}
下面是兩個實體類對應的DAO:
import org.springframework.data.jpa.repository.JpaRepository;
public interface EmployeeRepository extends JpaRepository<Employee, Long> {
}
import org.springframework.data.jpa.repository.JpaRepository;
public interface EmployeeAccountRepository extends JpaRepository<EmployeeAccount, Long> {
EmployeeAccount findByEmployeeId(Long employeeId);
}
下面是業務處理方法:
因為要保證錢發放到賬戶之後才能將employee的狀態改為已發放,所以要使用事務。我們選擇使用spring的宣告式事務。
@Service
@AllArgsConstructor
public class EmployeeServiceImpl {
private EmployeeRepository employeeRepository;
private EmployeeAccountRepository employeeAccountRepository;
/**
* 方案1:將事件整個方法作為一個事務,保證了一致性
* 但是當員工很多的時候,是一個長事務,有效率問題。不推薦
*/
@Transactional(rollbackFor = Exception.class)
public void giveOutBonusForAllEmployees(){
final List<Employee> allEmployees = employeeRepository.findAll();
for (Employee employee : allEmployees) {
employee.setHasGiveOut(true);
employeeRepository.save(employee);
final EmployeeAccount account = employeeAccountRepository.findByEmployeeId(employee.getId());
account.setEmployeeAccount(account.getEmployeeAccount().add(new BigDecimal("1000")));
employeeAccountRepository.save(account);
}
}
}
測試方法:
@Autowired
private EmployeeServiceImpl employeeService;
@Test
public void testTransaction(){
employeeService.giveOutBonusForAllEmployees();
}
方案一可以被spring事務管理,朋友們可以自行驗證。為了解決長事務,我們將業務處理方法抽出來,單獨做成一個短事務。
package com.jack.transaction;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
@Service
@AllArgsConstructor
public class EmployeeServiceImpl {
private EmployeeRepository employeeRepository;
private EmployeeAccountRepository employeeAccountRepository;
public void giveOutBonusForAllEmployees(){
final List<Employee> allEmployees = employeeRepository.findAll();
for (Employee employee : allEmployees) {
try{
giveOutOne(employee, new BigDecimal("1000"));
}catch (Exception e){
//一個出錯了不管,繼續發放其他的
}
}
}
@Transactional(rollbackFor = Exception.class)
public void giveOutOne(Employee employee, BigDecimal money){
employee.setHasGiveOut(true);
employeeRepository.save(employee);
final EmployeeAccount account = employeeAccountRepository.findByEmployeeId(employee.getId());
account.setEmployeeAccount(account.getEmployeeAccount().add(money));
// 模擬丟擲異常
if ("jack".equals(employee.getName())){
throw new RuntimeException("儲存失敗了");
}
employeeAccountRepository.save(account);
}
}
下面我們來驗證結果:
- 原始資料:
select employee.name,employee.has_give_out,ea.account from employee join employee_account ea on employee.id = ea.employee_id;
呼叫測試方法之後的資料:
事務失效了!!!jack損失了1000塊大洋
2、宣告式事務失效原因
有很多類似的部落格貼出了原始碼來解釋這個問題。筆者表達能力有限,不想用太多原始碼來說明這個原因。我想盡量用自己的語言將這個事務失效的原因告訴讀者,原始碼還是要靠自己去深究。
我們都知道,spring宣告式事務是通過AOP實現的。簡單點說,所有在方法上加了@Transactional
註解的方法,已經被spring代理了,成為了有事務能力的代理方法,只有真正呼叫到了這個代理方法才能實現事務。而我們程式碼中的方法呼叫giveOutOne(employee, new BigDecimal("1000"));
實際上是this.giveOutOne(employee, new BigDecimal("1000"));
,它呼叫的僅僅是原物件的原方法,並沒有呼叫到代理方法,自然也就沒有事務能力了。
3、解決方案
事務失效的原因大概清楚了,那麼解決事務失效的方法的思路也就很清楚了,只要想辦法呼叫到這個代理方法即可。
- 方法一:從容器中取出代理類,呼叫它的代理方法。當然也也可以使用
@Autowired
注入EmployeeServiceImpl
,但是這樣會顯得很奇怪。
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
@Service
public class EmployeeServiceImpl implements ApplicationContextAware {
private EmployeeRepository employeeRepository;
private EmployeeAccountRepository employeeAccountRepository;
public EmployeeServiceImpl(EmployeeRepository employeeRepository, EmployeeAccountRepository employeeAccountRepository) {
this.employeeRepository = employeeRepository;
this.employeeAccountRepository = employeeAccountRepository;
}
private ApplicationContext applicationContext;
public void giveOutBonusForAllEmployees(){
final EmployeeServiceImpl employeeService = applicationContext.getBean(EmployeeServiceImpl.class);
final List<Employee> allEmployees = employeeRepository.findAll();
for (Employee employee : allEmployees) {
try{
employeeService.giveOutOne(employee, new BigDecimal("1000"));
}catch (Exception e){
//一個出錯了不管,繼續發放其他的
}
}
}
@Transactional(rollbackFor = Exception.class)
public void giveOutOne(Employee employee, BigDecimal money){
employee.setHasGiveOut(true);
employeeRepository.save(employee);
final EmployeeAccount account = employeeAccountRepository.findByEmployeeId(employee.getId());
account.setEmployeeAccount(account.getEmployeeAccount().add(money));
// 模擬丟擲異常
if ("jack".equals(employee.getName())){
throw new RuntimeException("儲存失敗了");
}
employeeAccountRepository.save(account);
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
}
將資料復原,再跑一次測試方法看看:
- 方法二:使用AOP暴露出來的代理物件,其本質也跟上面的一樣。
第一步:在啟動類上加註解EnableAspectJAutoProxy
,指定使用AspectJ
作動態代理,並將代理物件暴露出來exposeProxy = true
。
@SpringBootApplication
@EnableAspectJAutoProxy(exposeProxy = true)
public class App {
public static void main(String[] args) {
SpringApplication.run(App.class, args);
}
}
第二步:在程式碼中使用AopContext獲取代理物件
import lombok.AllArgsConstructor;
import org.springframework.aop.framework.AopContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.List;
@Service
@AllArgsConstructor
public class EmployeeServiceImpl{
private EmployeeRepository employeeRepository;
private EmployeeAccountRepository employeeAccountRepository;
public void giveOutBonusForAllEmployees(){
final List<Employee> allEmployees = employeeRepository.findAll();
for (Employee employee : allEmployees) {
try{
((EmployeeServiceImpl)AopContext.currentProxy()).giveOutOne(employee, new BigDecimal("1000"));
}catch (Exception e){
//一個出錯了不管,繼續發放其他的
}
}
}
@Transactional(rollbackFor = Exception.class)
public void giveOutOne(Employee employee, BigDecimal money){
employee.setHasGiveOut(true);
employeeRepository.save(employee);
final EmployeeAccount account = employeeAccountRepository.findByEmployeeId(employee.getId());
account.setEmployeeAccount(account.getEmployeeAccount().add(money));
// 模擬丟擲異常
if ("jack".equals(employee.getName())){
throw new RuntimeException("儲存失敗了");
}
employeeAccountRepository.save(account);
}
}
驗證結果:
4、總結
這個問題在面試中經常會問到的,而且也是工作中時常會發生的。程式設計師需要學的東西很多,時間久了會遺忘很多之前遇到過的坑,然後再踩一遍,所以以後在工作或學習中遇到一些問題,記錄下來,自己偶爾看看。作為同行,如果我的經驗有幫助到你,我也很開心,本人才疏學淺,如果文章中有錯誤的地方,還望大神不吝指教。