BH4FFUBH4FFU

程序员
业余无线电爱好者

RedisTemplate操作redis,key和value怎么就"乱码"了呢?

本文共约2000字,预计阅读时间10分钟

0x01 哪里"乱码"?

使用过redisTemplate的小伙伴可能遇到过redis中key和value"乱码"的问题,

spring boot框架中已经集成了redis,在1.x.x的版本时默认使用的jedis客户端,现在是2.x.x版本默认使用的lettuce客户端,两种客户端的区别如下
1、Jedis 是直连模式,在多个线程间共享一个 Jedis 实例时是线程不安全的,如果想要在多线程环境下使用 Jedis,需要使用连接池,
每个线程都去拿自己的 Jedis 实例,当连接数量增多时,物理连接成本就较高了。
2、Lettuce的连接是基于Netty的,连接实例可以在多个线程间共享,所以,一个多线程的应用可以使用同一个连接实例,而不用担心并发线程的数量。
在本文中,springboot的版本是2.3.1,redis客户端默认的是Lettuce

代码如下

redisTemplate.opsForZSet().incrementScore("pageView", pageId, 1);

redis中看到的结果
redis中key和zset中的member乱码显示

客户端的问题? 来看一下redis-cli中看到的结果

1、key和对应的value都"乱码"了,即使他们都是基本数据类型。从结果上看,不是某一客户端显示的问题,是写入的问题

0x02 为什么"乱码"?

以DefaultZSetOperations.incrementScore()方法为例

@Override
public Double incrementScore(K key, V value, double delta) {

    byte[] rawKey = rawKey(key);
    byte[] rawValue = rawValue(value);
    return execute(connection -> connection.zIncrBy(rawKey, delta, rawValue), true);
}

从源码中看,先通过rawKey方法将使用默认的序列化器将key和value序列化为字节数组,然后将序列化后的key和value放入到reids中。
那默认的序列化器是什么呢?
通过跟踪代码很容易找到,在RedisTempalte的afterPropertiesSet()方法中,默认序列化器为JdkSerializationRedisSerializer

@Override
public void afterPropertiesSet() {
    super.afterPropertiesSet();
    boolean defaultUsed = false;
    if (defaultSerializer == null) {
        defaultSerializer = new JdkSerializationRedisSerializer(
                classLoader != null ? classLoader : this.getClass().getClassLoader());
    }}

JdkSerializationRedisSerializer序列化后的结果就是我们在redis客户端工具中看到的摸样:

\xac\xed\x00\x05t\x00\x08pageView

那应该怎么做呢?

0x03 怎么解决?

  • 1、使用StringRedisTemplate,如果key和value都是字符串的话。StringRedisTemplate内部使用StringRedisSerializer
    public StringRedisTemplate() {
        setKeySerializer(RedisSerializer.string());
        setValueSerializer(RedisSerializer.string());
        setHashKeySerializer(RedisSerializer.string());
        setHashValueSerializer(RedisSerializer.string());
    }

修改后的代码

// redisTemplate.opsForZSet().incrementScore("pageView", pageId, 1);
stringRedisTemplate.opsForZSet().incrementScore("pageView_new", String.valueOf(pageId), 1);

修改后的效果

  • 2、修改RedisTemplate的key和value的序列化器和反序列化器,这个方案网上的博客都是这么写的,大家可以搜一下。如果不是特别的需求用方法一就好了,方便简洁不花哨,符合KISS原则。

0x04 多了解一点?

我们的问题解决了,但是还是有几个小问号

  1. \xac\xed\x00\x05t\x00\x08pageView中的\xac\xed\x00\x05t\x00\x08怎么来的?是什么?
  2. 什么时候使用JdkSerializationRedisSerializer?

第一个问题,这串特殊字符串是怎么来的以及是什么?

1、怎么来的?

小学二年级我们学过redis底层都是String,String都是用字节数组来表示。所以我们上层应用和redis通信,传入的都是字节数组。
回到我们的问题,

  • 当我们使用StringRedisTemplate和redis交互,得到的key和value都是我们期望的字符串;
  • 使用RedisTemplate得到的就是一些夹杂特殊字符的字符串,如下:

这种问题,我们首先应该把精力放在StringRedisTemplate和RedisTemplate上。为什么?因为redis还是那个redis,使用的工具变了,结果也就变了。小学一年级的知识,控制变量法,没记错的话。
不妨大胆假设一下,StringRedisTemplate和RedisTemplate两个工具类生成的字节数组不同。通过源码,我们发现
StringRedisTemplate通过string.getBytes(charset)得到字节数组

    @Override
public byte[] serialize(@Nullable String string) {
    return (string == null ? null : string.getBytes(charset));
}

RedisTempalte通过ObjectOutputStream.wirteObject().toByteArray()序列化的方式得到字节数组

@Override
public void serialize(Object object, OutputStream outputStream) throws IOException {
    if (!(object instanceof Serializable)) {
        throw new IllegalArgumentException(getClass().getSimpleName() + " requires a Serializable payload " +
                "but received an object of type [" + object.getClass().getName() + "]");
    }
    ObjectOutputStream objectOutputStream = new ObjectOutputStream(outputStream);
    objectOutputStream.writeObject(object);
    objectOutputStream.flush();
}

