使用Redis作為時間序列資料庫:原因及方法
自從Redis出現以來,就在時間序列資料的儲存與分析方面得到了一定程度的使用。Redis最初只是被實現為一種緩衝,其目的是用於日誌的記錄,而隨著其功能的不斷髮展,它已經具備了5種顯式、3種隱式的結構或類型,為Redis中的資料分析提供了多種方法。本文將為讀者介紹使用Redis進行時間序列分析最靈活的一種方法。
關於競態與事務
在Redis中,每個單獨的命令本身都是原子性的,但按順序執行的多條命令卻未必是原子性的,有可能因出現競態而導致不正確的行為。為了應對這一限制,本文將使用“事務管道”以及“Lua指令碼"這兩種方式避免出現資料的競態衝突。
在使用Redis以及用於串連Redis的Python用戶端時,我們會調用Redis串連的.pipeline()方法以建立一個“事務管道”(在使用其他用戶端時,通常也將其稱為“事務”或“MULTI/EXEC 事務”),在調用時無需傳入參數,或者可以傳入一個布爾值True。通過該方法建立的管道將收集所有傳入的命令,直到調用.execute()方法為止。當.execute()方法調用之後,用戶端將對Redis發送MULTI命令,然後發送所收集的全部命令,最後是EXEC命令。當Redis在執行這一組命令時,不會被其他任何命令所打斷,從而確保了原子性的執行。
在Redis中對一系列命令進行原子性的執行還存在著另一種選擇,即服務端的Lua指令碼。簡單來說,Lua指令碼的行為與關係型資料庫中的預存程序非常相似,但僅限於使用Lua語言以及一種專用的Redis API以執行Lua。與事務的行為非常相似,Lua中的指令碼在執行時通常來說不會被打斷 1 ,不過未處理的錯誤也會造成Lua指令碼提前中斷。從文法上說,我們將通過調用Redis連線物件的.register_script()方法以載入一個Lua指令碼,該方法所返回的對象可以作為一個函數,以調用Redis中的指令碼,而無需再調用Redis串連中的其他方法,並結合使用SCRIPT LOAD與EVALSHA命令以載入與執行指令碼。
用例
當談到Redis以及使用它作為一個時間序列資料庫時,我們首先提出的一個問題是:“時間序列資料庫的用途或目的是什嗎?”時間序列資料庫的用例更多地與資料相關,尤其在你的資料結構被定義為一系列事件、一個或多個值的樣本、以及隨著時間推移而變化的度量值的情況下。以下是這些方面應用的一些樣本(但不僅限於此):
- 股票交易的賣價與交易量
- 線上零售商的訂單總價與寄送地址
- 視頻遊戲中玩家的操作
- IoT裝置中內嵌的感應器中收集的資料
我們將繼續進行深入的探討,不過基本上來說,時間序列資料庫的作用就是如果發生了某件事,或是你進行了一次評估操作後,可以在記錄的資料中加入一個時間戳記。一旦你收集了某些事件的資訊,就可以對這些事件進行分析。你可以選擇在收集的同時進行即時分析,也可以在事件發生後需要進行某些更複雜的查詢時進行分析。
使用通過有序集合與雜湊進行進階分析
在Redis中,對於時間序列資料的儲存與分析有一種最為靈活的方式,它需要結合使用Redis中的兩種不同的結構,即有序集合(Sorted Set)與雜湊(Hash)。
在Redis中,有序集合這種結構融合了雜湊表與排序樹(Redis在內部使用了一個跳錶結構,不過你可以先忽略這一細節)的特性。簡單來說,有序集合中的每個項都是一個字串型的“成員”以及一個double型的“分數”的組合。成員在雜湊中扮演了鍵的角色,而分數則承擔了樹中的排序值的作用。通過這種組合,你就可以通過成員或分數的值直接存取成員與分數,此外,你也可以通過多種方式對按照分數的值排好序的成員與分數進行訪問 2 。
儲存事件
如今,從各種方面來說,使用一個或多個有序集合以及部分雜湊的組合用於儲存時間序列資料的做法都是Redis最常見的用例之一。它表現了一種底層的構建塊,用於實現各種不同的應用程式。包括像Twitter一樣的社交網路,以及類似於Reddit和Hacker News一樣的新聞網站,乃至於基於Redis本身的一種接近完成的關係-對象映射器
在本文的樣本中,我們將擷取使用者在網站中的各種行為所產生的事件。所有的事件都將共用4種屬性,以及不同數量的其他屬性,這取決於事件的類型。我們已知的屬性包括:id、timestamp、type以及user。為了儲存每個事件,我們將使用一個Redis雜湊,它的鍵由事件的id所派生而來。為了建置事件的id,我們將在大量的源中選擇一種方式,但現在我們將通過Redis中的一個計數器來產生我們的id。如果在64位的平台上使用64位的Redis,我們將能夠建立最多2 63 -1個事件,主要的限制取決於可用的記憶體大小。
當我們準備好進行資料的記錄與插入後,我們就需要將資料儲存為雜湊,並在有序集合中插入一個成員/分數對,分別對應事件的id(成員)與事件的時間戳記(分數)。記錄某個事件的代碼如下
def record_event(conn, event):
id = conn.incr('event:id')
event['id'] = id
event_key = 'event:{id}'.format(id=id)
pipe = conn.pipeline(True)
pipe.hmset(event_key, event)
pipe.zadd('events', **{id: event['timestamp']})
pipe.execute()
在這個record_event()函數中,我們擷取了一個事件,從Redis中獲得一個計算得出的新id,將它賦給事件,並產生了事件儲存的鍵。這個鍵的構成是字串“event”加上新的id,並在兩者之間由冒號分割所構成的 3 。隨後我們建立了一個管道,並準備設定該事件相關的全部資料,同時準備將事件id與時間戳記對儲存在有序集合中。當事務管道完成執行之後,這一事件將被記錄並儲存在Redis中。
事件分析
從現在起,我們可以通過多種方式對時間序列進行分析了。我們可以通過ZRANGE 4 的設定對最新或最早的事件id進行掃描,並且可以在稍後擷取這些事件本身以進行分析。通過結合使用ZRANGEBYSCORE與LIMIT參數,我們能夠立即擷取到某個時間戳記之前或之後的10個、甚至是100個事件。我們也可以通過ZCOUNT計算某一特定時間段內事件發生的次數,甚至選擇用Lua指令碼實現自己的分析方式。以下的樣本將通過Lua指令碼計算在一個給定時間範圍內各種不同的事件類型的數量。
import json
def count_types(conn, start, end):
counts = count_types_lua(keys=['events'], args=[start, end])
return json.loads(counts)
count_types_lua = conn.register_script('''
local counts = {}
local ids = redis.call('ZRANGEBYSCORE', KEYS[1], ARGV[1], ARGV[2])
for i, id in ipairs(ids) do
local type = redis.call('HGET', 'event:' .. id, 'type')
counts[type] = (counts[type] or 0) + 1
end
return cjson.encode(counts)
''')
這裡所定義的count_types()函數首先將參數傳遞給經過封裝的Lua指令碼,並對經過json編碼的事件類型與數量的映射進行解碼。Lua指令碼首先建立了一個結果表(對應counts變數),隨後通過ZRANGEBYSCORE讀取這一時間範圍內的事件id的列表。當擷取到這些id之後,指令碼將一次性讀取每個事件中的類型屬性,讓事件數目量表保持不斷增長,最後結束時返回一個經過json編碼的映射結果。
對效能的思考以及資料建模
正如代碼所展示的一樣,這個用於在特定時間範圍內計算不同事件類型數量的方法能夠正常工作,但這種方式需要對這一時間範圍內的每個事件的類型屬性進行大量的讀取。對於包含幾百或是幾千個事件的時間範圍來說,這樣的分析是比較快的。但如果某個時間範圍內飲食了幾萬乃至幾百萬個事件,情況又會如何呢?答案很簡單,Redis在計算結果時將會阻塞。
有一種方法能夠處理在分析事件流時,由於長時間的指令碼執行而產生的效能問題,即預先考慮一下需要被執行的查詢。具體來說,如果你知道你需要對某一段時間範圍內的每種事件的總數進行查詢,你就可以為每種事件類型使用一個額外的有序集合,每個集合只儲存這種類型事件的id與時間戳記對。當你需要計算每種類型事件的總數時,你可以執行一系列ZCOUNT或相同功能的方法調用 5 ,並返回該結果。讓我們來看一下這個修改後的record_event()函數,它將儲存基於事件類型的有序集合。
def record_event_by_type(conn, event):
id = conn.incr('event:id')
event['id'] = id
event_key = 'event:{id}'.format(id=id)
type_key = 'events:{type}'.format(type=event['type'])
ref = {id: event['timestamp']}
pipe = conn.pipeline(True)
pipe.hmset(event_key, event)
pipe.zadd('events', **ref)
pipe.zadd(type_key, **ref)
pipe.execute()
新的record_event_by_type()函數與舊的record_event()函數在許多方面都是相同的,但新加入了一些操作。在新的函數中,我們將計算一個type_key,這裡將儲存該事件在對應這一類型事件的有序集合中的位置索引。當id與時間戳記對添加到events有序集合中後,我們還要將id與時間戳記對添加到type_key這個有序集合中,隨後與舊的方法一樣執行資料插入操作。
現在,如果需要計算兩個時間點之間“visit”這一類型的事件所發生的次數,我們只需在調用ZCOUNT命令時傳入所計算的事件類型的特定鍵,以及開始與結束的時間戳記。
def count_type(conn, type, start, end):
type_key = 'events:{type}'.format(type=type)
return conn.zcount(type_key, start, end)
如果我們能夠預Crowdsourced Security Testing道所有可能出現的事件類型,我們就能夠對每種類型分別調用以上的count_type()函數,並構建出之前在count_types()中所建立的表。而如果我們無法預Crowdsourced Security Testing道所有可能會出現的事件類型,或是有可能在未來出現新的事件類型,我們將可以將每種類型加入一個集合(Set)結構中,並在之後使用這個集合以發現所有的事件類型。以下是經我們修改後的記錄事件函數。
def record_event_types(conn, event):
id = conn.incr('event:id')
event['id'] = id
event_key = 'event:{id}'.format(id=id)
type_key = 'events:{type}'.format(type=event['type'])
ref = {id: event['timestamp']}
pipe = conn.pipeline(True)
pipe.hmset(event_key, event)
pipe.zadd('events', **ref)
pipe.zadd(type_key, **ref)
pipe.sadd('event:types', event['type'])
pipe.execute()
如果某個時間範圍記憶體在大量的事件,那麼新的count_types_fast()函數將比舊的count_types()函數執行更快,主要原因在於ZCOUNT命令比起從雜湊中擷取每個事件類型速度更快。
以Redis作為資料存放區
雖然Redis內建的分析工具及其命令和Lua指令碼非常靈活並且效能出色,但某些類型的時間序列分析還能夠從特定的計算方法、庫或工具中受益。對於這些情形來說,將資料儲存在Redis中仍然是一種非常有意義的做法,因為Redis對於資料的存取非常快。
舉例來說,對於一支股票來說,整個10年的成交金額資料按照每分鐘取樣也最多不過120萬條資料,這點資料能夠輕易地儲存在Redis中。但如果要通過Redis中的Lua指令碼對資料執行任何複雜的函數,則需要對現有的最佳化庫進行移植或是調試,讓他們在Redis中也實現相同的功能。而如果使用Redis進行資料存放區,你就可以擷取時間範圍內的資料,將他們儲存在已有的經過最佳化的核心中,以計算不斷變化的平均價格、價格波動等等。
那麼為什麼不選用一種關係型資料庫作為替代呢?原因就在於速度。Redis將所有資料都儲存在RAM中,並且對資料結構進行了最佳化(正如我們所舉的有序集合的例子一樣)。在記憶體中儲存資料及經過最佳化的資料結構的結合在速度上不僅比起以SSD為儲存介質的資料庫快了3個數量級,並且對於一般的記憶體KVStore for Redis系統、或是在記憶體中儲存序列化資料的系統也快了1至2個數量級。
結論及後續
當使用Redis進行時間序列分析,乃至任何類型的分析時,一種合理的方式是記錄不同事件的某些通用屬性與數值,儲存在一個通用的地址,以便於搜尋包含這些通用屬性與數值的事件。我們通過為每個事件類型實現對應的有序集合實現了這一點,並且也提到了集合的使用。雖然這篇文章主要討論的是有序集合的應用,但Redis中還存在著更多的結構,在分析工作中使用Redis還存在其他許多不同的選擇。除了有序集合與雜湊之外,在分析工作中還有一些常用的結構,包括(但不限於):位元影像、數組索引的位元組字串、HyperLogLogs、列表(List)、集合,以及很快將發布的基於地理位置索引的有序集合命令 6 。
在使用Redis時,你會不時地重新思索如何為更特定的資料訪問模式添加相關的資料結構。你所選擇的資料儲存形式既為你提供了儲存能力,也限定了你能夠執行的查詢的類型,這一點幾乎總是不變的。理解這一點很重要,因為與傳統的、更為人熟悉的關係型資料庫不同,在Redis中可用的查詢與操作受限於資料儲存的類型。
在看過了分析時間序列資料的這些樣本之後,你可以進一步閱讀《Redis in Action》這本書第7章中關於通過建立索引尋找相關資料的各種方法,可以在RedisLabs.com的eBooks欄目中找到它。而在《Redis in Action》一書的第8章中提供了一個近乎完整的、類似於Twitter的社交網路的實現,包括粉絲、列表、時間軸、以及一個流伺服器,這些內容對於理解如何使用Redis儲存時間序列中的時間軸及事件以及對查詢的響應是一個很好的起點。
1 如果你啟動了lua-time-limit這一配置選項,並且指令碼的執行時間超過了配置的上限,那麼唯讀指令碼也可能會被打斷。
2 當分數相同時,將按照成員本身的字母順序對於項目進行排序。
3 在本文中,我們通常使用冒號作為操作Redis資料時對名稱、命名空間以及資料的分割符,但你也可以隨意選擇任何一種符號。其他Redis使用者可能會選擇句號“.”或分號“;”等作為分割符。只要選擇一種在鍵或資料中通常不會出現的字元,就是一種比較好的做法。
4 ZRANGE及ZREVRANGE提供了基於排序位置從有序集合中擷取元素的功能,ZRANGE的最小分數索引為0,而ZREVRANGE的最大分數索引為0。
5 ZCOUNT命令將對有序集合中某個範圍內的資料計算值的總和,但它的做法是從某個端點開始增量式的遍曆整個範圍。對於包含大量項目的範圍來說,這一命令的開銷可能會很大。作為另一種選擇,你可以使用ZRANGEBYSCORE和ZREVRANGEBYSCORE命令以尋找範圍內成員的起始與終結點。而通過在成員列表的兩端使用ZRANK,你可以尋找這些成員在有序集合中的兩個索引,通過使用這兩個索引,你可以將兩者相減(再加上1)以得到相同的結果,而其計算開銷則大大減少了,即使這種方式需要對Redis進行更多的調用。
6 在Redis 2.8.9中引入的Z*LEX命令會使用有序集合以提供對有序集合有限的首碼搜尋功能,與之類似,最新的還未發布的Redis 3.2中將通過GEO*命令提供有限的地理位置搜尋與索引功能。
下面關於Redis的文章您也可能喜歡,不妨參考下:
Ubuntu 14.04下Redis安裝及簡單測試
Redis主從複製基本配置
Redis叢集明細文檔
Ubuntu 12.10下安裝Redis(圖文詳解)+ Jedis串連Redis
Redis系列-安裝部署維護篇
CentOS 6.3安裝Redis
Redis安裝部署學習筆記
Redis設定檔redis.conf 詳解
Redis 的詳細介紹:請點這裡
Redis 的:請點這裡
查看英文原文: Using Redis As a Time Series Database: Why and How
本文永久更新連結地址: