
若依RuoYi这套脚手架我在不同项目里改过好几轮从单体版到微服务版都趟过一遍。很多人第一次真正注意到它的 Redis是在ruoyi-common-redis里看到那个FastJson2JsonRedisSerializer然后一头雾水为什么我redis-cli进去看到的 key 前面带冒号、value 里还夹着type也有人的问题更直接——发版之后用户集体掉线或者字典改了前端不生效最后发现根子都在 Redis 的用法上。这篇不讲 Redis 本身的入门语法只讲若依这个框架里 Redis 被用在了哪、每一处为什么这么设计、哪些地方容易出问题、线上真出事了怎么一步步排查。不管你是刚开始二次开发还是已经在生产上跑着若依这里面的东西基本都能直接对照自己的环境验证。下面先做一件最基础但最多人没做全的事把整个项目里所有落到 Redis 的 key 盘一遍。1. 若依把哪些数据放进了 Redis一次完整的 key 盘点如果把若依当成一个黑盒你会发现它对 Redis 的依赖比想象中重。登录、鉴权、验证码、参数、字典、限流、防重复提交全都在 Redis 上。这些 key 分散在CacheConstants、TokenService、RepeatSubmitInterceptor、RateLimiterAspect、SysPasswordService等多个类里不系统盘一遍排查问题时很容易漏。1.1 先看模块分层ruoyi-common-redis 到底提供了什么若依把 Redis 能力单独抽了一个模块ruoyi-common-redis里面就三个核心类加一个常量类RedisConfig负责把 Spring 原生的RedisTemplate重新装配一遍换掉默认的 JDK 序列化器并注册一个限流用的 Lua 脚本 Bean。RedisCache一个包装类把redisTemplate的操作包成setCacheObject / getCacheObject / deleteObject / setCacheList这类语义化方法业务代码不用直接碰opsForValue()。RedisUtilsRedisCache的静态门面内部用Autowired的 setter 注入一个静态实例方便在静态方法或非 Spring 管理的类里调用。CacheConstants所有 key 前缀和 Spring Cache 的 cacheName 常量。注意RedisUtils这类静态门面 setter 注入的写法只在应用启动阶段由 Spring 赋值一次不能在静态初始化块里提前调用。我见过有人在static {}里调RedisUtils.getCacheObject()结果必然是 NPE因为那时候redisCache还没被注入。这种分层的好处是明显的业务层永远只面向RedisCache这层语义 API将来要把 Redis 换成别的缓存组件改一个类就够了。代价是很多人只背了RedisUtils.setCacheObject()的用法完全没看过RedisConfig里的序列化配置——而事故基本都发生在那一层。1.2 六类 key 的完整清单与各自的生命周期我把若依-Vue 里常见的 key 整理成一张表这张表建议你直接抄进自己的项目文档key 前缀常量名value 类型TTL用途login_tokens:LOGIN_TOKEN_KEYLoginUser 对象默认 30 分钟滑动续期登录态与权限集合captcha_codes:CAPTCHA_CODE_KEYString2 分钟图形验证码答案sys_config:SYS_CONFIG_KEYString常驻启动加载 手动刷新系统参数sys_dict:SYS_DICT_KEYListSysDictData常驻手动刷新字典数据repeat_submit:REPEAT_SUBMIT_KEYString时间戳默认 10 秒防重复提交rate_limit:RATE_LIMIT_KEYLong计数默认 60 秒接口限流pwd_err_cnt:PWD_ERR_CNT_KEYInteger次数默认 10 分钟密码错误锁定这张表里有两个坑值得单独说。第一login_tokens:用的是滚动过期。TokenService里判断逻辑大致是如果当前时间距离expireTime只剩不到 20 分钟就重新 set 一次并刷新 TTL。也就是说用户只要在活跃登录态就一直在续只有连续 30 分钟没有任何请求才会掉线。这也是为什么上午改配置、下午用户才集体掉线这类现象多半和 token 续期逻辑被破坏有关后面第 3 节会详细讲。第二sys_config:和sys_dict:这两类 key 是没有 TTL 的常驻 key。它们靠PostConstruct在应用启动时全量加载靠管理后台的刷新缓存按钮重建。这意味着运维如果在数据库里直接改了一条参数或字典Redis 里的旧值不会自己失效——这是若依项目里最高频的数据不一致投诉来源没有之一。1.3 登录态为什么不放 Session一次请求里 Redis 被读了几次若依前后端分离鉴权走的是Authorization: Bearer tokentoken 本质是一个 UUID真正的用户信息全在 Redis 里。这样做的直接收益是应用无状态、可以随便扩容、重启不丢登录态、踢人只需要删一个 key。代价是每一次请求都要往 Redis 打一次读。一次典型的前端请求会经过JwtAuthenticationTokenFilter从请求头取 token调用tokenService.getLoginUser(request)从login_tokens:xxx里取出LoginUser然后判断是否需要续期。如果续期条件命中还会再写回一次。也就是说一个活跃用户的高频接口Redis 上至少是一读 偶尔一写。这个量级在几百人的内部系统里完全不算事但如果你把它放到高并发场景——比如几千人同时在线的业务系统或者压测脚本直接打登录后的业务接口——Redis 的 QPS 会被显著抬起来。这也是后面第 5 节要聊的内容验证码、登录、字典这几个接口在压测时的压力分布和普通业务接口完全不是一回事。1.4 短生命周期 key量最大、最容易被忽视的一批验证码captcha_codes:、防重复提交repeat_submit:、限流rate_limit:、密码错误计数pwd_err_cnt:这四类 key 的共同特点是TTL 短、写入频繁、很少被关注。验证码登录接口每次调用生成一个2 分钟自动过期校验时取出来就删。压测时如果脚本不绕开验证码会瞬间产生海量短命 key。防重复提交key 由 URL、token、请求参数拼出来默认 10 秒内不允许重复提交。注意它是按 URL 参数维度去重的和方法级分布式锁完全不是一回事。限流走的是一个 Lua 脚本用increxpire实现固定窗口计数粒度为接口 IP 或全局限流。密码错误计数登录失败一次加一次超过阈值直接抛异常锁定时间默认 10 分钟。这四类 key 平时没什么存在感但只要做压力测试或者遇到恶意刷接口它们就是 Redis 内存增长和命令数飙升的主力。我一般建议在监控面板上单独给这几个前缀做一层 key 数量统计比看总内存直观得多。2. 序列化方案若依 Redis 里大部分怪问题的根子在若依项目里排查 Redis 问题我有一条几乎不会错的经验先怀疑序列化。乱码、取不到值、强转失败、升级框架后报反序列化异常十有八九都能在这里找到答案。这一节把这件事从头讲透。2.1 什么配置都不改时Redis 里到底存了什么Spring Boot 默认装配的RedisTemplate用的是JdkSerializationRedisSerializerkey 和 value 都会被 JDK 序列化成二进制。你用redis-cli执行keys *看到的是类似\xac\xed\x00\x05t\x00\x04name这种鬼东西换成可视化工具键名那一列干脆显示成乱码或者空白。这不是Redis 坏了而是设计如此。JDK 序列化能保留完整的 Java 类型信息反序列化时能精确还原成原来的类但它有三个致命缺点体积大、跨语言完全不可读、对类结构变更极其敏感改个字段名或加个字段就可能反序列化失败。在生产环境里不可读这一条几乎就是不可接受的——出问题时你在命令行上什么都看不见。2.2 RedisConfig 里真正起作用的就那几行若依-Vue 的ruoyi-common-redis里RedisConfig的经典实现大致是这样不同小版本可能有细微差异以你实际拉下来的代码为准Bean SuppressWarnings(value { unchecked, rawtypes }) public RedisTemplateObject, Object redisTemplate(RedisConnectionFactory connectionFactory) { RedisTemplateObject, Object template new RedisTemplate(); template.setConnectionFactory(connectionFactory); FastJson2JsonRedisSerializer serializer new FastJson2JsonRedisSerializer(Object.class); ObjectMapper mapper new ObjectMapper(); mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY); mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, ObjectMapper.DefaultTyping.NON_FINAL, JsonTypeInfo.As.PROPERTY); serializer.setObjectMapper(mapper); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(serializer); template.setHashKeySerializer(new StringRedisSerializer()); template.setHashValueSerializer(serializer); template.afterPropertiesSet(); return template; }这段代码里真正决定你日常体验的是四处key 用StringRedisSerializer。这是最关键的一处。key 不参与 JSON 序列化原样以字符串写入所以你在redis-cli里scan login_tokens:*能干干净净看到真正的键名。如果 key 也走 JSON 序列化键名会变成带引号的login_tokens:xxx而用StringRedisTemplate去读又读不到——这是很多人踩过的明明存进去了却查不到的坑。value 用 JSON 序列化。体积小、可读、跨语言出问题时能直接在命令行GET出来看一眼。activateDefaultTyping这一段是灵魂。它会在 JSON 里额外写入一个类型标记默认字段名是type反序列化时靠这个标记才能还原成原来的 Java 类。如果不加这一句RedisCache.getCacheObject()拿回来的其实是LinkedHashMap或JSONObject你再强转成LoginUser就会抛ClassCastException。所以网上那些我关掉了 defaultTyping结果取出来报类型转换错误的帖子根子就在这里。setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY)。这句让 Jackson 忽略 getter/setter 的可见性限制直接按字段序列化。好处是实体类不用为每个字段写 getter坏处是没有 getter 的字段也会被写进 JSON比如一些你不想暴露或者不想持久化的中间字段。2.3 换成 Jackson 通用序列化时必须处理的几个类型很多人接手若依之后喜欢把它改成纯 Jackson 方案理由是团队更熟 Jackson、不想要 fastjson 的依赖。这个改造本身没问题但有三个点必须处理第一Java 8 时间类型。LoginUser里挂着SysUser而SysUser里有Date类型的创建时间、更新时间。Date还好Jackson 原生支持但如果你自己加过LocalDateTime字段默认会报Java 8 date/time type not supported by default必须注册JavaTimeModule并关闭WRITE_DATES_AS_TIMESTAMPS否则存进去是一串时间戳数字读出来对不上。第二集合类型的还原。LoginUser里有permissionsSetString和roles。JSON 本身不区分 List 和 Set反序列化回来默认是ArrayList直接强转成Set会炸。要么靠activateDefaultTyping保留类型信息要么在业务代码里显式做一次new HashSet(list)。我个人的偏好是保留类型信息因为若依里这类强转的场景不止一处。第三GenericJackson2JsonRedisSerializer与自定义序列化器的差异。前者内部已经帮你处理了类型信息代码更简洁但它默认会往 JSON 里塞class字段格式和若依原来的type不一样切换时存量 key 是读不出来的必须处理兼容或者干脆清库。这一点在第 3 节的排查案例里还会提到。2.4 我实际遇到过的三个序列化翻车现场现场一两套模板混用同 key 不同格式。有人写业务代码时图省事直接Autowired StringRedisTemplate存了一个字符串到sys_config:xxx而读取走的是若依的RedisCache.getCacheObject()。存的时候是裸字符串abc读的时候按 JSON 解析直接报解析异常。更隐蔽的是反过来RedisCache存进去的是带引号的abcStringRedisTemplate读出来就多了一对引号页面上显示参数的引号需要肉眼才能发现。建议一个项目里统一用一套模板别混。提示判断某台机器上某个 key 是谁写的最快的办法是redis-cli monitor但这个命令会放大 Redis 负载生产环境只在低峰期短时间用用完立刻退出。更稳的做法是在应用侧给 key 加上业务前缀从命名上就能看出来源。现场二getCacheList的类型假设。若依的RedisCache里有个getCacheList方法内部按ListT做转换。如果你存进去的是一个自定义对象而不是 List或者存的是 List 但元素类型对不上反序列化阶段就会直接抛异常。字典缓存sys_dict:xxx存的就是ListSysDictData这个场景一般没问题但如果你拿它当通用容器用迟早翻车。现场三全开类型白名单带来的反序列化风险。LaissezFaireSubTypeValidator.instance的含义是不校验任何类型意味着 JSON 里写什么类名Jackson 就尝试实例化什么类。这在 Redis 数据被外部写入的场景下是有风险的——一旦有人能往 Redis 里写数据比如共享实例权限过大、或者某个中间环节可写就可以构造恶意 payload 触发危险类的实例化。我的做法是把它换成BasicPolymorphicTypeValidator只放开自己项目里真正需要多态的那几个包名如果做不到至少要保证 Redis 实例只对本项目的应用网段开放并开启密码认证。3. 从一次发版后用户集体掉线的完整排查说起这一节我不打算直接给结论而是把整个排查链路完整写出来。因为大部分 Redis 事故的排查思路是通用的看一遍完整的链路比记十个结论有用。3.1 现象不是立刻掉线而是半小时后开始掉事情是这样的某次发版改了 Redis 客户端的配置项同时把 Spring Boot 从 2.x 升到了 3.x。发版当时一切正常登录、菜单、字典全都好用监控上也没报错。大概三十多分钟后客服群开始有人反馈点什么都跳登录页且规模越来越大。这里第一个关键的观察点是不是发版后立刻掉线而是延迟了大约一个 token 的有效期时长。这条信息价值很高它直接指向登录态续期没能正常写回。因为如果 Redis 完全连不上用户会立刻全部掉线而延迟掉线意味着读可能还是通的或者读到的是旧数据但写回/续期那条路径出了问题。3.2 第一轮排查先确定影响面是全局还是局部排查任何线上问题第一步永远是把影响范围量化而不是急着上去改配置。我做了三件事看 nginx 的 upstream 状态页和访问日志确认是所有后端实例都在报掉线还是只有某几台。在监控上分别看每台实例的 Redis 连接数、命令成功率。随机挑几个报障用户看他们的请求打到了哪台后端。结论很快出来掉线请求集中在新扩容的两台机器上老机器一切正常。这个结论一下把问题范围从框架用错了缩小到了这两台机器的环境和别人不一样。3.3 第二轮排查看 Redis 里到底有没有这个 key第二步验证 Redis 侧的数据状态。我从掉线用户的请求头里拿到 token在 Redis 上执行# 确认 key 是否存在 redis-cli exists login_tokens:用户token # 如果存在看它的过期时间 redis-cli ttl login_tokens:用户token # 直接看内容确认反序列化前的原始 JSON redis-cli get login_tokens:用户token结果是key 不存在。但同一时刻另一个正常用户的 token在另一台 Redis 实例上能查到。到这里基本可以定性了不是序列化问题是数据被写到了不同的 Redis 实例上。因为如果是反序列化失败key 会存在但读的时候抛异常现在 key 直接不存在说明写入的目标实例和读取的目标实例不是同一个。3.4 第三轮排查找出这两台机器连的是谁接下来就是定位它们连到哪儿去了。在应用侧最直接的办法是启动时打印一次 Redis 连接信息在服务侧可以用# 查看当前有哪些客户端连着以及它们的地址 redis-cli client list # 看实例自身的信息run_id 和 tcp_port 能唯一标识一个实例 redis-cli info server | grep -E run_id|tcp_port|redis_version结果印证了猜测那两台新机器连着的是一个本机 localhost 上的 Redis。而这个 Redis 是镜像里带的、恰好也能连上的一个孤岛实例。所以整个链路是用户登录时被负载均衡打到新机器上token 写进了本机 Redis下一个请求被转发到老机器老机器去自己的 Redis 里查查不到直接判定未登录。3.5 真凶一个配置项改名 一个默认值静默组合成了事故根因说出来其实很简单。Spring Boot 3 把 Redis 的配置前缀从spring.redis.*改成了spring.data.redis.*。这两台新机器的启动参数里配的是老前缀spring.redis.hostSpring Boot 3 根本不认于是 host 回落到默认值localhost。而localhost上恰好有一个能连通的 Redis镜像里带的所以应用启动不报错、连接池正常、健康检查通过、日志一片安静。这就是最要命的地方配置错了却完全没有症状只有一个延迟半小时的数据不一致。修复动作有三步把所有环境的配置项统一成spring.data.redis.*并清理掉残留的老前缀配置避免混淆。去掉镜像里的本地 Redis让配置错误在启动阶段就暴露成连接失败而不是静默降级。在启动流程里加一段 Redis 探活逻辑连接成功后打印INFO server里的tcp_port和实例地址和预期的配置做一次比对对不上直接启动失败。3.6 复盘为什么这个坑值得单独记一笔这个案例之所以值得写下来是因为它把三种看起来很安全的操作叠在了一起配置项改名时的静默兼容。框架给了默认值默认值又恰好是可用的于是错误被吞掉了。环境里存在恰好能用的替代品。镜像里带的本地 Redis 本来是给开发调试用的结果在生产上变成了错误配置的遮羞布。扩容放大了问题。如果只有一台机器这个错误永远不会表现为用户掉线只会表现为重启后登录态丢失根本不会被发现。提示任何时候在启动参数里写 Redis 地址都要顺手确认一遍如果这个配置被忽略默认值会指向哪里。localhost、127.0.0.1、空密码、db0 这几个默认值组合起来恰好能连上环境里很多意外存在的实例这是最容易被忽略的隐患。补一句实操经验我现在的习惯是在每个环境的配置文件里显式写全 host、port、database、password、timeout、连接池参数这六项哪怕某些项用的是默认值也写出来。配置里的显式冗余换来的是排查时不用再猜。4. 缓存治理字典和参数缓存的失效边界在哪第 3 节讲的是登录态这一节聊另一类 key——sys_config:和sys_dict:。它们没有 TTL、启动时全量加载、改动后需要手动刷新是缓存与数据库不一致投诉的重灾区。4.1 若依里其实并存着两套缓存命名空间这一点很多人没意识到但它会直接影响你排查为什么刷新了缓存还是不生效。若依把数据写进 Redis 有两条路径手动路径SysConfigServiceImpl和SysDictTypeServiceImpl在PostConstruct里遍历数据库用redisCache.setCacheObject(getCacheKey(xxx), value)写入。这里的 key 是sys_config: 参数键、sys_dict: 字典类型单冒号。注解路径Service 的增删改方法上挂着CacheEvict(cacheNames Constants 里定义的 cacheName, allEntries true)。Spring Cache 对 Redis 的默认 key 生成规则是cacheName::key也就是双冒号。两条路径的前缀规则不一样。所以当你看到我明明在方法上加了CacheEvict(allEntries true)为什么字典还是旧的时第一件要确认的事就是真正被读取的那个 key和注解清掉的那个 key是不是同一批。验证方法很直接# 看手动加载的 key redis-cli --scan --pattern sys_dict:* # 看 Spring Cache 命名空间下的 key redis-cli --scan --pattern sys_dict::*如果只有第一类存在第二类是空的那就说明注解清的是一个没人读的命名空间真正生效的是 Service 里手动调用的清缓存逻辑。注意不同若依版本的实现细节不完全一样有的版本在resetDictCache()里同时做了清空 重新加载有的版本则依赖注解。不要靠记忆--scan一遍比什么都准。4.2 刷新缓存的三个入口以及每个入口的适用场景实际操作中让缓存更新的入口有三个入口操作方式影响范围适用场景管理后台参数管理 / 字典管理页面的刷新缓存按钮当前应用实例能看到的全部缓存日常运维最推荐命令行redis-cli del sys_dict:xxx单个 key精准验证某一条数据是否异常重启应用触发PostConstruct全量重载该实例的所有缓存大批量数据变更后兜底这里有个细节要提醒这三条路径对多实例部署的行为是不一样的。管理后台的刷新按钮实际是打了某一个实例的接口但因为它操作的是共享 Redis所以对所有实例都生效——这是若依这个设计比较讨巧的地方无需广播。而重启应用重载只影响被重启的那台如果你有十台实例理论上它们的加载时间点不同中间会有一小段缓存内容不一致的窗口不过这个窗口通常可以接受。4.3 缓存不一致的三个典型来源来源一直接在数据库里改数据。这是最常见的。运维或者 DBA 为了图快直接update sys_dict_data set dict_label xxx然后发现前端毫无变化。这不是 bug是设计Redis 里的值不会因为你改了库就失效。解决办法就一句改完必须刷缓存把这条写进运维手册。来源二多环境共用 Redis 实例。测试环境和生产环境连了同一个 Redis、同一个 databasekey 前缀又完全一样于是测试环境刷一次缓存把生产环境的字典也覆盖了。这个问题的处理方式通常是三重保险不同环境用不同 database至少要区分、不同环境用不同 password、不同环境在 key 上加环境前缀需要小幅改造RedisCache。来源三缓存被当成最终数据源用。有些二次开发会把一些需要持久化的业务数据比如统计结果、任务状态也塞进 Redis 靠 TTL 管理。一旦 Redis 重启或者触发了淘汰策略数据就永久丢失。判断标准很简单这份数据丢了之后能不能从数据库完全重建如果答案是不能它就不该只放在 Redis 里。4.4 大 key 与热 keysys_dict 在最坏情况下的表现字典缓存有一个不容易被注意到的特征它的 value 是一个完整的ListSysDictData。假设你的系统里有几百个字典类型每个类型的字典项平均几十条单条序列化后几百字节——那么一个大的字典类型可能序列化出几十甚至上百 KB 的 value。这就构成了典型的大 key。大 key 的危害不在于占内存而在于阻塞。Redis 是单线程处理命令的一次读取 100KB 的 value 和一次读取 100 字节的 value耗时差着数量级。如果这个字典恰好是首页高频组件在用比如状态枚举它同时还是个热 key大 key 热 key 叠加就是 Redis 单核 CPU 打满的经典成因。我在实际项目里用过两个缓解手段给字典读取加本地二级缓存。在DictUtils外面包一层轻量的本地缓存比如 Caffeine设置几十秒的过期时间配合原来的手动刷新入口做主动失效。这样高频字典的读取完全不落 RedisRedis 只承担分发更新的角色。改造成本不高效果立竿见影。拆分大字典。如果某个字典类型确实有上千条数据说明它本来就不适合当字典用应该改造成独立的业务表走分页查询而不是整个列表塞进缓存。至于怎么找出这些大 keyredis-cli --bigkeys是最省事的工具它会按类型扫描并列出每种类型里最大的几个 key。注意这个命令内部走的是scan对线上实例有压力但不至于阻塞建议在低峰期跑或者直接在从库上跑。5. 分布式锁与高并发若依缺的那块拼图怎么补若依给了一套很完整的脚手架但它没有内置分布式锁。RepeatSubmit注解经常被误当成锁来用这是我自己也踩过的坑。这一节讲清楚边界在哪、该怎么补。5.1 RepeatSubmit 为什么不是锁先看它的规则key 由请求 URL、token、请求参数拼成写入时带着intervalTime默认 10 秒的过期时间同一个 key 在有效期内再次写入会被拒绝直接返回不允许重复提交。它的能力边界在三个地方它是按请求维度去重的不是按业务资源维度。两个不同用户的请求token 不同key 就不同互不干扰。但如果是同一件商品被两个不同用户同时下单导致超卖它完全无能为力。它的时间窗口是固定且很短的。过了 10 秒窗口自动失效用户还能再提一次。它不是原子的业务保护。它的作用是拦掉明显的手抖重复点击而不是保证业务操作的并发正确性。判断标准很简单如果你的问题用同一个人短时间内点两次就能描述清楚用RepeatSubmit如果要用两个请求同时操作同一份数据才能描述清楚那就必须上锁。5.2 手写一把能用的 Redis 锁三个必要条件网上关于 Redis 分布式锁的文章很多但真正能落地的最小可用实现绕不开三个条件第一加锁必须原子SET key value NX PX milliseconds。不要用setnx加expire两步走中间崩溃就会留下永不释放的死锁。同理也别指望先查再设。第二value 必须是唯一标识。通常是UUID 线程 ID。为什么因为释放锁的时候必须确认这把锁是我加的否则会出现 A 的锁超时自动释放、B 拿到了锁、A 执行完把 B 的锁删了这种经典事故。第三释放锁必须用 Lua 保证判断 删除的原子性-- KEYS[1]: 锁的 key -- ARGV[1]: 加锁时写入的唯一标识 if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end对应的 Java 侧若依项目里可以直接用RedisTemplate执行这个脚本注册成一个 Bean 复用若依的RedisConfig里已经有注册 Lua 脚本的先例限流用的那个就是public boolean tryLock(String key, String value, long expireMillis) { Boolean ok redisTemplate.opsForValue() .setIfAbsent(key, value, expireMillis, TimeUnit.MILLISECONDS); return Boolean.TRUE.equals(ok); } public boolean releaseLock(String key, String value) { Long result (Long) redisTemplate.execute(unlockScript, Collections.singletonList(key), value); return result ! null result 0; }注意setIfAbsent(key, value, timeout, unit)这个方法在 Spring Data Redis 里底层就是SET NX PX是原子的可以放心用。但如果你用的是setIfAbsent(key, value)再加expire(key, ...)那就退化成两步操作了一定要避免。还有一个必须想清楚的问题锁的过期时间该设多久设短了业务还没跑完锁就自动释放别的线程进来了设长了持锁线程崩了之后要等很久才恢复。如果业务耗时不可控纯手写方案基本无解这时候就该考虑框架了。5.3 若依场景下真正需要加锁的几个位置结合若依常见的二开场景我列几个必须加锁的地方单号生成。比如每天重置的流水号如果实现方式是incr一个计数器然后拼日期本身是原子的没问题但如果是先查最大值再 1多实例并发必然重号必须加锁或者直接用INCR。库存 / 余额类扣减。校验和扣减必须在同一个临界区里且最好结合数据库的行锁或乐观锁一起用Redis 锁只做前置削峰。定时任务的多实例防重。若依的定时任务用的是 Quartz如果部署了多实例且没有做集群配置同一个任务会在多台机器上同时执行。用一把带 TTL 的 Redis 锁key 里带任务 ID 和执行时间片来保证只有一个实例真正执行是很常见的做法。字典缓存的并发重建。如果缓存刷新改成了按需加载 过期重建在过期瞬间可能有很多请求同时去查库重建形成缓存击穿。用锁保证只有一个线程去重建其他线程短暂等待是标准解法。5.4 什么时候该直接上 Redisson手写锁的适用场景很明确临界区逻辑简单、耗时可控、不需要重入、不需要等待队列。一旦出现下面任何一种情况我就会直接换 Redisson需要可重入同一个线程多次加锁不死锁。业务耗时不确定需要自动续期Redisson 的看门狗机制默认每 10 秒续一次锁默认 30 秒过期。需要公平锁、读写锁、信号量、闭锁这些高级语义。需要和tryLock(waitTime, leaseTime, unit)这样的等待语义配合。引入方式很轻加一个依赖配置一个RedissonClientBean 就行和若依原有的RedisTemplate并存不冲突。唯一要注意的是配置要指向同一个 Redis 实例别出现Redisson 连 A、RedisTemplate连 B的情况——这和前面那个连接错实例的坑是同一类问题。5.5 压测视角Redis 在若依里的压力分布长什么样最后聊聊压测。用 JMeter 压若依的时候Redis 的压力分布和直觉往往不一样。我实测下来压力最大的三个点依次是登录接口。每次调用都要生成验证码 key、校验后删除、写入login_tokens:、失败还要写pwd_err_cnt:。一个登录请求产生的 Redis 命令数远超一个普通业务查询。带权限校验的业务接口。每个请求都要读一次login_tokens:如果刚好命中续期窗口还要写一次。字典/参数密集的页面初始化接口。一次请求可能触发多次sys_dict:读取且 value 体积大。所以压测脚本里如果包含登录务必先把 Redis 侧的指标QPS、慢查询、CPU、连接数单独拉出来看别只盯着应用的响应时间。我也建议在压测前先做一件事把验证码在测试环境的开关关掉否则你压的其实是验证码生成这个动作而不是业务逻辑。6. 部署形态变了Redis 的用法也要跟着变同一套若依代码跑在单机、跑在容器集群、跑在云主机上Redis 遇到的问题完全不一样。这一节讲部署形态带来的差异。6.1 单机、主从、哨兵、集群在配置上的差异若依默认的配置是单机形态形如spring: data: redis: host: 127.0.0.1 port: 6379 database: 0 password: your-password timeout: 10s lettuce: pool: min-idle: 0 max-idle: 8 max-active: 8 max-wait: -1ms注意Spring Boot 2.x 用的是spring.redis.*3.x 换成了spring.data.redis.*。这一条在第 3 节的案例里已经出过一次事故升级框架时务必全局搜索一遍老前缀。主从和哨兵的配置差别在于去掉了 host改成主节点名和节点列表spring: data: redis: password: your-password sentinel: master: mymaster nodes: 10.0.0.1:26379,10.0.0.2:26379,10.0.0.3:26379集群形态则是spring: data: redis: password: your-password cluster: nodes: 10.0.0.1:6379,10.0.0.2:6379,10.0.0.3:6379 max-redirects: 3这里有个必须提前知道的约束Redis 集群模式下不支持多 key 跨槽操作。若依的RedisCache里有一些操作是按 key 逐个处理的这类没问题但如果你自己写过多 key 的del或者用keys做批量操作切到集群后可能直接报CROSSSLOT Keys in request dont hash to the same slot。解决方式是给需要一起操作的 key 加 hash tag用{}包住相同的部分让它们落到同一个槽上。6.2 微服务版key 命名空间与网关鉴权若依微服务版RuoYi-Cloud里Redis 的角色更重。网关层要做统一鉴权会直接从 Redis 里读login_tokens:判断请求是否放行ruoyi-auth模块负责发 token其他业务模块复用同一份登录态。这套设计带来两个实践建议第一所有模块必须连同一个 Redis 实例。这听起来像废话但在多模块各自维护配置文件的架构里很容易出现某个模块漏配、连到本地 Redis 的情况。表现就是某些接口偶尔 401非常难查。我的做法是在网关和每个业务模块的启动日志里都打印 Redis 实例的run_id启动后对比一遍。第二key 命名空间要考虑模块隔离。单机版里 key 前缀冲突的概率低微服务里几个团队各自加缓存很容易撞车。约定一个前缀规范比如业务模块名 数据类型 业务标识从命名上就把冲突挡掉。6.3 跨环境搬迁与扩容哪些数据可以丢哪些不能把一整套若依微服务从容器环境迁到云主机或者做一次 Redis 实例扩容绕不开怎么保证不停服、不丢数据这个问题。这里的关键认知是若依的 Redis 数据要分两类看。可以丢的login_tokens:、captcha_codes:、repeat_submit:、rate_limit:、pwd_err_cnt:。这些是会话态和临时态的丢了最多就是用户重新登录、验证码重新获取、限流窗口重置。它们的共同特征是生命周期短且能从用户行为重建。不能丢或者说必须能重建的sys_config:、sys_dict:。这些严格来说也不是必须从 Redis 迁走——因为它们的源头在数据库应用启动时会通过PostConstruct全量加载。所以最省事的做法是迁移时不迁这两类 key让新实例启动时自己从数据库重建。基于这个认知一次相对平滑的搬迁流程大致是先在新环境把 Redis 实例搭好确认网络连通、认证正常、容量和淘汰策略符合预期。部署若依应用到新环境但暂时不切流量。此时应用会自己加载参数和字典缓存并把 Redis 连接信息打进日志。用少量内部账号通过 hosts 绑定或灰度规则走一遍完整流程登录、菜单、字典、参数、导出、定时任务。确认无误后逐步切换流量同时观察 Redis 的命令数、连接数、慢查询、内存增长曲线。老环境保留一段时间不删作为回滚兜底。如果是必须把存量数据也带过去的场景比如有些二开把业务数据也放进了 Redis那就得走主从复制或者双写方案复杂度会显著上升。这时候我建议先把那些不该放 Redis 的业务数据挪回数据库再来谈迁移否则你只是在把问题搬到新环境。6.4 可视化工具与日常排障的固定动作最后说工具。可视化客户端我用得最多的是 RedisInsight 和 Another Redis Desktop Manager 这两个前者官方出品、功能全、内置了内存分析和慢查询视图后者胜在轻量和多连接管理方便。选哪个不重要重要的是别把可视化工具当成排查的终点——它的 value 是帮你快速扫一眼 key 结构和内容真正的定位还是要靠命令行和日志。我现在的固定排查动作是这么几个场景命令说明找大 keyredis-cli --bigkeys走 scan低峰期执行找热 keyredis-cli --hotkeys需要maxmemory-policy为 LFU 系列看慢查询redis-cli slowlog get 20定位具体是哪个命令慢看连接来源redis-cli client list排查连错实例这类问题看 key 分布redis-cli --scan --pattern 前缀* | head永远别用keys *看实例身份redis-cli info server关注 run_id 和 tcp_port提示keys *和monitor这两个命令在生产环境基本等于自残。前者会阻塞 Redis 主线程后者会成倍放大命令数和网络流量并且会把所有命令包括密码类字段的明文打印出来。要遍历 key一律用--scan。我自己的一个习惯是每次上线前先在测试环境--scan一遍所有 key 前缀和文档里的清单对一次。多出来的前缀要么是有人偷偷加了缓存没登记要么是某个功能的 key 拼错了。这件事花不了五分钟但挡掉过好几次缓存到处乱写、没人知道哪来的的混乱局面。至于 token 的续期时长、锁的过期时间、本地缓存的刷新周期这几个参数我的经验是宁可先设保守一点再往上调——调大容易出事之后往回找原因难。