記畢設過程中遇到的一個InnoDB的"坑"

情景描述

  • 我的畢設其中一個模塊需要實現(xiàn)多線程爬蟲,爬蟲模塊中的url容器打算使用mysql的一張表(表名叫url_catcher)來實現(xiàn),里面涉及到url防重,子線程監(jiān)控,url提取,路徑計算方案等不是重點,不細講。

  • 重點來了,在這個并發(fā)環(huán)境下,最關鍵的一步自然就是多線程對同一條url的搶鎖的實現(xiàn),先說明我的原先的思路:通過對url_catcher中的一行status字段狀態(tài)位進行CAS操作實現(xiàn)搶鎖,貼代碼:

  1. Mybatis映射文件
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cn.coselding.docsearcher.catcher.dao.UrlCatcherDao">
    <select id="getTop1" resultType="UrlCatcher">
        select * from url_catcher where status=#{status} limit 0,1;
    </select>
    <update id="setStatus">
        update url_catcher set status= #{newStatus},err_msg=#{errMsg} where id = #{id} and status= #{oldStatus}
    </update>
</mapper>
  1. Dao只是個接口:
public interface UrlCatcherDao {

    UrlCatcher getTop1(@Param("status") Integer status);

    int setStatus(@Param("id") Integer id,
                  @Param("oldStatus") Integer oldStatus,
                  @Param("newStatus") Integer newStatus,
                  @Param("errMsg") String errMsg);
}
  1. url狀態(tài)枚舉:
    public enum CatcherStatus {

        NO_CATCH(0, "還沒爬取"),
        CATCHING(1, "正在爬取"),
        CATCHED(2, "爬取過了"),
        FAILED(3, "爬取失敗");

        private int code;
        private String description;
    }
  1. 搶鎖關鍵Service
    @Transactional(isolation = Isolation.REPEATABLE_READ)
    public UrlCatcher findTopAndLock() {
        UrlCatcher catcher = null;
        int count = 0;
        while (count <= 0) {
            //(1)獲取表中status為NO_CATCH的第一條記錄,sql語句見上面的Mybatis映射文件
            catcher = urlCatcherDao.getTop1(CatcherStatus.NO_CATCH.getCode());
            System.out.println("catcher = " + catcher + ",count = " + count);
            //(2)爬蟲停止
            if (catcher == null) {
                break;//容器中沒url了
            }
            //(3) 修改狀態(tài),確認鎖定:將對應id的記錄,如果狀態(tài)為NO_CATCH就修改為CATCHING,否則不修改,返回值count為這條sql執(zhí)行后對表中影響的行數(shù)
            count = urlCatcherDao.setStatus(catcher.getId(), CatcherStatus.NO_CATCH.getCode(), CatcherStatus.CATCHING.getCode(), "");
            //count小于等于0表示沒搶到,接著循環(huán)搶下一個,搶到了就返回
        }
        logger.info("搶鎖成功:catcher = {}", catcher);
        return catcher;
    }
  • 說明:
  1. 第一步:獲取表中status為NO_CATCH的第一條記錄,sql語句見上面的Mybatis映射文件,很好理解
  2. 第二步:獲取的第一條記錄catcher為空,表示表中沒有可用url,退出循環(huán)返回,這不是重點,這是爬蟲停止條件
  3. 第三步:對第一步獲取的catcher對象id進行CAS搶鎖(如果狀態(tài)為NO_CATCH就修改為CATCHING,否則不修改,返回值count為這條sql執(zhí)行后對表中影響的行數(shù)),這樣的結果就是如果該線程搶到了,狀態(tài)修改成功(即加鎖),count>0退出循環(huán)返回,否則就是被其他線程搶了,count=0繼續(xù)外層while循環(huán)
  4. @Transactional(isolation = Isolation.REPEATABLE_READ)設定該操作的事務隔離級別
  • 這樣看似沒什么問題啊,運行起來卻是偶爾正常,偶爾不正常。。。如下:


    running-not-exist.png

死循環(huán)了吧~
看見id為1450了嗎?我讓這個死循環(huán)接著運行著,然后控制臺sql查一下這條記錄的status:

select id,status from url_catcher WHERE id=1450

結果如下:


sql-result.png

