我最近参与的一个项目中有一个功能是获取OCR的识别结果,相关接口由另外一家公司写好了,我们在服务器端通过WebService调用就行。然而该功能十分的耗时,平均每次请求大约要花10秒,所以我开始考虑使用并发来增加速度的问题。

业务场景描述

在审判领域,一个案件中会涉及诸多的图片材料,我们希望在法官从案件列表选择一个案件进行查看之后,就开始获取所有图片的OCR结果,避免法官在点击显示OCR之后出现长时间的等待,并且在获取ocr请求时,不应对用户的正常操作产生影响,即用户还可以进行其他的http请求。

前端并发

由于一开始我是负责的前端,所以我希望能从前端解决的问题就尽量在前端解决了。 我们都知道,服务器是默认多线程或多进程的,一个连接建立一个线程,所以只要在前端并发的发送请求给服务器就OK了,问题似乎很简单。

JS的“多”线程

这篇博客JavaScript 进阶(一)JS的”多线程”写得非常好

一开始我以为JS和python 一样没有真正的多线程,只是一个模拟的多线程,但实际上浏览器是支持多线程的,所以这也就有了我们熟悉的ajax异步,在执行ajax请求的Http连接的部分,浏览器另外开了一个线程。借用上述博客中的一张图示意如下:

但不管怎样,只要能实现我要的那种“同时扔出去几十个请求”的效果就OK。查了下资料,发现JS下实现多线程的方式,大概有两种

  • Web Worker, HTML5的新特性,效果很好,但是似乎不支持ie8,所以放弃。相关资料比较全,所以这里就不多赘述了。
  • Concurrent.Thread.js, 一个日本人写的库,相关资料比较少,但是使用比较简单,且不需要像WebWorker一样把异步的代码全部放到单独的JS中,所以非常喜欢。

使用Concurrent.Thread.js作并发

简单的教程可以参考这篇博客: JavaScript 编写线程代码引用Concurrent.Thread.js

上面的博客中,给出了传递基本类型参数的方法:

Concurrent.Thread.create(f, 100000);

其中f为要执行的函数,100000为传递给f的参数,我模仿这个函数,写下了我的代码,大致如下:

Concurrent.Thread.create(function (selectedAjxh, tree_image_list) {
        for (var i = 0; i < tree_image_list.length; i++) {
            var wdId = tree_image_list[i];
            console.log(wdId);
            $.ajax({
                url: baseUrl + '/getOcr',
                type: 'post',
                data: {
                    ajxh: selectedAjxh,
                    wdId: wdId,
                },
                success: function (data) {
                    if (check_result(data)) {
                        vm.tree_image_ocr_dict[data.object.wdId] = data.object.content;
                    }
                }
            })
        }
    }, vm.selectedAjxh, vm.tree_image_list);

就是传入两个参数,一个为int型,一个数组,因为使用的是avalon框架,所以参数都放在vm中进行传递。在这个线程中开启一个for循环,把ajax扔出去,进入另外一个异步,再把请求的结果保存到vm中。一切看起来都非常顺利,至少在少量图片测试的时候。

浏览器对前端并发的限制

上面代码在对少数图片的时候,确实执行比较流畅,能达到预期的效果,但是当图片数量变多时,用户新触发的请求需要花费非常长的时间等待(比如获取文档),用户体验极差。为了定位问题,到底是前端阻塞还是后端不堪压力,我找了很久,一度想甩锅给后端,最后终于在chrome开发者工具中的Timing中发现了这样一个东西

可以发现,其中Stalled的时间达到了惊人的10min,而实际的响应请求(后端处理)时间也就17s,也就是说这条请求在等了10分钟之后才发出去,这就导致了用户后面产生的请求等待的时间比这个还要长! 于是我开始思考为什么会产生这样的原因,于是我又翻到了这一篇博客:关于请求被挂起页面加载缓慢问题的追查(stalled 时间过长)

原来
浏览器对并发数量有限制!!!
浏览器对并发数量有限制!!!
浏览器对并发数量有限制!!!
重要的事情说三遍,其中各种浏览器的限制的数量如下:

由于经常一次产生上百条请求,所以我决定放弃前端并发,改为后端并发。

后端并发

设计思路如下,

前端通过ajax将所有图片的id传给后端,后端开始并发的获取图片和图片的ocr结果,并将结果以HashMap的形式存储在Session中(使用Session主要是为了方便,如果愿意折腾,可以考虑使用NoSQL)。

Java并发的方案就不赘述了,这个资料挺多的,这里就讲一下多线程中使用Session中遇到的坑。

