解決mongo的tickets被耗盡導致卡頓問題

近一年來,項目線上環境的mongo數據庫出現多次tickets被耗盡,導致數據庫卡頓,並且都是突然出現,等待一段時間後又能自動恢復。

為瞭解決這個問題,我們進行瞭長期的探索和研究,先後從多個角度進行優化,於此記錄和分享一下這一路的歷程。

tickets是什麼

為瞭解決這個問題,我們首先要明白ticktes是什麼,其實網上基本都說的一知半解,沒有一個能說明白的,但是有一個查詢tieckts消耗情況的mongo命令:

db.serverStatus().wiredTiger.concurrentTransactions

查詢結果:

{
	"write" : {
		"out" : 0,
		"available" : 128,
		"totalTickets" : 128
	},
	"read" : {
		"out" : 1,
		"available" : 127,
		"totalTickets" : 128
	}
}

可以看到tickets分為讀寫兩種,那ticktets到底是什麼呢,我們根據這個查詢命令,其實大致可以猜測認為是當前同時存在的事務數量。

也就是mongo限制瞭同時進行的事務數。

早期因為不知道tickets到底是什麼意思,嘗試過很多思路錯誤的優化,所以解決問題,最好還是能弄明白問題本身,才能對癥下藥。

思考歷程

在眾多數據庫卡頓的經歷中,曾有一次因為rabbitmq導致的數據庫卡頓,原因是一小夥伴在請求的過濾層加瞭一個發送mq的邏輯,但是沒有進行限制,導致每次隻有有接口被調,都會去發佈一個mq消息,由於過高的並發導致rabbitmq不堪重負,倒是讓人想不明到的是mq卡的同時,數據庫也卡住瞭。

一開始以為是因為消息過多,導致消費者瘋狂消費,壓垮瞭數據庫,其實不存在這個問題,因為我們的mq配置單個消費者機器是串行的,也就是同一臺機器同一時間隻會消費同一個消息隊列的一條消息,所以並不會因為消息的多給數據庫帶來壓力,隻會堆積在mq集群裡。所以這次其實沒有找到mq卡頓導致mongo卡頓的原因。

我們接入的幾傢第三方服務,比如給我們提供IM消息服務的融雲,每次他們出現問題的時候,我們也會出現數據庫卡頓,並且每次時間出奇的一直,但也始終找不到原因。

起初經過對他們調用我們接口情況進行分析,發現每次他們出問題時,我們收到的請求會倍增,以為是這個原因導致的數據庫壓力過大,並且我們基於redis和他們回調的流水號進行瞭攔截,攔截方式如下:

  • 當請求過來時從redis中查詢該筆流水號狀態,如果狀態為已完結,則直接成功返回
  • 如果查詢到狀態是進行中,則拋異常給第三方,從而讓他繼續重試
  • 如果查詢不到狀態,則嘗試設置狀態為進行中並設置10秒左右的過期時間,如果設置成功,則放到數據庫層面進行數據處理;如果設置失敗,也拋異常給第三方,等待下次重試
  • 等數據庫曾處理完成後,將redis中的流水號狀態改為已完結。

避免重復請求給我們帶來的數據庫的壓力。這其實也算是一部分原因但還是不算主要原因。

引起mongo卡頓的還有發佈版本,有一段時間隔三差五發佈版本,就會出現卡頓,但是查看更新的代碼也都是一些無關痛癢理論上不會引起問題的內容。

後來發現是發佈版本時每次同時關閉和啟動的機器從原來的一臺改成瞭兩臺(一臺一臺發佈太慢,所以運維改成瞭兩臺兩臺一起發),感覺原因應該就在這裡,後來想到會不會和優雅關閉有關,當機器關閉時仍然有mq消費者以及內置循環腳本在執行,當進程殺死時,會產生大量需要立馬回滾的事務,從而導致mongo卡頓。

後來經過和運維小夥伴的溝通發現,在優雅關閉方面確實存在問題,他們關閉容器時會小容器內的主進程發一個容器即將關閉的信號,然後等待幾十秒後,如果主進程沒有自己關閉,則會直接殺死進程。