對比上面的枚舉類,可以知道該url當前的狀態(tài)為CATCHING,被哪個線程搶了我不管,但是已經(jīng)被搶了,這樣在搶鎖邏輯中urlCatcherDao.getTop1(CatcherStatus.NO_CATCH.getCode());這句肯定不應該獲取這條記錄的,我們把死循環(huán)的程序停了,看看打的日志:
不在公司,屏幕比較小,沒法完成截圖,復制其中一行看看:

catcher = UrlCatcher{id=1450, docId=1, fullUrlPath='http://hadoop.apache.org/docs/r2.6.5/hadoop-mapreduce-client/hadoop-mapreduce-client-hs/images/logos/', rootUrlPath='http://hadoop.apache.org/docs/r2.6.5/hadoop-mapreduce-client/hadoop-mapreduce-client-hs', rootFilePath='/Users/coselding/test1', parentPath='/images/logos', filename='', createTime=1491723334618, status=0, errMsg='null'},count = 0
20

看重點?。。d=1450,status=0(對應枚舉NO_CATCH
這尼瑪,同樣在運行中,死循環(huán)中查詢出的1450記錄status=0,而我用控制臺sql得到的status=1,心中千萬只草泥馬奔騰而過?。?!

解決過程

  1. 首先想到的自然是事務隔離級別,我就在草稿紙上畫兩個事務線程的可能的執(zhí)行軌跡,不論怎么畫,都想不到有怎樣的軌跡能夠達到這種執(zhí)行結果?。?!這些不是重點,不貼圖了,然后我就不想理論的了,直接把@Transactional(isolation = Isolation.REPEATABLE_READ)換著測試,一開始還挺順利,換成SERIALIZABLE就不死循環(huán)了,但是因為沒有理論支撐,我多執(zhí)行了幾次,然后死循環(huán)依然出現(xiàn)了,看來是鎖粒度變大導致了死循環(huán)發(fā)生頻率降低了,但是至少說明了我對這些事務隔離級別的理解還是對的,世界觀沒崩塌,還好還好~
  2. 然后還是查資料:mysql緩存?就算是緩存也有有效時間,不可能死循環(huán)
  3. 先查查事務隔離級別,找思路,直到找到了這幾篇博客:
  1. 重新回顧了一下二段鎖協(xié)議,也了解了InnoDB的行級鎖基于索引的,沒建立索引的字段無法觸發(fā)行級鎖,還有間隙鎖,感覺自己了解的還是不夠,之后還是得花時間好好補補
  2. 重點來了:InnoDB的樂觀鎖實現(xiàn)MVCC,規(guī)則如下:
    innoDB-MVCC.png

InnoDB基于事務版本號對select實現(xiàn)了快照讀,insert、delete、update是當前讀,簡單說呢,就是select在并發(fā)環(huán)境下可能讀取到的就是歷史紀錄(和死循環(huán)的現(xiàn)象吻合),具體詳解可以參照Innodb中的事務隔離級別和鎖的關系,有了這個思路,我們來分析一下上面的死循環(huán)原因~

原因過程分析

還是id=1450作為例子,初始創(chuàng)建版本號createVersion為0,刪除版本號deleteVersion為null

過程 事務線程1 事務線程2
事務開始 createVersion=0,deleteVersion=null createVersion=0,deleteVersion=null
事務版本號 version=1 version=2
1 getTop1執(zhí)行:createVersion<version,deleteVersion=null
2 獲取status=0
3 getTop1執(zhí)行:createVersion<version,deleteVersion=null
4 獲取status=0
5 setStatus執(zhí)行,update規(guī)則:新紀錄(status=1,createVersion=2,deleteVersion=null),原記錄快照(status=0,createVersion=0,deleteVersion=2)
6 搶鎖成功,commit
7 返回,該事務線程結束
8 setStatus搶鎖失敗
9 下次循環(huán):getTop1執(zhí)行:createVersion<version,deleteVersion=null,查找到了剛才的原記錄快照(status=0)
10 setStatus搶鎖失敗,接著循環(huán)
11 該事務線程永遠無法commit,version版本號永遠不變,永遠查找到原記錄快照,status永遠為0,陷入死循環(huán)
  • 分析:由于兩個事務線程需要按照上表的順序交錯進行才能出現(xiàn)死循環(huán),因此和之前的結論:死循環(huán)偶爾出現(xiàn)是相吻合的
  • 根本原因:外層循環(huán)中多次的getTop1執(zhí)行時由于在同一個事務中,事務版本號始終不變,導致始終獲取快照記錄,導致死循環(huán)
  • 解決方案:讓getTop1和setStatus分離在不同事務中執(zhí)行,即去掉@Transactional(isolation = Isolation.REPEATABLE_READ)

最終代碼

  • 就去掉了個注解
    public UrlCatcher findTopAndLock() {
        UrlCatcher catcher = null;
        int count = 0;
        while (count <= 0) {
            catcher = urlCatcherDao.getTop1(CatcherStatus.NO_CATCH.getCode());
            System.out.println("catcher = " + catcher + ",count = " + count);
            if (catcher == null) {
                break;//容器中沒url了
            }
            //搶分布式鎖:開始搶鎖
            //修改狀態(tài),確認鎖定
            count = urlCatcherDao.setStatus(catcher.getId(), CatcherStatus.NO_CATCH.getCode(), CatcherStatus.CATCHING.getCode(), "");
            //count小于等于0表示沒搶到,接著循環(huán)搶下一個,搶到了就返回
        }
        logger.info("搶鎖成功:catcher = {}", catcher);
        return catcher;
    }
  • 由于我這個爬蟲所處理的業(yè)務規(guī)模不會無限膨脹,再進行了優(yōu)化,將搶鎖操作轉(zhuǎn)移到內(nèi)存中執(zhí)行,降低mysql壓力,如下:
public class UrlCatcherServiceImpl implements UrlCatcherService {

    private final Logger logger = LoggerFactory.getLogger(this.getClass());
    @Autowired
    private UrlCatcherDao urlCatcherDao;
    private static ConcurrentHashMap<Integer, Long> urlLockMap = new ConcurrentHashMap<Integer, Long>();

    public void prepareLockMap() {
        urlLockMap.clear();
    }

    public UrlCatcher findTopAndLock() {
        UrlCatcher catcher = null;
        int count = 0;
        Long currentThreadId = Thread.currentThread().getId();
        while (count <= 0) {
            catcher = urlCatcherDao.getTop1(CatcherStatus.NO_CATCH.getCode());
            logger.info("catcher = " + catcher + ",count = " + count);
            if (catcher == null) {
                break;//容器中沒url了
            }
            //搶分布式鎖:開始搶鎖
            Long beforeThreadId = urlLockMap.putIfAbsent(catcher.getId(), currentThreadId);
            if (beforeThreadId == null) {
                //搶到了:之前該id為key沒有映射關系
                //修改狀態(tài),確認鎖定
                count = urlCatcherDao.setStatus(catcher.getId(), CatcherStatus.NO_CATCH.getCode(), CatcherStatus.CATCHING.getCode(), "");
            } else {
                //沒搶到:之前已經(jīng)有映射關系了
                count = 0;
            }
            //count小于等于0表示沒搶到,接著循環(huán)搶下一個,搶到了就返回
        }
        logger.info("搶鎖成功:catcher = {}", catcher);
        return catcher;
    }
}
  • 改成這樣之后,我前前后后重新執(zhí)行了不下幾十遍,再也沒有出現(xiàn)死循環(huán),算是解決了,如果后期還出現(xiàn)問題的話我再來更新博客哈哈哈,在此只是提供一個解決問題的思路~

總結

  • 雖然最終解決方案只是去掉一個注解,但是這其中蘊含的原理卻頗為深刻,專門花了時間記錄一下,也讓我意識到對MySQL的理解還遠遠不夠,之后還是得重新把《高性能MySQL》重新拿來好好啃啃。
  • 之前由于接觸不多,總感覺數(shù)據(jù)庫InnoDB、高并發(fā)對于自己比較遙遠,或者說是沒有豐富的實戰(zhàn)經(jīng)驗,導致對其中可能出現(xiàn)的問題、如何排查、如何解決問題等有種恐懼加拖延癥,這次花了半天時間排查這個問題,時間上算是損失慘重,但是也讓我對自己更加自信,算是第一次在我手中解決了一個高并發(fā)環(huán)境下的問題哈哈哈哈
最后編輯于
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容