JAVA 拾遗 --Instrument 机制

最近在研究 skywalking,发现其作为一个 APM 框架,比起作为 trace 框架的 zipkin 多了一个监控维度:对 JVM 的监控。而 skywalking 集成进系统的方式也和传统的框架不太一样,由于其需要对 JVM 进行无侵入式的监控,所以借助了 JAVA5 提供的 Instrument 机制。关于“Instrument”这个单词,没找到准确的翻译,个人理解为“增强,装配”。

如果我们想要无侵入式的修改一个方法,大多数人想到的可能是 AOP 技术,Instrument 有异曲同工之处,它可以对方法进行增强,甚至替换整个类。

下面借助一个 demo,了解下 Instrument 是如何使用的。第一个 demo 很简单,在某一方法调用时,额外打印出其调用时的时间。

1
2
3
4
5
public class Dog {
public String hello() {
return "wow wow~";
}
}
1
2
3
4
5
6
7
public class Main {

public static void main(String[] args) {
System.out.println(new Dog().hello());
}

}

Dog 存在一个 hello 方法,希望在调用该方法时打印出是什么时刻发生的调用。

实现 Agent

GreetingTransformer

1
2
3
4
5
6
7
8
9
10
public class GreetingTransformer implements ClassFileTransformer {

@Override
public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
if ("moe/cnkirito/agent/Dog".equals(className)) {
System.out.println("Dog's method invoke at\t" + new Date());
}
return null;
}
}

对类进行装配的第一步是编写一个 GreetingTransformer 类,其继承自:java.lang.instrument.ClassFileTransformer,打印语句便编写在其中。对于入参和返参我们先不去纠结,因为仅仅完成这么一个简单的 AOP 功能,还不需要了解它们。

GreetingAgent

除了上述的 Transformer,我们还需要有一个容器去加载它。

1
2
3
4
5
6
7
8
9
10
public class GreetingAgent {
public static void premain(String options, Instrumentation ins) {
if (options != null) {
System.out.printf("I've been called with options: \"%s\"\n", options);
}
else
System.out.println("I've been called with no options.");
ins.addTransformer(new GreetingTransformer());
}
}

GreetingAgent 便是我们后面要用的代理,可以发现它只有一个 premain 方法,很简单很形象,它和 main 方法真的很像

1
2
public static void main(String[] args) {
}

不同的是 main 函数的参数是一个 string[],而 premain 的入参是一个 String 和一个 Instrumentation。

前者不用过多赘述,而后者 Instrumentation 便是 JAVA5 的 Instrument 机制的核心,它负责为类添加 ClassFileTransformer 的实现,从而对类进行装配。注意 premain 和它的两个参数不能随意修改,为啥?我们使用 main 函数的时候也没问为啥一定是 public static void main(String[] args) 啊,规定!规定!从 premain 的命名也可以看出,它的运行显然是在 main 函数之前的。

MANIFEST.MF

我们最终会把上面的 GreetingTransformer 和 GreetingAgent 打成一个 jar 包,然后让 Main 函数在启动时加载,但想要使用这个 jar 包还得额外做的工作。

我们得告诉 JVM 在哪儿加载我们的 premain 方法,所以需要在 classpath 下增加一个 resources\META-INF\MANIFEST.MF 文件

1
2
3
Manifest-Version: 1.0
Premain-Class: moe.cnkirito.agent.GreetingAgent
Can-Redefine-Classes: true

MAVEN 插件

为了打包 agent 我们需要额外添加 maven 插件,将 mf 文件和两个类一起打包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<build>
<finalName>agent</finalName>

<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<version>2.3.1</version>
<configuration>
<archive>
<manifestFile>src/main/resources/META-INF/MANIFEST.MF</manifestFile>
</archive>
</configuration>
</plugin>

<plugin>
<artifactId>maven-assembly-plugin</artifactId>
<configuration>
<outputDirectory>${basedir}</outputDirectory>
<archive>
<index>true</index>
<manifest>
<addClasspath>true</addClasspath>
</manifest>
<manifestEntries>
<Premain-Class>moe.cnkirito.agent.GreetingAgent</Premain-Class>
</manifestEntries>
</archive>
<descriptorRefs>
<descriptorRef>jar-with-dependencies</descriptorRef>
</descriptorRefs>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>1.8</source>
<target>1.8</target>
</configuration>
</plugin>
</plugins>
</build>

完成上述的配置,使用 maven install 即可得到一个 agent.jar,到这儿一切的准备工作就完成了。

使用代理运行 Main 方法

如果不使用代理运行 Main 方法,毫无疑问我们只会得到一行 wow wow~

如果你使用的 IDEA,eclipse,只需要添加一行启动参数即可:

启动参数

-javaagent:jar_path=[options] 其中的 jar_path 为 agent.jar 的路径,options 是一个可选参数,其值会被 premain 方法的第一个参数接收 public static void premain(String options, Instrumentation ins).

当需要装配多个 agent.jar 时,重复书写多次即可 -javaagent:C:\Users\xujingfeng\Desktop\agent.jar=hello -javaagent:C:\Users\xujingfeng\Desktop\agent.jar=hello2 …

运行 Main.jar 的话就是这样的形式:java -javaagent:C:\Users\xujingfeng\Desktop\agent.jar=hello Main

运行结果

1
2
3
  I've been called with options:"hello"
Dog's method invoke at Sun Feb 04 23:54:45 CST 2018
wow wow~

I’ve been called with options:”hello” 代表我们的 premain 已经装载成功,并且正确接收到了启动参数。第二行语句也正常打印出了调用时间,至此便完成了 Dog 的装配。

Instrument 进阶

什么?为了打印一行调用时间,我们花了这么大精力,这是要跟自己过不去吗?你可能会有这样的疑惑,但请不要质疑 Instrument 的价值。

1
2
3
4
5
6
7
8
public interface ClassFileTransformer {
byte[] transform( ClassLoader loader,
String className,
Class<?> classBeingRedefined,
ProtectionDomain protectionDomain,
byte[] classfileBuffer)
throws IllegalClassFormatException;
}

ClassFileTransformer 可以对所有的方法进行拦截,看见返回值 byte[] 了没有

The implementation of this method may transform the supplied class file and return a new replacement class file.

这个方法的实现可能会改变提供的类文件并返回一个新的替换类文件。

这给了我们足够的操作自由度,我们甚至可以替换一个类的实现,只要你能够返回一个正确的替换类。ClassLoader 代表被转换类的类加载器,如果是 bootstrap loader 则可以省略,className 代表全类名,注意是以 / 作为分隔符。其他参数我也不是太懂,想深究的同学自行翻看下文档。byte[] 代表被转换后的类的字节,为 null 则代表不转换。

替换 Dog 的实现

1
2
3
4
5
public class Dog {
public String hello() {
return "miao miao~";
}
}

注意,这里我修改了 Dog 的实现,不是打印 wow wow~ 而是 miao miao ~,只是为了得到新 Dog 的字节码 Dog.class。我将新的 Dog.class 丢在了我的桌面方便加载:C:/Users/xujingfeng/Desktop

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public class DogTransformer implements ClassFileTransformer {

public byte[] transform(ClassLoader loader, String className, Class<?> classBeingRedefined, ProtectionDomain protectionDomain, byte[] classfileBuffer) throws IllegalClassFormatException {
System.out.println("className:" + className);
if (!className.equalsIgnoreCase("moe/cnkirito/agent/Dog")) {
return null;
}
return getBytesFromFile("C:/Users/xujingfeng/Desktop/Dog.class");// 新的 Dog
// return getBytesFromFile("app/target/classes/moe/cnkirito/agent/Dog.class");
}

public static byte[] getBytesFromFile(String fileName) {
File file = new File(fileName);
try (InputStream is = new FileInputStream(file)) {
// precondition

long length = file.length();
byte[] bytes = new byte[(int) length];

// Read in the bytes
int offset = 0;
int numRead = 0;
while (offset <bytes.length
&& (numRead = is.read(bytes, offset, bytes.length - offset)) >= 0) {
offset += numRead;
}

if (offset < bytes.length) {
throw new IOException("Could not completely read file"
+ file.getName());
}
is.close();
return bytes;
} catch (Exception e) {
System.out.println("error occurs in _ClassTransformer!"
+ e.getClass().getName());
return null;
}
}

}

return getBytesFromFile(“C:/Users/xujingfeng/Desktop/Dog.class”) 一行返回了新的 Dog 试图替换原先的 Dog。注意,这一切都放生在 Agent.jar 之中,我并没有对 Main 函数(也就是我们自己的源代码)做任何改动。

控制台输出

1
miao miao~

替换成功!我们并没有对 Main 程序的 Dog 做任何修改,只是加载了一个新的 Dog.class 替换了 Main 程序中的 Dog。

统计方法运行耗时

这个需求有点接近我们研究 Instrument 的初衷了,统计方法的运行耗时。由于代码的篇幅问题,在本文中只给出思路,详细的实现,可以参考文末的 github 链接,本文的三个例子:

  1. 打印 hello
  2. 替换 Dog
  3. 统计方法运行耗时

代码都在其中。

思路:对每个需要统计耗时的方法替换字节码,在方法开始前插入开始时间,在方法结束时插入结束时间,计算差值,more 你可以连同 methodName 和耗时一起发送出去,给 collector 统一采集…wait,这不就是一个简易的监控吗?!~

运行结果:

1
2
Call to method hello_timing took 1 ms.
wow wow~

JAVA6 的 agentmain

值得一提的是,java6 提供了 public static void agentmain (String agentArgs, Instrumentation inst); 这个新的方法,可以在 main 函数之后装配(premain 是在 main 之前),这使得操控现有程序的自由度变得更高了,有兴趣的朋友可以去了解下 premain 和 agentmain 的特性。

本文示例代码

https://github.com/lexburner/java5-Instrumentation-demo

参考资料

Java 5 特性 Instrumentation 实践

Java SE 6 的新特性:虚拟机启动后的动态 instrument

芋道源码

分享到

中文文案排版指北

统一中文文案、排版的相关用法,降低团队成员之间的沟通成本,增强网站气质。原文出处:https://github.com/mzlogin/chinese-copywriting-guidelines


  • 目录

    空格

    「有研究显示,打字的时候不喜欢在中文和英文之间加空格的人,感情路都走得很辛苦,有七成的比例会在 34 岁的时候跟自己不爱的人结婚,而其余三成的人最后只能把遗产留给自己的猫。毕竟爱情跟书写都需要适时地留白。

    与大家共勉之。」——vinta/paranoid-auto-spacing

    中英文之间需要增加空格

    正确:

    在 LeanCloud 上,数据存储是围绕 AVObject 进行的。

    错误:

    在LeanCloud上,数据存储是围绕AVObject进行的。

    在 LeanCloud上,数据存储是围绕AVObject 进行的。

    完整的正确用法:

    在 LeanCloud 上,数据存储是围绕 AVObject 进行的。每个 AVObject 都包含了与 JSON 兼容的 key-value 对应的数据。数据是 schema-free 的,你不需要在每个 AVObject 上提前指定存在哪些键,只要直接设定对应的 key-value 即可。

    例外:「豆瓣FM」等产品名词,按照官方所定义的格式书写。

    中文与数字之间需要增加空格

    正确:

    今天出去买菜花了 5000 元。

    错误:

    今天出去买菜花了 5000元。

    今天出去买菜花了5000元。

    数字与单位之间无需增加空格

    正确:

    我家的光纤入户宽带有 10Gbps,SSD 一共有 10TB。

    错误:

    我家的光纤入户宽带有 10 Gbps,SSD 一共有 20 TB。

    另外,度/百分比与数字之间不需要增加空格:

    正确:

    今天是 233° 的高温。

    新 MacBook Pro 有 15% 的 CPU 性能提升。

    错误:

    今天是 233 ° 的高温。

    新 MacBook Pro 有 15 % 的 CPU 性能提升。

    全角标点与其他字符之间不加空格

    正确:

    刚刚买了一部 iPhone,好开心!

    错误:

    刚刚买了一部 iPhone ,好开心!

    -ms-text-autospace to the rescue?

    Microsoft 有个 -ms-text-autospace.aspx) 的 CSS 属性可以实现自动为中英文之间增加空白。不过目前并未普及,另外在其他应用场景,例如 OS X、iOS 的用户界面目前并不存在这个特性,所以请继续保持随手加空格的习惯。

    标点符号

    不重复使用标点符号

    正确:

    德国队竟然战胜了巴西队!

    她竟然对你说「喵」?!

    错误:

    德国队竟然战胜了巴西队!!

    德国队竟然战胜了巴西队!!!!!!!!

    她竟然对你说「喵」??!!

    她竟然对你说「喵」?!?!??!!

    全角和半角

    不明白什么是全角(全形)与半角(半形)符号?请查看维基百科词条『全角和半角』。

    使用全角中文标点

    正确:

    嗨!你知道嘛?今天前台的小妹跟我说「喵」了哎!

    核磁共振成像(NMRI)是什么原理都不知道?JFGI!

    错误:

    嗨! 你知道嘛? 今天前台的小妹跟我说 “喵” 了哎!

    嗨!你知道嘛?今天前台的小妹跟我说”喵”了哎!

    核磁共振成像 (NMRI) 是什么原理都不知道? JFGI!

    核磁共振成像(NMRI)是什么原理都不知道?JFGI!

    数字使用半角字符

    正确:

    这件蛋糕只卖 1000 元。

    错误:

    这件蛋糕只卖 1000 元。

    例外:在设计稿、宣传海报中如出现极少量数字的情形时,为方便文字对齐,是可以使用全角数字的。

    遇到完整的英文整句、特殊名词,其內容使用半角标点

    正确:

    乔布斯那句话是怎么说的?「Stay hungry, stay foolish.」

    推荐你阅读《Hackers & Painters: Big Ideas from the Computer Age》,非常的有趣。

    错误:

    乔布斯那句话是怎么说的?「Stay hungry,stay foolish。」

    推荐你阅读《Hackers&Painters:Big Ideas from the Computer Age》,非常的有趣。

    名词

    专有名词使用正确的大小写

    大小写相关用法原属于英文书写范畴,不属于本 wiki 讨论內容,在这里只对部分易错用法进行简述。

    正确:

    使用 GitHub 登录

    我们的客户有 GitHub、Foursquare、Microsoft Corporation、Google、Facebook, Inc.。

    错误:

    使用 github 登录

    使用 GITHUB 登录

    使用 Github 登录

    使用 gitHub 登录

    使用 gイんĤЦ8 登录

    我们的客户有 github、foursquare、microsoft corporation、google、facebook, inc.。

    我们的客户有 GITHUB、FOURSQUARE、MICROSOFT CORPORATION、GOOGLE、FACEBOOK, INC.。

    我们的客户有 Github、FourSquare、MicroSoft Corporation、Google、FaceBook, Inc.。

    我们的客户有 gitHub、fourSquare、microSoft Corporation、google、faceBook, Inc.。

    我们的客户有 gイんĤЦ8、キouЯƧquムгє、๓เςг๏ร๏Ŧt ς๏гק๏гคtเ๏ภn、900913、ƒ4ᄃëв๏๏к, IПᄃ.。

    注意:当网页中需要配合整体视觉风格而出现全部大写/小写的情形,HTML 中请使用标准的大小写规范进行书写;并通过 text-transform: uppercase;text-transform: lowercase; 对表现形式进行定义。

    不要使用不地道的缩写

    正确:

    我们需要一位熟悉 JavaScript、HTML5,至少理解一种框架(如 Backbone.js、AngularJS、React 等)的前端开发者。

    错误:

    我们需要一位熟悉 Js、h5,至少理解一种框架(如 backbone、angular、RJS 等)的 FED。

    争议

    以下用法略带有个人色彩,即:无论是否遵循下述规则,从语法的角度来讲都是正确的。

    链接之间增加空格

    用法:

    提交一个 issue 并分配给相关同事。

    访问我们网站的最新动态,请 点击这里 进行订阅!

    对比用法:

    提交一个 issue 并分配给相关同事。

    访问我们网站的最新动态,请点击这里进行订阅!

    简体中文使用直角引号

    用法:

    「老师,『有条不紊』的『紊』是什么意思?」

    对比用法:

    “老师,‘有条不紊’的‘紊’是什么意思?”

    工具

    | 仓库 | 语言 |
    | ———————————————————— | ————— |
    | vinta/paranoid-auto-spacing | JavaScript |
    | huei90/pangu.node | Node.js |
    | huacnlee/auto-correct | Ruby |
    | sparanoid/space-lover | PHP (WordPress) |
    | nauxliu/auto-correct | PHP |
    | ricoa/copywriting-correct | PHP |
    | hotoo/pangu.vim | Vim |
    | sparanoid/grunt-auto-spacing | Node.js (Grunt) |
    | hjiang/scripts/add-space-between-latin-and-cjk | Python |

    谁在这样做?

    | 网站 | 文案 | UGC |
    | ————————————————- | —- | ———— |
    | Apple 中国 | Yes | N/A |
    | Apple 香港 | Yes | N/A |
    | Apple 台湾 | Yes | N/A |
    | Microsoft 中国 | Yes | N/A |
    | Microsoft 香港 | Yes | N/A |
    | Microsoft 台湾 | Yes | N/A |
    | LeanCloud | Yes | N/A |
    | 知乎 | Yes | 部分用户达成 |
    | V2EX | Yes | Yes |
    | SegmentFault | Yes | 部分用户达成 |
    | Apple4us | Yes | N/A |
    | 豌豆荚 | Yes | N/A |
    | Ruby China | Yes | 标题达成 |
    | PHPHub | Yes | 标题达成 |
    | 少数派 | Yes | N/A |
    | 力扣 LeetCode | Yes | Yes |

    参考文献

分享到

研究优雅停机时的一点思考

开头先废话几句,有段时间没有更新博客了,除了公司项目比较忙之外,还有个原因就是开始思考如何更好地写作。远的来说,我从大一便开始在 CSDN 上写博客,回头看那时的文笔还很稚嫩,一心想着反正只有自己看,所以更多的是随性发挥,随意吐槽,内容也很简陋:刷完一道算法题记录下解题思路,用 JAVA 写完一个 demo 之后,记录下配置步骤。近的来看,工作之后开始维护自己的博客站点: www.cnkirito.moe 也会同步更新自己公众号。相比圈子里其他前辈来说,读者会少很多,但毕竟有人看,每次动笔之前便会开始思考一些事。除了给自己的学习经历做一个归档,还多了一些顾虑:会不会把知识点写错?会不会误人子弟?自己的理解会不会比较片面,不够深刻?等等等等。但自己的心路历程真的发生了一些改变。在我还是个小白的时候,学习技术:第一个想法是百度,搜别人的博客,一步步跟着别人后面配置,把 demo run 起来。而现在,遇到问题的第一思路变成了:源码 debug,官方文档。我便开始思考官方文档和博客的区别,官方文档的优势除了更加全面之外,还有就是:“它只教你怎么做”,对于一个有经验有阅历的程序员来说,这反而是好事,这可以让你有自己的思考。而博客则不一样,如果这个博主特别爱 BB,便会产生很多废话(就像本文的第一段),它会有很多作者自己思考的产物,一方面它比官方文档更容易出错,更容易片面,一方面它比官方文档更容易启发人,特别是读到触动到我的好文时,会抑制不住内心的喜悦想要加到作者的好友,这便是共情。我之后的文章也会朝着这些点去努力:不避重就轻,多思考不想当然,求精。

最近瞥了一眼项目的重启脚本,发现运维一直在使用 kill -9 <pid> 的方式重启 springboot embedded tomcat,其实大家几乎一致认为:kill -9 <pid> 的方式比较暴力,但究竟会带来什么问题却很少有人能分析出个头绪。这篇文章主要记录下自己的思考过程。

kill -9 和 kill -15 有什么区别?

在以前,我们发布 WEB 应用通常的步骤是将代码打成 war 包,然后丢到一个配置好了应用容器(如 Tomcat,Weblogic)的 Linux 机器上,这时候我们想要启动 / 关闭应用,方式很简单,运行其中的启动 / 关闭脚本即可。而 springboot 提供了另一种方式,将整个应用连同内置的 tomcat 服务器一起打包,这无疑给发布应用带来了很大的便捷性,与之而来也产生了一个问题:如何关闭 springboot 应用呢?一个显而易见的做法便是,根据应用名找到进程 id,杀死进程 id 即可达到关闭应用的效果。

上述的场景描述引出了我的疑问:怎么优雅地杀死一个 springboot 应用进程呢?这里仅仅以最常用的 Linux 操作系统为例,在 Linux 中 kill 指令负责杀死进程,其后可以紧跟一个数字,代表 信号编号 (Signal),执行 kill -l 指令,可以一览所有的信号编号。

1
2
xu@ntzyz-qcloud ~ % kill -l                                                                     
HUP INT QUIT ILL TRAP ABRT BUS FPE KILL USR1 SEGV USR2 PIPE ALRM TERM STKFLT CHLD CONT STOP TSTP TTIN TTOU URG XCPU XFSZ VTALRM PROF WINCH POLL PWR SYS

本文主要介绍下第 9 个信号编码 KILL,以及第 15 个信号编号 TERM

先简单理解下这两者的区别:kill -9 pid 可以理解为操作系统从内核级别强行杀死某个进程,kill -15 pid 则可以理解为发送一个通知,告知应用主动关闭。这么对比还是有点抽象,那我们就从应用的表现来看看,这两个命令杀死应用到底有啥区别。

代码准备

由于笔者 springboot 接触较多,所以以一个简易的 springboot 应用为例展开讨论,添加如下代码。

1 增加一个实现了 DisposableBean 接口的类

1
2
3
4
5
6
7
@Component
public class TestDisposableBean implements DisposableBean{
@Override
public void destroy() throws Exception {
System.out.println("测试 Bean 已销毁 ...");
}
}

2 增加 JVM 关闭时的钩子

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@SpringBootApplication
@RestController
public class TestShutdownApplication implements DisposableBean {

public static void main(String[] args) {
SpringApplication.run(TestShutdownApplication.class, args);
Runtime.getRuntime().addShutdownHook(new Thread(new Runnable() {
@Override
public void run() {
System.out.println("执行 ShutdownHook ...");
}
}));
}
}

测试步骤

  1. 执行 java -jar test-shutdown-1.0.jar 将应用运行起来
  2. 测试 kill -9 pidkill -15 pidctrl + c 后输出日志内容

测试结果

kill -15 pid & ctrl + c,效果一样,输出结果如下

1
2
3
4
5
2018-01-14 16:55:32.424  INFO 8762 --- [Thread-3] ationConfigEmbeddedWebApplicationContext : Closing org.springframework.boot.context.embedded.AnnotationConfigEmbeddedWebApplicationContext@2cdf8d8a: startup date [Sun Jan 14 16:55:24 UTC 2018]; root of context hierarchy
2018-01-14 16:55:32.432 INFO 8762 --- [Thread-3] o.s.j.e.a.AnnotationMBeanExporter : Unregistering JMX-exposed beans on shutdown
执行 ShutdownHook ...
测试 Bean 已销毁 ...
java -jar test-shutdown-1.0.jar 7.46s user 0.30s system 80% cpu 9.674 total

kill -9 pid,没有输出任何应用日志

1
2
[1]    8802 killed     java -jar test-shutdown-1.0.jar
java -jar test-shutdown-1.0.jar 7.74s user 0.25s system 41% cpu 19.272 total

可以发现,kill -9 pid 是给应用杀了个措手不及,没有留给应用任何反应的机会。而反观 kill -15 pid,则比较优雅,先是由 AnnotationConfigEmbeddedWebApplicationContext (一个 ApplicationContext 的实现类)收到了通知,紧接着执行了测试代码中的 Shutdown Hook,最后执行了 DisposableBean#destory() 方法。孰优孰劣,立判高下。

一般我们会在应用关闭时处理一下“善后”的逻辑,比如

  1. 关闭 socket 链接
  2. 清理临时文件
  3. 发送消息通知给订阅方,告知自己下线
  4. 将自己将要被销毁的消息通知给子进程
  5. 各种资源的释放

等等

而 kill -9 pid 则是直接模拟了一次系统宕机,系统断电,这对于应用来说太不友好了,不要用收割机来修剪花盆里的花。取而代之,便是使用 kill -15 pid 来代替。如果在某次实际操作中发现:kill -15 pid 无法关闭应用,则可以考虑使用内核级别的 kill -9 pid ,但请事后务必排查出是什么原因导致 kill -15 pid 无法关闭。

springboot 如何处理 -15 TERM Signal

上面解释过了,使用 kill -15 pid 的方式可以比较优雅的关闭 springboot 应用,我们可能有以下的疑惑: springboot/spring 是如何响应这一关闭行为的呢?是先关闭了 tomcat,紧接着退出 JVM,还是相反的次序?它们又是如何互相关联的?

尝试从日志开始着手分析,AnnotationConfigEmbeddedWebApplicationContext 打印出了 Closing 的行为,直接去源码中一探究竟,最终在其父类 AbstractApplicationContext 中找到了关键的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
@Override
public void registerShutdownHook() {
if (this.shutdownHook == null) {
this.shutdownHook = new Thread() {
@Override
public void run() {
synchronized (startupShutdownMonitor) {
doClose();
}
}
};
Runtime.getRuntime().addShutdownHook(this.shutdownHook);
}
}

@Override
public void close() {
synchronized (this.startupShutdownMonitor) {
doClose();
if (this.shutdownHook != null) {
Runtime.getRuntime().removeShutdownHook(this.shutdownHook);
}
}
}

protected void doClose() {
if (this.active.get() && this.closed.compareAndSet(false, true)) {
LiveBeansView.unregisterApplicationContext(this);
// 发布应用内的关闭事件
publishEvent(new ContextClosedEvent(this));
// Stop all Lifecycle beans, to avoid delays during individual destruction.
if (this.lifecycleProcessor != null) {
this.lifecycleProcessor.onClose();
}
// spring 的 BeanFactory 可能会缓存单例的 Bean
destroyBeans();
// 关闭应用上下文 &BeanFactory
closeBeanFactory();
// 执行子类的关闭逻辑
onClose();
this.active.set(false);
}
}

为了方便排版以及便于理解,我去除了源码中的部分异常处理代码,并添加了相关的注释。在容器初始化时,ApplicationContext 便已经注册了一个 Shutdown Hook,这个钩子调用了 Close()方法,于是当我们执行 kill -15 pid 时,JVM 接收到关闭指令,触发了这个 Shutdown Hook,进而由 Close() 方法去处理一些善后手段。具体的善后手段有哪些,则完全依赖于 ApplicationContext 的 doClose() 逻辑,包括了注释中提及的销毁缓存单例对象,发布 close 事件,关闭应用上下文等等,特别的,当 ApplicationContext 的实现类是 AnnotationConfigEmbeddedWebApplicationContext 时,还会处理一些 tomcat/jetty 一类内置应用服务器关闭的逻辑。

