记毕设过程中遇到的一个InnoDB的"坑"

情景描述

  • 我的毕设其中一个模块需要实现多线程爬虫,爬虫模块中的url容器打算使用mysql的一张表(表名叫url_catcher)来实现,里面涉及到url防重,子线程监控,url提取,路径计算方案等不是重点,不细讲。

  • 重点来了,在这个并发环境下,最关键的一步自然就是多线程对同一条url的抢锁的实现,先说明我的原先的思路:通过对url_catcher中的一行status字段状态位进行CAS操作实现抢锁,贴代码:

  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状态枚举:
    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) 修改状态,确认锁定:将对应id的记录,如果状态为NO_CATCH就修改为CATCHING,否则不修改,返回值count为这条sql执行后对表中影响的行数
            count = urlCatcherDao.setStatus(catcher.getId(), CatcherStatus.NO_CATCH.getCode(), CatcherStatus.CATCHING.getCode(), "");
            //count小于等于0表示没抢到,接着循环抢下一个,抢到了就返回
        }
        logger.info("抢锁成功:catcher = {}", catcher);
        return catcher;
    }
  • 说明:
  1. 第一步:获取表中status为NO_CATCH的第一条记录,sql语句见上面的Mybatis映射文件,很好理解
  2. 第二步:获取的第一条记录catcher为空,表示表中没有可用url,退出循环返回,这不是重点,这是爬虫停止条件
  3. 第三步:对第一步获取的catcher对象id进行CAS抢锁(如果状态为NO_CATCH就修改为CATCHING,否则不修改,返回值count为这条sql执行后对表中影响的行数),这样的结果就是如果该线程抢到了,状态修改成功(即加锁),count>0退出循环返回,否则就是被其他线程抢了,count=0继续外层while循环
  4. @Transactional(isolation = Isolation.REPEATABLE_READ)设定该操作的事务隔离级别
  • 这样看似没什么问题啊,运行起来却是偶尔正常,偶尔不正常。。。如下:


    running-not-exist.png

死循环了吧~
看见id为1450了吗?我让这个死循环接着运行着,然后控制台sql查一下这条记录的status:

select id,status from url_catcher WHERE id=1450

结果如下:


sql-result.png

对比上面的枚举类,可以知道该url当前的状态为CATCHING,被哪个线程抢了我不管,但是已经被抢了,这样在抢锁逻辑中urlCatcherDao.getTop1(CatcherStatus.NO_CATCH.getCode());这句肯定不应该获取这条记录的,我们把死循环的程序停了,看看打的日志:
不在公司,屏幕比较小,没法完成截图,复制其中一行看看:

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

