系統(tǒng)Redis深度實(shí)踐:從緩存配置到分布式鎖與集群部署)
如果你問我后臺(tái)管理系統(tǒng)里最容易被低估的中間件是什么我會(huì)說(shuō)Redis。很多團(tuán)隊(duì)把Redis當(dāng)成一個(gè)“放Token的緩存庫(kù)”就完事了但實(shí)際上在eladmin這種基于Spring Boot的后臺(tái)管理系統(tǒng)里Redis能承擔(dān)的角色遠(yuǎn)比你想象得多。從用戶登錄態(tài)、驗(yàn)證碼、在線用戶到字典緩存、分布式鎖、接口限流計(jì)數(shù)器再到集群部署后的Session協(xié)調(diào)它幾乎參與了業(yè)務(wù)鏈路的每一層。我最早接手團(tuán)隊(duì)eladmin項(xiàng)目時(shí)Redis已經(jīng)配好了但一查監(jiān)控全是問題——key亂碼、緩存不生效、分布式鎖偶爾失效、主從切換后計(jì)數(shù)器歸零。這篇文章我把這幾輪踩坑和重構(gòu)的過程完整寫出來(lái)包含配置、序列化、緩存注解、分布式鎖、故障處理和部署升級(jí)路線適合正在用或準(zhǔn)備用eladmin做二次開發(fā)的朋友參考。先說(shuō)個(gè)結(jié)論eladmin本身自帶一套緩存機(jī)制很多基礎(chǔ)代碼里也預(yù)留了Redis的入口但默認(rèn)狀態(tài)下的使用深度遠(yuǎn)遠(yuǎn)不夠。如果你只是把它當(dāng)成“驗(yàn)證碼臨時(shí)存儲(chǔ)”那你既沒發(fā)揮Redis的價(jià)值也遲早會(huì)在高并發(fā)或集群場(chǎng)景下栽跟頭。接下來(lái)我按實(shí)際項(xiàng)目推進(jìn)的順序來(lái)講。1. eladmin為什么需要Redis從登錄態(tài)到緩存的架構(gòu)動(dòng)因1.1 eladmin原生架構(gòu)下的存儲(chǔ)瓶頸eladmin的默認(rèn)設(shè)計(jì)里用戶認(rèn)證走的是JWT也就是服務(wù)端簽發(fā)一個(gè)Token前端每次請(qǐng)求都帶上后端通過攔截器解析鑒權(quán)。好處是服務(wù)端無(wú)狀態(tài)但問題也很明顯Token一旦簽發(fā)服務(wù)端沒法主動(dòng)讓它失效。你改了用戶權(quán)限、禁用了賬號(hào)、踢了下線只要Token沒到期用戶依然能訪問接口。這是我在實(shí)際項(xiàng)目里最痛苦的體驗(yàn)之一。解決思路很簡(jiǎn)單把Token的狀態(tài)存到Redis里。用戶登錄后生成一個(gè)會(huì)話KeyJWT信息、用戶基本信息、權(quán)限快照都放進(jìn)去鑒權(quán)時(shí)先查Redis里的“會(huì)話是否還在”。這樣賬號(hào)禁用、權(quán)限變動(dòng)、強(qiáng)制下線都能實(shí)時(shí)生效。eladmin的登錄邏輯本身是基于在線用戶管理的如果配合Redis做會(huì)話存儲(chǔ)整個(gè)認(rèn)證鏈路的可控性會(huì)提升一個(gè)檔次。1.2 Redis在eladmin里的四種典型角色我把Redis在一個(gè)成熟eladmin項(xiàng)目里承擔(dān)的工作分成四類緩存層字典數(shù)據(jù)、部門樹、角色權(quán)限、菜單、系統(tǒng)配置項(xiàng)這類讀多寫少的數(shù)據(jù)全部放進(jìn)Redis降低數(shù)據(jù)庫(kù)壓力。會(huì)話與臨時(shí)數(shù)據(jù)登錄Token、驗(yàn)證碼、短信驗(yàn)證碼、文件上傳分片信息、導(dǎo)入導(dǎo)出的臨時(shí)文件索引。分布式協(xié)調(diào)定時(shí)任務(wù)防重執(zhí)行、庫(kù)存扣減、操作冪等、分布式鎖。計(jì)數(shù)器與限流登錄失敗次數(shù)統(tǒng)計(jì)、短信發(fā)送頻率控制、接口調(diào)用頻控依賴Redis的原子自增操作。這四類場(chǎng)景在eladmin里幾乎都能找到對(duì)應(yīng)的業(yè)務(wù)入口。你會(huì)發(fā)現(xiàn)Redis在后臺(tái)系統(tǒng)里不是“可選項(xiàng)”而是“基礎(chǔ)設(shè)施”。1.3 引入Redis前后的一次對(duì)比我團(tuán)隊(duì)當(dāng)時(shí)做了一個(gè)很小的壓測(cè)同樣的字典查詢接口數(shù)據(jù)庫(kù)直連查詢平均12ms命中Redis緩存之后平均0.8msQPS從900左右升到接近7000。這個(gè)差距在單機(jī)環(huán)境不明顯但一旦上了集群、數(shù)據(jù)量上來(lái)緩存層的存在就是生死線。而且Redis本身是單線程模型IO多路復(fù)用性能非常穩(wěn)定配合Spring Boot Cache抽象層幾乎不用在業(yè)務(wù)代碼里寫很重的緩存邏輯。提示eladmin的依賴?yán)锲鋵?shí)已經(jīng)引入了spring-boot-starter-data-redis但很多分支版本沒有把序列化器配好。第一步不是寫業(yè)務(wù)而是把RedisTemplate的序列化配置修正否則后面全是亂碼和ClassCastException。2. RedisTemplate序列化配置接入eladmin的第一個(gè)分水嶺2.1 默認(rèn)JDK序列化帶來(lái)的亂碼問題我見過太多eladmin項(xiàng)目Redis里長(zhǎng)這樣一堆以\xAC\xED\x00\x05t...開頭的keyvalue用Redis Desktop Manager打開全是亂碼。這是因?yàn)镾pring Boot默認(rèn)的RedisTemplate用的是JdkSerializationRedisSerializer它會(huì)把對(duì)象序列化成二進(jìn)制字節(jié)不僅肉眼不可讀還會(huì)因?yàn)轭惤Y(jié)構(gòu)變化導(dǎo)致反序列化失敗。另外JDK序列化會(huì)附帶大量類元信息一條簡(jiǎn)單的用戶緩存可能膨脹到幾KB內(nèi)存浪費(fèi)嚴(yán)重。這在開發(fā)階段還能忍上了生產(chǎn)就是事故。比如你發(fā)版時(shí)改了實(shí)體類的字段舊緩存的二進(jìn)制流反序列化直接拋異常緩存層級(jí)崩潰數(shù)據(jù)庫(kù)瞬間被打爆。2.2 用GenericJackson2JsonRedisSerializer做全局序列化eladmin的RedisConfig應(yīng)該重寫兩個(gè)BeanRedisTemplate和StringRedisTemplate。核心思路是key序列化用StringRedisSerializer保證可讀性。value序列化用GenericJackson2JsonRedisSerializer把對(duì)象轉(zhuǎn)成JSON字符串存儲(chǔ)兼容性好且能通過class字段保存類型信息反序列化時(shí)不丟類型。hash的key和value也分別指定序列化器。一個(gè)我實(shí)際在用的配置如下Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); // key 使用 String 序列化 StringRedisSerializer stringSerializer new StringRedisSerializer(); GenericJackson2JsonRedisSerializer jsonSerializer new GenericJackson2JsonRedisSerializer(); template.setKeySerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setValueSerializer(jsonSerializer); template.setHashValueSerializer(jsonSerializer); template.afterPropertiesSet(); return template; } }這里有個(gè)細(xì)節(jié)GenericJackson2JsonRedisSerializer默認(rèn)會(huì)把類型信息寫在JSON的class字段里。如果你緩存的對(duì)象里有接口類型、父類引用或者泛型反序列化時(shí)這一手非常關(guān)鍵。但它也不是萬(wàn)能的遇到LocalDateTime這類Java 8時(shí)間類型時(shí)容易出問題一個(gè)常見的坑是序列化后變成數(shù)組格式。我在eladmin里處理這種場(chǎng)景時(shí)會(huì)在ObjectMapper里注冊(cè)JavaTimeModule并調(diào)整日期序列化的格式避免前后端解析不一致。2.3 緩存key的設(shè)計(jì)規(guī)范序列化只是第一步key的命名同樣重要。Redis是鍵值存儲(chǔ)沒有表結(jié)構(gòu)約束key設(shè)計(jì)是否規(guī)范直接決定后續(xù)運(yùn)維的體驗(yàn)。我在eladmin里定的規(guī)范是業(yè)務(wù)域:模塊:唯一標(biāo)識(shí)例如auth:token:{userId}用戶會(huì)話sys:dict:{dictName}字典緩存sys:dept:tree部門樹緩存lock:task:{taskName}定時(shí)任務(wù)的分布式鎖這樣用Redis Desktop Manager或者命令行SCAN匹配前綴時(shí)非常清晰也能通過命名空間做批量清理。另外key盡量短一些能用ID就不拼長(zhǎng)字符串節(jié)省內(nèi)存也提高查詢效率。2.4 配置完成后的最小驗(yàn)證配置改完后不要急著寫業(yè)務(wù)先做一個(gè)最小驗(yàn)證SpringBootTest public class RedisTest { Resource private RedisTemplateString, Object redisTemplate; Test public void test() { redisTemplate.opsForValue().set(auth:token:1, hello-redis); Object value redisTemplate.opsForValue().get(auth:token:1); System.out.println(value); } }跑通這一步再去Redis客戶端看key是否可讀、value是不是JSON字符串。如果看到類似hello-redis的文本內(nèi)容就說(shuō)明序列化配置生效了。提示不要用RedisTemplate直接存泛型對(duì)象比如ListUser如果JSON里只存了class到List內(nèi)部元素類型容易丟失。穩(wěn)妥做法是自定義一個(gè)帶類型包裝的DTO或者用redisTemplate.opsForValue().set(key, JSON.toJSONString(list))配合反序列化手動(dòng)指定類型。3. 緩存注解和RedisUtils兩種緩存方式的邊界3.1 Cacheable適合哪些業(yè)務(wù)eladmin里大量使用Spring Cache的注解緩存比如Cacheable、CacheEvict。注解方式的好處是侵入性小業(yè)務(wù)代碼里不用手寫存取邏輯。適合以下幾類字典類型列表按類型名緩存。部門樹、菜單樹按用戶ID緩存。系統(tǒng)配置項(xiàng)按配置鍵緩存。角色權(quán)限集合按角色I(xiàn)D緩存。一個(gè)典型例子Cacheable(value sys:dict, key #type, unless #result null) public ListDictDetail getDictDetails(String type) { // 數(shù)據(jù)庫(kù)查詢 }這樣第一次查詢后結(jié)果進(jìn)入Redis后續(xù)請(qǐng)求直接命中緩存。但注解緩存有幾個(gè)隱藏問題我后面還會(huì)講失效時(shí)間不好精細(xì)控制、緩存擊穿場(chǎng)景下注解幫不上忙、更新時(shí)機(jī)容易亂。3.2 eladmin里的RedisUtils手動(dòng)緩存eladmin原生的RedisUtils工具類是非常實(shí)用的封裝它把常用的set、get、delete、exists、expire都包了一遍并在設(shè)置值時(shí)自動(dòng)指定過期時(shí)間。對(duì)于簡(jiǎn)單的數(shù)據(jù)我建議直接用這個(gè)工具類而不是每個(gè)地方都寫redisTemplate.opsForValue().xxx()。手動(dòng)緩存適合以下場(chǎng)景數(shù)據(jù)短生命周期比如登錄驗(yàn)證碼5分鐘有效。需要精細(xì)控制過期時(shí)間。緩存數(shù)據(jù)需要在業(yè)務(wù)中頻繁更新比如在線用戶狀態(tài)。數(shù)據(jù)緩存與數(shù)據(jù)庫(kù)更新之間需要手動(dòng)維護(hù)一致性注解方式很難表達(dá)“先改庫(kù)再刪緩存”的順序。舉個(gè)eladmin里的實(shí)際例子登錄成功之后把用戶信息塞進(jìn)Redis設(shè)置30分鐘過期每次請(qǐng)求時(shí)刷新過期時(shí)間實(shí)現(xiàn)“滑動(dòng)續(xù)期”。這個(gè)需求用Cacheable做不到因?yàn)樽⒔饩彺娌恢С謩?dòng)態(tài)刷新TTL但RedisUtils可以輕松完成。3.3 緩存更新機(jī)制優(yōu)先刪而不是更新這是我吃了虧才明白的道理。很多人寫完緩存后想當(dāng)然地認(rèn)為數(shù)據(jù)庫(kù)更新后把Redis值也更新就好了。但緩存更新的路數(shù)很多組合方式容易出錯(cuò)。比如先更新數(shù)據(jù)庫(kù)還是先更新緩存、失敗了怎么辦、并發(fā)下會(huì)不會(huì)讀到舊值。eladmin業(yè)務(wù)里我統(tǒng)一采用“先更新數(shù)據(jù)庫(kù)再刪除緩存”的策略。下一請(qǐng)求發(fā)現(xiàn)緩存未命中再回源數(shù)據(jù)庫(kù)重建緩存。這個(gè)策略在大多數(shù)讀多寫少的后臺(tái)系統(tǒng)里足夠用而且天然避免了“寫緩存失敗導(dǎo)致數(shù)據(jù)不一致”的問題。刪除緩存這個(gè)動(dòng)作本身也是冪等的就算刪晚了也只是多一次數(shù)據(jù)庫(kù)查詢。3.4 字典緩存的完整案例以eladmin的字典模塊為例它的查詢頻率很高但字典數(shù)據(jù)基本穩(wěn)定。如果每次查字典都走數(shù)據(jù)庫(kù)查詢接口響應(yīng)會(huì)受DB性能影響。我的處理方式啟動(dòng)時(shí)預(yù)熱項(xiàng)目啟動(dòng)后調(diào)用一次字典查詢接口把常用字典寫進(jìn)Redis。查詢時(shí)先查緩存命中直接返回。后臺(tái)編輯字典后調(diào)用刪除緩存接口保證下次查詢回源。這里有一個(gè)細(xì)節(jié)分頁(yè)查詢的字典列表不適合直接整體緩存因?yàn)榉猪?yè)排序條件太多緩存命中率低還會(huì)造成緩存key爆炸。我只對(duì)“按字典名稱查詢所有明細(xì)”這種固定場(chǎng)景做緩存列表頁(yè)不緩存Excel導(dǎo)出也不緩存。緩存不是越多越好命中率低的緩存不僅浪費(fèi)內(nèi)存還增加了維護(hù)成本。4. 分布式鎖從緩存組件到協(xié)調(diào)組件4.1 為什么單機(jī)鎖不夠eladmin里的定時(shí)任務(wù)、庫(kù)存扣減類操作在單機(jī)部署時(shí)用Synchronized或ReentrantLock沒問題。但項(xiàng)目上了集群同樣的定時(shí)任務(wù)會(huì)在每個(gè)節(jié)點(diǎn)各執(zhí)行一遍造成重復(fù)數(shù)據(jù)。比如定時(shí)同步訂單狀態(tài)如果兩臺(tái)機(jī)器同時(shí)跑數(shù)據(jù)庫(kù)可能被更新兩次產(chǎn)生臟數(shù)據(jù)。單機(jī)鎖只對(duì)本進(jìn)程生效這時(shí)就需要一個(gè)跨進(jìn)程的鎖分布式鎖。Redis實(shí)現(xiàn)分布式鎖是最常見的方案因?yàn)樗旧硎枪蚕泶鎯?chǔ)所有節(jié)點(diǎn)都能訪問且提供了原子操作。4.2 用Redis手寫一個(gè)可用的分布式鎖最簡(jiǎn)單可靠的方案就是基于SET key value NX EX seconds這個(gè)命令能同時(shí)保證“key不存在才設(shè)置”和“過期時(shí)間自動(dòng)設(shè)置”。用RedisTemplate實(shí)現(xiàn)如下public boolean tryLock(String key, String requestId, long expireSeconds) { // NX: 不存在才設(shè)置 EX: 設(shè)置過期時(shí)間 // 注意 SET_IF_ABSENT NX, SET_ABSENT_TIME EX Boolean result redisTemplate.opsForValue() .setIfAbsent(key, requestId, Duration.ofSeconds(expireSeconds)); return Boolean.TRUE.equals(result); } public boolean releaseLock(String key, String requestId) { // 使用Lua腳本保證“校驗(yàn)持有者再刪除”是原子操作 String script if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end; DefaultRedisScriptLong redisScript new DefaultRedisScript(script, Long.class); Long result redisTemplate.execute(redisScript, List.of(key), requestId); return Long.valueOf(1).equals(result); }這里有兩個(gè)關(guān)鍵點(diǎn)requestId可以是UUID是鎖持有者的唯一標(biāo)識(shí)。釋放鎖時(shí)必須校驗(yàn)這個(gè)標(biāo)識(shí)防止“自己不持有的鎖被自己刪掉”。用Lua腳本包裹“檢查-刪除”操作保證原子性。如果拆成兩步可能在GET之后、DEL之前鎖到期被另一個(gè)線程搶走然后你把別人的鎖刪了。4.3 鎖的續(xù)期問題和Redisson選擇手寫鎖有個(gè)麻煩業(yè)務(wù)執(zhí)行時(shí)間超過鎖的過期時(shí)間怎么辦比如加鎖時(shí)設(shè)了30秒但業(yè)務(wù)跑了40秒鎖自動(dòng)釋放另一個(gè)線程進(jìn)來(lái)了。解決思路是續(xù)期——在鎖即將到期但業(yè)務(wù)還沒結(jié)束時(shí)自動(dòng)延長(zhǎng)過期時(shí)間。這個(gè)邏輯很煩不建議手寫。我在這塊直接引入了Redisson它內(nèi)置了看門狗機(jī)制默認(rèn)鎖續(xù)期到30秒每10秒檢查一次業(yè)務(wù)沒結(jié)束就自動(dòng)續(xù)期業(yè)務(wù)結(jié)束就釋放鎖。代碼反而簡(jiǎn)單很多RLock lock redissonClient.getLock(lock:task:syncOrder); boolean isLocked lock.tryLock(3, 10, TimeUnit.SECONDS); if (isLocked) { try { // 業(yè)務(wù)邏輯 } finally { lock.unlock(); } }引入Redisson的代價(jià)是加一個(gè)依賴和一點(diǎn)內(nèi)存但省去了自己處理續(xù)期、重入、異常釋放的雜活。eladmin里如果已經(jīng)有Redisson依賴優(yōu)先用它的tryLock如果沒有手寫一個(gè)簡(jiǎn)單的也夠用。提示手寫鎖務(wù)必設(shè)置過期時(shí)間絕不能只用setIfAbsent(key, value)不帶過期時(shí)間。一旦分布式鎖的key沒有過期時(shí)間而持有鎖的服務(wù)宕機(jī)這個(gè)鎖就永遠(yuǎn)解不開了下游業(yè)務(wù)全部卡死。4.4 鎖定在eladmin里的實(shí)際場(chǎng)景我在eladmin里落地分布式鎖最多的場(chǎng)景是定時(shí)任務(wù)。eladmin有定時(shí)任務(wù)管理模塊可以動(dòng)態(tài)啟停任務(wù)但任務(wù)本身可能被多個(gè)節(jié)點(diǎn)同時(shí)觸發(fā)。處理方式任務(wù)執(zhí)行入口先嘗試獲取鎖。搶到鎖的節(jié)點(diǎn)執(zhí)行任務(wù)沒搶到的節(jié)點(diǎn)直接跳過。執(zhí)行完成后主動(dòng)釋放鎖。另外批量導(dǎo)入導(dǎo)出、文件分片合并、訂單狀態(tài)機(jī)轉(zhuǎn)換這類場(chǎng)景也需要鎖。核心思路是凡是“多實(shí)例下同一時(shí)間只允許一個(gè)執(zhí)行”的操作都值得用分布式鎖。5. 緩存三大故障現(xiàn)場(chǎng)穿透、擊穿、雪崩的解決實(shí)錄5.1 緩存穿透不存在的key瘋狂打庫(kù)eladmin里權(quán)限校驗(yàn)接口如果被惡意刷每次都會(huì)攜帶一個(gè)不存在的用戶ID查數(shù)據(jù)庫(kù)Redis里始終沒有緩存數(shù)據(jù)庫(kù)壓力直線上升。這種查詢一個(gè)“必然不存在的數(shù)據(jù)”的現(xiàn)象就是緩存穿透。最常用的兩個(gè)辦法緩存空值查詢結(jié)果為空也寫緩存設(shè)置一個(gè)較短的過期時(shí)間比如60秒這樣后續(xù)相同Key不會(huì)打到DB。但要注意空值過多會(huì)占用內(nèi)存需要設(shè)置過期時(shí)間。布隆過濾器把所有合法的用戶ID加載到布隆過濾器查詢前先判斷ID是否存在不存在直接返回。適用于ID集合較小且相對(duì)固定的場(chǎng)景。我在eladmin里處理驗(yàn)證碼校驗(yàn)接口時(shí)用的就是緩存空值方案驗(yàn)證碼錯(cuò)誤就緩存一個(gè)錯(cuò)誤標(biāo)記過期時(shí)間比驗(yàn)證碼本身還短。這樣既保護(hù)了數(shù)據(jù)庫(kù)又不會(huì)影響正常用戶的重新發(fā)送。5.2 緩存擊穿熱點(diǎn)key瞬間過期打崩DB緩存擊穿和穿透容易混淆擊穿指的是某個(gè)熱點(diǎn)key在過期瞬間大量并發(fā)請(qǐng)求同時(shí)回源數(shù)據(jù)庫(kù)。比如eladmin的系統(tǒng)配置緩存如果剛好在零點(diǎn)過期所有請(qǐng)求都去查數(shù)據(jù)庫(kù)一次就夠數(shù)據(jù)庫(kù)喝一壺。解決手段通常有兩種互斥重建用分布式鎖包住回源邏輯只允許一個(gè)線程回源數(shù)據(jù)庫(kù)寫緩存其他線程等待緩存重建完成。缺點(diǎn)是鎖等待會(huì)增加接口耗時(shí)。邏輯過期緩存中不設(shè)置物理過期時(shí)間而是存一個(gè)邏輯過期戳。查詢時(shí)發(fā)現(xiàn)戳已經(jīng)過期就異步重建。優(yōu)點(diǎn)是接口永遠(yuǎn)有數(shù)據(jù)可返回但實(shí)現(xiàn)稍復(fù)雜。在eladmin的字典緩存里我采用的是折中方案熱點(diǎn)key的物理過期時(shí)間拉長(zhǎng)到小時(shí)級(jí)后端修改字典后通過ActiveMQ或者直接調(diào)用刪除緩存接口來(lái)維護(hù)一致性避免“自然過期”引發(fā)擊穿。這個(gè)方案簡(jiǎn)單但依賴“刪除操作必須可靠”。5.3 緩存雪崩大量key同一時(shí)刻失效雪崩和擊穿的區(qū)別在于影響范圍。雪崩是大批key在同一時(shí)間過期或者Redis服務(wù)整體不可用導(dǎo)致數(shù)據(jù)庫(kù)瞬間被沖垮。解決辦法我總結(jié)三條過期時(shí)間加隨機(jī)抖動(dòng)比如基礎(chǔ)過期時(shí)間300秒實(shí)際設(shè)置時(shí)加一個(gè)0到60秒的隨機(jī)數(shù)避免同一秒集體失效。eladmin里的緩存工具類我已經(jīng)統(tǒng)一封裝了這個(gè)邏輯。多級(jí)緩存配合Redis之上加一層本地緩存Caffeine本地緩存失效再查RedisRedis失效再查DB。這樣即使Redis短時(shí)間不可用本地緩存還能扛一部分。Redis高可用部署主從加哨兵或者Redis Cluster避免單點(diǎn)故障。這個(gè)我放在第6節(jié)詳細(xì)講。5.4 曾經(jīng)踩過的坑INCR在分布式環(huán)境下的“不準(zhǔn)”熱詞里有一條“redis incr不準(zhǔn)”我在eladmin里也遇到過。場(chǎng)景是短信驗(yàn)證碼發(fā)送頻率限制Long count redisTemplate.opsForValue().increment(limit:sms: phone); redisTemplate.expire(limit:sms: phone, 60, TimeUnit.SECONDS);這段代碼在單機(jī)Redis下沒有問題。但在主從架構(gòu)下主機(jī)自增后同步給從機(jī)如果主機(jī)宕機(jī)、從機(jī)頂上自增的值可能丟失或重復(fù)導(dǎo)致計(jì)數(shù)不準(zhǔn)。解決方案有幾個(gè)使用Redis Cluster的單一分片鎖住key的哈希槽保證只有一臺(tái)機(jī)器處理該key。頻率限制場(chǎng)景可以接受誤差時(shí)改成SETNX 過期時(shí)間判斷邏輯。或者在Redis事務(wù)Pipeline中執(zhí)行減少錯(cuò)誤概率。另外INCR和EXPIRE是兩個(gè)獨(dú)立命令如果想讓key自動(dòng)過期最好用Lua腳本把兩個(gè)操作包起來(lái)保證原子性否則極端情況下可能key永不過期計(jì)數(shù)累加超過預(yù)期。提示eladmin的接口限流功能如果用了IP計(jì)數(shù)千萬(wàn)不要用get再set的復(fù)合操作必須用原生INCR。兩步操作在并發(fā)下會(huì)丟失增量計(jì)數(shù)這是非常隱蔽的一類bug。6. 從單機(jī)到集群eladmin上線后的Redis部署升級(jí)路線6.1 單機(jī)版夠用嗎開發(fā)環(huán)境和日活幾千的小項(xiàng)目單機(jī)Redis完全夠用。eladmin這種后臺(tái)系統(tǒng)數(shù)據(jù)庫(kù)字段多、接口讀寫比例高單臺(tái)8G內(nèi)存的Redis實(shí)例支撐幾百個(gè)并發(fā)是沒問題的。但是單機(jī)的問題在于故障域。Redis宕機(jī)所有緩存全部失效數(shù)據(jù)庫(kù)被瞬時(shí)打滿整個(gè)系統(tǒng)雪崩。而且一臺(tái)機(jī)器上Redis進(jìn)程如果OOMRedis會(huì)直接拒絕寫入進(jìn)而影響所有依賴Redis的業(yè)務(wù)。因此正規(guī)一點(diǎn)的項(xiàng)目都會(huì)考慮主從。6.2 主從加哨兵怎么配更穩(wěn)主從模式解決的是“單點(diǎn)故障”哨兵解決的是“自動(dòng)故障轉(zhuǎn)移”。我在eladmin生產(chǎn)環(huán)境用的一主二從加三哨兵架構(gòu)主節(jié)點(diǎn)負(fù)責(zé)讀寫。從節(jié)點(diǎn)負(fù)責(zé)讀擴(kuò)展和備份。哨兵監(jiān)控主從狀態(tài)主節(jié)點(diǎn)宕機(jī)后自動(dòng)從從節(jié)點(diǎn)選舉出一個(gè)新主節(jié)點(diǎn)修改客戶端配置的地址指向新主節(jié)點(diǎn)。Spring Boot里配置Sentinel模式的地址很簡(jiǎn)單spring: data: redis: sentinel: master: mymaster nodes: - 192.168.1.10:26379 - 192.168.1.11:26379 - 192.168.1.12:26379注意哨兵模式下應(yīng)用連接的是哨兵地址而不是Redis節(jié)點(diǎn)地址。哨兵會(huì)告訴你當(dāng)前可寫的主節(jié)點(diǎn)是誰(shuí)。這塊如果配錯(cuò)應(yīng)用會(huì)一直連不上Redis日志里反復(fù)報(bào)Connection refused。主從復(fù)制還有一個(gè)核心點(diǎn)需要注意replica-read-only建議設(shè)置為yes。從節(jié)點(diǎn)只做讀寫操作全部走主節(jié)點(diǎn)否則可能會(huì)發(fā)生數(shù)據(jù)不一致。6.3 什么時(shí)候值得上ClusterRedis Sentinel解決的是“故障轉(zhuǎn)移”但單節(jié)點(diǎn)的內(nèi)存容量上限還在。當(dāng)緩存數(shù)據(jù)量大到單節(jié)點(diǎn)內(nèi)存裝不下或者寫入并發(fā)超過單實(shí)例上限時(shí)就該考慮Redis Cluster。Cluster采用分片存儲(chǔ)每個(gè)key根據(jù)CRC16算法映射到16384個(gè)哈希槽中的某一個(gè)然后哈希槽分配給不同節(jié)點(diǎn)。好處是容量和性能都線性擴(kuò)展壞處是配置復(fù)雜、客戶端要支持Cluster模式且Redis Cluster不支持多key操作除非這些key在同一個(gè)Hash標(biāo)簽下。eladmin項(xiàng)目的緩存體量通常到不了Cluster的規(guī)模但如果未來(lái)做多租戶、數(shù)據(jù)量暴漲可以提前把key的Hash Tag設(shè)計(jì)好比如把所有需要一起操作的key都加上{user:1}這樣的Hash前綴確保它們落在同一個(gè)槽里。這個(gè)設(shè)計(jì)越早做越省事。6.4 Docker部署Redis時(shí)的常見錯(cuò)誤很多團(tuán)隊(duì)用Docker部署Redis熱詞里提到的“docker search redis request returned 500 internal server error”我碰到過多次。這個(gè)報(bào)錯(cuò)通常是Docker Desktop的引擎沒起來(lái)或者API版本不兼容解決辦法是重啟Docker Desktop或者檢查Docker引擎的版本。真正進(jìn)入docker pull redis階段之后還需要注意掛載持久化目錄比如/data防止容器重啟后數(shù)據(jù)丟失。指定Redis密碼和配置文件不要用默認(rèn)無(wú)密碼的配置。如果宿主機(jī)和Redis容器不在同一網(wǎng)絡(luò)需要注意防火墻和端口映射。用Docker Compose部署一主二從加哨兵集群是本地復(fù)現(xiàn)和學(xué)習(xí)的好方式比手動(dòng)敲命令省心很多。但這個(gè)更多是運(yùn)維話題本文就不展開了。7. 體檢清單慢查詢、大key和熱點(diǎn)key的排查手段7.1 Redis慢查詢?nèi)罩驹趺纯碦edis的慢查詢?nèi)罩竞蚆ySQL的slow query log思路類似。配置項(xiàng)slowlog-log-slower-than設(shè)置超時(shí)閾值單位微秒slowlog-max-len設(shè)置日志條數(shù)。執(zhí)行SLOWLOG GET可以查看最近慢查詢命令。eladmin的緩存基本都是小Key很少有長(zhǎng)命令但如果有KEYS *這類命令出現(xiàn)在慢查詢里基本可以斷定代碼有問題。Redis是單線程的一個(gè)耗時(shí)的KEYS命令會(huì)阻塞所有客戶端響應(yīng)線上絕不能執(zhí)行。我見過有人用KEYS auth:token:*來(lái)批量清理用戶會(huì)話導(dǎo)致Redis卡了幾秒所有依賴緩存的接口全部超時(shí)。正確做法是用SCAN游標(biāo)遍歷分批次刪除。7.2 大key是隱藏的內(nèi)存炸彈大key通常指value特別大的key比如一個(gè)Hash里塞了幾十萬(wàn)個(gè)字段或者一個(gè)String存了幾MB的數(shù)據(jù)。大key會(huì)導(dǎo)致Redis內(nèi)存碎片增加、刪除/序列化耗時(shí)變長(zhǎng)還會(huì)在主從復(fù)制時(shí)造成網(wǎng)絡(luò)帶寬壓力。在eladmin里最常見的兩個(gè)大key場(chǎng)景緩存的用戶權(quán)限集合沒有控制大小一個(gè)超管用戶的權(quán)限菜單串成一個(gè)大JSON。字典緩存把所有數(shù)據(jù)一股腦塞進(jìn)一個(gè)key而不是按類型拆分。排查手段用redis-cli --bigkeys它會(huì)掃描找出大key并給出類型和建議。治理方式很直接大key拆小key、續(xù)期策略調(diào)整、數(shù)據(jù)結(jié)構(gòu)優(yōu)化。比如權(quán)限集合按角色拆分字典按類型拆分。7.3 內(nèi)存淘汰策略的選擇Redis內(nèi)存寫滿后怎么辦配置maxmemory-policy決定了淘汰策略。后臺(tái)管理系統(tǒng)里我推薦volatile-lru只在設(shè)置了過期時(shí)間的key里淘汰最近最少使用的數(shù)據(jù)。這樣業(yè)務(wù)緩存是“可淘汰”的但正在使用的會(huì)話數(shù)據(jù)不會(huì)被強(qiáng)制清掉。相比之下allkeys-lru會(huì)對(duì)所有key做淘汰包括你不想丟的業(yè)務(wù)緩存可能導(dǎo)致關(guān)鍵數(shù)據(jù)意外消失。還有一點(diǎn)maxmemory不要設(shè)成Redis所在服務(wù)器的全部?jī)?nèi)存要留出一部分給操作系統(tǒng)、其他進(jìn)程防止Redis OOM把整臺(tái)服務(wù)器壓垮。我一般給Redis分配機(jī)器內(nèi)存的60%-70%左右。7.4 可視化工具和日常巡檢建議命令行工具就是redis-cli圖形化工具我常用Another Redis Desktop Manager和Redis Insight。選工具主要看是否支持連接哨兵、Cluster、SSH隧道。日常巡檢建議做成定時(shí)腳本每天掃一遍INFO memory查看內(nèi)存使用率。INFO replication查看主從復(fù)制狀態(tài)確認(rèn)沒有斷連。INFO stats看連接數(shù)和命中率。SLOWLOG GET 50檢查慢命令。這些指標(biāo)配合Prometheus和Grafana可以做到很完善的監(jiān)控但哪怕沒有專業(yè)監(jiān)控系統(tǒng)定時(shí)敲一下命令也比完全不看強(qiáng)一百倍。Redis掛了不可怕可怕的是你不知道它什么時(shí)候開始變慢、內(nèi)存什么時(shí)候開始飆升。結(jié)尾經(jīng)驗(yàn)這套東西從頭到尾梳理下來(lái)我覺得在eladmin里用好Redis的關(guān)鍵只有兩點(diǎn)一是序列化和key設(shè)計(jì)必須在一開始就定好規(guī)范這個(gè)決定后續(xù)所有緩存代碼的體驗(yàn)二是把分布式鎖、緩存擊穿、集群部署這些“進(jìn)階能力”當(dāng)作標(biāo)配去考慮而不是等出了事故再補(bǔ)。我經(jīng)歷過Redis配置亂碼導(dǎo)致生產(chǎn)環(huán)境接口直接500的夜晚也經(jīng)歷過分布式鎖誤刪把定時(shí)任務(wù)數(shù)據(jù)弄亂的尷尬。現(xiàn)在已經(jīng)養(yǎng)成的習(xí)慣是任何redisTemplate的讀寫都先問自己“key規(guī)范嗎序列化對(duì)嗎過期時(shí)間設(shè)了嗎這行代碼在集群下還成立嗎”這四個(gè)問題能擋住絕大部分線上事故。希望這篇實(shí)踐對(duì)正在折騰eladmin的朋友有幫助。