欢迎访问Spring Cloud中国社区

《重新定义Spring Cloud实战》由Spring Cloud中国社区倾力打造,基于Spring Cloud的Finchley.RELEASE版本,本书内容宽度足够广、深度足够深,而且立足于生产实践,直接从生产实践出发,包含大量生产实践的配置。欢迎加微信Software_King进群答疑,国内谁在使用Spring Cloud?欢迎登记

在Spring Cloud中使用Tars

Leyoh · 4月前 · 1954 ·

Tars RPC on Spring Cloud

导语

Spring Cloud 是一个优秀的开源微服务解决方案,通常采用 http + json 的 REST 接口对外提供服务,简洁易用部署方便,很多公司也基于 Spring Cloud 作为基础架构去构建自身的微服务架构。但是随着业务规模和用户规模的增长,传统基于的 Http 的服务会逐步暴露出一些问题。首先是性能的问题,随着用户请求量的增长和业务逻辑复杂度的提升,我们会发现微服务的单机性能会成为系统瓶颈。其次是稳定性问题,当一个服务节点A需要依赖于后端的几个服务的时候,我们会发现当其中一个被依赖的服务发生卡顿,很可能会导致前端的服务节点A产生毛刺甚至无法继续提供服务,而且当问题节点没有能够被及时屏蔽或者恢复的时候,还有可能会导致整个系统雪崩。

Tars是腾讯从2008年到今天一直在使用的后台逻辑层的统一应用框架,上述问题在Tars框架的发展过程中已经得到了比较好的解决。现在,Tars通过插件集成到Spring Cloud体系中,希望通过输出Tars的RPC能力针对某些对性能和稳定性要求更高应用的场景提供一种新的解决方案,并且提供了基于Spring Boot的开发方式,符合Spring Cloud开发者的使用习惯,可以仅使用较小的开发成本在整个Spring Cloud体系中引入Tars的RPC能力。

github地址:https://github.com/Tencent/Tars

瓶颈分析

传统Http服务性能瓶颈分析:

  1. 网络连接使用率不高

    由于http协议本身是无状态的,所以发起一次请求的时候必须等待上一个请求响应才能再次使用这个连接,就算是采用流水线模式一个连接上的请求也会被之前发起的请求所阻塞,如果要提高并发能力则必须要建立大量的连接,而连接的建立、维持和销毁都会消耗系统资源。

  2. 通讯协议性能低下

    http + json 本身是一种可读性很高的文本协议,因此实际传输的数据包会比二进制协议要大不少,而且文本协议在数据的序列化反序列化效率上相比二进制数据的效率要低很多,所以 http 协议本身的性能就不高。

  3. 传统 http 服务基于同步的线程模型

    传统的 http 服务多是基于同步的线程模型,由于 http 协议本身无状态,所以在协议层面就不支持异步,所以当我们在客户端发起一次 http 调用时主调线程必须挂起等待被调响应请求,这个时候主调线程的资源则被浪费了,因为线程资源是有限的,大量线程被挂起等待白白浪费了主调方的运算资源。

将Tars结合到Spring Cloud中使用,通过Tars提供的长连接异步调用和二进制协议可以明显的提升RPC调用性能。长连接通过连接复用减少整体的连接数量减少了资源消耗,同时通过二进制协议提升了编解码效率提升了整体的RPC性能。

传统http服务稳定性问题分析:

  1. 服务端基于同步的线程模型

    微服务的服务端基于同步的线程模型面临的最大的隐患就是线程的IO等待,比如说一个基于同步的线程模型的微服务,依赖后面的3个接口,微服务本身的线程数是50个,那么当后面依赖的3个接口中有一个延时飚高,只需要有50个对问题接口的调用,就足够把整个微服务的进程挂起,因为当前已经没有线程能够对外提供服务了(当然可以设置超时,但是设置超时治标不治本)。同时,由于微服务线程对问题接口的IO等待,会导致微服务的队列中堆积大量的等待时间过长(可能已经超时)的请求,当问题接口恢复后,服务端会消耗资源去处理大量的过期的请求(请求超时,客户端不再等待)导致问题进一步恶化,严重的甚至会导致系统雪崩。

在这种情况下Tars提供了纯异步化编程,和服务端过载保护的能力,在服务端保证收到大量的请求也能够保证服务的正常处理效率,其次因为主调方采用长连接和异步调用,避免了大量新建连接和阻塞带来的资源浪费从而提升了服务的整体稳定性。

Tars RPC特性支持:

连接复用

因为http协议的特性,http的回包是依赖于请求的先后顺序的,必须要按照顺序处理完一个请求再处理下一个请求,如果希望并行的处理请求则只能通过建立新的链接从而产生建连的时间开销以及维护连接需要的CPU和内存资源。

