- 已审核
本本分分做人,踏踏实实做事!!!
前言
做开发很简单所以培训班 4 个月就可以入门找工作了。做开发同时也很难所以经验丰富的程序员很少,因为只有少部分人能够坚持下去。今天我们通过一个案例来分析为何如此。
- 我们开发一个功能很简单,比如我们接下来我们就很容易开发一个查询功能。
场景
- 首先我们创建两张表,分别是用户表、用户额外信息表
sql
drop TABLE IF EXISTS t_user;
CREATE TABLE `t_user`(
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` VARCHAR(50) DEFAULT '' COMMENT '用户名称',
`email` VARCHAR(50) NOT NULL COMMENT '邮箱',
`phone` VARCHAR(20) DEFAULT '' COMMENT '手机号',
`gender` TINYINT DEFAULT '0' COMMENT '性别(0-男 : 1-女)',
`password` VARCHAR(100) NOT NULL COMMENT '密码',
`age` TINYINT DEFAULT '0' COMMENT '年龄',
`create_time` DATETIME DEFAULT NOW(),
`update_time` DATETIME DEFAULT NOW(),
PRIMARY KEY (`id`)
)ENGINE = INNODB DEFAULT CHARSET = utf8 COMMENT='用户表';
drop TABLE IF EXISTS t_user_addtion_info;
CREATE TABLE `t_user_addtion_info`(
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_name` VARCHAR(50) NOT NULL COMMENT '关联用户名称',
`fav` VARCHAR(20) DEFAULT '' COMMENT '爱好',
PRIMARY KEY(`id`)
)ENGINE = INNODB DEFAULT CHARSET=utf8 COMMENT='额外信息表';
- 基于这两张表我们实现一个查询用户列表的功能。spring: 探索实现spring各种功能 。 一个功能一个分支对应
- 拉取上面代码切换到
feature/optimization/reduce-io-loop
分支即可。
xml
<select id="selectUserDetailInfos" resultType="java.util.Map">
select * from t_user tu left join t_user_addtion_info tuai on tu.name=tuai.user_name;
</select>
- 上面是我们实现的 sql 没有啥难度的,直接关联查询简单明了大家都能看得懂。此时接口的响应时间应该也很快。但是随着时间的推移我们的数据量会越来越大。为了能够看到直观的效果我们新增一个存储过程模拟数据
sql
DROP FUNCTION IF EXISTS mock_data;
SET GLOBAL log_bin_trust_function_creators=TRUE; -- 创建函数一定要写这个
DELIMITER $$ -- 写函数之前必须要写,该标志
CREATE FUNCTION mock_data() -- 创建函数(方法)
RETURNS INT -- 返回类型
BEGIN -- 函数方法体开始
DECLARE num INT DEFAULT 1000000; -- 定义一个变量num为int类型。默认值为100 0000
DECLARE i INT DEFAULT 1;
WHILE i < num DO -- 循环条件
INSERT INTO t_user(`name`,`email`,`phone`,`gender`,`password`,`age`)
VALUES(CONCAT('用户',i),'2548928007qq.com',CONCAT('18',FLOOR(RAND() * ((999999999 - 100000000) + 1000000000))),FLOOR(RAND() * 2),UUID(),FLOOR(RAND() * 100));
insert into t_user_addtion_info(`user_name`,`fav`)
values(CONCAT('用户',i),RAND());
SET i = i + 1; -- i自增
END WHILE; -- 循环结束
RETURN i;
END; -- 函数方法体结束
$$
DELIMITER ;
- 新增好 mock_data 函数之后我们
select mock_data()
直接模拟生成一百万条数据。
-
通过接口响应数据能够看得出来,整个响应时间 1 分钟,其中将近 50S 都是服务端的等待时间。任何的场景下接口等待时间 1 分钟应该都算是性能问题了。有的同学可能会说谁家的网站会有一百万用户,我这里只是举例说明而已,达到一百万的用户量虽然不大可能,但是表中数据达到一百万是很容易的。而且我们这个接口没有任何的代码逻辑,纯粹就是 sql 查询传输,所以我们的性能就是 sql 的优化。
-
同样的数据我们如果不需要用户的额外信息的话,仅仅查询
t_user
单表的话,看看接口的响应时长吧。
- 虽然也是很长,但是对比而言单表查询比关联查询快了。
- 那么想要优化接口的性能,我觉得主要从三个方面出发;一个是纯通过 sql 索引来提升查询效率,另外一个就是通过改变代码逻辑来避免 sql 的关联查询,最后一个就是老生常谈的缓存技术了。
横向对比
- 上面的按钮就是为了告诉我们,写代码实现功能很容易,面对海量数据依旧稳定运行的功能才是很难的。接下来我们通过上述的三个方向来进行性能优化。
避免关联查询
- 避免关联查询就是将数据拼接放在我们的 java 代码里处理。那么势必需要通过 for 循环来一个一个处理。我们先来看下新手的改造代码
java
public List<Map<String, Object>> selectUserInfoOptimi(Map<String, Object> paramMap) {
List<Map<String, Object>> list = testRepository.selectUserInfo(paramMap);
for (Map<String, Object> map : list) {
Map<String,Object> addtionMap = testRepository.selectAddtionInfoBaseUserName(map.getOrDefault("name", "").toString());
map.put("fav", addtionMap.getOrDefault("fav", "").toString());
}
return list;
}
- 最终接口的响应时间比原先还要长并且伴随的 CPU 飙升现象,这就是新手的动手能力,完全的遵循避免关联查询。
- 上面的代码最大的问题就是在循环体内进行 IO 操作。在海量数据面前这无疑是灾难。循环 IO 就是造成 CPU 飙升的罪魁祸首。
避免循环
- 上面的确做到了避免关联查询了。但是同时引入了 IO 频繁的问题。为了解决 IO 问题,我们需要避免在循环体中 IO 。
java
@Override
public List<Map<String, Object>> selectUserInfoOptimi2(Map<String, Object> paramMap) {
List<Map<String, Object>> list = testRepository.selectUserInfo(paramMap);
List<Map<String,Object>> addtionList = testRepository.selectAddtionInfo();
Map<String, Map<String, Object>> userNameAddtionMap = addtionList.stream().collect(Collectors.toMap(item -> {
return item.getOrDefault("user_name", "").toString();
}, a -> a, (k1, k2) -> k1));
for (Map<String, Object> map : list) {
map.put("fav", userNameAddtionMap.getOrDefault(map.getOrDefault("name", "").toString(), new HashMap<>()).get("fav"));
}
return list;
}
- 这样整个过程我们只需要和数据库 IO 两次交互。将数据全部加载到内存中,然后利用内存的优势来规避 IO 问题。
- 可以明显观察到速度快了一点点。这样做的缺点也显而易见---浪费内存。在深究下去该接口在并发情况下可能会造成 OOM 内存溢出问题,而原始方法通过关联查询在极限状况下反而更不容易发生 OOM 问题。
阶段小结
- 对于上面三种方法,我们暂时总结下优缺点。
- 综合考虑,我还是觉得通过循环的方式比较占优势,因为在数据库优化后,我们通过循环可以再次提高查询的效率,说到底发生 OOM,我们完全可以通过增加内存的方式来规避。
数据库优化
-
上面我们的优化思路是通过代码的业务逻辑来进行优化,可是能够发现优化的提升并不是很明显,我们的瓶颈在于海量数据。面对海量数据我们如何提高查询效率这个还得靠我们的数据库层面优化。
-
还记得上面我们接口完成的响应时间吗?在最后一种方案中等待服务端数据响应的时间花费了 47.77s。我们在观察下数据库执行 sql 的用时。
- 关于数据库查询慢日志的配置,我们必须保证
slow_query_log
是打开状态。上述我的配置是打开的且查询时间大于 4 S 的才会认为是慢日志。set global slow_query_log=1
用于打开慢日志。如果需要持久化配置我们可以在my.cnf
中增加配置。slow_query_log =1
-
通过查询慢日志能够发现,我们优化后的两次 IO 交互分别是 33S、10 S,共计 44 S ,占用了整个后台响应的 92.24% 的时间。这就说明数据库的性能完全绝对了本次请求的性能。也侧面反映了内存处理的优势,内存处理所耗费的时间基本可以忽略不计。
-
由于间隔了一天,我们已当前时间为准。查看下今天两个 sql 分别需要多久。注意看下时间
- 我们仔细想想在查询
t_user_addtion_info
这张表因为是 user_name 作为关联查询 fav 字段的,其中 id 字段我们并不需要,所以我们完全可以避免查询所有字段减少 IO 交互的数据量。这对于传输也是一个优化。select user_name , fav from t_user_addtion_info
。
-
很显然,查询时间变短了而且还不影响我们的功能,这里我们得出一个结论避免使用星号查询
-
除了减少不必要的字段以外,sql 优化离不开索引的使用。我们先来看看
t_user_addtion_info
这张表里有哪些索引吧
- 之前我们学习过如何命中索引以及如何避免回表查询带来性能的提升。因为我们需要
user_name
、以及fav
字段,正常逻辑我们会将user_name
作为索引建立,但是为了能够避免回表我们这里选择将user_name
和fav
作为联合索引使用。
仅创建 user_name 索引
- 我们在观察下慢日志情况能够发现,创建了 user_name 索引又没有命中,和全表扫描差不多,也验证了 explain 分析的结果。
user_name 和 fav 的联合索引
-
加上索引反而比不加索引慢了。这里需要理解下,每次的执行时间不可能一成不变的。尤其我们这数据量大优点波动也是正常的。
-
对于
t_user
这张表没啥好优化,本身查询就是全表查询。
缓存
-
上面不管是代码逻辑优化还是数据库优化都无法显著的提升性能。究其原因还是在数据的查询和处理上。但是如何加入缓存那就不一样了。命中缓存的情况下就只剩下数据传输了。这能大大较少不必要的开销。
-
本文主要是介绍方案以及演示,所以这里的缓存我们就直接使用内存缓存数据吧,这里也不考虑过期的问题了。
-
第一次肯定还是很慢,我们就直接看第二次以后的请求效果
-
50 S 变成 8 S 就是因为我们少了数据查询与处理的流程。正式环境我们通过缓存一致性来解决缓存的过期问题。
代码兼容
- 上面终极解决方案还是得引入缓存机制来规避数据查询带来的性能瓶颈问题。对于代码层面其实还有一个问题,上面我们查询所有用户的详细信息,如果我们的方法是查询指定用户集的信息呢?我们的方法就需要这么改造了。
xml
<select id="selectUserInfo" resultType="java.util.Map">
select * from t_user tu
<where>
<if test="userId!=null and userId!=''">
and id=#{userId}
</if>
</where>
</select>
- 我们的方法内部虽然避免了循环体内 IO 查询。但是我们无法保证调用我们方法的地方不是在循环体内。如果在循环中,那么还是会出现 IO 频繁查询问题。
- 方法内部有我们掌控,外部谁调用,怎么调用我们就无法控制了。难道这就没办法控制了吗?其实也不是,我们可以通过线程变量缓存机制来缓解这种问题。
java
@Override
public List<Map<String, Object>> selectUserInfoThreadLocal(Map<String, Object> paramMap) {
List<Map<String, Object>> list = new ArrayList<>();
List<Map<String, Object>> addtionList = new ArrayList<>();
if (CollectionUtils.isEmpty(ThreadLocalFactory.getUserBaseInfoThreadLocal().get())) {
list = testRepository.selectUserInfo(paramMap);
ThreadLocalFactory.getUserBaseInfoThreadLocal().set(list);
}
if (CollectionUtils.isEmpty(ThreadLocalFactory.getUserAddtionInfoThreadLocal().get())) {
addtionList = testRepository.selectAddtionInfo();
ThreadLocalFactory.getUserAddtionInfoThreadLocal().set(addtionList);
}
Map<String, Map<String, Object>> userNameAddtionMap = addtionList.stream().collect(Collectors.toMap(item -> {
return item.getOrDefault("user_name", "").toString();
}, a -> a, (k1, k2) -> k1));
for (Map<String, Object> map : list) {
map.put("fav", userNameAddtionMap.getOrDefault(map.getOrDefault("name", "").toString(), new HashMap<>()).get("fav"));
}
return list;
}
- 通过上述的代码,对于单次调用不完产生额外的影响,同时还能够保证如果调用者循环调用的话使用的是缓存,且该缓存尽在该线程内是有效的,其他缓存则会失效。这样既能保证多个线程效果实时性,也能保证单个线程数据一致性。
放松一刻
The self is not something ready-made, but something in continuous formation through choice of action. --- John Dewey