Redis 实战(基础篇):把一个老 RedisUtil 搬到 Spring Boot 4,一路踩出来的坑(真机实测)

这一篇讲什么

我手上有一个几年前写的 RedisUtil,跑在 Spring Boot 2.1 上,网上流传的版本大同小异:expire、get/set、hmset、lSet......一个类包圆。

这次把它搬到 Spring Boot 4.1.1 上重写,每个方法都连真实的 Redis 跑了一遍,顺手把老写法里会出事的地方逐个做成了测试。结果是:

  • 有的编译就过不去(旧的 Jackson2 序列化器类在新版里没了);
  • 更多的是不报错、但行为不对:配置没生效、连错了机器、数据存成了别的样子、读回来类型变了。

后一类最麻烦,所以这篇的重点不是「RedisTemplate 怎么用」,而是每一处都贴出实际看到的现象和报错原文。第 3 节是完整的工具类代码,可以直接拿去用。

完整工程(工具类 + 每个坑的复现测试) :github.com/xiaoqiMikko...

实测环境(依赖版本是从 Maven 依赖树里读出来的):

项 版本
Spring Boot 4.1.1
Spring Data Redis 4.1.1
Lettuce(默认客户端) 7.5.2.RELEASE
Jackson 3.1.5(包名已经是 tools.jackson.*)
commons-pool2 2.13.1
JDK 17
Redis 服务端 Redis 6.2.4,源码编译,CentOS 7.9 虚拟机,测试用 db 15

1. 配置:两个不报错的坑

1.1 spring.redis 原样搬过来:静默连到 localhost

老项目的 application.yml 是这样的(Boot 2.x 的写法):

yaml 复制代码
spring:
  redis:
    database: 15
    host: 192.168.3.108
    port: 6379
    password:
    lettuce:
      pool:
        max-active: 500
        max-idle: 8
        min-idle: 0
        max-wait: 10000ms
        # 连接超时时间(毫秒)
        timeout: 30000

原样放进 Spring Boot 4.1.1 的项目,启动不报错,日志里一个 spring.redis 的字都没有。启动后把连接工厂的实际配置打出来:

ini 复制代码
host=localhost port=6379 database=0 commandTimeout=PT1M
pool.maxTotal=8 pool.maxWait=PT-0.001S

host、库号、连接池全是默认值 ------ 整段配置一个都没被读到。第一次真正访问 Redis 时才报错:

vbnet 复制代码
org.springframework.data.redis.RedisConnectionFailureException: Unable to connect to Redis
  org.springframework.data.redis.connection.PoolException: Could not get a resource from the pool
  io.lettuce.core.RedisConnectionException: Unable to connect to localhost/<unresolved>:6379
  io.netty.channel.AbstractChannel$AnnotatedConnectException: Connection refused: no further information: localhost/127.0.0.1:6379

报错里写的是 localhost,不是你配的那台机器 ------ 看到这个就该先怀疑配置前缀。

原因:配置类 DataRedisProperties 上的注解是 @ConfigurationProperties("spring.data.redis")。jar 包自带的元数据里,33 个 spring.redis.* 全部标成了这样:

json 复制代码
{"name": "spring.redis.host", "deprecation": {"replacement": "spring.data.redis.host", "level": "error", "since": "3.0.0"}}

level: error 的意思是「这个 key 已经不生效」。但这条信息默认不会出现在日志里。

改法 :前缀改成 spring.data.redis,见 1.3 的完整配置。

想让它报出来 :临时加上 spring-boot-properties-migrator:

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-properties-migrator</artifactId>
    <scope>runtime</scope>
</dependency>

同一份旧配置再启动,日志多了两段:

yaml 复制代码
WARN  PropertiesMigrationListener :
The use of configuration keys that have been renamed was found in the environment:
	Key: spring.redis.database   Replacement: spring.data.redis.database
	Key: spring.redis.host       Replacement: spring.data.redis.host
	Key: spring.redis.password   Replacement: spring.data.redis.password
	Key: spring.redis.port       Replacement: spring.data.redis.port
Each configuration key has been temporarily mapped to its replacement for your convenience.

ERROR PropertiesMigrationListener :
The use of configuration keys that are no longer supported was found in the environment:
	Key: spring.redis.lettuce.pool.max-active   Reason: none
	Key: spring.redis.lettuce.pool.max-idle     Reason: none
	Key: spring.redis.lettuce.pool.max-wait     Reason: none
	Key: spring.redis.lettuce.pool.min-idle     Reason: none

(为了好读,我把每个 key 的 Line: 行去掉、合成了一行。)