為此我們需要在程序中實現對關閉信號的監聽,並實現優雅關閉的邏輯,在spring中,我們可以通過spring的時間拿到外部即將關閉的信號:

	@Volatile
	private var consumeSwitch = true

	/**
	* 銷毀邏輯
	*/
	@EventListener
	fun close(event: ContextClosedEvent){
		consumeSwitch = false
		logger.info("----------------------rabbitmq停止消費----------------------")
	}

可以通過如上方式,對系統中的mq消費者或者其他內置程序進行優雅關停控制,對優雅關閉問題優化後,服務器關閉重啟導致的數據庫卡頓確實得到瞭有效解決。

上面的融雲問題優化過後,後來融雲再次卡頓的時候,還是會出現mongo卡頓,由此可見,肯定和第三方有關,但上面說的問題肯定不是主要原因。

後來我看到我們調用第三方的邏輯很多都在@Transactional代碼塊中間,後來去看瞭第三方sdk裡的邏輯,其實就是封裝瞭一個http請求,但是http請求的請求超時時間長達60秒,那就會有一個問題,如果這個時候第三方服務器卡頓瞭,這個請求就會不斷地等,知道60s超時,而由於這個操作是在事務塊中,意味著這個事務也不會commit掉,那等於這個事務所占用的tickets也一直不會放掉,至此根本原因似乎找到瞭,是因為事務本身被卡住瞭,導致tickets耗盡,從而後面新的事務全部都在等待狀態,全部都卡住瞭。

其實這次找的原因,同樣也可以解釋前面mq卡頓導致的數據庫卡頓,因為同樣有大量的發送mq的操作在事務塊中,因為短時間瘋狂發mq,導致mq服務端卡頓,從而導致發mq的操作出現卡頓,這就會出現整個事務被卡住,接著tickets被消耗殆盡,整個數據庫卡頓。

找到確定問題後就好對癥下藥瞭,第三方的問題由於我們不能保證第三方的穩定性,所以當第三方出現問題時的思路應該是進行服務降級,允許部分功能不可用,確定核心業務不受影響,我們基於java線程池進行瞭同步改異步處理,並且由於第三方的工作是給用戶推送im消息,所以配置的舍棄策略是當阻塞隊列堆積滿之後,將最老的進行丟棄。

而如果是mq導致的這種情況,我們這邊沒有進行額外的處理,因為這種情況是有自身的bug導致的,這需要做好整理分享工作,避免再次出現這樣的bug。

//自己實現的runnable
abstract class RongCloudRunnable(
	private val taskDesc: String,
	private val params: Map<String, Any?>
	) : Runnable {


	override fun toString(): String {
		return "任務名稱:${taskDesc};任務參數:${params}"
	}
}		
//構建線程池
private val rongCloudThreadPool = ThreadPoolExecutor(
	externalProps.rongCloud.threadPoolCoreCnt, externalProps.rongCloud.threadPoolMaxCnt, 5,
	TimeUnit.MINUTES, LinkedBlockingQueue<Runnable>(externalProps.rongCloud.threadPoolQueueLength),
	RejectedExecutionHandler { r, executor ->
		if (!executor.isShutdown) {
			val item = executor.queue.poll()
			logger.warn("當前融雲阻塞任務過多,舍棄最老的任務:${item}")
			executor.execute(r)
		}
	}
)

//封裝線程池任務處理方法
fun taskExecute(taskDesc: String, params: Map<String,Any?>, handle: ()-> Unit){
	rongCloudThreadPool.execute(object :RongCloudRunnable(taskDesc, params){
		override fun run() {
			handle()
		}
	})
}

//具體使用
taskExecute("發送消息", mapOf(
	"from_id" to fromId,
	"target_ids" to targetIds,
	"data" to data,
	"is_include_sender" to isIncludeSender
)){
	sendMessage(BatchSendData(fromId, targetIds, data, isIncludeSender))
}		

總結

以上為個人經驗,希望能給大傢一個參考,也希望大傢多多支持WalkonNet。

推薦閱讀: