一次诡异的线上RPC序列化错误分析

背景

我司服务化框架采用开源的Dapeng-soa(https://github.com/dapeng-soa), 集群内部使用基于thrift的tcp协议, 对外通过service mesh提供http网关. 虽然不断在迭代优化, 添加新功能, 但线上跑了大半年了, 一直很稳.

一次诡异的线上RPC序列化错误分析_第1张图片
image

这几天, 通过我们的EFK(Elastic-fluent-bit/fluentd-kibana)平台, 发现了几条服务端的RPC反序列化报错(为了更聚焦异常, 我们加了一下过滤条件):
一次诡异的线上RPC序列化错误分析_第2张图片
image.png

且这个错误每天总有那么三两次, 甚是烦人.

故障分析

通过异常码Err-Core-513, 我们知道这个报错是服务端报出来的. (Dapeng-soa异常码规范:https://github.com/dapeng-soa/dapeng-soa/wiki/Exception-Codes)
同时, 从上面LogFilter的日志可以看出, 该请求给路由到192.168.20.102:9086这个节点去了.
然后通过sessionTid或者其他过滤条件, 我们拿到服务端的原始异常信息如下:

07-07 21:55:13 079 nioEventLoopGroup-3-7 ERROR [] - response[seqId:300659, respCode:Err-Core-513]:service[com.today.api.goods.service.OpenGoodsService]:version[1.0.0]:method[listSkuDetailBySkuNos]
com.github.dapeng.core.SoaException: com.github.dapeng.org.apache.thrift.transport.TTransportException: Cannot read. Remote side has closed. Tried to read 41 bytes, but only got 8 bytes. (This is often indicative of an internal error on the server side. Please check your server logs.)
        at com.github.dapeng.impl.plugins.netty.SoaMsgDecoder.parseSoaMsg(SoaMsgDecoder.java:130) ~[dapeng-container-impl-2.0.4.jar:na]
        at com.github.dapeng.impl.plugins.netty.SoaMsgDecoder.decode(SoaMsgDecoder.java:49) ~[dapeng-container-impl-2.0.4.jar:na]
        at com.github.dapeng.impl.plugins.netty.SoaMsgDecoder.decode(SoaMsgDecoder.java:32) ~[dapeng-container-impl-2.0.4.jar:na]
        at io.netty.handler.codec.MessageToMessageDecoder.channelRead(MessageToMessageDecoder.java:88) ~[netty-all-4.1.20.Final.jar:4.1.20.Final]
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:362) [netty-all-4.1.20.Final.jar:4.1.20.Final]
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:348) [netty-all-4.1.20.Final.jar:4.1.20.Final]
        at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:340) [netty-all-4.1.20.Final.jar:4.1.20.Final]
        at com.github.dapeng.impl.plugins.netty.SoaLinkStateHandler.channelRead(SoaLinkStateHandler.java:37) [dapeng-container-impl-2.0.4.jar:na]
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:362) [netty-all-4.1.20.Final.jar:4.1.20.Final]
        at io.netty.channel.AbstractChannelHandlerContext.invokeChannelRead(AbstractChannelHandlerContext.java:348) [netty-all-4.1.20.Final.jar:4.1.20.Final]
        at io.netty.channel.AbstractChannelHandlerContext.fireChannelRead(AbstractChannelHandlerContext.java:340) [netty-all-4.1.20.Final.jar:4.1.20.Final]

大概的意思是, 服务端收到请求后, 在解码的过程中报错, 可能是对端关闭了连接.


一次诡异的线上RPC序列化错误分析_第3张图片
image.png

会不会是channel真的关闭了?
但马上否认了这个结论, tcp是可靠的传输协议, 客户端已经收到服务端的响应了(尽管是报错的响应), 说明channel没有关闭, 连接状态是正常的.

会不会是客户端序列化的时候有问题呢?
api网关基于Dapeng-Json(一个高性能Json序列化器, 支持字符串跟基于thrift的二进制之间的直接相互转换), 已经稳定跑了大半年.
为了印证这点, 我们从网关的日志里捞出请求参数(是一个json字符串), 直接通过curl post到api网关, 反复多次, 没发现有异常.

思绪一下断了.
重新整理了一下思路, 尽管疑点偏向于客户端, 但还是缺乏有力证据. 于是祭出了抓包大法.

通过tcpdump抓包分析

