天天看点

服务优化实战指南

作者:Java资深架构师

一:基础服务历史介绍

某居接口All in one存在的一些(特点)问题,以及随着业务的日益增长,爆发了一些问题。(此架构或者做法也带着业务渡过了一定的周期,一切问题的爆发都是随着量的积累出现的,如:用户量,数据量,并发量,或者开发人员的数量)

1: 某居几乎所有的业务接口都在一个应用程序里。(2020-04之前)

①:开发、测试、部署困难。即使只改动一个小功能,也需要整个应用一起发布。有时候人为疏忽不小心带上了一些未经测试的代码,或者修改 了一个功能后,另一个意想不到的地方出错了。为了减轻发布可能产生的问题的影响和线上业务停顿的影响,所有应用都要在凌晨一点点执行发布。发布后为了验证应用正常运行,还得盯到第二天白天的用户高峰期。

②:jvm oom、线程堆积、慢sql、cpu 100%、磁盘、内存、cpu、网络 一个故障导致(本机器)整个业务不可用。

③:业务没有主次之分,一个边缘业务异常可能导致整个业务流程不可用。可扩展性查,稍有改动就会牵连很多业务代码。

④:提交代码频繁出现大量冲突 : 十几个人开发一个模块,使用git做代码管理,则经常会遇到的事情就是代码提交冲突、 覆盖(人越多 冲突概率越大 )

⑤:单个应用为了给其他应用提供接口,渐渐地越改越大,包含了很多本来就不属于它的逻辑。应用边界模糊,功能归属混乱

⑥:开发人员太多,代码质量风格千变万化,最常见的是一行代码性能导致整个服务不可用。

⑦:应用间耦合严重,业务扩展性差。同一功能在各个应用中都有实现,改一处功能,其他系统都得一起改动。

故事一:调用大数据接口因为超时时间设置问题太长,导致线程大量堆积,一个接口影响了整个某居核心服务。

故事二:因为业务逻辑等问题从数据库查询出了600多万条数据,导致系统OOM,一个边缘影响了整个某居核心服务

事故三:流量冲击导致服务高负载,影响了整个某居核心服务

2:云平台大多服务共用一个数据库,即smart库。(2021-09之前)

如access-server、mbs、sub、message、iot-rpc、iot-scene、third-access、 网关云gateway 、mcloudbox等几十个服务。

①:几个慢SQL,会造成数据库压力急剧上升,应用超时增多,前端应用超时,用户重试,流量飙升,资源耗尽

②:所有应用都在一个数据库上操作,数据库出现性能瓶颈。特别是数据分析跑起来的时候,数据库性能急剧下降。

③:数据库成为性能瓶颈,并且有单点故障的风险。

④:并发数耗完。 数据库的连接数成为应用服务器扩容的瓶颈,因为连接 MySQL 的客户端数量是有限制的

故事一:MBS或者接入层发版,大量上报消息update或者select设备属性表,对数据库造成冲击,其他依赖于smart库的服务都收到影响

事故二:任何一个应用中写的一个低质量的SQL导致慢查询,其他依赖于smart库的服务都收到影响

事故三:大表加字段导致数据库发生主从切换,整个平台告警

3:云平台大多服务共用一个redis(一般比如运维问要操作哪个redis,一般回答就是:那个大redis、共用redis、主redis)。(2021-06之前)

如access-server、mbs、sub、message、iot-rpc、iot-scene、third-access、 网关云gateway 、mcloudbox等几十个服务。

①:redis共用,redis出现不可用,那么整个平台就会瘫痪,大key,keys。

keys:keys进行模糊匹配引发Redis锁,造成Redis锁住,CPU飙升,引起了所有调用链路的超时并且卡住,等Redis锁的那几秒结束,所有的请求流量全部请求到RDS数据库中,使数据库产生了雪崩,使数据库宕机。

②:redis内存不足整个某居业务都不可用

③: 除了带来极大的内存占用外,在访问量高时,很容易就会将网卡流量占满,进而造成整个服务器上的所有服务不可用,并引发雪崩效应,造成各个系统瘫痪

事故一:keys的使用导致依赖于主redis的业务全部不可用

事故二:大key长时间阻塞redis线程其他请求得不到处理,导致依赖于主redis的业务全部不可用

事故三:接口流量的冲击导致redis内存不足,依赖于主redis的业务全部不可用

4:云平台大多服务都依赖了一个common jar包,这个jar包的内容包括:

对smart库一些核心表的增删改查,对主redis的一些操作,一些通用的如VO对象等。

①:数据库表结构可能被多个服务依赖,牵一发而动全身,很难调整 ,如需要加字段,几乎全部的服务都得重新打包,mybatis.sql需要select出这个字段,common里的VO对象需要加上这个属性。否则放入redis的key少了属性导致出问题。

②:上生产前构建打包耗费大量的时间。

③:服务优化难,如数据更改入口分散在这几十个服务里,导致我们不能做:如 不能做本地缓存,无法做数据异构等

5:通用问题

①:单点故障影响整个服务

②:SLA下降,频繁事故

③:扩容时候流量不好预估

④:无法满足快速迭代的需求

对于以上存在的各种问题,已经不是扩容(机器,数据库、reids)可以解决的了。所以我们花了大概一年半的时间对以上存在的问题进行了改造。从代码拆分、部署独立、缓存解耦、业务拆分、数据库独立等一系列措施。通过这些步骤后,我们的服务解决了上面提到的大部分痛点,如:

1:主业务流程和附加业务分离

2:减少单点问题造成的影响面

3:多模块开发不相互影响,责任更加清晰,每个人专心负责为其他人提供更好的服务

4:通用业务下沉为基础服务

5:收拢数据维护入口,各个服务可以横向扩展如分库分表读写分离等

6:快速发版,滚动发布,影响面小

拆分是降低难度最重要,最有效的方法,人们总会遇到难度大于能力的问题,所以就需要拆分这种方法,把问题的难度降低,从而使得能力大于问题难度,从而把问题解决

服务拆分后,在接口稳定的情况下,不同的模块可以独立上线。这样上线的次数增多,单次上线的需求列表变小,可以随时回滚,风险变小,时间变短,影响面小,从而迭代速度加快。

服务拆分虽然解决了旧问题,也引入了新的问题。

①:微服务架构整个应用分散成多个服务,定位故障点非常困难。[链路追踪]

②:稳定性下降。服务数量变多导致其中一个服务出现故障的概率增大,并且一个服务故障可能会产生雪崩效用,导致整个系统故障。事实上,在大访问量的生产场景下,故障总是会出现的。[故障隔离,故障传播]

③:服务数量非常多,部署、管理的工作量很大。[ devOps ,CI/CD]

④:一方面尽量减少故障发生的概率,另一方面降低故障造成的影响.[服务自我保护:①:业务间隔离,②:应对上游的冲击,③:应对下游的阻塞,常见的词语:资源隔离,熔断,限流,降级]

以上拆分后存在的关于微服务治理、服务自我保护等一些相关的问题,后续后空会和大家一起分享。

我今天我们主要围绕着:sql优化,线程池等相关的知识给大家分享。其中穿插着一些生产事解析,这样可以让大家在以后的工作中避免犯同样的错误。

二:业务代码优化

这一部分的内容主要是从我们云端的代码仓库里随便抽查了一些代码作为案例来讲解。主要是告诉大家一个道理:优化有时候可能就是顺手就可以做的事情,并不需要什么高大上的东西。

程序逻辑优化,比如将大概率阻断执行流程的判断逻辑前置、For循环的计算逻辑优化,或者采用更高效的算法 。

各种池化技术的使用和池大小的设置,包括HTTP请求池、线程池(考虑CPU密集型还是IO密集型设置核心参数)、数据库和Redis连接池等

池化实际上是预处理和延后处理的一种应用场景,通过池子将各类资源的创建提前和销毁延后。

减少IO操作

比如我们使用sql查询数据,如果一条条去查询的话,那么每次执行查询,整个查询流程都会执行一遍(连接、查询缓存、分析器、优化器、执行器等,最终io返回给调用方),这样不但耗时而且耗费数据库连接,我们可以使用批量查询的方式,把需要查询的条件一次性的给到mysql服务。

List ids = new ArrayList<>(Arrays.asList(1,2,3,4,5,6,7,8,9,10));
List<User> list = new ArrayList();
for(int id : ids){
   User user = redis.get(id);//一次IO
   if(user == null){
       user = Dao.get(id);//一次IO
   }
   list.add(u);
}           

优化后:

List<User> list = redis.multiGet(ids);//key过大分批
ids.removeAll(list.stream.map(User::getId).collect(Collectors.toList()));
if(!ids.isEmpty()){
    list.addAll(Dao.batchGet(ids));//IN过大可以分批查询
}           

将请求拦截在系统上游

尽量把参数校验或者不不消耗资源的校验放在最前方,避免程序向后执行到sql层消耗数据库资源。

<select id="queryUserByCondition" parameterType="com.midea.iot.svc.user.entity.vo.UserVo"
            resultType="com.midea.iot.svc.user.entity.UserAll">
        select
        id,owner_app_id,src_app_id,nick_name,password,mobile,email,address,account_status,update_time,register_time,
        signature,profile_pic_url,sex,phone,age,uid from t_ms_user where 1=1
        <if test="uid != null and uid != ''">
            and uid=#{uid}
        </if>
        <if test="mobile != null and mobile != '' and ownerAppId == null">
            and mobile = #{mobile,jdbcType=VARCHAR} and owner_app_id is null
        </if>
        <if test="email != null and email != ''">
            and email = #{email}
        </if>
 </select>           

问题原因:以上接口层开发同学未做参数校验,导致以上条件都不满足, 从而导致生成的 sql 缺失了查询条件,导致全表扫码扫描545688条。多次请求加载后导致程序导致OOM。

for (Room_restore room_restore : roomList) {
            Long roomId = createRoom(room_restore, homegroupId, user.getId(),req.getReqId());//一次IO
            if (CollectionUtils.isNotEmpty(room_restore.getGatewayList())) {
                for (GateWay gateWay : room_restore.getGatewayList()) {
                    Long gateWayId = createGateway(gateWay, homegroupId, roomId,user.getId(),reqId);//一次IO
                    if (CollectionUtils.isNotEmpty(gateWay.getSubdeviceList()) && gateWayId != null) {
                        for (Subdevice subdevice : gateWay.getSubdeviceList()) {
                            roomId = createRoom(new Room_restore(subdevice.getRoomId(), subdevice.getRoomName(), subdevice.getRoomType()), homegroupId, user.getId(),req.getReqId());//一次IO
                            createSubDevice(subdevice, homegroupId, roomId, gateWayId,user.getId(),reqId);//一次IO
                        }
                    }
                }
            }
        }           

在我们的代码中,也看到类似以下的情况:

insert的时候业务代码不校验是否为空,把是否为空的校验交给mysql的not null约束去校验,这样浪费了不必要的资源开销。

并行调用

http多次请求外部接口,如果调用之间没有结果上的依赖,那么适当的情况下可以考虑使用多线程的方式进行。因为在这个过程中cpu并不过多参与io,所以我们可以开启更多线程去抢占cpu时间片。

CompletableFuture<Void> applianceStatusFeature = CompletableFuture.runAsync(()->{
            applianceStatusQueryService.batchGetStatus(applianceList,status);
        }, applianceListExecutor);
                
    CompletableFuture<Void> appliancePropertiesFeature = CompletableFuture.runAsync(()->{
            batchGetApplianceProperties(applianceList,nfcDetail,bluetoothDetail);
            fillApplianceBindType(applianceList);
        }, applianceListExecutor);
      try {
            CompletableFuture.allOf(applianceStatusFeature,appliancePropertiesFeature).get(5, TimeUnit.SECONDS);
    } catch (TimeoutException e) {
            logger.error("appliance home list error",e);
            throw new ApplianceApiException(CommonErrorCode.SYSTEM_ERROR);
    }           

日志输出

因为字符串常量池所在的内存块没有空间,根本原因是因为e.printStackTrace() 语句产生的字符串记录的是堆栈信息,太长太多,内存被填满了 。同样的这个是标准输出到控制台的,对定位问题也没啥用。

try {
        Map<String, String> v2Bean = beanToMap(v1Bean.getMap());
        v2Bean.put("meijuVersion", "4.0");
        v2.params = v2Bean;
       } catch (Exception) {
        e.printStackTrace();
}           

日志输出规范,第五章 会细讲。

数据异构

比如保存一些其他系统不经常变化的数据放在本地缓存,典型场景:设备控制时候判断设备是否属于某个用户,可以把结果缓存起来,每次控制设备,如果是则直接控制,如果不属于则调用设备服务接口查询,查询结果缓存起来。同时在设备,用户关系变更发布事件,控制系统删除本地缓存。 在高并发或者时延敏感的场景下,为了提升接口性能,可以保存冗余数据的数据异构方案 来替代远程接口的调用。

数据异构的方案, 就会出现数据一致性问题 。每种方案都是空间和时间的平衡,在以上场景下即使数据不一致也不会影响用户体验。如果有一定的一致性要求,则可以想一些数据同步方案,如数据变更方发事件等,消费方监听事件做缓存清理等操作。

降低锁粒度

降低锁粒度 concurrenthashmap ,尽可能只在临界点加锁,无锁,或者乐观锁(读多写少的场景),避免在循环中加锁(阻塞和唤醒很费时间),不如直接把整个循环加锁(如果不影响多线程间的逻辑关系)

此片段摘自阿里巴巴java开发规范(泰山版)

【强制】高并发时,同步调用应该去考量锁的性能损耗。能用无锁数据结构,就不要用锁;能

锁区块,就不要锁整个方法体;能用对象锁,就不要用类锁。

说明:尽可能使加锁的代码块工作量尽可能的小,避免在锁代码块中调用 RPC 方法。

单例,效率低下:

private static Singleton singleton;

    private Singleton() {

    }

public static synchronized Singleton getInstance() {
            if (singleton == null) {
                Thread.sleep(1000);
                singleton = new Singleton();
            }
        return singleton;
    }           

DCL优化后:

public class Singleton {

    private static volatile Singleton singleton;

    private Singleton() {

    }

    public static Singleton getInstance() {
            if (singleton== null) {
                Thread.sleep(1000);
                synchronized (Singleton.class) {
                    if(null == singleton) {
                        singleton = new Singleton();
                    }
                }
            }
        return singleton;
    }
}           

乐观锁,如java中的CAS,AtomicInteger ,数据库实现乐观锁并发更新等。

源码中的案例:

protected Object getSingleton(String beanName, boolean allowEarlyReference) {
        Object singletonObject = this.singletonObjects.get(beanName);
        if (singletonObject == null && isSingletonCurrentlyInCreation(beanName)) {
            synchronized (this.singletonObjects) {
                singletonObject = this.earlySingletonObjects.get(beanName);
                if (singletonObject == null && allowEarlyReference) {
                    ObjectFactory<?> singletonFactory = this.singletonFactories.get(beanName);
                    if (singletonFactory != null) {
                        singletonObject = singletonFactory.getObject();
                        this.earlySingletonObjects.put(beanName, singletonObject);
                        this.singletonFactories.remove(beanName);
                    }
                }
            }
        }
        return singletonObject;
    }           

线程池/减少开销

此片段摘自阿里巴巴java开发规范(泰山版)

【强制】线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。

说明:线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源的开销,解决资源不足的问题。

如果不使用线程池,有可能造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题。

public void updateSceneDataAsyn(Map<Long, Long> sceneIdMap, String uid, Long homegroupId, String reqId,
            String stamp) throws Exception {

        logger.info("updateSceneDataAsyn sceneIdMap:{},uid:{},homegroupId{},reqId:{}",sceneIdMap,uid,homegroupId,reqId);
        if (sceneIdMap != null && !sceneIdMap.isEmpty()) {
            new Thread(() -> {
                logger.info("begin {} updateSceneDataAsyn",reqId);
                try {
                    Iterator<Entry<Long, Long>> iterator = sceneIdMap.entrySet().iterator();
                    while (iterator.hasNext()) {
                        Entry<Long, Long> entry = iterator.next();
                        Long oldSceneId = entry.getKey(), newSceneId = entry.getValue();
                        if(oldSceneId.longValue() != newSceneId.longValue()) {
                            updateSceneActionResult(oldSceneId, newSceneId);
                            updateSceneResult(oldSceneId, newSceneId, homegroupId);
                        }
                      }
                    } catch (Exception e) {
                     logger.error("updateSceneData error", e);
                  }
                logger.info("end {} updateSceneDataAsyn",reqId);
            },"updateSceneDataAsyn").start();
        }
    }           

这里涉及到线程池参数设置问题,会在下面讲解。

减少不必要的查询

比如,在 if(A && B) 运算的时候如果两边或者一边存在io操作来到得到结果,如果可能的话拆开来写,把代价小的判断先执行,这样当这部分不满足的情况下可以省掉后面不必要的操作。

public void updateSubscribe(AppSubscribeTypeUpdateRequest appSubscribeTypeUpdateRequest) throws Exception {
        Integer appId = Integer.parseInt(OpenSecurityUtil.decrypt(appSubscribeTypeUpdateRequest.getClientId()));
        //修改成缓存
        App app = openAppCache.getByAppId(appSubscribeTypeUpdateRequest.getReqId(), appId);  代码①
        AppSrc appSrc = openAppCache.getSrcByAppId(appSubscribeTypeUpdateRequest.getReqId(), appId); 代码②
        if (app != null && appSrc != null) {
          //修改授权相关信息
        }
    }           