窥见了 springboot 内部的这些细节,更加应该了解到优雅关闭应用的必要性。JAVA 和 C 都提供了对 Signal 的封装,我们也可以手动捕获操作系统的这些 Signal,在此不做过多介绍,有兴趣的朋友可以自己尝试捕获下。

还有其他优雅关闭应用的方式吗?

spring-boot-starter-actuator 模块提供了一个 restful 接口,用于优雅停机。

添加依赖

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

添加配置

1
2
3
4
#启用 shutdown
endpoints.shutdown.enabled=true
#禁用密码验证
endpoints.shutdown.sensitive=false

生产中请注意该端口需要设置权限,如配合 spring-security 使用。

执行 curl -X POST host:port/shutdown 指令,关闭成功便可以获得如下的返回:

1
{"message":"Shutting down, bye..."}

虽然 springboot 提供了这样的方式,但按我目前的了解,没见到有人用这种方式停机,kill -15 pid 的方式达到的效果与此相同,将其列于此处只是为了方案的完整性。

如何销毁作为成员变量的线程池?

尽管 JVM 关闭时会帮我们回收一定的资源,但一些服务如果大量使用异步回调,定时任务,处理不当很有可能会导致业务出现问题,在这其中,线程池如何关闭是一个比较典型的问题。

1
2
3
4
5
6
7
8
9
10
11
12
@Service
public class SomeService {
ExecutorService executorService = Executors.newFixedThreadPool(10);
public void concurrentExecute() {
executorService.execute(new Runnable() {
@Override
public void run() {
System.out.println("executed...");
}
});
}
}

我们需要想办法在应用关闭时(JVM 关闭,容器停止运行),关闭线程池。

初始方案:什么都不做。在一般情况下,这不会有什么大问题,因为 JVM 关闭,会释放之,但显然没有做到本文一直在强调的两个字,没错 —- 优雅。

方法一的弊端在于线程池中提交的任务以及阻塞队列中未执行的任务变得极其不可控,接收到停机指令后是立刻退出?还是等待任务执行完成?抑或是等待一定时间任务还没执行完成则关闭?

方案改进:

发现初始方案的劣势后,我立刻想到了使用 DisposableBean 接口,像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
@Service
public class SomeService implements DisposableBean{

ExecutorService executorService = Executors.newFixedThreadPool(10);

public void concurrentExecute() {
executorService.execute(new Runnable() {
@Override
public void run() {
System.out.println("executed...");
}
});
}

@Override
public void destroy() throws Exception {
executorService.shutdownNow();
//executorService.shutdown();
}
}

紧接着问题又来了,是 shutdown 还是 shutdownNow 呢?这两个方法还是经常被误用的,简单对比这两个方法。

ThreadPoolExecutor 在 shutdown 之后会变成 SHUTDOWN 状态,无法接受新的任务,随后等待正在执行的任务执行完成。意味着,shutdown 只是发出一个命令,至于有没有关闭还是得看线程自己。

ThreadPoolExecutor 对于 shutdownNow 的处理则不太一样,方法执行之后变成 STOP 状态,并对执行中的线程调用 Thread.interrupt() 方法(但如果线程未处理中断,则不会有任何事发生),所以并不代表“立刻关闭”。

查看 shutdown 和 shutdownNow 的 java doc,会发现如下的提示:

shutdown():Initiates an orderly shutdown in which previously submitted tasks are executed, but no new tasks will be accepted.Invocation has no additional effect if already shut down.This method does not wait for previously submitted tasks to complete execution.Use {@link #awaitTermination awaitTermination} to do that.

shutdownNow():Attempts to stop all actively executing tasks, halts the processing of waiting tasks, and returns a list of the tasks that were awaiting execution. These tasks are drained (removed) from the task queue upon return from this method.This method does not wait for actively executing tasks to terminate. Use {@link #awaitTermination awaitTermination} to do that.There are no guarantees beyond best-effort attempts to stop processing actively executing tasks. This implementation cancels tasks via {@link Thread#interrupt}, so any task that fails to respond to interrupts may never terminate.

两者都提示我们需要额外执行 awaitTermination 方法,仅仅执行 shutdown/shutdownNow 是不够的。

最终方案:参考 spring 中线程池的回收策略,我们得到了最终的解决方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public abstract class ExecutorConfigurationSupport extends CustomizableThreadFactory
implements DisposableBean{
@Override
public void destroy() {
shutdown();
}

/**
* Perform a shutdown on the underlying ExecutorService.
* @see java.util.concurrent.ExecutorService#shutdown()
* @see java.util.concurrent.ExecutorService#shutdownNow()
* @see #awaitTerminationIfNecessary()
*/
public void shutdown() {
if (this.waitForTasksToCompleteOnShutdown) {
this.executor.shutdown();
}
else {
this.executor.shutdownNow();
}
awaitTerminationIfNecessary();
}

/**
* Wait for the executor to terminate, according to the value of the
* {@link #setAwaitTerminationSeconds "awaitTerminationSeconds"} property.
*/
private void awaitTerminationIfNecessary() {
if (this.awaitTerminationSeconds > 0) {
try {
this.executor.awaitTermination(this.awaitTerminationSeconds, TimeUnit.SECONDS));
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
}
}
}

保留了注释,去除了一些日志代码,一个优雅关闭线程池的方案呈现在我们的眼前。

1 通过 waitForTasksToCompleteOnShutdown 标志来控制是想立刻终止所有任务,还是等待任务执行完成后退出。

2 executor.awaitTermination(this.awaitTerminationSeconds, TimeUnit.SECONDS)); 控制等待的时间,防止任务无限期的运行(前面已经强调过了,即使是 shutdownNow 也不能保证线程一定停止运行)。

更多需要我们的思考的优雅停机策略

在我们分析 RPC 原理的系列文章里面曾经提到,服务治理框架一般会考虑到优雅停机的问题。通常的做法是事先隔断流量,接着关闭应用。常见的做法是将服务节点从注册中心摘除,订阅者接收通知,移除节点,从而优雅停机;涉及到数据库操作,则可以使用事务的 ACID 特性来保证即使 crash 停机也能保证不出现异常数据,正常下线则更不用说了;又比如消息队列可以依靠 ACK 机制 + 消息持久化,或者是事务消息保障;定时任务较多的服务,处理下线则特别需要注意优雅停机的问题,因为这是一个长时间运行的服务,比其他情况更容易受停机问题的影响,可以使用幂等和标志位的方式来设计定时任务…

事务和 ACK 这类特性的支持,即使是宕机,停电,kill -9 pid 等情况,也可以使服务尽量可靠;而同样需要我们思考的还有 kill -15 pid,正常下线等情况下的停机策略。最后再补充下整理这个问题时,自己对 jvm shutdown hook 的一些理解。

When the virtual machine begins its shutdown sequence it will start all registered shutdown hooks in some unspecified order and let them run concurrently. When all the hooks have finished it will then run all uninvoked finalizers if finalization-on-exit has been enabled. Finally, the virtual machine will halt.

shutdown hook 会保证 JVM 一直运行,知道 hook 终止 (terminated)。这也启示我们,如果接收到 kill -15 pid 命令时,执行阻塞操作,可以做到等待任务执行完成之后再关闭 JVM。同时,也解释了一些应用执行 kill -15 pid 无法退出的问题,没错,中断被阻塞了。

参考资料

[1] https://stackoverflow.com/questions/2921945/useful-example-of-a-shutdown-hook-in-java

[2] spring 源码

[3] jdk 文档

分享到

深入理解 RPC 之服务注册与发现篇

在我们之前 RPC 原理的分析中,主要将笔墨集中在 Client 和 Server 端。而成熟的服务治理框架中不止存在这两个角色,一般还会有一个 Registry(注册中心)的角色。一张图就可以解释注册中心的主要职责。

注册中心的地位

  • 注册中心,用于服务端注册远程服务以及客户端发现服务
  • 服务端,对外提供后台服务,将自己的服务信息注册到注册中心
  • 客户端,从注册中心获取远程服务的注册信息,然后进行远程过程调用

目前主要的注册中心可以借由 zookeeper,eureka,consul,etcd 等开源框架实现。互联网公司也会因为自身业务的特性自研,如美团点评自研的 MNS,新浪微博自研的 vintage。

本文定位是对注册中心有一定了解的读者,所以不过多阐述注册中心的基础概念。

注册中心的抽象

借用开源框架中的核心接口,可以帮助我们从一个较为抽象的高度去理解注册中心。例如 motan 中的相关接口:

服务注册接口

1
2
3
4
5
6
7
8
9
10
11
12
public interface RegistryService {
//1. 向注册中心注册服务
void register(URL url);
//2. 从注册中心摘除服务
void unregister(URL url);
//3. 将服务设置为可用,供客户端调用
void available(URL url);
//4. 禁用服务,客户端无法发现该服务
void unavailable(URL url);
//5. 获取已注册服务的集合
Collection<URL> getRegisteredServiceUrls();
}

服务发现接口

1
2
3
4
5
6
7
8
public interface DiscoveryService {
//1. 订阅服务
void subscribe(URL url, NotifyListener listener);
//2. 取消订阅
void unsubscribe(URL url, NotifyListener listener);
//3. 发现服务列表
List<URL> discover(URL url);
}

主要使用的方法是 RegistryService#register(URL) 和 DiscoveryService#discover(URL)。其中这个 URL 参数被传递,显然也是很重要的一个类。

1
2
3
4
5
6
7
8
9
public class URL {
private String protocol;// 协议名称
private String host;
private int port;
// interfaceName, 也代表着路径
private String path;
private Map<String, String> parameters;
private volatile transient Map<String, Number> numbers;
}

注册中心也没那么玄乎,其实可以简单理解为:提供一个存储介质,供服务提供者和服务消费者共同连接,而存储的主要信息就是这里的 URL。但是具体 URL 都包含了什么实际信息,我们还没有一个直观的感受。

注册信息概览

以元老级别的注册中心 zookeeper 为例,看看它实际都存储了什么信息以及它是如何持久化上一节的 URL。

为了测试,我创建了一个 RPC 服务接口 com.sinosoft.student.api.DemoApi , 并且在 6666 端口暴露了这个服务的实现类,将其作为服务提供者。在 6667 端口远程调用这个服务,作为服务消费者。两者都连接本地的 zookeeper,本机 ip 为 192.168.150.1。

使用 zkClient.bash 或者 zkClient.sh 作为客户端连接到本地的 zookeeper,执行如下的命令:

1
2
[zk: localhost:2181(CONNECTED) 1] ls /motan/demo_group/com.sinosoft.student.api.DemoApi
> [client, server, unavailableServer]

zookeeper 有着和 linux 类似的命令和结构,其中 motan,demo_group,com.sinosoft.student.api.DemoApi,client, server, unavailableServer 都是一个个节点。可以从上述命令看出他们的父子关系。

/motan/demo_group/com.sinosoft.student.api.DemoApi 的结构为 / 框架标识 / 分组名 / 接口名,其中的分组是 motan 为了隔离不同组的服务而设置的。这样,接口名称相同,分组不同的服务无法互相发现。如果此时有一个分组名为 demo_group2 的服务,接口名称为 DemoApi2,则 motan 会为其创建一个新的节点 /motan/demo_group2/com.sinosoft.student.api.DemoApi2

而 client,server,unavailableServer 则就是服务注册与发现的核心节点了。我们先看看这些节点都存储了什么信息。

server 节点:

1
2
3
4
5
[zk: localhost:2181(CONNECTED) 2] ls /motan/demo_group/com.sinosoft.student.api.DemoApi/server
> [192.168.150.1:6666]

[zk: localhost:2181(CONNECTED) 3] get /motan/demo_group/com.sinosoft.student.api.DemoApi/server/192.168.150.1:6666
> motan://192.168.150.1:6666/com.sinosoft.student.api.DemoApi?serialization=hessian2&protocol=motan&isDefault=true&maxContentLength=1548576&shareChannel=true&refreshTimestamp=1515122649835&id=motanServerBasicConfig&nodeType=service&export=motan:6666&requestTimeout=9000000&accessLog=false&group=demo_group&

client 节点:

1
2
3
4
[zk: localhost:2181(CONNECTED) 4] ls /motan/demo_group/com.sinosoft.student.api.DemoApi/client
> [192.168.150.1]
[zk: localhost:2181(CONNECTED) 5] get /motan/demo_group/com.sinosoft.student.api.DemoApi/client/192.168.150.1
> motan://192.168.150.1:0/com.sinosoft.student.api.DemoApi?singleton=true&maxContentLength=1548576&check=false&nodeType=service&version=1.0&throwException=true&accessLog=false&serialization=hessian2&retries=0&protocol=motan&isDefault=true&refreshTimestamp=1515122631758&id=motanClientBasicConfig&requestTimeout=9000&group=demo_group&

unavailableServer 节点是一个过渡节点,所以在一切正常的情况下不会存在信息,它的具体作用在下面会介绍。

从这些输出数据可以发现,注册中心承担的一个职责就是存储服务调用中相关的信息,server 向 zookeeper 注册信息,保存在 server 节点,而 client 实际和 server 共享同一个接口,接口名称就是路径名,所以也到达了同样的 server 节点去获取信息。并且同时注册到了 client 节点下(为什么需要这么做在下面介绍)。

注册信息详解

Server 节点

server 节点承担着最重要的职责,它由服务提供者创建,以供服务消费者获取节点中的信息,从而定位到服务提供者真正网络拓扑位置以及得知如何调用。demo 中我只在本机 [192.168.150.1:6666] 启动了一个实例,所以在 server 节点之下,只存在这么一个节点,继续 get 这个节点,可以获取更详细的信息

1
motan://192.168.150.1:6666/com.sinosoft.student.api.DemoApi?serialization=hessian2&protocol=motan&isDefault=true&maxContentLength=1548576&shareChannel=true&refreshTimestamp=1515122649835&id=motanServerBasicConfig&nodeType=service&export=motan:6666&requestTimeout=9000000&accessLog=false&group=demo_group&

作为一个 value 值,它和 http 协议的请求十分相似,不过是以 motan:// 开头,表达的意图也很明确,这是 motan 协议和相关的路径及参数,关于 RPC 中的协议,可以翻看我的上一篇文章《深入理解 RPC 之协议篇》。

serialization 对应序列化方式,protocol 对应协议名称,maxContentLength 对应 RPC 传输中数据报文的最大长度,shareChannel 是传输层用到的参数,netty channel 中的一个属性,group 对应分组名称。

上述的 value 包含了 RPC 调用中所需要的全部信息。

Client 节点

在 motan 中使用 zookeeper 作为注册中心时,客户端订阅服务时会向 zookeeper 注册自身,主要是方便对调用方进行统计、管理。但订阅时是否注册 client 不是必要行为,和不同的注册中心实现有关,例如使用 consul 时便没有注册。

由于我们使用 zookeeper,也可以分析下 zookeeper 中都注册了什么信息。

1
motan://192.168.150.1:0/com.sinosoft.student.api.DemoApi?singleton=true&maxContentLength=1548576&check=false&nodeType=service&version=1.0&throwException=true&accessLog=false&serialization=hessian2&retries=0&protocol=motan&isDefault=true&refreshTimestamp=1515122631758&id=motanClientBasicConfig&requestTimeout=9000&group=demo_group

和 Server 节点的值类似,但也有客户独有的一些属性,如 singleton 代表服务是否单例,check 检查服务提供者是否存在,retries 代表重试次数,这也是 RPC 中特别需要注意的一点。

UnavailableServer 节点

unavailableServer 节点也不是必须存在的一个节点,它主要用来做 server 端的延迟上线,优雅关机。

延迟上线:一般推荐的服务端启动流程为:server 向注册中心的 unavailableServer 注册,状态为 unavailable,此时整个服务处于启动状态,但不对外提供服务,在服务验证通过,预热完毕,此时打开心跳开关,此时正式提供服务。

优雅关机:当需要对 server 方进行维护升级时,如果直接关闭,则会影响到客户端的请求。所以理想的情况应当是首先切断流量,再进行 server 的下线。具体的做法便是:先关闭心跳开关,客户端感知停止调用后,再关闭服务进程。

感知服务的下线

服务上线时自然要注册到注册中心,但下线时也得从注册中心中摘除。注册是一个主动的行为,这没有特别要注意的地方,但服务下线却是一个值得思考的问题。服务下线包含了主动下线和系统宕机等异常方式的下线。

临时节点 + 长连接

在 zookeeper 中存在持久化节点和临时节点的概念。持久化节点一经创建,只要不主动删除,便会一直持久化存在;临时节点的生命周期则是和客户端的连接同生共死的,应用连接到 zookeeper 时创建一个临时节点,使用长连接维持会话,这样无论何种方式服务发生下线,zookeeper 都可以感知到,进而删除临时节点。zookeeper 的这一特性和服务下线的需求契合的比较好,所以临时节点被广泛应用。

主动下线 + 心跳检测

并不是所有注册中心都有临时节点的概念,另外一种感知服务下线的方式是主动下线。例如在 eureka 中,会有 eureka-server 和 eureka-client 两个角色,其中 eureka-server 保存注册信息,地位等同于 zookeeper。当 eureka-client 需要关闭时,会发送一个通知给 eureka-server,从而让 eureka-server 摘除自己这个节点。但这么做最大的一个问题是,如果仅仅只有主动下线这么一个手段,一旦 eureka-client 非正常下线(如断电,断网),eureka-server 便会一直存在一个已经下线的服务节点,一旦被其他服务发现进而调用,便会带来问题。为了避免出现这样的情况,需要给 eureka-server 增加一个心跳检测功能,它会对服务提供者进行探测,比如每隔 30s 发送一个心跳,如果三次心跳结果都没有返回值,就认为该服务已下线。

注册中心对比

Feature Consul zookeeper etcd euerka
服务健康检查 服务状态,内存,硬盘等 (弱) 长连接,keepalive 连接心跳 可配支持
多数据中心 支持
kv 存储服务 支持 支持 支持
一致性 raft paxos raft
cap ca cp cp ap
使用接口 (多语言能力) 支持 http 和 dns 客户端 http/grpc http(sidecar)
watch 支持 全量 / 支持 long polling 支持 支持 long polling 支持 long polling/ 大部分增量
自身监控 metrics metrics metrics
安全 acl /https acl https 支持(弱)
spring cloud 集成 已支持 已支持 已支持 已支持

一般而言注册中心的特性决定了其使用的场景,例如很多框架支持 zookeeper,在我自己看来是因为其老牌,易用,但业界也有很多人认为 zookeeper 不适合做注册中心,它本身是一个分布式协调组件,并不是为注册服务而生,server 端注册一个服务节点,client 端并不需要在同一时刻拿到完全一致的服务列表,只要最终一致性即可。在跨 IDC,多数据中心等场景下 consul 发挥了很大的优势,这也是很多互联网公司选择使用 consul 的原因。 eureka 是 ap 注册中心,并且是 spring cloud 默认使用的组件,spring cloud eureka 较为贴近 spring cloud 生态。

总结

注册中心主要用于解耦服务调用中的定位问题,是分布式系统必须面对的一个问题。更多专业性的对比,可以期待 spring4all.com 的注册中心专题讨论,相信会有更为细致地对比。

分享到

深入理解 RPC 之协议篇

协议(Protocol)是个很广的概念,RPC 被称为远程过程调用协议,HTTP 和 TCP 也是大家熟悉的协议,也有人经常拿 RPC 和 RESTFUL 做对比,后者也可以被理解为一种协议… 我个人偏向于把“协议”理解为不同厂家不同用户之间的“约定”,而在 RPC 中,协议的含义也有多层。

Protocol 在 RPC 中的层次关系

翻看 dubbo 和 motan 两个国内知名度数一数二的 RPC 框架(或者叫服务治理框架可能更合适)的文档,他们都有专门的一章介绍自身对多种协议的支持。RPC 框架是一个分层结构,从我的这个《深入理解 RPC》系列就可以看出,是按照分层来介绍 RPC 的原理的,前面已经介绍过了传输层,序列化层,动态代理层,他们各自负责 RPC 调用生命周期中的一环,而协议层则是凌驾于它们所有层之上的一层。简单描述下各个层之间的关系:

protocol 层主要用于配置 refer(发现服务) 和 exporter(暴露服务) 的实现方式,transport 层定义了传输的方式,codec 层诠释了具体传输过程中报文解析的方式,serialize 层负责将对象转换成字节,以用于传输,proxy 层负责将这些细节屏蔽。

它们的包含关系如下:protocol > transport > codec > serialize

motan 的 Protocol 接口可以佐证这一点:

1
2
3
4
5
public interface Protocol {
<T> Exporter<T> export(Provider<T> provider, URL url);
<T> Referer<T> refer(Class<T> clz, URL url, URL serviceUrl);
void destroy();
}

我们都知道 RPC 框架支持多种协议,由于协议处于框架层次的较高位置,任何一种协议的替换,都可能会导致服务发现和服务注册的方式,传输的方式,以及序列化的方式,而不同的协议也给不同的业务场景带来了更多的选择,下面就来看看一些常用协议。

Dubbo 中的协议

dubbo://

Dubbo 缺省协议采用单一长连接和 NIO 异步通讯,适合于小数据量高并发的服务调用,以及服务消费者机器数远大于服务提供者机器数的情况。

反之,Dubbo 缺省协议不适合传送大数据量的服务,比如传文件,传视频等,除非请求量很低。

适用场景:常规远程服务方法调用

rmi://

RMI 协议采用 JDK 标准的 java.rmi.* 实现,采用阻塞式短连接和 JDK 标准序列化方式。

适用场景:常规远程服务方法调用,与原生 RMI 服务互操作

hessian://

Hessian 协议用于集成 Hessian 的服务,Hessian 底层采用 Http 通讯,采用 Servlet 暴露服务,Dubbo 缺省内嵌 Jetty 作为服务器实现。

Dubbo 的 Hessian 协议可以和原生 Hessian 服务互操作,即:

  • 提供者用 Dubbo 的 Hessian 协议暴露服务,消费者直接用标准 Hessian 接口调用
  • 或者提供方用标准 Hessian 暴露服务,消费方用 Dubbo 的 Hessian 协议调用。

Hessian 在之前介绍过,当时仅仅是用它来作为序列化工具,但其本身其实就是一个协议,可以用来做远程通信。

适用场景:页面传输,文件传输,或与原生 hessian 服务互操作

http://

基于 HTTP 表单的远程调用协议,采用 Spring 的 HttpInvoker 实现

适用场景:需同时给应用程序和浏览器 JS 使用的服务。

webserivice://

基于 WebService 的远程调用协议,基于 Apache CXF 的 frontend-simpletransports-http 实现。

可以和原生 WebService 服务互操作,即:

  • 提供者用 Dubbo 的 WebService 协议暴露服务,消费者直接用标准 WebService 接口调用,
  • 或者提供方用标准 WebService 暴露服务,消费方用 Dubbo 的 WebService 协议调用

适用场景:系统集成,跨语言调用

thrift://

当前 dubbo 支持的 thrift 协议是对 thrift 原生协议的扩展,在原生协议的基础上添加了一些额外的头信息,比如 service name,magic number 等。

memcached://

基于 memcached 实现的 RPC 协议

redis://

基于 Redis 实现的 RPC 协议。

dubbo 支持的众多协议详见 http://dubbo.io/books/dubbo-user-book/references/protocol/dubbo.html

dubbo 的一个分支 dangdangdotcom/dubbox 扩展了 REST 协议

rest://

JAX-RS 是标准的 Java REST API,得到了业界的广泛支持和应用,其著名的开源实现就有很多,包括 Oracle 的 Jersey,RedHat 的 RestEasy,Apache 的 CXF 和 Wink,以及 restlet 等等。另外,所有支持 JavaEE 6.0 以上规范的商用 JavaEE 应用服务器都对 JAX-RS 提供了支持。因此,JAX-RS 是一种已经非常成熟的解决方案,并且采用它没有任何所谓 vendor lock-in 的问题。

JAX-RS 在网上的资料非常丰富,例如下面的入门教程:

更多的资料请自行 google 或者百度一下。就学习 JAX-RS 来说,一般主要掌握其各种 annotation 的用法即可。

注意:dubbo 是基于 JAX-RS 2.0 版本的,有时候需要注意一下资料或 REST 实现所涉及的版本。

适用场景:跨语言调用

千米网也给 dubbo 贡献了一个扩展协议:https://github.com/dubbo/dubbo-rpc-jsonrpc

jsonrpc://

Why HTTP

在互联网快速迭代的大潮下,越来越多的公司选择 nodejs、django、rails 这样的快速脚本框架来开发 web 端应用 而后端的服务用 Java 又是最合适的,这就产生了大量的跨语言的调用需求。
而 http、json 是天然合适作为跨语言的标准,各种语言都有成熟的类库
虽然 Dubbo 的异步长连接协议效率很高,但是在脚本语言中,这点效率的损失并不重要。

Why Not RESTful

Dubbox 在 RESTful 接口上已经做出了尝试,但是 REST 架构和 dubbo 原有的 RPC 架构是有区别的,
区别在于 REST 架构需要有资源 (Resources) 的定义, 需要用到 HTTP 协议的基本操作 GET、POST、PUT、DELETE 对资源进行操作。
Dubbox 需要重新定义接口的属性,这对原有的 Dubbo 接口迁移是一个较大的负担。
相比之下,RESTful 更合适互联网系统之间的调用,而 RPC 更合适一个系统内的调用,
所以我们使用了和 Dubbo 理念较为一致的 JsonRPC

JSON-RPC 2.0 规范 和 JAX-RS 一样,也是一个规范,JAVA 对其的支持可参考 jsonrpc4j

适用场景:跨语言调用

Motan 中的协议

motan://

motan 协议之于 motan,地位等同于 dubbo 协议之于 dubbo,两者都是各自默认的且都是自定义的协议。内部使用 netty 进行通信(旧版本使用 netty3 ,最新版本支持 netty4),默认使用 hessian 作为序列化器。

适用场景:常规远程服务方法调用

injvm://

顾名思义,如果 Provider 和 Consumer 位于同一个 jvm,motan 提供了 injvm 协议。这个协议是 jvm 内部调用,不经过本地网络,一般在服务化拆分时,作为过渡方案使用,可以通过开关机制在本地和远程调用之间进行切换,等过渡完成后再去除本地实现的引用。

grpc:// 和 yar://

这两个协议的诞生缘起于一定的历史遗留问题,moton 是新浪微博开源的,而其内部有很多 PHP 应用,为解决跨语言问题,这两个协议进而出现了。

适用场景:较为局限的跨语言调用

restful://

motan 在 0.3.1 (2017-07-11) 版本发布了 restful 协议的支持(和 dubbo 的 rest 协议本质一样),dubbo 默认使用 jetty 作为 http server,而 motan 使用则是 netty 。主要实现的是 java 对 restful 指定的规范,即 javax.ws.rs 包下的类。

适用场景:跨语言调用

motan2://

motan 1.0.0 (2017-10-31) 版本发布了 motan2 协议,用于对跨语言的支持,不同于 restful,jsonrpc 这样的通用协议,motan2 把请求的一些元数据作为单独的部分传输,更适合不同语言解析。

适用场景:跨语言调用

Motan is a cross-language remote procedure call(RPC) framework for rapid development of high performance distributed services.

Motan-go is golang implementation.

Motan-PHP is PHP client can interactive with Motan server directly or through Motan-go agent.

Motan-openresty is a Lua(Luajit) implementation based on Openresty

从 motan 的 changeLog 以及 github 首页的介绍来看,其致力于打造成一个跨语言的服务治理框架,这倒是比较亦可赛艇的事。

面向未来的协议

motan 已经支持 motan2://,计划支持 mcq://,kafka:// … 支持更多的协议,以应对复杂的业务场景。对这个感兴趣的朋友,可以参见这篇文章:http://mp.weixin.qq.com/s/XZVCHZZzCX8wwgNKZtsmcA

总结

如果仅仅是将 dubbo,motan 作为一个 RPC 框架使用,那大多人会选择其默认的协议(dubbo 协议,motan 协议),而如果是有历史遗留原因,如需要对接异构系统,就需要替换成其他协议了。大多数互联网公司选择自研 RPC 框架,或者改造自己的协议,都是为了适配自身业务的特殊性,协议层的选择非常重要。

分享到

Motan 中使用异步 RPC 接口

这周六参加了一个美团点评的技术沙龙,其中一位老师在介绍他们自研的 RPC 框架时提到一点:RPC 请求分为 sync,future,callback,oneway,并且需要遵循一个原则:能够异步的地方就不要使用同步。正好最近在优化一个业务场景:在一次页面展示中,需要调用 5 个 RPC 接口,导致页面响应很慢。正好启发了我。

为什么慢?

大多数开源的 RPC 框架实现远程调用的方式都是同步的,假设 [接口 1,…,接口 5] 的每一次调用耗时为 200ms (其中接口 2 依赖接口 1,接口 5 依赖接口 3,接口 4),那么总耗时为 1s,这整个是一个串行的过程。

多线程加速

第一个想到的解决方案便是多线程,那么 [1=>2] 编为一组,[[3,4]=>5]编为一组,两组并发执行,[1=>2]串行执行耗时 400ms,[3,4]并发执行耗时 200ms,[[3,4]=>5]总耗时 400ms ,最终 [[1=>2],[[3,4]=>5]] 总耗时 400ms(理论耗时)。相比较于原来的 1s,的确快了不少,但实际编写接口花了不少功夫,创建线程池,管理资源,分析依赖关系… 总之代码不是很优雅。

RPC 中,多线程着重考虑的点是在客户端优化代码,这给客户端带来了一定的复杂性,并且编写并发代码对程序员的要求更高,且不利于调试。

异步调用

如果有一种既能保证速度,又能像同步 RPC 调用那样方便,岂不美哉?于是引出了 RPC 中的异步调用。

在 RPC 异步调用之前,先回顾一下 java.util.concurrent 中的基础知识:CallableFuture

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
public class Main {

public static void main(String[] args) throws Exception{

final ExecutorService executorService = Executors.newFixedThreadPool(10);
long start = System.currentTimeMillis();
Future<Integer> resultFuture1 = executorService.submit(new Callable<Integer>() {
@Override
public Integer call() throws Exception {
return method1()+ method2();
}
});
Future<Integer> resultFuture2 = executorService.submit(new Callable<Integer>() {
@Override
public Integer call() throws Exception {
Future<Integer> resultFuture3 = executorService.submit(new Callable<Integer>() {
@Override
public Integer call() throws Exception {
return method3();
}
});
Future<Integer> resultFuture4 = executorService.submit(new Callable<Integer>() {
@Override
public Integer call() throws Exception {
return method4();
}
});
return method5()+resultFuture3.get()+resultFuture4.get();
}
});
int result = resultFuture1.get()+ resultFuture2.get();

System.out.println("result ="+result+", total cost"+(System.currentTimeMillis()-start)+"ms");

executorService.shutdown();
}

static int method1(){
delay200ms();
return 1;
}
static int method2(){
delay200ms();
return 2;
}
static int method3(){
delay200ms();
return 3;
}
static int method4(){
delay200ms();
return 4;
}
static int method5(){
delay200ms();
return 5;
}

static void delay200ms(){
try{
Thread.sleep(200);
}catch (Exception e){
e.printStackTrace();
}
}

}

最终控制台打印:

result = 15, total cost 413 ms

五个接口,如果同步调用,便是串行的效果,最终耗时必定在 1s 之上,而异步调用的优势便是,submit 任务之后立刻返回,只有在调用 future.get() 方法时才会阻塞,而这期间多个异步方法便可以并发的执行。

RPC 异步调用

我们的项目使用了 Motan 作为 RPC 框架,查看其 changeLog ,0.3.0 (2017-03-09) 该版本已经支持了 async 特性。可以让开发者很方便地实现 RPC 异步调用。

1 为接口增加 @MotanAsync 注解

1
2
3
4
@MotanAsync
public interface DemoApi {
DemoDto randomDemo(String id);
}

2 添加 Maven 插件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>build-helper-maven-plugin</artifactId>
<version>1.10</version>
<executions>
<execution>
<phase>generate-sources</phase>
<goals>
<goal>add-source</goal>
</goals>
<configuration>
<sources>
<source>${project.build.directory}/generated-sources/annotations</source>
</sources>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>

安装插件后,可以借助它生成一个和 DemoApi 关联的异步接口 DemoApiAsync 。

1
2
3
public interface DemoApiAsync extends DemoApi {
ResponseFuture randomDemoAsync(String id);
}

3 注入接口即可调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Service
public class DemoService {

@MotanReferer
DemoApi demoApi;

@MotanReferer
DemoApiAsync demoApiAsync;//<1>

public DemoDto randomDemo(String id){
DemoDto demoDto = demoApi.randomDemo(id);
return demoDto;
}

public DemoDto randomDemoAsync(String id){
ResponseFuture responseFuture = demoApiAsync.randomDemoAsync(id);//<2>
DemoDto demoDto = (DemoDto) responseFuture.getValue();
return demoDto;
}

}

<1> DemoApiAsync 如何生成的已经介绍过,它和 DemoApi 并没有功能性的区别,仅仅是同步异步调用的差距,而 DemoApiAsync 实现的的复杂性完全由 RPC 框架帮助我们完成,开发者无需编写 Callable 接口。

<2> ResponseFuture 是 RPC 中 Future 的抽象,其本身也是 juc 中 Future 的子类,当 responseFuture.getValue() 调用时会阻塞。

总结

在异步调用中,如果发起一次异步调用后,立刻使用 future.get(),则大致和同步调用等同。其真正的优势是在 submit 和 future.get() 之间可以混杂一些非依赖性的耗时操作,而不是同步等待,从而充分利用时间片。

另外需要注意,如果异步调用涉及到数据的修改,则多个异步操作直接不能保证 happens-before 原则,这属于并发控制的范畴了,谨慎使用。查询操作则大多没有这样的限制。

在能使用并发的地方使用并发,不能使用的地方才选择同步,这需要我们思考更多细节,但可以最大限度的提升系统的性能。

分享到

深入理解 RPC 之传输篇

RPC 被称为“远程过程调用”,表明了一个方法调用会跨越网络,跨越进程,所以传输层是不可或缺的。一说到网络传输,一堆名词就蹦了出来:TCP、UDP、HTTP,同步 or 异步,阻塞 or 非阻塞,长连接 or 短连接…

本文介绍两种传输层的实现:使用 Socket 和使用 Netty。前者实现的是阻塞式的通信,是一个较为简单的传输层实现方式,借此可以了解传输层的工作原理及工作内容;后者是非阻塞式的,在一般的 RPC 场景下,性能会表现的很好,所以被很多开源 RPC 框架作为传输层的实现方式。

RpcRequest 和 RpcResponse

传输层传输的主要对象其实就是这两个类,它们封装了请求 id,方法名,方法参数,返回值,异常等 RPC 调用中需要的一系列信息。

1
2
3
4
5
6
7
8
9
10
public class RpcRequest implements Serializable {
private String interfaceName;
private String methodName;
private String parametersDesc;
private Object[] arguments;
private Map<String, String> attachments;
private int retries = 0;
private long requestId;
private byte rpcProtocolVersion;
}
1
2
3
4
5
6
7
8
9
public class RpcResponse implements Serializable {
private Object value;
private Exception exception;
private long requestId;
private long processTime;
private int timeout;
private Map<String, String> attachments;// rpc 协议版本兼容时可以回传一些额外的信息
private byte rpcProtocolVersion;
}

Socket 传输

Server

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
public class RpcServerSocketProvider {


public static void main(String[] args) throws Exception {

// 序列化层实现参考之前的章节
Serialization serialization = new Hessian2Serialization();

ServerSocket serverSocket = new ServerSocket(8088);
ExecutorService executorService = Executors.newFixedThreadPool(10);
while (true) {
final Socket socket = serverSocket.accept();
executorService.execute(() -> {
try {
InputStream is = socket.getInputStream();
OutputStream os = socket.getOutputStream();
try {
DataInputStream dis = new DataInputStream(is);
int length = dis.readInt();
byte[] requestBody = new byte[length];
dis.read(requestBody);
// 反序列化 requestBody => RpcRequest
RpcRequest rpcRequest = serialization.deserialize(requestBody, RpcRequest.class);
// 反射调用生成响应 并组装成 rpcResponse
RpcResponse rpcResponse = invoke(rpcRequest);
// 序列化 rpcResponse => responseBody
byte[] responseBody = serialization.serialize(rpcResponse);
DataOutputStream dos = new DataOutputStream(os);
dos.writeInt(responseBody.length);
dos.write(responseBody);
dos.flush();
} catch (Exception e) {
e.printStackTrace();
} finally {
is.close();
os.close();
}
} catch (Exception e) {
e.printStackTrace();
} finally {
try {
socket.close();
} catch (Exception e) {
e.printStackTrace();
}
}
});
}

}

public static RpcResponse invoke(RpcRequest rpcRequest) {
// 模拟反射调用
RpcResponse rpcResponse = new RpcResponse();
rpcResponse.setRequestId(rpcRequest.getRequestId());
//... some operation
return rpcResponse;
}

}

Client

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class RpcSocketConsumer {

public static void main(String[] args) throws Exception {

// 序列化层实现参考之前的章节
Serialization serialization = new Hessian2Serialization();

Socket socket = new Socket("localhost", 8088);
InputStream is = socket.getInputStream();
OutputStream os = socket.getOutputStream();
// 封装 rpc 请求
RpcRequest rpcRequest = new RpcRequest();
rpcRequest.setRequestId(12345L);
// 序列化 rpcRequest => requestBody
byte[] requestBody = serialization.serialize(rpcRequest);
DataOutputStream dos = new DataOutputStream(os);
dos.writeInt(requestBody.length);
dos.write(requestBody);
dos.flush();
DataInputStream dis = new DataInputStream(is);
int length = dis.readInt();
byte[] responseBody = new byte[length];
dis.read(responseBody);
// 反序列化 responseBody => rpcResponse
RpcResponse rpcResponse = serialization.deserialize(responseBody, RpcResponse.class);
is.close();
os.close();
socket.close();

System.out.println(rpcResponse.getRequestId());
}
}

dis.readInt()和 dis.read(byte[] bytes) 决定了使用 Socket 通信是一种阻塞式的操作,报文头 + 报文体的传输格式是一种常见的格式,除此之外,使用特殊的字符如空行也可以划分出报文结构。在示例中,我们使用一个 int(4 字节)来传递报问题的长度,之后传递报文体,在复杂的通信协议中,报文头除了存储报文体还会额外存储一些信息,包括协议名称,版本,心跳标识等。

在网络传输中,只有字节能够被识别,所以我们在开头引入了 Serialization 接口,负责完成 RpcRequest 和 RpcResponse 与字节的相互转换。(Serialization 的工作机制可以参考之前的文章)

使用 Socket 通信可以发现:每次 Server 处理 Client 请求都会从线程池中取出一个线程来处理请求,这样的开销对于一般的 Rpc 调用是不能够接受的,而 Netty 一类的网络框架便派上了用场。

Netty 传输

Server 和 ServerHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class RpcNettyProvider {

public static void main(String[] args) throws Exception{

EventLoopGroup bossGroup = new NioEventLoopGroup();
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
// 创建并初始化 Netty 服务端 Bootstrap 对象
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup);
bootstrap.channel(NioServerSocketChannel.class);
bootstrap.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel channel) throws Exception {
ChannelPipeline pipeline = channel.pipeline();
pipeline.addLast(new RpcDecoder(RpcRequest.class)); // 解码 RPC 请求
pipeline.addLast(new RpcEncoder(RpcResponse.class)); // 编码 RPC 响应
pipeline.addLast(new RpcServerHandler()); // 处理 RPC 请求
}
});
bootstrap.option(ChannelOption.SO_BACKLOG, 1024);
bootstrap.childOption(ChannelOption.SO_KEEPALIVE, true);
ChannelFuture future = bootstrap.bind("127.0.0.1", 8087).sync();
// 关闭 RPC 服务器
future.channel().closeFuture().sync();
} finally {
workerGroup.shutdownGracefully();
bossGroup.shutdownGracefully();
}
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class RpcServerHandler extends SimpleChannelInboundHandler<RpcRequest> {

@Override
public void channelRead0(final ChannelHandlerContext ctx, RpcRequest request) throws Exception {
RpcResponse rpcResponse = invoke(request);
// 写入 RPC 响应对象并自动关闭连接
ctx.writeAndFlush(rpcResponse).addListener(ChannelFutureListener.CLOSE);
}

private RpcResponse invoke(RpcRequest rpcRequest) {
// 模拟反射调用
RpcResponse rpcResponse = new RpcResponse();
rpcResponse.setRequestId(rpcRequest.getRequestId());
//... some operation
return rpcResponse;
}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
cause.printStackTrace();
ctx.close();
}
}

