現(xiàn)登錄:從 Session 到 Token 的完整方案)
這套點(diǎn)評(píng)實(shí)戰(zhàn)項(xiàng)目我前后做了好幾輪登錄模塊是第一個(gè)被推倒重來(lái)又徹底定稿的部分。很多剛上手 Spring Boot 的朋友都會(huì)問(wèn)登錄不就是校驗(yàn)用戶(hù)名密碼然后塞 Session 嗎干嘛非得引入 Redis等真把項(xiàng)目部署成多實(shí)例、把前端換成小程序和 App、再被測(cè)試反饋換臺(tái)設(shè)備登錄態(tài)就丟了請(qǐng)求頭里 Cookie 根本帶不過(guò)來(lái)的時(shí)候才明白 Session 這套方案在今天的主流項(xiàng)目里有多別扭。這篇文章把基于 Redis 實(shí)現(xiàn)登錄的完整思路、核心代碼和真實(shí)踩坑記錄全放出來(lái)當(dāng)成這個(gè)系列的第一篇后面幾篇我們接著聊緩存穿透、分布式鎖和熱點(diǎn)數(shù)據(jù)優(yōu)化。這套登錄方案不挑語(yǔ)言思路我用 Java Spring Boot 實(shí)現(xiàn)核心邏輯就是把用戶(hù)登錄態(tài)從服務(wù)器內(nèi)存搬到 Redis用 Token 代替 Session ID所有依賴(lài)登錄的接口通過(guò)攔截器統(tǒng)一校驗(yàn)。適合正在做單體應(yīng)用但準(zhǔn)備橫向擴(kuò)容的同學(xué)也適合想搞清楚為什么要用 Redis 存登錄態(tài)的人。讀完你不僅能照著抄還能明白每行配置為什么這么寫(xiě)。1. 先想清楚登錄方案為什么要?jiǎng)?Redis1.1 傳統(tǒng) Session 在真實(shí)項(xiàng)目里的四個(gè)痛點(diǎn)教科書(shū)里把 Session 講得很簡(jiǎn)單用戶(hù)登錄成功服務(wù)器把用戶(hù)信息存進(jìn)內(nèi)存生成一個(gè) Session ID 通過(guò) Cookie 返回給瀏覽器下次請(qǐng)求帶上 Cookie服務(wù)器比對(duì)一下 ID 就能認(rèn)出你是誰(shuí)。這套流程在單機(jī)開(kāi)發(fā)環(huán)境里跑得飛快但一旦放到真實(shí)項(xiàng)目問(wèn)題一個(gè)接一個(gè)蹦出來(lái)。第一個(gè)痛點(diǎn)是多實(shí)例部署。只要項(xiàng)目上了兩臺(tái)服務(wù)器負(fù)載均衡一轉(zhuǎn)發(fā)用戶(hù)第一次請(qǐng)求落在 A 節(jié)點(diǎn)第二次請(qǐng)求落在 B 節(jié)點(diǎn)B 節(jié)點(diǎn)內(nèi)存里根本沒(méi)有這個(gè) Session用戶(hù)就被強(qiáng)制登出了。解決方案不是沒(méi)有Session 粘滯、Session 復(fù)制都有人用但粘滯會(huì)把負(fù)載均衡的能力廢掉一大半復(fù)制又會(huì)在節(jié)點(diǎn)多的時(shí)候產(chǎn)生大量冗余數(shù)據(jù)越用越難受。第二個(gè)痛點(diǎn)是跨域和移動(dòng)端適配。Web 項(xiàng)目還好Cookie 是瀏覽器原生支持的但小程序、App 這類(lèi)客戶(hù)端對(duì) Cookie 的支持并不可控很多 HTTP 客戶(hù)端根本不會(huì)自動(dòng)維護(hù) Cookie 會(huì)話(huà)。做前后端分離更是如此接口域名和前端域名不一樣還得專(zhuān)門(mén)處理 CORS 里的 Credentials一個(gè)沒(méi)配好Cookie 就帶不過(guò)來(lái)了。第三個(gè)痛點(diǎn)是內(nèi)存占用。Session 默認(rèn)存服務(wù)器內(nèi)存用戶(hù)越多內(nèi)存被吃掉的越多而且很多框架的 Session 還要做持久化重啟不丟這又引入了額外的存儲(chǔ)和序列化成本。我見(jiàn)過(guò)一個(gè)項(xiàng)目從沒(méi)配過(guò) Session 清理內(nèi)存里堆了一大堆幾百年沒(méi)活躍過(guò)的用戶(hù)會(huì)話(huà)最后只能靠重啟暫時(shí)緩解。第四個(gè)痛點(diǎn)是被動(dòng)失效難。你想讓某個(gè)用戶(hù)下線(xiàn) Session 方案里你得知道他對(duì)應(yīng)哪個(gè) Session ID還得找到那臺(tái)存他 Session 的服務(wù)器想想就頭大。想做一個(gè)賬號(hào)只能在一處登錄管理員強(qiáng)制踢人這類(lèi)運(yùn)營(yíng)需求 Session 方案實(shí)現(xiàn)起來(lái)非常繞。1.2 Redis 為什么適合做登錄態(tài)能過(guò)期、能共享、能精確刪除Session 的痛點(diǎn)Redis 幾乎是為解決它們而生的。先說(shuō)共享。Redis 是獨(dú)立部署的服務(wù)業(yè)務(wù)服務(wù)器無(wú)論有多少臺(tái)節(jié)點(diǎn)訪(fǎng)問(wèn)的都是同一個(gè) Redis登錄態(tài)天然就是全局共享的。擴(kuò)容時(shí)新加節(jié)點(diǎn)不用做任何會(huì)話(huà)同步因?yàn)闀?huì)話(huà)根本不在節(jié)點(diǎn)上把服務(wù)發(fā)現(xiàn)有問(wèn)題的同學(xué)直接解放。再說(shuō)過(guò)期。Redis 的 key 支持設(shè)置 TTL到期自動(dòng)刪除這跟會(huì)話(huà)的生命周期太契合了。用戶(hù)登錄態(tài)可以設(shè)置比如 30 分鐘過(guò)期用戶(hù)在活躍時(shí)每次請(qǐng)求都刷新這個(gè) TTL就實(shí)現(xiàn)了滑動(dòng)過(guò)期。用戶(hù)不活躍了TTL 到了Redis 自動(dòng)把數(shù)據(jù)清掉不需要開(kāi)發(fā)手寫(xiě)定時(shí)任務(wù)去掃。然后是精確刪除。一個(gè) DEL 命令就能把某個(gè)用戶(hù)的登錄態(tài)立刻干掉實(shí)現(xiàn)退出登錄踢人下線(xiàn)頂號(hào)登錄都非常直接。Session 方案里難以實(shí)現(xiàn)的運(yùn)營(yíng)需求Redis 方案下就是一條命令的事。最后可以對(duì)比一下同樣是無(wú)狀態(tài)登錄的 JWT 方案。JWT 把用戶(hù)信息加密進(jìn)客戶(hù)端保存的令牌里服務(wù)端不存任何狀態(tài)天然適合分布式。但它有個(gè)硬傷你沒(méi)法主動(dòng)讓一個(gè)已簽發(fā)的 JWT 失效。用戶(hù)被盜號(hào)了你想把他踢下線(xiàn)做不到只能等 token 自己過(guò)期。想實(shí)現(xiàn)頂號(hào)登錄得額外引入黑名單機(jī)制那本質(zhì)上又是在服務(wù)端存狀態(tài)了既然這樣還不如直接用 Redis 存登錄態(tài)一切盡在掌握。這就是我最終選擇 Redis 的核心原因可共享、可過(guò)期、可主動(dòng)刪除三個(gè)特性正好補(bǔ)齊 Session 和 JWT 各自的短板。2. 模塊拆解登錄在點(diǎn)評(píng)項(xiàng)目里到底做了哪幾件事2.1 三條主線(xiàn)流程發(fā)碼、登錄、校驗(yàn)登錄模塊不是只有一個(gè)登錄接口那么簡(jiǎn)單。我把它拆成了三個(gè)流程每個(gè)流程面臨的 Redis 操作重點(diǎn)都不一樣。第一個(gè)流程是發(fā)送驗(yàn)證碼。用戶(hù)輸入手機(jī)號(hào)點(diǎn)擊獲取驗(yàn)證碼后端要做的不是立刻發(fā)短信而是一連串防御動(dòng)作先校驗(yàn)手機(jī)號(hào)格式對(duì)不對(duì)再看這個(gè)手機(jī)號(hào)是不是剛剛發(fā)過(guò)防止用戶(hù)狂點(diǎn)按鈕把短信接口刷爆然后生成一個(gè) 6 位隨機(jī)驗(yàn)證碼存進(jìn) Redis 并設(shè)置過(guò)期時(shí)間最后才真正調(diào)用短信服務(wù)商把驗(yàn)證碼發(fā)出去。在這個(gè)流程里Redis 承擔(dān)的是頻控 存儲(chǔ)兩個(gè)職責(zé)。第二個(gè)流程是登錄。用戶(hù)輸入手機(jī)號(hào)和驗(yàn)證碼后端從 Redis 里把之前存的驗(yàn)證碼取出來(lái)比對(duì)比對(duì)正確、刪掉這個(gè) key驗(yàn)證碼一次性用完即焚然后去用戶(hù)表查這個(gè)手機(jī)號(hào)有沒(méi)有注冊(cè)過(guò)沒(méi)有就自動(dòng)創(chuàng)建一個(gè)新用戶(hù)最后生成一個(gè) Token把用戶(hù)的核心信息存進(jìn) Redis設(shè)置 TTL再把 Token 返回給前端。這個(gè)流程里Redis 承擔(dān)的是存儲(chǔ)登錄態(tài)的職責(zé)。第三個(gè)流程是登錄態(tài)校驗(yàn)。前端在后續(xù)所有需要登錄的請(qǐng)求頭里帶上 Token后端通過(guò)攔截器統(tǒng)一處理拿到 Token去 Redis 查有沒(méi)有對(duì)應(yīng)的用戶(hù)查到了就放行查不到或者過(guò)期了就返回 401。同時(shí)還要做一件事——刷新 Token 的 TTL讓活躍用戶(hù)的登錄態(tài)自動(dòng)續(xù)期。這個(gè)流程里Redis 承擔(dān)的是鑒權(quán) 續(xù)期兩個(gè)職責(zé)。三個(gè)流程看起來(lái)簡(jiǎn)單但每個(gè)環(huán)節(jié)都有細(xì)節(jié)下面用 Redis 的數(shù)據(jù)結(jié)構(gòu)設(shè)計(jì)把這些細(xì)節(jié)串起來(lái)。2.2 key 設(shè)計(jì)與 TTL 管理怎么設(shè)計(jì)才能不串號(hào)、不占內(nèi)存Redis 是個(gè)大字典所有人共用同一片命名空間key 設(shè)計(jì)不好就等著數(shù)據(jù)互相覆蓋或者排查到崩潰。我在這個(gè)項(xiàng)目里統(tǒng)一用業(yè)務(wù)前綴 冒號(hào) 唯一標(biāo)識(shí)的格式。前綴用英文單詞加模塊名比如驗(yàn)證碼用login:code登錄態(tài)用login:token登錄態(tài)所屬用戶(hù)如果需要反向索引再用login:user。冒號(hào)不是隨便加的Redis 官方推薦這種風(fēng)格它在可視化工具里會(huì)自動(dòng)按前綴折疊分組排查問(wèn)題的時(shí)候一眼就能看到某個(gè)業(yè)務(wù)下有哪些 key。具體到登錄場(chǎng)景我用了三個(gè)核心 keykeyvalueTTL用途login:code:{手機(jī)號(hào)}6 位隨機(jī)數(shù)字驗(yàn)證碼5 分鐘存驗(yàn)證碼過(guò)期作廢login:token:{token}用戶(hù)信息的 JSON 字符串30 分鐘存登錄態(tài)滑動(dòng)續(xù)期login:user:{用戶(hù)id}token 字符串30 分鐘反查登錄態(tài)實(shí)現(xiàn)頂號(hào)驗(yàn)證碼設(shè) 5 分鐘是行業(yè)通行做法太短用戶(hù)收個(gè)短信再看一眼就不夠用了太長(zhǎng)又給暴力破解留了太多時(shí)間窗口。登錄態(tài)設(shè) 30 分鐘配合每次請(qǐng)求刷新 TTL用戶(hù)只要保持在用狀態(tài)就永遠(yuǎn)不掉線(xiàn)真正超過(guò) 30 分鐘完全沒(méi)活躍才需要重新登錄大多數(shù)場(chǎng)景下這個(gè)配置是合理的。需要更長(zhǎng)時(shí)長(zhǎng)的業(yè)務(wù)比如記住我這類(lèi)功能可以單獨(dú)給另一個(gè) key 設(shè)置更長(zhǎng) TTL不要試圖用一個(gè) key 兼顧所有場(chǎng)景。TTL 不是越長(zhǎng)越好Redis 雖然會(huì)自動(dòng)清理過(guò)期 key但大量長(zhǎng)期不過(guò)期的登錄態(tài)堆在內(nèi)存里最終會(huì)觸發(fā)內(nèi)存淘汰策略到時(shí)候就不是你想保留哪個(gè)的問(wèn)題了。login:user:{用戶(hù)id}這個(gè)反向索引很多人會(huì)忽略但它是實(shí)現(xiàn)頂號(hào)登錄的關(guān)鍵。用戶(hù)在新設(shè)備上登錄按用戶(hù) id 把舊的 token 對(duì)應(yīng)的 key 刪掉再存新 token就能保證同一時(shí)刻只有一個(gè)設(shè)備在線(xiàn)。如果產(chǎn)品需求是多端在線(xiàn)這個(gè) key 就不需要了做成 token 列表也行。需求決定設(shè)計(jì)別一上來(lái)就把所有 key 都建了。3. 實(shí)操?gòu)牧愕揭粚?shí)現(xiàn) Redis 登錄3.1 序列化方案這一步做錯(cuò)登錄態(tài)直接亂碼真正開(kāi)始寫(xiě)代碼前我建議先花五分鐘把 Redis 的序列化方案定下來(lái)。Spring Boot 里訪(fǎng)問(wèn) Redis 有兩大工具RedisTemplate和StringRedisTemplate。很多初學(xué)者上來(lái)就用RedisTemplate結(jié)果往 Redis 里一存控制臺(tái)看到的是一串\xAC\xED\x00\x05t...之類(lèi)的亂碼這就是 JDK 默認(rèn)序列化搞的鬼。JDK 序列化有三個(gè)毛病存進(jìn)去的內(nèi)容人類(lèi)完全讀不懂排查問(wèn)題極其痛苦序列化后的體積特別大浪費(fèi)內(nèi)存而且 JDK 序列化的數(shù)據(jù)只有 Java 程序能讀將來(lái)如果引入 PHP、Go 寫(xiě)的服務(wù)想共用數(shù)據(jù)直接傻眼。我的選擇很簡(jiǎn)單統(tǒng)一用StringRedisTemplatevalue 直接存 JSON 字符串。key 本來(lái)就是字符串value 用 JSON 序列化后的字符串Redis 里看到的就是干干凈凈的明文哪個(gè) key 對(duì)應(yīng)什么業(yè)務(wù)一目了然。配合項(xiàng)目里已有的 Jackson用戶(hù)對(duì)象轉(zhuǎn)成 JSON 再存取出時(shí)再反序列化回對(duì)象全程不碰 JDK 序列化。這里有一個(gè)容易踩的坑如果用戶(hù)對(duì)象里有LocalDateTime這種 Java 8 時(shí)間類(lèi)型直接用 Jackson 轉(zhuǎn) JSON 會(huì)報(bào)錯(cuò)或者轉(zhuǎn)成數(shù)組。需要引入jackson-datatype-jsr310模塊然后給ObjectMapper注冊(cè)JavaTimeModule再把writeDatesAsTimestamps關(guān)掉。這個(gè)坑不下三次在我手底下出現(xiàn)過(guò)提前寫(xiě)進(jìn)配置能省半天排查時(shí)間。Configuration public class RedisConfig { Bean public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory factory) { StringRedisTemplate template new StringRedisTemplate(); template.setConnectionFactory(factory); return template; } Bean public ObjectMapper objectMapper() { ObjectMapper mapper new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); return mapper; } }3.2 發(fā)驗(yàn)證碼接口頻控、校驗(yàn)、倒計(jì)時(shí)一次搞定發(fā)送驗(yàn)證碼接口的核心不是發(fā)短信而是怎么擋掉無(wú)效請(qǐng)求。我完整走一遍這個(gè)接口的邏輯。第一步校驗(yàn)手機(jī)號(hào)格式。手機(jī)號(hào)這關(guān)不過(guò)后面全都不用執(zhí)行。正則表達(dá)式校驗(yàn)第一位是 1第二位是 3-9后面 9 位數(shù)字基本覆蓋國(guó)內(nèi)手機(jī)號(hào)格式。這一步可以省掉一次 Redis 查詢(xún)和一次短信費(fèi)用值得認(rèn)真寫(xiě)。第二步查 Redis 里有沒(méi)有l(wèi)ogin:code:{手機(jī)號(hào)}這個(gè) key。如果存在說(shuō)明 60 秒內(nèi)才剛剛發(fā)過(guò)驗(yàn)證碼直接提示發(fā)送過(guò)于頻繁連短信都不必調(diào)。這就是倒計(jì)時(shí)的實(shí)現(xiàn)原理倒計(jì)時(shí)不是前端渲染的而是后端根據(jù) Redis 里 key 的 TTL 剩多少返回給前端計(jì)算的。前端做 60 秒倒計(jì)時(shí)只是交互展示后端的 Redis 判斷才是真正的護(hù)欄。第三步生成 6 位隨機(jī)驗(yàn)證碼。我用了String.format(%06d, random.nextInt(1000000))注意補(bǔ)齊前導(dǎo)零比如生成的是004728格式化后丟不了 0。有人喜歡用 UUID 截一段當(dāng)驗(yàn)證碼沒(méi)必要6 位數(shù)字對(duì)用戶(hù)最友好暴力破解風(fēng)險(xiǎn)靠 TTL 和頻控已經(jīng)壓下來(lái)了。第四步寫(xiě) Redis設(shè)置 5 分鐘 TTL。這里再疊加一個(gè)每日發(fā)送上限的 key比如login:code:limit:{手機(jī)號(hào)}每發(fā)一次就incr一次第一次發(fā)送時(shí)設(shè)置expire到當(dāng)天 24 點(diǎn)超過(guò)比如 10 次就拒絕發(fā)送。這個(gè)設(shè)計(jì)能防住拿別人手機(jī)號(hào)惡意刷短信的腳本上線(xiàn)當(dāng)天就被我用上了不然短信費(fèi)一天比一天離譜。PostMapping(/code) public Result sendCode(RequestParam(phone) String phone) { // 1. 校驗(yàn)手機(jī)號(hào)格式 if (!RegexUtils.isPhoneInvalid(phone)) { return Result.fail(手機(jī)號(hào)格式錯(cuò)誤); } // 2. 檢查 60 秒內(nèi)是否已經(jīng)發(fā)過(guò) String codeKey login:code: phone; if (stringRedisTemplate.hasKey(codeKey)) { Long ttl stringRedisTemplate.getExpire(codeKey, TimeUnit.SECONDS); return Result.fail(發(fā)送過(guò)于頻繁請(qǐng) ttl 秒后重試); } // 3. 生成 6 位隨機(jī)驗(yàn)證碼 String code String.format(%06d, new Random().nextInt(1000000)); // 4. 存入 Redis5 分鐘有效 stringRedisTemplate.opsForValue().set(codeKey, code, 5, TimeUnit.MINUTES); // 5. 記錄每日發(fā)送次數(shù) String limitKey login:code:limit: phone; Long count stringRedisTemplate.opsForValue().increment(limitKey); if (count ! null count 1) { stringRedisTemplate.expire(limitKey, Duration.between(LocalDateTime.now(), LocalDate.now().plusDays(1).atStartOfDay()).toMinutes(), TimeUnit.MINUTES); } // 6. 模擬調(diào)用短信服務(wù)商真實(shí)項(xiàng)目中替換成具體 SDK log.info(驗(yàn)證碼{}, code); return Result.ok(); }有一個(gè)細(xì)節(jié)要注意第 2 步用hasKey判斷彈出的提示語(yǔ)最好做成返回剩余秒數(shù)而不是固定寫(xiě)1 分鐘內(nèi)只能發(fā)一次。用戶(hù)體驗(yàn)上差別很大而且實(shí)現(xiàn)上只是多一行g(shù)etExpire的事。3.3 登錄接口驗(yàn)證碼校驗(yàn)、新用戶(hù)自動(dòng)注冊(cè)、Token 生成登錄接口的核心是三個(gè)動(dòng)作驗(yàn)證碼比對(duì)、用戶(hù)查詢(xún)/創(chuàng)建、Token 生成與登錄態(tài)落庫(kù)。驗(yàn)證碼比對(duì)有個(gè)關(guān)鍵點(diǎn)先判斷 key 是否存在再比對(duì)值最后無(wú)論對(duì)錯(cuò)都要?jiǎng)h掉這個(gè) key。刪掉的邏輯很多人會(huì)漏驗(yàn)證碼是一次性的不能允許同一個(gè)驗(yàn)證碼被反復(fù)提交。哪怕用戶(hù)輸錯(cuò)了也要把舊驗(yàn)證碼刪掉讓他重新獲取這種用完即焚的習(xí)慣能擋住大部分驗(yàn)證碼重放攻擊。比對(duì)的時(shí)候我建議用字符串的equals別用有人在這里踩過(guò)坑拿反序列化的對(duì)象去比較結(jié)果莫名的 NPE。用戶(hù)查詢(xún)與創(chuàng)建查用戶(hù)表用手機(jī)號(hào)查查不到說(shuō)明是新用戶(hù)自動(dòng)注冊(cè)一個(gè)初始昵稱(chēng)給個(gè)默認(rèn)值、頭像給個(gè)默認(rèn)圖這些生成邏輯在真實(shí)項(xiàng)目里也是小小的產(chǎn)品決策先別管花哨的機(jī)制能跑通流程最重要。Token 生成有人用 UUID有人用 Hutool 的IdUtil.simpleUUID()還有人用雪花算法。登錄態(tài) Token 不需要可讀性要的是隨機(jī)性和唯一性UUID 足夠了。注意生成后要替換掉中間的橫杠省幾個(gè)字節(jié)是一回事主要在 URL 和請(qǐng)求頭里裸奔的時(shí)候帶橫杠的字符串也沒(méi)啥大問(wèn)題但干凈一點(diǎn)總沒(méi)壞處。登錄態(tài)落庫(kù)把用戶(hù) ID、手機(jī)號(hào)、昵稱(chēng)這些必要信息塞進(jìn)一個(gè)UserDTO轉(zhuǎn)成 JSON 存到login:token:{token}這個(gè) key 里TTL 30 分鐘。再次強(qiáng)調(diào)不要把密碼、密保問(wèn)題答案這類(lèi)敏感字段放進(jìn)這個(gè) JSON登錄態(tài)里只需要鑒權(quán)必需的信息。你存得越少泄露之后損失越小。這個(gè)習(xí)慣我是在一次線(xiàn)上數(shù)據(jù)被拖庫(kù)后養(yǎng)成的當(dāng)年登錄態(tài)里塞了明文手機(jī)號(hào)和個(gè)人簡(jiǎn)介教訓(xùn)相當(dāng)深刻。頂號(hào)邏輯這里提一下。如果你要做一個(gè)賬號(hào)同時(shí)只能一個(gè)設(shè)備登錄把新 Token 存進(jìn)去之后順手從反向 keylogin:user:{用戶(hù)id}里取出舊 Token把舊的login:token:{舊token}刪掉再寫(xiě)入新的反向索引。這樣用戶(hù)一登錄新設(shè)備老設(shè)備立刻失效實(shí)現(xiàn)頂號(hào)。PostMapping(/login) public Result login(RequestParam(phone) String phone, RequestParam(code) String code) { String codeKey login:code: phone; String savedCode stringRedisTemplate.opsForValue().get(codeKey); if (savedCode null || !savedCode.equals(code)) { return Result.fail(驗(yàn)證碼錯(cuò)誤或已過(guò)期); } // 驗(yàn)證碼一次性刪掉防重放 stringRedisTemplate.delete(codeKey); // 查用戶(hù)不存在則自動(dòng)注冊(cè) User user userService.lambdaQuery().eq(User::getPhone, phone).one(); if (user null) { user createUserWithDefaultProfile(phone); } // 生成 Token保存登錄態(tài) String token UUID.randomUUID().toString().replace(-, ); UserDTO userDTO BeanUtil.copyProperties(user, UserDTO.class); String tokenKey login:token: token; stringRedisTemplate.opsForValue().set(tokenKey, objectMapper.writeValueAsString(userDTO), 30, TimeUnit.MINUTES); // 反向索引用于頂號(hào)登錄 String reverseKey login:user: user.getId(); String oldToken stringRedisTemplate.opsForValue().get(reverseKey); if (oldToken ! null) { stringRedisTemplate.delete(login:token: oldToken); } stringRedisTemplate.opsForValue().set(reverseKey, token, 30, TimeUnit.MINUTES); return Result.ok(token); }登錄接口里還有一個(gè)容易被忽略的環(huán)節(jié)把UserDTO轉(zhuǎn) JSON 時(shí)拋出的異常。運(yùn)營(yíng)反饋為什么登錄偶發(fā)失敗排查到最后是對(duì)象里有字段序列化不成功。所以我在實(shí)際項(xiàng)目里都會(huì)把 JSON 轉(zhuǎn)換單獨(dú)抽一個(gè)方法try-catch 包住失敗了記日志避免異常一路拋到前端返回一個(gè) 500用戶(hù)完全看不懂發(fā)生了什么。3.4 登錄攔截器一勞永逸的登錄態(tài)校驗(yàn)與刷新登錄態(tài)存好了總不能每個(gè)需要登錄的接口都寫(xiě)一遍查 Redis、解析用戶(hù)吧。用攔截器統(tǒng)一處理這是這個(gè)模塊的核心價(jià)值所在。先寫(xiě)業(yè)務(wù)攔截器。preHandle里做三件事。第一從請(qǐng)求頭authorization里取出 Token注意大小寫(xiě)我見(jiàn)過(guò)前端傳Authorization、后端取authorization導(dǎo)致全部 401 的冤案建議前后端約定同一種寫(xiě)法第二拿著 Token 去查 Redis查不到說(shuō)明登錄態(tài)過(guò)期或者被頂號(hào)直接返回 401這里一定要response.setStatus(401)不要只返回一個(gè) JSON 里帶 code 的錯(cuò)誤前后端分離的架構(gòu)里HTTP 狀態(tài)碼也是協(xié)議的一部分第三查到了就把這個(gè) key 的 TTL 續(xù)期到 30 分鐘再把UserDTO放進(jìn)去ThreadLocal方便后面的業(yè)務(wù)代碼直接拿當(dāng)前用戶(hù)不用在每個(gè) Controller 方法里定義多余的形參。public class LoginInterceptor implements HandlerInterceptor { private final StringRedisTemplate stringRedisTemplate; private final ObjectMapper objectMapper; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(authorization); if (token null || token.isEmpty()) { response.setStatus(401); return false; } String key login:token: token; String userJson stringRedisTemplate.opsForValue().get(key); if (userJson null) { response.setStatus(401); return false; } UserDTO userDTO objectMapper.readValue(userJson, UserDTO.class); // 滑動(dòng)續(xù)期 stringRedisTemplate.expire(key, 30, TimeUnit.MINUTES); UserHolder.saveUser(userDTO); return true; } Override public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { UserHolder.removeUser(); } }ThreadLocal的使用有個(gè)鐵律用完必須清理。afterCompletion里調(diào)用removeUser()這一步不能省。Tomcat 的工作線(xiàn)程是復(fù)用的線(xiàn)程處理完一個(gè)請(qǐng)求不會(huì)銷(xiāo)毀不清ThreadLocal下一個(gè)請(qǐng)求就會(huì)讀到上一個(gè)用戶(hù)的信息輕則數(shù)據(jù)串號(hào)重則越權(quán)訪(fǎng)問(wèn)。這個(gè)坑我栽過(guò)上線(xiàn)第一天就收到了用戶(hù) A 看到了用戶(hù) B 數(shù)據(jù)的工單排查半天才定位到是ThreadLocal沒(méi)清。最后配置攔截器。實(shí)現(xiàn)WebMvcConfigurer把攔截器注冊(cè)進(jìn)去并設(shè)置放行路徑。登錄相關(guān)接口、靜態(tài)資源、錯(cuò)誤頁(yè)要放行其余/**全部攔截。很多人這里會(huì)犯一個(gè)錯(cuò)把WebMvcConfigurer寫(xiě)成了普通的Component而沒(méi)有繼承任何東西或者忘記加Configuration結(jié)果攔截器悄悄失效。另一個(gè)容易忽略的是路徑匹配如果你用 Spring Boot 3.xant_path_matcher的配置有時(shí)候需要手動(dòng)開(kāi)否則/**不生效所有請(qǐng)求都被攔下來(lái)一片紅。Configuration public class WebMvcConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor(stringRedisTemplate)) .addPathPatterns(/**) .excludePathPatterns(/login, /code, /error); } }4. 上線(xiàn)前會(huì)踩的坑實(shí)測(cè)問(wèn)題與排查記錄4.1 攔截器明明寫(xiě)了卻不生效項(xiàng)目里第一個(gè)攔路虎就是攔截器失效。日志里能看到LoginInterceptor被 Spring 加載了但請(qǐng)求一個(gè)都不攔全部直接進(jìn)了 Controller。排查順序我建議固定為三層。第一層看有沒(méi)有配置類(lèi)真被掃描到Configuration注解的類(lèi)所在的包必須能被ComponentScan掃到否則配置類(lèi)等于沒(méi)寫(xiě)。第二層看是不是實(shí)現(xiàn)了WebMvcConfigurerSpring Boot 3 以前還有一種繼承WebMvcConfigurationSupport的寫(xiě)法這個(gè)寫(xiě)法會(huì)把 Spring Boot 自動(dòng)配置的 MVC 全部覆蓋掉靜態(tài)資源映射、消息轉(zhuǎn)換器全部失效能不碰就別碰。第三層看攔截器注冊(cè)時(shí)addPathPatterns(/**)的寫(xiě)法在 Spring Boot 2.6 之后路徑匹配策略變了部分情況下要用PathMatchConfigurer把匹配策略切回AntPathMatcher否則/**匹配不到任何路徑。三層排查法走下來(lái)絕大多數(shù)攔截器不生效都能解決。4.2 Token 拿到手卻查不到用戶(hù)序列化格式不一致有一次前端反饋某些用戶(hù)明明剛登錄成功下一次請(qǐng)求就 401。追蹤發(fā)現(xiàn)login:token:對(duì)應(yīng)的 key 確實(shí)存在而且 TTL 還有二十多分鐘但StringRedisTemplate.opsForValue().get(key)返回了null。問(wèn)題出在存的時(shí)候和取的時(shí)候用了兩套序列化。登錄接口里用了StringRedisTemplate存了一個(gè) JSON 字符串但后來(lái)攔截器里圖方便用了RedisTemplate去取RedisTemplate默認(rèn) JDK 序列化它用 JDK 序列化的方式去解析一個(gè) JSON 字符串自然解析得七零八落取出來(lái)是null或者解析異常。這一類(lèi)的詭異問(wèn)題直接統(tǒng)一所有操作 Redis 的地方都用StringRedisTemplate就不會(huì)發(fā)生。這也是我一直強(qiáng)調(diào)定方案要趁早的原因前后端接口字段統(tǒng)一用駝峰、Date 用時(shí)間戳還是字符串Redis 序列化用 JSON 還是用別的這類(lèi)全局約定一定是在寫(xiě)第一行代碼之前就拍死的。4.3 驗(yàn)證碼接口被腳本刷爆這個(gè)坑是我在生產(chǎn)環(huán)境真實(shí)撞見(jiàn)的。上線(xiàn)第二天監(jiān)控發(fā)現(xiàn)短信接口的調(diào)用量是昨天同期的三倍打開(kāi)后臺(tái)一看全是同一個(gè) IP 在狂點(diǎn)獲取驗(yàn)證碼顯然是有人拿腳本在刷。光靠60 秒發(fā)一次根本擋不住腳本可以隨機(jī)換手機(jī)號(hào)每次都是新手機(jī)號(hào)就繞過(guò)了單個(gè)手機(jī)號(hào)的頻率限制。治本辦法是把頻率限制從手機(jī)號(hào)維度擴(kuò)展到設(shè)備/IP 維度和全局配額維度。Redis 里用login:code:ip:{ip}統(tǒng)計(jì)每個(gè) IP 每日發(fā)送量超過(guò)閾值就拒絕再加一個(gè)全局計(jì)數(shù)器比如整個(gè)系統(tǒng)每天最多發(fā)多少條驗(yàn)證碼量到了就該人工介入審查了。短信服務(wù)商那邊也要配限額萬(wàn)一 Redis 數(shù)據(jù)被清、后端的判斷全部失效服務(wù)商的配額還能兜底這是最后一道防線(xiàn)。4.4 ThreadLocal 用戶(hù)串號(hào)前面提到過(guò)這里再展開(kāi)一次。用戶(hù) A 登錄后看到了用戶(hù) B 的訂單數(shù)據(jù)排查過(guò)程十分詭異兩個(gè)用戶(hù)是不同手機(jī)號(hào)、不同賬號(hào)Redis 里的數(shù)據(jù)也沒(méi)串但 Controller 拿到的用戶(hù)卻是 B。最后定位在ThreadLocalTomcat 的線(xiàn)程池復(fù)用了工作線(xiàn)程前一個(gè)請(qǐng)求是 B處理完后沒(méi)有清掉ThreadLocal下一個(gè)請(qǐng)求雖然攔截器正確地從 Redis 拿到了 A 的用戶(hù)信息但業(yè)務(wù)代碼因?yàn)橛芯彺嬷苯幼x了舊的ThreadLocal于是 A 就看到了 B 的數(shù)據(jù)。解決辦法已經(jīng)寫(xiě)在了攔截器代碼里afterCompletion一定要清理。按我的習(xí)慣凡是往ThreadLocal里寫(xiě)了東西的都得像對(duì)待InputStream一樣強(qiáng)制在finally里清理一步都不能少?,F(xiàn)象可能原因排查方法解決方案所有請(qǐng)求 401攔截器放行配置錯(cuò)登錄接口也被攔看日志是否有LoginInterceptor在 excludePathPatterns 加/login登錄態(tài)隨機(jī)丟失存和取用了不同 RedisTemplate打印 key 的 value 看是否亂碼統(tǒng)一用 StringRedisTemplate驗(yàn)證碼同一秒被刷檢查邏輯里沒(méi)有先查再發(fā)的原子操作看 Redis key 的 TTL用setIfAbsent加分布式鎖產(chǎn)生了大量永久 key登錄 key 沒(méi)設(shè)置 TTLredis-cli --bigkeys統(tǒng)計(jì)所有 key 都要帶 TTL用戶(hù)數(shù)據(jù)串號(hào)ThreadLocal 未清理在 afterCompletion 打印日志強(qiáng)制調(diào)用 removeUser4.5 頂號(hào)登錄的邊界情況頂號(hào)登錄邏輯看起來(lái)簡(jiǎn)單但有幾個(gè)邊界情況很考驗(yàn)?zāi)X筋。一是同一個(gè)用戶(hù)在極短的時(shí)間內(nèi)兩次登錄第一次登錄寫(xiě)入 token1第二次登錄時(shí)刪 token1、寫(xiě)入 token2如果第一次的請(qǐng)求比第二次晚到達(dá)就會(huì)出現(xiàn) token1 已經(jīng)被刪了但第一次請(qǐng)求的校驗(yàn)還能通過(guò)短暫地把 token1 又寫(xiě)回來(lái)了。解決思路是刪舊 token 前先判斷反向索引里的 token 是不是等于當(dāng)前請(qǐng)求要?jiǎng)h的 token不一致就不刪。二是用戶(hù) A 在設(shè)備 1 登錄被盜號(hào)者拿去設(shè)備 2 登錄頂號(hào)后設(shè)備 1 確實(shí)失效了但 Redis 里可能殘留設(shè)備 1 創(chuàng)建的其他業(yè)務(wù)數(shù)據(jù)這些數(shù)據(jù)要設(shè)計(jì)生命周期不能全部綁定在登錄態(tài)上。三是頂號(hào)策略本身要看產(chǎn)品需求是想讓新設(shè)備頂?shù)衾显O(shè)備還是干脆禁止重復(fù)登錄這直接決定了反向索引寫(xiě)不寫(xiě)別上來(lái)就照抄。5. 上線(xiàn)前最后再檢查這幾件事代碼寫(xiě)完了不代表能上線(xiàn)我這套登錄模塊上線(xiàn)前反復(fù)檢查的點(diǎn)整理成一份清單照著走一遍能省很多半夜攔截器報(bào)警的功夫。第一個(gè)檢查 Redis 的配置。連接池超時(shí)時(shí)間、最大連接數(shù)、空閑淘汰參數(shù)都要根據(jù)業(yè)務(wù)預(yù)估量提前估算。尤其要注意 Redis 的密碼和網(wǎng)絡(luò)安全組配置不要讓 Redis 裸奔在公網(wǎng)我見(jiàn)過(guò)不止一個(gè)團(tuán)隊(duì)把 Redis 端口暴露到公網(wǎng)數(shù)據(jù)庫(kù)被清空的事件做技術(shù)的人一定要有這個(gè)敏感度。第二個(gè)檢查 TTL 策略。確認(rèn)所有業(yè)務(wù) key 都帶了合理的過(guò)期時(shí)間別有不帶 TTL 的野 key 長(zhǎng)期占用內(nèi)存。上線(xiàn)前可以寫(xiě)個(gè)腳本統(tǒng)計(jì)一下 key 的數(shù)量和分布心里先有個(gè)底。第三個(gè)檢查異常處理鏈路。Redis 故障了怎么辦這個(gè)問(wèn)題值得想清楚。登錄接口在 Redis 不可用時(shí)應(yīng)該快速失敗并返回友好提示而不是讓用戶(hù)在轉(zhuǎn)圈圈。有的業(yè)務(wù)對(duì)可用性要求極高會(huì)把 Redis 做成主從加哨兵或者干脆用集群模式但那是架構(gòu)層面的事了至少要在代碼里做好降級(jí)比如緩存 Redis 不可用標(biāo)志走臨時(shí)白名單避免雪崩。第四個(gè)檢查日志。登錄這種高頻操作日志一定要有但絕不能冗長(zhǎng)。打印用戶(hù) id、token 前幾位、登錄結(jié)果和耗時(shí)不打印驗(yàn)證碼明文、不打印完整 token這是安全和排障的平衡點(diǎn)。第五個(gè)檢查壓測(cè)。驗(yàn)證碼接口和登錄接口是典型的高并發(fā)寫(xiě)入場(chǎng)景用壓測(cè)工具灌一波看看 Redis 的 CPU、內(nèi)存和響應(yīng)時(shí)間別抱僥幸心理該提前發(fā)現(xiàn)的問(wèn)題一定要壓出來(lái)。最后再分享一個(gè)小經(jīng)驗(yàn)。登錄模塊是整個(gè)系統(tǒng)的地基地基歪了后面全是歪的。我見(jiàn)過(guò)很多項(xiàng)目組上來(lái)就懟各種高大上的框架結(jié)果一頓操作猛如虎一問(wèn)登錄態(tài)怎么存的答曰用的默認(rèn)的 Session。不是說(shuō) Sesseion 不行而是你得知道你選的方案在將來(lái)項(xiàng)目變大時(shí)會(huì)不會(huì)成為瓶頸。我在這個(gè)點(diǎn)評(píng)項(xiàng)目里選擇了 Redis 存登錄態(tài)三臺(tái)實(shí)例隨意擴(kuò)縮容登錄模塊從頭到尾沒(méi)出過(guò)狀態(tài)相關(guān)的事故這個(gè)方案經(jīng)受住了驗(yàn)證。接下來(lái)這個(gè)系列要繼續(xù)講緩存穿透、熱點(diǎn) key 和分布式鎖都是登錄做完之后緊接著要面對(duì)的真實(shí)問(wèn)題回頭見(jiàn)。