注意这个结果:加了 migrator 之后,host/port/库号被临时映射 ,应用能连上了(PING 返回 PONG),但连接池那 4 项没有映射 ,池子仍然是默认的 8 个连接。写在 pool 下面的那个 timeout 更是一个字都没提。所以 migrator 只是用来找出问题的,不能把它当成修复,看完日志就把配置改掉、再把依赖删掉。

1.2 timeout 写在 lettuce.pool 下面:被忽略

上面那份旧配置还有一处:timeout: 30000 写在了 lettuce.pool 底下。把前缀改对以后单独测这一项:

timeout 写在哪 实际命令超时 连接池
spring.data.redis.lettuce.pool.timeout PT1M(默认 60 秒) maxTotal=500、maxWait=10s 都生效了
spring.data.redis.timeout PT30S 同上

写错层级同样不报错 。连接池那边没有 timeout 这个属性,Spring Boot 绑定配置时遇到不认识的 key 默认是跳过的。

1.3 改好的配置

yaml 复制代码
spring:
  data:
    redis:
      host: 192.168.3.108
      port: 6379
      database: 15
      # 命令超时,写在 redis 这一层
      timeout: 5s
      lettuce:
        pool:
          max-active: 8
          max-idle: 8
          min-idle: 0
          max-wait: 3s

连接池要生效,pom 里要有 commons-pool2(Spring Boot 管版本,不用写):

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
    <groupId>org.apache.commons</groupId>
    <artifactId>commons-pool2</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-json</artifactId>
</dependency>

2. RedisConfig:旧的序列化器类已经没了

老项目的 RedisConfig 是网上最常见的那种写法:

java 复制代码
Jackson2JsonRedisSerializer<Object> jackson2JsonRedisSerializer = new Jackson2JsonRedisSerializer<>(Object.class);
ObjectMapper om = new ObjectMapper();
om.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
om.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
jackson2JsonRedisSerializer.setObjectMapper(om);

搬到 Boot 4.1.1 编译不过 。我列了 spring-data-redis-4.1.1.jar 里 serializer 包的全部类:没有 Jackson2JsonRedisSerializer,也没有 GenericJackson2JsonRedisSerializer,只有 Jackson 3 版本的 JacksonJsonRedisSerializer 和 GenericJacksonJsonRedisSerializer(构造参数是 tools.jackson.databind.ObjectMapper)。Jackson 3 的 MapperBuilder 上也没有旧的 enableDefaultTyping(DefaultTyping) 方法了,只有 activateDefaultTyping(...)。

新写法用 GenericJacksonJsonRedisSerializer.builder():

java 复制代码
package com.example.redisutil.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.GenericJacksonJsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
import tools.jackson.databind.jsontype.PolymorphicTypeValidator;

@Configuration
public class RedisConfig {

    /**
     * key 用 String,value 用 Jackson 3 的 JSON。
     * 读回时要还原成原来的类,所以开了类型信息,但只放行白名单里的包(不用 enableUnsafeDefaultTyping)。
     */
    @Bean
    public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
        PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
                .allowIfSubType("com.example.redisutil.")
                .allowIfSubType("java.util.")
                .allowIfSubType("java.lang.")
                .build();
        GenericJacksonJsonRedisSerializer json = GenericJacksonJsonRedisSerializer.builder()
                .enableDefaultTyping(ptv)
                .build();
        StringRedisSerializer str = new StringRedisSerializer();

        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(factory);
        template.setKeySerializer(str);
        template.setHashKeySerializer(str);
        template.setValueSerializer(json);
        template.setHashValueSerializer(json);
        template.afterPropertiesSet();
        return template;
    }
}

allowIfSubType("com.example.redisutil.") 换成你自己的包名。

2.1 为什么要开类型信息,又为什么要白名单

不开类型信息 (GenericJacksonJsonRedisSerializer(JsonMapper.builder().build())),存一个 User:

json 复制代码
{"id":1,"name":"张三","age":20}

读回来是 java.util.LinkedHashMap,强转 User 直接报错:

vbnet 复制代码
java.lang.ClassCastException: class java.util.LinkedHashMap cannot be cast to class com.example.redisutil.User

开了类型信息 ,存进去的 JSON 多了一个 @class,读回来就是 User:

less 复制代码
{"@class":"com.example.redisutil.User","id":1,"name":"张三","age":20}

问题在于:读的时候,Jackson 会按 @class 里写的类名去创建对象。谁能往你的 Redis 里写数据,谁就能决定你的程序创建什么类 。这就是老写法 enableDefaultTyping(NON_FINAL) 的风险所在,也是 Jackson / fastjson 一长串反序列化漏洞的根子。

