【摸鱼吃瓜工作录】刚到公司,如何让项目经理对你刮目相看

819 阅读13分钟

我是「 kangarooking(袋鼠帝) 」,不是弟弟的弟。一个互联网小蜗「 gua 」牛,真心给大家分享经验和技术干货,希望我的文章对你有帮助。关注me,每天进步亿点点 ❗ ❗ ❗

这期干货满满,走过路过,不要错过阿。

摆哈龙门阵

最近深圳的疫情越来越严重了阿,我们公司附近有些大楼都封了。有很多人被隔离在公司了,看了一下他们的聊天记录,太惨了,每天排队洗头,醒来就上班,下班就晚安...。然后我们公司还好,看到这种情况直接组织全员远程办公。现在是远程办公的第四天了,远程办公是真香,每天早上可以睡到上班点,中午按时吃饭,吃完饭直接躺床上睡,贼舒服,基本准点下班(哈哈哈)。但是线上的会议就变得多了起来,其实我觉得程序员这个职业就是该远程办公嘛,我们所有的工作都是在互联网上面展开的,现在线上沟通又很方便。我倒是希望可以实现程序员都远程办公,不知道啥时候能实现阿。

希望疫情能够好转,我们终究会胜利!

本文涉及知识点:

  • 线程池八股文
  • 线程池的关闭
  • 执行不同类型任务,线程数的选择
  • redis pipeline
  • 函数式编程

背景

这天,我和往常一样悠闲的上着班,正在带薪拉屎的我津津有味的看着某音上小超梦天神下凡般的操作,突然手机上方微信信息不断闪出,一看是咱们架构师又被项目经理质问了:

群聊天.png

下载.png

其实就是一个将数据库数据同步到redis的定时任务,由于数据量太大,需要分页查询,然后同步。

这是待优化的旧代码,逻辑是,搞一个while无限循环,分页查询用户表数据,每查询执行完一次pageNum+1,直到查询出来的列表为空直接返回。saveOrUpdateToRedis方法的逻辑就是将一个用户的数据进行一系列处理和转化后hmsetredis

long taskStart = System.currentTimeMillis();
        int pageSize = 1000;
        int pageNum = 1;
        while (true) {
            long start = System.currentTimeMillis();
            logger.info("开始处理第" + pageNum + "页数据...");
            PageDto page = PageUtil.setPage(pageNum, pageSize);
    
            long cxStart = System.currentTimeMillis();
            List<UserDto> userDtoList = userDao.selectUser(page.getBeginNum(), page.getEndNum());
            long cxEnd = System.currentTimeMillis();
            logger.info("查询user耗时:" + (cxEnd - cxStart) + "毫秒");

            if (CollectionUtils.isEmpty(userDtoList)) {
                logger.info("每次 {} 人,第 {} 批没有数据直接返回", pageSize, pageNum);
                long taskEnd = System.currentTimeMillis();
                logger.info("任务完成,总耗时:{}毫秒", taskEnd - taskStart);
                return;
            }
            logger.info("第 {} 批数据正在执行", pageNum);
            for (UserDto user : userDtoList) {
                String xxx = user.getXXX();
                if (!StringUtils.isEmpty(xxx)) {
                    //保存到redis的逻辑
                    saveOrUpdateToRedis(user, xxx, xxxx);
                }
            }
            long end = System.currentTimeMillis();
            logger.info("第{}批数据执行完毕,耗时:{}毫秒", pageNum, (end - start));
            pageNum++;
        }
private void saveOrUpdateToRedis(xxx,xxx,xxx) {

        //...上面省略一堆乱七八糟的数据处理转化细节
        //最终以hash的结构存放到redis
        redisUtils.hmset(xxx,xxx,time);
    }

逻辑很简单,但是该定时任务在生产环境却要执行一个小时左右,当然数据量大其中一个因素(百万级数据),但是我们主要还是从代码入手。

要求:改动小,显著提高执行效率,要求稳定,影响小。

biemoyu.gif

心路历程

我先分析

要优化一个代码的执行效率,我首先得知道影响效率的主要因素有哪些。然后有针对性的去优化,先把大头优化,然后在去看那些细枝末节。

我直接开干