而Tars的协议设计是Tars的私有协议,每个请求会带有一个请求id,通过同一个链接来发送多个请求可以通过id来匹配返回从而避免了线程阻塞,从而降低了硬件资源消耗。

通过上图可以看到,Tars可以在同一个连接上不断的写入请求和接收响应,而客户端通过请求Id来关联每一个请求和对应的响应,从而可以复用连接,避免了资源的浪费,通常情况下一个客户端和一个服务端之间仅使用数个连接就可以满足传输的要求。

二进制协议

Tars的数据传输采用的是Tars协议进行编解码,Tars协议是一种二进制协议,相较于常见的JSON等文本协议,二进制协议主要有两个方面的优势:

  1. 编解码效率

    二进制协议的编解码是按二进制位直接进行编解码的,减少了对不确定的字符串解析的过程,直接从对应的二进制位读取数据,效率相比解析文本协议有非常大的提升。

  2. 网络包大小

    因为所有的数据都是采用二进制存储,数据按位存储减少了对空间的浪费,使得数据序列化后能减少对空间的占用。

Tars协议采用.tars文件定义接口和数据接口,通过提供的工具可以将数据和接口定义翻译成各种语言的代码实现。

比如我们定义如下接口:

module TestApp
{
    interface Hello
    {
        string hello(int no, string name);
    };
};

则可以通过maven插件生成如下客户端接口和服务端接口:

@Servant
public interface HelloServant {

    public String hello(int no, String name);
}

@Servant
public interface HelloPrx {

    public String hello(int no, String name);

    public String hello(int no, String name, @TarsContext java.util.Map<String, String> ctx);

    public void async_hello(@TarsCallback HelloPrxCallback callback, int no, String name);

    public void async_hello(@TarsCallback HelloPrxCallback callback, int no, String name, @TarsContext java.util.Map<String, String> ctx);
}

接口的共享只需要提供接口的定义文件,使用者通过定义文件直接生成客户端接口代码即可。这样减少了双方的沟通成本,避免了需要写大量的接口定义文档以及解析JSON所需的对象。

异步调用

相比于使用Http协议的常规方案,Tars首先提供的特性就是异步长连接的RPC调用方式:

发起一个异步调用之后,当前线程并不会被阻塞而是继续执行,当收到服务端响应之后在回调线程池中通过回掉函数来执行结果的处理。这样所有的处理线程都一直处于工作的状态中,而不会挂起导致线程资源的浪费。整体上提升了服务的处理能力。

Tars的异步能力主要是通过两个部分的异步来实现的,首先是网络首发包的异步,Tars的网络层实现采用了Reactor模型,通过nio提供的事件IO实现基于事件的异步网络IO。第二是线程模型的异步,我们从线程模型上来看Tars如何是做到异步调用的:

Tars的主要通过上图的过程来完成异步调用,首先主调线程发起异步调用,主调线程将请求内容加入网络线程池的发送队列中,之后该线程继续执行。网络线程池使用Reactor模型实现,通过nio提供的Selecter实现事件IO,所以所有网络线程均是事件驱动的异步IO,当监听到对应连接的写事件后将请求发送,等待监听到读事件后读取响应并交给回调线程处理响应。这样所有的线程都避免了IO阻塞达到了更高的利用效率。

过载保护

此外,如果服务端收到过量的请求往往会导致服务端的线程竞争,让服务端的处理能力低于正常的处理水平,在Tars则通过队列来进行过载保护。我们来看Tars的线程模型:

在网络线程收到请求后,Tars会将请求先加入请求队列,工作线程从请求队列中获取请求进行处理,如果短时间内大量请求到达只会被缓存到请求队列中并不会影响工作线程池的处理能力。如果工作线程池从队列中取到请求发现其已经超时则会直接丢弃请求避免处理无效的请求。

集成样例

通常可以简单可以简单的改造服务,将本来的Http接口改为使用Tars,我们对比一下在同步调用场景下Tars调用和Http调用的性能差异,这里规定了服务端线程数为100个线程,服务端的处理都为简单的echo服务。我们对比一下在同一台机器上不同RPC方式、不同的客户端线程数以及不同数据包大小的TPS数据:

可以看出,因为采用了连接复用和二进制的协议,整体的调用效率相比使用Http有了非常明显的提升,而且是仅仅在简单优化了一下调用方式的情况下,对业务处理逻辑并没有影响。

