用戶管理子服務(wù)架構(gòu)設(shè)計與踩坑實踐:從表結(jié)構(gòu)到緩存策略)
做IM系統(tǒng)用戶管理子服務(wù)往往是看起來最簡單、坑卻最多的那一塊。很多人一開始會覺得無非就是注冊、登錄、改個昵稱、拉個好友列表有什么好寫的但真正上手做才發(fā)現(xiàn)一個IM系統(tǒng)里最基礎(chǔ)的用戶服務(wù)牽扯到數(shù)據(jù)一致性、在線狀態(tài)、增量同步、token安全、熱點緩存一堆問題任何一個環(huán)節(jié)沒設(shè)計好后面消息推送和會話模塊全都跟著遭殃。這篇文章把我在實際開發(fā)用戶管理子服務(wù)過程中涉及的架構(gòu)設(shè)計、表結(jié)構(gòu)、緩存策略、接口實現(xiàn)和踩坑實錄都整理出來適合正在做IM項目、或者準(zhǔn)備把用戶模塊獨(dú)立成微服務(wù)的朋友參考。1. 項目背景與總體設(shè)計思路1.1 IM系統(tǒng)里為什么用戶服務(wù)必須獨(dú)立拆出來很多單體項目里用戶模塊就是一個UserController加幾張表但在IM項目里用戶服務(wù)幾乎是整個系統(tǒng)的心臟。你想想看每一次消息發(fā)送都要查用戶關(guān)系、每一個會話列表都要拉取用戶資料、每一次好友操作都要變更關(guān)系鏈數(shù)據(jù)如果這些邏輯全部跟消息模塊耦合在一起后期幾乎無法維護(hù)。我參與的這個IM項目團(tuán)隊在一開始就確定了微服務(wù)拆分方向。用戶管理子服務(wù)作為最底層的基礎(chǔ)服務(wù)獨(dú)立負(fù)責(zé)賬號體系、用戶資料、好友關(guān)系、在線狀態(tài)和通信錄同步其他所有服務(wù)都在它之上工作。這樣的好處非常明顯首先是解耦消息服務(wù)只管消息推送服務(wù)只管推送用戶服務(wù)只管用戶出了問題各查各的日志其次是彈性伸縮比如做活動拉新的時候注冊量暴漲只需要單獨(dú)擴(kuò)容用戶服務(wù)不必把整個系統(tǒng)都帶起來再有就是故障隔離用戶服務(wù)掛了已經(jīng)在線的人還能繼續(xù)收發(fā)消息不會導(dǎo)致全盤崩潰。不過獨(dú)立拆服務(wù)也帶來一個必須正視的問題跨服務(wù)的用戶數(shù)據(jù)訪問怎么辦。不可能每個服務(wù)都直連用戶數(shù)據(jù)庫那樣連接數(shù)先撐不住。所以實際架構(gòu)里消息服務(wù)、會話服務(wù)都要通過用戶服務(wù)提供的接口獲取信息同時用Redis緩存扛住熱點流量。這也是這次項目架構(gòu)設(shè)計里反復(fù)權(quán)衡的一個點。1.2 用戶管理子服務(wù)的職責(zé)邊界先把這個服務(wù)到底管什么理清楚。我梳理下來用戶管理子服務(wù)大概覆蓋六個核心域賬號注冊與登錄、用戶資料管理昵稱、頭像、個性簽名、好友關(guān)系鏈管理添加、刪除、拉黑、在線狀態(tài)管理、用戶設(shè)置管理消息免打擾、加好友驗證方式、增量同步多端登錄時的資料同步。邊界之外的東西堅決不往里放。比如消息推送那是推送服務(wù)的事文件存儲那是對象存儲服務(wù)的事敏感詞過濾和審核那是內(nèi)容安全服務(wù)的事。這個邊界一開始就要在團(tuán)隊內(nèi)部達(dá)成共識不然每個人都在用戶服務(wù)里順手加接口最后變成一個“大雜燴”。尤其是頭像上傳這個功能最容易被放在用戶服務(wù)里。我的建議是頭像上傳盡量走獨(dú)立的文件服務(wù)用戶服務(wù)只保存一個頭像URL和更新版本號。道理很簡單文件上傳涉及大流量和存儲擴(kuò)容如果圖片流量把用戶服務(wù)的網(wǎng)絡(luò)帶寬占滿正常的查詢接口就直接變慢了。1.3 整體技術(shù)架構(gòu)與數(shù)據(jù)流向用戶管理子服務(wù)的技術(shù)棧選型比較常規(guī)但組合起來效果很穩(wěn)定。后端用的是Java棧Spring Boot對外提供接口內(nèi)部通過OpenFeign給其他微服務(wù)調(diào)用數(shù)據(jù)庫用MySQL存主數(shù)據(jù)Redis用來扛緩存和在線狀態(tài)消息中間件用的RocketMQ來處理注冊成功之后的異步事件比如初始化默認(rèn)設(shè)置、創(chuàng)建通信錄目錄、發(fā)送歡迎消息等。數(shù)據(jù)流向分兩條線。一條是寫鏈路客戶端請求進(jìn)來先經(jīng)過網(wǎng)關(guān)鑒權(quán)然后到用戶服務(wù)接口層落庫MySQL同時同步更新Redis緩存再發(fā)MQ事件給其他下游服務(wù)。另一條是讀鏈路優(yōu)先查Redis緩存沒命中的時候查MySQL然后回填緩存。這套讀寫分離加上緩存兜底的模式在百萬用戶級別撐住日常流量問題不大。有一個細(xì)節(jié)值得說用戶服務(wù)作為基礎(chǔ)服務(wù)很多時候不是面對最終用戶而是面對其他微服務(wù)。比如消息服務(wù)要判斷兩個用戶是不是好友關(guān)系、會話服務(wù)要拉取群成員的昵稱頭像這些內(nèi)部調(diào)用接口在設(shè)計時需要更高可用性不能因為某一瞬間的流量波峰就雪崩所以接口層做了統(tǒng)一熔斷和降級處理下游服務(wù)依賴用戶服務(wù)的時候也要做好容錯。2. 技術(shù)選型與數(shù)據(jù)模型設(shè)計2.1 存儲選型MySQL加Redis加對象存儲的搭配用戶數(shù)據(jù)的存儲架構(gòu)我最終定下來的是MySQL做持久化主存儲Redis做緩存和在線狀態(tài)存儲頭像和文件類的二進(jìn)制數(shù)據(jù)放在對象存儲里MySQL里只存元信息。把這個組合拆開說。MySQL的選擇幾乎沒什么懸念成熟穩(wěn)定事務(wù)支持好適合存儲用戶基礎(chǔ)資料和好友關(guān)系這類強(qiáng)一致性要求高的數(shù)據(jù)。表引擎統(tǒng)一用InnoDB支持行鎖和事務(wù)好友關(guān)系變更這種高頻操作不會有鎖表問題。Redis則是整個服務(wù)性能的主力注冊用戶資料、熱點用戶信息、好友關(guān)系列表、用戶在線狀態(tài)全都在Redis里有對應(yīng)緩存層。對象存儲解決的是頭像這類大文件的存儲問題下載走CDN數(shù)據(jù)庫不會因為靜態(tài)資源被打爆。很多人會問為什么不用MongoDB或者PostgreSQL代替MySQL說實話IM系統(tǒng)的用戶數(shù)據(jù)量沒有大到需要引入太復(fù)雜的存儲方案MySQL在互聯(lián)網(wǎng)行業(yè)的成熟運(yùn)維經(jīng)驗最豐富出問題能找到的解決方案也最多。技術(shù)上夠用就好不必為了新而新。2.2 核心表結(jié)構(gòu)設(shè)計用戶基礎(chǔ)表和好友關(guān)系表是用戶管理子服務(wù)最重要的兩張表設(shè)計上花了很多功夫。用戶基礎(chǔ)表第一版設(shè)計的時候字段盡量精簡核心就用戶ID、昵稱、頭像URL、性別、個性簽名、手機(jī)號或郵箱作為登錄憑證。有一個很容易踩的坑是用戶ID到底用自增ID還是雪花ID。IM系統(tǒng)里用戶ID會出現(xiàn)在消息內(nèi)容、分享鏈接、好友添加等對外場景用自增ID容易被遍歷抓取而且MySQL自增主鍵在分庫分表場景下會沖突。所以最終用戶ID用了雪花算法生成的長整型對外展示和內(nèi)部存儲都用同一個ID避免了兩套ID映射的麻煩。登錄憑證字段的設(shè)計也值得注意。手機(jī)號和郵箱都可以作為登錄憑證如果分開建兩個字段并都允許為空會出現(xiàn)一個用戶既有手機(jī)號又有郵箱時數(shù)據(jù)冗余的情況。實際實現(xiàn)時建了一張登錄憑證表用戶ID、憑證類型、憑證值三個字段一個用戶多條記錄靈活擴(kuò)展且查詢效率不差。好友關(guān)系表的細(xì)節(jié)更多。首先是雙向好友關(guān)系處理A加B為好友成功后關(guān)系鏈里同時存在A對B、B對A兩條記錄每條記錄都有一個狀態(tài)字段區(qū)分正常、已刪除、拉黑中。其次是好友備注名A給B備注的名字和B給A備注的名字可以不一樣這兩個字段必須分別保存不能只存一條關(guān)系一個備注名。最后是索引設(shè)計關(guān)系表上建了用戶ID加狀態(tài)的聯(lián)合索引查詢好友列表的語句走這個索引實測全表百萬級數(shù)據(jù)量下響應(yīng)時間穩(wěn)定在幾十毫秒。用戶設(shè)置表同樣是獨(dú)立的一張表消息免打擾開關(guān)、好友驗證方式允許任何人添加、需要驗證、不允許添加、被拉黑之后是否可見在線狀態(tài)等都放這張表里和用戶基礎(chǔ)表分開的好處是不會因為設(shè)置項頻繁更新導(dǎo)致基礎(chǔ)表的緩存頻繁失效。2.3 緩存設(shè)計Key規(guī)范與數(shù)據(jù)結(jié)構(gòu)選擇緩存設(shè)計直接決定用戶服務(wù)的查詢性能這塊的規(guī)范在項目里是強(qiáng)制執(zhí)行的。Redis緩存的Key命名統(tǒng)一采用“服務(wù)名:業(yè)務(wù)域:用戶ID:內(nèi)容類型”的格式比如用戶資料緩存是user:profile:10001好友關(guān)系緩存是user:friends:10001:list在線狀態(tài)是user:status:10001。統(tǒng)一規(guī)范看著是小事但等后面緩存幾十個業(yè)務(wù)域的時候排查問題和避免Key沖突會方便太多。數(shù)據(jù)結(jié)構(gòu)的選擇上用戶資料緩存用Hash比用JSON字符串更合適。Hash類型可以單獨(dú)修改某個字段而不需要整體重新序列化比如用戶只是改了個昵稱直接hset一個字段就夠了其他字段的緩存不受影響。我之前用JSON字符串存整個用戶資料用戶修改昵稱之后要對整個對象做讀寫和緩存更新在高頻更新場景下浪費(fèi)了不少不必要的序列化開銷。好友關(guān)系列表則用ZSet存儲把好友ID作為member成為好友的時間戳作為score這樣拉取好友列表天然按時間排序也能方便地做分頁。緩存過期時間也做過一個統(tǒng)一約定。用戶基礎(chǔ)資料這種變更頻率不高但又需要高可用的數(shù)據(jù)設(shè)置為24小時過期并主動更新雙寫在線狀態(tài)這種強(qiáng)時效數(shù)據(jù)不設(shè)置過期時間完全依賴狀態(tài)上報來覆蓋。項目里的原則是緩存必須有過期兜底防止極端情況下緩存和數(shù)據(jù)庫不一致時永遠(yuǎn)無法自愈。3. 核心接口與實操實現(xiàn)3.1 注冊流程服務(wù)端怎么防重復(fù)、防漏洞注冊接口是整個用戶服務(wù)流量最大的接口之一也是最容易被人搞的接口。我在實現(xiàn)注冊流程時把重點放在三個環(huán)節(jié)合法性校驗、防重復(fù)提交、數(shù)據(jù)一致性。客戶端傳入手機(jī)號和驗證碼之后先做格式校驗然后用驗證碼確認(rèn)手機(jī)號是真實在用的。服務(wù)端再根據(jù)手機(jī)號查登錄憑證表判斷這個手機(jī)號是否已經(jīng)注冊過這里要用唯一索引兜底因為哪怕代碼邏輯里判斷了不存在高并發(fā)下兩個人同時注冊同一個手機(jī)號還是可能插入兩條記錄唯一索引能保證數(shù)據(jù)庫層面不會出現(xiàn)臟數(shù)據(jù)。注冊時數(shù)據(jù)寫入用了事務(wù)同時更新用戶基礎(chǔ)表、登錄憑證表和用戶設(shè)置表任何一個操作失敗都會回滾。注冊成功之后把新用戶ID和手機(jī)號的映射寫入Redis方便后續(xù)登錄時快速反查同時發(fā)送一個注冊成功的MQ事件下游服務(wù)收到事件后初始化用戶通信錄目錄、發(fā)送好友推薦等。這里典型的坑是如果MQ發(fā)送失敗會導(dǎo)致下游服務(wù)收不到通知所以發(fā)送事件要失敗重試并記錄日志重試幾次仍然失敗就要走定時任務(wù)對賬補(bǔ)償。3.2 登錄與Token方案雙Token為什么是標(biāo)配登錄流程在IM項目里和普通網(wǎng)站登錄不一樣不只是為了拿到一個身份憑證更重要的是保證多端登錄互不影響、消息推送能定位到具體設(shè)備。每個設(shè)備獨(dú)立登錄所以登錄成功后生成的是設(shè)備級的會話憑證而不是用戶級。Token方案最終用了雙Token設(shè)計短期TokenAccess Token有效期兩小時用于實際接口調(diào)用的身份認(rèn)證長期TokenRefresh Token有效期三十天用于短期Token過期后無感刷新。客戶端拿Refresh Token換新的Access Token這樣即使用戶長期沒打開App只要三十天內(nèi)打開過一次就能自動續(xù)期體驗上不會出現(xiàn)頻繁要求重新登錄的情況。Access Token采用了JWT格式簽名密鑰單獨(dú)配置并且定期輪換JWT里只放用戶ID、設(shè)備ID、會話類型幾個必要字段不放任何敏感信息。Refresh Token則不是JWT而是服務(wù)端生成的隨機(jī)串存Redis設(shè)置有效期并關(guān)聯(lián)設(shè)備ID每次刷新使用后輪換一次舊Token作廢發(fā)新Token。之所以不對稱處理是因為JWT無法服務(wù)端主動作廢而Refresh Token需要支持踢人下線、強(qiáng)制注銷等操作必須能被服務(wù)端控制。登出操作要做的就是刪除Redis里的Refresh Token、清理設(shè)備在線狀態(tài)、發(fā)送下線通知給消息服務(wù)。這里有一個很容易被忽視的細(xì)節(jié)用戶賬號在別的設(shè)備上登錄或者被管理員封禁時需要做到強(qiáng)制下線那就得把所有設(shè)備對應(yīng)的Refresh Token全部刪除同時維護(hù)一個用戶維度的Token版本號存在Redis里Access Token校驗時會檢查這個版本號一旦版本號變了舊Access Token就算沒過期也直接失效。3.3 增量同步與在線狀態(tài)管理多端登錄場景下一個手機(jī)端的用戶改了昵稱另一個平板端要盡快看到更新。如果每次改完立刻通知所有在線端會制造大量推送如果完全依賴客戶端主動拉取數(shù)據(jù)又不及時。最終的方案是版本號加增量同步機(jī)制。用戶基礎(chǔ)表加一個數(shù)據(jù)版本號字段任何資料變更都讓版本號加一??蛻舳伺空埱笤隽客浇涌跁r帶上本地版本號服務(wù)端拉取更高級別的數(shù)據(jù)變化返回給客戶端。Redis里用一個ZSet保存用戶數(shù)據(jù)的版本歷史member存字段IDscore存版本號這樣斷線重連后只需要同步缺失的那一段版本不需要全量拉取傳輸數(shù)據(jù)量小受網(wǎng)絡(luò)波動影響也小。在線狀態(tài)管理的設(shè)計經(jīng)歷了兩次迭代。最初是用推模式用戶上線上線都通過消息服務(wù)實時推給所有好友這樣做的好處是好友用戶看到在線狀態(tài)最及時但代價是消息量大——一個千萬用戶級別的系統(tǒng)如果一個百萬粉絲用戶頻繁上下線光狀態(tài)推送就能把消息服務(wù)打爆。后來改成拉模式用戶在線狀態(tài)保存在Redis里狀態(tài)變更只寫Redis并通知自己的所有在線設(shè)備好友列表頁面加載或刷新時才主動拉取好友的在線狀態(tài)。這樣把推送的扇出量降下來了代價是狀態(tài)展示有延遲但對于絕大多數(shù)IM使用場景這個延遲完全能接受。在線狀態(tài)的數(shù)據(jù)模型用的是Redis StringKey是用戶IDValue是設(shè)備列表加最新心跳時間的JSON結(jié)構(gòu)客戶端每隔60秒發(fā)一次心跳服務(wù)端記錄心跳時間。判斷下線的邏輯是如果某個設(shè)備超過90秒沒有心跳認(rèn)為該設(shè)備離線從設(shè)備列表里移除一個用戶所有設(shè)備都離線了整個用戶才標(biāo)記為離線。心跳報文越頻繁狀態(tài)越精準(zhǔn)但服務(wù)端壓力越大60秒心跳加90秒超時這個參數(shù)組合在線上跑下來是性能和準(zhǔn)確率的平衡點。4. 常見問題與排查實錄4.1 用戶資料在不同端不一致改完昵稱之后手機(jī)端顯示新昵稱電腦端還是舊昵稱這個問題我們在灰度測試階段復(fù)現(xiàn)過很多次。排查下來無非兩種原因一種是某個端的本地緩存太久沒有過期沒有及時觸發(fā)增量同步另一種是服務(wù)端緩存更新失敗導(dǎo)致舊數(shù)據(jù)又返回給了客戶端。服務(wù)端緩存更新失敗這個原因比較隱蔽。當(dāng)時的實現(xiàn)是先更新MySQL再刪除緩存結(jié)果MySQL更新成功、刪除緩存時Redis剛好發(fā)生主從切換舊緩存沒被清掉后續(xù)讀請求又把舊數(shù)據(jù)回填了。解決方案是引入更新緩存時的版本號比對寫入緩存的用戶資料版本號如果小于當(dāng)前版本就丟棄同時修復(fù)了緩存刪除失敗的重試機(jī)制。排查這個問題時用了一個取巧的方法直接對比用戶在MySQL里的數(shù)據(jù)版本號和客戶端拿到的最新版本號版本號對不上順著鏈路查下去很快就能定位。4.2 登錄態(tài)突然失效用戶反映App用著用著突然被退出登錄這類問題的排查方向應(yīng)該放在Refresh Token的刷新流程上。最容易出問題的是并發(fā)刷新場景客戶端多個請求同時發(fā)現(xiàn)Access Token過期同時拿同一個Refresh Token去刷新后發(fā)起的刷新請求把先發(fā)起的舊Refresh Token覆蓋掉導(dǎo)致其中一個請求拿到一個已經(jīng)作廢的Token然后用戶被判定為異常登錄強(qiáng)制下線。解決方式也簡單Refresh Token的刷新接口要做并發(fā)控制同一個用戶同一個設(shè)備的刷新請求串行化處理Redis里用分布式鎖保證同一時間只有一個刷新請求在處理其他請求稍等一下復(fù)用同一個刷新結(jié)果就行。另外客戶端也做了配合在Token過期時全局只觸發(fā)一次刷新流程其他接口等待刷新完成后再重放業(yè)務(wù)請求從源頭避免了并發(fā)刷新的可能性。4.3 在線狀態(tài)誤判與抖動用戶明明在線好友那邊顯示離線或者剛顯示在線過一會又變灰了過幾分鐘又亮了。這種抖動的常見原因是客戶端網(wǎng)絡(luò)切換——比如WiFi切換到5G網(wǎng)絡(luò)時TCP連接斷開重建心跳包發(fā)出去了但中間有一個短暫的斷檔超過90秒超時閾值后服務(wù)端誤判離線。針對這個情況做了兩方面優(yōu)化一是客戶端心跳不依賴網(wǎng)絡(luò)連接狀態(tài)每次進(jìn)后臺和回前臺的時候主動補(bǔ)發(fā)一次心跳二是服務(wù)端收到心跳時如果發(fā)現(xiàn)用戶之前已被標(biāo)記為離線恢復(fù)在線狀態(tài)時不要立刻向好友端廣播上線通知而是延遲30秒等心跳穩(wěn)定后再廣播避免網(wǎng)絡(luò)切換導(dǎo)致的上線、下線通知風(fēng)暴。有一次線上觀察到某個用戶的好友端瘋狂收到上下線通知原因就是這個用戶在地鐵里頻繁進(jìn)出隧道導(dǎo)致網(wǎng)絡(luò)反復(fù)切換加上延遲廣播機(jī)制后這個現(xiàn)象基本消除。4.4 緩存穿透與熱點用戶問題緩存穿透問題在用戶服務(wù)里很容易被忽略因為大部分用戶數(shù)據(jù)是存在的但偏偏注冊、查詢陌生人資料這些場景會頻繁查一個不存在的用戶ID。攻擊者可以偽造一批不存在的用戶ID批量請求資料接口請求全部穿透到MySQL。解決方法很直接用戶查詢接口緩存空結(jié)果空值也緩存5分鐘同時用布隆過濾器做第一層攔截盡快把不存在的ID擋在前面。熱點用戶問題的典型場景是系統(tǒng)里某些達(dá)人賬號粉絲量特別大好友狀態(tài)被大量用戶同時輪詢一個ZSet緩存被高頻讀取單線程的Redis在這個Key上就成了瓶頸。臨時方案是熱點Key做多副本把同一個邏輯Key的數(shù)據(jù)復(fù)制到多個物理Key上讀請求通過hash分散到不同副本上能在不改變整體架構(gòu)的情況下把單Key壓力降下來。更長期的方案是把這個讀取邏輯移出同步鏈路走異步批量聚合接口一次查詢批量合并多個熱點用戶的資料返回減少客戶端單用戶逐個請求的次數(shù)。4.5 好友關(guān)系數(shù)據(jù)不一致A給B發(fā)消息被提示“您還不是對方好友”但兩邊的好友列表里明明都顯示對方。這類問題多半是好友關(guān)系表的分庫分表策略和消息服務(wù)判斷好友關(guān)系時用的索引不一致造成的。我們的好友關(guān)系表是按用戶ID取模分片的消息服務(wù)判斷好友關(guān)系時調(diào)用用戶服務(wù)接口如果接口內(nèi)部查詢時沒有帶全分片鍵就會落到錯誤的庫上把關(guān)系查丟了。排查下來發(fā)現(xiàn)是接口實現(xiàn)時在內(nèi)部方法調(diào)用加了一層防重邏輯把好友關(guān)系查詢參數(shù)做了緩存Key歸一化歸一化后丟失了分片鍵信息導(dǎo)致路由錯庫。去掉這層錯誤的緩存歸一化強(qiáng)制所有好友關(guān)系查詢必須攜帶用戶ID全文路由后問題解決。這里也提醒我微服務(wù)接口對外暴露的入?yún)⒈仨殧y帶足夠的路由信息任何緩存Key的歸一化邏輯都要仔細(xì)評審不然很容易在這個環(huán)節(jié)上埋雷。4.6 發(fā)布與灰度用戶管理子服務(wù)這種基礎(chǔ)服務(wù)發(fā)布的時候稍微有點問題就會影響全站。我們的做法是灰度發(fā)布分三步走先在灰度環(huán)境的網(wǎng)關(guān)層只放行內(nèi)部測試賬號的流量觀察半小時日志和錯誤率然后把線上5%的流量切到新版本重點關(guān)注注冊、登錄、資料更新三個核心鏈路的錯誤率和耗時曲線穩(wěn)定運(yùn)行一小時后逐步放量到20%、50%、100%。每個階段都有關(guān)聯(lián)告警任何一步指標(biāo)異常就自動回滾到舊版本。上線前還會做一個核對任務(wù)把灰度時段內(nèi)用戶基礎(chǔ)表和好友關(guān)系表的變更數(shù)據(jù)導(dǎo)出和舊版本服務(wù)處理的結(jié)果做對賬確保兩邊的數(shù)據(jù)完全一致。這項工作多花半小時但能擋住很多潛在的邏輯錯誤。有一次就是通過這個方法發(fā)現(xiàn)新版本在好友備注名更新時丟失了源備注字段全量發(fā)布前攔了下來。5. 項目復(fù)盤幾個值得堅持的決策和踩坑總結(jié)做完這個用戶管理子服務(wù)有幾個決策我覺得堅持得對第一是用戶基礎(chǔ)表和登錄憑證表分開設(shè)計這讓后來增加郵箱登錄、第三方登錄都只需要在憑證表加記錄改動成本非常低第二是緩存Key的規(guī)范化管理排查問題時省了大量時間第三是狀態(tài)管理堅持拉模型配合心跳的折中方案雖然狀態(tài)有秒級延遲但換來了系統(tǒng)整體的穩(wěn)定性。踩過的最大坑是在設(shè)計接口時沒有充分考慮到下游服務(wù)的異常場景。比如消息服務(wù)在用戶服務(wù)不可用時應(yīng)該返回降級數(shù)據(jù)而不是報錯當(dāng)時沒有在客戶端做這層容錯導(dǎo)致用戶服務(wù)一次短暫抖動引發(fā)了消息模塊的連環(huán)超時。后來專門在所有下游調(diào)用方加了一組降級策略配置用戶服務(wù)不可用時直接返回緩存里的舊資料而不是等超時再去查數(shù)據(jù)庫?;A(chǔ)服務(wù)的穩(wěn)定性不是靠自身不犯錯保證的而是靠上下游一起兜底保證的。如果讓我重新做一遍我會把用戶數(shù)據(jù)模型設(shè)計的重心再往前移一點在動手寫代碼之前做一輪更徹底的數(shù)據(jù)對賬和容量規(guī)劃比如幾個關(guān)鍵表的預(yù)期數(shù)據(jù)量、單Key緩存的大小上限、在線狀態(tài)Redis的持久化策略。這些在項目初期沒有被足夠重視中期都回來找補(bǔ)了代價。做底層服務(wù)設(shè)計上的預(yù)見性永遠(yuǎn)比寫代碼的速度重要。