tcpdump命令很复杂, 各种参数让人眼花缭乱, 但好在有我们家老王(https://github.com/wangzaixiang)精简出来的两条指令:
nohup tcpdump -s 0 -nn -w /data/packet_2018_7_7_1530.cap host 192.168.20.102 and port 9086 &

这个命令需要在api网关上输入, 意思是把api网关到192.168.20.102的9086端口之间的网络通讯包保存到/data/packet_2018_7_7_1530.cap文件中.
抓包的原则是尽量缩小范围, 否则可能会对生产网络造成影响, 且抓到的包可能很多,很乱

启动tcpdump后, 静候序列化异常的再次出现.
在等待了差不多6个小时后, 终于又看到那个异常了. 果断把网络包文件(/data/packet_2018_7_7_1530.cap)拖回来, 然后通过下面的命令打开(也可以通过wireshark等工具打开):
tcpdump -nn -vv -tttt -X -r /data/packet_2018_7_7_1530.cap

我们摘取关键的一段:

2018-07-07 21:55:13.077207 IP (tos 0x0, ttl 63, id 6785, offset 0, flags [DF], proto TCP (6), length 268)
    192.168.10.125.41260 > 192.168.20.102.9086: Flags [P.], cksum 0xa132 (incorrect -> 0xc01a), seq 74671:74887, ack 35442039, win 12752, options [nop,nop,TS val 405535203 ecr 2123448917], length 216
0x0000:  4500 010c 1a81 4000 3f06 8037 c0a8 0a7d  E.....@.?..7...}
0x0010:  c0a8 1466 a12c 237e 90be c869 8c17 2411  ...f.,#~...i..$.
0x0020:  8018 31d0 a132 0000 0101 080a 182b f9e3  ..1..2.......+..
0x0030:  7e91 4255 0000 00d4 0201 0100 0496 730b  ~.BU..........s.
0x0040:  0001 0000 002c 636f 6d2e 746f 6461 792e  .....,com.today.
0x0050:  6170 692e 676f 6f64 732e 7365 7276 6963  api.goods.servic
0x0060:  652e 4f70 656e 476f 6f64 7353 6572 7669  e.OpenGoodsServi
0x0070:  6365 0b00 0200 0000 156c 6973 7453 6b75  ce.......listSku
0x0080:  4465 7461 696c 4279 536b 754e 6f73 0b00  DetailBySkuNos..
0x0090:  0300 0000 0531 2e30 2e30 0b00 0400 0000  .....1.0.0......
0x00a0:  252f 6170 692f 6531 6266 6437 3632 3332  %/api/c1b3d76232
0x00b0:  3165 3430 3963 6565 3461 6330 6236 6538  1e4z9cae4ac086e8
0x00c0:  3431 3936 3363 0800 05ac 1200 020a 0007  41963c..........
0x00d0:  ac1e 0002 0000 c30e 0800 0879 292c 580a  ...........y),X.
0x00e0:  0009 ac1e 0002 0000 c30d 0d00 170b 0b00  ................
0x00f0:  0000 0000 1c19 f882 8000 0832 3035 3433  ...........20543
0x0100:  3833 3929 f581 8000 0400 0003            839)........

可以看到, 请求包确实已经发出去了.

0x0030:  7e91 4255 0000 00d4 0201 0100 0496 730b  ~.BU..........s.

应用包长度(int, 占4字节):0000 00d4=212, 加上长度位本身所占的4个字节, 刚好216字节, 跟tcpdump显示的payload长度一致.
长度位后是包头开始位(0x02), 版本号(0x01), 还有应用协议(0x01, 代表是Thrift的压缩二进制协议).
包尾结束位(0x03)在最后一行的最后一个byte.

0x0100:  3833 3929 f581 8000 0400 0003            839)........

这个请求包对应的原始json如下:

{
  "body": {
    "request": {
      "skuNos": [
        "20543839"
      ],
      "skuDetailResponseTypes": [
        "SKU_SUPPLIER_LIST"
      ]
    }
  }
}

粗略看, 感觉没毛病啊. 但肉眼看实在是太粗了, 毕竟还有很多字符没法肉眼解读.

为了保险起见, 赶紧写了段反序列化测试代码, 首先把16进制格式的字节流转换回byte[]:

    /**
     * transfer hex string to bytes
     * @param hex
     * @return
     */
    public static byte[] hexStr2bytes(String hex) {
        int length = hex.length()/2;
        // must be multiple of 2
        assert hex.length() % 2 == 0;
        byte[] result = new byte[length];
        for (int i = 0, j=0; j < length; j+=2, i+=4) {
            if (i + 4 > hex.length()) {
                String _2bytes = hex.substring(i, i+2) + "00";
                int anInt = Integer.parseInt(_2bytes, 16);
                result[j] = (byte)((anInt >> 8) & 0xff);
            } else {
                String _2bytes = hex.substring(i, i+4);
                int anInt = Integer.parseInt(_2bytes, 16);
                result[j] = (byte)((anInt >> 8) & 0xff);
                result[j+1] = (byte)(anInt & 0xff);
            }

        }
        return result;
    }

一个16进制的字符对应4个bit, 4个字符对应2个字节. 为了简单起见, 我们以4个字符为1个转换单元进行处理.
int类型是4个字节, 而short刚好是2个字节, 用int而不是short的原因是, short最高位用于符号位, 导致真正有用的只有15bit.
而对于int, 实际上我们只用到了低位2个字节(所以也不存在最高位符号位的问题了).
如果最后没凑够4个字符, 那么补上"00".

