一次線上Redis類轉換異常排查引發的思考

luoxn28發表於2019-06-24

之前同事反饋說線上遇到Redis反序列化異常問題,異常如下:

XxxClass1 cannot be cast to XxxClass2

已知資訊如下:

  • 該異常不是必現的,偶爾才會出現;
  • 出現該異常後重啟應用或者過一會就好了;
  • 序列化協議使用了hessian。

因為偶爾出現,首先看了報異常那塊業務邏輯是不是有問題,看了一遍也發現什麼問題。看了下對應日誌,發現是在Redis讀超時之後才出現的該異常,因此懷疑redis client操作邏輯那塊導致的(公司架構組對redis做了一層封裝),發現獲取/釋放redis連線如下程式碼:

 1 try {
 2     jedis = jedisPool.getResource();
 3     // jedis業務讀寫操作
 4 } catch (Exception e) {
 5     // 異常處理
 6 } finally {
 7     if (jedis != null) {
 8         // 歸還給連線池
 9         jedisPool.returnResourceObject(jedis);
10     }
11 }

初步認定原因為:發生了讀寫超時的連線,直接歸還給連線池,下次使用該連線時讀取到了上一次Redis返回的資料。因此本地驗證下,示例程式碼如下:

 1 @Data
 2 @NoArgsConstructor
 3 @AllArgsConstructor
 4 static class Person implements Serializable {
 5     private String name;
 6     private int age;
 7 }
 8 @Data
 9 @NoArgsConstructor
10 @AllArgsConstructor
11 static class Dog implements Serializable {
12     private String name;
13 }
14 
15 public static void main(String[] args) throws Exception {
16     JedisPoolConfig config = new JedisPoolConfig();
17     config.setMaxTotal(1);
18     JedisPool jedisPool = new JedisPool(config, "192.168.193.133", 6379, 2000, "123456");
19 
20     Jedis jedis = jedisPool.getResource();
21     jedis.set("key1".getBytes(), serialize(new Person("luoxn28", 26)));
22     jedis.set("key2".getBytes(), serialize(new Dog("tom")));
23     jedisPool.returnResourceObject(jedis);
24 
25     try {
26         jedis = jedisPool.getResource();
27         Person person = deserialize(jedis.get("key1".getBytes()), Person.class);
28         System.out.println(person);
29     } catch (Exception e) {
30         // 發生了異常之後,未對該連線做任何處理
31         System.out.println(e.getMessage());
32     } finally {
33         if (jedis != null) {
34             jedisPool.returnResourceObject(jedis);
35         }
36     }
37 
38     try {
39         jedis = jedisPool.getResource();
40         Dog dog = deserialize(jedis.get("key2".getBytes()), Dog.class);
41         System.out.println(dog);
42     } catch (Exception e) {
43         System.out.println(e.getMessage());
44     } finally {
45         if (jedis != null) {
46             jedisPool.returnResourceObject(jedis);
47         }
48     }
49 }

連線超時時間設定2000ms,為了方便測試,可以在redis伺服器上使用gdb命令斷住redis程式(如果redis部署在Linux系統上的話,還可以使用iptable命令在防火牆禁止某個回包),比如在執行 jedis.get("key1".getBytes() 程式碼前,對redis程式使用gdb命令斷住,那麼就會導致讀取超時,然後就會觸發如下異常:

Person cannot be cast to Dog

既然已經知道了該問題原因並且本地復現了該問題,對應解決方案是,在發生異常時歸還給連線池時關閉該連線即可(jedis.close內部已經做了判斷),程式碼如下:

 1 try {
 2     jedis = jedisPool.getResource();
 3     // jedis業務讀寫操作
 4 } catch (Exception e) {
 5     // 異常處理
 6 } finally {
 7     if (jedis != null) {
 8         // 歸還給連線池
 9         jedis.close();
10     }
11 }

至此,該問題解決。注意,因為使用了hessian序列化(其包含了型別資訊,類似的有Java本身序列化機制),所有會報類轉換異常;如果使用了json序列化(其只包含物件屬性資訊),反序列化時不會報異常,只不過因為不同類的屬性不同,會導致反序列化後的物件屬性為空或者屬性值混亂,使用時會導致問題,並且這種問題因為沒有報異常所以更不容易發現。

 

既然說到了Redis的連線,要知道的是,Redis基於RESP(Redis Serialization Protocol)協議來通訊,並且通訊方式是停等方式,也就說一次通訊獨佔一個連線直到client讀取到返回結果之後才能釋放該連線讓其他執行緒使用。小夥伴們可以思考一下,Redis通訊能否像dubbo那樣使用單連線+序列號(標識單次通訊)通訊方式呢?理論上是可以的,不過由於RESP協議中並沒有一個"序列號"的欄位,所以直接靠原生的通訊方法來實現是不現實的。不過我們可以通過echo命令傳遞並返回"序列號"+正常的讀寫方式來實現,這裡要保證二者執行的原子性,可以通過lua指令碼或者事務來實現,事務方式如下:

MULTI
ECHO "唯一序列號"
GET key1
EXEC

然後客戶端收到的結果是一個 [ "唯一序列號", "value1" ]的列表,你可以根據前一項識別出這是你傳送的哪個請求。

為什麼Redis通訊方式並沒有採用類似於dubbo這種通訊方式呢,個人認為有以下幾點:

  • 使用停等這種通訊方式實現簡單,並且協議欄位儘可能緊湊;
  • Redis都是記憶體操作,處理效能較強,停等協議不會造成客戶端等待時間較長;
  • 目前來看,通訊方式這塊不是Redis使用上的效能瓶頸,這一點很重要。

 

推薦閱讀:

 歡迎小夥伴掃描以下二維碼閱讀更多精彩好文。

 

相關文章