搶紅包案例分析以及程式碼實現(二)

Java團長_發表於2018-11-07

概述

上一篇文章中使用ssm+mysql實現,存在併發超發問題,這裡我們使用悲觀鎖的方式來解決這個邏輯錯誤,並驗證資料一致性和效能狀況。


超發問題分析

針對這個案例,使用者搶到紅包後,紅包總量應-1,當多個使用者同時搶紅包,此時多個執行緒同時讀得庫存為n,相應的邏輯執行後,最後將均執update T_RED_PACKET set stock = stock - 1 where id = #{id} ,很明顯這是錯誤的。


使用資料庫鎖的解決方案

使用悲觀鎖(排它鎖 for update)

  1. 執行緒1在查詢紅包數時使用排他鎖 select id, user_id as userId, amount, send_date as sendDate, total, unit_amount as unitAmount, stock, version, note from T_RED_PACKET where id = #{id} for update

  2. 然後進行後續的操作(redPacketDao.decreaseRedPacket 和 userRedPacketDao.grapRedPacket),更新紅包數量,最後提交事務。

  3. 執行緒2在查詢紅包數時,如果執行緒1還未釋放排他鎖,它將等待。

  4. 執行緒3同執行緒2,依次類推。


使用樂觀鎖(依靠表的設計和程式碼)

  1. 在紅包表新增version版本欄位或者timestamp時間戳欄位,這裡我們使用version

  2. 執行緒1查詢後,執行更新變成了update T_RED_PACKET set stock = stock - 1, version = version + 1 where id = #{id} and version = #{version}


這樣,保證了修改的資料是和它查詢出來的資料是一致的,而其他執行緒並未進行修改。當然,如果更新失敗,表示在更新操作之前有其他執行緒已經更新了該紅包數,那麼就可以嘗試重入機制來保證更新成功。


總結

1. 悲觀鎖使用了排他鎖,當程式獨佔鎖時,其他程式就連查詢都是不允許的,導致吞吐較低。如果在查詢較多的情況下,可使用樂觀鎖。

2. 樂觀鎖更新有可能會失敗,甚至是更新幾次都失敗,這是有風險的。所以如果寫入較頻繁,對吞吐要求不高,可使用悲觀鎖。



悲觀鎖(抽象的描述,不真實存在這個鎖)

悲觀鎖是在運算元據時,認為此操作會出現資料衝突,所以在進行每次操作時都要通過獲取鎖才能進行對相同資料的操作,所以悲觀鎖需要耗費較多的時間。另悲觀鎖是由資料庫自己實現了的,使用的時候,直接呼叫資料庫的相關語句即可。

由悲觀鎖涉及到的另外兩個鎖概念就出來了,它們就是共享鎖與排它鎖。共享鎖和排它鎖是悲觀鎖的不同的實現,它倆都屬於悲觀鎖的範疇。

資料庫的增刪改操作預設都會加排他鎖,而查詢不會加任何鎖。


共享鎖(S鎖)

共享鎖指的就是對於多個不同的事務,對同一個資源共享同一個鎖。

對某一資源加共享鎖,自身可以讀該資源,其他人也可以讀該資源(也可以再繼續加共享鎖,即 共享鎖可多個共存),但無法修改。要想修改就必須等所有共享鎖都釋放完之後。

語法:

select * from table lock in share mode ;

排他鎖(X鎖)

排它鎖與共享鎖相對應,就是指對於多個不同的事務,對同一個資源只能有一把鎖。對某一資源加排他鎖,自身可以進行增刪改查,其他人無法進行任何操作。

與共享鎖型別,在需要執行的語句後面加上for update就可以了。

語法:

select * from table for update

程式碼改造

分析

為了不影響上個版本,我們新加個介面方法和Mapper對映。 因為悲觀鎖是資料庫提供的功能,所以僅僅在Dao層修改Sql,Service層無需新增新的介面,只需要切換下呼叫的Dao層的方法即可。


RedPacketDao新增介面方法

/**
  * 獲取紅包資訊. 悲觀鎖的實現方式
  *
  * @param id
  *            --紅包id
  * @return 紅包具體資訊
  */

 public RedPacket getRedPacketForUpdate(Long id);

RedPacket.xml配置對映檔案

