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)


在使用一些開源的框架上還真是要熟悉人家的實現機制,然後纔可以大膽的使用啊,不然被坑死都覺得自己很冤枉。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章