需要注意的是,redis客户端看到的所谓的乱码都是经过hex编码过的
以pageView为例,用这两种方式分别输出一下hex编码后的值,看是不是和redis中显示的能对应起来,反正电费也没多少钱。为了节约大家的时间,直接上结果:

// 使用string中的getBytes方法:
70, 61, 67, 65, 56, 69, 65, 77
// 使用java序列化:
ac, ed, 00, 05, 74, 00, 08, 70, 61, 67, 65, 56, 69, 65, 77

有点意思了吧,通过对照以下的密码本,我们可以看到,使用JdkSerializationRedisSerializer得到的结果和我们在redis客户端中看到的一致,即:\xac\xed\x00\x05t\x00\x08

通过查看ObjectOutputStream的源码发现,也确实如此,有兴趣的可以debug一下源码或者直接看一下ObjectStreamConstants这个接口中的常量,你会发现很多熟悉的面孔。

2、是什么?

那么\xac\xed\x00\x05t\x00\x08到底是什么呢?逼逼这么久也只是说了下它怎么来的
重点来了(其实是越跑越远 /逃)...

回答这个问题,要从java的序列化说起。java序列化要解决的问题是对象的转储问题,要解决转储问题,不仅要记录对象的内容(本例为pageView字符串)还要记录一些类型信息。为了更好的说明这个问题,我们debug一下ObjectOutputStream源码看一下(以String为例)
1、构造方法

public ObjectOutputStream(OutputStream out) throws IOException {
    verifySubclass();
    bout = new BlockDataOutputStream(out);
    handles = new HandleTable(10, (float) 3.00);
    subs = new ReplaceTable(10, (float) 3.00);
    enableOverride = false;
    writeStreamHeader();
    bout.setBlockDataMode(true);
    if (extendedDebugInfo) {
        debugInfoStack = new DebugTraceInfoStack();
    } else {
        debugInfoStack = null;
    }
}
protected void writeStreamHeader() throws IOException {
    bout.writeShort(STREAM_MAGIC);
    bout.writeShort(STREAM_VERSION);
}
/**
 * Magic number that is written to the stream header.
 */
final static short STREAM_MAGIC = (short)0xaced;

/**
 * Version number that is written to the stream header.
 */
final static short STREAM_VERSION = 5;

通过构造方法我们知道,序列化String首先在stream中写入头信息,包含两部分

  1. 魔法值: 0xaced

魔法值或者叫魔术值唯一的作用就是确定是否是JDK序列化之后的值。这种用魔术值标明身份的做法,业界有很多,比如class文件,图片格式(GIF或者JPEG等在文件头中都存有魔数)等。

  1. 版本号:5

这两部分对应\xac\xed\x00\x05

2、writeObject方法,重点一下writeString方法

private void writeString(String str, boolean unshared) throws IOException {
    handles.assign(unshared ? null : str);
    long utflen = bout.getUTFLength(str);
    if (utflen <= 0xFFFF) {
        bout.writeByte(TC_STRING);
        bout.writeUTF(str, utflen);
    } else {
        bout.writeByte(TC_LONGSTRING);
        bout.writeLongUTF(str, utflen);
    }
}

首先计算一下字符串的长度为8,如果长度小于0xFFFF,写入三部分信息:

  1. 写入TC_STRING:0x74
  2. 写入长度信息: 8
  3. 写入字符串本身的信息:pageView

这三部分的信息对应:t\x00\x08pageView

具体序列化后Stream的格式,可以参考oracle的官方文档:Object Serialization Stream Protocol

那问题又来了,什么是魔法值,版本号,TC_STRING又是干什么的? 继续扌

第二个问题,什么时候使用JdkSerializationRedisSerializer?

通过上面的分析,我们知道,JdkSerializationRedisSerializer是通过JDK提供的ObjectOutputStream提供对Java对象的序列化。JDK提供的序列化方式性能比较差,序列化后的流比较大,而且也不能跨语言。所以大家在选择序列化框架时,可以选择时空效率都比较高的框架, 比如
kryo, FST等。
另外,spring-data-redis中提供的序列化器还有:

  • GenericToStringSerializer: 可以将任何对象泛化为字符串并序列化
  • Jackson2JsonRedisSerializer: 跟JacksonJsonRedisSerializer实际上是一样的
  • JacksonJsonRedisSerializer: 序列化object对象为json字符串
  • JdkSerializationRedisSerializer: 序列化java对象
  • StringRedisSerializer: 简单的字符串序列化

就这么多吧,感谢您的阅读,希望有所收获(没想到吧,原来是个java序列化的文章...)

参考资料

https://www.cnblogs.com/taiyonghai/p/9454764.html
https://www.cnblogs.com/throwable/p/11644790.html
https://calculla.com/ascii_hex_table
https://www.cnblogs.com/binarylei/p/10987933.html
https://docs.oracle.com/javase/8/docs/platform/serialization/spec/protocol.html
https://cloud.tencent.com/developer/article/1752784
https://tech.meituan.com/2015/02/26/serialization-vs-deserialization.html
https://www.shenyanchao.cn/blog/2019/02/13/redis-serializer/

本原创文章未经允许不得转载 | 当前页面:BH4FFU » RedisTemplate操作redis,key和value怎么就"乱码"了呢?

评论

文章评论已关闭!