dubbo介面方法過載且入參未顯式指定序列化id導致ClassCastException分析

不曉得儂發表於2022-01-16

問題描述&模擬

線上登入介面,通過監控檢視,有型別轉換異常,具體報錯如下圖

image-20220108220128374

此報錯資訊是dubbo consumer端顯示,且登入大部分是正常,有少量部分會報型別轉換異常,同事通過更換方法名+顯示指定序列化id解決此問題,但是產生這個問題的真正原因是什麼呢?沒有指定序列化id嗎?還是dubbo方法過載問題?為什麼服務端不顯示此錯誤資訊呢?,下面根據錯誤模擬下情況。

線上執行情況說明,報錯的這臺客戶端部署在容器內,jdk版本

image-20220108220823552

服務方是混跑,有虛擬機器和容器,容器的jdk版本相同,虛擬機器jdk版本

image-20220108220912474

一開始認為是由於沒有顯示指定序列化id導致容器呼叫虛擬機器的服務,由於jvm版本不一致導致的解碼問題,但是分析和試驗後,發現並非如此,模擬情況如下:

定義一個dubbo服務,方法過載且入參不顯示指定序列化id,程式碼如下

//定義dubbo服務
public interface ProductService {
	Result<ProductVO> findProduct(String data);
	Result<ProductVO> findProduct(ProductDTO product);
}

//入參
@Data
public class ProductDTO  implements Serializable {
    //不顯示指定序列化id
	private Integer productId;
	private String sn;
	private String code;
}

//出參
@Data
public class ProductVO implements Serializable{
	private static final long serialVersionUID = 4529782262922750326L;
	private Integer productId;
	private String productName;
}

dubbo客戶端呼叫ProductService.findProduct(ProductDTO product),並使用jdk1.8.0_202版本,服務方使用jdk1.8.0_73版本,經過試驗(jmeter壓測),發現並未出現型別轉換異常,現在通過程式碼分析來排除。

分析&dubbo provider處理請求流程

採用逆序方法,使用arthas進行反編譯dubbo生成的代理類,ProductService生成的代理類是Wrapper2,內容如下

public Object invokeMethod(Object object, String name, Class[] classArray, Object[] objectArray)
			throws InvocationTargetException {
		ProductService productService;
		try {
			productService = (ProductService) object;
		} catch (Throwable throwable) {
			throw new IllegalArgumentException(throwable);
		}
		try {
			if ("findProduct".equals(name) && classArray.length == 1
					&& classArray[0].getName().equals("java.lang.String")) {
				return productService.findProduct((String) objectArray[0]);
			}
			if ("findProduct".equals(name) && classArray.length == 1
					&& classArray[0].getName().equals("org.pangu.dto.ProductDTO")) {
				return productService.findProduct((ProductDTO) objectArray[0]);
			}
		} catch (Throwable throwable) {
			throw new InvocationTargetException(throwable);
		}
		throw new NoSuchMethodException(new StringBuffer().append("Not found method \"").append(name)
				.append("\" in class org.pangu.api.ProductService.").toString());
	}
}

通過檢視反編譯後的程式碼,得知dubbo方法過載,會根據方法型別和引數個數找到對應的目標方法執行。對於我這個線上問題,引數是ProductDTO,如果呼叫的是findProduct(String data),說明classArray[0]即引數型別是String型別,那麼引數型別是如何得來的呢?根據自己之前寫的dubbo流程分析,檢視原始碼,在com.alibaba.dubbo.rpc.proxy.AbstractProxyInvoker#invoke(Invocation invocation),程式碼內容如下

image-20220108223056936

方法名稱+方法型別+方面引數都封裝在Invocation內,接著查詢Invocation的來源,在DubboProtocol的匿名內部類DubboProtocol$1內發現,具體是reply(ExchangeChannel channel, Object message)方法內,引數message就是Invocation。

image-20220108225859703

接著看哪裡呼叫DubboProtocol$1.reply(ExchangeChannel channel, Object message)方法,在com.alibaba.dubbo.remoting.exchange.support.header.HeaderExchangeHandler#handleRequest(ExchangeChannel channel, Request req)方法內,com.alibaba.dubbo.remoting.exchange.Request.getData()獲取此Invocation,即DecodeableRpcInvocation,那麼接著看Request 以及Request.mData的來源;

