2015年4月19日 星期日

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

webber0928
最近用了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,AZ,az),這樣可以使這些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/
轉接文章連結

By webber0928

一個小菜鳥工程師,對籃球還有夢想的男孩。

0 意見:

張貼留言

Coprights @ 2016, Blogger Templates Designed By Templateism | Distributed By Gooyaabi Templates