架構(gòu)的進(jìn)階之路)
只會(huì)寫增刪改查的Java程序員技術(shù)提升的突破口從來不是去背更多的框架而是先把“CRUD工程師”這個(gè)身份背后的思維瓶頸想清楚。我先說個(gè)現(xiàn)象很多人工作兩三年日常需求無非是寫接口、調(diào)接口、改表結(jié)構(gòu)遇到性能問題先加索引加完不行就上緩存緩存還不行就換機(jī)器。這種狀態(tài)不是技術(shù)差而是接觸面太窄導(dǎo)致的問題。CRUD本身不是罪罪在你看不到CRUD之外的系統(tǒng)全貌。這篇文章我想聊聊我從這個(gè)狀態(tài)走出來的一些實(shí)際經(jīng)驗(yàn)包括怎么判斷自己卡在哪、優(yōu)先補(bǔ)什么、用什么路線補(bǔ)以及真實(shí)項(xiàng)目里踩過的坑。適合那些每天寫著簡(jiǎn)單業(yè)務(wù)代碼、又想往上走一層但不知道怎么下手的Java開發(fā)者。1. 先承認(rèn)一件事CRUD寫多了問題不在代碼在思路1.1 “會(huì)CRUD”只是熟練工不是技術(shù)能力我見過不少工作三五年的開發(fā)寫接口速度極快增刪改查一氣呵成單表查詢、多表join、事務(wù)注解用得飛起。但一問“這個(gè)接口為什么這么慢”“這個(gè)表為什么這么設(shè)計(jì)”“這個(gè)緩存失效了怎么辦”就答不上來了。這不是貶低熟練工而是說CRUD是結(jié)果不是能力。你每天在做的是把業(yè)務(wù)需求翻譯成數(shù)據(jù)庫(kù)操作這個(gè)翻譯過程如果一直停留在“表里有什么字段就返回什么字段”的層面那寫一萬個(gè)接口和寫一百個(gè)接口成長(zhǎng)幅度沒區(qū)別。真正拉開差距的是你能不能回答這幾個(gè)問題這個(gè)查詢?yōu)槭裁醋哌@個(gè)索引能不能不走這個(gè)索引這個(gè)表設(shè)計(jì)成第三范式夠不夠要不要反范式這接口的響應(yīng)時(shí)間預(yù)算花在哪里網(wǎng)絡(luò)SQL序列化這服務(wù)部署幾臺(tái)機(jī)器瓶頸在CPU還是IO怎么證明這個(gè)緩存一致性問題用哪種方案為什么選這種這些問題的答案沒有一個(gè)長(zhǎng)在MyBatis的XML里。它們分布在操作系統(tǒng)、網(wǎng)絡(luò)、數(shù)據(jù)結(jié)構(gòu)、數(shù)據(jù)庫(kù)原理、分布式系統(tǒng)這些“看著沒用”的基礎(chǔ)知識(shí)里。1.2 技術(shù)提升的第一步把“會(huì)寫”變成“能講清楚”我有一個(gè)很樸素的方法隨便挑一個(gè)你最近寫的接口試著用30分鐘把它的全鏈路講清楚。從瀏覽器發(fā)出HTTP請(qǐng)求開始經(jīng)過網(wǎng)關(guān)、負(fù)載均衡、服務(wù)進(jìn)程、線程池、SQL解析、索引選擇、緩沖池、返回結(jié)果、序列化、網(wǎng)絡(luò)回包每一環(huán)你都能講出頭緒嗎講不清楚的地方就是你該補(bǔ)課的地方。我在某公司帶新人時(shí)經(jīng)常讓他們干一件事給自己負(fù)責(zé)的模塊畫一張架構(gòu)圖標(biāo)注每個(gè)請(qǐng)求從進(jìn)入到返回經(jīng)過的所有組件和數(shù)據(jù)結(jié)構(gòu)。畫不出來就去翻源碼、看監(jiān)控、查日志直到畫出來為止。這事看著不難但真做完的人技術(shù)視野立刻不一樣了——因?yàn)楫?dāng)你開始追問“這一層到底發(fā)生了什么”CRUD就只是整個(gè)鏈路里的一個(gè)環(huán)節(jié)而不是全部。2. 主攻方向怎么定別盲目追新框架先補(bǔ)底層和周邊2.1 底層基礎(chǔ)補(bǔ)什么數(shù)據(jù)結(jié)構(gòu)、網(wǎng)絡(luò)、操作系統(tǒng)很多Java開發(fā)者一聽“底層”就頭疼覺得跟工作沒關(guān)系。但你做性能排查的時(shí)候就明白這些知識(shí)多重要了。舉個(gè)最直接的例子你寫一個(gè)接口查了數(shù)據(jù)庫(kù)返回了1000條記錄每條記錄有個(gè)時(shí)間字段你要按時(shí)間倒序排。你的第一反應(yīng)是“SQL加個(gè)ORDER BY”這沒錯(cuò)。可如果數(shù)據(jù)量大到內(nèi)存放不下你要考慮排序算法如果數(shù)據(jù)分布在多個(gè)節(jié)點(diǎn)你要考慮歸并思想如果響應(yīng)很慢你要先排查是網(wǎng)絡(luò)傳輸占時(shí)間還是數(shù)據(jù)庫(kù)排序占時(shí)間。這些問題的基礎(chǔ)全在計(jì)算機(jī)底層知識(shí)里。我建議按這個(gè)優(yōu)先級(jí)補(bǔ)數(shù)據(jù)結(jié)構(gòu)與算法數(shù)組、鏈表、哈希表、樹、排序、二分。夠用就行不用刷題刷到天昏地暗但核心結(jié)構(gòu)的時(shí)間復(fù)雜度必須脫口而出。網(wǎng)絡(luò)基礎(chǔ)HTTP協(xié)議、TCP三次握手與四次揮手、連接池原理。尤其要理解“一次HTTP請(qǐng)求背后有多少次網(wǎng)絡(luò)交互”。操作系統(tǒng)進(jìn)程與線程、上下文切換、IO模型。Java的NIO、Netty背后全是IO模型的知識(shí)。JVM內(nèi)存區(qū)域劃分、垃圾回收算法與收集器、類加載機(jī)制。這不是面試八股是排查內(nèi)存溢出、GC頻繁的必備工具。2.2 周邊技術(shù)補(bǔ)什么緩存、消息隊(duì)列、搜索引擎、分庫(kù)分表光有底層知識(shí)還不夠你得會(huì)“用”。第二梯隊(duì)要補(bǔ)的是中間件。我在實(shí)際項(xiàng)目里遇到過這么一件事某系統(tǒng)有個(gè)統(tǒng)計(jì)接口需要聚合前一天的全量數(shù)據(jù)查一張上億行的流水表單次查詢要好幾秒。這接口被運(yùn)營(yíng)同學(xué)每天上班點(diǎn)一次點(diǎn)完就去倒咖啡。后來流量漲了變成每幾分鐘刷一次數(shù)據(jù)庫(kù)直接扛不住了。這個(gè)場(chǎng)景的出路其實(shí)很清楚離線結(jié)果先算好存起來實(shí)時(shí)只讀緩存結(jié)果。這就是緩存思想如果數(shù)據(jù)量進(jìn)一步膨脹還可以考慮把統(tǒng)計(jì)結(jié)果異步寫入用消息隊(duì)列削峰填谷。這些都不是什么高深理論就是“把計(jì)算從請(qǐng)求鏈路里挪走”的思路。我推薦的周邊技術(shù)學(xué)習(xí)清單Redis別只停留在String和Hash的set/get要理解緩存淘汰策略、持久化機(jī)制、分布式鎖的坑、緩存穿透與雪崩。消息隊(duì)列以RocketMQ或Kafka為例理解生產(chǎn)者、消費(fèi)者、Topic、分區(qū)、消費(fèi)組、消息可靠性與順序性。搜索引擎Elasticsearch的倒排索引、分詞、聚合分析這個(gè)對(duì)“列表頁多條件篩選”的場(chǎng)景是降維打擊。分庫(kù)分表理解垂直拆分與水平拆分、分片鍵選擇、擴(kuò)容方案這是數(shù)據(jù)量上來之后躲不開的問題。2.3 架構(gòu)視野補(bǔ)什么從單機(jī)到分布式先理解演進(jìn)邏輯我不建議一上來就讀《大型網(wǎng)站技術(shù)架構(gòu)》《分布式系統(tǒng)原理》這類書——不是說不好而是沒踩過坑的人讀不進(jìn)去讀完了也記不住。正確的方式是從你自己的項(xiàng)目出發(fā)問一句“如果我的服務(wù)扛不住了怎么辦”。剛工作那會(huì)兒我做的一個(gè)會(huì)員系統(tǒng)接口單機(jī)部署高峰期CPU跑滿80%。當(dāng)時(shí)我的反應(yīng)是加機(jī)器——但加了機(jī)器之后發(fā)現(xiàn)用戶的會(huì)話信息存在單機(jī)內(nèi)存里負(fù)載均衡一轉(zhuǎn)發(fā)用戶就被踢下線了。這就是從單機(jī)到分布式必然遇到的第一個(gè)問題狀態(tài)怎么共享。你真正需要補(bǔ)的架構(gòu)知識(shí)是圍繞“扛不住”這三個(gè)字展開的一臺(tái)變多臺(tái)負(fù)載均衡怎么配、會(huì)話怎么共享引入Redis。數(shù)據(jù)庫(kù)扛不住讀寫分離、主從同步、分庫(kù)分表。服務(wù)間調(diào)用變復(fù)雜引入服務(wù)發(fā)現(xiàn)與注冊(cè)、遠(yuǎn)程調(diào)用框架、配置中心。流量瞬間暴漲引入限流、降級(jí)、熔斷。數(shù)據(jù)不能丟引入消息隊(duì)列做異步持久化。這串演進(jìn)邏輯跟“公司規(guī)模變大、組織拆分”是一樣的道理。你先理解要解決什么問題再看技術(shù)方案就特別順。3. 實(shí)操篇用“訂單查詢接口”這個(gè)案例走一遍完整提升流程3.1 從問題出發(fā)一個(gè)慢接口的排查過程先定一個(gè)具體場(chǎng)景訂單列表接口業(yè)務(wù)方反饋“加載很慢有時(shí)候要6秒”。這個(gè)接口的邏輯是按用戶ID查訂單主表返回訂單列表每筆訂單要關(guān)聯(lián)查一次訂單明細(xì)表、一次商品表、一次店鋪表。我先說最初的實(shí)現(xiàn)長(zhǎng)什么樣SELECT * FROM t_order WHERE user_id ? ORDER BY create_time DESC LIMIT 20;然后循環(huán)拿訂單ID去查明細(xì)SELECT * FROM t_order_item WHERE order_id ?;商品表和店鋪表同理。一眼看出問題循環(huán)里的N次查詢就是教科書級(jí)別的“N1問題”。但只把問題定位到這里還不夠需要一步步排查。我當(dāng)時(shí)的步驟是這樣的第一步打開慢查詢?nèi)罩敬_認(rèn)哪些SQL耗時(shí)最長(zhǎng)。結(jié)果顯示訂單主表的查詢走了全表掃描沒有命中索引。第二步用EXPLAIN看執(zhí)行計(jì)劃發(fā)現(xiàn)組合索引沒建對(duì)——查詢條件是user_id排序條件是create_time恰好需要一個(gè)聯(lián)合索引覆蓋這兩種場(chǎng)景。第三步排查其余N次循環(huán)查詢發(fā)現(xiàn)每一次都是單條記錄的主鍵查詢耗時(shí)不高但20次循環(huán)累加起來網(wǎng)絡(luò)往返和SQL解析的開銷占了總耗時(shí)的一半。第四步查看接口的調(diào)用鏈數(shù)據(jù)確認(rèn)耗時(shí)分布數(shù)據(jù)庫(kù)查詢占了70%剩余30%在JSON序列化和網(wǎng)絡(luò)傳輸。很多人走到第一步就停了加個(gè)索引完事。但后面這幾步才決定你能走多遠(yuǎn)。3.2 針對(duì)性的優(yōu)化方案索引、批量、緩存三層疊加定位問題之后我用了三層方案來解決第一層索引優(yōu)化。ALTER TABLE t_order ADD INDEX idx_user_create (user_id, create_time DESC);注意這里不只是加個(gè)user_id索引而是讓索引同時(shí)覆蓋過濾條件和排序條件省掉一次filesort。第二層批量查詢替代循環(huán)查詢。把“循環(huán)查明細(xì)”改成“一次IN查詢?nèi)棵骷?xì)”在應(yīng)用層做內(nèi)存歸組SELECT * FROM t_order_item WHERE order_id IN (?, ?, ?, ...);然后按order_id分組成Map再按訂單組裝數(shù)據(jù)。這步改完接口從6秒降到了1秒左右——注意這一步的收益比加索引還大因?yàn)槭〉舻氖?9次網(wǎng)絡(luò)往返。第三層緩存熱點(diǎn)數(shù)據(jù)。商品名稱、店鋪名這類“讀多寫少”的數(shù)據(jù)用一個(gè)本地緩存Caffeine或者Redis緩存扛起來。這步做完接口平均響應(yīng)時(shí)間在200毫秒以內(nèi)。這個(gè)案例想說明的不是技術(shù)高深而是分析思路的遞進(jìn)先是數(shù)據(jù)庫(kù)層的索引與查詢優(yōu)化再是應(yīng)用層的批量化改造最后才是緩存兜底。順序很重要——如果你一上來就上緩存數(shù)據(jù)庫(kù)層面的浪費(fèi)依然存在只是被掩蓋了。3.3 從這個(gè)案例能學(xué)到的三個(gè)通用套路這套排查思路不是只適用于訂單查詢換成任何“列表接口慢”的場(chǎng)景都通用先確認(rèn)數(shù)據(jù)量表有多少行條件有沒有索引執(zhí)行計(jì)劃怎么走再確認(rèn)訪問模式是單條訪問還是批量訪問能不能一次查完最后確認(rèn)數(shù)據(jù)特性數(shù)據(jù)會(huì)不會(huì)頻繁變化能不能緩存緩存失效的一致性能不能忍我后來帶團(tuán)隊(duì)時(shí)給組里定了一個(gè)規(guī)矩任何接口優(yōu)化先寫清楚“當(dāng)前耗時(shí)分布”再動(dòng)手。很多人一上來就改代碼結(jié)果改了半天瓶頸在數(shù)據(jù)庫(kù)完全白費(fèi)功夫。4. 學(xué)習(xí)路線落地半年時(shí)間從CRUD到能獨(dú)立扛模塊4.1 前3個(gè)月深入“當(dāng)前工作”的技術(shù)棧很多人提升技術(shù)的誤區(qū)是工作里用不上就抽空學(xué)學(xué)學(xué)的和用的完全脫節(jié)。最后結(jié)果是學(xué)的忘得快用的沒長(zhǎng)進(jìn)。我的建議是前3個(gè)月先把工作里正在用的東西挖透。如果你在用MyBatis-Plus別只調(diào)用BaseMapper提供的方法去翻一遍它的條件構(gòu)造器實(shí)現(xiàn)理解它在底層怎么生成SQL。如果你在用Spring Boot別只寫Controller去搞懂自動(dòng)裝配原理、條件注解、Bean的生命周期。如果你在用MySQL別只寫SQL去了解InnoDB的索引結(jié)構(gòu)、事務(wù)隔離級(jí)別、鎖機(jī)制。這個(gè)階段有一個(gè)標(biāo)志性能力你負(fù)責(zé)模塊里的任何一個(gè)配置項(xiàng)都能解釋清楚它是干什么的、它的默認(rèn)值是什么、改它會(huì)影響什么。有一個(gè)很實(shí)用的訓(xùn)練方法挑一個(gè)你天天用的注解比如Transactional寫一個(gè)Demo程序把“同一個(gè)類內(nèi)部調(diào)用事務(wù)失效”的坑重現(xiàn)一遍然后看源碼確認(rèn)原因。這種“從坑反推源碼”的學(xué)習(xí)方式效率比泛泛讀書高得多。類似的還有Autowired和Resource的區(qū)別、ArrayList和LinkedList在不同場(chǎng)景下的性能差異都是可以親手實(shí)驗(yàn)的小課題。4.2 第4到第6個(gè)月橫向擴(kuò)展中間件與實(shí)踐項(xiàng)目深入到一定程度后就需要橫向擴(kuò)展了。這時(shí)候不要“從入門到精通”式地看視頻要帶著問題做小項(xiàng)目。我當(dāng)時(shí)給自己布置了一個(gè)練手項(xiàng)目模擬一個(gè)“秒殺系統(tǒng)”的簡(jiǎn)化版。這個(gè)項(xiàng)目麻雀雖小但能把周邊中間件全串起來Redis緩存商品庫(kù)存用Lua腳本保證扣減原子性。消息隊(duì)列做異步下單削峰填谷。數(shù)據(jù)庫(kù)層做庫(kù)存扣減的樂觀鎖控制。用JMeter壓測(cè)觀察TPS和響應(yīng)時(shí)間的變化。這個(gè)項(xiàng)目做完我相當(dāng)于把緩存、消息、并發(fā)控制、性能壓測(cè)都過了一遍。期間遇到的很多問題比任何教程都值錢——比如緩存和數(shù)據(jù)庫(kù)一致性問題、消息重復(fù)消費(fèi)問題、超賣問題。這些問題面試官愛問工作中也一定會(huì)遇到。應(yīng)該再?gòu)?qiáng)調(diào)一下一定要用項(xiàng)目帶動(dòng)學(xué)習(xí)不要干看視頻。你可以從改自己的業(yè)務(wù)系統(tǒng)開始比如把某個(gè)定時(shí)任務(wù)改成基于消息隊(duì)列的延遲消息或者給現(xiàn)有模塊加一個(gè)緩存查詢功能。沒有條件就給自己造一個(gè)條件。4.3 復(fù)盤與輸出把知識(shí)講出來才算真的學(xué)會(huì)最后一個(gè)建議也是我自己堅(jiān)持最久的一個(gè)習(xí)慣每學(xué)會(huì)一個(gè)知識(shí)點(diǎn)就用“費(fèi)曼技巧”輸出一次。不看筆記把一個(gè)技術(shù)點(diǎn)講給一個(gè)完全不懂的人聽講到他能聽懂為止。講不清楚的地方就是你還沒真正理解的地方??梢詫懠夹g(shù)筆記、畫流程圖、錄短視頻都行但核心是輸出。我見過太多人“看了很多書、收藏了很多文章”問他學(xué)到了什么說不出三句話。這不是笨是缺少輸出倒逼輸入的過程。我自己的做法是每完成一個(gè)小項(xiàng)目或者搞懂一個(gè)疑難問題就寫一篇復(fù)盤筆記。筆記里必須包含三部分遇到的問題是什么。排查過程是什么重點(diǎn)記錄中間走過的彎路。最終結(jié)論和后續(xù)可以優(yōu)化的點(diǎn)。這個(gè)習(xí)慣堅(jiān)持一年半之后的效果是看問題的方式完全變了。從“這個(gè)代碼怎么寫”變成了“這個(gè)模塊該怎么設(shè)計(jì)”這個(gè)轉(zhuǎn)變就是技術(shù)分水嶺。5. 常見問題與避坑指南這些坑我先踩了告訴你省點(diǎn)時(shí)間5.1 學(xué)習(xí)計(jì)劃總是半途而廢怎么辦這是被問得最多的一個(gè)問題。我的答案是把學(xué)習(xí)目標(biāo)定得小而具體不要定“三個(gè)月學(xué)完Redis”這種抽象目標(biāo)要定“本周用Redis實(shí)現(xiàn)一個(gè)分布式鎖并寫一篇筆記”這種可交付目標(biāo)。人的惰性無法消滅只能靠降低啟動(dòng)成本來對(duì)抗。每天下班后打開一本書的啟動(dòng)成本遠(yuǎn)大于打開一個(gè)已經(jīng)寫了一半的Demo項(xiàng)目。所以我的建議是工作日不要安排大塊學(xué)習(xí)時(shí)間只安排1小時(shí)的“動(dòng)手項(xiàng)目時(shí)間”周末再安排整塊時(shí)間看原理性書籍。5.2 學(xué)了原理覺得沒用記不住怎么辦原理性知識(shí)在工作里確實(shí)不是每天都用得上但它在關(guān)鍵時(shí)刻決定你能不能解決問題。比如一次線上事故某服務(wù)頻繁Full GC如果是只看現(xiàn)象去重啟機(jī)器的人永遠(yuǎn)找不到根因而懂JVM的人會(huì)先Dump堆內(nèi)存、分析對(duì)象引用鏈定位到是某個(gè)大List沒有釋放。有些知識(shí)學(xué)了不顯眼但真到關(guān)鍵問題就是你手里的工具。學(xué)的時(shí)候可能用不上但需要的時(shí)候你手里沒有就完了。5.3 公司項(xiàng)目老技術(shù)棧舊怎么提升先說結(jié)論技術(shù)棧舊不代表沒有提升空間。我在前面提過的某系統(tǒng)用的是一個(gè)很老的技術(shù)棧但這種系統(tǒng)里充滿了歷史遺留問題慢SQL、無索引表、重復(fù)代碼、循環(huán)調(diào)用、事務(wù)過長(zhǎng)。這些問題的優(yōu)化空間極大而且都是通用能力。你完全可以主動(dòng)說一句“這塊代碼我來優(yōu)化一下?!卑岩粋€(gè)慢接口從6秒優(yōu)化到200毫秒這個(gè)經(jīng)驗(yàn)放進(jìn)簡(jiǎn)歷里比“看過某框架源碼”有說服力得多。公司項(xiàng)目老反而給了你一個(gè)練手的實(shí)戰(zhàn)環(huán)境——幾乎每個(gè)老項(xiàng)目都有一堆能優(yōu)化的點(diǎn)。5.4 需不需要報(bào)班、買課、刷面試題我個(gè)人不反對(duì)付費(fèi)課程但有一個(gè)前提課程必須配合自己的項(xiàng)目實(shí)踐不能只聽。聽課的輸入效率看起來很高但缺少“動(dòng)手踩坑”的環(huán)節(jié)知識(shí)很難內(nèi)化。刷面試題更是本末倒置不該以“記住答案”為目標(biāo)而應(yīng)該以“能現(xiàn)場(chǎng)推導(dǎo)”為目標(biāo)。5.5 一個(gè)人學(xué)習(xí)堅(jiān)持不下去怎么辦找到同路人??梢允枪镜耐乱部梢允羌夹g(shù)社群里認(rèn)識(shí)的同行。我之前和一個(gè)同事組了個(gè)“每周互考小組”周五下午互相給對(duì)方出三個(gè)本周學(xué)習(xí)內(nèi)容相關(guān)問題答不上來就回去翻書。這種輕微的外部壓力效果比定鬧鐘好得多。6. 最后再說點(diǎn)實(shí)在話技術(shù)提升的本質(zhì)是解決問題從只會(huì)寫CRUD到能獨(dú)立優(yōu)化一個(gè)接口、設(shè)計(jì)一個(gè)模塊這個(gè)變化靠的不是某個(gè)“武林秘籍”而是一整套思維方式的轉(zhuǎn)變從“這個(gè)功能怎么寫”變成“這個(gè)功能為什么這么設(shè)計(jì)”從“用輪子”變成“理解輪子”從“做需求”變成“扛結(jié)果”。哪怕你現(xiàn)在每天都還在寫增刪改查只要每次動(dòng)手之前多想一句“這里為什么這樣做”每次寫完代碼之后多看一眼“底層到底發(fā)生了什么”進(jìn)步就已經(jīng)開始了。技術(shù)債不是一天欠下的技術(shù)能力也不是一天練成的但開始練的那一刻永遠(yuǎn)不晚。對(duì)我個(gè)人而言讓我走出CRUD狀態(tài)的轉(zhuǎn)折點(diǎn)就是某天在排查一個(gè)慢接口時(shí)發(fā)現(xiàn)自己不再滿足于加一個(gè)索引收工而是想把整個(gè)鏈路的每一個(gè)環(huán)節(jié)都弄清楚。希望你也能找到那個(gè)瞬間。