Spring集成Servlet3.0 AsyncRequest + Micrometer監(jiān)控的坑

背景

為了提高tomcat線程利用率焦读,避免tomcat連接被打滿增大吞吐量贮缅,準(zhǔn)備在項(xiàng)目中集成Servlet3.0異步請求寄症。

相關(guān)技術(shù)

  • 異步請求的基石:Servlet3.0 async servlet技術(shù)
  • Spring相關(guān)封裝:@Async古徒,Callable,DeferedResult
Async注解

這個注解容易混淆停蕉,其實(shí)他跟 async servlet 沒什么關(guān)系愕鼓。 被 @Async 注解的方法會由 Spring 使用 TaskExecutor 在新線程中異步執(zhí)行。

Callable谷徙,DeferedResult

這兩個才是 Spring 中實(shí)現(xiàn)異步請求的關(guān)鍵拒啰。
@ResponseBody 注解的方法如果返回 Callable 或 DeferedResult ,Spring 會自動替我們將這個請求轉(zhuǎn)換為異步請求完慧。Callable 和 DeferedResult 比較相似谋旦,不過 DeferedResult 更為強(qiáng)大,Callable 是異步執(zhí)行返回結(jié)果屈尼,而 DeferedResult 更為靈活册着,甚至可以在另外一個請求中放置響應(yīng)結(jié)果,類似與閉包脾歧。

  • 如果是簡單的使用場景甲捏,Controller 直接返回 DeferedResult 就可以變成一個異步接口。
  • Spring 處理異步請求的核心類是 RequestMappingHandlerAdapter鞭执, 一個容器級的過濾器司顿,想深入研究去追這個類就行。

問題

??Spring 異步請求默認(rèn)不會使用線程池兄纺,雖然經(jīng)過配置優(yōu)化(AsyncSupportConfigurer)可以使用大溜,但這個線程池是整個服務(wù)共享的,不能做到針對接口進(jìn)行線程池隔離估脆,避免量大請求影響量小請求钦奋。
??為了達(dá)到接口級線程隔離的目的,只能自己封裝 Servlet API 來實(shí)現(xiàn)疙赠。大致處理流程如下:
在 Controller 層進(jìn)行 AOP 攔截付材,開啟線程池執(zhí)行切面邏輯

request.startAsync();
threadPool.execute(()->{
    ...
    runController...
    writeResponse...
    request.complete();
    ...
})
return null;

??本來這套邏輯跑著挺好,然而當(dāng)集成 Micrometer 進(jìn)行 Http 請求監(jiān)控的時候圃阳,Controller 中的接口只有在 400 狀態(tài)時才被記錄 metrics厌衔。查看 Micrometer 攔截 Http 的代碼:

io.micrometer.spring.web.servlet.WebMvcMetricsFilter.java

    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        HandlerExecutionChain handler = null;
        try {
            MatchableHandlerMapping matchableHandlerMapping = mappingIntrospector.getMatchableHandlerMapping(request);
            if (matchableHandlerMapping != null) {
                handler = matchableHandlerMapping.getHandler(request);
            }
        } catch (Exception e) {
            logger.debug("Unable to time request", e);
            filterChain.doFilter(request, response);
            return;
        }

        final Object handlerObject = handler == null ? null : handler.getHandler();

        // If this is the second invocation of the filter in an async request, we don't
        // want to start sampling again (effectively bumping the active count on any long task timers).
        // Rather, we'll just use the sampling context we started on the first invocation.
        TimingSampleContext timingContext = (TimingSampleContext) request.getAttribute(TIMING_SAMPLE);
        if (timingContext == null) {
            timingContext = new TimingSampleContext(request, handlerObject);
        }

        try {
            filterChain.doFilter(request, response);

            if (request.isAsyncSupported()) {
                // this won't be "started" until after the first call to doFilter
                if (request.isAsyncStarted()) {
                    request.setAttribute(TIMING_SAMPLE, timingContext);
                }
            }

            if (!request.isAsyncStarted()) {
                record(timingContext, response, request,
                    handlerObject, (Throwable) request.getAttribute(DispatcherServlet.EXCEPTION_ATTRIBUTE));
            }
        } catch (NestedServletException e) {
            response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value());
            record(timingContext, response, request, handlerObject, e.getCause());
            throw e;
        }
    }

