构建可伸缩系统 Scala vs Java

在我上一篇文章里我说明了拿Scala和Java对比是没有意义的,最后总结时提到在性能层面,你应该问的问题是“Scala如何帮助你解决服务器因为非预期的高负载崩溃的问题”。在这篇文章里我将尝试回答这个问题,并且展示作为构建可伸缩系统的语言,Scala确实比Java要好很多。

但是,别指望我们能轻松回答这个问题。首先呢,尽管做一些微小的基准很简单,但尝试展示真实世界里的应用是否能有效处理高负载却很难,因为在这篇简单的博客中创建一个足够小的应用来演示和解释是很难的,同时还要足够复杂来模拟出真实世界里的应用在负载下的行为,而且模拟真实世界中的高负载也很难。所以我将做个假设——真实世界的应用中将会出现一些非预期的错误,再展示Scala如何帮助你解决这些问题,而Java却不能做到。然后我将解释这只是冰山一角,Scala有更多能给你有效帮助的情景和特性。

一个在线商店

在这个练习中我实现了一个在线商店。下图是它的架构:

 

你能看到,这个商店系统会调用一个支付服务和一个搜索服务,它自身处理三种请求,一种是首页,它不请求其他服务,一种是执行支付,需要使用支付服务,另一个是搜索商店的商品列表,它用到了搜索服务。我将使用这个在线商店系统来进行压测,制定基准。分别用Java和Scala来实现它,然后进行对比。搜索和支付服务不会变,它们只是简单的JSON API,将返回硬编码的值,但是都会模拟一个20ms的处理过程。

在Java实现中,我将尽可能的简单,直接使用servlet来接收请求,使用Apache的HTTP client来伪造请求,使用Jackson来解析和格式化JSON。应用部署在Tomcat上,配置Tomcat为NIO的连接器,使用默认连接限制10000和线程池200.

在Scala实现中我使用Play框架2.1,使用基于Ning HTTP客户端的Play的WS API来伪造请求,使用基于Jackson的Play的JSON API来处理JSON的解析和格式化。Play框架构建于Netty,它没有连接限制,使用Akka做线程池,我配置它使用默认的线程池大小,也就是一个CPU一个线程,我的机器是4核。

我将使用JMeter来执行压测、计算基准。对每个请求类型(首页,支付和搜索)我将让300个线程不断循环来伪造请求,每个请求之间随机停顿500到1500毫秒。这达到了每种请求类型平均最大300 qps的吞吐量,或者总计900的qps。

所以,我们看看Java压测的结果。

在此图中我对每种请求类型都做了三种度量。median表示中等的请求耗时。对首页来说,接近于0忽略不计,对请求和支付请求来说,大概是77毫秒。图中还放置了90% line一项,此项是web应用的常用度量方式,它表现出90%的请求耗时低于多少,这很好的体现了处理慢的请求的情况。对于index页面来说这些都忽略不计,搜索和支付请求都达到116毫秒。最后一个度量是吞吐量,它体现出每秒请求处理数量。理论上的最大值不算太离谱,index页面是290请求每秒,搜索和支付是270请求每秒。这些结果不错,Java服务处理这些负载不在话下。

现在我们看下Scala的基准:

就像你看到的,它和Java的结果几乎相同。这并不令人意外,因为它们的业务逻辑都非常简单,大部分处理时间其实用在远程服务调用上。

出现非预期的故障时

所以,我们看到了Scala和Java两者轻松实现了这个在线商店,轻易解决了压测时的负载。但是当事情不是这么轻松随意时会发生什么?如果它们调用的服务挂掉了会发生什么?我们假设搜索服务需要花费30秒才能返回,并且返回错误。这不是一个罕见的失败场景,特别当你通过一个代理做负载均衡,代理尝试连接到服务,超过30秒后,返回一个网关错误是常有的事。接下来我们看看它们实现应用程序如何处理这种负载。我将设定搜索请求花费至少30秒才返回,那上述图中的指标将如何变化?这里是Java的结果:

好了,我们不再轻松开心了。搜索请求自然花费了很长的时间,但是支付请求也平均耗费9秒才返回,90% line变成至少20秒。不仅如此,idnex页面也受到类似的冲击。用户不会在你的首页等待那么久的时间。每个请求类型的吞吐量降低到30个请求每秒。这不容乐观,因为你的搜索服务挂了,你整个站点都特别的不可用,你即将丢失大量用户和金钱。

那Scala应用表现如何呢?这里给你揭晓:

在分析这些指标之前,我想指出在图中我已经重新设置搜索的耗时到160毫秒——搜索请求实际耗时30秒,但是在图中这样会导致其他柱状图的数据过小而难以展示。我们需要看到的重点是尽管搜索不可用了,我们的支付和index页面请求回复时间和吞吐量几乎没变。明显,用户不能使用搜索肯定会不开心,但是至少他们仍然能使用站点的其他部分,查看首页等指定页面,甚至能为商品支付。而且Google没挂,他们仍然能使用Google搜索到你的站点。所以你可能会丢失一些业务,但是冲击是有限的。

所以,在这个基准中,我们能看到Scala大获全胜。当事情变糟时,Scala应用将轻而易举的解决,为你做到最好,然而Java应用很可能全面瘫痪。

但是在Java中我们也能做到这样

对上述基准肯定会出现很多意料之中的的批判。首先,最明显的,就是Scala解决方案使用了异步IO,Java没有,这就没法比较了。这是事实,在Java中也能实现异步解决方案,在那种场景下Java的结果也许和Scala就没什么差异了。但是,尽管我们做到这样,Java开发者,不会这么做。不是说他们不能,而是他们不会。我写过很多web应用,使用Java调用其他系统,我很少使用异步IO,仅仅在非常特殊的环境下,我给你解释下原因。

假设你要做一系列的远程服务调用,每个依赖上一个返回的数据。这里是一个传统的Java同步解决方案:

User user = getUserById(id);
List<Order> orders = getOrdersForUser(user.email);
List<Product> products = getProductsForOrders(orders);
List<Stock> stock = getStockForProducts(products);

上面的代码很简单,Java开发者会感到非常亲切自然。为了完整性,我们看看Scala如何做相同的事情:

val user = getUserById(id)
val orders = getOrdersForUser(user.email)
val products = getProductsForOrders(orders)
val stock = getStockForProducts(products)

现在我们看看使用异步方式调用,Java中看起来如何?

Promise<User> user = getUserById(id);
Promise<List<Order>> orders = user.flatMap(new Function<User, List<Order>>() {
  public Promise<List<Order>> apply(User user) {
    return getOrdersForUser(user.email);
  }
}
Promise<List<Product>> products = orders.flatMap(new Function<List<Order>, List<Product>>() {
  public Promise<List<Product>> apply(List<Order> orders) {
    return getProductsForOrders(orders);
  }
}
Promise<List<Stock>> stock = products.flatMap(new Function<List<Product>, List<Stock>>() {
  public Promise<List<Stock>> apply(List<Product> products) {
    return getStockForProducts(products);
  }
}

首先,上面的代码非常难读,事实上它很难看懂,相比真正做事情的有效代码,出现了非常多的噪声代码,并且非常容易犯错和疏忽。其次,它写起来冗长乏味,没有开发者希望写这样的代码,我讨厌这么写。任何希望用这种方式开发的开发者估计都是疯了。最后,它感觉很不自然,这不是我们在Java中的做事方式,这不符合语言的习惯,它和其他Java生态系统也玩不转,第三方库和这种方式集成不好。像我之前说的,Java开发者能写这种代码,但他们不会,理由很充分。

我们看看Scala的异步方案:

for {
  user <- getUserById(id)
  orders <- getOrdersForUser(user.email)
  products <- getProductsForOrders(orders)
  stock <- getStockForProducts(products)
} yield stock

和Java的异步方案对比,这个就完全可读了,几乎和Scala、Java的同步方案一样。而且这不是开发者很少涉及的奇异的Scala特性,而是典型的Scala开发者每天编写的程序。Scala类库是为这种语法量身定做的,感觉很自然,这个语言也是为你量身定制的,像这样使用Scala编码令人十分愉悦。

这篇文章不是关于使用哪种语言性能更好,而是关于Scala如何帮助你编写默认就是可伸缩的应用程序,使用很自然的、可读的、符合语言习惯的代码。老实说,在编写可伸缩程序时,Scala为你推波助澜,而Java则让人逆流而上。

可伸缩包含更多的内容

虽然我提供的这个演示程序是一个非常特殊的例子,但哪个因为高负载而瘫痪的应用不特殊呢?这里我们再看看一些其他的例子,关于Scala如何利用优秀的异步IO支持,帮助你编写可伸缩代码:

  • 使用Akka,你能为不同类型的请求轻易的定义actor,并且给他们分配不同的资源限制。所以如果你的单个应用的特定部分出现问题或遇到非预期的负载,它可能停止响应,但你应用的其余部分可保持稳定
  • Scala,Play和Akka使得并行计算环境下使用多线程处理单个请求变得非常简单,它允许你在短时间内处理大量请求。Klout为他们的实现方案写过一篇非常优秀的文章
  • 因为它极其简单的异步IO,处理过程可以非常安全的分担到多台机器上,不会在接收到请求的机器上占用大量线程。

Java8将让异步IO变得简单

Java8可能将要支持闭包,这是Java世界的好消息,尤其当你想做异步IO时。然而,语法仍然不会像上述Scala代码一样可读。而且啥时候才能发布呢?Java7去年才发布,而且花了5年时间。Java8计划2013夏天发布,即使它能按时发,生态系统要花多久才能跟上?Java开发者要花多久才能从同步的思维切换到异步?在我看来,近期期待Java8不太靠谱。

不只是异步IO

目前为止我讨论和展示的是Scala能多么轻易的实现异步IO,如何帮助你实现可伸缩。但是不仅如此,下面介绍一些Scala的其他特性。immutability(不变性)

当你使用多线程处理单个请求时,就需要在多线程之间共享状态。这使得事情变得很复杂,因为计算机系统中的共享状态世界是很疯狂的,经常发生诡异的事情。比如死锁、比如一个线程更新内存,另一个线程脏读,比如资源竞争,比如使用互斥锁导致的性能瓶颈等等。

事情还没有那么糟,因为有一个非常简单的解决方案,让所有状态immutable(不可变)。如果所有状态是不可变的,那上述问题都不会发生。这是Scala帮你的另一个大忙,因为在Scala中,事物默认是不可变的。集合相关的API(类似java.util.collections中的API,如Map、List)就是不可变的,想要一个可变集合的话还需要明确声明。

而在Java中,你也能让事物不可变。有很多类库帮助你(即使比较笨拙)来实现不可变集合。但是却很容易因为偶然的疏忽导致事物变得可变。JavaAPI和语言本身就不易于实现不可变的体系结构,如果你使用第三方类库,它也很可能没有使用不可变结构,而且经常要求你使用可变结构,比如JPA。

我们看看一些代码,这里是一个Scala的不可变类

case class User(id: Long, name: String, email: String)

这个结构是不可变的,同时,它自动为这些属性产生访问器。我们看看Java的实现:

public class User {

  private final long id;
  private final String name;
  private final String email;

  public User(long id, String name, String email) {
    this.id = id;
    this.name = name;
    this.email = email;
  }

  public long getId() {
    return id;
  }

  public String getName() {
    return name;
  }

  public String getEmail() {
    return email
  }
}

又要写这么冗长的代码!而且我增加一个属性时咋办?我需要添加一个新的参数到构造函数而且还会截断现有的代码,或者我需要定义第二个构造函数。而在Scala中我仅需这么做:

case class User(id: Long, name: String, email: String, company: Option[Company] = None)

所有我已存在的调用这个构造函数的代码仍然能正常运行。当对象增长到拥有10个属性时构造函数将成为噩梦!Java中解决这个的方案是使用builder模式,这样将导致代码量翻倍。在Scala中,你能为参数命名,很简单能把参数对上号,它们还不必在正确的顺序上。修改一个属性时Scala也只需这样:

mail: String, company: Option[Company] = None) {
  def copy(id: Long = id, name: String = name, email: String = email, company: Option[Company] = company) = User(id, name, email, company)
}

val james = User(1, "James", "james@jazzy.id.au")
val jamesWithCompany = james.copy(company = Some(Company("Typesafe")))

上述代码很自然,很简单、可读,它是Scala开发者每天编写的代码,它是不可变的。它很适合并发编码,允许你安全编写可伸缩系统。Java也能做到,但是却需要冗长编码,写起来很不爽。我非常拥护Java中的不可变代码,我也写过很多不可变的类,但是很痛苦。而在Scala中,编写可变对象的代码反而更复杂。再次强调一下,Scala更能帮助你编写可伸缩代码。

总结

我不可能阐述所有Scala相比Java的优势,但是我希望让你感觉到编写可伸缩系统时,Scala是站在你一边的。我已经展示一些具体的度量值,也比较了Java和Scala的解决方案,可以看出并不是Scala系统比Java系统更可伸缩,而是在编写可伸缩系统时,Scala总是为你着想。它被设计为偏向可伸缩,它提倡帮助你提升可伸缩的实践。而Java却让事情变得复杂,和你对着干。

如果你对在线商店代码感兴趣,你能在此GitHub库找到。性能测试中的数据能在这里找到。

英文原文:Scaling Scala vs. Java,编译:ImportNew - 储晓颖

译文链接: http://www.importnew.com/2277.html

【如需转载,请在正文中标注并保留原文链接、译文链接和译者等信息,谢谢合作!】

关于作者: 储晓颖

现任支付宝架构师,负责监控分析域的架构和产品设计。架构时严谨,编码时疯狂。新浪微博:@疯狂编码中的xiaoY

查看储晓颖的更多文章 >>



相关文章

发表评论

Comment form

(*) 表示必填项

还没有评论。

跳到底部
返回顶部