1. 程式人生 > >dubbo原始碼分析(二):超時原理以及應用場景

dubbo原始碼分析(二):超時原理以及應用場景

本篇主要記錄dubbo中關於超時的常見問題,實現原理,解決的問題以及如何在服務降級中體現作用等。

超時問題

為了檢查對dubbo超時的理解,嘗試回答如下幾個問題,如果回答不上來或者不確定那麼說明此處需要再多研究研究。

我只是針對個人的理解提問題,並不代表我理解的就是全面深入的,但我的問題如果也回答不了,那至少說明理解的確是不夠細的。

  • 超時是針對消費端還是服務端?
  • 超時在哪設定?
  • 超時設定的優先順序是什麼?
  • 超時的實現原理是什麼?
  • 超時解決的是什麼問題?

問題解答

RPC場景

本文所有問題均以下圖做為業務場景,一個web api做為前端請求,product service是產品服務,其中呼叫comment service(評論服務)獲取產品相關評論,comment service從持久層中載入資料。

超時是針對消費端還是服務端?

  • 如果是爭對消費端,那麼當消費端發起一次請求後,如果在規定時間內未得到服務端的響應則直接返回超時異常,但服務端的程式碼依然在執行。

  • 如果是爭取服務端,那麼當消費端發起一次請求後,一直等待服務端的響應,服務端在方法執行到指定時間後如果未執行完,此時返回一個超時異常給到消費端。

dubbo的超時是爭對客戶端的,由於是一種NIO模式,消費端發起請求後得到一個ResponseFuture,然後消費端一直輪詢這個ResponseFuture直至超時或者收到服務端的返回結果。雖然超時了,但僅僅是消費端不再等待服務端的反饋並不代表此時服務端也停止了執行。

按上圖的業務場景,看看生成的日誌:

product service:報超時錯誤,因為comment service 載入資料需要5S,但介面只等1S 。


Caused by: com.alibaba.dubbo.remoting.TimeoutException: Waiting server-side response timeout. start time: 2017-08-05 18:14:52.751, end time: 2017-08-05 18:14:53.764, client elapsed: 6 ms, server elapsed: 1006 ms, timeout: 1000 ms, request: Request [id=0, version=2.0.0, twoway=true, event=false, broken=false, data=RpcInvocation [methodName=getCommentsByProductId, parameterTypes=[class java.lang.Long], arguments=[1], attachments={traceId=6299543007105572864, spanId=6299543007105572864, input=259, path=com.jim.framework.dubbo.core.service.CommentService, interface=com.jim.framework.dubbo.core.service.CommentService, version=0.0.0}]], channel: /192.168.10.222:53204 -> /192.168.10.222:7777
    at com.alibaba.dubbo.remoting.exchange.support.DefaultFuture.get(DefaultFuture.java:107) ~[dubbo-2.5.3.jar:2.5.3]
    at com.alibaba.dubbo.remoting.exchange.support.DefaultFuture.get(DefaultFuture.java:84) ~[dubbo-2.5.3.jar:2.5.3]
    at com.alibaba.dubbo.rpc.protocol.dubbo.DubboInvoker.doInvoke(DubboInvoker.java:96) ~[dubbo-2.5.3.jar:2.5.3]
    ... 42 common frames omitted

comment service : 並沒有異常,而是慢慢悠悠的執行自己的邏輯:


2017-08-05 18:14:52.760  INFO 846 --- [2:7777-thread-5] c.j.f.d.p.service.CommentServiceImpl     : getComments start:Sat Aug 05 18:14:52 CST 2017
2017-08-05 18:14:57.760  INFO 846 --- [2:7777-thread-5] c.j.f.d.p.service.CommentServiceImpl     : getComments end:Sat Aug 05 18:14:57 CST 2017

從日誌來看,超時影響的是消費端,與服務端沒有直接關係。

超時在哪設定?

消費端

  • 全域性控制
<dubbo:consumer timeout="1000"></dubbo:consumer>
  • 介面控制
  • 方法控制

服務端

  • 全域性控制
<dubbo:provider timeout="1000"></dubbo:provider>
  • 介面控制
  • 方法控制

可以看到dubbo針對超時做了比較精細化的支援,無論是消費端還是服務端,無論是介面級別還是方法級別都有支援。

超時設定的優先順序是什麼?

上面有提到dubbo支援多種場景下設定超時時間,也說過超時是針對消費端的。那麼既然超時是針對消費端,為什麼服務端也可以設定超時呢?

這其實是一種策略,其實服務端的超時配置是消費端的預設配置,即如果服務端設定了超時,任務消費端可以不設定超時時間,簡化了配置。

另外針對控制的粒度,dubbo支援了介面級別也支援方法級別,可以根據不同的實際情況精確控制每個方法的超時時間。所以最終的優先順序為:客戶端方法級>客戶端介面級>客戶端全域性>服務端方法級>服務端介面級>服務端全域性。