<!-- 查詢紅包具體資訊  悲觀鎖的實現方式for update -->
 <select id="getRedPacketForUpdate" parameterType="long"
   resultType="com.artisan.redpacket.pojo.RedPacket">

   select
     id, user_id as userId, amount, send_date as sendDate, total, unit_amount as unitAmount, stock, version, note
   from
     T_RED_PACKET
   where id = #{id} for update
 </select>


悲觀鎖是一種利用資料庫內部機制提供的鎖的方法,也就是對更新的資料加鎖,這樣在併發期間一旦有一個事務持有了資料庫記錄的鎖,其他的執行緒將不能再對資料進行更新。

在 SQL 中加入的 for update 語句,意味著將持有對資料庫記錄的行更新鎖(因為這裡使用主鍵查詢,所以只會對行加鎖。如果使用的是非主鍵查詢,要考慮是否對全表加鎖的問題,加鎖後可能引發其他查詢的阻塞〉,那就意味著在高併發的場景下 , 當一條事務持有了這個更新鎖才能往下操作,其他的執行緒如果要更新這條記錄,都需要等待,這樣就不會出現超發現象引發的資料一致性問題了。


Service層呼叫新的Dao方法

640


還原資料,部署測試

將T_RED_PACKET和T_USER_RED_PACKET中的資料還原為初始資料後,啟動應用,通過FireFox 訪問http://localhost:8080/ssm_redpacket/grap.jsp


統計報告

一致性資料統計:

SELECT
 a.id,
 a.amount,
 a.stock
FROM
 T_RED_PACKET a
WHERE
 a.id = 1
UNION ALL
 SELECT
   max(b.user_id),
   sum(b.amount),
   count(*)
 FROM
   T_USER_RED_PACKET b
 WHERE
   b.red_packet_id = 1;

640

這裡已經解決了超發的問題,所以結果是正確的,最起碼邏輯是正確的了。除了結果正確,我們還需要考慮效能問題,統計來看下。

效能資料統計:

SELECT
 (
   UNIX_TIMESTAMP(max(a.grab_time)) - UNIX_TIMESTAMP(min(a.grab_time))
 )  AS lastTime
FROM
 T_USER_RED_PACKET a;

640


注意事項

不使用悲觀鎖時,2萬個紅包190秒【主機配置很低】搶完(但存在超發現象),現在是275秒。 目前只是對資料庫加了一個鎖,當加的鎖比較多的時候,資料庫的效能還會持續下降,所以要區分不同的業務場景,慎重使用。


悲觀鎖導致效能下降的原因探究

對於悲觀鎖來說,當一條執行緒搶佔了資源後,其他的執行緒將得不到資源,那麼這個時, CPU 就會將這些得不到資源的執行緒掛起,掛起的執行緒也會消耗 CPU 的資源尤其是在高併發的請求中。

640

只能有一個事務佔據資源,其他事務被掛起等待持有資源的事務提交併釋放資源。當此時就進入了執行緒 2 , 執行緒 3……執行緒n,開始搶奪資源的步驟了,這裡假設執行緒 3 搶到資源。
640

一旦執行緒1 提交了事務,那麼鎖就會被釋放,這個時候被掛起的執行緒就會開始競爭紅包資源,那麼競爭到的執行緒就會被 CPU 恢復到執行狀態,繼續執行。

於是頻繁掛起,等待持有鎖執行緒釋放資源, 一旦釋放資源後,就開始搶奪,恢復執行緒,直至所有紅包資源搶完。

在高併發的過程中,使用悲觀鎖就會造成大量的執行緒被掛起和恢復,這將十分消耗資源,這就是為什麼使用悲觀鎖效能不佳的原因。

有些時候,我們也會把悲觀鎖稱為獨佔鎖,畢竟只有一個執行緒可以獨佔這個資源,或者稱為阻塞鎖,因為它會造成其他執行緒的阻塞。無論如何它都會造成併發能力的下降,從而導致 CPU頻繁切換執行緒上下文,造成效能低下。

為了克服這個問題,提高併發的能力,避免大量執行緒因為阻塞導致 CPU 進行大量的上下文切換,目前比較普遍的是樂觀鎖機制。


程式碼

https://github.com/yangshangwei/ssm_redpacket

PS:如果覺得我的分享不錯,歡迎大家隨手點贊、轉發。

(完)

640

Java團長

專注於Java乾貨分享

640

掃描上方二維碼獲取更多Java乾貨

相關文章