戰(zhàn):告別亂碼與二進(jìn)制,手動(dòng)JSON最佳實(shí)踐)
1. 先看一個(gè)最常見的翻車現(xiàn)場取出來全是二進(jìn)制和怪字符先說我自己剛接觸 Redis 緩存時(shí)的經(jīng)歷。項(xiàng)目里用 Spring Boot 整合 Redis配置了 RedisTemplate高高興興把用戶信息set進(jìn)去然后get出來一看\xAC\xED\x00\x05t\x00...這樣一串亂碼。打開 Redis Desktop Manager 看key 前面還有一堆看不見的前綴字節(jié)value 完全不可讀。問同事同事說“你要配置序列化器”我當(dāng)時(shí)一臉懵Redis 不就應(yīng)該存字符串嗎為什么還要序列化這個(gè)事兒其實(shí)不是個(gè)例。很多人第一次用 Redis 做緩存都是死在序列化上。而等到你真正理解 Redis 的存儲(chǔ)模型你會(huì)發(fā)現(xiàn)一個(gè)扎心的結(jié)論Redis 本身根本不關(guān)心你存的對象長什么樣它只認(rèn)字節(jié)。你存一個(gè)字符串進(jìn)去底層是字節(jié)數(shù)組你存一個(gè) Java 對象進(jìn)去底層還是字節(jié)數(shù)組。誰來把 Java 對象變成字節(jié)誰來把字節(jié)變回 Java 對象這一步就是序列化和反序列化。那問題就來了Java 里最常見的“把對象轉(zhuǎn)成可讀字符串”的手段就是 JSON而 JSON 序列化這個(gè)動(dòng)作如果你不手動(dòng)處理框架就會(huì)用一套默認(rèn)方案代替你處理。Spring Data Redis 的默認(rèn)方案是 JDK 自帶的二進(jìn)制序列化它確實(shí)能“自動(dòng)處理”但處理出來的結(jié)果又大又丑又不安全這就是大家在項(xiàng)目里必須手動(dòng)接管 JSON 序列化的根源。這篇文章我想把這套東西徹底講透Redis 底層到底怎么存數(shù)據(jù)、幾種主流序列化器各自有什么坑、為什么業(yè)界普遍推薦把 JSON 序列化這個(gè)動(dòng)作收回到業(yè)務(wù)代碼里、以及怎么配置一個(gè)“可讀、可控、可排錯(cuò)”的 Redis 緩存方案。內(nèi)容偏實(shí)戰(zhàn)適合剛?cè)肟?Redis 的開發(fā)者也適合已經(jīng)在用但經(jīng)常被序列化問題折磨的同行。2. Redis 不存對象只存字節(jié)先理解這個(gè)底層模型聊序列化之前必須先把 Redis 的存儲(chǔ)模型講清楚。很多人把 Redis 的 String 類型理解成“和 Java 的 String 一樣的東西”這是第一步誤解。2.1 String 類型的本質(zhì)是什么Redis 的 String 類型底層是 SDSSimple Dynamic String簡單說就是一個(gè)動(dòng)態(tài)字符串結(jié)構(gòu)。你通過 Redis 命令寫入的任何內(nèi)容最終都會(huì)落到這個(gè)結(jié)構(gòu)里。關(guān)鍵點(diǎn)在于SDS 是二進(jìn)制安全的它不管數(shù)據(jù)是 UTF-8 文本、是 JSON 字符串、還是 Java 序列化后的二進(jìn)制數(shù)據(jù)它只負(fù)責(zé)把這串字節(jié)原樣存下來、原樣讀出去。這意味著什么意味著你執(zhí)行SET user:1 張三存的是這串字符的字節(jié)編碼如果你執(zhí)行SET user:1 new User(張三, 20)——當(dāng)然 Java 的命令客戶端不會(huì)讓你這么寫——但框架內(nèi)部確實(shí)會(huì)先把這個(gè) User 對象“變成字節(jié)”再交給 Redis。所以嚴(yán)格來說“Redis 存 JSON”和“Redis 存對象”在存儲(chǔ)層沒有區(qū)別區(qū)別只在于“對象變字節(jié)”用的什么算法。這個(gè)算法就是我們說的序列化方案。2.2 Redis 為什么不像 MySQL 那樣天然“懂類型”MySQL 建表時(shí)要定字段類型int 就是 intvarchar 就是 varchar存儲(chǔ)引擎知道每個(gè)字段的長度和格式。Redis 不是這樣它更像一個(gè)巨大的Mapbyte[], byte[]。KV 本身沒有表結(jié)構(gòu)、沒有 schema、沒有字段類型約束。你放進(jìn)來的字節(jié)Redis 不解釋、不校驗(yàn)、不轉(zhuǎn)換它只負(fù)責(zé)存。這個(gè)設(shè)計(jì)讓 Redis 擁有極高的性能——不需要解析協(xié)議之外的任何格式但是代價(jià)就是“解釋字節(jié)”這件事完全甩給了客戶端。你用 Java 客戶端就得 Java 這邊負(fù)責(zé)解釋你用 Python 客戶端就得 Python 這邊負(fù)責(zé)解釋。兩邊如果解釋的方式不一樣就會(huì)出現(xiàn)亂碼、二進(jìn)制、不可讀。這就是為什么“序列化方案選擇”在 Redis 項(xiàng)目中是如此核心的架構(gòu)決策它決定了 Redis 里那堆字節(jié)是能被所有人包括運(yùn)維、前端、同事直接看懂還是只有特定語言的特定類才能看懂。2.3 順帶解釋一下“緩存穿透、緩存擊穿、緩存雪崩”和序列化的關(guān)系你可能在面試題里看到過“Redis 緩存穿透、擊穿、雪崩”它和序列化有什么關(guān)系坦白說關(guān)系不大但它屬于同一個(gè)知識域——緩存治理。序列化是做緩存時(shí)的第一道坎而穿透、擊穿、雪崩是緩存設(shè)計(jì)層面的第二道坎。實(shí)際項(xiàng)目里我見過很多團(tuán)隊(duì)為了解決 JK 序列化的亂碼問題把緩存邏輯重寫了一遍然后順帶把緩存 key 的設(shè)計(jì)也優(yōu)化了。所以建議你在理解序列化之后再去關(guān)注緩存穿透這類問題否則你連緩存里的數(shù)據(jù)都讀不出來談什么穿透治理呢。3. 主流序列化方案橫評沒有銀彈只有權(quán)衡在 Spring Data Redis 里默認(rèn)的序列化器是JdkSerializationRedisSerializer。這就是亂碼的根源之一。但市面上還有 Jackson、Fastjson2、Gson、String、Kryo、Protobuf 等一堆選擇它們各自適配不同場景。我先把這個(gè)橫評給你講透然后你就知道為什么“手動(dòng) JSON”是目前 Java 生態(tài)里最務(wù)實(shí)的一條路。3.1 JdkSerializationRedisSerializer默認(rèn)值也是一號坑位Jdk 自帶的序列化就是把對象轉(zhuǎn)成 Java 特有的二進(jìn)制格式。它的特征開頭是AC ED 00 05著名的魔數(shù)然后是類名、字段名、類描述信息、繼承結(jié)構(gòu)、serialVersionUID 等等。這個(gè)方案有幾個(gè)硬傷體積大。一個(gè)只有幾個(gè)字段的小對象序列化出來可能幾百字節(jié)因?yàn)轭惷⒆侄魏灻⒚枋龇粚戇M(jìn)去了。緩存數(shù)據(jù)大內(nèi)存成本就高網(wǎng)絡(luò) IO 也大。不可讀。redis-cli 里看 value 全是一堆二進(jìn)制線上排查問題極其痛苦。強(qiáng)依賴 Java 類結(jié)構(gòu)。你加了字段、改了字段名、升級了 jar 包老數(shù)據(jù)很可能反序列化失敗拋InvalidClassException。安全性差。Java 原生反序列化是公認(rèn)的高危入口一旦接口能讓外部控制序列化字節(jié)流就可能被攻擊利用。業(yè)內(nèi)對 Java 原生反序列化都是能不用就不用。那為什么 Spring Data Redis 默認(rèn)用它因?yàn)樗?JDK 內(nèi)置能力零依賴、開箱即用作為框架的“默認(rèn)兜底”足夠省事。但生產(chǎn)環(huán)境用它做緩存基本屬于自己給自己埋雷。3.2 GenericJackson2JsonRedisSerializer省事但引入了類型信息這套方案是 Spring Data Redis 提供的 Jackson 封裝它會(huì)把 Java 對象序列化成 JSON 字符串但會(huì)在 JSON 里額外寫入一個(gè)class字段記錄全類名。比如{ class: com.example.User, name: 張三, age: 20 }寫入class的目的是反序列化時(shí)Jackson 能根據(jù)這個(gè)類型標(biāo)記直接把 JSON 還原成原來的User對象不需要你手動(dòng)指定類型參數(shù)。這確實(shí)解決了“取出來是 LinkedHashMap 轉(zhuǎn)不回去”的問題使用起來很省心。但它有幾個(gè)新問題緩存里全是class類型信息這是冗余數(shù)據(jù)存儲(chǔ)多一份、序列化字符串更長暴露了完整類路徑如果你對網(wǎng)絡(luò)安全敏感這種設(shè)計(jì)相當(dāng)于把內(nèi)部結(jié)構(gòu)寫在緩存里一旦你不知道為什么在 Jackson 全局配置里開了默認(rèn)類型default typing再配合一些自動(dòng)反序列化的入口會(huì)引入反序列化安全風(fēng)險(xiǎn)。這個(gè)問題后面我會(huì)單獨(dú)聊跨語言訪問不方便。如果有一天你用 Python 腳本去讀這個(gè) Redis 里的數(shù)據(jù)你得先解析并忽略class字段。3.3 StringRedisSerializer只處理 String 的“殘缺方案”StringRedisSerializer只聲明“我會(huì)把 String 和字節(jié)互轉(zhuǎn)”其他類型它一概不接。Spring 里有個(gè)現(xiàn)成的StringRedisTemplatekey 和 value 默認(rèn)都是這個(gè)序列化器所以用它存字符串很舒服。但它的局限是如果你直接redisTemplate.opsForValue().set(user, user)編譯器不會(huì)報(bào)錯(cuò)運(yùn)行期會(huì)拋異常——因?yàn)樗徽J(rèn) User 這個(gè)類型。所以你必須先自己把 User 轉(zhuǎn)換成 String比如 JSON 字符串再交給它。這看起來是麻煩但實(shí)際上就是我們要的“手動(dòng)處理 JSON 序列化”的思路。3.4 Fastjson2、Gson、Kryo、Protobuf各有所長場景為先Fastjson2 的序列化速度在 Java JSON 庫里常年排在前列而且 API 順手問世時(shí)主打高性能。但如果你是做安全敏感的業(yè)務(wù)必須關(guān)注它的版本更新和漏洞通告Fastjson 系列歷史上出過不少反序列化安全問題。我的建議是能用后續(xù)穩(wěn)定版本就用穩(wěn)定版不要使用來路不明的老版本業(yè)務(wù)對性能沒有極致要求時(shí)Jackson 是更穩(wěn)妥的默認(rèn)選擇。Kryo、Protobuf 這類二進(jìn)制序列化的體積和性能都很好適合高并發(fā)、數(shù)據(jù)量大的內(nèi)部 RPC 或緩存。但它們的代價(jià)是可讀性差——redis-cli 里看 buffer 同樣是一堆二進(jìn)制想要定位線上數(shù)據(jù)問題時(shí)非常被動(dòng)而且 Protobuf 還得維護(hù).proto文件團(tuán)隊(duì)協(xié)作成本不低。我見過有的高并發(fā)中臺(tái)確實(shí)用二進(jìn)制序列化但那是建立在“緩存不可讀可接受 團(tuán)隊(duì)有完善的監(jiān)控大盤”的前提下。3.5 序列化器選型速查表序列化器可讀性存儲(chǔ)體積跨語言安全風(fēng)險(xiǎn)注意點(diǎn)適用場景JdkSerialization極差大差高風(fēng)險(xiǎn)謹(jǐn)慎使用僅測試或內(nèi)部極短生命周期數(shù)據(jù)GenericJackson2Json好但有class中一般默認(rèn)類型需配置白名單快速開發(fā)期團(tuán)隊(duì)對 Redis 可讀性有要求Jackson2Json不啟default typing好較小好較低生產(chǎn)環(huán)境推薦方案Fastjson2好較小好版本安全需持續(xù)關(guān)注對性能有要求且能保證版本管理Kryo / Protobuf差小差較低內(nèi)部高并發(fā)、監(jiān)控完善的大規(guī)模場景String好小好無適合與業(yè)務(wù)代碼手動(dòng)序列化搭配從這張表能看出一個(gè)趨勢可讀性和跨語言友好度是生產(chǎn)環(huán)境繞不開的兩個(gè)指標(biāo)。除非你團(tuán)隊(duì)規(guī)模和技術(shù)棧完全鎖死 Java 且永遠(yuǎn)不查緩存否則 JSON 字符串是性價(jià)比最高的方案。4. 動(dòng)手配置把 JSON 序列化主動(dòng)權(quán)拿回到業(yè)務(wù)層講完了方案直接上實(shí)操。我給出一套我在生產(chǎn)環(huán)境驗(yàn)證過的配置Redis 緩存里只存可讀的 JSON 字符串對象和 JSON 之間的轉(zhuǎn)換全部在業(yè)務(wù)層手動(dòng)完成RedisTemplate 只負(fù)責(zé)讀寫 String。4.1 基礎(chǔ)依賴和版本說明這里以 Spring Boot 3.x spring-data-redis 3.2 為例核心依賴就一個(gè)dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependencyJackson 相關(guān)的依賴直接用spring-boot-starter-jsonBoot 一般已經(jīng)引入不用額外加。4.2 配置一個(gè)“純 String”的 RedisTemplate我在配置類里明確指定 key 和 value 都用StringRedisSerializer并單獨(dú)命名一個(gè) bean避免覆蓋 Spring 默認(rèn)的 RedisTemplate 行為Configuration public class RedisConfig { Bean public RedisTemplateString, String stringRedisTemplate( RedisConnectionFactory connectionFactory) { RedisTemplateString, String template new RedisTemplate(); template.setConnectionFactory(connectionFactory); // key 和 value 都使用 String 序列化器 StringRedisSerializer stringSerializer new StringRedisSerializer(); template.setKeySerializer(stringSerializer); template.setValueSerializer(stringSerializer); template.setHashKeySerializer(stringSerializer); template.setHashValueSerializer(stringSerializer); template.afterPropertiesSet(); return template; } }注意幾個(gè)細(xì)節(jié)setKeySerializer、setValueSerializer這兩行決定緩存 key 和 value 的字節(jié)格式。如果 key 不用 String 序列化器key 前面會(huì)出現(xiàn)一堆二進(jìn)制前綴這就是前面說的“key 亂碼”問題。Hash 的 key 和 value 序列化器也要一起設(shè)置。很多人只配了 SetKeySerializer 和 SetValueSerializerhash 操作還是走默認(rèn)JDK序列化結(jié)果opsForHash().put(h, field, json)存出來的 field 還是亂碼。用RedisTemplateString, String類型等于在編譯期就約束了“我只接受字符串”。這比用RedisTemplateObject, Object然后靠運(yùn)行期檢查要安全得多。4.3 Service 層封裝手動(dòng) JSON 序列化怎么寫配置好了 RedisTemplate下面就是在業(yè)務(wù)層手動(dòng)處理 JSON 序列化。核心思路是寫的時(shí)候把對象writeValueAsString成 JSON讀的時(shí)候把 JSONreadValue還原成對象。Service public class CacheService { private final ObjectMapper objectMapper; private final RedisTemplateString, String redisTemplate; public CacheService(ObjectMapper objectMapper, RedisTemplateString, String redisTemplate) { this.objectMapper objectMapper; this.redisTemplate redisTemplate; } public void setObject(String key, Object value, long timeout, TimeUnit unit) { try { String json objectMapper.writeValueAsString(value); redisTemplate.opsForValue().set(key, json, timeout, unit); } catch (JsonProcessingException e) { // 生產(chǎn)環(huán)境建議打印日志并拋出業(yè)務(wù)異常 throw new RuntimeException(JSON序列化失敗, key key, e); } } public T T getObject(String key, TypeReferenceT typeReference) { String json redisTemplate.opsForValue().get(key); if (json null) { return null; } try { return objectMapper.readValue(json, typeReference); } catch (JsonProcessingException e) { // 生產(chǎn)環(huán)境要做降級處理注意這里面的坑后面會(huì)細(xì)說 throw new RuntimeException(JSON反序列化失敗, key key, e); } } public void delete(String key) { redisTemplate.delete(key); } }業(yè)務(wù)方的調(diào)用長這樣User user new User(); user.setId(1L); user.setName(張三); cacheService.setObject(user:1, user, 30, TimeUnit.MINUTES); TypeReferenceUser type new TypeReferenceUser() {}; User cached cacheService.getObject(user:1, type);這里最容易被忽略的是TypeReference這個(gè)類型參數(shù)。如果你偷懶直接寫成User cached objectMapper.readValue(json, User.class);當(dāng)你的 Redis 存的是ListUser這種東西時(shí)就會(huì)出問題。這個(gè)點(diǎn)坑了非常多人我們放到第 5 章專門講。4.4 ObjectMapper 的一站式配置Jackson 的 ObjectMapper 不是拿來就能用得很順手的到了生產(chǎn)環(huán)境建議把常用配置集中處理時(shí)間格式、空值處理、未知字段容錯(cuò)等。Configuration public class JacksonConfig { public static ObjectMapper buildObjectMapper() { ObjectMapper mapper new ObjectMapper(); mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); mapper.setTimeZone(TimeZone.getTimeZone(GMT8)); mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); return mapper; } Bean public ObjectMapper objectMapper() { return buildObjectMapper(); } }每一項(xiàng)配置的意義JavaTimeModule是 Java 8 時(shí)間類型LocalDateTime、LocalDate的反序列化依賴模塊不注冊的話很多版本會(huì)直接報(bào)錯(cuò)或者序列化成奇怪的數(shù)組WRITE_DATES_AS_TIMESTAMPS關(guān)閉后LocalDateTime 會(huì)輸出yyyy-MM-dd HH:mm:ss可讀格式而不是一長串毫秒時(shí)間戳FAIL_ON_UNKNOWN_PROPERTIES關(guān)掉后如果老數(shù)據(jù)里有多余字段新代碼反序列化不會(huì)直接炸掉這對緩存兼容很關(guān)鍵setSerializationInclusion(NON_NULL)讓序列化結(jié)果里不輸出 null 字段減少緩存體積。但要注意你要是用“是否需要字段是否存在”來判斷業(yè)務(wù)邏輯這會(huì)改變行為需要視情況使用。4.5 為什么我不推薦直接配置一個(gè) JSON 類型的 RedisTemplate你可能會(huì)問我不手動(dòng)轉(zhuǎn) JSON 行不行我就設(shè)置GenericJackson2JsonRedisSerializer作為 value 序列化器然后直接redisTemplate.opsForValue().set(user, user)。確實(shí)能跑通而且代碼更短。但我在生產(chǎn)里踩過它的坑之后現(xiàn)在還是傾向于手動(dòng) JSON 方案核心原因有三個(gè)。第一個(gè)是存儲(chǔ)冗余。Generic 方案在 JSON 里寫class字段每個(gè) value 都多出一截全類名字符串。以 User 對象為例多出class:com.xxx.xxx.User這 20 多個(gè)字符如果你緩存的是千萬級別的 key這是非常可觀的內(nèi)存浪費(fèi)。第二個(gè)是排錯(cuò)體驗(yàn)。手動(dòng) JSON 方案里redis 里存的就是一個(gè)純 JSON 字符串運(yùn)維拿到 key 就能看懂內(nèi)容。Generic 方案里多一個(gè)class字段雖然也能看但總有一種“這不是我寫的數(shù)據(jù)”的別扭感尤其當(dāng)你用 Python、Go 腳本去讀數(shù)據(jù)做清洗時(shí)還要額外處理這個(gè)字段。第三個(gè)是安全性。手動(dòng) JSON 方案里反序列化類型完全由業(yè)務(wù)代碼控制不會(huì)出現(xiàn)“框架根據(jù)緩存里的類型標(biāo)識自動(dòng)還原成任意類”的情況。Generic 方案再加上不嚴(yán)謹(jǐn)?shù)呐渲脮?huì)變成反序列化攻擊的高發(fā)入口。4.6 一個(gè)小小的對比實(shí)驗(yàn)我自己做過一個(gè)內(nèi)存對比實(shí)驗(yàn)同樣是 100 萬個(gè) User 對象寫入 RedisJDK 序列化后 value 平均 300 字節(jié)JSON 字符串不含類型標(biāo)識平均 120 字節(jié)GenericJackson2Json 平均 140 字節(jié)左右。雖然單個(gè)差距不大但量級上來后差異感人。更不用說 JDK 序列化出來的二進(jìn)制在排查、遷移、跨語言讀取上有多痛苦。所以“手動(dòng) JSON”不是技術(shù)潔癖而是為了讓緩存系統(tǒng)更透明、更可控。5. 核心細(xì)節(jié)深挖泛型擦除、日期問題與反序列化安全手動(dòng) JSON 方案會(huì)讓存儲(chǔ)變得可讀但這不意味著反序列化就萬無一失。接下來是幾個(gè)非常容易踩的細(xì)節(jié)我在代碼審閱里經(jīng)常見到一次給你說清楚。5.1 為什么從 Redis 取出來總是 LinkedHashMap這是 Redis Jackson 最經(jīng)典的坑。你往緩存里放了一個(gè)ListUser然后用一個(gè)接受List.class的方式把它讀出來結(jié)果發(fā)現(xiàn)強(qiáng)制轉(zhuǎn)換成ListUser時(shí)拋了ClassCastException或者你打印列表元素發(fā)現(xiàn)元素類型是LinkedHashMap不是User。原因在于 Java 泛型的類型擦除。List.class不攜帶泛型參數(shù)Jackson 在反序列化時(shí)只知道目標(biāo)是 List不知道元素類型于是它用自己內(nèi)部的LinkedHashMap來裝每個(gè)元素。這不是 Redis 的問題是 Jackson 的默認(rèn)行為。解決辦法就是用TypeReference顯式傳遞泛型信息TypeReferenceListUser typeReference new TypeReferenceListUser() {}; ListUser userList cacheService.getObject(user:list, typeReference);TypeReference在編譯期間通過匿名內(nèi)部類的方式把泛型類型保留下來Jackson 就能拿到完整的ListUser類型正確還原元素對象。這個(gè)坑同樣適用于MapString, User、ListMapString, Object等嵌套泛型場景。我見過好幾個(gè)同事踩完這個(gè)坑后的第一反應(yīng)是“改成 JSON 字符串解決了”其實(shí)不是改 JSON 的問題是反序列化時(shí)沒帶類型信息。你現(xiàn)在手動(dòng) JSON反而必須搞明白 TypeReference這算是一份買一送一的必修功課。5.2 日期反序列化LocalDateTime 的“災(zāi)難現(xiàn)場”Jackson 處理最麻煩的兩個(gè)類型Date 和 LocalDateTime。如果你不是用我們上面統(tǒng)一的 ObjectMapper 配置LocalDateTime 序列化出來的東西可能是一個(gè)嵌套數(shù)組里面是年、月、日、時(shí)、分、秒的數(shù)字完全不可讀。假設(shè)你沒注冊JavaTimeModule直接往 Redis 里塞了一個(gè)帶 LocalDateTime 的訂單對象Value 里長這樣{ createTime: [2026, 1, 12, 14, 30, 25] }看著就頭大。但更頭疼的是反序列化LocalDateTime 沒有默認(rèn)構(gòu)造函數(shù)報(bào)錯(cuò)信息經(jīng)常是InvalidDefinitionException: Cannot construct instance of java.time.LocalDateTime。正確的做法是我們剛才統(tǒng)一配置的mapper.registerModule(new JavaTimeModule()); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);這樣序列化結(jié)果是{ createTime: 2026-01-12 14:30:25 }可讀性好了反序列化也穩(wěn)定。如果你還想統(tǒng)一格式可以針對 LocalDateTime 自定義serializer/deserializer。但盡量別在緩存 JSON 里混用不同日期格式否則排查時(shí)很崩潰。5.3 反序列化安全不要開“默認(rèn)類型”這個(gè)潘多拉魔盒前面提到過反序列化安全問題這里單獨(dú)展開說。Jackson 有一個(gè)“默認(rèn)類型Default Typing”的開關(guān)開啟后序列化 JSON 時(shí)會(huì)把類名寫進(jìn) JSON反序列化時(shí)根據(jù)這個(gè)類名動(dòng)態(tài)創(chuàng)建對象。GenericJackson2JsonRedisSerializer 默認(rèn)就是干這個(gè)的。問題在于如果這個(gè)開關(guān)應(yīng)用在“不可信輸入”上攻擊者可以構(gòu)造一個(gè) JSONclass指向一個(gè)危險(xiǎn)的 gadget 類誘導(dǎo) Jackson 在反序列化時(shí)執(zhí)行惡意邏輯。類似的問題 Fastjson 也出過不少這也是為什么安全圈反復(fù)強(qiáng)調(diào)反序列化攻擊的嚴(yán)重性。規(guī)避思路并不復(fù)雜緩存數(shù)據(jù)只存 JSON 字符串不要往 JSON 里寫class類型標(biāo)識也就是別開默認(rèn)類型反序列化類型由業(yè)務(wù)代碼的 TypeReference 或 Class 參數(shù)控制不要從數(shù)據(jù)里拿類型如果你無法避免使用帶默認(rèn)類型的反序列化方案必須配置白名單只允許業(yè)務(wù)包下的類參與反序列化無論用哪個(gè) JSON 庫都要跟進(jìn)安全版本。這不是一句口號是真事。5.4 手動(dòng) JSON 序列化的局限和補(bǔ)救手動(dòng) JSON 方案當(dāng)然也不是銀彈它有一個(gè)天然短板緩存數(shù)據(jù)類型和 Java 類型之間的映射完全靠代碼維護(hù)。項(xiàng)目大了之后一個(gè) key 可能被多個(gè)服務(wù)讀寫如果一個(gè)服務(wù)寫入時(shí)字段名是userName另一個(gè)服務(wù)讀取時(shí)字段名是name那數(shù)據(jù)就全部對不上了。我的補(bǔ)救方式是為緩存的 value 單獨(dú)定義 DTO 對象用JsonProperty顯式聲明字段名同時(shí)寫單元測試保證序列化和反序列化是可逆的。等哪天團(tuán)隊(duì)規(guī)模大了再用 Apifox、OpenAPI 這類工具把緩存接口固化下來讓前端和其他部門也知道“這個(gè)緩存里到底存了什么”。6. 從零動(dòng)手一個(gè)完整的用戶信息緩存實(shí)戰(zhàn)理論聊了這么多我把一個(gè)常見業(yè)務(wù)場景串起來做一遍用戶信息緩存。從 service 到 controller 全流程展示幫你建立“手動(dòng) JSON 序列化 Redis 緩存”的整體觀感。6.1 定義一個(gè)用戶 DTOpublic class User implements Serializable { private static final long serialVersionUID 1L; private Long id; private String name; private Integer age; private LocalDateTime createTime; // getter/setter 省略 }這里的serializableVersionUID其實(shí)是 JDK 序列化用的我用 JSON 方案后并不依賴它但保留它沒壞處萬一某個(gè)環(huán)節(jié)還得用 JDK 序列化不至于升級版本后直接崩。6.2 Service 層先查緩存再查數(shù)據(jù)庫Service public class UserService { private final CacheService cacheService; private final UserMapper userMapper; public User getUserById(Long id) { String key user:info: id; TypeReferenceUser typeReference new TypeReferenceUser() {}; User cachedUser cacheService.getObject(key, typeReference); if (cachedUser ! null) { return cachedUser; } User user userMapper.selectById(id); if (user ! null) { cacheService.setObject(key, user, 30, TimeUnit.MINUTES); } return user; } public void updateUser(User user) { userMapper.updateById(user); String key user:info: user.getId(); cacheService.delete(key); } }兩個(gè)容易忽略的點(diǎn)updateUser里要主動(dòng)刪緩存而不是直接覆蓋寫。因?yàn)槟憧赡苤桓铝?user 表的一列而緩存里還有關(guān)聯(lián)數(shù)據(jù)比如角色列表直接覆蓋寫入容易留下臟數(shù)據(jù)。先刪緩存下次讀時(shí)再回源是最穩(wěn)妥的做法。setObject時(shí)要考慮緩存穿透場景。如果數(shù)據(jù)庫查不到緩存里就不會(huì)有值大量惡意請求會(huì)直接打到數(shù)據(jù)庫。業(yè)界常見的做法是緩存空值并設(shè)置較短的過期時(shí)間或者用布隆過濾器攔截。這個(gè)和序列化無關(guān)但屬于緩存鏈路必備知識。6.3 回源停頓與緩存擊穿的小建議如果你的業(yè)務(wù)里某個(gè)熱 key 過期后瞬間有大量請求打進(jìn)來全部落到數(shù)據(jù)庫這就是緩存擊穿。手動(dòng) JSON 序列化不解決這類問題它只解決“怎么讓緩存數(shù)據(jù)安全可讀”。實(shí)際項(xiàng)目中我見到的做法是加分布式鎖Redisson讓回源邏輯只允許一個(gè)線程去查庫其他線程等鎖后直接讀新緩存。要注意的是鎖的粒度和緩存 key 綁定別在整個(gè) service 上鎖否則并發(fā)效率全沒了。偽代碼public User getUserByIdWithLock(Long id) { String key user:info: id; User cached cacheService.getObject(key, new TypeReferenceUser() {}); if (cached ! null) { return cached; } String lockKey lock:user:info: id; boolean locked lockService.tryLock(lockKey, 3, TimeUnit.SECONDS); if (!locked) { // 沒拿到鎖短暫自旋或直接返回降級結(jié)果 return fallback(id); } try { // 雙重檢查避免拿到鎖后重復(fù)查庫 User cachedAgain cacheService.getObject(key, new TypeReferenceUser() {}); if (cachedAgain ! null) { return cachedAgain; } User user userMapper.selectById(id); if (user ! null) { cacheService.setObject(key, user, 30, TimeUnit.MINUTES); } return user; } finally { lockService.unlock(lockKey); } }這段代碼比普通教科書里的例子多了“雙重檢查”實(shí)際項(xiàng)目里非常有效能大幅減少重復(fù)回源。6.4 緩存 key 設(shè)計(jì)的一些習(xí)慣再補(bǔ)一個(gè)實(shí)用的小細(xì)節(jié)手動(dòng) JSON 序列化后redis 里的 key 和 value 都可讀了但你依然要設(shè)計(jì)好 key 的命名空間。我喜歡用業(yè)務(wù)模塊作為前綴中間用冒號隔開比如user:info:1、order:detail:10086、sys:config:1。Redis Desktop Manager 里會(huì)自動(dòng)按冒號分組看著層次分明。另外value 是 JSON 字符串時(shí)我習(xí)慣在 JSON 里加一個(gè)version字段或者使用字段名版本化如userName變name就新建 key方便后續(xù)字段升級時(shí)做遷移。這個(gè)在緩存治理里叫“數(shù)據(jù)版本控制”手動(dòng) JSON 方案做起來非常順因?yàn)閿?shù)據(jù)本身就是文本。7. 常見問題排查與避坑速查表這部分是實(shí)際運(yùn)維中最常用的東西我把它整理成一張速查表再補(bǔ)充一些排查經(jīng)驗(yàn)。現(xiàn)象根因解法key 出現(xiàn)\xAC\xED前綴key 的序列化器是 JDK 默認(rèn)設(shè)置 keySerializer 為 Stringvalue 全是二進(jìn)制不可讀value 沒配 JSON 序列化或用了 JDK 序列化配置 StringRedisSerializer手動(dòng)存 JSON 字符串取出的 List 元素是 LinkedHashMap泛型擦除反序列化時(shí)沒有類型信息使用 TypeReference反序列化報(bào) LocalDateTime 相關(guān)異常缺少 JavaTimeModule 或時(shí)間戳配置錯(cuò)誤注冊 JavaTimeModule 并關(guān)閉 WRITE_DATES_AS_TIMESTAMPS老版本數(shù)據(jù)讀出來字段缺失或報(bào)錯(cuò)類結(jié)構(gòu)變更、字段重命名、刪除字段升級緩存版本號或?qū)懠嫒葸壿媟edis-cli 查 value 前面多出長度前綴協(xié)議層面數(shù)據(jù)其實(shí)是 bytesredis-cli 正常顯示加--raw參數(shù)查詢某字段為 null 導(dǎo)致序列化結(jié)果體積大沒配置 NON_NULL資源配置 Inclusion.NON_NULL排查經(jīng)驗(yàn)第一條永遠(yuǎn)先用 redis-cli 手動(dòng) get 一下。很多人遇到亂碼第一反應(yīng)是去改代碼其實(shí)用 redis-cli 能立刻判斷是存儲(chǔ)層問題還是客戶端顯示問題。如果 redis-cli 看到的是正常 JSON說明字節(jié)本身沒問題八成是客戶端工具顯示問題或者代碼里反序列化類型不對。第二條不要一次性把所有緩存都換成 JSON 方案然后直接上線。緩存是最怕“一刀切”的最好按 key 維度灰度遷移上線前寫個(gè)掃描任務(wù)統(tǒng)計(jì)老數(shù)據(jù)的比例遷移后用舊 key 新 key 雙讀雙寫過渡幾天。我見過有團(tuán)隊(duì)一把梭上線后老緩存全讀不出來數(shù)據(jù)庫瞬間被打掛這就是把緩存升級做成了事故。第三條序列化失敗時(shí)日志里一定要帶上完整的 key 和原始 value。你排查問題時(shí)最絕望的瞬間就是日志只告訴你“反序列化失敗”卻不知道是哪個(gè) key、哪個(gè) value。我的習(xí)慣是在catch里把 key 和 value 的前 500 個(gè)字符都打出來雖然有人覺得日志太長但關(guān)鍵時(shí)刻能救命。第四條定時(shí)清理無效緩存。手動(dòng) JSON 方案里 key 和 value 都可讀但也意味著垃圾數(shù)據(jù)一眼就能看出來比如某個(gè) list 的 key 下面殘留了過期 key。可以定期掃一遍把長期不訪問且不在業(yè)務(wù)白名單里的 key 清理掉給 Redis 內(nèi)存減負(fù)。這個(gè)動(dòng)作在 Redis 側(cè)可以做也可以用腳本定時(shí)處理。8. 最后聊一點(diǎn)我個(gè)人的體會(huì)這套“手動(dòng) JSON 序列化”方案本質(zhì)上不是選了個(gè)更麻煩的技術(shù)方案而是選了“把主動(dòng)權(quán)握在自己手里”的架構(gòu)思路。Redis 底層只存字節(jié)框架的默認(rèn)序列化器雖然能省一行代碼卻把數(shù)據(jù)格式的黑盒留給了你手動(dòng) JSON 多寫了幾行代碼但換來的是緩存完全透明、可讀、可排查、可跨語言。我在多個(gè)項(xiàng)目里切換到這個(gè)方案后線上排查緩存問題的速度提升不是一點(diǎn)點(diǎn)——以前自己是“盲人摸象”現(xiàn)在直接看 JSON 就能定位是數(shù)據(jù)問題、類型問題還是過期問題。最后再分享一個(gè)小技巧如果你的團(tuán)隊(duì)剛開始做這件事可以先定一個(gè)約束——所有走 Redis 緩存的對象都必須有對應(yīng)的 DTO 類并且配套序列化/反序列化的單元測試。測試?yán)飳懬宄按孢M(jìn)去的 JSON 長什么樣讀出來之后能不能精確還原”。這一步看起來麻煩但能攔住后面一大半的亂碼問題和類型轉(zhuǎn)換問題。緩存這東西線上出了故障往往都是靜默發(fā)生的還不如一開始就把格式定死、測試補(bǔ)齊讓問題暴露在開發(fā)階段。