• JAVA代码优化,接口优化,SQL优化 (小技巧)


    这里写目录标题

    🟡第一章:Java中代码优化的30个小技巧

    1.用String.format拼接字符串

    不知道你有没有拼接过字符串,特别是那种有多个参数,字符串比较长的情况。

    比如现在有个需求:要用get请求调用第三方接口,url后需要拼接多个参数。

    以前我们的请求地址是这样拼接的:

    String url = "http://susan.sc.cn?userName="+userName+"&age="+age+"&address="+address+"&sex="+sex+"&roledId="+roleId;
    
    • 1

    字符串使用+号拼接,非常容易出错。

    后面优化了一下,改为使用StringBuilder拼接字符串:

    StringBuilder urlBuilder = new StringBuilder("http://susan.sc.cn?");
    urlBuilder.append("userName=")
    .append(userName)
    .append("&age=")
    .append(age)
    .append("&address=")
    .append(address)
    .append("&sex=")
    .append(sex)
    .append("&roledId=")
    .append(roledId);
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    代码优化之后,稍微直观点。

    但还是看起来比较别扭。

    这时可以使用String.format方法优化:

    String requestUrl = "http://susan.sc.cn?userName=%s&age=%s&address=%s&sex=%s&roledId=%s";
    String url = String.format(requestUrl,userName,age,address,sex,roledId);
    
    • 1
    • 2

    代码的可读性,一下子提升了很多。

    我们平常可以使用String.format方法拼接url请求参数,日志打印等字符串。

    但不建议在for循环中用它拼接字符串,因为它的执行效率,比使用+号拼接字符串,或者使用StringBuilder拼接字符串都要慢一些。

    2.创建可缓冲的IO流

    IO流想必大家都使用得比较多,我们经常需要把数据写入某个文件,或者从某个文件中读取数据到内存中,甚至还有可能把文件a,从目录b,复制到目录c下等。

    JDK给我们提供了非常丰富的API,可以去操作IO流。

    例如:

    public class IoTest1 {
        public static void main(String[] args) {
            FileInputStream fis = null;
            FileOutputStream fos = null;
            try {
                File srcFile = new File("/Users/dv_susan/Documents/workspace/jump/src/main/java/com/sue/jump/service/test1/1.txt");
                File destFile = new File("/Users/dv_susan/Documents/workspace/jump/src/main/java/com/sue/jump/service/test1/2.txt");
                fis = new FileInputStream(srcFile);
                fos = new FileOutputStream(destFile);
                int len;
                while ((len = fis.read()) != -1) {
                    fos.write(len);
                }
                fos.flush();
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                try {
                    if (fos != null) {
                        fos.close();
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
                try {
                    if (fis != null) {
                        fis.close();
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34

    这个例子主要的功能,是将1.txt文件中的内容复制到2.txt文件中。这例子使用普通的IO流从功能的角度来说,也能满足需求,但性能却不太好。

    因为这个例子中,从1.txt文件中读一个字节的数据,就会马上写入2.txt文件中,需要非常频繁的读写文件。

    优化:

    public class IoTest {
        public static void main(String[] args) {
            BufferedInputStream bis = null;
            BufferedOutputStream bos = null;
            FileInputStream fis = null;
            FileOutputStream fos = null;
            try {
                File srcFile = new File("/Users/dv_susan/Documents/workspace/jump/src/main/java/com/sue/jump/service/test1/1.txt");
                File destFile = new File("/Users/dv_susan/Documents/workspace/jump/src/main/java/com/sue/jump/service/test1/2.txt");
                fis = new FileInputStream(srcFile);
                fos = new FileOutputStream(destFile);
                bis = new BufferedInputStream(fis);
                bos = new BufferedOutputStream(fos);
                byte[] buffer = new byte[1024];
                int len;
                while ((len = bis.read(buffer)) != -1) {
                    bos.write(buffer, 0, len);
                }
                bos.flush();
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                try {
                    if (bos != null) {
                        bos.close();
                    }
                    if (fos != null) {
                        fos.close();
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
                try {
                    if (bis != null) {
                        bis.close();
                    }
                    if (fis != null) {
                        fis.close();
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45

    这个例子使用BufferedInputStreamBufferedOutputStream创建了可缓冲的输入输出流。

    最关键的地方是定义了一个buffer字节数组,把从1.txt文件中读取的数据临时保存起来,后面再把该buffer字节数组的数据,一次性批量写入到2.txt中。

    这样做的好处是,减少了读写文件的次数,而我们都知道读写文件是非常耗时的操作。也就是说使用可缓存的输入输出流,可以提升IO的性能,特别是遇到文件非常大时,效率会得到显著提升。

    3.减少循环次数

    在我们日常开发中,循环遍历集合是必不可少的操作。

    但如果循环层级比较深,循环中套循环,可能会影响代码的执行效率。

    反例

    for(User user: userList) {
       for(Role role: roleList) {
          if(user.getRoleId().equals(role.getId())) {
             user.setRoleName(role.getName());
          }
       }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    这个例子中有两层循环,如果userList和roleList数据比较多的话,需要循环遍历很多次,才能获取我们所需要的数据,非常消耗cpu资源。

    正例

    Map<Long, List<Role>> roleMap = roleList.stream().collect(Collectors.groupingBy(Role::getId));
    for (User user : userList) {
        List<Role> roles = roleMap.get(user.getRoleId());
        if(CollectionUtils.isNotEmpty(roles)) {
            user.setRoleName(roles.get(0).getName());
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    减少循环次数,最简单的办法是,把第二层循环的集合变成map,这样可以直接通过key,获取想要的value数据。

    虽说map的key存在hash冲突的情况,但遍历存放数据的链表或者红黑树时间复杂度,比遍历整个list集合要小很多。

    4.用完资源记得及时关闭

    在我们日常开发中,可能经常访问资源,比如:获取数据库连接,读取文件等。

    我们以获取数据库连接为例。

    反例

    //1. 加载驱动类
    Class.forName("com.mysql.jdbc.Driver");
    //2. 创建连接
    Connection	connection = DriverManager.getConnection("jdbc:mysql//localhost:3306/db?allowMultiQueries=true&useUnicode=true&characterEncoding=UTF-8","root","123456");
    //3.编写sql
    String sql ="select * from user";
    //4.创建PreparedStatement
    PreparedStatement pstmt = conn.prepareStatement(sql);
    //5.获取查询结果
    ResultSet rs = pstmt.execteQuery();
    while(rs.next()){
       int id = rs.getInt("id");
       String name = rs.getString("name");
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    上面这段代码可以正常运行,但却犯了一个很大的错误,即:ResultSet、PreparedStatement和Connection对象的资源,使用完之后,没有关闭。

    我们都知道,数据库连接是非常宝贵的资源。我们不可能一直创建连接,并且用完之后,也不回收,白白浪费数据库资源。

    正例

    //1. 加载驱动类
    Class.forName("com.mysql.jdbc.Driver");
    
    Connection	connection = null;
    PreparedStatement pstmt = null;
    ResultSet rs = null;
    try {
        //2. 创建连接
        connection = DriverManager.getConnection("jdbc:mysql//localhost:3306/db?allowMultiQueries=true&useUnicode=true&characterEncoding=UTF-8","root","123456");
        //3.编写sql
        String sql ="select * from user";
        //4.创建PreparedStatement
        pstmt = conn.prepareStatement(sql);
        //5.获取查询结果
        rs = pstmt.execteQuery();
        while(rs.next()){
           int id = rs.getInt("id");
           String name = rs.getString("name");
        }
    } catch(Exception e) {
      log.error(e.getMessage(),e);
    } finally {
       if(rs != null) {
          rs.close();
       }
       
       if(pstmt != null) {
          pstmt.close();
       }
       
       if(connection != null) {
          connection.close();
       }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34

    这个例子中,无论是ResultSet,或者PreparedStatement,还是Connection对象,使用完之后,都会调用close方法关闭资源。

    在这里温馨提醒一句:ResultSet,或者PreparedStatement,还是Connection对象,这三者关闭资源的顺序不能反了,不然可能会出现异常。

    5.使用池技术

    我们都知道,从数据库查数据,首先要连接数据库,获取Connection资源。

    想让程序多线程执行,需要使用Thread类创建线程,线程也是一种资源。

    通常一次数据库操作的过程是这样的:

    1. 创建连接
    2. 进行数据库操作
    3. 关闭连接

    而创建连接和关闭连接,是非常耗时的操作,创建连接需要同时会创建一些资源,关闭连接时,需要回收那些资源。

    如果用户的每一次数据库请求,程序都都需要去创建连接和关闭连接的话,可能会浪费大量的时间。

    此外,可能会导致数据库连接过多。

    我们都知道数据库的最大连接数是有限的,以mysql为例,最大连接数是:100,不过可以通过参数调整这个数量。

    如果用户请求的连接数超过最大连接数,就会报:too many connections异常。如果有新的请求过来,会发现数据库变得不可用。

    这时可以通过命令:

    show variables like max_connections
    
    • 1

    查看最大连接数。

    然后通过命令:

    set GLOBAL max_connections=1000
    
    • 1

    手动修改最大连接数。

    这种做法只能暂时缓解问题,不是一个好的方案,无法从根本上解决问题。

    最大的问题是:数据库连接数可以无限增长,不受控制。

    这时我们可以使用数据库连接池

    目前Java开源的数据库连接池有:

    • DBCP:是一个依赖Jakarta commons-pool对象池机制的数据库连接池。
    • C3P0:是一个开放源代码的JDBC连接池,它在lib目录中与Hibernate一起发布,包括了实现jdbc3和jdbc2扩展规范说明的Connection 和Statement 池的DataSources 对象。
    • Druid:阿里的Druid,不仅是一个数据库连接池,还包含一个ProxyDriver、一系列内置的JDBC组件库、一个SQL Parser。
    • Proxool:是一个Java SQL Driver驱动程序,它提供了对选择的其它类型的驱动程序的连接池封装,可以非常简单的移植到已有代码中。

    目前用的最多的数据库连接池是:Druid

    6.反射时加缓存

    我们都知道通过反射创建对象实例,比使用new关键字要慢很多。

    由此,不太建议在用户请求过来时,每次都通过反射实时创建实例。

    有时候,为了代码的灵活性,又不得不用反射创建实例,这时该怎么办呢?

    答:加缓存

    其实spring中就使用了大量的反射,我们以支付方法为例。

    根据前端传入不同的支付code,动态找到对应的支付方法,发起支付。

    我们先定义一个注解。

    @Retention(RetentionPolicy.RUNTIME)  
    @Target(ElementType.TYPE)  
    public @interface PayCode {  
         String value();    
         String name();  
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    在所有的支付类上都加上该注解

    @PayCode(value = "alia", name = "支付宝支付")  
    @Service
    public class AliaPay implements IPay {  
    
         @Override
         public void pay() {  
             System.out.println("===发起支付宝支付===");  
         }  
    }  
    
    @PayCode(value = "weixin", name = "微信支付")  
    @Service
    public class WeixinPay implements IPay {  
     
         @Override
         public void pay() {  
             System.out.println("===发起微信支付===");  
         }  
    } 
     
    @PayCode(value = "jingdong", name = "京东支付")  
    @Service
    public class JingDongPay implements IPay {  
         @Override
         public void pay() {  
            System.out.println("===发起京东支付===");  
         }  
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28

    然后增加最关键的类:

    @Service
    public class PayService2 implements ApplicationListener<ContextRefreshedEvent> {  
         private static Map<String, IPay> payMap = null;  
         
         @Override
         public void onApplicationEvent(ContextRefreshedEvent contextRefreshedEvent) {  
             ApplicationContext applicationContext = contextRefreshedEvent.getApplicationContext();  
             Map<String, Object> beansWithAnnotation = applicationContext.getBeansWithAnnotation(PayCode.class);  
            
             if (beansWithAnnotation != null) {  
                 payMap = new HashMap<>();  
                 beansWithAnnotation.forEach((key, value) ->{  
                     String bizType = value.getClass().getAnnotation(PayCode.class).value();  
                     payMap.put(bizType, (IPay) value);  
                 });  
             }  
         }  
        
         public void pay(String code) {  
            payMap.get(code).pay();  
         }  
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22

    PayService2类实现了ApplicationListener接口,这样在onApplicationEvent方法中,就可以拿到ApplicationContext的实例。这一步,其实是在spring容器启动的时候,spring通过反射我们处理好了。

    我们再获取打了PayCode注解的类,放到一个map中,map中的key就是PayCode注解中定义的value,跟code参数一致,value是支付类的实例。

    这样,每次就可以每次直接通过code获取支付类实例,而不用if…else判断了。如果要加新的支付方法,只需在支付类上面打上PayCode注解定义一个新的code即可。

    注意:这种方式的code可以没有业务含义,可以是纯数字,只要不重复就行。

    7.多线程处理

    很多时候,我们需要在某个接口中,调用其他服务的接口。

    比如有这样的业务场景:

    在用户信息查询接口中需要返回:用户名称、性别、等级、头像、积分、成长值等信息。

    而用户名称、性别、等级、头像在用户服务中,积分在积分服务中,成长值在成长值服务中。为了汇总这些数据统一返回,需要另外提供一个对外接口服务。

    于是,用户信息查询接口需要调用用户查询接口、积分查询接口 和 成长值查询接口,然后汇总数据统一返回。

    调用过程如下图所示:
    在这里插入图片描述

    调用远程接口总耗时 530ms = 200ms + 150ms + 180ms

    显然这种串行调用远程接口性能是非常不好的,调用远程接口总的耗时为所有的远程接口耗时之和。

    那么如何优化远程接口性能呢?

    上面说到,既然串行调用多个远程接口性能很差,为什么不改成并行呢?

    如下图所示:
    在这里插入图片描述

    调用远程接口总耗时 200ms = 200ms(即耗时最长的那次远程接口调用)

    在java8之前可以通过实现Callable接口,获取线程返回结果。

    java8以后通过CompleteFuture类实现该功能。我们这里以CompleteFuture为例:

    public UserInfo getUserInfo(Long id) throws InterruptedException, ExecutionException {
        final UserInfo userInfo = new UserInfo();
        CompletableFuture userFuture = CompletableFuture.supplyAsync(() -> {
            getRemoteUserAndFill(id, userInfo);
            return Boolean.TRUE;
        }, executor);
    
        CompletableFuture bonusFuture = CompletableFuture.supplyAsync(() -> {
            getRemoteBonusAndFill(id, userInfo);
            return Boolean.TRUE;
        }, executor);
    
        CompletableFuture growthFuture = CompletableFuture.supplyAsync(() -> {
            getRemoteGrowthAndFill(id, userInfo);
            return Boolean.TRUE;
        }, executor);
        CompletableFuture.allOf(userFuture, bonusFuture, growthFuture).join();
    
        userFuture.get();
        bonusFuture.get();
        growthFuture.get();
    
        return userInfo;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    温馨提醒一下,这两种方式别忘了使用线程池。示例中我用到了executor,表示自定义的线程池,为了防止高并发场景下,出现线程过多的问题。

    8.懒加载

    有时候,创建对象是一个非常耗时的操作,特别是在该对象的创建过程中,还需要创建很多其他的对象时。

    我们以单例模式为例。

    在介绍单例模式的时候,必须要先介绍它的两种非常著名的实现方式:饿汉模式懒汉模式

    8.1 饿汉模式

    实例在初始化的时候就已经建好了,不管你有没有用到,先建好了再说。具体代码如下:

    public class SimpleSingleton {
        //持有自己类的引用
        private static final SimpleSingleton INSTANCE = new SimpleSingleton();
    
        //私有的构造方法
        private SimpleSingleton() {
        }
        //对外提供获取实例的静态方法
        public static SimpleSingleton getInstance() {
            return INSTANCE;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12

    使用饿汉模式的好处是:没有线程安全的问题,但带来的坏处也很明显。

    private static final SimpleSingleton INSTANCE = new SimpleSingleton();
    
    • 1

    一开始就实例化对象了,如果实例化过程非常耗时,并且最后这个对象没有被使用,不是白白造成资源浪费吗?

    还真是啊。

    这个时候你也许会想到,不用提前实例化对象,在真正使用的时候再实例化不就可以了?

    这就是我接下来要介绍的:懒汉模式

    8.2 懒汉模式

    顾名思义就是实例在用到的时候才去创建,“比较懒”,用的时候才去检查有没有实例,如果有则返回,没有则新建。具体代码如下:

    public class SimpleSingleton2 {
    
        private static SimpleSingleton2 INSTANCE;
    
        private SimpleSingleton2() {
        }
    
        public static SimpleSingleton2 getInstance() {
            if (INSTANCE == null) {
                INSTANCE = new SimpleSingleton2();
            }
            return INSTANCE;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    示例中的INSTANCE对象一开始是空的,在调用getInstance方法才会真正实例化。

    懒汉模式相对于饿汉模式,没有提前实例化对象,在真正使用的时候再实例化,在实例化对象的阶段效率更高一些。

    除了单例模式之外,懒加载的思想,使用比较多的可能是:

    1. spring的@Lazy注解。在spring容器启动的时候,不会调用其getBean方法初始化实例。
    2. mybatis的懒加载。在mybatis做级联查询的时候,比如查用户的同时需要查角色信息。如果用了懒加载,先只查用户信息,真正使用到角色了,才取查角色信息。

    9.初始化集合时指定大小

    我们在实际项目开发中,需要经常使用集合,比如:ArrayList、HashMap等。

    但有个问题:你在初始化集合时指定了大小的吗?

    反例

    public class Test2 {
    
        public static void main(String[] args) {
            List<Integer> list = new ArrayList<>();
            long time1 = System.currentTimeMillis();
            for (int i = 0; i < 100000; i++) {
                list.add(i);
            }
            System.out.println(System.currentTimeMillis() - time1);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    执行时间:

    12
    
    • 1

    如果在初始化集合时指定了大小。

    正例

    public class Test2 {
    
        public static void main(String[] args) {
            List<Integer> list2 = new ArrayList<>(100000);
            long time2 = System.currentTimeMillis();
            for (int i = 0; i < 100000; i++) {
                list2.add(i);
            }
            System.out.println(System.currentTimeMillis() - time2);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    执行时间:

    6
    
    • 1

    我们惊奇的发现,在创建集合时指定了大小,比没有指定大小,添加10万个元素的效率提升了一倍。

    如果你看过ArrayList源码,你就会发现它的默认大小是10,如果添加元素超过了一定的阀值,会按1.5倍的大小扩容。

    你想想,如果装10万条数据,需要扩容多少次呀?而每次扩容都需要不停的复制元素,从老集合复制到新集合中,需要浪费多少时间呀。

    10.不要满屏try…catch异常

    以前我们在开发接口时,如果出现异常,为了给用户一个更友好的提示,例如:

    @RequestMapping("/test")
    @RestController
    public class TestController {
    
        @GetMapping("/add")
        public String add() {
            int a = 10 / 0;
            return "成功";
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    如果不做任何处理,当我们请求add接口时,执行结果直接报错:

    在这里插入图片描述

    what?用户能直接看到错误信息?

    这种交互方式给用户的体验非常差,为了解决这个问题,我们通常会在接口中捕获异常:

    @GetMapping("/add")
    public String add() {
        String result = "成功";
        try {
            int a = 10 / 0;
        } catch (Exception e) {
            result = "数据异常";
        }
        return result;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    接口改造后,出现异常时会提示:“数据异常”,对用户来说更友好。

    看起来挺不错的,但是有问题。。。

    如果只是一个接口还好,但是如果项目中有成百上千个接口,都要加上异常捕获代码吗?

    答案是否定的,这时全局异常处理就派上用场了:RestControllerAdvice

    @RestControllerAdvice
    public class GlobalExceptionHandler {
    
        @ExceptionHandler(Exception.class)
        public String handleException(Exception e) {
            if (e instanceof ArithmeticException) {
                return "数据异常";
            }
            if (e instanceof Exception) {
                return "服务器内部异常";
            }
            retur nnull;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    只需在handleException方法中处理异常情况,业务接口中可以放心使用,不再需要捕获异常(有人统一处理了)。真是爽歪歪。

    11.位运算效率更高

    如果你读过JDK的源码,比如:ThreadLocalHashMap等类,你就会发现,它们的底层都用了位运算

    为什么开发JDK的大神们,都喜欢用位运算?

    答:因为位运算的效率更高。

    在ThreadLocal的get、set、remove方法中都有这样一行代码:

    int i = key.threadLocalHashCode & (len-1);
    
    • 1

    通过key的hashCode值,数组的长度减1。其中key就是ThreadLocal对象,数组的长度减1,相当于除以数组的长度减1,然后取模

    这是一种hash算法。

    接下来给大家举个例子:假设len=16,key.threadLocalHashCode=31,

    于是: int i = 31 & 15 = 15

    相当于:int i = 31 % 16 = 15

    计算的结果是一样的,但是使用与运算效率跟高一些。

    为什么与运算效率更高?

    答:因为ThreadLocal的初始大小是16,每次都是按2倍扩容,数组的大小其实一直都是2的n次方。

    这种数据有个规律就是高位是0,低位都是1。在做与运算时,可以不用考虑高位,因为与运算的结果必定是0。只需考虑低位的与运算,所以效率更高。

    12.巧用第三方工具类

    在Java的庞大体系中,其实有很多不错的小工具,也就是我们平常说的:轮子

    如果在我们的日常工作当中,能够将这些轮子用户,再配合一下idea的快捷键,可以极大得提升我们的开发效率。

    如果你引入com.google.guava的pom文件,会获得很多好用的小工具。这里推荐一款com.google.common.collect包下的集合工具:Lists

    它是在太好用了,让我爱不释手。

    如果你想将一个大集合分成若干个小集合

    之前我们是这样做的:

    List<Integer> list = Lists.newArrayList(1, 2, 3, 4, 5);
    
    List<List<Integer>> partitionList = Lists.newArrayList();
    int size = 0;
    List<Integer> dataList = Lists.newArrayList();
    for(Integer data : list) {
       if(size >= 2) {
          dataList = Lists.newArrayList();
          size = 0;
       } 
       size++;
       dataList.add(data);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    将list按size=2分成多个小集合,上面的代码看起来比较麻烦。

    如果使用Listspartition方法,可以这样写代码:

    List<Integer> list = Lists.newArrayList(1, 2, 3, 4, 5);
    List<List<Integer>> partitionList = Lists.partition(list, 2);
    System.out.println(partitionList);
    
    • 1
    • 2
    • 3

    执行结果:

    [[1, 2], [3, 4], [5]]
    
    • 1

    这个例子中,list有5条数据,我将list集合按大小为2,分成了3页,即变成3个小集合。

    这个是我最喜欢的方法之一,经常在项目中使用。

    比如有个需求:现在有5000个id,需要调用批量用户查询接口,查出用户数据。但如果你直接查5000个用户,单次接口响应时间可能会非常慢。如果改成分页处理,每次只查500个用户,异步调用10次接口,就不会有单次接口响应慢的问题。

    如果你了解更多非常有用的第三方工具类的话,可以看看我的另一篇文章《吐血推荐17个提升开发效率的“轮子”》。

    13.用同步代码块代替同步方法

    在某些业务场景中,为了防止多个线程并发修改某个共享数据,造成数据异常。

    为了解决并发场景下,多个线程同时修改数据,造成数据不一致的情况。通常情况下,我们会:加锁

    但如果锁加得不好,导致锁的粒度太粗,也会非常影响接口性能。

    在java中提供了synchronized关键字给我们的代码加锁。

    通常有两种写法:在方法上加锁在代码块上加锁

    先看看如何在方法上加锁:

    public synchronized doSave(String fileUrl) {
        mkdir();
        uploadFile(fileUrl);
        sendMessage(fileUrl);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    这里加锁的目的是为了防止并发的情况下,创建了相同的目录,第二次会创建失败,影响业务功能。

    但这种直接在方法上加锁,锁的粒度有点粗。因为doSave方法中的上传文件和发消息方法,是不需要加锁的。只有创建目录方法,才需要加锁。

    我们都知道文件上传操作是非常耗时的,如果将整个方法加锁,那么需要等到整个方法执行完之后才能释放锁。显然,这会导致该方法的性能很差,变得得不偿失。

    这时,我们可以改成在代码块上加锁了,具体代码如下:

    public void doSave(String path,String fileUrl) {
        synchronized(this) {
          if(!exists(path)) {
              mkdir(path);
           }
        }
        uploadFile(fileUrl);
        sendMessage(fileUrl);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    这样改造之后,锁的粒度一下子变小了,只有并发创建目录功能才加了锁。而创建目录是一个非常快的操作,即使加锁对接口的性能影响也不大。

    最重要的是,其他的上传文件和发送消息功能,任然可以并发执行。

    14.不用的数据及时清理

    在Java中保证线程安全的技术有很多,可以使用synchroizedLock等关键字给代码块加锁

    但是它们有个共同的特点,就是加锁会对代码的性能有一定的损耗。

    其实,在jdk中还提供了另外一种思想即:用空间换时间

    没错,使用ThreadLocal类就是对这种思想的一种具体体现。

    ThreadLocal为每个使用变量的线程提供了一个独立的变量副本,这样每一个线程都能独立地改变自己的副本,而不会影响其它线程所对应的副本。

    ThreadLocal的用法大致是这样的:

    1. 先创建一个CurrentUser类,其中包含了ThreadLocal的逻辑。
    public class CurrentUser {
        private static final ThreadLocal<UserInfo> THREA_LOCAL = new ThreadLocal();
        
        public static void set(UserInfo userInfo) {
            THREA_LOCAL.set(userInfo);
        }
        
        public static UserInfo get() {
           THREA_LOCAL.get();
        }
        
        public static void remove() {
           THREA_LOCAL.remove();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    1. 在业务代码中调用CurrentUser类。
    public void doSamething(UserDto userDto) {
       UserInfo userInfo = convert(userDto);
       CurrentUser.set(userInfo);
       ...
    
       //业务代码
       UserInfo userInfo = CurrentUser.get();
       ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    在业务代码的第一行,将userInfo对象设置到CurrentUser,这样在业务代码中,就能通过CurrentUser.get()获取到刚刚设置的userInfo对象。特别是对业务代码调用层级比较深的情况,这种用法非常有用,可以减少很多不必要传参。

    但在高并发的场景下,这段代码有问题,只往ThreadLocal存数据,数据用完之后并没有及时清理。

    ThreadLocal即使使用了WeakReference(弱引用)也可能会存在内存泄露问题,因为 entry对象中只把key(即threadLocal对象)设置成了弱引用,但是value值没有。

    那么,如何解决这个问题呢?

    public void doSamething(UserDto userDto) {
       UserInfo userInfo = convert(userDto);
       
       try{
         CurrentUser.set(userInfo);
         ...
         
         //业务代码
         UserInfo userInfo = CurrentUser.get();
         ...
       } finally {
          CurrentUser.remove();
       }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    需要在finally代码块中,调用remove方法清理没用的数据。

    15.用equals方法比较是否相等

    不知道你在项目中有没有见过,有些同事对Integer类型的两个参数使用==号比较是否相等?

    反正我见过的,那么这种用法对吗?

    我的回答是看具体场景,不能说一定对,或不对。

    有些状态字段,比如:orderStatus有:-1(未下单),0(已下单),1(已支付),2(已完成),3(取消),5种状态。

    这时如果用==判断是否相等:

    Integer orderStatus1 = new Integer(1);
    Integer orderStatus2 = new Integer(1);
    System.out.println(orderStatus1 == orderStatus2);
    
    • 1
    • 2
    • 3

    返回结果会是true吗?

    答案:是false。

    有些同学可能会反驳,Integer中不是有范围是:-128-127的缓存吗?

    为什么是false?

    先看看Integer的构造方法:

    在这里插入图片描述

    它其实并没有用到缓存

    那么缓存是在哪里用的?

    答案在valueOf方法中:

    在这里插入图片描述

    如果上面的判断改成这样:

    String orderStatus1 = new String("1");
    String orderStatus2 = new String("1");
    System.out.println(Integer.valueOf(orderStatus1) == Integer.valueOf(orderStatus2));
    
    • 1
    • 2
    • 3

    返回结果会是true吗?

    答案:还真是true。

    我们要养成良好编码习惯,尽量少用==判断两个Integer类型数据是否相等,只有在上述非常特殊的场景下才相等。

    而应该改成使用equals方法判断:

    Integer orderStatus1 = new Integer(1);
    Integer orderStatus2 = new Integer(1);
    System.out.println(orderStatus1.equals(orderStatus2));
    
    • 1
    • 2
    • 3

    运行结果为true。

    16.避免创建大集合

    很多时候,我们在日常开发中,需要创建集合。比如:为了性能考虑,从数据库查询某张表的所有数据,一次性加载到内存的某个集合中,然后做业务逻辑处理。

    例如:

    List<User> userList = userMapper.getAllUser();
    for(User user:userList) {
       doSamething();
    }
    
    • 1
    • 2
    • 3
    • 4

    从数据库一次性查询出所有用户,然后在循环中,对每个用户进行业务逻辑处理。

    如果用户表的数据量非常多时,这样userList集合会很大,可能直接导致内存不足,而使整个应用挂掉。

    针对这种情况,必须做分页处理

    例如:

    private static final int PAGE_SIZE = 500;
    
    int currentPage = 1;
    RequestPage page = new RequestPage();
    page.setPageNo(currentPage);
    page.setPageSize(PAGE_SIZE);
    
    Page<User> pageUser = userMapper.search(page);
    
    while(pageUser.getPageCount() >= currentPage) {
        for(User user:pageUser.getData()) {
           doSamething();
        }
       page.setPageNo(++currentPage);
       pageUser = userMapper.search(page);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    通过上面的分页改造之后,每次从数据库中只查询500条记录,保存到userList集合中,这样userList不会占用太多的内存。

    这里特别说明一下,如果你查询的表中的数据量本来就很少,一次性保存到内存中,也不会占用太多内存,这种情况也可以不做分页处理。

    此外,还有中特殊的情况,即表中的记录数并算不多,但每一条记录,都有很多字段,单条记录就占用很多内存空间,这时也需要做分页处理,不然也会有问题。

    整体的原则是要尽量避免创建大集合,导致内存不足的问题,但是具体多大才算大集合。目前没有一个唯一的衡量标准,需要结合实际的业务场景进行单独分析。

    17.状态用枚举

    在我们建的表中,有很多状态字段,比如:订单状态、禁用状态、删除状态等。

    每种状态都有多个值,代表不同的含义。

    比如订单状态有:

    • 1:表示下单
    • 2:表示支付
    • 3:表示完成
    • 4:表示撤销

    如果没有使用枚举,一般是这样做的:

    public static final int ORDER_STATUS_CREATE = 1;
    public static final int ORDER_STATUS_PAY = 2;
    public static final int ORDER_STATUS_DONE = 3;
    public static final int ORDER_STATUS_CANCEL = 4;
    public static final String ORDER_STATUS_CREATE_MESSAGE = "下单";
    public static final String ORDER_STATUS_PAY = "下单";
    public static final String ORDER_STATUS_DONE = "下单";
    public static final String ORDER_STATUS_CANCEL = "下单";
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    需要定义很多静态常量,包含不同的状态和状态的描述。

    使用枚举定义之后,代码如下:

    public enum OrderStatusEnum {  
         CREATE(1, "下单"),  
         PAY(2, "支付"),  
         DONE(3, "完成"),  
         CANCEL(4, "撤销");  
    
         private int code;  
         private String message;  
    
         OrderStatusEnum(int code, String message) {  
             this.code = code;  
             this.message = message;  
         }  
       
         public int getCode() {  
            return this.code;  
         }  
    
         public String getMessage() {  
            return this.message;  
         }  
      
         public static OrderStatusEnum getOrderStatusEnum(int code) {  
            return Arrays.stream(OrderStatusEnum.values()).filter(x -> x.code == code).findFirst().orElse(null);  
         }  
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26

    使用枚举改造之后,职责更单一了。

    而且使用枚举的好处是:

    1. 代码的可读性变强了,不同的状态,有不同的枚举进行统一管理和维护。
    2. 枚举是天然单例的,可以直接使用==号进行比较。
    3. code和message可以成对出现,比较容易相关转换。
    4. 枚举可以消除if…else过多问题。

    18.把固定值定义成静态常量

    不知道你在实际的项目开发中,有没有使用过固定值?

    例如:

    if(user.getId() < 1000L) {
       doSamething();
    }
    
    • 1
    • 2
    • 3

    或者:

    if(Objects.isNull(user)) {
       throw new BusinessException("该用户不存在");
    }
    
    • 1
    • 2
    • 3

    其中1000L该用户不存在是固定值,每次都是一样的。

    既然是固定值,我们为什么不把它们定义成静态常量呢?

    这样语义上更直观,方便统一管理和维护,更方便代码复用。

    代码优化为:

    private static final int DEFAULT_USER_ID = 1000L;
    ...
    if(user.getId() < DEFAULT_USER_ID) {
       doSamething();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    或者:

    private static final String NOT_FOUND_MESSAGE = "该用户不存在";
    ...
    if(Objects.isNull(user)) {
       throw new BusinessException(NOT_FOUND_MESSAGE);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    使用static final关键字修饰静态常量,static表示静态的意思,即类变量,而final表示不允许修改

    两个关键字加在一起,告诉Java虚拟机这种变量,在内存中只有一份,在全局上是唯一的,不能修改,也就是静态常量

    19.避免大事务

    很多小伙伴在使用spring框架开发项目时,为了方便,喜欢使用@Transactional注解提供事务功能。

    没错,使用@Transactional注解这种声明式事务的方式提供事务功能,确实能少写很多代码,提升开发效率。

    但也容易造成大事务,引发其他的问题。

    下面用一张图看看大事务引发的问题。

    在这里插入图片描述

    从图中能够看出,大事务问题可能会造成接口超时,对接口的性能有直接的影响。

    我们该如何优化大事务呢?

    1. 少用@Transactional注解
    2. 将查询(select)方法放到事务外
    3. 事务中避免远程调用
    4. 事务中避免一次性处理太多数据
    5. 有些功能可以非事务执行
    6. 有些功能可以异步处理

    20.消除过长的if…else

    我们在写代码的时候,if…else的判断条件是必不可少的。不同的判断条件,走的代码逻辑通常会不一样。

    废话不多说,先看看下面的代码。

    public interface IPay {  
        void pay();  
    }  
    
    @Service
    public class AliaPay implements IPay {  
         @Override
         public void pay() {  
            System.out.println("===发起支付宝支付===");  
         }  
    }  
    
    @Service
    public class WeixinPay implements IPay {  
         @Override
         public void pay() {  
             System.out.println("===发起微信支付===");  
         }  
    }  
      
    @Service
    public class JingDongPay implements IPay {  
         @Override
         public void pay() {  
            System.out.println("===发起京东支付==="); 
         }  
    }  
    
    @Service
    public class PayService {  
         @Autowired
         private AliaPay aliaPay;  
         @Autowired
         private WeixinPay weixinPay;  
         @Autowired
         private JingDongPay jingDongPay;  
       
         public void toPay(String code) {  
             if ("alia".equals(code)) {  
                 aliaPay.pay();  
             } elseif ("weixin".equals(code)) {  
                  weixinPay.pay();  
             } elseif ("jingdong".equals(code)) {  
                  jingDongPay.pay();  
             } else {  
                  System.out.println("找不到支付方式");  
             }  
         }  
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49

    PayService类的toPay方法主要是为了发起支付,根据不同的code,决定调用用不同的支付类(比如:aliaPay)的pay方法进行支付。

    这段代码有什么问题呢?也许有些人就是这么干的。

    试想一下,如果支付方式越来越多,比如:又加了百度支付、美团支付、银联支付等等,就需要改toPay方法的代码,增加新的else…if判断,判断多了就会导致逻辑越来越多?

    很明显,这里违法了设计模式六大原则的:开闭原则 和 单一职责原则。

    开闭原则:对扩展开放,对修改关闭。就是说增加新功能要尽量少改动已有代码。

    单一职责原则:顾名思义,要求逻辑尽量单一,不要太复杂,便于复用。

    那么,如何优化if…else判断呢?

    答:使用 策略模式+工厂模式

    策略模式定义了一组算法,把它们一个个封装起来, 并且使它们可相互替换。
    工厂模式用于封装和管理对象的创建,是一种创建型模式。

    public interface IPay {
        void pay();
    }
    
    @Service
    public class AliaPay implements IPay {
    
        @PostConstruct
        public void init() {
            PayStrategyFactory.register("aliaPay", this);
        }
    
        @Override
        public void pay() {
            System.out.println("===发起支付宝支付===");
        }
    }
    
    @Service
    public class WeixinPay implements IPay {
    
        @PostConstruct
        public void init() {
            PayStrategyFactory.register("weixinPay", this);
        }
    
        @Override
        public void pay() {
            System.out.println("===发起微信支付===");
        }
    }
    
    @Service
    public class JingDongPay implements IPay {
    
        @PostConstruct
        public void init() {
            PayStrategyFactory.register("jingDongPay", this);
        }
    
        @Override
        public void pay() {
            System.out.println("===发起京东支付===");
        }
    }
    
    public class PayStrategyFactory {
    
        private static Map<String, IPay> PAY_REGISTERS = new HashMap<>();
    
        public static void register(String code, IPay iPay) {
            if (null != code && !"".equals(code)) {
                PAY_REGISTERS.put(code, iPay);
            }
        }
    
        public static IPay get(String code) {
            return PAY_REGISTERS.get(code);
        }
    }
    
    @Service
    public class PayService3 {
    
        public void toPay(String code) {
            PayStrategyFactory.get(code).pay();
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67
    • 68

    这段代码的关键是PayStrategyFactory类,它是一个策略工厂,里面定义了一个全局的map,在所有IPay的实现类中注册当前实例到map中,然后在调用的地方通过PayStrategyFactory类根据code从map获取支付类实例即可。

    如果加了一个新的支付方式,只需新加一个类实现IPay接口,定义init方法,并且重写pay方法即可,其他代码基本上可以不用动。

    当然,消除又臭又长的if…else判断,还有很多方法,比如:使用注解、动态拼接类名称、模板方法、枚举等等。由于篇幅有限,在这里我就不过多介绍了,更详细的内容可以看看我的另一篇文章《消除if…else是9条锦囊妙计

    21.防止死循环

    有些小伙伴看到这个标题,可能会感到有点意外,代码中不是应该避免死循环吗?为啥还是会产生死循环?

    殊不知有些死循环是我们自己写的,例如下面这段代码:

    while(true) {
        if(condition) {
            break;
        }
        System.out.println("do samething");
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    这里使用了while(true)的循环调用,这种写法在CAS自旋锁中使用比较多。

    当满足condition等于true的时候,则自动退出该循环。

    如果condition条件非常复杂,一旦出现判断不正确,或者少写了一些逻辑判断,就可能在某些场景下出现死循环的问题。

    出现死循环,大概率是开发人员人为的bug导致的,不过这种情况很容易被测出来。

    还有一种隐藏的比较深的死循环,是由于代码写的不太严谨导致的。如果用正常数据,可能测不出问题,但一旦出现异常数据,就会立即出现死循环。

    其实,还有另一种死循环:无限递归

    如果想要打印某个分类的所有父分类,可以用类似这样的递归方法实现:

    public void printCategory(Category category) {
      if(category == null 
          || category.getParentId() == null) {
         return;
      } 
      System.out.println("父分类名称:"+ category.getName());
      Category parent = categoryMapper.getCategoryById(category.getParentId());
      printCategory(parent);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    正常情况下,这段代码是没有问题的。

    但如果某次有人误操作,把某个分类的parentId指向了它自己,这样就会出现无限递归的情况。导致接口一直不能返回数据,最终会发生堆栈溢出。

    建议写递归方法时,设定一个递归的深度,比如:分类最大等级有4级,则深度可以设置为4。然后在递归方法中做判断,如果深度大于4时,则自动返回,这样就能避免无限循环的情况。

    22.注意BigDecimal的坑

    通常我们会把一些小数类型的字段(比如:金额),定义成BigDecimal,而不是Double,避免丢失精度问题。

    使用Double时可能会有这种场景:

    double amount1 = 0.02;
    double amount2 = 0.03;
    System.out.println(amount2 - amount1);
    
    • 1
    • 2
    • 3

    正常情况下预计amount2 - amount1应该等于0.01

    但是执行结果,却为:

    0.009999999999999998
    
    • 1

    实际结果小于预计结果。

    Double类型的两个参数相减会转换成二进制,因为Double有效位数为16位这就会出现存储小数位数不够的情况,这种情况下就会出现误差。

    常识告诉我们使用BigDecimal能避免丢失精度。

    但是使用BigDecimal能避免丢失精度吗?

    答案是否定的。

    为什么?

    BigDecimal amount1 = new BigDecimal(0.02);
    BigDecimal amount2 = new BigDecimal(0.03);
    System.out.println(amount2.subtract(amount1));
    
    • 1
    • 2
    • 3

    这个例子中定义了两个BigDecimal类型参数,使用构造函数初始化数据,然后打印两个参数相减后的值。

    结果:

    0.0099999999999999984734433411404097569175064563751220703125
    
    • 1

    不科学呀,为啥还是丢失精度了?

    JdkBigDecimal构造方法上有这样一段描述:

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-2GZwP8G9-1659601147001)(https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/66bdb6a7e9b9466d9931135b34710041~tplv-k3u1fbpfcp-zoom-1.image)]

    大致的意思是此构造函数的结果可能不可预测,可能会出现创建时为0.1,但实际是0.1000000000000000055511151231257827021181583404541015625的情况。

    由此可见,使用BigDecimal构造函数初始化对象,也会丢失精度。

    那么,如何才能不丢失精度呢?

    BigDecimal amount1 = new BigDecimal(Double.toString(0.02));
    BigDecimal amount2 = new BigDecimal(Double.toString(0.03));
    System.out.println(amount2.subtract(amount1));
    
    • 1
    • 2
    • 3

    我们可以使用Double.toString方法,对double类型的小数进行转换,这样能保证精度不丢失。

    其实,还有更好的办法:

    BigDecimal amount1 = BigDecimal.valueOf(0.02);
    BigDecimal amount2 = BigDecimal.valueOf(0.03);
    System.out.println(amount2.subtract(amount1));
    
    • 1
    • 2
    • 3

    使用BigDecimal.valueOf方法初始化BigDecimal类型参数,也能保证精度不丢失。在新版的阿里巴巴开发手册中,也推荐使用这种方式创建BigDecimal参数。

    23.尽可能复用代码

    ctrl + cctrl + v可能是程序员使用最多的快捷键了。

    没错,我们是大自然的搬运工。哈哈哈。

    在项目初期,我们使用这种工作模式,确实可以提高一些工作效率,可以少写(实际上是少敲)很多代码。

    但它带来的问题是:会出现大量的代码重复。例如:

    @Service
    @Slf4j
    public class TestService1 {
    
        public void test1()  {
            addLog("test1");
        }
    
        private void addLog(String info) {
            if (log.isInfoEnabled()) {
                log.info("info:{}", info);
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    @Service
    @Slf4j
    public class TestService2 {
    
        public void test2()  {
            addLog("test2");
        }
    
        private void addLog(String info) {
            if (log.isInfoEnabled()) {
                log.info("info:{}", info);
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    @Service
    @Slf4j
    public class TestService3 {
    
        public void test3()  {
            addLog("test3");
        }
    
        private void addLog(String info) {
            if (log.isInfoEnabled()) {
                log.info("info:{}", info);
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    在TestService1、TestService2、TestService3类中,都有一个addLog方法用于添加日志。

    本来该功能用得好好的,直到有一天,线上出现了一个事故:服务器磁盘满了。

    原因是打印的日志太多,记了很多没必要的日志,比如:查询接口的所有返回值,大对象的具体打印等。

    没办法,只能将addLog方法改成只记录debug日志。

    于是乎,你需要全文搜索,addLog方法去修改,改成如下代码:

    private void addLog(String info) {
        if (log.isDebugEnabled()) {
            log.debug("debug:{}", info);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    这里是有三个类中需要修改这段代码,但如果实际工作中有三十个、三百个类需要修改,会让你非常痛苦。改错了,或者改漏了,都会埋下隐患,把自己坑了。

    为何不把这种功能的代码提取出来,放到某个工具类中呢?

    @Slf4j
    public class LogUtil {
    
        private LogUtil() {
            throw new RuntimeException("初始化失败");
        }
    
        public static void addLog(String info) {
            if (log.isDebugEnabled()) {
                log.debug("debug:{}", info);
            }
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    然后,在其他的地方,只需要调用。

    @Service
    @Slf4j
    public class TestService1 {
    
        public void test1()  {
            LogUtil.addLog("test1");
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    如果哪天addLog的逻辑又要改了,只需要修改LogUtil类的addLog方法即可。你可以自信满满的修改,不需要再小心翼翼了。

    我们写的代码,绝大多数是可维护性的代码,而非一次性的。所以,建议在写代码的过程中,如果出现重复的代码,尽量提取成公共方法。千万别因为项目初期一时的爽快,而给项目埋下隐患,后面的维护成本可能会非常高。

    24.foreach循环中不remove元素

    我们知道在Java中,循环有很多种写法,比如:while、for、foreach等。

    public class Test2 {
        public static void main(String[] args) {
            List<String> list = Lists.newArrayList("a","b","c");
            for (String temp : list) {
                if ("c".equals(temp)) {
                    list.remove(temp);
                }
            }
            System.out.println(list);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11

    执行结果:

    Exception in thread "main" java.util.ConcurrentModificationException
    	at java.util.ArrayList$Itr.checkForComodification(ArrayList.java:901)
    	at java.util.ArrayList$Itr.next(ArrayList.java:851)
    	at com.sue.jump.service.test1.Test2.main(Test2.java:24)
    
    • 1
    • 2
    • 3
    • 4

    这种在foreach循环中调用remove方法删除元素,可能会报ConcurrentModificationException异常。

    如果想在遍历集合时,删除其中的元素,可以用for循环,例如:

    public class Test2 {
    
        public static void main(String[] args) {
            List<String> list = Lists.newArrayList("a","b","c");
            for (int i = 0; i < list.size(); i++) {
                String temp = list.get(i);
                if ("c".equals(temp)) {
                    list.remove(temp);
                }
            }
            System.out.println(list);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    执行结果:

    [a, b]
    
    • 1

    25.避免随意打印日志

    在我们写代码的时候,打印日志是必不可少的工作之一。

    因为日志可以帮我们快速定位问题,判断代码当时真正的执行逻辑。

    但打印日志的时候也需要注意,不是说任何时候都要打印日志,比如:

    @PostMapping("/query")
    public List<User> query(@RequestBody List<Long> ids) {
        log.info("request params:{}", ids);
        List<User> userList = userService.query(ids);
        log.info("response:{}", userList);
        return userList;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    对于有些查询接口,在日志中打印出了请求参数和接口返回值。

    咋一看没啥问题。

    但如果ids中传入值非常多,比如有1000个。而该接口被调用的频次又很高,一下子就会打印大量的日志,用不了多久就可能把磁盘空间打满。

    如果真的想打印这些日志该怎么办?

    @PostMapping("/query")
    public List<User> query(@RequestBody List<Long> ids) {
        if (log.isDebugEnabled()) {
            log.debug("request params:{}", ids);
        }
    
        List<User> userList = userService.query(ids);
    
        if (log.isDebugEnabled()) {
            log.debug("response:{}", userList);
        }
        return userList;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    使用isDebugEnabled判断一下,如果当前的日志级别是debug才打印日志。生产环境默认日志级别是info,在有些紧急情况下,把某个接口或者方法的日志级别改成debug,打印完我们需要的日志后,又调整回去。

    方便我们定位问题,又不会产生大量的垃圾日志,一举两得。

    26.比较时把常量写前面

    在比较两个参数值是否相等时,通常我们会使用==号,或者equals方法。

    我在第15章节中说过,使用==号比较两个值是否相等时,可能会存在问题,建议使用equals方法做比较。

    反例

    if(user.getName().equals("苏三")) {
       System.out.println("找到:"+user.getName());
    }
    
    • 1
    • 2
    • 3

    在上面这段代码中,如果user对象,或者user.getName()方法返回值为null,则都报NullPointerException异常。

    那么,如何避免空指针异常呢?

    正例

    private static final String FOUND_NAME = "苏三";
    ...
    
    if(null == user) {
      return;
    }
    if(FOUND_NAME.equals(user.getName())) {
       System.out.println("找到:"+user.getName());
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    在使用equals做比较时,尽量将常量写在前面,即equals方法的左边。

    这样即使user.getName()返回的数据为null,equals方法会直接返回false,而不再是报空指针异常。

    27.名称要见名知意

    java中没有强制规定参数、方法、类或者包名该怎么起名。但如果我们没有养成良好的起名习惯,随意起名的话,可能会出现很多奇怪的代码。

    27.1 有意义的参数名

    有时候,我们写代码时为了省事(可以少敲几个字母),参数名起得越简单越好。假如同事A写的代码如下:

    int a = 1;
    int b = 2;
    String c = "abc";
    boolean b = false;
    
    • 1
    • 2
    • 3
    • 4

    一段时间之后,同事A离职了,同事B接手了这段代码。

    他此时一脸懵逼,a是什么意思,b又是什么意思,还有c…然后心里一万个草泥马。

    给参数起一个有意义的名字,是非常重要的事情,避免给自己或者别人埋坑。

    正解:

    int supplierCount = 1;
    int purchaserCount = 2;
    String userName = "abc";
    boolean hasSuccess = false;
    
    • 1
    • 2
    • 3
    • 4

    27.2 见名知意

    光起有意义的参数名还不够,我们不能就这点追求。我们起的参数名称最好能够见名知意,不然就会出现这样的情况:

    String yongHuMing = "苏三";
    String 用户Name = "苏三";
    String su3 = "苏三";
    String suThree = "苏三";
    
    • 1
    • 2
    • 3
    • 4

    这几种参数名看起来是不是有点怪怪的?

    为啥不定义成国际上通用的(地球人都能看懂)英文单词呢?

    String userName = "苏三";
    String susan = "苏三";
    
    • 1
    • 2

    上面的这两个参数名,基本上大家都能看懂,减少了好多沟通成本。

    所以建议在定义不管是参数名、方法名、类名时,优先使用国际上通用的英文单词,更简单直观,减少沟通成本。少用汉子、拼音,或者数字定义名称。

    27.3 参数名风格一致

    参数名其实有多种风格,列如:

    //字母全小写
    int suppliercount = 1;
    
    //字母全大写
    int SUPPLIERCOUNT = 1;
    
    //小写字母 + 下划线
    int supplier_count = 1;
    
    //大写字母 + 下划线
    int SUPPLIER_COUNT = 1;
    
    //驼峰标识
    int supplierCount = 1;
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    如果某个类中定义了多种风格的参数名称,看起来是不是有点杂乱无章?

    所以建议类的成员变量、局部变量和方法参数使用supplierCount,这种驼峰风格,即:第一个字母小写,后面的每个单词首字母大写。例如:

    int supplierCount = 1;
    
    • 1

    此外,为了好做区分,静态常量建议使用SUPPLIER_COUNT,即:大写字母 + 下划线分隔的参数名。例如:

    private static final int SUPPLIER_COUNT = 1;
    
    • 1

    28.SimpleDateFormat线程不安全

    在java8之前,我们对时间的格式化处理,一般都是用的SimpleDateFormat类实现的。例如:

    @Service
    public class SimpleDateFormatService {
    
        public Date time(String time) throws ParseException {
            SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
            return dateFormat.parse(time);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    如果你真的这样写,是没问题的。

    就怕哪天抽风,你觉得dateFormat是一段固定的代码,应该要把它抽取成常量。

    于是把代码改成下面的这样:

    @Service
    public class SimpleDateFormatService {
    
       private static SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
    
        public Date time(String time) throws ParseException {
            return dateFormat.parse(time);
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    dateFormat对象被定义成了静态常量,这样就能被所有对象共用。

    如果只有一个线程调用time方法,也不会出现问题。

    但Serivce类的方法,往往是被Controller类调用的,而Controller类的接口方法,则会被tomcat线程池调用。换句话说,可能会出现多个线程调用同一个Controller类的同一个方法,也就是会出现多个线程会同时调用time方法。

    而time方法会调用SimpleDateFormat类的parse方法:

    @Override
    public Date parse(String text, ParsePosition pos) {
        ...
        Date parsedDate;
        try {
            parsedDate = calb.establish(calendar).getTime();
            ...
        } catch (IllegalArgumentException e) {
            pos.errorIndex = start;
            pos.index = oldStart;
            return null;
        }
       return parsedDate;
    } 
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    该方法会调用establish方法:

    Calendar establish(Calendar cal) {
        ...
        //1.清空数据
        cal.clear();
        //2.设置时间
        cal.set(...);
        //3.返回
        return cal;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    其中的步骤1、2、3是非原子操作。

    但如果cal对象是局部变量还好,坏就坏在parse方法调用establish方法时,传入的calendar是SimpleDateFormat类的父类DateFormat的成员变量:

    public abstract class DateFormat extends Forma {
        ....
        protected Calendar calendar;
        ...
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    这样就可能会出现多个线程,同时修改同一个对象即:dateFormat,它的同一个成员变量即:Calendar值的情况。

    这样可能会出现,某个线程设置好了时间,又被其他的线程修改了,从而出现时间错误的情况。

    那么,如何解决这个问题呢?

    1. SimpleDateFormat类的对象不要定义成静态的,可以改成方法的局部变量。
    2. 使用ThreadLocal保存SimpleDateFormat类的数据。
    3. 使用java8的DateTimeFormatter类。

    29.少用Executors创建线程池

    我们都知道JDK5之后,提供了ThreadPoolExecutor类,用它可以自定义线程池

    线程池的好处有很多,下面主要说说这3个方面。

    1. 降低资源消耗:避免了频繁的创建线程和销毁线程,可以直接复用已有线程。而我们都知道,创建线程是非常耗时的操作。
    2. 提供速度:任务过来之后,因为线程已存在,可以拿来直接使用。
    3. 提高线程的可管理性:线程是非常宝贵的资源,如果创建过多的线程,不仅会消耗系统资源,甚至会影响系统的稳定。使用线程池,可以非常方便的创建、管理和监控线程。

    当然JDK为了我们使用更便捷,专门提供了:Executors类,给我们快速创建线程池

    该类中包含了很多静态方法

    • newCachedThreadPool:创建一个可缓冲的线程,如果线程池大小超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程。
    • newFixedThreadPool:创建一个固定大小的线程池,如果任务数量超过线程池大小,则将多余的任务放到队列中。
    • newScheduledThreadPool:创建一个固定大小,并且能执行定时周期任务的线程池。
    • newSingleThreadExecutor:创建只有一个线程的线程池,保证所有的任务安装顺序执行。

    在高并发的场景下,如果大家使用这些静态方法创建线程池,会有一些问题。

    那么,我们一起看看有哪些问题?

    • newFixedThreadPool: 允许请求的队列长度是Integer.MAX_VALUE,可能会堆积大量的请求,从而导致OOM。
    • newSingleThreadExecutor:允许请求的队列长度是Integer.MAX_VALUE,可能会堆积大量的请求,从而导致OOM。
    • newCachedThreadPool:允许创建的线程数是Integer.MAX_VALUE,可能会创建大量的线程,从而导致OOM。

    那我们该怎办呢?

    优先推荐使用ThreadPoolExecutor类,我们自定义线程池。

    具体代码如下:

    ExecutorService threadPool = new ThreadPoolExecutor(
        8, //corePoolSize线程池中核心线程数
        10, //maximumPoolSize 线程池中最大线程数
        60, //线程池中线程的最大空闲时间,超过这个时间空闲线程将被回收
        TimeUnit.SECONDS,//时间单位
        new ArrayBlockingQueue(500), //队列
        new ThreadPoolExecutor.CallerRunsPolicy()); //拒绝策略
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    顺便说一下,如果是一些低并发场景,使用Executors类创建线程池也未尝不可,也不能完全一棍子打死。在这些低并发场景下,很难出现OOM问题,所以我们需要根据实际业务场景选择。

    30.Arrays.asList转换的集合别修改

    在我们日常工作中,经常需要把数组转换成List集合。

    因为数组的长度是固定的,不太好扩容,而List的长度是可变的,它的长度会根据元素的数量动态扩容。

    在JDK的Arrays类中提供了asList方法,可以把数组转换成List

    正例

    String [] array = new String [] {"a","b","c"};
    List<String> list = Arrays.asList(array);
    for (String str : list) {
        System.out.println(str);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    在这个例子中,使用Arrays.asList方法将array数组,直接转换成了list。然后在for循环中遍历list,打印出它里面的元素。

    如果转换后的list,只是使用,没新增或修改元素,不会有问题。

    反例

    String[] array = new String[]{"a", "b", "c"};
    List<String> list = Arrays.asList(array);
    list.add("d");
    for (String str : list) {
        System.out.println(str);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    执行结果:

    Exception in thread "main" java.lang.UnsupportedOperationException
    at java.util.AbstractList.add(AbstractList.java:148)
    at java.util.AbstractList.add(AbstractList.java:108)
    at com.sue.jump.service.test1.Test2.main(Test2.java:24)
    
    • 1
    • 2
    • 3
    • 4

    会直接报UnsupportedOperationException异常。

    为什么呢?

    答:使用Arrays.asList方法转换后的ArrayList,是Arrays类的内部类,并非java.util包下我们常用的ArrayList

    Arrays类的内部ArrayList类,它没有实现父类的add和remove方法,用的是父类AbstractList的默认实现。

    我们看看AbstractList是如何实现的:

    public void add(int index, E element) {
       throw new UnsupportedOperationException();
    }
    
    public E remove(int index) {
       throw new UnsupportedOperationException();
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    该类的addremove方法直接抛异常了,因此调用Arrays类的内部ArrayList类的add和remove方法,同样会抛异常。

    说实话,Java代码优化是一个比较大的话题,它里面可以优化的点非常多,我没办法一一列举完。在这里只能抛砖引玉,介绍一下比较常见的知识点,更全面的内容,需要小伙伴们自己去思考和探索。

    🟡第二章: 接口性能优化

    接口性能优化对于从事后端开发的同学来说,肯定再熟悉不过了,因为它是一个跟开发语言无关的公共问题。

    该问题说简单也简单,说复杂也复杂。

    有时候,只需加个索引就能解决问题。

    有时候,需要做代码重构。

    有时候,需要增加缓存。

    有时候,需要引入一些中间件,比如mq。

    有时候,需要需要分库分表。

    有时候,需要拆分服务。

    等;

    导致接口性能问题的原因千奇百怪,不同的项目不同的接口,原因可能也不一样。

    本文我总结了一些行之有效的,优化接口性能的办法,给有需要的朋友一个参考。

    1.索引

    接口性能优化大家第一个想到的可能是:优化索引

    没错,优化索引的成本是最小的。

    你通过查看线上日志或者监控报告,查到某个接口用到的某条sql语句耗时比较长。

    这时你可能会有下面这些疑问:

    1. 该sql语句加索引了没?
    2. 加的索引生效了没?
    3. mysql选错索引了没?

    最近无意间获得一份BAT大厂大佬写的刷题笔记,一下子打通了我的任督二脉,越来越觉得算法没有想象中那么难了。

    1.1 没加索引

    sql语句中where条件的关键字段,或者order by后面的排序字段,忘了加索引,这个问题在项目中很常见。

    项目刚开始的时候,由于表中的数据量小,加不加索引sql查询性能差别不大。

    后来,随着业务的发展,表中数据量越来越多,就不得不加索引了。

    可以通过命令:

    show index from `order`;
    
    • 1

    能单独查看某张表的索引情况。

    也可以通过命令:

    show create table `order`;
    
    • 1

    查看整张表的建表语句,里面同样会显示索引情况。

    通过ALTER TABLE命令可以添加索引:

    ALTER TABLE `order` ADD INDEX idx_name (name);
    
    • 1

    也可以通过CREATE INDEX命令添加索引:

    CREATE INDEX idx_name ON `order` (name);
    
    • 1

    不过这里有一个需要注意的地方是:想通过命令修改索引,是不行的。

    目前在mysql中如果想要修改索引,只能先删除索引,再重新添加新的。

    删除索引可以用DROP INDEX命令:

    ALTER TABLE `order` DROP INDEX idx_name;
    
    • 1

    DROP INDEX命令也行:

    DROP INDEX idx_name ON `order`;
    
    • 1

    1.2 索引没生效

    通过上面的命令我们已经能够确认索引是有的,但它生效了没?此时你内心或许会冒出这样一个疑问。

    那么,如何查看索引有没有生效呢?

    答:可以使用explain命令,查看mysql的执行计划,它会显示索引的使用情况。

    例如:

    explain select * from `order` where code='002';
    
    • 1

    结果:
    在这里插入图片描述

    通过这几列可以判断索引使用情况,执行计划包含列的含义如下图所示:
    在这里插入图片描述

    说实话,sql语句没有走索引,排除没有建索引之外,最大的可能性是索引失效了。

    下面说说索引失效的常见原因:
    在这里插入图片描述

    如果不是上面的这些原因,则需要再进一步排查一下其他原因。

    1.3 选错索引

    此外,你有没有遇到过这样一种情况:明明是同一条sql,只有入参不同而已。有的时候走的索引a,有的时候却走的索引b?

    没错,有时候mysql会选错索引。

    必要时可以使用force index来强制查询sql走某个索引。

    至于为什么mysql会选错索引,后面有专门的文章介绍的,这里先留点悬念。

    2. sql优化

    如果优化了索引之后,也没啥效果。

    接下来试着优化一下sql语句,因为它的改造成本相对于java代码来说也要小得多。

    下面给大家列举了sql优化的15个小技巧:
    在这里插入图片描述

    由于这些技巧在我之前的文章中已经详细介绍过了,在这里我就不深入了。

    3. 远程调用

    很多时候,我们需要在某个接口中,调用其他服务的接口。

    比如有这样的业务场景:

    在用户信息查询接口中需要返回:用户名称、性别、等级、头像、积分、成长值等信息。

    而用户名称、性别、等级、头像在用户服务中,积分在积分服务中,成长值在成长值服务中。为了汇总这些数据统一返回,需要另外提供一个对外接口服务。

    于是,用户信息查询接口需要调用用户查询接口、积分查询接口 和 成长值查询接口,然后汇总数据统一返回。

    调用过程如下图所示:
    在这里插入图片描述

    调用远程接口总耗时 530ms = 200ms + 150ms + 180ms

    显然这种串行调用远程接口性能是非常不好的,调用远程接口总的耗时为所有的远程接口耗时之和。

    那么如何优化远程接口性能呢?

    3.1 并行调用

    上面说到,既然串行调用多个远程接口性能很差,为什么不改成并行呢?

    如下图所示:在这里插入图片描述

    调用远程接口总耗时 200ms = 200ms(即耗时最长的那次远程接口调用)

    在java8之前可以通过实现Callable接口,获取线程返回结果。

    java8以后通过CompleteFuture类实现该功能。我们这里以CompleteFuture为例:

    public UserInfo getUserInfo(Long id) throws InterruptedException, ExecutionException {
        final UserInfo userInfo = new UserInfo();
        CompletableFuture userFuture = CompletableFuture.supplyAsync(() -> {
            getRemoteUserAndFill(id, userInfo);
            return Boolean.TRUE;
        }, executor);
    
        CompletableFuture bonusFuture = CompletableFuture.supplyAsync(() -> {
            getRemoteBonusAndFill(id, userInfo);
            return Boolean.TRUE;
        }, executor);
    
        CompletableFuture growthFuture = CompletableFuture.supplyAsync(() -> {
            getRemoteGrowthAndFill(id, userInfo);
            return Boolean.TRUE;
        }, executor);
        CompletableFuture.allOf(userFuture, bonusFuture, growthFuture).join();
    
        userFuture.get();
        bonusFuture.get();
        growthFuture.get();
    
        return userInfo;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24

    温馨提醒一下,这两种方式别忘了使用线程池。示例中我用到了executor,表示自定义的线程池,为了防止高并发场景下,出现线程过多的问题。

    3.2 数据异构

    上面说到的用户信息查询接口需要调用用户查询接口、积分查询接口 和 成长值查询接口,然后汇总数据统一返回。

    那么,我们能不能把数据冗余一下,把用户信息、积分和成长值的数据统一存储到一个地方,比如:redis,存的数据结构就是用户信息查询接口所需要的内容。然后通过用户id,直接从redis中查询数据出来,不就OK了?

    如果在高并发的场景下,为了提升接口性能,远程接口调用大概率会被去掉,而改成保存冗余数据的数据异构方案。
    在这里插入图片描述

    但需要注意的是,如果使用了数据异构方案,就可能会出现数据一致性问题。

    用户信息、积分和成长值有更新的话,大部分情况下,会先更新到数据库,然后同步到redis。但这种跨库的操作,可能会导致两边数据不一致的情况产生。

    4. 重复调用

    重复调用在我们的日常工作代码中可以说随处可见,但如果没有控制好,会非常影响接口的性能。

    不信,我们一起看看。

    4.1 循环查数据库

    有时候,我们需要从指定的用户集合中,查询出有哪些是在数据库中已经存在的。

    实现代码可以这样写:

    public List<User> queryUser(List<User> searchList) {
        if (CollectionUtils.isEmpty(searchList)) {
            return Collections.emptyList();
        }
    
        List<User> result = Lists.newArrayList();
        searchList.forEach(user -> result.add(userMapper.getUserById(user.getId())));
        return result;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    这里如果有50个用户,则需要循环50次,去查询数据库。我们都知道,每查询一次数据库,就是一次远程调用。

    如果查询50次数据库,就有50次远程调用,这是非常耗时的操作。

    那么,我们如何优化呢?

    具体代码如下:

    public List<User> queryUser(List<User> searchList) {
        if (CollectionUtils.isEmpty(searchList)) {
            return Collections.emptyList();
        }
        List<Long> ids = searchList.stream().map(User::getId).collect(Collectors.toList());
        return userMapper.getUserByIds(ids);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    提供一个根据用户id集合批量查询用户的接口,只远程调用一次,就能查询出所有的数据。

    这里有个需要注意的地方是:id集合的大小要做限制,最好一次不要请求太多的数据。要根据实际情况而定,建议控制每次请求的记录条数在500以内。

    4.2 死循环

    有些小伙伴看到这个标题,可能会感到有点意外,死循环也算?

    代码中不是应该避免死循环吗?为啥还是会产生死循环?

    有时候死循环是我们自己写的,例如下面这段代码:

    while(true) {
        if(condition) {
            break;
        }
        System.out.println("do samething");
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    这里使用了while(true)的循环调用,这种写法在CAS自旋锁中使用比较多。

    当满足condition等于true的时候,则自动退出该循环。

    如果condition条件非常复杂,一旦出现判断不正确,或者少写了一些逻辑判断,就可能在某些场景下出现死循环的问题。

    出现死循环,大概率是开发人员人为的bug导致的,不过这种情况很容易被测出来。

    还有一种隐藏的比较深的死循环,是由于代码写的不太严谨导致的。如果用正常数据,可能测不出问题,但一旦出现异常数据,就会立即出现死循环。

    4.3 无限递归

    如果想要打印某个分类的所有父分类,可以用类似这样的递归方法实现:

    public void printCategory(Category category) {
      if(category == null 
          || category.getParentId() == null) {
         return;
      } 
      System.out.println("父分类名称:"+ category.getName());
      Category parent = categoryMapper.getCategoryById(category.getParentId());
      printCategory(parent);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    正常情况下,这段代码是没有问题的。

    但如果某次有人误操作,把某个分类的parentId指向了它自己,这样就会出现无限递归的情况。导致接口一直不能返回数据,最终会发生堆栈溢出。

    建议写递归方法时,设定一个递归的深度,比如:分类最大等级有4级,则深度可以设置为4。然后在递归方法中做判断,如果深度大于4时,则自动返回,这样就能避免无限循环的情况。

    5. 异步处理

    有时候,我们接口性能优化,需要重新梳理一下业务逻辑,看看是否有设计上不太合理的地方。

    比如有个用户请求接口中,需要做业务操作,发站内通知,和记录操作日志。为了实现起来比较方便,通常我们会将这些逻辑放在接口中同步执行,势必会对接口性能造成一定的影响。

    接口内部流程图如下:
    在这里插入图片描述

    这个接口表面上看起来没有问题,但如果你仔细梳理一下业务逻辑,会发现只有业务操作才是核心逻辑,其他的功能都是非核心逻辑

    在这里有个原则就是:核心逻辑可以同步执行,同步写库。非核心逻辑,可以异步执行,异步写库。

    上面这个例子中,发站内通知和用户操作日志功能,对实时性要求不高,即使晚点写库,用户无非是晚点收到站内通知,或者运营晚点看到用户操作日志,对业务影响不大,所以完全可以异步处理。

    通常异步主要有两种:多线程mq

    5.1 线程池

    使用线程池改造之后,接口逻辑如下:在这里插入图片描述

    发站内通知和用户操作日志功能,被提交到了两个单独的线程池中。

    这样接口中重点关注的是业务操作,把其他的逻辑交给线程异步执行,这样改造之后,让接口性能瞬间提升了。

    但使用线程池有个小问题就是:如果服务器重启了,或者是需要被执行的功能出现异常了,无法重试,会丢数据。

    那么这个问题该怎么办呢?

    5.2 mq

    使用mq改造之后,接口逻辑如下:

    在这里插入图片描述

    对于发站内通知和用户操作日志功能,在接口中并没真正实现,它只发送了mq消息到mq服务器。然后由mq消费者消费消息时,才真正的执行这两个功能。

    这样改造之后,接口性能同样提升了,因为发送mq消息速度是很快的,我们只需关注业务操作的代码即可。

    6. 避免大事务

    很多小伙伴在使用spring框架开发项目时,为了方便,喜欢使用@Transactional注解提供事务功能。

    没错,使用@Transactional注解这种声明式事务的方式提供事务功能,确实能少写很多代码,提升开发效率。

    但也容易造成大事务,引发其他的问题。

    下面用一张图看看大事务引发的问题。

    在这里插入图片描述

    从图中能够看出,大事务问题可能会造成接口超时,对接口的性能有直接的影响。

    我们该如何优化大事务呢?

    1. 少用@Transactional注解
    2. 将查询(select)方法放到事务外
    3. 事务中避免远程调用
    4. 事务中避免一次性处理太多数据
    5. 有些功能可以非事务执行
    6. 有些功能可以异步处理

    关于大事务问题我的另一篇文章《让人头痛的大事务问题到底要如何解决?》,它里面做了非常详细的介绍,如果大家感兴趣可以看看。

    7. 锁粒度

    在某些业务场景中,为了防止多个线程并发修改某个共享数据,造成数据异常。

    为了解决并发场景下,多个线程同时修改数据,造成数据不一致的情况。通常情况下,我们会:加锁

    但如果锁加得不好,导致锁的粒度太粗,也会非常影响接口性能。

    7.1 synchronized

    在java中提供了synchronized关键字给我们的代码加锁。

    通常有两种写法:在方法上加锁在代码块上加锁

    先看看如何在方法上加锁:

    public synchronized doSave(String fileUrl) {
        mkdir();
        uploadFile(fileUrl);
        sendMessage(fileUrl);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    这里加锁的目的是为了防止并发的情况下,创建了相同的目录,第二次会创建失败,影响业务功能。

    但这种直接在方法上加锁,锁的粒度有点粗。因为doSave方法中的上传文件和发消息方法,是不需要加锁的。只有创建目录方法,才需要加锁。

    我们都知道文件上传操作是非常耗时的,如果将整个方法加锁,那么需要等到整个方法执行完之后才能释放锁。显然,这会导致该方法的性能很差,变得得不偿失。

    这时,我们可以改成在代码块上加锁了,具体代码如下:

    public void doSave(String path,String fileUrl) {
        synchronized(this) {
          if(!exists(path)) {
              mkdir(path);
           }
        }
        uploadFile(fileUrl);
        sendMessage(fileUrl);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    这样改造之后,锁的粒度一下子变小了,只有并发创建目录功能才加了锁。而创建目录是一个非常快的操作,即使加锁对接口的性能影响也不大。

    最重要的是,其他的上传文件和发送消息功能,任然可以并发执行。

    当然,这种做在单机版的服务中,是没有问题的。但现在部署的生产环境,为了保证服务的稳定性,一般情况下,同一个服务会被部署在多个节点中。如果哪天挂了一个节点,其他的节点服务任然可用。

    多节点部署避免了因为某个节点挂了,导致服务不可用的情况。同时也能分摊整个系统的流量,避免系统压力过大。

    同时它也带来了新的问题:synchronized只能保证一个节点加锁是有效的,但如果有多个节点如何加锁呢?

    答:这就需要使用:分布式锁了。目前主流的分布式锁包括:redis分布式锁、zookeeper分布式锁 和 数据库分布式锁。

    由于zookeeper分布式锁的性能不太好,真实业务场景用的不多,这里先不讲。

    下面聊一下redis分布式锁。

    7.2 redis分布式锁

    在分布式系统中,由于redis分布式锁相对于更简单和高效,成为了分布式锁的首先,被我们用到了很多实际业务场景当中。

    使用redis分布式锁的伪代码如下:

    public void doSave(String path,String fileUrl) {
      try {
        String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
        if ("OK".equals(result)) {
          if(!exists(path)) {
             mkdir(path);
             uploadFile(fileUrl);
             sendMessage(fileUrl);
          }
          return true;
        }
      } finally{
          unlock(lockKey,requestId);
      }  
      return false;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    跟之前使用synchronized关键字加锁时一样,这里锁的范围也太大了,换句话说就是锁的粒度太粗,这样会导致整个方法的执行效率很低。

    其实只有创建目录的时候,才需要加分布式锁,其余代码根本不用加锁。

    于是,我们需要优化一下代码:

    public void doSave(String path,String fileUrl) {
       if(this.tryLock()) {
          mkdir(path);
       }
       uploadFile(fileUrl);
       sendMessage(fileUrl);
    }
    
    private boolean tryLock() {
        try {
        String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime);
        if ("OK".equals(result)) {
          return true;
        }
      } finally{
          unlock(lockKey,requestId);
      }  
      return false;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19

    上面代码将加锁的范围缩小了,只有创建目录时才加了锁。这样看似简单的优化之后,接口性能能提升很多。说不定,会有意外的惊喜喔。哈哈哈。

    redis分布式锁虽说好用,但它在使用时,有很多注意的细节,隐藏了很多坑,如果稍不注意很容易踩中。详细内容可以看看我的另一篇文章《聊聊redis分布式锁的8大坑

    7.3 数据库分布式锁

    mysql数据库中主要有三种锁:

    • 表锁:加锁快,不会出现死锁。但锁定粒度大,发生锁冲突的概率最高,并发度最低。
    • 行锁:加锁慢,会出现死锁。但锁定粒度最小,发生锁冲突的概率最低,并发度也最高。
    • 间隙锁:开销和加锁时间界于表锁和行锁之间。它会出现死锁,锁定粒度界于表锁和行锁之间,并发度一般。

    并发度越高,意味着接口性能越好。

    所以数据库锁的优化方向是:

    优先使用行锁,其次使用间隙锁,再其次使用表锁

    赶紧看看,你用对了没?

    8.分页处理

    有时候我会调用某个接口批量查询数据,比如:通过用户id批量查询出用户信息,然后给这些用户送积分。

    但如果你一次性查询的用户数量太多了,比如一次查询2000个用户的数据。参数中传入了2000个用户的id,远程调用接口,会发现该用户查询接口经常超时。

    调用代码如下:

    List<User> users = remoteCallUser(ids);
    
    • 1

    众所周知,调用接口从数据库获取数据,是需要经过网络传输的。如果数据量太大,无论是获取数据的速度,还是网络传输受限于带宽,都会导致耗时时间比较长。

    那么,这种情况要如何优化呢?

    答:分页处理

    将一次获取所有的数据的请求,改成分多次获取,每次只获取一部分用户的数据,最后进行合并和汇总。

    其实,处理这个问题,要分为两种场景:同步调用异步调用

    8.1 同步调用

    如果在job中需要获取2000个用户的信息,它要求只要能正确获取到数据就好,对获取数据的总耗时要求不太高。

    但对每一次远程接口调用的耗时有要求,不能大于500ms,不然会有邮件预警。

    这时,我们可以同步分页调用批量查询用户信息接口。

    具体示例代码如下:

    List<List<Long>> allIds = Lists.partition(ids,200);
    
    for(List<Long> batchIds:allIds) {
       List<User> users = remoteCallUser(batchIds);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5

    代码中我用的googleguava工具中的Lists.partition方法,用它来做分页简直太好用了,不然要巴拉巴拉写一大堆分页的代码。

    8.2 异步调用

    如果是在某个接口中需要获取2000个用户的信息,它考虑的就需要更多一些。

    除了需要考虑远程调用接口的耗时之外,还需要考虑该接口本身的总耗时,也不能超时500ms。

    这时候用上面的同步分页请求远程接口,肯定是行不通的。

    那么,只能使用异步调用了。

    代码如下:

    List<List<Long>> allIds = Lists.partition(ids,200);
    
    final List<User> result = Lists.newArrayList();
    allIds.stream().forEach((batchIds) -> {
       CompletableFuture.supplyAsync(() -> {
            result.addAll(remoteCallUser(batchIds));
            return Boolean.TRUE;
        }, executor);
    })
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    使用CompletableFuture类,多个线程异步调用远程接口,最后汇总结果统一返回。

    9.加缓存

    解决接口性能问题,加缓存是一个非常高效的方法。

    但不能为了缓存而缓存,还是要看具体的业务场景。毕竟加了缓存,会导致接口的复杂度增加,它会带来数据不一致问题。

    在有些并发量比较低的场景中,比如用户下单,可以不用加缓存。

    还有些场景,比如在商城首页显示商品分类的地方,假设这里的分类是调用接口获取到的数据,但页面暂时没有做静态化。

    如果查询分类树的接口没有使用缓存,而直接从数据库查询数据,性能会非常差。

    那么如何使用缓存呢?

    9.1 redis缓存

    通常情况下,我们使用最多的缓存可能是:redismemcached

    但对于java应用来说,绝大多数都是使用的redis,所以接下来我们以redis为例。

    由于在关系型数据库,比如:mysql中,菜单是有上下级关系的。某个四级分类是某个三级分类的子分类,这个三级分类,又是某个二级分类的子分类,而这个二级分类,又是某个一级分类的子分类。

    这种存储结构决定了,想一次性查出这个分类树,并非是一件非常容易的事情。这就需要使用程序递归查询了,如果分类多的话,这个递归是比较耗时的。

    所以,如果每次都直接从数据库中查询分类树的数据,是一个非常耗时的操作。

    这时我们可以使用缓存,大部分情况,接口都直接从缓存中获取数据。操作redis可以使用成熟的框架,比如:jedis和redisson等。

    用jedis伪代码如下:

    String json = jedis.get(key);
    if(StringUtils.isNotEmpty(json)) {
       CategoryTree categoryTree = JsonUtil.toObject(json);
       return categoryTree;
    }
    return queryCategoryTreeFromDb();
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6

    先从redis中根据某个key查询是否有菜单数据,如果有则转换成对象,直接返回。如果redis中没有查到菜单数据,则再从数据库中查询菜单数据,有则返回。

    此外,我们还需要有个job每隔一段时间,从数据库中查询菜单数据,更新到redis当中,这样以后每次都能直接从redis中获取菜单的数据,而无需访问数据库了。

    在这里插入图片描述

    这样改造之后,能快速的提升性能。

    但这样做性能提升不是最佳的,还有其他的方案,我们一起看看下面的内容。

    9.2 二级缓存

    上面的方案是基于redis缓存的,虽说redis访问速度很快。但毕竟是一个远程调用,而且菜单树的数据很多,在网络传输的过程中,是有些耗时的。

    有没有办法,不经过请求远程,就能直接获取到数据呢?

    答:使用二级缓存,即基于内存的缓存。

    除了自己手写的内存缓存之后,目前使用比较多的内存缓存框架有:guava、Ehcache、caffine等。

    我们在这里以caffeine为例,它是spring官方推荐的。

    第一步,引入caffeine的相关jar包

    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-cache</artifactId>
    </dependency>
    <dependency>
        <groupId>com.github.ben-manes.caffeine</groupId>
        <artifactId>caffeine</artifactId>
        <version>2.6.0</version>
    </dependency>
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    第二步,配置CacheManager,开启EnableCaching

    @Configuration
    @EnableCaching
    public class CacheConfig {
        @Bean
        public CacheManager cacheManager(){
            CaffeineCacheManager cacheManager = new CaffeineCacheManager();
            //Caffeine配置
            Caffeine<Object, Object> caffeine = Caffeine.newBuilder()
                    //最后一次写入后经过固定时间过期
                    .expireAfterWrite(10, TimeUnit.SECONDS)
                    //缓存的最大条数
                    .maximumSize(1000);
            cacheManager.setCaffeine(caffeine);
            return cacheManager;
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16

    第三步,使用Cacheable注解获取数据

    @Service
    public class CategoryService {
       
       @Cacheable(value = "category", key = "#categoryKey")
       public CategoryModel getCategory(String categoryKey) {
          String json = jedis.get(categoryKey);
          if(StringUtils.isNotEmpty(json)) {
             CategoryTree categoryTree = JsonUtil.toObject(json);
             return categoryTree;
          }
          return queryCategoryTreeFromDb();
       }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    调用categoryService.getCategory()方法时,先从caffine缓存中获取数据,如果能够获取到数据,则直接返回该数据,不进入方法体。

    如果不能获取到数据,则再从redis中查一次数据。如果查询到了,则返回数据,并且放入caffine中。

    如果还是没有查到数据,则直接从数据库中获取到数据,然后放到caffine缓存中。

    具体流程图如下:

    在这里插入图片描述

    该方案的性能更好,但有个缺点就是,如果数据更新了,不能及时刷新缓存。此外,如果有多台服务器节点,可能存在各个节点上数据不一样的情况。

    由此可见,二级缓存给我们带来性能提升的同时,也带来了数据不一致的问题。使用二级缓存一定要结合实际的业务场景,并非所有的业务场景都适用。

    但上面我列举的分类场景,是适合使用二级缓存的。因为它属于用户不敏感数据,即使出现了稍微有点数据不一致也没有关系,用户有可能都没有察觉出来。

    10. 分库分表

    有时候,接口性能受限的不是别的,而是数据库。

    当系统发展到一定的阶段,用户并发量大,会有大量的数据库请求,需要占用大量的数据库连接,同时会带来磁盘IO的性能瓶颈问题。

    此外,随着用户数量越来越多,产生的数据也越来越多,一张表有可能存不下。由于数据量太大,sql语句查询数据时,即使走了索引也会非常耗时。

    这时该怎么办呢?

    答:需要做分库分表

    如下图所示:

    在这里插入图片描述

    图中将用户库拆分成了三个库,每个库都包含了四张用户表。

    如果有用户请求过来的时候,先根据用户id路由到其中一个用户库,然后再定位到某张表。

    路由的算法挺多的:

    • 根据id取模,比如:id=7,有4张表,则7%4=3,模为3,路由到用户表3。
    • 给id指定一个区间范围,比如:id的值是0-10万,则数据存在用户表0,id的值是10-20万,则数据存在用户表1。
    • 一致性hash算法

    分库分表主要有两个方向:垂直水平

    说实话垂直方向(即业务方向)更简单。

    在水平方向(即数据方向)上,分库和分表的作用,其实是有区别的,不能混为一谈。

    • 分库:是为了解决数据库连接资源不足问题,和磁盘IO的性能瓶颈问题。
    • 分表:是为了解决单表数据量太大,sql语句查询数据时,即使走了索引也非常耗时问题。此外还可以解决消耗cpu资源问题。
    • 分库分表:可以解决 数据库连接资源不足、磁盘IO的性能瓶颈、检索数据耗时 和 消耗cpu资源等问题。

    如果在有些业务场景中,用户并发量很大,但是需要保存的数据量很少,这时可以只分库,不分表。

    如果在有些业务场景中,用户并发量不大,但是需要保存的数量很多,这时可以只分表,不分库。

    如果在有些业务场景中,用户并发量大,并且需要保存的数量也很多时,可以分库分表。

    关于分库分表更详细的内容,可以看看我另一篇文章,里面讲的更深入《阿里二面:为什么分库分表?

    11. 辅助功能

    优化接口性能问题,除了上面提到的这些常用方法之外,还需要配合使用一些辅助功能,因为它们真的可以帮我们提升查找问题的效率。

    11.1 开启慢查询日志

    通常情况下,为了定位sql的性能瓶颈,我们需要开启mysql的慢查询日志。把超过指定时间的sql语句,单独记录下来,方面以后分析和定位问题。

    开启慢查询日志需要重点关注三个参数:

    • slow_query_log 慢查询开关
    • slow_query_log_file 慢查询日志存放的路径
    • long_query_time 超过多少秒才会记录日志

    通过mysql的set命令可以设置:

    set global slow_query_log='ON'; 
    set global slow_query_log_file='/usr/local/mysql/data/slow.log';
    set global long_query_time=2;
    
    • 1
    • 2
    • 3

    设置完之后,如果某条sql的执行时间超过了2秒,会被自动记录到slow.log文件中。

    当然也可以直接修改配置文件my.cnf

    [mysqld]
    slow_query_log = ON
    slow_query_log_file = /usr/local/mysql/data/slow.log
    long_query_time = 2
    
    • 1
    • 2
    • 3
    • 4

    但这种方式需要重启mysql服务。

    很多公司每天早上都会发一封慢查询日志的邮件,开发人员根据这些信息优化sql。

    11.2 加监控

    为了出现sql问题时,能够让我们及时发现,我们需要对系统做监控

    目前业界使用比较多的开源监控系统是:Prometheus

    它提供了 监控预警 的功能。

    架构图如下:
    在这里插入图片描述

    我们可以用它监控如下信息:

    • 接口响应时间
    • 调用第三方服务耗时
    • 慢查询sql耗时
    • cpu使用情况
    • 内存使用情况
    • 磁盘使用情况
    • 数据库使用情况

    等等。。。

    它的界面大概长这样子:

    可以看到mysql当前qps,活跃线程数,连接数,缓存池的大小等信息。

    如果发现数据量连接池占用太多,对接口的性能肯定会有影响。

    这时可能是代码中开启了连接忘了关,或者并发量太大了导致的,需要做进一步排查和系统优化。

    截图中只是它一小部分功能,如果你想了解更多功能,可以访问Prometheus的官网:https://prometheus.io/

    11.3 链路跟踪

    有时候某个接口涉及的逻辑很多,比如:查数据库、查redis、远程调用接口,发mq消息,执行业务代码等等。

    该接口一次请求的链路很长,如果逐一排查,需要花费大量的时间,这时候,我们已经没法用传统的办法定位问题了。

    有没有办法解决这问题呢?

    用分布式链路跟踪系统:skywalking

    架构图如下:
    在这里插入图片描述

    通过skywalking定位性能问题:在这里插入图片描述

    在skywalking中可以通过traceId(全局唯一的id),串联一个接口请求的完整链路。可以看到整个接口的耗时,调用的远程服务的耗时,访问数据库或者redis的耗时等等,功能非常强大。

    之前没有这个功能的时候,为了定位线上接口性能问题,我们还需要在代码中加日志,手动打印出链路中各个环节的耗时情况,然后再逐一排查。

    🟡第三章:SQL 优化

    在这里插入图片描述

    1 避免使用select *

    很多时候,我们写sql语句时,为了方便,喜欢直接使用select *,一次性查出表中所有列的数据。

    反例:

    select * from user where id=1;
    
    • 1

    在实际业务场景中,可能我们真正需要使用的只有其中一两列。查了很多数据,但是不用,白白浪费了数据库资源,比如:内存或者cpu。

    此外,多查出来的数据,通过网络IO传输的过程中,也会增加数据传输的时间。

    还有一个最重要的问题是:select *不会走覆盖索引,会出现大量的回表操作,而从导致查询sql的性能很低。

    那么,如何优化呢?

    正例:

    select name,age from user where id=1;
    
    • 1

    sql语句查询时,只查需要用到的列,多余的列根本无需查出来。

    2 用union all代替union

    我们都知道sql语句使用union关键字后,可以获取排重后的数据。

    而如果使用union all关键字,可以获取所有数据,包含重复的数据。

    反例:

    (select * from user where id=1) 
    union 
    (select * from user where id=2);
    
    • 1
    • 2
    • 3

    排重的过程需要遍历、排序和比较,它更耗时,更消耗cpu资源。

    所以如果能用union all的时候,尽量不用union。

    正例:

    (select * from user where id=1) 
    union all
    (select * from user where id=2);
    
    • 1
    • 2
    • 3

    除非是有些特殊的场景,比如union all之后,结果集中出现了重复数据,而业务场景中是不允许产生重复数据的,这时可以使用union。

    3 小表驱动大表

    小表驱动大表,也就是说用小表的数据集驱动大表的数据集。

    假如有order和user两张表,其中order表有10000条数据,而user表有100条数据。

    这时如果想查一下,所有有效的用户下过的订单列表。

    可以使用in关键字实现:

    select * from order
    where user_id in (select id from user where status=1)
    
    • 1
    • 2

    也可以使用exists关键字实现:

    select * from order
    where exists (select 1 from user where order.user_id = user.id and status=1)
    
    • 1
    • 2

    前面提到的这种业务场景,使用in关键字去实现业务需求,更加合适。

    为什么呢?

    因为如果sql语句中包含了in关键字,则它会优先执行in里面的子查询语句,然后再执行in外面的语句。如果in里面的数据量很少,作为条件查询速度更快。

    而如果sql语句中包含了exists关键字,它优先执行exists左边的语句(即主查询语句)。然后把它作为条件,去跟右边的语句匹配。如果匹配上,则可以查询出数据。如果匹配不上,数据就被过滤掉了。

    这个需求中,order表有10000条数据,而user表有100条数据。order表是大表,user表是小表。如果order表在左边,则用in关键字性能更好。

    总结一下:

    • in 适用于左边大表,右边小表。
    • exists 适用于左边小表,右边大表。

    不管是用in,还是exists关键字,其核心思想都是用小表驱动大表。

    4 批量操作

    如果你有一批数据经过业务处理之后,需要插入数据,该怎么办?

    反例:

    for(Order order: list){
       orderMapper.insert(order):
    }
    
    • 1
    • 2
    • 3

    在循环中逐条插入数据。

    insert into order(id,code,user_id) 
    values(123,'001',100);
    
    • 1
    • 2

    该操作需要多次请求数据库,才能完成这批数据的插入。

    但众所周知,我们在代码中,每次远程请求数据库,是会消耗一定性能的。而如果我们的代码需要请求多次数据库,才能完成本次业务功能,势必会消耗更多的性能。

    那么如何优化呢?

    正例:

    orderMapper.insertBatch(list):
    
    • 1

    提供一个批量插入数据的方法。

    insert into order(id,code,user_id) 
    values(123,'001',100),(124,'002',100),(125,'003',101);
    
    • 1
    • 2

    这样只需要远程请求一次数据库,sql性能会得到提升,数据量越多,提升越大。

    但需要注意的是,不建议一次批量操作太多的数据,如果数据太多数据库响应也会很慢。批量操作需要把握一个度,建议每批数据尽量控制在500以内。如果数据多于500,则分多批次处理。

    5 多用limit

    有时候,我们需要查询某些数据中的第一条,比如:查询某个用户下的第一个订单,想看看他第一次的首单时间。

    反例:

    select id, create_date 
     from order 
    where user_id=123 
    order by create_date asc;
    
    • 1
    • 2
    • 3
    • 4

    根据用户id查询订单,按下单时间排序,先查出该用户所有的订单数据,得到一个订单集合。 然后在代码中,获取第一个元素的数据,即首单的数据,就能获取首单时间。

    List<Order> list = orderMapper.getOrderList();
    Order order = list.get(0);
    
    • 1
    • 2

    虽说这种做法在功能上没有问题,但它的效率非常不高,需要先查询出所有的数据,有点浪费资源。

    那么,如何优化呢?

    正例:

    select id, create_date 
     from order 
    where user_id=123 
    order by create_date asc 
    limit 1;
    
    • 1
    • 2
    • 3
    • 4
    • 5

    使用limit 1,只返回该用户下单时间最小的那一条数据即可。

    此外,在删除或者修改数据时,为了防止误操作,导致删除或修改了不相干的数据,也可以在sql语句最后加上limit。

    例如:

    update order set status=0,edit_time=now(3) 
    where id>=100 and id<200 limit 100;
    
    • 1
    • 2

    这样即使误操作,比如把id搞错了,也不会对太多的数据造成影响。

    6 in中值太多

    对于批量查询接口,我们通常会使用in关键字过滤出数据。比如:想通过指定的一些id,批量查询出用户信息。

    sql语句如下:

    select id,name from category
    where id in (1,2,3...100000000);
    
    • 1
    • 2

    如果我们不做任何限制,该查询语句一次性可能会查询出非常多的数据,很容易导致接口超时。

    这时该怎么办呢?

    select id,name from category
    where id in (1,2,3...100)
    limit 500;
    
    • 1
    • 2
    • 3

    可以在sql中对数据用limit做限制。

    不过我们更多的是要在业务代码中加限制,伪代码如下:

    public List<Category> getCategory(List<Long> ids) {
       if(CollectionUtils.isEmpty(ids)) {
          return null;
       }
       if(ids.size() > 500) {
          throw new BusinessException("一次最多允许查询500条记录")
       }
       return mapper.getCategoryList(ids);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    还有一个方案就是:如果ids超过500条记录,可以分批用多线程去查询数据。每批只查500条记录,最后把查询到的数据汇总到一起返回。

    不过这只是一个临时方案,不适合于ids实在太多的场景。因为ids太多,即使能快速查出数据,但如果返回的数据量太大了,网络传输也是非常消耗性能的,接口性能始终好不到哪里去。

    7 增量查询

    有时候,我们需要通过远程接口查询数据,然后同步到另外一个数据库。

    反例:

    select * from user;
    
    • 1

    如果直接获取所有的数据,然后同步过去。这样虽说非常方便,但是带来了一个非常大的问题,就是如果数据很多的话,查询性能会非常差。

    这时该怎么办呢?

    正例:

    select * from user 
    where id>#{lastId} and create_time >= #{lastCreateTime} 
    limit 100;
    
    • 1
    • 2
    • 3

    按id和时间升序,每次只同步一批数据,这一批数据只有100条记录。每次同步完成之后,保存这100条数据中最大的id和时间,给同步下一批数据的时候用。

    通过这种增量查询的方式,能够提升单次查询的效率。

    8 高效的分页

    有时候,列表页在查询数据时,为了避免一次性返回过多的数据影响接口性能,我们一般会对查询接口做分页处理。

    在mysql中分页一般用的limit关键字:

    select id,name,age 
    from user limit 10,20;
    
    • 1
    • 2

    如果表中数据量少,用limit关键字做分页,没啥问题。但如果表中数据量很多,用它就会出现性能问题。

    比如现在分页参数变成了:

    select id,name,age 
    from user limit 1000000,20;
    
    • 1
    • 2

    mysql会查到1000020条数据,然后丢弃前面的1000000条,只查后面的20条数据,这个是非常浪费资源的。

    那么,这种海量数据该怎么分页呢?

    优化sql:

    select id,name,age 
    from user where id > 1000000 limit 20;
    
    • 1
    • 2

    先找到上次分页最大的id,然后利用id上的索引查询。不过该方案,要求id是连续的,并且有序的。

    还能使用between优化分页。

    select id,name,age 
    from user where id between 1000000 and 1000020;
    
    • 1
    • 2

    需要注意的是between要在唯一索引上分页,不然会出现每页大小不一致的问题。

    9 用连接查询代替子查询

    mysql中如果需要从两张以上的表中查询出数据的话,一般有两种实现方式:子查询连接查询

    子查询的例子如下:

    select * from order
    where user_id in (select id from user where status=1)
    
    • 1
    • 2

    子查询语句可以通过in关键字实现,一个查询语句的条件落在另一个select语句的查询结果中。程序先运行在嵌套在最内层的语句,再运行外层的语句。

    子查询语句的优点是简单,结构化,如果涉及的表数量不多的话。

    但缺点是mysql执行子查询时,需要创建临时表,查询完毕后,需要再删除这些临时表,有一些额外的性能消耗。

    这时可以改成连接查询。 具体例子如下:

    select o.* from order o
    inner join user u on o.user_id = u.id
    where u.status=1
    
    • 1
    • 2
    • 3

    10 join的表不宜过多

    根据阿里巴巴开发者手册的规定,join表的数量不应该超过3个。

    反例:

    select a.name,b.name.c.name,d.name
    from a 
    inner join b on a.id = b.a_id
    inner join c on c.b_id = b.id
    inner join d on d.c_id = c.id
    inner join e on e.d_id = d.id
    inner join f on f.e_id = e.id
    inner join g on g.f_id = f.id
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    如果join太多,mysql在选择索引的时候会非常复杂,很容易选错索引。

    并且如果没有命中中,nested loop join 就是分别从两个表读一行数据进行两两对比,复杂度是 n^2。

    所以我们应该尽量控制join表的数量。

    正例:

    select a.name,b.name.c.name,a.d_name 
    from a 
    inner join b on a.id = b.a_id
    inner join c on c.b_id = b.id
    
    • 1
    • 2
    • 3
    • 4

    如果实现业务场景中需要查询出另外几张表中的数据,可以在a、b、c表中冗余专门的字段,比如:在表a中冗余d_name字段,保存需要查询出的数据。

    不过我之前也见过有些ERP系统,并发量不大,但业务比较复杂,需要join十几张表才能查询出数据。

    所以join表的数量要根据系统的实际情况决定,不能一概而论,尽量越少越好。

    11 join时要注意

    我们在涉及到多张表联合查询的时候,一般会使用join关键字。

    而join使用最多的是left join和inner join。

    • left join:求两个表的交集外加左表剩下的数据。
    • inner join:求两个表交集的数据。

    使用inner join的示例如下:

    select o.id,o.code,u.name 
    from order o 
    inner join user u on o.user_id = u.id
    where u.status=1;
    
    • 1
    • 2
    • 3
    • 4

    如果两张表使用inner join关联,mysql会自动选择两张表中的小表,去驱动大表,所以性能上不会有太大的问题。

    使用left join的示例如下:

    select o.id,o.code,u.name 
    from order o 
    left join user u on o.user_id = u.id
    where u.status=1;
    
    • 1
    • 2
    • 3
    • 4

    如果两张表使用left join关联,mysql会默认用left join关键字左边的表,去驱动它右边的表。如果左边的表数据很多时,就会出现性能问题。

    要特别注意的是在用left join关联查询时,左边要用小表,右边可以用大表。如果能用inner join的地方,尽量少用left join。

    12 控制索引的数量

    众所周知,索引能够显著的提升查询sql的性能,但索引数量并非越多越好。

    因为表中新增数据时,需要同时为它创建索引,而索引是需要额外的存储空间的,而且还会有一定的性能消耗。

    阿里巴巴的开发者手册中规定,单表的索引数量应该尽量控制在5个以内,并且单个索引中的字段数不超过5个。

    mysql使用的B+树的结构来保存索引的,在insert、update和delete操作时,需要更新B+树索引。如果索引过多,会消耗很多额外的性能。

    那么,问题来了,如果表中的索引太多,超过了5个该怎么办?

    这个问题要辩证的看,如果你的系统并发量不高,表中的数据量也不多,其实超过5个也可以,只要不要超过太多就行。

    但对于一些高并发的系统,请务必遵守单表索引数量不要超过5的限制。

    那么,高并发系统如何优化索引数量?

    能够建联合索引,就别建单个索引,可以删除无用的单个索引。

    将部分查询功能迁移到其他类型的数据库中,比如:Elastic Seach、HBase等,在业务表中只需要建几个关键索引即可。

    13 选择合理的字段类型

    char表示固定字符串类型,该类型的字段存储空间的固定的,会浪费存储空间。

    alter table order 
    add column code char(20) NOT NULL;
    
    • 1
    • 2

    varchar表示变长字符串类型,该类型的字段存储空间会根据实际数据的长度调整,不会浪费存储空间。

    alter table order 
    add column code varchar(20) NOT NULL;
    
    • 1
    • 2

    如果是长度固定的字段,比如用户手机号,一般都是11位的,可以定义成char类型,长度是11字节。

    但如果是企业名称字段,假如定义成char类型,就有问题了。

    如果长度定义得太长,比如定义成了200字节,而实际企业长度只有50字节,则会浪费150字节的存储空间。

    如果长度定义得太短,比如定义成了50字节,但实际企业名称有100字节,就会存储不下,而抛出异常。

    所以建议将企业名称改成varchar类型,变长字段存储空间小,可以节省存储空间,而且对于查询来说,在一个相对较小的字段内搜索效率显然要高些。

    我们在选择字段类型时,应该遵循这样的原则:

    1. 能用数字类型,就不用字符串,因为字符的处理往往比数字要慢。
    2. 尽可能使用小的类型,比如:用bit存布尔值,用tinyint存枚举值等。
    3. 长度固定的字符串字段,用char类型。
    4. 长度可变的字符串字段,用varchar类型。
    5. 金额字段用decimal,避免精度丢失问题。

    还有很多原则,这里就不一一列举了。

    14 提升group by的效率

    我们有很多业务场景需要使用group by关键字,它主要的功能是去重和分组。

    通常它会跟having一起配合使用,表示分组后再根据一定的条件过滤数据。

    反例:

    select user_id,user_name from order
    group by user_id
    having user_id <= 200;
    
    • 1
    • 2
    • 3

    这种写法性能不好,它先把所有的订单根据用户id分组之后,再去过滤用户id大于等于200的用户。

    分组是一个相对耗时的操作,为什么我们不先缩小数据的范围之后,再分组呢?

    正例:

    select user_id,user_name from order
    where user_id <= 200
    group by user_id
    
    • 1
    • 2
    • 3

    使用where条件在分组前,就把多余的数据过滤掉了,这样分组时效率就会更高一些。

    其实这是一种思路,不仅限于group by的优化。我们的sql语句在做一些耗时的操作之前,应尽可能缩小数据范围,这样能提升sql整体的性能。

    15 索引优化

    sql优化当中,有一个非常重要的内容就是:索引优化

    很多时候sql语句,走了索引,和没有走索引,执行效率差别很大。所以索引优化被作为sql优化的首选。

    索引优化的第一步是:检查sql语句有没有走索引。

    那么,如何查看sql走了索引没?

    可以使用explain命令,查看mysql的执行计划。

    例如:

    explain select * from `order` where code='002';
    
    • 1

    结果:在这里插入图片描述

    通过这几列可以判断索引使用情况,执行计划包含列的含义如下图所示:

    说实话,sql语句没有走索引,排除没有建索引之外,最大的可能性是索引失效了。

    下面说说索引失效的常见原因:
    在这里插入图片描述

    如果不是上面的这些原因,则需要再进一步排查一下其他原因。

    最近无意间获得一份BAT大厂大佬写的刷题笔记,一下子打通了我的任督二脉,越来越觉得算法没有想象中那么难了。

    此外,你有没有遇到过这样一种情况:明明是同一条sql,只有入参不同而已。有的时候走的索引a,有的时候却走的索引b?

    没错,有时候mysql会选错索引。

    必要时可以使用force index来强制查询sql走某个索引。

    文章来自:1

  • 相关阅读:
    R语言ggplot2可视化:使用ggpubr包的ggbarplot函数可视化水平柱状图(条形图)、使用orientation参数设置柱状图转置为条形图
    Springboot+Easyexcel将数据写入模板文件并导出Excel
    UNIAPP实战项目笔记38 购物车的添加商品到购物车功能
    Kotlin + 协程 + Room 结合使用
    Qt编程中segfault问题解决方法
    Web服务器和PHP解释器
    Anaconda下的pkgs占用空间13G,如何安全的清理(已解决)
    图神经网络 图像处理,为什么用图神经网络
    JS,数组语法+示例,超详细!!!
    自已定义一个Java异常——子定义异常,和异常遇到的面试题。
  • 原文地址:https://blog.csdn.net/qq_42055933/article/details/126161780