一个Mysql的性能问题

111 阅读7分钟

image.png

事情是酱紫的

业务背景

某App上,有一个业务模块,包含:主题、帖子。

主题:字面意思,比如“吃货”,“滑雪”,“摄影”等等,都是一个单独的主题,用于聚合一些相关的话题,由用户建立(所以主题可以非常多)。用户可以关注主题。主题可以设置公开/私有。对应的两张表subject, subject_member

CREATE TABLE `subject` (
  `id` int NOT NULL AUTO_INCREMENT,
  `name` varchar(100) NOT NULL,
  `create_time` bigint NOT NULL DEFAULT '0',
  `update_time` bigint NOT NULL DEFAULT '0',
  `public_type` tinyint NOT NULL DEFAULT '1',
  PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
CREATE TABLE `subject_member` (
  `id` bigint NOT NULL AUTO_INCREMENT,
  `user_id` bigint NOT NULL,
  `subject_id` int NOT NULL,
  `create_time` bigint NOT NULL,
  PRIMARY KEY (`id`) USING BTREE,
  KEY `subject_member_user_id_IDX` (`user_id`, `subject_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

帖子:属于某一个主题,用于分享某一件具体的事情,比如“吃货”主题下,发一篇“XX路的卤煮特别赞”的帖子(为什么是“卤煮”,因为最近在看三体)

CREATE TABLE `subject_post` (
  `id` int NOT NULL AUTO_INCREMENT,
  `title` varchar(150) NOT NULL,
  `content` varchar(100) DEFAULT NULL,
  `subject_id` int NOT NULL,
  `create_time` bigint NOT NULL DEFAULT '0',
  PRIMARY KEY (`id`) USING BTREE,
  KEY `subject_post_create_time_IDX` (`create_time`,`id`,`subject_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

业务

有一个单独的帖子列表页

过滤条件:用户关注了对应的主题,或者主题本身是公开的(public_type=1)

排序:一级排序 帖子的创建时间(create_time)倒序, 二级排序 id倒序

简单总结一下业务特点:有表A和B,是一对多的关系,A是一方,B是多方,用户和表A直接关联。现在要查用户能查看到的B列表。

代码(Sql)怎么写

select sp.*
from subject_post sp 
left join subject s on s.id = sp.subject_id 
where s.public_type = 1 or sp.subject_id  in (select subject_id from subject_member where user_id = ?)
order by sp.create_time desc, sp.id desc limit ?,?

简单解释下:先子查询查出用户关注过的所有主题id,作为条件,对帖子和主题的join结果做in过滤

性能问题

首先明确一点,排序如果要做到性能好,就要尽量避免文件排序和临时表(都是遍历整张表),也就是要争取做到用索引来排序。

所以这里优化的一个思路,既然排序规则是(一级 帖子的创建时间(create_time)倒序, 二级 id倒序),那么就在subject_post表上,建一个(create_time, id)开头的索引,并且因为要和subject表做join,为了避免扫描二级索引过程中频繁回表去找subject_id,因此把subject_id也加上,所以最终方案就是建立这样一个索引(create_time, id, subject_id)

我对此的预期是,mysql去扫描subject_post的二级索引,并在扫描过程中,逐渐对subject做过滤,等找到足够多的数据后,就直接返回。这样虽然会对二级索引做非常多的扫描,但是相比较于文件排序扫描整张表的聚集索引,开销仍然要小很多

我在本地做了一个测试,往这三张表插入了一些数据

image.png 经过explain后发现,当分页较浅时,确实是符合自己的期望,但当进行深度分页时,就会出现filesort,mysql竟然认为filesort更快,实际执行时间也由1秒内,提高到了3秒多。

加上force index,强制其走索引后,耗时又降了下来,但深度分页仍然会有接近1秒的耗时,在实际生产中,这个耗时是肯定不行的

解决方案:解决提出问题的人

这种问题对于Mysql来说,很棘手。所以,作为开发,PK产品是必备技能,既然从技术上解决不了,那就从“武力”上去解决,这种场景,用户很少会去翻很深的页,所以可以直接限制分页条数最多只能是1w条(ElasticSearch默认分页限制也是这个数,所以1W不是拍脑袋想的,是有依据的)。

技术研究

当然,作为技术人员,除了能解决产品经理外,还是需要对技术抱有深入研究的兴趣。那么问题来了,这种场景,如何能够达到正常分页,且能够保障性能。

保底方案

这个方案初看有点离谱,但仔细分析后,也不是那么的离谱:所有帖子和主题数据放内存,然后通过一定的机制来监听数据变化,在内存中做同步(比如zk,消息队列广播都可以做到实时监听)

您先别急着骂,听我慢慢道来

首先,所有帖子放内存,并不是把整张表都放进来,而是只放关键信息,并且用基本类型代替包装类型,以尽量减少对象的体积,例如帖子,需要的属性就是id,subjectId,createTIme,那么对应的类就是

/**
 * 内存中的帖子情况
 * 一个对象的大小:header=8,id=4,subjectId=4,createTime=8,总和=24
 *
 * 100 0000个帖子,对象本身:100 0000 * 24 /1024/1024 = 22M
 * 还需要一个100 0000长度的List:100 0000 * 4 /1024/1024 = 4M
 */
@Data
public class PostProperty {
    private int id;
    private int subjectId;
    private long createTime;
}

所以,100W的数据,经过合理设计,在内存中,其实只需要不到30M的内存。

那么这个列表页的流程就是,先查出用户关注的所有主题id,然后遍历内存中的这个集合,进行过滤,并找到对应的分页数据。拿到最终这一页的帖子id后,再去数据库查这几条帖子详细信息即可,数据库的压力就非常小了

打消疑虑

大家一般的思维里,对于“数据常驻内存”,都是有一些抵触的,但是仔细想想,在某些场景下,其实可以适当用用这种技巧。

第一个问题:数据太大,占用内存,导致别的请求无法拿到内存,最终OOM

经过上述分析,100W数据,需要不到30M内存,保守点,扩大5倍,也就是150M。那么在原有jvm参数的基础上,将堆扩大150M,并且都给到老年代,那OOM这个问题便不是问题了

第二个问题:影响GC

这些数据,变化的可能性不大,所以,经过若干次gc后,它们便会进入老年代,然后再经过若干次fullgc,这些数据就会进入老年代“底部”(空间整理的方向,如果是G1那就是那种老顽固对象所在的region),并且在后续的空间整理中,因为它已经在底部了,所以也无需再进行移动。并且,这些对象,也都不会产生对新生代的跨代引用,所以对ygc的扫描也没有影响。

image.png

结论,对于GC的影响,在服务初期,比较明显(会造成频繁ygc),但服务运行一段时间后,当这些数据进入老年代“底部”后,影响就很小了(fullgc的扫描阶段会增加部分工作量,但不会增加空间整理/复制的工作量),并且一个调优良好的jvm进程,fullgc周期是以天为单位的,所以这种影响微乎其微。

综上,分析后发现,其实这些数据常驻内存,也没有什么大的影响,但对性能的提升确实显而易见的。

锦上添花

如果觉得上百万数据放内存,还是不靠谱,那么可以和产品PK,比如要求只展示最近半年的帖子

sql优化

对于排序的优化,可以采用inner的形式,因为深度分页,可能会让mysql优化器放弃索引,所以这种情况下,可以采取先走索引查出id,然后再用inner join关联主表的方式,具体sql如下

elect post.* from subject_post post inner join (
    select sp.id id
    from subject_post sp 
    left join subject s on s.id = sp.subject_id 
    where s.public_type = 1 or sp.subject_id  in (select subject_id from subject_member where user_id = ?)
    order by sp.create_time desc, sp.id desc limit ?,?
) sub on sub.id = post.id

因为内层查询sub的select只包含一个id字段,所以可以使用覆盖索引,mysql就有充足的理由去选择索引,而不是选择filesort。当利用索引,查到分页数据的那些id去和subject_post表做关联,获取到最终数据。

经过测试,limit 20000, 10,采用inner join优化后,执行时间从3900ms,降低到了75ms,可以说是非常大的提升了

写在最后

学无止境,其实我所在项目,数据量根本达不到这个量级,即使有filesort,响应时间也不差,但做技术,还是要对性能有敬畏之心,不然永远都只能是一个低端劳动力,共勉(卷)!