【轉(zhuǎn)載請(qǐng)注明出處】:http://www.reibang.com/p/6287599cd0fd
業(yè)務(wù)還原
首先環(huán)境是:Spring Boot 2.1.0 + data-jpa + mysql + lombok
數(shù)據(jù)庫(kù)設(shè)計(jì)
對(duì)于一個(gè)有評(píng)論功能的博客系統(tǒng)來(lái)說(shuō)梧税,通常會(huì)有兩個(gè)表:1.文章表 2.評(píng)論表沦疾。其中文章表除了保存一些文章信息等,還有個(gè)字段保存評(píng)論數(shù)量第队。我們?cè)O(shè)計(jì)一個(gè)最精簡(jiǎn)的表結(jié)構(gòu)來(lái)還原該業(yè)務(wù)場(chǎng)景哮塞。
article 文章表
字段 | 類(lèi)型 | 備注 |
---|---|---|
id | INT | 自增主鍵id |
title | VARCHAR | 文章標(biāo)題 |
comment_count | INT | 文章的評(píng)論數(shù)量 |
comment 評(píng)論表
字段 | 類(lèi)型 | 備注 |
---|---|---|
id | INT | 自增主鍵id |
article_id | INT | 評(píng)論的文章id |
content | VARCHAR | 評(píng)論內(nèi)容 |
當(dāng)一個(gè)用戶(hù)評(píng)論的時(shí)候,1. 根據(jù)文章id獲取到文章 2. 插入一條評(píng)論記錄 3. 該文章的評(píng)論數(shù)增加并保存
代碼實(shí)現(xiàn)
首先在maven中引入對(duì)應(yīng)的依賴(lài)
<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>
然后編寫(xiě)對(duì)應(yīng)數(shù)據(jù)庫(kù)的實(shí)體類(lèi)
@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;
}
接著創(chuàng)建這兩個(gè)實(shí)體類(lèi)對(duì)應(yīng)的Repository凳谦,由于spring-jpa-data的CrudRepository
已經(jīng)幫我們實(shí)現(xiàn)了最常見(jiàn)的CRUD操作忆畅,所以我們的Repository只需要繼承CrudRepository
接口其他啥都不用做。
public interface ArticleRepository extends CrudRepository<Article, Long> {
}
public interface CommentRepository extends CrudRepository<Comment, Long> {
}
接著我們就簡(jiǎn)單的實(shí)現(xiàn)一下Controller接口和Service實(shí)現(xiàn)類(lèi)尸执。
@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("沒(méi)有對(duì)應(yīng)的文章");
}
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);
}
}
并發(fā)問(wèn)題分析
從剛才的代碼實(shí)現(xiàn)里可以看出這個(gè)簡(jiǎn)單的評(píng)論功能的流程家凯,當(dāng)用戶(hù)發(fā)起評(píng)論的請(qǐng)求時(shí),從數(shù)據(jù)庫(kù)找出對(duì)應(yīng)的文章的實(shí)體類(lèi)Article
剔交,然后根據(jù)文章信息生成對(duì)應(yīng)的評(píng)論實(shí)體類(lèi)Comment
肆饶,并且插入到數(shù)據(jù)庫(kù)中,接著增加該文章的評(píng)論數(shù)量岖常,再把修改后的文章更新到數(shù)據(jù)庫(kù)中,整個(gè)流程如下流程圖葫督。
在這個(gè)流程中有個(gè)問(wèn)題竭鞍,當(dāng)有多個(gè)用戶(hù)同時(shí)并發(fā)評(píng)論時(shí),他們同時(shí)進(jìn)入步驟1中拿到Article橄镜,然后插入對(duì)應(yīng)的Comment偎快,最后在步驟3中更新評(píng)論數(shù)量保存到數(shù)據(jù)庫(kù)。只是由于他們是同時(shí)在步驟1拿到的Article洽胶,所以他們的Article.commentCount的值相同晒夹,那么在步驟3中保存的Article.commentCount+1也相同裆馒,那么原來(lái)應(yīng)該+3的評(píng)論數(shù)量,只加了1丐怯。
我們用測(cè)試用例代碼試一下
@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", "測(cè)試內(nèi)容" + finalI);
String result = testRestTemplate.postForObject(url, params, String.class);
}).start();
}
}
}
這里我們開(kāi)了100個(gè)線(xiàn)程喷好,同時(shí)發(fā)送評(píng)論請(qǐng)求,對(duì)應(yīng)的文章id為1读跷。
在發(fā)送請(qǐng)求前梗搅,數(shù)據(jù)庫(kù)數(shù)據(jù)為
select * from article
select count(*) comment_count from comment
發(fā)送請(qǐng)求后,數(shù)據(jù)庫(kù)數(shù)據(jù)為
select * from article
select count(*) comment_count from comment
明顯的看到在article表里的comment_count的值不是100效览,這個(gè)值不一定是我圖里的14无切,但是必然是不大于100的,而comment表的數(shù)量肯定等于100丐枉。
這就展示了在文章開(kāi)頭里提到的并發(fā)問(wèn)題哆键,這種問(wèn)題其實(shí)十分的常見(jiàn),只要有類(lèi)似上面這樣評(píng)論功能的流程的系統(tǒng)瘦锹,都要小心避免出現(xiàn)這種問(wèn)題籍嘹。
下面就用實(shí)例展示展示如何通過(guò)悲觀鎖和樂(lè)觀鎖防止出現(xiàn)并發(fā)數(shù)據(jù)問(wèn)題,同時(shí)給出SQL方案和JPA自帶方案沼本,SQL方案可以通用“任何系統(tǒng)”噩峦,甚至不限語(yǔ)言,而JPA方案十分快捷抽兆,如果你恰好用的也是JPA识补,那就可以簡(jiǎn)單的使用上樂(lè)觀鎖或悲觀鎖。最后也會(huì)根據(jù)業(yè)務(wù)比較一下樂(lè)觀鎖和悲觀鎖的一些區(qū)別
悲觀鎖解決并發(fā)問(wèn)題
悲觀鎖顧名思義就是悲觀的認(rèn)為自己操作的數(shù)據(jù)都會(huì)被其他線(xiàn)程操作辫红,所以就必須自己獨(dú)占這個(gè)數(shù)據(jù)凭涂,可以理解為”獨(dú)占鎖“。在java中synchronized
和ReentrantLock
等鎖就是悲觀鎖贴妻,數(shù)據(jù)庫(kù)中表鎖切油、行鎖、讀寫(xiě)鎖等也是悲觀鎖名惩。
利用SQL解決并發(fā)問(wèn)題
行鎖就是操作數(shù)據(jù)的時(shí)候把這一行數(shù)據(jù)鎖住澎胡,其他線(xiàn)程想要讀寫(xiě)必須等待,但同一個(gè)表的其他數(shù)據(jù)還是能被其他線(xiàn)程操作的娩鹉。只要在需要查詢(xún)的sql后面加上for update
攻谁,就能鎖住查詢(xún)的行,特別要注意查詢(xún)條件必須要是索引列弯予,如果不是索引就會(huì)變成表鎖戚宦,把整個(gè)表都鎖住。
現(xiàn)在在原有的代碼的基礎(chǔ)上修改一下锈嫩,先在ArticleRepository
增加一個(gè)手動(dòng)寫(xiě)sql查詢(xún)方法受楼。
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
中使用的查詢(xún)方法由原來(lái)的findById
改為我們自定義的方法
public class CommentService {
...
public void postComment(Long articleId, String content) {
// Optional<Article> articleOptional = articleRepository.findById(articleId);
Optional<Article> articleOptional = articleRepository.findArticleForUpdate(articleId);
...
}
}
這樣我們查出來(lái)的Article
垦搬,在我們沒(méi)有將其提交事務(wù)之前,其他線(xiàn)程是不能獲取修改的艳汽,保證了同時(shí)只有一個(gè)線(xiàn)程能操作對(duì)應(yīng)數(shù)據(jù)猴贰。
現(xiàn)在再用測(cè)試用例測(cè)一下,article.comment_count
的值必定是100骚灸。
利用JPA自帶行鎖解決并發(fā)問(wèn)題
對(duì)于剛才提到的在sql后面增加for update
糟趾,JPA有提供一個(gè)更優(yōu)雅的方式,就是@Lock
注解甚牲,這個(gè)注解的參數(shù)可以傳入想要的鎖級(jí)別义郑。
現(xiàn)在在ArticleRepository
中增加JPA的鎖方法,其中LockModeType.PESSIMISTIC_WRITE
參數(shù)就是行鎖丈钙。
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
里把查詢(xún)方法改為findArticleWithPessimisticLock()
非驮,再測(cè)試用例測(cè)一下,肯定不會(huì)有并發(fā)問(wèn)題雏赦。而且這時(shí)看一下控制臺(tái)打印信息劫笙,發(fā)現(xiàn)實(shí)際上查詢(xún)的sql還是加了for update
,只不過(guò)是JPA幫我們加了而已星岗。
樂(lè)觀鎖解決并發(fā)問(wèn)題
樂(lè)觀鎖顧名思義就是特別樂(lè)觀填大,認(rèn)為自己拿到的資源不會(huì)被其他線(xiàn)程操作所以不上鎖,只是在插入數(shù)據(jù)庫(kù)的時(shí)候再判斷一下數(shù)據(jù)有沒(méi)有被修改俏橘。所以悲觀鎖是限制其他線(xiàn)程允华,而樂(lè)觀鎖是限制自己,雖然他的名字有鎖寥掐,但是實(shí)際上不算上鎖靴寂,只是在最后操作的時(shí)候再判斷具體怎么操作。
樂(lè)觀鎖通常為版本號(hào)機(jī)制或者CAS算法
利用SQL實(shí)現(xiàn)版本號(hào)解決并發(fā)問(wèn)題
版本號(hào)機(jī)制就是在數(shù)據(jù)庫(kù)中加一個(gè)字段當(dāng)作版本號(hào)召耘,比如我們加個(gè)字段version百炬。那么這時(shí)候拿到Article
的時(shí)候就會(huì)帶一個(gè)版本號(hào),比如拿到的版本是1污它,然后你對(duì)這個(gè)Article
一通操作剖踊,操作完之后要插入到數(shù)據(jù)庫(kù)了。發(fā)現(xiàn)哎呀衫贬,怎么數(shù)據(jù)庫(kù)里的Article
版本是2蜜宪,和我手里的版本不一樣啊,說(shuō)明我手里的Article
不是最新的了祥山,那么就不能放到數(shù)據(jù)庫(kù)了。這樣就避免了并發(fā)時(shí)數(shù)據(jù)沖突的問(wèn)題掉伏。
所以我們現(xiàn)在給article表加一個(gè)字段version
article 文章表
字段 | 類(lèi)型 | 備注 |
---|---|---|
version | INT DEFAULT 0 | 版本號(hào) |
然后對(duì)應(yīng)的實(shí)體類(lèi)也增加version字段
@Data
@Entity
public class Article {
...
private Long version;
}
接著在ArticleRepository
增加更新的方法缝呕,注意這里是更新方法澳窑,和悲觀鎖時(shí)增加查詢(xún)方法不同。
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有一個(gè)判斷version的條件供常,并且會(huì)set version = version + 1摊聋。這就保證了只有當(dāng)數(shù)據(jù)庫(kù)里的版本號(hào)和要更新的實(shí)體類(lèi)的版本號(hào)相同的時(shí)候才會(huì)更新數(shù)據(jù)。
接著在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("服務(wù)器繁忙,更新數(shù)據(jù)失敗");
}
// articleRepository.save(article);
}
首先對(duì)于Article
的查詢(xún)方法只需要普通的findById()
方法就行不用上任何鎖麻裁。
然后更新Article
的時(shí)候改用新加的updateArticleWithVersion()
方法≡雌恚可以看到這個(gè)方法有個(gè)返回值煎源,這個(gè)返回值代表更新了的數(shù)據(jù)庫(kù)行數(shù),如果值為0的時(shí)候表示沒(méi)有符合條件可以更新的行香缺。
這之后就可以由我們自己決定怎么處理了手销,這里是直接回滾,spring就會(huì)幫我們回滾之前的數(shù)據(jù)操作图张,把這次的所有操作都取消以保證數(shù)據(jù)的一致性锋拖。
現(xiàn)在再用測(cè)試用例測(cè)一下
select * from article
select count(*) comment_count from comment
現(xiàn)在看到Article
里的comment_count和Comment
的數(shù)量都不是100了,但是這兩個(gè)的值必定是一樣的了祸轮。因?yàn)閯偛盼覀兲幚淼臅r(shí)候假如Article
表的數(shù)據(jù)發(fā)生了沖突兽埃,那么就不會(huì)更新到數(shù)據(jù)庫(kù)里,這時(shí)拋出異常使其事務(wù)回滾适袜,這樣就能保證沒(méi)有更新Article
的時(shí)候Comment
也不會(huì)插入柄错,就解決了數(shù)據(jù)不統(tǒng)一的問(wèn)題。
這種直接回滾的處理方式用戶(hù)體驗(yàn)比較差痪蝇,通常來(lái)說(shuō)如果判斷Article
更新條數(shù)為0時(shí)鄙陡,會(huì)嘗試重新從數(shù)據(jù)庫(kù)里查詢(xún)信息并重新修改,再次嘗試更新數(shù)據(jù)躏啰,如果不行就再查詢(xún)趁矾,直到能夠更新為止。當(dāng)然也不會(huì)是無(wú)線(xiàn)的循環(huán)這樣的操作给僵,會(huì)設(shè)置一個(gè)上線(xiàn)毫捣,比如循環(huán)3次查詢(xún)修改更新都不行,這時(shí)候才會(huì)拋出異常帝际。
利用JPA實(shí)現(xiàn)版本現(xiàn)解決并發(fā)問(wèn)題
JPA對(duì)悲觀鎖有實(shí)現(xiàn)方式蔓同,樂(lè)觀鎖自然也是有的,現(xiàn)在就用JPA自帶的方法實(shí)現(xiàn)樂(lè)觀鎖蹲诀。
首先在Article
實(shí)體類(lèi)的version字段上加上@Version
注解斑粱,我們進(jìn)注解看一下源碼的注釋?zhuān)梢钥吹接胁糠謱?xiě)到:
The following types are supported for version properties: int, Integer, short, Short, long, Long, java.sql.Timestamp.
注釋里面說(shuō)版本號(hào)的類(lèi)型支持int, short, long三種基本數(shù)據(jù)類(lèi)型和他們的包裝類(lèi)以及Timestamp,我們現(xiàn)在用的是Long類(lèi)型脯爪。
@Data
@Entity
public class Article {
...
@Version
private Long version;
}
接著只需要在CommentService
里的評(píng)論流程修改回我們最開(kāi)頭的“會(huì)觸發(fā)并發(fā)問(wèn)題”的業(yè)務(wù)代碼就行了则北。說(shuō)明JPA的這種樂(lè)觀鎖實(shí)現(xiàn)方式是非侵入式的矿微。
// CommentService
public void postComment(Long articleId, String content) {
Optional<Article> articleOptional = articleRepository.findById(articleId);
...
article.setCommentCount(article.getCommentCount() + 1);
articleRepository.save(article);
}
和前面同樣的,用測(cè)試用例測(cè)試一下能否防止并發(fā)問(wèn)題的出現(xiàn)尚揣。
select * from article
select count(*) comment_count from comment
同樣的Article
里的comment_count和Comment
的數(shù)量也不是100涌矢,但是這兩個(gè)數(shù)值肯定是一樣的】炱看一下IDEA的控制臺(tái)會(huì)發(fā)現(xiàn)系統(tǒng)拋出了ObjectOptimisticLockingFailureException
的異常娜庇。
這和剛才我們自己實(shí)現(xiàn)樂(lè)觀鎖類(lèi)似,如果沒(méi)有成功更新數(shù)據(jù)則拋出異撤嚼海回滾保證數(shù)據(jù)的一致性名秀。如果想要實(shí)現(xiàn)重試流程可以捕獲ObjectOptimisticLockingFailureException
這個(gè)異常,通常會(huì)利用AOP+自定義注解來(lái)實(shí)現(xiàn)一個(gè)全局通用的重試機(jī)制恭取,這里就是要根據(jù)具體的業(yè)務(wù)情況來(lái)拓展了泰偿,想要了解的可以自行搜索一下方案。
悲觀鎖和樂(lè)觀鎖比較
悲觀鎖適合寫(xiě)多讀少的場(chǎng)景蜈垮。因?yàn)樵谑褂玫臅r(shí)候該線(xiàn)程會(huì)獨(dú)占這個(gè)資源耗跛,在本文的例子來(lái)說(shuō)就是某個(gè)id的文章,如果有大量的評(píng)論操作的時(shí)候攒发,就適合用悲觀鎖调塌,否則用戶(hù)只是瀏覽文章而沒(méi)什么評(píng)論的話(huà),用悲觀鎖就會(huì)經(jīng)常加鎖惠猿,增加了加鎖解鎖的資源消耗羔砾。
樂(lè)觀鎖適合寫(xiě)少讀多的場(chǎng)景。由于樂(lè)觀鎖在發(fā)生沖突的時(shí)候會(huì)回滾或者重試偶妖,如果寫(xiě)的請(qǐng)求量很大的話(huà)姜凄,就經(jīng)常發(fā)生沖突,經(jīng)常的回滾和重試趾访,這樣對(duì)系統(tǒng)資源消耗也是非常大态秧。
所以悲觀鎖和樂(lè)觀鎖沒(méi)有絕對(duì)的好壞,必須結(jié)合具體的業(yè)務(wù)情況來(lái)決定使用哪一種方式扼鞋。另外在阿里巴巴開(kāi)發(fā)手冊(cè)里也有提到:
如果每次訪(fǎng)問(wèn)沖突概率小于 20%申鱼,推薦使用樂(lè)觀鎖,否則使用悲觀鎖云头。樂(lè)觀鎖的重試次
數(shù)不得小于 3 次捐友。
阿里巴巴建議以沖突概率20%這個(gè)數(shù)值作為分界線(xiàn)來(lái)決定使用樂(lè)觀鎖和悲觀鎖,雖然說(shuō)這個(gè)數(shù)值不是絕對(duì)的溃槐,但是作為阿里巴巴各個(gè)大佬總結(jié)出來(lái)的也是一個(gè)很好的參考匣砖。
【轉(zhuǎn)載請(qǐng)注明出處】:http://www.reibang.com/p/6287599cd0fd