Netty中的那些坑(上篇)

最近開發(fā)了一個純異步的redis客戶端,算是比較深入的使用了一把netty。在使用過程中一邊優(yōu)化,一邊解決各種坑。兒這些坑大部分基本上是Netty4對Netty3的改進部分引起的。

注:這里說的坑不是說netty不好,只是如果這些地方不注意,或者不去看netty的代碼,就有可能掉進去了。

坑1: Netty 4的線程模型轉變

在Netty

3的時候,upstream是在IO線程里執(zhí)行的,而downstream是在業(yè)務線程里執(zhí)行的。比如netty從網(wǎng)絡讀取一個包傳遞給你的

handler的時候,你的handler部分的代碼是執(zhí)行在IO線程里,而你的業(yè)務線程調(diào)用write向網(wǎng)絡寫出一些東西的時候,你的handler是

執(zhí)行在業(yè)務線程里。而Netty 4修改了這一模型。在Netty

4里inbound(upstream)和outbound(downstream)都是執(zhí)行在EventLoop(IO線程)里。也就是你如果在業(yè)務線

程里通過channel.write向網(wǎng)絡寫出一些東西的時候,在某一點,netty

4會往這個channel的EventLoop里提交一個寫出的任務。那也就是業(yè)務線程和IO線程是異步執(zhí)行的。

有什么問題呢?一般我們在網(wǎng)絡通信里,業(yè)務層寫出的都是對象。然后經(jīng)過序列化等手段轉換成字節(jié)流到網(wǎng)絡,而Netty給我們提供了很好的編碼解碼的模型,

一般我們也會將序列化和反序列化放到一個handler里處理,而在Netty

4里這些handler都是在EventLoop里執(zhí)行,那么就意味著在Netty 4里下面的代碼可能會導致一些微妙的結果:

User user = new User();

user.setName("admin");

channel.write(user);

user.setName("guest");

為序列化和業(yè)務線程異步執(zhí)行,那么在write執(zhí)行后并不表示user對象已經(jīng)序列化了,如果這個時候修改了user對象那么傳遞到peer的對象可能就

不再是你期望的那個user了。所以在Netty

4里如果還是使用handler實現(xiàn)序列化就一定要小心了。你要么在調(diào)用channel.write寫出之前將對象進行深度拷貝,要么就不在

handler里進行序列化了,直接將序列化好的東西傳遞給channel。

2. 在不同的線程里使用PooledByteBufAllocator分配和回收

個問題其實是上面一個問題的續(xù)集。在碰到之前一個問題后,我們就決定不再在handler里做序列化了,而是直接在業(yè)務線程里做。但是為了減少內(nèi)存的拷

貝,我們就期望在序列化的時候直接將字節(jié)流序列化到DirectByteBuf里,這樣通過socket寫出的時候就不進行拷貝了。而

DirectByteBuf的分配成本比HeapByteBuf的成本要高,為此Netty

4借鑒jemalloc的思路實現(xiàn)了一個PooledByteBufAllocator。顧名思義,就是將DirectByteBuf池化起來,回收的時

候不真正回收,分配的時候從池里取一個空閑的。這對于大多數(shù)應用來說優(yōu)化效果還是很明顯的,比如在一些RPC場景中,我們所傳遞的對象的大小往往是差不多

的,這可以充分利用池化的效果。

但是我們在使用類似下面的偽代碼的時候內(nèi)存占用不斷飆高,然后瘋狂Full GC,并且有的時候還會出現(xiàn)OOM。這好像是內(nèi)存泄漏的跡象:

//業(yè)務線程

PooledByteBufAllocator allocator = PooledByteBufAllocator.DEFAULT;

ByteBuf buffer = allocator.buffer();

User user = new User();

//將對象直接序列化到ByteBuf

serialization.serialize(buffer, user);

//進入EventLoop

channel.writeAndFlush(buffer);

面的代碼表面看沒什么問題。但實際上,PooledByteBufAllocator為了減少鎖競爭,池是通過thread

local來實現(xiàn)的。也就是分配的時候會從本線程(這里就是業(yè)務線程)的thread

local里取。而channel.writeAndFlush調(diào)用后,在將buffer寫到socket后,這個buffer將被回收到池里。回收的時

候也是通過thread local找到對應的池,回收掉。這樣就有一個問題,分配的時候是在業(yè)務線程,也就是說從業(yè)務線程的thread

local對應的池里分配的,而回收的時候是在IO線程。這兩個是不同的線程。池的作用完全喪失了,一個線程不斷地去分配,不斷地轉移到另外一個池。

3. ByteBuf擴展引起的問題

其實這個問題和上面一個問題是一樣的。但是比之前的問題更加隱晦,就在你彈冠相慶的時候給你致命一擊。在碰到上面一個問題后我們就在想,既然分配和回收都得在同一個線程里執(zhí)行,那我們是不是可以啟動一個專門的線程來負責分配和回收呢?于是就有了下面的代碼:

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 bufferQueue = new ArrayBlockingQueue(100);

private static final BlockingQueue toCleanQueue = new LinkedBlockingQueue();

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 toClean = new ArrayList(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);

}

}

在業(yè)務線程里調(diào)用alloc,從queue里拿到專用的線程分配好的buffer。在將buffer寫出到socket之后再調(diào)用release回收:

//業(yè)務線程

ByteBuf buffer = Allocator.alloc();

//序列化

........

//寫出

ChannelPromise promise = channel.newPromise();

