情景描述
我的畢設其中一個模塊需要實現(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)搶鎖,貼代碼:
- 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>
- 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);
}
- url狀態(tài)枚舉:
public enum CatcherStatus {
NO_CATCH(0, "還沒爬取"),
CATCHING(1, "正在爬取"),
CATCHED(2, "爬取過了"),
FAILED(3, "爬取失敗");
private int code;
private String description;
}
- 搶鎖關鍵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;
}
- 說明:
- 第一步:獲取表中status為
NO_CATCH的第一條記錄,sql語句見上面的Mybatis映射文件,很好理解- 第二步:獲取的第一條記錄catcher為空,表示表中沒有可用url,退出循環(huán)返回,這不是重點,這是爬蟲停止條件
- 第三步:對第一步獲取的catcher對象id進行
CAS搶鎖(如果狀態(tài)為NO_CATCH就修改為CATCHING,否則不修改,返回值count為這條sql執(zhí)行后對表中影響的行數(shù)),這樣的結果就是如果該線程搶到了,狀態(tài)修改成功(即加鎖),count>0退出循環(huán)返回,否則就是被其他線程搶了,count=0繼續(xù)外層while循環(huán)@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
結果如下:

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

