为什么我们使用Netty?

在rpc服务中,Netty扮演了什么角色?顺便聊聊我对分布式的一些认识。

读书笔记,参考自微信公众号《码农翻身》。

另参考知乎回答:https://www.zhihu.com/question/25536695/answer/221638079

 

一、首先聊聊巨型应用

说实话,这是一个很大的话题。我打算说说我的浅薄理解,如果文笔不好或者知识有错漏,请不吝赐教。

我在学校曾经做过这样一个商城项目,所有模块大致如下:校淘

这个商城系统就是一个典型的巨型应用的雏形。一个系统集成了所有模块,相当于把所有鸡蛋都放在了同一个篮子里。

(1)巨型应用容易出现的问题

1.模块出现问题

假如某模块出现了问题(比如说“忘记密码”),我们怎么办?要把整个项目下线进行修复吗?

在原则上,“忘记密码”根本不应该对剩余的商城业务造成影响,商家还是能管理他们的商品,用户依然能继续商品的选购。但是现在我们面临着一个两难选择:“如果下线项目修复模块,将影响正常业务流程。如果不下线项目,“忘记密码”无法修复,将影响真正忘记密码的用户。”无论怎么选择,都会得罪一部分用户。任何一个模块出问题,都会影响整体的运行,可谓是牵一发而动全身。

如果使用分布式,只需要下线出问题的模块即可,别的模块依然能够正常作用。

2.高耦合

最基本的应用层次为:数据库 -> dao -> service -> controller。因为当前业务量比较少,所以不太需要考虑优化问题,可以把所有表都放在同一个库中,表之间可以用视图/外键来联系,应用可以放在同一个服务器上。

假如现在并发量上去了,我们怎么办?

如果数据量多了,最基本的优化就是分库分表。那么问题来了,一旦分库分表,原来的视图/外键怎么办,join之类的sql全部都完蛋了…

底层逻辑完蛋了,dao层要怎么改变?原来的数据我一次就从一个数据库中拿完了,现在我要从多个数据库中获取、拼装各种数据,dao层的逻辑怎么写?dao层变动了,service层也要跟着变。这也是另一种形式的牵一发而动全身。

举个例子:

我想查询某个商品,需要同时获取商品信息 + 商家资料,一般的操作是:“写一个商品资料视图,通过商家id关联商品和商家,查询之”(需要查询一次)/“查询商品id获取商品信息,根据商品信息中的商家id查询商家资料”(需要两次查询)。

现在商家多了,我想把商家用户相关的表段拆分出来,放到另外一台服务器的库中。在这种情况下,第一种方式就行不通了。如果你不幸使用了视图,那么你不但需要改sql,还要修改数据库映射的实体类,还要修改dao层的操作,还要更新service层…

但是,使用分布式的麻烦就少了吗?

如果使用第二种方式,你需要在第一个库中查询出商品信息,然后再拿商品信息中的商家id,去第二个库中查询商家资料,无形之中就多了一次查询操作(而且这两次查询是同步的,第二次查询会被第一次阻塞)。而且,这些静态信息往往是需要做缓存的,第一种方式只需要做一次缓存就足够了,第二种方式需要在两个模块分别做缓存,无形之中对代码有了更高的要求。

我个人认为,这些系统架构一开始就应该考虑好。等项目做大了、性能要求变高了,再来考虑优化的问题,就已经太迟了。最初的设计者可能没必要面对这么多复杂的因素,想怎么设计就怎么设计,难受的是后来的开发者。

如果使用分布式,可以强制设计者一开始就考虑到这些问题,优化起来会比较简单。当然,无论哪种设计都存在各种各样的问题,所以架构师的门槛才那么高。

3.低可用性

如果服务因为某些不可抗性的原因倒下了(比如数据库承受不住连接数挂了、数据库断电了、服务端频繁GC无法处理请求了),能否进行重启服务?有没有备用机制?

如果巨型应用倒下了,我们又面临着一个两难选择:“是赶紧重启服务,还是先找问题?”如果重启,谁能保证服务不会再次倒下(重启的成本也很高)?如果暂时无法解决问题/问题没有重现,谁知道是不是埋下了一个定时炸弹?如果停机先找问题,谁有把握在短时间内把问题解决呢?这段空窗期带来的损失由谁承担?

如果使用分布式,倒下的范围基本限于一个/数个服务,排查问题的范围就缩小了。就算要立刻重启,微服务重启的成本也很低,起码保证整个系统的大部分功能依然能够正常运行。

