天天看點

Spring Boot+SQL/JPA實戰悲觀鎖和樂觀鎖

【轉載請注明出處】: https://developer.aliyun.com/article/758432

業務還原

首先環境是:Spring Boot 2.1.0 + data-jpa + mysql + lombok

資料庫設計

對于一個有評論功能的部落格系統來說,通常會有兩個表:1.文章表 2.評論表。其中文章表除了儲存一些文章資訊等,還有個字段儲存評論數量。我們設計一個最精簡的表結構來還原該業務場景。

article 文章表

字段 類型 備注
id INT 自增主鍵id
title VARCHAR 文章标題
comment_count 文章的評論數量

comment 評論表

article_id 評論的文章id
content 評論内容

當一個使用者評論的時候,1. 根據文章id擷取到文章 2. 插入一條評論記錄 3. 該文章的評論數增加并儲存

代碼實作

首先在maven中引入對應的依賴

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>2.1.0.RELEASE</version>
    <relativePath/> <!-- lookup parent from repository -->
</parent>

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-jpa</artifactId>
    </dependency>
    <dependency>
        <groupId>mysql</groupId>
        <artifactId>mysql-connector-java</artifactId>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
        <optional>true</optional>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>           

然後編寫對應資料庫的實體類

@Data
@Entity
public class Article {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String title;

    private Long commentCount;
}           
@Data
@Entity
public class Comment {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Long articleId;

    private String content;
}           

接着建立這兩個實體類對應的Repository,由于spring-jpa-data的

CrudRepository

已經幫我們實作了最常見的CRUD操作,是以我們的Repository隻需要繼承

CrudRepository

接口其他啥都不用做。

public interface ArticleRepository extends CrudRepository<Article, Long> {
}           
public interface CommentRepository extends CrudRepository<Comment, Long> {
}           

接着我們就簡單的實作一下Controller接口和Service實作類。

@Slf4j
@RestController
public class CommentController {

    @Autowired
    private CommentService commentService;

    @PostMapping("comment")
    public String comment(Long articleId, String content) {
        try {
            commentService.postComment(articleId, content);
        } catch (Exception e) {
            log.error("{}", e);
            return "error: " + e.getMessage();
        }
        return "success";
    }
}           
@Slf4j
@Service
public class CommentService {
    @Autowired
    private ArticleRepository articleRepository;

    @Autowired
    private CommentRepository commentRepository;

    public void postComment(Long articleId, String content) {
        Optional<Article> articleOptional = articleRepository.findById(articleId);
        if (!articleOptional.isPresent()) {
            throw new RuntimeException("沒有對應的文章");
        }
        Article article = articleOptional.get();

        Comment comment = new Comment();
        comment.setArticleId(articleId);
        comment.setContent(content);
        commentRepository.save(comment);

        article.setCommentCount(article.getCommentCount() + 1);
        articleRepository.save(article);
    }
}           

并發問題分析

從剛才的代碼實作裡可以看出這個簡單的評論功能的流程,當使用者發起評論的請求時,從資料庫找出對應的文章的實體類

Article

,然後根據文章資訊生成對應的評論實體類

Comment

,并且插入到資料庫中,接着增加該文章的評論數量,再把修改後的文章更新到資料庫中,整個流程如下流程圖。

在這個流程中有個問題,當有多個使用者同時并發評論時,他們同時進入步驟1中拿到Article,然後插入對應的Comment,最後在步驟3中更新評論數量儲存到資料庫。隻是由于他們是同時在步驟1拿到的Article,是以他們的Article.commentCount的值相同,那麼在步驟3中儲存的Article.commentCount+1也相同,那麼原來應該+3的評論數量,隻加了1。

我們用測試用例代碼試一下