Java 多线程中使用Session作缓存

为了保证前端能及时获得已经处理好了的ocr结果,前端在发送请求后,记录下图片的数量,并使用setInterval向后端进行轮询请求(当然有兴趣的还可以折腾webSocket),后端不断的查询Session(Session中通过每个案件的id对图片进行分类,存储的形式是HashMap, 即: {案件id: {‘wdId0’: ”, ‘wdId1’:”,…}}), 如果返回的ocr的数量达到了图片的数量,则停止轮询。

这里我使用的是 implements Runnable的方式实现的多线程,通过在线程类中定义一个变量,对变量进行赋值的方式进行传参。相关代码如下:

 public class OcrModel {
    private int ajxh;
    private UserModel user;
    private HttpSession session;    
    ....
}


public class OcrThread implements Runnable {
    private OcrModel ocrModel;

    @Override
    public void run() {
        ...
        ...
        session_key = ocrModel.getAjxh + ocrModel.getUser().getName() + "_ocr_map";
        httpSession session = ocrModel.getSession();
        ocrResultMap = ....

        # 保存结果到全局Session中
        session.setAttribute(session_key, ocrResultMap);
        ....
    }   
    public void initOcrModel(HttpSession session, UserModel user, int ajxh) {
        ocrModel = new OcrModel();
        ocrModel.setAjxh(ajxh);
        ocrModel.setUser(user);
        ocrModel.setSession(session);
    }
}
OcrThread ocrThread = new OcrThread();
ocrThread.initOcrModel(session, user, ajxh);
Thread thread = new Thread(ocrThread);
thread.start();

需要注意的是,initOcrModel中传入的session类型是HttpSession,不能使用HttpServletRequest request, 因为如果在多线程环境中使用request.getSession(), 获取到的Session会一直为null,因为HttpServletRequest 是线程不安全的,而HttpSession是线程安全的。

终于,这个问题被圆满解决。

附:分布式服务器中的Session共享

对项目负载均衡后,发现Session没有在各台机器之间共享,查了以下,Springboot 提供了一套比较方便的解决方案,即使用redis做缓存,下面做一个简单的介绍

  • 1.在pom.xml中添加如下依赖:
     <dependency>
        <groupId>org.springframework.session</groupId>
        <artifactId>spring-session-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-redis</artifactId>
        <version>1.3.8.RELEASE</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.data</groupId>
        <artifactId>spring-data-redis</artifactId>
    </dependency>

    2.建立RedisConfig类,添加以下注解

    @Configuration
    @EnableCaching
    @EnableRedisHttpSession(maxInactiveIntervalInSeconds = 3600)
    public class RedisSessionConfig {
    
    }
  • 3.在application.yml中添加redis配置(根据自己的需要进行修改):
    spring:
        redis:
        host: 127.0.0.1
        port: 6379
        password: Passw0rd

    需要注意的是,这里我只使用了一台服务器中的redis来做缓存,如果要把redis也做成分布式,可以参考这篇博客Spring Boot使用Spring Data Redis操作Redis(单机/集群)

    此外,如果不考虑集成spring-data-redis,还可以单独使用ShardedJedis来实现redis的分布式,很久以前写过这样一段代码: JedisPoolManager.java

  • 4.进行简单的测试

进行简单的登录操作(触发session保存)后,在redis-cli中发现了存在这样的键值:

分布式服务器、多线程程序的Session共享问题

按照上面的步骤,确实完成了session的共享,比如用户的登录信息,都被保存在了session里。但是我保存的ocr信息却出现了神奇的bug,在多线程中调试发现,数据确实被存进去了,但是读取时却一直出现null,后来没有耐心调试了,就把缓存的任务交给redis了,如果有类似问题的同学可以交流以下。

附2: 使用nginx做负载均衡

1.安装nginx

2.修改配置文件中的upstream(一般需要自己添加该字段),即进行负载均衡的服务器地址:

    upstream my_project {
        least_conn;
        server localhost:8080 weight=1;
        server localhost:8000 weight=1;
    }

其中localhost:8080和localhost:8000表示两个服务器;
weight表示权重,这里表示平均每两次请求中有一次访问localhost:8080,一次访问localhost:8000;
least_conn比较重要,表示把请求转发给连接数较少的服务器,具体算法可以搜索一下。

3.修改location如下:

    location / {
        proxy_set_header Host localhost:80;
        proxy_pass http://my_project;
    }

4.启动nginx,就可以通过localhost:80来访问服务器了

点击量:1020

分类: 开发笔记

发表评论

电子邮件地址不会被公开。 必填项已用*标注