問題描述
最近生產有個服務突然出現頻繁告警,介面P99響應時間變長,運維同學觀察到相應的pod cpu飆升,記憶體佔用很高。
cpu升高問題排查是老生常談的話題了,一般可以使用top -p pid -H檢視是哪個執行緒佔用cpu高,再結合jstack找到對應的java執行緒程式碼。
不過經驗告訴我們,cpu升高還有另外一個更常見的原因,記憶體不足導致頻繁gc。垃圾收集器回收記憶體後又很快不足,繼續回收,迴圈這個過程,而gc期間涉及到STW,使用者執行緒會被掛起,響應時間自然會增加。這裡的記憶體不足可能是正常的服務本身記憶體就不夠用,也可以是異常的程式bug導致記憶體溢位。
果不其然,當時節點的full gc時間陡增,透過jstat -gcutil pid 500 30也可以看到fc非常頻繁。如圖:
這個問題實際月初也出現過,當時研發同學和運維同學透過重啟暫時解決,今天又出現了,看來不是簡單透過“重啟大法”能解決的,這次我們需要分析解決它。
排查過程
這次我們透過heap dump將堆匯出分析,命令:
jmap -dump:format=b,file=./pid.hprof pid
用jdk自帶的virsualvm或idea virsualvm launcher外掛開啟堆檔案可以看到
很明顯,跟redisson相關,我們使用的版本是3.17.1!查詢服務涉及到redisson的地方並不多,呼叫量高且可疑的只有一處,簡化後的程式碼如下:
RLock lock = this.redissonClient.getLock("mytest");
lock.tryLock(50, 100, TimeUnit.MILLISECONDS);
//業務程式碼...
RLock lock2 = this.redissonClient.getLock("mytest");
if (lock2.isLocked() && lock2.isHeldByCurrentThread()) {
lock2.unlock();
}
首先我們先簡單分析下RedissonLock tryLock和unlock的原始碼,主要地方新增了備註。
@Override
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
long time = unit.toMillis(waitTime);
long current = System.currentTimeMillis();
long threadId = Thread.currentThread().getId();
Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// 獲取到鎖,返回成功
if (ttl == null) {
return true;
}
time -= System.currentTimeMillis() - current;
if (time <= 0) {
//或取不到鎖,且超過等待時間,返回失敗
acquireFailed(waitTime, unit, threadId);
return false;
}
current = System.currentTimeMillis();
//訂閱鎖釋放訊息,subscribe是本次的核心!!!
CompletableFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
try {
subscribeFuture.get(time, TimeUnit.MILLISECONDS);
} catch (ExecutionException | TimeoutException e) {
//超時,獲取鎖失敗
if (!subscribeFuture.cancel(false)) {
subscribeFuture.whenComplete((res, ex) -> {
if (ex == null) {
unsubscribe(res, threadId);
}
});
}
acquireFailed(waitTime, unit, threadId);
return false;
}
try {
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
//鎖釋放了,還未超時,自旋嘗試獲取
while (true) {
long currentTime = System.currentTimeMillis();
ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// 獲取到鎖,返回成功
if (ttl == null) {
return true;
}
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
//或取不到鎖,且超過等待時間,返回失敗
acquireFailed(waitTime, unit, threadId);
return false;
}
// 等待鎖釋放
currentTime = System.currentTimeMillis();
if (ttl >= 0 && ttl < time) {
commandExecutor.getNow(subscribeFuture).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
commandExecutor.getNow(subscribeFuture).getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
//或取不到鎖,且超過等待時間,返回失敗
acquireFailed(waitTime, unit, threadId);
return false;
}
}
} finally {
//取消訂閱
unsubscribe(commandExecutor.getNow(subscribeFuture), threadId);
}
}
@Override
public RFuture<Void> unlockAsync(long threadId) {
RFuture<Boolean> future = unlockInnerAsync(threadId);
CompletionStage<Void> f = future.handle((opStatus, e) -> {
//取消鎖續期
cancelExpirationRenewal(threadId);
//...
});
return new CompletableFutureWrapper<>(f);
}
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end; " +
"return nil;",
Arrays.asList(getRawName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}
redisson加解鎖主要利用了lua指令碼和redis的釋出訂閱機制,使用到的資料結構是hash。
lua指令碼保證了多個命令執行的原子性,不會有併發問題。
在java程式碼中使用synchroized/lock加鎖失敗時,可以將執行緒放到連結串列中等待喚醒重新獲取鎖。在使用redis的分散式系統中,使用的是釋出訂閱機制,透過訂閱channel,當鎖釋放時重新獲取鎖。redis的釋出訂閱跟我們使用kafka等mq中介軟體是一樣的原理,實際也可以用redis的釋出訂閱機制來實現mq功能,如下channel相當於是mq中的topic。相關命令是:
- PUBLISH channel message,釋出一個訊息到channel。
- SUBSCRIBE channel [channel ...],訂閱channel,當channel有訊息時,客戶端會收到通知。
- UNSUBSCRIBE [channel [channel ...]],取消訂閱
- PSUBSCRIBE pattern [pattern ...],訂閱匹配模式的channel
- PUNSUBSCRIBE [pattern [pattern ...]],取消訂閱匹配模式的channel
接下來是我們的排查過程:
懷疑寫法問題
回到我們的程式碼,首先映入眼簾值得懷疑的是,加鎖和解鎖使用不是同個物件,如果redisson加解鎖是與物件狀態相關的,那就會有問題。
但從原始碼分析可以看到,解鎖邏輯非常簡單,主要使用到的是執行緒id,這個是不會變的。當然這種寫法還是要修正,除了會給人誤導,也沒必要多建立一個鎖物件。此外持有鎖的時間設定為100ms也太短了,儘管業務邏輯處理很快,但如果持有鎖期間發生full gc,鎖就會過期,其它執行緒就可以獲取到鎖,出現併發執行。
懷疑網路問題
由於不是頻繁出現問題,一個月就出現一兩次,所以懷疑是不是某些特殊條件才觸發,例如當時出現過網路抖動,主從切換等異常情況。聯絡dba同學得知前一天redis網路確實出現過抖動,結合生產日誌發現8月份出現兩次問題的前一天都有redis異常,redisson github上也有一些相關討論,這更堅定了我的推測,在網路異常情況下可能觸發某個bug,導致記憶體溢位,驗證這一點也浪費了我們不少時間。
網路問題主要有兩種,連線直接斷開和讀取超時。連線直接斷開我們連開發環境的redis很好模擬,直接將內網斷開即可。讀取超時可以使用redis-cli登入redis server,然後使用client pause命令阻塞客戶端,如下會阻塞所有客戶端請求10s,這個命令在我平時一些模擬測試也經常用到。
client pause 10000
接著寫程式碼迴圈測試,使用jvirsualvm觀察記憶體物件,發現並沒有問題,redisson相關物件佔比都很低,且能被gc回收。
for (int i = 0; i < 10000000; i++) {
//貼入前面的程式碼
}
原始碼分析
前面的原始碼分析是最外層,最簡單的部分,還不足以幫忙我們發現問題。從前面subscribe方法進入,內部還有大量邏輯做併發控制和釋出訂閱相關邏輯。
進入subscribe,會呼叫PublishScribe的subscribe方法,接著會呼叫AsyncSemaphore的acquire方法獲取訊號量。jdk的Semaphore我們都很熟悉,AsyncSemaphore是非同步的形式,使用訊號量最關鍵的就是申請到許可使用完後,要呼叫release方法歸還,否則其它申請者就無法再次申請到許可。
public CompletableFuture<E> subscribe(String entryName, String channelName) {
AsyncSemaphore semaphore = service.getSemaphore(new ChannelName(channelName));
CompletableFuture<E> newPromise = new CompletableFuture<>();
semaphore.acquire(() -> {
if (newPromise.isDone()) {
semaphore.release();
return;
}
E entry = entries.get(entryName);
if (entry != null) {
entry.acquire();
//1.釋放許可
semaphore.release();
//...
return;
}
E oldValue = entries.putIfAbsent(entryName, value);
if (oldValue != null) {
//2.釋放許可
semaphore.release();
//...
return;
}
RedisPubSubListener<Object> listener = createListener(channelName, value);
CompletableFuture<PubSubConnectionEntry> s = service.subscribeNoTimeout(LongCodec.INSTANCE, channelName, semaphore, listener);
//...
});
return newPromise;
}
AsyncSemaphore主要程式碼如下,permits是1,listeners是一個無界佇列。在我們dump出來的異常例項中有一個AsyncSemaphore lambda物件,也有CompletableFuture lambda物件,看起來和這裡高度匹配,這裡大機率就是問題所在了,應該是在某種情況下,acquire後沒有呼叫release,導致其它執行緒呼叫decrementAndGet的時候是<=0,進而沒法執行listeners.poll()移除元素,最終listeners佇列元素越來越多,直到記憶體溢位。
public class AsyncSemaphore {
private final AtomicInteger counter;
private final Queue<CompletableFuture<Void>> listeners = new ConcurrentLinkedQueue<>();
public AsyncSemaphore(int permits) {
counter = new AtomicInteger(permits);
}
public CompletableFuture<Void> acquire() {
CompletableFuture<Void> future = new CompletableFuture<>();
listeners.add(future);
tryRun();
return future;
}
public void acquire(Runnable listener) {
acquire().thenAccept(r -> listener.run());
}
private void tryRun() {
while (true) {
if (counter.decrementAndGet() >= 0) {
CompletableFuture<Void> future = listeners.poll();
if (future == null) {
counter.incrementAndGet();
return;
}
if (future.complete(null)) {
return;
}
}
if (counter.incrementAndGet() <= 0) {
return;
}
}
}
public void release() {
counter.incrementAndGet();
tryRun();
}
}
關於Semaphore還有話說,如果一次acquire,但程式異常多次呼叫release,將導致許可超發,後續的acquire可以申請到許可執行。解決方案可以參考rocketmq SemaphoreReleaseOnlyOnce,它封裝了Semaphore,並維護一個AtomicBoolean,保證只能釋放一次。
回到上面subscribe方法,有兩處正常呼叫了release,還有一處進入了PublishSubscribeServie的subscribeNoTimeout(LongCodec.INSTANCE, channelName, semaphore, listener)方法,重點這裡傳的topicType型別是PubSubType.SUBSCRIBE。
public CompletableFuture<PubSubConnectionEntry> subscribeNoTimeout(Codec codec, String channelName,
AsyncSemaphore semaphore, RedisPubSubListener<?>... listeners) {
CompletableFuture<PubSubConnectionEntry> promise = new CompletableFuture<>();
//重點:PubSubType.SUBSCRIBE
subscribeNoTimeout(codec, new ChannelName(channelName), getEntry(new ChannelName(channelName)), promise,
PubSubType.SUBSCRIBE, semaphore, new AtomicInteger(), listeners);
return promise;
}
裡面的邏輯比較複雜,有興趣的同學可以自己分析分析,但我們關注的是每個分支最終都需要呼叫semaphore.release。
按照這個思路,最終筆者在此處發現一處可能沒有呼叫release的方法:org.redisson.pubsub.PublishSubscribeService#unsubscribe。
unsubscribe方法在complete的時候會執行lock.release(),它的complete是在BaseRedisPubSubListener回撥中呼叫的,只有if條件成立才會執行。前面我們說傳記錄的topicType是subscribe,而這裡BaseRedisPubSubListener處理的是unsubscribe和punsubscribe型別,對應不上了,這就導致whenComplete不會執行,lock.release()不會執行。
private CompletableFuture<Void> addListeners(ChannelName channelName, CompletableFuture<PubSubConnectionEntry> promise,
PubSubType type, AsyncSemaphore lock, PubSubConnectionEntry connEntry,
RedisPubSubListener<?>... listeners) {
//...
subscribeFuture.whenComplete((res, e) -> {
if (e != null) {
lock.release();
return;
}
if (!promise.complete(connEntry)) {
if (!connEntry.hasListeners(channelName)) {
unsubscribe(type, channelName)
.whenComplete((r, ex) -> {
//這裡不會被執行,AsyncSemaphore release沒有執行!
lock.release();
});
} else {
lock.release();
}
} else {
lock.release();
}
});
return subscribeFuture;
}
public CompletableFuture<Void> unsubscribe(PubSubType topicType, ChannelName channelName) {
//...
BaseRedisPubSubListener listener = new BaseRedisPubSubListener() {
@Override
public boolean onStatus(PubSubType type, CharSequence channel) {
//這個if不會進入...
if (type == topicType && channel.equals(channelName)) {
executed.set(true);
if (entry.release() == 1) {
MasterSlaveEntry msEntry = getEntry(channelName);
msEntry.returnPubSubConnection(entry.getConnection());
}
//觸發外面whenComplete的執行
result.complete(null);
return true;
}
return false;
}
};
ChannelFuture future;
//這裡是unsubscribe和punsubscribe,而前面傳進來的topicType是subscribe,對不上了
if (topicType == PubSubType.UNSUBSCRIBE) {
future = entry.unsubscribe(channelName, listener);
} else {
future = entry.punsubscribe(channelName, listener);
}
return result;
}
問題復現
前面分析得頭頭是道,我們還得透過實踐證明一下,有理有據才行。
我的復現程式碼如下,透過併發呼叫加鎖,開始執行加個斷點在org.redisson.pubsub.PublishSubscribeService#unsubscribe裡的BaseRedisPubSubListener的onStatus方法,發現正如前面所說,topicType確實對不上。接著執行一段時間後,打一個斷點在AsyncSemaphore.acquire方法,觀察到listener屬性的size不斷增長,透過jmap pid GC.run觸發gc後也不會回收,問題得以復現。
public void test() {
for (int i = 0; i < 20000000; i++) {
executor.submit(() -> {
//貼入前面的程式碼,提交到執行緒池
});
}
}
問題解決
在開始排查問題的時候,筆者就在github提issue諮詢是什麼原因,如何解決。他們的回覆是跟這個相關,並推薦升級到3.21.2版本,不過裡面提到的描述跟我的不太一樣,所以按照版本選擇的經驗,我決定將版本升級到3.17最後一個小版本3.17.7試一下,重新跑上面的測試程式碼,跑一段時間後,發現問題沒有出現了。
檢視org.redisson.pubsub.PublishSubscribeService#unsubscribe原始碼,發現出問題那段邏輯已經被修復了。
經驗總結
遇到難啃問題幾乎是每個開發不可避免的事情,解決問題的過程,方法和事後覆盤,經驗總結非常重要,對個人的學習和能力提升有很大的幫助。
以下幾點是我本次的總結:
-
及時止損
當生產出現問題,很多開發同學首先會想如何找到原因,解決根本問題,但實際情況應該是評估影響,及時止損,避免問題發散,擴大影響。
例如不能在短時間內解決的,還要下來慢慢看日誌,分析程式碼的,能回滾的先回滾,能重啟的先重啟,爭取在出現資損前解決問題,減少對業務產生影響。 -
向上彙報
遇到棘手問題不要悶聲自己想辦法解決,正確做法是先向你的leader彙報問題和風險。如果問題比較棘手和嚴重,可以請求協助,避免因為個人能力不足遲遲不能解決問題,小問題拖成大問題。 -
保留現場
有時候問題是難以復現的,像我們本次的情況一個月可能就出現一次,如果直接重啟服務,那麼等下次問題出現就非常久了。所以正確的做法是保留現場,同時要不影響業務,可以保留一個節點,將其流量摘除,透過jstack/jmap dump出程式堆疊,其它節點重啟。 -
保持耐心
有些問題不是一時半會就能解決的,有的以天為單位,有的可能要一個月才解決。所以保持耐心很重要,多看看官方文件,github issue,分析原始碼,嘗試各種方式,排除各種可能,相信總會找到解決方法。 -
版本選擇
我們選擇的redisson版本是3.17.1,實際這個選擇不是很好。按照x.y.z的版本規範,x表示大版本,通常是有重大更新,y表示小版本,通常是一些功能迭代,z表示修復版本,通常是修bug用的。例如springboot從2.x升級到3.0,jdk版本要求最低17,是一個非常重大的更新。
上面我為什麼選擇3.17.7來測試,是因為3.17.7是3.17的最後一個小版本,看到這個版本的release報告你就知道是為什麼了,它全部都是在修bug。
當然本次的問題修復不一定在.7這個版本,可能是在1-7之間的某個版本,有興趣的可以再細看下。
更多分享,歡迎關注我的github:https://github.com/jmilktea/jtea