@RunWith(SpringRunner.class)
@SpringBootTest(classes = LockAndTransactionApplication.class, webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
public class CommentControllerTests {
    @Autowired
    private TestRestTemplate testRestTemplate;

    @Test
    public void concurrentComment() {
        String url = "http://localhost:9090/comment";
        for (int i = 0; i < 100; i++) {
            int finalI = i;
            new Thread(() -> {
                MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
                params.add("articleId", "1");
                params.add("content", "測試内容" + finalI);
                String result = testRestTemplate.postForObject(url, params, String.class);
            }).start();
        }

    }
}           

這裡我們開了100個線程,同時發送評論請求,對應的文章id為1。

在發送請求前,資料庫資料為

select * from article           
select count(*) comment_count from comment           

發送請求後,資料庫資料為

select * from article           
select count(*) comment_count from comment           

明顯的看到在article表裡的comment_count的值不是100,這個值不一定是我圖裡的14,但是必然是不大于100的,而comment表的數量肯定等于100。

這就展示了在文章開頭裡提到的并發問題,這種問題其實十分的常見,隻要有類似上面這樣評論功能的流程的系統,都要小心避免出現這種問題。

下面就用執行個體展示展示如何通過悲觀鎖和樂觀鎖防止出現并發資料問題,同時給出SQL方案和JPA自帶方案,SQL方案可以通用“任何系統”,甚至不限語言,而JPA方案十分快捷,如果你恰好用的也是JPA,那就可以簡單的使用上樂觀鎖或悲觀鎖。最後也會根據業務比較一下樂觀鎖和悲觀鎖的一些差別

悲觀鎖解決并發問題

悲觀鎖顧名思義就是悲觀的認為自己操作的資料都會被其他線程操作,是以就必須自己獨占這個資料,可以了解為”獨占鎖“。在java中

synchronized

ReentrantLock

等鎖就是悲觀鎖,資料庫中表鎖、行鎖、讀寫鎖等也是悲觀鎖。

利用SQL解決并發問題

行鎖就是操作資料的時候把這一行資料鎖住,其他線程想要讀寫必須等待,但同一個表的其他資料還是能被其他線程操作的。隻要在需要查詢的sql後面加上

for update

,就能鎖住查詢的行,特别要注意查詢條件必須要是索引列,如果不是索引就會變成表鎖,把整個表都鎖住。

現在在原有的代碼的基礎上修改一下,先在

ArticleRepository

增加一個手動寫sql查詢方法。

public interface ArticleRepository extends CrudRepository<Article, Long> {
    @Query(value = "select * from article a where a.id = :id for update", nativeQuery = true)
    Optional<Article> findArticleForUpdate(Long id);
}           

然後把

CommentService

中使用的查詢方法由原來的

findById

改為我們自定義的方法

public class CommentService {
    ...

    public void postComment(Long articleId, String content) {
        // Optional<Article> articleOptional = articleRepository.findById(articleId);
        Optional<Article> articleOptional = articleRepository.findArticleForUpdate(articleId);

        ...
    }
}           

這樣我們查出來的

Article

,在我們沒有将其送出事務之前,其他線程是不能擷取修改的,保證了同時隻有一個線程能操作對應資料。

現在再用測試用例測一下,

article.comment_count

的值必定是100。

利用JPA自帶行鎖解決并發問題

對于剛才提到的在sql後面增加

for update

,JPA有提供一個更優雅的方式,就是

@Lock

注解,這個注解的參數可以傳入想要的鎖級别。

現在在

ArticleRepository

中增加JPA的鎖方法,其中

LockModeType.PESSIMISTIC_WRITE

參數就是行鎖。

public interface ArticleRepository extends CrudRepository<Article, Long> {
    ...

    @Lock(value = LockModeType.PESSIMISTIC_WRITE)
    @Query("select a from Article a where a.id = :id")
    Optional<Article> findArticleWithPessimisticLock(Long id);
}           

同樣的隻要在

CommentService

裡把查詢方法改為

findArticleWithPessimisticLock()

,再測試用例測一下,肯定不會有并發問題。而且這時看一下控制台列印資訊,發現實際上查詢的sql還是加了

for update

,隻不過是JPA幫我們加了而已。

樂觀鎖解決并發問題

樂觀鎖顧名思義就是特别樂觀,認為自己拿到的資源不會被其他線程操作是以不上鎖,隻是在插入資料庫的時候再判斷一下資料有沒有被修改。是以悲觀鎖是限制其他線程,而樂觀鎖是限制自己,雖然他的名字有鎖,但是實際上不算上鎖,隻是在最後操作的時候再判斷具體怎麼操作。

樂觀鎖通常為版本号機制或者CAS算法

利用SQL實作版本号解決并發問題

版本号機制就是在資料庫中加一個字段當作版本号,比如我們加個字段version。那麼這時候拿到

Article

的時候就會帶一個版本号,比如拿到的版本是1,然後你對這個

Article

一通操作,操作完之後要插入到資料庫了。發現哎呀,怎麼資料庫裡的

Article

版本是2,和我手裡的版本不一樣啊,說明我手裡的

Article

不是最新的了,那麼就不能放到資料庫了。這樣就避免了并發時資料沖突的問題。

是以我們現在給article表加一個字段version

version INT DEFAULT 0 版本号

然後對應的實體類也增加version字段

@Data
@Entity
public class Article {
    ...

    private Long version;
}           

接着在

ArticleRepository

增加更新的方法,注意這裡是更新方法,和悲觀鎖時增加查詢方法不同。

public interface ArticleRepository extends CrudRepository<Article, Long> {
    @Modifying
    @Query(value = "update article set comment_count = :commentCount, version = version + 1 where id = :id and version = :version", nativeQuery = true)
    int updateArticleWithVersion(Long id, Long commentCount, Long version);
}           

可以看到update的where有一個判斷version的條件,并且會set version = version + 1。這就保證了隻有當資料庫裡的版本号和要更新的實體類的版本号相同的時候才會更新資料。

CommentService

裡稍微修改一下代碼。

// CommentService
public void postComment(Long articleId, String content) {
    Optional<Article> articleOptional = articleRepository.findById(articleId);

    ...    

    int count = articleRepository.updateArticleWithVersion(article.getId(), article.getCommentCount() + 1, article.getVersion());
    if (count == 0) {
        throw new RuntimeException("伺服器繁忙,更新資料失敗");
    }
    // articleRepository.save(article);
}           

首先對于

Article

的查詢方法隻需要普通的

findById()

方法就行不用上任何鎖。

然後更新

Article

的時候改用新加的

updateArticleWithVersion()

方法。可以看到這個方法有個傳回值,這個傳回值代表更新了的資料庫行數,如果值為0的時候表示沒有符合條件可以更新的行。

這之後就可以由我們自己決定怎麼處理了,這裡是直接復原,spring就會幫我們復原之前的資料操作,把這次的所有操作都取消以保證資料的一緻性。

現在再用測試用例測一下

select * from article           
select count(*) comment_count from comment           

現在看到

Article

裡的comment_count和

Comment

的數量都不是100了,但是這兩個的值必定是一樣的了。因為剛才我們處理的時候假如

Article

表的資料發生了沖突,那麼就不會更新到資料庫裡,這時抛出異常使其事務復原,這樣就能保證沒有更新

Article

的時候

Comment

也不會插入,就解決了資料不統一的問題。

這種直接復原的處理方式使用者體驗比較差,通常來說如果判斷

Article

更新條數為0時,會嘗試重新從資料庫裡查詢資訊并重新修改,再次嘗試更新資料,如果不行就再查詢,直到能夠更新為止。當然也不會是無線的循環這樣的操作,會設定一個上線,比如循環3次查詢修改更新都不行,這時候才會抛出異常。

利用JPA實作版本現解決并發問題

JPA對悲觀鎖有實作方式,樂觀鎖自然也是有的,現在就用JPA自帶的方法實作樂觀鎖。

首先在

Article

實體類的version字段上加上

@Version

注解,我們進注解看一下源碼的注釋,可以看到有部分寫到:

The following types are supported for version properties: int, Integer, short, Short, long, Long, java.sql.Timestamp.

注釋裡面說版本号的類型支援int, short, long三種基本資料類型和他們的包裝類以及Timestamp,我們現在用的是Long類型。

@Data
@Entity
public class Article {
    ...

    @Version
    private Long version;
}           

接着隻需要在

CommentService

裡的評論流程修改回我們最開頭的“會觸發并發問題”的業務代碼就行了。說明JPA的這種樂觀鎖實作方式是非侵入式的。

// CommentService
public void postComment(Long articleId, String content) {
    Optional<Article> articleOptional = articleRepository.findById(articleId);
    ...

    article.setCommentCount(article.getCommentCount() + 1);
    articleRepository.save(article);
}           

和前面同樣的,用測試用例測試一下能否防止并發問題的出現。

select * from article           
select count(*) comment_count from comment           

同樣的

Article

Comment

的數量也不是100,但是這兩個數值肯定是一樣的。看一下IDEA的控制台會發現系統抛出了

ObjectOptimisticLockingFailureException

的異常。

這和剛才我們自己實作樂觀鎖類似,如果沒有成功更新資料則抛出異常復原保證資料的一緻性。如果想要實作重試流程可以捕獲

ObjectOptimisticLockingFailureException

這個異常,通常會利用AOP+自定義注解來實作一個全局通用的重試機制,這裡就是要根據具體的業務情況來拓展了,想要了解的可以自行搜尋一下方案。

悲觀鎖和樂觀鎖比較

悲觀鎖适合寫多讀少的場景。因為在使用的時候該線程會獨占這個資源,在本文的例子來說就是某個id的文章,如果有大量的評論操作的時候,就适合用悲觀鎖,否則使用者隻是浏覽文章而沒什麼評論的話,用悲觀鎖就會經常加鎖,增加了加鎖解鎖的資源消耗。

樂觀鎖适合寫少讀多的場景。由于樂觀鎖在發生沖突的時候會復原或者重試,如果寫的請求量很大的話,就經常發生沖突,經常的復原和重試,這樣對系統資源消耗也是非常大。

是以悲觀鎖和樂觀鎖沒有絕對的好壞,必須結合具體的業務情況來決定使用哪一種方式。另外在阿裡巴巴開發手冊裡也有提到:

如果每次通路沖突機率小于 20%,推薦使用樂觀鎖,否則使用悲觀鎖。樂觀鎖的重試次

數不得小于 3 次。

阿裡巴巴建議以沖突機率20%這個數值作為分界線來決定使用樂觀鎖和悲觀鎖,雖然說這個數值不是絕對的,但是作為阿裡巴巴各個大佬總結出來的也是一個很好的參考。