白名单的效果,用一个不在白名单里的类 demo.other.Order 试一下:写得进 ({"@class":"demo.other.Order","no":"NO-1"}),读的时候被拦:

typescript 复制代码
org.springframework.data.redis.serializer.SerializationException: Could not read JSON: Could not resolve type id 'demo.other.Order' as a subtype of `demo.other.Order`: Configured `PolymorphicTypeValidator` (of type `tools.jackson.databind.jsontype.BasicPolymorphicTypeValidator`) denied resolution

builder 上还有一个 enableUnsafeDefaultTyping(),用它同样的数据照读不误(读回 demo.other.Order)------ 它就是老写法的等价物,名字里已经写了 Unsafe。

3. RedisUtil 完整代码

在老版本的基础上改了几处,原因后面第 5 节逐条有实测:

  • 返回值一律用包装类型(Long/Boolean),不拆箱;
  • 异常不吞(老版本是 catch 之后 printStackTrace 再 return false);
  • 批量放 list 单独叫 lSetAll,不和 lSet(Object) 重载;
  • expire 传 0 或负数直接抛异常;
  • 补了 ZSet 和 SCAN。

(源码同步在 GitHub:github.com/xiaoqiMikko...,src/main/java/com/example/redisutil/util/RedisUtil.java。)

java 复制代码
package com.example.redisutil.util;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.TimeUnit;

import org.springframework.data.redis.core.Cursor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ScanOptions;
import org.springframework.data.redis.core.ZSetOperations.TypedTuple;
import org.springframework.stereotype.Component;

@Component
public class RedisUtil {

    private final RedisTemplate<String, Object> redisTemplate;

    public RedisUtil(RedisTemplate<String, Object> redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    // ============================== 通用 ==============================

    /**
     * 设置过期时间(秒)。time 必须大于 0;Redis 的 EXPIRE 传 0 或负数会直接删掉 key。
     *
     * @return key 存在并设置成功返回 true,key 不存在返回 false
     */
    public Boolean expire(String key, long time) {
        if (time <= 0) {
            throw new IllegalArgumentException("过期时间必须大于 0,传 0 或负数 Redis 会直接删掉 key: " + time);
        }
        return redisTemplate.expire(key, time, TimeUnit.SECONDS);
    }

    /** 剩余过期时间(秒)。-1 表示永不过期,-2 表示 key 不存在。 */
    public Long getExpire(String key) {
        return redisTemplate.getExpire(key, TimeUnit.SECONDS);
    }

    /** 去掉过期时间,变成永久 key */
    public Boolean persist(String key) {
        return redisTemplate.persist(key);
    }

    public Boolean hasKey(String key) {
        return redisTemplate.hasKey(key);
    }

    /** 删除一个或多个 key,返回实际删掉的个数 */
    public Long del(String... keys) {
        if (keys == null || keys.length == 0) {
            return 0L;
        }
        return redisTemplate.delete(Arrays.asList(keys));
    }

    /**
     * 按模式查 key,用 SCAN 分批遍历,不用 KEYS(KEYS 会阻塞整个 Redis)。
     *
     * @param pattern 例如 user:*
     * @param count   每批建议数量(只是提示,Redis 不保证)
     */
    public List<String> scan(String pattern, long count) {
        List<String> keys = new ArrayList<>();
        ScanOptions options = ScanOptions.scanOptions().match(pattern).count(count).build();
        try (Cursor<String> cursor = redisTemplate.scan(options)) {
            cursor.forEachRemaining(keys::add);
        }
        return keys;
    }

    // ============================== String ==============================

    public Object get(String key) {
        return redisTemplate.opsForValue().get(key);
    }

    public void set(String key, Object value) {
        redisTemplate.opsForValue().set(key, value);
    }

    /** 放值并设置过期时间(秒),一条 SET key value EX time 完成,不是先 SET 再 EXPIRE */
    public void set(String key, Object value, long time) {
        if (time <= 0) {
            throw new IllegalArgumentException("过期时间必须大于 0: " + time);
        }
        redisTemplate.opsForValue().set(key, value, time, TimeUnit.SECONDS);
    }

    /** key 不存在才放(SET NX),放进去了返回 true,key 已存在返回 false */
    public Boolean setIfAbsent(String key, Object value, long time) {
        return redisTemplate.opsForValue().setIfAbsent(key, value, time, TimeUnit.SECONDS);
    }

    /** 递增,返回加完之后的值 */
    public Long incr(String key, long delta) {
        return redisTemplate.opsForValue().increment(key, delta);
    }

    /** 递减,返回减完之后的值 */
    public Long decr(String key, long delta) {
        return redisTemplate.opsForValue().decrement(key, delta);
    }

    // ============================== Hash ==============================

    public Object hGet(String key, String hashKey) {
        return redisTemplate.opsForHash().get(key, hashKey);
    }

    public Map<Object, Object> hmget(String key) {
        return redisTemplate.opsForHash().entries(key);
    }

    public void hSet(String key, String hashKey, Object value) {
        redisTemplate.opsForHash().put(key, hashKey, value);
    }

    public void hmset(String key, Map<String, ?> map) {
        redisTemplate.opsForHash().putAll(key, map);
    }

    /** 返回实际删掉的字段个数 */
    public Long hDel(String key, Object... hashKeys) {
        return redisTemplate.opsForHash().delete(key, hashKeys);
    }

    public Boolean hHasKey(String key, String hashKey) {
        return redisTemplate.opsForHash().hasKey(key, hashKey);
    }

    /** 字段加减,delta 为负数就是减 */
    public Long hIncr(String key, String hashKey, long delta) {
        return redisTemplate.opsForHash().increment(key, hashKey, delta);
    }

    // ============================== Set ==============================

    public Set<Object> sGet(String key) {
        return redisTemplate.opsForSet().members(key);
    }

    public Boolean sHasKey(String key, Object value) {
        return redisTemplate.opsForSet().isMember(key, value);
    }

    /** 返回新加进去的个数(已存在的不算) */
    public Long sSet(String key, Object... values) {
        return redisTemplate.opsForSet().add(key, values);
    }

    public Long sGetSetSize(String key) {
        return redisTemplate.opsForSet().size(key);
    }

    /** 返回实际移除的个数 */
    public Long setRemove(String key, Object... values) {
        return redisTemplate.opsForSet().remove(key, values);
    }

    // ============================== List ==============================

    /** 取一段,0 到 -1 表示全部 */
    public List<Object> lGet(String key, long start, long end) {
        return redisTemplate.opsForList().range(key, start, end);
    }

    public Long lGetListSize(String key) {
        return redisTemplate.opsForList().size(key);
    }

    /** index 从 0 开始,-1 是最后一个 */
    public Object lGetIndex(String key, long index) {
        return redisTemplate.opsForList().index(key, index);
    }

    /** 从右边放一个元素,返回放完之后 list 的长度 */
    public Long lSet(String key, Object value) {
        return redisTemplate.opsForList().rightPush(key, value);
    }

    /** 从右边批量放,集合里每个元素占 list 的一个位置,返回放完之后 list 的长度 */
    public Long lSetAll(String key, Collection<?> values) {
        return redisTemplate.opsForList().rightPushAll(key, new ArrayList<Object>(values));
    }

    public void lUpdateIndex(String key, long index, Object value) {
        redisTemplate.opsForList().set(key, index, value);
    }

    /** 移除值为 value 的元素,count = 0 全部移除,count > 0 从左往右移 count 个;返回实际移除的个数 */
    public Long lRemove(String key, long count, Object value) {
        return redisTemplate.opsForList().remove(key, count, value);
    }

    // ============================== ZSet ==============================

    /** 新加进去返回 true,已存在(只更新分数)返回 false */
    public Boolean zAdd(String key, Object value, double score) {
        return redisTemplate.opsForZSet().add(key, value, score);
    }

    /** 分数加减,返回加完之后的分数 */
    public Double zIncr(String key, Object value, double delta) {
        return redisTemplate.opsForZSet().incrementScore(key, value, delta);
    }

    public Double zScore(String key, Object value) {
        return redisTemplate.opsForZSet().score(key, value);
    }

    /** 按分数从高到低的排名,第一名是 0;不存在返回 null */
    public Long zRevRank(String key, Object value) {
        return redisTemplate.opsForZSet().reverseRank(key, value);
    }

    /** 按分数从高到低取一段(排行榜),带分数 */
    public Set<TypedTuple<Object>> zRevRangeWithScores(String key, long start, long end) {
        return redisTemplate.opsForZSet().reverseRangeWithScores(key, start, end);
    }

    public Long zSize(String key) {
        return redisTemplate.opsForZSet().zCard(key);
    }

    public Long zRemove(String key, Object... values) {
        return redisTemplate.opsForZSet().remove(key, values);
    }
}

4. 每个操作:Java 调用、对应的 Redis 命令、实测结果

「结果」列是集成测试里断言过的值(测试连真实 Redis 跑,全部通过);「实际发出的命令」列是跑测试时在服务端开 redis-cli monitor 抓到的 ------ 有几个和想当然的不一样,比如 expire 发的是 PEXPIRE、getExpire 发的是 PTTL。(值经过 Jackson 序列化后都带一层引号,如 "\"java\"";为了表格好读,下面只在头几行写出来,其余省略。)

4.1 通用 / String

Java 实际发出的命令 结果
set("name", "张三") SET name "\"张三\""(值带引号,见第 5 节) ---
get("name") GET name 张三
set("code", "8848", 60) SET code "\"8848\"" EX 60(一条命令,不是 SET 再 EXPIRE) getExpire 立刻读到 59
getExpire("name")(没设过期) PTTL name -1
getExpire("none")(key 不存在) PTTL none -2
get("none") GET none null
incr("count", 5) → decr("count", 2) INCRBY count 5 / DECRBY count 2 5 → 3
setIfAbsent("lock", "1", 10) 连调两次 SET lock "\"1\"" EX 10 NX true,第二次 false
hasKey("a") EXISTS a true
expire("a", 100) / 对不存在的 key PEXPIRE a 100000 true / false
persist("a") PERSIST a true,之后 getExpire 为 -1
del("a", "b", "none") DEL a b none 2(不存在的不算)
scan("ru:t:*", 100) SCAN 0 MATCH ru:t:* COUNT 100 两个 key 都找到

4.2 Hash

Java 实际发出的命令 结果
hSet(k, "name", "李四") HSET k name "\"李四\"" ---
hmset(k, Map.of("age", 30, "city", "深圳")) HMSET k age 30 city "\"深圳\"" ---
hGet(k, "age") HGET k age 30(Integer)
hmget(k) HGETALL k 3 个字段
hIncr(k, "age", 1) HINCRBY k age 1 31
hDel(k, "city", "none") HDEL k city none 1
hHasKey(k, "city") HEXISTS k city false

4.3 List

Java 实际发出的命令 结果
lSet(k, "a") RPUSH k "\"a\"" 1
lSetAll(k, List.of("b", "c", "a")) RPUSH k 三个值一条命令 4
lGet(k, 0, -1) LRANGE k 0 -1 [a, b, c, a]
lGetIndex(k, -1) LINDEX k -1 a
lUpdateIndex(k, 1, "B") LSET k 1 B ---
lRemove(k, 0, "a") LREM k 0 a 2,剩 [B, c]

4.4 Set

Java 实际发出的命令 结果
sSet(k, "java", "redis", "mysql"),再 sSet(k, "java") SADD 3,第二次 0
sHasKey(k, "redis") / "go" SISMEMBER true / false
setRemove(k, "mysql", "go") SREM k mysql go 1
sGetSetSize(k) SCARD k 2

4.5 ZSet(排行榜)

Java 实际发出的命令 结果
zAdd 张三 90、李四 85、王五 95 ZADD k 90.0 "\"张三\"" ...... 都是 true
zAdd(k, "李四", 88)(已存在) ZADD false(只改分数)
zIncr(k, "李四", 10) ZINCRBY k 10.0 "\"李四\"" 98.0
zRevRank(k, "李四") / 不存在的 "赵六" ZREVRANK 0 / null
zRevRangeWithScores(k, 0, 1) ZREVRANGE k 0 1 WITHSCORES [李四:98.0, 王五:95.0]
zRemove(k, "张三") ZREM 1

5. redis-cli 里的「乱码」、引号和 \xac\xed

用上面的 RedisUtil 写一组数据,再到服务器上用 redis-cli 看:

swift 复制代码
127.0.0.1:6379[15]> get ru:demo:name
"\"\xe5\xbc\xa0\xe4\xb8\x89\""
127.0.0.1:6379[15]> get ru:demo:user:1
"{\"@class\":\"com.example.redisutil.User\",\"id\":1,\"name\":\"\xe5\xbc\xa0\xe4\xb8\x89\",\"age\":20}"
127.0.0.1:6379[15]> get ru:demo:count
"3"
127.0.0.1:6379[15]> hgetall ru:demo:user:hash:1
1) "name"
2) "\"\xe6\x9d\x8e\xe5\x9b\x9b\""
3) "age"
4) "30"
127.0.0.1:6379[15]> lrange ru:demo:list 0 -1
1) "\"a\""
2) "\"b\""
3) "\"c\""

