注:這里說的坑不是說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)機制,然后才可以大膽的使用啊,不然被坑死都覺得自己很冤枉。