SSM (十五) 樂觀鎖與悲觀鎖的實際應用

crossoverJie發表於2017-07-10

前言

隨著網際網路的興起,現在三高(高可用、高效能、高併發)專案是越來越流行。

本次來談談高併發。首先假設一個業務場景:資料庫中有一條資料,需要獲取到當前的值,在當前值的基礎上+10,然後再更新回去。
如果此時有兩個執行緒同時併發處理,第一個執行緒拿到資料是10,+10=20更新回去。第二個執行緒原本是要在第一個執行緒的基礎上再+20=40,結果由於併發訪問取到更新前的資料為10,+20=30

這就是典型的存在中間狀態,導致資料不正確。來看以下的例子:

併發所帶來的問題

和上文提到的類似,這裡有一張price表,表結構如下:

CREATE TABLE `price` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
  `total` decimal(12,2) DEFAULT '0.00' COMMENT '總值',
  `front` decimal(12,2) DEFAULT '0.00' COMMENT '消費前',
  `end` decimal(12,2) DEFAULT '0.00' COMMENT '消費後',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1268 DEFAULT CHARSET=utf8複製程式碼

我這裡寫了一個單測:就一個主執行緒,迴圈100次,每次把front的值減去10,再寫入一次流水記錄,正常情況是寫入的每條記錄都會每次減去10。

    /**
     * 單執行緒消費
     */
    @Test
    public void singleCounsumerTest1(){

        for (int i=0 ;i<100 ;i++){
            Price price = priceMapper.selectByPrimaryKey(1);
            int ron = 10 ;
            price.setFront(price.getFront().subtract(new BigDecimal(ron)));
            price.setEnd(price.getEnd().add(new BigDecimal(ron)));
            price.setTotal(price.getFront().add(price.getEnd()));

            priceMapper.updateByPrimaryKey(price) ;

            price.setId(null);
            priceMapper.insertSelective(price) ;
        }
    }複製程式碼

執行結果如下:

01.png
01.png

可以看到確實是每次都遞減10。

但是如果是多執行緒的情況下會是如何呢:

我這裡新建了一個PriceController

     /**
     * 執行緒池 無鎖
     * @param redisContentReq
     * @return
     */
    @RequestMapping(value = "/threadPrice",method = RequestMethod.POST)
    @ResponseBody
    public BaseResponse<NULLBody> threadPrice(@RequestBody RedisContentReq redisContentReq){
        BaseResponse<NULLBody> response = new BaseResponse<NULLBody>() ;

        try {

            for (int i=0 ;i<10 ;i++){
                Thread t = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        Price price = priceMapper.selectByPrimaryKey(1);
                        int ron = 10 ;
                        price.setFront(price.getFront().subtract(new BigDecimal(ron)));
                        price.setEnd(price.getEnd().add(new BigDecimal(ron)));
                        priceMapper.updateByPrimaryKey(price) ;

                        price.setId(null);
                        priceMapper.insertSelective(price) ;
                    }
                });

                config.submit(t);

            }

            response.setReqNo(redisContentReq.getReqNo());
            response.setCode(StatusEnum.SUCCESS.getCode());
            response.setMessage(StatusEnum.SUCCESS.getMessage());
        }catch (Exception e){
            logger.error("system error",e);
            response.setReqNo(response.getReqNo());
            response.setCode(StatusEnum.FAIL.getCode());
            response.setMessage(StatusEnum.FAIL.getMessage());
        }

        return response ;

    }複製程式碼

其中為了節省資源使用了一個執行緒池:

@Component
public class ThreadPoolConfig {

    private static final int MAX_SIZE = 10 ;
    private static final int CORE_SIZE = 5;
    private static final int SECOND = 1000;

    private ThreadPoolExecutor executor ;

    public ThreadPoolConfig(){
        executor = new ThreadPoolExecutor(CORE_SIZE,MAX_SIZE,SECOND, TimeUnit.MICROSECONDS,new LinkedBlockingQueue<Runnable>()) ;
    }

    public void submit(Thread thread){
        executor.submit(thread) ;
    }
}複製程式碼

關於執行緒池的使用今後會仔細探討。這裡就簡單理解為有10個執行緒併發去處理上面單執行緒的邏輯,來看看結果怎麼樣:

02.png
02.png

會看到明顯的資料錯誤,導致錯誤的原因自然就是有執行緒讀取到了中間狀態進行了錯誤的更新。

進而有了以下兩種解決方案:悲觀鎖和樂觀鎖。

