摘要:所以悲觀鎖是限制其他線程,而樂觀鎖是限制自己,雖然他的名字有鎖,但是實際上不算上鎖,只是在最后操作的時候再判斷具體怎么操作。悲觀鎖和樂觀鎖比較悲觀鎖適合寫多讀少的場景。
最近在公司的業務上遇到了并發的問題,并且還是很常見的并發問題,算是低級的失誤了。由于公司業務相對比較復雜且不適合公開,在此用一個很常見的業務來還原一下場景,同時介紹悲觀鎖和樂觀鎖是如何解決這類并發問題的。
公司業務就是最常見的“訂單+賬戶”問題,在解決完公司問題后,轉頭一想,我的博客項目Fame中也有同樣的問題(雖然訪問量根本完全不需要考慮并發問題...),那我就拿這個來舉例好了。
業務還原首先環境是:Spring Boot 2.1.0 + data-jpa + mysql + lombok
數據庫設計對于一個有評論功能的博客系統來說,通常會有兩個表:1.文章表 2.評論表。其中文章表除了保存一些文章信息等,還有個字段保存評論數量。我們設計一個最精簡的表結構來還原該業務場景。
article 文章表
字段 | 類型 | 備注 |
---|---|---|
id | INT | 自增主鍵id |
title | VARCHAR | 文章標題 |
comment_count | INT | 文章的評論數量 |
comment 評論表
字段 | 類型 | 備注 |
---|---|---|
id | INT | 自增主鍵id |
article_id | INT | 評論的文章id |
content | VARCHAR | 評論內容 |
當一個用戶評論的時候,1. 根據文章id獲取到文章 2. 插入一條評論記錄 3. 該文章的評論數增加并保存
代碼實現首先在maven中引入對應的依賴
org.springframework.boot spring-boot-starter-parent 2.1.0.RELEASE org.springframework.boot spring-boot-starter-web org.springframework.boot spring-boot-starter-data-jpa mysql mysql-connector-java org.projectlombok lombok true org.springframework.boot spring-boot-starter-test test
然后編寫對應數據庫的實體類
@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{ }
public interface CommentRepository extends CrudRepository{ }
接著我們就簡單的實現一下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并發問題分析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(() -> { MultiValueMapparams = 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{ @Query(value = "select * from article a where a.id = :id for update", nativeQuery = true) Optional findArticleForUpdate(Long id); }
然后把CommentService中使用的查詢方法由原來的findById改為我們自定義的方法
public class CommentService { ... public void postComment(Long articleId, String content) { // OptionalarticleOptional = articleRepository.findById(articleId); Optional 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{ ... @Lock(value = LockModeType.PESSIMISTIC_WRITE) @Query("select a from Article a where a.id = :id") Optional findArticleWithPessimisticLock(Long id); }
同樣的只要在CommentService里把查詢方法改為findArticleWithPessimisticLock(),再測試用例測一下,肯定不會有并發問題。而且這時看一下控制臺打印信息,發現實際上查詢的sql還是加了for update,只不過是JPA幫我們加了而已。
樂觀鎖解決并發問題樂觀鎖顧名思義就是特別樂觀,認為自己拿到的資源不會被其他線程操作所以不上鎖,只是在插入數據庫的時候再判斷一下數據有沒有被修改。所以悲觀鎖是限制其他線程,而樂觀鎖是限制自己,雖然他的名字有鎖,但是實際上不算上鎖,只是在最后操作的時候再判斷具體怎么操作。
樂觀鎖通常為版本號機制或者CAS算法利用SQL實現版本號解決并發問題
版本號機制就是在數據庫中加一個字段當作版本號,比如我們加個字段version。那么這時候拿到Article的時候就會帶一個版本號,比如拿到的版本是1,然后你對這個Article一通操作,操作完之后要插入到數據庫了。發現哎呀,怎么數據庫里的Article版本是2,和我手里的版本不一樣啊,說明我手里的Article不是最新的了,那么就不能放到數據庫了。這樣就避免了并發時數據沖突的問題。
所以我們現在給article表加一個字段version
article 文章表
字段 | 類型 | 備注 |
---|---|---|
version | INT DEFAULT 0 | 版本號 |
然后對應的實體類也增加version字段
@Data @Entity public class Article { ... private Long version; }
接著在ArticleRepository增加更新的方法,注意這里是更新方法,和悲觀鎖時增加查詢方法不同。
public interface ArticleRepository extends CrudRepository{ @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) { OptionalarticleOptional = 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) { OptionalarticleOptional = articleRepository.findById(articleId); ... article.setCommentCount(article.getCommentCount() + 1); articleRepository.save(article); }
和前面同樣的,用測試用例測試一下能否防止并發問題的出現。
select * from article
select count(*) comment_count from comment
同樣的Article里的comment_count和Comment的數量也不是100,但是這兩個數值肯定是一樣的。看一下IDEA的控制臺會發現系統拋出了ObjectOptimisticLockingFailureException的異常。
這和剛才我們自己實現樂觀鎖類似,如果沒有成功更新數據則拋出異常回滾保證數據的一致性。如果想要實現重試流程可以捕獲ObjectOptimisticLockingFailureException這個異常,通常會利用AOP+自定義注解來實現一個全局通用的重試機制,這里就是要根據具體的業務情況來拓展了,想要了解的可以自行搜索一下方案。
悲觀鎖和樂觀鎖比較悲觀鎖適合寫多讀少的場景。因為在使用的時候該線程會獨占這個資源,在本文的例子來說就是某個id的文章,如果有大量的評論操作的時候,就適合用悲觀鎖,否則用戶只是瀏覽文章而沒什么評論的話,用悲觀鎖就會經常加鎖,增加了加鎖解鎖的資源消耗。
樂觀鎖適合寫少讀多的場景。由于樂觀鎖在發生沖突的時候會回滾或者重試,如果寫的請求量很大的話,就經常發生沖突,經常的回滾和重試,這樣對系統資源消耗也是非常大。
所以悲觀鎖和樂觀鎖沒有絕對的好壞,必須結合具體的業務情況來決定使用哪一種方式。另外在阿里巴巴開發手冊里也有提到:
如果每次訪問沖突概率小于 20%,推薦使用樂觀鎖,否則使用悲觀鎖。樂觀鎖的重試次
數不得小于 3 次。
阿里巴巴建議以沖突概率20%這個數值作為分界線來決定使用樂觀鎖和悲觀鎖,雖然說這個數值不是絕對的,但是作為阿里巴巴各個大佬總結出來的也是一個很好的參考。
原文地址:Spring Boot+SQL/JPA實戰悲觀鎖和樂觀鎖
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/72750.html
摘要:用戶態不能干擾內核態所以指令就有兩種特權指令和非特權指令不同的狀態對應不同的指令。非特權指令所有程序均可直接使用。用戶態常態目態執行非特權指令。 這是我今年從三月份開始,主要的大廠面試經過,有些企業面試的還沒來得及整理,可能有些沒有帶答案就發出來了,還請各位先思考如果是你怎么回答面試官?這篇文章會持續更新,請各位持續關注,希望對你有所幫助! 面試清單 平安產險 飛豬 上汽大通 浩鯨科...
摘要:作者鏈接來源??途W今天剛剛收到的電話,開心,簡單記錄一下美團的面經。當時面試官評價基礎不是很好,其他還行。的三次握手四次揮手。整體感覺美團的面試比較基礎,但是各個方面都有涉及到。 作者:icysnowgx鏈接:https://www.nowcoder.com/disc...來源:??途W 今天剛剛收到hr的電話,開心,簡單記錄一下美團的面經。時間隔的比較久了,簡單回憶下,最后會給出我之前...
摘要:加鎖才能保證線程安全使用之后,不加鎖,也是線程安全的。確保不出現線程安全問題。一般在數據庫中使用樂觀鎖都會拿版本號作為對比值,因為版本號會一直增加,沒有重復的,所以不會出現這個問題。 悲觀鎖: 認為每次獲取數據的時候數據一定會被人修改,所以它在獲取數據的時候會把操作的數據給鎖住,這樣一來就只有它自己能夠操作,其他人都堵塞在那里。 樂觀鎖: 認為每次獲取數據的時候數據不會被別人修改,所以...
閱讀 1990·2021-09-22 16:05
閱讀 9255·2021-09-22 15:03
閱讀 2880·2019-08-30 15:53
閱讀 1698·2019-08-29 11:15
閱讀 903·2019-08-26 13:52
閱讀 2348·2019-08-26 11:32
閱讀 1798·2019-08-26 10:38
閱讀 2562·2019-08-23 17:19