# 应用性能优化实战

loading

# 一、项目背景和问题

这个项目来自为电信教育系统设计开发的一套自适应的考试学习系统,面向的用户主要是职业学院的的老师和学生以及短时间脱产学习的在职人员。什么叫自适应呢?就是当时提出一种教育理念,对学员的学习要求经常考试进行检查,学员的成绩出来以后,老师会要求系统根据每个学员的考卷上错误的题目从容量为 10 万左右的题库中抽取题目,为每个学员生成一套各自个性化的考后复习和练习的离线练习册。所以,每次考完试,特别是比较大型的考试后,要求生成的离线文档数量是比较多的,一个考试 2000 多人,就要求生成 2000 多份文档。当时我们在做这个项目的时候,因为时间紧,人员少,很快做出第一版就上线运营了。

当然,大家可以想到,问题是很多的,但是反应最大,用户最不满意的就是这个离线文档生成的功能,用户最不满意的点:离线文档生成的速度非常慢,慢到什么程度呢?一份离线文档的生成平均时长在 50~55 秒左右,遇到成绩不好的学生,文档内容多的,生成甚至需要 3 分钟,大家可以算一下,2000 人,平均55 秒,全部生成完,需要 2000*55=110000 秒,大约是 30 个小时。

为什么如此之慢?这跟离线文档的生成机制密切相关,对于每一个题目要从保存题库的数据库中找到需要的题目,单个题目的表现形式如图,数据库中存储则采用类 html 形式保存,对于每个题目而言,解析题目文本,找到需要下载的图片,每道题目都含有大量或大型的图片需要下载,等到文档中所有题目图片下载到本地完成后,整个文档才能继续进行处理。

# 二、逐步分析和改进

# 1、第一版-web串行

第一版的实现,服务器在接收到老师的请求后,就会把批量生成请求分解为一个个单独的任务,然后串行的完成。

web串行

主业务的大致逻辑如下:

public class SingleWeb {

