一次使用 Redis 優化查詢性能的實踐

一次使用 Redis 優化查詢性能的實踐

應用背景

有一個應用需要上傳一組ID到服務器來查詢這些ID所對應的數據,數據庫中存儲的數據量是7千萬,每次上傳的ID數量一般都是幾百至上千數量級別。

以前的解決方案

  1. 數據存儲在Oracle中,爲ID建立了索引;
  2. 查詢時,先將這些上傳的ID數據存儲到臨時表中,然後用表關聯的方法來查詢。

這樣做的優點是減少了查詢次數(不用每個ID都查詢一次),減少了解析SQL的時間(只需要執行1次查詢SQL,但是多了插入數據的SQL處理時間)。

但是這樣的設計仍然存在巨大的提升空間,當併發查詢的數量增加時,數據庫的響應就會很久。雖然建立了索引,但是每個ID查詢的時間複雜度仍是O(logn)級別的,那麼總的查詢時間複雜度就應該是m*O(logn)。不知道Oracle對錶關聯查詢有做了哪些優化,但應該也是改變不了時間複雜度的級別。

解決方法

一遇到讀數據庫存在瓶頸的問題,首先想到的就是要用內存數據庫,用緩存來解決。首選 Redis,因爲Redis是一種提供了豐富數據結構的key-value數據庫,value可以存儲STRING(字符串)、HASH(哈希),LIST(列表),ZSET(有序集)。

首先需要將數據的存儲改成 key-value 架構。簡單的做法就是一個ID對應一個字符串的 Value。但是一個 ID 可以對應多條數據,而且一條數據內又可以包含多個字段。這時候就需要將這些數據重新組合一下,拼在一起,或者是採用列表、哈希或集合來存儲 Value。

Redis內部採用 HashTable(哈希表)來實現key-value的數據結構,是一種空間佔用較高的數據結構。而我的應用場景又是ID有幾千萬規模的,如果按上述方法,使用每個ID作爲key,那麼內存的消耗將是巨大的。每個key-vaulue結構,Redis本身的維護開銷就要80幾字節,即便value存儲的是純數字(會使用long類型,佔用4個字節),也依然很大,1000萬的數據,就要佔用快1G內存。

使用兩級Hash優化內存

依據官方文檔的內存優化方法,以及這篇文章 節約內存:Instagram的Redis實踐,建議對ID分段作爲key,並使用 hash 來存儲第一級 key 的 value,第二級存儲較少的數據量(推薦1000),因此第二級的key使用ID的後3位。

爲了節約內存,Redis默認使用ziplist(壓縮列表)來存儲HASH(哈希),LIST(列表),ZSET(有序集)這些數據結構。當某些條件被滿足時,自動轉換成 hash table(哈希表),linkedlist(雙端列表),skiplist(跳錶)。

ziplist是用一個數組來實現的雙向鏈表結構,顧名思義,使用ziplist可以減少雙向鏈表的存儲空間,主要是節省了鏈表指針的存儲,如果存儲指向上一個鏈表結點和指向下一個鏈表結點的指針需要8個字節,而轉化成存儲上一個結點長度和當前結點長度在大多數情況下可以節省很多空間(最好的情況下只需2個字節)。但是每次向鏈表增加元素都需要重新分配內存。—— 引用自這裏的描述

ziplist的詳細信息請看 Redis book ziplist 章節

查看 Redis 的 .conf 文件,可以查看到轉換條件的設置信息。

# Hashes are encoded using a memory efficient data structure when they have a
# small number of entries, and the biggest entry does not exceed a given
# threshold. These thresholds can be configured using the following directives.
hash-max-ziplist-entries 512
hash-max-ziplist-value 64

# Similarly to hashes, small lists are also encoded in a special way in order
# to save a lot of space. The special representation is only used when
# you are under the following limits:
list-max-ziplist-entries 512
list-max-ziplist-value 64

