扫表/刷数脚本应该怎么写

484 阅读5分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第2天,点击查看活动详情

背景

最近有这么一个业务需求,需要基于客户信息,根据一套业务规则,给客户打上不同的标签。客户的量级是千万级别。因为客户信息会变,所以要求每天都要跑一遍全量客户打上新的标签。

挺简单的逻辑,就是分页查询整个数据库表就行了,但是实现过程中仍然采了不少坑,这里记录分享下。代码是用Python 写的,但是注释里贴上了对应的sql,所以不了解 Python 也可以瞄一瞄。

关键字:SQL 调优,扫表,刷数,数据库

第一个坑:深度分页

当时心想,不就是分页嘛,这能难到我?

我上去就是啪啪啪一顿输出:

def step1():
    """
    SELECT id FROM customer WHERE id LIMIT 0 500
    SELECT id FROM customer WHERE id LIMIT 500 500
    SELECT id FROM customer WHERE id LIMIT 1000 500
    """
    page = 1
    page_size = 500
    while True:
        print("doing,数据扫描中 page=%s" % (page))
        query_result = db.session.query(CustomerModel.id) \
        .offset((page - 1) * page_size) \
        .limit((page_size)).all()
        customer_id_list = [i[0] for i in query_result]
        if not customer_id_list:
            print("finish,数据已扫描完毕")
            break
        for customer_id in customer_id_list:
            pass
            # 各种刷数逻辑
        page = page + 1

大致思路是,一个while ture死循环,每次循环查一页数据,如果查询结果为空,就说明扫描完了。

结果上线一跑,我不禁眉头紧锁:咋越跑越慢呢?

后来分析一波,是数据库深度分页导致的,当时的数据库表大概是 800W 的数据量。

MySQL 分页,虽然结果只会返回某一页的数据,但会遍历之前的所有rows。所以分页到后面,SQL 执行时间会越来越长。

第二个坑:不带Order by,漏跑数据

分页有问题,那我就分页了,用游标的方式来搞:

def step2():
    """
    SELECT id FROM customer WHERE id > 0 LIMIT 500
    SELECT id FROM customer WHERE id > 500 LIMIT 500
    SELECT id FROM customer WHERE id > 1000 LIMIT 500
    """
    start = 0
    limit = 500
    while True:
        print("doing,数据扫描中 start=%s" % (start))
        query_result = db.session.query(CustomerModel.id) \
            .filter(CustomerModel.id > start) \
            .limit(limit).all()
        customer_id_list = [i[0] for i in query_result]
        if not customer_id_list:
            print("finish,数据已扫描完毕")
            break
        for customer_id in customer_id_list:
            pass
            # 各种刷数逻辑

        start = customer_id_list[-1]

结果上线一跑,发现有的客户没有执行我的刷数逻辑。后来排查半天,发现是没有指定 id 排序,导致有的数据页被 MySQL 直接跳过没有返回。

啥也不说了,加上 Order by 搞定:

def step3():
    """
    SELECT id FROM customer WHERE id > 0 LIMIT 500 ORDER BY id ASC
    SELECT id FROM customer WHERE id > 500 LIMIT 500 ORDER BY id ASC
    SELECT id FROM customer WHERE id > 1000 LIMIT 500 ORDER BY id ASC
    """
    start = 0
    limit = 500
    while True:
        print("doing,数据扫描中 start=%s" % (start))
        query_result = db.session.query(CustomerModel.id) \
            .filter(CustomerModel.id > start) \
            .order_by(CustomerModel.id) \
            .limit(limit) \
            .all()
        customer_id_list = [i[0] for i in query_result]
        if not customer_id_list:
            print("finish,数据已扫描完毕")
            break
        for customer_id in customer_id_list:
            pass
            # 各种刷数逻辑

        start = customer_id_list[-1]

第三个坑:数据量太大,脚本耗时太长

之后这个脚本一直在线上好好的跑着,每天跑一遍全量数据,半年了,没来打扰我平淡的生活。突然有一天,我无意中发现这个脚本跑一次要五六天,原来所扫描的客户表已经从 800W 膨胀到 3000W+。

那么该如何优化?一种思路是多线程,但是代码复杂性高,Python 线程池封装的也不好,就没考虑。

另外一种思路的是根据 modifyTime 来搞。因为是基于客户信息进行打标签的业务,客户信息不变,业务标签也就不会变,所以不需要全表扫描,每天跑一次,根据 modifyTime,把近一天修改过的客户重新跑一遍打一次标签就可以了。

