提交 c039fc0a 编写于 作者: 沉默王二's avatar 沉默王二 💬

别再用 System.currentTimeMillis() 统计耗时了,太 Low,StopWatch 好用到爆!

上级 9d803209
---
title: 别再用 System.currentTimeMillis() 统计耗时了,太 Low,StopWatch 好用到爆!
shortTitle: 别再用 System.currentTimeMillis() 统计耗时了,太 Low,StopWatch 好用到爆!
description: 真香!!
author: 栈长
category:
- 微信公众号
head:
- - meta
- name: description
content: 真香!!
---
## 背景
你还在用 System.currentTimeMillis... 统计耗时?
比如下面这段代码:
```
/**  * @author: 栈长  * @from: Java技术栈  */ @Test public void jdkWasteTime() throws InterruptedException {     long start = System.currentTimeMillis();     Thread.sleep(3000);     System.out.printf("耗时:%dms.", System.currentTimeMillis() - start); }
```
System.currentTimeMillis...这种方式统计耗时确实是用的最多的,因为它不用引入其他的 JAR 包,JDK 就能搞定,但是它用起来有几个不方便的地方:
1)需要定义初始时间值,再用当前时间进行手工计算;
2)统计多个任务的耗时比较麻烦,如果 start 赋值搞错可能还会出现逻辑问题;
有没有其他的更好的替代方案呢?答案是肯定的:**StopWatch**
## StopWatch
StopWatch 是一个统计耗时的工具类:
![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/weixin-biezysystemcurrenttimemillistjhsltlowstopwatchhydb-71db7631-47a7-4349-9c1c-d6ce42390636.jpg)
常用的 StopWatch 工具类有以下两种:
* commons-lang3(Apache 提供的通用工具包)
* spring-core(Spring 核心包)
虽然两个工具类的名称是一样的,但是用法大不相同,本文栈长就给大家分别演示下。
### commons-lang3 提供的 StopWatch
#### 引入依赖
commons-lang3 是 Apache 开源的通用工具包,需要额外引入 Maven 依赖:
```
<dependency>     <groupId>org.apache.commons</groupId>     <artifactId>commons-lang3</artifactId>     <version>${commons-lang3.version}</version> </dependency>
```
#### 简单示例
创建一个 StopWatch 实例有以下 3 种方法:
1) 使用 new 关键字
```
StopWatch sw = new StopWatch();
```
2)使用 create 工厂方法
```
StopWatch sw = StopWatch.create();
```
3)使用 createStarted 方法
```
StopWatch sw = StopWatch.createStarted();
```
这个方法不但会创建一个实例,同时还会启动计时。
**来看一个简单的例子:**
```
// 创建一个 StopWatch 实例并开始计时 StopWatch sw = StopWatch.createStarted(); // 休眠1秒 Thread.sleep(1000); // 1002ms System.out.printf("耗时:%dms.\n", sw.getTime()); 
```
#### 更多用法
接之前的示例继续演示。
**暂停计时:**
```
// 暂停计时 sw.suspend(); Thread.sleep(1000); // 1000ms System.out.printf("暂停耗时:%dms.\n", sw.getTime()); 
```
因为暂停了,所以还是 1000ms,暂停后中间休眠的 1000 ms 不会被统计。
**恢复计时:**
```
// 恢复计时 sw.resume(); Thread.sleep(1000); // 2001ms System.out.printf("恢复耗时:%dms.\n", sw.getTime()); 
```
因为恢复了,结果是 2001 ms,恢复后中间休眠的 1000 ms 被统计了。
**停止计时:**
```
Thread.sleep(1000); // 停止计时 sw.stop(); Thread.sleep(1000); // 3009ms System.out.printf("总耗时:%dms.\n", sw.getTime()); 
```
停止计时前休眠了 1000ms,所以结果是 3009ms,停止计时后就不能再使用暂停、恢复功能了。
**重置计时:**
```
// 重置计时 sw.reset(); // 开始计时 sw.start(); Thread.sleep(1000); // 1000ms System.out.printf("重置耗时:%dms.\n", sw.getTime()); 
```
因为重置计时了,所以重新开始计时后又变成了 1000ms。
### Spring 提供的 StopWatch
来看一个简单的例子:
```
// 创建一个 StopWatch 实例 StopWatch sw = StopWatch("Java技术栈:测试耗时"); // 开始计时 sw.start("任务1"); // 休眠1秒 Thread.sleep(1000); // 停止计时 sw.stop(); // 1002ms System.out.printf("任务1耗时:%d%s.\n", sw.getLastTaskTimeMillis(), "ms");
```
Spring 创建实例的方法就是 new,开始计时,以及获取时间需要手动 start、stop。
继续再新增 2 个任务:
```
Thread.sleep(1000); sw.start("任务2"); Thread.sleep(1100); sw.stop(); // 1100ms. System.out.printf("任务2耗时:%d%s.\n", sw.getLastTaskTimeMillis(), "ms"); sw.start("任务3"); Thread.sleep(1200); sw.stop(); // 1203ms. System.out.printf("任务3耗时:%d%s.\n", sw.getLastTaskTimeMillis(), "ms"); // 3.309373456s. System.out.printf("任务数量:%s,总耗时:%ss.\n", sw.getTaskCount(), sw.getTotalTimeSeconds());
```
Spring 一个重要的亮点是支持格式化打印结果:
```
System.out.println(sw.prettyPrint());
```
来看最后的输出结果:
![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/weixin-biezysystemcurrenttimemillistjhsltlowstopwatchhydb-9584ef69-33ea-4ec4-bd4d-5c6b34c8039a.jpg)
## 实现原理
分别来看下 commons-lang3 和 Spring 的核心源码:
![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/weixin-biezysystemcurrenttimemillistjhsltlowstopwatchhydb-048a7d6b-df2f-4d67-bd62-5ac20311a4f6.jpg)
![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/weixin-biezysystemcurrenttimemillistjhsltlowstopwatchhydb-16378e71-7f8f-452a-851d-81e5efd1c355.jpg)
其实也都是利用了 JDK 中的 System 系统类去实现的,做了一系列封装而已。
## 总结
commons-lang3 工具包和 Spring 框架中的 StopWatch 都能轻松完成多个任务的计时以及总耗时,再也不要用手工计算耗时的方式了,手动计算如果 start 赋值错误可能还会出错。
当然,以上两个 StopWatch 的功能也远不止栈长介绍的,栈长介绍的这些已经够用了,更多的可以深入研究。
**总结一下这两种计时工具类优缺点:**
1)commons-lang3 中的 StopWatch 的用法比 Spring 中的要更简单一些;
2)commons-lang3 中的 StopWatch 功能比 Spring 中的要更灵活、更强大一些,支持暂停、恢复、重置等功能;
3)Spring 提供每个子任务名称,以及按格式化打印结果功能,针对多任务统计时更好一点;
综上所述,个人推荐使用 commons-lang3 工具包中的,更灵活、更强大,如果不想额外引入包,也可以考虑 Spring 中的,根据自己的系统需求定。
**所以,别再用 System.currentTimeMillis... 统计耗时了,太 low,赶紧分享转发下吧,规范起来!**
* * *
**微信8.0将好友放开到了一万,小伙伴可以加我大号了,先到先得,再满就真没了**
**扫描下方二维码即可加我微信啦,`2022,抱团取暖,一起牛逼。`**
![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/weixin-biezysystemcurrenttimemillistjhsltlowstopwatchhydb-4343b516-4747-4e12-be7f-8719a372dfc7.jpg)
## 推荐阅读
* [Grafana 9 正式发布,更易用,更酷炫了!](https://mp.weixin.qq.com/s?__biz=MzU1Nzg4NjgyMw==&mid=2247500998&idx=1&sn=58d1222ef56fa3bef2abeb832c3a3c32&scene=21#wechat_redirect)
* [如何优雅的写 Controller 层代码?](https://mp.weixin.qq.com/s?__biz=MzU1Nzg4NjgyMw==&mid=2247500925&idx=1&sn=0a937f3b4281987b3633666421bf275b&scene=21#wechat_redirect)
* [Mall电商实战项目全面升级!支持最新版SpringBoot,干掉循环依赖...](https://mp.weixin.qq.com/s?__biz=MzU1Nzg4NjgyMw==&mid=2247500820&idx=1&sn=9895bd4c39b90d45eb2a10efedb236ac&scene=21#wechat_redirect)
* [阿里出品!SpringBoot应用自动化部署神器,IDEA版Jenkins?](https://mp.weixin.qq.com/s?__biz=MzU1Nzg4NjgyMw==&mid=2247500584&idx=1&sn=14ab8fa74ed8391a5cb91449f699123a&scene=21#wechat_redirect)
* [再见命令行!一键部署应用到远程服务器,IDEA官方Docker插件真香!](https://mp.weixin.qq.com/s?__biz=MzU1Nzg4NjgyMw==&mid=2247500482&idx=1&sn=713a30c88cea125f4768e6a0df939600&scene=21#wechat_redirect)
* [还在从零开始搭建项目?这款升级版快速开发脚手架值得一试!](https://mp.weixin.qq.com/s?__biz=MzU1Nzg4NjgyMw==&mid=2247500084&idx=1&sn=5bd4e684af3cfede8f332c423a478abf&scene=21#wechat_redirect)
* [重磅更新!Mall实战教程全面升级,瞬间高大上了!](https://mp.weixin.qq.com/s?__biz=MzU1Nzg4NjgyMw==&mid=2247499376&idx=1&sn=3ed28795cdd35fbaa3506e74a56703b0&scene=21#wechat_redirect)
* [40K+Star!Mall电商实战项目开源回忆录!](https://mp.weixin.qq.com/s?__biz=MzU1Nzg4NjgyMw==&mid=2247486684&idx=1&sn=807fd808adac8019eb2095ba088efe54&scene=21#wechat_redirect)
![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/weixin-biezysystemcurrenttimemillistjhsltlowstopwatchhydb-eba82a19-3f81-4707-a327-8901d03a9c49.jpg)
>转载链接:[https://mp.weixin.qq.com/s/lo9vq-YToQF-u76b_fabvg](https://mp.weixin.qq.com/s/lo9vq-YToQF-u76b_fabvg),出处:macrozheng,整理:沉默王二
---
title: 全网显示 IP 归属地,用上这个开源库,实现也太简单了!
shortTitle: 全网显示 IP 归属地,用上这个开源库,实现也太简单了!
description: 用上开源库,给自己的网站加上IP属地!
author: 陌溪
category:
- 微信公众号
head:
- - meta
- name: description
content: 用上开源库,给自己的网站加上IP属地!
---
细心的小伙伴可能会发现,最近蘑菇新上线了 **IP** 属地的功能,小伙伴在发表动态、发表评论以及聊天的时候,都会显示自己的 **IP** 属地信息
![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/weixin-quanwxsipgzdyszgkyksxytjdl-60ba9e45-080c-4dca-922e-cccfd3071587.jpg)
动态显示IP属地
在蘑菇群聊中,也可以展示 IP 属地,下面是小伙伴们在交流群中显示的
![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/weixin-quanwxsipgzdyszgkyksxytjdl-7fefb9a6-d55d-4764-8197-bd007395d6db.jpg)
下面,我就来讲讲,**Java** 中是如何获取 **IP** 属地的,主要分为以下几步
* 通过 HttpServletRequest 对象,获取用户的 **IP** 地址
* 通过 IP 地址,获取对应的省份、城市
首先需要写一个 **IP** 获取的工具类,因为每一次用户的 **Request** 请求,都会携带上请求的 **IP** 地址放到请求头中。
```
public class IpUtil {     public static String getIpAddr(ServerHttpRequest request) {         HttpHeaders headers = request.getHeaders();         String ipAddress = headers.getFirst("X-Forwarded-For");         if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {             ipAddress = headers.getFirst("Proxy-Client-IP");         }         if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {             ipAddress = headers.getFirst("WL-Proxy-Client-IP");         }         if (ipAddress == null || ipAddress.length() == 0 || "unknown".equalsIgnoreCase(ipAddress)) {             ipAddress = request.getRemoteAddress().getAddress().getHostAddress();             if (ipAddress.equals("127.0.0.1") || ipAddress.equals("0:0:0:0:0:0:0:1")) {                 // 根据网卡取本机配置的IP                 try {                     InetAddress inet = InetAddress.getLocalHost();                     ipAddress = inet.getHostAddress();                 } catch (UnknownHostException e) {                     log.error("根据网卡获取本机配置的IP异常", e);                 }             }         }         // 对于通过多个代理的情况,第一个IP为客户端真实IP,多个IP按照','分割         if (ipAddress != null && ipAddress.indexOf(",") > 0) {             ipAddress = ipAddress.split(",")[0];         }         return ipAddress;     } }
```
这里有三个名词,分别是
* **X-Forwarded-For****一个 HTTP** 扩展头部,主要是为了让 **Web** 服务器获取访问用户的真实 **IP** 地址。每个 **IP** 地址,每个值通过逗号+空格分开,最左边是最原始客户端的 **IP** 地址,中间如果有多层代理,每⼀层代理会将连接它的客户端 **IP** 追加在 **X-Forwarded-For** 右边。
* **X-Real-IP**:一般只记录真实发出请求的客户端IP
* **Proxy-Client-IP**:这个一般是经过 **Apache http** 服务器的请求才会有,用 **Apache http** 做代理时一般会加上 **Proxy-Client-IP** 请求头
* **WL-Proxy-Client-IP**:也是通过 Apache http 服务器,在 **weblogic** 插件加上的头。
在我们获取到用户的 **IP** 地址后,那么就可以获取对应的 **ip** 信息了
蘑菇最开始使用的是淘宝 **IP**
> 地址:https://ip.taobao.com/
![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/weixin-quanwxsipgzdyszgkyksxytjdl-d1796439-3c8b-4fb5-bf21-ff0057a21d92.jpg)
接入方式也比较简单,就是通过封装一个 **http** 请求,传入用户的 **ip** 作为参数,就可以返回 **ip** 对应的国家,省,城市 信息
![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/weixin-quanwxsipgzdyszgkyksxytjdl-f4108a0c-8cf3-400d-a108-f126faded625.jpg)
原来的请求方式如下
```
/**  * 获取IP地址来源  *  * @param content        请求的参数 格式为:name=xxx&pwd=xxx  * @param encodingString 服务器端请求编码。如GBK,UTF-8等  * @return  * @throws UnsupportedEncodingException  */ public static String getAddresses(String content, String encodingString) {     String ip = content.substring(3);     if (!Util.isIpAddress(ip)) {         log.info("IP地址为空");         return null;     }     // 淘宝IP宕机,目前使用Ip2region:https://github.com/lionsoul2014/ip2region     String cityInfo = getCityInfo(ip);     log.info("返回的IP信息:{}", cityInfo);     // TODO 淘宝接口目前已经宕机,因此暂时注释下面代码     try {         // 这里调用pconline的接口         String urlStr = "http://ip.taobao.com/service/getIpInfo.php";         // 从http://whois.pconline.com.cn取得IP所在的省市区信息         String returnStr = getResult(urlStr, content, encodingString);         if (returnStr != null) {             // 处理返回的省市区信息             log.info("调用IP解析接口返回的内容:" + returnStr);             String[] temp = returnStr.split(",");             //无效IP,局域网测试             if (temp.length < 3) {                 return "0";             }             // 国家             String country = "";             // 区域             String area = "";             // 省             String region = "";             // 市             String city = "";             // 县             String county = "";             // 运营商             String isp = "";             Map<String, Object> map = JsonUtils.jsonToMap(returnStr);             if (map.get("code") != null) {                 Map<String, String> data = (Map<String, String>) map.get("data");                 country = data.get("country");                 area = data.get("area");                 region = data.get("region");                 city = data.get("city");                 county = data.get("area");                 isp = data.get("isp");             }             log.info("获取IP地址对应的地址" + country + "=" + area + "=" + region + "=" + city + "=" + county + "=" + isp);             StringBuffer result = new StringBuffer();             result.append(country);             result.append("|");             result.append(region);             result.append("|");             result.append(city);             result.append("|");             result.append(isp);             return result.toString();         }     } catch (Exception e) {         log.error(e.getMessage());         return null;     }     return null; }
```
但是,之前接入淘宝 **IP** 库的时候,也经常会遇到服务不可用的情况,并且由于限制了 **QPS****1**,所以如果访问量大的话,就没办法获取了。
而到现在的话倒好了,这个接口也不对外提供服务了,直接下线了,不让调用了。
![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/weixin-quanwxsipgzdyszgkyksxytjdl-698d6eaa-3ca1-4a76-9ba3-7cfd6624b675.jpg)
后面,陌溪在 **Github** 冲浪的时候,发现了 **Ip2region** 项目。
一个准确率 **99.9%** 的离线 **IP** 地址定位库,**0.0x** 毫秒级查询,**ip2region.db** 数据库只有数 **MB**,提供了 java,php,c,python,nodejs,golang,c# 等查询绑定和**Binary****B树**,内存三种查询算法。
![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/weixin-quanwxsipgzdyszgkyksxytjdl-45292cab-6e8b-465a-8ab4-4ad9e99d32f0.jpg)
数据聚合了一些知名 **ip** 到地名查询提供商的数据,这些是他们官方的的准确率,经测试着实比经典的纯真 **IP** 定位准确一些。**ip2region** 的数据聚合自以下服务商的开放 **API** 或者数据。
* 80%, 淘宝IP地址库, http://ip.taobao.com/
* ≈10%, GeoIP, https://geoip.com/
* ≈2%, 纯真IP库, http://www.cz88.net/
> **备注**:如果上述开放API或者数据都不给开放数据时ip2region将停止数据的更新服务。
每条 **ip** 数据段都固定了格式:
```
_城市Id|国家|区域|省份|城市|ISP_
```
只有中国的数据精确到了城市,其他国家有部分数据只能定位到国家,后前的选项全部是 **0**,已经包含了全部你能查到的大大小小的国家
生成的数据库文件 **ip2region.db** 只有几 **MB**,最小的版本只有 **1.5MB**,随着数据的详细度增加数据库的大小也慢慢增大,目前还没超过 **8MB**
## 内置的三种查询算法
全部的查询客户端单次查询都在 **0.x** 毫秒级别,内置了三种查询算法
* **memory** 算法:整个数据库全部载入内存,单次查询都在0.1x毫秒内,C语言的客户端单次查询在0.00x毫秒级别。
* **binary** 算法:基于二分查找,基于ip2region.db文件,不需要载入内存,单次查询在0.x毫秒级别。
* **b-tree** 算法:基于btree算法,基于ip2region.db文件,不需要载入内存,单词查询在0.x毫秒级别,比binary算法更快。
## ip2region安装
下面,就让我们给项目引入 **ip2region**,进行 **ip** 信息转换吧
首先引入 **maven** 依赖
```
<dependency>     <groupId>org.lionsoul</groupId>     <artifactId>ip2region</artifactId>     <version>1.7.2</version> </dependency>
```
然后编写一个工具类 **IpUtils** ,首先需要加载 **ip2region.db** 文件
```
static {     dbPath = createFtlFileByFtlArray() + "ip2region.db";     try {         config = new DbConfig();     } catch (DbMakerConfigException e) {         e.printStackTrace();     }     try {         searcher = new DbSearcher(config, dbPath);     } catch (FileNotFoundException e) {         e.printStackTrace();     } }
```
在加载的时候,需要下载仓库中的 **ip2region.db** 文件,然后放到 **resource** 目录下
![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/weixin-quanwxsipgzdyszgkyksxytjdl-0e8d29e4-8374-461b-b5cb-2f7be9891b8f.jpg)
然后,通过内置的三种算法,分别转换用户 **ip** 地址
```
    public static String getCityInfo(String ip) {         if (StringUtils.isEmpty(dbPath)) {             log.error("Error: Invalid ip2region.db file");             return null;         }         if(config == null || searcher == null){             log.error("Error: DbSearcher or DbConfig is null");             return null;         }         //查询算法         //B-tree, B树搜索(更快)         int algorithm = DbSearcher.BTREE_ALGORITHM;         //Binary,使用二分搜索         //DbSearcher.BINARY_ALGORITHM         //Memory,加载内存(最快)         //DbSearcher.MEMORY_ALGORITYM         try {             // 使用静态代码块,减少文件读取操作 //            DbConfig config = new DbConfig(); //            DbSearcher searcher = new DbSearcher(config, dbPath);             //define the method             Method method = null;             switch (algorithm) {                 case DbSearcher.BTREE_ALGORITHM:                     method = searcher.getClass().getMethod("btreeSearch", String.class);                     break;                 case DbSearcher.BINARY_ALGORITHM:                     method = searcher.getClass().getMethod("binarySearch", String.class);                     break;                 case DbSearcher.MEMORY_ALGORITYM:                     method = searcher.getClass().getMethod("memorySearch", String.class);                     break;                 default:             }             DataBlock dataBlock = null;             if (Util.isIpAddress(ip) == false) {                 System.out.println("Error: Invalid ip address");             }             dataBlock = (DataBlock) method.invoke(searcher, ip);             String ipInfo = dataBlock.getRegion();             if (!StringUtils.isEmpty(ipInfo)) {                 ipInfo = ipInfo.replace("|0", "");                 ipInfo = ipInfo.replace("0|", "");             }             return ipInfo;         } catch (Exception e) {             e.printStackTrace();         }         return null;     }
```
下面,我们编写 **main** 函数进行测试,发现可以正常的解析出 **ip** 信息
![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/weixin-quanwxsipgzdyszgkyksxytjdl-c7dabe2e-93e5-4c18-bf42-dcb44d6a2bb3.jpg)
由于 ip 属地在国内的话,只会展示省份,而国外的话,只会展示国家。所以我们还需要对这个方法进行一下封装,得到获取 IP 属地的信息。
```
/**  * 获取IP属地  * @param ip  * @return  */ public static String getIpPossession(String ip) {     String cityInfo = getCityInfo(ip);     if (!StringUtils.isEmpty(cityInfo)) {         cityInfo = cityInfo.replace("|", " ");         String[] cityList = cityInfo.split(" ");         if (cityList.length > 0) {             // 国内的显示到具体的省             if ("中国".equals(cityList[0])) {                 if (cityList.length > 1) {                     return cityList[1];                 }             }             // 国外显示到国家             return cityList[0];         }     }     return "未知"; }
```
下面,我们在找一个 国外的 **IP** 测试一下效果。可以看到已经能够正常的显示 **IP** 属地信息了~
![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/weixin-quanwxsipgzdyszgkyksxytjdl-677306bd-bac7-4d43-99d2-324f7f01b265.jpg)
到这里如果获取用户的 IP 属地已经完成啦,如果想要了解关于更多 **ip2region** 的功能,欢迎访问其 **Github** 地址进行学习。
## 项目地址
https://github.com/lionsoul2014/ip2region
* * *
**微信8.0将好友放开到了一万,小伙伴可以加我大号了,先到先得,再满就真没了**
**扫描下方二维码即可加我微信啦,`2022,抱团取暖,一起牛逼。`**
![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/weixin-quanwxsipgzdyszgkyksxytjdl-7dd82667-7566-4c78-9aff-da27d172b7ab.jpg)
## 推荐阅读
* [新来个技术总监,把DDD落地的那叫一个高级,服气!](https://mp.weixin.qq.com/s?__biz=MzU1Nzg4NjgyMw==&mid=2247501082&idx=1&sn=c2ad0e4ba438dab00f89660d942707d3&scene=21#wechat_redirect)
* [还在用命令行看日志?快用Kibana吧,可视化日志分析YYDS!](https://mp.weixin.qq.com/s?__biz=MzU1Nzg4NjgyMw==&mid=2247501074&idx=1&sn=629db39555b3d344f928b87abecbba69&scene=21#wechat_redirect)
* [Grafana 9 正式发布,更易用,更酷炫了!](https://mp.weixin.qq.com/s?__biz=MzU1Nzg4NjgyMw==&mid=2247500998&idx=1&sn=58d1222ef56fa3bef2abeb832c3a3c32&scene=21#wechat_redirect)
* [Mall电商实战项目全面升级!支持最新版SpringBoot,干掉循环依赖...](https://mp.weixin.qq.com/s?__biz=MzU1Nzg4NjgyMw==&mid=2247500820&idx=1&sn=9895bd4c39b90d45eb2a10efedb236ac&scene=21#wechat_redirect)
* [阿里出品!SpringBoot应用自动化部署神器,IDEA版Jenkins?](https://mp.weixin.qq.com/s?__biz=MzU1Nzg4NjgyMw==&mid=2247500584&idx=1&sn=14ab8fa74ed8391a5cb91449f699123a&scene=21#wechat_redirect)
* [再见命令行!一键部署应用到远程服务器,IDEA官方Docker插件真香!](https://mp.weixin.qq.com/s?__biz=MzU1Nzg4NjgyMw==&mid=2247500482&idx=1&sn=713a30c88cea125f4768e6a0df939600&scene=21#wechat_redirect)
* [重磅更新!Mall实战教程全面升级,瞬间高大上了!](https://mp.weixin.qq.com/s?__biz=MzU1Nzg4NjgyMw==&mid=2247499376&idx=1&sn=3ed28795cdd35fbaa3506e74a56703b0&scene=21#wechat_redirect)
* [40K+Star!Mall电商实战项目开源回忆录!](https://mp.weixin.qq.com/s?__biz=MzU1Nzg4NjgyMw==&mid=2247486684&idx=1&sn=807fd808adac8019eb2095ba088efe54&scene=21#wechat_redirect)
![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/weixin-quanwxsipgzdyszgkyksxytjdl-ca4d4747-df0a-434e-9512-4fba2dcb272a.jpg)
>转载链接:[https://mp.weixin.qq.com/s/tQ-4ClnfCucUREF-_ynQ2g](https://mp.weixin.qq.com/s/tQ-4ClnfCucUREF-_ynQ2g),出处:macrozheng,整理:沉默王二
---
title: 一次显著的接口性能优化,从20s到500ms,就用了这三招!
shortTitle: 一次显著的接口性能优化,从20s到500ms,就用了这三招!
description: 接口性能问题,对于从事后端开发的同学来说,是一个绕不开的话题。
author: 苏三呀
category:
- 微信公众号
head:
- - meta
- name: description
content: 接口性能问题,对于从事后端开发的同学来说,是一个绕不开的话题。
---
## 前言
接口性能问题,对于从事后端开发的同学来说,是一个绕不开的话题。想要优化一个接口的性能,需要从多个方面着手。
其实,我之前也写过一篇接口性能优化相关的文章《[聊聊接口性能优化的11个小技巧](https://mp.weixin.qq.com/s?__biz=MzkwNjMwMTgzMQ==&mid=2247490731&idx=1&sn=29ed0295c7990157a3a56ba33cf7f8be&chksm=c0ebc443f79c4d55a2bac81744992c96f97737e5d0717ec99231f4d08f57a7f0220eafdac9c9&token=660773166&lang=zh_CN&scene=21#wechat_redirect)》,发表之后在全网广受好评,感兴趣的小伙们可以仔细看看。
本文将会接着接口性能优化这个话题,从实战的角度出发,聊聊我是如何优化一个慢查询接口的。
上周我优化了一下线上的批量评分查询接口,将接口性能从最初的`20s`,优化到目前的`500ms`以内。
总体来说,用三招就搞定了。
到底经历了什么?
## 1\. 案发现场
我们每天早上上班前,都会收到一封线上慢查询接口汇总邮件,邮件中会展示`接口地址``调用次数``最大耗时``平均耗时``traceId`等信息。
我看到其中有一个批量评分查询接口,最大耗时达到了`20s`,平均耗时也有`2s`
`skywalking`查看该接口的调用信息,发现绝大数情况下,该接口响应还是比较快的,大部分情况都是500ms左右就能返回,但也有少部分超过了20s的请求。
这个现象就非常奇怪了。
莫非跟数据有关?
比如:要查某一个组织的数据,是非常快的。但如果要查平台,即组织的根节点,这种情况下,需要查询的数据量非常大,接口响应就可能会非常慢。
但事实证明不是这个原因。
很快有个同事给出了答案。
他们在结算单列表页面中,批量请求了这个接口,但他传参的数据量非常大。
怎么回事呢?
当初说的需求是这个接口给分页的列表页面调用,每页大小有:10、20、30、50、100,用户可以选择。
换句话说,调用批量评价查询接口,一次性最多可以查询100条记录。
但实际情况是:结算单列表页面还包含了很多订单。基本上每一个结算单,都有多个订单。调用批量评价查询接口时,需要把结算单和订单的数据合并到一起。
这样导致的结果是:调用批量评价查询接口时,一次性传入的参数非常多,入参list中包含几百、甚至几千条数据都有可能。
## 2\. 现状
如果一次性传入几百或者几千个id,批量查询数据还好,可以走主键索引,查询效率也不至于太差。
但那个批量评分查询接口,逻辑不简单。
伪代码如下:
```
public List<ScoreEntity> query(List<SearchEntity> list) {     //结果     List<ScoreEntity> result = Lists.newArrayList();     //获取组织id     List<Long> orgIds = list.stream().map(SearchEntity::getOrgId).collect(Collectors.toList());     //通过regin调用远程接口获取组织信息     List<OrgEntity> orgList = feginClient.getOrgByIds(orgIds);          for(SearchEntity entity : list) {         //通过组织id找组织code         String orgCode = findOrgCode(orgList, entity.getOrgId());              //通过组合条件查询评价         ScoreSearchEntity scoreSearchEntity = new ScoreSearchEntity();         scoreSearchEntity.setOrgCode(orgCode);         scoreSearchEntity.setCategoryId(entity.getCategoryId());         scoreSearchEntity.setBusinessId(entity.getBusinessId());         scoreSearchEntity.setBusinessType(entity.getBusinessType());         List<ScoreEntity> resultList = scoreMapper.queryScore(scoreSearchEntity);                  if(CollectionUtils.isNotEmpty(resultList)) {             ScoreEntity scoreEntity = resultList.get(0);             result.add(scoreEntity);         }     }     return result; }
```
其实在真实场景中,代码比这个复杂很多,这里为了给大家演示,简化了一下。
最关键的地方有两点:
1. 在接口中远程调用了另外一个接口
2. 需要在for循环中查询数据
其中的第1点,即:在接口中远程调用了另外一个接口,这个代码是必须的。
因为如果在`评价表`中冗余一个组织code字段,万一哪天`组织表`中的组织code有修改,不得不通过某种机制,通知我们同步修改评价表的组织code,不然就会出现数据不一致的问题。
很显然,如果要这样调整的话,业务流程上要改了,代码改动有点大。
所以,还是先保持在接口中远程调用吧。
这样看来,可以优化的地方只能在:for循环中查询数据。
## 3\. 第一次优化
由于需要在for循环中,每条记录都要根据不同的条件,查询出想要的数据。
由于业务系统调用这个接口时,没有传`id`,不好在`where`条件中用`id in (...)`,这方式批量查询数据。
其实,有一种办法不用循环查询,一条sql就能搞定需求:使用`or`关键字拼接,例如:(org\_code='001' and category\_id=123 and business\_id=111 and business\_type=1) `or` (org\_code='002' and category\_id=123 and business\_id=112 and business\_type=2) `or` (org\_code='003' and category\_id=124 and business\_id=117 and business\_type=1)...
这种方式会导致sql语句会非常长,性能也会很差。
其实还有一种写法:
```
where (a,b) in ((1,2),(1,3)...)
```
不过这种sql,如果一次性查询的数据量太多的话,性能也不太好。
居然没法改成批量查询,就只能优化单条查询sql的执行效率了。
首先从`索引`入手,因为改造成本最低。
> 第一次优化是`优化索引`。
评价表之前建立一个business\_id字段的`普通索引`,但是从目前来看效率不太理想。
由于我果断加了`联合索引`
```
alter table user_score add index  `un_org_category_business` (`org_code`,`category_id`,`business_id`,`business_type`) USING BTREE;
```
该联合索引由:`org_code``category_id``business_id``business_type`四个字段组成。
经过这次优化,效果立竿见影。
批量评价查询接口最大耗时,从最初的`20s`,缩短到了`5s`左右。
## 4\. 第二次优化
由于需要在for循环中,每条记录都要根据不同的条件,查询出想要的数据。
只在一个线程中查询数据,显然太慢。
那么,为何不能改成多线程调用?
> 第二次优化,查询数据库由`单线程`改成`多线程`。
但由于该接口是要将查询出的所有数据,都返回回去的,所以要获取查询结果。
使用多线程调用,并且要获取返回值,这种场景使用java8中的`CompleteFuture`非常合适。
代码调整为:
```
CompletableFuture[] futureArray = dataList.stream()      .map(data -> CompletableFuture           .supplyAsync(() -> query(data), asyncExecutor)           .whenComplete((result, th) -> {        })).toArray(CompletableFuture[]::new); CompletableFuture.allOf(futureArray).join();
```
`CompleteFuture`的本质是创建`线程`执行,为了避免产生太多的线程,所以使用`线程池`是非常有必要的。
优先推荐使用`ThreadPoolExecutor`类,我们自定义线程池。
具体代码如下:
```
ExecutorService threadPool = new ThreadPoolExecutor(     8, //corePoolSize线程池中核心线程数     10, //maximumPoolSize 线程池中最大线程数     60, //线程池中线程的最大空闲时间,超过这个时间空闲线程将被回收     TimeUnit.SECONDS,//时间单位     new ArrayBlockingQueue(500), //队列     new ThreadPoolExecutor.CallerRunsPolicy()); //拒绝策略
```
也可以使用`ThreadPoolTaskExecutor`类创建线程池:
```
@Configuration public class ThreadPoolConfig {     /**      * 核心线程数量,默认1      */     private int corePoolSize = 8;     /**      * 最大线程数量,默认Integer.MAX_VALUE;      */     private int maxPoolSize = 10;     /**      * 空闲线程存活时间      */     private int keepAliveSeconds = 60;     /**      * 线程阻塞队列容量,默认Integer.MAX_VALUE      */     private int queueCapacity = 1;     /**      * 是否允许核心线程超时      */     private boolean allowCoreThreadTimeOut = false;     @Bean("asyncExecutor")     public Executor asyncExecutor() {         ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();         executor.setCorePoolSize(corePoolSize);         executor.setMaxPoolSize(maxPoolSize);         executor.setQueueCapacity(queueCapacity);         executor.setKeepAliveSeconds(keepAliveSeconds);         executor.setAllowCoreThreadTimeOut(allowCoreThreadTimeOut);         // 设置拒绝策略,直接在execute方法的调用线程中运行被拒绝的任务         executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());         // 执行初始化         executor.initialize();         return executor;     } }
```
经过这次优化,接口性能也提升了5倍。
`5s`左右,缩短到`1s`左右。
但整体效果还不太理想。
## 5\. 第三次优化
经过前面的两次优化,批量查询评价接口性能有一些提升,但耗时还是大于1s。
出现这个问题的根本原因是:`一次性查询的数据太多`
那么,我们为什么不限制一下,每次查询的记录条数呢?
> 第三次优化,限制一次性查询的记录条数。其实之前也做了限制,不过最大是2000条记录,从目前看效果不好。
限制该接口一次只能查`200`条记录,如果超过`200`条则会报错提示。
如果直接对该接口做限制,则可能会导致业务系统出现异常。
为了避免这种情况的发生,必须跟业务系统团队一起讨论一下优化方案。
主要有下面两个方案:
### 5.1 前端做分页
在结算单列表页中,每个结算单默认只展示1个订单,多余的分页查询。
这样的话,如果按照每页最大100条记录计算的话,结算单和订单最多一次只能查询200条记录。
这就需要业务系统的前端做`分页功能`,同时后端接口要调整支持`分页查询`
但目前现状是前端没有多余开发资源。
由于人手不足的原因,这套方案目前只能暂时搁置。
### 5.2 分批调用接口
业务系统后端之前是`一次性`调用评价查询接口,现在改成`分批`调用。
比如:之前查询500条记录,业务系统只调用一次查询接口。
现在改成业务系统每次只查100条记录,分5批调用,总共也是查询500条记录。
这样不是变慢了吗?
答:如果那5批调用评价查询接口的操作,是在for循环中单线程顺序的,整体耗时当然可能会变慢。
但业务系统也可以改成`多线程`调用,只需最终汇总结果即可。
此时,有人可能会问题:在评价查询接口的服务器多线程调用,跟在其他业务系统中多线程调用不是一回事?
还不如把批量评价查询接口的服务器中,`线程池``最大线程数`调大一点?
显然你忽略了一件事:线上应用一般不会被部署成单点。绝大多数情况下,为了避免因为服务器挂了,造成单点故障,基本会部署至少2个节点。这样即使一个节点挂了,整个应用也能正常访问。
> 当然也可能会出现这种情况:假如挂了一个节点,另外一个节点可能因为访问的流量太大了,扛不住压力,也可能因此挂掉。
换句话说,通过业务系统中的多线程调用接口,可以将访问接口的流量负载均衡到不同的节点上。
他们也用8个线程,将数据分批,每批100条记录,最后将结果汇总。
经过这次优化,接口性能再次提升了1倍。
`1s`左右,缩短到小于`500ms`
温馨提醒一下,无论是在批量查询评价接口查询数据库,还是在业务系统中调用批量查询评价接口,使用多线程调用,都只是一个临时方案,并不完美。
这样做的原因主要是为了先快速解决问题,因为这种方案改动是最小的。
要从根本上解决问题,需要重新设计这一套功能,需要修改表结构,甚至可能需要修改业务流程。但由于牵涉到多条业务线,多个业务系统,只能排期慢慢做了。
* * *
**微信8.0将好友放开到了一万,小伙伴可以加我大号了,先到先得,再满就真没了**
**扫描下方二维码即可加我微信啦,`2022,抱团取暖,一起牛逼。`**
![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/weixin-yicxzdjkxnyhcsdmsjylzsz-e5ad6bcd-46d8-4911-8390-f988d742ece7.jpg)
## 推荐阅读
* [新来个技术总监,把 RabbitMQ 讲的那叫一个透彻,佩服!](https://mp.weixin.qq.com/s?__biz=MzU1Nzg4NjgyMw==&mid=2247501313&idx=1&sn=4dc42f4e2662a5637ebbab646d699150&scene=21#wechat_redirect)
* [支持Nacos 2.1.0!这套Spring Cloud Gateway+Oauth2终极权限解决方案升级了!](https://mp.weixin.qq.com/s?__biz=MzU1Nzg4NjgyMw==&mid=2247501304&idx=1&sn=c9a17d313ad432081982132b2c1e79cc&scene=21#wechat_redirect)
* [JetBrains正式宣布:产品涨价!](https://mp.weixin.qq.com/s?__biz=MzU1Nzg4NjgyMw==&mid=2247501215&idx=1&sn=cd5e281cde81c8873b4bd19979d40191&scene=21#wechat_redirect)
* [当开发同事辞职,接手到垃圾代码怎么办?](https://mp.weixin.qq.com/s?__biz=MzU1Nzg4NjgyMw==&mid=2247501139&idx=1&sn=21d24ce2dff862350eadfd876a3ea585&scene=21#wechat_redirect)
* [还在用命令行看日志?快用Kibana吧,可视化日志分析YYDS!](https://mp.weixin.qq.com/s?__biz=MzU1Nzg4NjgyMw==&mid=2247501074&idx=1&sn=629db39555b3d344f928b87abecbba69&scene=21#wechat_redirect)
* [Mall电商实战项目全面升级!支持最新版SpringBoot,干掉循环依赖...](https://mp.weixin.qq.com/s?__biz=MzU1Nzg4NjgyMw==&mid=2247500820&idx=1&sn=9895bd4c39b90d45eb2a10efedb236ac&scene=21#wechat_redirect)
* [重磅更新!Mall实战教程全面升级,瞬间高大上了!](https://mp.weixin.qq.com/s?__biz=MzU1Nzg4NjgyMw==&mid=2247499376&idx=1&sn=3ed28795cdd35fbaa3506e74a56703b0&scene=21#wechat_redirect)
* [40K+Star!Mall电商实战项目开源回忆录!](https://mp.weixin.qq.com/s?__biz=MzU1Nzg4NjgyMw==&mid=2247486684&idx=1&sn=807fd808adac8019eb2095ba088efe54&scene=21#wechat_redirect)
![](http://cdn.tobebetterjavaer.com/tobebetterjavaer/images/nice-article/weixin-yicxzdjkxnyhcsdmsjylzsz-ffb0ad5d-a393-4a43-b594-74374c54f601.jpg)
>转载链接:[https://mp.weixin.qq.com/s/fPgFzGloyUTzSHkXa43M9w](https://mp.weixin.qq.com/s/fPgFzGloyUTzSHkXa43M9w),出处:macrozheng,整理:沉默王二
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册