接著向上找,在com.alibaba.dubbo.remoting.exchange.support.header.HeaderExchangeHandler#received(Channel channel, Object message)的入參message就是Request ;

繼續向上找,com.alibaba.dubbo.remoting.transport.DecodeHandler#received(Channel channel, Object message)的入參就是Request ,其中會對Request.mData即Invocation進行解碼(預設在IO執行緒已經解碼過,這裡實際並不會再執行解碼DecodeableRpcInvocation#hasDecoded=true)。

image-20220108230753355

繼續向上找,com.alibaba.dubbo.remoting.transport.dispatcher.ChannelEventRunnable#run()執行緒,message屬性就是Request,那麼接著只能找ChannelEventRunnable是如何建立並提交的

image-20220108230921910

繼續向上找,在com.alibaba.dubbo.remoting.transport.dispatcher.all.AllChannelHandler#received(Channel channel, Object message)方法內建立ChannelEventRunnable並提交到執行緒池執行。

繼續向上找,在com.alibaba.dubbo.remoting.exchange.support.header.HeartbeatHandler.received(Channel channel, Object message),入參message就是Request

繼續向上找,com.alibaba.dubbo.remoting.transport.MultiMessageHandler.received(Channel channel, Object message)

image-20220109003230986

繼續向上找,com.alibaba.dubbo.remoting.transport.AbstractPeer.received(Channel ch, Object msg)

繼續向上找,com.alibaba.dubbo.remoting.transport.netty4.NettyServerHandler.channelRead(ChannelHandlerContext ctx, Object msg),看到這個就說明是netty的work執行緒,NettyServerHandler是個inbound & outbound事件

dubbo service netty啟動新增的inbound&outbound即pipeline chain[HeadContext InternalDecoder InternalEncoder NettyServerHandler TailContext],說明前面肯定有執行InternalDecoder 的channelRead事件。此時入參message就是Request。

下面著重分析InternalDecoder 的channelRead事件,執行堆疊依次為:

InternalDecoder(io.netty.handler.codec.ByteToMessageDecoder).channelRead(ChannelHandlerContext ctx, Object msg)
InternalDecoder(io.netty.handler.codec.ByteToMessageDecoder).callDecode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out)
InternalDecoder.decode(ChannelHandlerContext ctx, ByteBuf input, List<Object> out)
DubboCountCodec.decode(Channel channel, ChannelBuffer buffer)
DubboCodec(ExchangeCodec).decode(Channel channel, ChannelBuffer buffer)
DubboCodec(ExchangeCodec).decode(Channel channel, ChannelBuffer buffer, int readable, byte[] header)
DubboCodec.decodeBody(Channel channel, InputStream is, byte[] header)
DecodeableRpcInvocation.decode()
DecodeableRpcInvocation.decode(Channel channel, InputStream input)

InternalDecoder是netty pipeline的inboud事件,執行的是channelRead,具體邏輯在InternalDecoder.decode(ChannelHandlerContext ctx, ByteBuf input, List<Object> out)內,程式碼如下

image-20220109010146098

接著觸發下一個inbound的channelRead動作,傳入的就是Request了,程式碼說明如下

image-20220109010534969

接著看DubboCountCodec.decode(Channel channel, ChannelBuffer buffer),這裡進行解碼