超時的實現原理是什麼?

之前有簡單提到過, dubbo預設採用了netty做為網路元件,它屬於一種NIO的模式。消費端發起遠端請求後,執行緒不會阻塞等待服務端的返回,而是馬上得到一個ResponseFuture,消費端通過不斷的輪詢機制判斷結果是否有返回。因為是通過輪詢,輪詢有個需要特別注要的就是避免死迴圈,所以為了解決這個問題就引入了超時機制,只在一定時間範圍內做輪詢,如果超時時間就返回超時異常。

原始碼

ResponseFuture介面定義


public interface ResponseFuture {

    /**
     * get result.
     * 
     * @return result.
     */
    Object get() throws RemotingException;

    /**
     * get result with the specified timeout.
     * 
     * @param timeoutInMillis timeout.
     * @return result.
     */
    Object get(int timeoutInMillis) throws RemotingException;

    /**
     * set callback.
     * 
     * @param callback
     */
    void setCallback(ResponseCallback callback);

    /**
     * check is done.
     * 
     * @return done or not.
     */
    boolean isDone();

}

ReponseFuture的實現類:DefaultFuture

只看它的get方法,可以清楚看到輪詢的機制。

 public Object get(int timeout) throws RemotingException {
        if (timeout <= 0) {
            timeout = Constants.DEFAULT_TIMEOUT;
        }
        if (! isDone()) {
            long start = System.currentTimeMillis();
            lock.lock();
            try {
                while (! isDone()) {
                    done.await(timeout, TimeUnit.MILLISECONDS);
                    if (isDone() || System.currentTimeMillis() - start > timeout) {
                        break;
                    }
                }
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            } finally {
                lock.unlock();
            }
            if (! isDone()) {
                throw new TimeoutException(sent > 0, channel, getTimeoutMessage(false));
            }
        }
        return returnFromResponse();
    }

超時解決的是什麼問題?

設定超時主要是解決什麼問題?如果沒有超時機制會怎麼樣?

回答上面的問題,首先要了解dubbo這類rpc產品的執行緒模型。下圖是我之前個人RPC學習產品的示例圖,與dubbo的執行緒模型大致是相同的,有興趣的可參考我的筆記:簡單RPC框架-業務執行緒池

我們從dubbo的原始碼看下這下執行緒模型是怎麼用的:

netty boss

主要是負責socket連線之類的工作。

netty wokers

將一個請求分給後端的某個handle去處理,比如心跳handle ,執行業務請求的 handle等。

Netty Server中可以看到上述兩個執行緒池是如何初始化的:

首選是open方法,可以看到一個boss一個worker執行緒池。

protected void doOpen() throws Throwable {
        NettyHelper.setNettyLoggerFactory();
        ExecutorService boss = Executors.newCachedThreadPool(new NamedThreadFactory("NettyServerBoss", true));
        ExecutorService worker = Executors.newCachedThreadPool(new NamedThreadFactory("NettyServerWorker", true));
        ChannelFactory channelFactory = new NioServerSocketChannelFactory(boss, worker, getUrl().getPositiveParameter(Constants.IO_THREADS_KEY, Constants.DEFAULT_IO_THREADS));
        bootstrap = new ServerBootstrap(channelFactory);
        // ......
}

再看ChannelFactory的建構函式:

    public NioServerSocketChannelFactory(Executor bossExecutor, Executor workerExecutor, int workerCount) {
        this(bossExecutor, 1, workerExecutor, workerCount);
    }

可以看出,boss執行緒池的大小為1,worker執行緒池的大小也是可以配置的,預設大小是當前系統的核心數+1,也稱為IO執行緒。

busines(業務執行緒池)

為什麼會有業務執行緒池,這裡不多解釋,可以參考我上面的文章。

預設是採用固定大小的執行緒池,dubbo提供了三種不同型別的執行緒池供使用者選擇。我們看看這個類:AllChannelHandler,它是其中一種handle,處理所有請求,它的一個作用就是呼叫業務執行緒池去執行業務程式碼,其中有獲取執行緒池的方法:

 private ExecutorService getExecutorService() {
        ExecutorService cexecutor = executor;
        if (cexecutor == null || cexecutor.isShutdown()) { 
            cexecutor = SHARED_EXECUTOR;
        }
        return cexecutor;
    }

上面程式碼中的變數executor來自於AllChannelHandler的父類WrappedChannelHandler,看下它的建構函式:

public WrappedChannelHandler(ChannelHandler handler, URL url) {
       //......
        executor = (ExecutorService) ExtensionLoader.getExtensionLoader(ThreadPool.class).getAdaptiveExtension().getExecutor(url);

        //......
}

獲取執行緒池來自於SPI技術,從程式碼中可以看出執行緒池的預設配置就是上面提到的固定大小執行緒池。

