ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Redis序列化报错InvalidClassException?一文搞懂serialVersionUID的真正作用

Redis序列化报错InvalidClassException?一文搞懂serialVersionUID的真正作用 1. 缓存存进去是对象取出来报错问题出在哪干后端这么多年Redis里最阴的坑不是缓存穿透也不是雪崩而是你明明把对象存进去了取出来的时候Java直接给你甩一个InvalidClassException服务当场挂掉。报错信息还不明不白一会儿是serialVersionUID对不上一会儿又是please ensure that class is marked as serializable这种玄学提示。第一次遇到的人基本都是一脸懵序列化我代码里也没写序列化啊其实你一直在用只是没注意。这事的根源在于Redis本身不认识Java对象它只认字节。你把一个Java对象往Redis里放底层一定会发生一次翻译——把对象变成字节流这个翻译过程就叫序列化从Redis里取出来的时候再做一次逆向翻译把字节流变回Java对象这叫反序列化。只要这两步里任何一步的规则变了或者翻译出来的版本对不上号就会炸出各种各样的序列化异常。今天的重点就是把这个翻译过程说透尤其是serialVersionUID这个绝大多数人写代码时压根不会多看它一眼、但一出问题就让人抓狂的字段。我尽量用大白话讲不整那些抄来抄去的官方定义把我实际踩过的坑、排查过的线上故障、以及最后沉淀下来的处理习惯都摆出来希望对正在被Redis序列化折磨的人有点帮助。2. Redis底层那点事为什么存Java对象非得序列化2.1 Redis的存储模型内存里的字节数组不是Java对象Redis是一个内存型键值数据库它的value支持String、Hash、List、Set、ZSet这些数据结构。但不管你塞什么类型进去在Redis服务器内部数据最终都是以字节数组的形式躺在内存里的。它不关心你原来是一个User对象还是一个Order对象它只认识一段raw bytes。这一点和MySQL不一样。MySQL的表结构会定义列的类型驱动帮你把Java对象映射成关系表里的行。Redis完全没有这个能力它就是一把巨大的、存字节的HashMap。所以当你用RedisTemplate或者其他客户端把一个Java对象set进去的时候客户端这边必须先做序列化把对象压成字节数组再发给Redis。取的时候反过来把字节数组反序列化成对象。这一来一回里藏着一整套决定怎么翻译的规则。规则选得怎么样直接影响到存进去的数据占多大内存、读写速度多快、Redis Desktop Manager里能不能看懂、以及代码版本升级之后老数据还认不认。2.2 主流序列化方式哪些在Redis实战里真的值得用Java世界里把一个对象变成字节流的方式非常多我挑几个实际在Redis场景里被用得最多的从说人话的角度盘点一下。JDK原生序列化实现java.io.Serializable接口然后用ObjectOutputStream写入这是Java自带的也是最根正苗红的一种方式。它的特点就是零依赖随便一个类implements Serializable就能用。但是坑也明显序列化出来的是Java私有的二进制格式体积比JSON大好几倍而且里面会带一坨类元数据——类名、字段名、字段类型、版本号全给你写进去。你想想一个User对象可能就几个字段序列化完一变字节数立马膨胀。在Redis这种以字节为存储单位的地方JDK序列化出来的数据会让内存压力倍增在Redis Desktop Manager里看起来就是一坨乱码完全不可读。JSON序列化用Jackson、Gson、Fastjson这类库把对象转成JSON字符串再存进去。这个做法的好处是人类可读能直接在Redis客户端里看到内容排错非常方便。坏处是JSON里不带Java类型信息反序列化的时候你得手动指定目标类型。而且如果对象里嵌套了泛型、多态类型就很容易反序列化失败。另外像LocalDateTime、BigDecimal这类特殊类型需要注册序列化器否则存进去要么是数组、要么直接报错。Kryo / Protostuff / Hessian这类第三方二进制序列化体积比JDK小、速度比JSON快适合对存储成本和性能有要求的场景。但问题是它们通常要求类有无参构造函数、字段不能随意增删、有的甚至要求注册类。在Redis缓存场景里一旦类和序列化规则不兼容反序列化就是一场灾难。从我的实际经验来看业务缓存用JSON是最省心的性能和可读性平衡得最好如果对性能极度敏感比如要扛高并发可以上Kryo但它引入了额外复杂度不是没有代价。这里不展开聊选型重点还是回到题目里的serialVersionUID——因为不管选哪种序列化方案只要用了JDK原生序列化或者某些基于Java序列化机制的工具这个字段就是一个绕不开的坎。2.3 一个类要能序列化Java究竟在背后干了什么一个普通的Java类要能被JDK序列化必须实现java.io.Serializable这个标记接口。它里面一个方法都没有纯粹是个通行证。实现了这个接口ObjectOutputStream在写入对象时才会放行否则直接抛NotSerializableException。但仅仅实现接口还不够。Java序列化机制还要求这个类有一个序列化版本号也就是serialVersionUID。它是什么它是一个长期不变的static final long值相当于这个类的序列化身份指纹。写代码的时候这个字段你写了Java就认你的版本号你不写Java会在运行时根据类名、字段名、字段类型、方法签名等信息用算法自动算出一个默认的serialVersionUID。注意这个自动算它是个隐患——每次编译只要类的结构有一丁点变化自动算出来的值就可能变。所以反序列化时如果新的默认值跟序列化时的不一样就会报InvalidClassException。那问题来了为什么Java不能通融一下版本号不一样就照样反序列化因为它要保证安全。如果类结构已经从一个name字段改成了一个name字段加一个age字段老数据里根本没有age的值强行塞进去age就只能拿到默认值可能引发更隐蔽的逻辑错误。Java选择用序列化版本号来明确告诉你对不起这份字节流和我现在的类结构不匹配我不认这个账。这就是serialVersionUID存在的最根本原因给类的序列化形态一个版本标识让反序列化时能快速判断这份数据是不是用我这个版本的类生成的。3. serialVersionUID说人话它到底管什么、不管什么3.1 相当于快递包裹上的规格标签打个比方。你把一件家具拆成零件装进纸箱寄给朋友纸箱外面贴了一张标签写着这是乐高积木零件数200件版本V1.0。朋友收到箱子后要照着标签来拼装。如果哪天家具厂商改了设计零件从200件变成了220件但标签还写着V1.0朋友拼到一半肯定炸了——零件对不上。serialVersionUID就是那个版本标签。它告诉你反序列化的一方这份字节流是用V1.0版本的User类生成的。反序列化时当前类也带着自己的serialVersionUID如果两边都是V1.0就可以放心还原如果一边是V1.0、另一边是V2.0就直接判死绝不硬拼。理解了这层你就明白为什么大家都说serialVersionUID一定要手动声明。手动声明等于你牢牢把控了这个版本标签的变更节奏不声明等于把标签交给编译器随机生成。你今天写了个类没声明编译出来的默认UID是A明天在多行注释里加了一行注释重新编译默认UID可能就变成B了——注意加注释都能改变自动生成的serialVersionUID因为自动生成算法会基于类的详细信息计算注释信息有一些也会参与进去不同的JDK版本表现还不太一样。这时候你Redis里存的旧数据取出来就会反序列化失败。3.2 不写serialVersionUIDRedis缓存立刻变成定时炸弹很多人有一个错觉我的类实现了Serializable不写serialVersionUID本地测试一切正常没问题啊。对本地测试确实没问题。因为你本地从头到尾只有一份类文件写入和读取用的是同一个编译产物默认UID自然是同一个。但Redis缓存有个特性写入的一方和读取的一方常常不是同一个进程、同一段时间发布的代码。典型的场景两个微服务服务A在凌晨三点把用户对象写入Redis服务B在早上九点读取。如果A服务的代码是昨天编译的B服务的代码是今天刚发版新编译的而User类在两边的编译产物里因为某些原因比如编译器版本升级、JDK小版本变了算出的默认serialVersionUID不一样B在读取时就会直接报InvalidClassException。更常见的还是业务类字段变更后引发的连环事故。上线前你给User加了一个字段重新打包发布谁也没注意serialVersionUID这回事因为没手动声明。发布后读Redis的请求直接一片报错。为什么因为旧缓存数据是用项目上一个发布版本的User类序列化的新类没有手动声明UID编译器算出来的新默认UID变了两者对不上Java拒绝反序列化。3.3 版本演进翻车实录一个越改越乱的User类我把这个真实发生过的场景完整还原给你过程简化后大概是这样。项目里有这么个User类public class User implements Serializable { private String name; private int age; private String phone; // getter/setter 省略 }注意它没写serialVersionUID。第一个版本上线后缓存里已经写入了一批这样的对象。一个月后业务调整要在User里加一个String email字段。开发同学直接改了类加了字段然后发版。这中间编译时编译器为User类自动生成的serialVersionUID因为类结构变了已经和上一版不一样了。新代码启动后去Redis里读旧数据Java一看新UID ! 旧UID直接抛异常。那么把serialVersionUID手动固定下来就能完全避免这个问题吗答案是可以。因为只要你固定写了private static final long serialVersionUID 1L;无论你加了email字段还是减了phone字段这个值都不会变。Java在反序列化时看到版本号一样就会尝试把老字节流还原成新类对象email字段在老数据里没有就填默认值null。这样至少不会报错数据还能用。这就引出一个很重要的认知serialVersionUID的作用是让Java在版本不一致的时候要么直接拒绝没匹配上要么尽力兼容匹配上了但字段差异按规则处理而不是保证数据100%正确还原。加个新字段老数据里没有这个值它只能给默认值删了个旧字段老数据里那个信息直接丢了。真正的数据兼容还是要靠你的序列化方案和字段设计策略来兜底。4. 我在Redis缓存里被serialVersionUID坑过的全过程4.1 事故现场一条让人抓狂的InvalidClassException有一次线上突然冒出来大量报警核心报错长这样com.esotericsoftware.kryo.KryoException: Unable to load class ...别急这不是Kryo我再还原一个更经典的、JDK序列化场景的报错java.io.InvalidClassException: com.example.User; local class incompatible: stream classdesc serialVersionUID 7684720433459267490, local class serialVersionUID -7829174494623382564这个报错翻译成人话就是你Redis里存的那份字节流里面的类版本号是76847……你现在本地这个类版本号是-7829……两边对不上。如果你用的是Kryo报错形式又不太一样通常是一句晦涩的Class cannot be loaded或Unable to load class。但根因往往还是类结构变化导致序列化器内部的type registration跟老数据不一致。这里不展开。最气人的是这个报错不会在你发版的第一时间爆发而是有选择地爆发。因为Redis缓存有TTL有些key过期了读的时候会重新写有些key还没过期下次请求直接命中老数据就炸了。所以你会看到线上的错误是间歇性的一会儿好一会儿坏排查起来非常痛苦。我当时的第一步反应是问谁改过User类一查git log果然前一天有人在这个类里加了一个字段。而User类恰好没写serialVersionUID于是新旧字节流的版本号就不兼容了。4.2 完整排查链路从Redis Desktop Manager看到乱码讲起如果你用的Redis客户端是JDK默认序列化那么打开Redis Desktop Manager现在很多人叫它Another Redis Desktop Manager你会看到key对应的value是一坨以\xAC\xED\x00\x05开头的乱码。AC ED是Java序列化流标准的魔数magic number看到这个基本可以确定这是JDK原生序列化产物。排查的过程我是这么一步步走的第一先看Redis里存的到底是什么。拿到出问题的key用DUMP命令或者客户端工具把内容导出来确认确实是Java序列化的二进制。如果value开头是{那是JSONJSON就不会有serialVersionUID的问题如果是\xAC\xED开头那就是JDK序列化问题范围瞬间锁定。第二确认异常是从读缓存还是写缓存来的。看堆栈如果是在RedisTemplate的deserialize方法里抛的InvalidClassException那就是反序列化失败问题出在读取的一方。第三对比新旧类。把当前运行版本的User.class反编译一下找到它的serialVersionUID再从出问题之前那个发布包找到一个旧User.class对比两者的serialVersionUID。差异明显根因就清楚了类结构变了但没手动声明UID自动生成的默认UID也跟着变了。第四决定应急方案。如果数据不重要直接清掉相关缓存key让流量把缓存重建这是最快的止血手段。如果数据重要那就不能简单清缓存得用兼容手段迁移数据。但说实话如果当初类写了serialVersionUID连这个应急步骤都不需要。4.3 热搜里那个serializable报错其实和serialVersionUID是两码事最近网上搜please ensure that class is marked as serializable的人也很多这个报错看着像序列化版本号的问题实际上不是它是Kotlin序列化框架kotlinx.serialization的报错。如果你在Kotlin项目里写了一个类想用kotlinx.serialization做JSON序列化但这门语言跟Java不一样Java的序列化靠implements SerializableKotlin则是要求类标注Serializable注解。漏标了编译器或运行时就给你整出这句please ensure that class is marked as serializable and that the serializer is registered。那它跟Redis里的serialVersionUID有什么关联关联在凡是序列化都要有明确的版本和格式契约这个通用认知上。不管是Java的serialVersionUID还是Kotlin的Serializable注解本质都是在序列化之前先把这份数据怎么翻译、怎么还原的规则定死。Redis缓存里只要涉及对象存取这两个关键词总会以某种方式出现在你面前。我建议的做法是圈子不同不要混。如果是纯Java项目遇到serializable报错看看是不是引了Kotlin相关依赖、或者无意中用了kotlinx.serialization的API如果是Kotlin项目老老实实给每个要序列化的类加上Serializable注解并手动声明serialVersionUID的习惯也保留因为Kotlin类也可以实现Java的Serializable接口在某些场景下还是要走JDK序列化协议。4.4 分布式锁和会话共享场景serialVersionUID翻车更隐蔽除了缓存还有两个场景也特别容易因为serialVersionUID翻车值得单独拎出来说。一个是Redis分布式锁搭配的本地锁存的上下文信息。很多人会拿一个自定义对象锁在Redis里或者把一些上下文信息序列化进锁的值里。业务迭代快那段代码很快加了字段、改了结构然后分布式锁的获取和校验就开始偶发抖动——为什么偶发因为锁有续期、有释放不是每次都能碰到老数据。另一个是集群会话共享Spring Session。Spring Session把session信息存进Redissession里存的往往是一些Java对象比如登录用户信息。你发布新版本User类改了字段老session反序列化失败用户被迫重新登录。这个问题我在很多项目里都见过发版之后所有在线用户全部掉线重新登录流量瞬间打到数据库上。这种问题的共性就是写入和读出的时间跨度太大跨了代码版本。你永远不知道一条Redis数据是什么时候写进去的可能是上一个发布周期的也可能是上个月的。唯一能对抗的就是从一开始就把序列化规则定死不依赖编译器自动行为。5. 四条铁律让我之后很少再因为序列化在Redis上翻车5.1 凡是可序列化类一律手动声明serialVersionUID这是最基础但最重要的一条。把所有实现了Serializable的类都写上public class User implements Serializable { private static final long serialVersionUID 1L; // 字段略 }值本身用1L就够了不用搞复杂算法。你的目的不是让UID有唯一性而是让它完全不随类结构变化而变。一个类的序列化形态改了你想强制性拒绝老数据那就手动把UID改成别的值你想兼容老数据就保持UID不变。把变更的主动权握在自己手里。IDE里也可以开检查IDEA的Settings里搜Serializable勾上Serializable class without serialVersionUID所有漏写的可序列化类会直接在编辑器里给你黄色警告。我自己是把这个警告当error级别看的。5.2 给Redis里存的对象加壳而不是裸奔第二招就是不要直接拿你业务里那些经常变的、含有复杂结构的对象去存Redis。而是定义一个专门给Redis用的、结构稳定的Cache对象。Cache对象里只放必要字段不要去承载完整的业务模型。好处很明显第一Redis里存的数据体积小第二业务对象怎么变只要Cache对象的字段不变序列化就一直兼容第三Cache对象和业务对象之间的转换逻辑是显式写在转换器里的字段增删不会在暗处翻车。比如线上User类今天要加一个address字段你不用动Redis Cache对象只在转换器里决定要不要把它映射进缓存。哪怕映射进去也不影响老缓存的反序列化——前提是Cache对象也手动声明了serialVersionUID并且新增字段时保持UID不变。5.3 默认优先用JSON/字符串存储少用JDK序列化如果你问我在Redis里存Java对象什么方案最简单、最不容易出问题我的答案始终是存JSON字符串。存JSON有几个天然优势可读性强出问题能在Redis Desktop Manager里直接看到数据不依赖Java类结构字段变了老数据也能被宽松解析Jackson和Gson默认对未知字段是忽略的不会报错序列化/反序列化的性能在绝大多数业务场景下都够用。配合泛型反序列化的时候注意类型信息比如用TypeReference指定目标类型不然容易反成LinkedHashMap。User user objectMapper.readValue(json, new TypeReferenceUser() {});如果你业务里已经用了JDK序列化存Redis也可以慢慢迁移到JSON。迁移策略双读双写先用新逻辑写JSON读的时候优先读JSON读不到再兼容旧二进制数据等旧key自然过期后再把兼容代码删掉。这个方法我实践过平滑无痛。5.4 给Redis key的上游加版本意识最后一条是在设计Redis key的时候直接带上版本字段。比如缓存一个用户信息user:info:v1:1001 user:info:v2:1001当User类发生大的结构变更老版本反序列化不兼容了你直接改一个v3的key来读写老key等它自然过期。这个做法把序列化兼容问题转成了key的命名问题看起来土但特别有效。新旧数据互不干扰回滚也方便——把代码回滚到旧版本继续读旧key一切照常。这也是缓存治理里很实用的一招。很多团队上线前手忙脚乱清缓存就是因为没有这种版本隔离的设计。有了版本意识缓存升级就变成了写新key、切流量的过程跟发版一样可以控制节奏。6. 写在最后的几句经验回头看Redis和serialVersionUID这两个词放在一起本质是存储和代码演进之间的矛盾。Redis数据比代码活得久这是常态。代码可以一周发一版但Redis里一条key可能已经安静地躺了三个月。你每次发版改动类结构都在对这份旧数据施加压力而serialVersionUID就是那个压力阀。我自己吃了几次亏之后养成的习惯很简单所有可序列化类必须声明serialVersionUIDRedis缓存默认存JSON字符串业务对象和缓存对象分层隔离大版本改动直接换key。这套组合拳打下来已经很久没有被InvalidClassException半夜叫起来过了。如果你现在正被某个序列化异常折磨先别急着清缓存把异常堆栈打开看看里面对不上的是不是serialVersionUID如果是再去git log里排查是不是有人动了那个类。多数时候清缓存只是止血把UID写上去才是根治。希望这篇流水账能帮你少走点弯路。
返回列表