//com.alibaba.dubbo.rpc.protocol.dubbo.DubboCountCodec#decode(Channel channel, ChannelBuffer buffer)
public Object decode(Channel channel, ChannelBuffer buffer) throws IOException {
    int save = buffer.readerIndex();//獲取讀位置
    MultiMessage result = MultiMessage.create();//MultiMessage是Request的集合
    do {
        Object obj = codec.decode(channel, buffer);//使用DubboCodec進行解碼,下面根據解碼結果進行不同處理
        if (Codec2.DecodeResult.NEED_MORE_INPUT == obj) {//說明發生了tcp粘包,退出迴圈
            buffer.readerIndex(save);
            break;
        } else {
            result.addMessage(obj);//把obj即Request新增到集合MultiMessage
            logMessageLength(obj, buffer.readerIndex() - save);
            save = buffer.readerIndex();//設定新的buffer讀位置,繼續使用DubboCodec進行解碼
        }
    } while (true);
    if (result.isEmpty()) {
        return Codec2.DecodeResult.NEED_MORE_INPUT;
    }
    if (result.size() == 1) {//如果MultiMessage只有一個元素,則說明本次沒有發生粘包
        return result.get(0);//返回Request
    }
    return result;//返回MultiMessage,在後續的MultiMessagehandler內獲取Request的集合遍歷處理
}

接著看DubboCodec(ExchangeCodec).decode(Channel channel, ChannelBuffer buffer)解碼過程,如何對dubbo協議解碼的,先看下dubbo協議的報文結構

接著看程式碼,對著報文結構進行解碼

//DubboCodec(ExchangeCodec).decode(Channel channel, ChannelBuffer buffer)
@Override
public Object decode(Channel channel, ChannelBuffer buffer) throws IOException {
    int readable = buffer.readableBytes();
    byte[] header = new byte[Math.min(readable, HEADER_LENGTH)];
    buffer.readBytes(header);//把緩衝區位元組存放到header
    return decode(channel, buffer, readable, header);
}

//DubboCodec(ExchangeCodec).decode(Channel channel, ChannelBuffer buffer, int readable, byte[] header)
@Override
protected Object decode(Channel channel, ChannelBuffer buffer, int readable, byte[] header) throws IOException {
    // check magic number.
    if (readable > 0 && header[0] != MAGIC_HIGH
        || readable > 1 && header[1] != MAGIC_LOW) {//非魔數,說明非dubbo報文的開頭,說明發生了tcp拆包/粘包
        int length = header.length;
        if (header.length < readable) {
            header = Bytes.copyOf(header, readable);
            buffer.readBytes(header, length, readable - length);
        }
        for (int i = 1; i < header.length - 1; i++) {
            if (header[i] == MAGIC_HIGH && header[i + 1] == MAGIC_LOW) {
                buffer.readerIndex(buffer.readerIndex() - header.length + i);
                header = Bytes.copyOf(header, i);
                break;
            }
        }
        return super.decode(channel, buffer, readable, header);
    }
    // check length.
    if (readable < HEADER_LENGTH) {//為什麼是小於16呢?因為dubbo報文 magic(2)+falg(1)+status(1)+invokerId(8)+bodyLenght(4)就是16位元組了,小於16位元組,肯定發生了拆包,本次接收到的資料並沒有body
        return DecodeResult.NEED_MORE_INPUT;
    }

    // get data length.
    int len = Bytes.bytes2int(header, 12);//12的原因是dubbo報文 magic(2)+falg(1)+status(1)+invokerId(8)等於12,從12位後取4位,轉換為int,就是body的長度
    checkPayload(channel, len);

    int tt = len + HEADER_LENGTH;
    if (readable < tt) {//可讀取數少於bodylen+16,說明tcp拆包,需要繼續進網路讀取
        return DecodeResult.NEED_MORE_INPUT;
    }

    // limit input stream.
    ChannelBufferInputStream is = new ChannelBufferInputStream(buffer, len);

    try {
        return decodeBody(channel, is, header);//解碼body內容
    } finally {
        if (is.available() > 0) {
            try {
                if (logger.isWarnEnabled()) {
                    logger.warn("Skip input stream " + is.available());
                }
                StreamUtils.skipUnusedStream(is);
            } catch (IOException e) {
                logger.warn(e.getMessage(), e);
            }
        }
    }
}

接著看解碼dubbo body,在com.alibaba.dubbo.rpc.protocol.dubbo.DubboCodec#decodeBody

