天天看點

【分布式鎖的演化】分布式鎖居然還能用MySQL?

MySQL也能實作分布式鎖,你知道麼?

前言

之前的文章中通過電商場景中秒殺的例子和大家分享了單體架構中鎖的使用方式,但是現在很多應用系統都是相當龐大的,很多應用系統都是微服務的架構體系,那麼在這種跨jvm的場景下,我們又該如何去解決并發。

單體應用鎖的局限性

在進入實戰之前簡單和大家粗略聊一下網際網路系統中的架構演進。

【分布式鎖的演化】分布式鎖居然還能用MySQL?

在網際網路系統發展之初,消耗資源比較小,使用者量也比較小,我們隻部署一個tomcat應用就可以滿足需求。一個tomcat我們可以看做是一個jvm的程序,當大量的請求并發到達系統時,所有的請求都落在這唯一的一個tomcat上,如果某些請求方法是需要加鎖的,比如上篇文章中提及的秒殺扣減庫存的場景,是可以滿足需求的。但是随着通路量的增加,一個tomcat難以支撐,這時候我們就需要叢集部署tomcat,使用多個tomcat支撐起系統。

在上圖中簡單演化之後,我們部署兩個Tomcat共同支撐系統。當一個請求到達系統的時候,首先會經過nginx,由nginx作為負載均衡,它會根據自己的負載均衡配置政策将請求轉發到其中的一個tomcat上。當大量的請求并發通路的時候,兩個tomcat共同承擔所有的通路量。這之後我們同樣進行秒殺扣減庫存的時候,使用單體應用鎖,還能滿足需求麼?

之前我們所加的鎖是JDK提供的鎖,這種鎖在單個jvm下起作用,當存在兩個或者多個的時候,大量并發請求分散到不同tomcat,在每個tomcat中都可以防止并發的産生,但是多個tomcat之間,每個Tomcat中獲得鎖這個請求,又産生了并發。進而扣減庫存的問題依舊存在。這就是單體應用鎖的局限性。那我們如果解決這個問題呢?接下來就要和大家分享分布式鎖了。

分布式鎖

什麼是分布式鎖?

那麼什麼是分布式鎖呢,在說分布式鎖之前我們看到單體應用鎖的特點就是在一個jvm進行有效,但是無法跨越jvm以及程序。是以我們就可以下一個不那麼官方的定義,分布式鎖就是可以跨越多個jvm,跨越多個程序的鎖,像這樣的鎖就是分布式鎖。

設計思路

【分布式鎖的演化】分布式鎖居然還能用MySQL?

由于tomcat是java啟動的,是以每個tomcat可以看成一個jvm,jvm内部的鎖無法跨越多個程序。是以我們實作分布式鎖,隻能在這些jvm外去尋找,通過其他的元件來實作分布式鎖。

上圖兩個tomcat通過第三方的元件實作跨jvm,跨程序的分布式鎖。這就是分布式鎖的解決思路。

實作方式

那麼目前有哪些第三方元件來實作呢?目前比較流行的有以下幾種:

  • 資料庫,通過資料庫可以實作分布式鎖,但是高并發的情況下對資料庫的壓力比較大,是以很少使用。
  • Redis,借助redis可以實作分布式鎖,而且redis的java用戶端種類很多,是以使用方法也不盡相同。
  • Zookeeper,也可以實作分布式鎖,同樣zk也有很多java用戶端,使用方法也不同。

針對上述實作方式,老貓還是通過具體的代碼例子來一一示範。

基于資料庫的分布式鎖

思路:基于資料庫悲觀鎖去實作分布式鎖,用的主要是select ... for update。select ... for update是為了在查詢的時候就對查詢到的資料進行了加鎖處理。當使用者進行這種行為操作的時候,其他線程是禁止對這些資料進行修改或者删除操作,必須等待上個線程操作完畢釋放之後才能進行操作,進而達到了鎖的效果。

實作:我們還是基于電商中超賣的例子和大家分享代碼。

咱們還是利用上次單體架構中的超賣的例子和大家分享,針對上次的代碼進行改造,我們新鍵一張表,叫做distribute_lock,這張表的目的主要是為了提供資料庫鎖,我們來看一下這張表的情況。

【分布式鎖的演化】分布式鎖居然還能用MySQL?

由于我們這邊模拟的是訂單超賣的場景,是以在上圖中我們有一條訂單的鎖資料。

我們将上一篇中的代碼改造一下抽取出一個controller然後通過postman去請求調用,當然背景是啟動兩個jvm進行操作,分别是8080端口以及8081端口。完成之後的代碼如下:

/**
 * @author [email protected]
 * @date 2021/1/3 10:48
 * @desc 公衆号“程式員老貓”
 */
@Service
@Slf4j
public class MySQLOrderService {
    @Resource
    private KdOrderMapper orderMapper;
    @Resource
    private KdOrderItemMapper orderItemMapper;
    @Resource
    private KdProductMapper productMapper;
    @Resource
    private DistributeLockMapper distributeLockMapper;
    //購買商品id
    private int purchaseProductId = 100100;
    //購買商品數量
    private int purchaseProductNum = 1;
    