以上 代码①,代码② 无论何时都是会执行的。但是看if里的逻辑当都不为空的时候才执行逻辑。所以这种情况我们可以先查询代码①(比如代码①查询DB的性能比代码②高一些)。

当然:如果业务上不存在传非法参数导致查询db查询App为空或者说db里一定存在这些数据的情况下,则没必要优化。

public void updateSubscribe(AppSubscribeTypeUpdateRequest appSubscribeTypeUpdateRequest) throws Exception {
        Integer appId = Integer.parseInt(OpenSecurityUtil.decrypt(appSubscribeTypeUpdateRequest.getClientId()));
        //修改成缓存
     App app = openAppCache.getByAppId(appSubscribeTypeUpdateRequest.getReqId(), appId);  代码①
     if (app != null ) {
        AppSrc appSrc = openAppCache.getSrcByAppId(appSubscribeTypeUpdateRequest.getReqId(), appId); 代码②
            if(appSrc != null){
                 //修改授权相关信息
            }
     }
  }           

以下代码也可以进行优化

private void updateSubscribeInfo(List<String> subTypes, Integer appId, Integer srcId,String reqId){
  SubscriptionThird subscriptionThird =  subscriptionApiManager.getSubscriptionThird(appId,DEFAULT_THIRD_TOPIC,reqId);
        if (subscriptionThird == null && CollectionUtils.isNotEmpty(subTypes)) {
            subscriptionApiManager.insertSubscriptionThird(appId,DEFAULT_THIRD_TOPIC,reqId);
        } 
     }
}            

改为:

private void updateSubscribeInfo(List<String> subTypes, Integer appId, Integer srcId,String reqId){
  
        if (CollectionUtils.isNotEmpty(subTypes)) {//判断代价很小,这里不满足将会省去下面的io查询操作
            SubscriptionThird subscriptionThird =             subscriptionApiManager.getSubscriptionThird(appId,DEFAULT_THIRD_TOPIC,reqId);
            if( subscriptionThird == null){
                subscriptionApiManager.insertSubscriptionThird(appId,DEFAULT_THIRD_TOPIC,reqId);
            }
        } 
     }
}            

此片段摘自阿里巴巴java开发规范(泰山版)

减少不必要的更新:不要写一个大而全的数据更新接口。传入为 POJO 类,不管是不是自己的目标更新字 段,都进行 update table set c1=value1,c2=value2,c3=value3; 这是不对的。执行 SQL 时, 不要更新无改动的字段,一是易出错;二是效率低;三是增加 binlog 存储;四是生成undolog;五是更新本身是会加排他锁,无效的更新降低了并发性。

印象最深的是MBS(很早之前的mbs)上报设备属性的并发很高,特别是接入层发版后整个云平台可能都会瘫痪(大多数服务共用smart库)。因为:

大量的设备上报,mbs大量的更新属性表,其实属性的频率变化很低,没必要每次/每个字段都去更新,所以mbs利用先查询再判断上报的数据是否和上报的有变化,只更新变化的字段,无变化的不用更新。

避免长事物

如果程序使用了事物,那一定要注意事物的作用范围,尽量以最快的速度完成事物操作。如果事物的执行时间过长,则与事物相关的数据就会被锁住,影响系统的并发性与整体性能。

案例一:避免在事物中进行远程调用(http,mq,rpc等)

在以下案例中,绑定完成设备这个需要事物操作,但是绑定完成后的http调用是不需要事物的,如果放在事物操作里,如果远程服务不可用或者网络不通的情况下或导致http接口或者MQ写入阻塞,从而导致 过长时会占用数据库连接 ,高并发下耗尽数据库连接。

@Transactional
public void applianceBind(Appliance appliance,Long homegroupId){
   
   dao.updateAppliance(appliance);
   dao.updateApplianceHomeGroup(appliance.getId(),homegroupId);
   msgHandler.send(appliance,homegroupId);//远程http调用消息中心推送消息,或者放入MQ
   //方法运行完成事物才结束
}           

数据库操作与发送消息解耦 , 在数据成功保存且事务提交了就发送消息

@Transactional
public void applianceBind(Appliance appliance,Long homegroupId){
   
   dao.updateAppliance(appliance);
   dao.updateApplianceHomeGroup(appliance.getId(),homegroupId);
    TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronizationAdapter(){
       @Override
       public void afterCommit() {//事物提交成功之后执行
           msgHandler.send(appliance,homegroupId);//远程http调用消息中心推送消息或者放入MQ
       }
    });
}           

或者使用spring提供的时间监听器去解决这种 在处理数据库事务提交成功后再执行某些操作 ,可以了解@TransactionalEventListener注解的原理。

案例二:热点数据更新问题

比如双11晚上淘宝商品大卖,用户下单后的钱都支付给淘宝企业银行账号,此时很多支付请求会同时更新淘宝企业银行账号的余额,然后再生成订单信息同时记录一笔操作流水。

@Transactional
public void paymentOrder(Order order){
   aliAccount.updateBalance(order.getAmount());//更新淘宝账户余额属于热点记录的操作   1s
   orderDao.updateOrderStatus(order.getId(),1);//更新订单状态  1s
   logDao.Log(order);//记录一条日志   1s
}           

以上在一个事物中,更新淘宝账户余额 的操作属于对共有资源的操作,这里存在排他锁 ,为了提高并发,我们可以把这个操作放在最后一行。

备注:分享的时候我可能没把这里讲的很明白,这里再次解释下

假设:以上三个dao操作,每个操作都花费1s的时间,那么aliAccount.updateBalance(order.getAmount()) 这条被更新的数据将持有锁的时间是大概3s,在这3s内,任何调用这个方法的请求都会阻塞在这行代码。直到第一个请求执行完整个方法才会释放锁。其他等待锁的请求中会有一个请求获取到锁继续执行,其他的请求继续在aliAccount.updateBalance(order.getAmount())等待。

如果我们把aliAccount.updateBalance(order.getAmount())放在最后一行,那么aliAccount.updateBalance(order.getAmount())这条被更新的数据将持有锁的时间是大概1s,占用锁的时间会变短,其他请求会先执行其他的两个更新语句后才等待锁的释放。

知识点:两阶段锁协议

两阶段加锁协议:用于单机事务中的一致性和隔离性

在一个事务操作中,分为加锁阶段和解锁阶段,且所有的加锁操作在解锁操作之前 。

加锁时机:当对记录进行更新操作或者select for update(X锁)、lock in share mode(S锁)时,会对记录进行加锁。

何时解锁: 在一个事务中,只有在commit或者rollback时,才是解锁阶段。

题外的避坑指南 :事物中分布式锁的场景要慎重。

描述:在老的设备服务中存在以下代码,出现的问题是绑定记录插入重复。

原因:锁释放,事物还未提交,其他事物又到达条件区域导致代码执行重复。