//com.alibaba.dubbo.rpc.protocol.dubbo.DubboCodec#decodeBody(Channel channel, InputStream is, byte[] header)
protected Object decodeBody(Channel channel, InputStream is, byte[] header) throws IOException {
    byte flag = header[2], proto = (byte) (flag & SERIALIZATION_MASK);
    // get request id.
    long id = Bytes.bytes2long(header, 4);
    if ((flag & FLAG_REQUEST) == 0) {//是響應,編碼
        //省略
    } else {//請求,解碼
        // decode request.
        Request req = new Request(id);
        req.setVersion(Version.getProtocolVersion());
        req.setTwoWay((flag & FLAG_TWOWAY) != 0);
        if ((flag & FLAG_EVENT) != 0) {
            req.setEvent(Request.HEARTBEAT_EVENT);
        }
        try {
            Object data;
            if (req.isHeartbeat()) {//心跳
                data = decodeHeartbeatData(channel, CodecSupport.deserialize(channel.getUrl(), is, proto));
            } else if (req.isEvent()) {//事件
                data = decodeEventData(channel, CodecSupport.deserialize(channel.getUrl(), is, proto));
            } else {
                DecodeableRpcInvocation inv;
                if (channel.getUrl().getParameter(
                    Constants.DECODE_IN_IO_THREAD_KEY,
                    Constants.DEFAULT_DECODE_IN_IO_THREAD)) {//預設是在netty work執行緒進行解碼
                    inv = new DecodeableRpcInvocation(channel, req, is, proto);
                    inv.decode();//解碼dubbo body,解碼結果儲存在DecodeableRpcInvocation
                } else {
                    inv = new DecodeableRpcInvocation(channel, req,
                                                      new UnsafeByteArrayInputStream(readMessageData(is)), proto);//否則在業務執行緒ChannelEventRunnable進行解碼
                }
                data = inv;
            }
            req.setData(data);//把Invocation儲存到Request.mData
        } catch (Throwable t) {
            if (log.isWarnEnabled()) {
                log.warn("Decode request failed: " + t.getMessage(), t);
            }
            // bad request
            req.setBroken(true);
            req.setData(t);
        }
        return req;
    }
}

接著看DecodeableRpcInvocation解碼dubbo body

//com.alibaba.dubbo.rpc.protocol.dubbo.DecodeableRpcInvocation#decode()
@Override
public void decode() throws Exception {
    if (!hasDecoded && channel != null && inputStream != null) {
        try {
            decode(channel, inputStream);//解碼
        } catch (Throwable e) {
            if (log.isWarnEnabled()) {
                log.warn("Decode rpc invocation failed: " + e.getMessage(), e);
            }
            request.setBroken(true);
            request.setData(e);
        } finally {
            hasDecoded = true;//解碼後置位已經解碼,這樣在ChannelEventRunnable執行緒內就不會再進行解碼
        }
    }
}

//com.alibaba.dubbo.rpc.protocol.dubbo.DecodeableRpcInvocation#decode(com.alibaba.dubbo.remoting.Channel, java.io.InputStream)
@Override
public Object decode(Channel channel, InputStream input) throws IOException {
    ObjectInput in = CodecSupport.getSerialization(channel.getUrl(), serializationType)
        .deserialize(channel.getUrl(), input);//根據序列化標識獲取反序列物件,dubbo spi的自適應

    String dubboVersion = in.readUTF();//從輸入流讀取dubbo version
    request.setVersion(dubboVersion);
    setAttachment(Constants.DUBBO_VERSION_KEY, dubboVersion);

    setAttachment(Constants.PATH_KEY, in.readUTF());//從輸入流讀path
    setAttachment(Constants.VERSION_KEY, in.readUTF());//從輸入流讀版本

    setMethodName(in.readUTF());//從輸入流讀 呼叫的目標方法名
    try {
        Object[] args;
        Class<?>[] pts;
        String desc = in.readUTF();//從輸入流讀 引數描述符,即引數的型別 比如[Ljava/lang/String
        if (desc.length() == 0) {//dubbo呼叫方法不存在入參
            pts = DubboCodec.EMPTY_CLASS_ARRAY;
            args = DubboCodec.EMPTY_OBJECT_ARRAY;
        } else {//dubbo呼叫方法存在入參
            pts = ReflectUtils.desc2classArray(desc);//型別描述符轉換為型別,比如[Ljava/lang/String => Ljava.lang.String
            args = new Object[pts.length];//引數長度
            for (int i = 0; i < args.length; i++) {
                try {
                    args[i] = in.readObject(pts[i]);//從輸入流讀取引數,這裡是readObject,執行反序列化
                } catch (Exception e) {
                    if (log.isWarnEnabled()) {
                        log.warn("Decode argument failed: " + e.getMessage(), e);
                    }
                }
            }
        }
        setParameterTypes(pts);//把引數型別儲存到Invocation物件,即parameterTypes屬性上

        Map<String, String> map = (Map<String, String>) in.readObject(Map.class);//從輸入流讀取隱式引數並解碼
        if (map != null && map.size() > 0) {
            Map<String, String> attachment = getAttachments();
            if (attachment == null) {
                attachment = new HashMap<String, String>();
            }
            attachment.putAll(map);
            setAttachments(attachment);
        }
        //decode argument ,may be callback
        for (int i = 0; i < args.length; i++) {
            args[i] = decodeInvocationArgument(channel, this, pts, i, args[i]);
        }

        setArguments(args);

    } catch (ClassNotFoundException e) {
        throw new IOException(StringUtils.toString("Read invocation data failed.", e));
    } finally {
        if (in instanceof Cleanable) {
            ((Cleanable) in).cleanup();
        }
    }
    return this;
}