这里其实有两件事。

\xe5\xbc\xa0\xe4\xb8\x89 不是存坏了 ,它就是「张三」的 UTF-8 字节,只是 redis-cli 在终端里默认把非 ASCII 字节转义显示。加 --raw 再看:

arduino 复制代码
$ redis-cli -n 15 --raw get ru:demo:name
"张三"

外面那层引号是真的存进去了 :Jackson 把字符串序列化成 JSON 字符串,JSON 字符串本来就带引号。对照用 StringRedisTemplate 写的同一个值:

ruby 复制代码
127.0.0.1:6379[15]> get ru:demo:str
"\xe5\xbc\xa0\xe4\xb8\x89"
$ redis-cli -n 15 --raw get ru:demo:str
张三

小提示:通过 ssh 主机 'redis-cli ...' 这种非终端方式执行时,redis-cli 默认就是 raw 输出;想看到上面那种转义形式要加 --no-raw。

5.1 不配序列化器:\xac\xed

如果自己 new RedisTemplate<>() 却不设序列化器,用的是 JDK 序列化。写一个 key ru:demo:jdk、值 "张三",在 redis-cli 里:

ruby 复制代码
127.0.0.1:6379[15]> keys ru:*
1) "ru:demo:count"
2) "ru:demo:name"
...                                    ← 没有 ru:demo:jdk
$ redis-cli -n 15 --no-raw --scan --pattern "*ru:demo:jdk*"
"\xac\xed\x00\x05t\x00\x0bru:demo:jdk"
> get "\xac\xed\x00\x05t\x00\x0bru:demo:jdk"
"\xac\xed\x00\x05t\x00\x06\xe5\xbc\xa0\xe4\xb8\x89"