确定了第一步就开干吧,我直接找到这段定时任务在生产执行的日志,查看日志进行分析,发现最耗时的部分在分页的sql查询上面,平均耗时3秒,其他耗时都在毫秒级别。这要进行sql优化啊,于是我就着手开始sql优化,连接上数据库我人傻了。这个服务怎么使用的是oracle啊,mysql我熟得很但oracle怎么sql调优阿,我猜测可能是因为深度分页的原因?但是看日志发现日志的开头和结束位置sql查询耗时差不多。我还是在百度里面不断地遨游,期间考虑是否没有设置索引,是否分页效率低,后面又查看sql执行计划,折腾了半个多小时,不断地修改sql语句,并进行效率测试。最后我发现我优化后的sql执行效率和原来也差不多,得出结论,这个sql很难再优化了(也有可能是我的水平不够 哈哈哈)。

走不通,换一个优化角度

sql优化的路走不通,那就换另外一个角度去优化,之前老任务使用单线程去执行。如果我现在使用线程池搞个多线程,那不是速度立马起飞。

代码搬运工

代码懒得敲,直接打开之前看得开源框架--shenyu源码,复制一个线程池过来(我不写代码,我只是代码的搬运工,哈哈哈)改巴改巴。考虑方便设置线程的一些属性和溯源,把它的ThreadFactory也搬运过来了。

然后我就想到底是把线程池放到成员变量里面还是在方法里面。最后决定放在方法,因为这个线程池就跑这个同步任务的时候被需要,同步任务结束直接把线程池关闭掉,释放资源。

ThreadFactory,可以自定义线程组,线程名称,是否为守护线程,线程优先级等。

public final class XxlThreadFactory implements ThreadFactory {

    private static final AtomicLong THREAD_NUMBER = new AtomicLong(1);

    private static final ThreadGroup THREAD_GROUP = new ThreadGroup("xxl");

    private final boolean daemon;

    private final String namePrefix;

    private final int priority;

    private XxlThreadFactory(final String namePrefix, final boolean daemon, final int priority) {
        this.namePrefix = namePrefix;
        this.daemon = daemon;
        this.priority = priority;
    }

    /**
     * create custom thread factory.
     *
     * @param namePrefix prefix
     * @param daemon     daemon
     * @return {@linkplain ThreadFactory}
     */
    public static ThreadFactory create(final String namePrefix, final boolean daemon) {
        return create(namePrefix, daemon, Thread.NORM_PRIORITY);
    }

    /**
     * create custom thread factory.
     *
     * @param namePrefix prefix
     * @param daemon     daemon
     * @param priority   priority
     * @return {@linkplain ThreadFactory}
     */
    public static ThreadFactory create(final String namePrefix, final boolean daemon, final int priority) {
        return new XxlThreadFactory(namePrefix, daemon, priority);
    }