從解碼dubbo body看出,從輸入流解碼獲取呼叫的目標方法名稱、方法型別、方法入參、隱式引數都儲存到Invocation物件(即DecodeableRpcInvocation),其中讀取入參和隱式引數使用到了序列化解碼(需要使用到序列化id),而從輸入流獲取方法名稱+引數型別並沒有使用物件的反序列化。

dubbo provider處理接收總結

dubbo prodiver端從網路到dubbo業務執行緒池呼叫以及如何解碼流程分析完,現在總結下:

image-20220109225136447

dubbo provider接收並處理consumer請求分兩步

1.網路通訊,在io執行緒上解碼,解碼結果儲存到Request。

2.IO執行緒調起dubbo業務執行緒,傳入解碼結果Request,通過Invoker呼叫目標方法,傳入要執行目標方法的物件、方法名、引數型別、引數進行呼叫目標方法。

該問題分析

解決2個問題

問題1:為什麼在服務端報錯ClassCastException,在服務端沒有任何error日誌呢?只有在客戶端才有error日誌

由於在dubbo代理類Wrapper2呼叫目標方法導致ClassCastException,異常被捕捉封裝為InvocationTargetException向上拋,接著在com.alibaba.dubbo.rpc.proxy.AbstractProxyInvoker#invoke內異常被捕捉,封裝為RpcResult,繼而在ExceptionFilter內異常資訊被封裝為RuntimeException返回客戶端。這中間並沒有日誌列印,因此不產生error日誌,所以服務端看不到。

問題2:dubbo方法過載會導致問題嗎?

結論,基本不會,dubbo的動態代理類WrapperX會根據Invocation的methodName+引數型別+引數進行呼叫目標方法,因此不會。網上有個大佬說dubbo方法過載在某種情況會導致問題,但是他寫的語句有些不通順且凌亂,而且藍綠是流量隔離的,不會調錯,我認為他的舉例不合適,感興趣的可以參考dubbo同名方法的問題及思考

問題3:是否是未顯式指定序列化id導致的呢?

經過前面分析,是由於判斷引數型別是String(本來應該是DTO型別),導致執行目標方法時候把引數轉換為String導致的異常,引數型別來源於Invocation物件(即RpcInvocation.parameterTypes),而Invocation來源於Request.mData,而Request是網路通訊解碼得來,其中在com.alibaba.dubbo.rpc.protocol.dubbo.DecodeableRpcInvocation#decode(com.alibaba.dubbo.remoting.Channel, java.io.InputStream)String desc = in.readUTF();從輸入流讀取位元組流並解碼為引數型別描述符,這個地方並不涉及到物件的序列化和反序列化。

看客戶端編碼程式碼InternalEncoder,編碼引數型別程式碼如下圖

image-20220111002311123

