如何在 Mysql 中实现上亿数据的遍历查询?先来介绍一下系统主角:关注系统,主要是维护京东用户和业务对象之前的关注关系;并对外提供各种关系查询,比如查询用户的关注商品或店铺列表,查询用户是否关注了某个商品或店铺等。但是最近接到了一个新需求,要求提供查询关注对象的粉丝列表接口功能。该功能的难点就是关注对象的粉丝数量过多,不少店铺的粉丝数量都是千万级别,并且有些大 V 粉丝数量能够达到上亿级别。而这些粉丝列表数据目前全都存储在 Mysql 库中,然后通过业务对象 ID 进行分库分表,所有的粉丝列表数据分布在 16 个分片的 256 张表中。同时为了方便查询粉丝列表,同一个业务对象的所有粉丝都会路由到同一张表中,每个表的数据量都能够达到 2 亿 +。
数据库表结构示例如下:
CREATE TABLE follow_fans_[0-255]
(
id bigint(11) NOT NULL AUTO_INCREMENT COMMENT '自增id',
biz_content VARCHAR(50) DEFAULT NULL COMMENT '业务对象ID',
source VARCHAR(50) DEFAULT NULL COMMENT '来源',
pin VARCHAR(50) DEFAULT NULL COMMENT '用户pin',
ext VARCHAR(5000) DEFAULT NULL COMMENT '扩展信息',
status TINYINT(2) DEFAULT 1 COMMENT '状态,0是失效,1是正常',
created_time DATETIME DEFAULT NULL COMMENT '创建时间',
modified_time DATETIME DEFAULT NULL COMMENT '修改时间',
PRIMARY KEY(id),
UNIQUE INDEX uniq_biz_content_pin (biz_content, pin)
)
ENGINE = InnoDB AUTO_INCREMENT = 1 DEFAULT CHARSET = utf8 COMMENT = '关注粉丝表';
由于同一个业务对象的所有粉丝都保存到一张数据库表中,对于分页查询列表接口,首先想到的就是用 limit 实现,对于粉丝数量很少的关注对象,查询接口性能还不错。但是随着关注对象的粉丝数量越来越多,接口查询性能就会越来越慢。后来经过接口压测,当业务对象粉丝列表数量达到几十万级别的时候,查询页码数量越大,查询耗时越多。limit 深分页为什么会变慢?这就和 sql 的执行计划有关了,limit 语句会先扫描 offset+n 行,然后再丢弃掉前 offset 行,返回后 n 行数据。也就是说limit 100000,10
,就会扫描 100010 行,而limit 0,10
,只扫描 10 行。查询 sql 示例如下:
select id,biz_content,pin FROM follow_fans_1 where biz_content = #{bizContent} order by id desc limit 10, 10;
Limit 深分页问题的本质原因就是:偏移量(offset)越大,mysql 就会扫描越多的行,然后再抛弃掉,这样就导致查询性能的下降。所以我们可以采用标签记录法,就是标记一下上次查询到哪一条了,下次再来查的时候,从该条开始往下扫描。具体做法方式是,查询粉丝列表中按照自增主键 ID 倒序查询,查询结果中返回主键 ID,然后查询入参中增加 maxId 参数,该参数需要透传上一次请求粉丝列表中最后一条记录主键 ID,第一次查询时可以为空,但是需要查询下一页时就必传。最后根据查询时返回的行数是否等于 10 来判断整个查询是否可以结束。优化后的查询 sql 参考如下:
select id,biz_content,pin FROM follow_fans_1 where biz_content = #{bizContent} and id < #{lastId} order by id desc limit 10;
标签记录法最后一页查询超时就是因为不知道何时终止查询,所以我们可以提供一个区间限制范围来告诉引擎查询到此结束。
查询 sql 再次优化后参考如下:
select id,biz_content,pin FROM follow_fans_1 where biz_content = #{bizContent} and id < #{lastId} and id >={minId} order by id desc limit 10;
由于查询时需要带上 minId 参数,所以在执行查询粉丝列表之前,我们就需要先把 minId 查询出来,查询 sql 参考如下:
select min(id) from follow_fans_1 where biz_content = #{bizContent}
由于表中数据量太大,每个表中总数据量都是上亿级别,导致第一步查询 minId 就直接超时了,根本没有机会去执行第二步。但是考虑到上一个查询方案只有最后一页才会查询超时,前 N-1 页查询根本用不到 minId 作为区间限制。所以当表中数据量很大时,通常从第一页到最后一页查询之间会存在一定的时间差。我们就可以正好去利用这个时间差去异步查询 minId,然后将查询出来的 minId 存储到缓存中,考虑到这个 minId 可能会被删除,可以设置一定的过期时间。最后优化后的查询流程如下:
但是在上述方案中,如果表中的数据量达到上亿级别时,第二步的异步获取 minId 任务还是会存在超时的风险,从而导致查询最后一页粉丝列表出现超时。所以我们又引入了离线数据计算任务,通过在大数据平台离线计算获取每个 biz_content 下的 minId,然后将计算结果 minId 推送到缓存中。为了保证 minId 能够及时更新,我们可以自由设置该离线任务的执行周期,比如每周执行一次。通过大数据平台的离线计算 minId,从而大大减少了在查询粉丝列表时执行 select min(id)的业务数据库压力。只有当缓存没有命中的时候才去执行 select min(id),通常这些缓存没有命中的 minId 也都是一些被离线任务遗漏的少量数据,不会影响接口的整体查询性能。
通过对以上三种方案的探索实践,发现每一种方案都有自己的优缺点和它的适用场景,我们不能脱离实际业务场景去谈方案的好坏。所以我们要结合实际的业务环境以及表中数据量的大小去综合考虑、权衡利弊,然后找到更适合的技术方案。以下是总结的几条 SQL 优化建议:
索引主要分为两大类,聚簇索引和非聚簇索引,可以通过 explain 查看 sql 执行计划判断查询是否使用了索引。
聚簇索引 (clustered index):聚簇索引的叶子节点存储行记录,InnoDB 必须要有且只有一个聚簇索引:
非聚簇索引(secondary index):InnoDB 非聚簇索引的叶子节点存储的是行记录的主键值,而 MyISAM 叶子节点存储的是行指针。 通常情况下,需要先遍历非聚簇索引获得聚簇索引的主键 ID,然后在遍历聚簇索引获取对应行记录。
可以参考以下几点索引原则:
回表查询就是先定位主键值,在根据主键值定位行记录,需要扫描两遍索引。 解决方案:只需要在一颗索引树上能够获取 SQL 所需要的所有列数据,则无需回表查询,速度更快。可以将要查询的字段,建立到联合索引里去,这就是索引覆盖。查询 sql 在进行 explain 解析时,Extra 字段为 Using Index 时,则触发索引覆盖。没有触发索引覆盖,发生了回表查询时,Extra 字段为 Using Index condition。
作者:京东零售 曹志飞
来源:京东云开发者社区 转载请注明来源