本篇日志将记录之前做过的mmall项目的一些优化过程,主要包括JVM调优和数据库优化两个方面,通过不断动手实践并总结心得,希望能在此积累起许多经验,为以后能更得心应手的写出高效而又稳健的代码打好基础。

商品表的优化

在实际应用中我们往往都会遇到根据名称来查询某个商品或者根据昵称查询某个用户,如果返回的行数较多则要使用分页,而之前项目一直都是使用的PageHelper这个框架来完成分页功能的,这么做自然简单方便,但是当数据量达到几十万甚至百万时就会遇到性能瓶颈,尽管能够使用一些索引进行优化,但一个查询仍然需要十几二十秒才能完成,显然还远不能达标。在阅读PageHelper源码后可以发现,之所以会发生这种情况是因为PageHelper主要是通过拼接LIMIT语句来实现分页功能的,我们知道LIMIT在偏移量很大的时候会扫描很多不必要的行,因此需要对查询进行改进才能更好的应用在数据量比较大的场景下。

我们先建立一张商品表,还未在上面建立任何索引(除主键外):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
DROP TABLE IF EXISTS `mmall_product`;
CREATE TABLE `mmall_product` (
`id` int(11) NOT NULL AUTO_INCREMENT COMMENT '商品id',
`category_id` int(11) NOT NULL COMMENT '分类id,对应mmall_category表的主键',
`name` varchar(100) NOT NULL COMMENT '商品名称',
`subtitle` varchar(200) DEFAULT NULL COMMENT '商品副标题',
`main_image` varchar(500) DEFAULT NULL COMMENT '产品主图,url相对地址',
`sub_images` text COMMENT '图片地址,json格式,扩展用',
`detail` text COMMENT '商品详情',
`price` decimal(20,2) NOT NULL COMMENT '价格,单位-元保留两位小数',
`stock` int(11) NOT NULL COMMENT '库存数量',
`status` int(6) DEFAULT '1' COMMENT '商品状态.1-在售 2-下架 3-删除',
`create_time` datetime DEFAULT NULL COMMENT '创建时间',
`update_time` datetime DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=30 DEFAULT CHARSET=utf8;

接着使用存储过程往里面插入100万条数据,为了提高插入时的速度,需要先修改my.ini配置文件的以下两处:

1
2
innodb_flush_log_at_trx_commit=0
max_allowed_packet=100M

重启MySQL后再执行以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
drop procedure if exists product_insert;
DELIMITER ;;
CREATE PROCEDURE product_insert()
BEGIN
DECLARE y INT DEFAULT 1;
WHILE y < 100000
DO
insert into mmall_product(category_id, name, subtitle, main_image, sub_images, detail, price, stock, status, create_time, update_time)
values(y%30+100001, 'ab', substring(MD5(RAND()),15,20),
'241997c4-9e62-4824-b7f0-7425c3c28917.jpeg',
'241997c4-9e62-4824-b7f0-7425c3c28917.jpeg,b6c56eb0-1748-49a9-98dc-bcc4b9788a54.jpeg,92f17532-1527-4563-aa1d-ed01baa0f7b2.jpeg,3adbe4f7-e374-4533-aa79-cc4a98c529bf.jpeg',
'<p><img alt="miaoshu.jpg" src="http://img.happymmall.com/9c5c74e6-6615-4aa0-b1fc-c17a1eff6027.jpg" width="790" height="444"><br></p><p><img alt="miaoshu2.jpg" src="http://img.happymmall.com/31dc1a94-f354-48b8-a170-1a1a6de8751b.jpg" width="790" height="1441"><img alt="miaoshu3.jpg" src="http://img.happymmall.com/7862594b-3063-4b52-b7d4-cea980c604e0.jpg" width="790" height="1442"><img alt="miaoshu4.jpg" src="http://img.happymmall.com/9a650563-dc85-44d6-b174-d6960cfb1d6a.jpg" width="790" height="1441"><br></p>',
RAND() * 10000,
RAND() * 100,
1, now(), now());
SET y=y+1;
END WHILE ;
commit;
END;;
CALL product_insert();

这里的商品名、子标题等都使用的随机字符串,没有太多考究。此时就可以根据商品名name按价格price排序后进行查询了:

1
2
3
4
5
SELECT * FROM mmall_product WHERE name = 'ab' ORDER BY price LIMIT 100, 10;

SELECT * FROM mmall_product WHERE name = 'ab' ORDER BY price LIMIT 1000, 10;

SELECT * FROM mmall_product WHERE name = 'ab' ORDER BY price LIMIT 10000, 10;

执行时间如下:

可以看出,在不断增大LIMIT的偏移量后,查询时间着实吓人,通过EXPLAIN分析执行计划发现type那列显示ALL,说明要全表扫描一百多万行,并且还有Using filesort。我们先根据WHERE语句和ORDER BY语句建立如下组合索引:

1
ALTER TABLE mmall_product ADD INDEX index_name_price (name, price);

执行SHOW INDEX FROM mmall_product;查看索引是否添加如下:

这时在初步优化后分析执行计划可以看到查询不再是全表扫描,而是使用到了上面的索引,效率有所提升,但此时增大偏移量后查询依然会变得十分缓慢,还需要进一步优化。这里就可以用到“延迟关联”的技巧,由于LIMIT每扫描一行时都要去主索引拿到许多不必要的数据再丢弃,那么可以让其先在二级索引覆盖扫描得到满足条件的id,然后再与原表关联得到最终结果,代码如下:

1
2
SELECT * FROM mmall_product 
INNER JOIN (SELECT id FROM mmall_product WHERE name = 'ab' ORDER BY price LIMIT 100000, 10) AS mmall_product_id USING(id);

此时,就算偏移量为一百万时,查询也可以很轻松的在0.5S内完成,效果还是令人满意的。

PageHelper原理:PageHelper实现了MyBatis提供的Interceptor接口得到分页拦截器PageInterceptor,使用分页查询的时候,先调用PageHelper.startPage在当前线程上下文中设置一个ThreadLocal变量,分页拦截器拦截到SQL后会从ThreadLocal中拿到分页的信息,拼接分页语句并进行分页查询,最后再把ThreadLocal中的东西清除掉。

GC调优

看了《深入理解Java虚拟机》也有一段时间了,书本的知识虽然都能理解,但实际的优化却从来没试过,这方面可以说是毫无经验。都说读万卷书不如行万里路,在网上看了一些GC优化的实际案例后,决定亲自动手在这个项目中尝试一下。

这次实践使用的垃圾收集器为ParNew+CMS(CMS失败时Serial Old替补)。首先通过以下参数设置垃圾收集器并打开GC日志:

1
2
3
4
5
6
-XX:+UseConcMarkSweepGC
-XX:+UseParNewGC
-XX:+PrintGC
-Xloggc:C:\Users\canjie\Desktop\gc.log
-XX:+PrintHeapAtGC
-XX:+PrintGCDateStamps

然后使用jmeter工具模拟多用户持续请求接口的场景,这里设置的一分钟的用户数5000人。jemeter的聚合报告显示如下,主要关注TP99这一指标:

请求结束后分析GC日志发现Minor GC执行的十分频繁,而Major GC仅仅五分钟内就执行了好几次,每次耗时约0.2s,频繁且耗时的STW对接口响应时间造成了很大的影响,对于追求低延时的服务来说肯定是不可取的。要想优化就必须得先知其原因,首先JVM的默认内存为64M,这肯定是不够的,其次频繁的Major GC主要是因为老年代的空间不够,那接下来就是通过调整总堆大小以及年轻代和老年代的比例来减少GC的频率和STW的时间。当然,这里的内存不是调的越大越好,调的过小会导致GC频率过高,而调的过大虽然GC频率降低了,但每次GC的耗时也会变长。

先通过GC日志得到活跃数据的大小(活跃数据的大小是指Full GC后堆中老年代占用空间的大小),然后通过以下策略设置基本参数:

空间 倍数
总堆 3-4 倍活跃数据的大小
新生代 1-1.5 活跃数据的大小
老年代 2-3 倍活跃数据的大小
永久代 1.2-1.5 倍Full GC后的永久代空间占用

我在这个例子中经过计算后设置的参数如下:

1
2
3
4
-Xmx640m
-Xms640m
-XX:NewSize=240m
-XX:MaxNewSize=240m

此时再启动项目并用jmeter模拟真实环境进行测试,可以发现调大总堆大小并设置合适的年轻代与老年代的比例后,Minor GC每分钟的频率已经降到了70-80次,而Major GC十分钟才会出现一次,每次的耗时在100ms以下,TP95下降了约10ms,TP99下降了约100ms。