    @Transactional(propagation = Propagation.REQUIRED)
    public  Integer createOrder() throws Exception{
        log.info("進入了方法");
        DistributeLock lock = distributeLockMapper.selectDistributeLock("order");
        if(lock == null) throw new Exception("該業務分布式鎖未配置");
        log.info("拿到了鎖");
        //此處為了手動示範并發,是以我們暫時在這裡休眠1分鐘
        Thread.sleep(60000);

        KdProduct product = productMapper.selectByPrimaryKey(purchaseProductId);
        if (product==null){
            throw new Exception("購買商品:"+purchaseProductId+"不存在");
        }
        //商品目前庫存
        Integer currentCount = product.getCount();
        log.info(Thread.currentThread().getName()+"庫存數"+currentCount);
        //校驗庫存
        if (purchaseProductNum > currentCount){
            throw new Exception("商品"+purchaseProductId+"僅剩"+currentCount+"件,無法購買");
        }

        //在資料庫中完成減量操作
        productMapper.updateProductCount(purchaseProductNum,"kd",new Date(),product.getId());
        //生成訂單
        ...次數省略,源代碼可以到老貓的github下載下傳:https://github.com/maoba/kd-distribute
        return order.getId();
    }
}

           

SQL的寫法如下:

select
   *
    from distribute_lock
    where business_code = #{business_code,jdbcType=VARCHAR}
    for update
           

以上為主要實作邏輯,關于代碼中的注意點:

  • createOrder方法必須要有事務,因為隻有在事務存在的情況下才能觸發select for update的鎖。
  • 代碼中必須要對目前鎖的存在性進行判斷,如果為空的情況下,會報異常

我們來看一下最終運作的效果,先看一下console日志,

8080的console日志情況:

11:49:41  INFO 16360 --- [nio-8080-exec-2] c.k.d.service.MySQLOrderService          : 進入了方法
11:49:41  INFO 16360 --- [nio-8080-exec-2] c.k.d.service.MySQLOrderService          : 拿到了鎖
           

8081的console日志情況:

11:49:48  INFO 17640 --- [nio-8081-exec-2] c.k.d.service.MySQLOrderService          : 進入了方法
           

通過日志情況,兩個不同的jvm,由于第一個到8080的請求優先拿到了鎖,是以8081的請求就處于等待鎖釋放才會去執行,這說明我們的分布式鎖生效了。

再看一下完整執行之後的日志情況:

8080的請求:

11:58:01  INFO 15380 --- [nio-8080-exec-1] c.k.d.service.MySQLOrderService          : 進入了方法
11:58:01  INFO 15380 --- [nio-8080-exec-1] c.k.d.service.MySQLOrderService          : 拿到了鎖
11:58:07  INFO 15380 --- [nio-8080-exec-1] c.k.d.service.MySQLOrderService          : http-nio-8080-exec-1庫存數1
           

8081的請求:

11:58:03  INFO 16276 --- [nio-8081-exec-1] c.k.d.service.MySQLOrderService          : 進入了方法
11:58:08  INFO 16276 --- [nio-8081-exec-1] c.k.d.service.MySQLOrderService          : 拿到了鎖
11:58:14  INFO 16276 --- [nio-8081-exec-1] c.k.d.service.MySQLOrderService          : http-nio-8081-exec-1庫存數0
11:58:14 ERROR 16276 --- [nio-8081-exec-1] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.Exception: 商品100100僅剩0件,無法購買] with root cause

java.lang.Exception: 商品100100僅剩0件,無法購買
	at com.kd.distribute.service.MySQLOrderService.createOrder(MySQLOrderService.java:61) ~[classes/:na]
           

很明顯第二個請求由于沒有庫存,導緻最終購買失敗的情況,當然這個場景也是符合我們正常的業務場景的。最終我們資料庫的情況是這樣的:

【分布式鎖的演化】分布式鎖居然還能用MySQL?
【分布式鎖的演化】分布式鎖居然還能用MySQL?

很明顯,我們到此資料庫的庫存和訂單數量也都正确了。到此我們基于資料庫的分布式鎖實戰示範完成,下面我們來歸納一下如果使用這種鎖,有哪些優點以及缺點。

  • 優點:簡單友善、易于了解、易于操作。
  • 缺點:并發量大的時候對資料庫的壓力會比較大。
  • 建議:作為鎖的資料庫和業務資料庫分開。

寫在最後

對于上述資料庫分布式鎖,其實在我們的日常開發中用的也是比較少的。基于redis以及zk的鎖倒是用的比較多一些,本來老貓想把redis鎖以及zk鎖放在這一篇中一起分享掉,但是再寫在同一篇上面的話,篇幅就顯得過長了,是以本篇就和大家分享這一種分布式鎖。源碼大家可以在老貓的github中下載下傳到。位址是:https://github.com/maoba/kd-distribute,後面老貓會把redis鎖以及zk鎖都分享給大家,敬請期待,當然更多的幹貨分享,也歡迎大家關注公衆号“程式員老貓”。

熱愛技術,熱愛産品,熱愛生活,一個懂技術,懂産品,懂生活的程式員~

更多精彩内容,可以關注公衆号“程式員老貓”。

一起讨論技術,探讨一下點子,研究研究賺錢!

繼續閱讀