救命!Java Web項(xiàng)目中的MQ消息堆積讓我抓狂
我之前參與開(kāi)發(fā)了一個(gè)餐廳系統(tǒng),該系統(tǒng)在午餐和晚餐高峰時(shí)段面臨巨大的并發(fā)需求。
為了確保系統(tǒng)順暢運(yùn)行,公司要求各個(gè)部門(mén)在用餐時(shí)間輪流值班,以便及時(shí)解決在線(xiàn)問(wèn)題。
我所在的團(tuán)隊(duì)負(fù)責(zé)廚房展示系統(tǒng),這是訂單系統(tǒng)的下游服務(wù)。
當(dāng)用戶(hù)下單后,訂單系統(tǒng)會(huì)向我們的系統(tǒng)發(fā)送一條 Kafka 消息。我們的系統(tǒng)讀取消息,處理業(yè)務(wù)邏輯,保存訂單和菜品數(shù)據(jù),然后在菜品管理客戶(hù)端上顯示出來(lái)。
通過(guò)這種方式,廚師們可以了解每個(gè)訂單所需的菜品,一旦有菜品準(zhǔn)備好,系統(tǒng)會(huì)通知服務(wù)員上菜。
上菜后,服務(wù)員會(huì)更新菜品狀態(tài),這樣用戶(hù)就能知道哪些菜已上,哪些菜還在準(zhǔn)備中。
這個(gè)系統(tǒng)極大地提高了從廚房到顧客的效率。
圖片
這其中的關(guān)鍵是消息中間件 Kafka。如果它出現(xiàn)問(wèn)題,將直接影響廚房展示系統(tǒng)的正常運(yùn)作。
在本文中,我將分享我們?cè)谔幚怼跋⒎e壓?jiǎn)栴}”時(shí)的經(jīng)驗(yàn),希望能對(duì)你有所幫助。
初次遇到消息積壓?jiǎn)栴}
最初,我們的用戶(hù)量較小,系統(tǒng)上線(xiàn)后的一段時(shí)間里,消息隊(duì)列(MQ)的消息通信非常順暢。
隨著用戶(hù)量的增長(zhǎng),每個(gè)商戶(hù)每天都生成大量訂單數(shù)據(jù),每個(gè)訂單包含多個(gè)菜品。這導(dǎo)致我們菜品管理系統(tǒng)的數(shù)據(jù)量顯著增加。
某天下午,我們收到了商戶(hù)的投訴,用戶(hù)下單后,菜品列表在平板上出現(xiàn)延遲。
廚房只有在幾分鐘后才能看到這些菜品。
我們立即開(kāi)始調(diào)查原因。
菜品展示延遲問(wèn)題通常與 Kafka 有關(guān),因此我們首先檢查了 Kafka。
果不其然,存在 消息積壓。
消息積壓的常見(jiàn)原因有以下兩種:
- MQ 消費(fèi)者服務(wù)宕機(jī)。
- MQ 生產(chǎn)者產(chǎn)生消息的速率超過(guò) MQ 消費(fèi)者消費(fèi)消息的速率。
我檢查了監(jiān)控系統(tǒng),發(fā)現(xiàn) MQ 消費(fèi)者服務(wù)運(yùn)行正常,沒(méi)有異常。
剩下的原因可能是 MQ 消費(fèi)者的消息處理速度變慢了。
接下來(lái),我檢查了菜品管理表,發(fā)現(xiàn)只有幾十萬(wàn)條記錄。
似乎有必要優(yōu)化 MQ 消費(fèi)者的處理邏輯。
我在代碼中添加了一些日志,打印出 MQ 消費(fèi)者中各個(gè)關(guān)鍵節(jié)點(diǎn)的耗時(shí)。
發(fā)現(xiàn)兩處存在明顯的延遲:
- 在 for 循環(huán)中逐條查詢(xún)數(shù)據(jù)庫(kù)的代碼。
- 進(jìn)行多條件數(shù)據(jù)查詢(xún)的代碼。
我針對(duì)性地進(jìn)行了優(yōu)化:
對(duì)于 for 循環(huán)逐條查詢(xún)數(shù)據(jù)庫(kù)的代碼,我改成了使用參數(shù)集合的 批量查詢(xún)。
有時(shí),我們需要查詢(xún)指定集合中的哪些用戶(hù)已經(jīng)存在于數(shù)據(jù)庫(kù)中。實(shí)現(xiàn)方式如下:
publicList<User>queryUser(List<User> searchList){
if(CollectionUtils.isEmpty(searchList)){
returnCollections.emptyList();
}
List<User> result = Lists.newArrayList();
searchList.forEach(user -> result.add(userMapper.getUserById(user.getId())));
return result;
}
如果有 50 個(gè)用戶(hù),這種方法需要查詢(xún)數(shù)據(jù)庫(kù) 50 次。眾所周知,每次數(shù)據(jù)庫(kù)查詢(xún)都是一次遠(yuǎn)程調(diào)用。
查詢(xún)數(shù)據(jù)庫(kù) 50 次意味著需要進(jìn)行 50 次遠(yuǎn)程調(diào)用,耗時(shí)非常長(zhǎng)。
那么,如何優(yōu)化呢?
優(yōu)化后的代碼如下:
publicList<User>queryUser(List<User> searchList){
if(CollectionUtils.isEmpty(searchList)){
returnCollections.emptyList();
}
List<Long> ids = searchList.stream().map(User::getId).collect(Collectors.toList());
return userMapper.getUserByIds(ids);
}
這種方式提供了一個(gè)基于用戶(hù) ID 集合進(jìn)行批量查詢(xún)的接口,僅需一次遠(yuǎn)程調(diào)用就可以獲取所有數(shù)據(jù)。
對(duì)于多條件數(shù)據(jù)查詢(xún),我添加了一個(gè) 組合索引,解決了這一問(wèn)題。
經(jīng)過(guò)這些優(yōu)化后,MQ 消費(fèi)者的消息處理速度顯著提升,成功解決了消息積壓?jiǎn)栴}。
再次遇到消息積壓?jiǎn)栴}
幾個(gè)月后,我們?cè)俅斡龅搅讼⒎e壓?jiǎn)栴}。
這次問(wèn)題是偶發(fā)性的,只在某些時(shí)候出現(xiàn),大部分時(shí)間沒(méi)有問(wèn)題。
積壓持續(xù)時(shí)間很短,對(duì)用戶(hù)的影響較小,商戶(hù)也沒(méi)有投訴。
我檢查了菜品管理表,此時(shí)已有幾百萬(wàn)條記錄。
通過(guò)監(jiān)控和 DBA 提供的每日慢查詢(xún)郵件,我發(fā)現(xiàn)了一些異常。
我注意到有些 SQL 語(yǔ)句的 WHERE 條件相同,但僅參數(shù)值不同,卻使用了不同的索引。
例如,order_id=123 使用索引 a,而 order_id=124 使用索引 b。
該表存在多種查詢(xún)場(chǎng)景,為滿(mǎn)足不同的業(yè)務(wù)需求,添加了多個(gè)組合索引。
MySQL 根據(jù)以下幾種因素來(lái)選擇索引:
- 通過(guò)數(shù)據(jù)采樣估算掃描的行數(shù)。掃描的行數(shù)越多,I/O 操作和 CPU 使用率越高。
- 是否使用臨時(shí)表,臨時(shí)表會(huì)影響查詢(xún)速度。
- 是否需要排序,排序也會(huì)影響查詢(xún)速度。
綜合這些因素,MySQL 優(yōu)化器選擇其認(rèn)為最合適的索引。
MySQL 優(yōu)化器通過(guò)采樣估算掃描的行數(shù),這涉及從數(shù)據(jù)頁(yè)中選擇一些進(jìn)行統(tǒng)計(jì)估算,這種方法會(huì)帶來(lái)一定的誤差。
由于 MVCC 機(jī)制,數(shù)據(jù)頁(yè)存在多個(gè)版本,例如被刪除的數(shù)據(jù)在其他事務(wù)中仍然可見(jiàn),因此索引并未真正刪除。這可能導(dǎo)致統(tǒng)計(jì)數(shù)據(jù)不準(zhǔn)確,影響優(yōu)化器的決策。
這些因素可能導(dǎo)致 MySQL 在執(zhí)行 SQL 語(yǔ)句時(shí) 選擇了錯(cuò)誤的索引。
即便使用索引 a 更為高效,MySQL 也可能使用了索引 b。
為了解決 MySQL 選擇錯(cuò)誤索引的問(wèn)題,我們使用了 FORCE INDEX 關(guān)鍵字,強(qiáng)制 SQL 查詢(xún)使用索引 a。
經(jīng)過(guò)這一優(yōu)化,消息的輕微積壓?jiǎn)栴}也得到了解決。
第三次遭遇消息積壓
六個(gè)月后,某天晚上大約六點(diǎn),幾位商家投訴菜品管理系統(tǒng)出現(xiàn)延遲。
他們反饋說(shuō)下單后菜品要過(guò)幾分鐘才會(huì)顯示。
檢查監(jiān)控系統(tǒng)后,我發(fā)現(xiàn) Kafka 消息再次堆積了。
我復(fù)查了 MySQL 的索引,發(fā)現(xiàn)索引是正確的,但數(shù)據(jù)查詢(xún)依然很慢。
接著查看了菜品管理表,發(fā)現(xiàn)表中的數(shù)據(jù)量在短短六個(gè)月內(nèi)竟然增長(zhǎng)到了 3000 萬(wàn)條記錄。
通常情況下,當(dāng)單表數(shù)據(jù)過(guò)多時(shí),查詢(xún)和寫(xiě)入性能都會(huì)下降。
這次查詢(xún)變慢的原因就是因?yàn)閿?shù)據(jù)量過(guò)于龐大。
為了解決這個(gè)問(wèn)題,我們需要:
- 實(shí)施數(shù)據(jù)庫(kù)和表的分區(qū)
- 備份歷史數(shù)據(jù)
在當(dāng)時(shí),實(shí)施數(shù)據(jù)庫(kù)和表分區(qū)的成本太高,而且商家數(shù)量尚不足以支持這樣的解決方案。
因此,我們決定備份歷史數(shù)據(jù)。
在與產(chǎn)品經(jīng)理和 DBA 討論后,我們決定菜品管理表僅保留最近 30 天的數(shù)據(jù),超過(guò) 30 天的數(shù)據(jù)會(huì)被移動(dòng)到一個(gè)“歷史表”中。
經(jīng)過(guò)這個(gè)優(yōu)化后,菜品管理表在 30 天內(nèi)只積累了幾百萬(wàn)條數(shù)據(jù),對(duì)性能的影響較小。
這樣一來(lái),消息堆積問(wèn)題得以解決。
第四次遇到消息堆積問(wèn)題
在上述優(yōu)化之后,系統(tǒng)長(zhǎng)時(shí)間內(nèi)運(yùn)行順利,沒(méi)有出現(xiàn)消息堆積的問(wèn)題。
然而,一年后的一天下午,一些商家又來(lái)投訴。
我查閱了公司郵件,發(fā)現(xiàn)有大量關(guān)于 Kafka 消息堆積的監(jiān)控告警郵件。
由于我當(dāng)時(shí)正在開(kāi)會(huì),錯(cuò)過(guò)了這些告警。
這次問(wèn)題的時(shí)間點(diǎn)比較奇怪。
通常高并發(fā)都是在午餐或晚餐的高峰時(shí)段,但這次消息堆積卻發(fā)生在“下午”。
這很不尋常。
一開(kāi)始,我沒(méi)有任何線(xiàn)索能找到問(wèn)題的原因。
于是,我詢(xún)問(wèn)了訂單團(tuán)隊(duì)是否在下午發(fā)布了新版本或者執(zhí)行了某些特定操作。
因?yàn)槲覀兊牟似饭芾硐到y(tǒng)是他們的下游系統(tǒng),直接和他們的操作相關(guān)。
一位同事提到,半小時(shí)前他們進(jìn)行了一個(gè)批量更新數(shù)萬(wàn)個(gè)訂單狀態(tài)的作業(yè)。
更改訂單狀態(tài)會(huì)自動(dòng)發(fā)送 MQ 消息。
這導(dǎo)致他們的程序在極短時(shí)間內(nèi)產(chǎn)生了大量 MQ 消息。
我們的 MQ 消費(fèi)端無(wú)法快速處理這些消息,因而出現(xiàn)了消息堆積。
我們查看了 Kafka 消息堆積情況,發(fā)現(xiàn)有數(shù)十萬(wàn)條消息在排隊(duì)等待處理。
為了快速提高 MQ 消費(fèi)端的處理速度,我們考慮了兩個(gè)解決方案:
- 增加分區(qū)數(shù)量。
- 使用線(xiàn)程池處理消息。
然而,由于消息已經(jīng)堆積在現(xiàn)有分區(qū)中,增加新的分區(qū)并不會(huì)有太大幫助。
因此,我們決定重構(gòu)代碼,使用線(xiàn)程池來(lái)處理消息。
為了解決堆積消息,我們將線(xiàn)程池的核心線(xiàn)程數(shù)和最大線(xiàn)程數(shù)增加到 50。
這些參數(shù)是可以動(dòng)態(tài)配置的。
經(jīng)過(guò)這個(gè)調(diào)整后,堆積的數(shù)十萬(wàn)條消息在大約 20 分鐘內(nèi)被處理完畢。
這次突發(fā)的消息堆積問(wèn)題得到了妥善解決。
解決問(wèn)題后,我們保留了線(xiàn)程池的消息消費(fèi)邏輯,將核心線(xiàn)程數(shù)設(shè)置為 8,最大線(xiàn)程數(shù)設(shè)置為 10。
這樣在遇到消息堆積問(wèn)題時(shí),我們可以臨時(shí)調(diào)整線(xiàn)程數(shù)以快速應(yīng)對(duì),而不會(huì)對(duì)用戶(hù)造成明顯影響。
注意:使用線(xiàn)程池消費(fèi) MQ 消息并不是一個(gè)通用的解決方案。它存在一些缺點(diǎn),比如可能會(huì)導(dǎo)致消息順序性問(wèn)題,以及服務(wù)器 CPU 使用率的飆升風(fēng)險(xiǎn)。此外,如果在多線(xiàn)程環(huán)境中調(diào)用第三方接口,可能會(huì)造成第三方服務(wù)的超負(fù)荷甚至崩潰。
總結(jié)來(lái)說(shuō),MQ 消息堆積不是一個(gè)簡(jiǎn)單的問(wèn)題。
根本原因是 MQ 生產(chǎn)端的消息生產(chǎn)速率超過(guò)了消費(fèi)端的消息消費(fèi)速率,但具體原因可能有多種。
在實(shí)際場(chǎng)景中,我們需要根據(jù)不同的業(yè)務(wù)情況進(jìn)行優(yōu)化。
對(duì) MQ 隊(duì)列消息堆積的監(jiān)控和告警至關(guān)重要,能夠及時(shí)發(fā)現(xiàn)問(wèn)題。
沒(méi)有完美的解決方案,只有最適合當(dāng)前業(yè)務(wù)場(chǎng)景的方案。