@Transactional
public void activeBind(Long applianceCode,Long homegroupId){
        DistributedLock lock = null;
        try{
            lock = getDistributedLock("activeBind:"+applianceCode);
            if (lock.lock()) {
                 Appliance appliance = selectOne(applianceCode);
                    if (appliance != null && shoppingOrder.getStatus() == 0) {//条件区域
                        appliance.setActiveStatus(1);
                        appliance.setActiveTime(new Date());
                        xxxService.updateAppliance(appliance);
                        xxxService.addBindRecord(applianceCode,homegroupId);//插入绑定记录
                    }
        }catch (Exception e) {
        
        }finally {
            if (lock != null) {
                lock.unlock();
            }
        }
}           

请求合并

在高并发接口设计的过程中有一个技巧是可以采用接口合并的策略。它的前提是要求下游服务要提供批量接口。

首先为每一个请求分配一个唯一的标识来表示该次请求,然后将请求放入队列,通过定时任务每隔10ms去队列里面获取一批请求,然后组装成批量请求的参数向批量接口发起请求。在收到返回结果以后,再根据唯一标识来讲结果赋予对应的请求。这样可以降低io请求次数的目的,提高并发性能。

单个请求:

服务优化实战指南

合并请求

服务优化实战指南

请求合并原理示意图

服务优化实战指南

声明:此图是照着别人的图画的,图即代码。这里主要用到线程池、定时任务、等待/唤醒的相关工具类、结果分发等一些基础知识。

package com.example.demo.service;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executors;
import java.util.concurrent.LinkedBlockingDeque;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Function;
import java.util.stream.Collectors;

import javax.annotation.PostConstruct;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;

import com.example.demo.third.ApplianceSvcService;
import com.example.demo.vo.Appliance;
import com.midea.framework.commons.http.R;
import com.midea.framework.commons.json.JsonUtil;

@Component
public class ApplianceService {

    private static final Logger LOGGER = LoggerFactory.getLogger(ApplianceService.class);

    private LinkedBlockingDeque<ApplianceBatchSendRequest> applianceBatchSendRequestQueue = new LinkedBlockingDeque<>();

    @Autowired
    private ApplianceSvcService applianceSvcService;

    public void put(CompletableFuture<Appliance> completedFuture, Long applianceCode) {

        ApplianceBatchSendRequest applianceBatchSendRequest = new ApplianceBatchSendRequest();
        applianceBatchSendRequest.setApplianceCode(applianceCode);
        applianceBatchSendRequest.setCompletedFuture(completedFuture);
        applianceBatchSendRequestQueue.add(applianceBatchSendRequest);
    }

    @PostConstruct
    public void init() {
        ScheduledExecutorService scheduledExecutorService = Executors.newScheduledThreadPool(50);
        scheduledExecutorService.scheduleAtFixedRate(() -> {
            List<ApplianceBatchSendRequest> applianceBatchSendRequestList = new ArrayList<>();
            List<Long> applianceCodeList = new ArrayList<>();

            int size = applianceBatchSendRequestQueue.size();
            for (int i = 0; i < size; i++) {
                ApplianceBatchSendRequest deviceCreateRequest = applianceBatchSendRequestQueue.poll();
                if (Objects.nonNull(deviceCreateRequest)) {
                    applianceBatchSendRequestList.add(deviceCreateRequest);
                    applianceCodeList.add(deviceCreateRequest.getApplianceCode());
                }
            }

            if (!applianceBatchSendRequestList.isEmpty()) {
                try {
                    List<Appliance> response = mutilGetApplianceByCodes(applianceCodeList);
                    Map<Long, Appliance> collect = response.stream().collect(
                            Collectors.toMap(Appliance::getApplianceCode, Function.identity(), (key1, key2) -> key2));

                    for (ApplianceBatchSendRequest applianceBatchSendRequest : applianceBatchSendRequestList) {
                        applianceBatchSendRequest.getCompletedFuture()
                                .complete(collect.get(applianceBatchSendRequest.getApplianceCode()));
                    }
                } catch (Throwable throwable) {
                    applianceBatchSendRequestList.forEach(deviceCreateRequest -> deviceCreateRequest
                            .getCompletedFuture().obtrudeException(throwable));
                }
            }
        }, 0, 10, TimeUnit.MILLISECONDS);

    }

    public List<Appliance> mutilGetApplianceByCodes(List<Long> applianceCodeList) {

        LOGGER.info("mutilGetApplianceByCodes,param:{}", applianceCodeList);

        R r = applianceSvcService.mutilGetApplianceByCodes(applianceCodeList);
        if (r == null || r.getStatusCode() != HttpStatus.OK.value()) {
            LOGGER.error("mutilGetApplianceByCodes error,result:{}", r == null ? null : JsonUtil.toJson(r));
            return Collections.emptyList();
        }
        return JsonUtil.parseArray(r.getResponseText(), Appliance.class);

    }

    public Appliance getByCode(Long applianceCode) {

        LOGGER.info("get appliance by code,param:{}", applianceCode);

        R r = applianceSvcService.getApplianceByCode(applianceCode);
        if (r == null || r.getStatusCode() != HttpStatus.OK.value()) {
            LOGGER.error("mutilGetApplianceByCodes error,result:{}", r == null ? null : JsonUtil.toJson(r));
            return null;
        }
        return JsonUtil.parse(r.getResponseText(), Appliance.class);

    }
}           
10_111_0_121
ab -n 1000000 -c 590 http://10.xxx.1.136:8800/thread/request/defered/merge?applianceCode=211106233032263           

以上经过测试:服务端接口支持的最大QPS为10000多一点,请求合并后,调用方端调用的QPS达到将近20000.

​

服务优化实战指南
服务优化实战指南

请求合并的弊端:

  • 启用请求的成本是执行实际逻辑之前增加的延迟,还有批量接口带来的延时(可以忽略不计)。
  • 如果平均仅需要5毫秒的执行时间,放在一个10毫秒的做一次批处理的合并场景下,则在最坏的情况下,执行时间可能会变为15毫秒。(不适合低延迟的RPC场景、不适合低并发场景)

使用场景 :

  • 如果很少有超过1或2个请求会并发在一起,则没有必要用。
  • 一个特定的查询同时被大量使用,并且可以将几+个甚至数百个批处理在一起,那么如果能接受处理时间变长一点点,用来减少网络连接,这是值得的。(典型如:数据库、Http接口)

相关的开源框架 :

在SpringCloud的组件spring-cloud-starter-netflix-hystrix中已经有封装好的轮子Hystrix的HystrixCollapser来实现请求的合并,以减少通信消耗和线程数的占用。这个组件比较复杂,也更全面,支持异步,同步,超时,异常等的处理机制。

package com.example.demo.service.merge;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Future;
import java.util.function.Function;
import java.util.stream.Collectors;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;

import com.example.demo.third.ApplianceSvcService;
import com.example.demo.vo.Appliance;
import com.midea.framework.commons.http.R;
import com.midea.framework.commons.json.JsonUtil;
import com.netflix.hystrix.contrib.javanica.annotation.HystrixCollapser;
import com.netflix.hystrix.contrib.javanica.annotation.HystrixCommand;
import com.netflix.hystrix.contrib.javanica.annotation.HystrixProperty;

@Component
public class ApplianceBatchGetWithHystrixService {

	private static final Logger LOGGER = LoggerFactory.getLogger(ApplianceBatchGetWithHystrixService.class);

	@Autowired
	private ApplianceSvcService applianceSvcService;

	@HystrixCollapser(scope = com.netflix.hystrix.HystrixCollapser.Scope.GLOBAL,batchMethod = "mutilGetApplianceByCodes",collapserProperties = {
			@HystrixProperty(name = "timerDelayInMilliseconds", value = "7"),
			@HystrixProperty(name = "maxRequestsInBatch",value = "80"),
			//@HystrixProperty(name = "requestCache.enabled", value = "true")		
	})
	public Future<Appliance> getByCode(Long applianceCode) {
		throw new RuntimeException("the method will never be executed");
	}

	@HystrixCommand(
			groupKey = "mutilGetApplianceByCodes",
    threadPoolKey = "mutilGetApplianceByCodes",
    threadPoolProperties = {
            @HystrixProperty(name = "coreSize", value = "50"),
            @HystrixProperty(name = "maxQueueSize", value = "2000"),
            @HystrixProperty(name = "queueSizeRejectionThreshold", value = "1000")
    })
    
    
	public Collection<Appliance> mutilGetApplianceByCodes(List<Long> applianceCodeList) {
		LOGGER.info("mutilGetApplianceByCodes,param:{}", applianceCodeList);

		R r = applianceSvcService.mutilGetApplianceByCodes(applianceCodeList);
		if (r == null || r.getStatusCode() != HttpStatus.OK.value()) {
			LOGGER.error("mutilGetApplianceByCodes error,result:{}", r == null ? null : JsonUtil.toJson(r));
			return new ArrayList<>();
		}
		Map<Long, Appliance> collect = JsonUtil.parseArray(r.getResponseText(), Appliance.class).stream().collect(
				Collectors.toMap(Appliance::getApplianceCode, Function.identity(), (key1, key2) -> key2));
		List<Appliance> list = new ArrayList<>();
		for(Long code : applianceCodeList) {
			list.add(collect.get(code));
		}
		return list;
	}
}           

从底层思路来说,就是线程之间的通信,线程的切换,队列等一些并发编程相关的技术。

总结下来看具有相同结构的独立请求按如下方式合并:

  1. 等待一段时间汇集批量操作
  2. 通过实现批量执行方法,执行批量操作
  3. 将批量操作结果与请求映射,最终返回

三:数据库索引

索引的概念

索引是在Mysql存储引擎层实现的为了加速对表中数据行的检索而创建的一种排好序的数据结构。

索引的分类

MySQL的索引包括普通索引、唯一性索引、全文索引、单列索引、多列索引等。

  • 从 功能逻辑 上说,索引主要有 4 种,分别是普通索引、唯一索引、主键索引、全文索引。
  • 按照 物理实现方式 ,索引可以分为 2 种:聚簇索引和非聚簇索引。
  • 按照 作用字段个数进行划分,分成单列索引和联合索引。

创建索引原则

哪些情况适合创建索引

  1. 字段的数值有唯一性的限制
  2. 频繁作为 WHERE 查询条件的字段
  3. 经常 GROUP BY 和 ORDER BY 的列
  4. UPDATE、DELETE 的 WHERE 条件列
  5. DISTINCT 字段需要创建索引
  6. 多表 JOIN 连接操作时,创建索引注意事项
  7. 使用列的类型小的创建索引
  8. 使用字符串前缀创建索引
  9. 区分度高(散列性高)的列适合作为索引
  10. 使用最频繁的列放到联合索引的左侧
  11. 在多个字段都要创建索引的情况下,联合索引优于单值索引(尽量扩展索引,不要新建索引)
  12. 限制索引的数目

哪些情况不适合创建索引

  1. 在where中使用不到的字段,不要设置索引

WHERE条件(包括GROUP BY、ORDER BY)里用不到的字段不需要创建索引,索引的价值是快速定位,

如果起不 到定位的字段通常 是不需要创建索引的。

2.数据量小的表最好不要使用索引

如果表记录太少,比如少于1000个,那么就不需要建立索引。由于表记录太少,是否建立索引,对查询效率的影响并不大。

  1. 有大量重复数据的列上不要建立索引(选择性低)
  2. 避免对经常更新的表创建过多的索引
  3. 不建议用无序的值作为索引
  4. 删除不再使用或者很少使用的索引
  5. 不要定义冗余或重复的索引

索引优化策略

关键词: 覆盖索引 最左前缀原则 索引下推 前缀索引 避免索引失效

策略1:尽量考虑覆盖索引

策略2:遵循最左前缀匹配

策略3:范围查询字段放最后

策略4:不对索引字段进行逻辑操作(函数操作)

策略5:尽量避免select *

策略6:Like查询,左侧尽量不要加%

策略7:注意null/not null 可能对索引有影响

策略8:尽量减少使用不等于

策略9:避免类型转换

策略10:OR关键字左右尽量都为索引列

索引的数据结构

聚簇索引(聚集索引,主键索引)

聚簇索引就是按照每张表的主键构造一颗B+树,叶子节点中存放的就是整张表的行记录数据,也将聚集索引的叶子节点称为数据页。这个特性决定了索引组织表中数据也是索引的一部分,每张表只能拥有一个聚簇索引。

服务优化实战指南

  Innodb通过主键聚集数据,如果没有定义主键,innodb会选择非空的唯一索引代替。如果没有这样的索引,innodb会隐式的定义一个主键来作为聚簇索引。

非聚簇索引

在innoDB中,除了聚簇索引以外,其余的索引都可以成为非聚簇索引。和聚簇索引相比,辅助索引的叶子节点存放主键ID,而不是整条数据。 通过非聚簇索引首先找到的是数据的主键值,再通过主键值从聚簇索引树上查找对应的数据。一张表可以有多个非聚簇索引。在innodb中也称非聚簇索引为 辅助索引为非主键索引,二级索引。

服务优化实战指南

复合索引(联合索引)

在多个列上建立的索引,这种索引叫做复合索引。使用联合索引可以过滤掉更多的数据,提升索引的命中率。

服务优化实战指南

主键索引和辅助索引的区别

主键索引和非主键索引的区别是:非主键索引的叶子节点存放的是主键的值,而主键索引的叶子节点存放的是整行数据,其中非主键索引也被称为二级索引,而主键索引也被称为聚簇索引。

根据这两种结构我们来进行下查询,看看他们在查询上有什么区别。

1、如果查询语句是 select * from table where ID = 100,即主键查询的方式,则只需要搜索 ID 这棵 B+树。

2、如果查询语句是 select * from table where k = 1,即非主键的查询方式,则先搜索k索引树,得到ID=100,再到ID索引树搜索一次,这个过程也被称为回表。

SQL优化原则

慢SQL :就是执行效率很慢的sql语句。

危害:mysql服务器的资源,如CPU、IO、内存等 是有限的,尤其在高并发场景下需要快速的处理掉请求,否则一旦出现慢SQL就会阻塞掉很多正常的请求,造成大面积的失败或者超时。

SQL优化目的:是为了提高执行效率,达到快速检索的目的 。

为了写好高效的SQL,接下来我主要从以下几个方面讲解一些SQL优化的知识:

1:是否建立正确的/高效的索引

2:是否使用到索引/失效没起到作用

索引失效指的是没有利用到索引的二分查询进行数据过滤。

总结几个索引失效的场景(有的场景是需要优化器根据具体的一些性能指标去估算的,不是一成不变的):

组合索引左匹配原则

发生隐式转换

组合索引,in + order by 会阻断排序用索引

范围查询会阻断组合索引,索引涉及到范围查询的索引字段要放在组合索引的最后面。

前模糊匹配导致索引失效

or查询,查询条件部分有索引,部分无索引,导致索引失效。

查询条件使用了函数运算、四则运算等。

使用了!=、not in

选择性过低

asc和desc混用

3:是否使用到了高效的索引

SQL优化实战

以下mysql采用的版本号是:5.6.16,InnoDB存储引擎

CREATE TABLE `t_employees` (
  `emp_no` bigint(20) NOT NULL,
  `name` varchar(14) NOT NULL,
  `gender` enum('M','F') NOT NULL DEFAULT 'M',
  `age` int(11) NOT NULL DEFAULT '0',
  `mobile` varchar(32) DEFAULT NULL COMMENT '年龄',
  `department_id` int(11) NOT NULL DEFAULT '0',
  `hire_date` date NOT NULL DEFAULT '1970-01-01',
  `birth_date` date NOT NULL DEFAULT '1970-01-01',
  `address` varchar(30) DEFAULT NULL,
  PRIMARY KEY (`emp_no`),
  UNIQUE KEY `mobile` (`mobile`),
  KEY `idx_name_age_address` (`name`,`age`,`address`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4           

在进行实战之前,我先用几张B+树的图让大家熟悉一下以下概念:

  • 聚簇索引
  • 非聚簇索引
  • 索引页
  • 数据页
  • 全表扫描
  • 索引全扫描
  • 聚合索引
  • 回表
  • 覆盖索引
  • 最左前缀原则
  • 索引下推
  • 前缀索引

一:避免类型隐式转换

若varchar类型字段值不加单引号,可能会发生数据类型隐式转化,自动转换为int型,使索引无效

正例

EXPLAIN SELECT * FROM `t_employees` WHERE mobile='13600000002'           
服务优化实战指南

反例

EXPLAIN SELECT * FROM `t_employees` WHERE mobile=13600000002;           
服务优化实战指南

为什么上面查询传递的值是数字导致索引失效?因为字段是字符类型,传参是数字,mysql做了隐式类型转换。

知识点:EXTENDED + SHOW WARNINGS 查看sql执行计划返回的警告信息

EXPLAIN EXTENDED SELECT * FROM `t_employees` WHERE mobile=13600000002;
SHOW WARNINGS           
服务优化实战指南

mysql还有一些其他的转换规则:

  • 不以数字开头的字符串都将转换为0。如'abc'、'a123bc'、'abc123'都会转化为0;
  • 以数字开头的字符串转换时会进行截取,从第一个字符截取到第一个非数字内容为止。比如'123abc'会转换为123,'012abc'会转换为012也就是12,'5.3a66b78c'会转换为5.3,其他同理。

如:运行

SELECT CAST('111a' AS UNSIGNED);           

输出: 111

select 'a'+'b'='c';           

输出:1

a+b=c结果为1,1在MySQL中可以理解为true,因为'a'+'b'的结果为0,c也会隐式转化为0,因此比较其实是:0=0也就是true,也就是1.

案例:

SELECT '1234781712345555456' = 1234781712345555455;           

输出:1

我们发现,将两个不同的字符串转化为浮点数后,结果是一样的 。

因此,当MySQL遇到字段类型不匹配的时候,会进行各种隐式转化,一定要小心,有可能导致精度丢失

SELECT * FROM t_ms_applian WHERE sn = 1234781712345555455;           
服务优化实战指南
SELECT * FROM t_ms_applian WHERE appliance_code = '18446744073709551615aaa';           
服务优化实战指南

发现我们输入的数字后面带了字符串依然查询出了记录,原因就是上面讲的转换规则胡忽略掉后面的非数字字符。

服务优化实战指南

更多的规则见官网:

https://dev.mysql.com/doc/refman/5.7/en/cast-functions.html#function_cast

https://dev.mysql.com/doc/refman/5.7/en/out-of-range-and-overflow.html

二:联合索引-最左前缀原则

联合索引命中必须遵循最左前缀原则。即SQL过滤条件字段必须从索引的最左侧的列开始匹配,不能跳过索引中的列 。也就是:

  1. 需要查询的列和联合索引的列顺序一致( 最左边的字段要出现在查询条件中 )
  2. 查询不要跨列

所以,在创建联合索引时,where中使用最频繁的字段放在组合索引的最左侧。

正例:

where条件中包含了复合索引的最左列,则会命中索引

explain SELECT * FROM t_employees WHERE NAME='吴某';
explain SELECT * FROM t_employees WHERE NAME='吴某' AND age=41;
explain SELECT * FROM t_employees WHERE NAME='吴某' AND age=41 AND address='保定市';
explain SELECT * FROM t_employees WHERE age=41 AND NAME='吴某' AND address='保定市';           
服务优化实战指南

反例:

跨列索引失效

如果where条件中不包含聚合索引的最左索引字段, 那么将不会走任何索引。

EXPLAIN  SELECT * FROM t_employees WHERE  age=41  AND address='保定市';           

​​

服务优化实战指南

跨索引列只能使用最左依次连续的索引。从key_len可以知道只使用到了name索引。(这里Using index condition表示索引下推,后面会讲)

explain SELECT * FROM t_employees WHERE NAME='吴某' AND address='保定市';           
服务优化实战指南

范围查询阻断联合索引

在以下sql中,范围查询age后面的address不会走索引,通过执行计划细节里的used_key_parts可以告诉我们这一点。

知识点:FORMAT=json 以json格式输出执行计划更方便查看使用到的索引字段

EXPLAIN FORMAT=json SELECT * FROM t_employees WHERE NAME='吴某' AND age > 40 AND address='保定市';           

以上SQL的匹配过程:首先匹配到name字段,二分查找定位到'吴某'后,再二分查找定位到age=40,把所有age>40的数据取出来。此时匹到的数据的age值有 40,41,42,但是此时address是无序的(只有精准定位到age时候address才有序),所以不能在B+树里进行二分法查找,address使用不到索引。

服务优化实战指南

输出:

{
  "query_block": {
    "select_id": 1,
    "table": {
      "table_name": "t_employees",
      "access_type": "range",
      "possible_keys": [
        "idx_name_age_address"
      ],
      "key": "idx_name_age_address",# 实际使用的索引
      "used_key_parts": [ # 使用到的索引列
        "name",
        "age"
      ],
      "key_length": "62",
      "rows": 45798,
      "filtered": 100,
      "index_condition": "((`appliance`.`t_employees`.`name` = '吴某') and (`appliance`.`t_employees`.`age` > 40) and (`appliance`.`t_employees`.`address` = '保定市'))"
    }
  }
}           

通过以上看出,使用到的索引列为name,age.

我们也可以根据key_len推断出生效的索引列。

知识点 : 通过key_len的值可以推导出在联合索引中生效的索引列。

key_len 用于表示本次查询中,所选择的索引长度有多少字节,通常我们可借此判断联合索引有多少列被选择了。

key_len算法:

字符串

1) 列长度n:

2) 列是否为空: NULL(+1),NOT NULL(+0)

3) 表字符集长度: 如 utf8mb4=4,utf8=3,gbk=2,latin1=1

4) 列类型为字符: 如 varchar(+2), char(+0)

计算公式:key_len= 列长度n * (表字符集长度) + 1(Null) + 2(变长列)

如utf8mb4编码:

varchar(n) = n*4+(允许为null ? 1: 0) + 2

char(n) = n*4+(允许为null ? 1: 0)

数值类型

1) tinyint: 1字节

2) smallint:2字节

3) int: 4字节

4) bigint:8字节

时间类型

1) date: 3字节

2) timstamp:4字节

3) datetime:8字节

以上key_len=62的计算如下:

name: charact_set=utf8mb4, varchar(14), not null

age:int, not null

key_len=(14*4+0+2) + (4) = 62

所以得出name,age使用到了索引,后面的address没使用到索引。

查询计划可以告诉我们更多的信息,具体见官网和官方博客:

官网:https://dev.mysql.com/doc/refman/5.6/en/execution-plan-information.html

官方博客:https://blogs.oracle.com/mysql/post/mysql-query-optimization-top-3-tips?share_token=DE434EBC-5040-47A0-8BA7-F69807FDA10C&tt_from=copy_link&utm_source=copy_link&utm_medium=toutiao_ios&utm_campaign=client_share%20MySQL%20Query%20Optimization:%20Top%203%20Tips

特殊情况:不满足最左前缀原则,但是满足覆盖索引,避免了回表。

EXPLAIN  SELECT `emp_no`,age,`address` FROM t_employees WHERE  age=41  AND address='保定市';           
服务优化实战指南

联合索引的开销:

如例子中,建了一个联合索引(name,age,address),实际相当于建了(name),(name,age),(name,age,address)三个索引。每多一个索引,都会增加写操作的开销和磁盘空间的开销。

最左前缀原则不仅用在where中,还能用在order by中。在mysql中,有两种方式生成排序结果:

  1. 通过有序索引顺序扫描直接返回有序数据
  2. filesort排序,对返回的数据进行排序

因为索引的数据结构是B+树,索引中的数据是按照一定顺序进行排序的,所以在排序查询中如果能利用索引,就能避免额外的排序操作。执行计划分析时,Extra显示为Using index。

所有不是通过索引直接返回排序结果的操作都是fileSort排序,也就是说进行了额外的排序操作。查询计划分析时,Extra显示为Using filesort。当出现Using filesort时对性能损失较大,所以要尽量避免using filesort。

具体order by示例见:[八:order by]

三:利用覆盖索引来进行查询操作,避免回表

覆盖索引:覆盖索引不是一种索引类型,而是一种索引的查询方式。 当查询条件使用了索引,并且select的字段都在此索引上可以找到,也就是一颗索引数既满足了检索也满足了查询结果,无需为了拿到需要的字段而去回表。一般我们不会只select一个字段,所以覆盖索引一般用于联合索引。

回表:SQL查询的列,超出了覆盖索引的列范围,SQL执行引擎会根据辅助索引叶子节点中存放的主键ID,去主键索引中查找对应的数据。

使用原则:尽量使用覆盖索引,减少对select *的使用。也不要查询不使用的字段,尽量查询结果都从索引字段中获取,从而减少回表操作。

覆盖索引的优点:

如果要查询辅助索引中不包含的字段,需要先遍历辅助索引,再遍历聚集索引,而如果需要查询的字段在辅助索引树上可以直接拿到,就不需要再去查询聚集索引,这样省去了一些IO操作。在实际应用中,覆盖索引是提升性能的主要手段。

此片段摘自阿里巴巴java开发规范(泰山版)

说明:如果一本书需要知道第 11 章是什么标题,会翻开第 11 章对应的那一页吗?目录浏览一下就好,这

个目录就是起到覆盖索引的作用。

正例:能够建立索引的种类分为主键索引、唯一索引、普通索引三种,而覆盖索引只是一种查询的一种效

果,用 explain 的结果,extra 列会出现:using index。

满足覆盖索引(查询的字段在辅助索引树上可以直接拿到,不需要再去扫描聚合索引树)

EXPLAIN  SELECT `emp_no`,NAME,age,address FROM t_employees WHERE NAME='吴';           
服务优化实战指南

满足覆盖索引([name,age,address]索引,下面根据age和address查询,扫描了整个索引树,证明了一点:最左前缀原则不是绝对的)

EXPLAIN  SELECT `emp_no`,age,`address` FROM t_employees WHERE  age=41  AND address = '保定市';           
服务优化实战指南

经过验证,覆盖索引 不以最左前缀开始,则key_len等于复合索引各个长度之和,也就是说各个字段都是用到了索引。

Using index: 表示已经使用了覆盖索引。

索引树上已经包含emp_no,age,address,则不用再次回表。

需要回表:(mobile字段在idx_name_age_address索引树上查询不到,需要根据id去扫描主键索引树上去查询):

EXPLAIN  SELECT `emp_no`,name,age,`address`,mobile FROM t_employees WHERE name='吴某' and age=41;           
服务优化实战指南

全表扫描:1:不满足最左前缀原则 2:需要查询的字段也不满足覆盖索引

EXPLAIN  SELECT `emp_no`,age,`address`,mobile FROM t_employees WHERE  age=41  AND address = '保定市';           
服务优化实战指南

四:like查询

like查询时候应该把通配符%放在右边,只有左边是个确定的值才能使用索引的有序性来快速定位数据。

正例:

EXPLAIN SELECT * FROM t_employees WHERE NAME LIKE '吴%'           
服务优化实战指南

反例:

EXPLAIN  SELECT * FROM t_employees WHERE NAME LIKE '%吴'           
服务优化实战指南

此片段摘自阿里巴巴java开发规范(泰山版)

【强制】页面搜索严禁左模糊或者全模糊,如果需要请走搜索引擎来解决。