除了以上三点,因为巨型应用往往有一些年代了,往往会伴随各种各样的问题:比如文档丢失、测试用例不全、代码冗余、逻辑过于复杂、缺少注释。个人认为:这完全就是让后来的开发者为前期的不规范买单。要在理解原系统的基础上,再做拓展,我就想问一问“难度是不是有些高(我看你是在为难我胖虎)?”事实上,这种代码我也写过不少,等于是在自己打脸。

正因为有这些遗憾,我每次面试的时候,都会强调这个商城项目给我带来的教训:“如果再给我一次机会,我一定会把这个商城做成分布式。我会把模块合理划分,使用spring cloud构建微服务,分模块设计数据库,用redis做各种缓存,使用Nginx做负载均衡/静态资源服务,使用Jenkins做版本迭代,使用git做版本管理”。

 

(2)什么导致了巨型应用的出现?

最常见的原因,就是开发者、设计者的水平不够,根本没考虑拓展和优化的问题。算是低级失误,这里就不谈了。

这里我想聊聊重构的问题。当一个系统遇到了性能瓶颈,往往有两种解决办法,集群(硬件的方式)/重构(软件的方式)。比如一个商城系统,预计用户量10w,结果实际用户量为100w,这时候我们有两种选择:往集群中加服务器,做负载均衡/重要模块重构为分布式。个人认为,使用集群确实可以在短时间内解决问题,但是集群不可能是无上限的。随着业务的增多,最终还是要把系统调整为分布式。

现在问题变成了,判断重构的最佳时机。如果满足于使用集群解决问题,在这段时间内,应用的规模可能会不断增大。系统变越复杂,重构的难度就越大,付出的成本就越多,产生这样一个恶性循环:性能瓶颈 -> 扩充硬件(因为重构成本太高) -> 拓展业务 -> 性能瓶颈,时间拖得越久,越难重构,最后就发展成了巨型应用。

当然,重构是个很有争议性的话题,和“为什么很多Java程序员都不愿意升级到最新的 jdk?”这个问题差不多。

话题链接:https://www.zhihu.com/question/26844110

个人很认同里面的一个回答:

商业的成功是产品的成功,不是技术的成功。

也许每个开发者都有一颗重构的心,但是现实却是无能为力,只能不断地增加业务代码,亲手铸造一个巨型应用。但是我一直坚信,使用软件方式优化项目才是成本最低,效果最好的(只要时机恰当)。重构(或者说使用新技术)确实有风险,但是高风险往往也有高回报,如果某天我有幸能够一言九鼎,我还是很乐意去试一试的。

(3)不要盲目使用分布式

虽然说了这么多分布式的好话,在考虑系统架构时,还是要冷静分析,不能盲目。

比如一个校级图书管理系统,三四个模块,用户量不到1w,单应用,两三台服务器已经可以运行得很好了(连巨型应用都算不上),上分布式简直就是一种浪费。更何况分布式本身就有各种各样的问题有待解决,完全就是自己给自己找麻烦…

但是,只要有并发数超出预期的可能,就要时刻做好上分布式的准备。比如一个国家级的图书管理系统,用户量预期100w,当然要考虑一开始就上分布式。如果没有相应的计划,个人认为设计者就要反省一下了。

二、读书笔记

随着移动互联网的爆发性增长,小明公司的电子商务系统访问量越来越大。由于现有系统是个单体的巨型应用,已经无法满足海量的并发请求,拆分势在必行。

在微服务的大潮之中,架构师小明把系统拆分成了多个服务,根据需要部署在多个机器上。这些服务非常灵活,可以随着访问量弹性拓展。

世界上没有免费的午餐,拆分成多个微服务以后虽然增加了弹性,但是也带来了一个巨大的挑战:服务之间互相调用的开销

比如说:原来用户下一个订单需要登陆、浏览商品详情、加入购物车、支付、扣库存等一系列操作,在单体应用的时候都在一台机器的同一个进程中。说白了就是模块之间的函数调用,效率超级高。

现在好了,服务被安置在不同的服务器上,一个订单流程,几乎每个操作都需要越网络,都是远程过程调用(RPC),那执行时间、执行效率可远远比不上以前了。

远程调用的第一版实现使用了HTTP协议,也就是说各个服务对外提供HTTP接口,服务之间的请求也是HTTP请求。小明发现,虽然HTTP协议简单明了,但是废话太多。仅仅是给服务器发个简单的信息都会附带一大堆无用信息。

 

