徐靖峰|个人博客 2019-09-26T09:45:31.281Z http://lexburner.github.io/ 徐靖峰 Hexo 使用 JMeter 进行 Dubbo 性能测试 http://lexburner.github.io/dubbo-perf-benchmark/ 2019-09-05T11:45:52.000Z 2019-09-26T09:45:31.281Z 1 前言

说道性能测试工具,你会立刻联想到哪一个?ab(ApacheBench)、JMeter、LoadRunner、wrk…可以说市面上的压测工具实在是五花八门。那如果再问一句,对 Dubbo 进行性能压测,你会 pick 哪一个?可能大多数人就懵逼了。可以发现,大多数的压测工具对开放的协议支持地比较好,例如:HTTP 协议,但对于 Dubbo 框架的私有协议:dubbo,它们都显得力不从心了。

如果不从通用的压测工具上解决 Dubbo 的压测需求问题,可以自己写 Dubbo 客户端,自己统计汇总结果,但总归不够优雅,再加上很多开发同学没有丰富的测试经验,很容易出现一些偏差。说到底,还是压测工具靠谱,于是便引出了本文的主角 —— jmeter-plugins-for-apache-dubbo。这是一款由 Dubbo 社区 Commiter – 凝雨 同学开发的 JMeter 插件,可以非常轻松地对 Dubbo 实现性能测试。

2 JMeter 介绍

在开始压测 Dubbo 之前,先简单介绍一下这款开源的性能测试工具 —— JMeter。JMeter 是 Apache 组织基于 Java 开发的一款性能测试工具。它最初被设计用于 Web 应用测试,但后来扩展到其他测试领域,并可以在 Windows、Mac、Linux 环境下安装使用。JMeter 还提供了图形界面,这使得编写测试用例变得非常简单,具有易学和易操作的特点。

JMeter 官网:http://jmeter.apache.org/download_jmeter.cgi

2.1 安装 JMeter

截止本文发布,官方的最新版本为:apache-jmeter-5.1.1.zip , 下载后直接解压即可。

jmeter 目录

在 ${JMETER_HOME}/bin 下找到启动脚本,可以打开图形化界面

  • Mac/Linux 用户可以直接使用 jmeter 可执行文件,或者 jmeter.sh 启动脚本
  • Windows 用户可以使用 jmeter.bat 启动脚本

2.2 命令行提示信息

启动过程中会有一段命令行日志输出:

1
2
3
4
5
6
7
8
================================================================================
Don't use GUI mode for load testing !, only for Test creation and Test debugging.
For load testing, use CLI Mode (was NON GUI):
jmeter -n -t [jmx file] -l [results file] -e -o [Path to web report folder]
& increase Java Heap to meet your test requirements:
Modify current env variable HEAP="-Xms1g -Xmx1g -XX:MaxMetaspaceSize=256m" in the jmeter batch file
Check : https://jmeter.apache.org/usermanual/best-practices.html
================================================================================

注意到第一行的提示,GUI 仅仅能够用于调试和创建测试计划,实际的性能测试需要使用命令行工具进行。

jmeter -n -t [jmx file] -l [results file] -e -o [Path to web report folder]

  • 【jmx file】:使用 GUI 创建的测试计划文件,后缀名为 .jmx
  • 【results file】:测试结果文本文件输出路径
  • 【Path to web report folder】:测试报告输出路径,JMeter 的强大之处,可以生成图文并茂的测试报告

2.3 GUI 界面展示

image-20190905211412101

上图所示为 JMeter 的主界面。官方提供了国际化支持,通过 【Options】->【Choose Language】可以将界面语言变更为简体中文。

3 JMeter 压测 HTTP

本节以 JMeter 压测 HTTP 为引子,介绍 JMeter 的使用方式,让没有使用过 JMeter 的读者对这款工具有一个较为直观的感受。

3.1 创建线程组

在“测试计划”上右键 【添加】–>【线程(用户)】–>【线程组】。

image-20190905211637435

给线程组起一个名字,方便记忆。

image-20190905211831670

  • 线程数:决定了由多少线程并发压测
  • Ramp-Up:代表了 JMeter 创建所有线程所需要的时间,如图所示则代表每 0.1s 创建一个线程
  • 循环次数:在运行所设置的次数之后,压测将会终止。如果想要运行固定时长的压测,可以设置为:永远,并在下面的调度器中指定持续时间

3.2 增加 HTTP 取样器

在刚刚创建的线程组上右键 【添加】–>【取样器】–>【HTTP 请求】。

image-20190905211606505

为 HTTP 取样器配置上压测地址和必要的参数

image-20190905212937824

3.3 添加察看结果树

在刚刚创建的线程组上右键 【添加】–>【监听器】–>【察看结果树】。

image-20190905213114409

只有添加了【察看结果树】才能让我们看到 GUI 中测试的结果。

3.4 准备 HTTP Server

使用 SpringBoot 可以快速构建一个 RestController,其暴露了 localhost:8080/queryOrder/{orderNo} 做为压测入口

1
2
3
4
5
6
7
8
9
10
11
12
@RestController
public class OrderRestController {

@Autowired
OrderService orderService;

@RequestMapping("/queryOrder/{orderNo}")
public OrderDTO queryOrder(@PathVariable("orderNo") long orderNo) {
return orderService.queryOrder(orderNo);
}

}

被压测的服务 OrderService :

1
2
3
4
5
6
7
8
9
10
11
@Component
public class OrderService {

public OrderDTO queryOrder(long orderNo) {
OrderDTO orderDTO = new OrderDTO();
orderDTO.setOrderNo(orderNo);
orderDTO.setTotalPrice(new BigDecimal(ThreadLocalRandom.current().nextDouble(100.0D)));
orderDTO.setBody(new byte[1000]);
return orderDTO;
}
}

3.5 验证结果

在刚刚创建的线程组上右键 【验证】,执行单次验证,可以用来测试与服务端的连通性。在【察看结果树】选项卡中可以看到【响应数据】已经正常返回了。

image-20190905214317033

3.6 执行测试计划

还记得之前启动 GUI 时控制台曾经提示过我们,GUI 只负责创建测试计划并验证,不能用于执行实际的并发压测。在 GUI 中准备就绪之后,我们可以在【文件】->【保存测试计划为】中将测试计划另存为 rest-order-thread-group.jmx 测试文件,以便我们在命令行进行压测:

1
jmeter -n -t ./rest-order-thread-group.jmx -l ./result.txt -e -o ./webreport

下图展示了最终生成的测试报告,主要汇总了执行次数、响应时间、吞吐量、网络传输速率。

image-20190905215339406

在实际的测试报告中,还有更加详细的维度可以展示,上述只是展示了汇总信息。

4 JMeter 压测 Dubbo

JMeter 默认并不支持私有的 dubbo 协议,但其优秀的扩展机制使得只需要添加插件,就可以完成 Dubbo 压测,这一节也是本文重点介绍的部分。

4.1 安装 jmeter-plugins-for-apache-dubbo

插件地址:https://github.com/thubbo/jmeter-plugins-for-apache-dubbo

目前该插件支持对最新版本的 Dubbo 进行压测,推荐的安装方式:

  1. 克隆项目:git clone https://github.com/thubbo/jmeter-plugins-for-apache-dubbo.git

  2. 打包项目,构建 JMeter 插件:mvn clean install ,得到:jmeter-plugins-dubbo-2.7.3-jar-with-dependencies.jar

  3. 将插件添加到 ${JMETER_HOME}\lib\ext

安装插件后的 ext 目录

4.2 增加 Dubbo 取样器

之前的小结已经介绍了如何添加线程组和 HTTP 取样器,现在想要对 Dubbo 应用进行性能测试,可以直接复用之前的线程组配置,在线程组上右键 【添加】–>【取样器】–>【Dubbo Sample】。

image-20190906141506679

创建 Dubbo 取样器之后,可以对其进行配置

image-20190906143444779

4.3 准备 Dubbo Provider

复用 HTTP 取样器时的 OrderService

1
2
3
4
5
6
7
8
9
10
11
@Service
public class OrderDubboProvider implements OrderApi {

@Autowired
OrderService orderService;

@Override
public OrderDTO queryOrder(long orderNo) {
return orderService.queryOrder(orderNo);
}
}

配置 application.properties,注册服务到 Zookeeper 注册中心:

1
2
3
4
dubbo.scan.basePackages=com.alibaba.edas.benchmark
dubbo.application.name=dubbo-provider-demo
dubbo.registry.address=zookeeper://127.0.0.1:2181
dubbo.protocol.port=20880

4.4 验证结果

在 JMeter 中配置好 Dubbo 服务所连接的注册中心,接着通过 Get Provider List 可以获取到服务提供者列表,以供压测选择。在线程组上右键 【验证】,执行单次验证,可以用来测试与服务端的连通性。在【察看结果树】选项卡中可以看到【响应数据】可以正常执行 Dubbo 调用了。

image-20190906143425928

4.5 执行测试计划

可以将 Dubbo 取样器和 HTTP 取样器包含在同一个测试计划中一起执行,同时进行了 Dubbo 接口与 Rest 接口的性能对比。在命令行进行压测:

1
jmeter -n -t ./rest-order-thread-group.jmx -l ./result.txt -e -o ./webreport

下图展示了最终生成的测试报告:

image-20190906144422407

Dubbo 接口与 Rest 接口所封装的业务接口均为 OrderService,所以压测上的差距直接体现出了 Dubbo 和 Rest 的差距。从报告对比上来看,Dubbo 接口的平均 RT 远低于 Rest 接口。

5 总结

本文从零到一介绍了使用 JMeter 压测 HTTP 的方法,让读者熟悉 JMeter 的使用方式,并着重介绍了使用 jmeter-plugins-for-apache-dubbo 插件压测 Dubbo 的方法。

由于 JMeter Plugin 的限制,目前 Dubbo 的压测请求是通过泛化调用进行发送的,会有一定程度的性能下降,所以在实际评估 Dubbo 接口性能时,接口实际性能会比压测结果更加乐观。

]]>
<h2 id="1-前言"><a href="#1-前言" class="headerlink" title="1 前言"></a>1 前言</h2><p>说道性能测试工具,你会立刻联想到哪一个?ab(ApacheBench)、JMeter、LoadRunner、wrk…可以说市面上的压测工具实在是五花八门。那如果再问一句,对 Dubbo 进行性能压测,你会 pick 哪一个?可能大多数人就懵逼了。可以发现,大多数的压测工具对开放的协议支持地比较好,例如:HTTP 协议,但对于 Dubbo 框架的私有协议:<code>dubbo</code>,它们都显得力不从心了。</p> <p>如果不从通用的压测工具上解决 Dubbo 的压测需求问题,可以自己写 Dubbo 客户端,自己统计汇总结果,但总归不够优雅,再加上很多开发同学没有丰富的测试经验,很容易出现一些偏差。说到底,还是压测工具靠谱,于是便引出了本文的主角 —— <strong><a href="https://github.com/thubbo/jmeter-plugins-for-apache-dubbo" target="_blank" rel="noopener">jmeter-plugins-for-apache-dubbo</a></strong>。这是一款由 Dubbo 社区 Commiter – <a href="https://ningyu1.github.io/blog/about/" target="_blank" rel="noopener">凝雨</a> 同学开发的 JMeter 插件,可以非常轻松地对 Dubbo 实现性能测试。</p>
华为云 TaurusDB 性能挑战赛赛题总结 http://lexburner.github.io/taurusdb-race/ 2019-09-02T12:19:23.000Z 2019-09-26T09:45:29.481Z 1 前言

image-20190902204538276

回顾第一次参加性能挑战赛 – 第四届阿里中间件性能挑战赛,那时候真的是什么都不会,只有一腔热情,借着比赛学会了 Netty、学会了文件 IO 的最佳实践,到了这次华为云举办的 TaurusDB 性能挑战赛,已经是第三次参加比赛了,同时也是最“坎坷”的一次比赛。经过我和某位不愿意透露姓名的 96 年小迷妹的不懈努力,最终跑分排名为第 3 名。

如果要挑选一个词来概括这次比赛的核心内容,那非”计算存储分离“莫属了,通过这次比赛,自己也对计算存储分离架构有了比较直观的感受。为了比较直观的体现计算存储分离的优势,以看电影来举个例子:若干年前,我总是常备一块大容量的硬盘存储小电影,但自从家里带宽升级到 100mpbs 之后,我从来不保存电影了,要看直接下载 / 缓冲,基本几分钟就好了。这在几年前还不可想象,如今是触手可及的事实,归根到底是随着互联网的发展,网络 IO 已经不再是瓶颈了。

计算存储分离架构相比传统本地存储架构而言,具有更加灵活、成本更低等特性,但架构的复杂性也会更高,也会更加考验选手的综合能力。

计算存储分离架构的含义:

  • 存储端有状态,只存储数据,不处理业务逻辑。
  • 计算端无状态,只处理逻辑,不持久化存储数据。

2 赛题概览

比赛整体分成了初赛和复赛两个部分,初赛要求实现一个简化、高效的本地 kv 存储引擎,复赛在初赛的基础上增加了计算存储分离的架构,计算节点需要通过网络传输将数据递交给存储节点存储。

1
2
3
4
5
6
7
8
public interface KVStoreRace {

public boolean init(final String dir, final int thread_num) throws KVSException;

public long set(final String key, final byte[] value) throws KVSException;

public long get(final String key, final Ref<byte[]> val) throws KVSException;
}

计算节点和存储节点共用上述的接口,评测程序分为 2 个阶段:

正确性评测

此阶段评测程序会并发写入随机数据(key 8B、value 4KB),写入数据过程中进行任意次进程意外退出测试,引擎需要保证异常中止不影响已经写入的数据正确性。
异常中止后,重启引擎,验证已经写入数据正确性和完整性,并继续写入数据,重复此过程直至数据写入完毕。
只有通过此阶段测试才会进入下一阶段测试。

性能评测

随机写入:16 个线程并发随机写入,每个线程使用 Set 各写 400 万次随机数据(key 8B、value 4KB)
顺序读取:16 个线程并发按照写入顺序逐一读取,每个线程各使用 Get 读取 400 万次随机数据
热点读取:16 个线程并发读取,每个线程按照写入顺序热点分区,随机读取 400 万次数据,读取范围覆盖全部写入数据。热点的逻辑为:按照数据的写入顺序按 10MB 数据粒度分区,分区逆序推进,在每个 10MB 数据分区内随机读取。随机读取次数会增加约 10%。

语言限定

CPP & Java,一起排名

3 赛题剖析

看过我之前《PolarDB 数据库性能大赛 Java 选手分享》的朋友应该对题目不会感到陌生,基本可以看做是在 PolarDB 数据库性能挑战赛上增加一个网络通信的部分,所以重头戏基本是在复赛网络通信的比拼上。初赛主要是文件 IO 和存储架构的设计,如果对文件 IO 常识不太了解,可以先行阅读 《文件 IO 操作的一些最佳实践》

image-20190902214231821

3.1 架构设计

计算节点只负责生成数据,在实际生产中计算节点还承担额外的计算开销,由于计算节点是无状态的,所以不能够聚合数据写入、落盘等操作,但可以在 Get 触发网络 IO 时一次读取大块数据用作缓存,减少网络 IO 次数。

存储节点负责存储数据,考验了选手对磁盘 IO 和缓存的设计,可以一次使用缓存写入 / 读取大块数据,减少磁盘 IO 次数。

所以选手们将会围绕网络 IO、磁盘 IO 和缓存设计来设计整体架构。

3.2 正确性检测

赛题明确表示会进行 kill -9 并验证数据的一致性,正确性检测主要影响的是写入阶段。

存储节点负责存储数据,需要保证 kill -9 不丢失数据,但并不要求断电不丢失,这间接地阐释了一点:我们可以使用 PageCache 来做写入缓存;正确性检测对于计算节点与存储节点之间通信影响便是:每次写入操作都必须 ack,所以选手必须保证同步通信,类似于 ping/pong 模型。

3.3 性能评测

性能评测由随机写、顺序读、热点读(随机读取热点数据)三部分构成。

随机写阶段与 PolarDB 的评测不同,TaurusDB 随机写入 key 的 16 个线程是隔离的,即 A 线程写入的数据只会由 A 线程读出,可以认为是彼此独立的 16 个实例在执行评测,这大大简化了我们的架构。

顺序读阶段的描述也很容易理解,需要注意的是这里的顺序是按照写入顺序,而不是 Key 的字典序,所以随机写可以转化为顺序写,也方便了选手去设计顺序读的架构。

热点读阶段有点故弄玄虚了,其实就是按照 10M 数据为一个分区进行逆序读,同时在 10M 数据范围内掺杂一些随机读,由于操作系统的预读机制只会顺序预读,无法逆序预读,PageCache 将会在这个环节会失效,考验了选手自己设计磁盘 IO 缓存的能力。

4 架构详解

4.1 全局架构

image-20190903130239656

计算存储分离架构自然会分成计算节点和存储节点两部分来介绍。计算节点会在内存维护数据的索引表;存储节点负责存储持久化数据,包括索引文件和数据文件;计算节点与存储节点之间的读写都会经过网络 IO。

4.2 随机写架构

image-20190903134509621

随机写阶段,评测程序调用计算节点的 set 接口,发起网络 IO,存储节点接受到数据后不会立刻落盘,针对 data 和 index 的处理也会不同。针对 data 部分,会使用一块缓冲区(如图:Mmap Merge IO)承接数据,由于 Mmap 的特性,会形成 Merge File 文件,一个数据缓冲区可以聚合 16 个数据,当缓冲区满后,将缓冲区的数据追加到数据文件后,并清空 Merge File;针对 index 部分,使用 Mmap 直接追加到索引文件中。

F: 1. data 部分为什么搞这么复杂,需要聚合 16 个数据再刷盘?

Q: 针对此次比赛的数据盘,实测下来 16 个数据刷盘可以打满 IO。

F: 2. 为什么使用 Mmap Merge IO 而不直接使用内存 Merge IO?

Q: 正确性检测阶段,存储节点可能会被随机 kill,Mmap 做缓存的好处是操作系统会帮我们落盘,不会丢失数据

F: 3. 为什么 index 部分直接使用 Mmap,而不和 data 部分一样处理?

Q: 这需要追溯到 Mmap 的特点,Mmap 适合直接写索引这种小数据,所以不需要聚合。

4.3 热点读 & 顺序读架构

image-20190903134612617

热点读取阶段 & 顺序读取阶段 ,这两个阶段其实可以认为是一种策略,只不过一个正序,一个逆序,这里以热点读为例介绍。我们采取了贪心的思想,一次读取操作本应该只会返回 4kb 的数据,但为了做预读缓存,我们决定会存储节点返回 10M 的数据,并缓存在计算节点中,模拟了一个操作系统预读的机制,同时为了能够让计算节点精确知道缓存是否命中,会同时返回索引数据,并在计算节点的内存中维护索引表,这样便减少了成吨的网络 IO 次数。

4.4 存储设计

image-20190903133433218

站在每个线程的视角,可以发现在我们的架构中,每个线程都是独立的。评测程序会对每个线程写入 400w 数据,最终形成 16 16G 的数据文件和 16 32M 左右的索引文件。

数据文件不停追加 MergeFile,相当于一次落盘单位是 64K(16 个数据),由于自行聚合了数据,所以可以采用 Direct IO,减少操作系统的 overhead。

索引文件由小数据构成,所以采用 Mmap 方式直接追加写

计算节点由于无状态的特性,只能在内存中维护索引结构。

4.5 网络通信设计

image-20190903193128706

我们都知道 Java 中有 BIO(阻塞 IO)和 NIO(非阻塞 IO)之分,并且大多数人可能会下意识觉得:NIO 就是比 BIO 快。而这次比赛恰恰是要告诉大家,这两种 IO 方式没有绝对的快慢之分,只有在合适的场景中选择合适的 IO 方式才能发挥出最佳性能。

稍微分析下这次比赛的通信模型,写入阶段由于需要保证每次 set 不受 kill 的影响,所以需要等到同步返回后才能进行下一次 set,而 get 本身依赖于返回值进行数据校验,所以从通信模型上看只能是同步 ping/pong 模型;从线程数上来看,只有固定的 16 个线程进行收发消息。以上两个因素暗示了 BIO 将会非常契合这次比赛。

在很多人的刻板印象中,阻塞就意味着慢,非阻塞就意味着快,这种理解是完全错误的,快慢取决于通信模型、系统架构、带宽、网卡等因素。我测试了 NIO + CountDownLatch 和 BIO 的差距,前者会比后者整体慢 100s ~ 130s。

5 细节优化点

5.1 最大化磁盘吞吐量

但凡是涉及到磁盘 IO 的比赛,首先需要测试便是在 Direct IO 下,一次读写多大的块能够打满 IO,在此基础上,才能进行写入缓冲设计和读取缓存设计,否则在这种争分夺秒的性能挑战赛中不可能取得较好的名次。测试方法也很简单,如果能够买到对应的机器,直接使用 iostat 观察不同刷盘大小下的 iops 即可,如果比赛没有机器,只能祭出调参大法,不停提交了,这次 TaurusDB 的盘实测下来 64k、128K 都可以获得最大的吞吐量。

5.2 批量回传数据

计算节点设计缓存是一个比较容易想到的优化点,按照常规的思路,索引应该是维护在存储节点,但这样做的话,计算节点在 get 数据时就无法判断是否命中缓存,所以在前文的架构介绍中,我们将索引维护在了计算节点之上,在第一次 get 时,顺便恢复索引。批量返回数据的优势在于增加了缓存命中率、降低总网络 IO 次数、减少上行网络 IO 数据量,是整个比赛中分量较重的一个优化点。

5.3 流控

image-20190903201156406

在比赛中容易出现的一个问题,在批量返回 10M 数据时经常会出现网络卡死的情况,一时间无法定位到问题,以为是代码 BUG,但有时候又能跑出分数,不得以尝试过一次返回较少的数据量,就不会报错。最后还是机智的小迷妹定位到问题是 CPU 和 IO 速率不均等导致的,解决方案便是在一次 pong 共计返回 10M 的基础上,将报文拆分成 64k 的小块,中间插入额外的 CPU 操作,最终保证了程序稳定性的同时,也保障了最佳性能。

额外的 CPU 操作例如:for(int i=0;i<700;i++),不要小看这个微不足道的一个 for 循环哦。

流控其实也是计算存储分离架构一个常见设计点,存储节点与计算节点的写入速度需要做一个平衡,避免直接打垮存储节点,也有一种”滑动窗口“机制专门应对这种问题,不在此赘述了。

5.4 预分配文件

在 Cpp 中可以使用 fallocate 预先分配好文件大小,会使得写入速度提升 2s。在 Java 中没有 fallocate 机制,但是可以利用评测程序的漏洞,在 static 块中事先写好 16 * 16G 的文件,同样可以获得 fallocate 的效果。

5.5 合理设计索引结构

get 时需要根据 key 查询到文件偏移量,这显示是一个 Map 结构,在这个 Map 上也有几个点需要注意。以 Java 为例,使用 HashMap 是否可行呢?当然可以,但是缺点也很明显,其会占用比较大的内存,而且存取性能不好,可以使用 LongIntHashMap 来代替,看过我之前文章的朋友应该不会对这个数据结构感到陌生,它是专门为基础数据类型设计的 Map 容器。

每个线程 400w 数据,每个线程独享一个索引 Map,为了避免出现扩容,需要合理的设置扩容引子和初始化容量:new LongIntHashMap(410_0000, 0.99);

5.6 Direct IO

最终进入决赛的,有三支 Java 队伍,相比较 Cpp 得天独厚的对操作系统的灵活控制性,Java 选手更像是带着镣铐在舞蹈,幸好有了上次 PolarDB 比赛的经验,我提前封装好了 Java 的 Direct IO 类库:https://github.com/lexburner/kdio,相比 FileChannel,它能够使得磁盘 IO 效率更高。得知有 Java 选手真的在比赛中使用了我的 Direct IO 类库,也是比赛中实实切切的乐趣之一。

6 失败的优化点

6.1 预读线程先行

考虑到网络 IO 还是比本地磁盘 IO 要慢的,一个本以为可行的方案是单独使用预读线程进行存储节点的磁盘 IO,设计一个 RingBuffer,不断往前预读,直到环满,计算阶段 get 时会消费 RingBuffer 的一格缓存,从而使得网络 IO 和磁盘 IO 不会相互等待。实际测试下来,发现瓶颈主要还是在于网络 IO,这样的优化徒增了不少代码,不利于进行其他的优化尝试,最终放弃。

6.2 计算节点聚合写入缓冲

既然在 get 阶段时存储节点批量返回数据给计算节点可以提升性能,那 set 阶段聚合批量的数据再发送给存储节点按理来说也能提升性能吧?的确如此,如果不考虑正确性检测,这的确是一个不错的优化点,但由于 kill 的特性使得我们不得不每一次 set 都进行 ACK。但是!可以对将 4/8/16 个线程编为一组进行聚合呀!通过调整参数来确定该方案是否可行。

image-20190903215024344

然后事与愿违,该方案并没有取得成效。

7 聊聊比赛吧

之前此类工程性质的性能挑战赛只有阿里一家互联网公司承办过,作为热衷于中间件性能优化的参赛选手而言,非常高兴华为也能够举办这样性质的比赛。虽然比赛中出现了诸多的幺蛾子,但毕竟是第一次承办比赛,我也就不表了。

如果你同样也是性能挑战赛的爱好者,想要在下一次中间件性能挑战赛中有一群小伙伴一起解题、组队,体验冲分的乐趣,欢迎关注我的微信公众号:【Kirito 的技术分享】,也欢迎加入微信技术交流群进行交流 ~

]]>
<h2 id="1-前言"><a href="#1-前言" class="headerlink" title="1 前言"></a>1 前言</h2><p><img src="http://kirito.iocoder.cn/image-20190902204538276.png" alt="image-20190902204538276"></p> <p>回顾第一次参加性能挑战赛 – 第四届阿里中间件性能挑战赛,那时候真的是什么都不会,只有一腔热情,借着比赛学会了 Netty、学会了文件 IO 的最佳实践,到了这次华为云举办的 TaurusDB 性能挑战赛,已经是第三次参加比赛了,同时也是最“坎坷”的一次比赛。经过我和某位不愿意透露姓名的 96 年小迷妹的不懈努力,最终跑分排名为第 3 名。</p>
日本东京游记 || 内含秋叶原、动漫打卡地攻略 http://lexburner.github.io/tokyo-travel/ 2019-09-02T12:19:23.000Z 2019-09-26T09:45:31.509Z 由于表弟是个狂热的二次元爱好者,受我小姨之托,带他去日本游玩了一趟。趁着这个机会,打算给大家分享一下日本旅游的一些攻略,以祭奠我逝去的年假。这是我第二次去日本了,上一次还是大二时跟我初中舍友一起去的,所以这次去已经有了一些经验了,很多朋友表示想去日本,期待我能写一篇攻略,所以这篇攻略将会偏小白向,如果你是第一次去日本,那这篇攻略想必不会让你失望。

出行准备

护照 / 签证

出国旅游前需要准备两样最基础的东西:护照和签证。

护照 。申请护照一般是由本人到户籍所在地的公安局出入境管理处办理,办理时长一般在 15 个工作日左右。因为我 4 年前去过日本,那时候已经办理了护照,一般护照有效期是 10 年左右,所以这次只需要办理签证就行了。

护照œ

签证 。日本现在有 单次旅游 /3 年多次往返 /5 年多次往返 三种签证。一般来讲,个人旅游办理的都是单次旅游,条件限制比较低,多次往返签证则相对要求较高。因为领区的不同,具体地区送签材料要求大家可以自己再去咨询。普通送签的话机票酒店信息,银行流水,在职在读等等都需要准备,需要的材料很多,选择跟团的话就可以省去很多麻烦事,旅行社办理签证可以节省下不少准备材料。

这次跟我表弟去日本,由于我在杭州工作,江浙沪属于上海领区,而他在广州,属于广州领区,所有即使是跟团游,我也只能找到旅行社单独办理个签。另外有一点值得注意的是,日本签证要求提供的个人照片尺寸为 4.5cm x 4.5cm,不太符合常规的中国制式,一般各个国家的签证照片都有各自的规定,准备时可以稍微留意下。

行程安排

关于行程安排,每个人自然有自己的想法,东京、北海道、大阪、京都都有各自吸引游客的景点。

有的人选择跟团,也有的人选择自由行,也有跟团和自由行的组合半自由行。我们这次选择的就是半自由行,旅行社会负责包揽机票、住宿和固定景点等事宜,也会有 2 天时间供我们在东京自由行,没有自由活动的日本之行是没有灵魂的!

赴日旅游的游客,我个人还是挺推荐这种 半自由行 的。旅行社会有包车穿梭于各个景点之间,酒店机票等事宜也不用自己操心,可以为前期攻略省下不少时间。提到自由行,虽然我不会日语,但是日本的中国人挺多的,并且所看到的大部分汉字基本都能看懂,服务中心一般用英语也可以沟通,自由行的时候也不用太过于担心。

现金置换

支付宝支付

这次去日本算是体会到了日本的进步,在上次去日本时,主要还是通过现金、银联卡两种方式支付,而 2019 年的今天再去东京,大到商场百货,小到街边的拉面店,都已经支持了支付宝、微信这两种中国本土的快捷支付方式。

虽然刷卡、快捷支付很方便,但仍然还是有不少地方只支持现金的,例如日本的电车,所以出行前建议置换好足量的现金,我这次准备了 4000 人民币合 6w 日元的现金。价值较高的商品可以选择使用银联卡或者快捷支付这两种方式。

在国内置换日元。可以选择在银行柜台置换,也可以选择在机场置换。选择在机场置换大概率比银行置换要亏,需要交纳 50 块的手续费,而且汇率也不同,具体没有太多研究,但推荐大家在银行置换好日元。

在日本置换日元。在日本街头的 ATM 使用银联卡就可以取出日元了,当然也可以在到达机场兑换,另外值得一提的是日本的 711 和全家等便利连锁店都设有 ATM,秋叶原等外国游客较多的地方也设有外汇置换点,但是价格较贵。

小 tips:¥是人民币(CNY)和日元(JPY)的货币符号。这两种货币的单位都是元,在日本可不要被高额的标价吓到哦,那是日元,不是人民币。

流量 & 网络

在日本,没有开通国际漫游的用户是没法使用手机的,建议赴日旅游前一定要提前准备好随身 wifi 或者开通国际流量包,两者价格都很实惠,推荐后者,毕竟随身 wifi 占据了一定的空间还需要一天充一次电。

由于我的手机卡不支持国际流量包,所以在淘宝提前租好了随身 wifi,一天只需要 9~13 块,网速还可以。如果想要租借随身 wifi,一定要记得在出发前提前 2 天下单,一般店铺都支持在机场自行取货。

小 tips:随身 wifi 不能托运。

APP 推荐

谷歌地图

goog-618x338

自由行期间必备的 APP,基本走到哪儿都靠他了。可能会有朋友会问,为什么不推荐百度地图?实际上两个地图我这次都用到了,但总体感受是谷歌地图体验更好。

换乘案内

换成案内

换乘案内 是在日本查询坐车信息的一个软件,功能跟谷歌相似,但比谷歌更详细,也更加准确,但是没有导航功能。在这个软件输入始发站以及到达站就会出来所有的乘坐信息,价格,速度,换乘。

因为日本的公共交通系统极为复杂,光东京一个地方就有 JR/ 都营 /Metro/ 京急 / 小田急等,类似于国内的 1 号线、2 号线,不同的线路分属于不同的铁道公司。例如 JR(Japan Railway) 线,就是其中最大规模的铁道路线。也是游客利用最多的线路之一。

在日本坐车需要注意以下几点:

  • 不同入口可能对应不同的线路,所以不要以为很近的两个入口都可以到达目的地,很有可能是不同线路的入口。
  • 同一个站台,可能会有去往 不同方向 的电车,要注意看站台提示牌上显示的下一辆车的目的地。
  • 同一个站台,同一个线路,会有 特急 急行 快速 准急 普通 等种类的电车,例如特急电车在很多站台就不会停靠,所有得严格按照软件的提示来乘坐。

大众点评

大众点评 这款软件即使在日本也可以用于搜索附近的美食、游玩地点!在成田车站附近时,我发现有比较多的拉面店、居酒屋,实在不知道该如何选择,就是通过大众点评来查看的评价和价钱,再做判断,比较实用。

极简汇率

汇率换算软件。大多数情况下,记住汇率可以大致算出对应的人民币价钱,但在帮朋友代购或者比价时,就得精准地按汇率计算了,这款小巧的软件可以解决这个问题。

小 tips:日本商品会直接包含 8% 的税率,有一部分商场面向外国游客,标记的是退税之后的价格,注意区分。

百度翻译

百度翻译

虽然很多人说日本人英语不好,但东京作为一个国际大都市,给我的感觉是懂英语的人还是很多的,记得在秋叶原的电器小店中一位日本老爷爷,都可以跟我使用简单的英语词汇进行沟通。不过,也有秋叶原街头也有完全听不懂英语的女仆小姐姐,所以这个时候想要体验到日本的服务,一个翻译软件就显得非常重要了。

东京购物

东京有一些著名的商区,例如新宿、池袋、涩谷,也有二次元圣地秋叶原、世界三大奢侈品购物街银座。

tips:购物时一定要随身携带护照,这是退税的证明,商品超过 500 元左右就可以使用护照来退税。

新宿

新宿站

LUMINE:属于日本年轻潮流百货,拥有许多日系少女风服装,日本的本土时尚品牌也非常齐全,并且价格比国内专柜要便宜。LUMINE 分 1、2 和 EST 三个馆。EST 偏平价一些,1 的品牌价格较 EST 来说贵一点,LUMINE 2 价格最高。

小田急百货 :是非常推荐购物的一家商场,貌似只有新宿 / 町田 / 藤泽有店。可以在大黑屋或者 Access Ticket 购买 300 日元一张的小田急 9 折折扣券,9 折+免税几乎在这家百货店的所有柜台都可以使用。新宿因为位于热门商业区,因此可能会出现一些热门产品的断货可能,因此如果想要安静购物且货物相对比较全的可以选择町田或者藤泽的店,游客相对较少。

伊势丹百货 :日本的高档百货店,有众多品牌

京王百货店 :同样汇集了众多知名品牌,同样是购物的好去处

大黑屋 :日本中古店,可以去淘一些

Big Camera:大型购物中心,以售卖电器为主,但是也有各类生活用品以及药妆(注意:这家店 93 折的券是购买除药妆以及部分电器产品以外的才可以使用,药妆折扣是 95 折,部分电器比如 switch 不参加折扣。

如果是准备在东京购物的话,个人还是十分推荐在新宿的 。新宿是各大百货店云集的地方,并且价格平价到高端的都有,适合各种人群,能够满足各种购物需求 ~ 基本你想要的在这里都能找到。因为每家百货店都有其特色,所以如果在新宿购物的话,还是可以考虑一下多分给新宿一些时间的,因为新宿一天真的逛不完。

池袋

在池袋同样有几家百货店很推荐去,比如 东武百货店,西武百货,LUMINE,还有就是 0101 丸井百货 ,这是一家面向年轻人的综合百货店,也值得一逛。

另外池袋有一个叫 Sunshine City,这是一个大型综合商业设施,休闲购物观光餐饮都有。在这里可以俯瞰东京全景,如果大家想要去看整个东京的话这也是一个不错的选择 ~

池袋是除了秋叶原以外另一个动漫爱好者的圣地了,比较有名的有 周刊 JUMP 主题乐园 ,另一个则是神奇宝贝迷的天堂, 超级宝可梦中心 。如果是喜好二次元的小伙伴们可以做一下功课去池袋转一转哦

我们这一回在池袋的地铁站买到了超人气泡芙 CHOUXCREAM CHOUXCRI,在日本非常的有名,泡芙很好吃。喜欢甜食的小伙伴一定要去池袋打卡这家店哦 ~

涩谷

涩谷是日本众多潮流的发源地,有很多潮牌以及首饰专卖店,算是年轻的潮流街区。涩谷站周边有很多百货商场,以及著名的忠犬八公像。

涩谷 109:是涩谷的标志性建筑,巨大的 109 标志在街道上一目了然。里面有很多日系的少女品牌,非常推荐大家去这里购物,以销售少女平价潮流服饰而闻名。

西武百货 :大牌比较齐全,游客也相对较少,日本有连锁。

东急百货店 :涩谷店是日本的总店,品牌比较多,非常值得一逛。

LOFT:日本是一个以文具著名的国家。LOFT 是文具控们必去的一家啦,其中最全的一家就位于涩谷,想要带些文具走的小伙伴们一定要去逛一逛啊。

银座

银座是世界三大繁华购物街之一 ,很多高端品牌的旗舰店都坐落于银座,这里同样也是百货商场云集的地方,可以供大家选择的购物场所非常的多。同样的这次,虽然这次我们的行程里面没有安排银座,但是以下的商场都是来自于油皮朋友的推荐

三越百货: 日本一家老牌顶级百货商场了,历史非常悠久,这家商场品牌非常的全,购物服务非常的好。(如果来这家百货店,那么你名下有黑卡或者白金信用卡的话就可以去游客中心换一张 95 折的会员卡)

松屋银座百货: 算是银座的地标性建筑之一,LV 的店面引人注目

银座 SIX: 同样是一家购物环境非常不错的百货店,值得一提的是这家店有 1860 年创立的辻利茶铺。喜欢抹茶甜品的小伙伴可以在购物的间隙来这里歇歇脚,品尝下日本百年抹茶老店的味道

银座 DSM: 是川久保玲开的一家买手店,东京这家非常的大,一共有 7 层。是高端潮流品牌和奢侈品品牌并存的店。店铺的设计还有售卖的单品都有主理人川久保玲的色彩在里面。不过里面从平价到贵价的商品都有,喜欢潮流的小伙伴可以来这里看一看。

伊东屋: 这家店也是文具控必去的一家店铺 ~ 一共有 12 层,每层售卖的东西都不一样。

银座的旗舰店和百货商场非常多,但是总的来说是属于消费水平整体非常高的街区,所以大家可以根据自己的情况选择适合自己的商区 ~

秋叶原

电器街

秋叶原作为日本最出名的二次元朝圣地,同时还有一个电器街的身份,可以说技术宅的天堂了。

这次的行程中,花了一天半在秋叶原,可以说非常尽兴了。如果你乘坐大巴,不用问司机、不用看地图就可以清晰地辨认出你来到了秋叶原,整个街道的风格弥漫在二次元之中,在这里有鳞次栉比的手办店,日本特色的影像店,也有动次打次的电玩店,卡哇伊的女仆店。

游玩攻略

镰仓

镰仓车站

神奈川县镰仓,小小的街道,没有太多人。战神源义经的子孙在这里创立了大名鼎鼎的镰仓幕府;几乎笼罩了所有 80 后童年的灌篮高手片头曲里,樱木花道对赤木晴子挥手的地方就取景于镰仓高校前站;倒数第二次恋爱中千明深夜从东京回来居住的小城也是这里。作为继京都、奈良后日本第三座知名的古都,这里的人流量比起其他两座城市要小很多。但就是因为这样,当你坐车江之电瞎转或是慢悠悠散布在海岸上的时候,能发现很多有爱的小细节,请用随意的慢节奏去体会这座海边的古都。

在镰仓不能错过的是乘坐著名的绿皮电车江之电电车,电车穿梭于一排排日本民宿之中,驰行一段时间后,还可以看到海边,迎着和煦并带着一丝湿意的海风,再过一段会经过镰仓高校前站,正好看到放学的高中生,一切都充满了青春的气息。遗憾的是中途路过著名的护栏景点,只可远观,没能下车打卡。

江之岛

江之岛

从镰仓乘坐江之电到江之岛站便可以到达江之岛,江之电之所叫这个名字,最大的原因就是为了服务这里最著名的景点江之岛。 江之岛是湘南海岸的代表景点,也是神奈川县指定史迹名胜。江之岛是一个陆系岛,通过一道沙洲与大陆相连。岛上有几处观光景点。包括神社、公园、展望台和和岩洞。岛上有三处神社统称江之岛神社,可以参拜弁天,以求财富与好运。通往神社的一条商业街,街两旁都是各种小玩意和小吃,推荐一家当地的网红小吃:虾饼。

刚到达岛上就有一个中文非常棒的日本志愿者大妈在发放着岛上的地图,据说 2020 东京奥运会会有项目在此举办。

秋叶原

在购物篇提到了秋叶原,但秋叶原游玩的地方肯定比购物的地方要多的多,并且还有很多日本“特色”的游玩场所。

秋叶原夜 - 街头拉客的女仆

两次来日本都逛了秋叶原,但这次有一个独特的经历便是在深夜の巡礼。原本以为秋叶原街头只有 1~2 家女仆咖啡馆、女仆餐厅,夜晚来到里街才看到街边站着一排排女仆小姐姐,在招揽着 master。大多数店铺会派出 1 个女仆到街边拉客,在店里提供的大多数是爱心蛋包饭、咖啡等轻食,并且会有很多互动的小游戏,如果不会日语,就比较吃亏了,不过只要脸皮厚加上翻译软件的帮助,即使女仆小姐姐不会英语,也可以让你感受到这源自于大和民族独特的宅文化。

女仆店打卡

除了声名远扬的女仆店,秋叶原还有遍地的各种类型的新奇店铺:

  • 电玩店,最出名的非 Sega 莫属了,里面充斥着各种年龄段的人群,跟国内大多数人童年印象中的游戏厅不同,这里的电玩店主要以 Galgame、小钢珠、音游为主(还有一些看都看不大懂的游戏),由于自己是个电玩小白,只能感受个氛围,凑个热闹。

复古游戏

  • 写真店,我也是来了日本才知道,竟然东京街头还有专门卖女子偶像写真营生的店铺,以 AKB48 为首。
  • AKB48 主题餐厅、高达主题餐厅,这两个餐厅可以说是秋叶原的地标了,第二次来日本,竟然店铺还换了个位置,不过牌子倒是还在。

  • 手办店。关于日本的手办店,去逛的时候一定要做好心理预期,因为那些没有包装盒的手办基本都是二手的,所以看起来很精致,而且价格也不是很贵,普通的手办只需要 200-400 人民币就可以拿下;很多低于 1000 元的手办其实都有着 made in China 的说明,有人会觉得大老远跑到日本买个中国制造不是有猫病吗,其实不建议这么想,因为日本的人工费很高,所以大多数手办都是日本出图纸,转到中国制造,最后根据成品质量来决定价格。

手办店

歌舞伎町

歌舞伎町一番街

歌舞伎町说白了就是中国的风月场所,位于新宿的歌舞伎町有两条街:一番街和二番街,仅仅走马观花地看了一遍街道布局,但并不推荐大家体验。由于笔者是一个非常纯洁的人,不太了解这里,所以选取了知乎的一些介绍:

「就在这个夜里各种外围女陪酒男出没的地区,拿到执照可以与从业人员发生关系的店面,其实仅有 5 家。而其他上千家的店面,客人任何试图发生性行为的举动,店家都会强制报警以猥亵罪起诉你,或者向你索取高额的封口费,这个数字从几千人民币到几万不等。

那些其他的店面,虽然也属于风月场所,但接客内容分为两类:一种是按摩、SPA 类型的内容,同样,一旦有猥亵店员的行为,请参照上面说明。

但话说回来,这些店里的最大问题是收费:一瓶市场价 1600 人民币左右的唐培里侬香槟,在店里至少会收 1 万人民币一瓶… 而如果想跟哪个姑娘熟络起来,至少得去个 3-4 次,每次不开个香槟、红酒什么的,基本别想了(看过日剧《黑色皮革手册》之类的朋友,肯定有心理准备)。

当然,借着酒劲占女孩子便宜的客人不是没有,但你得明白,这些店基本上跟当地的黑社会的关系是非常近的。我曾经亲眼见过被从店面后门拖出来,两眼像熊猫的客人。」

旨在打破那些去日本游玩的朋友在异国他乡发生一段奇妙旅程的幻想。

新宿御苑

言叶之庭

新宿御苑是跨新宿与涩谷的一个庭园,也是新海诚的《言叶之庭》的灵感发源地。公园位于市中心,乘坐 JR 线在御苑前站下站,走两步就可以直接到达了,距离之前提到的购物圣地 LUMINE 也只有 10 分钟的步程。绿荫环绕的和式庭院与周围的摩天大楼形成绝妙的反差,非常适合高强度逛街过后来放松一下。

新宿御苑

这里四季都有不一样的美,特别推荐在在樱花盛开的季节来赏樱。可惜的是,我冬天来了一次,夏天来了一次,都没能见到樱花盛开,

须贺神社

日本人的宗教信仰主要以佛教和神道教为主,寺庙之于佛教相当于神社之于神道教。佛教自不必多介绍,神道教是什么来历呢?日本有八百万神明之说,上至神话传说中的神灵,中到历史上名人伟人,下至自然界的大小万物都会成为供奉的对象,每个神社主管的范围内都不一样。比如说稻荷神社是供奉主管农业和商业的稻荷诸神的(不是供奉狐狸的,狐狸是稻荷神的使者);天满宫是供奉学问之神菅原道真的(这位是历史名人);以神宫为名的通常都是供奉某位天皇或者皇室成员的;还有些比较有趣的,比如奈良的冰室神社,是管冷冻的,所以很多奉纳的都是制冷、冰库、冷藏物流的企业…

按照导游的介绍,区分神社和寺庙实际上非常简单,神社门口大多会有一个红色的鸟居,参拜的地方(拜殿)会有稻草编制的注连绳,进入神社前会有用于清洁的手水舍。

手水舍

既然神社这么多,那么自然得推荐大家去看点有意义的神社,例如新海诚导演的《你的名字》就是以须贺神社作为的取经地。须贺神社可以乘坐 JR 线在六本木四丁目站下站之后步行不到 10 分钟就可到达,片中男主与女主最后见面的阶梯就位于须贺神社旁。

你的名字取景地 - 阶梯

当天玩的太晚,到达神社时已经是夜晚了,人烟也很稀少,相比繁华的东京,这里更加的幽静。喜欢动漫的读者可以将这里作为一个不错的打卡地点。片中还有不少景点,例如天桥取景自 JR 信浓町站旁边,港区的东京塔,涩谷区地标建筑 NTT DoCoMo 代代木大厦等等。

国立新美术馆

你的名字取景地 - 美术馆

同样是《你的名字》中的取景地,片中泷被三叶套路后,与奥寺前辈第一次约会时吃中餐时的咖啡店,位于六本木的国立新美术馆。这家美术馆是世界著名设计师同时也是日本建筑界三杰之一的黑川纪章所设计的最后一件作品,是现在日本楼板面积最大的美术馆,并且也拥有日本国内最大的展示空间。喜欢文艺范的游客可以考虑来此地观光。

一般参观美术馆以及很多的展览都是免费的,这次我们就参观了书法展以及画展。馆内除了艺术展览还有设餐厅、咖啡厅、以及博物馆商店。馆内的设计非常好看,两个倒锥体的设计很抢眼。

温泉

这次跟团有一晚是在富士山脚下的温泉酒店住宿。说到富士山,这次实在是有点气,第一是因为这次的温泉酒店实在令人失望,其次是由于天气原因,没办法看到富士山的全貌,只能看到山脚。

泡温泉的最佳时间和地点我可以说是相当有感触,上次来日本最深刻的印象便是在冬天在北海道,那时候外面飘着鹅毛大雪,远处是高山,在一处露天温泉,头顶着一条毛巾,特别惬意。虽然是东京旅游攻略,但有了这次的对比,还是推荐大家有机会一定要在冬天去一次北海道,别有一番风味。

日本见闻

街道

东京这个城市,抬头看到的是高楼大厦,低头看到的便是街道了,日本的街道很容易给来日本旅游的人以深刻的印象,因为太干净了。在街道上,很少看到垃圾桶,所以日本人会有随身携带垃圾袋的习惯。

池袋的街道

除了干净还有一个印象便是安静,在车水马龙的街道上,几乎没有车辆会按喇叭,但我的旅途上发生了一个小插曲,导游刚介绍完日本的司机基本不会按喇叭,我们旅游大巴的喇叭就不争气的坏了,导致一路上总是会自己发出声响,打的一手好脸。

楼梯

在商场、地铁等会出现手扶梯的地方,可以看到一个独特的风景,虽然大多数楼梯都是双行道,但所有人都会靠左站立,把右侧留出来给急着赶路的人。

据导游介绍,关东的东京无论是楼梯、扶梯、街道通行都会习惯靠左,而关西的大阪则是与其相反 —— 靠右。

吃喝

日本的食物偏生冷,新鲜是新鲜,但是种类实在太少,相比之下,让我更加感慨中华美食的魅力。日本街头遍历都是拉面店、寿司店,当年看火影忍者时就对片中的一乐拉面饱含期待,在日本领略过当地的拉面后,并没有感到失望,日本拉面店会习惯配上冰水,这个让我感到一本满足。

胜牛

要说日本远超预期的美食,非和牛饭莫属了,肉质非常鲜嫩,让人流连忘返。

居酒屋

而傍晚直至深夜,居酒屋这样的场所会涌入一批批上班族,这是日本特有的文化,社长会带着社员一天会轮流去好几个居酒屋,含蓄的日本人只有借助酒意,才敢向领导吐露心声。

穿着

浴衣

在日本街头随处可见身着浴衣、脚蹬木屐的女子,日本人真的是把传统服饰融入到了生活之中。

除此之外,第一次来日本时同样是在东京街头,让我感到惊讶的是,日本的女孩子在寒冷的冬天依旧穿着短裙,上身则穿着毛衣。

出行

在之前 【APP 推荐】中介绍换乘案内时,就介绍了日本出行的主要方式了——电车。电车作为日本人最主要的出行方式,主要的原因是,打车实在是太贵了!比国内贵的多的多,所以国内能够随地打车真的是一件非常幸福的事情。日本的出租车司机很有范,个个西装笔挺的打扮,带着白手套;公交车司机给我留下的印象则是年纪偏大,日本是个老龄化严重的国家,所以这些老年人通常退休的时间也会比国内要晚。说道老龄化的对策,最近安倍政府刚推行了一项政策,在九年义务教育的基础上,推行了 3-5 岁幼儿教育费全免的政策,凡是日本国籍或者在日打工的外籍纳税人都可以享受这份政策,从而拯救佛系的日本青年们。

魅力的小姐姐

乘坐电车时,除了之前提到的坐错车的问题,还需要有不少其他注意事项。日本人是一个秉承着尽量不去打扰别人理念的民族,所以在车厢内或者餐厅都尽量保持安静。在日本的地铁或者电车上有些会贴有将手机调成震动的提醒,大家可以注意一下。日本的电车设有女性车厢,以防止电车之狼,在限定时间段内男性最好还是不要去乘坐的好,要注意电车上贴的标志,不然一群女性投来的目光也会挺尴尬的。

住宿

由于是旅行社负责了全部的住宿问题,所以没有太关注如何订房这件事。对于想要完全自由行的朋友,可以通过非常多的 app 来预定酒店以及机票,比如飞猪 / 携程 / 途牛等等。

日本的住宿费用相对是国内一线城市的水准,并不便宜。想要省钱的话,可以选择民宿,一些性价比高的酒店则需要提前很早预定。有一些住宿的地方会提供榻榻米,不过个人不太感冒,睡地板实在不太习惯。住宿 check out 的时候要注意退宿须知,有的地方会有叠好被单、整理褥子之类的要求,特别是民宿需要格外留意。

尾记

以上就是本次东京游玩的全部攻略啦,其实自己对日本也不是特别了解,只能凭借零散的记忆组织起来这篇攻略,期间也参考了不少大牛的攻略文章,想要了解更多攻略的同学可以在 B 站搜搜看相关的视频,很多有用的建议,国庆也快到了,祝有个愉快的旅程 ~

]]>
<p>由于表弟是个狂热的二次元爱好者,受我小姨之托,带他去日本游玩了一趟。趁着这个机会,打算给大家分享一下日本旅游的一些攻略,以祭奠我逝去的年假。这是我第二次去日本了,上一次还是大二时跟我初中舍友一起去的,所以这次去已经有了一些经验了,很多朋友表示想去日本,期待我能写一篇攻略,所以这篇攻略将会偏小白向,如果你是第一次去日本,那这篇攻略想必不会让你失望。</p>
Dubbo 中的 http 协议 http://lexburner.github.io/dubbo-http-protocol/ 2019-07-16T11:13:06.000Z 2019-09-26T09:45:30.214Z 太阳红彤彤,花儿五颜六色,各位读者朋友好,又来到了分享 Dubbo 知识点的时候了。说到 Dubbo 框架支持的协议,你的第一反应是什么?大概会有 Dubbo 默认支持的 dubbo 协议,以及老生常谈的由当当贡献给 Dubbo 的 rest 协议,或者是今天的主角 http。截止到目前,Dubbo 最新版本演进到了 2.7.3,已经支持了:dubbo,hessain,http,injvm,jsonrpc,memcached,native-thrift,thrift,redis,rest,rmi,webservice,xml 等协议,有些协议的使用方式还没有补全到官方文档中。原来 Dubbo 支持这么多协议,是不是有点出乎你的意料呢?

这么多 RPC 协议,可能有人会产生如下的疑问:rest,jsonrpc,webservice 不都是依靠 http 通信吗?为什么还单独有一个 http 协议?先不急着回答这个问题,而是引出今天的话题,先来介绍下 Dubbo 框架中所谓的 http 协议。

Dubbo 中的 http 协议

在 Dubbo 使用 http 协议和其他协议基本一样,只需要指定 protocol 即可。

1
<dubbo:protocol name="http" port="8080" server="jetty" />

server 属性可选值:jetty,tomcat,servlet。

配置过后,当服务消费者向服务提供者发起调用,底层便会使用标准的 http 协议进行通信。可以直接在 https://github.com/apache/dubbo-samples 中找到官方示例,其中的子模块:dubbo-samples-http 构建了一个 http 协议调用的例子。

为避免大家误解,特在此声明:本文中,所有的 http 协议特指的是 dubbo 中的 http 协议,并非那个大家耳熟能详的通用的 http 协议。

http 协议的底层原理

从默认的 dubbo 协议改为 http 协议是非常简单的一件事,上面便是使用者视角所看到的全部的内容了,接下来我们将会探讨其底层实现原理。

翻看 Dubbo 的源码,找到 HttpProtocol 的实现,你可能会吃惊,基本就依靠 HttpProtocol 一个类,就实现了 http 协议

image-20190717185724385

要知道实现自定义的 dubbo 协议,有近 30 个类!http 协议实现的如此简单,背后主要原因有两点:

  1. remoting 层使用 http 通信,不需要自定义编解码
  2. 借助了 Spring 提供的 HttpInvoker 封装了 refer 和 exporter 的逻辑

Spring 提供的 HttpInvoker 是何方神圣呢?的确是一个比较生僻的概念,但并不复杂,简单来说,就是使用 Java 序列化将对象转换成字节,通过 http 发送出去,在 server 端,Spring 能根据 url 映射,找到容器中对应的 Bean 反射调用的过程,没见识过它也不要紧,可以通过下面的示例快速掌握这一概念。

Spring HttpInvoker

本节内容可参见 Spring 文档:https://docs.spring.io/spring/docs/4.3.24.RELEASE/spring-framework-reference/htmlsingle/#remoting-httpinvoker-server

下面的示例将会展示如何使用 Spring 原生的 HttpInvoker 实现远程调用。

创建服务提供者

1
2
3
4
5
6
7
public class AccountServiceImpl implements AccountService {
@Override
public Account findById(int id) {
Account account = new Account(id, new Date().toString());
return account;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
@Bean
AccountService accountService(){
return new AccountServiceImpl();
}

@Bean("/AccountService")
public HttpInvokerServiceExporter accountServiceExporter(AccountService accountService){
HttpInvokerServiceExporter exporter = new HttpInvokerServiceExporter();
exporter.setService(accountService);
exporter.setServiceInterface(AccountService.class);
return exporter;
}

暴露服务的代码相当简单,需要注意两点:

  1. org.springframework.remoting.httpinvoker.HttpInvokerServiceExporter 是 Spring 封装的一个服务暴露器,它会以 serviceInterface 为公共接口,以 service 为实现类向外提供服务。
  2. @Bean(“/AccountService”) 不仅仅指定了 IOC 容器中 bean 的名字,还充当了路径映射的功能,如果本地服务器暴露在 8080 端口,则示例服务的访问路径为 http://localhost:8080/AccountService

创建服务消费者

1
2
3
4
5
6
7
8
9
10
@Configuration
public class HttpProxyConfig {
@Bean("accountServiceProxy")
public HttpInvokerProxyFactoryBean accountServiceProxy(){
HttpInvokerProxyFactoryBean accountService = new HttpInvokerProxyFactoryBean();
accountService.setServiceInterface(AccountService.class);
accountService.setServiceUrl("http://localhost:8080/AccountService");
return accountService;
}
}
1
2
3
4
5
6
7
8
@SpringBootApplication
public class HttpClientApp {
public static void main(String[] args) {
ConfigurableApplicationContext applicationContext = SpringApplication.run(HttpClientApp.class, args);
AccountService accountService = applicationContext.getBean(AccountService.class);
System.out.println(accountService.findById(10086));
}
}

消费者端引用服务同样有两个注意点:

  1. org.springframework.remoting.httpinvoker.HttpInvokerProxyFactoryBean 是 Spring 封装的一个服务引用器,serviceInterface 指定了生成代理的接口,serviceUrl 指定了服务所在的地址,与之前配置的服务暴露者的路径需要对应。
  2. HttpInvokerProxyFactoryBean 注册到容器之中时,会同时生成一个 AccountService 接口的代理类,由 Spring 封装远程调用的逻辑。

调用细节分析

对于 Spring HttpInvoker 的底层实现,就没必要深究了,但大家肯定还是会好奇一些细节:dubbo 中的 http 报文体是怎么组织的?如何序列化对象的?

我们使用 wireshark 可以抓取到客户端发送的请求以及服务端响应的报文。

image-20190717193241396

追踪报文流,可以看到详细的请求和响应内容

image-20190717193339739

ContentType: application/x-java-serialized-object 和报文 Body 部分的 ASCII 码可以看出,使用的是 Java Serialize 序列化。我们将 Body 部分导出成文件,使用 Java Serialize 反序列化响应,来验证一下它的庐山真面目:

image-20190717194908741

使用 Java Serialize 可以正常反序列化报文,得到结果是 Spring 内置的包装类 RemoteInvocationResult,里面装饰着实际的业务返回结果。

http 协议的意义

Dubbo 提供的众多协议有各自适用的场景,例如

  • dubbo://,dubbo 协议是默认的协议,自定义二进制协议;单个长连接节省资源;基于 tcp,架构于 netty 之上,性能还算可以;协议设计上没有足够的前瞻性,不适合做 service-mesh 谈不上多么优雅,但是好歹风风雨雨用了这么多年,周边也有不少配套组件例如 dubbo2.js, dubbo-go, dubbo-cpp,一定程度解决了多语言的问题。
  • webservice://,hession://,thrift:// 等协议,基本是为了适配已有协议的服务端 / 客户端,借助于 dubbo 框架的 api,可以使用其功能特性,意义不是特别大。
  • redis://,memcached:// 等协议,并非是暴露给用户配置的协议,一般是 dubbo 自用,在注册中心模块中会使用到相应的扩展

所有协议的具体使用场景和其特性,我可能会单独写文章来分析,而如今我们要思考的是 dubbo 提供 http 协议到底解决什么问题,什么场景下用户会考虑使用 dubbo 的 http 协议。

我个人认为 dubbo 现如今的 http 协议比较鸡肋,原生 http 通信的优势在于其通用性,基本所有语言都有配套的 http 客户端和服务端支持,但是 dubbo 的 http 协议却使用了 application/x-java-serialized-object 的格式来做为默认的 payload,使得其丧失了跨语言的优势。可能有读者会反驳:HttpInvoker 支持配置序列化格式,不能这么草率的诟病它。但其实我们所关注的恰恰是默认实现,正如 dubbo:// 协议也可以配置 fastjson 作为序列化方案,但是我们同样不认为 dubbo:// 协议是一个优秀的跨语言方案,理由是一样的。当然,评价一个应用层协议是否是优秀的,是否适合做 mesh 等等,需要多种方向去分析,这些我不在本文去分析。

说到底,本文花了一定的篇幅向大家介绍了 dubbo 的 http 协议,到头来却是想告诉你:这是一个比较鸡肋的协议,是不是有些失望呢?不要失望,dubbo 可能在 2.7.4 版本废弃现有的 http 协议,转而使用 jsonrpc 协议替代,其实也就是将 jsonrpc 协议换了个名字而已,而关于 jsonrpc 的细节,我将会在下一篇文章中介绍,届时,我也会分析,为什么 jsonrpc 比现有的 http 协议更适合戴上 http 协议的帽子,至于现有的 http 协议,我更倾向于称之为:spring-httpinvoker 协议。

总结,dubbo 现有 http 协议的意义是什么?如果你习惯于使用 Spring HttpInvoker,那或许现有的 http 协议还有一定的用处,但从 dubbo 交流群和 Spring 文档介绍其所花费的篇幅来看,它还是非常小众的。同时也可以让我们更好地认识协议发展的历史,知道一个协议为什么存在,为什么会被淘汰。

当然,我说了不算,最终还是要看 dubbo 社区的决策,如果你对这个迁移方案感兴趣,想要参与讨论,欢迎大家在 dubbo 社区的邮件列表中发表你的见解

Topic:[Proposal] replace the protocol=”http” with protocol=”jsonrpc”

欢迎关注我的微信公众号:「Kirito 的技术分享」,关于文章的任何疑问都会得到回复,带来更多 Java 相关的技术分享。

关注微信公众号

]]>
<p>太阳红彤彤,花儿五颜六色,各位读者朋友好,又来到了分享 Dubbo 知识点的时候了。说到 Dubbo 框架支持的协议,你的第一反应是什么?大概会有 Dubbo 默认支持的 dubbo 协议,以及老生常谈的由当当贡献给 Dubbo 的 rest 协议,或者是今天的主角 http。截止到目前,Dubbo 最新版本演进到了 2.7.3,已经支持了:dubbo,hessain,http,injvm,jsonrpc,memcached,native-thrift,thrift,redis,rest,rmi,webservice,xml 等协议,有些协议的使用方式还没有补全到官方文档中。原来 Dubbo 支持这么多协议,是不是有点出乎你的意料呢?</p> <p>这么多 RPC 协议,可能有人会产生如下的疑问:rest,jsonrpc,webservice 不都是依靠 http 通信吗?为什么还单独有一个 http 协议?先不急着回答这个问题,而是引出今天的话题,先来介绍下 Dubbo 框架中所谓的 http 协议。</p>
IDEA 插件推荐:Cloud Toolkit 测评 http://lexburner.github.io/cloud-toolkit-benchmark/ 2019-06-27T11:19:41.000Z 2019-09-26T09:45:31.575Z 产品介绍

Cloud Toolkit 是一款 IDE 插件,帮助开发者更高效地开发、测试、诊断并部署应用。开发者能够方便地将本地应用一键部署到任意机器,或 ECS、EDAS、Kubernetes;并内置 Arthas 诊断、高效执行终端命令和 SQL 等。

对这款产品最直观的感受:这是一款发布工具,帮助用户在 IDE 中直接打包应用并部署到各种终端。原本看到其产品介绍位于阿里云的页面中,以为是一款和阿里云服务强绑定的产品,但试用过后发现,即使对于普通的云主机,其也非常适用,可以解决很多开发运维的痛点,非阿里云用户可以放心使用。

在 Cloud Toolkit 出现之前

作为一个 Java 程序员,我们现在大多数都会在 Intellij IDEA 中基于 SpringBoot 来开发 WEB 应用,所以本文中的测评将会基于如下架构

  • 开发环境:IDEA
  • 项目组织方式:Maven
  • 开发框架:SpringBoot

来构建。在接触 Cloud Toolkit 之前,可以怎么部署一个 SpringBoot 应用呢?作为一个偏正经的测评人员,我不会为了凸显出 Cloud Toolkit 的强大而去翻出一些上古的部署工具来做对比,而是直接使用 Intellij IDEA 的内置功能与之对比。

第一步:配置服务器信息

Tools -> Deployment 中可以找到 IDEA 对项目部署支持的内置插件

Deployment 插件

我们可以在其中进行服务器信息的配置,包括服务器地址和权限认证,并且在 Mapping 选项卡中完成本地工程与服务器路径的映射。

第二步:配置 Maven 打包插件

1
2
3
4
5
6
7
8
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

由于是 SpringBoot 应用,配置专用的打包插件后,可以将整个工程打成一个 fatjar,示例工程非常简单:

1
2
3
4
5
6
7
8
9
10
11
12
@SpringBootApplication
@RestController
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}

@RequestMapping("/hello")
public String hello() {
return "hello world~~~~~~~~~~~~~~~~";
}
}

之后,只要执行 install,即可得到一个可运行的 jar 包:

打包

第三步:部署 jar 包

部署

由于我们在第一步已经配置过项目路径与服务器路径的映射,可以选择直接对 fatjar 右键,upload 到远程服务器上。

第四步:启动应用

启动

上图中展示的是 IDEA 中两个非常棒的内置功能,可以在 Tools -> Start SSH session 中开启远程服务器的终端,在 IDEA 下方可以执行远程指令;也可以在 Tools -> Deployment ->Browse Remote Host 中展开如图右侧的结构,可视化地浏览服务器上的文件列表,检查应用是否部署成功。

在远程终端中,找到对应的 fatjar,执行 java -jar spring-demo-1.0-SNAPSHOT.jar 便完成了整个部署流程。

IDEA 内置插件总结

IDEA 内置插件已经提供了相当强大的能力,整个部署过程我们完全没有离开 IDEA!避免了频繁切换窗口,装各种部署工具,可以说已经很方便了,Cloud Toolkit 必须要比这个部署过程做的更加强大才行,那下面就让我们来体验下 Cloud Toolkit 是怎么优化的吧。

Cloud Toolkit 初体验

我们不急着用 Cloud Toolkit 来部署应用。虽然笔者是一位开发,但还是从产品的角度来研究下它的菜单项,看看它的产品定位。IDEA 安装插件的过程省略,详情可以参考 《Intellij IDEA 安装 Cloud Toolkit 教程》

多种部署方式

其他菜单项暂且抛到一边,这 5 个核心能力应该就是 Cloud Toolkit 的核心了。

即使作为一个插件小白,应该也能够望名知意,猜到这几个菜单对应的功能:

  • Deploy to Host:部署到任意服务器。这一个功能决定了 Cloud Toolkit 强大的功能可以使得每个开发者受益,它其实并不是和阿里云厂商强绑定的。在下文也会重点测评下这个功能。
  • Deploy to ECS:这里的 ECS 指的阿里云的 ECS,如果你的服务部署在阿里云 ECS 上,可以选择使用这个功能,获得比 Deploy to Host 更加丰富的功能。在下文我也会简单测评下这个功能。
  • Deploy to EDAS,Deploy to EDAS Serverless:EDAS & EDAS Serverless 是阿里云上提供的分布式服务治理服务,可以理解为商业版的 Dubbo,具有强大的服务治理、服务调度能力,Cloud Toolkit 对 EDAS 做了个性化的部署支持,使得使用者无需登录控制台,在 IDEA 中即可完成 EDAS 的部署。
  • Deploy to CS K8S:云原生时代很多应用使用容器化的方式进行部署,Cloud Toolkit 这一点做的还是不错的,已经具备了容器化部署的能力,具有一定的前瞻性。

其实从简单的功能介绍就可以看出,Cloud Toolkit 相比 IDEA 内置的部署能力的确是高出一大截了,甚至可以说,Deploy to Host 这一能力完全就可以覆盖 IDEA 插件的所有能力,并且对流程还进行了一些简化。下面我重点测评下 Deploy to Host 这一能力,与之前的部署流程进行一个对比。

使用 Cloud Toolkit 部署应用到任意服务器

Deploy to Host

上图展示的 Deploy to Host 功能的配置项,实际上涵盖了

  • 远程服务器配置
  • 部署方式:Maven 构建,直接上传文件(目前还不支持 Gradle 构建,可能在后续的版本会支持)
  • 本地文件与服务器路径的映射配置
  • 启动脚本的集成

账号管理

SSH 登录账户可以在 Preferences -> Alibaba Cloud Toolkit -> SSH Profile 中管理,找不到也没关系,需要设置的时候一般都会有超链接跳转,这点做得很人性化。

SSH 账号管理

主机管理

服务信息可以在 Tools -> Alibaba Cloud ->Alibaba Cloud View 中展开,如下图所示

image-20190602191159882

Deploy to Host

配置完账号信息和主机信息,然后只需要右键项目选择 Alibaba Cloud -> Deploy to Host-> Run ,一切就搞定了。这个过程相比之前变得非常简易

  • 不需要自己打包。Cloud Toolkit 集成了 Maven 插件。
  • 不需要登录远程终端去执行脚本启动服务。Cloud Toolkit 提供了应用部署生命周期必要的钩子,只需要设置好启动脚本即可。
  • 修改完本地代码,点击下 Deploy to Host,即可完成改动代码的部署。

经过如上的测评过程,相信即使没有使用过 Cloud Toolkit 的用户,也可以直观体会到这是怎么样一款插件了,并且它的功能是多么的实用。

使用 Cloud Toolkit 部署应用到 ECS

从产品设计的角度来分析,Cloud Toolkit 提供如此众多的部署能力,可以想到是其直接预设了使用人群。例如一个阿里云的 ECS 用户,在选择部署方式时,既可以使用 Deploy to Host 也可以使用 Deploy to ECS;例如一个 EDAS 用户,在选择部署方式时,既可以使用 Deploy to Host、Deploy to ECS,也可以使用 Deploy to EDAS(EDAS 可以理解为一个定制化的 ECS)。从产品的角度,越定制化的功能服务的人群越少,同时功能更强大;从用户体验的角度,其实也透露了云服务的一个特点,云厂商正在为其所提供的云服务提供更好的用户体验,借助于此类插件,可以降低使用者的开发运维门槛。

可以预见的一件事是,对于非阿里云用户来说,Deploy to Host 是使用 Cloud Toolkit 最大的诱惑了。作为一个测评文章,除了 Deploy to Host 之外,我还选择了 Deploy to ECS 这一功能来进行测评。为此我购买了一台阿里云的 ECS 来部署与上文相同的应用。

Accounts

在阿里云控制台可以获取到账号的 Access Key/Access Key Secret,在 IDEA 中的 Preferences -> Alibaba Cloud Toolkit -> Accounts 中可以设置账号。

在账号设置完毕后,Cloud Toolkit 看起来是通过内置的 API 直接关联到了我的 ECS 实例,在选择部署时,可以直接根据 region 选择实例列表中的机器进行部署。

实例列表

其余的部署流程和 Deploy to Host 相差无几。也就是说,Deploy to ECS 更多的其实完成了权限管理和主机管理,ECS 用户使用这个功能就显得非常高效了。

Cloud Toolkit 的亮点功能

Cloud Toolkit 除了主打的部署能力,还提供了不少亮点功能,我选择了其中的 3 个功能:上传文件,远程 Terminal,内置应用诊断功能来进行评测。

上传文件

upload

有些脚本我们希望在本地编辑之后上传到服务器上,Cloud Toolkit 对每一个主机都提供了一个 Upload 操作,可以将本地的文件上传到远程主机上,并且还可以触发一个 commond,这个功能也是很人性化的,因为上传脚本后,往往需要运行一次,避免了我们再登录到远程主机上执行一次运行操作。

远程 Terminal

特别是在 Mac 中,我一直苦恼的一件事便是如何管理众多的远程机器,我需要偶尔去搭建了博客的主机上查看下个人博客为什么挂了,偶尔又要去看看我的 VPN 主机排查下为什么无法转发流量了,在开发测试阶段,又要经常去测试主机上简单的执行一些命令。所有这一切通过 ssh 工具去完成都不麻烦,但所有的麻烦事集合到一起时往往会让我变得焦头烂额,这一点,Cloud Toolkit 简直是一个 Life Saver。

image-20190604201228263

事实上,在前面的测评中我们已经了解到 IDEA 内置了远程 Terminal 这个功能,Cloud Toolkit 是进一步优化了它的体验,用户可以直接在可视化的页面选择想要远程登录的主机,在对主机加了 Tag 之后,这个过程会更加直观。

内置应用诊断功能

在测评体验过程中,意外地发现了 Cloud Toolkit 的一个功能支持,就是前面的截图有显示,但我未提到的 Diagnostic (诊断)功能。Cloud Toolkit 集成了阿里巴巴开源的一款应用诊断框架 – Arthas

  • 对于本地主机,可以直接通过 Tools -> Alibaba Cloud -> Diagnostic Tools 开启诊断。
  • 对于远程主机,可以通过主机管理中的 Diagnostic 选项卡,开启远程诊断。

远程诊断

在过去,我们想要进行诊断,必须要手动在服务器上安装 Arthas,Cloud Toolkit 借助于 Remote Terminal 和 Arthas 的集成,让这一切都可以在 IDEA 中完成,似乎是想要贯彻:彻底杜绝第三方工具,一切都用插件完成。

当你遇到以下类似问题而束手无策时,Arthas 可以帮助你解决:

  1. 这个类从哪个 jar 包加载的?为什么会报各种类相关的 Exception?
  2. 我改的代码为什么没有执行到?难道是我没 commit?分支搞错了?
  3. 遇到问题无法在线上 debug,难道只能通过加日志再重新发布吗?
  4. 线上遇到某个用户的数据处理有问题,但线上同样无法 debug,线下无法重现!
  5. 是否有一个全局视角来查看系统的运行状况?
  6. 有什么办法可以监控到 JVM 的实时运行状态?

作为一个偏正经的评测,我们试用一下远程诊断的功能,选取比较直观的 trace 命令来进行评测

慢应用

如上图所示,我们构造了一个慢请求,其中 invokeServiceA_B() 相对于其他方法十分耗时,我们希望通过 Cloud Toolkit 定位到慢调用的源头,找出 invokeServiceA_B 这个罪魁祸首。

arthas

点击 IDEA 中对应部署服务器的 Diagnostic 菜单项,就会出现如上图所示的一个 Arthas 诊断页面,它会自动关联到用户的 Java 进程,用户只需要选择相应诊断的进程即可。

image-20190604200009328

在关联到相应的进程之后,我们执行 trace 指令

trace moe.cnkirito.demo.Application * -j

这个指令的含义是当 moe.cnkirito.demo.Application 中的任意方法被触发调用后,会打印出相应的调用栈,并计算耗时,-j 的含义是过滤掉 JDK 内置的类,简化堆栈。正如上图所示,我们定位到是 invokeServiceA 的 invokeServiceA_B 最为耗时。用户可以自行监控对应的方法,把 * 替换为想要监控的方式即可。更多的监控指令可以参考:Arthas 文档

测评中发现的不足

是软件就必然有 bug,或者是用户体验不好的地方,花费了一个下午进行测评,简单罗列下我认为的缺陷。

远程连接容易出现异常

这个问题不是特别容易复现,表现是长时间运行项目后,再部署,会提示远程连接失败,在重启 IDEA 之后可以解决这个问题,原因未知。在后面想要复现时一直无法复现,但的确耗费了我很长的时间,不知道有没有其他的用户遇到同样的问题。

文件浏览器过于简陋

ssh

当尝试配置 SSH 公私钥以实现免密登录时,发现 Browse 打开的文件浏览器无法正常显示 Mac 中的 .ssh 隐藏文件夹,大多数情况下用户会将 SSH 公私钥存放在 ~/.ssh 中,这个用户体验不是很好,或许有办法在这个文件浏览器中访问到隐藏文件夹,但至少我还没找到方法。

缺少远程主机的可视化功能

IDEA 的默认插件支持 Remote Host

Remote Host

这个可以提升用户体验,Cloud Toolkit 提供了远程主机的管理,额外实现一个 ftp 协议可能会更方便用户查看自己的部署结果。从连接协议的选择上也可以发现,Cloud Toolkit 目前只支持 sftp 协议,而 IDEA 内置的 Deployment 插件还支持 ftp、ftps 等方式。

产品定位 & 评价 & 竞品

其实本文基本是围绕 IDEA 的内置 Deployment 顺带着 Cloud Toolkit 的测评一起进行的。实际上我并不觉得 Cloud Toolkit 存在什么竞品

xftp 或者 xshell 吗?它们只是一款 ssh 工具罢了,人家压根没想着跟你竞争。

jenkins 吗?jenkins 有自己的 devops 流程,侧重在持续集成,而 Cloud Toolkit 定位是在日常开发中完成部署验证等行为。

在我的测评过程中,能够感受到这款产品的匠心,几乎为所有用户可能遇到的问题都做配备了文档:不知道启动脚本怎么写?链接了常用的 Java 应用启动脚本;不清楚该使用哪种部署方式?每种方式都有完整的部署文档;多语言?同时提供了 Go、NodeJS 的部署案例…

同时还支持了一些赠品功能:查看实时日志,文件上传,SQL 执行等。

以个人愚见,聊聊这款产品的定位,一方面是云厂商无关的特性,Cloud Toolkit 提供了 Deploy to Host、内置 Arthas 诊断等功能,造福了广大的开发者,另一方面是阿里云服务绑定的一些功能,Cloud Toolkit 为 ECS、EDAS 用户带来了福音,可以享受比普通应用部署更加便捷的操作。前者为 Cloud Toolkit 积累了业界口碑,后者为阿里云付费用户增加了信心,同时也为潜在的阿里云用户埋下了种子。

]]>
<h2 id="产品介绍"><a href="#产品介绍" class="headerlink" title="产品介绍"></a>产品介绍</h2><p><a href="https://cn.aliyun.com/product/cloudtoolkit" target="_blank" rel="noopener">Cloud Toolkit</a> 是一款 IDE 插件,帮助开发者更高效地开发、测试、诊断并部署应用。开发者能够方便地将本地应用一键部署到任意机器,或 ECS、EDAS、Kubernetes;并内置 Arthas 诊断、高效执行终端命令和 SQL 等。</p> <p>对这款产品最直观的感受:这是一款发布工具,帮助用户在 IDE 中直接打包应用并部署到各种终端。原本看到其产品介绍位于阿里云的页面中,以为是一款和阿里云服务强绑定的产品,但试用过后发现,即使对于普通的云主机,其也非常适用,可以解决很多开发运维的痛点,非阿里云用户可以放心使用。</p>
研究网卡地址注册时的一点思考 http://lexburner.github.io/network-interfaces/ 2019-04-29T11:09:53.000Z 2019-09-26T09:45:29.822Z 我曾经写过一篇和本文标题类似的文章《研究优雅停机时的一点思考》,上文和本文都有一个共同点:网卡地址注册和优雅停机都是一个很小的知识点,但是背后牵扯到的知识点却是庞大的体系,我在写这类文章前基本也和大多数读者一样,处于“知道有这么个东西,但不了解细节”的阶段,但一旦深挖,会感受到其中的奇妙,并有机会接触到很多平时不太关注的知识点。

另外,我还想介绍一个叫做”元阅读“的技巧,可能这个词是我自己造的,也有人称之为”超视角阅读“。其内涵指的是,普通读者从我的文章中学到的是某个知识点,而元阅读者从我的文章中可能会额外关注,我是如何掌握某个知识点的,在一个知识点的学习过程中我关注了哪些相关的知识点,又是如何将它们联系在一起,最终形成一个体系的。这篇文章就是一个典型的例子,我会对一些点进行发散,大家可以尝试着跟我一起来思考”网卡地址注册“这个问题。

1 如何选择合适的网卡地址

可能相当一部分人还不知道我这篇文章到底要讲什么,我说个场景,大家应该就明晰了。在分布式服务调用过程中,以 Dubbo 为例,服务提供者往往需要将自身的 IP 地址上报给注册中心,供消费者去发现。在大多数情况下 Dubbo 都可以正常工作,但如果你留意过 Dubbo 的 github issue,其实有不少人反馈:Dubbo Provider 注册了错误的 IP。如果你能立刻联想到:多网卡、内外网地址共存、VPN、虚拟网卡等关键词,那我建议你一定要继续将本文看下去,因为我也想到了这些,它们都是本文所要探讨的东西!那么“如何选择合适的网卡地址”呢,Dubbo 现有的逻辑到底算不算完备?我们不急着回答它,而是带着这些问题一起进行研究,相信到文末,其中答案,各位看官自有评说。

2 Dubbo 是怎么做的

Dubbo 获取网卡地址的逻辑在各个版本中也是千回百转,走过弯路,也做过优化,我们用最新的 2.7.2-SNAPSHOT 版本来介绍,在看以下源码时,大家可以怀着质疑的心态去阅读,在 dubbo github 的 master 分支可以获取源码。获取 localhost 的逻辑位于 org.apache.dubbo.common.utils.NetUtils#getLocalAddress0() 之中

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 InetAddress getLocalAddress0() {
InetAddress localAddress = null;
// 首先尝试获取 /etc/hosts 中 hostname 对应的 IP
localAddress = InetAddress.getLocalHost();
Optional<InetAddress> addressOp = toValidAddress(localAddress);
if (addressOp.isPresent()) {
return addressOp.get();
}

// 没有找到适合注册的 IP,则开始轮询网卡
Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
if (null == interfaces) {
return localAddress;
}
while (interfaces.hasMoreElements()) {
NetworkInterface network = interfaces.nextElement();
Enumeration<InetAddress> addresses = network.getInetAddresses();
while (addresses.hasMoreElements()) {
// 返回第一个匹配的适合注册的 IP
Optional<InetAddress> addressOp = toValidAddress(addresses.nextElement());
if (addressOp.isPresent()) {
return addressOp.get();
}
}
}
return localAddress;
}

Dubbo 这段选取本地地址的逻辑大致分成了两步

  1. 先去 /etc/hosts 文件中找 hostname 对应的 IP 地址,找到则返回;找不到则转 2
  2. 轮询网卡,寻找合适的 IP 地址,找到则返回;找不到返回 null,再 getLocalAddress0 外侧还有一段逻辑,如果返回 null,则注册 127.0.0.1 这个本地回环地址

首先强调下,这段逻辑并没有太大的问题,先别急着挑刺,让我们来分析下其中的一些细节,并进行验证。

2.1 尝试获取 hostname 映射 IP

Dubbo 首先选取的是 hostname 对应的 IP,在源码中对应的 InetAddress.getLocalHost();*nix 系统实际部署 Dubbo 应用时,可以首先使用 hostname 命令获取主机名

1
2
xujingfengdeMacBook-Pro:~ xujingfeng$ hostname
xujingfengdeMacBook-Pro.local

紧接着在 /etc/hosts 配置 IP 映射,为了验证 Dubbo 的机制,我们随意为 hostname 配置一个 IP 地址

1
2
127.0.0.1localhost
1.2.3.4 xujingfengdeMacBook-Pro.local

接着调用 NetUtils.getLocalAddress0() 进行验证,控制台打印如下:

1
xujingfengdeMacBook-Pro.local/1.2.3.4

2.2 判定有效的 IP 地址

在 toValidAddress 逻辑中,Dubbo 存在以下逻辑判定一个 IP 地址是否有效

1
2
3
4
5
6
7
8
9
10
11
12
private static Optional<InetAddress> toValidAddress(InetAddress address) {
if (address instanceof Inet6Address) {
Inet6Address v6Address = (Inet6Address) address;
if (isValidV6Address(v6Address)) {
return Optional.ofNullable(normalizeV6Address(v6Address));
}
}
if (isValidV4Address(address)) {
return Optional.of(address);
}
return Optional.empty();
}

依次校验其符合 Ipv6 或者 Ipv4 的 IP 规范,对于 Ipv6 的地址,见如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
static boolean isValidV6Address(Inet6Address address) {
boolean preferIpv6 = Boolean.getBoolean("java.net.preferIPv6Addresses");
if (!preferIpv6) {
return false;
}
try {
return address.isReachable(100);
} catch (IOException e) {
// ignore
}
return false;
}

首先获取 java.net.preferIPv6Addresses 参数,其默认值为 false,鉴于大多数应用并没有使用 Ipv6 地址作为理想的注册 IP,这问题不大,紧接着通过 isReachable 判断网卡的连通性。例如一些网卡可能是 VPN/ 虚拟网卡的地址,如果没有配置路由表,往往无法连通,可以将之过滤。

对于 Ipv4 的地址,见如下代码:

1
2
3
4
5
6
7
8
9
10
11
static boolean isValidV4Address(InetAddress address) {
if (address == null || address.isLoopbackAddress()) {
return false;
}
String name = address.getHostAddress();
boolean result = (name != null
&& IP_PATTERN.matcher(name).matches()
&& !Constants.ANYHOST_VALUE.equals(name)
&& !Constants.LOCALHOST_VALUE.equals(name));
return result;
}

对比 Ipv6 的判断,这里我们已经发现前后不对称的情况了

  • Ipv4 相比 Ipv6 的逻辑多了 Ipv4 格式的正则校验、本地回环地址校验、ANYHOST 校验
  • Ipv4 相比 Ipv6 的逻辑少了网卡连通性的校验

大家都知道,Ipv4 将 127.0.0.1 定为本地回环地址, Ipv6 也存在回环地址:0:0:0:0:0:0:0:1 或者表示为 ::1。改进建议也很明显,我们放到文末统一总结。

2.3 轮询网卡

如果上述地址获取为 null 则进入轮询网卡的逻辑(例如 hosts 未指定 hostname 的映射或者 hostname 配置成了 127.0.0.1 之类的地址便会导致获取到空的网卡地址),轮询网卡对应的源码是 NetworkInterface.getNetworkInterfaces() ,这里面涉及的知识点就比较多了,支撑起了我写这篇文章的素材,Dubbo 的逻辑并不复杂,进行简单的校验,返回第一个可用的 IP 即可。

性子急的读者可能忍不住了,多网卡!合适的网卡可能不止一个,Dubbo 怎么应对呢?按道理说,我们也替 Dubbo 说句公道话,客官要不你自己指定下?我们首先得对多网卡的场景达成一致看法,才能继续把这篇文章完成下去:我们只能 尽可能 过滤那些“ 不对 ”的网卡。Dubbo 看样子对所有网卡是一视同仁了,那么是不是可以尝试优化一下其中的逻辑呢?

许多开源的服务治理框架在 stackoverflow 或者其 issue 中,注册错 IP 相关的问题都十分高频,大多数都是轮询网卡出了问题。既然事情发展到这儿,势必需要了解一些网络、网卡的知识,我们才能过滤掉那些明显不适合 RPC 服务注册的 IP 地址了。

3 Ifconfig 介绍

我并没有想要让大家对后续的内容望而却步,特地选择了这个大家最熟悉的 Linux 命令!对于那些吐槽:“天呐,都 2019 年了,你怎么还在用 net-tools/ifconfig,iproute2/ip 了解一下”的言论,请大家视而不见。无论你使用的是 mac,还是 linux,都可以使用它去 CRUD 你的网卡配置。

3.1 常用指令

启动关闭指定网卡:

1
2
ifconfig eth0 up
ifconfig eth0 down

ifconfig eth0 up 为启动网卡 eth0,ifconfig eth0 down 为关闭网卡 eth0。ssh 登陆 linux 服务器操作的用户要小心执行这个操作了,千万不要蠢哭自己。不然你下一步就需要去 google:“禁用 eth0 网卡后如何远程连接 Linux 服务器” 了。

为网卡配置和删除 IPv6 地址:

1
2
ifconfig eth0 add 33ffe:3240:800:1005::2/64    #为网卡 eth0 配置 IPv6 地址
ifconfig eth0 del 33ffe:3240:800:1005::2/64 #为网卡 eth0 删除 IPv6 地址

用 ifconfig 修改 MAC 地址:

1
ifconfig eth0 hw ether 00:AA:BB:CC:dd:EE

配置 IP 地址:

1
2
3
[root@localhost ~]# ifconfig eth0 192.168.2.10
[root@localhost ~]# ifconfig eth0 192.168.2.10 netmask 255.255.255.0
[root@localhost ~]# ifconfig eth0 192.168.2.10 netmask 255.255.255.0 broadcast 192.168.2.255

启用和关闭 arp 协议:

1
2
ifconfig eth0 arp    #开启网卡 eth0 的 arp 协议
ifconfig eth0 -arp #关闭网卡 eth0 的 arp 协议

设置最大传输单元:

1
ifconfig eth0 mtu 1500    #设置能通过的最大数据包大小为 1500 bytes

3.2 查看网卡信息

在一台 ubuntu 上执行 ifconfig -a

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
ubuntu@VM-30-130-ubuntu:~$ ifconfig -a
eth0 Link encap:Ethernet HWaddr 52:54:00:a9:5f:ae
inet addr:10.154.30.130 Bcast:10.154.63.255 Mask:255.255.192.0
UP BROADCAST RUNNING MULTICAST MTU:1500 Metric:1
RX packets:149673 errors:0 dropped:0 overruns:0 frame:0
TX packets:152271 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:1000
RX bytes:15205083 (15.2 MB) TX bytes:21386362 (21.3 MB)

lo Link encap:Local Loopback
inet addr:127.0.0.1 Mask:255.0.0.0
UP LOOPBACK RUNNING MTU:65536 Metric:1
RX packets:0 errors:0 dropped:0 overruns:0 frame:0
TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:1
RX bytes:0 (0.0 B) TX bytes:0 (0.0 B)

docker0 Link encap:Ethernet HWaddr 02:42:58:45:c1:15
inet addr:172.17.0.1 Bcast:172.17.255.255 Mask:255.255.0.0
UP BROADCAST MULTICAST MTU:1500 Metric:1
RX packets:0 errors:0 dropped:0 overruns:0 frame:0
TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:0
RX bytes:0 (0.0 B) TX bytes:0 (0.0 B)

tun0 Link encap:UNSPEC HWaddr 00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00
UP POINTOPOINT NOARP MULTICAST MTU:1500 Metric:1
RX packets:0 errors:0 dropped:0 overruns:0 frame:0
TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:100
RX bytes:0 (0.0 B) TX bytes:0 (0.0 B)

为了防止黑客对我的 Linux 发起攻击,我还是偷偷对 IP 做了一点“改造”,请不要为难一个趁着打折 + 组团购买廉价云服务器的小伙子。对于部门网卡的详细解读:

eth0 表示第一块网卡, 其中 HWaddr 表示网卡的物理地址,可以看到目前这个网卡的物理地址 (MAC 地址)是 02:42:38:52:70:54

inet addr 用来表示网卡的 IP 地址,此网卡的 IP 地址是 10.154.30.130,广播地址, Bcast: 172.18.255.255,掩码地址 Mask:255.255.0.0

lo 是表示主机的回环地址,这个一般是用来测试一个网络程序,但又不想让局域网或外网的用户能够查看,只能在此台主机上运行和查看所用的网络接口。比如把 HTTPD 服务器的指定到回坏地址,在浏览器输入 127.0.0.1 就能看到你所架 WEB 网站了。但只是你能看得到,局域网的其它主机或用户无从知晓。

第一行:连接类型:Ethernet(以太网)HWaddr(硬件 mac 地址)

第二行:网卡的 IP 地址、子网、掩码

第三行:UP(代表网卡开启状态)RUNNING(代表网卡的网线被接上)MULTICAST(支持组播)MTU:1500(最大传输单元):1500 字节(ipconfig 不加 -a 则无法看到 DOWN 的网卡)

第四、五行:接收、发送数据包情况统计

第七行:接收、发送数据字节数统计信息。

紧接着的两个网卡 docker0,tun0 是怎么出来的呢?我在我的 ubuntu 上装了 docker 和 openvpn。这两个东西应该是日常干扰我们做服务注册时的罪魁祸首了,当然,也有可能存在 eth1 这样的第二块网卡。ifconfig -a 看到的东西就对应了 JDK 的 api :NetworkInterface.getNetworkInterfaces() 。我们简单做个总结,大致有三个干扰因素

  • 以 docker 网桥为首的虚拟网卡地址,毕竟这东西这么火,怎么也得单独列出来吧?
  • 以 TUN/TAP 为代表的虚拟网卡地址,多为 VPN 场景
  • 以 eth1 为代表的多网卡场景,有钱就可以装多网卡了!

我们后续的篇幅将针对这些场景做分别的介绍,力求让大家没吃过猪肉,起码看下猪怎么跑的。

4 干扰因素一:Docker 网桥

熟悉 docker 的朋友应该知道 docker 会默认创建一个 docker0 的网桥,供容器实例连接。如果嫌默认的网桥不够直观,我们可以使用 bridge 模式自定义创建一个新的网桥:

1
2
3
4
5
6
7
8
9
10
ubuntu@VM-30-130-ubuntu:~$ docker network create kirito-bridge
a38696dbbe58aa916894c674052c4aa6ab32266dcf6d8111fb794b8a344aa0d9
ubuntu@VM-30-130-ubuntu:~$ ifconfig -a
br-a38696dbbe58 Link encap:Ethernet HWaddr 02:42:6e:aa:fd:0c
inet addr:172.19.0.1 Bcast:172.19.255.255 Mask:255.255.0.0
UP BROADCAST MULTICAST MTU:1500 Metric:1
RX packets:0 errors:0 dropped:0 overruns:0 frame:0
TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:0
RX bytes:0 (0.0 B) TX bytes:0 (0.0 B)

使用 docker network 指令创建网桥之后,自动创建了对应的网卡,我只给出了 ifconfig -a 的增量返回部分,可以看出多了一个 br-a38696dbbe58 的网卡。

我有意区分了“网桥”和“网卡”,可以使用 bridge-utils/brctl 来查看网桥信息:

1
2
3
4
ubuntu@VM-30-130-ubuntu:~$ sudo brctl show
bridge namebridge idSTP enabledinterfaces
br-a38696dbbe588000.02426eaafd0cno
docker08000.02425845c215no

网桥是一个虚拟设备,这个设备只有 brctl show 能看到,网桥创建之后,会自动创建一个同名的网卡,并将这个网卡加入网桥。

5 干扰因素二:TUN/TAP 虚拟网络设备

平时我们所说的虚拟网卡、虚拟机,大致都跟 TUN/TAP 有关。我的读者大多数是 Java 从业者,相信我下面的内容并没有太超纲,不要被陌生的名词唬住。对于被唬住的读者,也可以直接跳过 5.1~5.3,直接看 5.4 的实战。

5.1 真实网卡工作原理

1918847-496d0e96c237f25a

上图中的 eth0 表示我们主机已有的真实的网卡接口 (interface)。

网卡接口 eth0 所代表的真实网卡通过网线 (wire) 和外部网络相连,该物理网卡收到的数据包会经由接口 eth0 传递给内核的网络协议栈(Network Stack)。然后协议栈对这些数据包进行进一步的处理。

对于一些错误的数据包, 协议栈可以选择丢弃;对于不属于本机的数据包,协议栈可以选择转发;而对于确实是传递给本机的数据包, 而且该数据包确实被上层的应用所需要,协议栈会通过 Socket API 告知上层正在等待的应用程序。

5.2 TUN 工作原理

1918847-85ea08bc89d9427e

我们知道,普通的网卡是通过网线来收发数据包的话,而 TUN 设备比较特殊,它通过一个文件收发数据包。

如上图所示,tunX 和上面的 eth0 在逻辑上面是等价的, tunX 也代表了一个网络接口, 虽然这个接口是系统通过软件所模拟出来的.

网卡接口 tunX 所代表的虚拟网卡通过文件 /dev/tunX 与我们的应用程序 (App) 相连,应用程序每次使用 write 之类的系统调用将数据写入该文件,这些数据会以网络层数据包的形式,通过该虚拟网卡,经由网络接口 tunX 传递给网络协议栈,同时该应用程序也可以通过 read 之类的系统调用,经由文件 /dev/tunX 读取到协议栈向 tunX 传递的 所有 数据包。

此外,协议栈可以像操纵普通网卡一样来操纵 tunX 所代表的虚拟网卡。比如说,给 tunX 设定 IP 地址,设置路由,总之,在协议栈看来,tunX 所代表的网卡和其他普通的网卡区别不大,当然,硬要说区别,那还是有的, 那就是 tunX 设备不存在 MAC 地址,这个很好理解,tunX 只模拟到了网络层,要 MAC 地址没有任何意义。当然,如果是 tapX 的话,在协议栈的眼中,tapX 和真实网卡没有任何区别。

是不是有些懵了?我是谁,为什么我要在这篇文章里面学习 TUN!因为我们常用的 VPN 基本就是基于 TUN/TAP 搭建的,如果我们使用 TUN 设备搭建一个基于 UDPVPN ,那么整个处理过程可能是这幅样子:

1918847-ac4155ec7e9489b2

5.3 TAP 工作原理

TAP 设备与 TUN 设备工作方式完全相同,区别在于:

  1. TUN 设备是一个三层设备,它只模拟到了 IP 层,即网络层 我们可以通过 /dev/tunX 文件收发 IP 层数据包,它无法与物理网卡做 bridge,但是可以通过三层交换(如 ip_forward)与物理网卡连通。可以使用 ifconfig 之类的命令给该设备设定 IP 地址。
  2. TAP 设备是一个二层设备,它比 TUN 更加深入,通过 /dev/tapX 文件可以收发 MAC 层数据包,即数据链路层,拥有 MAC 层功能,可以与物理网卡做 bridge,支持 MAC 层广播。同样的,我们也可以通过 ifconfig 之类的命令给该设备设定 IP 地址,你如果愿意,我们可以给它设定 MAC 地址。

关于文章中出现的二层,三层,我这里说明一下,第一层是物理层,第二层是数据链路层,第三层是网络层,第四层是传输层。

5.4 openvpn 实战

openvpn 是 Linux 上一款开源的 vpn 工具,我们通过它来复现出影响我们做网卡选择的场景。

安装 openvpn

1
sudo apt-get install openvpn

安装一个 TUN 设备:

1
2
3
ubuntu@VM-30-130-ubuntu:~$ sudo openvpn --mktun --dev tun0
Mon Apr 29 22:23:31 2019 TUN/TAP device tun0 opened
Mon Apr 29 22:23:31 2019 Persist state set to: ON

安装一个 TAP 设备:

1
2
3
ubuntu@VM-30-130-ubuntu:~$ sudo openvpn --mktun --dev tap0
Mon Apr 29 22:24:36 2019 TUN/TAP device tap0 opened
Mon Apr 29 22:24:36 2019 Persist state set to: ON

执行 ifconfig -a 查看网卡,只给出增量的部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
tap0      Link encap:Ethernet  HWaddr 7a:a2:a8:f1:6b:df
BROADCAST MULTICAST MTU:1500 Metric:1
RX packets:0 errors:0 dropped:0 overruns:0 frame:0
TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:100
RX bytes:0 (0.0 B) TX bytes:0 (0.0 B)

tun0 Link encap:UNSPEC HWaddr 00-00-00-00-00-00-00-00-00-00-00-00-00-00-00-00
inet addr:10.154.30.131 P-t-P:10.154.30.131 Mask:255.255.255.255
UP POINTOPOINT NOARP MULTICAST MTU:1500 Metric:1
RX packets:0 errors:0 dropped:0 overruns:0 frame:0
TX packets:0 errors:0 dropped:0 overruns:0 carrier:0
collisions:0 txqueuelen:100
RX bytes:0 (0.0 B) TX bytes:0 (0.0 B)

这样就解释了文章一开始为什么会有 tun0 这样的网卡了。这里读者可能会有疑惑,使用 ifconfig 不是也可以创建 tap 和 tun 网卡吗?当然啦,openvpn 是一个 vpn 工具,只能创建名为 tunX/tapX 的网卡,其遵守着一定的规范,ifconfig 可以随意创建,但没人认那些随意创建的网卡。

6 干扰因素三:多网卡

image-20190429223515625

这个没有太多好说的,有多张真实的网卡,从普哥那儿搞到如上的 IP 信息。

7 MAC 下的差异

虽然 ifconfig 等指令是 *nux 通用的,但是其展示信息,网卡相关的属性和命名都有较大的差异。例如这是我 MAC 下执行 ifconfig -a 的返回:

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
xujingfengdeMacBook-Pro:dubbo-in-action xujingfeng$ ifconfig -a
lo0: flags=8049<UP,LOOPBACK,RUNNING,MULTICAST> mtu 16384
options=1203<RXCSUM,TXCSUM,TXSTATUS,SW_TIMESTAMP>
inet 127.0.0.1 netmask 0xff000000
inet6 ::1 prefixlen 128
inet6 fe80::1%lo0 prefixlen 64 scopeid 0x1
nd6 options=201<PERFORMNUD,DAD>
gif0: flags=8010<POINTOPOINT,MULTICAST> mtu 1280
stf0: flags=0<> mtu 1280
XHC0: flags=0<> mtu 0
XHC20: flags=0<> mtu 0
en0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
ether 88:e9:fe:88:a0:76
inet6 fe80::1cab:f689:60d1:bacb%en0 prefixlen 64 secured scopeid 0x6
inet 30.130.11.242 netmask 0xffffff80 broadcast 30.130.11.255
nd6 options=201<PERFORMNUD,DAD>
media: autoselect
status: active
p2p0: flags=8843<UP,BROADCAST,RUNNING,SIMPLEX,MULTICAST> mtu 2304
ether 0a:e9:fe:88:a0:76
media: autoselect
status: inactive
awdl0: flags=8943<UP,BROADCAST,RUNNING,PROMISC,SIMPLEX,MULTICAST> mtu 1484
ether 66:d2:8c:8c:dd:85
inet6 fe80::64d2:8cff:fe8c:dd85%awdl0 prefixlen 64 scopeid 0x8
nd6 options=201<PERFORMNUD,DAD>
media: autoselect
status: active
en1: flags=8963<UP,BROADCAST,SMART,RUNNING,PROMISC,SIMPLEX,MULTICAST> mtu 1500
options=60<TSO4,TSO6>
ether aa:00:d0:13:0e:01
media: autoselect <full-duplex>
status: inactive
en2: flags=8963<UP,BROADCAST,SMART,RUNNING,PROMISC,SIMPLEX,MULTICAST> mtu 1500
options=60<TSO4,TSO6>
ether aa:00:d0:13:0e:00
media: autoselect <full-duplex>
status: inactive
bridge0: flags=8863<UP,BROADCAST,SMART,RUNNING,SIMPLEX,MULTICAST> mtu 1500
options=63<RXCSUM,TXCSUM,TSO4,TSO6>
ether aa:00:d0:13:0e:01
Configuration:
id 0:0:0:0:0:0 priority 0 hellotime 0 fwddelay 0
maxage 0 holdcnt 0 proto stp maxaddr 100 timeout 1200
root id 0:0:0:0:0:0 priority 0 ifcost 0 port 0
ipfilter disabled flags 0x2
member: en1 flags=3<LEARNING,DISCOVER>
ifmaxaddr 0 port 9 priority 0 path cost 0
member: en2 flags=3<LEARNING,DISCOVER>
ifmaxaddr 0 port 10 priority 0 path cost 0
nd6 options=201<PERFORMNUD,DAD>
media: <unknown type>
status: inactive
utun0: flags=8051<UP,POINTOPOINT,RUNNING,MULTICAST> mtu 2000
inet6 fe80::3fe0:3e8b:384:9968%utun0 prefixlen 64 scopeid 0xc
nd6 options=201<PERFORMNUD,DAD>
utun1: flags=8051<UP,POINTOPOINT,RUNNING,MULTICAST> mtu 1380
inet6 fe80::7894:3abc:5abd:457d%utun1 prefixlen 64 scopeid 0xd
nd6 options=201<PERFORMNUD,DAD>

内容很多,我挑几点差异简述下:

  • 内容展示形式不一样,没有 Linux 下的接收、发送数据字节数等统计信息

  • 真实网卡的命名不一样:eth0 -> en0

  • 虚拟网卡的命名格式不一样:tun/tap -> utun

对于这些常见网卡命名的解读,我摘抄一部分来自 stackoverflow 的回答:

In arbitrary order of my familarity / widespread relevance:

lo0 is loopback.

en0 at one point “ethernet”, now is WiFi (and I have no idea what extra en1 or en2 are used for).

fw0 is the FireWire network interface.

stf0 is an IPv6 to IPv4 tunnel interface) to support the transition from IPv4 to the IPv6 standard.

gif0 is a more generic tunneling interface) [46]-to-[46].

awdl0 is Apple Wireless Direct Link

p2p0 is related to AWDL features. Either as an old version, or virtual interface with different semantics than awdl.

the “Network” panel in System Preferences to see what network devices “exist” or “can exist” with current configuration.

many VPNs will add additional devices, often “utun#” or “utap#” following TUN/TAP (L3/L2)virtual networking devices.

use netstat -nr to see how traffic is currently routed via network devices according to destination.

interface naming conventions started in BSD were retained in OS X / macOS, and now there also additions.

8 Dubbo 改进建议

我们进行了以上探索,算是对网卡有一点了解了。回过头来看看 Dubbo 获取网卡的逻辑,是否可以做出改进呢?

Dubbo Action 1:

保持 Ipv4 和 Ipv6 的一致性校验。为 Ipv4 增加连通性校验;为 Ipv6 增加 LoopBack 和 ANYHOST 等校验。

Dubbo Action 2:

1
2
3
4
NetworkInterface network = interfaces.nextElement();
if (network.isLoopback() || network.isVirtual()|| !network.isUp()) {
continue;
}

JDK 提供了以上的 API,我们可以利用起来,过滤一部分一定不正确的网卡。

Dubbo Action 3:

我们本文花了较多的篇幅介绍了 docker 和 TUN/TAP 两种场景导致的虚拟网卡的问题,算是较为常见的一个影响因素,虽然他们的命名具有固定性,如 docker0、tunX、tapX,但我觉得通过网卡名称的判断方式去过滤注册 IP 有一些 hack,所以不建议 dubbo contributor 提出相应的 pr 去增加这些 hack 判断,尽管可能会对判断有所帮助。

对于真实多网卡、内外网 IP 共存的场景,不能仅仅是框架侧在做努力,用户也需要做一些事,就像爱情一样,我可以主动一点,但你也得反馈,才能发展出故事。

Dubbo User Action 1:

可以配置 /etc/hosts 文件,将 hostname 对应的 IP 显示配置进去。

Dubbo User Action 2:

可以使用启动参数去显示指定注册的 IP:

1
-DDUBBO_IP_TO_REGISTRY=1.2.3.4

也可以指定 Dubbo 服务绑定在哪块网卡上:

1
-DDUBBO_IP_TO_BIND=1.2.3.4

9 参考文章

TUN/TAP 设备浅析

what-are-en0-en1-p2p-and-so-on-that-are-displayed-after-executing-ifconfig

欢迎关注我的微信公众号:「Kirito 的技术分享」,关于文章的任何疑问都会得到回复,带来更多 Java 相关的技术分享。

关注微信公众号

]]>
<p>我曾经写过一篇和本文标题类似的文章《研究优雅停机时的一点思考》,上文和本文都有一个共同点:网卡地址注册和优雅停机都是一个很小的知识点,但是背后牵扯到的知识点却是庞大的体系,我在写这类文章前基本也和大多数读者一样,处于“知道有这么个东西,但不了解细节”的阶段,但一旦深挖,会感受到其中的奇妙,并有机会接触到很多平时不太关注的知识点。</p> <p>另外,我还想介绍一个叫做”元阅读“的技巧,可能这个词是我自己造的,也有人称之为”超视角阅读“。其内涵指的是,普通读者从我的文章中学到的是某个知识点,而元阅读者从我的文章中可能会额外关注,我是如何掌握某个知识点的,在一个知识点的学习过程中我关注了哪些相关的知识点,又是如何将它们联系在一起,最终形成一个体系的。这篇文章就是一个典型的例子,我会对一些点进行发散,大家可以尝试着跟我一起来思考”网卡地址注册“这个问题。</p>
【Dubbo3.0 新特性】集成 RSocket, 新增响应式支持 http://lexburner.github.io/dubbo-rsocket/ 2019-04-11T11:19:41.000Z 2019-09-26T09:45:31.122Z 响应式编程

响应式编程现在是现在一个很热的话题。响应式编程让开发者更方便地编写高性能的异步代码,关于响应式编程更详细的信息可以参考 http://reactivex.io/ 。很可惜,在之前很长一段时间里,Dubbo 并不支持响应式编程,简单来说,Dubbo 不支持在 rpc 调用时,使用 Mono/Flux 这种流对象(reactive-stream 中流的概念 ),给用户使用带来了不便。

RSocket 是一个支持 reactive-stream 语义的开源网络通信协议,它将 reactive 语义的复杂逻辑封装了起来,使得上层可以方便实现网络程序。RSocket 详细资料:http://rsocket.io/。

Dubbo 在 3.0.0-SNAPSHOT 版本里基于 RSocket 对响应式编程提供了支持,用户可以在请求参数和返回值里使用 Mono 和 Flux 类型的对象。下面我们给出使用范例,源码可以在文末获取。

Dubbo RSocket 初体验

服务接口

1
2
3
4
public interface DemoService {
Mono<String> requestMonoWithMonoArg(Mono<String> m1, Mono<String> m2);
Flux<String> requestFluxWithFluxArg(Flux<String> f1, Flux<String> f2);
}
1
2
3
4
5
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
<version>3.2.3-RELEASE</version>
</dependency>

在服务定义层,引入了 Mono,Flux 等 reactor 的概念,所以需要添加 reactor-core 的依赖。

服务提供者

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class DemoServiceImpl implements DemoService {
@Override
public Mono<String> requestMonoWithMonoArg(Mono<String> m1, Mono<String> m2) {
return m1.zipWith(m2, new BiFunction<String, String, String>() {
@Override
public String apply(String s, String s2) {
return s+" "+s2;
}
});
}

@Override
public Flux<String> requestFluxWithFluxArg(Flux<String> f1, Flux<String> f2) {
return f1.zipWith(f2, new BiFunction<String, String, String>() {
@Override
public String apply(String s, String s2) {
return s+" "+s2;
}
});
}
}

除了常规的 Dubbo 必须依赖之外,还需要添加 dubbo-rsocket 的扩展

1
2
3
4
5
//... other dubbo moudle
<dependency>
<groupId>org.apache.dubbo</groupId>
<artifactId>dubbo-rpc-rsocket</artifactId>
</dependency>

配置并启动服务端,注意协议名字填写 rsocket:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:dubbo="http://dubbo.apache.org/schema/dubbo"
xmlns="http://www.springframework.org/schema/beans"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://dubbo.apache.org/schema/Dubbo http://dubbo.apache.org/schema/dubbo/dubbo.xsd">

<!-- provider's application name, used for tracing dependency relationship -->
<dubbo:application name="demo-provider"/>

<!-- use registry center to export service -->
<dubbo:registry address="zookeeper://127.0.0.1:2181"/>

<!-- use Dubbo protocol to export service on port 20890 -->
<dubbo:protocol name="rsocket" port="20890"/>

<!-- service implementation, as same as regular local bean -->
<bean id="demoService" class="org.apache.dubbo.samples.basic.impl.DemoServiceImpl"/>

<!-- declare the service interface to be exported -->
<dubbo:service interface="org.apache.dubbo.samples.basic.api.DemoService" ref="demoService"/>

</beans>

服务提供者的 bootstrap:

1
2
3
4
5
6
7
8
9
public class RsocketProvider {

public static void main(String[] args) throws Exception {
ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext(new String[]{"spring/rsocket-provider.xml"});
context.start();
System.in.read(); // press any key to exit
}

}

服务消费者

然后配置并启动消费者消费者如下, 注意协议名填写 rsocket:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<beans xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:dubbo="http://dubbo.apache.org/schema/Dubbo"
xmlns="http://www.springframework.org/schema/beans"
xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd
http://dubbo.apache.org/schema/dubbo http://dubbo.apache.org/schema/dubbo/dubbo.xsd">

<!-- consumer's application name, used for tracing dependency relationship (not a matching criterion),
don't set it same as provider -->
<dubbo:application name="demo-consumer"/>

<!-- use registry center to discover service -->
<dubbo:registry address="zookeeper://127.0.0.1:2181"/>

<!-- generate proxy for the remote service, then demoService can be used in the same way as the
local regular interface -->
<dubbo:reference id="demoService" check="true" interface="org.apache.dubbo.samples.basic.api.DemoService"/>

</beans>
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
public class RsocketConsumer {

public static void main(String[] args) {
ClassPathXmlApplicationContext context = new ClassPathXmlApplicationContext(new String[]{"spring/rsocket-consumer.xml"});
context.start();
DemoService demoService = (DemoService) context.getBean("demoService"); // get remote service proxy

while (true) {
try {
Mono<String> monoResult = demoService.requestMonoWithMonoArg(Mono.just("A"), Mono.just("B"));
monoResult.doOnNext(new Consumer<String>() {
@Override
public void accept(String s) {
System.out.println(s);
}
}).block();

Flux<String> fluxResult = demoService.requestFluxWithFluxArg(Flux.just("A","B","C"), Flux.just("1","2","3"));
fluxResult.doOnNext(new Consumer<String>() {
@Override
public void accept(String s) {
System.out.println(s);
}
}).blockLast();

} catch (Throwable throwable) {
throwable.printStackTrace();
}
}
}
}

可以看到配置上除了协议名使用 rsocket 以外其他并没有特殊之处。

实现原理

以前用户并不能在参数或者返回值里使用 Mono/Flux 这种流对象(reactive-stream 里的流的概念)。因为流对象自带异步属性,当业务把流对象作为参数或者返回值传递给框架之后,框架并不能将流对象正确的进行序列化。

Dubbo 基于 RSocket 提供了 reactive 支持。RSocket 将 reactive 语义的复杂逻辑封装起来了,给上层提供了简洁的抽象如下:

1
2
3
4
5
6
7
Mono<Void> fireAndForget(Payload payload);

Mono<Payload> requestResponse(Payload payload);

Flux<Payload> requestStream(Payload payload);

Flux<Payload> requestChannel(Publisher<Payload> payloads);

  • 从客户端视角看,框架建立连接之后,只需要将请求信息编码到 Payload 里,然后通过 requestStream 方法即可向服务端发起请求。
  • 从服务端视角看,RSocket 收到请求之后,会调用我们实现的 requestStream 方法,我们从 Payload 里解码得到请求信息之后,调用业务方法,然后拿到 Flux 类型的返回值即可。

需要注意的是业务返回值一般是 Flux<BizDO>,而 RSocket 要求的是 Flux<Payload>,所以我们需要通过 map operator 拦截业务数据,将 BizDO 编码为 Payload 才可以递交给 RSocket。而 RSocket 会负责数据的传输和 reactive 语义的实现。

结语

Dubbo 2.7 相比 Dubbo 2.6 提供了 CompletableFuture 的异步化支持,在 Dubbo 3.0 又继续拥抱了 Reactive,不断对新特性的探索,无疑是增加了使用者的信心。RSocket 这一框架 / 协议,如今在国内外也是比较火的一个概念,它提供了丰富的 Reactive 语义以及多语言的支持,使得服务治理框架可以很快地借助它实现 Reactive 语义。有了响应式编程支持,业务可以更加方便的实现异步逻辑。

本篇文章对 Dubbo RSocket 进行了一个简单的介绍,对 Reactive、RSocket 感兴趣的同学也可以浏览下 Dubbo 3.0 源码对 RSocket 的封装。

相关链接:

[1] 文中源码:https://github.com/apache/incubator-dubbo-samples/tree/3.x/dubbo-samples-rsocket

[2] Dubbo 3.x 开发分支:https://github.com/apache/incubator-Dubbo/tree/3.x-dev

]]>
<h2 id="响应式编程"><a href="#响应式编程" class="headerlink" title="响应式编程"></a>响应式编程</h2><p>响应式编程现在是现在一个很热的话题。响应式编程让开发者更方便地编写高性能的异步代码,关于响应式编程更详细的信息可以参考 <a href="http://reactivex.io/" target="_blank" rel="noopener">http://reactivex.io/</a> 。很可惜,在之前很长一段时间里,Dubbo 并不支持响应式编程,简单来说,Dubbo 不支持在 rpc 调用时,使用 Mono/Flux 这种流对象(reactive-stream 中流的概念 ),给用户使用带来了不便。</p> <p>RSocket 是一个支持 reactive-stream 语义的开源网络通信协议,它将 reactive 语义的复杂逻辑封装了起来,使得上层可以方便实现网络程序。RSocket 详细资料:<a href="http://rsocket.io/。" target="_blank" rel="noopener">http://rsocket.io/。</a></p> <p>Dubbo 在 <a href="https://github.com/apache/incubator-Dubbo/tree/3.x-dev" target="_blank" rel="noopener">3.0.0-SNAPSHOT</a> 版本里基于 RSocket 对响应式编程提供了支持,用户可以在请求参数和返回值里使用 Mono 和 Flux 类型的对象。下面我们给出使用范例,源码可以在文末获取。</p>
Dubbo2.7 三大新特性详解 http://lexburner.github.io/dubbo27-features/ 2019-03-21T06:12:40.000Z 2019-09-26T09:45:30.083Z 1 背景介绍

自 2017 年 7 月阿里重启 Dubbo 开源,到目前为止 github star 数,contributor 数都有了非常大的提升。2018 年 2 月 9 日阿里决定将 Dubbo 项目贡献给 Apache,经过一周的投票,顺利成为了 Apache 的孵化项目,也就是大家现在看到的 Incubator Dubbo。预计在 2019 年 4 月,Dubbo 可以达成毕业,成为 Apache 的顶级项目。

2 分支介绍

分支

Dubbo 目前有如图所示的 5 个分支,其中 2.7.1-release 只是一个临时分支,忽略不计,对其他 4 个分支进行介绍。

  • 2.5.x 近期已经通过投票,Dubbo 社区即将停止对其的维护。
  • 2.6.x 为长期支持的版本,也是 Dubbo 贡献给 Apache 之前的版本,其包名前缀为:com.alibaba,JDK 版本对应 1.6。
  • 3.x-dev 是前瞻性的版本,对 Dubbo 进行一些高级特性的补充,如支持 rx 特性。
  • master 为长期支持的版本,版本号为 2.7.x,也是 Dubbo 贡献给 Apache 的开发版本,其包名前缀为:org.apache,JDK 版本对应 1.8。

如果想要研究 Dubbo 的源码,建议直接浏览 master 分支。

3 Dubbo 2.7 新特性

Dubbo 2.7.x 作为 Apache 的孵化版本,除了代码优化之外,还新增了许多重磅的新特性,本文将会介绍其中最典型的三个新特性:

  • 异步化改造
  • 三大中心改造
  • 服务治理增强

4 异步化改造

4.1 几种调用方式

调用方式

在远程方法调用中,大致可以分为这 4 种调用方式。oneway 指的是客户端发送消息后,不需要接受响应。对于那些不关心服务端响应的请求,比较适合使用 oneway 通信。

注意,void hello() 方法在远程方法调用中,不属于 oneway 调用,虽然 void 方法表达了不关心返回值的语义,但在 RPC 层面,仍然需要做通信层的响应。

sync 是最常用的通信方式,也是默认的通信方法。

future 和 callback 都属于异步调用的范畴,他们的区别是:在接收响应时,future.get() 会导致线程的阻塞;callback 通常会设置一个回调线程,当接收到响应时,自动执行,不会对当前线程造成阻塞。

4.2 Dubbo 2.6 异步化

异步化的优势在于客户端不需要启动多线程即可完成并行调用多个远程服务,相对多线程开销较小。介绍 2.7 中的异步化改造之前,先回顾一下如何在 2.6 中使用 Dubbo 异步化的能力。

  1. 将同步接口声明成 async=true

    1
    <dubbo:reference id="asyncService" interface="org.apache.dubbo.demo.api.AsyncService" async="true"/>
    1
    2
    3
    public interface AsyncService {
    String sayHello(String name);
    }
  2. 通过上下文类获取 future

    1
    2
    3
    AsyncService.sayHello("Han Meimei");
    Future<String> fooFuture = RpcContext.getContext().getFuture();
    fooFuture.get();

可以看出,这样的使用方式,不太符合异步编程的习惯,竟然需要从一个上下文类中获取到 Future。如果同时进行多个异步调用,使用不当很容易造成上下文污染。而且,Future 并不支持 callback 的调用方式。这些弊端在 Dubbo 2.7 中得到了改进。

4.3 Dubbo 2.7 异步化

  1. 无需配置中特殊声明,显示声明异步接口即可

    1
    2
    3
    4
    5
    6
    public interface AsyncService {
    String sayHello(String name);
    default CompletableFuture<String> sayHiAsync(String name) {
    return CompletableFuture.completedFuture(sayHello(name));
    }
    }
  2. 使用 callback 方式处理返回值

    1
    2
    3
    4
    5
    6
    7
    8
    CompletableFuture<String> future = asyncService.sayHiAsync("Han MeiMei");
    future.whenComplete((retValue, exception) -> {
    if (exception == null) {
    System.out.println(retValue);
    } else {
    exception.printStackTrace();
    }
    });

Dubbo 2.7 中使用了 JDK1.8 提供的 CompletableFuture 原生接口对自身的异步化做了改进。CompletableFuture 可以支持 future 和 callback 两种调用方式,用户可以根据自己的喜好和场景选择使用,非常灵活。

4.4 异步化设计 FAQ

Q:如果 RPC 接口只定义了同步接口,有办法使用异步调用吗?

A:2.6 中的异步调用唯一的优势在于,不需要在接口层面做改造,又可以进行异步调用,这种方式仍然在 2.7 中保留;使用 Dubbo 官方提供的 compiler hacker,编译期自动重写同步方法,请 在此 讨论和跟进具体进展。


Q:关于异步接口的设计问题,为何不提供编译插件,根据原接口,自动编译出一个 XxxAsync 接口?

A:Dubbo 2.7 采用采用过这种设计,但接口的膨胀会导致服务类的增量发布,而且接口名的变化会影响服务治理的一些相关逻辑,改为方法添加 Async 后缀相对影响范围较小。


Q:Dubbo 分为了客户端异步和服务端异步,刚刚你介绍的是客户端异步,为什么不提服务端异步呢?

A:Dubbo 2.7 新增了服务端异步的支持,但实际上,Dubbo 的业务线程池模型,本身就可以理解为异步调用,个人认为服务端异步的特性较为鸡肋。

5 三大中心改造

三大中心指的:注册中心,元数据中心,配置中心。

在 2.7 之前的版本,Dubbo 只配备了注册中心,主流使用的注册中心为 zookeeper。新增加了元数据中心和配置中心,自然是为了解决对应的痛点,下面我们来详细阐释三大中心改造的原因。

5.1 元数据改造

元数据是什么?元数据定义为描述数据的数据,在服务治理中,例如服务接口名,重试次数,版本号等等都可以理解为元数据。在 2.7 之前,元数据一股脑丢在了注册中心之中,这造成了一系列的问题:

推送量大 -> 存储数据量大 -> 网络传输量大 -> 延迟严重

生产者端注册 30+ 参数,有接近一半是不需要作为注册中心进行传递;消费者端注册 25+ 参数,只有个别需要传递给注册中心。有了以上的理论分析,Dubbo 2.7 进行了大刀阔斧的改动,只将真正属于服务治理的数据发布到注册中心之中,大大降低了注册中心的负荷。

同时,将全量的元数据发布到另外的组件中:元数据中心。元数据中心目前支持 redis(推荐),zookeeper。这也为 Dubbo 2.7 全新的 Dubbo Admin 做了准备,关于新版的 Dubbo Admin,我将会后续准备一篇独立的文章进行介绍。

示例:使用 zookeeper 作为元数据中心

1
<dubbo:metadata-report address="zookeeper://127.0.0.1:2181"/>

5.2 Dubbo 2.6 元数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
dubbo://30.5.120.185:20880/com.alibaba.dubbo.demo.DemoService?
anyhost=true&
application=demo-provider&
interface=com.alibaba.dubbo.demo.DemoService&
methods=sayHello&
bean.name=com.alibaba.dubbo.demo.DemoService&
dubbo=2.0.2&
executes=4500&
generic=false&
owner=kirito&
pid=84228&
retries=7&
side=provider&
timestamp=1552965771067

从本地的 zookeeper 中取出一条服务数据,通过解码之后,可以看出,的确有很多参数是不必要。

5.3 Dubbo 2.7 元数据

在 2.7 中,如果不进行额外的配置,zookeeper 中的数据格式仍然会和 Dubbo 2.6 保持一致,这主要是为了保证兼容性,让 Dubbo 2.6 的客户端可以调用 Dubbo 2.7 的服务端。如果整体迁移到 2.7,则可以为注册中心开启简化配置的参数:

1
<dubbo:registry address=“zookeeper://127.0.0.1:2181” simplified="true"/>

Dubbo 将会只上传那些必要的服务治理数据,一个简化过后的数据如下所示:

1
2
3
4
5
dubbo://30.5.120.185:20880/org.apache.dubbo.demo.api.DemoService?
application=demo-provider&
dubbo=2.0.2&
release=2.7.0&
timestamp=1552975501873

对于那些非必要的服务信息,仍然全量存储在元数据中心之中:

元数据

元数据中心的数据可以被用于服务测试,服务 MOCK 等功能。目前注册中心配置中 simplified 的默认值为 false,因为考虑到了迁移的兼容问题,在后续迭代中,默认值将会改为 true。

5.4 配置中心支持

衡量配置中心的必要性往往从三个角度出发:

  1. 分布式配置统一管理

  2. 动态变更推送

  3. 安全性

Spring Cloud Config, Apollo, Nacos 等分布式配置中心组件都对上述功能有不同程度的支持。在 2.7 之前的版本中,在 zookeeper 中设置了部分节点:configurators,routers,用于管理部分配置和路由信息,它们可以理解为 Dubbo 配置中心的雏形。在 2.7 中,Dubbo 正式支持了配置中心,目前支持的几种注册中心 Zookeeper,Apollo,Nacos(2.7.1-release 支持)。

在 Dubbo 中,配置中心主要承担了两个作用

  • 外部化配置。启动配置的集中式存储

  • 服务治理。服务治理规则的存储与通知

示例:使用 Zookeeper 作为配置中心

1
<dubbo:config-center address="zookeeper://127.0.0.1:2181"/>

引入配置中心后,需要注意配置项的覆盖问题,优先级如图所示

配置覆盖优先级

6 服务治理增强

我更倾向于将 Dubbo 当做一个服务治理框架,而不仅仅是一个 RPC 框架。在 2.7 中,Dubbo 对其服务治理能力进行了增强,增加了标签路由的能力,并抽象出了应用路由和服务路由的概念。在最后一个特性介绍中,着重对标签路由 TagRouter 进行探讨。

在服务治理中,路由层和负载均衡层的对比。区别 1,Router:m 选 n,LoadBalance:n 选 1;区别 2,路由往往是叠加使用的,负载均衡只能配置一种。

在很长的一段时间内,Dubbo 社区经常有人提的一个问题是:Dubbo 如何实现流量隔离和灰度发布,直到 2.7 提供了标签路由,用户可以使用这个功能,来实现上述的需求。

标签路由

标签路由提供了这样一个能力,当调用链路为 A -> B -> C -> D 时,用户给请求打标,最典型的打标方式可以借助 attachment(他可以在分布式调用中传递下去),调用会优先请求那些匹配的服务端,如 A -> B,C -> D,由于集群中未部署 C 节点,则会降级到普通节点。

打标方式会收到集成系统差异的影响,从而导致很大的差异,所以 Dubbo 只提供了 RpcContext.getContext().setAttachment() 这样的基础接口,用户可以使用 SPI 扩展,或者 server filter 的扩展,对测试流量进行打标,引导进入隔离环境 / 灰度环境。

新版的 Dubbo Admin 提供了标签路由的配置项:

标签路由配置

Dubbo 用户可以在自己系统的基础上对标签路由进行二次扩展,或者借鉴标签路由的设计,实现自己系统的流量隔离,灰度发布。

7 总结

本文介绍了 Dubbo 2.7 比较重要的三大新特性:异步化改造,三大中心改造,服务治理增强。Dubbo 2.7 还包含了很多功能优化、特性升级,可以在项目源码的 CHANGES.md 中浏览全部的改动点。最后提供一份 Dubbo 2.7 的升级文档:2.7 迁移文档,欢迎体验。

欢迎关注我的微信公众号:「Kirito 的技术分享」,关于文章的任何疑问都会得到回复,带来更多 Java 相关的技术分享。

关注微信公众号

]]>
<h2 id="1-背景介绍"><a href="#1-背景介绍" class="headerlink" title="1 背景介绍"></a>1 背景介绍</h2><p>自 2017 年 7 月阿里重启 Dubbo 开源,到目前为止 github star 数,contributor 数都有了非常大的提升。2018 年 2 月 9 日阿里决定将 Dubbo 项目贡献给 Apache,经过一周的投票,顺利成为了 Apache 的孵化项目,也就是大家现在看到的 <strong>Incubator Dubbo</strong>。预计在 2019 年 4 月,Dubbo 可以达成毕业,成为 Apache 的顶级项目。</p>
一文探讨堆外内存的监控与回收 http://lexburner.github.io/nio-buffer-recycle/ 2019-03-17T06:12:40.000Z 2019-09-26T09:45:30.702Z 引子

记得那是一个风和日丽的周末,太阳红彤彤,花儿五颜六色,96 年的普哥微信找到我,描述了一个诡异的线上问题:线上程序使用了 NIO FileChannel 的 堆内内存(HeapByteBuffer)作为缓冲区,读写文件,逻辑可以说相当简单,但根据监控,却发现堆外内存(DirectByteBuffer)飙升,导致了 OutOfMemeory 的异常。

由这个线上问题,引出了这篇文章的主题,主要包括:FileChannel 源码分析,堆外内存监控,堆外内存回收。

问题分析 & 源码分析

根据异常日志的定位,发现的确使用的是 HeapByteBuffer 来进行读写,但却导致堆外内存飙升,随即翻了 FileChannel 的源码,来一探究竟。

FileChannel 使用的是 IOUtil 进行读写操作(本文只分析读的逻辑,写和读的代码逻辑一致,不做重复分析)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//sun.nio.ch.IOUtil#read
static int read(FileDescriptor var0, ByteBuffer var1, long var2, NativeDispatcher var4) throws IOException {
if (var1.isReadOnly()) {
throw new IllegalArgumentException("Read-only buffer");
} else if (var1 instanceof DirectBuffer) {
return readIntoNativeBuffer(var0, var1, var2, var4);
} else {
ByteBuffer var5 = Util.getTemporaryDirectBuffer(var1.remaining());
int var7;
try {
int var6 = readIntoNativeBuffer(var0, var5, var2, var4);
var5.flip();
if (var6 > 0) {
var1.put(var5);
}
var7 = var6;
} finally {
Util.offerFirstTemporaryDirectBuffer(var5);
}
return var7;
}
}

可以发现当使用 HeapByteBuffer 时,会走到下面这行看似有点疑问的代码分支:

1
Util.getTemporaryDirectBuffer(var1.remaining());

这个 Util 封装了更为底层的一些 IO 逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
package sun.nio.ch;
public class Util {
private static ThreadLocal<Util.BufferCache> bufferCache;

public static ByteBuffer getTemporaryDirectBuffer(int var0) {
if (isBufferTooLarge(var0)) {
return ByteBuffer.allocateDirect(var0);
} else {
// FOUCS ON THIS LINE
Util.BufferCache var1 = (Util.BufferCache)bufferCache.get();
ByteBuffer var2 = var1.get(var0);
if (var2 != null) {
return var2;
} else {
if (!var1.isEmpty()) {
var2 = var1.removeFirst();
free(var2);
}

return ByteBuffer.allocateDirect(var0);
}
}
}
}

isBufferTooLarge 这个方法会根据传入 Buffer 的大小决定如何分配堆外内存,如果过大,直接分配大缓冲区;如果不是太大,会使用 bufferCache 这个 ThreadLocal 变量来进行缓存,从而复用(实际上这个数值非常大,几乎不会走进直接分配堆外内存这个分支)。这么看来似乎发现了两个不得了的结论:

  1. 使用 HeapByteBuffer 读写都会经过 DirectByteBuffer,写入数据的流转方式其实是:HeapByteBuffer -> DirectByteBuffer -> PageCache -> Disk,读取数据的流转方式正好相反。
  2. 使用 HeapByteBuffer 读写会申请一块跟线程绑定的 DirectByteBuffer。这意味着,线程越多,临时 DirectByteBuffer 就越会占用越多的空间。

看到这儿,线上的问题似乎有了一点眉目:很有可能是多线程使用 HeapByteBuffer 写入文件,而额外分配的这块 DirectByteBuffer 导致了内存溢出。在验证这个猜测之前,我们最好能直观地监控到堆外内存的使用量,这才能增加我们定位问题的信心。

实现堆外内存的监控

JDK 提供了一个非常好用的监控工具 —— Java VisualVM。我们只需要为它安装一个插件,即可很方便地实现堆外内存的监控。

进入本地 JDK 的可执行目录(在我本地是:/Library/Java/JavaVirtualMachines/jdk1.8.0_181.jdk/Contents/Home/bin),找到 jvisualvm 命令,双击打开一个可视化的界面

jvisualVM 主界面

左侧树状目录可以选择需要监控的 Java 进程,右侧是监控的维度信息,除了 CPU、线程、堆、类等信息,还可以通过上方的【工具 (T)】 安装插件,增加 MBeans、Buffer Pools 等维度的监控。

jvisualVM 插件

Buffer Pools 插件可以监控堆外内存(包含 DirectByteBuffer 和 MappedByteBuffer),如下图所示:

image-20190315194327416

左侧对应 DirectByteBuffer,右侧对应 MappedByteBuffer。

复现问题

为了复现线上的问题,我们使用一个程序,不断开启线程使用堆内内存作为缓冲区进行文件的读取操作,并监控该进程的堆外内存使用情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class ReadByHeapByteBufferTest {
public static void main(String[] args) throws IOException, InterruptedException {
File data = new File("/tmp/data.txt");
FileChannel fileChannel = new RandomAccessFile(data, "rw").getChannel();
ByteBuffer buffer = ByteBuffer.allocate(4 * 1024 * 1024);
for (int i = 0; i < 1000; i++) {
Thread.sleep(1000);
new Thread(new Runnable() {
@Override
public void run() {
try {
fileChannel.read(buffer);
buffer.clear();
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
}
}
}

运行一段时间后,我们观察下堆外内存的使用情况

image-20190315194712532

如上图左所示,堆外内存的确开始疯涨了,的确符合我们的预期,堆外缓存和线程绑定,当线程非常多时,即使只使用了 4M 的堆内内存,也可能会造成极大的堆外内存膨胀,在中间发生了一次断崖,推测是线程执行完毕 or GC,导致了内存的释放。

知晓了这一点,相信大家今后使用堆内内存时可能就会更加注意了,我总结了两个注意点:

  1. 使用 HeapByteBuffer 还需要经过一次 DirectByteBuffer 的拷贝,在追求极致性能的场景下是可以通过直接复用堆外内存来避免的。
  2. 多线程下使用 HeapByteBuffer 进行文件读写,要注意 ThreadLocal<Util.BufferCache> bufferCache 导致的堆外内存膨胀的问题。

问题深究

那大家有没有想过,为什么 JDK 要如此设计?为什么不直接使用堆内内存写入 PageCache 进而落盘呢?为什么一定要经过 DirectByteBuffer 的拷贝呢?

在知乎的相关问题中,R 大和 曾泽堂 两位同学进行了解答,是我比较认同的解释:

作者:RednaxelaFX

链接:https://www.zhihu.com/question/57374068/answer/152691891

来源:知乎

这里其实是在迁就 OpenJDK 里的 HotSpot VM 的一点实现细节。

HotSpot VM 里的 GC 除了 CMS 之外都是要移动对象的,是所谓“compacting GC”。

如果要把一个 Java 里的 byte[] 对象的引用传给 native 代码,让 native 代码直接访问数组的内容的话,就必须要保证 native 代码在访问的时候这个 byte[] 对象不能被移动,也就是要被“pin”(钉)住。

可惜 HotSpot VM 出于一些取舍而决定不实现单个对象层面的 object pinning,要 pin 的话就得暂时禁用 GC——也就等于把整个 Java 堆都给 pin 住。

所以 Oracle/Sun JDK / OpenJDK 的这个地方就用了点绕弯的做法。它假设把 HeapByteBuffer 背后的 byte[] 里的内容拷贝一次是一个时间开销可以接受的操作,同时假设真正的 I/O 可能是一个很慢的操作。

于是它就先把 HeapByteBuffer 背后的 byte[] 的内容拷贝到一个 DirectByteBuffer 背后的 native memory 去,这个拷贝会涉及 sun.misc.Unsafe.copyMemory() 的调用,背后是类似 memcpy() 的实现。这个操作本质上是会在整个拷贝过程中暂时不允许发生 GC 的。

然后数据被拷贝到 native memory 之后就好办了,就去做真正的 I/O,把 DirectByteBuffer 背后的 native memory 地址传给真正做 I/O 的函数。这边就不需要再去访问 Java 对象去读写要做 I/O 的数据了。

总结一下就是:

  • 为了方便 GC 的实现,DirectByteBuffer 指向的 native memory 是不受 GC 管辖的
  • HeapByteBuffer 背后使用的是 byte 数组,其占用的内存不一定是连续的,不太方便 JNI 方法的调用
  • 数组实现在不同 JVM 中可能会不同

堆外内存的回收

继续深究下一个话题,也是我的微信交流群中曾经有人提出过的一个疑问,到底该如何回收 DirectByteBuffer?既然可以监控堆外内存,那验证堆外内存的回收就变得很容易实现了。

CASE 1:分配 1G 的 DirectByteBuffer,等待用户输入后,复制为 null,之后阻塞持续观察堆外内存变化

1
2
3
4
5
6
7
8
public class WriteByDirectByteBufferTest {
public static void main(String[] args) throws IOException, InterruptedException {
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 1024);
System.in.read();
buffer = null;
new CountDownLatch(1).await();
}
}

image-20190315201608522

结论:变量虽然置为了 null,但内存依旧持续占用。

CASE 2:分配 1G DirectByteBuffer,等待用户输入后,复制为 null,手动触发 GC,之后阻塞持续观察堆外内存变化

1
2
3
4
5
6
7
8
9
public class WriteByDirectByteBufferTest {
public static void main(String[] args) throws IOException, InterruptedException {
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 1024);
System.in.read();
buffer = null;
System.gc();
new CountDownLatch(1).await();
}
}

GC

结论:GC 时会触发堆外空闲内存的回收。

CASE 3:分配 1G DirectByteBuffer,等待用户输入后,手动回收堆外内存,之后阻塞持续观察堆外内存变化

1
2
3
4
5
6
7
8
public class WriteByDirectByteBufferTest {
public static void main(String[] args) throws IOException, InterruptedException {
ByteBuffer buffer = ByteBuffer.allocateDirect(1024 * 1024 * 1024);
System.in.read();
((DirectBuffer) buffer).cleaner().clean();
new CountDownLatch(1).await();
}
}

手动回收

结论:手动回收可以立刻释放堆外内存,不需要等待到 GC 的发生。

对于 MappedByteBuffer 这个有点神秘的类,它的回收机制大概和 DirectByteBuffer 类似,体现在右边的 Mapped 之中,我们就不重复 CASE1 和 CASE2 的测试了,直接给出结论,在 GC 发生或者操作系统主动清理时 MappedByteBuffer 会被回收。但也不是不进行测试,我们会对 MappedByteBuffer 进行更有意思的研究。

CASE 4:手动回收 MappedByteBuffer。

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
public class MmapUtil {
public static void clean(MappedByteBuffer mappedByteBuffer) {
ByteBuffer buffer = mappedByteBuffer;
if (buffer == null || !buffer.isDirect() || buffer.capacity()== 0)
return;
invoke(invoke(viewed(buffer), "cleaner"), "clean");
}

private static Object invoke(final Object target, final String methodName, final Class<?>... args) {
return AccessController.doPrivileged(new PrivilegedAction<Object>() {
public Object run() {
try {
Method method = method(target, methodName, args);
method.setAccessible(true);
return method.invoke(target);
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
});
}

private static Method method(Object target, String methodName, Class<?>[] args)
throws NoSuchMethodException {
try {
return target.getClass().getMethod(methodName, args);
} catch (NoSuchMethodException e) {
return target.getClass().getDeclaredMethod(methodName, args);
}
}

private static ByteBuffer viewed(ByteBuffer buffer) {
String methodName = "viewedBuffer";
Method[] methods = buffer.getClass().getMethods();
for (int i = 0; i < methods.length; i++) {
if (methods[i].getName().equals("attachment")) {
methodName = "attachment";
break;
}
}
ByteBuffer viewedBuffer = (ByteBuffer) invoke(buffer, methodName);
if (viewedBuffer == null)
return buffer;
else
return viewed(viewedBuffer);
}
}

这个类曾经在我的《文件 IO 的一些最佳实践》中有所介绍,在这里我们将验证它的作用。编写测试类:

1
2
3
4
5
6
7
8
9
10
11
public class WriteByMappedByteBufferTest {
public static void main(String[] args) throws IOException, InterruptedException {
File data = new File("/tmp/data.txt");
data.createNewFile();
FileChannel fileChannel = new RandomAccessFile(data, "rw").getChannel();
MappedByteBuffer map = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, 1024L * 1024 * 1024);
System.in.read();
MmapUtil.clean(map);
new CountDownLatch(1).await();
}
}

mmap 手动回收

结论:通过一顿复杂的反射操作,成功地手动回收了 Mmap 的内存映射。

CASE 5:测试 Mmap 的内存占用

1
2
3
4
5
6
7
8
9
10
11
12
public class WriteByMappedByteBufferTest {
public static void main(String[] args) throws IOException, InterruptedException {
File data = new File("/tmp/data.txt");
data.createNewFile();
FileChannel fileChannel = new RandomAccessFile(data, "rw").getChannel();
for (int i = 0; i < 1000; i++) {
fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, 1024L * 1024 * 1024);
}
System.out.println("map finish");
new CountDownLatch(1).await();
}
}

我尝试映射了 1000G 的内存,我的电脑显然没有 1000G 这么大内存,那么监控是如何反馈的呢?

mmap 映射 1000G

几乎在瞬间,控制台打印出了 map finish 的日志,也意味着 1000G 的内存映射几乎是不耗费时间的,为什么要做这个测试?就是为了解释内存映射并不等于内存占用,很多文章认为内存映射这种方式可以大幅度提升文件的读写速度,并宣称“写 MappedByteBuffer 就等于写内存”,实际是非常错误的认知。通过控制面板可以查看到该 Java 进程(pid 39040)实际占用的内存,仅仅不到 100M。(关于 Mmap 的使用场景和方式可以参考我之前的文章)

实际消耗内存

结论:MappedByteBuffer 映射出一片文件内容之后,不会全部加载到内存中,而是会进行一部分的预读(体现在占用的那 100M 上),MappedByteBuffer 不是文件读写的银弹,它仍然依赖于 PageCache 异步刷盘的机制。 通过 Java VisualVM 可以监控到 mmap 总映射的大小,但并不是实际占用的内存量

总结

本文借助一个线上问题,分析了使用堆内内存仍然会导致堆外内存分析的现象以及背后 JDK 如此设计的原因,并借助安装了插件之后的 Java VisualVM 工具进行了堆外内存的监控,进而讨论了如何正确的回收堆外内存,以及纠正了一个关于 MappedByteBuffer 的错误认知。

欢迎关注我的微信公众号:「Kirito 的技术分享」,关于文章的任何疑问都会得到回复,带来更多 Java 相关的技术分享。

关注微信公众号

]]>
<h2 id="引子"><a href="#引子" class="headerlink" title="引子"></a>引子</h2><p>记得那是一个风和日丽的周末,太阳红彤彤,花儿五颜六色,96 年的普哥微信找到我,描述了一个诡异的线上问题:线上程序使用了 NIO FileChannel 的 堆内内存(HeapByteBuffer)作为缓冲区,读写文件,逻辑可以说相当简单,但根据监控,却发现堆外内存(DirectByteBuffer)飙升,导致了 OutOfMemeory 的异常。</p> <p>由这个线上问题,引出了这篇文章的主题,主要包括:FileChannel 源码分析,堆外内存监控,堆外内存回收。</p>
Dubbo 的前世今生 & Dubbo Meetup 南京 http://lexburner.github.io/dubbo-meetup-nj/ 2019-03-08T06:12:40.000Z 2019-09-26T09:45:30.305Z Dubbo 的前世今生

2011 年 10 月 27 日,阿里巴巴开源了自己的 SOA 服务化治理方案的核心框架 Dubbo,服务治理和 SOA 的设计理念开始逐渐在国内软件行业中落地,并被广泛应用。自开源后,许多非阿里系公司选择使用 Dubbo,其中既有当当网、网易考拉等互联网公司,也有中国人寿、青岛海尔等传统企业。

2012 年 10 月 23 日 Dubbo 2.5.3 发布后,在 Dubbo 开源将满一周年之际,阿里基本停止了对 Dubbo 的主要升级。

2013 年,2014 年,更新了 2 次 Dubbo 2.4 的维护版本,然后停止了所有维护工作。至此,Dubbo 对 Srping 的支持也停留在了 Spring 2.5.6 版本上。

阿里停止维护和升级 Dubbo 期间,当当网开始维护自己的 Dubbo 分支版本 Dubbox,新增支持了新版本的 Spring,支持了 Rest 协议等,并对外开源了 Dubbox。同时,网易考拉也维护了自己的独立分支 Dubbok,可惜并未对外开源。

2017 年 9 月 7 日,Dubbo 悄悄在 GitHub 发布了 2.5.4 版本。随后,又迅速发布了 2.5.5、2.5.6、2.5.7 等版本。在 10 月举行的云栖大会上,阿里宣布 Dubbo 被列入集团重点维护开源项目,这也就意味着 Dubbo 起死回生,开始重新进入快车道。

2018 年 1 月 8 日,Dubbo 2.6.0 版本发布,新版本将之前当当网开源的 Dubbox 进行了合并,实现了 Dubbo 版本的统一整合。

2018 年 2 月 9 日,Apache 基金会的邮件列表上发起了讨论是否接纳阿里的 Dubbo 项目进入 Apache 孵化器的投票。经过一周的投票,邮件列表显示,Dubbo 获得了 14 张赞成票,在无弃权和反对票的情况下,正式通过投票,顺利成为 Apache 基金会孵化项目。

自此,Dubbo 开始了两个长期维护的版本,Dubbo 2.6.x (包名:com.alibaba)稳定维护版本和 Dubbo 2.7.x (包名:org.apache)apache 孵化版本。

2018 ~ 2019 年,在此期间,Dubbo 发布了 4、5 个版本,并发布了 nodejs,python,go 等多语言的客户端。在此期间,Dubbo 社区相继在北京、上海、深圳、成都、杭州等地举办了开发者沙龙。

2019 年 1 月,2.7.0 release 版本发布,这个即将毕业的 apache 版本支持了丰富的新特性,全新的 Dubbo Ops 控制台。

报名 | Dubbo Meetup 南京

meetup

欢迎关注我的微信公众号:「Kirito 的技术分享」,关于文章的任何疑问都会得到回复,带来更多 Java 相关的技术分享。

关注微信公众号

]]>
<h3 id="Dubbo-的前世今生"><a href="#Dubbo-的前世今生" class="headerlink" title="Dubbo 的前世今生"></a>Dubbo 的前世今生</h3><p>2011 年 10 月 27 日,阿里巴巴开源了自己的 SOA 服务化治理方案的核心框架 Dubbo,服务治理和 SOA 的设计理念开始逐渐在国内软件行业中落地,并被广泛应用。自开源后,许多非阿里系公司选择使用 Dubbo,其中既有当当网、网易考拉等互联网公司,也有中国人寿、青岛海尔等传统企业。</p>
Java 文件 IO 操作之 DirectIO http://lexburner.github.io/direct-io/ 2019-03-02T07:45:13.000Z 2019-09-26T09:46:40.510Z 在前文《文件 IO 操作的一些最佳实践》中,我介绍了一些 Java 中常见的文件操作的接口,并且就 PageCache 和 DIrect IO 进行了探讨,最近我自己封装了一个 Direct IO 的库,趁着这个机会,本文重点谈谈 Java 中 Direct IO 的意义,以及简单介绍下我自己的轮子。

Java 中的 Direct IO

如果你阅读过我之前的文章,应该已经了解 Java 中常用的文件操作接口为:FileChannel,并且没有直接操作 Direct IO 的接口。这也就意味着 Java 无法绕开 PageCache 直接对存储设备进行读写,但对于使用 Java 语言来编写的数据库,消息队列等产品而言,的确存在绕开 PageCache 的需求:

  • PageCache 属于操作系统层面的概念,用户层面很难干预,User BufferCache 显然比 Kernel PageCache 要可控
  • 现代操作系统会使用尽可能多的空闲内存来充当 PageCache,当操作系统回收 PageCache 内存的速度低于应用写缓存的速度时,会影响磁盘写入的速率,直接表现为写入 RT 增大,这被称之为“毛刺现象”

PageCache 可能会好心办坏事,采用 Direct IO + 自定义内存管理机制会使得产品更加的可控,高性能。

Direct IO 的限制

在 Java 中使用 Direct IO 最终需要调用到 c 语言的 pwrite 接口,并设置 O_DIRECT flag,使用 O_DIRECT 存在不少限制

  • 操作系统限制:Linux 操作系统在 2.4.10 及以后的版本中支持 O_DIRECT flag,老版本会忽略该 Flag;Mac OS 也有类似于 O_DIRECT 的机制
  • 用于传递数据的缓冲区,其内存边界必须对齐为 blockSize 的整数倍
  • 用于传递数据的缓冲区,其传递数据的大小必须是 blockSize 的整数倍。
  • 数据传输的开始点,即文件和设备的偏移量,必须是 blockSize 的整数倍

查看系统 blockSize 大小的方式:stat /boot/|grep “IO Block”

ubuntu@VM-30-130-ubuntu:~$ stat /boot/|grep “IO Block”
Size: 4096 Blocks: 8 IO Block: 4096 directory

通常为 4kb

Java 使用 Direct IO

项目地址

https://github.com/lexburner/kdio

引入依赖

1
2
3
4
5
<dependency>
<groupId>moe.cnkirito.kdio</groupId>
<artifactId>kdio-core</artifactId>
<version>1.0.0</version>
</dependency>

注意事项

1
2
3
4
5
// file path should be specific since the different file path determine whether your system support direct io
public static DirectIOLib directIOLib = DirectIOLib.getLibForPath("/");
// you should always write into your disk the Integer-Multiple of block size through direct io.
// in most system, the block size is 4kb
private static final int BLOCK_SIZE = 4 * 1024;

Direct IO 写

1
2
3
4
5
6
7
8
9
10
11
12
13
private static void write() throws IOException {
if (DirectIOLib.binit) {
ByteBuffer byteBuffer = DirectIOUtils.allocateForDirectIO(directIOLib, 4 * BLOCK_SIZE);
for (int i = 0; i < BLOCK_SIZE; i++) {
byteBuffer.putInt(i);
}
byteBuffer.flip();
DirectRandomAccessFile directRandomAccessFile = new DirectRandomAccessFile(new File("./database.data"), "rw");
directRandomAccessFile.write(byteBuffer, 0);
} else {
throw new RuntimeException("your system do not support direct io");
}
}

Direct IO 读

1
2
3
4
5
6
7
8
9
10
11
12
13
public static void read() throws IOException {
if (DirectIOLib.binit) {
ByteBuffer byteBuffer = DirectIOUtils.allocateForDirectIO(directIOLib, 4 * BLOCK_SIZE);
DirectRandomAccessFile directRandomAccessFile = new DirectRandomAccessFile(new File("./database.data"), "rw");
directRandomAccessFile.read(byteBuffer, 0);
byteBuffer.flip();
for (int i = 0; i < BLOCK_SIZE; i++) {
System.out.print(byteBuffer.getInt() + " ");
}
} else {
throw new RuntimeException("your system do not support direct io");
}
}

主要 API

  1. DirectIOLib.java 提供 Native 的 pwrite 和 pread
  2. DirectIOUtils.java 提供工具类方法,比如分配 Block 对齐的 ByteBuffer
  3. DirectChannel/DirectChannelImpl.java 提供对 fd 的 Direct 包装,提供类似 FileChannel 的读写 API。
  4. DirectRandomAccessFile.java 通过 DIO 的方式打开文件,并暴露 IO 接口。

总结

这个简单的 Direct IO 框架参考了 smacke/jaydio,这个库自己搞了一套 Buffer 接口跟 JDK 的类库不兼容,且读写实现里面加了一块 Buffer 用于缓存内容至 Block 对齐有点破坏 Direct IO 的语义。同时,感谢尘央同学的指导,这个小轮子的代码量并不多,初始代码引用自他的一个小 demo(已获得本人授权)。为什么需要这么一个库?主要是考虑后续会出现像「中间件性能挑战赛」和「PolarDB 性能挑战赛」这样的比赛,Java 本身的 API 可能不足以发挥其优势,如果有一个库可以屏蔽掉 Java 和 CPP 选手的差距,岂不是美哉?我也将这个库发到了中央仓库,方便大家在自己的代码中引用。

后续会视需求,会这个小小的轮子增加注入 fadvise,mmap 等系统调用的映射,也欢迎对文件操作感兴趣的同学一起参与进来,pull request & issue are welcome!

扩展阅读

《文件 IO 操作的一些最佳实践》

《PolarDB 数据库性能大赛 Java 选手分享》

欢迎关注我的微信公众号:「Kirito 的技术分享」,关于文章的任何疑问都会得到回复,带来更多 Java 相关的技术分享。

关注微信公众号

]]>
<p>在前文《文件 IO 操作的一些最佳实践》中,我介绍了一些 Java 中常见的文件操作的接口,并且就 PageCache 和 DIrect IO 进行了探讨,最近我自己封装了一个 Direct IO 的库,趁着这个机会,本文重点谈谈 Java 中 Direct IO 的意义,以及简单介绍下我自己的轮子。</p>
一致性哈希负载均衡算法的探讨 http://lexburner.github.io/consistent-hash-lb/ 2019-02-15T07:45:13.000Z 2019-09-26T09:45:30.947Z 前言

一致性哈希算法在很多领域有应用,例如分布式缓存领域的 MemCache,Redis,负载均衡领域的 Nginx,各类 RPC 框架。不同领域场景不同,需要顾及的因素也有所差异,本文主要讨论在 负载均衡 中一致性哈希算法的设计。

在介绍一致性哈希算法之前,我将会介绍一些哈希算法,讨论它们的区别和使用场景。也会给出一致性哈希算法的 Java 通用实现,可以直接引用,文末会给出 github 地址。

友情提示:阅读本文前,最好对一致性哈希算法有所了解,例如你最好听过一致性哈希环这个概念,我会在基本概念上缩短篇幅。

一致性哈希负载均衡介绍

负载均衡这个概念可以抽象为:从 n 个候选服务器中选择一个进行通信的过程。负载均衡算法有多种多样的实现方式:随机、轮询、最小负载优先等,其中也包括了今天的主角:一致性哈希负载均衡。一致性哈希负载均衡需要保证的是“相同的请求尽可能落到同一个服务器上”,注意这短短的一句描述,却包含了相当大的信息量。“相同的请求” — 什么是相同的请求?一般在使用一致性哈希负载均衡时,需要指定一个 key 用于 hash 计算,可能是:

  1. 请求方 IP
  2. 请求服务名称,参数列表构成的串
  3. 用户 ID

“尽可能” —为什么不是一定?因为服务器可能发生上下线,所以少数服务器的变化不应该影响大多数的请求。这也呼应了算法名称中的“一致性”。

同时,一个优秀的负载均衡算法还有一个隐性要求:流量尽可能均匀分布。

综上所述,我们可以概括出一致性哈希负载均衡算法的设计思路。

  • 尽可能保证每个服务器节点均匀的分摊流量
  • 尽可能保证服务器节点的上下线不影响流量的变更

哈希算法介绍

哈希算法是一致性哈希算法中重要的一个组成部分,你可以借助 Java 中的 int hashCode() 去理解它。 说到哈希算法,你想到了什么?Jdk 中的 hashCode、SHA-1、MD5,除了这些耳熟能详的哈希算法,还存在很多其他实现,详见 HASH 算法一览。可以将他们分成三代:

  • 第一代:SHA-1(1993),MD5(1992),CRC(1975),Lookup3(2006)
  • 第二代:MurmurHash(2008)
  • 第三代:CityHash, SpookyHash(2011)

这些都可以认为是广义上的哈希算法,你可以在 wiki 百科 中查看所有的哈希算法。当然还有一些哈希算法如:Ketama,专门为一致性哈希算法而设计。

既然有这么多哈希算法,那必然会有人问:当我们在讨论哈希算法时,我们再考虑哪些东西?我大概总结下有以下四点:

  1. 实现复杂程度
  2. 分布均匀程度
  3. 哈希碰撞概率
  4. 性能

先聊聊性能,是不是性能越高就越好呢?你如果有看过我曾经的文章 《该如何设计你的 PasswordEncoder?》 ,应该能了解到,在设计加密器这个场景下,慢 hash 算法反而有优势;而在负载均衡这个场景下,安全性不是需要考虑的因素,所以性能自然是越高越好。

优秀的算法通常比较复杂,但不足以构成评价标准,有点黑猫白猫论,所以 2,3 两点:分布均匀程度,哈希碰撞概率成了主要考虑的因素。

我挑选了几个值得介绍的哈希算法,重点介绍下。

  1. MurmurHash 算法:高运算性能,低碰撞率,由 Austin Appleby 创建于 2008 年,现已应用到 Hadoop、libstdc++、nginx、libmemcached 等开源系统。2011 年 Appleby 被 Google 雇佣,随后 Google 推出其变种的 CityHash 算法。官方只提供了 C 语言的实现版本。

    Java 界中 Redis,Memcached,Cassandra,HBase,Lucene 都在使用它。

    在 Java 的实现,Guava 的 Hashing 类里有,上面提到的 Jedis,Cassandra 里都有相关的 Util 类。

  2. FNV 算法:全名为 Fowler-Noll-Vo 算法,是以三位发明人 Glenn Fowler,Landon Curt Noll,Phong Vo 的名字来命名的,最早在 1991 年提出。

    特点和用途:FNV 能快速 hash 大量数据并保持较小的冲突率,它的高度分散使它适用于 hash 一些非常相近的字符串,比如 URL,hostname,文件名,text,IP 地址等。

  3. Ketama 算法:将它称之为哈希算法其实不太准确,称之为一致性哈希算法可能更为合适,其他的哈希算法有通用的一致性哈希算法实现,只不过是替换了哈希方式而已,但 Ketama 是一整套的流程,我们将在后面介绍。

以上三者都是最合适的一致性哈希算法的强力争夺者。

一致性哈希算法实现

一致性 hash

一致性哈希的概念我不做赘述,简单介绍下这个负载均衡中的一致性哈希环。首先将服务器(ip+ 端口号)进行哈希,映射成环上的一个节点,在请求到来时,根据指定的 hash key 同样映射到环上,并顺时针选取最近的一个服务器节点进行请求(在本图中,使用的是 userId 作为 hash key)。

当环上的服务器较少时,即使哈希算法选择得当,依旧会遇到大量请求落到同一个节点的问题,为避免这样的问题,大多数一致性哈希算法的实现度引入了虚拟节点的概念。

一致性 hash 虚拟节点

在上图中,只有两台物理服务器节点:11.1.121.1 和 11.1.121.2,我们通过添加后缀的方式,克隆出了另外三份节点,使得环上的节点分布的均匀。一般来说,物理节点越多,所需的虚拟节点就越少。

介绍完了一致性哈希换,我们便可以对负载均衡进行建模了:

1
2
3
public interface LoadBalancer {
Server select(List<Server> servers, Invocation invocation);
}

下面直接给出通用的算法实现:

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
public class ConsistentHashLoadBalancer implements LoadBalancer{

private HashStrategy hashStrategy = new JdkHashCodeStrategy();

private final static int VIRTUAL_NODE_SIZE = 10;
private final static String VIRTUAL_NODE_SUFFIX = "&&";

@Override
public Server select(List<Server> servers, Invocation invocation) {
int invocationHashCode = hashStrategy.getHashCode(invocation.getHashKey());
TreeMap<Integer, Server> ring = buildConsistentHashRing(servers);
Server server = locate(ring, invocationHashCode);
return server;
}

private Server locate(TreeMap<Integer, Server> ring, int invocationHashCode) {
// 向右找到第一个 key
Map.Entry<Integer, Server> locateEntry = ring.ceilingEntry(invocationHashCode);
if (locateEntry == null) {
// 想象成一个环,超过尾部则取第一个 key
locateEntry = ring.firstEntry();
}
return locateEntry.getValue();
}

private TreeMap<Integer, Server> buildConsistentHashRing(List<Server> servers) {
TreeMap<Integer, Server> virtualNodeRing = new TreeMap<>();
for (Server server : servers) {
for (int i = 0; i < VIRTUAL_NODE_SIZE; i++) {
// 新增虚拟节点的方式如果有影响,也可以抽象出一个由物理节点扩展虚拟节点的类
virtualNodeRing.put(hashStrategy.getHashCode(server.getUrl() + VIRTUAL_NODE_SUFFIX + i), server);
}
}
return virtualNodeRing;
}

}

对上述的程序做简单的解读:

Server 是对服务器的抽象,一般是 ip+port 的形式。

1
2
3
public class Server {
private String url;
}

Invocation 是对请求的抽象,包含一个用于 hash 的 key。

1
2
3
public class Invocation {
private String hashKey;
}

使用 TreeMap 作为一致性哈希环的数据结构,ring.ceilingEntry 可以获取环上最近的一个节点。在 buildConsistentHashRing 之中包含了构建一致性哈希环的过程,默认加入了 10 个虚拟节点。

计算方差,标准差的公式:

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
public class StatisticsUtil {

// 方差 s^2=[(x1-x)^2 +...(xn-x)^2]/n
public static double variance(Long[] x) {
int m = x.length;
double sum = 0;
for (int i = 0; i < m; i++) {// 求和
sum += x[i];
}
double dAve = sum / m;// 求平均值
double dVar = 0;
for (int i = 0; i < m; i++) {// 求方差
dVar += (x[i] - dAve)* (x[i] - dAve);
}
return dVar / m;
}

// 标准差σ=sqrt(s^2)
public static double standardDeviation(Long[] x) {
int m = x.length;
double sum = 0;
for (int i = 0; i < m; i++) {// 求和
sum += x[i];
}
double dAve = sum / m;// 求平均值
double dVar = 0;
for (int i = 0; i < m; i++) {// 求方差
dVar += (x[i] - dAve)* (x[i] - dAve);
}
return Math.sqrt(dVar / m);
}

}

其中,HashStrategy 是下文中重点讨论的一个内容,他是对 hash 算法的抽象,我们将会着重对比各种 hash 算法给测评结果带来的差异性。

1
2
3
public interface HashStrategy {
int getHashCode(String origin);
}

测评程序

前面我们已经明确了一个优秀的一致性哈希算法的设计思路。这一节我们给出实际的量化指标:假设 m 次请求打到 n 个候选服务器上

  • 统计每个服务节点收到的流量,计算方差、标准差。测量流量分布均匀情况,我们可以模拟 10000 个随机请求,打到 100 个指定服务器,测试最后个节点的方差,标准差。
  • 记录 m 次请求落到的服务器节点,下线 20% 的服务器,重放流量,统计 m 次请求中落到跟原先相同服务器的概率。测量节点上下线的情况,我们可以模拟 10000 个随机请求,打到 100 个指定服务器,之后下线 20 个服务器并重放流量,统计请求到相同服务器的比例。
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
public class LoadBalanceTest {

static String[] ips = {...}; // 100 台随机 ip

/**
* 测试分布的离散情况
*/
@Test
public void testDistribution() {
List<Server> servers = new ArrayList<>();
for (String ip : ips) {
servers.add(new Server(ip+":8080"));
}
LoadBalancer chloadBalance = new ConsistentHashLoadBalancer();
// 构造 10000 随机请求
List<Invocation> invocations = new ArrayList<>();
for (int i = 0; i < 10000; i++) {
invocations.add(new Invocation(UUID.randomUUID().toString()));
}
// 统计分布
AtomicLongMap<Server> atomicLongMap = AtomicLongMap.create();
for (Server server : servers) {
atomicLongMap.put(server, 0);
}
for (Invocation invocation : invocations) {
Server selectedServer = chloadBalance.select(servers, invocation);
atomicLongMap.getAndIncrement(selectedServer);
}
System.out.println(StatisticsUtil.variance(atomicLongMap.asMap().values().toArray(new Long[]{})));
System.out.println(StatisticsUtil.standardDeviation(atomicLongMap.asMap().values().toArray(new Long[]{})));
}

/**
* 测试节点新增删除后的变化程度
*/
@Test
public void testNodeAddAndRemove() {
List<Server> servers = new ArrayList<>();
for (String ip : ips) {
servers.add(new Server(ip));
}
List<Server> serverChanged = servers.subList(0, 80);
ConsistentHashLoadBalancer chloadBalance = new ConsistentHashLoadBalancer();
// 构造 10000 随机请求
List<Invocation> invocations = new ArrayList<>();
for (int i = 0; i < 10000; i++) {
invocations.add(new Invocation(UUID.randomUUID().toString()));
}
int count = 0;
for (Invocation invocation : invocations) {
Server origin = chloadBalance.select(servers, invocation);
Server changed = chloadBalance.select(serverChanged, invocation);
if (origin.getUrl().equals(changed.getUrl())) count++;
}
System.out.println(count / 10000D);
}

不同哈希算法的实现及测评

最简单、经典的 hashCode 实现:

1
2
3
4
5
6
public class JdkHashCodeStrategy implements HashStrategy {
@Override
public int getHashCode(String origin) {
return origin.hashCode();
}
}

FNV1_32_HASH 算法实现:

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

private static final long FNV_32_INIT = 2166136261L;
private static final int FNV_32_PRIME = 16777619;

@Override
public int getHashCode(String origin) {
final int p = FNV_32_PRIME;
int hash = (int) FNV_32_INIT;
for (int i = 0; i < origin.length(); i++)
hash = (hash ^ origin.charAt(i)) * p;
hash += hash << 13;
hash ^= hash >> 7;
hash += hash << 3;
hash ^= hash >> 17;
hash += hash << 5;
hash = Math.abs(hash);
return hash;
}
}

CRC 算法:

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
public class CRCHashStrategy implements HashStrategy {

private static final int LOOKUP_TABLE[] = {0x0000, 0x1021, 0x2042, 0x3063,
0x4084, 0x50A5, 0x60C6, 0x70E7, 0x8108, 0x9129, 0xA14A, 0xB16B,
0xC18C, 0xD1AD, 0xE1CE, 0xF1EF, 0x1231, 0x0210, 0x3273, 0x2252,
0x52B5, 0x4294, 0x72F7, 0x62D6, 0x9339, 0x8318, 0xB37B, 0xA35A,
0xD3BD, 0xC39C, 0xF3FF, 0xE3DE, 0x2462, 0x3443, 0x0420, 0x1401,
0x64E6, 0x74C7, 0x44A4, 0x5485, 0xA56A, 0xB54B, 0x8528, 0x9509,
0xE5EE, 0xF5CF, 0xC5AC, 0xD58D, 0x3653, 0x2672, 0x1611, 0x0630,
0x76D7, 0x66F6, 0x5695, 0x46B4, 0xB75B, 0xA77A, 0x9719, 0x8738,
0xF7DF, 0xE7FE, 0xD79D, 0xC7BC, 0x48C4, 0x58E5, 0x6886, 0x78A7,
0x0840, 0x1861, 0x2802, 0x3823, 0xC9CC, 0xD9ED, 0xE98E, 0xF9AF,
0x8948, 0x9969, 0xA90A, 0xB92B, 0x5AF5, 0x4AD4, 0x7AB7, 0x6A96,
0x1A71, 0x0A50, 0x3A33, 0x2A12, 0xDBFD, 0xCBDC, 0xFBBF, 0xEB9E,
0x9B79, 0x8B58, 0xBB3B, 0xAB1A, 0x6CA6, 0x7C87, 0x4CE4, 0x5CC5,
0x2C22, 0x3C03, 0x0C60, 0x1C41, 0xEDAE, 0xFD8F, 0xCDEC, 0xDDCD,
0xAD2A, 0xBD0B, 0x8D68, 0x9D49, 0x7E97, 0x6EB6, 0x5ED5, 0x4EF4,
0x3E13, 0x2E32, 0x1E51, 0x0E70, 0xFF9F, 0xEFBE, 0xDFDD, 0xCFFC,
0xBF1B, 0xAF3A, 0x9F59, 0x8F78, 0x9188, 0x81A9, 0xB1CA, 0xA1EB,
0xD10C, 0xC12D, 0xF14E, 0xE16F, 0x1080, 0x00A1, 0x30C2, 0x20E3,
0x5004, 0x4025, 0x7046, 0x6067, 0x83B9, 0x9398, 0xA3FB, 0xB3DA,
0xC33D, 0xD31C, 0xE37F, 0xF35E, 0x02B1, 0x1290, 0x22F3, 0x32D2,
0x4235, 0x5214, 0x6277, 0x7256, 0xB5EA, 0xA5CB, 0x95A8, 0x8589,
0xF56E, 0xE54F, 0xD52C, 0xC50D, 0x34E2, 0x24C3, 0x14A0, 0x0481,
0x7466, 0x6447, 0x5424, 0x4405, 0xA7DB, 0xB7FA, 0x8799, 0x97B8,
0xE75F, 0xF77E, 0xC71D, 0xD73C, 0x26D3, 0x36F2, 0x0691, 0x16B0,
0x6657, 0x7676, 0x4615, 0x5634, 0xD94C, 0xC96D, 0xF90E, 0xE92F,
0x99C8, 0x89E9, 0xB98A, 0xA9AB, 0x5844, 0x4865, 0x7806, 0x6827,
0x18C0, 0x08E1, 0x3882, 0x28A3, 0xCB7D, 0xDB5C, 0xEB3F, 0xFB1E,
0x8BF9, 0x9BD8, 0xABBB, 0xBB9A, 0x4A75, 0x5A54, 0x6A37, 0x7A16,
0x0AF1, 0x1AD0, 0x2AB3, 0x3A92, 0xFD2E, 0xED0F, 0xDD6C, 0xCD4D,
0xBDAA, 0xAD8B, 0x9DE8, 0x8DC9, 0x7C26, 0x6C07, 0x5C64, 0x4C45,
0x3CA2, 0x2C83, 0x1CE0, 0x0CC1, 0xEF1F, 0xFF3E, 0xCF5D, 0xDF7C,
0xAF9B, 0xBFBA, 0x8FD9, 0x9FF8, 0x6E17, 0x7E36, 0x4E55, 0x5E74,
0x2E93, 0x3EB2, 0x0ED1, 0x1EF0,};

/**
* Create a CRC16 checksum from the bytes. implementation is from
* mp911de/lettuce, modified with some more optimizations
*
* @param bytes
* @return CRC16 as integer value
*/
public static int getCRC16(byte[] bytes) {
int crc = 0x0000;

for (byte b : bytes) {
crc = ((crc << 8) ^ LOOKUP_TABLE[((crc >>> 8) ^ (b & 0xFF)) & 0xFF]);
}
return crc & 0xFFFF;
}

public static int getCRC16(String key) {
return getCRC16(key.getBytes(Charset.forName("UTF-8")));
}

@Override
public int getHashCode(String origin) {
// optimization with modulo operator with power of 2
// equivalent to getCRC16(key) % 16384
return getCRC16(origin) & (16384 - 1);
}
}

Ketama 算法:

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
public class KetamaHashStrategy implements HashStrategy {

private static MessageDigest md5Digest;

static {
try {
md5Digest = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("MD5 not supported", e);
}
}

@Override
public int getHashCode(String origin) {
byte[] bKey = computeMd5(origin);
long rv = ((long) (bKey[3] & 0xFF)<< 24)
| ((long) (bKey[2] & 0xFF)<< 16)
| ((long) (bKey[1] & 0xFF)<< 8)
| (bKey[0] & 0xFF);
return (int) (rv & 0xffffffffL);
}

/**
* Get the md5 of the given key.
*/
public static byte[] computeMd5(String k) {
MessageDigest md5;
try {
md5 = (MessageDigest) md5Digest.clone();
} catch (CloneNotSupportedException e) {
throw new RuntimeException("clone of MD5 not supported", e);
}
md5.update(k.getBytes());
return md5.digest();
}
}

MurmurHash 算法:

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
public class MurmurHashStrategy implements HashStrategy {
@Override
public int getHashCode(String origin) {

ByteBuffer buf = ByteBuffer.wrap(origin.getBytes());
int seed = 0x1234ABCD;

ByteOrder byteOrder = buf.order();
buf.order(ByteOrder.LITTLE_ENDIAN);

long m = 0xc6a4a7935bd1e995L;
int r = 47;

long h = seed ^ (buf.remaining() * m);

long k;
while (buf.remaining() >= 8) {
k = buf.getLong();

k *= m;
k ^= k >>> r;
k *= m;

h ^= k;
h *= m;
}

if (buf.remaining() > 0) {
ByteBuffer finish = ByteBuffer.allocate(8).order(
ByteOrder.LITTLE_ENDIAN);
// for big-endian version, do this first:
// finish.position(8-buf.remaining());
finish.put(buf).rewind();
h ^= finish.getLong();
h *= m;
}
h ^= h >>> r;
h *= m;
h ^= h >>> r;

buf.order(byteOrder);
return (int) (h & 0xffffffffL);
}
}

测评结果:

方差标准差不变流量比例
JdkHashCodeStrategy29574.08171.970.6784
CRCHashStrategy3013.0254.890.7604
FnvHashStrategy961.6431.010.7892
KetamaHashStrategy1254.6435.420.7986
MurmurHashStrategy815.7228.560.7971

其中方差和标准差反映了均匀情况,越低越好,可以发现 MurmurHashStrategy,KetamaHashStrategy,FnvHashStrategy 都表现的不错。

不变流量比例体现了服务器上下线对原有请求的影响程度,不变流量比例越高越高,可以发现 KetamaHashStrategy 和 MurmurHashStrategy 表现最为优秀。

我并没有对小集群,小流量进行测试,样本偏差性较大,仅从这个常见场景来看,MurmurHashStrategy 是一个不错的选择,多次测试后发现 FnvHashStrategyKetamaHashStrategyMurmurHashStrategy 差距不是很大。

至于性能测试,MurmurHash 也十分的高性能,我并没有做测试(感兴趣的同学可以对几种 strategy 用 JMH 测评一下), 这里我贴一下 MurmurHash 官方的测评数据:

OneAtATime - 354.163715 mb/secFNV - 443.668038 mb/secSuperFastHash - 985.335173 mb/seclookup3 - 988.080652 mb/secMurmurHash 1.0 - 1363.293480 mb/secMurmurHash 2.0 - 2056.885653 mb/sec

扩大虚拟节点可以明显降低方差和标准差,但虚拟节点的增加会加大内存占用量以及计算量

Ketama 一致性哈希算法实现

Ketama 算法有其专门的配套实现方式

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
public class KetamaConsistentHashLoadBalancer implements LoadBalancer {

private static MessageDigest md5Digest;

static {
try {
md5Digest = MessageDigest.getInstance("MD5");
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("MD5 not supported", e);
}
}

private final static int VIRTUAL_NODE_SIZE = 12;
private final static String VIRTUAL_NODE_SUFFIX = "-";

@Override
public Server select(List<Server> servers, Invocation invocation) {
long invocationHashCode = getHashCode(invocation.getHashKey());
TreeMap<Long, Server> ring = buildConsistentHashRing(servers);
Server server = locate(ring, invocationHashCode);
return server;
}

private Server locate(TreeMap<Long, Server> ring, Long invocationHashCode) {
// 向右找到第一个 key
Map.Entry<Long, Server> locateEntry = ring.ceilingEntry(invocationHashCode);
if (locateEntry == null) {
// 想象成一个环,超过尾部则取第一个 key
locateEntry = ring.firstEntry();
}
return locateEntry.getValue();
}

private TreeMap<Long, Server> buildConsistentHashRing(List<Server> servers) {
TreeMap<Long, Server> virtualNodeRing = new TreeMap<>();
for (Server server : servers) {
for (int i = 0; i < VIRTUAL_NODE_SIZE / 4; i++) {
byte[] digest = computeMd5(server.getUrl() + VIRTUAL_NODE_SUFFIX + i);
for (int h = 0; h < 4; h++) {
Long k = ((long) (digest[3 + h * 4] & 0xFF)<< 24)
| ((long) (digest[2 + h * 4] & 0xFF)<< 16)
| ((long) (digest[1 + h * 4] & 0xFF)<< 8)
| (digest[h * 4] & 0xFF);
virtualNodeRing.put(k, server);

}
}
}
return virtualNodeRing;
}

private long getHashCode(String origin) {
byte[] bKey = computeMd5(origin);
long rv = ((long) (bKey[3] & 0xFF)<< 24)
| ((long) (bKey[2] & 0xFF)<< 16)
| ((long) (bKey[1] & 0xFF)<< 8)
| (bKey[0] & 0xFF);
return rv;
}

private static byte[] computeMd5(String k) {
MessageDigest md5;
try {
md5 = (MessageDigest) md5Digest.clone();
} catch (CloneNotSupportedException e) {
throw new RuntimeException("clone of MD5 not supported", e);
}
md5.update(k.getBytes());
return md5.digest();
}

}

稍微不同的地方便在于:Ketama 将四个节点标为一组进行了虚拟节点的设置。

方差标准差不变流量比例
KetamaConsistentHashLoadBalancer911.0830.180.7936

实际结果并没有太大的提升,可能和测试数据的样本规模有关。

总结

优秀的哈希算法和一致性哈希算法可以帮助我们在大多数场景下应用的高性能,高稳定性,但在实际使用一致性哈希负载均衡的场景中,最好针对实际的集群规模和请求哈希方式进行压测,力保流量均匀打到所有的机器上,这才是王道。

不仅仅是分布式缓存,负载均衡等等有限的场景,一致性哈希算法、哈希算法,尤其是后者,是一个用处很广泛的常见算法,了解它的经典实现是很有必要的,例如 MurmurHash,在 guava 中就有其 Java 实现,当需要高性能,分布均匀,碰撞概率小的哈希算法时,可以考虑使用它。

本文代码的 github 地址:https://github.com/lexburner/consistent-hash-algorithm

扩展阅读

深入理解 RPC 之集群篇

《该如何设计你的 PasswordEncoder?》

参考文章

MurmurHash

memcached Java 客户端 spymemcached 的一致性 Hash 算法

]]>
<h3 id="前言"><a href="#前言" class="headerlink" title="前言"></a>前言</h3><p>一致性哈希算法在很多领域有应用,例如分布式缓存领域的 MemCache,Redis,负载均衡领域的 Nginx,各类 RPC 框架。不同领域场景不同,需要顾及的因素也有所差异,本文主要讨论在 <strong> 负载均衡 </strong> 中一致性哈希算法的设计。</p> <p>在介绍一致性哈希算法之前,我将会介绍一些哈希算法,讨论它们的区别和使用场景。也会给出一致性哈希算法的 Java 通用实现,可以直接引用,文末会给出 github 地址。</p> <blockquote> <p>友情提示:阅读本文前,最好对一致性哈希算法有所了解,例如你最好听过一致性哈希环这个概念,我会在基本概念上缩短篇幅。</p> </blockquote>
定时器的几种实现方式 http://lexburner.github.io/timer/ 2019-01-24T10:47:55.000Z 2019-09-26T09:45:31.563Z 1 前言

在开始正题之前,先闲聊几句。有人说,计算机科学这个学科,软件方向研究到头就是数学,硬件方向研究到头就是物理,最轻松的是中间这批使用者,可以不太懂物理,不太懂数学,依旧可以使用计算机作为自己谋生的工具。这个规律具有普适应,看看“定时器”这个例子,往应用层研究,有 Quartz,Spring Schedule 等框架;往分布式研究,又有 SchedulerX,ElasticJob 等分布式任务调度;往底层实现看,又有多种定时器实现方案的原理、工作效率、数据结构可以深究…简单上手使用一个框架,并不能体现出个人的水平,如何与他人构成区分度?我觉得至少要在某一个方向有所建树:

  1. 深入研究某个现有框架的实现原理,例如:读源码
  2. 将一个传统技术在分布式领域很好地延伸,很多成熟的传统技术可能在单机 work well,但分布式场景需要很多额外的考虑。
  3. 站在设计者的角度,如果从零开始设计一个轮子,怎么利用合适的算法、数据结构,去实现它。

回到这篇文章的主题,我首先会围绕第三个话题讨论:设计实现一个定时器,可以使用什么算法,采用什么数据结构。接着再聊聊第一个话题:探讨一些优秀的定时器实现方案。

2 理解定时器

很多场景会用到定时器,例如

  1. 使用 TCP 长连接时,客户端需要定时向服务端发送心跳请求。
  2. 财务系统每个月的月末定时生成对账单。
  3. 双 11 的 0 点,定时开启秒杀开关。

定时器像水和空气一般,普遍存在于各个场景中,一般定时任务的形式表现为:经过固定时间后触发、按照固定频率周期性触发、在某个时刻触发。定时器是什么?可以理解为这样一个数据结构:

存储一系列的任务集合,并且 Deadline 越接近的任务,拥有越高的执行优先级
在用户视角支持以下几种操作:
NewTask:将新任务加入任务集合
Cancel:取消某个任务
在任务调度的视角还要支持:
Run:执行一个到期的定时任务

判断一个任务是否到期,基本会采用轮询的方式, 每隔一个时间片 去检查 最近的任务 是否到期,并且,在 NewTask 和 Cancel 的行为发生之后,任务调度策略也会出现调整。

说到底,定时器还是靠线程轮询实现的。

3 数据结构

我们主要衡量 NewTask(新增任务),Cancel(取消任务),Run(执行到期的定时任务)这三个指标,分析他们使用不同数据结构的时间 / 空间复杂度。

3.1 双向有序链表

在 Java 中,LinkedList 是一个天然的双向链表

NewTask:O(N)
Cancel:O(1)
Run:O(1)
N:任务数

NewTask O(N) 很容易理解,按照 expireTime 查找合适的位置即可;Cancel O(1) ,任务在 Cancel 时,会持有自己节点的引用,所以不需要查找其在链表中所在的位置,即可实现当前节点的删除,这也是为什么我们使用双向链表而不是普通链表的原因是 ;Run O(1),由于整个双向链表是基于 expireTime 有序的,所以调度器只需要轮询第一个任务即可。

3.2 堆

在 Java 中,PriorityQueue 是一个天然的堆,可以利用传入的 Comparator 来决定其中元素的优先级。

NewTask:O(logN)
Cancel:O(logN)
Run:O(1)
N:任务数

expireTime 是 Comparator 的对比参数。NewTask O(logN) 和 Cancel O(logN) 分别对应堆插入和删除元素的时间复杂度 ;Run O(1),由 expireTime 形成的小根堆,我们总能在堆顶找到最快的即将过期的任务。

堆与双向有序链表相比,NewTask 和 Cancel 形成了 trade off,但考虑到现实中,定时任务取消的场景并不是很多,所以堆实现的定时器要比双向有序链表优秀。

3.3 时间轮

Netty 针对 I/O 超时调度的场景进行了优化,实现了 HashedWheelTimer 时间轮算法。

时间轮算法

HashedWheelTimer 是一个环形结构,可以用时钟来类比,钟面上有很多 bucket ,每一个 bucket 上可以存放多个任务,使用一个 List 保存该时刻到期的所有任务,同时一个指针随着时间流逝一格一格转动,并执行对应 bucket 上所有到期的任务。任务通过 取模 决定应该放入哪个 bucket 。和 HashMap 的原理类似,newTask 对应 put,使用 List 来解决 Hash 冲突。

以上图为例,假设一个 bucket 是 1 秒,则指针转动一轮表示的时间段为 8s,假设当前指针指向 0,此时需要调度一个 3s 后执行的任务,显然应该加入到 (0+3=3) 的方格中,指针再走 3 次就可以执行了;如果任务要在 10s 后执行,应该等指针走完一轮零 2 格再执行,因此应放入 2,同时将 round(1)保存到任务中。检查到期任务时只执行 round 为 0 的, bucket 上其他任务的 round 减 1。

再看图中的 bucket5,我们可以知道在 $18+5=13s$ 后,有两个任务需要执行,在 $28+5=21s$ 后有一个任务需要执行。

NewTask:O(1)
Cancel:O(1)
Run:O(M)
Tick:O(1)
M: bucket ,M ~ N/C ,其中 C 为单轮 bucket 数,Netty 中默认为 512

时间轮算法的复杂度可能表达有误,比较难算,仅供参考。另外,其复杂度还受到多个任务分配到同一个 bucket 的影响。并且多了一个转动指针的开销。

传统定时器是面向任务的,时间轮定时器是面向 bucket 的。

构造 Netty 的 HashedWheelTimer 时有两个重要的参数:tickDurationticksPerWheel

  1. tickDuration:即一个 bucket 代表的时间,默认为 100ms,Netty 认为大多数场景下不需要修改这个参数;
  2. ticksPerWheel:一轮含有多少个 bucket ,默认为 512 个,如果任务较多可以增大这个参数,降低任务分配到同一个 bucket 的概率。

3.4 层级时间轮

Kafka 针对时间轮算法进行了优化,实现了层级时间轮 TimingWheel

如果任务的时间跨度很大,数量也多,传统的 HashedWheelTimer 会造成任务的 round 很大,单个 bucket 的任务 List 很长,并会维持很长一段时间。这时可将轮盘按时间粒度分级:

层级时间轮

现在,每个任务除了要维护在当前轮盘的 round,还要计算在所有下级轮盘的 round。当本层的 round 为 0 时,任务按下级 round 值被下放到下级轮子,最终在最底层的轮盘得到执行。

NewTask:O(H)
Cancel:O(H)
Run:O(M)
Tick:O(1)
H:层级数量

设想一下一个定时了 3 天,10 小时,50 分,30 秒的定时任务,在 tickDuration = 1s 的单层时间轮中,需要经过:$3246060+106060+5060+30$ 次指针的拨动才能被执行。但在 wheel1 tickDuration = 1 天,wheel2 tickDuration = 1 小时,wheel3 tickDuration = 1 分,wheel4 tickDuration = 1 秒 的四层时间轮中,只需要经过 $3+10+50+30$ 次指针的拨动!

相比单层时间轮,层级时间轮在时间跨度较大时存在明显的优势。

4 常见实现

4.1 Timer

JDK 中的 Timer 是非常早期的实现,在现在看来,它并不是一个好的设计。

1
2
3
4
5
6
7
8
// 运行一个一秒后执行的定时任务
Timer timer = new Timer();
timer.schedule(new TimerTask() {
@Override
public void run() {
// do sth
}
}, 1000);

使用 Timer 实现任务调度的核心是 TimerTimerTask。其中 Timer 负责设定 TimerTask 的起始与间隔执行时间。使用者只需要创建一个 TimerTask 的继承类,实现自己的 run 方法,然后将其丢给 Timer 去执行即可。

1
2
3
4
public class Timer {
private final TaskQueue queue = new TaskQueue();
private final TimerThread thread = new TimerThread(queue);
}

其中 TaskQueue 是使用数组实现的一个简易的堆。另外一个值得注意的属性是 TimerThreadTimer 使用唯一的线程负责轮询并执行任务。Timer 的优点在于简单易用,但也因为所有任务都是由同一个线程来调度,因此整个过程是串行执行的,同一时间只能有一个任务在执行,前一个任务的延迟或异常都将会影响到之后的任务。

轮询时如果发现 currentTime < heapFirst.executionTime,可以 wait(executionTime - currentTime) 来减少不必要的轮询时间。这是普遍被使用的一个优化。

  1. Timer 只能被单线程调度
  2. TimerTask 中出现的异常会影响到 Timer 的执行。

由于这两个缺陷,JDK 1.5 支持了新的定时器方案 ScheduledExecutorService

4.2 ScheduledExecutorService

1
2
3
4
5
6
7
8
// 运行一个一秒后执行的定时任务
ScheduledExecutorService service = Executors.newScheduledThreadPool(10);
service.scheduleA(new Runnable() {
@Override
public void run() {
//do sth
}
}, 1, TimeUnit.SECONDS);

相比 TimerScheduledExecutorService 解决了同一个定时器调度多个任务的阻塞问题,并且任务异常不会中断 ScheduledExecutorService

ScheduledExecutorService 提供了两种常用的周期调度方法 ScheduleAtFixedRate 和 ScheduleWithFixedDelay。

ScheduleAtFixedRate 每次执行时间为上一次任务开始起向后推一个时间间隔,即每次执行时间为 : $initialDelay$, $initialDelay+period$, $initialDelay+2*period$, …

ScheduleWithFixedDelay 每次执行时间为上一次任务结束起向后推一个时间间隔,即每次执行时间为:$initialDelay$, $initialDelay+executeTime+delay$, $initialDelay+2executeTime+2delay$, …

由此可见,ScheduleAtFixedRate 是基于固定时间间隔进行任务调度,ScheduleWithFixedDelay 取决于每次任务执行的时间长短,是基于不固定时间间隔的任务调度。

ScheduledExecutorService 底层使用的数据结构为 PriorityQueue,任务调度方式较为常规,不做特别介绍。

4.3 HashedWheelTimer

1
2
3
4
5
6
7
8
Timer timer = new HashedWheelTimer();
// 等价于 Timer timer = new HashedWheelTimer(100, TimeUnit.MILLISECONDS, 512);
timer.newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
//do sth
}
}, 1, TimeUnit.SECONDS);

前面已经介绍过了 Netty 中 HashedWheelTimer 内部的数据结构,默认构造器会配置轮询周期为 100ms,bucket 数量为 512。其使用方法和 JDK 的 Timer 十分相似。

1
2
private final Worker worker = new Worker();// Runnable
private final Thread workerThread;// Thread

由于篇幅限制,我并不打算做详细的源码分析,但上述两行来自 HashedWheelTimer 的代码阐释了一个事实:HashedWheelTimer 内部也同样是使用单个线程进行任务调度。与 JDK 的 Timer 一样,存在”前一个任务执行时间过长,影响后续定时任务执行“的问题。

理解 HashedWheelTimer 中的 ticksPerWheel,tickDuration,对二者进行合理的配置,可以使得用户在合适的场景得到最佳的性能。

5 最佳实践

5.1 选择合适的定时器

毋庸置疑,JDK 的 Timer 使用的场景是最窄的,完全可以被后两者取代。如何在 ScheduledExecutorServiceHashedWheelTimer 之间如何做选择,需要区分场景,做一个简单的对比:

  1. ScheduledExecutorService 是面向任务的,当任务数非常大时,使用堆 (PriorityQueue) 维护任务的新增、删除会导致性能下降,而 HashedWheelTimer 面向 bucket,设置合理的 ticksPerWheel,tickDuration ,可以不受任务量的限制。所以在任务非常多时,HashedWheelTimer 可以表现出它的优势。
  2. 相反,如果任务量少,HashedWheelTimer 内部的 Worker 线程依旧会不停的拨动指针,虽然不是特别消耗性能,但至少不能说:HashedWheelTimer 一定比 ScheduledExecutorService 优秀。
  3. HashedWheelTimer 由于开辟了一个 bucket 数组,占用的内存会稍大。

上述的对比,让我们得到了一个最佳实践:在任务非常多时,使用 HashedWheelTimer 可以获得性能的提升。例如服务治理框架中的心跳定时任务,服务实例非常多时,每一个客户端都需要定时发送心跳,每一个服务端都需要定时检测连接状态,这是一个非常适合使用 HashedWheelTimer 的场景。

5.2 单线程与业务线程池

我们需要注意 HashedWheelTimer 使用单线程来调度任务,如果任务比较耗时,应当设置一个业务线程池,将 HashedWheelTimer 当做一个定时触发器,任务的实际执行,交给业务线程池。

如果所有的任务都满足: taskNStartTime - taskN-1StartTime > taskN-1CostTime,即任意两个任务的间隔时间小于先执行任务的执行时间,则无需担心这个问题。

5.3 全局定时器

实际使用 HashedWheelTimer 时, 应当将其当做一个全局的任务调度器,例如设计成 static 。时刻谨记一点:HashedWheelTimer 对应一个线程,如果每次实例化 HashedWheelTimer,首先是线程会很多,其次是时间轮算法将会完全失去意义。

5.4 为 HashedWheelTimer 设置合理的参数

ticksPerWheel,tickDuration 这两个参数尤为重要,ticksPerWheel 控制了时间轮中 bucket 的数量,决定了冲突发生的概率,tickDuration 决定了指针拨动的频率,一方面会影响定时的精度,一方面决定 CPU 的消耗量。当任务数量非常大时,考虑增大 ticksPerWheel;当时间精度要求不高时,可以适当加大 tickDuration,不过大多数情况下,不需要 care 这个参数。

5.5 什么时候使用层级时间轮

当时间跨度很大时,提升单层时间轮的 tickDuration 可以减少空转次数,但会导致时间精度变低,层级时间轮既可以避免精度降低,又避免了指针空转的次数。如果有时间跨度较长的定时任务,则可以交给层级时间轮去调度。此外,也可以按照定时精度实例化多个不同作用的单层时间轮,dayHashedWheelTimer、hourHashedWheelTimer、minHashedWheelTimer,配置不同的 tickDuration,此法虽 low,但不失为一个解决方案。Netty 设计的 HashedWheelTimer 是专门用来优化 I/O 调度的,场景较为局限,所以并没有实现层级时间轮;而在 Kafka 中定时器的适用范围则较广,所以其实现了层级时间轮,以应对更为复杂的场景。

6 参考资料

[1] https://www.ibm.com/developerworks/cn/java/j-lo-taskschedule/index.html

[2] http://novoland.github.io/ 并发 /2014/07/26/ 定时器(Timer)的实现.html

[3] http://www.cs.columbia.edu/~nahum/w6998/papers/sosp87-timing-wheels.pdf

欢迎关注我的微信公众号:「Kirito 的技术分享」,关于文章的任何疑问都会得到回复,带来更多 Java 相关的技术分享。

关注微信公众号

]]>
<h3 id="1-前言"><a href="#1-前言" class="headerlink" title="1 前言"></a>1 前言</h3><p>在开始正题之前,先闲聊几句。有人说,计算机科学这个学科,软件方向研究到头就是数学,硬件方向研究到头就是物理,最轻松的是中间这批使用者,可以不太懂物理,不太懂数学,依旧可以使用计算机作为自己谋生的工具。这个规律具有普适应,看看“定时器”这个例子,往应用层研究,有 Quartz,Spring Schedule 等框架;往分布式研究,又有 SchedulerX,ElasticJob 等分布式任务调度;往底层实现看,又有多种定时器实现方案的原理、工作效率、数据结构可以深究…简单上手使用一个框架,并不能体现出个人的水平,如何与他人构成区分度?我觉得至少要在某一个方向有所建树:</p> <ol> <li>深入研究某个现有框架的实现原理,例如:读源码</li> <li>将一个传统技术在分布式领域很好地延伸,很多成熟的传统技术可能在单机 work well,但分布式场景需要很多额外的考虑。</li> <li>站在设计者的角度,如果从零开始设计一个轮子,怎么利用合适的算法、数据结构,去实现它。</li> </ol> <p>回到这篇文章的主题,我首先会围绕第三个话题讨论:设计实现一个定时器,可以使用什么算法,采用什么数据结构。接着再聊聊第一个话题:探讨一些优秀的定时器实现方案。</p>
提问前,请先让自己成为值得被教的人 http://lexburner.github.io/thinging-in-ask/ 2019-01-21T18:18:51.000Z 2019-09-26T09:45:29.551Z 每一个不恰当的提问都在消耗别人对你的耐心,程序员届早已经有了诸如《提问的智慧》之类的经典文章介绍了什么是蠢问题,如何避免问蠢问题。然而,常年混迹于十几个技术交流微信群的我,发现很多小白程序员并不懂得这一点,为改善微信群的技术交流氛围,转此文,意图是让大家在担任提问者的角色时,尽可能提高提问的素质,让自己成为值得被教的人。

原文出处:https://github.com/aptx4869yuyang2017/How-To-Ask-Questions-The-Smart-Way

用清晰、正确、精准并语法正确的语句

我们从经验中发现,粗心的提问者通常也会粗心的写程序与思考(我敢打包票)。回答粗心大意者的问题很不值得,我们宁愿把时间耗在别处。

正确的拼字、标点符号和大小写是很重要的。一般来说,如果你觉得这样做很麻烦,不想在乎这些,那我们也觉得麻烦,不想在乎你的提问。花点额外的精力斟酌一下字句,用不着太僵硬与正式。

更白话地说,如果你写得像是个小白,那多半得不到理睬。

如果在使用非母语的论坛提问,你可以犯点拼写和语法上的小错,但决不能在思考上马虎(没错,我们通常能弄清两者的分别)。同时,除非你知道回复者使用的语言,否则请使用英语书写。繁忙的程序员一般会直接删除用他们看不懂语言写的消息。在网络上英语是通用语言,用英语书写可以将你的问题在尚未被阅读就被直接删除的可能性降到最低。

如果英文是你的外语(Second language),提示潜在回复者你有潜在的语言困难是很好的: [译注:以下附上原文以供使用]

English is not my native language; please excuse typing errors.

  • 英文不是我的母语,请原谅我的错字或语法

If you speak $LANGUAGE, please email/PM me; I may need assistance translating my question.

  • 如果你说 某语言 ,请寄信 / 私讯给我;我需要有人协助我翻译我的问题

I am familiar with the technical terms, but some slang expressions and idioms are difficult for me.

  • 我对技术名词很熟悉,但对于俗语或是特别用法比较不甚了解。

I’ve posted my question in $LANGUAGE and English. I’ll be glad to translate responses, if you only use one or the other.

  • 我把我的问题用 某语言 和英文写出来,如果你只用一种语言回答,我会乐意将其翻译成另一种。

精确的描述问题并言之有物

  • 仔细、清楚地描述你的问题或 Bug 的症状。
  • 描述问题发生的环境(机器配置、操作系统、应用程序、以及相关的信息),提供经销商的发行版和版本号(如:Fedora Core 4Slackware 9.1 等)。
  • 描述在提问前你是怎样去研究和理解这个问题的。
  • 描述在提问前为确定问题而采取的诊断步骤。
  • 描述最近做过什么可能相关的硬件或软件变更。
  • 尽可能的提供一个可以 重现这个问题的可控环境 的方法。

尽量去揣测一个程序员会怎样反问你,在你提问之前预先将程序员们可能遇到的问题回答一遍。

以上几点中,当你报告的是你认为可能在代码中的问题时,给程序员一个可以重现你的问题的环境尤其重要。当你这么做时,你得到有效的回答的机会和速度都会大大的提升。

Simon Tatham 写过一篇名为《如何有效的报告 Bug》的出色文章。强力推荐你也读一读。

话不在多而在精

你需要提供精确有内容的信息。这并不是要求你简单的把成堆的出错代码或者资料完全转录到你的提问中。如果你有庞大而复杂的测试样例能重现程序挂掉的情境,尽量将它剪裁得越小越好。

这样做的用处至少有三点。 第一,表现出你为简化问题付出了努力,这可以使你得到回答的机会增加; 第二,简化问题使你更有可能得到 有用 的答案; 第三,在精炼你的 bug 报告的过程中,你很可能就自己找到了解决方法或权宜之计。

别动辄声称找到 Bug

当你在使用软件中遇到问题,除非你非常、 非常 的有根据,不要动辄声称找到了 Bug。提示:除非你能提供解决问题的源代码补丁,或者提供回归测试来表明前一版本中行为不正确,否则你都多半不够完全确信。这同样适用在网页和文件,如果你(声称)发现了文件的 Bug,你应该能提供相应位置的修正或替代文件。

请记得,还有许多其它使用者没遇到你发现的问题,否则你在阅读文件或搜索网页时就应该发现了(你在抱怨前 已经做了这些,是吧?)。这也意味着很有可能是你弄错了而不是软件本身有问题。

编写软件的人总是非常辛苦地使它尽可能完美。如果你声称找到了 Bug,也就是在质疑他们的能力,即使你是对的,也有可能会冒犯到其中某部分人。当你在标题中嚷嚷着有 Bug 时,这尤其严重。

提问时,即使你私下非常确信已经发现一个真正的 Bug,最好写得像是 做错了什么。如果真的有 Bug,你会在回复中看到这点。这样做的话,如果真有 Bug,维护者就会向你道歉,这总比你惹恼别人然后欠别人一个道歉要好一点。

低声下气不能代替你的功课

有些人明白他们不该粗鲁或傲慢的提问并要求得到答复,但他们选择另一个极端 – 低声下气:我知道我只是个可悲的新手,一个撸瑟,但...。这既使人困扰,也没有用,尤其是伴随着与实际问题含糊不清的描述时更令人反感。

别用原始灵长类动物的把戏来浪费你我的时间。取而代之的是,尽可能清楚地描述背景条件和你的问题情况。这比低声下气更好地定位了你的位置。

有时网页论坛会设有专为新手提问的版面,如果你真的认为遇到了初学者的问题,到那去就是了,但一样别那么低声下气。

描述问题症状而非你的猜测

告诉程序员们你认为问题是怎样造成的并没什么帮助。(如果你的推断如此有效,还用向别人求助吗?),因此要确信你原原本本告诉了他们问题的症状,而不是你的解释和理论;让程序员们来推测和诊断。如果你认为陈述自己的猜测很重要,清楚地说明这只是你的猜测,并描述为什么它们不起作用。

蠢问题

我在编译内核时接连遇到 SIG11 错误, 我怀疑某条飞线搭在主板的走线上了,这种情况应该怎样检查最好?

聪明问题

我的组装电脑是 FIC-PA2007 主机板搭载 AMD K6/233 CPU(威盛 Apollo VP2 芯片组), 256MB Corsair PC133 SDRAM 内存,在编译内核时,从开机 20 分钟以后就频频产生 SIG11 错误, 但是在头 20 分钟内从没发生过相同的问题。重新启动也没有用,但是关机一晚上就又能工作 20 分钟。 所有内存都换过了,没有效果。相关部分的标准编译记录如下…。

由于以上这点似乎让许多人觉得难以配合,这里有句话可以提醒你:所有的诊断专家都来自密苏里州。 美国国务院的官方座右铭则是:让我看看(出自国会议员 Willard D. Vandiver 在 1899 年时的讲话:我来自一个出产玉米,棉花,牛蒡和民主党人的国家,滔滔雄辩既不能说服我,也不会让我满意。我来自密苏里州,你必须让我看看。) 针对诊断者而言,这并不是一种怀疑,而只是一种真实而有用的需求,以便让他们看到的是与你看到的原始证据尽可能一致的东西,而不是你的猜测与归纳的结论。所以,大方的展示给我们看吧!

按发生时间先后列出问题症状

问题发生前的一系列操作,往往就是对找出问题最有帮助的线索。因此,你的说明里应该包含你的操作步骤,以及机器和软件的反应,直到问题发生。在命令行处理的情况下,提供一段操作记录(例如运行脚本工具所生成的),并引用相关的若干行(如 20 行)记录会非常有帮助。

如果挂掉的程序有诊断选项(如 -v 的详述开关),试着选择这些能在记录中增加调试信息的选项。记住, 不等于 。试着选取适当的调试级别以便提供有用的信息而不是让读者淹没在垃圾中。

如果你的说明很长(如超过四个段落),在开头简述问题,接下来再按时间顺序详述会有所帮助。这样程序员们在读你的记录时就知道该注意哪些内容了。

描述目标而不是过程

如果你想弄清楚如何做某事(而不是报告一个 Bug),在开头就描述你的目标,然后才陈述重现你所卡住的特定步骤。

经常寻求技术帮助的人在心中有个更高层次的目标,而他们在自以为能达到目标的特定道路上被卡住了,然后跑来问该怎么走,但没有意识到这条路本身就有问题。结果要费很大的劲才能搞定。

蠢问题

我怎样才能从某绘图程序的颜色选择器中取得十六进制的的 RGB 值?

聪明问题

我正试着用替换一幅图片的色码(color table)成自己选定的色码,我现在知道的唯一方法是编辑每个色码区块(table slot), 但却无法从某绘图程序的颜色选择器取得十六进制的的 RGB 值。

第二种提问法比较聪明,你可能得到像是 建议采用另一个更合适的工具 的回复。

清楚明确的表达你的问题以及需求

漫无边际的提问是近乎无休无止的时间黑洞。最有可能给你有用答案的人通常也正是最忙的人(他们忙是因为要亲自完成大部分工作)。这样的人对无节制的时间黑洞相当厌恶,所以他们也倾向于厌恶那些漫无边际的提问。

如果你明确表述需要回答者做什么(如提供指点、发送一段代码、检查你的补丁、或是其他等等),就最有可能得到有用的答案。因为这会定出一个时间和精力的上限,便于回答者能集中精力来帮你。这么做很棒。

要理解专家们所处的世界,请把专业技能想像为充裕的资源,而回复的时间则是稀缺的资源。你要求他们奉献的时间越少,你越有可能从真正专业而且很忙的专家那里得到解答。

所以,界定一下你的问题,使专家花在辨识你的问题和回答所需要付出的时间减到最少,这技巧对你有用答案相当有帮助 – 但这技巧通常和简化问题有所区别。因此,问 我想更好的理解 X,可否指点一下哪有好一点说明? 通常比问 你能解释一下 X 吗? 更好。如果你的代码不能运作,通常请别人看看哪里有问题,比要求别人替你改正要明智得多。

询问有关代码的问题时

别要求他人帮你调试有问题的代码,不提示一下应该从何入手。张贴几百行的代码,然后说一声:它不能工作 会让你完全被忽略。只贴几十行代码,然后说一句:在第七行以后,我期待它显示 <x>,但实际出现的是 <y> 比较有可能让你得到回应。

最有效描述程序问题的方法是提供最精简的 Bug 展示测试用例(bug-demonstrating test case)。什么是最精简的测试用例?那是问题的缩影;一小个程序片段能 刚好 展示出程序的异常行为,而不包含其他令人分散注意力的内容。怎么制作最精简的测试用例?如果你知道哪一行或哪一段代码会造成异常的行为,复制下来并加入足够重现这个状况的代码(例如,足以让这段代码能被编译 / 直译 / 被应用程序处理)。如果你无法将问题缩减到一个特定区块,就复制一份代码并移除不影响产生问题行为的部分。总之,测试用例越小越好。

一般而言,要得到一段相当精简的测试用例并不太容易,但永远先尝试这样做的是种好习惯。这种方式可以帮助你了解如何自行解决这个问题 —- 而且即使你的尝试不成功,程序员们也会看到你在尝试取得答案的过程中付出了努力,这可以让他们更愿意与你合作。

如果你只是想让别人帮忙审查(Review)一下代码,在信的开头就要说出来,并且一定要提到你认为哪一部分特别需要关注以及为什么。

别把自己家庭作业的问题贴上来

程序员们很擅长分辨哪些问题是家庭作业式的问题;因为我们中的大多数都曾自己解决这类问题。同样,这些问题得由 来搞定,你会从中学到东西。你可以要求给点提示,但别要求得到完整的解决方案。

如果你怀疑自己碰到了一个家庭作业式的问题,但仍然无法解决,试试在使用者群组,论坛或(最后一招)在项目的 使用者 邮件列表或论坛中提问。尽管程序员们 看出来,但一些有经验的使用者也许仍会给你一些提示。

去掉无意义的提问句

避免用无意义的话结束提问,例如 有人能帮我吗? 或者 这有答案吗?

首先:如果你对问题的描述不是很好,这样问更是画蛇添足。

其次:由于这样问是画蛇添足,程序员们会很厌烦你 – 而且通常会用逻辑上正确,但毫无意义的回答来表示他们的蔑视, 例如:没错,有人能帮你 或者 不,没答案

一般来说,避免用 是或否对或错有或没有 类型的问句,除非你想得到 是或否类型的回答

礼多人不怪,而且有时还很有帮助

彬彬有礼,多用 谢谢您的关注,或 谢谢你的关照。让大家都知道你对他们花时间免费提供帮助心存感激。

坦白说,这一点并没有比清晰、正确、精准并合法语法和避免使用专用格式重要(也不能取而代之)。程序员们一般宁可读有点唐突但技术上鲜明的 Bug 报告,而不是那种有礼但含糊的报告。(如果这点让你不解,记住我们是按问题能教给我们什么来评价问题的价值的)

然而,如果你有一串的问题待解决,客气一点肯定会增加你得到有用回应的机会。

(我们注意到,自从本指南发布后,从资深程序员那里得到的唯一严重缺陷反馈,就是对预先道谢这一条。一些程序员觉得 先谢了 意味着事后就不用再感谢任何人的暗示。我们的建议是要么先说 先谢了 然后 事后再对回复者表示感谢,或者换种方式表达感激,譬如用 谢谢你的关注谢谢你的关照。)

问题解决后,加个简短的补充说明

问题解决后,向所有帮助过你的人发个说明,让他们知道问题是怎样解决的,并再一次向他们表示感谢。如果问题在新闻组或者邮件列表中引起了广泛关注,应该在那里贴一个说明比较恰当。

最理想的方式是向最初提问的话题回复此消息,并在标题中包含 已修正已解决 或其它同等含义的明显标记。在人来人往的邮件列表里,一个看见讨论串 问题 X问题 X - 已解决 的潜在回复者就明白不用再浪费时间了(除非他个人觉得 问题 X 的有趣),因此可以利用此时间去解决其它问题。

补充说明不必很长或是很深入;简单的一句 你好,原来是网线出了问题!谢谢大家 – Bill 比什么也不说要来的好。事实上,除非结论真的很有技术含量,否则简短可爱的小结比长篇大论更好。说明问题是怎样解决的,但大可不必将解决问题的过程复述一遍。

对于有深度的问题,张贴调试记录的摘要是有帮助的。描述问题的最终状态,说明是什么解决了问题,在此 之后 才指明可以避免的盲点。避免盲点的部分应放在正确的解决方案和其它总结材料之后,而不要将此信息搞成侦探推理小说。列出那些帮助过你的名字,会让你交到更多朋友。

除了有礼貌和有内涵以外,这种类型的补充也有助于他人在邮件列表 / 新闻群组 / 论坛中搜索到真正解决你问题的方案,让他们也从中受益。

至少,这种补充有助于让每位参与协助的人因问题的解决而从中得到满足感。如果你自己不是技术专家或者程序员,那就相信我们,这种感觉对于那些你向他们求助的大师或者专家而言,是非常重要的。问题悬而未决会让人灰心;程序员们渴望看到问题被解决。好人有好报,满足他们的渴望,你会在下次提问时尝到甜头。

思考一下怎样才能避免他人将来也遇到类似的问题,自问写一份文件或加个常见问题(FAQ)会不会有帮助。如果是的话就将它们发给维护者。

在程序员中,这种良好的后继行动实际上比传统的礼节更为重要,也是你如何透过善待他人而赢得声誉的方式,这是非常有价值的资产。

如何解读答案

RTFM 和 STFW:如何知道你已完全搞砸了

有一个古老而神圣的传统:如果你收到 RTFM (Read The Fucking Manual) 的回应,回答者认为你 应该去读他妈的手册 。当然,基本上他是对的,你应该去读一读。

RTFM 有一个年轻的亲戚。如果你收到 STFW(Search The Fucking Web) 的回应,回答者认为你 应该到他妈的网上搜索 过了。那人多半也是对的,去搜索一下吧。(更温和一点的说法是 Google 是你的朋友 !)

在论坛,你也可能被要求去爬爬论坛的旧文。事实上,有人甚至可能热心地为你提供以前解决此问题的讨论串。但不要依赖这种关照,提问前应该先搜索一下旧文。

通常,用这两句之一回答你的人会给你一份包含你需要内容的手册或者一个网址,而且他们打这些字的时候也正在读着。这些答复意味着回答者认为

  • 你需要的信息非常容易获得
  • 你自己去搜索这些信息比灌给你,能让你学到更多

你不应该因此不爽; 依照程序员的标准,他已经表示了对你一定程度的关注,而没有对你的要求视而不见 。你应该对他祖母般的慈祥表示感谢。

如果还是搞不懂

如果你看不懂回应,别立刻要求对方解释。像你以前试着自己解决问题时那样(利用手册,FAQ,网络,身边的高手),先试着去搞懂他的回应。如果你真的需要对方解释,记得表现出你已经从中学到了点什么。

比方说,如果我回答你:看来似乎是 zentry 卡住了;你应该先清除它。,然后,这是一个 很糟的 后续问题回应:zentry 是什么? 的问法应该是这样:哦 ~~~ 我看过说明了但是只有 -z 和 -p 两个参数中提到了 zentries,而且还都没有清楚的解释如何清除它。你是指这两个中的哪一个吗?还是我看漏了什么?

处理无礼的回应

很多程序员圈子中看似无礼的行为并不是存心冒犯。相反,它是直接了当,一针见血式的交流风格,这种风格更注重解决问题,而不是使人感觉舒服而却模模糊糊。

如果你觉得被冒犯了,试着平静地反应。如果有人真的做了出格的事,邮件列表、新闻群组或论坛中的前辈多半会招呼他。如果这 没有 发生而你却发火了,那么你发火对象的言语可能在程序员社区中看起来是正常的,而 将被视为有错的一方,这将伤害到你获取信息或帮助的机会。

另一方面,你偶而真的会碰到无礼和无聊的言行。与上述相反,对真正的冒犯者狠狠地打击,用犀利的语言将其驳得体无完肤都是可以接受的。然而,在行事之前一定要非常非常的有根据。纠正无礼的言论与开始一场毫无意义的口水战仅一线之隔,程序员们自己莽撞地越线的情况并不鲜见。如果你是新手或外人,避开这种莽撞的机会并不高。如果你想得到的是信息而不是消磨时光,这时最好不要把手放在键盘上以免冒险。

(有些人断言很多程序员都有轻度的自闭症或亚斯伯格综合症,缺少用于润滑人类社会 正常 交往所需的神经。这既可能是真也可能是假的。如果你自己不是程序员,兴许你认为我们脑袋有问题还能帮助你应付我们的古怪行为。只管这么干好了,我们不在乎。我们 喜欢 我们现在这个样子,并且通常对病患标记都有站得住脚的怀疑。)

Jeff Bigler 的观察总结和这个相关也值得一读 (tact filters)。

在下一节,我们会谈到另一个问题,当 行为不当时所会受到的 冒犯

如何避免扮演失败者

在程序员社区的论坛中有那么几次你可能会搞砸 – 以本指南所描述到的或类似的方式。而你会在公开场合中被告知你是如何搞砸的,也许攻击的言语中还会带点夹七夹八的颜色。

这种事发生以后,你能做的最糟糕的事莫过于哀嚎你的遭遇、宣称被口头攻击、要求道歉、高声尖叫、憋闷气、威胁诉诸法律、向其雇主报怨、忘了关马桶盖等等。相反地,你该这么做:

熬过去,这很正常。事实上,它是有益健康且合理的。

社区的标准不会自行维持,它们是通过参与者积极而 公开地 执行来维持的。不要哭嚎所有的批评都应该通过私下的邮件传送,它不是这样运作的。当有人评论你的一个说法有误或者提出不同看法时,坚持声称受到个人攻击也毫无益处,这些都是失败者的态度。

也有其它的程序员论坛,受过高礼节要求的误导,禁止参与者张贴任何对别人帖子挑毛病的消息,并声称 如果你不想帮助用户就闭嘴。 结果造成有想法的参与者纷纷离开,这么做只会使它们沦为毫无意义的唠叨与无用的技术论坛。

夸张的讲法是:你要的是 友善 (以上述方式)还是有用?两个里面挑一个。

记着:当程序员说你搞砸了,并且(无论多么刺耳)告诉你别再这样做时,他正在为关心 他的社区 而行动。对他而言,不理你并将你从他的生活中滤掉更简单。如果你无法做到感谢,至少要表现得有点尊严,别大声哀嚎,也别因为自己是个有戏剧性超级敏感的灵魂和自以为有资格的新来者,就指望别人像对待脆弱的洋娃娃那样对你。

有时候,即使你没有搞砸(或者只是在他的想像中你搞砸了),有些人也会无缘无故地攻击你本人。在这种情况下,抱怨倒是 真的 会把问题搞砸。

这些来找麻烦的人要么是毫无办法但自以为是专家的不中用家伙,要么就是测试你是否真会搞砸的心理专家。其它读者要么不理睬,要么用自己的方式对付他们。这些来找麻烦的人在给他们自己找麻烦,这点你不用操心。

也别让自己卷入口水战,最好不要理睬大多数的口水战 – 当然,这是在你检验它们只是口水战,并且未指出你有搞砸的地方,同时也没有巧妙地将问题真正的答案藏于其后(这也是有可能的)。

不该问的问题

以下是几个经典蠢问题,以及程序员没回答时心中所想的:

问题:我能在哪找到 X 程序或 X 资源?

问题:我怎样用 X 做 Y?

问题:如何设定我的 shell 提示?

问题:我可以用 Bass-o-matic 文件转换工具将 AcmeCorp 档案转换为 TeX 格式吗?

问题:我的程序 / 设定 /SQL 语句没有用

问题:我的 Windows 电脑有问题,你能帮我吗?

问题:我的程序不会动了,我认为系统工具 X 有问题

问题:我在安装 Linux(或者 X )时有问题,你能帮我吗?

问题:我怎么才能破解 root 帐号 / 窃取 OP 特权 / 读别人的邮件呢?


问题:我能在哪找到 X 程序或 X 资源?

回答:就在我找到它的地方啊,白痴 – 搜索引擎的那一头。天哪!难道还有人不会用 Google 吗?

问题:我怎样用 X 做 Y?

回答:如果你想解决的是 Y ,提问时别给出可能并不恰当的方法。这种问题说明提问者不但对 X 完全无知,也对 Y 要解决的问题糊涂,还被特定形势禁锢了思维。最好忽略这种人,等他们把问题搞清楚了再说。

问题:如何设定我的 shell 提示??

回答:如果你有足够的智慧提这个问题,你也该有足够的智慧去 RTFM,然后自己去找出来。

问题:我可以用 Bass-o-matic 文件转换工具将 AcmeCorp 档案转换为 TeX 格式吗?

回答:试试看就知道了。如果你试过,你既知道了答案,就不用浪费我的时间了。

问题:我的 {程序 / 设定 /SQL 语句} 不工作

回答:这不算是问题吧,我对要我问你二十个问题才找得出你真正问题的问题没兴趣 – 我有更有意思的事要做呢。在看到这类问题的时候,我的反应通常不外如下三种

  • 你还有什么要补充的吗?
  • 真糟糕,希望你能搞定。
  • 这关我有什么屁事?

问题:我的 Windows 电脑有问题,你能帮我吗?

回答:能啊,扔掉微软的垃圾,换个像 Linux 或 BSD 的开放源代码操作系统吧。

注意:如果程序有官方版 Windows 或者与 Windows 有互动(如 Samba),你 可以 问与 Windows 相关的问题, 只是别对问题是由 Windows 操作系统而不是程序本身造成的回复感到惊讶, 因为 Windows 一般来说实在太烂,这种说法通常都是对的。

问题:我的程序不会动了,我认为系统工具 X 有问题

回答:你完全有可能是第一个注意到被成千上万用户反复使用的系统调用与函数库档案有明显缺陷的人,更有可能的是你完全没有根据。不同凡响的说法需要不同凡响的证据,当你这样声称时,你必须有清楚而详尽的缺陷说明文件作后盾。

问题:我在安装 Linux(或者 X )时有问题,你能帮我吗?

回答:不能,我只有亲自在你的电脑上动手才能找到毛病。还是去找你当地的 Linux 使用群组者寻求实际的指导吧(你能在 这儿 找到使用者群组的清单)。

注意:如果安装问题与某 Linux 的发行版有关,在它的邮件列表、论坛或本地使用者群组中提问也许是恰当的。此时,应描述问题的准确细节。在此之前,先用 Linux 所有 被怀疑的硬件作关键词仔细搜索。

问题:我怎么才能破解 root 帐号 / 窃取 OP 特权 / 读别人的邮件呢?

回答:想要这样做,说明了你是个卑鄙小人;想找个程序员帮你,说明你是个白痴!

好问题与蠢问题

最后,我将透过举一些例子,来说明怎样聪明的提问;同一个问题的两种问法被放在一起,一种是愚蠢的,另一种才是明智的。

蠢问题

我可以在哪儿找到关于 Foonly Flurbamatic 的资料?

这种问法无非想得到 STFW 这样的回答。

聪明问题

我用 Google 搜索过 “Foonly Flurbamatic 2600”,但是没找到有用的结果。谁知道上哪儿去找对这种设备编程的资料?

这个问题已经 STFW 过了,看起来他真的遇到了麻烦。

蠢问题

我从 foo 项目找来的源码没法编译。它怎么这么烂?

他觉得都是别人的错,这个傲慢自大的提问者。

聪明问题

foo 项目代码在 Nulix 6.2 版下无法编译通过。我读过了 FAQ,但里面没有提到跟 Nulix 有关的问题。这是我编译过程的记录,我有什么做的不对的地方吗?

提问者已经指明了环境,也读过了 FAQ,还列出了错误,并且他没有把问题的责任推到别人头上,他的问题值得被关注。

蠢问题

我的主机板有问题了,谁来帮我?

某程序员对这类问题的回答通常是:好的,还要帮你拍拍背和换尿布吗?,然后按下删除键。

聪明问题

我在 S2464 主机板上试过了 X 、 Y 和 Z ,但没什么作用,我又试了 A 、 B 和 C 。请注意当我尝试 C 时的奇怪现象。显然 florbish 正在 grommicking,但结果出人意料。通常在 Athlon MP 主机板上引起 grommicking 的原因是什么?有谁知道接下来我该做些什么测试才能找出问题?

这个家伙,从另一个角度来看,值得去回答他。他表现出了解决问题的能力,而不是坐等天上掉答案。

在最后一个问题中,注意 告诉我答案给我启示,指出我还应该做什么诊断工作 之间微妙而又重要的区别。

事实上,后一个问题源自于 2001 年 8 月在 Linux 内核邮件列表(lkml)上的一个真实的提问。我(Eric)就是那个提出问题的人。我在 Tyan S2464 主板上观察到了这种无法解释的锁定现象,列表成员们提供了解决这一问题的重要信息。

通过我的提问方法,我给了别人可以咀嚼玩味的东西;我设法让人们很容易参与并且被吸引进来。我显示了自己具备和他们同等的能力,并邀请他们与我共同探讨。通过告诉他们我所走过的弯路,以避免他们再浪费时间,我也表明了对他们宝贵时间的尊重。

事后,当我向每个人表示感谢,并且赞赏这次良好的讨论经历的时候, 一个 Linux 内核邮件列表的成员表示,他觉得我的问题得到解决并非由于我是这个列表中的 名人 ,而是因为我用了正确的方式来提问。

程序员从某种角度来说是拥有丰富知识但缺乏人情味的家伙;我相信他是对的,如果我 个乞讨者那样提问,不论我是谁,一定会惹恼某些人或者被他们忽视。他建议我记下这件事,这直接导致了本指南的出现。

如果得不到回答

如果仍得不到回答,请不要以为我们觉得无法帮助你。有时只是看到你问题的人不知道答案罢了。没有回应不代表你被忽视,虽然不可否认这种差别很难区分。

总的来说,简单的重复张贴问题是个很糟的点子。这将被视为无意义的喧闹。有点耐心,知道你问题答案的人可能生活在不同的时区,可能正在睡觉,也有可能你的问题一开始就没有组织好。

你可以通过其他渠道获得帮助,这些渠道通常更适合初学者的需要。

有许多网上的以及本地的使用者群组,由热情的软件爱好者(即使他们可能从没亲自写过任何软件)组成。通常人们组建这样的团体来互相帮助并帮助新手。

另外,你可以向很多商业公司寻求帮助,不论公司大还是小。别为要付费才能获得帮助而感到沮丧!毕竟,假使你的汽车发动机汽缸密封圈爆掉了 – 完全可能如此 – 你还得把它送到修车铺,并且为维修付费。就算软件没花费你一分钱,你也不能强求技术支持总是免费的。

对像是 Linux 这种大众化的软件,每个开发者至少会对应到上万名使用者。根本不可能由一个人来处理来自上万名使用者的求助电话。要知道,即使你要为这些协助付费,和你所购买的同类软件相比,你所付出的也是微不足道的(通常封闭源代码软件的技术支持费用比开放源代码软件的要高得多,且内容也没那么丰富)。

如何更好地回答问题

态度和善一点 。问题带来的压力常使人显得无礼或愚蠢,其实并不是这样。

对初犯者私下回复 。对那些坦诚犯错之人没有必要当众羞辱,一个真正的新手也许连怎么搜索或在哪找常见问题都不知道。

如果你不确定,一定要说出来 !一个听起来权威的错误回复比没有还要糟,别因为听起来像个专家很好玩,就给别人乱指路。要谦虚和诚实,给提问者与同行都树个好榜样。

如果帮不了忙,也别妨碍他 。不要在实际步骤上开玩笑,那样也许会毁了使用者的设置 – 有些可怜的呆瓜会把它当成真的指令。

试探性的反问以引出更多的细节 。如果你做得好,提问者可以学到点东西 – 你也可以。试试将蠢问题转变成好问题,别忘了我们都曾是新手。

尽管对那些懒虫抱怨一声 RTFM 是正当的,能指出文件的位置(即使只是建议个 Google 搜索关键词)会更好。

如果你决定回答,就请给出好的答案 。当别人正在用错误的工具或方法时别建议笨拙的权宜之计(wordaround),应推荐更好的工具,重新界定问题。

正面的回答问题 !如果这个提问者已经很深入的研究而且也表明已经试过 X 、 Y 、 Z 、 A 、 B 、 C 但没得到结果,回答 试试看 A 或是 B 或者 试试 X 、 Y 、 Z 、 A 、 B 、 C 并附上一个链接一点用都没有。

帮助你的社区从问题中学习 。当回复一个好问题时,问问自己 如何修改相关文件或常见问题文件以免再次解答同样的问题?,接着再向文件维护者发一份补丁。

如果你是在研究一番后才做出的回答, 展现你的技巧而不是直接端出结果 。毕竟 授人以鱼不如授人以渔

tips:还有一点博主我觉得挺重要的:如果有妹子私聊你请教问题,请务必不要介意本文介绍的反例。

]]>
<p>每一个不恰当的提问都在消耗别人对你的耐心,程序员届早已经有了诸如《提问的智慧》之类的经典文章介绍了什么是蠢问题,如何避免问蠢问题。然而,常年混迹于十几个技术交流微信群的我,发现很多小白程序员并不懂得这一点,为改善微信群的技术交流氛围,转此文,意图是让大家在担任提问者的角色时,尽可能提高提问的素质,让自己成为值得被教的人。</p> <blockquote> <p>原文出处:<a href="https://github.com/aptx4869yuyang2017/How-To-Ask-Questions-The-Smart-Way" target="_blank" rel="noopener">https://github.com/aptx4869yuyang2017/How-To-Ask-Questions-The-Smart-Way</a></p> </blockquote>
一种心跳,两种设计 http://lexburner.github.io/heartbeat-design/ 2019-01-11T22:24:09.000Z 2019-09-26T09:45:29.612Z 1 前言

在前一篇文章 《聊聊 TCP 长连接和心跳那些事》 中,我们已经聊过了 TCP 中的 KeepAlive,以及在应用层设计心跳的意义,但却对长连接心跳的设计方案没有做详细地介绍。事实上,设计一个好的心跳机制并不是一件容易的事,就我所熟知的几个 RPC 框架,它们的心跳机制可以说大相径庭,这篇文章我将探讨一下 如何设计一个优雅的心跳机制,主要从 Dubbo 的现有方案以及一个改进方案来做分析

2 预备知识

因为后续我们将从源码层面来进行介绍,所以一些服务治理框架的细节还需要提前交代一下,方便大家理解。

2.1 客户端如何得知请求失败了?

高性能的 RPC 框架几乎都会选择使用 Netty 来作为通信层的组件,非阻塞式通信的高效不需要我做过多的介绍。但也由于非阻塞的特性,导致其发送数据和接收数据是一个异步的过程,所以当存在服务端异常、网络问题时,客户端接是接收不到响应的,那我们如何判断一次 RPC 调用是失败的呢?

误区一:Dubbo 调用不是默认同步的吗?

Dubbo 在通信层是异步的,呈现给使用者同步的错觉是因为内部做了阻塞等待,实现了异步转同步。

误区二: Channel.writeAndFlush 会返回一个 channelFuture,我只需要判断 channelFuture.isSuccess 就可以判断请求是否成功了。

注意,writeAndFlush 成功并不代表对端接受到了请求,返回值为 true 只能保证写入网络缓冲区成功,并不代表发送成功。

避开上述两个误区,我们再来回到本小节的标题:客户端如何得知请求失败? 正确的逻辑应当是以客户端接收到失败响应为判断依据 。等等,前面不还在说在失败的场景中,服务端是不会返回响应的吗?没错,既然服务端不会返回,那就只能客户端自己造了。

一个常见的设计是:客户端发起一个 RPC 请求,会设置一个超时时间 client_timeout,发起调用的同时,客户端会开启一个延迟 client_timeout 的定时器

  • 接收到正常响应时,移除该定时器。
  • 定时器倒计时完毕,还没有被移除,则认为请求超时,构造一个失败的响应传递给客户端。

Dubbo 中的超时判定逻辑:

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
public static DefaultFuture newFuture(Channel channel, Request request, int timeout) {
final DefaultFuture future = new DefaultFuture(channel, request, timeout);
// timeout check
timeoutCheck(future);
return future;
}
private static void timeoutCheck(DefaultFuture future) {
TimeoutCheckTask task = new TimeoutCheckTask(future);
TIME_OUT_TIMER.newTimeout(task, future.getTimeout(), TimeUnit.MILLISECONDS);
}
private static class TimeoutCheckTask implements TimerTask {
private DefaultFuture future;
TimeoutCheckTask(DefaultFuture future) {
this.future = future;
}
@Override
public void run(Timeout timeout) {
if (future == null || future.isDone()) {
return;
}
// create exception response.
Response timeoutResponse = new Response(future.getId());
// set timeout status.
timeoutResponse.setStatus(future.isSent() ? Response.SERVER_TIMEOUT : Response.CLIENT_TIMEOUT);
timeoutResponse.setErrorMessage(future.getTimeoutMessage(true));
// handle response.
DefaultFuture.received(future.getChannel(), timeoutResponse);
}
}

主要逻辑涉及的类:DubboInvokerHeaderExchangeChannelDefaultFuture ,通过上述代码,我们可以得知一个细节,无论是何种调用,都会经过这个定时器的检测, 超时即调用失败,一次 RPC 调用的失败,必须以客户端收到失败响应为准

2.2 心跳检测需要容错

网络通信永远要考虑到最坏的情况,一次心跳失败,不能认定为连接不通,多次心跳失败,才能采取相应的措施。

2.3 心跳检测不需要忙检测

忙检测的对立面是空闲检测,我们做心跳的初衷,是为了保证连接的可用性,以保证及时采取断连,重连等措施。如果一条通道上有频繁的 RPC 调用正在进行,我们不应该为通道增加负担去发送心跳包。 心跳扮演的角色应当是晴天收伞,雨天送伞。

3 Dubbo 现有方案

本文的源码对应 Dubbo 2.7.x 版本,在 apache 孵化的该版本中,心跳机制得到了增强。

介绍完了一些基础的概念,我们再来看看 Dubbo 是如何设计应用层心跳的。Dubbo 的心跳是双向心跳,客户端会给服务端发送心跳,反之,服务端也会向客户端发送心跳。

3.1 连接建立时创建定时器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class HeaderExchangeClient implements ExchangeClient {
private int heartbeat;
private int heartbeatTimeout;
private HashedWheelTimer heartbeatTimer;
public HeaderExchangeClient(Client client, boolean needHeartbeat) {
this.client = client;
this.channel = new HeaderExchangeChannel(client);
this.heartbeat = client.getUrl().getParameter(Constants.HEARTBEAT_KEY, dubbo != null && dubbo.startsWith("1.0.") ? Constants.DEFAULT_HEARTBEAT : 0);
this.heartbeatTimeout = client.getUrl().getParameter(Constants.HEARTBEAT_TIMEOUT_KEY, heartbeat * 3);
if (needHeartbeat) { <1>
long tickDuration = calculateLeastDuration(heartbeat);
heartbeatTimer = new HashedWheelTimer(new NamedThreadFactory("dubbo-client-heartbeat", true), tickDuration,
TimeUnit.MILLISECONDS, Constants.TICKS_PER_WHEEL); <2>
startHeartbeatTimer();
}
}
}

<1> 默认开启心跳检测的定时器

<2> 创建了一个 HashedWheelTimer 开启心跳检测 ,这是 Netty 所提供的一个经典的时间轮定时器实现,至于它和 jdk 的实现有何不同,不了解的同学也可以关注下,我就不拓展了。

不仅 HeaderExchangeClient 客户端开起了定时器,HeaderExchangeServer 服务端同样开起了定时器,由于服务端的逻辑和客户端几乎一致,所以后续我并不会重复粘贴服务端的代码。

Dubbo 在早期版本版本中使用的是 schedule 方案,在 2.7.x 中替换成了 HashWheelTimer。

3.2 开启两个定时任务

1
2
3
4
5
6
7
8
9
private void startHeartbeatTimer() {
long heartbeatTick = calculateLeastDuration(heartbeat);
long heartbeatTimeoutTick = calculateLeastDuration(heartbeatTimeout);
HeartbeatTimerTask heartBeatTimerTask = new HeartbeatTimerTask(cp, heartbeatTick, heartbeat); <1>
ReconnectTimerTask reconnectTimerTask = new ReconnectTimerTask(cp, heartbeatTimeoutTick, heartbeatTimeout); <2>

heartbeatTimer.newTimeout(heartBeatTimerTask, heartbeatTick, TimeUnit.MILLISECONDS);
heartbeatTimer.newTimeout(reconnectTimerTask, heartbeatTimeoutTick, TimeUnit.MILLISECONDS);
}

Dubbo 在 startHeartbeatTimer 方法中主要开启了两个定时器: HeartbeatTimerTaskReconnectTimerTask

<1> HeartbeatTimerTask 主要用于定时发送心跳请求

<2> ReconnectTimerTask 主要用于心跳失败之后处理重连,断连的逻辑

至于方法中的其他代码,其实也是本文的重要分析内容,先容我卖个关子,后面再来看追溯。

3.3 定时任务一:发送心跳请求

详细解析下心跳检测定时任务的逻辑 HeartbeatTimerTask#doTask

1
2
3
4
5
6
7
8
9
10
11
12
13
protected void doTask(Channel channel) {
Long lastRead = lastRead(channel);
Long lastWrite = lastWrite(channel);
if ((lastRead != null && now() - lastRead > heartbeat)
|| (lastWrite != null && now() - lastWrite > heartbeat)) {
Request req = new Request();
req.setVersion(Version.getProtocolVersion());
req.setTwoWay(true);
req.setEvent(Request.HEARTBEAT_EVENT);
channel.send(req);
}
}
}

前面已经介绍过,Dubbo 采取的是是双向心跳设计 ,即服务端会向客户端发送心跳,客户端也会向服务端发送心跳,接收的一方更新 lastRead 字段,发送的一方更新 lastWrite 字段,超过心跳间隙的时间,便发送心跳请求给对端。这里的 lastRead/lastWrite 同样会被同一个通道上的普通调用更新,通过更新这两个字段,实现了只在连接空闲时才会真正发送空闲报文的机制,符合我们一开始科普的做法。

注意:不仅仅心跳请求会更新 lastRead 和 lastWrite,普通请求也会。这对应了我们预备知识中的空闲检测机制。

3.4 定时任务二:处理重连和断连

继续研究下重连和断连定时器都实现了什么 ReconnectTimerTask#doTask

1
2
3
4
5
6
7
8
9
10
11
protected void doTask(Channel channel) {
Long lastRead = lastRead(channel);
Long now = now();
if (lastRead != null && now - lastRead > heartbeatTimeout) {
if (channel instanceof Client) {
((Client) channel).reconnect();
} else {
channel.close();
}
}
}

第二个定时器则负责根据客户端、服务端类型来对连接做不同的处理,当超过设置的心跳总时间之后,客户端选择的是重新连接,服务端则是选择直接断开连接。这样的考虑是合理的,客户端调用是强依赖可用连接的,而服务端可以等待客户端重新建立连接。

细心的朋友会发现,这个类被命名为 ReconnectTimerTask 是不太准确的,因为它处理的是重连和断连两个逻辑。

3.5 定时不精确的问题

在 Dubbo 的 issue 中曾经有人反馈过定时不精确的问题,我们来看看是怎么一回事。

Dubbo 中默认的心跳周期是 60s,设想如下的时序:

  • 第 0 秒,心跳检测发现连接活跃
  • 第 1 秒,连接实际断开
  • 第 60 秒,心跳检测发现连接不活跃

由于 时间窗口的问题,死链不能够被及时检测出来,最坏情况为一个心跳周期

为了解决上述问题,我们再倒回去看一下上面的 startHeartbeatTimer() 方法

1
2
long heartbeatTick = calculateLeastDuration(heartbeat); 
long heartbeatTimeoutTick = calculateLeastDuration(heartbeatTimeout);

其中 calculateLeastDuration 根据心跳时间和超时时间分别计算出了一个 tick 时间,实际上就是将两个变量除以了 3,使得他们的值缩小,并传入了 HashedWheelTimer 的第二个参数之中

1
2
heartbeatTimer.newTimeout(heartBeatTimerTask, heartbeatTick, TimeUnit.MILLISECONDS);
heartbeatTimer.newTimeout(reconnectTimerTask, heartbeatTimeoutTick, TimeUnit.MILLISECONDS);

tick 的含义便是定时任务执行的频率。这样,通过减少检测间隔时间,增大了及时发现死链的概率,原先的最坏情况是 60s,如今变成了 20s。这个频率依旧可以加快,但需要考虑资源消耗的问题。

定时不准确的问题出现在 Dubbo 的两个定时任务之中,所以都做了 tick 操作。事实上,所有的定时检测的逻辑都存在类似的问题。

3.6 Dubbo 心跳总结

Dubbo 对于建立的每一个连接,同时在客户端和服务端开启了 2 个定时器,一个用于定时发送心跳,一个用于定时重连、断连,执行的频率均为各自检测周期的 1/3。定时发送心跳的任务负责在连接空闲时,向对端发送心跳包。定时重连、断连的任务负责检测 lastRead 是否在超时周期内仍未被更新,如果判定为超时,客户端处理的逻辑是重连,服务端则采取断连的措施。

先不急着判断这个方案好不好,再来看看改进方案是怎么设计的。

4 Dubbo 改进方案

实际上我们可以更优雅地实现心跳机制,本小节开始,我将介绍一个新的心跳机制。

4.1 IdleStateHandler 介绍

Netty 对空闲连接的检测提供了天然的支持,使用 IdleStateHandler 可以很方便的实现空闲检测逻辑。

1
2
3
public IdleStateHandler(
long readerIdleTime, long writerIdleTime, long allIdleTime,
TimeUnit unit){}
  • readerIdleTime:读超时时间
  • writerIdleTime:写超时时间
  • allIdleTime:所有类型的超时时间

IdleStateHandler 这个类会根据设置的超时参数,循环检测 channelRead 和 write 方法多久没有被调用。当在 pipeline 中加入 IdleSateHandler 之后,可以在此 pipeline 的任意 Handler 的 userEventTriggered 方法之中检测 IdleStateEvent 事件,

1
2
3
4
5
6
7
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent) {
//do something
}
ctx.fireUserEventTriggered(evt);
}

为什么需要介绍 IdleStateHandler 呢?其实提到它的空闲检测 + 定时的时候,大家应该能够想到了,这不天然是给心跳机制服务的吗?很多服务治理框架都选择了借助 IdleStateHandler 来实现心跳。

IdleStateHandler 内部使用了 eventLoop.schedule(task) 的方式来实现定时任务,使用 eventLoop 线程的好处是还同时保证了 线程安全 ,这里是一个小细节。

4.2 客户端和服务端配置

首先是将 IdleStateHandler 加入 pipeline 中。

客户端:

1
2
3
4
5
6
bootstrap.handler(new ChannelInitializer<NioSocketChannel>() {
@Override
protected void initChannel(NioSocketChannel ch) throws Exception {
ch.pipeline().addLast("clientIdleHandler", new IdleStateHandler(60, 0, 0));
}
});

服务端:

1
2
3
4
5
6
serverBootstrap.childHandler(new ChannelInitializer<NioSocketChannel>() {
@Override
protected void initChannel(NioSocketChannel ch) throws Exception {
ch.pipeline().addLast("serverIdleHandler",new IdleStateHandler(0, 0, 200));
}
}

客户端配置了 read 超时为 60s,服务端配置了 write/read 超时为 200s,先在此埋下两个伏笔:

  1. 为什么客户端和服务端配置的超时时间不一致?
  2. 为什么客户端检测的是读超时,而服务端检测的是读写超时?

4.3 空闲超时逻辑 — 客户端

对于空闲超时的处理逻辑,客户端和服务端是不同的。首先来看客户端

1
2
3
4
5
6
7
8
9
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent) {
// send heartbeat
sendHeartBeat();
} else {
super.userEventTriggered(ctx, evt);
}
}

检测到空闲超时之后,采取的行为是向服务端发送心跳包,具体是如何发送,以及处理响应的呢?伪代码如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public void sendHeartBeat() {
Invocation invocation = new Invocation();
invocation.setInvocationType(InvocationType.HEART_BEAT);
channel.writeAndFlush(invocation).addListener(new CallbackFuture() {
@Override
public void callback(Future future) {
RPCResult result = future.get();
// 超时 或者 写失败
if (result.isError()) {
channel.addFailedHeartBeatTimes();
if (channel.getFailedHeartBeatTimes() >= channel.getMaxHeartBeatFailedTimes()) {
channel.reconnect();
}
} else {
channel.clearHeartBeatFailedTimes();
}
}
});
}

行为并不复杂,构造一个心跳包发送到服务端,接受响应结果

  • 响应成功,清空请求失败标记
  • 响应失败,心跳失败标记 +1,如果超过配置的失败次数,则重新连接

不仅仅是心跳,普通请求返回成功响应时也会清空标记

4.4 空闲超时逻辑 — 服务端

1
2
3
4
5
6
7
8
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent) {
channel.close();
} else {
super.userEventTriggered(ctx, evt);
}
}

服务端处理空闲连接的方式非常简单粗暴,直接关闭连接。

4.5 改进方案心跳总结

  1. 为什么客户端和服务端配置的超时时间不一致?

    因为客户端有重试逻辑,不断发送心跳失败 n 次之后,才认为是连接断开;而服务端是直接断开,留给服务端时间得长一点。60 * 3 < 200 还说明了一个问题,双方都拥有断开连接的能力,但连接的创建是由客户端主动发起的,那么客户端也更有权利去主动断开连接。

  2. 为什么客户端检测的是读超时,而服务端检测的是读写超时?

    这其实是一个心跳的共识了,仔细思考一下,定时逻辑是由客户端发起的,所以整个链路中不通的情况只有可能是:服务端接收,服务端发送,客户端接收。也就是说,只有客户端的 pong,服务端的 ping,pong 的检测是有意义的。

主动追求别人的是你,主动说分手的也是你。

利用 IdleStateHandler 实现心跳机制可以说是十分优雅的,借助 Netty 提供的空闲检测机制,利用客户端维护单向心跳,在收到 3 次心跳失败响应之后,客户端断开连接,交由异步线程重连,本质还是表现为客户端重连。服务端在连接空闲较长时间后,主动断开连接,以避免无谓的资源浪费。

5 心跳设计方案对比

Dubbo 现有方案Dubbo 改进方案
主体设计 开启两个定时器借助 IdleStateHandler,底层使用 schedule
心跳方向 双向单向(客户端 -> 服务端)
心跳失败判定方式 心跳成功更新标记,借助定时器定时扫描标记,如果超过心跳超时周期未更新标记,认为心跳失败。通过判断心跳响应是否失败,超过失败次数,认为心跳失败
扩展性 Dubbo 存在 mina,grizzy 等其他通信层实现,自定义定时器很容易适配多种扩展多通信层各自实现心跳,不做心跳的抽象
设计性 编码复杂度高,代码量大,方案复杂,不易维护编码量小,可维护性强

私下请教过 美团点评的长连接负责人:俞超(闪电侠),美点使用的心跳方案和 Dubbo 改进方案几乎一致,可以说该方案是标准实现了。

6 Dubbo 实际改动点建议

鉴于 Dubbo 存在一些其他通信层的实现,所以可以保留现有的定时发送心跳的逻辑。

  • 建议改动点一:

双向心跳的设计是不必要的,兼容现有的逻辑,可以让客户端在连接空闲时发送单向心跳,服务端定时检测连接可用性。定时时间尽量保证:客户端超时时间 * 3 ≈ 服务端超时时间

  • 建议改动点二:

去除处理重连和断连的定时任务,Dubbo 可以判断心跳请求是否响应失败,可以借鉴改进方案的设计,在连接级别维护一个心跳失败次数的标记,任意响应成功,清除标记;连续心跳失败 n 次,客户端发起重连。这样可以减少一个不必要的定时器,任何轮询的方式,都是不优雅的。

最后再聊聊可扩展性这个话题。其实我是建议把定时器交给更加底层的 Netty 去做,也就是完全使用 IdleStateHandler ,其他通信层组件各自实现自己的空闲检测逻辑,但是 Dubbo 中 mina,grizzy 的兼容问题囿住了我的拳脚,但试问一下,如今的 2019 年,又有多少人在使用 mina 和 grizzy?因为一些不太可能用的特性,而限制了主流用法的优化,这肯定不是什么好事。抽象,功能,可扩展性并不是越多越好,开源产品的人力资源是有限的,框架使用者的理解能力也是有限的,能解决大多数人问题的设计,才是好的设计。哎,谁让我不会 mina,grizzy,还懒得去学呢 [摊手]。

欢迎关注我的微信公众号:「Kirito 的技术分享」,关于文章的任何疑问都会得到回复,带来更多 Java 相关的技术分享。

关注微信公众号

]]>
<h3 id="1-前言"><a href="#1-前言" class="headerlink" title="1 前言"></a>1 前言</h3><p>在前一篇文章 <strong>《聊聊 TCP 长连接和心跳那些事》</strong> 中,我们已经聊过了 TCP 中的 KeepAlive,以及在应用层设计心跳的意义,但却对长连接心跳的设计方案没有做详细地介绍。事实上,设计一个好的心跳机制并不是一件容易的事,就我所熟知的几个 RPC 框架,它们的心跳机制可以说大相径庭,这篇文章我将探讨一下 <strong> 如何设计一个优雅的心跳机制,主要从 Dubbo 的现有方案以及一个改进方案来做分析 </strong>。</p>
聊聊 TCP 长连接和心跳那些事 http://lexburner.github.io/tcp-talk/ 2019-01-06T10:24:09.000Z 2019-09-26T09:45:29.692Z 前言

可能很多 Java 程序员对 TCP 的理解只有一个三次握手,四次握手的认识,我觉得这样的原因主要在于 TCP 协议本身稍微有点抽象(相比较于应用层的 HTTP 协议);其次,非框架开发者不太需要接触到 TCP 的一些细节。其实我个人对 TCP 的很多细节也并没有完全理解,这篇文章主要针对微信交流群里有人提出的长连接,心跳问题,做一个统一的整理。

在 Java 中,使用 TCP 通信,大概率会涉及到 Socket、Netty,本文将借用它们的一些 API 和设置参数来辅助介绍。

长连接与短连接

TCP 本身并没有长短连接的区别 ,长短与否,完全取决于我们怎么用它。

  • 短连接:每次通信时,创建 Socket;一次通信结束,调用 socket.close()。这就是一般意义上的短连接,短连接的好处是管理起来比较简单,存在的连接都是可用的连接,不需要额外的控制手段。
  • 长连接:每次通信完毕后,不会关闭连接,这样可以做到连接的复用。 长连接的好处是省去了创建连接的耗时。

短连接和长连接的优势,分别是对方的劣势。想要图简单,不追求高性能,使用短连接合适,这样我们就不需要操心连接状态的管理;想要追求性能,使用长连接,我们就需要担心各种问题:比如 端对端连接的维护,连接的保活

长连接还常常被用来做数据的推送,我们大多数时候对通信的认知还是 request/response 模型,但 TCP 双工通信的性质决定了它还可以被用来做双向通信。在长连接之下,可以很方便的实现 push 模型,长连接的这一特性在本文并不会进行探讨,有兴趣的同学可以专门去搜索相关的文章。

短连接没有太多东西可以讲,所以下文我们将目光聚焦在长连接的一些问题上。纯讲理论未免有些过于单调,所以下文我借助一些 RPC 框架的实践来展开 TCP 的相关讨论。

服务治理框架中的长连接

前面已经提到过,追求性能时,必然会选择使用长连接,所以借助 Dubbo 可以很好的来理解 TCP。我们开启两个 Dubbo 应用,一个 server 负责监听本地 20880 端口(众所周知,这是 Dubbo 协议默认的端口),一个 client 负责循环发送请求。执行 lsof -i:20880 命令可以查看端口的相关使用情况:

image-20190106203341694

  • *:20880 (LISTEN) 说明了 Dubbo 正在监听本地的 20880 端口,处理发送到本地 20880 端口的请求
  • 后两条信息说明请求的发送情况,验证了 TCP 是一个双向的通信过程,由于我是在同一个机器开启了两个 Dubbo 应用,所以你能够看到是本地的 53078 端口与 20880 端口在通信。我们并没有手动设置 53078 这个客户端端口,它是随机的。通过这两条信息,阐释了一个事实: 即使是发送请求的一方,也需要占用一个端口
  • 稍微说一下 FD 这个参数,他代表了 文件句柄 ,每新增一条连接都会占用新的文件句柄,如果你在使用 TCP 通信的过程中出现了 open too many files 的异常,那就应该检查一下,你是不是创建了太多连接,而没有关闭。细心的读者也会联想到长连接的另一个好处,那就是会占用较少的文件句柄。

长连接的维护

因为客户端请求的服务可能分布在多个服务器上,客户端自然需要跟对端创建多条长连接,我们遇到的第一个问题就是如何维护长连接。

1
2
3
4
5
6
7
8
9
// 客户端
public class NettyHandler extends SimpleChannelHandler {

private final Map<String, Channel> channels = new ConcurrentHashMap<String, Channel>(); // <ip:port, channel>
}
// 服务端
public class NettyServer extends AbstractServer implements Server {
private Map<String, Channel> channels; // <ip:port, channel>
}

在 Dubbo 中,客户端和服务端都使用 ip:port 维护了端对端的长连接,Channel 便是对连接的抽象。我们主要关注 NettyHandler 中的长连接,服务端同时维护一个长连接的集合是 Dubbo 的额外设计,我们将在后面提到。

这里插一句,解释下为什么我认为客户端的连接集合要重要一点。TCP 是一个双向通信的协议,任一方都可以是发送者,接受者,那为什么还抽象了 Client 和 Server 呢?因为 建立连接这件事就跟谈念爱一样,必须要有主动的一方,你主动我们就会有故事 。Client 可以理解为主动建立连接的一方,实际上两端的地位可以理解为是对等的。

连接的保活

这个话题就有的聊了,会牵扯到比较多的知识点。首先需要明确一点,为什么需要连接的保活?当双方已经建立了连接,但因为网络问题,链路不通,这样长连接就不能使用了。需要明确的一点是,通过 netstat,lsof 等指令查看到连接的状态处于 ESTABLISHED 状态并不是一件非常靠谱的事,因为连接可能已死,但没有被系统感知到,更不用提假死这种疑难杂症了。如果保证长连接可用是一件技术活。

连接的保活:KeepAlive

首先想到的是 TCP 中的 KeepAlive 机制。KeepAlive 并不是 TCP 协议的一部分,但是大多数操作系统都实现了这个机制(所以需要在操作系统层面设置 KeepAlive 的相关参数)。KeepAlive 机制开启后,在一定时间内(一般时间为 7200s,参数 tcp_keepalive_time)在链路上没有数据传送的情况下,TCP 层将发送相应的 KeepAlive 探针以确定连接可用性,探测失败后重试 10(参数 tcp_keepalive_probes)次,每次间隔时间 75s(参数 tcp_keepalive_intvl),所有探测失败后,才认为当前连接已经不可用。

在 Netty 中开启 KeepAlive:

1
bootstrap.option(ChannelOption.SO_KEEPALIVE, true)

Linux 操作系统中设置 KeepAlive 相关参数,修改 /etc/sysctl.conf 文件:

1
2
3
net.ipv4.tcp_keepalive_time=90
net.ipv4.tcp_keepalive_intvl=15
net.ipv4.tcp_keepalive_probes=2

KeepAlive 机制是在网络层面保证了连接的可用性 ,但站在应用框架层面我们认为这还不够。主要体现在三个方面:

  • KeepAlive 的开关是在应用层开启的,但是具体参数(如重试测试,重试间隔时间)的设置却是操作系统级别的,位于操作系统的 /etc/sysctl.conf 配置中,这对于应用来说不够灵活。
  • KeepAlive 的保活机制只在链路空闲的情况下才会起到作用,假如此时有数据发送,且物理链路已经不通,操作系统这边的链路状态还是 ESTABLISHED,这时会发生什么?自然会走 TCP 重传机制,要知道默认的 TCP 超时重传,指数退避算法也是一个相当长的过程。
  • KeepAlive 本身是面向网络的,并不面向于应用,当连接不可用,可能是由于应用本身的 GC 频繁,系统 load 高等情况,但网络仍然是通的,此时,应用已经失去了活性,连接应该被认为是不可用的。

我们已经为应用层面的连接保活做了足够的铺垫,下面就来一起看看,怎么在应用层做连接保活。

连接的保活:应用层心跳

终于点题了,文题中提到的 心跳 便是一个本文想要重点强调的另一个重要的知识点。上一节我们已经解释过了,网络层面的 KeepAlive 不足以支撑应用级别的连接可用性,本节就来聊聊应用层的心跳机制是实现连接保活的。

如何理解应用层的心跳?简单来说,就是客户端会开启一个定时任务,定时对已经建立连接的对端应用发送请求(这里的请求是特殊的心跳请求),服务端则需要特殊处理该请求,返回响应。如果心跳持续多次没有收到响应,客户端会认为连接不可用,主动断开连接。不同的服务治理框架对心跳,建连,断连,拉黑的机制有不同的策略,但大多数的服务治理框架都会在应用层做心跳,Dubbo/HSF 也不例外。

应用层心跳的设计细节

以 Dubbo 为例,支持应用层的心跳,客户端和服务端都会开启一个 HeartBeatTask,客户端在 HeaderExchangeClient 中开启,服务端将在 HeaderExchangeServer 开启。文章开头埋了一个坑:Dubbo 为什么在服务端同时维护 Map<String,Channel> 呢?主要就是为了给心跳做贡献,心跳定时任务在发现连接不可用时,会根据当前是客户端还是服务端走不同的分支,客户端发现不可用,是重连;服务端发现不可用,是直接 close。

1
2
3
4
5
6
// HeartBeatTask
if (channel instanceof Client) {
((Client) channel).reconnect();
} else {
channel.close();
}

Dubbo 2.7.x 相比 2.6.x 做了定时心跳的优化,使用 HashedWheelTimer 更加精准的控制了只在连接闲置时发送心跳。

再看看 HSF 的实现,并没有设置应用层的心跳,准确的说,是在 HSF2.2 之后,使用 Netty 提供的 IdleStateHandler 更加优雅的实现了应用的心跳。

1
2
ch.pipeline()
.addLast("clientIdleHandler", new IdleStateHandler(getHbSentInterval(), 0, 0));

处理 userEventTriggered 中的 IdleStateEvent 事件

1
2
3
4
5
6
7
8
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent) {
callConnectionIdleListeners(client, (ClientStream) StreamUtils.streamOfChannel(ctx.channel()));
} else {
super.userEventTriggered(ctx, evt);
}
}

对于客户端,HSF 使用 SendHeartbeat 来进行心跳,每次失败累加心跳失败的耗时,当超过最大限制时断开乱接;对于服务端 HSF 使用 CloseIdle 来处理闲置连接,直接关闭连接。一般来说,服务端的闲置时间会设置的稍长。

熟悉其他 RPC 框架的同学会发现,不同框架的心跳机制真的是差距非常大。心跳设计还跟连接创建,重连机制,黑名单连接相关,还需要具体框架具体分析。

除了定时任务的设计,还需要在协议层面支持心跳。最简单的例子可以参考 nginx 的健康检查,而针对 Dubbo 协议,自然也需要做心跳的支持,如果将心跳请求识别为正常流量,会造成服务端的压力问题,干扰限流等诸多问题。

dubbo protocol

其中 Flag 代表了 Dubbo 协议的标志位,一共 8 个地址位。低四位用来表示消息体数据用的序列化工具的类型(默认 hessian),高四位中,第一位为 1 表示是 request 请求,第二位为 1 表示双向传输(即有返回 response), 第三位为 1 表示是心跳事件

心跳请求应当和普通请求区别对待。

注意和 HTTP 的 KeepAlive 区别对待

  • HTTP 协议的 KeepAlive 意图在于连接复用,同一个连接上串行方式传递请求 - 响应数据
  • TCP 的 KeepAlive 机制意图在于保活、心跳,检测连接错误。

这压根是两个概念。

KeepAlive 常见错误

启用 TCP KeepAlive 的应用程序,一般可以捕获到下面几种类型错误

  1. ETIMEOUT 超时错误,在发送一个探测保护包经过 (tcp_keepalive_time + tcp_keepalive_intvl * tcp_keepalive_probes) 时间后仍然没有接收到 ACK 确认情况下触发的异常,套接字被关闭

    1
    java.io.IOException: Connection timed out
  2. EHOSTUNREACH host unreachable(主机不可达) 错误,这个应该是 ICMP 汇报给上层应用的。

    1
    java.io.IOException: No route to host
  3. 链接被重置,终端可能崩溃死机重启之后,接收到来自服务器的报文,然物是人非,前朝往事,只能报以无奈重置宣告之。

    1
    java.io.IOException: Connection reset by peer

总结

有三种使用 KeepAlive 的实践方案:

  1. 默认情况下使用 KeepAlive 周期为 2 个小时,如不选择更改,属于误用范畴,造成资源浪费:内核会为每一个连接都打开一个保活计时器,N 个连接会打开 N 个保活计时器。 优势很明显:
    • TCP 协议层面保活探测机制,系统内核完全替上层应用自动给做好了
    • 内核层面计时器相比上层应用,更为高效
    • 上层应用只需要处理数据收发、连接异常通知即可
    • 数据包将更为紧凑
  2. 关闭 TCP 的 KeepAlive,完全使用应用层心跳保活机制。由应用掌管心跳,更灵活可控,比如可以在应用级别设置心跳周期,适配私有协议。
  3. 业务心跳 + TCP KeepAlive 一起使用,互相作为补充,但 TCP 保活探测周期和应用的心跳周期要协调,以互补方可,不能够差距过大,否则将达不到设想的效果。

各个框架的设计都有所不同,例如 Dubbo 使用的是方案三,但阿里内部的 HSF 框架则没有设置 TCP 的 KeepAlive,仅仅由应用心跳保活。和心跳策略一样,这和框架整体的设计相关。

欢迎关注我的微信公众号:「Kirito 的技术分享」

关注微信公众号

]]>
<h3 id="前言"><a href="#前言" class="headerlink" title="前言"></a>前言</h3><p>可能很多 Java 程序员对 TCP 的理解只有一个三次握手,四次握手的认识,我觉得这样的原因主要在于 TCP 协议本身稍微有点抽象(相比较于应用层的 HTTP 协议);其次,非框架开发者不太需要接触到 TCP 的一些细节。其实我个人对 TCP 的很多细节也并没有完全理解,这篇文章主要针对微信交流群里有人提出的长连接,心跳问题,做一个统一的整理。 </p> <p>在 Java 中,使用 TCP 通信,大概率会涉及到 Socket、Netty,本文将借用它们的一些 API 和设置参数来辅助介绍。</p>
Dubbo 中的 URL 统一模型 http://lexburner.github.io/dubbo-url/ 2018-12-25T02:24:09.000Z 2019-09-26T09:45:31.376Z 定义

在不谈及 dubbo 时,我们大多数人对 URL 这个概念并不会感到陌生。统一资源定位器 (RFC1738――Uniform Resource Locators (URL))应该是最广为人知的一个 RFC 规范,它的定义也非常简单

因特网上的可用资源可以用简单字符串来表示,该文档就是描述了这种字符串的语法和语
义。而这些字符串则被称为:“统一资源定位器”(URL)

一个标准的 URL 格式 至多可以包含如下的几个部分

1
protocol://username:password@host:port/path?key=value&key=value

一些典型 URL

1
2
3
http://www.facebook.com/friends?param1=value1&amp;param2=value2
https://username:password@10.20.130.230:8080/list?version=1.0.0
ftp://username:password@192.168.1.7:21/1/read.txt

当然,也有一些 不太符合常规的 URL,也被归类到了 URL 之中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
192.168.1.3:20880
url protocol = null, url host = 192.168.1.3, port = 20880, url path = null

file:///home/user1/router.js?type=script
url protocol = file, url host = null, url path = home/user1/router.js

file://home/user1/router.js?type=script<br>
url protocol = file, url host = home, url path = user1/router.js

file:///D:/1/router.js?type=script
url protocol = file, url host = null, url path = D:/1/router.js

file:/D:/1/router.js?type=script
同上 file:///D:/1/router.js?type=script

/home/user1/router.js?type=script
url protocol = null, url host = null, url path = home/user1/router.js

home/user1/router.js?type=script
url protocol = null, url host = home, url path = user1/router.js

Dubbo 中的 URL

在 dubbo 中,也使用了类似的 URL,主要用于在各个扩展点之间传递数据,组成此 URL 对象的具体参数如下:

  • protocol:一般是 dubbo 中的各种协议 如:dubbo thrift http zk
  • username/password:用户名 / 密码
  • host/port:主机 / 端口
  • path:接口名称
  • parameters:参数键值对
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public URL(String protocol, String username, String password, String host, int port, String path, Map<String, String> parameters) {
if ((username == null || username.length() == 0)
&& password != null && password.length()> 0) {
throw new IllegalArgumentException("Invalid url, password without username!");
}
this.protocol = protocol;
this.username = username;
this.password = password;
this.host = host;
this.port = (port < 0 ? 0 : port);
this.path = path;
// trim the beginning "/"
while(path != null && path.startsWith("/")) {
path = path.substring(1);
}
if (parameters == null) {
parameters = new HashMap<String, String>();
} else {
parameters = new HashMap<String, String>(parameters);
}
this.parameters = Collections.unmodifiableMap(parameters);
}

可以看出,dubbo 认为 protocol,username,passwored,host,port,path 是主要的 URL 参数,其他键值对村房子啊 parameters 之中。

一些典型的 Dubbo URL

1
2
3
4
5
6
7
8
dubbo://192.168.1.6:20880/moe.cnkirito.sample.HelloService?timeout=3000
描述一个 dubbo 协议的服务

zookeeper://127.0.0.1:2181/org.apache.dubbo.registry.RegistryService?application=demo-consumer&dubbo=2.0.2&interface=org.apache.dubbo.registry.RegistryService&pid=1214&qos.port=33333&timestamp=1545721981946
描述一个 zookeeper 注册中心

consumer://30.5.120.217/org.apache.dubbo.demo.DemoService?application=demo-consumer&category=consumers&check=false&dubbo=2.0.2&interface=org.apache.dubbo.demo.DemoService&methods=sayHello&pid=1209&qos.port=33333&side=consumer&timestamp=1545721827784
描述一个消费者

可以说,任意的一个领域中的一个实现都可以认为是一类 URL,dubbo 使用 URL 来统一描述了元数据,配置信息,贯穿在整个框架之中。

URL 相关的生命周期

解析服务

基于 dubbo.jar 内的 META-INF/spring.handlers 配置,Spring 在遇到 dubbo 名称空间时,会回调 DubboNamespaceHandler

所有 dubbo 的标签,都统一用 DubboBeanDefinitionParser 进行解析,基于一对一属性映射,将 XML 标签解析为 Bean 对象。

ServiceConfig.export()ReferenceConfig.get() 初始化时,将 Bean 对象转换 URL 格式,所有 Bean 属性转成 URL 的参数。

然后将 URL 传给协议扩展点,基于扩展点自适应机制,根据 URL 的协议头,进行不同协议的服务暴露或引用。

暴露服务

1. 只暴露服务端口:

在没有注册中心,直接暴露提供者的情况下,ServiceConfig 解析出的 URL 的格式为:dubbo://service-host/com.foo.FooService?version=1.0.0

基于扩展点自适应机制,通过 URL 的 dubbo:// 协议头识别,直接调用 DubboProtocolexport() 方法,打开服务端口。

2. 向注册中心暴露服务:

在有注册中心,需要注册提供者地址的情况下,ServiceConfig 解析出的 URL 的格式为: registry://registry-host/org.apache.dubbo.registry.RegistryService?export=URL.encode("dubbo://service-host/com.foo.FooService?version=1.0.0")

基于扩展点自适应机制,通过 URL 的 registry:// 协议头识别,就会调用 RegistryProtocolexport() 方法,将 export 参数中的提供者 URL,先注册到注册中心。

再重新传给 Protocol 扩展点进行暴露: dubbo://service-host/com.foo.FooService?version=1.0.0,然后基于扩展点自适应机制,通过提供者 URL 的 dubbo:// 协议头识别,就会调用 DubboProtocolexport() 方法,打开服务端口。

引用服务

1. 直连引用服务:

在没有注册中心,直连提供者的情况下,ReferenceConfig 解析出的 URL 的格式为:dubbo://service-host/com.foo.FooService?version=1.0.0

基于扩展点自适应机制,通过 URL 的 dubbo:// 协议头识别,直接调用 DubboProtocolrefer() 方法,返回提供者引用。

2. 从注册中心发现引用服务:

在有注册中心,通过注册中心发现提供者地址的情况下,ReferenceConfig 解析出的 URL 的格式为:registry://registry-host/org.apache.dubbo.registry.RegistryService?refer=URL.encode("consumer://consumer-host/com.foo.FooService?version=1.0.0")

基于扩展点自适应机制,通过 URL 的 registry:// 协议头识别,就会调用 RegistryProtocolrefer() 方法,基于 refer 参数中的条件,查询提供者 URL,如: dubbo://service-host/com.foo.FooService?version=1.0.0

基于扩展点自适应机制,通过提供者 URL 的 dubbo:// 协议头识别,就会调用 DubboProtocolrefer() 方法,得到提供者引用。

然后 RegistryProtocol 将多个提供者引用,通过 Cluster 扩展点,伪装成单个提供者引用返回。

URL 统一模型的意义

对于 dubbo 中的 URL,有人理解为配置总线,有人理解为统一配置模型,说法虽然不同,但都是在表达一个意思,这样的 URL 在 dubbo 中被当做是 公共契约,所有扩展点参数都包含 URL 参数,URL 作为上下文信息贯穿整个扩展点设计体系。

在没有 URL 之前,只能以字符串传递参数,不停的解析和拼装,导致相同类型的接口,参数时而 Map, 时而 Parameters 类包装:

1
2
export(String url) 
createExporter(String host, int port, Parameters params)

使用 URL 一致性模型:

1
2
export(URL url) 
createExporter(URL url)

在最新的 dubbo 代码中,我们可以看到大量使用 URL 来进行上下文之间信息的传递,这样的好处是显而易见的:

  1. 使得代码编写者和阅读者能够将一系列的参数联系起来,进而形成规范,使得代码易写,易读。
  2. 可扩展性强,URL 相当于参数的集合 (相当于一个 Map),他所表达的含义比单个参数更丰富,当我们在扩展代码时,可以将新的参数追加到 URL 之中,而不需要改变入参,返参的结构。
  3. 统一模型,它位于 org.apache.dubbo.common 包中,各个扩展模块都可以使用它作为参数的表达形式,简化了概念,降低了代码的理解成本。

如果你能够理解 final 契约和 restful 契约,那我相信你会很好地理解 URL 契约。契约的好处我还是啰嗦一句:大家都这么做,就形成了默契,沟通是一件很麻烦的事,统一 URL 模型可以省去很多沟通成本,这边是 URL 统一模型存在的意义。

]]>
<h3 id="定义"><a href="#定义" class="headerlink" title="定义"></a>定义</h3><p>在不谈及 dubbo 时,我们大多数人对 URL 这个概念并不会感到陌生。统一资源定位器 (<a href="https://kimnote.com/rfc/cn/rfc1738.txt" target="_blank" rel="noopener">RFC1738</a>――Uniform Resource Locators (URL))应该是最广为人知的一个 RFC 规范,它的定义也非常简单</p> <blockquote> <p>因特网上的可用资源可以用简单字符串来表示,该文档就是描述了这种字符串的语法和语<br>义。而这些字符串则被称为:“统一资源定位器”(URL)</p> </blockquote> <p><strong> 一个标准的 URL 格式 </strong> 至多可以包含如下的几个部分</p> <figure class="highlight plain"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">protocol://username:password@host:port/path?key=value&amp;key=value</span><br></pre></td></tr></table></figure>
PolarDB 数据库性能大赛 Java 选手分享 http://lexburner.github.io/polardb-race/ 2018-12-10T10:43:56.000Z 2019-09-26T09:45:31.456Z 1 前言

排名

国际惯例,先报成绩,熬了无数个夜晚,最后依旧被绝杀出了第一页,最终排名第 21 名。前十名的成绩分布为 413.69~416.94,我最终的耗时是 422.43。成绩虽然不是特别亮眼,但与众多参赛选手使用 C++ 作为参赛语言不同,我使用的是 Java,一方面是我 C++ 的能力早已荒废,另一方面是我想验证一下使用 Java 编写存储引擎是否与 C++ 差距巨大 (当然,主要还是前者 QAQ)。所以在本文中,我除了介绍整体的架构之外,还会着重笔墨来探讨 Java 编写存储类型应用的一些最佳实践,文末会给出 github 的开源地址。

2 赛题概览

比赛总体分成了初赛和复赛两个阶段,整体要求实现一个简化、高效的 kv 存储引擎

初赛要求支持 Write、Read 接口。

1
2
public abstract void write(byte[] key, byte[] value);
public abstract byte[] read(byte[] key);

复赛在初赛题目基础上,还需要额外实现一个 Range 接口。

1
public abstract void range(byte[] lower, byte[] upper, AbstractVisitor visitor);

程序评测逻辑 分为 2 个阶段:
1)Recover 正确性评测:
此阶段评测程序会并发写入特定数据(key 8B、value 4KB)同时进行任意次 kill -9 来模拟进程意外退出(参赛引擎需要保证进程意外退出时数据持久化不丢失),接着重新打开 DB,调用 Read、Range 接口来进行正确性校验

2)性能评测

  • 随机写入:64 个线程并发随机写入,每个线程使用 Write 各写 100 万次随机数据(key 8B、value 4KB)
  • 随机读取:64 个线程并发随机读取,每个线程各使用 Read 读取 100 万次随机数据
  • 顺序读取:64 个线程并发顺序读取,每个线程各使用 Range 有序(增序)遍历全量数据 2 次
    注:
    2.2 阶段会对所有读取的 kv 校验是否匹配,如不通过则终止,评测失败;
    2.3 阶段除了对迭代出来每条的 kv 校 验是否匹配外,还会额外校验是否严格字典序递增,如不通过则终止,评测失败。

语言限定:C++ & JAVA,一起排名

3 赛题剖析

关于文件 IO 操作的一些基本常识,我已经在专题文章中进行了介绍,如果你没有浏览那篇文章,建议先行浏览一下:文件 IO 操作的一些最佳实践。再回归赛题,先对赛题中的几个关键词来进行解读。

3.1 key 8B, value 4kb

key 为固定的 8 字节,因此可使用 long 来表示。

value 为 4kb,这节省了我们很大的工作量,因为 4kb 的整数倍落盘是非常磁盘 IO 友好的。

value 为 4kb 的另一个好处是我们再内存做索引时,可以使用 int 而不是 long,来记录数据的逻辑偏移量:LogicOffset = PhysicalOffset / 4096,可以将 offset 的内存占用量减少一半。

3.2 kill -9 数据不丢失

首先赛题明确表示会进行 kill -9 并验证数据的一致性,这加大了我们在内存中做 write buffer 的难度。但它并没有要求断电不丢失,这间接地阐释了一点:我们可以使用 pageCache 来做写入缓存,在具体代码中我使用了 PageCache 来充当数据和索引的写入缓冲(两者策略不同)。同时这点也限制了参赛选手,不能使用 AIO 这样的异步落盘方式。

3.3 分阶段测评

赛题分为了随机写,随机读,顺序读三个阶段,每个阶段都会重新 open,且不会发生随机写到一半校验随机读这样的行为,所以我们在随机写阶段不需要在内存维护索引,而是直接落盘。随机读和顺序读阶段,磁盘均存在数据,open 阶段需要恢复索引,可以使用多线程并发恢复。

同时,赛题还有存在一些隐性的测评细节没有披露给大家,但通过测试,我们可以得知这些信息。

3.4 清空 PageCache 的耗时

虽然我们可以使用 PageCache,但评测程序在每个阶段之后都使用脚本清空了 PageCache,并且将这部分时间也算进了最终的成绩之中,所以有人感到奇怪:三个阶段的耗时相加比输出出来的成绩要差,其实那几秒便是清空 PageCache 的耗时。

1
2
3
4
5
6
#清理 pagecache (页缓存)
sysctl -w vm.drop_caches=1
#清理 dentries(目录缓存)和 inodes
sysctl -w vm.drop_caches=2
#清理 pagecache、dentries 和 inodes
sysctl -w vm.drop_caches=3

这一点启发我们,不能毫无节制的使用 PageCache,也正是因为这一点,一定程度上使得 Direct IO 这一操作成了本次竞赛的银弹。

3.5 key 的分布

这一个隐性条件可谓是本次比赛的关键,因为它涉及到 Range 部分的架构设计。本次比赛的 key 共计 6400w,但是他们的分布都是 均匀 的,在 《文件 IO 操作的一些最佳实践》 一文中我们已经提到了数据分区的好处,可以大大减少顺序读写的锁冲突,而 key 的分布均匀这一特性,启发我们在做数据分区时,可以按照 key 的搞 n 位来做 hash,从而确保 key 两个分区之间整体有序 (分区内部无序)。实际我尝试了将数据分成 1024、2048 个分区,效果最佳。

3.6 Range 的缓存设计

赛题要求 64 个线程 Range 两次全量的数据,限时 1h,这也启发了我们,如果不对数据进行缓存,想要在 1h 内完成比赛是不可能的,所以,我们的架构设计应该尽量以 Range 为核心,兼顾随机写和随机读。Range 部分也是最容易拉开差距的一个环节。

4 架构详解

首先需要明确的是,随机写指的是 key 的写入是随机的,但我们可以根据 key hash,将随机写转换为对应分区文件的顺序写。

1
2
3
4
5
6
7
8
9
/**
* using high ten bit of the given key to determine which file it hits.
*/
public class HighTenPartitioner implements Partitionable {
@Override
public int getPartition(byte[] key) {
return ((key[0] & 0xff)<< 2) | ((key[1] & 0xff)>> 6);
}
}

明确了高位分区的前提再来看整体的架构就变得明朗了

全局视角

全局视角

分区视角

分区视角

内存视角

内存中仅仅维护有序的 key[1024][625000] 数组和 offset[1024][625000] 数组。

上述两张图对整体的架构进行了一个很好的诠释,利用数据分布均匀的特性,可以将全局数据 hash 成 1024 个分区,在每个分区中存放两类文件:索引文件和数据文件。在随机写入阶段,根据 key 获得该数据对应分区位置,并按照时序,顺序追加到文件末尾,将全局随机写转换为局部顺序写。利用索引和数据一一对应的特性,我们也不需要将 data 的逻辑偏移量落盘,在 recover 阶段可以按照恢复 key 的次序,反推出 value 的逻辑偏移量。

在 range 阶段,由于我们事先按照 key 的高 10 为做了分区,所以我们可以认定一个事实,patition(N) 中的任何一个数据一定大于 partition(N-1) 中的任何一个数据,于是我们可以采用大块读,将一个 partition 整体读进内存,供 64 个 visit 线程消费。到这儿便奠定了整体的基调:读盘线程负责按分区读盘进入内存,64 个 visit 线程负责消费内存,按照 key 的次序随机访问内存,进行 Visitor 的回调。

5 随机写流程

介绍完了整体架构,我们分阶段来看一下各个阶段的一些细节优化点,有一些优化在各个环节都会出现,未避免重复,第二次出现的同一优化点我就不赘述了,仅一句带过。

使用 pageCache 实现写入缓冲区

主要看数据落盘,后讨论索引落盘。磁盘 IO 类型的比赛,第一步便是测量磁盘的 IOPS 以及多少个线程一次读写多大的缓存能够打满 IO,在固定 64 线程写入的前提下,16kb,64kb 均可以达到最理想 IOPS,所以理所当然的想到,可以为每一个分区分配一个写入缓存,凑齐 4 个 value 落盘。但是此次比赛,要做到 kill -9 不丢失数据,不能简单地在内存中分配一个 ByteBuffer.allocate(4096 * 4);, 而是可以考虑使用 mmap 内存映射出一片写入缓冲,凑齐 4 个刷盘,这样在 kill -9 之后,PageCache 不会丢失。实测 16kb 落盘比 4kb 落盘要快 6s 左右。

索引文件的落盘则没有太大的争议,由于 key 的数据量为固定的 8B,所以 mmap 可以发挥出它写小数据的优势,将 pageCache 利用起来,实测 mmap 相比 filechannel 写索引要快 3s 左右,相信如果把 polardb 这块盘换做其他普通的 ssd,这个数值还要增加。

写入时不维护内存索引,不写入数据偏移

一开始审题不清,在随机写之后误以为会立刻随机读,实际上每个阶段都是独立的,所以不需要在写入时维护内存索引;其次,之前的架构图中也已经提及,不需要写入连带 key+offset 一起写入文件,recover 阶段可以按照恢复索引的顺序,反推出 data 的逻辑偏移,因为我们的 key 和 data 在同一个分区内的位置是一一对应的。

6 恢复流程

recover 阶段的逻辑实际上包含在程序的 open 接口之中,我们需要再数据库引擎启动时,将索引从数据文件恢复到内存之中,在这之中也存在一些细节优化点。

由于 1024 个分区的存在,我们可以使用 64 个线程 (经验值) 并发地恢复索引,使用快速排序对 key[1024][625000] 数组和 offset[1024][625000] 进行 sort,之后再 compact,对 key 进行去重。需要注意的一点是,不要使用结构体,将 key 和 offset 封装在一起,这会使得排序和之后的二分效率非常低,这之中涉及到 CPU 缓存行的知识点,不了解的读者可以翻阅我之前的博客: 《CPU Cache 与缓存行》

1
2
3
4
5
// wrong
public class KeyOffset {
long key;
int offset;
}

整个 recover 阶段耗时为 1s,跟 cpp 选手交流后发现恢复流程比之慢了 600ms,这中间让我觉得比较诡异,加载索引和排序不应该这么慢才对,最终也没有优化成功。

7 随机读流程

随机读流程没有太大的优化点,优化空间实在有限,实现思路便是先根据 key 定位到分区,之后在有序的 key 数据中二分查找到 key/offset,拿到 data 的逻辑偏移和分区编号,便可以愉快的随机读了,随机读阶段没有太大的优化点,但仍然比 cpp 选手慢了 2-3s,可能是语言无法越过的差距。

8 顺序读流程

Range 环节是整个比赛的大头,也是拉开差距的分水岭。前面我们已经大概提到了 Range 的整体思路是一个生产者消费者模型,n 个生成者负责从磁盘读数据进入内存(n 作为变量,通过 benchmark 来确定多少合适,最终实测 n 为 4 时效果最佳),64 个消费者负责调用 visit 回调,来验证数据,visit 过程就是随机读内存的过程。在 Range 阶段,剩余的内存还有大概 1G 左右,所以我分配了 4 个堆外缓冲,一个 256M,从而可以缓存 4 个分区的数据,并且,我为每一个分区分配了一个读盘线程,负责 load 数据进入缓存,供 64 个消费者消费。

具体的顺序读架构可以参见下图:

range

大体来看,便是 4 个 fetch 线程负责读盘,fetch thread n 负责 partitionNo % 4 == n 编号的分区,完成后通知 visit 消费。这中间充斥着比较多的互斥等待逻辑,并未在图中体现出来,大体如下:

  1. fetch thread 1~4 加载磁盘数据进入缓存是并发的
  2. visit group 1~64 访问同一个 buffer 是并发的
  3. visit group 1~64 访问不同 partition 对应的 buffer 是按照次序来进行的 (打到全局有序)
  4. 加载 partitonN 会阻塞 visit bufferN,visit bufferN 会阻塞加载 partitionN+4(相当于复用 4 块缓存)

大块的加载读进缓存,最大程度复用,是 ReadSeq 部分的关键。顺序读两轮的成绩在 196~198s 左右,相比 C++ 又慢了 4s 左右。

9 魔鬼在细节中

这儿是个分水岭,介绍完了整体架构和四个阶段的细节实现,下面就是介绍下具体的优化点了。

10 Java 实现 Direct IO

由于这次比赛将 drop cache 的时间算进了测评程序之中,所以在不必要的地方应当尽量避免 pageCache,也就是说除了写索引之外,其他阶段不应该出现 pageCache。这对于 Java 选手来说可能是不小的障碍,因为 Java 原生没有提供 Direct IO,需要自己封装一套 JNA 接口,封装这套接口借鉴了开源框架 jaydio 的思路,感谢 @尘央的协助,大家可以在文末的代码中看到实现细节。这一点可以说是拦住了一大票 Java 选手。

Direct IO 需要注意的两个细节:

  1. 分配的内存需要对齐,对应 jna 方法:posix_memalign
  2. 写入的数据需要对齐通常是 pageSize 的整数倍,实际使用了 pread 的 O_DIRECT

11 直接内存优于堆内内存

这一点在《文件 IO 操作的一些最佳实践》中有所提及,堆外内存的两大好处是减少了一份内存拷贝,并且对 gc 友好,在 Direct IO 的实现中,应该配备一套堆外内存的接口,才能发挥出最大的功效。尤其在 Range 阶段,一个缓存区的大小便对应一个 partition 数据分区的大小:256M,大块的内存,更加适合用 DirectByteBuffer 装载。

12 JVM 调优

1
-server -Xms2560m -Xmx2560m -XX:MaxDirectMemorySize=1024m -XX:NewRatio=4 -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -XX:-UseBiasedLocking

众所周知 newRatio 控制的是 young 区和 old 区大小的比例,官方推荐参数为 -XX:NewRatio=1,很多不注意的 Java 选手可能没有意识去修改它,会在无形中被 gc 拖累。经过和 @阿杜的讨论,最终得出的结论:

  1. young 区过大,对象在年轻代待得太久,多次拷贝
  2. old 区过小,会频繁触发 old 区的 cms gc

在比赛中这显得尤为重要,-XX:NewRatio=4 放大老年代可以有效的减少 cms gc 的次数,将 126 次 cms gc,下降到最终的 5 次。

13 池化对象

无论是 apache 的 ObjectPool 还是 Netty 中的 Recycler,还是 RingBuffer 中预先分配的对象,都在传达一种思想,对于那些反复需要 new 出来的东西,都可以池化,分配内存再回收,这也是一笔不小的开销。在此次比赛的场景下,没必要大费周章地动用对象池,直接一个 ThreadLocal 即可搞定,事实上我对 key/value 的写入和读取都进行了 ThreadLocal 的缓存,做到了永远不再循环中分配对象。

14 减少线程切换

无论是网络 IO 还是磁盘 IO,io worker 线程的时间片都显得尤为的可贵,在我的架构中,range 阶段主要分为了两类线程:64 个 visit 线程并发随机读内存,4 个 io 线程并发读磁盘。木桶效应,我们很容易定位到瓶颈在于 4 个 io 线程,在 wait/notify 的模型中,为了尽可能的减少 io 线程的时间片流失,可以考虑使用 while(true) 进行轮询,而 visit 线程则可以 sleep(1us) 避免 cpu 空转带来的整体性能下降,由于评测机拥有 64 core,所以这样的分配算是较为合理的,为此我实现了一个简单粗暴的信号量。

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 LoopQuerySemaphore {

private volatile boolean permit;

public LoopQuerySemaphore(boolean permit) {
this.permit = permit;
}

// for 64 visit thread
public void acquire() throws InterruptedException {
while (!permit) {
Thread.sleep(0,1);
}
permit = false;
}

// for 4 fetch thread
public void acquireNoSleep() throws InterruptedException {
while (!permit) {
}
permit = false;
}

public void release() {
permit = true;
}

}

正确的在 IO 中 acquireNoSleep,在 Visit 中 acquire,可以让成绩相比使用普通的阻塞 Semaphore 提升 6s 左右。

15 绑核

线上机器的抖动在所难免,避免 IO 线程的切换也并不仅仅能够用依靠 while(true) 的轮询,一个 CPU 级别的优化便是腾出 4 个核心专门给 IO 线程使用,完全地避免 IO 线程的时间片争用。在 Java 中这也不难实现,依赖万能的 github,我们可以轻松地实现 Affinity。github 传送门:https://github.com/OpenHFT/Java-Thread-Affinity

使用方式:

1
2
3
try (final AffinityLock al2 = AffinityLock.acquireLock()) {
// do fetch ...
}

这个方式可以让你的代码快 1~2 s,并且保持测评的稳定性。

0 聊聊 FileChannel,MMAP,Direct IO,聊聊比赛

我在最终版本的代码中,几乎完全抛弃了 FileChannel,事实上,在不 Drop Cache 的场景下,它已经可以发挥出它利用 PageCache 的一些优势,并且优秀的 Java 存储引擎都主要使用了 FileChannel 来进行读写,在少量的场景下,使用了 MMAP 作为辅助,毕竟,MMAP 在写小数据量文件时存在其价值。

另外需要注意的一点,在跟 @96 年的亚普长谈的一个夜晚,发现 FileChannel 中出人意料的一个实现,在分配对内内存时,它仍然会拷贝一份堆外内存,这对于实际使用 FileChannel 的场景需要额外注意,这部分意料之外分配的内存很容易导致线上的问题(实际上已经遇到了,和 glibc 的 malloc 相关,当 buffer 大于 128k 时,会使用 mmap 分配一块内存作为缓存)

说回 FileChannel,MMAP,最容易想到的是 RocketMQ 之中对两者灵活的运用,不知道在其他 Java 实现的存储引擎之中,是不是可以考虑使用 Direct IO 来提升存储引擎的性能呢?我们可以设想一下,利用有限并且少量的 PageCache 来保证一致性,在主流程中使用 Direct IO 配合顺序读写是不是一种可以配套使用的方案,不仅仅 PolarDB,算作是参加本次比赛给予我的一个启发。

虽然无缘决赛,但使用 Java 取得这样的成绩还算不是特别难过,在 6400w 数据随机写,随机读,顺序读的场景下,Java 可以做到仅仅相差 C++ 不到 10s 的 overhead,我倒是觉得完全是可以接受的,哈哈。还有一些小的优化点就不在此赘述了,欢迎留言与我交流优化点和比赛感悟。

欢迎关注我的微信公众号:「Kirito 的技术分享」,关于文章的任何疑问都会得到回复,带来更多 Java 相关的技术分享。

关注微信公众号

]]>
<h3 id="1-前言"><a href="#1-前言" class="headerlink" title="1 前言"></a>1 前言</h3><p><img src="http://kirito.iocoder.cn/image-20181210184521001.png" alt="排名"></p> <p>国际惯例,先报成绩,熬了无数个夜晚,最后依旧被绝杀出了第一页,最终排名第 21 名。前十名的成绩分布为 413.69~416.94,我最终的耗时是 422.43。成绩虽然不是特别亮眼,但与众多参赛选手使用 C++ 作为参赛语言不同,我使用的是 Java,一方面是我 C++ 的能力早已荒废,另一方面是我想验证一下使用 Java 编写存储引擎是否与 C++ 差距巨大 (当然,主要还是前者 QAQ)。所以在本文中,我除了介绍整体的架构之外,还会着重笔墨来探讨 Java 编写存储类型应用的一些最佳实践,文末会给出 github 的开源地址。</p>
文件 IO 操作的一些最佳实践 http://lexburner.github.io/file-io-best-practise/ 2018-11-27T15:22:22.000Z 2019-09-26T09:45:30.439Z 背景

已经过去的中间件性能挑战赛,和正在进行中的 第一届 PolarDB 数据性能大赛 都涉及到了文件操作,合理地设计架构以及正确地压榨机器的读写性能成了比赛中获取较好成绩的关键。正在参赛的我收到了几位公众号读者朋友的反馈,他们大多表达出了这样的烦恼:“对比赛很感兴趣,但不知道怎么入门”,“能跑出成绩,但相比前排的选手,成绩相差 10 倍有余”…为了能让更多的读者参与到之后相类似的比赛中来,我简单整理一些文件 IO 操作的最佳实践,而不涉及整体系统的架构设计,希望通过这篇文章的介绍,让你能够欢快地参与到之后类似的性能挑战赛之中来。

知识点梳理

本文主要关注的 Java 相关的文件操作,理解它们需要一些前置条件,比如 PageCache,Mmap(内存映射),DirectByteBuffer(堆外缓存),顺序读写,随机读写… 不一定需要完全理解,但至少知道它们是个啥,因为本文将会主要围绕这些知识点来展开描述。

初识 FileChannel 和 MMAP

首先,文件 IO 类型的比赛最重要的一点,就是选择好读写文件的方式,那 JAVA 中文件 IO 有多少种呢?原生的读写方式大概可以被分为三种:普通 IO,FileChannel(文件通道),MMAP(内存映射)。区分他们也很简单,例如 FileWriter,FileReader 存在于 java.io 包中,他们属于普通 IO;FileChannel 存在于 java.nio 包中,属于 NIO 的一种,但是注意 NIO 并不一定意味着非阻塞,这里的 FileChannel 就是阻塞的;较为特殊的是后者 MMAP,它是由 FileChannel 调用 map 方法衍生出来的一种特殊读写文件的方式,被称之为内存映射。

使用 FIleChannel 的方式:

1
FileChannel fileChannel = new RandomAccessFile(new File("db.data"), "rw").getChannel();

获取 MMAP 的方式:

1
MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, filechannel.size();

MappedByteBuffer 便是 JAVA 中 MMAP 的操作类。

面向于字节传输的传统 IO 方式遭到了我们的唾弃,我们重点探讨 FileChannel 和 MMAP 这两种读写方式的区别。

FileChannel 读写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 写
byte[] data = new byte[4096];
long position = 1024L;
// 指定 position 写入 4kb 的数据
fileChannel.write(ByteBuffer.wrap(data), position);
// 从当前文件指针的位置写入 4kb 的数据
fileChannel.write(ByteBuffer.wrap(data));

// 读
ByteBuffer buffer = ByteBuffer.allocate(4096);
long position = 1024L;
// 指定 position 读取 4kb 的数据
fileChannel.read(buffer,position);
// 从当前文件指针的位置读取 4kb 的数据
fileChannel.read(buffer);

FileChannel 大多数时候是和 ByteBuffer 这个类打交道,你可以将它理解为一个 byte[] 的封装类,提供了丰富的 API 去操作字节,不了解的同学可以去熟悉下它的 API。值得一提的是,write 和 read 方法均是 线程安全 的,FileChannel 内部通过一把 private final Object positionLock = new Object(); 锁来控制并发。

FileChannel 为什么比普通 IO 要快呢?这么说可能不严谨,因为你要用对它,FileChannel 只有在一次写入 4kb 的整数倍时,才能发挥出实际的性能,这得益于 FileChannel 采用了 ByteBuffer 这样的内存缓冲区,让我们可以非常精准的控制写盘的大小,这是普通 IO 无法实现的。4kb 一定快吗?也不严谨,这主要取决你机器的磁盘结构,并且受到操作系统,文件系统,CPU 的影响,例如中间件性能挑战赛时的那块盘,一次至少写入 64kb 才能发挥出最高的 IOPS。

中间件性能挑战复赛的盘

然而 PolarDB 这块盘就完全不一样了,可谓是异常彪悍,具体是如何的表现由于比赛仍在进行中,不予深究,但凭借着 benchmark everyting 的技巧,我们完全可以测出来。

另外一点,成就了 FileChannel 的高效,介绍这点之前,我想做一个提问:FileChannel 是直接把 ByteBuffer 中的数据写入到磁盘吗?思考几秒…答案是:NO。ByteBuffer 中的数据和磁盘中的数据还隔了一层,这一层便是 PageCache,是用户内存和磁盘之间的一层缓存。我们都知道磁盘 IO 和内存 IO 的速度可是相差了好几个数量级。我们可以认为 filechannel.write 写入 PageCache 便是完成了落盘操作,但实际上,操作系统最终帮我们完成了 PageCache 到磁盘的最终写入,理解了这个概念,你就应该能够理解 FileChannel 为什么提供了一个 force() 方法,用于通知操作系统进行及时的刷盘。

同理,当我们使用 FileChannel 进行读操作时,同样经历了:磁盘 ->PageCache-> 用户内存这三个阶段,对于日常使用者而言,你可以忽略掉 PageCache,但作为挑战者参赛,PageCache 在调优过程中是万万不能忽视的,关于读操作这里不做过多的介绍,我们再下面的小结中还会再次提及,这里当做是引出 PageCache 的概念。

MMAP 读写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 写
byte[] data = new byte[4];
int position = 8;
// 从当前 mmap 指针的位置写入 4b 的数据
mappedByteBuffer.put(data);
// 指定 position 写入 4b 的数据
MappedByteBuffer subBuffer = mappedByteBuffer.slice();
subBuffer.position(position);
subBuffer.put(data);

// 读
byte[] data = new byte[4];
int position = 8;
// 从当前 mmap 指针的位置读取 4b 的数据
mappedByteBuffer.get(data);
// 指定 position 读取 4b 的数据
MappedByteBuffer subBuffer = mappedByteBuffer.slice();
subBuffer.position(position);
subBuffer.get(data);

FileChannel 已经足够强大了,MappedByteBuffer 还能玩出什么花来呢?请容许我卖个关子先,先介绍一下 MappedByteBuffer 的使用注意点。

当我们执行 fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, 1.5 * 1024 * 1024 * 1024); 之后,观察一下磁盘上的变化,会立刻获得一个 1.5G 的文件,但此时文件的内容全部是 0(字节 0)。这符合 MMAP 的中文描述:内存映射文件,我们之后对内存中 MappedByteBuffer 做的任何操作,都会被最终映射到文件之中,

mmap 把文件映射到用户空间里的虚拟内存,省去了从内核缓冲区复制到用户空间的过程,文件中的位置在虚拟内存中有了对应的地址,可以像操作内存一样操作这个文件,相当于已经把整个文件放入内存,但在真正使用到这些数据前却不会消耗物理内存,也不会有读写磁盘的操作,只有真正使用这些数据时,也就是图像准备渲染在屏幕上时,虚拟内存管理系统 VMS 才根据缺页加载的机制从磁盘加载对应的数据块到物理内存进行渲染。这样的文件读写文件方式少了数据从内核缓存到用户空间的拷贝,效率很高

看了稍微官方一点的描述,你可能对 MMAP 有了些许的好奇,有这么厉害的黑科技存在的话,还有 FileChannel 存在的意义吗!并且网上很多文章都在说,MMAP 操作大文件性能比 FileChannel 搞出一个数量级!然而,通过我比赛的认识,MMAP 并非是文件 IO 的银弹,它只有在 一次写入很小量数据的场景 下才能表现出比 FileChannel 稍微优异的性能。紧接着我还要告诉你一些令你沮丧的事,至少在 JAVA 中使用 MappedByteBuffer 是一件非常麻烦并且痛苦的事,主要表现为三点:

  1. MMAP 使用时必须实现指定好内存映射的大小,并且一次 map 的大小限制在 1.5G 左右,重复 map 又会带来虚拟内存的回收、重新分配的问题,对于文件不确定大小的情形实在是太不友好了。
  2. MMAP 使用的是虚拟内存,和 PageCache 一样是由操作系统来控制刷盘的,虽然可以通过 force() 来手动控制,但这个时间把握不好,在小内存场景下会很令人头疼。
  3. MMAP 的回收问题,当 MappedByteBuffer 不再需要时,可以手动释放占用的虚拟内存,但…方式非常的诡异。
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
public static void clean(MappedByteBuffer mappedByteBuffer) {
ByteBuffer buffer = mappedByteBuffer;
if (buffer == null || !buffer.isDirect() || buffer.capacity()== 0)
return;
invoke(invoke(viewed(buffer), "cleaner"), "clean");
}

private static Object invoke(final Object target, final String methodName, final Class<?>... args) {
return AccessController.doPrivileged(new PrivilegedAction<Object>() {
public Object run() {
try {
Method method = method(target, methodName, args);
method.setAccessible(true);
return method.invoke(target);
} catch (Exception e) {
throw new IllegalStateException(e);
}
}
});
}

private static Method method(Object target, String methodName, Class<?>[] args)
throws NoSuchMethodException {
try {
return target.getClass().getMethod(methodName, args);
} catch (NoSuchMethodException e) {
return target.getClass().getDeclaredMethod(methodName, args);
}
}

private static ByteBuffer viewed(ByteBuffer buffer) {
String methodName = "viewedBuffer";
Method[] methods = buffer.getClass().getMethods();
for (int i = 0; i < methods.length; i++) {
if (methods[i].getName().equals("attachment")) {
methodName = "attachment";
break;
}
}
ByteBuffer viewedBuffer = (ByteBuffer) invoke(buffer, methodName);
if (viewedBuffer == null)
return buffer;
else
return viewed(viewedBuffer);
}

对的,你没看错,这么长的代码仅仅是为了干回收 MappedByteBuffer 这一件事。

所以我建议,优先使用 FileChannel 去完成初始代码的提交,在必须使用小数据量 (例如几个字节) 刷盘的场景下,再换成 MMAP 的实现,其他场景 FileChannel 完全可以 cover(前提是你理解怎么合理使用 FileChannel)。至于 MMAP 为什么在一次写入少量数据的场景下表现的比 FileChannel 优异,我还没有查到理论根据,如果你有相关的线索,欢迎留言。理论分析下,FileChannel 同样是写入内存,但是在写入小数据量时,MMAP 表现的更加优秀,所以在索引数据落盘时,大多数情况应该选择使用 MMAP。至于 MMAP 分配的虚拟内存是否就是真正的 PageCache 这一点,我觉得可以近似理解成 PageCache。

顺序读比随机读快,顺序写比随机写快

无论你是机械硬盘还是 SSD,这个结论都是一定成立的,虽然背后的原因不太一样,我们今天不讨论机械硬盘这种古老的存储介质,重点 foucs 在 SSD 上,来看看在它之上进行的随机读写为什么比顺序读写要慢。即使各个 SSD 和文件系统的构成具有差异性,但我们今天的分析同样具备参考价值。

首先,什么是顺序读,什么是随机读,什么是顺序写,什么是随机写?可能我们刚接触文件 IO 操作时并不会有这样的疑惑,但写着写着,自己都开始怀疑自己的理解了,不知道你有没有经历过这样类似的阶段,反正我有一段时间的确怀疑过。那么,先来看看两段代码:

写入方式一:64 个线程,用户自己使用一个 atomic 变量记录写入指针的位置,并发写入

1
2
3
4
5
6
7
8
ExecutorService executor = Executors.newFixedThreadPool(64);
AtomicLong wrotePosition = new AtomicLong(0);
for(int i=0;i<1024;i++){
final int index = i;
executor.execute(()->{
fileChannel.write(ByteBuffer.wrap(new byte[4*1024]),wrote.getAndAdd(4*1024));
})
}

写入方式二:给 write 加了锁,保证了同步。

1
2
3
4
5
6
7
8
9
10
11
12
ExecutorService executor = Executors.newFixedThreadPool(64);
AtomicLong wrotePosition = new AtomicLong(0);
for(int i=0;i<1024;i++){
final int index = i;
executor.execute(()->{
write(new byte[4*1024]);
})
}

public synchronized void write(byte[] data){
fileChannel.write(ByteBuffer.wrap(new byte[4*1024]),wrote.getAndAdd(4*1024));
}

答案是方式二才算顺序写,顺序读也是同理。对于文件操作,加锁并不是一件非常可怕的事,不敢同步 write/read 才可怕!有人会问:FileChannel 内部不是已经有 positionLock 保证写入的线程安全了吗,为什么还要自己加同步?为什么这样会快?我用大白话来回答的话就是多线程并发 write 并且不加同步,会导致文件空洞,它的执行次序可能是

时序 1:thread1 write position[0~4096)

时序 2:thread3 write position[8194~12288)

时序 3:thread2 write position[4096~8194)

所以并不是完全的“顺序写”。不过你也别担心加锁会导致性能下降,我们会在下面的小结介绍一个优化:通过文件分片来减少多线程读写时锁的冲突。

在来分析原理,顺序读为什么会比随机读要快?顺序写为什么比随机写要快?这两个对比其实都是一个东西在起作用:PageCache,前面我们已经提到了,它是位于 application buffer(用户内存) 和 disk file(磁盘) 之间的一层缓存。

PageCache

以顺序读为例,当用户发起一个 fileChannel.read(4kb) 之后,实际发生了两件事

  1. 操作系统从磁盘加载了 16kb 进入 PageCache,这被称为预读
  2. 操作通从 PageCache 拷贝 4kb 进入用户内存

最终我们在用户内存访问到了 4kb,为什么顺序读快?很容量想到,当用户继续访问接下来的 [4kb,16kb] 的磁盘内容时,便是直接从 PageCache 去访问了。试想一下,当需要访问 16kb 的磁盘内容时,是发生 4 次磁盘 IO 快,还是发生 1 次磁盘 IO+4 次内存 IO 快呢?答案是显而易见的,这一切都是 PageCache 带来的优化。

深度思考:当内存吃紧时,PageCache 的分配会受影响吗?PageCache 的大小如何确定,是固定的 16kb 吗?我可以监控 PageCache 的命中情况吗? PageCache 会在哪些场景失效,如果失效了,我们又要哪些补救方式呢?

我进行简单的自问自答,背后的逻辑还需要读者去推敲:

  • 当内存吃紧时,PageCache 的预读会受到影响,实测,并没有搜到到文献支持
  • PageCache 是动态调整的,可以通过 linux 的系统参数进行调整,默认是占据总内存的 20%
  • https://github.com/brendangregg/perf-tools github 上一款工具可以监控 PageCache
  • 这是很有意思的一个优化点,如果用 PageCache 做缓存不可控,不妨自己做预读如何呢?

顺序写的原理和顺序读一致,都是收到了 PageCache 的影响,留给读者自己推敲一下。

直接内存 (堆外) VS 堆内内存

前面 FileChannel 的示例代码中已经使用到了堆内内存: ByteBuffer.allocate(4 * 1024),ByteBuffer 提供了另外的方式让我们可以分配堆外内存 : ByteBuffer.allocateDirect(4 * 1024)。这就引来的一系列的问题,我什么时候应该使用堆内内存,什么时候应该使用直接内存?

我不花太多笔墨去阐述了,直接上对比:

堆内内存堆外内存
底层实现 数组,JVM 内存unsafe.allocateMemory(size) 返回直接内存
分配大小限制 -Xms-Xmx 配置的 JVM 内存相关,并且数组的大小有限制,在做测试时发现,当 JVM free memory 大于 1.5G 时,ByteBuffer.allocate(900M) 时会报错可以通过 -XX:MaxDirectMemorySize 参数从 JVM 层面去限制,同时受到机器虚拟内存(说物理内存不太准确)的限制
垃圾回收 不必多说当 DirectByteBuffer 不再被使用时,会出发内部 cleaner 的钩子,保险起见,可以考虑手动回收:((DirectBuffer) buffer).cleaner().clean();
内存复制 堆内内存 -> 堆外内存 -> pageCache堆外内存 -> pageCache

关于堆内内存和堆外内存的一些最佳实践:

  1. 当需要申请大块的内存时,堆内内存会受到限制,只能分配堆外内存。
  2. 堆外内存适用于生命周期中等或较长的对象。(如果是生命周期较短的对象,在 YGC 的时候就被回收了,就不存在大内存且生命周期较长的对象在 FGC 对应用造成的性能影响)。
  3. 堆内内存刷盘的过程中,还需要复制一份到堆外内存,这部分内容可以在 FileChannel 的实现源码中看到细节,至于 Jdk 为什么需要这么做,可以参考我的另外一篇文章:《一文探讨堆外内存的监控与回收》
  4. 同时,还可以使用池 + 堆外内存 的组合方式,来对生命周期较短,但涉及到 I/O 操作的对象进行堆外内存的再使用 (Netty 中就使用了该方式)。在比赛中,尽量不要出现在频繁 new byte[] ,创建内存区域再回收也是一笔不小的开销,使用 ThreadLocal<ByteBuffer>ThreadLocal<byte[]> 往往会给你带来意外的惊喜 ~
  5. 创建堆外内存的消耗要大于创建堆内内存的消耗,所以当分配了堆外内存之后,尽可能复用它。

黑魔法:UNSAFE

1
2
3
4
5
6
7
8
9
10
11
12
public class UnsafeUtil {
public static final Unsafe UNSAFE;
static {
try {
Field field = Unsafe.class.getDeclaredField("theUnsafe");
field.setAccessible(true);
UNSAFE = (Unsafe) field.get(null);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}

我们可以使用 UNSAFE 这个黑魔法实现很多无法想象的事,我这里就稍微介绍一两点吧。

实现直接内存与内存的拷贝:

1
2
3
4
ByteBuffer buffer = ByteBuffer.allocateDirect(4 * 1024 * 1024);
long addresses = ((DirectBuffer) buffer).address();
byte[] data = new byte[4 * 1024 * 1024];
UNSAFE.copyMemory(data, 16, null, addresses, 4 * 1024 * 1024);

copyMemory 方法可以实现内存之间的拷贝,无论是堆内和堆外,1~2 个参数是 source 方,3~4 是 target 方,第 5 个参数是 copy 的大小。如果是堆内的字节数组,则传递数组的首地址和 16 这个固定的 ARRAY_BYTE_BASE_OFFSET 偏移常量;如果是堆外内存,则传递 null 和直接内存的偏移量,可以通过 ((DirectBuffer) buffer).address() 拿到。为什么不直接拷贝,而要借助 UNSAFE?当然是因为它快啊!少年!另外补充:MappedByteBuffer 也可以使用 UNSAFE 来 copy 从而达到写盘 / 读盘的效果哦。

至于 UNSAFE 还有那些黑科技,可以专门去了解下,我这里就不过多赘述了。

文件分区

前面已经提到了顺序读写时我们需要对 write,read 加锁,并且我一再强调的一点是:加锁并不可怕,文件 IO 操作并没有那么依赖多线程。但是加锁之后的顺序读写必然无法打满磁盘 IO,如今系统强劲的 CPU 总不能不压榨吧?我们可以采用文件分区的方式来达到一举两得的效果:既满足了顺序读写,又减少了锁的冲突。

那么问题又来了,分多少合适呢?文件多了,锁冲突变降低了;文件太多了,碎片化太过严重,单个文件的值太少,缓存也就不容易命中,这样的 trade off 如何平衡?没有理论答案,benchmark everything~

Direct IO

linux io

最后我们来探讨一下之前从没提到的一种 IO 方式,Direct IO,什么,Java 还有这东西?博主你骗我?之前怎么告诉我只有三种 IO 方式!别急着骂我,严谨来说,这并不是 JAVA 原生支持的方式,但可以通过 JNA/JNI 调用 native 方法做到。从上图我们可以看到 :Direct IO 绕过了 PageCache,但我们前面说到过,PageCache 可是个好东西啊,干嘛不用他呢?再仔细推敲一下,还真有一些场景下,Direct IO 可以发挥作用,没错,那就是我们前面没怎么提到的: 随机读 。当使用 fileChannel.read() 这类会触发 PageCache 预读的 IO 方式时,我们其实并不希望操作系统帮我们干太多事,除非真的踩了狗屎运,随机读都能命中 PageCache,但几率可想而知。Direct IO 虽然被 Linus 无脑喷过,但在随机读的场景下,依旧存在其价值,减少了 Block IO Layed(近似理解为磁盘) 到 Page Cache 的 overhead。

话说回来,Java 怎么用 Direct IO 呢?有没有什么限制呢?前面说过,Java 目前原生并不支持,但也有好心人封装好了 Java 的 JNA 库,实现了 Java 的 Direct IO,github 地址:https://github.com/smacke/jaydio

1
2
3
4
5
6
7
8
int bufferSize = 20 * 1024 * 1024;
DirectRandomAccessFile directFile = new DirectRandomAccessFile(new File("dio.data"), "rw", bufferSize);
for(int i= 0;i< bufferSize / 4096;i++){
byte[] buffer = new byte[4 * 1024];
directFile.read(buffer);
directFile.readFully(buffer);
}
directFile.close();

但需要注意的是, 只有 Linux 系统才支持 DIO! 所以,少年,是时候上手装一台 linux 了。值得一提的是,据说在 Jdk10 发布之后,Direct IO 将会得到原生的支持,让我们拭目以待吧!

总结

以上均是个人的实践积累而来的经验,有部分结论没有找到文献的支撑,所以如有错误,欢迎指正。关于 PolarDB 数据性能大赛的比赛分析,等复赛结束后我会专门另起一篇文章,分析下具体如何使用这些优化点,当然这些小技巧其实很多人都知道,决定最后成绩的还是整体设计的架构,以及对文件 IO,操作系统,文件系统,CPU 和语言特性的理解。虽然 JAVA 搞这种性能挑战赛并不吃香,但依旧是乐趣无穷,希望这些文件 IO 的知识能够帮助你,等下次比赛时看到你的身影 ~

欢迎关注我的微信公众号:「Kirito 的技术分享」,关于文章的任何疑问都会得到回复,带来更多 Java 相关的技术分享。

关注微信公众号

]]>
<h3 id="背景"><a href="#背景" class="headerlink" title="背景"></a>背景</h3><p>已经过去的中间件性能挑战赛,和正在进行中的 <a href="https://tianchi.aliyun.com/programming/introduction.htm?spm=5176.11165320.5678.1.483b4682l8fBSf&amp;raceId=231689" target="_blank" rel="noopener">第一届 PolarDB 数据性能大赛</a> 都涉及到了文件操作,合理地设计架构以及正确地压榨机器的读写性能成了比赛中获取较好成绩的关键。正在参赛的我收到了几位公众号读者朋友的反馈,他们大多表达出了这样的烦恼:“对比赛很感兴趣,但不知道怎么入门”,“能跑出成绩,但相比前排的选手,成绩相差 10 倍有余”…为了能让更多的读者参与到之后相类似的比赛中来,我简单整理一些文件 IO 操作的最佳实践,而不涉及整体系统的架构设计,希望通过这篇文章的介绍,让你能够欢快地参与到之后类似的性能挑战赛之中来。</p>
八个层面比较 Java 8, RxJava, Reactor http://lexburner.github.io/comparing-rxjava/ 2018-10-15T17:25:14.000Z 2019-09-26T09:45:31.052Z 前言

这是一篇译文,原文出处 戳这里。其实很久以前我就看完了这篇文章,只不过个人对响应式编程研究的不够深入,羞于下笔翻译,在加上这类译文加了原创还有争议性,所以一直没有动力。恰逢今天交流群里两个大佬对响应式编程的话题辩得不可开交,趁印象还算深刻,借机把这篇文章翻译一下。说道辩论的点,不妨也在这里抛出来:

响应式编程在单机环境下是否鸡肋?

结论是:没有结论,我觉得只能抱着怀疑的眼光审视这个问题了。另外还聊到了 RSocket 这个最近在 SpringOne 大会上比较火爆的响应式 “ 新“网络协议,github 地址 戳这里,为什么给”新“字打了个引号,仔细观察下 RSocket 的 commit log,其实三年前就有了。有兴趣的同学自行翻阅,说不定就是今年这最后两三个月的热点技术哦。

Java 圈子有一个怪事,那就是对 RxJava,Reactor,WebFlux 这些响应式编程的名词、框架永远处于渴望了解,感到新鲜,却又不甚了解,使用贫乏的状态。之前转载小马哥的那篇《Reactive Programming 一种技术,各自表述》时,就已经聊过这个关于名词之争的话题了,今天群里的讨论更是加深了我的映像。Java 圈子里面很多朋友一直对响应式编程处于一个了解名词,知道基本原理,而不是深度用户的状态 (我也是之一)。可能真的和圈子有关,按石冲兄的说法,其实 Scala 圈子里面的那帮人,不知道比咱们高到哪里去了(就响应式编程而言)。

实在是好久没发文章了,向大家说声抱歉,以后的更新频率肯定是没有以前那么勤了(说的好像以前很勤快似的),一部分原因是在公司内网写的文章没法贴到公众号中和大家分享讨论,另一部分是目前我也处于学习公司内部框架的阶段,不太方便提炼成文章,最后,最大的一部分原因还是我这段时间需要学 (tou) 习(lan)其 (da) 他(you)东 (xi) 西啦。好了,废话也说完了,下面是译文的正文部分。

引言

关于响应式编程 (Reactive Programming),你可能有过这样的疑问:我们已经有了 Java8 的 Stream, CompletableFuture, 以及 Optional,为什么还必要存在 RxJava 和 Reactor?

回答这个问题并不难,如果在响应式编程中处理的问题非常简单,你的确不需要那些第三方类库的支持。 但随着复杂问题的出现,你写出了一堆难看的代码。然后这些代码变得越来越复杂,难以维护,而 RxJava 和 Reactor 具有许多方便的功能,可以解决你当下问题,并保障了未来一些可预见的需求。本文从响应式编程模型中抽象出了 8 个标准,这将有助于我们理解标准特性与这些库之间的区别:

  1. Composable(可组合)
  2. Lazy(惰性执行)
  3. Reusable(可复用)
  4. Asynchronous(异步)
  5. Cacheable(可缓存)
  6. Push or Pull(推拉模型)
  7. Backpressure(回压)(译者注:按照石冲老哥的建议,这个词应当翻译成 “回压” 而不是 “背压”)
  8. Operator fusion(操作融合)

我们将会对以下这些类进行这些特性的对比:

  1. CompletableFuture(Java 8)
  2. Stream(Java 8)
  3. Optional(Java 8)
  4. Observable (RxJava 1)
  5. Observable (RxJava 2)
  6. Flowable (RxJava 2)
  7. Flux (Reactor Core)

让我们开始吧 ~

1. Composable(可组合)

这些类都是支持 Composable 特性的,使得各位使用者很便利地使用函数式编程的思想去思考问题,这也正是我们拥趸它们的原因。

CompletableFuture - 众多的 .then*() 方法使得我们可以构建一个 pipeline, 用以传递空值,单一的值,以及异常.

Stream - 提供了许多链式操作的编程接口,支持在各个操作之间传递多个值。

Optional - 提供了一些中间操作 .map(), .flatMap(), .filter().

Observable, Flowable, Flux - 和 Stream 相同

2. Lazy(惰性执行)

CompletableFuture - 不具备惰性执行的特性,它本质上只是一个异步结果的容器。这些对象的创建是用来表示对应的工作,CompletableFuture 创建时,对应的工作已经开始执行了。但它并不知道任何工作细节,只关心结果。所以,没有办法从上至下执行整个 pipeline。当结果被设置给 CompletableFuture 时,下一个阶段才开始执行。

Stream - 所有的中间操作都是延迟执行的。所有的终止操作 (terminal operations),会触发真正的计算 (译者注:如 collect() 就是一个终止操作 )。

Optional - 不具备惰性执行的特性,所有的操作会立刻执行。

Observable, Flowable, Flux - 惰性执行,只有当订阅者出现时才会执行,否则不执行。

3. Reusable(可复用)

CompletableFuture - 可以复用,它仅仅是一个实际值的包装类。但需要注意的是,这个包装是可更改的。.obtrude*() 方法会修改它的内容,如果你确定没有人会调用到这类方法,那么重用它还是安全的。

Stream - 不能复用。Java Doc 注释道:

A stream should be operated on (invoking an intermediate or terminal stream operation) only once. A stream implementation may throw IllegalStateException if it detects that the stream is being reused. However, since some stream operations may return their receiver rather than a new stream object, it may not be possible to detect reuse in all cases.

(译者注:Stream 只能被调用一次。如果被校测到流被重复使用了,它会跑出抛出一个 IllegalStateException 异常。但是某些流操作会返回他们的接受者,而不是一个新的流对象,所以无法在所有情况下检测出是否可以重用)

Optional - 完全可重用,因为它是不可变对象,而且所有操作都是立刻执行的。

Observable, Flowable, Flux - 生而重用,专门设计成如此。当存在订阅者时,每一次执行都会从初始点开始完整地执行一边。

4. Asynchronous(异步)

CompletableFuture - 这个类的要点在于它异步地把多个操作连接了起来。CompletableFuture 代表一项操作,它会跟一个 Executor 关联起来。如果不明确指定一个 Executor,那么会默认使用公共的 ForkJoinPool 线程池来执行。这个线程池可以用 ForkJoinPool.commonPool() 获取到。默认设置下它会创建系统硬件支持的线程数一样多的线程(通常和 CPU 的核心数相等,如果你的 CPU 支持超线程 (hyperthreading),那么会设置成两倍的线程数)。不过你也可以使用 JVM 参数指定 ForkJoinPool 线程池的线程数,

1
-Djava.util.concurrent.ForkJoinPool.common.parallelism=?

或者在创建 CompletableFuture 时提供一个指定的 Executor。

Stream - 不支持创建异步执行流程,但是可以使用 stream.parallel() 等方式创建并行流。

Optional - 不支持,它只是一个容器。

Observable, Flowable, Flux - 专门设计用以构建异步系统,但默认情况下是同步的。subscribeOnobserveOn 允许你来控制订阅以及接收(这个线程会调用 observer 的 onNext / onError / onCompleted 方法)。

subscribeOn 方法使得你可以决定由哪个 Scheduler 来执行 Observable.create 方法。即便你没有调用创建方法,系统内部也会做同样的事情。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
Observable
.fromCallable(() -> {
log.info("Reading on thread:" + currentThread().getName());
return readFile("input.txt");
})
.map(text -> {
log.info("Map on thread:" + currentThread().getName());
return text.length();
})
.subscribeOn(Schedulers.io()) // <-- setting scheduler
.subscribe(value -> {
log.info("Result on thread:" + currentThread().getName());
});

输出:

1
2
3
Reading file on thread: RxIoScheduler-2
Map on thread: RxIoScheduler-2
Result on thread: RxIoScheduler-2

相反的,observeOn() 控制在 observeOn() 之后,用哪个 Scheduler 来运行下游的执行阶段。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Observable
.fromCallable(() -> {
log.info("Reading on thread:" + currentThread().getName());
return readFile("input.txt");
})
.observeOn(Schedulers.computation()) // <-- setting scheduler
.map(text -> {
log.info("Map on thread:" + currentThread().getName());
return text.length();
})
.subscribeOn(Schedulers.io()) // <-- setting scheduler
.subscribe(value -> {
log.info("Result on thread:" + currentThread().getName());
});

输出:

1
2
3
Reading file on thread: RxIoScheduler-2
Map on thread: RxComputationScheduler-1
Result on thread: RxComputationScheduler-1

5. Cacheable(可缓存)

可缓存和可复用之间的区别是什么?假如我们有 pipeline A,重复使用它两次,来创建两个新的 pipeline B = A + X 以及 C = A + Y

  • 如果 B 和 C 都能成功执行,那么这个 A 就是是可重用的。
  • 如果 B 和 C 都能成功执行,并且 A 在这个过程中,整个 pipeline 只执行了一次,那么我们便称 A 是可缓存的。这意味着,可缓存一定代表可重用。

CompletableFuture - 跟可重用的答案一样。

Stream - 不能缓存中间操作的结果,除非调用了终止操作。

Optional - 可缓存,所有操作立刻执行,并且进行了缓存。

Observable, Flowable, Flux - 默认不可缓存的,但是可以调用 .cache() 把这些类变成可缓存的。例如:

1
2
3
4
5
6
Observable<Integer> work = Observable.fromCallable(() -> {
System.out.println("Doing some work");
return 10;
});
work.subscribe(System.out::println);
work.map(i -> i * 2).subscribe(System.out::println);

输出:

1
2
3
4
Doing some work
10
Doing some work
20

使用 .cache()

1
2
3
4
5
6
Observable<Integer> work = Observable.fromCallable(() -> {
System.out.println("Doing some work");
return 10;
}).cache(); // <- apply caching
work.subscribe(System.out::println);
work.map(i -> i * 2).subscribe(System.out::println);

输出:

1
2
3
Doing some work
10
20

6. Push or Pull(推拉模型)

Stream 和 Optional - 拉模型。调用不同的方法(.get(), .collect() 等)从 pipeline 拉取结果。拉模型通常和阻塞、同步关联,那也是公平的。当调用方法时,线程会一直阻塞,直到有数据到达。

CompletableFuture, Observable, Flowable, Flux - 推模型。当订阅一个 pipeline ,并且某些事件被执行后,你会得到通知。推模型通常和非阻塞、异步这些词关联在一起。当 pipeline 在某个线程上执行时,你可以做任何事情。你已经定义了一段待执行的代码,当通知到达的时候,这段代码就会在下个阶段被执行。

7. Backpressure(回压)

  • 支持回压的前提是 pipeline 必须是推模型。*

Backpressure(回压) 描述了 pipeline 中的一种场景:某些异步阶段的处理速度跟不上,需要告诉上游生产者放慢速度。直接失败是不能接受的,这会导致大量数据的丢失。

backpressure.jpg

Stream & Optional - 不支持回压,因为它们是拉模型。

CompletableFuture - 不存在这个问题,因为它只产生 0 个或者 1 个结果。

Observable(RxJava 1), Flowable, Flux - 支持。常用策略如下:

  • Buffering - 缓冲所有的 onNext 的值,直到下游消费它们。

  • Drop Recent - 如果下游处理速率跟不上,丢弃最近的 onNext 值。

  • Use Latest - 如果下游处理速率跟不上,只提供最近的 onNext 值,之前的值会被覆盖。

  • None - onNext 事件直接被触发,不做缓冲和丢弃。

  • Exception - 如果下游处理跟不上的话,抛出异常。

Observable(RxJava 2) - 不支持。很多 RxJava 1 的使用者用 Observable 来处理不适用回压的事件,或者是使用 Observable 的时候没有配置任何策略,导致了不可预知的异常。所以,RxJava 2 明确地区分两种情况,提供支持回压的 Flowable 和不支持回压的 Observable

8. Operator fusion(操作融合)

操作融合的内涵在于,它使得生命周期的不同点上的执行阶段得以改变,从而消除类库的架构因素所造成的系统开销。所有这些优化都在内部被处理完毕,从而让外部用户觉得这一切都是透明的。

只有 RxJava 2 和 Reactor 支持这个特性,但支持的方式不同。总的来说,有两种类型的优化:

Macro-fusion - 用一个操作替换 2 个或更多的相继的操作

macro-fusion_.png

Micro-fusion - 一个输出队列的结束操作,和在一个输入队列的开始操作,能够共享一个队列的实例。比如说,与其调用 request(1) 然后处理 onNext()`:

micro-fusion-1_1.png

不然让订阅者直接从父 observable 拉取值。

micro-fusion-2.png

更多信息可以参考 Part1Part2

总结

一图胜千言

2018-04-12_20-38-07.png

StreamCompletableFutureOptional 这些类的创建,都是为了解决特定的问题。 并且他们非常适合用于解决这些问题。 如果它们满足你的需求,你可以立马使用它们。

然而,不同的问题具有不同的复杂度,并且某些问题只有新技术才能很好的解决,新技术的出现也是为了解决那些高复杂度的问题。 RxJava 和 Reactor 是通用的工具,它们帮助你以声明方式来解决问题,而不是使用那些不够专业的工具,生搬硬套的使用其他的工具来解决响应式编程的问题,只会让你的解决方案变成一种 hack 行为。

欢迎关注我的微信公众号:「Kirito 的技术分享」,关于文章的任何疑问都会得到回复,带来更多 Java 相关的技术分享。

关注微信公众号

]]>
<h3 id="前言"><a href="#前言" class="headerlink" title="前言"></a>前言</h3><p>这是一篇译文,原文出处 <a href="http://alexsderkach.io/comparing-java-8-rxjava-reactor/" target="_blank" rel="noopener">戳这里</a>。其实很久以前我就看完了这篇文章,只不过个人对响应式编程研究的不够深入,羞于下笔翻译,在加上这类译文加了原创还有争议性,所以一直没有动力。恰逢今天交流群里两个大佬对响应式编程的话题辩得不可开交,趁印象还算深刻,借机把这篇文章翻译一下。说道辩论的点,不妨也在这里抛出来:</p> <blockquote> <p>响应式编程在单机环境下是否鸡肋?</p> </blockquote> <p>结论是:没有结论,我觉得只能抱着怀疑的眼光审视这个问题了。另外还聊到了 RSocket 这个最近在 SpringOne 大会上比较火爆的响应式 “ 新“网络协议,github 地址 <a href="https://github.com/rsocket/rsocket" target="_blank" rel="noopener">戳这里</a>,为什么给”新“字打了个引号,仔细观察下 RSocket 的 commit log,其实三年前就有了。有兴趣的同学自行翻阅,说不定就是今年这最后两三个月的热点技术哦。</p> <p> Java 圈子有一个怪事,那就是对 RxJava,Reactor,WebFlux 这些响应式编程的名词、框架永远处于渴望了解,感到新鲜,却又不甚了解,使用贫乏的状态。之前转载小马哥的那篇《Reactive Programming 一种技术,各自表述》时,就已经聊过这个关于名词之争的话题了,今天群里的讨论更是加深了我的映像。Java 圈子里面很多朋友一直对响应式编程处于一个了解名词,知道基本原理,而不是深度用户的状态 (我也是之一)。可能真的和圈子有关,按石冲兄的说法,其实 Scala 圈子里面的那帮人,不知道比咱们高到哪里去了(就响应式编程而言)。</p> <p>实在是好久没发文章了,向大家说声抱歉,以后的更新频率肯定是没有以前那么勤了(说的好像以前很勤快似的),一部分原因是在公司内网写的文章没法贴到公众号中和大家分享讨论,另一部分是目前我也处于学习公司内部框架的阶段,不太方便提炼成文章,最后,最大的一部分原因还是我这段时间需要学 (tou) 习(lan)其 (da) 他(you)东 (xi) 西啦。好了,废话也说完了,下面是译文的正文部分。</p>