写点什么

Square 从 Netty 3 升级到 Netty 4 的经验

2016 年 10 月 23 日

背景

Tracon 是 Square 公司的反向代理软件,最初它主要用于协调后段架构从传统单体架构向微服务架构的转换。作为反向代理前端,Tracon 需要有非常优秀的性能,同时能够支撑微服务架构下的各种功能定制,例如:服务发现、配置和生命周期管理等。因此 Tracon 网络层基于 Netty 构建,以提供高效代理服务。

Tracon 已经上线运行 3 年,其代码行数也增加到 30000 行。基于 Netty 3 的代理模块在如此庞大复杂的应用中运转正常,并抽离成独立模块应用到 Square 内部认证代理服务中。

Netty 4?

Netty 4 已经发布 3 年了,相比于 Netty 3,Netty 4 在内存模型和线程模型上都进行了修改。现在 Netty 4 已经非常成熟,并且对于 Square 公司来说,Netty 4 还有一个重大特性:对 HTTP/2 协议的原生支持。Square 期望其移动设备都使用HTTP/2 协议,并且正在将后台RPC 框架切换到 gRPC :一个基于 HTTP/2 协议的 RPC 框架。因此,Tracon 作为代理服务,必须支持 HTTP/2 协议。

Tracon 已经完成了到 Netty 4 的升级,整个升级过程也不是一帆风顺的,以下着重介绍一些在升级过程中容易遇到的问题。

单线程 channel

和 Netty 3 不同,Netty 4 的 inbound(数据输入)事件和 outbound(数据输出)事件的所有处理器(handler)都在同一个线程中。这是得在编写处理器的时候,可以移除线程安全相关的代码。但是,这个变化也使得在升级过程中遇到条件竞争导致的问题。

在 Netty 3 中,针对 pipeline 的操作都是线程安全的,但是在 Netty 4 中,所有操作都会以事件的形式放入事件循环中异步执行。作为代理服务的 Tracon,会有一个独立的 inbound channel 和上游服务器进行交互,一个独立的 outbound channel 和下游服务器进行交互。为了提高性能,和下游服务器的连接会被缓存起来,因此当事件循环中的事件触发了写操作时,这些写操作可能会并发进行。这对于 Netty 3 来说没有问题,每个写操作都会完成后再返回;但是对于 Netty 4,这些操作都进入了事件循环,可能会导致消息的乱序。

因此,在分块测试中,偶尔会遇到发出去的数据不是按照顺序到达,导致测试失败。

当从 Netty 3 升级到 Netty 4 时,如果有事件在事件循环外触发时,必须特别注意这些事件会被异步的调度。

连接何时真正建立?

Netty 3 中,连接建立之后会发出channelConnected事件;而在 Netty 4 中,这个事件变成了channelActive。对于一般应用程序来说,这个改动变化不大,修改一下对应的事件处理方法即可。但是 Tracon 使用了双向 TLS 认证以确认对方身份。

对于两个版本的SslHandler,TLS 握手完成消息处理方式完全不同。在 Netty 3 中,SslHandlerchannelConnected事件处理方法中阻塞,并完成整个 TLS 握手。因此后续的处理器在channelConnected事件处理方法中就可以获得完成握手的SSLSession。Netty 4 则不同,由于其事件机制,SslHandler完成 TLS 握手也是异步进行的,因此直接在channelConnected事件中,是无法获取到SSLSession的,此时 TLS 握手还没有完成。对应的SslHandler会在 TLS 握手完成之后,发出自定义的SslHandshakeCompletionEvent事件。

对于 Netty 4,TLS 握手完成后的逻辑应该改成:

复制代码
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws
if (evt.equals(SslHandshakeCompletionEvent.SUCCESS)) {
Principal peerPrincipal = engine.getSession().getPeerPrincipal();
// 身份验证
// ...
}
super.userEventTriggered(ctx, evt);
}

NIO 内存泄漏

由于 NIO 使用 direct 内存,对于 Netty 这类网络库,监控 direct 内存是很有必要的,这可以通过使用 JMX beanjava.nio:type=BufferPool,name=direct来进行。

Netty 4 引入了基于线程局部变量的回收器(thread-local Recyler)来回收对象池。默认情况下,一个回收器可以最多持有 262k 个对象,对于 ByteBuf 来说,小于 64k 的对象都默认共用缓存。也就是说,每个回收器最多可以持有 17G 的 direct 内存。

通常情况下,NIO 缓存足够应付瞬间的数据量。但是如果有一个读取速度很慢的后端,会大大增加内存使用。另外,当缓存中的 NIO 内存在被其他线程读写时,分配该内存的线程会无法回收这些内存。