假如我们在Spring Cloud中存在这样一个调用关系,A服务需要调用B服务,而B服务需要依赖处理耗时远大于是B服务的C服务。比如在通常的业务场景中,如果API接口需要调用一个订单生成的服务,而订单生成服务只需要生成订单ID计算量相对较小,但是他还需要依赖一个订单写入服务,应为涉及到库存修改、订单写入需要一系列的事务处理,整体耗时远远大于订单ID的生成,所以订单服务大量的线程资源浪费在了等待订单写入服务上。在这种情况下可以使用Tars改造订单服务和写入服务,从而使用异步调用写入服务来提升资源利用率,采用Tars提供的异步RPC能力来进行跟深度的改造:

通常情况下如果使用同步调用,因为B需要等待C服务的响应,需要花上自身处理耗时的数倍来进行等待C服务返回结果,线程被阻塞浪费线程资源。这样的情况我们可以保持A服务不变,提供REST接口,而B服务采用异步调用来进行改造。如下图所示:

我们通过简单的代码来模拟上述过程,加入C服务的逻辑时收到请求后Sleep 10s后返回结果,C服务有10个处理线程,最开始B和C之间采用同步调用,要达到最大的并发效率B服务必须也提供10个线程才能够达到最大的并发效率 TPS 为1。此时我们采用异步调用改造B服务:

// 获取异步上下文
final AsyncContext context = AsyncContext.startAsync();