给 modify_time 加上索引后,当时试跑的SQL如下,发现因为按 id 排序的缘故,MySQL仍然选择走的主键索引,而不是我们手动建的索引

EXPLAIN SELECT id FROM customer
WHERE id > 0 AND modify_time >= '2022-10-01T17:42:59'
ORDER BY id DESC LIMIT 10 ;

possible_keys   PRIMARY,idx_modify_time
key             PRIMARY
rows            18082890

随后加上了FORCE INDEX,强制走 modify_time 才解决,脚本如下:

def step5():
    """
    EXPLAIN SELECT id FROM customer
    FORCE INDEX(idx_modify_time)
    WHERE id > 0 AND modify_time >= '2022-10-01T17:42:59'
    ORDER BY id DESC LIMIT 10 ;

    possible_keys   idx_modify_time
    key             idx_modify_time
    rows            259884
    """
    start = 0
    limit = 500
    last_modify_time = datetime.datetime.now() - datetime.timedelta(hours=24)
    while True:
        print("doing,数据扫描中 start=%s" % (start))
        query_result = db.session.query(CustomerModel.id) \
            .with_hint(CustomerModel, 'FORCE INDEX(idx_modify_time)') \
            .filter(CustomerModel.id > start) \
            .filter(CustomerModel.modify_time >= last_modify_time) \
            .order_by(CustomerModel.id) \
            .limit(limit).all()
        customer_id_list = [i[0] for i in query_result]
        if not customer_id_list:
            print("finish,数据已扫描完毕")
            break
        for customer_id in customer_id_list:
            pass
            # 各种刷数逻辑

        start = customer_id_list[-1]

第四个坑:一段时间内有大量的客户被修改

之后这个脚本又安安静静跑了小半年,相安无事。

可是,某一天突然发现他一直超时,翻下日志发现 sql 执行又变慢了。抓出来一看,发现因为其他需求影响,刷了一遍数修改了大量客户信息,而且集中在某一段时间内,导致数据库扫描行数变大,触发了file sort:

    EXPLAIN SELECT id FROM customer
    FORCE INDEX(idx_modify_time)
    WHERE id > 0 AND modify_time >= '2022-10-01T17:42:59'
    ORDER BY id DESC LIMIT 10 ;

    possible_keys   idx_modify_time
    key             idx_modify_time
    rows            18084776
    Extra			Using filesort

这种情况的根本原因还是数据量大,又需要排序,随决定直接按时间片,一段一段的扫描数据,减少数据量,避免排序。

如下,把数据看做每分钟一组,每次循环处理一分钟内的数据:

def step6():
    """
    SELECT id
    FROM customer
    FORCE INDEX(idx_modify_time)
    WHERE modify_time >= '2022-09-27 02:00:00'
    AND modify_time <= '2022-09-27 02:00:59';

    possible_keys   idx_modify_time
    key             idx_modify_time
    rows            7470
    """
    now = datetime.datetime.now()
    start = datetime.datetime.now() - datetime.timedelta(hours=24)
    start_modify_time = datetime.datetime(start.year, start.month, start.day, start.hour, start.minute, 0)
    end_modify_time = datetime.datetime(start_modify_time.year, start_modify_time.month, start_modify_time.day,
                                        start_modify_time.hour, start_modify_time.minute, 59)
    while end_modify_time < now:
        print("doing,数据扫描中 start_modify_time=%s" % (start_modify_time))
        query_result = db.session.query(CustomerModel.id) \
            .filter(CustomerModel.modify_time >= start_modify_time) \
            .filter(CustomerModel.modify_time <= end_modify_time) \
            .all()
        customer_id_list = [i[0] for i in query_result]
        for customer_id in customer_id_list:
            pass
            # 各种刷数逻辑
        start_modify_time = start_modify_time + datetime.timedelta(minutes=1)
        end_modify_time = datetime.datetime(start_modify_time.year, start_modify_time.month, start_modify_time.day,
                                            start_modify_time.hour, start_modify_time.minute, 59)

总结

日常工作中,各种迁移、刷数可能都需要扫表。对于数据量较小的表,用排序+游标的方式就能完全应付。而对于数据量比较大的,短时间没法全量跑完的,就需要按照修改时间来处理。

后来又深入思考了下,其实按修改时间并不靠谱,因为修改时间这种字段的维护,比较隐性,保不齐出了问题也没人发现,所以大批量、日常性的刷数还是更应该让数据团队来处理。