字符编码问题记录

ReDisaster 2019-06-21

需求&问题

需要对序列化以后的对象 (java中的byte[]) 在redis中进行存取
由于redis声称只支持String(作为redis暴露出来的最基本的数据类型)形式的存取 (ref: https://redis.io/topics/internals, https://redis.io/topics/internals )
所以需要在存取前后将byte[]与String互相转换

发现从string decode出来的byte[]跟encode之前的byte[]不一样
即使强制指定了一致的编码解码方式, 结果仍不符合预期

byte[] origin = eh.toBytes(event); // serialized event

String str1 = new String(origin);
byte[] new1 = str1.getBytes();
System.out.println(Arrays.equals(origin, new1));
// output: false

String str2 = new String(origin, StandardCharsets.US_ASCII);
byte[] new2 = str2.getBytes(StandardCharsets.US_ASCII);
System.out.println(Arrays.equals(origin, new2));
// output: false

String str3 = new String(origin, StandardCharsets.UTF_8);
byte[] new3 = str3.getBytes(StandardCharsets.UTF_8);
System.out.println(Arrays.equals(origin, new3));
// output: false

猜测&尝试

  1. 怀疑是系统的默认编码方式与解码时指定的不同, 如上所示 强制指定后未果

  2. 照理说编码解码的算法是对称的, 对一个byte[]编码解码后的到byte[]理应也是一样的. 尝试使用apache的StringUtils编码解码, 结果徒然

原因&解释

经搜索试验后发现原因既与这个byte[]本身有关又与编码方式有关:

该场景中event结构中包含一个UUID, 未序列化前在java中以一个长度为32个字符的字符串表示, 例子“ce4326f3694b479dad472f250b975ee7”, 序列化后在java中为一个长度16个字节的字节数组

为了节省空间, UUID序列化的规则为: 依次将每2个字符视为一个16进制数, 将其转成对应的10进制数, 并写入一个字节空间中. 总共占16字节

一个字节占8个位, 范围为 0000 0000 ~ 1111 1111 (2进制), 00 ~ FF (16进制), 0 ~ 255 (10进制). java里的一个byte变量也能表示256种状态 (刚好相当于16进制数) 然而它的值(10进制)的范围是 -128 ~ 127, 而不是 0 ~ 255. 其中 -128 ~ -1 对应 128 ~ 255

这就导致了将序列化成byte[]以后的event encode成String的时候出现问题, 因为常用的 ASCII, UTF-8等字符集中均没有负数对应的字符. 这意味着event中UUID部分中 80 ~ FF 的值都会被无效encode

比如ASCII中这些值会默认被encode成’?’ (字符), decode成java的byte的时候就变成了63(10进制) ; 在UTF-8中更常见的情况是byte[]中的 byte序列不合法 (Invalid byte sequences) 也就是说该序列所代表的值不在UTF-8字符集支持的index范围之内. 导致了原始的byte[]和经过encode decode后的byte[]不同

Reference:
java - Encoding and decoding UTF-8 byte arrays from and to strings - Stack Overflow
java - Why are the lengths different when converting a byte array to a String and then back to a byte array? - Stack Overflow

解决方案

  1. 使用Base64安全的转换二进制与字符串, 但会使payload增加33%, 原因点此

  2. 使用 Latin-1 编码, 最大缺点是解码时对于UTF-8不兼容

  3. 直接传输二进制数据(java中的byte[]), 具体方式为使用jedis中的BinaryClient类, 其中的方法支持 byte[] 类型的参数


For anyone who’s curious enough:

显然方案3是比较理想的. 看到这里记性好的人不免发出疑问: 开头不是说redis只支持String形式的存取吗?

这里引用一段jedis的文档:

A note about String and Binary - what is native?

Redis/Jedis talks a lot about Strings. And here http://redis.io/topics/internals it says Strings are the basic building block of Redis. However, this stress on strings may be misleading. Redis' "String" refer to the C char type (8 bit), which is incompatible with Java Strings (16-bit). Redis sees only 8-bit blocks of data of predefined length, so normally it doesn't interpret the data (it's "binary safe"). Therefore in Java, byte[] data is "native", whereas Strings have to be encoded before being sent, and decoded after being retrieved by the SafeEncoder. This has some minor performance impact. In short: if you have binary data, don't encode it into String, but use the binary versions.

上文提到其实redis官方文档中多次提到的string是一种误导, 原来redis所说的”String”指的是它的实现语言C中的char (8bit), 对应java中的byte (8bit), 而不是java中的String或char (16bit). Redis只按8位8位地去裸读数据, 而不去解析(所谓的”二进制安全”). 所以, 从java的角度看redis, byte[]类型才是”原生”的

Redis实现中“String”的源码:

struct sdshdr {
    long len;
    long free;
    char buf[];
};

后来想了下, 从传输层面/角度来讲, 根本就没有什么类型, 都是1 0. 应时时提醒自己跳出问题之外, 从源头思考, 避免陷入本本主义

相关推荐