    @Override
    public Thread newThread(final Runnable runnable) {
        Thread thread = new Thread(THREAD_GROUP, runnable,
                THREAD_GROUP.getName() + "-" + namePrefix + "-" + THREAD_NUMBER.getAndIncrement());
        thread.setDaemon(daemon);
        thread.setPriority(priority);

        return thread;
    }

线程池:我将核心线程数和最大线程数设置为相同, 线程数取决于入参或者根据cpu核数来确定,因为这个同步任务是io密集型,所以如果没有传入自定义线程参数,那么就取cpu核数2倍左右的值为线程数(当然最终要测试之后取一个最佳数)。根据总任务数设置阻塞队列size=600。自定义拒绝策略:当线程池队列满了,让主线程进入一个一秒一次的循环,每次去判断当前队列任务是否被消耗过半了,直到队列里面的任务被消耗到一半以下,才让主线程break出循环继续提交任务。

这里获取cpu核数的方式也是以前看ConcurrentHashMap源码的时候学到的。大家知道看源码的好处了吧,其他的暂且不提,但一定会让你变成一个更优质的搬运工0.0

private ExecutorService getThreadPool(String param) {
        int poolSizeParam = 0;
        if (org.apache.commons.lang3.StringUtils.isNotBlank(param) && param.matches("[0-9]+")) {
            poolSizeParam = Integer.parseInt(param);
        }
        //根据传入的参数或者获取当前系统的cpu核心数,来确定线程池核心线程数和最大线程数
        int poolSize = poolSizeParam > 0 && poolSizeParam < NCPU * 3 + 3 ? poolSizeParam : NCPU <= 2 ? NCPU * 2 + 1 : NCPU * 2 - 1;
        ThreadPoolExecutor jobThreadPool = new ThreadPoolExecutor(poolSize, poolSize,
                10L, TimeUnit.SECONDS, queue,
                XxlThreadFactory.create("refreshCollectJob", false),
                //这里是自定义拒绝策略,当队列满了触发,这里让主线程循环等待
                //直到队列中任务数量被消耗到一半以下,主线程才退出循环继续提交任务
                (r, e) -> {
                    if (!e.isShutdown()) {
                        for (; ; ) {
                            ThreadUtils.sleep(1000);
                            log.info("队列已满,主线程停止提交任务!!!");
                            if (queue.size() < (QUEUE_SIZE / 2)) {
                                log.info("阻塞队列元素达到一半,继续往线程池提交任务!!!");
                                break;
                            }
                        }
                    }
                });
        log.info("线程池初始化完成,系统cpu数={} 核心线程数和最大线程数={},阻塞队列长度={}", NCPU, poolSize, QUEUE_SIZE);
        return jobThreadPool;

提交任务

下面说的提交任务是指,提交给线程池执行的任务。

线程池搞定了,那么接下来就是要提交任务给线程池。我最开始确认的方式是:主线程循环分页查库,每查询出一页数据就提交一个任务,多线程执行的任务就是根据每页获取的数据经过一些列转化处理后循环存储到redis。但是后面突然想到在生产环境一页数据的读取要耗时3秒,一个任务执行只需要20ms左右,这样的话就会出现刚提交一个任务池子里面的其中一个线程20ms就执行完了就等着,3秒后才提交下一个任务,出现供不应求的情况,这样效率基本没有什么提升。

优化后的方式:将分页查询也放到任务里面,多线程执行的任务就是根据当前获取的pageNumpageSize,查询出当前页数据,将数据进行一系列处理转化循环存储到redis。提交任务的主线程使用一个for循环来提交,根据总页数控制好提交任务的数量。

优化后的代码如下:

 public void kangarooking(){
        int pageSize = 1000;
        //获取线程池
        ExecutorService executor = getThreadPool();
        //记录当前页数
        AtomicInteger pageNumAtomic = new AtomicInteger(1);
        //记录同步失败任务数
        AtomicInteger failCount = new AtomicInteger(0);
        //先将总记录数查询出来
        int totalCount = userDao.selectUserCount();
        //计算得到总任务数(也就是总分页数)
        int taskNum = (totalCount + pageSize - 1) / pageSize;
        logger.info("开始往线程池提交任务 总记录数={} 总任务数={}", totalCount, taskNum);
        long taskStart = System.currentTimeMillis();
        for (int i = 0; i < taskNum; i++) {
            //使用线程池来并行执行多个任务
            executor.execute(() -> {
                int pageNum = pageNumAtomic.getAndIncrement();
                String threadName = Thread.currentThread().getName();
                try {
                    long start = System.currentTimeMillis();
                    logger.info("线程:{} 开始处理第{}页数据...", threadName, pageNum);
                    PageDto page = PageUtil.setPage(pageNum, pageSize);
                    List<UserDto> userDtoList = userCollectDao.selectUser(page.getBeginNum(), page.getEndNum());
                    if (CollectionUtils.isEmpty(userDtoList)) {
                        logger.info("每次 {} 人,第 {} 批没有数据直接返回", pageSize, pageNum);
                        return;
                    }
                    long mid = System.currentTimeMillis();
                    logger.info("线程:{} 第 {} 批数据正在执行,查库耗时={}毫秒", threadName, pageNum, mid - start);
                    //使用redis的pipeline来一次性发送多条指令,减少io耗时
                    redisUtils.hmsetPipeline(() -> {
                        Map<String, Map<String, Object>> resultMap = new HashMap<>();
                        for (UserDto user : userDtoList) {
                            String xxx = userCollect.getXXX();
                            if (!StringUtils.isEmpty(xxx)) {
                                //组装map数据
                                convertMap(resultMap, user, xxx, xxxx);
                            }
                        }
                        return resultMap;
                    }, REDIS_EXPIRES);
                    long end = System.currentTimeMillis();
                    logger.info("线程:{} 第{}批数据刷新完毕,pipeline耗时={}毫秒", threadName, pageNum, end - start);
                } catch (Exception e) {
                    logger.error("线程:{} 第{}批数据刷新失败", threadName, pageNum, e);
                    failCount.incrementAndGet();
                }
            });
        }
        executor.shutdown();
        try {
            logger.info("所有任务提交完毕,等待线程执行剩余任务...");
            //主线程停止循环,等待所有的线程执行完毕
            executor.awaitTermination(60, TimeUnit.MINUTES);
            long taskEnd = System.currentTimeMillis();
            logger.info("所有任务执行完毕,线程池退出。总任务数:{} 成功任务数:{} 失败任务数:{} 任务共耗时:{}毫秒", taskNum, taskNum - failCount.get(), failCount.get(), (taskEnd - taskStart));
        } catch (InterruptedException e) {
            logger.error("线程池退出失败", e);
        }
    }

这样设计的好处就是,我现在提交任务不用等数据库分页查询耗时(因为优化后,分页查询被移到多线程执行的代码块里面了)。因为是for循环提交任务,通过事先查询总页数来确定了任务提交的总数。效果就是主线程可以在1秒内将所有任务提交完毕,接下来就给线程池里面的所有线程分领任务去执行。这样相当于多页数据一起执行,大大提高了处理效率。

redis pipeline

相信大家也看到了代码中我使用了一个hmsetPipeline();方法

Redis本身是基于Request/Response协议的,正常情况下,客户端发送一个命令,等待Redis应答,Redis在接收到命令,处理后应答。在这种情况下,如果同时需要执行大量的命令,那就是等待上一条命令应答后再执行,这中间不仅仅多了RTT(Round Time Trip),而且还频繁的调用系统IO,发送网络请求。

这说的就是旧代码的那种调用方式,在一个循环里面不断的执行hmset命令,每循环一次都得有一次请求和响应,浪费时间。

为了提升效率,这时候Pipeline出现了,它允许客户端可以一次发送多条命令,而不等待上一条命令执行的结果,这和网络的Nagel算法有点像(TCP_NODELAY选项)。不仅减少了RTT,同时也减少了IO调用次数(IO调用涉及到用户态到内核态之间的切换)

所以我选择使用redis pipeline,将一个循环里面的命令打包,然后统一的发送给redis处理。这样从多次RTT,减少到一次RTT,从而提高效率,节约资源。

下面是hmsetPipeline方法的具体实现。RedisTemplate提供了redis pipelineapi,所以我这里直接使用api调用(大家有兴趣的可以去看看源码,其实就是对Jedis api的封装)。

/**
     * 使用redis pipeline set Map<K,Map<K,V>> 类型的数据
     * @param supplier 函数式编程
     * @param time 设置这批key的过期时间
     */
    public void hmsetPipeline(Supplier<Map<K, V>> supplier, long time) {
        redisTemplate.executePipelined(new SessionCallback<Integer>() {
            @Override
            public <K, V> Integer execute(RedisOperations<K, V> operations) throws DataAccessException {
                @SuppressWarnings("unchecked")
                Map<K, V> kMapMap = (Map<K, V>) supplier.get();
                for (Map.Entry<K, V> stringMapEntry : kMapMap.entrySet()) {
                    K key = stringMapEntry.getKey();
                    V value = stringMapEntry.getValue();
                    operations.opsForHash().putAll(key, (Map<?, ?>) value);
                    if (time > 0) {
                        operations.expire(key, time, TimeUnit.SECONDS);
                    }
                }
                return null;
            }
        });
    }

函数式编程

上面的入参Supplier<Map<K, V>> supplier可以实现函数式编程的写法。

Java8新引入函数式编程方式,大大的提高了编码效率。我们要清楚一个概念--函数式接口;它是指有且只有一个抽象方法的接口,一般通过@FunctionalInterface这个注解来表明某个接口是一个函数式接口。函数式接口是Java支持函数式编程的基础。

并不是说加上@FunctionalInterface才能是函数式接口,只要满足有且只有一个抽象方法的接口条件就行,这个注解只不过起到一个标识作用,没有实际的作用。

常见的函数式接口有Supplier,Function,Runable,Comparable等...你也可以自定义一个函数式接口,只要满足上面说的只有一个唯一抽象接口的条件。

下面我们就来理解怎么使用函数式接口: 先给大家看一个Supplier接口匿名内部类的写法:

匿名内部类写法.png

然后我把他转化为函数式写法:

    this.redisURISupplier = ()-> {
       AtomicReference<RedisURI> uriFieldReference = new AtomicReference<>();
       RedisURI uri = uriFieldReference.get();
       if (uri != null) {
          return uri;
       }
       uri = RedisURI.class.cast(new DirectFieldAccessor(client).getPropertyValue("redisURI"));
       return uriFieldReference.compareAndSet(null, uri) ? uri : uriFieldReference.get();
    };

这种写法就相当于创建了一个Supplier的实现类,{}括号里面的方法就是唯一抽象方法--get();方法的具体实现。然后将这个实现类赋值给this.redisURISupplier(因为这种写法,编译器就认定{}括号里面的代码就是唯一抽象方法的实现,如果你有多个抽象方法,编译期就会报错,因为它不知道你到底要实现哪个抽象方法。)

自定义实现

我们来自己实现一个函数式接口:

/**
 * 这里的@FunctionalInterface可加可不加
 * 它只是检查与标识当前接口是否是一个函数式接口
 * @param <T>
 */
@FunctionalInterface
public interface MyFunction<T> {
    T test1(String name, Integer age);
}

当有两个抽象方法时,编译报错:

两个函数抽象方法注解检查报错.png

对比一下匿名内部类的使用,就更容易看懂函数式写法是怎么回事了。

public class FunctionClient {
    public static String get(MyFunction<String> function, String name, Integer age) {
        //省略XXXX逻辑
        return function.test1(name, age);
    }

    public static void main(String[] args) {
        //这里的(name, age)相当于重写test1方法的形参
        get((name, age) -> {
            System.out.println("我是函数式写法,我相当于MyFunction接口的一个实现类");
            System.out.println("这里是MyFunction.test1的重写方法");
            return "返回值";
        }, "kangarooking", 24);

        /********************************************/

        get(new MyFunction<String>() {
            @Override
            public String test1(String name, Integer age) {
                System.out.println("我是匿名内部类写法,我也相当于MyFunction接口的一个实现类");
                System.out.println("这里是MyFunction.test1的重写方法");
                return "返回值";
            }
        }, "kangarooking", 20);

        /************* 或者还有小伙伴没有明白的,看下面 ***************/

        MyFunction<String> myFunction = new MyFunction<String>() {
            @Override
            public String test1(String name, Integer age) {
                System.out.println("我是匿名内部类写法,我也相当于MyFunction接口的一个实现类");
                System.out.println("这里是MyFunction.test1的重写方法");
                return "返回值";
            }
        };
        get(myFunction, "kangarooking", 20);
    }
}

还有一个扩展小知识,不知道大家在看框架,或者公司某个接口的实现类时,会不会看到下图的情况:

不同的写法.png

anonymous(匿名的)开头的表示匿名内部类的实现。上面()->{}这样的表示函数式实现。不信的小伙伴可以自己去找个函数式接口看一下。

线程池shutdown

当我把优化点做完后,我想了一下我想要统计整个线程池执行的耗时,以及当所有线程执行玩任务之后将线程池关闭。我脑子里面直接就浮现出之前背八股文了解到的线程池shutdown,我立马又打开百度复习了一遍。嗯~嗦嘎,I know,继续开干。思考了一下之后就写出了,优化后代码里面的这段代码:

    executor.shutdown();
    try {
        logger.info("所有任务提交完毕,等待线程执行剩余任务...");
        //主线程停止循环,等待所有的线程执行完毕
        executor.awaitTermination(60, TimeUnit.MINUTES);
        long taskEnd = System.currentTimeMillis();
        logger.info("所有任务执行完毕,线程池退出。总任务数:{} 成功任务数:{} 失败任务数:{} 任务共耗时:{}毫秒", taskNum, taskNum - failCount.get(), failCount.get(), (taskEnd - taskStart));
    } catch (InterruptedException e) {
        logger.error("线程池退出失败", e);
    }

executor.shutdown();是不会让线程池立马关闭的,它会等待所有的任务执行完毕之后再关闭线程池。 executor.awaitTermination(60, TimeUnit.MINUTES);这个方法是为了阻塞主线程,让主线程在这里停留等待所有的线程执行完毕,也就是为了统计整个任务的执行耗时。这里的60是设置等待超过60秒,主线程直接就往下执行了,不再等待。想了解细节实现的铁子可以去看看executor.shutdown();和executor.shutdownNow();的源码,或者等我哪天写个文章。

结果展示

讲到这里,这个同步任务优化的差不多了。我们看看大致的流程图:

用户自选同步方案.jpg

看看前后的效率对比: 老任务16秒

老任务执行效率截图.png

优化后只需要800毫秒

优化后的新任务.png

这是在数据量,和任务量较小的情况下,比原来快了20倍左右,真正放到线上跑的话预估速度会比原来快30~60倍。实际情况等后面再来告诉大家。

doutub_img.png

微信公众号「 袋鼠先生的客栈 」,有问题评论区见。如果你觉得我的分享对你有帮助,或者觉得我有两把刷子,就支持一下我这个初出茅庐的writer吧,三连,三连,三连~~。点赞👍 关注❤️ 分享👥