??從代碼中可以看到,如果開啟了異步捍岳,micrometer 并不會記錄此次請求富寿,這是出于什么原因呢? 注意注釋中寫了: If this is the second invocation of the filter in an async request祟同,也就是說一個異步請求會被 WebMvcMetricsFilter 攔截兩次作喘,第一次是異步的,而第二次不是晕城,所以會在第二次被攔截的時候記錄這個異步請求泞坦。
??為了驗(yàn)證這個說法,clone 了 micrometer 的 simple 運(yùn)行砖顷。結(jié)果一個異步請求還真是會被WebMvcMetricsFilter 攔截兩次贰锁,第一次異步赃梧,第二次異步并未打開。


image.png

順著 RequestMappingHandlerAdapter 找到異步處理核心類:
org.springframework.web.context.request.async.WebAsyncManager.java

    public void startCallableProcessing(final WebAsyncTask<?> webAsyncTask, Object... processingContext)
            throws Exception {
        [...]
        try {
            Future<?> future = this.taskExecutor.submit(() -> {
                Object result = null;
                try {
                    interceptorChain.applyPreProcess(this.asyncWebRequest, callable);
                    result = callable.call();
                }
                catch (Throwable ex) {
                    result = ex;
                }
                finally {
                    result = interceptorChain.applyPostProcess(this.asyncWebRequest, callable, result);
                }
                setConcurrentResultAndDispatch(result);
            });
            interceptorChain.setTaskFuture(future);
        }
        catch (RejectedExecutionException ex) {
            Object result = interceptorChain.applyPostProcess(this.asyncWebRequest, callable, ex);
            setConcurrentResultAndDispatch(result);
            throw ex;
        }
        [...]
    }

    private void setConcurrentResultAndDispatch(Object result) {
        synchronized (WebAsyncManager.this) {
            if (this.concurrentResult != RESULT_NONE) {
                return;
            }
            this.concurrentResult = result;
        }
        [...]
        this.asyncWebRequest.dispatch();
    }

??原來如此豌熄,Spring 并沒有使用 asyncContext.complete() 來結(jié)束這個異步請求授嘀,而是使用 dispatch(),dispatch() 中也會把這個異步請求標(biāo)記成 Completed 狀態(tài)锣险,所以 WebMvcMetricsFilter 會攔截兩次蹄皱。那就照著這個吧 asyncContext.complete() 換成 asyncContext.dispatch()。
??然而芯肤,事與愿違巷折,雖然代碼一樣,但表現(xiàn)完全不一樣崖咨,asyncContext.complete() 換成 asyncContext.dispatch()后并沒有攔截兩次锻拘,而且這個請求會循環(huán)調(diào)用下去,形成一個死循環(huán)击蹲。仔細(xì)分析后發(fā)現(xiàn)問題:

  • Spring DeferedResult 處理流程:
    request -> RequestHandlerMappingAdaptor 攔截 -> WebMvcMetricsFilter 攔截(is async) -> dispatch() -> RequestHandlerMappingAdaptor 攔截 -> WebMvcMetricsFilter 攔截(not async)
  • AOP 封裝 Servlet API的處理流程:
    request -> AOP攔截 -> startAsync() -> RequestHandlerMappingAdaptor 攔截 -> WebMvcMetricsFilter 攔截(is async) -> dispatch() -> AOP攔截 -> startAsync() -> RequestHandlerMappingAdaptor 攔截 -> WebMvcMetricsFilter 攔截(is async) -> dispatch() -> ....
    就是由于多了AOP的攔截署拟,dispatch 之后依然會開啟新的異步請求,導(dǎo)致在這里死循環(huán)歌豺⊥魄睿看來必須要標(biāo)記請求的執(zhí)行狀態(tài),參考 DeferedResult 的處理世曾,
@Slf4j
public class CustomerAsyncWebRequest extends StandardServletAsyncWebRequest {
    private final List<Runnable> errorHandlers = new ArrayList<Runnable>();
    public static final String ASYNC_COMPLETED = "com.xx.async.ASYNC_COMPLETED";

    /**
     * Create a new instance for the given request/response pair.
     *
     * @param request  current HTTP request
     * @param response current HTTP response
     */
    public CustomerAsyncWebRequest(HttpServletRequest request, HttpServletResponse response) {
        super(request, response);
    }

    @Override
    public void onComplete(AsyncEvent event) throws IOException {
        super.onComplete(event);
        this.getRequest().setAttribute(ASYNC_COMPLETED, true);
    }

