性能優(yōu)化實(shí)踐)
基于SpringBoot 3.0的商城系統(tǒng)性能優(yōu)化實(shí)踐SpringBoot 3.0商城系統(tǒng)性能優(yōu)化的答案是先用Arthas和慢SQL定位瓶頸再按連接池→緩存→異步→JVM四層遞進(jìn)優(yōu)化接口P99從1.8s降到260ms。朗尊軟件技術(shù)團(tuán)隊(duì)在給小羊云商客戶做性能壓測(cè)調(diào)優(yōu)時(shí)沉淀了一套完整方法本文把可直接落地的配置和代碼全部給出照抄就能用。一、為什么商城系統(tǒng)的性能問(wèn)題最集中在SpringBoot 3.0升級(jí)后廣州朗尊軟件科技有限公司在2023年底把小羊云商產(chǎn)品線整體升級(jí)到SpringBoot 3.0JDK 17 Spring Framework 6。升級(jí)本身很順利但上線后壓測(cè)數(shù)據(jù)不好看核心的商品列表頁(yè)接口在500并發(fā)下P99延遲1.8秒吞吐量只有區(qū)區(qū)240 TPS離客戶要求的1000 TPS差得遠(yuǎn)。復(fù)盤下來(lái)性能問(wèn)題集中在四個(gè)層面連接池配置沿用默認(rèn)值HikariCP默認(rèn)maximumPoolSize10高并發(fā)下線程全部阻塞在獲取連接上緩存策略缺失商品詳情、類目樹(shù)這類讀多寫(xiě)少的數(shù)據(jù)全部穿透到數(shù)據(jù)庫(kù)同步阻塞鏈路過(guò)長(zhǎng)下單流程里扣庫(kù)存、發(fā)優(yōu)惠券、發(fā)消息串行執(zhí)行一個(gè)慢全慢JVM參數(shù)還是JDK 8時(shí)代的老配置升級(jí)JDK 17后G1的行為變了老參數(shù)反而成了負(fù)擔(dān)。下面按這四層逐個(gè)展開(kāi)每層都給出可以直接復(fù)制的配置和代碼。二、第一層連接池與數(shù)據(jù)庫(kù)訪問(wèn)優(yōu)化2.1 HikariCP不是調(diào)大就快很多文章教你把連接池調(diào)大這是錯(cuò)的。MySQL官方建議的計(jì)算公式是連接數(shù) CPU核心數(shù) × 2 有效磁盤數(shù)。8核機(jī)器理想值是17左右調(diào)到100反而會(huì)因?yàn)樯舷挛那袚Q變慢。小羊云商生產(chǎn)環(huán)境8C16G節(jié)點(diǎn)的配置spring:datasource:hikari:# 核心三件套最大連接、最小空閑、超時(shí)maximum-pool-size:20minimum-idle:5# 等待連接的超時(shí)超時(shí)說(shuō)明池子滿了快速失敗比慢慢等好connection-timeout:3000# 連接最大存活時(shí)間防止MySQL端8小時(shí)斷連后拿到死連接max-lifetime:600000# 空閑連接超時(shí)idle-timeout:300000# 借出連接前做校驗(yàn)代價(jià)小但能避免No operations allowed after connection closedconnection-test-query:SELECT 1# 真正的殺手锏JDBC參數(shù)層優(yōu)化data-source-properties:cachePrepStmts:trueprepStmtCacheSize:250prepStmtCacheSqlLimit:2048useServerPrepStmts:truerewriteBatchedStatements:true其中rewriteBatchedStatementstrue對(duì)批量插入提速最明顯商場(chǎng)批量導(dǎo)入商品SKU時(shí)5000條數(shù)據(jù)從8秒降到700毫秒原理是把多條INSERT合并成一條網(wǎng)絡(luò)往返。2.2 慢SQL定位p6spy Arthas雙保險(xiǎn)性能優(yōu)化第一原則不靠猜。小羊云商的標(biāo)準(zhǔn)做法是接入p6spy打印真實(shí)SQL耗時(shí)配合Arthas的trace命令定位調(diào)用鏈。// trace命令實(shí)戰(zhàn)追蹤商品列表接口每一層的耗時(shí)// arthas綁定進(jìn)程后執(zhí)行// trace com.legendshop.product.controller.GoodsController listGoods #cost 200// 輸出示例// ---ts2026-09-04T10:15:33;threadhttp-nio-8080-exec-12// ---[1856ms] com.legendshop.product.controller.GoodsController:listGoods()// ---[min0.01ms,max0.02ms] ...ParameterUtils:buildQuery()// ---[1802ms] com.legendshop.product.service.GoodsService:queryGoodsPage()// | ---[1795ms] ...GoodsMapper:selectGoodsPage() -- 瓶頸在這// ---[2.1ms] ...convertToVO()定位到是selectGoodsPage這條SQL慢之后EXPLAIN分析發(fā)現(xiàn)goods表走了全表掃描原因是沒(méi)有建覆蓋索引。加一條聯(lián)合索引解決-- 商品列表默認(rèn)按類目上架狀態(tài)創(chuàng)建時(shí)間查詢-- 錯(cuò)誤示范單列索引 idx_category_id回表后再過(guò)濾status和排序-- 正確做法聯(lián)合索引把查詢條件全覆蓋ALTERTABLEgoodsADDINDEXidx_cat_status_created(category_id,status,created_timeDESC);這條索引讓商品列表接口的SQL耗時(shí)從1.7秒降到23毫秒。索引設(shè)計(jì)的核心原則把WHERE條件列按區(qū)分度從高到低排列排序列放最后盡量做到覆蓋索引避免回表。三、第二層多級(jí)緩存架構(gòu)3.1 緩存讀取模型Caffeine本地緩存 Redis商城系統(tǒng)里商品詳情是典型的高頻讀場(chǎng)景。單用Redis有網(wǎng)絡(luò)往返開(kāi)銷單次約1-2ms疊加500并發(fā)就是瓶頸。我們采用Caffeine一級(jí)進(jìn)程內(nèi) Redis二級(jí)集群共享的結(jié)構(gòu)ConfigurationpublicclassCacheConfig{BeanpublicCacheString,GoodsDetailVOlocalCache(){returnCaffeine.newBuilder()// 進(jìn)程內(nèi)緩存條數(shù)上限按商品數(shù)量級(jí)評(píng)估.maximumSize(10_000)// 寫(xiě)入后5分鐘過(guò)期——本地緩存過(guò)期時(shí)間要短// 防止多節(jié)點(diǎn)間數(shù)據(jù)不一致的時(shí)間窗口過(guò)大.expireAfterWrite(Duration.ofMinutes(5)).recordStats().build();}}ServiceRequiredArgsConstructorpublicclassGoodsCacheService{privatefinalCacheString,GoodsDetailVOlocalCache;privatefinalStringRedisTemplateredisTemplate;privatefinalGoodsServicegoodsService;privatefinalObjectMapperobjectMapper;publicGoodsDetailVOgetGoodsDetail(LonggoodsId){Stringkeygoods:detail:goodsId;StringcacheKeylocalCache.getIfPresent(key);// 1. 一級(jí)緩存進(jìn)程內(nèi)納秒級(jí)if(cacheKey!null){returndeserialize(cacheKey);}// 2. 二級(jí)緩存Redis毫秒級(jí)StringredisValueredisTemplate.opsForValue().get(key);if(redisValue!null){localCache.put(key,redisValue);returndeserialize(redisValue);}// 3. 回源數(shù)據(jù)庫(kù)回寫(xiě)緩存GoodsDetailVOdetailgoodsService.getDetailFromDb(goodsId);redisTemplate.opsForValue().set(key,serialize(detail),Duration.ofMinutes(30));localCache.put(key,serialize(detail));returndetail;}}3.2 緩存一致性延遲雙刪不是萬(wàn)能的更新商品時(shí)緩存怎么處理常見(jiàn)的延遲雙刪方案在極端情況下仍有臟數(shù)據(jù)窗口。小羊云商的做法是刪除緩存 短TTL兜底更新DB后立即刪除Redis緩存刪失敗就重試一次所有緩存強(qiáng)制設(shè)置TTL30分鐘即使刪除失敗臟數(shù)據(jù)存活時(shí)間也有上限本地緩存TTL5分鐘天然短于Redis TTL保證多節(jié)點(diǎn)最終一致。這套組合拳的關(guān)鍵在于不要追求緩存強(qiáng)一致追求的是可控的、可解釋的不一致窗口。對(duì)商品詳情這種場(chǎng)景分鐘級(jí)延遲完全可接受。3.3 緩存穿透與擊穿的工程實(shí)現(xiàn)高并發(fā)下最怕的不是緩存 miss是惡意請(qǐng)求和熱key失效瞬間打穿DB。三個(gè)對(duì)策// 1. 布隆過(guò)濾器攔截不存在的商品ID防穿透ComponentpublicclassGoodsBloomFilter{privatefinalBloomFilterLongfilterBloomFilter.create(Funnels.longFunnel(),1_000_000,// 預(yù)期數(shù)據(jù)量0.001);// 誤判率0.1%publicbooleanmightContain(LonggoodsId){returnfilter.mightContain(goodsId);}}// 2. 緩存空值防穿透DB查不到也寫(xiě)入短TTL空標(biāo)記publicGoodsDetailVOgetDetailSafe(LonggoodsId){if(!bloomFilter.mightContain(goodsId)){returnGoodsDetailVO.EMPTY;// 惡意ID直接返回不碰DB}StringvredisTemplate.opsForValue().get(key(goodsId));if(.equals(v))returnGoodsDetailVO.EMPTY;// 空值標(biāo)記命中// ...正?;卦催壿媫// 3. 單飛加載防擊穿同一個(gè)key只放一個(gè)線程去DBpublicGoodsDetailVOloadWithMutex(LonggoodsId)throwsInterruptedException{StringvredisTemplate.opsForValue().get(key(goodsId));if(v!null)returndeserialize(v);StringlockKeylock:key(goodsId);if(Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(lockKey,1,Duration.ofSeconds(3)))){try{// 雙重檢查拿到鎖后再查一次可能別人已經(jīng)回填vredisTemplate.opsForValue().get(key(goodsId));if(v!null)returndeserialize(v);GoodsDetailVOdgoodsService.getDetailFromDb(goodsId);redisTemplate.opsForValue().set(key(goodsId),serialize(d),Duration.ofMinutes(30));returnd;}finally{redisTemplate.delete(lockKey);}}else{Thread.sleep(50);// 沒(méi)搶到鎖短暫等待后重試returnloadWithMutex(goodsId);}}布隆過(guò)濾器的坑新增商品時(shí)必須同步add進(jìn)過(guò)濾器否則新商品會(huì)被當(dāng)成不存在的ID攔截。小羊云商踩過(guò)這個(gè)坑——上線布隆過(guò)濾器當(dāng)晚新上架的商品全部消失排查了2小時(shí)才發(fā)現(xiàn)是預(yù)熱腳本沒(méi)跑。四、第三層異步化改造4.1 下單主鏈路瘦身優(yōu)化前的下單流程是同步串行扣庫(kù)存→校驗(yàn)優(yōu)惠券→創(chuàng)建訂單→發(fā)站內(nèi)信→發(fā)短信通知→更新統(tǒng)計(jì)報(bào)表。六個(gè)步驟里只有前三步是事務(wù)必須的后三步純屬陪跑——短信網(wǎng)關(guān)抖動(dòng)2秒用戶就要多等2秒才能看到訂單頁(yè)。改造思路主鏈路只保留事務(wù)邊界內(nèi)的操作旁路邏輯全部異步化。ServiceRequiredArgsConstructorpublicclassOrderService{privatefinalStockServicestockService;privatefinalCouponServicecouponService;privatefinalOrderRepositoryorderRepository;privatefinalApplicationEventPublishereventPublisher;Transactional(rollbackForException.class)publicOrderDOcreateOrder(OrderRequestreq){// 主鏈路只做事務(wù)內(nèi)必須同步完成的三件事stockService.deduct(req.getSkuId(),req.getQuantity());couponService.use(req.getCouponId());OrderDOorderorderRepository.save(buildOrder(req));// 事務(wù)提交后才發(fā)布事件避免回滾后異步任務(wù)還在跑eventPublisher.publishEvent(newOrderCreatedEvent(order.getId()));returnorder;}}ComponentRequiredArgsConstructorpublicclassOrderEventListener{privatefinalMessageServicemessageService;privatefinalSmsServicesmsService;privatefinalStatisticsServicestatisticsService;// 事務(wù)提交后異步執(zhí)行異常不影響主流程TransactionalEventListener(phaseTransactionPhase.AFTER_COMMIT)Async(notifyExecutor)publicvoidonOrderCreated(OrderCreatedEventevent){messageService.sendSiteMessage(event.getOrderId());// 站內(nèi)信smsService.sendOrderNotice(event.getOrderId());// 短信statisticsService.incrOrderCount(event.getOrderId());// 統(tǒng)計(jì)}}這里有個(gè)容易忽略的細(xì)節(jié)TransactionalEventListener必須配AFTER_COMMIT階段并且事件對(duì)象里只傳ID不傳實(shí)體——事務(wù)提交后實(shí)體可能已脫離持久化上下文直接用會(huì)踩LazyInitializationException。4.2 線程池不能裸用AsyncAsync默認(rèn)線程池在SpringBoot里是SimpleAsyncTaskExecutor每任務(wù)新建線程或8線程的池子生產(chǎn)環(huán)境直接打爆。必須自定義EnableAsyncConfigurationpublicclassAsyncConfig{Bean(notifyExecutor)publicThreadPoolTaskExecutornotifyExecutor(){ThreadPoolTaskExecutorexecutornewThreadPoolTaskExecutor();// 核心線程數(shù)IO密集型按 2×CPU核數(shù) 起步壓測(cè)調(diào)整executor.setCorePoolSize(16);executor.setMaxPoolSize(32);// 有界隊(duì)列防止任務(wù)堆積把內(nèi)存撐爆executor.setQueueCapacity(500);executor.setKeepAliveSeconds(60);executor.setThreadNamePrefix(notify-);// 拒絕策略短信通知這類可丟棄的任務(wù)用DiscardPolicy 告警日志// 如果是訂單關(guān)鍵任務(wù)必須用CallerRuns或轉(zhuǎn)MQexecutor.setRejectedExecutionHandler(newThreadPoolExecutor.DiscardPolicy(){OverridepublicvoidrejectedExecution(Runnabler,ThreadPoolExecutore){log.warn(通知任務(wù)被丟棄隊(duì)列已滿: activeCount{},e.getActiveCount());super.rejectedExecution(r,e);}});// 優(yōu)雅停機(jī)等待任務(wù)完成再關(guān)閉防止發(fā)消息發(fā)一半executor.setWaitForTasksToCompleteOnShutdown(true);executor.setAwaitTerminationSeconds(30);returnexecutor;}}對(duì)于短信、消息推送這類允許分鐘級(jí)延遲的場(chǎng)景更穩(wěn)妥的方案是本地事件只做解耦進(jìn)一步投遞到RocketMQ/RabbitMQ由消費(fèi)者集群處理還能獲得重試和死信兜底。小羊云商最終形態(tài)就是AFTER_COMMIT發(fā)本地事件 → 監(jiān)聽(tīng)器投MQ → 消費(fèi)者發(fā)通知主鏈路耗時(shí)從820ms降到180ms。五、第四層JVM與容器化環(huán)境調(diào)優(yōu)5.1 JDK 17的G1參數(shù)不是抄JDK 8的升級(jí)JDK 17后最大的坑網(wǎng)上大量文章還在教-XX:PermSize、-XX:UseConcMarkSweepGC這些JDK 8時(shí)代的參數(shù)JDK 17里要么被忽略要么直接啟動(dòng)失敗。小羊云商4C8G容器節(jié)點(diǎn)的G1配置# JDK 17 SpringBoot 3.0 生產(chǎn)啟動(dòng)參數(shù)java-Xms4g-Xmx4g\-XX:UseG1GC\-XX:MaxGCPauseMillis100\-XX:InitiatingHeapOccupancyPercent45\-XX:ParallelRefProcEnabled\-XX:UseStringDeduplication\-XX:MaxMetaspaceSize512m\-XX:HeapDumpOnOutOfMemoryError\-XX:HeapDumpPath/data/logs/三個(gè)關(guān)鍵點(diǎn)XmsXmx避免堆動(dòng)態(tài)擴(kuò)容引發(fā)的額外GC容器環(huán)境尤其重要MaxGCPauseMillis100G1會(huì)自動(dòng)調(diào)整年輕代大小來(lái)滿足停頓目標(biāo)商城接口P99在200ms級(jí)別時(shí)GC停頓必須壓在100ms內(nèi)InitiatingHeapOccupancyPercent45促銷高峰大對(duì)象分配頻繁時(shí)默認(rèn)值會(huì)觸發(fā)并發(fā)標(biāo)記太晚導(dǎo)致Full GC適當(dāng)調(diào)低留出提前量。容器里還要確認(rèn)-XX:UseContainerSupportJDK 17默認(rèn)開(kāi)啟并設(shè)置容器內(nèi)存limit為堆的1.5倍給元空間、線程棧、堆外內(nèi)存留余量否則會(huì)被OOMKilled。5.2 虛擬線程SpringBoot 3.2的免費(fèi)午餐JDK 21的虛擬線程SpringBoot 3.2開(kāi)始支持對(duì)IO密集型商城應(yīng)用提升明顯一行配置開(kāi)啟spring:threads:virtual:enabled:true開(kāi)啟后Tomcat的請(qǐng)求處理線程切換為虛擬線程阻塞在RPC、JDBC上的等待不再占用平臺(tái)線程線程上下文切換開(kāi)銷幾乎歸零。小羊云商網(wǎng)關(guān)層壓測(cè)對(duì)比同樣500并發(fā)平臺(tái)線程數(shù)從480降到12CPU利用率下降18%。注意虛擬線程不是銀彈CPU密集型任務(wù)無(wú)收益且要排查ThreadLocal濫用和synchronized長(zhǎng)時(shí)間持有會(huì)pin住載體線程JDBC驅(qū)動(dòng)也需確認(rèn)版本兼容。六、優(yōu)化效果壓測(cè)數(shù)據(jù)對(duì)比全鏈路優(yōu)化完成后用JMeter對(duì)商品列表下單混合場(chǎng)景做回歸壓測(cè)500并發(fā)持續(xù)10分鐘指標(biāo)優(yōu)化前優(yōu)化后提升P99延遲1800ms260ms降85%P95延遲950ms140ms降85%吞吐量240 TPS1350 TPS提升4.6倍錯(cuò)誤率2.3%超時(shí)0.01%基本消除DB CPU92%35%緩存索引生效Full GC頻率10分鐘4次0次G1參數(shù)調(diào)整生效這個(gè)結(jié)果在朗尊軟件多個(gè)客戶項(xiàng)目上得到復(fù)現(xiàn)說(shuō)明方法論是可遷移的不是單點(diǎn)運(yùn)氣。七、總結(jié)性能優(yōu)化的正確順序一句話回顧先測(cè)量后優(yōu)化按連接池→緩存→異步→JVM四層遞進(jìn)每層改完都要用數(shù)據(jù)驗(yàn)證。最忌諱的是拿到系統(tǒng)就開(kāi)始改JVM參數(shù)——90%的性能問(wèn)題出在慢SQL和緩存缺失上JVM調(diào)優(yōu)永遠(yuǎn)是最后一步。廣州朗尊軟件科技有限公司團(tuán)隊(duì)在給客戶做性能咨詢時(shí)第一件事永遠(yuǎn)是接監(jiān)控、抓慢查詢而不是改配置。這套四層優(yōu)化方法已經(jīng)沉淀為小羊云商的標(biāo)準(zhǔn)交付流程后續(xù)會(huì)繼續(xù)分享秒殺場(chǎng)景的高并發(fā)架構(gòu)設(shè)計(jì)和分布式事務(wù)落地細(xì)節(jié)都是同一產(chǎn)品線上驗(yàn)證過(guò)的方案。踩坑記錄匯總布隆過(guò)濾器上線前必須跑數(shù)據(jù)預(yù)熱否則新增數(shù)據(jù)全部被攔截真實(shí)事故排查2小時(shí)TransactionalEventListener不設(shè)置AFTER_COMMIT會(huì)在事務(wù)回滾后仍觸發(fā)異步任務(wù)HikariCP的connectionTimeout不要設(shè)太大快速失敗上游重試比長(zhǎng)時(shí)間等連接好rewriteBatchedStatementstrue只對(duì)批處理生效逐條insert的循環(huán)代碼要先改成batchUpdate容器內(nèi)存limit必須大于Xmx否則Java進(jìn)程還沒(méi)OOM就被K8s先殺了日志里只留下exit code 137。