字元編碼問題記錄

sfpprxy發表於2019-05-12

需求&問題

需要對序列化以後的物件 (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. 應時時提醒自己跳出問題之外, 從源頭思考, 避免陷入本本主義

相關文章