而客戶端傳送建立Request是在com.alibaba.dubbo.remoting.exchange.support.header.HeaderExchangeChannel#request(java.lang.Object, int),而Invocation物件是在dubbo呼叫的入口InvokerInvocationHandler內(new RpcInvocation(method, args)封裝方法名+引數建立Invocation物件,繼而引數型別就儲存在了Invocation物件。

這樣分析得來,不顯示指定序列化id並不會導致這個問題

排除了jdk版本、不顯示指定序列化ID等原因,具體是什麼原因導致的dubbo方法過載導致呼叫ClassCastException呢?線上預發環境和生產網路是互通,是否是預發環境同事手工部署的應用只有入參String的方法呢(未和生產同步版本)?同事也記不清了,也無法查,這個問題暫時是無法知道答案了。

據我猜測,問題可能出現是預發環境部署的服務沒有和生產版本同步(缺少findProduct(ProductDTOdata)導致),我們預發和生成網路是互通的,應該是生產客戶端呼叫到了預發環境服務,而預發環境部署的此服務沒有findProduct(ProductDTOdata)。

為什麼需要顯示指定序列化id

rpc呼叫使用的tcp通訊,需要把物件轉換為二進位制流進行傳送(編碼)和接收(解碼),那麼就需要有套規則需要把記憶體中的java物件轉換為二進位制流,序列化就是做這個事情的。

在使用原生序列化的時候,serialVersionUID起到了一個類似版本號的作用,在反序列化的時候判斷serialVersionUID如果不相同,會丟擲InvalidClassException。

如果在使用原生序列化方式的時候官方是強烈建議指定一個serialVersionUID的,如果沒有指定,在序列化過程中,jvm會自動計算出一個值作為serialVersionUID,由於這種執行時計算serialVersionUID的方式依賴於jvm的實現方式,如果序列化和反序列化的jvm實現方式不一樣可能會導致丟擲異常InvalidClassException,所以強烈建議指定serialVersionUID。

不顯示指定序列化ID實際會導致問題嗎?

定義一個dubbo的入參,不顯示指定序列化id,客戶端執行不變更,服務端入參進行增加或刪除欄位(類結構發生變化),發現均能正常請求,並非像網上所說的不顯示指定序列化id情況下rpc引數類結構變化,並沒有導致什麼問題,當然我只是在jdk8版本下進行了此測試(當然現在都是jdk8),這樣情況下,實際使用過程中,不顯示指定序列化id好像也不會影響什麼呢

網上有說法,不顯示指定序列化id會導致一種情況出現問題:舉個例子:比如該入參沒有顯示指定序列化id,後面有個需求需要在這個入參增加個欄位,而且看沒有顯示指定序列化id,順手就增加了個序列化id,這樣線上執行的客戶端應用由於引用的還是舊jar,新的服務部署上去,就會傳送序列化失敗(客戶端jvm生成的序列化id和服務端顯示指定的序列化id不同),好像這種情況是無法避免的。但是我經過測試,不顯示指定序列化id情況下 對dubbo引數進行增加欄位、刪除欄位、增加方法等都不會造成反序列化問題(jdk8, dubbo2.6.8下測試),請求均正常。驗證結果說明jvm生成序列化id和類的結構沒有關係。可以參考別人測試結果,和我測試結果相同。

那麼是否就可以大膽的不指定序列化id呢?還是建議不要,鬼知道jvm生成序列化id的實現方式呢,不指定萬一線上哪天出現么蛾子。

驗證了半天,得到一個不指定序列化id也沒關係的實際驗證結論,但是又不敢完全放心大膽不顯示指定序列化id,抓狂。。。

最終結論

根據實際驗證(jdk8, dubbo2.6.8下測試),不顯示指定序列化id時,dubbo的傳輸物件在增加欄位、刪除欄位、增加方法等都不會造成反序列化問題,但是還是強烈建議顯示指定序列化id,萬一jvm生成序列化id不相容了呢

結尾

分析了這麼長,最終也沒找到這個問題的產生原因,但是對dubbo的通訊層又加深了理解,下面一篇記錄下總結的dubbo通訊層

相關文章