说明:索引文件具有 B-Tree 的左前缀匹配特性,如果左边的值未确定,那么无法使用此索引。

索引失效的原因: 要查询的数据就不能只在二级索引树里找了,得需要回表操作才能完成查询的工作,再加上是左模糊匹配,无法利用索引树的有序性来快速定位数据,所以得在二级索引树逐一遍历,获取主键值后,再到聚簇索引树检索到对应的数据行,这样花费的代价比全表扫描更大。所以,优化器认为上面这样的查询过程的成本实在太高了,所以直接选择全表扫描的方式来查询数据。

特殊情况:%前置,但是满足覆盖索引,避免了回表。这也是为什么不建议随便使用select *的原因之一

EXPLAIN SELECT `emp_no`,NAME,age,`address` FROM t_employees WHERE NAME LIKE '%吴'           
服务优化实战指南

为什么选择扫描二级索引?

因为二级索引树的记录东西很少,就只有 [索引列+主键值],而聚簇索引记录的东西会更多,比如聚簇索引中的叶子节点则记录了主键值、事务 id、用于事务和 mvcc 回滚指针以及所有的剩余列。再加上,这个SELECT emp_no,NAME,age,address 不用执行回表操作。 所以, MySQL 优化器认为直接遍历二级索引树要比遍历聚簇索引树的成本要小的多,因此 MySQL 选择了全扫描二级索引树 来查询数据。

同类的情况如:

myql会分析各种查询的成本,最终会选择最优的方案去执行。如下几个查询语句,最终都会选择idx_birth_date索引。

原因:扫描的是二级索引。二级索引叶子节点小,每个页存放的记录更多,读取文件的次数就越少。

EXPLAIN SELECT emp_no FROM `t_employees` EXPLAIN SELECT emp_no FROM t_employees WHERE `emp_no` IS NOT NULL; EXPLAIN SELECT emp_no FROM t_employees WHERE `birth_date` IS NOT NULL; EXPLAIN SELECT `birth_date` FROM t_employees WHERE `birth_date` IS NOT NULL;

Using index: 表示已经使用了覆盖索引。

type为index:表示是通过全扫描二级索引的 B+ 树的方式查询到数据的,也就是遍历了整颗索引树 。

五:列计算/函数/反向查询索引失效

类似以下操作将不会使用索引,但不是绝对的,与读取索引记录成本有关系.

EXPLAIN SELECT * FROM t_employees WHERE mobile !='13600000002';//成本估算问题
EXPLAIN SELECT * FROM t_employees WHERE mobile NOT IN ('13600000002');//这里不走索引,但是not in不走索引这个不是绝对的,只是成本估算问题
如:
EXPLAIN SELECT * FROM t_employees WHERE `department_id` not in( 10001);会走索引

EXPLAIN SELECT * FROM t_employees WHERE emp_no not in( 103730260);//主键not in会走索引

EXPLAIN SELECT * FROM t_employees WHERE left(mobile,8)  = '13600000';//在索引列上做了截取操作索引失去了原来的顺序无法进行二分法查找
EXPLAIN SELECT * FROM t_employees WHERE name  = '吴某' and age+1=32;           

特殊情况,如覆盖索引或/全索引树扫描/索引下推:

EXPLAIN SELECT mobile FROM t_employees WHERE mobile !='13600000002'; //这里扫描整个索引树可以拿到数据,无需回表           

以下两种情况方向操作索引也是有效的,但是会截断后面的索引

EXPLAIN SELECT * FROM t_employees WHERE `name`  = '吴某' AND age NOT IN (40) AND address= '陕西省';
EXPLAIN SELECT * FROM t_employees WHERE `name`  = '吴某' AND age !=40 AND address= '陕西省';           

总结:查询条件使用not in时:

如果是主键则走索引。

如果是普通索引,则索引可能失效,取决于能读取索引记录成本。

一般建议使用int明确参数,not in使得命中的范围不可控从而导致全表扫描。

六:索引(条件)下推

概念:https://dev.mysql.com/doc/refman/5.6/en/index-condition-pushdown-optimization.html

索引条件下推 (ICP) 这个只有辅助索引才有的特点,当未命中覆盖索引,需要查询一行中的其他的列信息时,执行引擎在辅助索引的层面先过滤出符合条件的数据,将这些数据回表到聚集索引上检索行数据。 它能减少回表查询次数,提高查询效率。

对于InnoDB表,ICP 仅用于二级索引。ICP 的目标是减少全行读取次数,从而减少 I/O 操作。对于 InnoDB聚集索引,完整的记录已经读入InnoDB 缓冲区。在这种情况下使用 ICP 不会减少 I/O。

索引下推的下推其实就是指将部分上层(服务层)负责的事情,交给了下层(引擎层)去处理。

在没有使用ICP的情况下,MySQL的查询:

  • 存储引擎读取索引记录;
  • 根据索引中的主键值,定位并读取完整的行记录;
  • 存储引擎把记录交给Server层去检测该记录是否满足WHERE条件。

使用ICP的情况下,查询过程:

  • 存储引擎读取索引记录(不是完整的行记录);
  • 判断WHERE条件部分能否用索引中的列来做检查,条件不满足,则处理下一行索引记录;
  • 条件满足,使用索引中的主键去定位并读取完整的行记录(回表);
  • 存储引擎把记录交给Server层,Server层检测该记录是否满足WHERE条件的其余部分

具体例子:

EXPLAIN SELECT * FROM t_employees WHERE NAME LIKE '吴%' AND age=41;           
服务优化实战指南
  • ① 首先在 idx_name_age_address 索引树,查找第一个以 '吴' 开头的记录对应的主键id
  • ② 根据主键id从主键索引树找到整行记录,并根据age做判断过滤,等于41的留下,否则丢弃。这个过程称为回表
  • ③ 然后,在 idx_name_age_address 联合索引树上向右遍历,找到下一个主键id
  • ④ 再执行第二步
  • ⑤ 后面重复执行第三步、第四步,直到name不是以 '吴' 开头,则结束
  • ⑥ 返回所有查询结果

由于按name的前缀匹配,idx_name_age_address 二级索引中的 age部分并没有发挥作用。导致了大量回表查询,性能较差。

服务优化实战指南

在mysql的5.6版本对以上情况进行了优化,引入了索引下推的概念

优化后,执行流程:

  • ① 首先在 idx_name_age_address 索引树,查找第一个以 '吴'开头的索引记录
  • ② 然后,判断这个索引记录中的 age是否等于 41。如果是,回表 取出整行数据,作为后面的结果返回;如果不是,则丢弃
  • ③ 在 idx_name_age_address 联合索引树上向右遍历,重复第二步,直到name不是以 '吴'开头,则结束
  • ④ 返回所有查询结果