对于回收器无法回收导致内存耗尽的问题,Netty 项目也做了一些修正,以解决限制对象增长的问题:

从升级 Netty 4 的经验来看,建议所有开发者基于可用内存和线程数来配置回收器。回收器最大持有对象数可以通过-Dio.netty.recycler.maxCapacity参数设置,共用内存最大限制可以通过-Dio.netty.threadLocalDirectBufferSize参数设置。如果要完全关闭回收器,可以将-Dio.netty.recycler.maxCapacity设置为 0,从 Tracon 的使用过程来看,使用回收器并没有对性能又多大的提升。

Tracon 在内存泄漏上还做了一个小的改动:当 JVM 抛出错误时,通过一个全局的异常处理类(UncaughtExceptionHandler)直接退出应用。因为通常情况下,当应用程序遇到了OutOfMemoryError错误时,已经无法自我恢复。

复制代码
class LoggingExceptionHandler implements Thread.UncaughtExceptionHandler {
private static final Logger logger = Logger.getLogger(LoggingExceptionHandler.class);
/** 注册成默认处理器 */
static void registerAsDefault() {
Thread.setDefaultUncaughtExceptionHandler(new LoggingExceptionHandler());
}
@Override
public void uncaughtException(Thread t, Throwable e) {
if (e instanceof Exception) {
logger.error("Uncaught exception killed thread named '" + t.getName() + "'.", e);
} else {
logger.fatal("Uncaught error killed thread named '" + t.getName() + "'." + " Exiting now.", e);
System.exit(1);
}
}
}

限制回收器使用解决了泄漏问题,但是一个读取速度很慢的后端还是会消耗大量缓存。Tracon 中通过使用channelWritabilityChanged事件来缓解写入缓存压力。通过增加如下处理器,可以关联两个 channel 的读写:

复制代码
/**
* 监听当前 inbound 管道是否可写,设置关联的 channel 是否自动读取。
* 这可以让代理通知另外一端当前 channel 有一个读取很慢的消费者,
* 仅当消费者准备完成后再进行数据读取。
*/
public class WritabilityHandler extends ChannelInboundHandlerAdapter {
private final Channel otherChannel;
public WritabilityHandler(Channel otherChannel) {
this.otherChannel = otherChannel;
}
@Override
public void channelWritabilityChanged(ChannelHandlerContext ctx) throws Exception {
boolean writable = ctx.channel().isWritable();
otherChannel.config().setOption(ChannelOption.AUTO_READ, writable);
super.channelWritabilityChanged(ctx);
}
}

当发送缓存到达高水位线时,将被标记为不可写,当发送缓存降低到低水位线时,重新被标记为可写。默认情况下,高水位线为 64kb,低水位线为 32kb。这些参数可以根据实际情况进行修改。

避免写异常丢失

当发生写操作失败时,如果没有对 promise 设置监听器,写操作失败会被忽略,这对于系统稳定性的分析会有很大影响。为了避免这种情况的发生,针对 promise 的监听器非常重要,但是如果每次创建 promise 时都需要设置一个日志记录的监听器,成本比较高,也容易遗忘。针对这种情况,Tracon 中针对 outbound 事件设置了专门的处理器,统一为写操作的 promise 设置日志记录监听器:

复制代码
@Singleton
@Sharable
public class PromiseFailureHandler extends ChannelOutboundHandlerAdapter {
private final Logger logger = Logger.getLogger(PromiseFailureHandler.class);
@Override
public void write(ChannelHandlerContext ctx, Object msg, ChannelPromise promise)
throws Exception {
promise.addListener(future -> {
if (!future.isSuccess()) {
logger.info("Write on channel %s failed", promise.cause(), ctx.channel());
}
});
super.write(ctx, msg, promise);
}
}

这样,只需要在 pipeline 中添加该处理器即可记录所有的写异常日志。

HTTP 解码器重构

Netty 4 对 HTTP 解码器做了重构,特别完善了对分块数据的支持。HTTP 消息体被拆分成HttpContent对象,如果 HTTP 数据通过分块的方式传输,会有多个HttpContent顺序到达,当数据块传输结束时,会有一个LastHttpContent对象达到。这里需要特别注意的是,LastHttpContent继承自HttpContent,千万不能用以下方式来处理:

复制代码
if (msg instanceof HttpContent) {
...
}
if (msg instanceof LastHttpContent) {
// 最后一个分块会重复处理,前面的 if 已经包含了 LastHttpContent
}

对于LastHttpContent还有一个需要注意的是,接收到这个对象时,HTTP 消息体可能已经传输完了,此时LastHttpContent只是作为 HTTP 传输的结束符(类似 EOF)。

灰度发布

这次升级 Netty 4,涉及到 100 多个文件共 8000 多行代码。并且,由于线程模型和内存模型的修改,Tracon 的替换必须非常小心。

在完成了发布前的单元测试、集成测试之后,首先需要部署到生产环境,并关闭流量。这样,代理服务能够和后端服务交互,同时避免用户真实流量导入。此时,需要正对这些服务做最终的确认,确保和线上后端服务交互没有任何问题。

完成验证之后,才能够开始逐步引入用户流量,最终完成 Netty 4 版本的 Tracon 升级。经过实际验证,使用UnpooledByteBufAllocator分配内存和之前 Netty 3 版本性能基本相同,期待以后使用PooledByteBufAllocator会有更好的性能。

总结

从 Netty 3 升级升级到 Netty 4,在带来了性能提升和新特性的同时,对原有代码的修改需要特别注意Netty 4 线程模型和内存模型的改变。以上这些遇到的问题,希望能够作为参考,避免在Netty 4 应用开发过程中再遇到类似问题。


感谢郭蕾对本文的审校。

给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家通过新浪微博( @InfoQ @丁晓昀),微信(微信号: InfoQChina )关注我们。

2016 年 10 月 23 日 19:003185

评论

发布
暂无评论
发现更多内容

深入Spring Security魔幻山谷-获取认证机制核心原理讲解

朱季谦

spring security

太赞了!华为工程师终于总结出了Linux归纳笔记,提供开放下载

小Q

实战解读丨Linux下实现高并发socket最大连接数的配置方法

华为云开发者社区

Linux TCP socket 高并发

Docker私有化部署gitlab gitlab-runner

InfoQ_e3332743a02f

gitlab 持续集成 runner

或许是史上最好的AQS源码分析了,你确定要错过?!

InfoQ_d2212957090d

【基础架构】不同场景下的数据存储技术,你用对了吗?

嘉为蓝鲸

网络 存储 系统 raid 磁盘挂载

同事跳槽阿里,临走甩给一份上千页的Linux源码笔记,真香

周老师

Java 编程 程序员 架构 面试

一个银行客户经理的“变形记”

华为云开发者社区

人工智能 金融科技

深入浅出java虚拟机

AI乔治

Java 架构 性能优化 JVM JVM原理

架构师课作业 - 第十二周

Tulane

浅析LR.Net工作流引擎

Philips

敏捷开发 工作流 软件开发流程 开发工具

内存型数据库Redis,是如何实现持久化的?

Zhongger

redis

XSKY全新一代SDS一体机五大场景之存储+灾备

XSKY融合存储

北京城市副中心将试点法定数字货币

CECBC区块链专委会

数字货币 货币

使用amoeba实现mysql读写分离

小Q

Java MySQL 编程 程序员

有的时候,到达目的地,还不如在旅途中。

空山

心理学 哲学 活在当下

产业互联网成区块链与数字货币的分水岭

CECBC区块链专委会

区块链 数字货币 产业互联网

喷一喷坑爹的面向UI编程

架构师修行之路

大数据管理:构建数据自己的“独门独院”

华为云开发者社区

大数据 数据湖

又踩Maven的两个坑

xiaoboey

maven Unknown lifecycle phase settings.xml 无效 PowerShell

快来看看!AQS 和 CountDownLatch 有怎么样的关系?

程序员小航

Java AQS 源码阅读 CountDownLatch JUC

深兰科技的征途,AI的赛场与战场

脑极体

CPU中的程序是怎么运行起来的

良知犹存

cpu

云图说 | 一分钟带你扫盲云容器黑话

华为云开发者社区

容器 节点 集群

为什么企业自主开发软件时,都会使用统一的模块化框架式开发平台?

Learun

敏捷开发 程序设计 开发工具 软件设计 技术方案

LeetCode题解:225. 用队列实现栈,两个队列, 压入 - O(n), 弹出 - O(1),JavaScript,详细注释

Lee Chen

LeetCode 前端进阶训练营

用 Python 实现一个简易版的 Pong 游戏 (一)

Matrix Chan

Python Turtle Python游戏

Redis 数据同步机制--主从模式

是老郭啊

redis 主从配置 主从同步 redis主从 主从复制

DB-Engines 9月数据库排名:ClickHouse一路猛冲,Redis坐稳第七

华章IT

MySQL 数据库 redis Clickhouse

鼓舞人心!主席支持数字经济!央行数字货币研究所为世界制定区块链相关国际标准

CECBC区块链专委会

区块链 金融

正在走进现实的“飞行汽车”,能否颠覆地面交通?

脑极体

InfoQ 极客传媒开发者生态共创计划线上发布会

InfoQ 极客传媒开发者生态共创计划线上发布会

Square从Netty 3升级到Netty 4的经验-InfoQ