從 Spring Rabbit 重複遙測到反覆修改方案,記錄我在 OpenTelemetry PR #19732 中遇到的 review、版本兼容問題,以及進入大型倉庫時想法的變化。
這是我到目前為止做過最折磨的一次開源修改。
一開始,問題看起來很明確:Spring @RabbitListener 消費 RabbitMQ 消息時,一次處理會產生兩個 process Span,messaging.process.duration 也被記錄了兩次。問題能復現,原因也不算特別難找。我當時更多在想,既然已經知道哪裏重複了,把其中一層關掉,應該就差不多了吧。
真正開始改以後,我才發現,難的不是讓重複的 Span 消失,而是讓這次修改和項目原來的架構、歷史版本以及遙測語義都能對上。好幾次我覺得方案已經能工作,review 又指出了我沒看到的地方。
PR #19732 從 8 月 20 日提交,到 9 月 2 日合併,來回改了不少。但回頭看,最值得記錄的不是改了多少行,而是這段過程中我怎麼從“這段代碼應該能跑”,慢慢開始問:這個項目希望我怎樣解決這類問題?
一條消息,為什麼會出現兩個 process Span?h2
Issue #19588 涉及 RabbitMQ 和 Spring Rabbit 兩套 instrumentation 同時啓用的情況。
以 SimpleMessageListenerContainer 為例,RabbitMQ 的 dispatch thread 先執行 consumer 的 handleDelivery()。但 Spring 的這個 consumer 並不在這裏直接調用用户的 listener,而是先把消息放進 BlockingQueue,再由另一個線程取出來處理。
RabbitMQ instrumentation 在第一段回調外面創建了一個 process Span;Spring Rabbit instrumentation 在實際調用 listener 時,又創建了一個。線程切換以後,第一段的當前上下文不會自動覆蓋第二段,於是原本想描述一次處理的兩層 instrumentation,都留下了自己的遙測。
這不只是“多一個 Span 不好看”。第一段主要是在入隊,真正執行用户 @RabbitListener 的是第二段,兩段工作的語義也不同。
DirectMessageListenerContainer 又是另一種情況:沒有同樣的線程交接,Spring 的 process instrumentation 可能因為已有 RabbitMQ process 上下文的 suppression 被抑制。也就是説,換一種 container,實際留下的遙測歸屬就變了。
所以我要解決的並不是隨便刪掉一個 Span,而是讓兩邊對誰負責這次消息處理達成一致。
我的實現能工作,但倉庫已經有自己的表達方式h2
為了讓兩套 instrumentation 協調,我最初加了一套狀態控制,其中一個版本用了 depth counter。進入 Spring 管理的 consumer 註冊時增加深度,退出時再減回來,我擔心的是調用嵌套以後狀態恢復不對。
但 trask 的 review 指出了兩個我沒考慮到的地方:這裏的註冊路徑並沒有我擔心的那種嵌套;倉庫也已經有類似問題的處理方式,比如 KafkaClientsConsumerProcessTracing 和 SpringSchedulingTaskTracing。
它們保存的是進入前的值,退出時恢復,而不是再引入一套計數規則。可以把這種思路簡化成:
// 状态保存与恢复的概念示意,不是完整的 Advice 实现boolean previous = isWrappingEnabled();setWrappingEnabled(false);try { registerConsumer();} finally { setWrappingEnabled(previous);}當時我有一點挫敗:我確實考慮了狀態恢復,為什麼還是要改?繼續看已有實現以後,我才意識到,我關注的是“自己這套邏輯能不能工作”,維護者還在考慮“這個項目一直怎麼表達同類問題”。
depth counter 並不是在所有場景下都不對。但這裏已經有夠用的模式,我再寫另一種,就讓以後讀代碼的人多理解一套規則。
這讓我第一次很具體地感覺到,成熟項目裏的修改,不只是把功能做出來,還得儘量接得上它原來的語言。
我以為只是在記錄一個指標,其實繞過了 Instrumenterh2
另一個印象很深的地方,是我為了保留 consumed-message 指標,曾經自己組織 AttributesExtractor、OperationListener 和指標的開始、結束流程。
我的想法是,RabbitMQ 的 process Span 不需要了,但計數還要保留,那就把需要的部分單獨拿出來。當時看起來只是多寫一個小 helper。
但 review 指出,這等於在 Instrumenter 之外維護一條完整的生命週期。它原本負責的 instrumentation scope、版本、schema URL、全局 customizer、異常原因解包和 start/end extractor 分工,都要由這條手寫路徑自己跟進。
我這才意識到,Instrumenter 不是單純為了少寫幾行代碼。看起來像包裝層的東西,後面可能揹着很多我還沒看到的責任。
最終實現沿用了 spring-kafka 的思路:在 Spring 負責的 listener 路徑中,關閉 RabbitMQ 的 process 遙測,把 consumed-message 指標註冊到 Spring Rabbit 的 Instrumenter 上。合併代碼裏的關鍵部分是:
// SpringRabbitSingletons 中注册 operation metrics 的片段.addOperationMetrics(MessagingProcessMetrics.get()).addOperationMetrics(MessagingConsumerMetrics.getConsumedMessages());指標數值一樣,也不代表語義沒變h3
回看這段討論時,我發現,自己還需要把“指標有沒有記錄”和“指標屬於誰”分開看。
這次遷移確實改變了 Spring listener 路徑中 consumed-message 指標的 scope:它從 io.opentelemetry.rabbitmq-2.7 轉到 io.opentelemetry.spring-rabbit-1.0。最終測試也相應檢查 Spring scope 下的計數,並確認 RabbitMQ scope 不再重複記錄這條指標。
討論裏也出現過不同意見。Copilot 曾建議保留 RabbitMQ 原來的指標歸屬,維護者給出的方向則是遷移到 Spring Instrumenter。把這些意見和最終代碼放在一起看,我才分清:這裏不是不能遷移,而是要讓指標跟着實際負責處理消息的 Instrumenter 走,並把相應的測試一起調整。
對我來説,這裏的收穫不是“scope 永遠不能改變”,而是:一個指標屬於誰,本身就是需要明確決定和驗證的行為。 不能只看 dashboard 上的數字仍然是一次,就覺得其他地方都沒有變化。
遷移時還要補上原來 RabbitMQ 路徑能拿到的信息。Spring 原來的 request 只有 Message,這次把 Channel 也帶進 SpringRabbitRequest,再通過已有的屬性提取方式補齊服務器和網絡信息,而不是隻把計數搬過去就結束。
Listener 類型,不一定等於實際的消息形態h2
我後來又遇到了一個看起來很合理、實際卻不夠可靠的判斷:根據 listener 是不是 batch listener,決定 Spring 要不要接管。
問題是,listener 類型和真正傳給 invokeListener() 的數據,不完全是一回事。一個 batch-typed listener,在 consumer batching 沒開啓的時候,也可能按單條 Message 進入調用路徑。如果 Spring 按實際消息處理,RabbitMQ 卻按 listener 類型決定是否保留 process,兩邊就可能再次同時記錄,或者互相 suppression。
這條 review 讓我記住了一個很實際的問題:跨模塊協作的時候,兩邊的判斷依據,真的是同一個事實嗎?
這裏也不能把所有“batch”都混為一談。consumer batching 和 producer-created batch 是不同路徑。最終實現不只補了註冊時的判斷,也讓 Spring Advice 能處理接收到的 Message 或非空 List<Message>,測試分別檢查 consumer batching 開關、producer batch 以及 Simple / Direct 的行為。
對我來説,這比一句“不要重複打點”具體得多。要讓兩個模塊配合好,得先把它們到底在處理什麼説清楚。
當前版本測試通過,舊版本可能連 Advice 都沒觸發h2
這次最讓我頭疼的,還是版本兼容。
我最開始在當前源碼裏找到了 BlockingQueueConsumer.consumeFromQueue(...),就把 Advice 放到這裏。當前版本能工作,很容易讓我以為已經覆蓋了 consumer 註冊。
但 自動 review 提醒,模塊支持的 Spring Rabbit 1.0.0 根本沒有這個方法。舊版本在 start() 裏直接調用 basicConsume。Advice 掛載點沒匹配到,修復就不會觸發,卻不一定像普通 API 不兼容那樣直接報編譯錯誤。
Direct Container 也有類似問題。維護者指出,2.0.0–2.1.2 沒有我選的 consume() 路徑,doConsumeFromQueue(String) 才包住了那裏的註冊過程。
| 路徑 | 最初忽略的地方 | 合併版本里的處理 |
|---|---|---|
| BlockingQueueConsumer | 1.0.0 沒有 consumeFromQueue(String) | 同時覆蓋無參 start() 和 consumeFromQueue(String) |
| Direct Container | 2.0.0–2.1.2 不走選中的 consume() | 匹配 doConsumeFromQueue 的一參、兩參形式 |
我以前想到兼容性,更多是在檢查“有沒有調用舊版本不存在的 API”。這次才發現,在 instrumentation 項目裏,還得問:我選的掛載點,在那些版本里真的存在嗎?執行路徑真的會經過它嗎?
維護者能很快指出舊版本里的這些差異時,我對項目經驗的感受特別直接。不是他寫 Java 比我快,而是他知道這個類以前長什麼樣、後來又在哪裏改過。
連測試裏的“先啓動,再清空”都需要多想一步h2
補 container 測試時,我一開始的思路很普通:先啓動,然後清掉初始化階段的遙測,再發消息做斷言。
container.start();testing.clearData();但 consumer registration 是異步完成的。start() 返回,不代表相關 startup Span 都已經產生。如果清空得太早,稍後才到的初始化遙測,就會混進後面的 exact trace assertion。
Copilot 的這條 review 建議先等確定會產生的 setup traces,再清空。最終測試裏的順序是:
// 对应这里预期的三条 setup traces,不是通用的固定等待数量container.start();testing.waitForTraces(3);testing.clearData();這個建議看起來只多了一行等待,但它要求我理解系統實際怎麼執行。不是把測試步驟按順序寫下來,異步工作就會按我想的順序完成。
review 裏還有命名、測試註解以及屬性 getter 寫法這些小建議。有些來自維護者,有些來自自動 review。它們也讓我開始注意,倉庫慣例不一定都寫在一份文檔裏,很多時候就藏在現有代碼中。
API 名字對得上,還得確認值從哪裏來h2
這次還有一位用户 leonard2901 用自己的 Spring 應用驗證了分支。他確認重複問題解決了,但發現 messaging.message.body.size 看起來總是 0。
繼續看才發現,Spring Rabbit 原來讀取的是消息屬性裏的 contentLength,而不是實際 body 的長度。如果發佈者沒有顯式設置這個屬性,收到消息以後它仍然可能是零。
我後來把單條消息的大小提取改為實際字節數組長度,並增加了非空 payload 的迴歸檢查。可以把這個變化理解為:
// 原来的来源:消息属性,不一定由发布者设置message.getMessageProperties().getContentLength();
// 单条消息改用实际 body 长度,省略完整 request 和 batch 分支byte[] body = message.getBody();return body == null ? null : (long) body.length;這件事挺讓我印象深刻。一個 API 名字看起來完全符合我要的東西,也不代表它在這個具體場景裏一定有值。實際用户帶來的配置和使用方式,會讓我看到自己的測試和理解之外的地方。
Review 不是隻在幫我找 Bugh2
最開始被指出這些問題時,我確實有點挫敗。有時會想,方案明明能工作,為什麼還要再改?歷史版本的這些細節,我第一次接觸這個倉庫,怎麼可能一下子全知道?
但後面慢慢發現,review 不是要求我一開始就知道所有東西,而是把我局部的理解,放回整個項目裏檢查。
“保存舊值再恢復”背後,是已經形成的協作模式;“用 Instrumenter”背後,是統一的生命週期;“這個舊版本沒有這個方法”背後,是模塊已經承諾支持的範圍。這些建議表面上讓我改幾行代碼,實際是在補上我暫時看不到的上下文。
我也開始意識到,review 的不同意見需要繼續核對,而不是誰説得更肯定就聽誰的。像 consumed-message 指標那段,得把維護者的目標、中間版本和最終代碼放在一起看,才能知道這次修改到底選擇了什麼。
維護者的經驗,到這裏對我來説變得很具體:不是腦子裏比我多一條算法,而是知道一個局部修改,會在項目其他地方留下什麼影響。
做完以後,我進入大型倉庫的方式變了一點h2
回頭看,我當時太急着自己設計方案了。理解 Issue、定位問題,下一步就想開始寫。review 卻反覆把我帶回已有實現:去看 Kafka 的狀態控制,去看 spring-kafka 的 ownership,再看 RabbitMQ 已經使用的屬性 getter。
這些實現一直都在那裏,只是我還沒有形成先找它們的習慣。
做完這個 PR,我開始留意一些原來沒注意到的地方:一個抽象為什麼要保留,某個測試為什麼要等,某個 Advice 為什麼要照顧多年前的調用點。以前讀代碼時,我更多是在找要改的位置,現在也會想多看一步,弄清楚它為什麼寫成這樣。
但這次至少讓我多了一個進入倉庫時會問的問題。以前是“這段代碼應該怎麼改”,現在還會問:這個倉庫以前遇到類似問題時,是怎麼做的?
我想,這比記住某一個類名更有用。代碼能跑、測試能過,當然重要;但讓一個修改真正適合這個項目,還需要理解它已經積累下來的選擇。我還在學這一部分,這次 review 正好讓我很直接地看到了差距,也知道下一次可以從哪裏開始補。