1. 程式人生 > >【原創】004 | 搭上SpringBoot事務詭異事件分析專車

【原創】004 | 搭上SpringBoot事務詭異事件分析專車

前言

如果這是你第二次看到師長,說明你在覬覦我的美色!

點贊+關注再看,養成習慣

沒別的意思,就是需要你的窺屏^_^

本專車系列文章

目前連載到第四篇,本專題是深入講解Springboot原始碼,畢竟是原始碼分析,相對會比較枯燥,但是通讀下來會讓你對boot有個透徹的理解!初級boot實戰小白教程,我後續也會出。大家放心。 前面三篇,還沒看過的大家可以看看。

【原創】001 | 搭上SpringBoot自動注入原始碼分析專車

【原創】002 | 搭上SpringBoot事務原始碼分析專車

【原創】003 | 搭上基於SpringBoot事務思想實戰專車

專車介紹

該趟專車是第四篇,開往Spring Boot事務詭異事件的專車,主要來複現和分析事務的詭異事件。

專車問題

  • @Transaction標註的同步方法,在多執行緒訪問情況下,為什麼還會出現髒資料?
  • 在service中通過this呼叫事務方法,為什麼事務就不起效了?

專車示例

示例一

控制器程式碼

@RestController
@RequestMapping("/test")
public class TestController {

    @Autowired
    private TestService testService;

    /**
     * @param id
     */
    @RequestMapping("/addStudentAge/{id}")
    public void addStudentAge(@PathVariable(name = "id") Integer id){
        for (int i = 0; i < 1000; i++) {
            new Thread(() -> {
                try {
                    testService.addStudentAge(id);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
        }
    }
}

service程式碼

@Service
public class TestService {

    @Autowired
    private StudentMapper studentMapper;

    @Autowired
    private TestService testService;
    
    @Transactional(rollbackFor = Exception.class)
    public synchronized void addStudentAge(Integer id) throws InterruptedException {
        Student student = studentMapper.getStudentById(id);
        studentMapper.updateStudentAgeById(student);
    }
}

示例程式碼很簡單,開啟1000個執行緒呼叫service的方法,service先從資料庫中查詢出使用者資訊,然後對使用者的年齡進行 + 1操作,service方法具有事務特性和同步特性。那麼大家來猜一下最終的結果是多少?

示例二

控制器程式碼

@RestController
@RequestMapping("/test")
public class TestController {

    @Autowired
    private TestService testService;

    @RequestMapping("/addStudent")
    public void addStudent(@RequestBody Student student) {
        testService.middleMethod(student);
    }
}

service程式碼

@Service
public class TestService {

    @Autowired
    private StudentMapper studentMapper;
    
    public void middleMethod(Student student) {
        // 請注意此處使用的是this
        this.addStudent(student);
    }
    
    @Transactional(rollbackFor = Exception.class)
    public void addStudent(Student student) {
        this.studentMapper.saveStudent(student);
        System.out.println(1/ 0);
    }
}

示例程式碼同樣很簡單,首先往資料庫中插入一條資料,然後輸出1 / 0的結果,那麼大家再猜一下資料庫中會不會插入一條記錄?

專車分析

示例一結果

執行順序 id Name Age
執行前 10001 xxx 0
執行後 10001 xxx 994

從如上資料庫結果可以看到,開啟1000個執行緒執行所謂帶有事務、同步特性的方法,結果並沒有1000,出現了髒資料。

示例一分析

我們再來看一下示例一的程式碼

@Service
public class TestService {

    @Autowired
    private StudentMapper studentMapper;

    @Autowired
    private TestService testService;
    
    @Transactional(rollbackFor = Exception.class)
    public synchronized void addStudentAge(Integer id) throws InterruptedException {
        Student student = studentMapper.getStudentById(id);
        studentMapper.updateStudentAgeById(student);
    }
}

我們可以把如上方法轉換成如下方法

@Service
public class TestService {

    @Autowired
    private StudentMapper studentMapper;

    @Autowired
    private TestService testService;
    
    // 事務切面,開啟事務
    public synchronized void addStudentAge(Integer id) throws InterruptedException {
        Student student = studentMapper.getStudentById(id);
        studentMapper.updateStudentAgeById(student);
    }
    // 事務切面,提交或者回滾事務
}

通過轉換我們可以清楚的看到方法執行完成後就釋放鎖,此時事務還沒來得及提交,下一個請求就進來了,讀取到的是上一個事務提交之前的結果,這樣就會導致最終髒資料的出現。

示例一解決方案

解決的重點:就是我們要在事務執行完成之後才釋放鎖,這樣可以保證前一個請求實實在在執行完成,包括提交事務才允許下一個請求來執行,可以保證結果的正確性。

解決示例程式碼

@RequestMapping("/addStudentAge1/{id}")
public void addStudentAge1(@PathVariable(name = "id") Integer id){
    for (int i = 0; i < 1000; i++) {
        new Thread(() -> {
            try {
                synchronized (this) {
                    testService.addStudentAge1(id);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

可以看到,加鎖的程式碼包含了事務程式碼,可以保證事務執行完成才釋放鎖。

示例一解決方案結果

執行順序 id Name Age
執行前 10001 xxx 0
執行後 10001 xxx 1000

可以看到資料庫中的結果最終和我們想要的結果是一致的。

示例二結果

執行順序 id Name Age
執行前 10001 xxx 1000
執行後 66666 transaction 22

可以看到即便執行的程式碼具有事務特性,並且事務方法裡面執行了會報錯的程式碼,資料庫中最終還是插入了一條資料,完全不符合事務的特性。

示例二分析

我們在來看下示例二的程式碼

@Service
public class TestService {

    @Autowired
    private StudentMapper studentMapper;
    
    public void middleMethod(Student student) {
        // 請注意此處使用的是this
        this.addStudent(student);
    }
    
    @Transactional(rollbackFor = Exception.class)
    public void addStudent(Student student) {
        this.studentMapper.saveStudent(student);
        System.out.println(1/ 0);
    }
}

可以看到middleMethod方法是通過this來呼叫其它事務方法,那麼就是方法間的普通呼叫,不存在任何的代理,也就不存在事務特性一說。所以最終即便方法報錯,資料庫也插入了一條記錄,是因為該方法雖被 @Transactional註解標註,卻不具備事務的功能。

示例二解決方案

解決方案很簡單,使用被代理物件來替換this

public void middleMethod1(Student student) {
    testService.addStudent(student);
}

因為testService物件是被代理的物件,呼叫被代理物件的方法的時候,會執行回撥,在回撥中開啟事務、執行目標方法、提交或者回滾事務。

示例二解決方案結果

執行順序 id Name Age
執行前 10001 xxx 1000

可以看到資料庫中並沒有插入新的記錄,說明我們service方法具有了事務的特性。

專車總結

研讀@Transactional原始碼並不只是為了讀懂事務是怎麼實現的,還可以幫助我們快速定位問題的源頭,並解決問題。

專車回顧

下面我們來回顧下開頭的兩個問題:

  • @Transaction標註的同步方法,在多執行緒訪問情況下,為什麼還會出現髒資料?是因為事務在鎖外層,鎖釋放了,事務還沒有提交。解決方案就是讓鎖來包裹事務,保證事務執行完成才釋放鎖。
  • 在service中通過this呼叫事務方法,為什麼事務就不起效了?因為this指的是當前物件,只是方法見的普通呼叫,並不能開啟事務特性。瞭解事務的我們都知道事務是通過代理來實現的,那麼我們需要使用被代理物件來呼叫service中的方法,就可以開啟事務特性了。

最後

師長,【java進階架構師】號主,短短一年在各大平臺斬獲15W+程式設計師關注,專注分享Java進階、架構技術、高併發、微服務、BAT面試、redis專題、JVM調優、Springboot原始碼、mysql優化等20大進階架構專題。