然后通过Dapeng-Json, 结合服务元数据信息(更多黑科技请持续关注Dapeng开源社区), 可以得到原始请求对象. 完整的测试代码如下:

String hex = "000000d4020101000496730b00010000002c636f6d2e746f6461792e6170692e676f6f64732e736572766963652e4f70656e476f6f6473536572766963650b0002000000156c697374536b7544657461696c4279536b754e6f730b000300000005312e302e300b0004000000252f6170692f6531626664373632333231653430396365653461633062366538343139363363080005ac1200020a0007ac1e00020000c30e08000879292c580a0009ac1e00020000c30d0d00170b0b00000000001c19f882800008323035343338333929f581800004000003";

final ByteBuf requestBuf = PooledByteBufAllocator.DEFAULT.buffer(8192);

byte[] bytes = hexStr2bytes(hex);
requestBuf.setBytes(0, bytes);
requestBuf.writerIndex(bytes.length);

//这里应该跟tcpdump结果一致
System.out.println(dumpToStr(requestBuf));
JsonSerializer jsonDecoder = new JsonSerializer(service, method, "1.0.0", struct);
SoaMessageParser parser = new SoaMessageParser<>(requestBuf, jsonDecoder);
parser.parseHeader();
System.out.println("after decode:\n" + parser.parseBody().getBody());

结果一跑, Oo。报错了, 跟线上服务端一模一样的错误.
能重现是成功的一半. 赶紧debug, 结果发现在反序列化数组的时候, 解析到数组长度, 明明数组只有一个元素, 这里居然是2.

What the hell... 黑人小哥脸色更凝重了.


一次诡异的线上RPC序列化错误分析_第4张图片
image.png

用原始Json做了一次序列化, 然后再反序列化, 结果是正常的, 但我内心是崩溃的.

事出反常必有妖, 凭感觉, 应该是生产环境多线程并发下, 序列化器在处理数组的时候, 数组长度有交叉污染. 这可是个惊天大Bug!

水落石出

回想起老司机(对, 又是我们家老王)深恶痛绝的可变量, 尤其是静态全局变量, 翻出代码一看, 果然在处理压缩二进制格式的数组的时候, 用了几个静态变量, 静态变量, 静态...我这时候只想静静..

    /**
     * 低位在前, 高位在后,
     * n = 3, result = 0x83 0x80 0x00
     * n = 129(1000 0001), result = 0x81 0x81 0x00
     * n = 130(1000 0010), result = 0x82 0x81 0x00
     * n = 65537(1 0000 0000 0000 0001) result = 0x81 0x80 0x04
     * Write an i32 as a varint. Always results in 3 bytes on the wire.
     */
    private static byte[] i32buf = new byte[3];

    private static void reWriteVarint32(int n, ByteBuf byteBuf) throws TException {
        int idx = 0;
        while (true) {
            if (idx >= i32buf.length) {
                throw new TException("Too long:" + n);
            }
            if ((n & ~0x7F) == 0) {
                i32buf[idx++] = (byte) n;
                break;
            } else {
                i32buf[idx++] = (byte) ((n & 0x7F) | 0x80);
                n >>>= 7;
            }
        }

        // 如果不够位数, 那么最后一个首位需要置1,说明后续还有数字.
        if (idx < i32buf.length) {
            i32buf[idx - 1] |= 0x80;
        }
        byteBuf.writeBytes(i32buf, 0, idx);
        for (int i = idx; i < i32buf.length - 1; i++) {
            byteBuf.writeByte((byte) 0x80);
        }
        if (idx < i32buf.length) {
            byteBuf.writeByte((byte) 0x00);
        }
    }

为了验证这个场景(两个包含不同长度数组的Json字符串, 并发序列化), 写了个单元测试如下:

        Executor ec = Executors.newFixedThreadPool(5);
        for (int i = 0; i < 10000; i++) {
            ec.execute(() -> {
                try {
                    listCategoryDetailBySkuNosTest();
                } catch (Throwable e) {
                   e.printStackTrace();
                   System.exit(-1);
                }
            });
            ec.execute(() -> {
                try {
                    listSkuDetailBySkuNosTest();
                } catch (Throwable e) {
                    e.printStackTrace();
                    System.exit(-1);
                }
            });
        }
        ((ExecutorService) ec).awaitTermination(100, TimeUnit.HOURS);

果然百分百重现.

后记

找到了原因, 修复就再简单不过了. 彻底抛弃静态变量, 静态方法.

有些后怕. 如果再晚一周, 系统大规模上线后, 这个问题就不是零星出现了.
对于平台架构代码, 还是要慎之又慎, 对每一行代码都要有敬畏之心.

一次诡异的线上RPC序列化错误分析_第5张图片
image

你可能感兴趣的:(一次诡异的线上RPC序列化错误分析)