@SPI("fixed")
public interface ThreadPool {
    
    /**
     * 執行緒池
     * 
     * @param url 執行緒引數
     * @return 執行緒池
     */
    @Adaptive({Constants.THREADPOOL_KEY})
    Executor getExecutor(URL url);

}

最後看下是如何將請求丟給執行緒池去執行的,在AllChannelHandler中有這樣的方法:

public void received(Channel channel, Object message) throws RemotingException {
        ExecutorService cexecutor = getExecutorService();
        try {
            cexecutor.execute(new ChannelEventRunnable(channel, handler, ChannelState.RECEIVED, message));
        } catch (Throwable t) {
            throw new ExecutionException(message, channel, getClass() + " error when process received event .", t);
        }
    }

典型問題:拒絕服務

如果上面提到的dubbo執行緒池模型理解了,那麼也就容易理解一個問題,當前端大量請求併發出現時,很有可以將業務執行緒池中的執行緒消費完,因為預設預設的執行緒池是固定大小(我現在版本預設執行緒池大小為200),此時會出現服務無法按預期響應的結果,當然由於是固定大小的執行緒池,當核心執行緒滿了後也有佇列可排,但預設是不排隊的,需要排隊需要單獨配置,我們可以從執行緒池的具體實現中看:


public class FixedThreadPool implements ThreadPool {

    public Executor getExecutor(URL url) {
        String name = url.getParameter(Constants.THREAD_NAME_KEY, Constants.DEFAULT_THREAD_NAME);
        int threads = url.getParameter(Constants.THREADS_KEY, Constants.DEFAULT_THREADS);
        int queues = url.getParameter(Constants.QUEUES_KEY, Constants.DEFAULT_QUEUES);
        return new ThreadPoolExecutor(threads, threads, 0, TimeUnit.MILLISECONDS, 
                queues == 0 ? new SynchronousQueue<Runnable>() : 
                    (queues < 0 ? new LinkedBlockingQueue<Runnable>() 
                            : new LinkedBlockingQueue<Runnable>(queues)),
                new NamedThreadFactory(name, true), new AbortPolicyWithReport(name, url));
    }

}

上面程式碼的結論是:

  • 預設執行緒池大小為200(不同的dubbo版本可能此值不同)
  • 預設執行緒池不排隊,如果需要排隊,需要指定佇列的大小

當業務執行緒用完後,服務端會報如下的錯誤:

Caused by: java.util.concurrent.RejectedExecutionException: Thread pool is EXHAUSTED! Thread Name: DubboServerHandler-192.168.10.222:9999, Pool Size: 1 (active: 1, core: 1, max: 1, largest: 1), Task: 8 (completed: 7), Executor status:(isShutdown:false, isTerminated:false, isTerminating:false), in dubbo://192.168.10.222:9999!
    at com.alibaba.dubbo.common.threadpool.support.AbortPolicyWithReport.rejectedExecution(AbortPolicyWithReport.java:53) ~[dubbo-2.5.3.jar:2.5.3]
    at java.util.concurrent.ThreadPoolExecutor.reject(ThreadPoolExecutor.java:823) [na:1.8.0_121]
    at java.util.concurrent.ThreadPoolExecutor.execute(ThreadPoolExecutor.java:1369) [na:1.8.0_121]
    at com.alibaba.dubbo.remoting.transport.dispatcher.all.AllChannelHandler.caught(AllChannelHandler.java:65) ~[dubbo-2.5.3.jar:2.5.3]
    ... 17 common frames omitted

通過上面的分析,對呼叫的服務設定超時時間,是為了避免因為某種原因導致執行緒被長時間佔用,最終出現執行緒池用完返回拒絕服務的異常。

超時與服務降級

按我們文章之前的場景,web api 請求產品明細時呼叫product service,為了查詢產品評論product service呼叫comment service。如果此時由於comment service異常,響應時間增大到10S(遠大於上游服務設定的超時時間),會發生超時異常,進而導致整個獲取產品明細的介面異常,這也就是平常說的強依賴。這類強依賴是超時不能解決的,解決方案一般是兩種:

  • 呼叫comment service時做異常捕獲,返回空值或者返回具體的錯誤碼,消費端根據不同的錯誤碼做不同的處理。
  • 呼叫coment service做服務降級,比如發生異常時返回一個mock的資料,dubbo預設支援mock。

只有通過做異常捕獲或者服務降級才能確保某些不重要的依賴出問題時不影響主服務的穩定性。而超時就可以與服務降級結合起來,當消費端發生超時時自動觸發服務降級, 這樣即使我們的評論服務一直慢,但不影響獲取產品明細的主體功能,只不過會犧牲部分體驗,使用者看到的評論不是真實的,但評論相對是個邊緣功能,相比看不到產品資訊要輕的多,某種程度上是可以捨棄的。