连 key 都被序列化了 ,前面多了 \xac\xed\x00\x05t\x00\x0b 这一串,所以 keys ru:* 列不出它,用字符串 key 去查 EXISTS 也是 false。只有同一个 template 自己能读回「张三」。

5.2 两种 template 混用

谁写 谁读 结果
RedisUtil(Jackson)写 张三 StringRedisTemplate "张三",多一对引号
StringRedisTemplate 写 张三 RedisUtil(Jackson) SerializationException: Could not read JSON: Invalid UTF-8 start byte 0xbc
StringRedisTemplate 写 abc RedisUtil(Jackson) Could not read JSON: Unrecognized token 'abc': was expecting (JSON String, Number, Array, Object or token 'null', 'true' or 'false')
StringRedisTemplate 写 100 RedisUtil(Jackson) 能读,得到 java.lang.Integer 100

100 能读是因为它恰好是合法的 JSON 数字。所以混用的问题有时候不会马上暴露,要等到存进来的是中文或字母时才炸。同一批 key 从头到尾只用一种 template 读写。

6. 老写法搬过来会出的事

下面每一条都是把老 RedisUtil 的方法原样复制出来,和新版放在一起跑的结果。

6.1 lSet 传 List<String>:整个 list 变成了一个元素

老版本有两个重载:

java 复制代码
public boolean lSet(String key, Object value)        // RPUSH 一个
public boolean lSet(String key, List<Object> value)  // RPUSH 一批

调用 lSet(key, List.of("a", "b", "c")):返回 true,但

ini 复制代码
LLEN = 1
LRANGE = [["a","b","c"]]

List<String> 不是 List<Object>,编译器选中的是 lSet(String, Object),整个 list 被序列化成一个 JSON 数组、当成一个元素塞了进去。新版改成 lSetAll(String, Collection<?>),同样的调用 LLEN = 3。

6.2 返回 long 的方法放进管道或事务:NPE,而且命令可能已经执行了

老版本:

java 复制代码
public long incr(String key, long delta) {
    return redisTemplate.opsForValue().increment(key, delta);
}

在 executePipelined 或 multi()/exec() 里调用,increment 返回的是 null(结果要等管道或事务结束才拿得到),拆箱成 long 时:

kotlin 复制代码
java.lang.NullPointerException: Cannot invoke "java.lang.Long.longValue()" because the return value of "org.springframework.data.redis.core.ValueOperations.increment(Object, long)" is null

更要命的是后续状态:

  • 管道 :代码报了 NPE,但 INCR 已经发出去执行了 ------ 之后 GET 读到的是 1。业务代码以为失败了,数据其实已经改了。
  • 事务 :NPE 发生在 exec() 之前,整个事务没提交,值没变。

新版返回 Long,管道里拿到 null 不会炸,executePipelined 结束后返回 [1]。

6.3 过期时间传 0

老版本的 expire:

java 复制代码
if (time > 0) {
    redisTemplate.expire(key, time, TimeUnit.SECONDS);
}
return true;

expire(key, 0) 返回 true、什么都没做,之后 TTL 是 -1(永不过期)。而直接调 redisTemplate.expire(key, Duration.ZERO),返回 true,key 被删掉了 (之后 EXISTS 是 false)。一个「静默不过期」,一个「静默删除」,都不报错。新版对 time <= 0 直接抛 IllegalArgumentException。

6.4 Map.of / List.of:写得进,读不出

开着类型信息时(第 2 节的配置),用 Map.of(...) 或 List.of(...) 直接当 value 存:

写入 Redis 里存的 读回
Map.of("k", 1) {"k":1} ❌ Could not resolve subtype of [simple type, class java.lang.Object]: missing type id property '@class'
List.of("a", "b") ["a","b"] ❌ Could not resolve type id 'a' as a subtype of java.lang.Object: no such class found
new HashMap<>(Map.of("k", 1)) {"@class":"java.util.HashMap","k":1} ✅ java.util.HashMap
new ArrayList<>(List.of("a", "b")) ["java.util.ArrayList",["a","b"]] ✅

Map.of / List.of 写进去时没带类型信息 ,读的时候又要求有,结果就是写的时候一切正常,读的时候才报错 ------ 往往已经是另一个请求、另一台机器了。存集合时用 new HashMap<>(...) / new ArrayList<>(...) 包一层。

6.5 存 Long,读回 Integer

set("long", 5L),Redis 里存的就是 5,读回来是 java.lang.Integer。(Long) redisUtil.get("long") 会 ClassCastException。数字读回来统一当 Number 处理:((Number) v).longValue()。

7. KEYS 和 SCAN:实测各自耗时

在 db 15 里用 DEBUG POPULATE 1000000 ru:pop 灌了 100 万个 key(内存从 851K 涨到 77.5M),查 ru:pop:99999*(命中 11 个):

方式 总耗时 执行期间,另一个连接 PING 要等多久
KEYS(redis-cli,连跑 3 次) 127 / 124 / 118 ms KEYS ru:pop:* 期间:337ms、271ms(空闲时 3ms)
KEYS(Java keys()) 130 ms ---
redis-cli --scan 1,900 ms 2--3 ms
Java scan,count = 10 38,045 ms ---
Java scan,count = 100 4,177 ms ---
Java scan,count = 1000 771 ms ---
Java scan,count = 10000 513 ms ---

slowlog(阈值 10ms)里记下了 4 条,全是 KEYS(114--354ms),SCAN 一条都没有。

读法:

  • KEYS 自己很快,但它执行的那段时间,别的请求全在排队。Redis 是单线程处理命令的,这 100 多毫秒就是所有客户端一起等的 100 多毫秒;key 更多时这个时间跟着涨。
  • SCAN 总耗时反而长得多,但每一批都很短,中间别人的请求能插进来,所以 PING 一直是 2--3ms。
  • count 不能太小:count 是每批大概看多少个 key 的提示,批次越多来回越多。上面 Java 是从另一台机器走网络连的,count = 10 时整整 38 秒。一般给 1000 左右。
  • 另外,redis-cli 6.2 的 --scan 不支持 --count 参数,加了会报 Unrecognized option or bad number of args for: '--count'。

8. 连不上:protected-mode

Redis 6.2.4 默认 protected-mode yes。如果配置文件里把 bind 注释掉了、又没设密码,远程连就会被拒。服务端发回来的原文是:

vbnet 复制代码
-DENIED Redis is running in protected mode because protected mode is enabled, no bind address was specified, no authentication password is requested to clients. In this mode connections are only accepted from the loopback interface. If you want to connect from external computers to Redis you may adopt one of the following solutions: 1) Just disable protected mode sending the command 'CONFIG SET protected-mode no' from the loopback interface by connecting to Redis from the same host the server is running, however MAKE SURE Redis is not publicly accessible from internet if you do so. Use CONFIG REWRITE to make this change permanent. 2) Alternatively you can just disable the protected mode by editing the Redis configuration file, and setting the protected mode option to 'no', and then restarting the server. 3) If you started the server manually just for testing, restart it with the '--protected-mode no' option. 4) Setup a bind address or an authentication password. NOTE: You only need to do one of the above things in order for the server to start accepting connections from the outside.

但 Java 这边看不到这段话。我在 Windows 上用 Spring Boot 连,拿到的是:

vbnet 复制代码
org.springframework.data.redis.RedisConnectionFailureException: Unable to connect to Redis
  org.springframework.data.redis.connection.PoolException: Could not get a resource from the pool
  io.lettuce.core.RedisConnectionException: Unable to connect to 192.168.3.108/<unresolved>:6379
  java.io.IOException: 你的主机中的软件中止了一个已建立的连接。

服务端发完那句话就把连接断了,客户端只看到「连接被中止」。看到「能连上、马上又被断开」这种报错,就去服务器上用 redis-cli 本机连一下,或者看看 protected-mode。

处理办法就是原文里列的那几种,其中最稳妥的是第 4 种:设密码(requirepass),或者 bind 到内网地址。直接 protected-mode no 只适合内网测试机,而且要确认这台机器的 6379 不对外。

9. 速查

现象 原因 处理
报 Unable to connect to localhost,但配的不是 localhost 用的还是 spring.redis.*,Boot 4.1.1 不读 改成 spring.data.redis.*;用 spring-boot-properties-migrator 找漏网的 key
超时配置不生效,还是 60 秒 timeout 写在 lettuce.pool 下 写在 spring.data.redis.timeout
Jackson2JsonRedisSerializer 找不到 Spring Data Redis 4.1.1 里已经没有这个类 用 GenericJacksonJsonRedisSerializer.builder()
读回来是 LinkedHashMap 没开类型信息 enableDefaultTyping(白名单)
... denied resolution 类不在白名单里 把自己的包加进白名单,别用 enableUnsafeDefaultTyping
missing type id property '@class' 存的是 Map.of / List.of 用 new HashMap<>(...) / new ArrayList<>(...)
redis-cli 里中文是 \xe5\x.. redis-cli 的显示转义 redis-cli --raw
值外面多一层引号 Jackson 序列化的 JSON 字符串 正常现象;别和 StringRedisTemplate 混用
key 前面有 \xac\xed 没配序列化器,用的 JDK 序列化 配 StringRedisSerializer 作 key 序列化器
Long 强转报错 数字读回是 Integer 当 Number 处理
list 里只有一个元素,内容是整个数组 List<String> 走了 lSet(Object) 重载 用 lSetAll(Collection<?>)
管道 / 事务里 NPE long 拆箱了 null 返回值用 Long
线上卡了一下,slowlog 里全是 KEYS KEYS 阻塞 换 SCAN,count 给 1000 左右
远程连上就被断开 protected-mode 设密码或 bind 内网地址

下一篇开始是 Redis 排查连载,在同一台机器上把内存写满、让 bgsave 失败、kill -9 看丢多少数据。

附录:源码

GitHub:github.com/xiaoqiMikko...

文件 内容
src/main/resources/application.yml 第 1.3 节的配置(地址用 REDIS_HOST 环境变量,默认 localhost)
src/main/java/.../config/RedisConfig.java 第 2 节
src/main/java/.../util/RedisUtil.java 第 3 节
src/test/java/.../RedisUtilTest.java 第 4 节表格里的每一行断言
src/test/java/.../PitfallTest.java 第 1、2、5、6 节的每个坑,现象写到 target/pitfall-out.txt
src/test/java/.../OldRedisUtil.java 老版本里出问题的几个方法,原样照抄,用来对照
ScanTimingTest / ProtectedModeTest / DemoWriteTest 第 7、8、5 节,要先准备环境,默认不跑

跑法:REDIS_HOST=你的Redis地址 mvn test(用 db 15,只动 ru: 开头的 key,前后会清理)。想看旧前缀被报出来:mvn test -Pmigrator -Dtest=PitfallTest#p1_oldPrefix。

相关推荐
小白的码BUG之路1 小时前
Jenkins -- 配置jdk和maven
java·jenkins·maven
yuniko-n1 小时前
【JUC】读写锁
java·开发语言
yuzhiboyouye1 小时前
后端怎么评估工作量
java
java资料站1 小时前
十五、Spring AI Alibaba · 高级 ·Graph
java·人工智能·spring
lv__pf1 小时前
redis【TL redis(6)】
数据库·redis·缓存
用户84913717547161 小时前
Redis 跳表用了"随机"的魔法,实现了比平衡树更快的查找!
数据结构·redis
金銀銅鐵2 小时前
[Java] 用 GUI 展示 ArrayList 扩容的过程
java·后端·ai编程
磊 子2 小时前
什么是虚假唤醒(spurious wakeup)?从 POSIX 标准讲到 futex 内核
java·开发语言
飘然生2 小时前
traceId链路 skywalking java agent
java·开发语言·skywalking