    // 這個在Spring5.x中已經(jīng)支持
    @Override
    public void onError(AsyncEvent event) throws IOException {
        log.error("AsyncListener onError ", event.getThrowable().getMessage());
        this.errorHandlers.forEach(Runnable::run);
    }

    // 這個在Spring5.x中已經(jīng)支持
    public void addErrorHandler(Runnable errorHandler){
        this.errorHandlers.add(errorHandler);
    }
}

AOP邏輯變?yōu)?/p>

if(customerRequest.isAsyncComplete()){
    return;
}
request.startAsync();
threadPool.execute(()->{
    ...
    runController...
    writeResponse...
    // 這里看著像多余缨恒,但是由于AsyncListener的存在谴咸,這個判斷是必不可少的
    if(customerRequest.isAsyncComplete()){
        return;
    }
    customerRequest.onComplete(null);
    asyncContext.dispatch();
    ...
})
return null;

至此轮听,改造完畢。所以在使用 micrometer 進(jìn)行監(jiān)控的時候岭佳,如果自己使用 Servlet API 實(shí)現(xiàn)異步請求血巍,不能使用 complete() 來結(jié)束,必須使用 dispatch() 結(jié)束請求珊随,micrometer 才會記錄述寡。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市叶洞,隨后出現(xiàn)的幾起案子鲫凶,更是在濱河造成了極大的恐慌,老刑警劉巖衩辟,帶你破解...
    沈念sama閱讀 207,248評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件螟炫,死亡現(xiàn)場離奇詭異,居然都是意外死亡艺晴,警方通過查閱死者的電腦和手機(jī)昼钻,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,681評論 2 381
  • 文/潘曉璐 我一進(jìn)店門掸屡,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人然评,你說我怎么就攤上這事仅财。” “怎么了碗淌?”我有些...
    開封第一講書人閱讀 153,443評論 0 344
  • 文/不壞的土叔 我叫張陵盏求,是天一觀的道長。 經(jīng)常有香客問我亿眠,道長风喇,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 55,475評論 1 279
  • 正文 為了忘掉前任缕探,我火速辦了婚禮魂莫,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘爹耗。我一直安慰自己耙考,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,458評論 5 374
  • 文/花漫 我一把揭開白布潭兽。 她就那樣靜靜地躺著倦始,像睡著了一般。 火紅的嫁衣襯著肌膚如雪山卦。 梳的紋絲不亂的頭發(fā)上鞋邑,一...
    開封第一講書人閱讀 49,185評論 1 284
  • 那天,我揣著相機(jī)與錄音账蓉,去河邊找鬼枚碗。 笑死,一個胖子當(dāng)著我的面吹牛铸本,可吹牛的內(nèi)容都是我干的肮雨。 我是一名探鬼主播,決...
    沈念sama閱讀 38,451評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼箱玷,長吁一口氣:“原來是場噩夢啊……” “哼怨规!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起锡足,我...
    開封第一講書人閱讀 37,112評論 0 261
  • 序言:老撾萬榮一對情侶失蹤波丰,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后舶得,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體掰烟,經(jīng)...
    沈念sama閱讀 43,609評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,083評論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了媚赖。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片霜瘪。...
    茶點(diǎn)故事閱讀 38,163評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖惧磺,靈堂內(nèi)的尸體忽然破棺而出颖对,到底是詐尸還是另有隱情,我是刑警寧澤磨隘,帶...
    沈念sama閱讀 33,803評論 4 323
  • 正文 年R本政府宣布缤底,位于F島的核電站,受9級特大地震影響番捂,放射性物質(zhì)發(fā)生泄漏个唧。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,357評論 3 307
  • 文/蒙蒙 一设预、第九天 我趴在偏房一處隱蔽的房頂上張望徙歼。 院中可真熱鬧,春花似錦鳖枕、人聲如沸魄梯。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,357評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽酿秸。三九已至,卻和暖如春魏烫,著一層夾襖步出監(jiān)牢的瞬間辣苏,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 31,590評論 1 261
  • 我被黑心中介騙來泰國打工哄褒, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留稀蟋,地道東北人。 一個月前我還...
    沈念sama閱讀 45,636評論 2 355
  • 正文 我出身青樓读处,卻偏偏與公主長得像糊治,于是被迫代替她去往敵國和親唱矛。 傳聞我的和親對象是個殘疾皇子罚舱,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,925評論 2 344