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