看看这些User-Agent,Accept-Language,这个协议明显是为了浏览器而生的!但是我这里是程序之间的调用,用这个HTTP有点亏。

(协议的作用是什么?就是保证信息的传达。应用层的HTTP协议携带了大量无用信息,占用更多的流量,需要更多的处理,所以不适合程序之间相互调用)

那么,能不能自定义一个精简的协议?在这个协议中我只需要把要调用的方法名和参数发给服务器即可,根本不需要这么多乱七八糟的额外信息。

但是,自定义协议客户端和服务器端就得直接使用“低级”的Socket了。尤其是服务器端,得能够处理高并发的访问请求才行。

小明复习了一下服务器端的socket编程,最早的java是所谓的阻塞IO(Blocking IO0),想处理多个socket的连接就需要创建多个线程,一个线程对应一个socket连接。

这种方式写起来倒是挺简单的,但是连接(socket)多了就受不了了。如果真的有成千上万个线程同时处理成千上万个socket,占用大量的空间不说,光是线程之间的切换就是一个巨大的开销。

更重要的是,虽然有大量的socket,但是真正需要处理的(可以读写数据的socket)却不多,大量的线程处于等待数据的状态(这也是为什么叫做阻塞的原因),资源浪费得让人心疼。

(socket只负责建立连接,传输数据。真正耗时的操作是在服务端、客户端的处理,但是socket必须一直保持连接,所有的资源都被阻塞,浪费资源)

后来java为了解决这个问题,又搞了一个非阻塞的IO(NIO:Non-Blocking IO,有人也叫做New IO),改变了一下思路:通过多路复用的方式,让一个线程去处理多个Socket。

(想要理解NIO并没有这么容易,请自行学习,个人认为难点在于“如何多路复用”、selector)

这样一来,只需要使用少量的线程就可以搞定多个Socket了。线程只需要通过selector去查看一下他所管理的Socket集合,哪个socket的数据准备好了,就去处理哪个Socket,一点都不浪费。

好了,这就是java NIO了!这是正确的方向!

小明先定义了一套精简的RPC的协议,里面规定了如何去调用一个服务,方法名和参数应该如何传递,返回值用什么格式…等等。然后雄心勃勃地要把这个协议用java NIO给实现了。

可是美好的理想很快被无情地现实击碎了。小明努力了一周,发现自己掉进了一个大坑。java的NIO虽然看起来简单,但是API还是太“低级”了,有太多的复杂性,没有强悍的、一流的办成能力根本没办法驾驭,根本做不到高并发情况下饿可靠和高效。

小明不死心,继续向领导人要资源,一定要把这个坑给填上。挣扎了6个月之后,小明终于实现一个自己的NIO框架,可以执行高并发的RPC调用了。

然后又是长达6个月的修修补补,小明经常半夜被叫醒:生产环境的RPC调用无法返回了!这样的Bug不知道改了多少个…

在这些不眠之夜中,小明经常仰天长叹:我用NIO做个高并发的RPC框架怎么这么难啊!

一年之后,自研的框架终于稳定。可是小明也从别人那里听到了一个令他崩溃的消息:有个叫Netty的开源框架,可以快速地开发高性能的面向协议的服务器和客户端。易用、健壮、安全、搞笑,你可以在Netty上轻松实现各种自定义的协议!

小明赶紧研究了一下Netty,突然泪流满面…看来编程之前先google是必要的。

三、结论

像上面小明的例子,想使用java NIO来实现一个高性能的RPC框架,调用协议,数据的格式和次序都是自己定义的,现有的HTTP根本玩不转,那使用Netty就是绝佳的选择。

用java原生的NIO又如何?可以是可以,就是难度太大。为什么不用别人造好的轮子呢?

其实游戏领域就是很好的例子。长连接,自定义协议,高并发,Netty就是绝配。

因为Netty本身就是一个基于NIO的网络框架,封装了Java NIO那些复杂的底层细节,给你提供了简单好用的抽象概念来编程。

注意几个关键词,首先他是个框架,是个“半成品”,不能开箱即用。你必须拿过来做点定制,利用它开发出自己的应用程序,然后才能运行,和Spring差不多。

一个更加知名的例子就是阿里的Dubbo,这个RPC框架的底层用的就是Netty。

另外一个关键词是高性能,如果你的应用根本没有高并发的压力,那就不一定要使用Netty了。

四、总结

学了那么久的Netty,终于总结了一次使用的目的。还要多多学习。