使用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最常見的用例之一。它表現了一種底層的構建塊,用于實現各種不同的應用程序。包括像推ter一樣的社交網絡,以及類似于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()</code></pre>
在這個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)
''')</code></pre>
這里所定義的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()</code></pre>
新的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)
如果我們能夠預先知道所有可能出現的事件類型,我們就能夠對每種類型分別調用以上的count_type()函數,并構建出之前在count_types()中所創建的表。而如果我們無法預先知道所有可能會出現的事件類型,或是有可能在未來出現新的事件類型,我們將可以將每種類型加入一個集合(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()</code></pre>
與之前相比,唯一的改變就在于我們將事件的類型加入了一個命名為“event:types”的集合,然后我們需要相應地修改一下count_types()函數的實現……
def count_types_fast(conn, start, end):
event_types = conn.smembers('event:types')
counts = {}
for event_type in event_types:
counts[event_type] = conn.zcount(
'events:{type}'.format(type=event_type), start, end)
return counts
如果某個時間范圍內存在大量的事件,那么新的count_types_fast()函數將比舊的count_types()函數執行更快,主要原因在于ZCOUNT命令比起從哈希中獲取每個事件類型速度更快。
以Redis作為數據存儲
雖然Redis自帶的分析工具及其命令和Lua腳本非常靈活并且性能出色,但某些類型的時間序列分析還能夠從特定的計算方法、庫或工具中受益。對于這些情形來說,將數據保存在Redis中仍然是一種非常有意義的做法,因為Redis對于數據的存取非常快。
舉例來說,對于一支股票來說,整個10年的成交金額數據按照每分鐘取樣也最多不過120萬條數據,這點數據能夠輕易地保存在Redis中。但如果要通過Redis中的Lua腳本對數據執行任何復雜的函數,則需要對現有的優化庫進行移植或是調試,讓他們在Redis中也實現相同的功能。而如果使用Redis進行數據存儲,你就可以獲取時間范圍內的數據,將他們保存在已有的經過優化的內核中,以計算不斷變化的平均價格、價格波動等等。
那么為什么不選用一種關系型數據庫作為替代呢?原因就在于速度。Redis將所有數據都保存在RAM中,并且對數據結構進行了優化(正如我們所舉的有序集合的例子一樣)。在內存中保存數據及經過優化的數據結構的結合在速度上不僅比起以SSD為存儲介質的數據庫快了3個數量級,并且對于一般的內存鍵值存儲系統、或是在內存中保存序列化數據的系統也快了1至2個數量級。
結論及后續
當使用Redis進行時間序列分析,乃至任何類型的分析時,一種合理的方式是記錄不同事件的某些通用屬性與數值,保存在一個通用的地址,以便于搜索包含這些通用屬性與數值的事件。我們通過為每個事件類型實現對應的有序集合實現了這一點,并且也提到了集合的使用。雖然這篇文章主要討論的是有序集合的應用,但Redis中還存在著更多的結構,在分析工作中使用Redis還存在其他許多不同的選擇。除了有序集合與哈希之外,在分析工作中還有一些常用的結構,包括(但不限于):位圖、數組索引的字節字符串、HyperLogLogs、列表(List)、集合,以及很快將發布的基于地理位置索引的有序集合命令 6 。
在使用Redis時,你會不時地重新思索如何為更特定的數據訪問模式添加相關的數據結構。你所選擇的數據保存形式既為你提供了保存能力,也限定了你能夠執行的查詢的類型,這一點幾乎總是不變的。理解這一點很重要,因為與傳統的、更為人熟悉的關系型數據庫不同,在Redis中可用的查詢與操作受限于數據保存的類型。
在看過了分析時間序列數據的這些示例之后,你可以進一步閱讀《Redis in Action》這本書第7章中關于通過創建索引查找相關數據的各種方法,可以在RedisLabs.com的eBooks欄目中找到它。而在《Redis in Action》一書的第8章中提供了一個近乎完整的、類似于推ter的社交網絡的實現,包括關注者、列表、時間線、以及一個流服務器,這些內容對于理解如何使用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*命令提供有限的地理位置搜索與索引功能。
關于作者
Josiah Carlson 博士是一位經驗豐富的數據庫專家,也是Redis社區的活躍貢獻者。作為初創公司方面的老手,自從了解了Salvatore Sanfilippo在2010年的工作后,Josiah就查覺了Redis的價值與目的。經過大量的使用、誤用以及幫助他人理解Redis的各種文檔化或未文檔化的特性之后,他最終在為他的前一家初創公司進行技術開拓時編寫了《Redis in Action》一書。他的部分工作成果轉化為了使用Redis的功能的相關開源庫,并且持續地在郵件列表中回答各種問題,同時也不時地編寫Redis與其他主題的博客文章。Josiah Carlson博士目前在Openmail擔任技術部的VP,這是一家位于洛杉磯的尚處于早期的初創公司。他非常樂于為你講解如何通過Redis幫助你解決公司中的各種問題。
來自: http://www.infoq.com/cn/articles/redis-time-series