# Similarly to hashes and lists, sorted sets are also specially encoded in
# order to save a lot of space. This encoding is only used when the length and
# elements of a sorted set are below the following limits:
zset-max-ziplist-entries 128
zset-max-ziplist-value 64

ziplist 查找的時間複雜度是 O(N),但是數據量較少,第二級Hash的查詢速度依然在O(1)級別。

對第二級Hash存儲的數據再編碼

在我的應用場景中每個ID對應的數據可以有很多個字段,這些字段有很多實際上是類型數據,存儲的也是ID。爲了進一步節約內存,對這些使用數字作爲ID的字段,採用base62編碼(0-9,A-Z,a-z),這樣可以使這些ID的字符長度變短,進一步減少在Redis中第二級hash需要存儲的數據量,從而減少Redis佔用的內存。

使用Lua腳本來處理批量操作

由於每次查詢都上傳幾百上千個ID,如果對這些ID,都單獨調用一次HGET命令,那麼一次查詢就需要上千次TCP通信,速度很慢。這個時候最好的方法就是一次性將所有的查詢都發送到 Redis Server,然後在 Redis Server 處再依次執行HGET命令,這個時候就要用到 Redis 的Pipelining(管道)Lua 腳本(需要 Redis 2.6以上版本)。這兩項功能可以用來處理批量操作。由於Lua腳本更簡單好用,因此我就直接選用Lua腳本。

Redis Lua 腳本具有原子性,執行過程會鎖住 Redis Server,因此 Redis Server 會全部執行完 Lua 腳本里面的所有命令,纔會去處理其他命令請求,不用擔心併發帶來的共享資源讀寫需要加鎖問題。實際上所有的 Redis 命令都是原子的,執行任何 Redis 命令,包括 info,都會鎖住 Redis Server。

不過需要注意的是:

爲了防止某個腳本執行時間過長導致Redis無法提供服務(比如陷入死循環),Redis提供了lua-time-limit參數限制腳本的最長運行時間,默認爲5秒鐘(見.conf配置文件)。當腳本運行時間超過這一限制後,Redis將開始接受其他命令但不會執行(以確保腳本的原子性,因爲此時腳本並沒有被終止),而是會返回"BUSY"錯誤——引用自這裏的描述

遇到這種情況,就需要使用 SCRIPT KILL 命令來終止 Lua 腳本的執行。因此,千萬要注意 Lua 腳本不能出現死循環,也不要用來執行費時的操作。

性能分析

測試環境:

  • 內存:1333MHz
  • CPU:Intel Core i3 2330M 2.2GHz
  • 硬盤:三星 SSD

實驗基本設置:

  1. 將7000萬數據按照上面描述的方法,使用兩級Hash以及對數據再編碼,存儲到Redis中。
  2. 模擬數據請求(沒有通過HTTP請求,直接函數調用),查詢數據,生成響應的JSON數據。

(數據僅供參考,因爲未真正結合Web服務器進行測試)

存儲空間

使用上述方法,對Redis的內存優化效果非常好。

Redis 查詢性能

實驗設置:

  1. 模擬每次查詢500個ID,分批次連續查詢。用於模擬測試併發情況下的查詢性能。

響應速度與查詢的數據量,幾乎是線性相關。30s 的時間就可以處理2000次請求,100W個ID的查詢。由於Oracle速度實在太慢,就不做測試了。

查詢性能 vs. 存儲的數據量

實驗設置:

  1. 連續查詢1W個ID,每次500個,分20次。用於測試Redis中存儲的數據量對查詢性能的影響。

查詢速度受存儲數據量的影響較小。當存儲的數據量較多時,第二級hash存儲的數據量就會更多,因此查詢時間會有略微的上升,但依然很快。

參考文獻

  1. Redis book(Redis設計與實現)
    http://redisbook.readthedocs.org/en/latest/
  2. Redis 官網 http://redis.io/
發佈了106 篇原創文章 · 獲贊 21 · 訪問量 34萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章