悲觀鎖

簡單理解下悲觀鎖:當一個事務鎖定了一些資料之後,只有噹噹前鎖提交了事務,釋放了鎖,其他事務才能獲得鎖並執行操作。

使用方式如下:
首先要關閉MySQL的自動提交:set autocommit = 0;

bigen --開啟事務
select id, total, front, end from price where id=1 for update 

insert into price values(?,?,?,?,?)

commit --提交事務複製程式碼

這裡使用select for update的方式利用資料庫開啟了悲觀鎖,鎖定了id=1的這條資料(注意:這裡除非是使用了索引會啟用行級鎖,不然是會使用表鎖,將整張表都鎖住。)。之後使用commit提交事務並釋放鎖,這樣下一個執行緒過來拿到的就是正確的資料。

悲觀鎖一般是用於併發不是很高,並且不允許髒讀等情況。但是對資料庫資源消耗較大。

樂觀鎖

那麼有沒有效能好,支援的併發也更多的方式呢?

那就是樂觀鎖。

樂觀鎖是首先假設資料衝突很少,只有在資料提交修改的時候才進行校驗,如果衝突了則不會進行更新。

通常的實現方式增加一個version欄位,為每一條資料加上版本。每次更新的時候version+1,並且更新時候帶上版本號。實現方式如下:

新建了一張price_version表:

CREATE TABLE `price_version` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主鍵',
  `total` decimal(12,2) DEFAULT '0.00' COMMENT '總值',
  `front` decimal(12,2) DEFAULT '0.00' COMMENT '消費前',
  `end` decimal(12,2) DEFAULT '0.00' COMMENT '消費後',
  `version` int(11) DEFAULT '0' COMMENT '併發版本控制',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1268 DEFAULT CHARSET=utf8複製程式碼

更新資料的SQL:

<update id="updateByVersion" parameterType="com.crossoverJie.pojo.PriceVersion">
    UPDATE price_version
    SET front = #{front,jdbcType=DECIMAL},
        version= version + 1
    WHERE id = #{id,jdbcType=INTEGER}
    AND version = #{version,jdbcType=INTEGER}
  </update>複製程式碼

呼叫方式:

    /**
     * 執行緒池,樂觀鎖
     * @param redisContentReq
     * @return
     */
    @RequestMapping(value = "/threadPriceVersion",method = RequestMethod.POST)
    @ResponseBody
    public BaseResponse<NULLBody> threadPriceVersion(@RequestBody RedisContentReq redisContentReq){
        BaseResponse<NULLBody> response = new BaseResponse<NULLBody>() ;

        try {

            for (int i=0 ;i<3 ;i++){
                Thread t = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        PriceVersion priceVersion = priceVersionMapper.selectByPrimaryKey(1);
                        int ron = new Random().nextInt(20);
                        logger.info("本次消費="+ron);
                        priceVersion.setFront(new BigDecimal(ron));
                        int count = priceVersionMapper.updateByVersion(priceVersion);
                        if (count == 0){
                            logger.error("更新失敗");
                        }else {
                            logger.info("更新成功");
                        }

                    }
                });

                config.submit(t);

            }

            response.setReqNo(redisContentReq.getReqNo());
            response.setCode(StatusEnum.SUCCESS.getCode());
            response.setMessage(StatusEnum.SUCCESS.getMessage());
        }catch (Exception e){
            logger.error("system error",e);
            response.setReqNo(response.getReqNo());
            response.setCode(StatusEnum.FAIL.getCode());
            response.setMessage(StatusEnum.FAIL.getMessage());
        }

        return response ;

    }複製程式碼

處理邏輯:開了三個執行緒生成了20以內的隨機數更新到front欄位。

當呼叫該介面時日誌如下:

03.jpg
03.jpg

可以看到執行緒1、4、5分別生成了15,2,11三個隨機數。最後執行緒4、5都更新失敗了,只有執行緒1更新成功了。

檢視資料庫:

04.jpg
04.jpg

發現也確實是更新的15。

樂觀鎖在實際應用相對較多,它可以提供更好的併發訪問,並且資料庫開銷較少,但是有可能存在髒讀的情況。

總結

以上兩種各有優劣,大家可以根據具體的業務場景來判斷具體使用哪種方式來保證資料的一致性。

專案地址:github.com/crossoverJi…

個人部落格地址:crossoverjie.top

weixinchat.jpg
weixinchat.jpg

相關文章