Client 和 ClientHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
public class RpcNettyConsumer {

public static void main(String[] args) throws Exception{
EventLoopGroup group = new NioEventLoopGroup();
try {
// 创建并初始化 Netty 客户端 Bootstrap 对象
Bootstrap bootstrap = new Bootstrap();
bootstrap.group(group);
bootstrap.channel(NioSocketChannel.class);
bootstrap.handler(new ChannelInitializer<SocketChannel>() {
@Override
public void initChannel(SocketChannel channel) throws Exception {
ChannelPipeline pipeline = channel.pipeline();
pipeline.addLast(new RpcEncoder(RpcRequest.class)); // 编码 RPC 请求
pipeline.addLast(new RpcDecoder(RpcResponse.class)); // 解码 RPC 响应
pipeline.addLast(new RpcClientHandler()); // 处理 RPC 响应
}
});
bootstrap.option(ChannelOption.TCP_NODELAY, true);
// 连接 RPC 服务器
ChannelFuture future = bootstrap.connect("127.0.0.1", 8087).sync();
// 写入 RPC 请求数据并关闭连接
Channel channel = future.channel();

RpcRequest rpcRequest = new RpcRequest();
rpcRequest.setRequestId(123456L);

channel.writeAndFlush(rpcRequest).sync();
channel.closeFuture().sync();
} finally {
group.shutdownGracefully();
}
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class RpcClientHandler extends SimpleChannelInboundHandler<RpcResponse> {

@Override
public void channelRead0(ChannelHandlerContext ctx, RpcResponse response) throws Exception {
System.out.println(response.getRequestId());// 处理响应
}

@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception {
cause.printStackTrace();
ctx.close();
}

}

使用 Netty 的好处是很方便地实现了非阻塞式的调用,关键部分都给出了注释。上述的代码虽然很多,并且和我们熟悉的 Socket 通信代码大相径庭,但大多数都是 Netty 的模板代码,启动服务器,配置编解码器等。真正的 RPC 封装操作大多集中在 Handler 的 channelRead 方法(负责读取)以及 channel.writeAndFlush 方法(负责写入)中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public class RpcEncoder extends MessageToByteEncoder {

private Class<?> genericClass;

Serialization serialization = new Hessian2Serialization();

public RpcEncoder(Class<?> genericClass) {
this.genericClass = genericClass;
}

@Override
public void encode(ChannelHandlerContext ctx, Object in, ByteBuf out) throws Exception {
if (genericClass.isInstance(in)) {
byte[] data = serialization.serialize(in);
out.writeInt(data.length);
out.writeBytes(data);
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class RpcDecoder extends ByteToMessageDecoder {

private Class<?> genericClass;

public RpcDecoder(Class<?> genericClass) {
this.genericClass = genericClass;
}

Serialization serialization = new Hessian2Serialization();

@Override
public void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) throws Exception {
if (in.readableBytes() < 4) {
return;
}
in.markReaderIndex();
int dataLength = in.readInt();
if (in.readableBytes() < dataLength) {
in.resetReaderIndex();
return;
}
byte[] data = new byte[dataLength];
in.readBytes(data);
out.add(serialization.deserialize(data, genericClass));
}
}

使用 Netty 不能保证返回的字节大小,所以需要加上 in.readableBytes()< 4 这样的判断,以及 in.markReaderIndex() 这样的标记,用来区分报文头和报文体。

同步与异步 阻塞与非阻塞

这两组传输特性经常被拿来做对比,很多文章声称 Socket 是同步阻塞的,Netty 是异步非阻塞,其实有点问题。

其实这两组并没有必然的联系,同步阻塞,同步非阻塞,异步非阻塞都有可能(同步非阻塞倒是没见过),而大多数使用 Netty 实现的 RPC 调用其实应当是同步非阻塞的(当然一般 RPC 也支持异步非阻塞)。

同步和异步关注的是 消息通信机制
所谓同步,就是在发出一个 调用 时,在没有得到结果之前,该 调用 就不返回。但是一旦调用返回,就得到返回值了。
换句话说,就是由 调用者 主动等待这个 调用 的结果。

而异步则是相反,调用在发出之后,这个调用就直接返回了,所以没有返回结果。换句话说,当一个异步过程调用发出后,调用者不会立刻得到结果。而是在 调用 发出后, 被调用者 通过状态、通知来通知调用者,或通过回调函数处理这个调用。

如果需要 RPC 调用返回一个结果,该结果立刻被使用,那意味着着大概率需要是一个同步调用。如果不关心其返回值,则可以将其做成异步接口,以提升效率。

阻塞和非阻塞关注的是 程序在等待调用结果(消息,返回值)时的状态 .

阻塞调用是指调用结果返回之前,当前线程会被挂起。调用线程只有在得到结果之后才会返回。
非阻塞调用指在不能立刻得到结果之前,该调用不会阻塞当前线程。

在上述的例子中可以看出 Socket 通信我们显示声明了一个包含 10 个线程的线程池,每次请求到来,分配一个线程,等待客户端传递报文头和报文体的行为都会阻塞该线程,可以见得其整体是阻塞的。而在 Netty 通信的例子中,每次请求并没有分配一个线程,而是通过 Handler 的方式处理请求(联想 NIO 中 Selector),是非阻塞的。

使用同步非阻塞方式的通信机制并不一定同步阻塞式的通信强,所谓没有最好,只有更合适,而一般的同步非阻塞 通信适用于 1. 网络连接数量多 2. 每个连接的 io 不频繁 的场景,与 RPC 调用较为契合。而成熟的 RPC 框架的传输层和协议层通常也会提供多种选择,以应对不同的场景。

总结

本文堆砌了一些代码,而难点主要是对 Socket 的理解,和 Netty 框架的掌握。Netty 的学习有一定的门槛,但实际需要掌握的知识点其实并不多(仅仅针对 RPC 框架所涉及的知识点而言),学习 Netty ,个人推荐《Netty IN ACTION》以及 https://waylau.gitbooks.io/netty-4-user-guide/Getting%20Started/Before%20Getting%20Started.html 该网站的例子。

参考资料:

http://javatar.iteye.com/blog/1123915 – 梁飞

https://gitee.com/huangyong/rpc – 黄勇

分享到

深入理解 RPC 之动态代理篇

提到 JAVA 中的动态代理,大多数人都不会对 JDK 动态代理感到陌生,Proxy,InvocationHandler 等类都是 J2SE 中的基础概念。动态代理发生在服务调用方 / 客户端,RPC 框架需要解决的一个问题是:像调用本地接口一样调用远程的接口。于是如何组装数据报文,经过网络传输发送至服务提供方,屏蔽远程接口调用的细节,便是动态代理需要做的工作了。RPC 框架中的代理层往往是单独的一层,以方便替换代理方式(如 motan 代理层位于 com.weibo.api.motan.proxy ,dubbo 代理层位于 com.alibaba.dubbo.common.bytecode )。

实现动态代理的方案有下列几种:

  • jdk 动态代理
  • cglib 动态代理
  • javassist 动态代理
  • ASM 字节码
  • javassist 字节码

其中 cglib 底层实现依赖于 ASM,javassist 自成一派。由于 ASM 和 javassist 需要程序员直接操作字节码,导致使用门槛相对较高,但实际上他们的应用是非常广泛的,如 Hibernate 底层使用了 javassist(默认)和 cglib,Spring 使用了 cglib 和 jdk 动态代理。

RPC 框架无论选择何种代理技术,所需要完成的任务其实是固定的,不外乎‘整理报文’,‘确认网络位置’,‘序列化’,’网络传输’,‘反序列化’,’返回结果’…

技术选型的影响因素

框架中使用何种动态代理技术,影响因素也不少。

性能

从早期 dubbo 的作者梁飞的博客 http://javatar.iteye.com/blog/814426 中可以得知 dubbo 选择使用 javassist 作为动态代理方案主要考虑的因素是 性能

从其博客的测试结果来看 javassist > cglib > jdk 。但实际上他的测试过程稍微有点瑕疵:在 cglib 和 jdk 代理对象调用时,走的是反射调用,而在 javassist 生成的代理对象调用时,走的是直接调用(可以先阅读下梁飞大大的博客)。这意味着 cglib 和 jdk 慢的原因并不是由动态代理产生的,而是由反射调用产生的(顺带一提,很多人认为 jdk 动态代理的原理是反射,其实它的底层也是使用的字节码技术)。而最终我的测试结果,结论如下: javassist ≈ cglib > jdk 。javassist 和 cglib 的效率基本持平 ,而他们两者的执行效率基本可以达到 jdk 动态代理的 2 倍(这取决于测试的机器以及 jdk 的版本,jdk1.8 相较于 jdk1.6 动态代理技术有了质的提升,所以并不是传闻中的那样:cglib 比 jdk 快 10 倍)。文末会给出我的测试代码。

依赖

motan 默认的实现是 jdk 动态代理,代理方案支持 SPI 扩展,可以自行扩展其他实现方式。

使用 jdk 做为默认,主要是减少 core 包依赖,性能不是唯一考虑因素。另外使用字节码方式 javaassist 性能比较优秀,动态代理模式下 jdk 性能也不会差多少。

rayzhang0603(motan 贡献者)

motan 选择使用 jdk 动态代理,原因主要有两个:减少 motan-core 的依赖,方便。至于扩展性,dubbo 并没有预留出动态代理的扩展接口,而是写死了 bytecode ,这点上 motan 做的较好。

易用性

从 dubbo 和 motan 的源码中便可以直观的看出两者的差距了,dubbo 为了使用 javassist 技术花费不少的精力,而 motan 使用 jdk 动态代理只用了一个类。dubbo 的设计者为了追求极致的性能而做出的工作是值得肯定的,motan 也预留了扩展机制,两者各有千秋。

动态代理入门指南

为了方便对比几种动态代理技术,先准备一个统一接口。

1
2
3
public interface BookApi {
void sell();
}

JDK 动态代理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
private static BookApi createJdkDynamicProxy(final BookApi delegate) {
BookApi jdkProxy = (BookApi) Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(),
new Class[]{BookApi.class}, new JdkHandler(delegate));
return jdkProxy;
}

private static class JdkHandler implements InvocationHandler {

final Object delegate;

JdkHandler(Object delegate) {
this.delegate = delegate;
}

@Override
public Object invoke(Object object, Method method, Object[] objects)
throws Throwable {
// 添加代理逻辑 <1>
if(method.getName().equals("sell")){
System.out.print("");
}
return null;
// return method.invoke(delegate, objects);
}

<1> 在真正的 RPC 调用中 ,需要填充‘整理报文’,‘确认网络位置’,‘序列化’,’网络传输’,‘反序列化’,’返回结果’ 等逻辑。

Cglib 动态代理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
private static BookApi createCglibDynamicProxy(final BookApi delegate) throws Exception {
Enhancer enhancer = new Enhancer();
enhancer.setCallback(new CglibInterceptor(delegate));
enhancer.setInterfaces(new Class[]{BookApi.class});
BookApi cglibProxy = (BookApi) enhancer.create();
return cglibProxy;
}

private static class CglibInterceptor implements MethodInterceptor {

final Object delegate;

CglibInterceptor(Object delegate) {
this.delegate = delegate;
}

@Override
public Object intercept(Object object, Method method, Object[] objects,
MethodProxy methodProxy) throws Throwable {
// 添加代理逻辑
if(method.getName().equals("sell")) {
System.out.print("");
}
return null;
// return methodProxy.invoke(delegate, objects);
}
}

和 JDK 动态代理的操作步骤没有太大的区别,只不过是替换了 cglib 的 API 而已。

需要引入 cglib 依赖:

1
2
3
4
5
<dependency>
<groupId>cglib</groupId>
<artifactId>cglib</artifactId>
<version>3.2.5</version>
</dependency>

Javassist 字节码

到了 javassist,稍微有点不同了。因为它是通过直接操作字节码来生成代理对象。

1
2
3
4
5
6
7
8
9
10
11
private static BookApi createJavassistBytecodeDynamicProxy() throws Exception {
ClassPool mPool = new ClassPool(true);
CtClass mCtc = mPool.makeClass(BookApi.class.getName() + "JavaassistProxy");
mCtc.addInterface(mPool.get(BookApi.class.getName()));
mCtc.addConstructor(CtNewConstructor.defaultConstructor(mCtc));
mCtc.addMethod(CtNewMethod.make(
"public void sell(){ System.out.print(\"\") ; }", mCtc));
Class<?> pc = mCtc.toClass();
BookApi bytecodeProxy = (BookApi) pc.newInstance();
return bytecodeProxy;
}

需要引入 javassist 依赖:

1
2
3
4
5
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.21.0-GA</version>
</dependency>

动态代理测试

测试环境:window i5 8g jdk1.8 cglib3.2.5 javassist3.21.0-GA

动态代理其实分成了两步:代理对象的创建,代理对象的调用。坊间流传的动态代理性能对比主要指的是后者;前者一般不被大家考虑,如果远程 Refer 的对象是单例的,其只会被创建一次,而如果是原型模式,多例对象的创建其实也是性能损耗的一个考虑因素(只不过远没有调用占比大)。

Create JDK Proxy: 21 ms

Create CGLIB Proxy: 342 ms

Create Javassist Bytecode Proxy: 419 ms

可能出乎大家的意料,JDK 创建动态代理的速度比后两者要快 10 倍左右。

下面是调用速度的测试:

case 1:

JDK Proxy invoke cost 1912 ms

CGLIB Proxy invoke cost 1015 ms

JavassistBytecode Proxy invoke cost 1280 ms

case 2:

JDK Proxy invoke cost 1747 ms

CGLIB Proxy invoke cost 1234 ms

JavassistBytecode Proxy invoke cost 1175 ms

case 3:

JDK Proxy invoke cost 2616 ms

CGLIB Proxy invoke cost 1373 ms

JavassistBytecode Proxy invoke cost 1335 ms

Jdk 的执行速度一定会慢于 Cglib 和 Javassist,但最慢也就 2 倍,并没有达到数量级的差距;Cglib 和 Javassist 不相上下,差距不大(测试中偶尔发现 Cglib 实行速度会比平时慢 10 倍,不清楚是什么原因)

所以出于易用性和性能,私以为使用 Cglib 是一个很好的选择(性能和 Javassist 持平,易用性和 Jdk 持平)。

反射调用

既然提到了动态代理和 cglib ,顺带提一下反射调用如何加速的问题。RPC 框架中在 Provider 服务端需要根据客户端传递来的 className + method + param 来找到容器中的实际方法执行反射调用。除了反射调用外,还可以使用 Cglib 来加速。

JDK 反射调用

1
2
Method method = serviceClass.getMethod(methodName, new Class[]{});
method.invoke(delegate, new Object[]{});

Cglib 调用

1
2
3
FastClass serviceFastClass = FastClass.create(serviceClass);
FastMethod serviceFastMethod = serviceFastClass.getMethod(methodName, new Class[]{});
serviceFastMethod.invoke(delegate, new Object[]{});

但实测效果发现 Cglib 并不一定比 JDK 反射执行速度快,还会跟具体的方法实现有关 (大雾)。

测试代码

略长…

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
public class Main {

public static void main(String[] args) throws Exception {

BookApi delegate = new BookApiImpl();
long time = System.currentTimeMillis();
BookApi jdkProxy = createJdkDynamicProxy(delegate);
time = System.currentTimeMillis() - time;
System.out.println("Create JDK Proxy:" + time + "ms");

time = System.currentTimeMillis();
BookApi cglibProxy = createCglibDynamicProxy(delegate);
time = System.currentTimeMillis() - time;
System.out.println("Create CGLIB Proxy:" + time + "ms");

time = System.currentTimeMillis();
BookApi javassistBytecodeProxy = createJavassistBytecodeDynamicProxy();
time = System.currentTimeMillis() - time;
System.out.println("Create JavassistBytecode Proxy:" + time + "ms");

for (int i = 0; i < 10; i++) {
jdkProxy.sell();//warm
}
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
jdkProxy.sell();
}
System.out.println("JDK Proxy invoke cost" + (System.currentTimeMillis() - start)+ "ms");

for (int i = 0; i < 10; i++) {
cglibProxy.sell();//warm
}
start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
cglibProxy.sell();
}
System.out.println("CGLIB Proxy invoke cost" + (System.currentTimeMillis() - start)+ "ms");

for (int i = 0; i < 10; i++) {
javassistBytecodeProxy.sell();//warm
}
start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
javassistBytecodeProxy.sell();
}
System.out.println("JavassistBytecode Proxy invoke cost" + (System.currentTimeMillis() - start)+ "ms");

Class<?> serviceClass = delegate.getClass();
String methodName = "sell";
for (int i = 0; i < 10; i++) {
cglibProxy.sell();//warm
}
// 执行反射调用
for (int i = 0; i < 10; i++) {//warm
Method method = serviceClass.getMethod(methodName, new Class[]{});
method.invoke(delegate, new Object[]{});
}
start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
Method method = serviceClass.getMethod(methodName, new Class[]{});
method.invoke(delegate, new Object[]{});
}
System.out.println("反射 invoke cost" + (System.currentTimeMillis() - start)+ "ms");

// 使用 CGLib 执行反射调用
for (int i = 0; i < 10; i++) {//warm
FastClass serviceFastClass = FastClass.create(serviceClass);
FastMethod serviceFastMethod = serviceFastClass.getMethod(methodName, new Class[]{});
serviceFastMethod.invoke(delegate, new Object[]{});
}
start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
FastClass serviceFastClass = FastClass.create(serviceClass);
FastMethod serviceFastMethod = serviceFastClass.getMethod(methodName, new Class[]{});
serviceFastMethod.invoke(delegate, new Object[]{});
}
System.out.println("CGLIB invoke cost" + (System.currentTimeMillis() - start)+ "ms");

}

private static BookApi createJdkDynamicProxy(final BookApi delegate) {
BookApi jdkProxy = (BookApi) Proxy.newProxyInstance(ClassLoader.getSystemClassLoader(),
new Class[]{BookApi.class}, new JdkHandler(delegate));
return jdkProxy;
}

private static class JdkHandler implements InvocationHandler {

final Object delegate;

JdkHandler(Object delegate) {
this.delegate = delegate;
}

@Override
public Object invoke(Object object, Method method, Object[] objects)
throws Throwable {
// 添加代理逻辑
if(method.getName().equals("sell")){
System.out.print("");
}
return null;
// return method.invoke(delegate, objects);
}
}

private static BookApi createCglibDynamicProxy(final BookApi delegate) throws Exception {
Enhancer enhancer = new Enhancer();
enhancer.setCallback(new CglibInterceptor(delegate));
enhancer.setInterfaces(new Class[]{BookApi.class});
BookApi cglibProxy = (BookApi) enhancer.create();
return cglibProxy;
}

private static class CglibInterceptor implements MethodInterceptor {

final Object delegate;

CglibInterceptor(Object delegate) {
this.delegate = delegate;
}

@Override
public Object intercept(Object object, Method method, Object[] objects,
MethodProxy methodProxy) throws Throwable {
// 添加代理逻辑
if(method.getName().equals("sell")) {
System.out.print("");
}
return null;
// return methodProxy.invoke(delegate, objects);
}
}