跟上面的过程差别,在于判断 name以'吴' 开头的人的年龄是否等于 41` 放在了遍历联合索引过程中进行,不需要回表判断,大大降低了回表的次数,提升性能。

服务优化实战指南

根据上面的两张图对比可以看出,假设数据库姓 吴 的雇员有10万,假如只有开始的 吴三,吴四是41岁,那么索引下推可以节省99998次回表。

Using index condition: 表示使用了索引下推。

按照以下步骤可以查看启用索引下推和禁用索引下推

show variables like '%ptimizer_switch%'

show variables where value like '%index_condition_pushdown=on%'

set optimizer_switch="index_condition_pushdown=off";

禁用索引下推后,查询

set optimizer_switch="index_condition_pushdown=off";
EXPLAIN SELECT * FROM t_employees WHERE NAME LIKE '吴%' AND age=41;           
服务优化实战指南

我们可以开启profiling查看 启用索引下推前和禁用索引下推查询语句的执行时间和系统资源消耗情况。

查看/开启profiling功能

查看/开启profiling功能

SHOW VARIABLES LIKE '%profiling%' 等价于 SELECT @@profiling;

set profiling=1;

profiling语法

SHOW PROFILE [type [, type] ... ] [FOR QUERY n] [LIMIT row_count [OFFSET offset]]

type: ALL --显示所有的开销信息 BLOCK IO --显示块IO相关开销 CONTEXT SWITCHES --上下文切换相关开销 CPU --显示CPU相关开销信息 IPC --显示发送和接收相关开销信息 MEMORY --显示内存相关开销信息 PAGE FAULTS --显示页面错误相关开销信息 SOURCE --显示和Source_function,Source_file,Source_line相关的开销信息 SWAPS --显示交换次数相关开销的信息

我们分别禁用索引下推和启用索引下推,发现 Sending data这一项花费的时间最长禁用后是启用的40多倍。

​​

服务优化实战指南

Sending data包括:回表查询 +返回给客户端的 时间。

误区

经过大量实践证明,出现了Using index condition不一定是索引下推。

EXPLAIN SELECT * FROM `t_employees`   WHERE  `gender`='M'  AND `birth_date` > '2001-01-01';
EXPLAIN SELECT * FROM `t_employees`   WHERE  `gender`='M' ;
EXPLAIN SELECT * FROM `t_employees`   WHERE   `birth_date` > '2001-01-01';           
服务优化实战指南

七:匹配过多数据导致索引失效

选择性过低会导致索引失效。由于通过二级索引查询后还有回表查询的开销,如果通过该字段只能过滤少量的数据,整体上还不如直接查询数据表的性能,则MySQL会放弃这个索引,直接使用全表扫描。底层会根据表大小、IO块大小、行数等信息进行评估决定。

线上运行了好久的程序,突然收到慢sql警告。原来是以前数据量少走了索引,随着数据越来越多慢慢的变为全表扫描

针对birth_date加索引

ALTER TABLE `t_employees` ADD INDEX idx_birth_date (`birth_date`);           
EXPLAIN  SELECT * FROM `t_employees`   WHERE  `birth_date`>'1981-01-02'   //使用到索引
EXPLAIN  SELECT * FROM `t_employees`   WHERE  `birth_date`>'1980-01-01';   //匹配记录多放弃使用索引           

以上同样的查询语句,只是查询的参数值不同,却会出现一个走索引,一个不走索引的情况。这是因为数据库引擎发现全表扫描比走索引效率更高,因此放弃了使用索引。

当mysql发现通过索引扫描的行记录数超过全表的10%-30%时,优化器可能会放弃使用索引,变为全表扫描。

在工作中线上遇见的场景一般是:出现在范围查询,业务起初数据量少,能匹配到的数据较少可以命中索引,当数据增多后因为匹配到的数据很多而导致索引失效。

特殊:无论如何只要满足覆盖索引,在二级索引上就可以拿到数据,不需要进行回表全表扫描。如以下,全表的1000万数据的age都>1,但是要查询的字段完全可以在二级索引上找到,不必全表扫描。

EXPLAIN SELECT `emp_no`,age,NAME,address FROM t_employees WHERE age>1;           

强制让优化器使用索引,上面匹配记录多放弃使用索引,我们可以使用FORCE INDEX让优化器强制使用索引

优化前后耗时截图

EXPLAIN FORMAT=json SELECT * FROM `t_employees`  FORCE INDEX(`idx_birth_date`) WHERE  `birth_date`>'1980-01-01';           

输出以下内容,根据执行计划看是用到了birth_date字段索引:

{
  "query_block": {
    "select_id": 1,
    "table": {
      "table_name": "t_employees",
      "access_type": "range",
      "possible_keys": [
        "idx_birth_date"
      ],
      "key": "idx_birth_date",
      "used_key_parts": [
        "birth_date"
      ],
      "key_length": "3",
      "rows": 1682738,
      "filtered": 100,
      "index_condition": "(`appliance`.`t_employees`.`birth_date` > '1980-01-01')"
    }
  }
}           

再比如如下如下情况使用 is null 和 is not null查询。

把数据库的一条数据的birth_date值设置为null,此时运行以下SQL发现Is null是可以走索引的。

EXPLAIN SELECT * FROM `t_employees` WHERE `birth_date` IS NULL;  --走索引,因为数据库里为null的数据很少
EXPLAIN SELECT * FROM `t_employees` WHERE `birth_date` IS NOT NULL;--不走索引           
{
  "query_block": {
    "select_id": 1,
    "table": {
      "table_name": "t_employees",
      "access_type": "ref",
      "possible_keys": [
        "idx_birth_date"
      ],
      "key": "idx_birth_date",
      "used_key_parts": [
        "birth_date"
      ],
      "key_length": "3",
      "ref": [
        "const"
      ],
      "rows": 1,
      "filtered": 100,
      "index_condition": "(`appliance`.`t_employees`.`birth_date` = '0000-00-00')"
    }
  }
}           

如果我们再把数据库里的birth_date设置为null,只保留一条数据birth_date有值,此时执行以上sql,发现Is null不走索引,is not null走了索引。

通过上面的例子可以看出:带索引字段使用null做判断是否走索引与数据量有关,归纳起来就是成本问题

根据以上情况,总结为:

索引(二级索引)扫描成本:

1、读取索引记录成本

2、反查主键索引查找完整数据成本即回表

八: order by

因为索引数是有序的,如果order by能够使用索引数有序的特性,那么可以避免二次排序带来的性能问题。

在使用order by关键字的时候,如果待排序的内容不能由所使用的索引直接完成排序的话,那么mysql有可能就要进行文件排序。

此片段摘自阿里巴巴java开发规范(泰山版)

如果有 order by 的场景,请注意利用索引的有序性。order by 最后的字段是组合索

引的一部分,并且放在索引组合顺序的最后,避免出现 file_sort 的情况,影响查询性能。

正例:where a=? and b=? order by c; 索引:a_b_c

反例:索引如果存在范围查询,那么索引有序性无法利用,如:WHERE a>10 ORDER BY b; 索引 a_b 无

法排序。

【这个 filesort 并不是说通过磁盘文件进行排序,而只是告诉我们进行了一个排序操作而已】。

当然,using filesort不一定引起mysql的性能问题。但是如果查询次数非常多,那么每次在mysql中进行排序,还是会有影响的。

1:排序的字段没有在索引中会出现Using filesort

EXPLAIN  EXTENDED  SELECT * FROM t_employees order by `hire_date`;           

2:不能由索引直接完成排序

EXPLAIN  EXTENDED  SELECT * FROM t_employees where name ='吴某' order by address;           
服务优化实战指南

3:可以直接排序

EXPLAIN  EXTENDED  SELECT * FROM t_employees WHERE NAME ='吴某' ORDER BY age;
EXPLAIN  EXTENDED  SELECT * FROM t_employees WHERE NAME ='吴某' ORDER BY age,address;           

同时,按照阿里巴巴java开发规范中的【如果有 order by 的场景,请注意利用索引的有序性。order by 最后的字段是组合索 引的一部分,并且放在索引组合顺序的最后,避免出现 file_sort 的情况,影响查询性能。】也验证了这一点。

4:索引如果存在范围查询,那么索引有序性无法利用,如:WHERE a>10 ORDER BY b; 索引 a_b 无 法排序。

5: desc 和asc混用时会导致索引失效,不建议混用

EXPLAIN SELECT * FROM t_employees WHERE `name`  ='吴某' ORDER BY age DESC,address DESC ;           
服务优化实战指南

以下desc 和asc混用,导致出现 file_sort 的情况

EXPLAIN  SELECT * FROM t_employees WHERE `name` = '吴某' ORDER BY age ASC,address DESC ;           
服务优化实战指南

总结order by的优化原则:

  • where条件和order by使用相同的索引
  • order by字段的顺序和索引的顺序一致
  • order by字段都是升序或者都是降序

九:区分性差

索引区分度低的字段不要加索引。

对于非聚簇索引,是要回表的。假如有 10000 条数据,在 sex 字段建立索引,扫描到 5000个 F,需要再回表扫描 5000行。还不如直接一次全表扫描。

所以,InnoDB 引擎对于这种场景就会放弃使用索引,至于区分度多低多少会放弃,大致是某类型的数据占到总的 30% 左右时,就会放弃使用该字段的索引。

EXPLAIN  SELECT * FROM `t_employees` WHERE  `gender`='F';           
服务优化实战指南

why?根据【七:匹配过多数据导致索引失效】验证,匹配记录多放弃使用索引的,但是这个例子中gender=F几乎站50%的数据,执行计划确提示使用了索引。

十:OR查询

当OR左右查询字段只有一个是索引,会使该索引失效,只有当OR左右查询字段均为索引列时,这些索引才会生效

正例:

EXPLAIN   SELECT * FROM `t_employees`   WHERE   NAME='吴某' OR `birth_date`='1972-01-01';           
服务优化实战指南
EXPLAIN   SELECT * FROM `t_employees`   WHERE   NAME='吴某' OR `gender`='M';           

这两个单独看都是用了索引,但是OR查询确实全表扫描? 唯一解释就是gender本身没走索引,上面的【区分性差】这里大概猜出执行计划有误判的可能。

服务优化实战指南

反例: OR的一边没有使用索引。进行了全表扫描

EXPLAIN   SELECT * FROM `t_employees`   WHERE   name='吴某' OR `address`='陕西省'           

十一:尽量避免select *

阿里巴巴java开发手册中明确说过,查询sql中禁止使用select* 。 一律不要使用 * 作为查询的字段列表,需要哪些字段必须明确写明。

EXPLAIN extended  SELECT * FROM t_employees where address='西安市';           
服务优化实战指南

在该sql中用了select *,从执行结果看,走了全表扫描,没有用到任何索引,查询效率是非常低的。

如果需要查询的字段刚都是符合做引的索引列,则避免了全表扫描。

EXPLAIN EXTENDED  SELECT `emp_no`,NAME,`address` FROM t_employees WHERE address='西安市';           
服务优化实战指南

该sql语句这次走了全索引扫描,比全表扫描效率更高。其实这里用到了:覆盖索引。

如果select语句中的查询列,都是索引列,那么这些列被称为覆盖索引。这种情况下,查询的相关字段都能走索引,索引查询效率相对来说更高一些。

备注:以上会发现,虽然不满足最左前缀原则,但是满足了覆盖索引,所以依然是会用到索引的。

十二:多索引混用

当复合索引和单列索引混合使用时(AND),优先使用单列索引(成本最低的)

explain SELECT * FROM `t_employees` WHERE   NAME ='吴某' and age=41 and `birth_date`='2001-01-01';           
服务优化实战指南

当以下查询把name值改为'吴某某',又走了联合索引

EXPLAIN SELECT * FROM `t_employees` WHERE   NAME ='吴某某' AND age=41 AND `birth_date`='2001-01-01';           
服务优化实战指南

两个单列索引同时使用,只有其中一个生效(OR或者其他特殊的除外,如index merge),优选器会评估使用哪个条件的索引的效率最高

EXPLAIN SELECT * FROM `t_employees`  WHERE  gender='F' AND  `birth_date`='1977-11-29';           
服务优化实战指南

十三:前缀索引

什么是前缀索引:

如,给身份证的前10位添加索引,类似这种给某列部分信息添加索引的方式叫做 前缀索引。

为什么要用前缀索引

前缀索引能有效的减少索引文件的大小(占用的空间更少),让每个所引页可以保存更多的索引值,从而提高索引的查询效率。

但是,前缀索引也有缺点,不能在order by 或者group by 中使用,也不能用于覆盖索引。

什么情况下适合使用前缀索引

当字符串本身可能比较长,而且从前几个字符串就开始不相同,适合使用前缀索引;

在 varchar 字段上建立索引时,必须指定索引长度,没必要对全字段建立索引,根据实际文本区分度决定索引长度。摘自阿里巴巴java开发规范(泰山版)

问题是,截取多少?截取得多了,达不到节省索引存储空间的目的; 截取得少了,重复内容太多,字段的散列度(选择性)会降低。

怎么计算不同的长度的选择性?

如,假设在员工体系系统里有10万员工,每个员工都有唯一的emai邮箱,邮箱后缀都是使用的@midea.com。 在email上建立全值索引会浪费一定的空间,此时可以考虑使用前缀索引。前缀的长度取决于选择度(这里举得例子因为构造的email是唯一的,所以选择度为1):

select count(distinct left(email,6)) / count(*)   from t_employees;
select count(distinct left(email,7)) / count(*)   from t_employees;
select count(distinct left(email,8)) / count(*)   from t_employees;
select count(distinct left(email,9)) / count(*)  from t_employees;//接近于全表选择度了,因为构造的数据使用的主键拼接的@midea.com           

全表的选择度:

select count(distinct email) / count(*) from t_employees;           

经过对比,当长度为9的时候选择性达到最高,此时建立索引

ALTER TABLE `t_employees` ADD key(email(9));           

运行以下查询发现命中了email索引:

EXPLAIN SELECT * FROM `t_employees` WHERE email = '[email protected]';           
服务优化实战指南

由于使用了前缀索引,只是在设定的前缀字符串在索引列上有序,所以如果使用order by对此字段排序,可能会有一定的影响。另外可能会额外的增加扫描记录的次数

对覆盖索引的影响:因为二级索引上存储的不是整个email值,所以不能走覆盖索引,需要回表查询

EXPLAIN SELECT emp_no,email FROM t_employees WHERE email='[email protected]';           
服务优化实战指南

十四:执行计划

在开发的过程中,当我们手写一条SQL的时候,应当第一时间确保SQL的执行效率,而不是等到上线出现慢SQL警或者事故才后知后觉。

我们第一时间想到的就是使用SQL执行计划查看SQL执行是否命中索引,是否进行了索引覆盖,还是是全表扫描等,这些都可以通过执行计划去反馈。

MySQL数据库的执行计划可以通过explain关键字查看,使用explain可以查看SELECT,DELETE,INSERT,REPLACE,UPDATE语句的执行计划。对于SELECT语句,还可以使用SHOW WARNINGS查看额外的执行计划信息 。

常用关键字 explain的作用等价于 DESCRIBE | DESC

官网文档:https://dev.mysql.com/doc/refman/5.6/en/using-explain.html

最常使用的如:

-- 以表格格式输出执行计划,默认方式
EXPLAIN sql_stmt

-- 以json格式输出执行计划
EXPLAIN FORMAT=JSON sql_stmt           

如:

explain FORMAT=JSON select * from `t_employees` where mobile='13600000002';           

执行计划关键字解释:

1.type:联接类型。下面给出各种联接类型,按照从最佳类型到最坏类型进行排序:(重点看ref,rang,index)

system:表只有一行记录(等于系统表),这是const类型的特例,平时不会出现,可以忽略不计 const:表示通过索引一次就找到了,const用于比较primary key 或者 unique索引。因为只需匹配一行数据,所有很快。如果将主键置于where列表中,mysql就能将该查询转换为一个const eq_ref:唯一性索引扫描,对于每个索引键,表中只有一条记录与之匹配。常见于主键 或 唯一索引扫描。 ref:非唯一性索引扫描,返回匹配某个单独值的所有行。本质是也是一种索引访问,它返回所有匹配某个单独值的行,然而他可能会找到多个符合条件的行,所以它应该属于查找和扫描的混合体。 range:只检索给定范围的行,使用一个索引来选择行。key列显示使用了那个索引。一般就是在where语句中出现了bettween、<、>、in等的查询。这种索引列上的范围扫描比全索引扫描要好。只需要开始于某个点,结束于另一个点,不用扫描全部索引。 index:Full Index Scan,index与ALL区别为index类型只遍历索引树。这通常为ALL块,应为索引文件通常比数据文件小。(Index与ALL虽然都是读全表,但index是从索引中读取,而ALL是从硬盘读取) ALL:Full Table Scan,遍历全表以找到匹配的行   

possible_keys:在该查询中,MySQL可能使用的索引,如果此列是NULL,则没有相关的索引,在这种情况下,需要检查WHERE字句,以确定是否适合创建索引

key:MySQL实际使用的索引。在大多数情况下,key中的值都在possible_key里面,但也会出现possible_key不存在该值,但key里面存在的情

key_len:该列指MySQL决定使用的索引长度。该值体现了在使用复合索引的时候,使用了复合索引的前面哪几个列(需要根据字段长度计算),如果key列为NULL,则该列也为NULL。由于key存储的格式原因,可以为NULL的列的key长度比NOT NULL的列长度大1。

rows:MySQL查询需要遍历的行数,对于innodb表,可能并不总是准确的

Extra:

Using index condition: 代表使用二级索引不够还要回表,但回表之前会过滤此二级索引能过滤的where条件 (表示使用了索引下推)

Using index: 查询的列被索引覆盖,并且where筛选条件是索引的是前导列 (表示 使用覆盖索引,不用回表 )

Using where: 代表数据库引擎返回结果后mysql server还会再次筛选。

1:查询的列未被索引覆盖,where筛选条件非索引的前导列

2: 查询的列未被索引覆盖,where筛选条件非索引列

using where 意味着通过索引或者表扫描的方式进程where条件的过滤。也就说是没有可用的索引查找,当然这里也要考虑索引扫描+回表与表扫描的代价。这里的type都是all,说明MySQL认为全表扫描是一种比较低的代价。

using index,using where:

Using filesort:

1、Mysql支持两种方式的排序fileSort和index,Using index是指Mysql扫描索引本身完成排序。index效率高,filesort效率低。

2、order by满足两种情况会使用Using index。

1)order by语句使用 索引最左前列。

2)使用where子句与order by子句 条件组合满足最左前缀原则。

3、尽量在索引列上完成排序,遵循索引建立时的最左前缀法则。

4、使用order by的条件不在索引列上,就会产生Using filesort。

5、能用覆盖索引就尽量使用覆盖索引。

6、group by与order by类似,实质是线排序后分组,遵循做做前缀原则。对于 group by 的优化如果不需要排序可以加order by null禁止排序。注意:where高于having ,能写在where中的条件就不要在having 中限定了。

十五:生产数据库相关问题

一:开发同学写的mybatis动态条件判断,调用方没有传递任何参数导致select没任何条件查询全表,最终服务OOM

<select id="queryUserByCondition" parameterType="com.midea.iot.svc.user.entity.vo.UserVo"
            resultType="com.midea.iot.svc.user.entity.UserAll">
        select
        id,owner_app_id,src_app_id,nick_name,password,mobile,email,address,account_status,update_time,register_time,
        signature,profile_pic_url,sex,phone,age,uid from t_ms_user where 1=1
        <if test="uid != null and uid != ''">
            and uid=#{uid}
        </if>
        <if test="mobile != null and mobile != '' and ownerAppId != null and ownerAppId != ''">
            and mobile = #{mobile,jdbcType=VARCHAR} and owner_app_id=#{ownerAppId,jdbcType=SMALLINT}
        </if>
        <if test="mobile != null and mobile != '' and ownerAppId == null">
            and mobile = #{mobile,jdbcType=VARCHAR} and owner_app_id is null
        </if>
    </select>           

结论:需要在业务代参数校验

二:使用mybatis plus框架,参数类型和实体字段不一致(sql条件值和实际数据库字段类型不一致),发生了类型转换,导致索引失效。

@Data
@TableName("t_ms_door_pwd")
public class DoorPwd implements Serializable {
	@TableId(value = "id", type = IdType.AUTO)
	private Long id;
	@TableField("nodeId")
	private String nodeId;
}           
private List<DoorPwd> getDoorPwd(Long nodeid) {
		return lambdaQuery().eq(DoorPwd::getNodeId, nodeid).list();
	}           

三:查询出大量的数据

CREATE TABLE `t_ms_appliance_remind_switch` (
  `id` bigint(20) NOT NULL AUTO_INCREMENT,
  `appliance_code` bigint(16) unsigned DEFAULT '0' COMMENT '家电code',
  `remind_code` varchar(16) DEFAULT NULL COMMENT '提醒代码',
  `ENABLE` tinyint(4) NOT NULL COMMENT '提醒开关\r\n0:关 1:开',
  `user_id` bigint(20) DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `idx_appliance_code_remind_code` (`appliance_code`,`user_id`,`remind_code`),
  KEY `tmsapplianceremindswitch_ix2` (`remind_code`,`user_id`)
) ENGINE=InnoDB AUTO_INCREMENT=529 DEFAULT CHARSET=utf8mb4 COMMENT='设备提醒开关表'           

因为站在业务方这个非必须字段,数据库存在700万+ appliance_code=0的数据。本身默认为0也没啥大毛病,也没人在意这个。

直到有一天,线上服务接口大量超时,最终OOM,定位到的问题是,刚好有个设备的code为0,业务上根据设备code去查询上面的表的时候查询出700万条数据,连续查询多次导致整个服务OOM。

四:连表查询需要加别名(阿里规范)

说明:对多表进行查询记录、更新记录、删除记录时,如果对操作列没有限定表的别名(或表名),并且操作列在多个表中存在时,就会抛异常。

正例:select t1.name from table_first as t1 , table_second as t2 where t1.id=t2.id;

反例:在某业务中,由于多表关联查询语句没有加表的别名(或表名)的限制,正常运行两年后,最近在

某个表中增加一个同名字段,在预发布环境做数据库变更后,线上查询语句出现出 1052 异常:Column 'name' in field list is ambiguous。

ambiguous 英[æmˈbɪɡjuəs] [ 模棱两可的; 含混不清的; 不明确的 ]

十六:总结

做好SQL优化大概总以下三个方面入手:

SQL要有索引(建立正确的索引)

索引要可用(避免索引失效)

高效(覆盖索引、索引的选择性)

四:线程池

通过本章,将会了解到以下知识:

一:线程池的好处

二:如何创建线程池

三:底层是如何实现的,核心参数的作用

四:线程池是如何实现线程复用的

五:如何配置线程池参数

六:线程池拒绝策略以及在工作中使用不当造成的生产事故

七:线程池监控以及参数动态调整策略

八:线程池隔离

九:为什么不推荐使用静态方法方式创建线程

十:线程的状态

在服务器开发领域,我们经常会为每个请求分配一个线程去处理,但是线程的创建销毁、调度都会带来额外的开销,线程太多也会导致系统整体性能下降。在这种场景下,我们通常会提前创建若干个线程,通过线程池来进行管理。当请求到来时,只需从线程池选一个线程去执行处理任务即可。

池化技术的使用和池大小的设置,包括HTTP请求池、线程池(考虑CPU密集型还是IO密集型设置核心参数)、数据库和Redis连接池等 。

线程池的好处

线程是稀缺资源,如果被无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,合理的使用线程池对线程进行统一分配、调优和监控,有以下好处: 1、降低资源消耗: 通过重复利用已创建的线程降低线程创建和销毁造成的消耗。 2、提高响应速度: 当任务到达时,任务可以不需要的等到线程创建就能立即执行。 3、提高线程的可管理性: 线程是稀缺资源,如果无限制的创建,不仅会消耗系统资源,还会降低系统的稳定性,使用线程池可以进行统一的分配,调优和监控。

如何创建线程池

Java1.5中引入的Executor框架把任务的提交和执行进行解耦,只需要定义好任务,然后提交给线程池,而不用关心该任务是如何执行、被哪个线程执行,以及什么时候执行。

以下是jdk中线程池的执行流程图。

服务优化实战指南

我们在工作中最常用的就是使用ThreadPoolExecutor去创建线程池,具体代码如下(这里会根据部分核心源码来讲线程池工作原理):

package java.util.concurrent;
.....
    
public ThreadPoolExecutor(int corePoolSize,
                              int maximumPoolSize,
                              long keepAliveTime,
                              TimeUnit unit,
                              BlockingQueue<Runnable> workQueue) {
        this(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
             Executors.defaultThreadFactory(), defaultHandler);
    }           

corePoolSize

线程池中的核心线程数,当提交一个任务时,线程池创建一个新线程执行任务,直到当前线程数等于corePoolSize;如果当前线程数为corePoolSize,继续提交的任务被保存到阻塞队列中,等待被执行;如果执行了线程池的prestartAllCoreThreads()方法,线程池会提前创建并启动所有核心线程。

maximumPoolSize

线程池中允许的最大线程数。如果当前阻塞队列满了,且继续提交任务,则创建新的线程执行任务,前提是当前线程数小于maximumPoolSize;

keepAliveTime

线程空闲时的存活时间,即当线程没有任务执行时,继续存活的时间;默认情况下,该参数只在线程数大于corePoolSize时才有用;

workQueue

用来保存等待被执行的任务的阻塞队列,且任务必须实现Runable接口,在JDK中提供了如下阻塞队列: 1、ArrayBlockingQueue:基于数组结构的有界阻塞队列,按FIFO排序任务; 2、LinkedBlockingQuene:基于链表结构的阻塞队列,按FIFO排序任务,吞吐量通常要高于ArrayBlockingQuene; 3、SynchronousQuene:一个不存储元素的阻塞队列,每个插入操作必须等到另一个线程调用移除操作,否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQuene; 4、priorityBlockingQuene:具有优先级的无界阻塞队列;

threadFactory

创建线程的工厂,通过自定义的线程工厂可以给每个新建的线程设置一个具有识别度的线程名。

handler

线程池的饱和策略,当阻塞队列满了,且没有空闲的工作线程,如果继续提交任务,必须采取一种策略处理该任务,线程池提供了4种策略: 1、AbortPolicy:直接抛出异常,默认策略; 2、CallerRunsPolicy:用调用者所在的线程来执行任务; 3、DiscardOldestPolicy:丢弃阻塞队列中靠最前的任务,并执行当前任务; 4、DiscardPolicy:直接丢弃任务; 当然也可以根据应用场景实现RejectedExecutionHandler接口,自定义饱和策略,如记录日志或持久化存储不能处理的任务。

poll.allowCoreThreadTimeOut 是否允许核心线程空闲超时后回收

prestartAllCoreThreads: 在线程池创建,但还没有接收到任何任务的情况下,先行创建符合corePoolSize参数值的线程数 。

如果需要看源码可以execute方法入手,此方法上面的三步注释就是线程池的执行步骤。

public void execute(Runnable command) {
        if (command == null)
            throw new NullPointerException();
        /*
         * Proceed in 3 steps:
         *
         * 1. If fewer than corePoolSize threads are running, try to
         * start a new thread with the given command as its first
         * task.  The call to addWorker atomically checks runState and
         * workerCount, and so prevents false alarms that would add
         * threads when it shouldn't, by returning false.
         *
         * 2. If a task can be successfully queued, then we still need
         * to double-check whether we should have added a thread
         * (because existing ones died since last checking) or that
         * the pool shut down since entry into this method. So we
         * recheck state and if necessary roll back the enqueuing if
         * stopped, or start a new thread if there are none.
         *
         * 3. If we cannot queue task, then we try to add a new
         * thread.  If it fails, we know we are shut down or saturated
         * and so reject the task.
         */
        int c = ctl.get();
        if (workerCountOf(c) < corePoolSize) {
            if (addWorker(command, true))
                return;
            c = ctl.get();
        }
        if (isRunning(c) && workQueue.offer(command)) {
            int recheck = ctl.get();
            if (! isRunning(recheck) && remove(command))
                reject(command);
            else if (workerCountOf(recheck) == 0)
                addWorker(null, false);
        }
        else if (!addWorker(command, false))
            reject(command);
    }           

拒绝策略|生产事故

CallerRunsPolicy:

注意点:当本身应当在线程池的线程中处理的耗时任务,在执行饱和策略,这些任务交由主线程处理,那么每个主线程被占用的时间将增长,高并发情况下,大量的主线程被占用,如tomcat线程,那么tomcat线程将被占满,新的请求将被拒绝,客户端出现超时。

堆大小配置:-Xms512m -Xmx512m

线程参数:

undertow:
    threads:
      io: 12
      worker: 450
corepoolSize: 50
maximumPoolSize: 100
capacity: 1000
policy: callerRuns           

模拟代码:

@Autowired
private Executor executor;

@RequestMapping("testpolicy")
	public void testpolicy() {
		executor.execute(() -> {
			demoService.getLongTime();
		});
}           

模拟请求:

ab -n 10000000 -c 600 -T "application/json" -H "Content-Type: application/json"  http://10.xxx.1.136:8800/thread/test/testpolicy           

现象:系统运行缓慢,大量接口超时,系统负载不高,cpu使用率也很低。

导出线程快照后发现大量处于WAITING(onobjectmonitor)状态,其中450条XNIO-1 task都处于此状态。

[mcloud@10_111_1_136 bin]$  jstack -l 12748 |  grep java.lang.Thread.State  |  awk '{print $2$3$4$5}' | sort | uniq -c
 31 RUNNABLE
 5 TIMED_WAITING(parking)
 1 TIMED_WAITING(sleeping)
 552 WAITING(on objectmonitor)
 100 WAITING(parking)           

挑出其中一条:由此说明使用CallerRunsPolicy使得异步变同步,在本例中阻塞了大量的容器线程导致生产事故。

服务优化实战指南

注意点:threadLocal属于线程私有的信息,被子线程的私有信息给覆盖掉。

package test;

import java.io.IOException;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;

public class CallerRunsPolicyTest {

	private static ThreadLocal<String> securityContext = new ThreadLocal<>();

	private static ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 2, 60, TimeUnit.SECONDS,
			new ArrayBlockingQueue<>(5), new ThreadPoolExecutor.CallerRunsPolicy());;

	public static void main(String[] args) throws IOException, InterruptedException, ExecutionException {
		testThread();
	}

	public static void testThread() throws IOException, InterruptedException, ExecutionException {

		securityContext.set("main-thread");//主线程
		System.out.println(securityContext.get());

		for (int i = 1; i <= 10; i++) {

			executor.execute(() -> {
				securityContext.set("sub-thread");//子线程
				System.out.println(Thread.currentThread().getName());
			});
		}
		TimeUnit.SECONDS.sleep(3);
		System.out.println(securityContext.get());
	}
}


输出:
main-thread
sub-thread           

原因如下:threadLocal.set("sub-thread");本应是给子线程自己设置线程私有数据,看ThreadLocal源码Thread.currentThread()指的是子线程自己。如果拒绝策略为CallerRunsPolicy,主线程将会执行securityContext.set("sub-thread")方法,Thread.currentThread()指的是主线程,那么主线程自己之前的value值将被覆盖。

同理,子线程调用ThreadLocal.remove(),交给主线程执行后会把主线程的线程私有数据给remove掉。

服务优化实战指南

DiscardPolicy:

注意点 :因为任务被丢弃,主线程在CompletableFuture.get()[LockSupport.parkNanos]处阻塞直到超时.

CompletableFuture的get()方法值会阻塞主线程,直到子线程执行任务完成返回结果才会取消阻塞。如果子线程一直不返回接口那么主线程就会一直阻塞,所以我们一般不建议直接使用CompletableFuture的get()方法,而是使用future.get(5, TimeUnit.SECONDS);方法指定超时时间。

当我们的线程池拒绝策略使用的是DiscardPolicy或者DiscardOldestPolicy,并且线程池饱和了的时候,我们将会直接丢弃任务,不会抛出任何异常。这个时候再来调用get方法是主线程就会一直等待子线程返回结果,直到超时抛出TimeoutException。 当线程池满了会直接丢弃任务,而不会终止主线程。这个时候执行get方法的时候,主线线程一直会等待直到超时为止 。如果请求量很大的情况,会导致大量的主线程(如tomcat线程)在此处等待直到超时,这样就阻塞了大量线程从而导致生产事故。

package test;

import java.io.IOException;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.RejectedExecutionHandler;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

@SuppressWarnings("all")
public class DiscardPolicyPolicyTest {

	private static ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 2, 60, TimeUnit.SECONDS,
			new ArrayBlockingQueue<>(5), new SelfDiscardPolicy());

	public static void main(String[] args) throws IOException, InterruptedException, ExecutionException {
		testThread();
	}

	public static void testThread() throws IOException, InterruptedException, ExecutionException {

		CompletableFuture<Integer>[] completableFutures = new CompletableFuture[10];

        long beginTime = System.currentTimeMillis();
		for (int i = 0; i < 10; i++) {

			CompletableFuture<Integer> c = CompletableFuture.supplyAsync(() -> {
				try {
					System.out.println(Thread.currentThread().getName());
					TimeUnit.MILLISECONDS.sleep(200);
				} catch (InterruptedException e) {
					System.out.println(e);
				}
				return 10;
			}, executor);
			completableFutures[i] = c;
		}
		try {
			CompletableFuture.allOf(completableFutures).get(10, TimeUnit.SECONDS);
		} catch (InterruptedException | ExecutionException | TimeoutException e) {
			System.out.println(e);
		}
        System.out.println("finish,cost:"+(System.currentTimeMillis()-beginTime));
	}
	
	
	public static class SelfDiscardPolicy implements RejectedExecutionHandler {
        
        public SelfDiscardPolicy() { }
       
        public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
        	System.out.println("discar");
        }
    }
}           

输出:

discar
discar
pool-1-thread-2
pool-1-thread-1
pool-1-thread-3
pool-1-thread-1
pool-1-thread-2
pool-1-thread-3
pool-1-thread-2
pool-1-thread-1
java.util.concurrent.TimeoutException
finish,cost:5162           

导出线程栈,发现主线程阻塞在CompletableFuture.get()方法处。

"pool-1-thread-3" #13 prio=5 os_prio=0 tid=0x000000001f5dc000 nid=0x1dc0 waiting on condition [0x000000002009e000]
   java.lang.Thread.State: TIMED_WAITING (parking)
	at sun.misc.Unsafe.park(Native Method)
	- parking to wait for  <0x000000076b836e50> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
	at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:215)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(AbstractQueuedSynchronizer.java:2078)
	at java.util.concurrent.ArrayBlockingQueue.poll(ArrayBlockingQueue.java:418)
	at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1073)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1134)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
	at java.lang.Thread.run(Thread.java:748)

   Locked ownable synchronizers:
	- None

"pool-1-thread-2" #12 prio=5 os_prio=0 tid=0x000000001f5d6000 nid=0x51f8 waiting on condition [0x000000001ff9e000]
   java.lang.Thread.State: TIMED_WAITING (parking)
	at sun.misc.Unsafe.park(Native Method)
	- parking to wait for  <0x000000076b836e50> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
	at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:215)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(AbstractQueuedSynchronizer.java:2078)
	at java.util.concurrent.ArrayBlockingQueue.poll(ArrayBlockingQueue.java:418)
	at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1073)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1134)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
	at java.lang.Thread.run(Thread.java:748)

   Locked ownable synchronizers:
	- None

"pool-1-thread-1" #11 prio=5 os_prio=0 tid=0x000000001f5d3800 nid=0x3910 waiting on condition [0x000000001fe9e000]
   java.lang.Thread.State: TIMED_WAITING (parking)
	at sun.misc.Unsafe.park(Native Method)
	- parking to wait for  <0x000000076b836e50> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
	at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:215)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.awaitNanos(AbstractQueuedSynchronizer.java:2078)
	at java.util.concurrent.ArrayBlockingQueue.poll(ArrayBlockingQueue.java:418)
	at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1073)
	at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1134)
	at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
	at java.lang.Thread.run(Thread.java:748)

   Locked ownable synchronizers:
	- None
	
"main" #1 prio=5 os_prio=0 tid=0x0000000002f20800 nid=0x5168 waiting on condition [0x0000000002f1f000]
   java.lang.Thread.State: TIMED_WAITING (parking)
	at sun.misc.Unsafe.park(Native Method)
	- parking to wait for  <0x000000076bad4eb8> (a java.util.concurrent.CompletableFuture$Signaller)
	at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:215)
	at java.util.concurrent.CompletableFuture$Signaller.block(CompletableFuture.java:1695)
	at java.util.concurrent.ForkJoinPool.managedBlock(ForkJoinPool.java:3323)
	at java.util.concurrent.CompletableFuture.timedGet(CompletableFuture.java:1775)
	at java.util.concurrent.CompletableFuture.get(CompletableFuture.java:1915)
	at test.DiscardPolicyPolicyTest.testThread(DiscardPolicyPolicyTest.java:43)
	at test.DiscardPolicyPolicyTest.main(DiscardPolicyPolicyTest.java:19)

   Locked ownable synchronizers:
	- None           

解决方案:

1:使用CompletableFuture的时候线程池拒绝策略最好使用AbortPolicy,如果线程池满了直接抛出异常中断主线程,达到快速失败的效果

2:不建议使用CompletableFuture的get()方法,而是使用CompletableFuture.get(long time, TimeUnit.SECONDS)指定超时时间

注意点 :任务被丢弃,计数器无法归0,主线程在countDownLatch.await(10, TimeUnit.SECONDS)处阻塞直到超时.

countdownLatch的使用场景一般用在多线程执行子任务,将串行流程分隔成并行调用,减少处理时间。大概思路是: 对象初始化时设置一个int值为需要等待的线程数量,一个等待线程执行完成后调用countdown方法计数器减1,在调用await方法等待的线程直到计数器值为0(所有子线程都执行完成)就可以继续执行了。

生产的问题:countdownLatch无法归0,await处等待直到超时,或者没设置的超时将无限等待下去。常见于以下两种场景:

一:线程池拒绝策略使用的丢弃策略,如果阻塞队列满了新的任务会被抛弃,countdownLatch永远不会为0,调用await方法会一直阻塞。

package test;

import java.io.IOException;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

public class CountDownLatchTest {

	static ThreadPoolExecutor executor = null;

	public static void main(String[] args)
			throws InterruptedException, IOException, ExecutionException, TimeoutException {
		int corePoolSize = 1;
		int queueSize = 1;
		int maximumPoolSize = 2;
		BlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(queueSize);
		long keepAliveTime = 60;
		TimeUnit unit = TimeUnit.SECONDS;
		executor = new ThreadPoolExecutor(corePoolSize, maximumPoolSize, keepAliveTime, unit, workQueue,
				new ThreadPoolExecutor.DiscardOldestPolicy());
		testThread();
	}

	public static void testThread() throws IOException, InterruptedException, ExecutionException {

		int taskSise = 4;
		
		CountDownLatch countDownLatch = new CountDownLatch(taskSise);
		Long begin_main = System.currentTimeMillis();
		for (int i = 1; i <= taskSise; i++) {
			executor.submit(() -> {
				try {
					Thread.sleep(1000);
					System.out.println("-----子线程--------");
				} catch (InterruptedException e) {
				}
				countDownLatch.countDown();
			});
		};
		countDownLatch.await(10, TimeUnit.SECONDS);
		System.out.println("-----主线程执行,耗时:" + (System.currentTimeMillis() - begin_main));
	}
}           

以上代码执行耗时10s,如果用在真实的业务中,请求量大的情况下是阻塞大量线程导致服务不可用。

结论:

1:AbortPolicy由于在executor.submit时候就抛出异常,主线程直接异常退出。

2:DiscardPolicy / DiscardOldestPolicy直接丢弃任务,导致计数器最终不能归0,主线程必须在await()方法处等待超时,如果没设置超时时间将会永远等待阻塞。高并发下hang住太多线程最终服务无响应

3:CallerRunsPolicy 调用者所在的线程来执行任务,会执行countDown()方法,计数器最终归0,主线程立即继续执行。

二:countdown需要在finally里执行减一操作。

public static void testThread() throws IOException, InterruptedException, ExecutionException {

		int taskSise = 4;
		
		CountDownLatch countDownLatch = new CountDownLatch(taskSise);
		Long begin_main = System.currentTimeMillis();
		for (int i = 1; i <= taskSise; i++) {
			executor.submit(() -> {
				System.out.println("-----子线程--------");
				int a = 1/0;
				countDownLatch.countDown();
			});
		};
		countDownLatch.await(10, TimeUnit.SECONDS);
		System.out.println("-----主线程执行,耗时:" + (System.currentTimeMillis() - begin_main));
}           

由于子线程在countDown()方法之前的业务执行异常未捕获,导致计数器不能归0,主线程必须在await()方法处等待超时,如果没设置超时时间将会永远等待阻塞。改为捕获异常在finally里进行计数器操作:

public static void testThread() throws IOException, InterruptedException, ExecutionException {

		int taskSise = 4;
		
		CountDownLatch countDownLatch = new CountDownLatch(taskSise);
		Long begin_main = System.currentTimeMillis();
		for (int i = 1; i <= taskSise; i++) {
			executor.submit(() -> {
				System.out.println("-----子线程--------");
				try {
					int a = 1/0;
				} catch (Exception e) {
				}finally {
					countDownLatch.countDown();
				}
			});
		};
		countDownLatch.await(10, TimeUnit.SECONDS);
		System.out.println("-----主线程执行,耗时:" + (System.currentTimeMillis() - begin_main));
	}           

abortPolicy:也是默认的拒绝策略,直接丢弃任务抛出RejectedExecutionException异常,异常阻止系统正常运行 ,如果我们需要业务正常运行则需要捕获此异常。(此处说的是在使用CompletableFuture的情况下)

package test;

import java.io.IOException;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

@SuppressWarnings("all")
public class AbortPolicyPolicyTest {

	private static ThreadPoolExecutor executor = new ThreadPoolExecutor(1, 3, 60, TimeUnit.SECONDS,
			new ArrayBlockingQueue<>(5), new SelfAbortPolicy());

	public static void main(String[] args) throws IOException, InterruptedException, ExecutionException {
		testThread();
	}

	public static void testThread() throws IOException, InterruptedException, ExecutionException {

		CompletableFuture<Integer>[] completableFutures = new CompletableFuture[10];

		for (int i = 0; i < 10; i++) {

			CompletableFuture<Integer> c = CompletableFuture.supplyAsync(() -> {
				try {
					System.out.println(Thread.currentThread().getName());
					TimeUnit.MILLISECONDS.sleep(200);
				} catch (InterruptedException e) {
				}
				return 10;
			}, executor);
			completableFutures[i] = c;
		}
		try {
			CompletableFuture.allOf(completableFutures).get(2, TimeUnit.SECONDS);
		} catch (InterruptedException | ExecutionException | TimeoutException e) {
		}
		System.out.println("finish");
	}
	
	
	public static class SelfAbortPolicy extends ThreadPoolExecutor.AbortPolicy {
		 public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
		       System.out.println("abort policy");
		        throw new RejectedExecutionException("abort");
		    }
    }
}           

自定义拒绝策略

根据实际业务包括是否允许业务丢失等,自己定义拒绝策略。

AbortPolicyWithReport:dubbo的拒绝策略,任务被拒绝后打印详细的日志信息并导出线程快照信息供定位线上问题。

运行状态监控

ThreadPoolExecutor提供了多个get方法供开发者获取线程池运行时信息,开发者可以以打印日志的方式或者集成springboot Prometheus插件,使用Grafana面板查看线程池的各项指标。

拒绝时监控:

public void rejectedExecution(Runnable r, ThreadPoolExecutor e) {
		String msg = String.format("thread-monitor-abort:%s"
				+ ", poolSize: %d (activeCount: %d, corePoolSize: %d, maxPoolSize: %d, largestPoolSize: %d), taskCount: %d (completed: "
				+ "%d, rejectCount:%d)" + ", Executor status:(isShutdown:%s, isTerminated:%s, isTerminating:%s) !",
				threadName, e.getPoolSize(), e.getActiveCount(), e.getCorePoolSize(), e.getMaximumPoolSize(),
				e.getLargestPoolSize(), e.getTaskCount(), e.getCompletedTaskCount(), count.incrementAndGet(),
				e.isShutdown(), e.isTerminated(), e.isTerminating());
		logger.debug(msg);
		dumpJStack();
		throw new RejectedExecutionException(msg);
	}           

实时监控:

继承ThreadPoolExecutor,重写beforeExecute(Thread t, Runnable r)和afterExecute(Runnable r, Throwable t) 方法,在方法前/后打印出上面的指标。

public class TraceThreadPool extends ThreadPoolExecutor {

    private final ThreadLocal<Long> startTime = new ThreadLocal<Long>();
    private final Logger log = Logger.getLogger("TimingThreadPool");
    private final AtomicLong numTasks = new AtomicLong();
    private final AtomicLong totalTime = new AtomicLong();

    @Override
    protected void beforeExecute(Thread t, Runnable r) {
        ......
        super.beforeExecute(t, r);
    }

    @Override
    protected void afterExecute(Runnable r, Throwable t) {
        .......
        super.afterExecute(r, t);
  }            

注意:不同业务的线程一定要用名字去区分,在导出线程快照/内存dump分析问题的时候极其重要

动态参数调整

随着线上的业务量增长,线上的参数需要跟着调整,线程池参数也是其中的一部分。在ThreadPoolExecutor中针对corePoolSize和maximumPoolSize等核心参数提供了set方法,可以方便我们在运行时设置线程池参数,如在配置中心等系统修改。但是队列容量确没法调整。如果需要修改队列容量,则需要copy一份LinkedBlockingQueue代码稍作修改。

public class LinkedBlockingQueue<E> extends AbstractQueue<E>
        implements BlockingQueue<E>, java.io.Serializable {
        ...
        
        private final int capacity;
}           

所以我们需要重写这个队列。一般是复制一份LinkedBlockingQueue源码,修改capacity去掉final,加上set方法。

private volatile int capacity;

    
	public int getCapacity() {
		return capacity;
	}


	public void setCapacity(int capacity) {
		if (capacity <= 0) throw new IllegalArgumentException();
		final int oldCapacity = this.capacity;
	    this.capacity = capacity;
	    final int size = count.get();
	    if (capacity > size && size >= oldCapacity) {
	        signalNotFull();
	    }
	}           

使用到capacity的判断需要改正。特别是 count.get() == capacity的地方。

在设置参数的时候需要先设置核心线程数,再设置最大线程数,代码如下:

ThreadPoolExecutor threadPoolExecutor = traceThreadPoolTaskExecutor.getThreadPoolExecutor();
			threadPoolExecutor.setCorePoolSize(conf.getCorePoolSize());
			threadPoolExecutor.setMaximumPoolSize(conf.getMaxPoolSize());
			BlockingQueue<Runnable> queue = threadPoolExecutor.getQueue();
			if (queue instanceof ResizableCapacityLinkedBlockIngQueue) {
				((ResizableCapacityLinkedBlockIngQueue<?>) queue).setCapacity(conf.getQueueCapacity());
    }           

配置参数推荐估算

合理的评估核心线程数和最大线程数,没有固定的公式

较为固定的公式: 计算密集 线程数 ≈ CPU核数。

初始估算公式: 线程数= CPU核心数/(1-阻塞系数) ,阻塞系数取值0.8-0.9

IO密集型没有固定的公式,只能大体取一个初值,然后根据压测和线程快照分析去实践一个合理的线程池数量。

压测的过程中需要检测服务器以及中间件的各项指标。同时根据情况不断的调整线程数量和队列大小,调节遵循以下原则:

1:最大线程数设置太小,工作队列设置偏小,导致服务接口大量出现RejectedExecutionException

2:最大线程数设置太小,工作队列设置过大,任务堆积过多,接口响应时间变长。

3:最大线程数设置太大,线程调度开销增大,处理速度反而下降。

4:核心线程数设置过大,空闲线程太多,占用系统资源。

如果在调节线程数和队列数后,在测结果的过程中导出线程快照,连续导出3次,对比三次线程快照,如果发现大部分线程都处于运行状态,说明线程数量设置的还算合理,此时还可以稍微调大点。如果对比发现大部分线程都处于waiting状态,可能是线程数设置过多(够用),需要调小再试。

jstack -l pid > pid.log

Thread Name
appliance-svc-task-36
State
Waiting on condition
Java Stack
at sun.misc.Unsafe.park(Native Method)
 at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
 at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2039)
 at java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:442)
 at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1067)
 at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1127)
 at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
 at java.lang.Thread.run(Thread.java:745)
 Native Stack
No Native stack trace available           

如果waiting on condition/monitor的是业务代码,那么可能意味着这片代码可能存在阻塞或者性能问题,如果是有性能问题则根据实际情况做优化后再压测。

队列大小估算:如果一个任务的执行时间在50~100ms,如果以上压测估算的核心线程数是50,假设此接口认为在1s内返回算合理,按照1s算,1s内50个线程可以处理(不考虑外部因素):1*1000ms / 75 ms * 50= 666,则在此范围浮动即可。这也算是个估算值,然后根据压测结果比如响应时间+QPS再结合上面说的监控拒绝数量调节一个实际的参数。

线程池业务隔离|生产事故

线程池隔离原理:给每个请求分配单独的线程池,每个请求做到互不影响,也可以使用一些成熟的框架比如Hystrix,阿里的Sentinel 等。

为什么需要隔离:线程池共用,别的业务执行时间过长,占用了核心线程,另外的业务的任务到达就会直接进入等待队列。如果长时间得不到执行就会就会使得接口响应缓慢。

优点:

(1):某些下游服务的请求时间过长,创建多个线程池,针对不同的下游服务的请求只在各自的线程池中的线程处理。不会因其他下游服务的请求耗时而导致业务异常,从而实现资源隔离。

(2):各个线程各自只做各自的事情,互不干扰,可以避免线程阻塞,提升吞吐量。

比如服务依赖外部服务A的接口,依赖外部服务B的接口,假如login接口依赖的A服务处理稍微耗时,则线程同一时间内做的事情将会减少,任务堆积到队列,home/get进来的请求只能在队列里等待,影响了home/get的功能。

home/get突发流量占满线程后,用户登录功能受影响。多个业务互相影响,所欲需要隔离。

package com.example.demo.controller;

import java.util.concurrent.Executor;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("user")
public class CommonPoolTestController {
	
	@Autowired
	private Executor commonExecutor;
	
	@RequestMapping("login")
	public void login(@RequestParam("userName") String userName) {
		
		commonExecutor.execute(()->{
			A.getNickName(userName);//外部A系统的接口
		});

	}
	
	@RequestMapping("home/get")
	public void homeGet(@RequestParam("userName") String userName) {
		commonExecutor.execute(()->{
			B.getHome(userName);//外部B系统的接口
		});

	}
}           

生产案例一:

消息中心出现不可用,用户服务做为消息的生产方(http调用消息中心发送消息):

一:未做线程隔离

整个系统共用一个线程池,默认使用LinkedBlockingQueue未设置队列大小(Integer.MAX_VALUE),啥业务都让里面丢(存在OOM风险)。

最终因为消息中心的不可用(消息发送占满整个线程池其他核心业务在队列里排队?占满的根本原因还有哪些?),导致其他核心业务瘫痪。

二:未设置超时时间

调用消息中心使用的是前人对apache httpclient的封装工具类,里面写死的30s超时(消息中心负载高,处理不过来,客户端只能等到超时 SocketTimeoutException ),也就是一个线程30s内只能等待直到超时(不只是30s),意味着几十个请求过来就可以打满线程池(核心线程数80),在这30s内源源不断的相关的其他请求业务都积压在队列(业务线程池的队列)里得不到处理,主线程(容器线程,如tomat)在等待直到超时,同事也占据了大量的容器线程(新的请求很难进来,系统负载高,处理缓慢)

三:重试

上面说的一个线程30s内只能等待直到超时,这个其实是90s的,把影响又放大了好多倍。 站在业务上是否允许丢失?重试是否必须?哪些异常需要重试?消费方是否能保证幂等性?重试次数/间隔?

生产案例二:

同上情况:同样是大数据服务不可用,APP--->设备nginx--->设备服务httpclient(30s超时)--->大数据首页环境信息接口。占满tomcat线程池,不能处理其他请求。到时设备服务全部瘫痪。(那时候刚做完部署隔离-代码仓库隔离,iot-rpc拆分为iot-appliance和iot-user),不然真的是某居首页都瘫了。这就是拆分的好处之一(好处远远不止这些)

事故现场还原:

client端:

package com.example.demo.controller.thread;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.example.demo.third.DemoService;
import com.midea.framework.commons.http.R;
import com.midea.framework.commons.json.JsonUtil;

@RestController
@RequestMapping("test")
public class ClientLongTimeController {
	
	private static final Logger LOGGER = LoggerFactory.getLogger(ClientLongTimeController.class);

	@Autowired
	private DemoService demoService;

	@RequestMapping("longTime")
	public void longTime() {
		R r = demoService.getLongTime();
		LOGGER.info("result:{}",JsonUtil.toJson(r));
	}
}           

Server端:

package com.example.demo.controller.server;

import java.util.Random;
import java.util.concurrent.TimeUnit;

import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequestMapping("v1")
public class ServerLongTimeController {

	@ResponseBody
	@RequestMapping("server/getLongTime")
	public String getLongTime() throws InterruptedException {
		int number = new Random().nextInt(5) + 1;
		TimeUnit.SECONDS.sleep(number);
		return String.valueOf(number);
	}
}           

模拟客户端发起请求:

ab -n 1000000 -c 600 -T "application/json" -H "Content-Type: application/json"  http://10.xxx.1.136:8800/test/longTime           

观察客户端的线程快照:

服务优化实战指南
服务优化实战指南

可以发现450个线程处于wait状态,且450为undertow的工作线程数量,说明undertow工作线程全部在此阻塞。

server:
  port : 8800
  undertow:
    threads:
      io: 12
      worker: 450           

此时出现的现象:

1:系统全部接口响应缓慢或者超时(容器工作线程大量等待,同一时间没有空闲的线程去处理请求)

2:cpu使用率不高

解决办法:

1:服务端优化接口耗时时长

2:客户端做线程隔离,不要影响其他业务

3:使用业务线程去阻塞耗时,避免容器线程阻塞

@RequestMapping("longTime/async")
	public DeferredResult<R> longTimeDef() {
		DeferredResult<R> d = new DeferredResult<>(1000L,new R());
		executor.execute(()->{
			R r = demoService.getLongTime();
			d.setResult(r);
			LOGGER.info("result:{}",JsonUtil.toJson(r));
		});
		return d;
	}           
服务优化实战指南

以上只是换了个阻塞方式,使用自定义的线程去阻塞,因为容器线程我们没法做业务隔离,但某个业务耗时耗尽容器的所有工作线程则其他业务同样受影响。

建议最终的解决办法是从服务端耗时去解决,同时客户端做好线程隔离,避免故障影响其他业务。

Executors 谨慎使用

JUC下的工具类,提供了静态方法供使用者快速的创建线程池。但是它对使用者来说屏蔽了关键的调优参数,所以禁止在生产环境使用。

此片段摘自阿里巴巴java开发规范(泰山版)

【强制】线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这

样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。

说明:Executors 返回的线程池对象的弊端如下:

1) FixedThreadPool 和 SingleThreadPool:

允许的请求队列长度为 Integer.MAX_VALUE,可能会堆积大量的请求,从而导致 OOM。

2) CachedThreadPool:

允许的创建线程数量为 Integer.MAX_VALUE,可能会创建大量的线程,从而导致 OOM。

配置:-Xms256m -Xmx256m -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/app/demo/bin/gc.log -XX:-UseAdaptiveSizePolicy

private static final Executor executor = Executors.newFixedThreadPool(30);
	@RequestMapping("newFixedThreadPool")
	public void testNewFixedThreadPoolOOM() {
		executor.execute(()->{
			demoService.getLongTime();
		});
	}           

模拟请求:

ab -n 1000000 -c 600 -T "application/json" -H "Content-Type: application/json"  http://10.xxx.1.136:8800/thread/test/newFixedThreadPool           

执行一段时间后发现系统响应缓慢,此时查看jvm内存使用信息

jmap -heap 21280

[mcloud@10_xxx_1_136 bin]$ jmap -heap 21280
Attaching to process ID 21280, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.92-b14

using thread-local object allocation.
Parallel GC with 8 thread(s)

Heap Configuration:
   MinHeapFreeRatio         = 40
   MaxHeapFreeRatio         = 70
   MaxHeapSize              = 268435456 (256.0MB)
   NewSize                  = 89128960 (85.0MB)
   MaxNewSize               = 89128960 (85.0MB)
   OldSize                  = 179306496 (171.0MB)
   NewRatio                 = 2
   SurvivorRatio            = 8
   MetaspaceSize            = 21807104 (20.796875MB)
   CompressedClassSpaceSize = 1073741824 (1024.0MB)
   MaxMetaspaceSize         = 17592186044415 MB
   G1HeapRegionSize         = 0 (0.0MB)

Heap Usage:
PS Young Generation
Eden Space:
   capacity = 67108864 (64.0MB)
   used     = 67108864 (64.0MB)
   free     = 0 (0.0MB)
   100.0% used
From Space:
   capacity = 11010048 (10.5MB)
   used     = 0 (0.0MB)
   free     = 11010048 (10.5MB)
   0.0% used
To Space:
   capacity = 11010048 (10.5MB)
   used     = 0 (0.0MB)
   free     = 11010048 (10.5MB)
   0.0% used
PS Old Generation
   capacity = 179306496 (171.0MB)
   used     = 178943424 (170.65374755859375MB)
   free     = 363072 (0.34625244140625MB)
   99.79751319216008% used

14581 interned Strings occupying 1354632 bytes.           

发现年老代试用率接近99.79%

代码执行一段时间之后,执行以下命发现fullGC很频繁或 jstat -gc 21280 5000

[mcloud@10_111_1_136 ~]$ jstat -gcutil 21280 1000
  S0     S1     E      O      M     CCS    YGC     YGCT    FGC    FGCT     GCT   
  0.00   0.00 100.00  99.91  94.74  93.12    847   26.130    59   54.985   81.115
  0.00   0.00  84.09  99.91  94.74  93.12    847   26.130    59   56.050   82.180
  0.00   0.00 100.00  99.91  94.74  93.12    847   26.130    60   56.050   82.180
  0.00   0.00 100.00  99.91  94.74  93.12    847   26.130    61   57.046   83.176
  0.00   0.00 100.00  99.91  94.74  93.12    847   26.130    62   58.082   84.212           

我们加入jvm参数输出具体的GC信息,配置:-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/app/demo/bin/gc.log

2022-01-12T11:27:30.414+0800: 262.093: [GC (Allocation Failure) [PSYoungGen: 68768K->3712K(76288K)] 243466K->178642K(251392K), 0.0327598 secs] [Times: user=0.23 sys=0.00, real=0.03 secs] 

2022-01-12T11:27:38.242+0800: 328.921: [Full GC (Ergonomics) [PSYoungGen: 65536K->12498K(76288K)] [ParOldGen: 174953K->174951K(175104K)] 240489K->187450K(251392K), [Metaspace: 45234K->45234K(1091584K)], 1.0362795 secs] [Times: user=7.05 sys=0.02, real=1.03 secs] 

2022-01-12T11:27:39.479+0800: 330.158: [Full GC (Ergonomics) [PSYoungGen: 65536K->12991K(76288K)] [ParOldGen: 174951K->174950K(175104K)] 240487K->187942K(251392K), [Metaspace: 45234K->45234K(1091584K)], 0.9920889 secs] [Times: user=6.67 sys=0.02, real=0.99 secs] 

2022-01-12T11:27:40.659+0800: 331.338: [Full GC (Ergonomics) [PSYoungGen: 65536K->12856K(76288K)] [ParOldGen: 174950K->174950K(175104K)] 240486K->187807K(251392K), [Metaspace: 45234K->45234K(1091584K)], 1.0017935 secs] [Times: user=6.88 sys=0.02, real=1.00 secs]            

格式介绍:

第一条日志:

1:328.921 GC发生时候虚拟机运行了多少秒

2:GC (Allocation Failure):发生了一次垃圾回收,这是一次Minor GC。括号里表示GC发生的原因,Allocation Failure的缘由是 年轻代没有足够的区域可以存放需要分配的对象 而失败。

3:PSYoungGen:使用的垃圾收集器的名字

4:68768K->3712K(76288K) 指的是 垃圾收集前->垃圾收集后(年轻代堆总大小)

5:243466K->178642K(251392K) 指的是垃圾收集前后,java堆的大小总共大小(251392K 包括新生代和年老代),年老代大小=251392K-76288K

6:0.0327598 secs :GC花费的时间

7:[Times: user=0.23 sys=0.00, real=0.03 secs],分别表示用户态耗时,内核态耗时,总耗时。

第二条日志:

基本同上,对应分代空间的前后和总大小。

从上面这段输出日志中可以看到,

  • 这段日志输出的是JVM启动后328秒左右的信息
  • fullGC每秒一次,每次耗时1s左右,在这期间JVM基本暂停。
  • 同时,可以观察到 这个应用运行状况不好,JVM几乎被垃圾回收给停止了,GC消耗了应用运行的99%的时间。并且Full GC也已经无法回收到多少内存空间了。这个应用在运行几分钟后,也抛出java.lang.OutOfMemoryError: GC overhead limit exceeded的错误并终止了

通过分析GC日志可以发现:

  1. 应用的GC负载过高。GC暂停时间越长,应用的吞吐量越低。一般来说,GC暂停时间超过应用运行时间的10%时,该应用已经处于不正常状况了
  2. 单次暂停时间过长。单个暂停耗时越长,应用的延迟越显著。当应用的延迟性能要求应用的每次事务处理必须在1000毫秒内完成时,我们就要确保GC暂停时间不能超过1000毫秒
  3. 老年代内存使用达到极限。当发生几次Full GC后,老年代空间的内存使用仍然达到其容量极限时,我们可以知道老年代空间已经成为应用的瓶颈了。这可能是由于老年代空间分配不足,或者应用发生了内存泄漏

我们dump出内存快照来进行分析

jmap -dump:live,format=b,file=heap-dump.bin 21280

服务优化实战指南
服务优化实战指南

由此证明使用Executors创建的newFixedThreadPool因为默认使用无界队列(队列长度int最大值)导致存在OOM的风险。

同样,使用其创建的newCachedThreadPool也存在开启大量线程导致系统资源不足的的风险。

类似的其他池化-httpclient连接池

1.复用http连接,省去了tcp的3次握手和4次挥手的时间,极大降低请求响应的时间 2.自动管理tcp连接,不用人为地释放/创建连接

如果未使用连接池的情况下,如果调用下游的请求很多,那么由于每个请求都需要建立一个连接,则会经常出现Address already in use (Bind failed)的问题。这是一个端口绑定冲突的问题,排查了一下当前系统的网络连接情况和端口使用情况,发现是有大量time_wait的连接一直占用着端口没释放,导致端口被占满(最高的时候6w+个),因此建立连接的时候会出现申请端口冲突的情况 。

所以在java中最常使用的是apache的httpclient作为http请求工具。

使用httpclient有几个重要参数除了超时时间相关的设置,还有两个参数需要注意,那就是连接池设置的最大连接数(MaxTotal) 和每个路由分配的最大连接数(DefaultMaxPerRoute)。如果设置不合理也可能导致生产事故。

1:MaxTotal表示连接池可以管理的最大的连接数量

2:defaultMaxPerRoute可以理解为给每个域名分配的最大连数。

如:如果设置defaultMaxPerRoute=50,则appliance服务掉 appliance-svc服务最大可以分配50个连接,掉user-svc也会重新最大分配50个连接 。

public void init () {
    	requestConfig = RequestConfig.custom().setSocketTimeout(1000).setConnectTimeout(1000).build();//设置    SocketTimeout时间
    	PoolingHttpClientConnectionManager cm = new PoolingHttpClientConnectionManager();
    	cm.setMaxTotal(200);
    	cm.setDefaultMaxPerRoute(100);
    	client = HttpClients.custom().setDefaultRequestConfig(requestConfig).setConnectionManager(cm).build();
 }           

生产案例:

iot-rpc服务调用cms接口获取用户昵称,由于开发同学没有仔细看过各个参数的含义,从别的地方复制了一份httpclient类和配置,修改了defaultMaxPerRoute的参数为10,由于相关业务是某居首页的业务请求量还是比较大的,上线后,这块业务出现了大量的超时,同时此服务里的其他接口也出现大量超时(那时候还没有监控报警链路日志,接口报错或者耗时全靠客诉找上门或者品质自动化脚本报错后找我们),然后客诉不断。

开发定位问题三板斧:先看log,再top free df,fullgc,阿里后台数据库,redis,网络。一切都正常。最后只能jstack线程快照。

从jstack的日志中可以看出来,有大量的线程在等待从连接池里获取的连接而进行排队,因此导致了线程堆积(tomcat线程),所以平均响应时间上升。由于堆积了太多的tomcat线程,其他的口平响也会因此升高,导致更需多的线程堆积。

​ ​

服务优化实战指南
服务优化实战指南

分析到是大量线程从httpclient连接池获取连接而进行排队,通过看源码得知是DefaultMaxPerRoute值设置的太小,通过以下命令看到请求外围系统也只有10个连接。修改defaultMaxPerRoute为100后,经过简单压测发现并发量提高了,没有出现大量线程阻塞的现象。

netstat -n | grep 10.xxx.1.46 | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'
netstat -ant | grep 10.xxx.1.46 |awk '{print $6}' | sort |uniq -c

[mcloud@10_xxx_1_136 log]netstat -n | grep 10.xxx.1.46 | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'
ESTABLISHED 10           

httpclient遇见的坑:

HttpClient4 调用springboot(undertow),客户端出现大量的CLOSE_WAIT

private String doRequestInner(HttpRequestBase httpRequestBase) throws IOException {
		CloseableHttpResponse response = null;
		long l1 = System.currentTimeMillis();
		try {
			response = client.execute(httpRequestBase);
			HttpEntity entity = response.getEntity();
			String result = EntityUtils.toString(entity, Consts.UTF_8);
			EntityUtils.consume(entity);

			long l2 = System.currentTimeMillis();
			logger.info(" http client cost:{} response:{} url:{}", (l2 - l1), result, httpRequestBase.getURI());

			return result;
		} finally {
			if (response != null) {
				logger.info("----close");
				response.close();
			}
			if(httpRequestBase != null) {
				logger.info("----releaseConnection");
				httpRequestBase.releaseConnection();
			}
		}
	}           

说明是服务端springboot(undertow)主动断开连接。具体原因如下:

HttpClient4发送的其实是http1.1的,而我上边的服务器自动关闭的知识点是http1.0的,

http1.0 默认短连接,服务器主动关闭, 需要长连接需要配置keepalive

http1.1 默认长连接,因此服务器不会主动关闭,而httpclient4我们一般会直接写httpPost.releaseConnection(),因此自然是我们客户端主动关闭了

因此,如果还是希望服务器短连接并且主动关闭,可以配置Connection:close在http header .

五:志规范

常见的日志级别有5种,分别是error、warn、info、debug、trace。日常开发中,我们需要选择恰当的日志级别

  • error:错误日志,指比较严重的错误,对正常业务有影响,需要运维配置监控的;
  • warn:警告日志,一般的错误,对业务影响不大,但是需要开发关注;
  • info:信息日志,记录排查问题的关键信息,如调用时间、出参入参等等;
  • debug:用于开发DEBUG的,关键逻辑里面的运行时数据;
  • trace:最详细的信息,一般这些信息只记录到日志文件中。

预先判断日志级别:对于DEBUG、INFO级别日志,必须使用条件输出或者使用占位符的方式打印。由于生产环境是禁止输出DEBUG日志且有选择地输出INFO日志,所以不使用上述方式则需要多执行一步使用字符串拼接/字符串格式化操作,影响程序执行效率。

避免无效日志打印:生产环境是禁止DEBUG日志且有选择地输出INFO日志。使用INFO、WARN级别来记录业务行为信息时,一定要控制日志输出量,以免出现磁盘空间不足。同时要为日志设置合理的生命周期,及时清理过期日志。避免重复打印,务必在日志配置文件中设置additivity=false。

区别对待错误日志:WARN日志:记录一些业务异常可以通过引导重试就能恢复正常的日志信息,如用户输入参数错误。ERROR日志:记录系统逻辑错误、异常或违法重要业务规则的日志信息(需要人工干预)。

保证记录内容完整:日志记录的内容包括现场上下文信息与异常堆栈信息。

对于业务系统来说,日志对于系统的性能影响非常大,不需要的日志尽量不要打印,避免占用IO资源。

正确的日志格式如下:

//直接字符串拼接打印(错误),无论是什么日志级别,程序每次运行到这里都会构造一个字符串
logger.debug("business is start,startTime:" + System.currentTimeMillis());

//使用条件判断(正确)
 if (logger.isDebugEnabled()){
    logger.debug("business is start,startTime:" + System.currentTimeMillis());
 }

//使用占位符形式,不用每次都手动添加isDebugEnabled判断(正确)
logger.debug("business is start,startTime:{}", System.currentTimeMillis());

//错误案例,在系统中出现的很多
logger.info("中台登陆签名SRC:{}" + signSrc);           

减少不必要的计算

错误案例:任何级别都会进行序列化

if (CollectionUtils.isEmpty(sceneInfoList)) {
	 logger.debug("The house={} mei zhi sceneInfoList is empty.", JSON.toJSONString(sceneInfoList));
	return sceneSuccess;
}

 private void updateSupportedApplianceTypeOrSubtype(String reqId, int appId, List<String> typesFromReq, Integer limitType, List<AppApplianceType> applianceTypes) {
            //兼容旧的开发者平台调用,后期可以去掉
            List<String> types = getApplianceTypeAfterDeal(typesFromReq);
            OpenSupportedApplianceType type = new OpenSupportedApplianceType(appId, StringUtils.join(types, ";"));
            Logger.debug("reqId: {},Update supported type with : {}", reqId, JsonUtil.toJson(type));
            openSupportedApplianceTypeCache.updateSupportedApplianceTypeByAppId(type);
    }           

正确案例:

if (CollectionUtils.isEmpty(sceneInfoList)) {
  if(logger.isDebugEnabled()){
   logger.debug("The house={} mei zhi sceneInfoList is empty.", JSON.toJSONString(sceneInfoList));
  }
   return sceneSuccess;
}           

线程几可能使用INFO或者更高的级别,如ERROR

不要使用e.printStackTrace()

错误:com.midea.smart.thirdpart.access.rpc.service.impl.GatewayApplianceServiceImpl.subdeviceAddReport(Long, String)

try{
  // 业务代码处理
}catch(Exception e){
  e.printStackTrace();
}           

正确:

try{
  // 业务代码处理
}catch(Exception e){
  log.error("用户注册异常",e);
}           
  • e.printStackTrace()打印出的堆栈日志跟业务代码日志是交错混合在一起的,通常排查异常日志不太方便。
  • e.printStackTrace()语句产生的字符串记录的是堆栈信息,如果信息太长太多,字符串常量池所在的内存块没有空间了,即内存满了,用户的请求就阻塞了

输出全部错误信息

错误:

try {
    //业务代码处理
} catch (Exception e) {
    // 错误
    LOG.error('你的程序有异常啦');
}           

错误:

try {
    //业务代码处理
} catch (Exception e) {
    // 错误
    LOG.error('你的程序有异常啦', e.getMessage());
}           

e.getMessage()不会记录详细的堆栈异常信息,只会记录错误基本描述信息,不利于排查问题。

正确:

try {
    //业务代码处理
} catch (Exception e) {
    LOG.error('你的程序有异常啦', e);
}           

禁止在线上环境开启 debug

禁止在线上环境开启debug,这一点非常重要。

因为一般系统的debug日志会很多,并且各种框架如Spring,Mybatis大量使用 debug的日志,线上开启debug不久可能会打满磁盘,影响业务系统的正常运行。

记录异常,又抛出异常

log.error("IO exception", e);
throw new MyException(e);           
  • 这样实现的话,通常会把栈信息打印两次。这是因为捕获了MyException异常的地方,还会再打印一次。
  • 这样的日志记录,或者包装后再抛出去,不要同时使用!否则你的日志看起来会让人很迷惑。
  • 最佳实践直接抛出异常 ,在统一异常处理处输出异常信息。如filter结束,aop后置结束,或者ExceptionHandler统一异常处理。

避免重复打印日志

if(user.isVip()){
  log.info("该用户是会员,Id:{}",user,getUserId());
  //冗余,可以跟前面的日志合并一起
  log.info("开始处理会员逻辑,id:{}",user,getUserId());
  //会员逻辑
}else{
  //非会员逻辑
}           

异步日志

推荐使用log4j2,也是新版本springboot官方推荐,结合了logback的特性对log4j进行了升级,性能优越。

  • 日志最终会输出到文件或者其它输出流中的,IO性能会有要求的。如果异步,就可以显著提升IO性能。
  • 使用异步的方式来输出日志。以logback为例,要配置异步,使用AsyncAppender
<appender name="FILE_ASYNC" class="ch.qos.logback.classic.AsyncAppender">
    <appender-ref ref="ASYNC"/>
</appender>           

模拟案例

大家可以访问以dev机器10.xxx.0.121 执行以下压测脚本:

ab -n 1000000 -c 590 http://10.xxx.1.136:8800/thread/request/defered/merge?applianceCode=211106233032263           

更改日志级别为debug对以上接口进行压测。压测过程中导出线程快照信息,看到里面有大量的log4j2相关的阻塞代码。同时会发现更改了日志级别后压测QPS下降了很多。

服务优化实战指南

六:命令/脚本

示例中得员工表 建表语句:

CREATE TABLE t_employees (
    `emp_no`      BIGINT          NOT NULL,
    `name`        VARCHAR(14)     NOT NULL,
    `gender`      ENUM ('M','F')  NOT NULL DEFAULT 'M',
    `age`         INT NOT NULL DEFAULT 0,
    `department_id`         INT NOT NULL DEFAULT 0,
    `hire_date`   DATE            NOT NULL DEFAULT '1970-01-01',
    `birth_date`  DATE            NOT NULL DEFAULT '1970-01-01',
    `address`     VARCHAR(30)     DEFAULT NULL,
    PRIMARY KEY (`emp_no`),
    KEY `idx_name_age_address` (`name`,`age`,`address`) USING BTREE
) ENGINE=INNODB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4;

ALTER TABLE t_employees ADD COLUMN mobile VARCHAR(32) DEFAULT NULL COMMENT '手机号' AFTER age;
ALTER TABLE `t_employees` ADD UNIQUE (`mobile`)

ALTER TABLE t_employees ADD COLUMN email VARCHAR(20) DEFAULT NULL COMMENT 'email' AFTER mobile;
ALTER TABLE `t_employees` ADD key(email(9));

ALTER TABLE `t_employees` ADD INDEX idx_department_id (`department_id`);           

示例中构造数据-存储过程:

DROP PROCEDURE IF EXISTS proc_initData;
DELIMITER $
CREATE PROCEDURE proc_initData()
BEGIN
DECLARE COUNT INT DEFAULT 0;
DECLARE chars_str VARCHAR(450) DEFAULT '赵钱孙李周吴郑王冯陈楮卫蒋沈韩杨朱秦尤许何吕施张孔曹严华金魏陶姜戚谢邹喻柏水窦章云苏潘葛奚范彭郎鲁韦昌马苗凤花方俞任袁柳酆鲍史唐费廉岑薛雷贺倪汤滕殷罗毕郝邬安常乐于时傅皮卞齐康伍余元卜顾孟平黄和穆萧尹姚邵湛汪祁毛禹狄米贝明臧计伏成戴谈宋茅庞熊纪舒屈项祝董梁杜阮蓝闽席季麻强贾路娄危江童颜郭梅盛林刁锺徐丘骆高夏蔡田樊胡凌霍虞万支柯昝管卢莫经房裘缪干解应宗丁宣贲邓郁单杭洪包诸左石崔吉钮龚程嵇邢滑裴陆荣翁荀羊於惠甄麹家封芮羿储靳汲邴糜松井段富巫乌焦巴弓牧隗山谷车侯宓蓬全郗班仰秋仲伊宫宁仇栾暴甘斜厉戎祖武符刘景詹束龙叶幸司韶郜黎蓟薄印宿白怀蒲邰从鄂索咸籍赖卓蔺屠蒙池乔阴郁胥能苍双闻莘党翟谭贡劳逄姬申扶堵冉宰郦雍郤璩桑桂濮牛寿通边扈燕冀郏浦尚农温别庄晏柴瞿阎充慕连茹习宦艾鱼容向古易慎戈廖庾终暨居衡步都耿满弘匡国文寇广禄阙东欧殳沃利蔚越夔隆师巩厍聂晁勾敖融冷訾辛阚那简饶空曾毋沙乜养鞠须丰巢关蒯相查后荆红游竺权逑盖益桓公仉督晋楚阎法汝鄢涂钦归海岳帅缑亢况后有琴商牟佘佴伯赏墨哈谯笪年爱阳佟赵钱孙李周吴郑王';
DECLARE birth_date DATE DEFAULT '1970-01-01';
DECLARE init_date DATE DEFAULT '1970-01-01';
DECLARE hire_date DATE DEFAULT NULL;
DECLARE gender DATE DEFAULT 'M';

DECLARE i INT  DEFAULT 1;
SET autocommit = 0;  #设置手动提交事务
WHILE i<=10000000 DO
# 新增数据
SET birth_date = DATE_ADD(init_date,INTERVAL FLOOR(1 + RAND()*4000) DAY);
IF(birth_date > '2002-01-01') THEN 
SET init_date = '1970-01-01';
SET birth_date = DATE_ADD(init_date,INTERVAL FLOOR(1 + RAND()*4000) DAY);
END IF;
SET hire_date = DATE_ADD(birth_date,INTERVAL 6570+FLOOR(1 + RAND()*500) DAY);        
  INSERT INTO t_employees 
        ( emp_no, birth_date, `name` , gender,age,hire_date,department_id) VALUES 
        ( 100000001 + i,birth_date, CONCAT(SUBSTRING(chars_str , FLOOR(1 + RAND()*444),1),"某"), CASE WHEN MOD(10001 + i,2)=0 THEN 'M' ELSE 'F' END,
        TIMESTAMPDIFF(YEAR, birth_date, NOW()),hire_date,10001); #执行的sql语句
        SET i = i+1;
    END WHILE;
    commit;
END $
# 调用存储过程
CALL proc_initData();           

继续阅读