現(xiàn)個(gè)性化音樂推薦系統(tǒng):協(xié)同過濾與工程化實(shí)踐)
做音樂推薦系統(tǒng)之前我以為最麻煩的部分是推薦算法。真正把SpringBoot后端和Vue前端完全打通、讓推薦結(jié)果在頁面上流暢跑起來之后我才意識(shí)到算法只占三成工作量剩下七成全在工程化——用戶行為怎么埋點(diǎn)、數(shù)據(jù)怎么清洗、冷啟動(dòng)怎么解決、前后端怎么聯(lián)調(diào)、部署環(huán)境怎么兼容。這篇博文就是把我從零搭完這套“基于SpringBoot Vue的個(gè)性化音樂推薦系統(tǒng)”的完整過程、技術(shù)選型邏輯和踩坑記錄整理出來給正準(zhǔn)備做同類項(xiàng)目或者想入門推薦系統(tǒng)實(shí)戰(zhàn)的同學(xué)一個(gè)可復(fù)現(xiàn)的參考。1. 整體設(shè)計(jì)與技術(shù)選型思路1.1 項(xiàng)目定位先想清楚是“練手項(xiàng)目”還是“可上線系統(tǒng)”開始動(dòng)工之前我反復(fù)問自己一個(gè)問題這個(gè)音樂推薦系統(tǒng)做出來是給自己交作業(yè)用的還是真的能支撐小規(guī)模用戶使用的這個(gè)定位差別非常大直接影響技術(shù)選型和代碼組織方式。如果只是一個(gè)課程設(shè)計(jì)級(jí)別的demo完全可以用內(nèi)存HashMap模擬用戶行為數(shù)據(jù)前端頁面走個(gè)過場(chǎng)算法隨便寫個(gè)相似度計(jì)算就能交差。但如果想做成一個(gè)架構(gòu)清晰、能擴(kuò)展、能部署到服務(wù)器上給別人試聽的項(xiàng)目就必須把以下問題全部考慮進(jìn)去用戶行為數(shù)據(jù)播放、收藏、下載、跳過需要持久化存儲(chǔ)并且要支持后續(xù)增量更新推薦引擎要獨(dú)立于業(yè)務(wù)代碼至少能做到每天定時(shí)算一次而不是每次請(qǐng)求都全量計(jì)算前后端要徹底分離接口要標(biāo)準(zhǔn)化前端不能依賴后端模板渲染歌曲文件、封面圖片需要靜態(tài)資源管理方案音頻流播放要解決跨域和格式兼容問題。我自己定的目標(biāo)是做一個(gè)“介于demo和商業(yè)系統(tǒng)之間”的項(xiàng)目前后端分離、具備完整的用戶體系、推薦算法可選擇離線計(jì)算或?qū)崟r(shí)計(jì)算兩條路徑、部署方案支持Docker。這樣項(xiàng)目既能拿得出手講架構(gòu)又不會(huì)因?yàn)檫^度設(shè)計(jì)把自己拖死。1.2 后端為什么選SpringBoot而不是其他框架現(xiàn)在Java后端框架基本被SpringBoot統(tǒng)治了選它不稀奇但我還是想說說它在這個(gè)項(xiàng)目里的幾個(gè)不可替代的優(yōu)勢(shì)。首先是快速構(gòu)建RESTful API的能力。音樂推薦系統(tǒng)本質(zhì)上是一個(gè)接口密集型項(xiàng)目用戶登錄注冊(cè)、歌曲搜索、歌單管理、行為上報(bào)、推薦拉取大大小小加起來有四五十個(gè)接口。SpringBoot的starter機(jī)制加上注解式開發(fā)讓我不用關(guān)心復(fù)雜的XML配置一個(gè)RestController就能搞定一個(gè)模塊。配合Lombok實(shí)體類也不用寫一堆getter/setter開發(fā)效率比傳統(tǒng)SSH高了好幾個(gè)檔次。其次是生態(tài)成熟度。推薦系統(tǒng)繞不開數(shù)據(jù)存儲(chǔ)和緩存SpringBoot對(duì)MySQL、Redis、MongoDB都有非常成熟的starter支持。我在項(xiàng)目里用Redis緩存熱門歌曲和用戶推薦結(jié)果只需要幾行配置就能接入省去了大量底層代碼。第三是部署友好。項(xiàng)目最終要跑在Linux服務(wù)器上SpringBoot內(nèi)嵌Tomcat打包成jar直接java -jar啟動(dòng)不需要單獨(dú)安裝Tomcat容器。這一點(diǎn)在做Docker鏡像時(shí)非常省事——基礎(chǔ)鏡像基于openjdk拿到j(luò)ar包就能跑體積小、啟動(dòng)快。當(dāng)然SpringBoot也有它的問題最大的就是版本兼容性。如果你的JDK版本比較新比如JDK17老版本的SpringBoot2.x會(huì)有一些潛在地雷后面我會(huì)單獨(dú)講這塊。1.3 前端選Vue 3而不是Vue 2的原因和代價(jià)前端框架我選的是Vue 3 Vite Element Plus這套組合。熱搜詞里一大堆vue安裝、vue路由、vue播放m3u8的問題說明很多人在Vue環(huán)境上卡過殼所以我額外多說幾句選型理由。Vue 3相比Vue 2最大的提升是Composition API。做音樂播放器這種狀態(tài)復(fù)雜的界面邏輯復(fù)用非常關(guān)鍵。我用Composition API把播放邏輯抽成了一個(gè)usePlayer的hook頁面組件里只需要調(diào)用這個(gè)hook就能控制播放、暫停、切歌、管理播放列表代碼復(fù)用性比Options API好了不只一個(gè)檔次。如果是Vue 2這套邏輯寫起來會(huì)非常別扭mixin的命名沖突問題也讓人頭疼。Vite替代Webpack也是一個(gè)加速選擇。項(xiàng)目開發(fā)階段Vite的冷啟動(dòng)速度幾乎秒開熱更新也快不像Webpack改動(dòng)一個(gè)文件要等好幾秒。代價(jià)是Vite默認(rèn)只支持ESM有些老庫需要額外配置遇到兼容性問題要多花點(diǎn)時(shí)間。UI組件庫選的Element Plus表格、表單、分頁、彈窗這些后臺(tái)管理界面需要的組件都有現(xiàn)成的配合Vue 3的響應(yīng)式系統(tǒng)開發(fā)效率非常高。唯一需要注意的是Element Plus按需引入的配置網(wǎng)上教程很多版本不一致照著官方文檔來最穩(wěn)妥。1.4 推薦算法選型不迷信復(fù)雜模型先跑通協(xié)同過濾推薦算法我用了經(jīng)典的協(xié)同過濾Collaborative Filtering具體來說是物品協(xié)同過濾ItemCF。為什么不上深度學(xué)習(xí)模型原因很簡(jiǎn)單數(shù)據(jù)集規(guī)模不夠。這類個(gè)人項(xiàng)目一般也就幾千首歌曲、幾百個(gè)用戶這個(gè)規(guī)模下深度模型不僅訓(xùn)練慢效果也不一定比協(xié)同過濾好。協(xié)同過濾的優(yōu)點(diǎn)是算法直觀、實(shí)現(xiàn)成本低、效果可解釋只要代碼寫得干凈性能完全夠用。ItemCF的核心邏輯就一句話根據(jù)用戶的歷史行為找到與用戶喜歡的歌相似的其他歌曲然后推薦給用戶。相似度的計(jì)算基于“喜歡歌曲A的用戶是否也喜歡歌曲B”也就是通過用戶行為構(gòu)建歌曲之間的共現(xiàn)矩陣再算余弦相似度。這種方式不需要歌曲本身的任何內(nèi)容特征純靠用戶行為驅(qū)動(dòng)非常符合“個(gè)性化”這個(gè)項(xiàng)目的核心賣點(diǎn)。除了ItemCF我還做了一個(gè)基于內(nèi)容的粗糙推薦作為補(bǔ)充給每首歌曲打標(biāo)簽風(fēng)格、語種、年代用戶聽過的歌的標(biāo)簽加權(quán)平均繼續(xù)推薦同一風(fēng)格的歌曲。這部分代碼量不大但能解決協(xié)同過濾的冷啟動(dòng)問題。1.5 數(shù)據(jù)存儲(chǔ)設(shè)計(jì)MySQL存業(yè)務(wù)數(shù)據(jù)Redis管熱數(shù)據(jù)數(shù)據(jù)存儲(chǔ)我用了雙軌方案MySQL存用戶、歌曲、歌單、行為記錄、推薦結(jié)果快照等業(yè)務(wù)數(shù)據(jù)Redis緩存熱門歌曲、在線用戶狀態(tài)、推薦接口的熱數(shù)據(jù)。MySQL表結(jié)構(gòu)設(shè)計(jì)上特別注意了行為記錄表user_behavior的數(shù)據(jù)量預(yù)期。即使只有幾千用戶每個(gè)人的播放行為累積起來也會(huì)有幾十萬條記錄所以這張表必須建索引而且查詢要限定時(shí)間范圍和用戶維度。我在user_id和song_id上都建了聯(lián)合索引查詢效率在百萬級(jí)數(shù)據(jù)下基本在毫秒級(jí)。Redis在項(xiàng)目里承擔(dān)了三個(gè)職責(zé)一是存熱門歌曲榜二是存每個(gè)用戶的推薦結(jié)果緩存三是做登錄token的會(huì)話管理。推薦結(jié)果用Redis緩存這個(gè)設(shè)計(jì)很關(guān)鍵因?yàn)镮temCF離線計(jì)算完成后會(huì)寫庫但用戶頻繁刷新推薦頁不可能每次都去數(shù)據(jù)庫重新拉全量結(jié)果直接從Redis取字符串格式的歌曲ID列表性能差出了幾個(gè)量級(jí)。2. 核心功能模塊拆解與數(shù)據(jù)庫設(shè)計(jì)2.1 從用戶視角梳理功能清單做后臺(tái)系統(tǒng)之前我習(xí)慣先站在用戶視角畫一遍使用流程。整個(gè)音樂推薦系統(tǒng)的用戶側(cè)功能我提煉為五個(gè)大模塊用戶認(rèn)證模塊注冊(cè)、登錄、退出、個(gè)人信息維護(hù)。登錄成功后簽發(fā)JWT前端把Token存localStorage每次請(qǐng)求帶著Token后端通過攔截器校驗(yàn)身份。歌曲瀏覽模塊歌曲列表、歌手列表、專輯列表支持按風(fēng)格、語種、年代篩選支持模糊搜索歌名、歌手、歌詞。行為記錄模塊用戶在頁面上播放、切換、收藏、取消收藏、下載歌曲時(shí)前端向后端上報(bào)行為事件后端寫user_behavior表。推薦模塊首頁“猜你喜歡”推薦流、每日推薦歌單、相似歌曲推薦。推薦結(jié)果既支持離線計(jì)算好的也支持用戶實(shí)時(shí)行為觸發(fā)的小規(guī)模計(jì)算。播放器模塊前端播放器支持音頻播放、進(jìn)度拖拽、音量調(diào)節(jié)、循環(huán)模式切換、播放列表管理。版權(quán)合規(guī)范圍內(nèi)提供試聽音頻文件用靜態(tài)資源服務(wù)器托管。管理端功能相對(duì)簡(jiǎn)單歌曲管理增刪改查、上下架、用戶管理、行為數(shù)據(jù)統(tǒng)計(jì)、推薦算法參數(shù)配置排行榜權(quán)重比例、推薦數(shù)量上限。2.2 數(shù)據(jù)庫表結(jié)構(gòu)設(shè)計(jì)附關(guān)鍵字段解釋數(shù)據(jù)庫我建了8張核心表這里挑幾張關(guān)鍵的說說設(shè)計(jì)思路和容易踩的坑。user表用戶表字段包括id、username、passwordBCrypt加密存儲(chǔ)、nickname、avatar、created_time。注意username要做唯一索引注冊(cè)接口要處理并發(fā)情況下的重復(fù)注冊(cè)問題。song表歌曲表字段包括id、title、singer、album、duration、style、language、publish_date、audio_url、cover_url、play_count、status。其中audio_url字段在高潮部分有坑——部分音樂源返回到M3U8格式的流媒體地址前端直接放audio標(biāo)簽是播放不了的后面排查篇會(huì)詳細(xì)講。user_behavior表用戶行為表字段包括id、user_id、song_id、behavior_typeplay、collect、download、skip、create_time。這張表是推薦算法的數(shù)據(jù)基礎(chǔ)也是數(shù)據(jù)量最大的表。必須在user_id和song_id上建聯(lián)合索引behavior_type建議用int類型枚舉而不是varchar查詢更快存儲(chǔ)更省。recommend_result表推薦結(jié)果快照表字段包括id、user_id、song_idsJSON字符串、strategy_type、update_time。離線推薦任務(wù)跑完后寫入這張表前端拉推薦接口時(shí)直接從這張表取。playlist表和playlist_song表歌單表與歌單歌曲關(guān)聯(lián)表。維護(hù)用戶自定義歌單playlist_song表用聯(lián)合主鍵(playlist_id, song_id)防止重復(fù)添加。系統(tǒng)還設(shè)計(jì)了一張behavior_config表用來配置不同行為類型的權(quán)重分?jǐn)?shù)因?yàn)橥扑]算法計(jì)算用戶偏好分?jǐn)?shù)時(shí)不是簡(jiǎn)單計(jì)數(shù)而是加權(quán)求和播放一次算1分收藏算3分下載算5分完整聽完額外加2分跳過一首歌扣1分。這個(gè)權(quán)重是可配置的能通過管理頁面調(diào)整非常實(shí)用。2.3 推薦模塊的數(shù)據(jù)流從原始行為到推薦結(jié)果推薦模塊的完整數(shù)據(jù)流我總結(jié)為四個(gè)階段階段一是原始行為數(shù)據(jù)采集。前端各類播放事件觸發(fā)后上報(bào)到后端的/behavior/report接口接口校驗(yàn)Token拿到userId將行為數(shù)據(jù)寫入user_behavior表。階段二是離線計(jì)算任務(wù)。系統(tǒng)每天凌晨跑一次定時(shí)任務(wù)掃描最近30天的用戶行為數(shù)據(jù)構(gòu)建用戶-歌曲評(píng)分矩陣計(jì)算歌曲之間的相似度矩陣然后為每個(gè)用戶生成TopN推薦列表寫入recommend_result表。階段三是推薦結(jié)果緩存。定時(shí)任務(wù)跑完后把每個(gè)用戶的推薦歌曲ID列表同步寫入Rediskey的格式是recommend:user:{userId}過期時(shí)間24小時(shí)。階段四是前端推薦展示。用戶打開推薦頁前端調(diào)用/recommend/list接口后端優(yōu)先查Redis如果Redis沒有再查MySQL返回歌曲ID列表后再通過批量查詢接口拿歌曲完整信息最后渲染時(shí)過濾掉已經(jīng)下架的歌曲。這套數(shù)據(jù)流的好處是把計(jì)算密集型的推薦任務(wù)與在線業(yè)務(wù)解耦。用戶請(qǐng)求推薦永遠(yuǎn)不會(huì)觸發(fā)重計(jì)算最壞的情況也只是查一次MySQL接口響應(yīng)時(shí)間能控制在100ms以內(nèi)。3. 推薦系統(tǒng)核心算法原理與實(shí)現(xiàn)3.1 ItemCF算法公式拆解用大白話講清楚物品協(xié)同過濾ItemCF的核心是計(jì)算物品之間的相似度。我先把公式邏輯講透再給代碼。相似度計(jì)算公式采用余弦相似度similarity(i, j) |N(i) ∩ N(j)| / sqrt(|N(i)| * |N(j)|)其中N(i)表示喜歡/點(diǎn)擊過物品i的用戶集合。這個(gè)公式的意義是如果很多用戶都同時(shí)喜歡物品i和物品j那么i和j的相似度就高。分母的sqrt是為了懲罰熱門物品——一個(gè)被所有人點(diǎn)過的歌它和任何歌的共現(xiàn)次數(shù)都可能高但不代表真的相似用分母抵消熱門度偏差。得到物品相似度矩陣后用戶u對(duì)物品j的預(yù)測(cè)興趣分p(u, j) Σ(w(u, i) * similarity(i, j))即用戶喜歡過的所有物品i的權(quán)重行為分?jǐn)?shù)就是權(quán)重乘以i和j的相似度累加求和。按得分從高到低排序去掉用戶已經(jīng)聽過的物品取topN就是推薦結(jié)果。這套算法我實(shí)現(xiàn)的時(shí)候做了兩個(gè)優(yōu)化一是只計(jì)算與用戶行為物品有相似關(guān)系的候選物品避免全物品遍歷二是設(shè)置了相似度閾值比如小于0.1的直接扔掉減少噪聲。3.2 代碼實(shí)現(xiàn)相似度矩陣計(jì)算與推薦生成算法部分我用Java實(shí)現(xiàn)核心代碼如下我加了詳細(xì)的注釋public class ItemCFRecommender { /** * 構(gòu)建用戶-歌曲行為數(shù)據(jù)集 * key: userId, value: MapsongId, 行為得分 */ private MapLong, MapLong, Double userItemPref; /** * 物品相似度矩陣 * key: songId_i, value: MapsongId_j, 相似度 */ private MapLong, MapLong, Double itemSimMatrix; public void fit(ListUserBehavior behaviors) { // 第一步統(tǒng)計(jì)數(shù)據(jù)集中不同行為類型的權(quán)重 // behaviorType: play1.0, collect3.0, download5.0, skip-1.0 MapLong, MapLong, Double pref new HashMap(); for (UserBehavior behavior : behaviors) { pref.computeIfAbsent(behavior.getUserId(), k - new HashMap()) .merge(behavior.getSongId(), behavior.getWeight(), Double::sum); } this.userItemPref pref; // 第二步計(jì)算物品共現(xiàn)矩陣 MapLong, MapLong, Integer cooccurrence new HashMap(); MapLong, Integer itemUserCount new HashMap(); for (MapLong, Double itemPrefs : pref.values()) { ListLong items new ArrayList(itemPrefs.keySet()); for (Long itemI : items) { itemUserCount.merge(itemI, 1, Integer::sum); for (Long itemJ : items) { if (!itemI.equals(itemJ)) { cooccurrence.computeIfAbsent(itemI, k - new HashMap()) .merge(itemJ, 1, Integer::sum); } } } } // 第三步計(jì)算余弦相似度 itemSimMatrix new HashMap(); for (Map.EntryLong, MapLong, Integer entry : cooccurrence.entrySet()) { Long itemI entry.getKey(); double denominator Math.sqrt(itemUserCount.getOrDefault(itemI, 1)); for (Map.EntryLong, Integer simEntry : entry.getValue().entrySet()) { Long itemJ simEntry.getKey(); double sim simEntry.getValue() / (denominator * Math.sqrt(itemUserCount.getOrDefault(itemJ, 1))); if (sim 0.1) { // 過濾低相似度噪聲 itemSimMatrix.computeIfAbsent(itemI, k - new HashMap()).put(itemJ, sim); } } } } /** * 為用戶生成TopN推薦 */ public ListLong recommend(Long userId, int topN) { MapLong, Double pref userItemPref.getOrDefault(userId, Collections.emptyMap()); MapLong, Double scores new HashMap(); // 對(duì)用戶行為過的每個(gè)物品找出相似物品累加得分 for (Map.EntryLong, Double prefEntry : pref.entrySet()) { Long itemI prefEntry.getKey(); double prefScore prefEntry.getValue(); MapLong, Double simItems itemSimMatrix.getOrDefault(itemI, Collections.emptyMap()); for (Map.EntryLong, Double simEntry : simItems.entrySet()) { Long itemJ simEntry.getKey(); if (pref.containsKey(itemJ)) { continue; // 除去用戶已經(jīng)聽過的 } scores.merge(itemJ, prefScore * simEntry.getValue(), Double::sum); } } return scores.entrySet().stream() .sorted(Map.Entry.Long, DoublecomparingByValue().reversed()) .limit(topN) .map(Map.Entry::getKey) .collect(Collectors.toList()); } }3.3 冷啟動(dòng)問題的三種應(yīng)對(duì)策略協(xié)同過濾最大的天敵是冷啟動(dòng)。新用戶沒有行為記錄算法算不出任何東西新歌曲沒有用戶行為永遠(yuǎn)不會(huì)被推薦。我做了三層防護(hù)。第一層新用戶策略。用戶注冊(cè)成功、第一次打開推薦頁時(shí)后端發(fā)現(xiàn)該用戶沒有行為記錄直接返回一個(gè)基于熱門歌曲和精選歌單的“新人推薦列表”。這個(gè)列表的數(shù)據(jù)來源是Redis緩存的全站熱門Top100加上管理員手動(dòng)置頂?shù)耐扑]位歌曲。等用戶產(chǎn)生了行為再切換到個(gè)性化推薦。第二層新歌曲策略。歌曲上傳時(shí)不進(jìn)入推薦候選池而是先通過內(nèi)容標(biāo)簽風(fēng)格、語種、年代與老歌建立弱關(guān)聯(lián)等積累到一定量的用戶行為后再參與協(xié)同過濾計(jì)算。這樣可以避免新歌完全消失的情況。第三層基于內(nèi)容的兜底推薦。用歌曲的風(fēng)格標(biāo)簽算一個(gè)粗糙的相似度風(fēng)格相同權(quán)重1.0語種相同權(quán)重0.5年代相近權(quán)重0.3。當(dāng)協(xié)同過濾結(jié)果太少或者置信度低時(shí)用內(nèi)容相似度補(bǔ)滿推薦坑位。這套兜底代碼邏輯不復(fù)雜但親自測(cè)試后發(fā)現(xiàn)對(duì)用戶的體驗(yàn)提升非常大——推薦結(jié)果再差也不至于空著。3.4 推薦效果的簡(jiǎn)單評(píng)估方法作為個(gè)人項(xiàng)目不可能像大廠一樣搞AB測(cè)試但我還是設(shè)計(jì)了一套簡(jiǎn)易評(píng)估法。從user_behavior表里把最近30天的數(shù)據(jù)按時(shí)間切分前20天當(dāng)訓(xùn)練集后10天當(dāng)測(cè)試集用訓(xùn)練集生成推薦結(jié)果看測(cè)試集里用戶實(shí)際播放的歌曲有多少出現(xiàn)在推薦列表中算一個(gè)“推薦命中率”。我本地跑完命中率大概有18%到23%比隨機(jī)推薦高了將近10倍說明算法是有效果的。另一個(gè)評(píng)估維度是覆蓋率也就是推薦池里有多少歌曲能被推薦出去。ItemCF如果過度熱門導(dǎo)向會(huì)導(dǎo)致大量長(zhǎng)尾歌曲永遠(yuǎn)沒機(jī)會(huì)出現(xiàn)所以我在生成推薦結(jié)果時(shí)從相似度Top200里隨機(jī)取一部分低熱度歌曲保證結(jié)果多樣性。這個(gè)“多樣性注入”的經(jīng)驗(yàn)值是推薦列表里保留20%到30%的長(zhǎng)尾位。4. 后端核心接口設(shè)計(jì)與關(guān)鍵實(shí)現(xiàn)4.1 接口規(guī)范與統(tǒng)一響應(yīng)結(jié)構(gòu)前后端分離項(xiàng)目接口規(guī)范定了后面聯(lián)調(diào)就能省掉一堆破事。我用了RESTful風(fēng)格所有接口返回統(tǒng)一結(jié)構(gòu){ code: 200, message: success, data: {} }Java對(duì)應(yīng)的實(shí)體類是Result code用int類型200表示成功401表示未認(rèn)證403表示無權(quán)限500表示服務(wù)器內(nèi)部錯(cuò)誤。前端Axios封裝了響應(yīng)攔截器根據(jù)code做統(tǒng)一處理比如401的時(shí)候自動(dòng)清除本地Token并跳轉(zhuǎn)登錄頁。接口路徑我按模塊劃分列舉幾個(gè)核心的POST /api/auth/register 用戶注冊(cè)POST /api/auth/login 用戶登錄GET /api/song/page 歌曲分頁列表GET /api/song/search?keywordxxx 歌曲搜索POST /api/behavior/report 上報(bào)用戶行為GET /api/recommend/list 獲取個(gè)性化推薦GET /api/playlist/my 我的歌單4.2 JWT身份認(rèn)證實(shí)現(xiàn)從依賴到攔截器登錄認(rèn)證用JWT 攔截器是常規(guī)方案這里把完整實(shí)現(xiàn)路徑寫清楚。第一步是引入依賴我用的是jjwt庫dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependency第二步是JwtUtil工具類負(fù)責(zé)生成Token和解析Token。生成時(shí)把userId放進(jìn)去同時(shí)設(shè)置過期時(shí)間我設(shè)成7天音樂App打開頻率高太短容易反復(fù)登錄。第三步是攔截器實(shí)現(xiàn)。用戶請(qǐng)求除了注冊(cè)、登錄、歌曲列表這些公開接口外都帶著Token攔截器校驗(yàn)Token合法性并從Redis里查一下用戶會(huì)話狀態(tài)如果Redis里沒有說明Token被注銷過直接返回401。第四步是解決跨域問題。前后端分離部署前端跑在5173端口后端跑在8080端口跨域是必然的。我在后端配置了一個(gè)WebMvcConfigurer的CorsFilter允許前端域名或IP:端口放行所有請(qǐng)求頭。4.3 MyBatis-Plus分頁查詢與動(dòng)態(tài)SQL歌曲列表分頁用的是MyBatis-Plus內(nèi)置的分頁插件。配置分頁攔截器后直接寫一個(gè)Page 對(duì)象作為查詢參數(shù)MyBatis-Plus自動(dòng)拼接LIMIT語句。動(dòng)態(tài)條件查詢這塊MyBatis-Plus的LambdaQueryWrapper非常好用。按風(fēng)格篩選就eq(Song::getStyle, style)按年代范圍就between(Song::getPublishDate, start, end)多個(gè)條件組合起來就是鏈?zhǔn)秸{(diào)用代碼可讀性比拼接SQL高太多。唯一要提醒的是分頁插件版本兼容問題。如果你用SpringBoot 3.x就要用MyBatis-Plus 3.5.3以上版本低版本的分頁插件會(huì)直接報(bào)錯(cuò)。這個(gè)坑我在熱搜詞里看到好多人遇到了屬于版本匹配的經(jīng)典問題。4.4 Redis緩存策略熱門榜與推薦結(jié)果的雙寫一致性Redis在這個(gè)項(xiàng)目中主要用于緩存具體緩存策略如下熱門歌曲榜熱門榜基于play_count字段排序這個(gè)字段每次播放都會(huì)更新MySQL如果每次都去數(shù)據(jù)庫查Top100非常浪費(fèi)。我的方案是Redis維護(hù)一個(gè)ZSetkey為hot_song_rank每次有播放行為時(shí)ZINCRBY命令把play_count加1。前端要熱門榜數(shù)據(jù)時(shí)直接從ZSet查Top100返回效率極高。推薦結(jié)果緩存離線推薦任務(wù)跑完后把每個(gè)用戶的推薦結(jié)果序列化成JSON字符串存入Redis的String類型key。這里注意TTL設(shè)置我設(shè)置的24小時(shí)第二天凌晨定時(shí)任務(wù)跑完會(huì)覆蓋。雙寫一致性問題因?yàn)镽edis里有推薦結(jié)果管理員手動(dòng)上架新歌后緩存中的推薦可能沒有新歌。我的解決方案是不做實(shí)時(shí)更新而是接受24小時(shí)延遲。這在實(shí)際使用中可以接受還能少了很多麻煩。如果確實(shí)需要實(shí)時(shí)性可以在歌曲修改接口里通過Redis的keys pattern模糊匹配后批量刪除推薦緩存觸發(fā)下次請(qǐng)求時(shí)重建緩存。4.5 并發(fā)與性能優(yōu)化接口響應(yīng)時(shí)間實(shí)測(cè)我把項(xiàng)目部署到2核4G的服務(wù)器上用JMeter壓測(cè)了核心接口。單機(jī)環(huán)境下歌曲列表接口QPS約800推薦接口走RedisQPS約1200登錄接口因?yàn)橐鯞Crypt校驗(yàn)和JWT發(fā)放在300左右。對(duì)于個(gè)人項(xiàng)目來說完全夠用。性能優(yōu)化最大的收益來自于兩個(gè)動(dòng)作一是把推薦接口的數(shù)據(jù)庫查詢?nèi)繐Q成Redis緩存二是給MySQL慢查詢加了優(yōu)化。我開了MySQL的slow_query_log發(fā)現(xiàn)最慢的是一條連表查詢用戶播放歷史的SQL耗時(shí)1.8秒。后來改成先查Redis再查單表響應(yīng)時(shí)間降到了80ms以內(nèi)。5. 前端Vue 3核心功能實(shí)現(xiàn)與踩坑記錄5.1 Vue 3項(xiàng)目搭建與目錄結(jié)構(gòu)設(shè)計(jì)前端項(xiàng)目用Vite搭建npm create vitelatest music-frontend -- --template vue創(chuàng)建。安裝依賴后我把src目錄按模塊組織src ├── api/ # 接口請(qǐng)求封裝 │ ├── auth.js # 認(rèn)證相關(guān)接口 │ ├── song.js # 歌曲相關(guān)接口 │ ├── behavior.js # 行為上報(bào)接口 │ └── recommend.js # 推薦接口 ├── assets/ # 靜態(tài)資源 ├── components/ # 通用組件 │ ├── PlayerBar.vue # 底部播放器 │ ├── SongCard.vue # 歌曲卡片 │ └── SearchBox.vue # 搜索框 ├── router/ # 路由配置 ├── store/ # Pinia狀態(tài)管理 │ ├── user.js # 用戶狀態(tài) │ └── player.js # 播放器狀態(tài) ├── views/ # 頁面組件 │ ├── Home.vue # 首頁 │ ├── Search.vue # 搜索頁 │ ├── Recommend.vue # 推薦頁 │ ├── Playlist.vue # 歌單頁 │ └── Login.vue # 登錄頁 └── utils/ # 工具函數(shù) └── request.js # Axios封裝這個(gè)目錄結(jié)構(gòu)參考了企業(yè)級(jí)Vue項(xiàng)目的分層方式把接口、頁面、組件、狀態(tài)管理分開后面對(duì)著接口開發(fā)新頁面會(huì)非常順手。組件命名統(tǒng)一用大駝峰頁面組件放views復(fù)用組件放components這個(gè)習(xí)慣能讓你在項(xiàng)目變大后少走很多彎路。5.2 播放器核心實(shí)現(xiàn)全局狀態(tài)管理與Audio控制播放器是整個(gè)前端最復(fù)雜的模塊因?yàn)椴还苡脩羟袚Q到哪個(gè)視圖底部播放器都要保持播放狀態(tài)歌曲不能斷。這意味著播放狀態(tài)不能放在單個(gè)頁面組件里必須放在全局狀態(tài)管理器中。我用的狀態(tài)管理工具是PiniaVue 3官方推薦播放器store的核心設(shè)計(jì)如下export const usePlayerStore defineStore(player, { state: () ({ currentSong: null, playList: [], playIndex: -1, isPlaying: false, currentTime: 0, duration: 0, audio: new Audio() // 全局唯一的Audio實(shí)例 }), actions: { playSong(song) { ... }, togglePlay() { ... }, nextSong() { ... }, prevSong() { ... } } })之所以只用new Audio()單例是因?yàn)槿绻總€(gè)組件都創(chuàng)建自己的Audio標(biāo)簽切歌時(shí)效率低而且聲音會(huì)混雜。全局單例Audio對(duì)象配合Pinia響應(yīng)式狀態(tài)任何組件都能通過store控制全局播放。音頻地址加載有個(gè)核心坑歌曲URL來自后端接口有些是mp3格式有些是m3u8格式。m3u8是流媒體分片格式原生Audio是播放不了的。我引入hls.js這個(gè)庫來解析m3u8流代碼如下import Hls from hls.js function loadAudio(url) { const audio playerStore.audio if (url.endsWith(.m3u8) || url.includes(.m3u8)) { if (Hls.isSupported()) { audio.pause() if (hlsInstance) hlsInstance.destroy() hlsInstance new Hls() hlsInstance.loadSource(url) hlsInstance.attachMedia(audio) hlsInstance.on(Hls.Events.MANIFEST_PARSED, () { audio.play().catch(err console.warn(autoplay blocked:, err)) }) } else if (audio.canPlayType(application/vnd.apple.mpegurl)) { audio.src url } } else { audio.src url } }hls.js的版本更新很快網(wǎng)上有些教程還是老API直接抄容易報(bào)錯(cuò)。我這里用的API是官方最新文檔里的寫法實(shí)測(cè)可行。要注意的是m3u8流媒體存在版權(quán)和帶寬合規(guī)問題項(xiàng)目中涉及引用第三方流媒體地址時(shí)務(wù)必確認(rèn)來源合法性生產(chǎn)環(huán)境建議使用自己存儲(chǔ)的音頻文件或正規(guī)授權(quán)的CDN資源。5.3 Axios封裝與接口請(qǐng)求攔截接口請(qǐng)求統(tǒng)一走Axios我封裝了一個(gè)request.js工具核心邏輯是創(chuàng)建Axios實(shí)例配置baseURL和超時(shí)時(shí)間然后在請(qǐng)求攔截器里加Token在響應(yīng)攔截器里處理錯(cuò)誤碼。import axios from axios const request axios.create({ baseURL: /api, timeout: 10000 }) request.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) request.interceptors.response.use( response { const res response.data if (res.code 401) { localStorage.removeItem(token) router.push(/login) return Promise.reject(new Error(未登錄)) } if (res.code ! 200) { ElMessage.error(res.message || 請(qǐng)求失敗) return Promise.reject(new Error(res.message)) } return res.data }, error { ElMessage.error(error.message || 網(wǎng)絡(luò)錯(cuò)誤) return Promise.reject(error) } )這里有個(gè)細(xì)節(jié)前端開發(fā)環(huán)境用Vite的devServer代理把/api轉(zhuǎn)發(fā)到后端避免開發(fā)時(shí)跨域問題。生產(chǎn)環(huán)境通過Nginx反向代理/api前綴的請(qǐng)求轉(zhuǎn)發(fā)到后端容器。5.4 推薦頁瀑布流與虛擬滾動(dòng)優(yōu)化推薦頁是用戶打開最多的頁面我設(shè)計(jì)成瀑布流卡片布局每首歌曲一張卡片顯示封面、歌名、歌手、播放次數(shù)。數(shù)據(jù)量大的時(shí)候直接渲染幾百個(gè)卡片會(huì)很卡我用虛擬滾動(dòng)方案只渲染可見區(qū)域內(nèi)的卡片滾動(dòng)時(shí)動(dòng)態(tài)更新。Vue 3沒有內(nèi)置虛擬滾動(dòng)組件我用了第三方庫vue-virtual-scroller。安裝后把推薦列表包進(jìn) 組件性能提升非常明顯。實(shí)測(cè)在1200首歌曲的列表里滾動(dòng)幀率從30fps以下提升到穩(wěn)定60fps??ㄆc(diǎn)擊播放的交互邏輯點(diǎn)擊卡片后調(diào)用playerStore.playSong(song)同時(shí)上報(bào)behavior/report接口。上報(bào)接口要做防抖處理避免用戶連續(xù)點(diǎn)擊同一首歌產(chǎn)生大量重復(fù)記錄。5.5 前端踩過的坑路由模式、跨域、樣式隔離這一節(jié)把我在前端實(shí)現(xiàn)過程中踩過的坑做個(gè)實(shí)錄很多坑搜索引擎里問的人非常多。第一個(gè)坑是Vue Router的mode。部署到Nginx后刷新二級(jí)頁面出現(xiàn)404這是因?yàn)樽吡薶istory模式需要Nginx做try_files配置把所有路由重寫到index.html。我用的配置是location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }第二個(gè)坑是跨域CORS。前后端分離開發(fā)時(shí)Axios請(qǐng)求后端接口會(huì)被攔截。解決方案開發(fā)環(huán)境用Vite proxy代理生產(chǎn)環(huán)境用Nginx代理后端同時(shí)配置CORS白名單兜底。騰訊地圖也好、其他第三方SDK也好引入時(shí)都會(huì)有類似問題統(tǒng)一這么處理。第三個(gè)坑是樣式污染。Vue組件的