promise.addListener(new GenericFutureListener>() {

@Override

public void operationComplete(Future future) throws Exception {

//buffer已經(jīng)輸出,可以回收,交給專用線程回收

Allocator.release(buffer);

}

});

//進入EventLoop

channel.write(buffer, promise);

好像問題解決了。而且我們通過壓測發(fā)現(xiàn)性能果然有提升,內(nèi)存占用也很正常,通過寫出各種不同大小的buffer進行了幾番測試結果都很OK。

過你如果再提高每次寫出包的大小的時候,問題就出現(xiàn)了。在我這個版本的netty里,ByteBufAllocator.buffer()分配的

buffer默認大小是256個字節(jié),當你將對象往這個buffer里序列化的時候,如果超過了256個字節(jié)ByteBuf就會自動擴展,而對于

PooledByteBuf來說,自動擴展是會去池里取一個,然后將舊的回收掉。而這一切都是在業(yè)務線程里進行的。意味著你使用專用的線程來做分配和回收

功虧一簣。

上面三個問題就好像冥冥之中,有一雙看不見的手將你一步一步帶入深淵,最后讓你絕望。一個問題引出一個必然的解決方案,而這個解決方案看起來將問題解決了,但卻是將問題隱藏地更深。

如果說前面三個問題是因為你不熟悉Netty的新機制造成的,那么下面這個問題我覺得就是Netty本身的API設計不合理導致使用的人出現(xiàn)這個問題了。

4. 連接超時

在網(wǎng)絡應用中,超時往往是最后一道防線,或是最后一根稻草。我們不怕干脆利索的宕機,怕就怕要死不活。當碰到要死不活的應用的時候往往就是依靠超時了。

在使用Netty編寫客戶端的時候,我們一般會有類似這樣的代碼:

bootstrap.connect(address).await(1000, TimeUnit.MILLISECONDS)

向?qū)Χ税l(fā)起一個連接,超時等待1秒鐘。如果1秒鐘沒有連接上則重連或者做其他處理。而其實在bootstrap的選項里,還有這樣的一項:

bootstrap.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000);

果這兩個值設置的不一致,在await的時候較短,而option里設置的較長就出問題了。這個時候你會發(fā)現(xiàn)connect里已經(jīng)超時了,你以為連接失敗

了,但實際上await超時Netty并不會幫你取消正在連接的鏈接。這個時候如果第2秒的時候連上了對端服務器,那么你剛才的判斷就失誤了。如果你根據(jù)

connect(address).await(1000,

TimeUnit.MILLISECONDS)來決定是否重連,很有可能你就建立了兩個連接,而且很有可能你的handler就在這兩個channel里

共享起來了,這就有可能讓你產(chǎn)生:哎呀,Netty的handler不是在單線程里執(zhí)行的這樣的假象。所以我的建議是,不要在await上設置超時,而總

是使用option上的選項來設置。這個更準確些,超時了就是真的表示沒有連上。

5. 異步處理,流控先行

個坑其實也不算坑,只是因為懶,該做的事情沒做。一般來講我們的業(yè)務如果比較小的時候我們用同步處理,等業(yè)務到一定規(guī)模的時候,一個優(yōu)化手段就是異步化。

異步化是提高吞吐量的一個很好的手段。但是,與異步相比,同步有天然的負反饋機制,也就是如果后端慢了,前面也會跟著慢起來,可以自動的調(diào)節(jié)。但是異步就

不同了,異步就像決堤的大壩一樣,洪水是暢通無阻。如果這個時候沒有進行有效的限流措施就很容易把后端沖垮。如果一下子把后端沖垮倒也不是最壞的情況,就

怕把后端沖的要死不活。這個時候,后端就會變得特別緩慢,如果這個時候前面的應用使用了一些無界的資源等,就有可能把自己弄死。那么現(xiàn)在要介紹的這個坑就

是關于Netty里的ChannelOutboundBuffer這個東西的。這個buffer是用在netty向channel

write數(shù)據(jù)的時候,有個buffer緩沖,這樣可以提高網(wǎng)絡的吞吐量(每個channel有一個這樣的buffer)。初始大小是32(32個元素,

不是指字節(jié)),但是如果超過32就會翻倍,一直增長。大部分時候是沒有什么問題的,但是在碰到對端非常慢(對端慢指的是對端處理TCP包的速度變慢,比如

對端負載特別高的時候就有可能是這個情況)的時候就有問題了,這個時候如果還是不斷地寫數(shù)據(jù),這個buffer就會不斷地增長,最后就有可能出問題了(我

們的情況是開始吃swap,最后進程被linux killer干掉了)。

為什么說這個地方是坑呢,因為大部分時候我們往一個channel寫數(shù)據(jù)會判斷channel是否active,但是往往忽略了這種慢的情況。

這個問題怎么解決呢?其實ChannelOutboundBuffer雖然無界,但是可以給它配置一個高水位線和低水位線,當buffer的大小超過高水

位線的時候?qū)猚hannel的isWritable就會變成false,當buffer的大小低于低水位線的時候,isWritable就會變成

true。所以應用應該判斷isWritable,如果是false就不要再寫數(shù)據(jù)了。高水位線和低水位線是字節(jié)數(shù),默認高水位是64K,低水位是

32K,我們可以根據(jù)我們的應用需要支持多少連接數(shù)和系統(tǒng)資源進行合理規(guī)劃。

.option(ChannelOption.WRITE_BUFFER_HIGH_WATER_MARK, 64 * 1024)

.option(ChannelOption.WRITE_BUFFER_LOW_WATER_MARK, 32 * 1024)

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

最后編輯于
?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內(nèi)容