看重点!!!id=1450,status=0(对应枚举NO_CATCH
这尼玛,同样在运行中,死循环中查询出的1450记录status=0,而我用控制台sql得到的status=1,心中千万只草泥马奔腾而过!!!

解决过程

  1. 首先想到的自然是事务隔离级别,我就在草稿纸上画两个事务线程的可能的执行轨迹,不论怎么画,都想不到有怎样的轨迹能够达到这种执行结果!!!这些不是重点,不贴图了,然后我就不想理论的了,直接把@Transactional(isolation = Isolation.REPEATABLE_READ)换着测试,一开始还挺顺利,换成SERIALIZABLE就不死循环了,但是因为没有理论支撑,我多执行了几次,然后死循环依然出现了,看来是锁粒度变大导致了死循环发生频率降低了,但是至少说明了我对这些事务隔离级别的理解还是对的,世界观没崩塌,还好还好~
  2. 然后还是查资料:mysql缓存?就算是缓存也有有效时间,不可能死循环
  3. 先查查事务隔离级别,找思路,直到找到了这几篇博客:
  1. 重新回顾了一下二段锁协议,也了解了InnoDB的行级锁基于索引的,没建立索引的字段无法触发行级锁,还有间隙锁,感觉自己了解的还是不够,之后还是得花时间好好补补
  2. 重点来了:InnoDB的乐观锁实现MVCC,规则如下:
    innoDB-MVCC.png

InnoDB基于事务版本号对select实现了快照读,insert、delete、update是当前读,简单说呢,就是select在并发环境下可能读取到的就是历史纪录(和死循环的现象吻合),具体详解可以参照Innodb中的事务隔离级别和锁的关系,有了这个思路,我们来分析一下上面的死循环原因~

原因过程分析

还是id=1450作为例子,初始创建版本号createVersion为0,删除版本号deleteVersion为null

过程 事务线程1 事务线程2
事务开始 createVersion=0,deleteVersion=null createVersion=0,deleteVersion=null
事务版本号 version=1 version=2
1 getTop1执行:createVersion<version,deleteVersion=null
2 获取status=0
3 getTop1执行:createVersion<version,deleteVersion=null
4 获取status=0
5 setStatus执行,update规则:新纪录(status=1,createVersion=2,deleteVersion=null),原记录快照(status=0,createVersion=0,deleteVersion=2)
6 抢锁成功,commit
7 返回,该事务线程结束
8 setStatus抢锁失败
9 下次循环:getTop1执行:createVersion<version,deleteVersion=null,查找到了刚才的原记录快照(status=0)
10 setStatus抢锁失败,接着循环
11 该事务线程永远无法commit,version版本号永远不变,永远查找到原记录快照,status永远为0,陷入死循环
  • 分析:由于两个事务线程需要按照上表的顺序交错进行才能出现死循环,因此和之前的结论:死循环偶尔出现是相吻合的
  • 根本原因:外层循环中多次的getTop1执行时由于在同一个事务中,事务版本号始终不变,导致始终获取快照记录,导致死循环
  • 解决方案:让getTop1和setStatus分离在不同事务中执行,即去掉@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了
            }
            //抢分布式锁:开始抢锁
            //修改状态,确认锁定
            count = urlCatcherDao.setStatus(catcher.getId(), CatcherStatus.NO_CATCH.getCode(), CatcherStatus.CATCHING.getCode(), "");
            //count小于等于0表示没抢到,接着循环抢下一个,抢到了就返回
        }
        logger.info("抢锁成功:catcher = {}", catcher);
        return catcher;
    }
  • 由于我这个爬虫所处理的业务规模不会无限膨胀,再进行了优化,将抢锁操作转移到内存中执行,降低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没有映射关系
                //修改状态,确认锁定
                count = urlCatcherDao.setStatus(catcher.getId(), CatcherStatus.NO_CATCH.getCode(), CatcherStatus.CATCHING.getCode(), "");
            } else {
                //没抢到:之前已经有映射关系了
                count = 0;
            }
            //count小于等于0表示没抢到,接着循环抢下一个,抢到了就返回
        }
        logger.info("抢锁成功:catcher = {}", catcher);
        return catcher;
    }
}
  • 改成这样之后,我前前后后重新执行了不下几十遍,再也没有出现死循环,算是解决了,如果后期还出现问题的话我再来更新博客哈哈哈,在此只是提供一个解决问题的思路~

总结

  • 虽然最终解决方案只是去掉一个注解,但是这其中蕴含的原理却颇为深刻,专门花了时间记录一下,也让我意识到对MySQL的理解还远远不够,之后还是得重新把《高性能MySQL》重新拿来好好啃啃。
  • 之前由于接触不多,总感觉数据库InnoDB、高并发对于自己比较遥远,或者说是没有丰富的实战经验,导致对其中可能出现的问题、如何排查、如何解决问题等有种恐惧加拖延症,这次花了半天时间排查这个问题,时间上算是损失惨重,但是也让我对自己更加自信,算是第一次在我手中解决了一个高并发环境下的问题哈哈哈哈
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,332评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,508评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,812评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,607评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,728评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,919评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,071评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,802评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,256评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,576评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,712评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,389评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,032评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,798评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,026评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,473评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,606评论 2 350

推荐阅读更多精彩内容