Netty中的那些坑(上篇)
Posted Qunar技术沙龙
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Netty中的那些坑(上篇)相关的知识,希望对你有一定的参考价值。
本文转自横刀天笑的博客,点击文章底部“阅读原文”,查看更多博客内容。
最近开发了一个纯异步的redis客户端,算是比较深入的使用了一把netty。在使用过程中一边优化,一边解决各种坑。儿这些坑大部分基本上是Netty4对Netty3的改进部分引起的。
注:这里说的坑不是说netty不好,只是如果这些地方不注意,或者不去看netty的代码,就有可能掉进去了。
坑1: Netty 4的线程模型转变
在Netty 3的时候,upstream是在IO线程里执行的,而downstream是在业务线程里执行的。比如netty从网络读取一个包传递给你的handler的时候,你的handler部分的代码是执行在IO线程里,而你的业务线程调用write向网络写出一些东西的时候,你的handler是执行在业务线程里。而Netty 4修改了这一模型。在Netty 4里inbound(upstream)和outbound(downstream)都是执行在EventLoop(IO线程)里。也就是你如果在业务线程里通过channel.write向网络写出一些东西的时候,在某一点,netty 4会往这个channel的EventLoop里提交一个写出的任务。那也就是业务线程和IO线程是异步执行的。
这有什么问题呢?一般我们在网络通信里,业务层写出的都是对象。然后经过序列化等手段转换成字节流到网络,而Netty给我们提供了很好的编码解码的模型,一般我们也会将序列化和反序列化放到一个handler里处理,而在Netty 4里这些handler都是在EventLoop里执行,那么就意味着在Netty 4里下面的代码可能会导致一些微妙的结果:
User user = new User();
user.setName("admin");
channel.write(user);
user.setName("guest");
因为序列化和业务线程异步执行,那么在write执行后并不表示user对象已经序列化了,如果这个时候修改了user对象那么传递到peer的对象可能就不再是你期望的那个user了。所以在Netty 4里如果还是使用handler实现序列化就一定要小心了。你要么在调用channel.write写出之前将对象进行深度拷贝,要么就不在handler里进行序列化了,直接将序列化好的东西传递给channel。
2. 在不同的线程里使用PooledByteBufAllocator分配和回收
这个问题其实是上面一个问题的续集。在碰到之前一个问题后,我们就决定不再在handler里做序列化了,而是直接在业务线程里做。但是为了减少内存的拷贝,我们就期望在序列化的时候直接将字节流序列化到DirectByteBuf里,这样通过socket写出的时候就不进行拷贝了。而DirectByteBuf的分配成本比HeapByteBuf的成本要高,为此Netty 4借鉴jemalloc的思路实现了一个PooledByteBufAllocator。顾名思义,就是将DirectByteBuf池化起来,回收的时候不真正回收,分配的时候从池里取一个空闲的。这对于大多数应用来说优化效果还是很明显的,比如在一些RPC场景中,我们所传递的对象的大小往往是差不多的,这可以充分利用池化的效果。
但是我们在使用类似下面的伪代码的时候内存占用不断飙高,然后疯狂Full GC,并且有的时候还会出现OOM。这好像是内存泄漏的迹象:
//业务线程
PooledByteBufAllocator allocator = PooledByteBufAllocator.DEFAULT;
ByteBuf buffer = allocator.buffer();
User user = new User();
//将对象直接序列化到ByteBuf
serialization.serialize(buffer, user);
//进入EventLoop
channel.writeAndFlush(buffer);
上面的代码表面看没什么问题。但实际上,PooledByteBufAllocator为了减少锁竞争,池是通过thread local来实现的。也就是分配的时候会从本线程(这里就是业务线程)的thread local里取。而channel.writeAndFlush调用后,在将buffer写到socket后,这个buffer将被回收到池里。回收的时候也是通过thread local找到对应的池,回收掉。这样就有一个问题,分配的时候是在业务线程,也就是说从业务线程的thread local对应的池里分配的,而回收的时候是在IO线程。这两个是不同的线程。池的作用完全丧失了,一个线程不断地去分配,不断地转移到另外一个池。
3. ByteBuf扩展引起的问题
其实这个问题和上面一个问题是一样的。但是比之前的问题更加隐晦,就在你弹冠相庆的时候给你致命一击。在碰到上面一个问题后我们就在想,既然分配和回收都得在同一个线程里执行,那我们是不是可以启动一个专门的线程来负责分配和回收呢?于是就有了下面的代码:
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.PooledByteBufAllocator;
import io.netty.util.ReferenceCountUtil;
import qunar.tc.qclient.redis.exception.RedisRuntimeException;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
public class Allocator {
public static final ByteBufAllocator allocator = PooledByteBufAllocator.DEFAULT;
private static final BlockingQueue<ByteBuf> bufferQueue = new ArrayBlockingQueue<ByteBuf>(100);
private static final BlockingQueue<ByteBuf> toCleanQueue = new LinkedBlockingQueue<ByteBuf>();
private static final int TO_CLEAN_SIZE = 50;
private static final long CLEAN_PERIOD = 100;
private static class AllocThread implements Runnable {
@Override
public void run() {
long lastCleanTime = System.currentTimeMillis();
while (!Thread.currentThread().isInterrupted()) {
try {
ByteBuf buffer = allocator.buffer();
//确保是本线程释放
buffer.retain();
bufferQueue.put(buffer);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
if (toCleanQueue.size() > TO_CLEAN_SIZE || System.currentTimeMillis() - lastCleanTime > CLEAN_PERIOD) {
final List<ByteBuf> toClean = new ArrayList<ByteBuf>(toCleanQueue.size());
toCleanQueue.drainTo(toClean);
for (ByteBuf buffer : toClean) {
ReferenceCountUtil.release(buffer);
}
lastCleanTime = System.currentTimeMillis();
}
}
}
}
static {
Thread thread = new Thread(new AllocThread(), "qclient-redis-allocator");
thread.setDaemon(true);
thread.start();
}
public static ByteBuf alloc() {
try {
return bufferQueue.take();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RedisRuntimeException("alloc interrupt");
}
}
public static void release(ByteBuf buf) {
toCleanQueue.add(buf);
}
}
在业务线程里调用alloc,从queue里拿到专用的线程分配好的buffer。在将buffer写出到socket之后再调用release回收:
//业务线程
ByteBuf buffer = Allocator.alloc();
//序列化
........
//写出
ChannelPromise promise = channel.newPromise();
promise.addListener(new GenericFutureListener<Future<Void>>() {
@Override
public void operationComplete(Future<Void> future) throws Exception {
//buffer已经输出,可以回收,交给专用线程回收
Allocator.release(buffer);
}
});
//进入EventLoop
channel.write(buffer, promise);
好像问题解决了。而且我们通过压测发现性能果然有提升,内存占用也很正常,通过写出各种不同大小的buffer进行了几番测试结果都很OK。
不过你如果再提高每次写出包的大小的时候,问题就出现了。在我这个版本的netty里,ByteBufAllocator.buffer()分配的buffer默认大小是256个字节,当你将对象往这个buffer里序列化的时候,如果超过了256个字节ByteBuf就会自动扩展,而对于PooledByteBuf来说,自动扩展是会去池里取一个,然后将旧的回收掉。而这一切都是在业务线程里进行的。意味着你使用专用的线程来做分配和回收功亏一篑。
上面三个问题就好像冥冥之中,有一双看不见的手将你一步一步带入深渊,最后让你绝望。一个问题引出一个必然的解决方案,而这个解决方案看起来将问题解决了,但却是将问题隐藏地更深。
如果说前面三个问题是因为你不熟悉Netty的新机制造成的,那么下面这个问题我觉得就是Netty本身的API设计不合理导致使用的人出现这个问题了。
4. 连接超时
在网络应用中,超时往往是最后一道防线,或是最后一根稻草。我们不怕干脆利索的宕机,怕就怕要死不活。当碰到要死不活的应用的时候往往就是依靠超时了。
在使用Netty编写客户端的时候,我们一般会有类似这样的代码:
bootstrap.connect(address).await(1000, TimeUnit.MILLISECONDS)
向对端发起一个连接,超时等待1秒钟。如果1秒钟没有连接上则重连或者做其他处理。而其实在bootstrap的选项里,还有这样的一项:
bootstrap.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000);
如果这两个值设置的不一致,在await的时候较短,而option里设置的较长就出问题了。这个时候你会发现connect里已经超时了,你以为连接失败了,但实际上await超时Netty并不会帮你取消正在连接的链接。这个时候如果第2秒的时候连上了对端服务器,那么你刚才的判断就失误了。如果你根据connect(address).await(1000, TimeUnit.MILLISECONDS)来决定是否重连,很有可能你就建立了两个连接,而且很有可能你的handler就在这两个channel里共享起来了,这就有可能让你产生:哎呀,Netty的handler不是在单线程里执行的这样的假象。所以我的建议是,不要在await上设置超时,而总是使用option上的选项来设置。这个更准确些,超时了就是真的表示没有连上。
5. 异步处理,流控先行
这个坑其实也不算坑,只是因为懒,该做的事情没做。一般来讲我们的业务如果比较小的时候我们用同步处理,等业务到一定规模的时候,一个优化手段就是异步化。异步化是提高吞吐量的一个很好的手段。但是,与异步相比,同步有天然的负反馈机制,也就是如果后端慢了,前面也会跟着慢起来,可以自动的调节。但是异步就不同了,异步就像决堤的大坝一样,洪水是畅通无阻。如果这个时候没有进行有效的限流措施就很容易把后端冲垮。如果一下子把后端冲垮倒也不是最坏的情况,就怕把后端冲的要死不活。这个时候,后端就会变得特别缓慢,如果这个时候前面的应用使用了一些无界的资源等,就有可能把自己弄死。那么现在要介绍的这个坑就是关于Netty里的ChannelOutboundBuffer这个东西的。这个buffer是用在netty向channel write数据的时候,有个buffer缓冲,这样可以提高网络的吞吐量(每个channel有一个这样的buffer)。初始大小是32(32个元素,不是指字节),但是如果超过32就会翻倍,一直增长。大部分时候是没有什么问题的,但是在碰到对端非常慢(对端慢指的是对端处理TCP包的速度变慢,比如对端负载特别高的时候就有可能是这个情况)的时候就有问题了,这个时候如果还是不断地写数据,这个buffer就会不断地增长,最后就有可能出问题了(我们的情况是开始吃swap,最后进程被linux killer干掉了)。
为什么说这个地方是坑呢,因为大部分时候我们往一个channel写数据会判断channel是否active,但是往往忽略了这种慢的情况。
那这个问题怎么解决呢?其实ChannelOutboundBuffer虽然无界,但是可以给它配置一个高水位线和低水位线,当buffer的大小超过高水位线的时候对应channel的isWritable就会变成false,当buffer的大小低于低水位线的时候,isWritable就会变成true。所以应用应该判断isWritable,如果是false就不要再写数据了。高水位线和低水位线是字节数,默认高水位是64K,低水位是32K,我们可以根据我们的应用需要支持多少连接数和系统资源进行合理规划。
.option(ChannelOption.WRITE_BUFFER_HIGH_WATER_MARK, 64 * 1024)
.option(ChannelOption.WRITE_BUFFER_LOW_WATER_MARK, 32 * 1024)
在使用一些开源的框架上还真是要熟悉人家的实现机制,然后才可以大胆的使用啊,不然被坑死都觉得自己很冤枉。
余昭辉 /技术部
余昭辉于2011年加入去哪儿网技术团队。曾参与去哪儿网火车票系统的开发,后与他人一起开发自动化测试框架,推广自动化测试在去哪儿网的实施。随后在刚成立不久的中间件团队参与开发了可靠消息中间件,任务调度中心,订单中心等基础组件。个人对系统性能优化,利用并行、异步构建高并发的系统很感兴趣,对编写Clean Code有执着的追求。
以上是关于Netty中的那些坑(上篇)的主要内容,如果未能解决你的问题,请参考以下文章