:從單體到分布式的RPC服務調用指南)
1. 從單體到分布式為什么要在 Spring 里引入 Dubbo很多做 Java 后端的同學第一次接觸分布式服務調用都是從“本地方法調用”直接跳到“遠程調用”這個概念的。在單體應用里Service A 調 Service B就是一次普通的 Java 方法調用編譯器幫你把參數(shù)壓棧、把返回值帶回來整個過程在同一個 JVM 里完成快得你根本感覺不到它的存在??梢坏I(yè)務膨脹訂單、庫存、用戶、支付這些模塊各自獨立部署本地調用就徹底失效了——你沒法在一個進程里 new 出另一個進程的對象。這時候就需要一個“遠程調用框架”來補位。Dubbo 就是干這個的它把“調用遠程服務”這件事偽裝得跟“調用本地方法”幾乎一樣。你寫orderService.createOrder(...)底層其實經(jīng)歷了網(wǎng)絡傳輸、序列化、負載均衡、服務發(fā)現(xiàn)這一整套流程但對你來說代碼寫起來跟本地調用沒區(qū)別。這就是 RPC遠程過程調用框架的核心價值讓分布式調用對開發(fā)者透明。那為什么是 Spring 整合 Dubbo而不是單獨用 Dubbo因為 Spring 的 IOC 容器和 AOP 能力能把 Dubbo 的服務暴露和引用過程變成幾個注解或者幾行 XML 配置的事。你不用手動去寫ServiceConfig、ReferenceConfig這些底層 APISpring 幫你管理 Bean 的生命周期Dubbo 幫你管理服務的注冊與發(fā)現(xiàn)。兩者結合才是生產(chǎn)環(huán)境里最常見的落地方式。這篇文章適合誰看如果你已經(jīng)會寫 Spring Boot 或者 Spring MVC 的常規(guī)業(yè)務代碼但對“服務怎么拆出去”“消費者怎么找到提供者”這些問題還比較模糊那這篇內(nèi)容就是給你準備的。我會從零開始把 Spring 整合 Dubbo 的完整鏈路拆開包括依賴選型、配置細節(jié)、啟動順序、常見報錯以及我實際踩過的幾個坑。不堆概念只講能跑起來的代碼和能復現(xiàn)的排查思路。提示本文基于 Dubbo 2.7.x 與 Spring Boot 2.x 的整合場景展開這是目前存量項目里最主流的組合。如果你用的是 Dubbo 3.x核心思路一致但部分配置項和注冊中心行為有差異我會在對應位置標注。2. 動手之前的選型賬注冊中心、協(xié)議與依賴版本2.1 注冊中心為什么首選 ZooKeeper 或 NacosDubbo 要工作必須有一個“服務注冊中心”。提供者啟動時把自己的地址寫進去消費者啟動時從里面讀出來這樣雙方才能建立連接。沒有注冊中心你就得手動配置提供者的 IP 和端口一旦提供者擴容或者遷移消費者就得跟著改配置這在生產(chǎn)環(huán)境里是不可接受的。常見的注冊中心有兩個主流選擇ZooKeeper 和 Nacos。ZooKeeper 是 Dubbo 早期默認的注冊中心基于 ZAB 協(xié)議保證一致性成熟穩(wěn)定很多老項目都在用。Nacos 是后來者除了服務注冊發(fā)現(xiàn)還集成了配置管理控制臺更友好支持臨時實例和持久實例的靈活切換。我個人的建議是新項目直接用 Nacos老項目如果已經(jīng)在用 ZooKeeper沒必要為了換而換。兩者的整合代碼差異很小主要就是依賴和配置地址不同。下面這張表可以幫你快速決策對比維度ZooKeeperNacos一致性協(xié)議ZABRaftCP DistroAP控制臺無原生UI需第三方自帶Web控制臺配置管理不支持原生支持健康檢查會話心跳心跳主動探測適用場景存量老項目新項目、云原生環(huán)境2.2 依賴引入別小看這幾個 artifactSpring 整合 Dubbo依賴引入是最容易出問題的地方。很多人直接抄網(wǎng)上的 pom結果啟動就報NoSuchMethodError或者ClassNotFoundException根源就是版本沒對齊。核心依賴其實就三個dubbo-spring-boot-starterDubbo 官方提供的 Spring Boot 整合包封裝了自動配置。dubboDubbo 核心包starter 會傳遞依賴進來但有時需要顯式指定版本。注冊中心客戶端用 ZooKeeper 就引curator-framework和curator-recipes用 Nacos 就引nacos-client。這里有個細節(jié)Dubbo 2.7.x 的 starter 版本號要和 Dubbo 核心版本保持一致。比如你用dubbo-spring-boot-starter:2.7.8那dubbo也應該是2.7.8。如果項目里還有其他依賴間接引入了不同版本的 Dubbo就會產(chǎn)生沖突。我習慣在 pom 里用dependencyManagement鎖定版本避免這種隱性沖突。dependencyManagement dependencies dependency groupIdorg.apache.dubbo/groupId artifactIddubbo-dependencies-bom/artifactId version2.7.8/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement注意Dubbo 2.7 之后groupId 從com.alibaba變成了org.apache如果你還在用老版本的com.alibaba.dubbo配置項前綴也是dubbo.而不是spring.dubbo.這個遷移坑很多人踩過。2.3 通信協(xié)議的選擇邏輯Dubbo 支持多種通信協(xié)議默認是dubbo協(xié)議基于 Netty 的單一長連接和 NIO 異步通信適合小數(shù)據(jù)量、高并發(fā)的服務調用。除此之外還有rmi、hessian、http等。為什么默認用 dubbo 協(xié)議因為它針對服務調用場景做了優(yōu)化單一長連接減少了連接建立的開銷二進制序列化比文本協(xié)議更緊湊異步通信提升了吞吐量。但如果你要調用的服務參數(shù)或返回值特別大比如傳輸大文件dubbo 協(xié)議的單一長連接反而會成為瓶頸這時候可以考慮hessian協(xié)議它支持多連接和短連接。對于入門場景直接用默認的 dubbo 協(xié)議就行不需要額外配置。等你遇到性能瓶頸或者跨語言調用需求時再考慮換協(xié)議。3. 提供者端把本地服務暴露成遠程服務3.1 接口抽離為什么必須單獨建一個 API 模塊這是新手最容易忽略的一步。提供者和消費者要能互相調用雙方必須共享同一份接口定義。如果你把接口寫在提供者模塊里消費者就得依賴提供者的整個 jar 包這顯然不合理——消費者可能只需要一個接口卻被迫引入了提供者的所有實現(xiàn)和依賴。正確的做法是單獨建一個 API 模塊里面只放接口和傳輸對象DTO提供者和消費者都依賴這個模塊。這樣消費者編譯時只需要 API 模塊的 jar運行時通過 Dubbo 動態(tài)代理調用遠程實現(xiàn)。// api 模塊中的接口定義 public interface UserService { UserDTO getUserById(Long id); boolean register(UserDTO user); }這個接口里不要出現(xiàn)任何提供者特有的類比如數(shù)據(jù)庫實體、內(nèi)部工具類。DTO 也要盡量簡單只包含需要傳輸?shù)淖侄尾⑶覍崿F(xiàn)Serializable接口——因為跨進程傳輸需要序列化。3.2 服務實現(xiàn)與 DubboService 注解提供者端的服務實現(xiàn)類就是普通的 Spring Bean只不過要加上DubboService注解Dubbo 2.7.7 之后推薦用這個之前是Service但會和 Spring 的Service沖突。DubboService(version 1.0.0, timeout 3000) public class UserServiceImpl implements UserService { Autowired private UserMapper userMapper; Override public UserDTO getUserById(Long id) { User user userMapper.selectById(id); if (user null) { return null; } UserDTO dto new UserDTO(); dto.setId(user.getId()); dto.setName(user.getName()); return dto; } Override public boolean register(UserDTO user) { // 注冊邏輯 return true; } }這里有幾個關鍵參數(shù)需要解釋version服務版本號。當同一個接口有多個實現(xiàn)時用版本號區(qū)分。消費者引用時必須指定相同的版本號否則找不到服務。timeout調用超時時間單位毫秒。默認是 1000ms但實際業(yè)務中數(shù)據(jù)庫查詢可能超過這個時間建議根據(jù)業(yè)務設置合理值。group服務分組用于隔離不同環(huán)境或不同業(yè)務線的服務。提示DubboService注解默認會把服務注冊到所有配置的注冊中心。如果你有多個注冊中心可以用registry屬性指定。3.3 配置文件里的那些必填項提供者的application.yml里至少需要配置應用名、注冊中心地址和協(xié)議端口dubbo: application: name: user-service-provider registry: address: nacos://127.0.0.1:8848 protocol: name: dubbo port: 20880 scan: base-packages: com.example.provider.service逐項說明dubbo.application.name應用名必須唯一。注冊中心里就是靠這個區(qū)分不同應用的。dubbo.registry.address注冊中心地址。用 Nacos 就寫nacos://ip:port用 ZooKeeper 就寫zookeeper://ip:port。dubbo.protocol.portDubbo 服務暴露的端口。默認是 20880如果一臺機器部署多個提供者端口會自增20880、20881...。生產(chǎn)環(huán)境建議顯式指定避免端口沖突。dubbo.scan.base-packages掃描DubboService注解的包路徑。不配這個注解不會被處理服務也不會暴露。我見過有人只配了注冊中心忘了配scan結果啟動沒報錯但消費者就是調不到服務。排查了半天才發(fā)現(xiàn)是注解沒被掃描到。這個坑很隱蔽因為 Dubbo 不會主動提示你“有注解沒掃描”。3.4 啟動順序與注冊中心連接驗證提供者啟動時Dubbo 會做幾件事連接注冊中心、創(chuàng)建服務代理、把服務地址寫入注冊中心、啟動 Netty 監(jiān)聽端口。如果注冊中心連不上啟動會失敗并拋出RpcException。啟動成功后你可以去 Nacos 控制臺或者 ZooKeeper 客戶端里驗證服務是否注冊成功。Nacos 的話在“服務管理-服務列表”里應該能看到你的應用名和對應的實例地址。ZooKeeper 的話用ls /dubbo/com.example.api.UserService/providers命令查看。如果注冊中心里沒有你的服務按這個順序排查DubboService注解是否加在實現(xiàn)類上而不是接口上。dubbo.scan.base-packages是否覆蓋了實現(xiàn)類所在的包。注冊中心地址是否正確網(wǎng)絡是否連通。是否有多個注冊中心配置沖突。4. 消費者端像調用本地 Bean 一樣調用遠程服務4.1 DubboReference 的注入邏輯消費者端要引用遠程服務只需要在字段上加DubboReference注解RestController public class UserController { DubboReference(version 1.0.0, timeout 3000) private UserService userService; GetMapping(/user/{id}) public UserDTO getUser(PathVariable Long id) { return userService.getUserById(id); } }Spring 啟動時Dubbo 會為這個字段生成一個動態(tài)代理對象。當你調用userService.getUserById(id)時代理對象會攔截調用從注冊中心獲取提供者地址列表經(jīng)過負載均衡選出一個提供者把方法名、參數(shù)類型、參數(shù)值序列化后通過網(wǎng)絡發(fā)送過去等待響應后再反序列化返回結果。整個過程對開發(fā)者完全透明。你不需要寫任何網(wǎng)絡代碼也不需要手動處理連接池。4.2 消費者配置少即是多消費者的配置比提供者更簡單通常只需要應用名和注冊中心地址dubbo: application: name: order-service-consumer registry: address: nacos://127.0.0.1:8848不需要配protocol因為消費者不暴露服務只發(fā)起調用。也不需要配scan因為DubboReference是在字段上Spring 處理 Bean 注入時就會處理。但有一個配置建議加上dubbo.consumer.checkfalse。默認情況下消費者啟動時會檢查依賴的服務是否可用如果提供者還沒啟動消費者會啟動失敗。在開發(fā)環(huán)境或者提供者啟動較慢的場景下這個檢查很煩人。設為false后消費者啟動時不會檢查調用時如果服務不可用才會報錯。dubbo: consumer: check: false timeout: 5000 retries: 2check啟動時是否檢查服務可用性。timeout默認調用超時時間可以被DubboReference上的配置覆蓋。retries失敗重試次數(shù)默認是 2。注意重試只對冪等操作安全非冪等操作比如插入數(shù)據(jù)要設為 0否則可能重復插入。4.3 直連模式開發(fā)調試時的救命稻草有時候你只想本地調試不想啟動注冊中心或者提供者還沒注冊到注冊中心。這時候可以用直連模式繞過注冊中心直接調用指定地址DubboReference(url dubbo://127.0.0.1:20880, version 1.0.0) private UserService userService;或者在配置里全局指定dubbo: reference: url: dubbo://127.0.0.1:20880直連模式在排查“是注冊中心問題還是服務本身問題”時特別有用。如果直連能調通說明服務實現(xiàn)沒問題問題出在注冊中心如果直連也調不通那就是服務本身或者網(wǎng)絡的問題。注意直連模式只適合開發(fā)調試生產(chǎn)環(huán)境絕對不要用。因為直連繞過了服務發(fā)現(xiàn)提供者擴容或遷移后消費者無法感知。4.4 異步調用與泛化調用簡介Dubbo 還支持異步調用消費者發(fā)起調用后不阻塞等待結果而是通過 Future 或者回調獲取。這在批量調用或者對響應時間敏感的場景下很有用。配置方式是在DubboReference上加async true然后通過RpcContext.getContext().getFuture()獲取 Future 對象。泛化調用則是消費者沒有 API 接口的情況下的兜底方案。比如你做一個網(wǎng)關需要調用各種未知的服務不可能為每個服務都引入 API 包。這時候可以用GenericService接口傳入方法名、參數(shù)類型和參數(shù)值Dubbo 會幫你完成調用。泛化調用的性能比正常調用略低因為少了編譯期的類型檢查但靈活性極高。這兩個特性入門階段可以先了解等有具體需求時再深入。5. 聯(lián)調時最容易卡住的幾個環(huán)節(jié)5.1 服務找不到從注冊中心到網(wǎng)絡的全鏈路排查“No provider available for the service”是 Dubbo 最常見的報錯。這個報錯的意思是消費者在注冊中心里找不到可用的提供者。排查要按鏈路走不要跳步。第一步確認提供者是否真的注冊成功了。去注冊中心控制臺看服務列表里有沒有提供者的應用名實例列表里有沒有具體的 IP 和端口。如果沒有回到提供者端排查DubboService和scan配置。第二步確認消費者和提供者連的是同一個注冊中心。這個聽起來很蠢但我確實見過有人提供者連的是測試環(huán)境的 Nacos消費者連的是本地的 ZooKeeper兩邊完全不在一個頻道上。第三步確認服務接口的全限定名、版本號、分組是否完全一致。消費者寫version 1.0.0提供者寫version 1.0.1或者消費者沒寫版本號而提供者寫了都會導致找不到服務。Dubbo 的匹配是精確匹配差一個字符都不行。第四步檢查網(wǎng)絡連通性。消費者能不能 ping 通提供者的 IP提供者的端口是不是被防火墻攔了。Dubbo 默認用 20880 端口有些公司的安全策略會封禁非標準端口。5.2 序列化異常實體類沒實現(xiàn) Serializable 的后果這個報錯信息通常是org.apache.dubbo.remoting.RemotingException: Failed to invoke the method ... cause: java.io.NotSerializableException。原因很簡單傳輸?shù)膶ο鬀]有實現(xiàn)Serializable接口。Dubbo 默認用 Hessian2 序列化它要求傳輸?shù)膶ο蟊仨毧尚蛄谢2粌H是 DTO 本身DTO 里嵌套的所有對象也都要可序列化。我遇到過有人在 DTO 里放了一個java.util.DateDate 本身是可序列化的但它的子類java.sql.Timestamp在某些序列化器下會有問題。所以 DTO 里的字段類型盡量用基本類型和 String復雜類型要確認序列化兼容性。另外如果 DTO 里有無參構造函數(shù)之外的構造函數(shù)但沒有保留無參構造函數(shù)Hessian2 反序列化時也會失敗。因為反序列化需要先創(chuàng)建實例再填充字段沒有無參構造函數(shù)就沒法創(chuàng)建。5.3 超時與重試默認參數(shù)在真實業(yè)務里的陷阱Dubbo 默認超時是 1000ms默認重試 2 次。這兩個默認值在真實業(yè)務里經(jīng)常出問題。先說超時。1000ms 對于簡單的內(nèi)存操作夠用但一旦涉及數(shù)據(jù)庫查詢、遠程緩存、第三方接口調用很容易超過。超時后消費者會拋出TimeoutException然后觸發(fā)重試。如果提供者的業(yè)務邏輯已經(jīng)執(zhí)行了比如已經(jīng)扣了庫存但響應因為網(wǎng)絡慢沒及時返回重試就會導致重復扣減。再說重試。默認重試 2 次加上首次調用一共 3 次。對于查詢操作重試是安全的還能提高成功率。但對于插入、更新、刪除操作重試就是災難。所以我的習慣是所有非冪等的服務在DubboService或DubboReference上顯式設置retries 0。DubboService(version 1.0.0, timeout 5000, retries 0) public class OrderServiceImpl implements OrderService { // 下單邏輯絕對不能重試 }超時時間也要根據(jù)業(yè)務設置。我的經(jīng)驗值是數(shù)據(jù)庫單表查詢設 3000ms復雜查詢設 5000ms調用第三方接口設 10000ms。不要全局設一個很大的值那樣一旦提供者真的掛了消費者會長時間阻塞拖垮整個調用鏈。5.4 啟動順序導致的假死現(xiàn)象在微服務架構里服務之間有依賴關系。如果消費者啟動時提供者還沒啟動消費者會報“No provider available”然后啟動失敗如果checktrue。但如果checkfalse消費者能啟動成功但第一次調用時會失敗。更隱蔽的情況是提供者啟動了但還沒完成服務注冊消費者就開始調用了。Dubbo 的服務注冊是異步的提供者啟動完成后注冊中心的通知可能還有延遲。如果消費者在這個窗口期發(fā)起調用就會失敗。解決辦法有兩個一是消費者配置checkfalse并配合重試機制二是用 Dubbo 的lazy屬性延遲初始化。但最根本的還是要保證啟動順序或者在網(wǎng)關層做健康檢查等所有依賴服務都就緒后再放流量進來。6. 從能跑到好用幾個值得提前知道的優(yōu)化點6.1 服務分組與多版本并行當同一個接口需要同時存在多個實現(xiàn)時比如灰度發(fā)布場景老版本和新版本要并行運行這時候就需要用group和version來區(qū)分。// 提供者老版本 DubboService(version 1.0.0, group stable) public class UserServiceStableImpl implements UserService { } // 提供者新版本 DubboService(version 2.0.0, group gray) public class UserServiceGrayImpl implements UserService { } // 消費者指定調用灰度版本 DubboReference(version 2.0.0, group gray) private UserService userService;這樣可以在不影響老用戶的情況下讓部分流量走新版本。等新版本驗證穩(wěn)定后再把所有消費者的配置切到新版本。6.2 本地偽裝與容錯降級Dubbo 提供了mock機制當遠程調用失敗時可以返回一個本地偽裝的結果而不是直接拋異常。這在依賴服務不可用時能保證主流程不被阻塞。DubboReference(version 1.0.0, mock com.example.mock.UserServiceMock) private UserService userService;UserServiceMock需要實現(xiàn)UserService接口并提供一個無參構造函數(shù)。當遠程調用失敗時Dubbo 會調用 Mock 類的對應方法返回兜底數(shù)據(jù)。這個機制在秒殺、大促場景下特別有用。比如推薦服務掛了返回一個默認的熱門列表總比整個頁面報錯要好。6.3 監(jiān)控與治理的入口Dubbo 自帶了一個簡單的監(jiān)控中心dubbo-monitor可以統(tǒng)計調用次數(shù)、成功率、響應時間等指標。雖然功能不如專業(yè)的 APM 系統(tǒng)但對于入門和中小項目來說夠用了。配置方式是在提供者和消費者都加上dubbo: monitor: protocol: registry這樣監(jiān)控數(shù)據(jù)會通過注冊中心上報。啟動 dubbo-monitor 后可以在控制臺看到服務的調用統(tǒng)計。如果公司有 Prometheus Grafana 的監(jiān)控體系Dubbo 也支持通過dubbo-metrics-prometheus模塊暴露指標接入現(xiàn)有的監(jiān)控大盤。6.4 線程模型與連接數(shù)控制Dubbo 默認的線程模型是all即所有消息都派發(fā)到業(yè)務線程池。線程池默認大小是 200隊列長度是 0即不排隊直接拒絕。在高并發(fā)場景下這個配置可能導致請求被拒絕。如果服務大多是 CPU 密集型線程數(shù)可以設小一點比如 CPU 核數(shù) 1。如果是 IO 密集型線程數(shù)可以設大一點比如 500。隊列長度建議設一個合理的值比如 1000避免瞬間流量直接打滿線程池。dubbo: protocol: threads: 500 queues: 1000連接數(shù)方面Dubbo 默認是共享單一長連接。如果提供者的服務處理時間較長單一連接可能成為瓶頸??梢酝ㄟ^connections參數(shù)增加連接數(shù)DubboReference(connections 10) private UserService userService;但連接數(shù)不是越多越好每個連接都會占用系統(tǒng)資源。一般建議根據(jù)壓測結果來調整不要盲目設大。7. 我踩過的三個真實坑與對應解法第一個坑是注解沖突。早期 Dubbo 用Service注解暴露服務但 Spring 也有Service。我在同一個類上同時寫了兩個Service結果 Spring 把它當成了普通 BeanDubbo 沒識別到服務一直注冊不上去。后來換成DubboService才解決。如果你用的是 Dubbo 2.7.7 之前的版本要么用 XML 配置要么確保Service的包路徑是org.apache.dubbo.config.annotation.Service。第二個坑是泛型擦除導致的序列化問題。我在 DTO 里用了一個ListT字段運行時 T 被擦除成 ObjectHessian2 序列化時無法確定具體類型反序列化后變成了ListMap。消費者拿到數(shù)據(jù)后強轉報ClassCastException。解決辦法是 DTO 里的集合字段盡量用具體類型比如ListUserDTO而不是ListT或者用JSONField之類的注解指定類型信息。第三個坑是注冊中心地址配了多個但只生效一個。我在dubbo.registry.address里寫了兩個地址用逗號分隔以為會同時注冊到兩個注冊中心。實際上 Dubbo 默認只使用第一個可用的地址后面的作為備份。如果要同時注冊到多個注冊中心需要配置多個dubbo.registries節(jié)點并在DubboService上指定registry屬性。這個細節(jié)官方文檔里寫得很隱蔽我是看了源碼才搞明白。最后分享一個排查小技巧Dubbo 啟動時會打印服務暴露和引用的日志把日志級別調到DEBUG可以看到服務注冊的詳細過程包括注冊中心地址、服務接口、版本號、分組等信息。如果服務找不到先看這些日志比盲目猜要快得多。logging: level: org.apache.dubbo: DEBUG這個配置在開發(fā)環(huán)境建議一直開著生產(chǎn)環(huán)境再調回INFO。