private static BookApi createJavassistBytecodeDynamicProxy() throws Exception {
ClassPool mPool = new ClassPool(true);
CtClass mCtc = mPool.makeClass(BookApi.class.getName() + "JavaassistProxy");
mCtc.addInterface(mPool.get(BookApi.class.getName()));
mCtc.addConstructor(CtNewConstructor.defaultConstructor(mCtc));
mCtc.addMethod(CtNewMethod.make(
"public void sell(){ System.out.print(\"\") ; }", mCtc));
Class<?> pc = mCtc.toClass();
BookApi bytecodeProxy = (BookApi) pc.newInstance();
return bytecodeProxy;
}

}
分享到

深入理解 RPC 之序列化篇 -- 总结篇

上一篇 《深入理解 RPC 之序列化篇 –Kryo》, 介绍了序列化的基础概念,并且详细介绍了 Kryo 的一系列特性,在这一篇中,简略的介绍其他常用的序列化器,并对它们进行一些比较。序列化篇仅仅由 Kryo 篇和总结篇构成可能有点突兀,等待后续有时间会补充详细的探讨。

定义抽象接口

1
2
3
4
5
6
public interface Serialization {

byte[] serialize(Object obj) throws IOException;

<T> T deserialize(byte[] bytes, Class<T> clz) throws IOException;
}

RPC 框架中的序列化实现自然是种类多样,但它们必须遵循统一的规范,于是我们使用 Serialization 作为序列化的统一接口,无论何种方案都需要实现该接口。

Kryo 实现

Kryo 篇已经给出了实现代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class KryoSerialization implements Serialization {

@Override
public byte[] serialize(Object obj) {
Kryo kryo = kryoLocal.get();
ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
Output output = new Output(byteArrayOutputStream);
kryo.writeObject(output, obj);
output.close();
return byteArrayOutputStream.toByteArray();
}

@Override
public <T> T deserialize(byte[] bytes, Class<T> clz) {
Kryo kryo = kryoLocal.get();
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(bytes);
Input input = new Input(byteArrayInputStream);
input.close();
return (T) kryo.readObject(input, clz);
}

private static final ThreadLocal<Kryo> kryoLocal = new ThreadLocal<Kryo>() {
@Override
protected Kryo initialValue() {
Kryo kryo = new Kryo();
kryo.setReferences(true);
kryo.setRegistrationRequired(false);
return kryo;
}
};

}

所需依赖:

1
2
3
4
5
<dependency>
<groupId>com.esotericsoftware</groupId>
<artifactId>kryo</artifactId>
<version>4.0.1</version>
</dependency>

Hessian 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Hessian2Serialization implements Serialization {

@Override
public byte[] serialize(Object data) throws IOException {
ByteArrayOutputStream bos = new ByteArrayOutputStream();
Hessian2Output out = new Hessian2Output(bos);
out.writeObject(data);
out.flush();
return bos.toByteArray();
}

@Override
public <T> T deserialize(byte[] bytes, Class<T> clz) throws IOException {
Hessian2Input input = new Hessian2Input(new ByteArrayInputStream(bytes));
return (T) input.readObject(clz);
}
}

所需依赖:

1
2
3
4
5
<dependency>
<groupId>com.caucho</groupId>
<artifactId>hessian</artifactId>
<version>4.0.51</version>
</dependency>

大名鼎鼎的 Hessian 序列化方案经常被 RPC 框架用来作为默认的序列化方案,可见其必然具备一定的优势。其具体的优劣我们放到文末的总结对比中与其他序列化方案一起讨论。而在此,着重提一点 Hessian 使用时的坑点。

BigDecimal 的反序列化

使用 Hessian 序列化包含 BigDecimal 字段的对象时会导致其值一直为 0,不注意这个 bug 会导致很大的问题,在最新的 4.0.51 版本仍然可以复现。解决方案也很简单,指定 BigDecimal 的序列化器即可,通过添加两个文件解决这个 bug:

resources\META-INF\hessian\serializers

1
java.math.BigDecimal=com.caucho.hessian.io.StringValueSerializer

resources\META-INF\hessian\deserializers

1
java.math.BigDecimal=com.caucho.hessian.io.BigDecimalDeserializer

Protostuff 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class ProtostuffSerialization implements Serialization {

@Override
public byte[] serialize(Object obj) throws IOException {
Class clz = obj.getClass();
LinkedBuffer buffer = LinkedBuffer.allocate(LinkedBuffer.DEFAULT_BUFFER_SIZE);
try {
Schema schema = RuntimeSchema.createFrom(clz);
return ProtostuffIOUtil.toByteArray(obj, schema, buffer);
} catch (Exception e) {
throw e;
} finally {
buffer.clear();
}
}

@Override
public <T> T deserialize(byte[] bytes, Class<T> clz) throws IOException {
T message = objenesis.newInstance(clz); // <1>
Schema<T> schema = RuntimeSchema.createFrom(clz);
ProtostuffIOUtil.mergeFrom(bytes, message, schema);
return message;
}

private Objenesis objenesis = new ObjenesisStd(); // <2>


}

所需依赖:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!-- Protostuff -->
<dependency>
<groupId>com.dyuproject.protostuff</groupId>
<artifactId>protostuff-core</artifactId>
<version>1.0.9</version>
</dependency>
<dependency>
<groupId>com.dyuproject.protostuff</groupId>
<artifactId>protostuff-runtime</artifactId>
<version>1.0.9</version>
</dependency>
<!-- Objenesis -->
<dependency>
<groupId>org.objenesis</groupId>
<artifactId>objenesis</artifactId>
<version>2.5</version>
</dependency>

Protostuff 可以理解为 google protobuf 序列化的升级版本,protostuff-runtime 无需静态编译,这比较适合 RPC 通信时的特性,很少见到有人直接拿 protobuf 作为 RPC 的序列化器,而 protostuff-runtime 仍然占据一席之地。

<1> 使用 Protostuff 的一个坑点在于其反序列化时需用户自己实例化序列化后的对象,所以才有了 T message = objenesis.newInstance(clz); 这行代码。使用 objenesis 工具实例化一个需要的对象,而后使用 ProtostuffIOUtil 完成赋值操作。

<2> 上述的 objenesis.newInstance(clz) 可以由 clz.newInstance() 代替,后者也可以实例化一个对象,但如果对象缺少无参构造函数,则会报错。借助于 objenesis 可以绕开无参构造器实例化一个对象,且性能优于直接反射创建。所以一般在选择 Protostuff 作为序列化器时,一般配合 objenesis 使用。

Fastjson 实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class FastJsonSerialization implements Serialization {
static final String charsetName = "UTF-8";
@Override
public byte[] serialize(Object data) throws IOException {
SerializeWriter out = new SerializeWriter();
JSONSerializer serializer = new JSONSerializer(out);
serializer.config(SerializerFeature.WriteEnumUsingToString, true);//<1>
serializer.config(SerializerFeature.WriteClassName, true);//<1>
serializer.write(data);
return out.toBytes(charsetName);
}

@Override
public <T> T deserialize(byte[] data, Class<T> clz) throws IOException {
return JSON.parseObject(new String(data), clz);
}
}

所需依赖:

1
2
3
4
5
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.28</version>
</dependency>

<1> JSON 序列化注意对枚举类型的特殊处理;额外补充类名可以在反序列化时获得更丰富的信息。

序列化对比

在我的 PC 上对上述序列化方案进行测试:

测试用例:对一个简单 POJO 对象序列化 / 反序列化 100W 次

serialize/ms deserialize/ms
Fastjson 2832 2242
Kryo 2975 1987
Hessian 4598 3631
Protostuff 2944 2541

测试用例:序列化包含 1000 个简单对象的 List,循环 1000 次

serialize/ms deserialize/ms
Fastjson 2551 2821
Kryo 1951 1342
Hessian 1828 2213
Protostuff 1409 2813

对于耗时类型的测试需要做到预热 + 平均值等条件,测试后效果其实并不如人意,从我不太严谨的测试来看,并不能明显地区分出他们的性能。另外,Kryo 关闭 Reference 可以加速,Protostuff 支持静态编译加速,Schema 缓存等特性,每个序列化方案都有自身的特殊性,启用这些特性会伴随一些限制。但在 RPC 实际地序列化使用中不会利用到这些特性,所以在测试时并没有特别关照它们。

序列化包含 1000 个简单对象的 List,查看字节数

字节数 /byte
Fastjson 120157
Kryo 39134
Hessian 86166
Protostuff 86084

字节数这个指标还是很直观的,Kryo 拥有绝对的优势,只有 Hessian,Protostuff 的一半,而 Fastjson 作为一个文本类型的序列化方案,自然无法和字节类型的序列化方案比较。而字节最终将用于网络传输,是 RPC 框架非常在意的一个性能点。

综合评价

经过个人测试,以及一些官方的测试结果,我觉得在 RPC 场景下,序列化的速度并不是一个很大考量标准,因为各个序列化方案都在有意优化速度,只要不是 jdk 序列化,速度就不会太慢。

Kryo:专为 JAVA 定制的序列化协议,序列化后字节数少,利于网络传输。但不支持跨语言(或支持的代价比较大)。dubbox 扩展中支持了 kryo 序列化协议。github 3018 star。

Hessian:支持跨语言,序列化后字节数适中,API 易用。是国内主流 rpc 框架:dubbo,motan 的默认序列化协议。hessian.caucho.com 未托管在 github

Protostuff:提起 Protostuff 不得不说到 Protobuf。Protobuf 可能更出名一些,因为其是 google 的亲儿子,grpc 框架便是使用 protobuf 作为序列化协议,虽然 protobuf 与语言无关平台无关,但需要使用特定的语法编写 .prpto 文件,然后静态编译,这带了一些复杂性。而 protostuff 实际是对 protobuf 的扩展,protostuff-runtime 模块继承了 protobuf 性能,且不需要预编译文件,但与此同时,也失去了跨语言的特性。所以 protostuff 的定位是一个 JAVA 序列化框架,其性能略优于 Hessian。tip :protostuff 反序列化时需用户自己初始化序列化后的对象,其只负责将该对象进行赋值。github 719 star。

Fastjson:作为一个 json 工具,被拉到 RPC 的序列化方案中似乎有点不妥,但 motan 该 RPC 框架除了支持 hessian 之外,还支持了 fastjson 的序列化。可以将其作为一个跨语言序列化的简易实现方案。github 11.8k star。

分享到

南京 IAS 架构师峰会观后感

上周六,周日在南京举办了 IAS 架构师峰会,这么多人的技术分享会还是头一次参加,大佬云集,涨了不少姿势。特此一篇记录下印象深刻的几场分享。由于全凭记忆叙述,故只能以流水账的形式还原出现场的收获。

大型支付交易平台的演进过程

大型支付交易平台的演进过程

陈斌,《架构即未来》译者,易宝支付 CTO。

交易系统具备以下特点,交易量大,并发度高,业务敏感度高,响应速度容忍度低… 从而使得支付交易平台需要有以下的特点:

  • 高可用:7X24*365 随时可用
  • 高安全:需满足 PCI-DSS 要求
  • 高效率:每笔交易的成本要低
  • 高扩展:随业务的快速发展扩张

从以上几点话题引申出了系统扩展的三个阶段

X 轴扩展 – 扩展机器

也就是通俗意义中集群方案,横向扩展,通过添加多台机器负载均衡,从而扩展计算能力,这是最简单粗暴,也是最直接易用的方案。

Y 轴扩展 – 拆分服务

当水平扩展遇到瓶颈后,可以进行服务的拆分,将系统按照业务模块进行拆分,从而可以选择性定制化地扩展特定的模块。如电商系统中拆分出订单模块,商品模块,会员模块,地址模块… 由于各个模块的职责不同,如订单模块在双 11 时压力很大,可以多部署一些订单模块,而其他压力不大的模块,则进行少量地部署。

Z 轴扩展 – 拆分数据

服务拆分之后仍然无法解决与日俱增的数据量问题,于是引发了第三层扩展,数据的分片,我理解的 sharding,不仅仅存在于数据库,还包含了 redis,文件等。

另外陈斌老师还聊了一个有意思的话题,系统可用性下降的原因根源是什么?最终他给出的答案是:人。系统升级后引发的事故 80% 是由于人的误操作或者触发了 bug 等人为因素导致的,是人就会手抖。借此引出了单元测试,持续集成,持续交付的重要性。健全这三者是保障系统可用性的最大利器。

在技术晚宴,陈斌老师又分享了一些管理经验: 如何打造一支优秀的技术团队

分析了构成团队的四要素:

  • 人员:健全职级体系,区别考评,挖掘潜能,及时鼓励,扁平化管理
  • 组织:面向产出,利于创新,敏捷小团队
  • 过程:聚集问题的根源,适当地使⽤用 ITIL,不断优化过程,自动化取代人工
  • 文化:鼓励分享,打破 devops 的边界,鼓励创新,树⽴立正确的技术负债观

对于技术人员来说可能有点抽象,不过对于立志于要成为 CIO 的人肯定是大有裨益的,具体的理解可以参考《架构即未来》中的具体阐释。(ps:这里的架构并不是指技术架构,别问我为什么知道,问问我看了一半后在落灰的那本书,你什么都明白了)

轻量级微服务架构实践之路

轻量级微服务架构实践之路

黄勇,特赞科技 CTO,《轻量级微服务架构》作者。

非常具有人格魅力的一位演讲者,这可能是当天最有价值的一场分享。

他首先提出了一个问题:什么是微服务?怎么理解这个 ‘微’ 字。随后他给出了自己的理解:微 = 合理。一知半解的微服务实践者可能盲目地拆分服务,微并不是代表颗粒越小越好,用领域驱动的术语来说,微服务模块需要用合适的限界上下文。黄勇老师给出了 4 个微服务拆分的技巧:

  1. 业务先行
  2. 由粗到细
  3. 避免耦合
  4. 持续改进

非常实用且具有指导意义的 4 个思想,当你还在犹豫到底该如何拆分你的模块时,可以尝试先从单体式开始开发,业务发展会指引你拆分出合适模块,合适的粒度。当一个个业务被剥离出 Monolithic 这个怪物,持续重构,持续改进,这样可以指引你深入理解微服务。

随后给出了轻量级微服务架构的技术选型,非常有参考价值。

轻量级微服务架构

其 PPT 总结了很多经验 list,可以在文末链接获取。

顺带一提,没记错的话黄勇老师介绍到其公司的语言栈有:Java,Node,Go,在后面其他老师的分享中集中介绍多语言栈的意义。

《轻量级微服务架构》上下册一起购买,赠送“技能图谱”,感兴趣的朋友可以阅读一下他的书籍。购买链接 ps: 谁让我白得了一本上册呢。

Cloud Native 架构一致性问题及解决方案

Cloud Native 架构一致性问题及解决方案

王启军,华为架构部资深架构师。

王启军老师则是带来了如今微服务架构最难的一个技术点的分享:分布式中的一致性问题。

他的分享中涵盖了很多经典的分布式一致性问题的案例,如两军问题,拜占庭将军问题。引出了经典的 CAP 理论,NWR,Lease,Replicated state machine,Paxos 算法。由于时间问题,45 分钟根本无法详细地介绍他们的流程,实属可惜。

一致性问题被分成了两类,包括:

以数据为中心的一致性模型

  • 严格一致性
  • 顺序一致性
  • 因果一致性
  • FIFO 一致性
  • 弱一致性
  • 释放一致性
  • 入口一致性

以用户为中心的一致性模型

  • 单调读一致性
  • 单调写一致性
  • 写后读一致性
  • 读后写一致性

这么多一致性分类太过于学术范,所以业界通常将他们简单的归为了三类:

  • 弱一致性
  • 最终一致性
  • 强一致性

对于各个一致性模型的科普,以及一些事务模型和解决方案如 2PC,3PC,TCC 型事务,PPT 中都给出了简单的介绍。

技术架构演变全景图 - 从单体式到云原生

技术架构演变全景图 - 从单体式到云原生

千米网首席架构师,曹祖鹏(右) & 当当网首席架构师,张亮(左)。知名开源框架 sharding-jdbc,elastic-job 作者。

别开生面的面向对象技术分享。也是我本次大会最期待的一场分享,分享涵盖的知识点很多,深度和广度得兼,其分享中阐释了云原生,服务编排、治理、调度等 2017 年处于潮流前线的技术热点,通俗易懂地介绍了 service mesh 的概念,让观众在惊叹于互联网技术变化如此之快的同时,也带来了很多思考。

分享中还对比了 Spring Cloud 和 Dubbo,当当网和千米网的团队都向 Dubbo 贡献过代码,Spring Cloud 又是国内话题最多的框架之一,台下观众对这样的话题自然是非常感兴趣。张亮老师着重介绍了 Spring Cloud 相关的组件,而曹祖鹏老师重点对比了其与 Dubbo 的区别。

Spring Cloud 的出现同时宣告了 Cloud Native 云原生的首映,其为微服务的构架带来了一整套初具雏形的解决方案,包含了 Zuul 网关,Ribbon 客户端负载均衡,Eureka 服务注册与发现,Hystrix 熔断… 并且有强大的 Spring 终端组件支持,活跃的社区,丰富的文档。

随后,介绍了云原生的技术全景图:

技术全景图

之后,简单解释了治理,编排,调度的概念后,并重点介绍了服务治理,编排相关的技术栈,老牌的 nginx,netflix ribbon,zuul 等产品,如今风靡的 k8s。尤其是介绍到 service mesh 这一比较新的概念时,分析了服务的治理,编排,调度从应用层转移到基础设施层的趋势,无疑是非常 exciting 的一件事。如 dubbo 等 rpc 框架的服务注册发现依赖于 zk,consul,而 spring cloud 的服务注册发现组件 eureka,以及其客户端路由组件 ribbon,服务端路由组件 zuul 等都是从应用层解决了服务的相关问题,而 service mesh 提供了一个新的思路,从基础设施层解决服务的相关问题:

service mesh

如果 service mesh 的开源产品 Linkerd 和 Lstio 能够保持好的势头,配合 k8s 在运维层的大一统,很有可能带来架构的新格局。与此同时,java 一枝独秀的时代即将宣告终结,多语言的优势将会被 service mesh 发扬光大,使用 go 编写高并发的模块,使用 java 编写业务型模块,nodejs 打通前端模块,python 处理性能要求不高模块提升开发效率… 而不用关心多语言交互的问题,这都交由 service mesh 解决,这几乎是 2017 最潮流的知识点,没有之一。

(引用一张 jimmysong 博客中的图片)

云原生演进

如上图所示,得知 Spring Cloud 竟然是 2015 兴起的技术栈时,可能还会有些吃惊,等到可以预见的 2018,运维层的技术栈开始向上侵蚀应用层的技术栈,不得不感叹互联网技术的日新月异。

两位老师从可追溯的历史到可预见的未来展现了云原生架构的演进史,着实给小白们好好科普了一番。

番外

此次技术分享会收获颇丰,不枉我早上 5 点起来赶高铁去南京了。但还是得吐槽一句,这门票真 tl 的贵啊,就不能便宜点吗!!![微笑 face]

其他分享者的话题也很有意思,不仅包含了微服务方向,还囊括了人工智能,机器学习,运维,领导力,架构演变,游戏架构等多个方向,笔者选择性的介绍了一些,全部的 PPT 可以在下方的链接中获得。

https://pan.baidu.com/s/1eSbCu5c

分享到