    public static void main(String[] args) {
        System.out.println("题库开始初始化……");
        SLQuestionBank.initBank();
        System.out.println("题库初始化完成。");

        List<SrcDocVo> docList = CreatePendingDocs.makePendingDoc(3);
        long startTotal = System.currentTimeMillis();
        for (SrcDocVo doc : docList) {
            System.out.println("[" + doc.getDocName() + "]共有题目:" + doc.getQuestionList().size() + "个");
            System.out.println("开始处理文档:" + doc.getDocName() + "……");
            long start = System.currentTimeMillis();
            String localName = ProduceDocService.makeDoc(doc);
            System.out.println("文档" + localName + "生成耗时:" + (System.currentTimeMillis() - start) + "ms");
            start = System.currentTimeMillis();
            String remoteUrl = ProduceDocService.upLoadDoc(localName);
            System.out.println("已上传至[" + remoteUrl + "]耗时:" + (System.currentTimeMillis() - start) + "ms");
        }
        System.out.println("共耗时:" + (System.currentTimeMillis() - startTotal) + "ms");
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

第8行生成了3分文档并上传至服务器,可以看出通过一个 for 循环,串行的将逻辑执行完毕,生成3分文档并上传至服务器的大致时间需要 146s 左右,也就是生成一个文档需要 49s左右。

# 2、第二版-服务化、异步化

第二版的实现上,首先做了个服务拆分,将生成离线文档的功能拆了出来成为了单独的服务,对外提供 RPC 接口,在 WEB 服务器接收到了老师们提出的批量生成离线文档的要求以后,将请求拆分后再一一调用离线文档生成 RPC 服务,这个 RPC 服务在实现的时候有一个缓冲的机制,会将收到的请求进行缓存,然后迅速返回一个结果给调用者,告诉调用者已经收到了请求,这样 WEB 服务器也可以很快的对用户的请求进行应答。

大致逻辑如下:

public class RpcServiceWebV1 {

    /**
     * 处理文档生成的线程池
     */
    private static ExecutorService docMakeService = Executors.newFixedThreadPool(Consts.THREAD_COUNT * 2);

    /**
     * 处理文档上传的线程池
     */
    private static ExecutorService docUploadService = Executors.newFixedThreadPool(Consts.THREAD_COUNT * 2);

    private static CompletionService<String> docCompletingService = new ExecutorCompletionService<>(docMakeService);
    private static CompletionService<String> docUploadCompletingService = new ExecutorCompletionService<>(docUploadService);

    public static void main(String[] args) throws ExecutionException, InterruptedException {
        int docCount = 60;
        System.out.println("题库开始初始化……");
        SLQuestionBank.initBank();
        System.out.println("题库初始化完成。");
        List<SrcDocVo> docList = CreatePendingDocs.makePendingDoc(docCount);
        long startTotal = System.currentTimeMillis();
        for (SrcDocVo doc : docList) {
            docCompletingService.submit(new MakeDocTask(doc));
        }
        for (int i = 0; i < docCount; i++) {
            Future<String> future = docCompletingService.take();
            docUploadCompletingService.submit(new UploadTask(future.get()));
        }
        // 展示时间
        for (int i = 0; i < docCount; i++) {
            docUploadCompletingService.take().get();
        }
        System.out.println("共耗时:" + (System.currentTimeMillis() - startTotal) + "ms");
    }

    /**
     * 生成文档的工作任务
     */
    private static class MakeDocTask implements Callable<String> {

        private SrcDocVo pendingDocVo;

        public MakeDocTask(SrcDocVo pendingDocVo) {
            this.pendingDocVo = pendingDocVo;
        }

        @Override
        public String call() throws Exception {
            long start = System.currentTimeMillis();
            String result = ProduceDocService.makeDoc(pendingDocVo);
            System.out.println("文档" + result + "生成耗时:" + (System.currentTimeMillis() - start) + "ms");
            return result;
        }
    }

    /**
     * 上传文档的工作任务
     */
    private static class UploadTask implements Callable<String> {

        private String fileName;

        public UploadTask(String fileName) {
            this.fileName = fileName;
        }

        @Override
        public String call() throws Exception {
            long start = System.currentTimeMillis();
            String result = ProduceDocService.upLoadDoc(fileName);
            System.out.println("已上传至[" + result + "]耗时:" + (System.currentTimeMillis() - start) + "ms");
            return result;
        }
    }
}
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
69
70
71
72
73
74
75
76

这里使用了2个线程池以及2个 CompletionService,首先使用 docCompletingService 提交所有的生成文档的任务,然后通过 docCompletingService 去获取已经生成好文档的任务,将它们上传至服务器,由于不再是串行执行任务,所以大大的减少了时间。

此时,生成60分文档并上传至服务器的大致时间需要 160s 左右,也就是生成一个文档需要 2.67s左右。

这个离线文档的生成独立性是很高的,天生就适用于多线程并发进行。所以在 RPC 服务实现的时候,使用了生产者消费者模式,RPC 接口的实现收到了一个调用方的请求时,会把请求打包放入一个容器,然后会有多个线程进行消费处理,也就是生成每个具体文档。

当文档生成后,再使用一次生产者消费者模式,投入另一个阻塞队列,由另外的一组线程负责进行上传。当上传成功完成后,由上传线程返回文档的下载地址,表示当前文档已经成功完成。

文档具体的下载地址则由 WEB 服务器单独去数据库或者缓存中去查询。

服务化、异步化

# 3、第三版-题目的并行化

对于每个离线文档生成本身,我们来看看它的业务:

  1. 从容量为 10 万左右的题库中为每个学生抽取适合他的题目
  2. 每道题目都含有大量的图片需要下载到本地,和文字部分一起渲染。

但是我们仔细考察整个系统的业务就会发现,我们是在一次考试后为学员生成自适应的练习册,换句话说,不管考试考察的内容如何,学生的成绩如何,每次考试的知识点是有限的,而从这些知识点中可以抽取的相关联的题目数也总是有限的,不同的学生之间所需要的题目会有很大的重复性。

举个例子我们为甲学生因为他考卷上的错误部分抽取了 80 个题目,有很大的概率其他学生跟甲学生错误的地方会有重复,相对应的题目也会有重复。对于这部分题目,我们是完全没有必要重复处理的,包括从数据库中重新获取题目、解析和下载图片。这也是我们可供优化的一大突破点。

其次,一篇练习册是由很多的题目组成的,每个题目相互之间是独立的,我们也可以完全并行的、异步的处理每个题目。

具体怎么做?要避免重复工作肯定是使用缓存机制,对已处理过的题目进行缓存。我们看看怎么使用缓存机制进行优化。这个业务,毋庸置疑,map 肯定是最适合的,因为我们要根据题目的 id 来找题目的详情,用哪个 map?我们现在是在多线程下使用,考虑的是并发安全的 concurrentHashMap

当我们的服务接收到处理一个题目的请求,首先会在缓存中 get() 一次,没有找到,可以认为这是个新题目,准备向数据库请求题目数据并进行题目的解析,图片的下载。

这里有一个并发安全的点需要注意,因为是多线程的应用,会发生多个线程在处理多个文档时有同时进行处理相同题目的情况,这种情况下不做控制,一是会造成数据冲突和混乱,比如同时读写同一个磁盘文件,二是会造成计算资源的浪费,同时为了防止文档的生成阻塞在当前题目上,因此每个新题目的处理过程会包装成一个 Callable 投入一个线程池中 而把处理结果作为一个 Future 返回,等到线程在实际生成文档时再从 Futureget() 出结果进行处理。因此在每个新题目实际处理前,还会检查当前是否有这个题目的处理任务正在进行。

如果题目在缓存中被找到,并不是直接引用就可以了,因为题库中的题目因为种种关系存在被修改的可能,比如存在错误,比如可能内容被替换,这个时候缓存中数据其实是失效过期的,所以需要先行检查一次。如何检查?

我们前面说过题库中的题目平均长度在 800 个字节左右,直接 equals() 来检查题目正文是否变动过,明显效率比较低,所以我们这里又做了一番处理,什么处理?对题目正文事先做了一次 SHA 的摘要并保存在数据库,并且要求题库开发小组在处理题目数据入库的时候进行 SHA 摘要。

在本机缓存中同样保存了这个摘要信息,在比较题目是否变动过时,首先检查摘要是否一致,摘要一致说明题目不需要更新,摘要不一致时,才需要更新题目文本,将这个题目视为新题目,进入新题目的处理流程,这样的话就减少了数据的传输量,也降低了数据库的压力。

题目处理的流程就变为:

题目的并行化

第二次改进:

  1. 在题目实体类 QuestionInDBVo 中增加一个 sha 属性,用于比较题目的缓存是否失效

  2. 增加一个题目保存在缓存中的实体类 QuestionInCacheVo,用于缓存题目的内容和 sha 值。

  3. 增加一个并发处理时返回的题目结果实体类 TaskResultVo,按照我们前面的描述,我们可以得知,题目要么已经处理完成,要么正在处理,所以在获取题目结果时,先从 ·questionDetail· 获取一次,如果获取为 null,则从 questionFuture 获取。那么这个类的构造方法需要单独处理一下。

public class TaskResultVo {

    /**
     * 题目处理后的文本
     */
    private final String questionDetail;

    /**
     * 处理题目的任务
     */
    private final Future<QuestionInCacheVo> questionFuture;

    public TaskResultVo(String questionDetail) {
        this.questionDetail = questionDetail;
        this.questionFuture = null;
    }

    public TaskResultVo(Future<QuestionInCacheVo> questionFuture) {
        this.questionFuture = questionFuture;
        this.questionDetail = null;
    }

    public String getQuestionDetail() {
        return questionDetail;
    }

    public Future<QuestionInCacheVo> getQuestionFuture() {
        return questionFuture;
    }
}
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
  1. 在处理文档的服务的类 ProduceDocService 中增加一个处理文档的新方法 makeDocAsync(),这个方法专门异步的处理文档。
    public static String makeDocAsync(SrcDocVo pendingDocVo) throws ExecutionException, InterruptedException {
        System.out.println("开始处理文档:" + pendingDocVo.getDocName());
        // 每个题目的处理结果,key 是题目的 id,value 是处理题目的返回结果
        Map<Integer, TaskResultVo> questionResultMap = new HashMap<>();
        for (Integer questionId : pendingDocVo.getQuestionList()) {
            questionResultMap.put(questionId, ParallelQuestionService.makeQuestion(questionId));
        }

        StringBuffer sb = new StringBuffer();
        for (Integer questionId : pendingDocVo.getQuestionList()) {
            TaskResultVo taskResultVo = questionResultMap.get(questionId);
            sb.append(taskResultVo.getQuestionDetail() == null ?
                    taskResultVo.getQuestionFuture().get().getQuestionDetail() : taskResultVo.getQuestionDetail());

        }
        return "complete_" + System.currentTimeMillis() + "_" + pendingDocVo.getDocName() + ".pdf";
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
  1. 、增加一个优化题目处理的类 ParallelQstService,其中提供了并发处理题目的方法
public class ParallelQuestionService {

    /**
     * 题目在本地的缓存
     */
    private static ConcurrentHashMap<Integer, QuestionInCacheVo> questionCache = new ConcurrentHashMap<>();

    /**
     * 正在处理的题目的缓存
     */
    private static ConcurrentHashMap<Integer, Future<QuestionInCacheVo>> processingQuestionCache = new ConcurrentHashMap<>();

    /**
     * 处理题目的线程池
     */
    private static ExecutorService makeQuestionExecutor = Executors.newCachedThreadPool();

    public static TaskResultVo makeQuestion(Integer questionId) {
        QuestionInCacheVo questionInCacheVo = questionCache.get(questionId);
        if (null == questionInCacheVo) {
            System.out.println("题目[" + questionId + "]不存在,准备启动任务");
            return new TaskResultVo(getQuestionFuture(questionId));
        } else {
            String questionSha = SLQuestionBank.getQuestionSha(questionId);
            if (questionInCacheVo.getQuestionSha().equals(questionSha)) {
                System.out.println("题目[" + questionId + "]在缓存已存在,可以使用");
                return new TaskResultVo(questionInCacheVo.getQuestionDetail());
            } else {
                System.out.println("题目[" + questionId + "]在缓存已过期,准备更新");
                return new TaskResultVo(getQuestionFuture(questionId));
            }
        }
    }

    private static Future<QuestionInCacheVo> getQuestionFuture(Integer questionId) {
        Future<QuestionInCacheVo> questionFuture = processingQuestionCache.get(questionId);
        try {
            if (questionFuture == null) {
                QuestionInDBVo questionInDBVo = SLQuestionBank.getQuetion(questionId);
                QuestionTask questionTask = new QuestionTask(questionId, questionInDBVo);

                // 将任务包装成FutureTask,投入线程池执行和保存到缓存
                FutureTask<QuestionInCacheVo> futureTask = new FutureTask<>(questionTask);
                questionFuture = processingQuestionCache.putIfAbsent(questionId, futureTask);
                if (questionFuture == null) {
                    // 当前线程成功占位了
                    questionFuture = futureTask;
                    makeQuestionExecutor.execute(futureTask);
                    System.out.println("当前任务已启动,请等待完成后");
                } else {
                    System.out.println("有其他线程开启了题目的计算任务,本任务无需开启");
                }
            } else {
                System.out.println("当前已经有了题目的计算任务,不必重复开启");
            }
            return questionFuture;
        } catch (Exception e) {
            processingQuestionCache.remove(questionId);
            e.printStackTrace();
            throw e;
        }
    }

    /**
     * 解析题目的任务类,调用最基础的题目生成服务即可
     */
    private static class QuestionTask implements Callable<QuestionInCacheVo> {

        private Integer questionId;
        private QuestionInDBVo questionDBVo;

        public QuestionTask(Integer questionId, QuestionInDBVo questionDBVo) {
            this.questionId = questionId;
            this.questionDBVo = questionDBVo;
        }

        @Override
        public QuestionInCacheVo call() throws Exception {
            try {
                String questionDetail = QuestionService.makeQuestion(questionId, questionDBVo.getDetail());
                String questionSha = questionDBVo.getSha();
                QuestionInCacheVo questionCacheVo = new QuestionInCacheVo(questionDetail, questionSha);
                questionCache.put(questionId, questionCacheVo);
                return questionCacheVo;
            } finally {
                // 无论正常还是异常,均要将生成题目的任务从缓存中移除
                processingQuestionCache.remove(questionId);
            }
        }
    }
}
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
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91

最终实现:

public class RpcServiceWebV2 {

    /**
     * 处理文档生成的线程池
     */
    private static ExecutorService docMakeService = Executors.newFixedThreadPool(Consts.THREAD_COUNT * 2);

    /**
     * 处理文档上传的线程池
     */
    private static ExecutorService docUploadService = Executors.newFixedThreadPool(Consts.THREAD_COUNT * 2);

    private static CompletionService<String> docCompletingService = new ExecutorCompletionService<>(docMakeService);
    private static CompletionService<String> docUploadCompletingService = new ExecutorCompletionService<>(docUploadService);


    public static void main(String[] args) throws ExecutionException, InterruptedException {
        int docCount = 60;
        System.out.println("题库开始初始化……");
        SLQuestionBank.initBank();
        System.out.println("题库初始化完成。");
        List<SrcDocVo> docList = CreatePendingDocs.makePendingDoc(docCount);
        long startTotal = System.currentTimeMillis();
        for (SrcDocVo doc : docList) {
            docCompletingService.submit(new MakeDocTask(doc));
        }
        for (int i = 0; i < docCount; i++) {
            Future<String> future = docCompletingService.take();
            docUploadCompletingService.submit(new UploadTask(future.get()));
        }
        // 展示时间
        for (int i = 0; i < docCount; i++) {
            docUploadCompletingService.take().get();
        }
        System.out.println("共耗时:" + (System.currentTimeMillis() - startTotal) + "ms");
    }

    /**
     * 生成文档的工作任务
     */
    private static class MakeDocTask implements Callable<String> {

        private SrcDocVo pendingDocVo;

        public MakeDocTask(SrcDocVo pendingDocVo) {
            this.pendingDocVo = pendingDocVo;
        }

        @Override
        public String call() throws Exception {
            long start = System.currentTimeMillis();
            String result = ProduceDocService.makeDocAsync(pendingDocVo);
            System.out.println("文档" + result + "生成耗时:" + (System.currentTimeMillis() - start) + "ms");
            return result;
        }
    }

    /**
     * 上传文档的工作任务
     */
    private static class UploadTask implements Callable<String> {

        private String fileName;

        public UploadTask(String fileName) {
            this.fileName = fileName;
        }

        @Override
        public String call() throws Exception {
            long start = System.currentTimeMillis();
            String result = ProduceDocService.upLoadDoc(fileName);
            System.out.println("已上传至[" + result + "]耗时:" + (System.currentTimeMillis() - start) + "ms");
            return result;
        }
    }
}
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
69
70
71
72
73
74
75
76
77

此时,生成60分文档并上传至服务器的大致时间需要 29s 左右,也就是生成一个文档需要 0.48s左右。

# 4、第四版-线程池中线程数的优化

# (1) 数据结构的改进

作为一个长期运行的服务,如果我们使用 ConcurrentHashMap,意味着随着时间的推进,缓存对内存的占用会不断的增长。最极端的情况,十万个题目全部被加载到内存,这种情况下会占据多少内存呢?我们做了统计,题库中题目的平均长度在 800 个字节左右,十万个题目大约会使用 75M 左右的空间。

看起来还好,但是一些问题。

第一,我们除了题目本身还会有其他的一些附属信息需要缓存,比如题目图片在本地磁盘的存储位置等等,那就说,实际缓存的数据内容会远远超过 800 个字节,

第二,Map 类型的的内存使用效率是比较低的,以 HashMap 为例,内存利用率一般只有 20%到 40%左右,而 ConcurrentHashMap 只会更低,有时候只有 HashMap 的十分之一到4分之一,这也就是说十万个题目放在 ConcurrentHashMap 中会实际占据几百兆的内存空间,是很容易造成内存溢出的,也就是大家常见的 OOM

考虑到这种情况,我们需要一种数据结构有 Map 的方便但同时可以限制内存的占用大小或者可以根据需要按照某种策略刷新缓存。最后,在实际的工作中,我们选择了 ConcurrentLinkedHashMap,这是由 Google 开源一个线程安全的 HashMap,它本身是对 ConcurrentHashMap 的封装,可以限定最大容量,并实现一个了基于 LRU 也就是最近最少使用算法策略的进行更新的缓存。很完美的契合了我们的要求,对于已经缓冲的题目,越少使用的就可以认为这个题目离当前考试考察的章节越远,被再次选中的概率就越小,在容量已满,需要腾出空间给新缓冲的题目时,越少使用就会优先被清除。

# (2) 线程数的设置

原来我们设置的线程数按照我们通用的 IO 密集型任务,两个线程池设置的都是机器的 CPU核心数 * 2,但是这个就是最佳的吗?不一定,通过反复试验我们发现,处理文档的线程池线程数设置为 CPU核心数 * 4,继续提高线程数并不能带来性能上的提升。而因为我们改进后处理文档的时间和上传文档的时间基本在 1:4 到 1:3 的样子,所以处理文档的线程池线程数设置为 CPU核心数 * 4 * 3

此种改进方法实现如下:

public class RpcServiceWebV3 {

    /**
     * 处理文档生成的线程池
     */
    private static ExecutorService docMakeService = Executors.newFixedThreadPool(Consts.THREAD_COUNT * 4);

    /**
     * 处理文档上传的线程池
     */
    private static ExecutorService docUploadService = Executors.newFixedThreadPool(Consts.THREAD_COUNT * 4 * 3);

    private static CompletionService<String> docCompletingService = new ExecutorCompletionService<>(docMakeService);
    private static CompletionService<String> docUploadCompletingService = new ExecutorCompletionService<>(docUploadService);


    public static void main(String[] args) throws ExecutionException, InterruptedException {
        int docCount = 60;
        System.out.println("题库开始初始化……");
        SLQuestionBank.initBank();
        System.out.println("题库初始化完成。");
        List<SrcDocVo> docList = CreatePendingDocs.makePendingDoc(docCount);
        long startTotal = System.currentTimeMillis();
        for (SrcDocVo doc : docList) {
            docCompletingService.submit(new MakeDocTask(doc));
        }
        for (int i = 0; i < docCount; i++) {
            Future<String> future = docCompletingService.take();
            docUploadCompletingService.submit(new UploadTask(future.get()));
        }
        // 展示时间
        for (int i = 0; i < docCount; i++) {
            docUploadCompletingService.take().get();
        }
        System.out.println("共耗时:" + (System.currentTimeMillis() - startTotal) + "ms");
    }

    /**
     * 生成文档的工作任务
     */
    private static class MakeDocTask implements Callable<String> {

        private SrcDocVo pendingDocVo;

        public MakeDocTask(SrcDocVo pendingDocVo) {
            this.pendingDocVo = pendingDocVo;
        }

        @Override
        public String call() throws Exception {
            long start = System.currentTimeMillis();
            String result = ProduceDocService.makeDocAsync(pendingDocVo);
            System.out.println("文档" + result + "生成耗时:" + (System.currentTimeMillis() - start) + "ms");
            return result;
        }
    }

    /**
     * 上传文档的工作任务
     */
    private static class UploadTask implements Callable<String> {

        private String fileName;

        public UploadTask(String fileName) {
            this.fileName = fileName;
        }

        @Override
        public String call() throws Exception {
            long start = System.currentTimeMillis();
            String result = ProduceDocService.upLoadDoc(fileName);
            System.out.println("已上传至[" + result + "]耗时:" + (System.currentTimeMillis() - start) + "ms");
            return result;
        }
    }
}
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
69
70
71
72
73
74
75
76
77

这一种的改进相对于第二次改进,仅仅是线程池中线程数设置的不同。

# (3) 缓存的改进

在这里我们除了本地内存缓存还使用了本地文件存储,启用了一个二级缓存机制。为什么要使用本地文件存储?因为考虑到服务器会升级、会宕机,已经在内存中缓存的数据会丢失,为了避免这一点,我们将相关的数据在本地进行了一个持久化的操作,保存在了本地磁盘。

此时,生成60分文档并上传至服务器的大致时间需要 13s 左右,也就是生成一个文档需要 0.21s左右。

# 三、改进对比

版本 处理一个文档需要的时间
Web串行 49s
服务化,异步化 2.67s
题目的并行化 0.48s
线程数的优化 0.21s

# 四、后记

这次项目优化的总结:

  • 性能优化一定要建立在对业务的深入分析上,比如我们在性能优化的切入点,在缓存数据结构的选择就建立在对业务的深入理解上;
  • 性能优化要善于利用语言的高并发特性,
  • 性能优化多多利用缓存,异步任务等机制,正是因为我们使用这些特性和机制,才让我们的应用在性能上有个了质的飞跃;
  • 引入各种机制的同时要注意避免带来新的不安全因素和瓶颈,比如说缓存数据过期的问题,并发时的线程安全问题,都是需要我们去克服和解决的。

上次更新: 2020-08-21 13:12:21(5 小时前)