// 回调函数
HelloPrxCallback callback = new HelloPrxCallback() {
    @Override
    public void callback_hello(String ret) {
        try {
            // 在回掉中通过异步上下文回复结果
            context.writeResult(ret);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    @Override
    public void callback_exception(Throwable ex) {
        ex.printStackTrace();
    }
    @Override
    public void callback_expired() {
    }
};

// 发起异步调用
proxy.async_hello(callback, "hello world");

此时仅需核数 + 1个线程即可达到最大的处理效率。在通常的业务使用中,如果所有IO均用异步实现,那么只使用核数+1个线程便能达到较高的处理效率,从而避免了同步IO带来的资源浪费。

对上述情况进行测试,我们规定C服务默认采用100个线程,服务的处理过程为Sleep 10s,用以模拟一个耗时比较高的资源服务。B服务为一个依赖资源服务C的普通服务,即收到C的结果即返回,在测试中B服务分别采用同步和异步的方式调用C服务,通过调整线程数记录B服务在不同线程数的情况下能提供的最大吞吐:

因为C服务能提供的最大TPS为10,可以看出使用Tars的异步调用因为避免了阻塞,仅使用较少的线程数便可以达到对资源服务C的充分利用,从而避免了对资源的浪费。

在以上改造中,对外的Http接口并不需要改动,可以仅在内部需要提升RPC性能和用到异步调用的地方进行改造即可,可以平滑的按服务逐步升级。而且因为均采用Spring boot实现,只需要修改接口,其余所有业务代码还是使用Spring注入即可。

总结

我们通过插件实现了Tars对Eureka服务发现的支持,提供了Spring boot starter包和相关的注解,能够通过符合Spring Cloud开发者习惯的开发方式快速开发服务。通过对服务发现和开发方式对Spring Cloud集成能够让开发者以较小的代价快速的在整个Spring Cloud的环境中快速引入Tars的RPC能力。

如何在 spring cloud 环境中快速构建 tars 服务

Tars框架提供了通过Spring boot启动的api,以及接入Eureka服务发现的能力。与通常的Spring Cloud服务一样也分为服务提供者和服务发现者。我们首先来编写一个简单的服务提供者,新建一个maven工程,引入依赖:

<dependency>
    <groupId>com.tencent.tars</groupId>
    <artifactId>tars-spring-cloud-starter</artifactId>
    <version>1.4.0</version>
</dependency>

接下来我们需要定义接口,但是与通常的RESTful接口定义不同,Tars的接口定义首先需要定义接口文件,也就是Tars文件。这里定义一个简单的Tars接口,详细的定义可以参见官方文档。

module TestApp 
{
    interface Hello
    {
        string hello(int no, string name);
    };
};

这样就定义好了一个简单的Tars接口,接口包含一个方法hello,该方法包含输入两个参数,返回一个字符串。接下来使用该接口生成Java代码,使用Tars提供的maven插件,再pom中添加如下配置:

<plugin>
    <groupId>com.tencent.tars</groupId>
    <artifactId>tars-maven-plugin</artifactId>
    <version>1.0.4</version>
    <configuration>
        <tars2JavaConfig>
            <!-- tars文件位置 -->
            <tarsFiles>
                <tarsFile>${basedir}/src/main/resources/hello.tars</tarsFile>
            </tarsFiles>
            <!-- 源文件编码 -->
            <tarsFileCharset>UTF-8</tarsFileCharset>
            <!-- 生成服务端代码 -->
            <servant>true</servant>
            <!-- 生成源代码编码 -->
            <charset>UTF-8</charset>
            <!-- 生成的源代码目录 -->
            <srcPath>${basedir}/src/main/java</srcPath>
            <!-- 生成源代码包前缀 -->
            <packagePrefixName>com.qq.tars.quickstart.server.</packagePrefixName>
        </tars2JavaConfig>
    </configuration>
</plugin>

之后执行命令mvn tars:tars2java即可生成对应的Java代码如下:

@Servant
public interface HelloServant {
    public String hello(int no, String name);          
}

一个包含@Servant注解的Java接口,实现我们定义的接口通过实现该Java接口即可。与一般使用Url不同,Tars把一个接口的集合称为一个Servant,一般一个Servant包含了提供一些列功能的一组方法,并生成一个Java接口。当然使用tars的接口定义也可以生成各种语言的接口代码,包括C++、nodejs、php等,但是这里主要讲解Java部分的使用。

生成接口后需要对接口进行实现。实现接口中的方法,之后对整个实现类添加@TarsServant注解,该注解表明被修饰的类是一个Tars Servant,并需要在注解中表明该Servant名称,作为客户端调用Servant的标识,按照Tars规范,servant名称采用”Obj”结尾。我们可以对上面的接口进行实现:

@TarsServant(name="HelloObj")
public class HelloServantImpl implements HelloServant {

    @Override
    public String hello(int no, String name) {
        return String.format("hello no=%s, name=%s, time=%s", no, name,        System.currentTimeMillis());
    }
}

这样我们就实现了上述接口,并给他命名叫做HelloObj。接下来我们就要编写服务端的启动方法了,与一般的Spring应用一样:

@SpringBootApplication
@EnableTarsConfiguration
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

    @Bean
    public HelloServantImpl helloServant() {
        return new HelloServantImpl();
    }
}

通过一个注解配置即可启动,这个注解配置唯一特殊的地方是需要添加一个注解@EnableTarsConfiguration用于表示这个Spring boot应用是一个Tars服务,需要启动服务端。同事我们需要将我们的接口实现定义为一个Bean注入到Spring容器中。

至此服务端的代码编写就完成了,接下来我们只需要在application.yml中添加上启动配置就可以启动了。

eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8761/eureka/     #服务注册中心的地址
tars:                                                #此标签下的都是tars-java特有的配置
  server:                                            
    port: 18601                                      #服务端口
    application: TestApp                             #应用名称
    server-name: HelloJavaServer                     #服务名称
    log-path: E:\tars\testServer\bin\log             #指定服务日志路径
    data-path: E:\tars\testServer\data               #指定数据文件路径
  client:
    async-invoke-timeout: 10000

向eureka注册中心注册的配置和普通的eureka客户端一直,没有特殊的要求。tars.server下有几个必须的配置项,第一个是服务启动的端口,第二个是服务的应用名和服务名(在Tars的体系中,一个接口定位由三个坐标确定,应用名.服务名.Servant名,如TestApp.HelloJavaServer.HelloObj)。此外还需要配置一个log目录。

至此所有的开发工作完成,即可通过main方法启动了。

接下来是如何消费Tars服务,首先还是通过之前的tars文件和maven插件生成客户端接口以及回调函数抽象类,如下:

@Servant
public interface HelloPrx {

    public String hello(int no, String name);

    public String hello(int no, String name, @TarsContext java.util.Map<String, String> ctx);

    public void async_hello(@TarsCallback HelloPrxCallback callback, int no, String name);

    public void async_hello(@TarsCallback HelloPrxCallback callback, int no, String name, @TarsContext java.util.Map<String, String> ctx);
}

public abstract class HelloPrxCallback extends TarsAbstractCallback {
    public abstract void callback_hello(String ret);
}

使用客户端接口只需要引入Tars相关依赖后使用@TarsClient注解来构造客户端即可

@Component
public class Client {
    @TarsClient(name = "TestApp.HelloJavaServer.HelloObj")
    private HelloPrx proxy;

    public String hello(int no, String name) {
        return proxy.hello(no, name);
    }
}

如若个使用异步调用则使用如下代码:

@Component
public class Client {
    @TarsClient(name = "TestApp.HelloJavaServer.HelloObj")
    private HelloPrx proxy;

    public void hello(int no, String name) {
        proxy.async_hello(new HelloPrxCallback() {
            @Override
            public void callback_expired() {
            }
            @Override
            public void callback_exception(Throwable ex) {
            }
            @Override
            public void callback_hello(String ret) {
                System.out.println(ret);
            }
        }, 1000, "Hello World");
    }
}

在服务端处理请求时客户端可以不用阻塞等待,而是在收到返回包后根据请求Id去处理结果,提高了连接的利用效率的同时避免了线程阻塞等待。所以tars一般在客户端和服务端之间维持数个长连接即可达到很高的传输效率。