使用分布式应用,是一个大型系统轻装减负,提升性能和可扩展性的必然途径。在分布式应用之中,不同服务之间的通讯,常常会使用RPC方式来调用。所以SOAP,Dubbo,Restful等协议或方法应运而生。高可用的微服务架构设计,也正是在这种背景之中蓬勃发展起来的。微服务架构之中的不同服务之间的通讯也是通过接口实现的,所以接口编程尤其重要,如果处理不当,可能会成为另一个性能瓶颈。
接口编程主要包括服务提供方,网络通讯和接口调用者等三个方面,在这三者之间到底谁才是决定性能的关键所在呢?
首先,服务细分本身就是一个提升性能的方法,细分出来的服务还可以任意实现负载均衡管理,所以对于服务提供方来说,保持高性能并不是问题,并且是可持续扩展的。
其次,在分布式环境中的网络通讯,最方便、实用和快捷的方式就是使用HTTP了,上面提到的SOAP,Dubbo,Restful说到底其实都是使用了HTTP协议,所以它们之间的性能比较应该没有什么大的区别。
这样来说,在分布式应用之间的接口编程,其性能关键的决定者就是第三方调用者自己了。你会认同这种看法吗?
下面,我们使用一个简单的压力测试工具,Apache的ab来验证一下接口调用的并发性情况,看看上面的分析正确与否。
例如,有一个简单的应用,它从数据库中读取一个指定的分类信息,然后用Json方式返回这一分类信息。
这个分类的数据结构具有主类和子类两个对象,它们的数据结构如下所示。
主类:
public class Sorts {
private Long id;
private String name;
private String operator;
private Date create;
private Set<Subsorts> subsortses = new HashSet<>();
......
}
子类:
public class Subsorts {
private Long id;
private String name;
private String operator;
private Date create;
......
}
我们通过一个控制器,可以返回按分类名称查询的分类信息:
@RestController
@RequestMapping("/sorts")
public class SortsController {
@Autowired
private SortsRepository sortsRepository;
@RequestMapping(value="/names/{name}")
public String findByName(@PathVariable String name) {
Sorts sorts = sortsRepository.findByName(name);
return new Gson().toJson(sorts);
}
}
其中,调用的SortsRepository的设计如下,即以分类名称作为参数查询分类信息:
@Repository
public interface SortsRepository extends GraphRepository<Sorts> {
Sorts findByName(@Param("name") String name);
}
启动应用后,在浏览器中输入如下网址:
http://localhost:9001/sorts/names/mainsorts
即可返回如下信息:
{"id":5016,"name":"mainsorts","operator":"editor","create":"Feb 8, 2017 4:24:23 PM","subsortses":[{"id":5017,"name":"subsorts","operator":"editor","create":"Feb 8, 2017 4:24:23 PM"}]}
即返回一个名称为“mainsorts”的主类,它包含一个子类名称为“subsorts”。
现在,我们用ab测试一下它的性能,即在1000个请求中,使用1000个并发进行压力测试。
ab -n 1000 -c 1000 http://localhost:9001/sorts/names/mainsorts
完成压力测试后,返回如下所示的主要信息:
Concurrency Level: 1000
Time taken for tests: 3.536202 seconds
Complete requests: 1000
Failed requests: 0
Write errors: 0
Total transferred: 366000 bytes
HTML transferred: 183000 bytes
Requests per second: 282.79 [#/sec] (mean)
Time per request: 3536.202 [ms] (mean)
Time per request: 3.536 [ms] (mean, across all concurrent requests)
Transfer rate: 100.96 [Kbytes/sec] received
在这些返回的参数中,有压力测试中比较有参考价值的吞吐率(Requests per second),请求响应反应时间(Time per request),并发的每个请求平均消耗时间(across all concurrent requests),平均每秒的流量(Transfer rate)等等。这里,我们并不细致地分析这些参数,我们只关心一个参数:即失败的请求(Failed requests),并将它作为我们的测试标准。
从这个测试中可以看出,它的并发性能是很不错的,1000个请求,1000个并发,全部通过。(这是在Windows本地中ab可用的最大限度,超过这个限度后ab无法正常运行)
如此一个简单的请求,理所当然应当如此。
现在,我们使用另一个应用,暂且就把它叫做调用者服务吧,通过RPC的方式使用Dubbo和Restful等方法调用上一个服务的分类信息接口:http://localhost:9001/sorts/names/mainsorts。
假如我们的调用者服务是这样设计的,这是一个使用Restful方式的访问方法:
@Service
public class SortsService {
@Autowired
private SortsClient sortsClient;
public String findByName(String name) {
return sortsClient.findByName(name);
}
}
其中SortsClient的设计如下所示:
@FeignClient("catolog")
public interface SortsClient {
@RequestMapping(method = RequestMethod.GET, value = "/sorts/names/{name}")
String findByName(@RequestParam("name") String name);
}
这里是使用了微服务的架构设计,即通过@FeignClient调用了分类信息服务中的Rest接口。你也可以使用RestTemplate或者WebService来设计,其结果并没有大的不同。
在调用者服务中,同样使用一个控制器来返回请求,不同的是,它不是从数据库中查询信息,而是调用了分类信息服务提供的接口。
@RestController
@RequestMapping("/sorts")
public class RestSortsController {
@Autowired
private SortsService sortsService;
@RequestMapping(value="/names/{name}")
public String findByName(@PathVariable String name) throws Exception{
return sortsService.findByName(name);
}
}
启动这个应用,使用如下网址进行访问:
http://localhost:9999/sorts/names/mainsorts
这同样也能返回跟上面分类信息服务在本地请求一样的分类Json数据。
那么它的压力测试的结果将是如何呢?先使用同样的参数进行测试:
ab -n 1000 -c 1000 http://localhost:9999/sorts/names/mainsorts
很不幸,它的失败请求太多了:
Concurrency Level: 1000
Time taken for tests: 2.918167 seconds
Complete requests: 1000
Failed requests: 981
(Connect: 0, Length: 981, Exceptions: 0)
Write errors: 0
Non-2xx responses: 41
Total transferred: 303086 bytes
HTML transferred: 121701 bytes
Requests per second: 342.68 [#/sec] (mean)
Time per request: 2918.167 [ms] (mean)
Time per request: 2.918 [ms] (mean, across all concurrent requests)
Transfer rate: 101.09 [Kbytes/sec] received
经过了多个测试,要使它没有失败请求,最多只能通过10个并发,即:
ab -n 1000 -c 10 http://localhost:9999/sorts/names/mainsorts
只有这个时候,它才能达到理想的请求效果:
Concurrency Level: 10
Time taken for tests: 2.727156 seconds
Complete requests: 1000
Failed requests: 0
Write errors: 0
Total transferred: 365000 bytes
HTML transferred: 183000 bytes
Requests per second: 366.68 [#/sec] (mean)
Time per request: 27.272 [ms] (mean)
Time per request: 2.727 [ms] (mean, across all concurrent requests)
Transfer rate: 130.54 [Kbytes/sec] received
想再加一个并发也是不行的,例如:
ab -n 1000 -c 11 http://localhost:9999/sorts/names/mainsorts
在1000个请求中,使用11个并发它同样是很糟糕的情况:
Concurrency Level: 11
Time taken for tests: 3.577205 seconds
Complete requests: 1000
Failed requests: 959
(Connect: 0, Length: 959, Exceptions: 0)
Write errors: 0
Total transferred: 362253 bytes
HTML transferred: 180253 bytes
Requests per second: 279.55 [#/sec] (mean)
Time per request: 39.349 [ms] (mean)
Time per request: 3.577 [ms] (mean, across all concurrent requests)
Transfer rate: 98.68 [Kbytes/sec] received
你会认为Restful太不给力了吗?那么你可以尝试使用WebService或者Dubbo试一试,我使用了Dubbo测试,还是这种情况。
这时候你会怀疑谁太不卖力?网络,CPU,内存,偶或IO?那么你就努力升级CPU,内存吧,甚至还可以更换网卡。
如果在调用者服务中进行负载均衡配置怎么样?当然这是一个不错的提议。经过测试,如果不考虑硬件因素的话,使用负载均衡每增加一个调用者服务,其并发数可增加一倍。
现在是不是该考虑一下调用者本身的问题了。
为什么不考虑一下多线程和异步请求呢?所幸的是,Java8的 CompletableFuture提供了这种功能。CompletableFuture是一种异步非阻塞反应式回调编程方法,简称反应式编程方法,它有两个特性,一个是使用了高并发多线程,另一个是使用了异步非阻塞回调。
这名称听起来虽然很复杂,但使用起来其实很简单。
在控制器和服务之间,增加一个SortsFuture设计就可以了:
@Component
public class SortsFuture {
@Autowired
private SortsService sortsService;
@AsyncTimed
public CompletableFuture<String> findByName(String name) {
return CompletableFuture.supplyAsync(() -> {
return sortsService.findByName(name);
});
}
}
然后控制器做一点小的改变:
@RestController
@RequestMapping("/sorts")
public class RestSortsController {
@Autowired
private SortsFuture sortsFuture;
@RequestMapping(value="/names/{name}")
public String findByName(@PathVariable String name) {
return sortsFuture.findByName(name). join ();
}
}
它只是简单地使用supplyAsync创建了一个CompletableFuture对象,调用上面的SortsService用类目名称查询一个类目信息。这里使用join ()取得返回结果,这还是一个阻塞式调用,使用CompletableFuture并不推荐这样调用。即使是这样,它的性能已经大为改观了。
你估计现在它的性能提高了几倍?10倍,20倍,50倍?甚至还远远不只!
使用如下方法测试:
ab -n 1000 -c 1000 http://localhost:9999/sorts/names/mainsorts
返回如下的测试结果:
Concurrency Level: 1000
Time taken for tests: 5.496315 seconds
Complete requests: 1000
Failed requests: 0
Write errors: 0
Total transferred: 365000 bytes
HTML transferred: 183000 bytes
Requests per second: 181.94 [#/sec] (mean)
Time per request: 5496.315 [ms] (mean)
Time per request: 5.496 [ms] (mean, across all concurrent requests)
Transfer rate: 64.77 [Kbytes/sec] received
天啊!它最小提高了100倍,这跟本地的调用几乎没有两样,它跟我们第一次在本地所进行的压力测试的效果几乎是一模一样的。
如果不使用阻塞式调用,它的表现会更加优秀。这可以使用thenApply方法将调用结果进行转换,即将请求取得的数据作为一个参数返回一个页面中:
@Autowired
private SortsFuture sortsFuture;
@RequestMapping(value="/names/{name}")
public CompletableFuture<ModelAndView> findByName(@PathVariable String name) throws Exception{
return sortsFuture.findByName(name).thenApply(sorts ->
new ModelAndView("sorts/show", "sorts", new Gson().fromJson(sorts, SortsQo.class)));
}
如果有多个请求,还可以使用组合方式。例如下列程序,首先调用了库存服务的商品信息,然后使用商品信息作为参数,调用了另一个远程服务即类目服务以取得分类信息,最后将这两类信息合并返回一个页面中。这样复杂的调用,使用压力测试,也能1000个请求1000个并发全部无错误通过。
@Autowired
private GoodsService goodsService;
@Autowired
private SortsService sortsService;
@RequestMapping(value="/{id}")
public CompletableFuture<ModelAndView> findById(@PathVariable Long id, ModelMap model) throws Exception{
return CompletableFuture.supplyAsync(() -> goodsService.findById(id))
.thenCompose(goods -> CompletableFuture.supplyAsync(() -> {
GoodsQo goodsQo = new Gson().fromJson(goods, GoodsQo.class);
String sorts = sortsService.findById(goodsQo.getSortsid());
model.addAttribute("goods", goodsQo);
model.addAttribute("sorts", sorts);
return new ModelAndView("goods/show");
}
));
}
程序中,GoodsService和SortsService分别调用了两个不同的远程服务,即库存商品服务和类目服务,其中使用thenCompose方法将两个调用进行组合,并且第二个调用使用了第一个调用的结果。
下面引用Java8的CompletableFuture的一些介绍,以便更加深入地了解它的用法:
CPU的计算能力发展迅速,已经达到顶峰造极的地步,特别是近几年的发展趋势,更是转向了多处理器的方向,即增加了处理器并行和并发的运算能力。如果还在使用单线程的程序,其处理能力是相当有限的,所以有人说,程序员必须要“从根本上转向并发”,使用多任务、多线程的处理方法才能赶上硬件发展的脚步。
即使可以使用传统的异步回调方法来编写API,但是这种方法的调用将会使程序陷入回调的深渊之中,代码将会很纠缠,难以理解,并且容易出错。所以最好的方法是使用Java8的CompletableFuture方法来编写API调用代码。
在Java 5的并发库中,主要关注于异步任务的处理,它采用了这样一种模式,producer线程创建任务并且利用阻塞队列将其传递给任务的consumer。这种模型在Java 7和8中进一步发展,并且开始支持另外一种风格的任务执行,那就是将任务的数据集分解为子集,每个子集都可以由独立且同质的子任务来负责处理。
Future 是Java 5添加的类,用来描述一个异步计算的结果。虽然 Future 以及相关使用方法提供了异步执行任务的能力,但是对于结果的获取却是很不方便,只能通过阻塞或者轮询的方式得到任务的结果。阻塞的方式显然和我们的异步编程的初衷相违背,轮询的方式又会耗费无谓的CPU资源,而且也不能及时地得到计算结果,为什么不能用观察者设计模式当计算结果完成时及时通知监听者呢?
很多语言,比如Node.js,采用回调的方式实现异步编程。Java的一些框架,比如Netty,自己扩展了Java的 Future 接口,提供了 addListener 等多个扩展方法。Google guava也提供了通用的扩展Future: ListenableFuture 、 SettableFuture 以及辅助类 Futures 等,方便异步编程。Scala也提供了简单易用且功能强大的Future/Promise 异步编程模式 。
在Java 8中,新增加了一个包含50个方法左右的类: CompletableFuture ,提供了非常强大的Future的扩展功能,可以帮助我们简化异步编程的复杂性,提供了反应式编程的能力,可以通过回调的方式处理计算结果,并且提供了转换和组合CompletableFuture的方法。
CompletableFuture的使用方法:
1. 创建CompletableFuture对象
public static CompletableFuture<Void> runAsync(Runnable runnable)
public static CompletableFuture<Void> runAsync(Runnable runnable, Executor executor)
public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier)
public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier, Executor executor)
这些方法中以 Async 结尾并且没有指定 Executor 的方法会使用 ForkJoinPool.commonPool() 作为它的线程池执行异步代码。
2. 运行完成的代码,可以使用这些方法:
CompletableFuture<Void> thenAccept(Consumer<? super T> block);
CompletableFuture<Void> thenRun(Runnable action);
3. 转换
由于回调风格的实现,我们不必因为等待一个计算完成而阻塞着调用线程,而是告诉 CompletableFuture 当计算完成的时候请执行某个 function 。而且我们还可以将这些操作串联起来,或者将 CompletableFuture 组合起来。
public CompletableFuture thenApply(Function fn)
public CompletableFuture thenApplyAsync(Function fn)
public CompletableFuture thenApplyAsync(Function fn, Executor executor)
这一组函数的功能是当原来的CompletableFuture计算完后,将结果传递给函数 fn ,将 fn 的结果作为新的 CompletableFuture 计算结果。因此它的功能相当于将 CompletableFuture<T> 转换成 CompletableFuture<U> 。
这三个函数的区别和上面介绍的一样,不以 Async 结尾的方法由原来的线程计算,以 Async 结尾的方法由默认的线程池 ForkJoinPool.commonPool() 或者指定的线程池 executor 运行。
4. 结合或链接两个Futrues的结果
<U> CompletableFuture<U> thenCompose(Function<? super T,CompletableFuture<U>> fn);
<U,V> CompletableFuture<V> thenCombine(CompletableFuture<? extends U> other, BiFunction<? super T,? super U,? extends V> fn)
下面的例子会将三个方法运行的结果即返回的字符串最后连接成一个输出:
public CompletableFuture<String> getMessage() {
return CompletableFuture.supplyAsync(() -> {
return "hello world";
})
.thenCombine(CompletableFuture.supplyAsync(() -> " and you "), (ret, msg) -> ret + msg)
.thenCompose(p -> CompletableFuture.supplyAsync(() -> p + " and all"));
}
最后输出的结果是:“hello world and you and all”。
最为难能可贵的是:不管在创建CompletableFuture的时候使用了多少层级的组合,它并不是嵌套的,而是扁平的,并且要获取它的结果仅仅只需要一步操作。