在上一章里,我們談論了Redis的5種數據結構,對于一些可能的用途也給出了用例。現在是時候來看看一些更高級,但依然很常見的主題和設計模式。
## 大O表示法(Big O Notation)
在本書中,我們之前就已經看到過大O表示法,包括O(1)和O(N)的表示。大O表示法的慣常用途是,描述一些用于處理一定數量元素的行為的綜合表現。在Redis里,對于一個要處理一定數量元素的命令,大O表示法讓我們能了解該命令的大概運行速度。
在Redis的文檔里,每一個命令的時間復雜度都用大O表示法進行了描述,還能知道各命令的具體性能會受什么因素影響。讓我們來看看一些用例。
常數時間復雜度O(1)被認為是最快速的,無論我們是在處理5個元素還是5百萬個元素,最終都能得到相同的性能。對于`sismember`命令,其作用是告訴我們一個值是否屬于一個集合,時間復雜度為O(1)。`sismember`命令很強大,很大部分的原因是其高效的性能特征。許多Redis命令都具有O(1)的時間復雜度。
對數時間復雜度O(log(N))被認為是第二快速的,其通過使需掃描的區間不斷皺縮來快速完成處理。使用這種“分而治之”的方式,大量的元素能在幾個迭代過程里被快速分解完整。`zadd`命令的時間復雜度就是O(log(N)),其中N是在分類集合中的元素數量。
再下來就是線性時間復雜度O(N),在一個表格的非索引列里進行查找就需要O(N)次操作。`ltrim`命令具有O(N)的時間復雜度,但是,在`ltrim`命令里,N不是列表所擁有的元素數量,而是被刪除的元素數量。從一個具有百萬元素的列表里用`ltrim`命令刪除1個元素,要比從一個具有一千個元素的列表里用`ltrim`命令刪除10個元素來的快速(實際上,兩者很可能會是一樣快,因為兩個時間都非常的小)。
根據給定的最小和最大的值的標記,`zremrangebyscore`命令會在一個分類集合里進行刪除元素操作,其時間復雜度是O(log(N)+M)。這看起來似乎有點兒雜亂,通過閱讀文檔可以知道,這里的N指的是在分類集合里的總元素數量,而M則是被刪除的元素數量。可以看出,對于性能而言,被刪除的元素數量很可能會比分類集合里的總元素數量更為重要。
(譯注:`zremrangebyscore`命令的具體構成是`ZREMRANGEBYSCORE Key max mix`。)
對于`sort`命令,其時間復雜度為O(N+M*log(M)),我們將會在下一章談論更多的相關細節。從`sort`命令的性能特征來看,可以說這是Redis里最復雜的一個命令。
還存在其他的時間復雜度描述,包括O(N^2)和O(C^N)。隨著N的增大,其性能將急速下降。在Redis里,沒有任何一個命令具有這些類型的時間復雜度。
值得指出的一點是,在Redis里,當我們發現一些操作具有O(N)的時間復雜度時,我們可能可以找到更為好的方法去處理。
(譯注:對于Big O Notation,相信大家都非常的熟悉,雖然原文僅僅是對該表示法進行簡單的介紹,但限于個人的算法知識和文筆水平實在有限,此小節的翻譯讓我頭痛頗久,最終成果也確實難以讓人滿意,望見諒。)
## 仿多關鍵字查詢(Pseudo Multi Key Queries)
時常,你會想通過不同的關鍵字去查詢相同的值。例如,你會想通過電子郵件(當用戶開始登錄時)去獲取用戶的具體信息,或者通過用戶id(在用戶登錄后)去獲取。有一種很不實效的解決方法,其將用戶對象分別放置到兩個字符串值里去:
~~~
set users:leto@dune.gov "{id: 9001, email: 'leto@dune.gov', ...}"
set users:9001 "{id: 9001, email: 'leto@dune.gov', ...}"
~~~
這種方法很糟糕,如此不但會產生兩倍數量的內存,而且這將會成為數據管理的惡夢。
如果Redis允許你將一個關鍵字鏈接到另一個的話,可能情況會好很多,可惜Redis并沒有提供這樣的功能(而且很可能永遠都不會提供)。Redis發展到現在,其開發的首要目的是要保持代碼和API的整潔簡單,關鍵字鏈接功能的內部實現并不符合這個前提(對于關鍵字,我們還有很多相關方法沒有談論到)。其實,Redis已經提供了解決的方法:散列。
使用散列數據結構,我們可以擺脫重復的纏繞:
~~~
set users:9001 "{id: 9001, email: leto@dune.gov, ...}"
hset users:lookup:email leto@dune.gov 9001
~~~
我們所做的是,使用域來作為一個二級索引,然后去引用單個用戶對象。要通過id來獲取用戶信息,我們可以使用一個普通的`get`命令:
~~~
get users:9001
~~~
而如果想通過電子郵箱來獲取用戶信息,我們可以使用`hget`命令再配合使用`get`命令(Ruby代碼):
~~~
id = redis.hget('users:lookup:email', 'leto@dune.gov')
user = redis.get("users:#{id}")
~~~
你很可能將會經常使用這類用法。在我看來,這就是散列真正耀眼的地方。在你了解這類用法之前,這可能不是一個明顯的用例。
## 引用和索引(References and Indexes)
我們已經看過幾個關于值引用的用例,包括介紹列表數據結構時的用例,以及在上面使用散列數據結構來使查詢更靈活一些。進行歸納后會發現,對于那些值與值間的索引和引用,我們都必須手動的去管理。誠實來講,這確實會讓人有點沮喪,尤其是當你想到那些引用相關的操作,如管理、更新和刪除等,都必須手動的進行時。在Redis里,這個問題還沒有很好的解決方法。
我們已經看到,集合數據結構很常被用來實現這類索引:
~~~
sadd friends:leto ghanima paul chani jessica
~~~
這個集合里的每一個成員都是一個Redis字符串數據結構的引用,而每一個引用的值則包含著用戶對象的具體信息。那么如果`chani`改變了她的名字,或者刪除了她的帳號,應該如何處理?從整個朋友圈的關系結構來看可能會更好理解,我們知道,`chani`也有她的朋友:
~~~
sadd friends_of:chani leto paul
~~~
如果你有什么待處理情況像上面那樣,那在維護成本之外,還會有對于額外索引值的處理和存儲空間的成本。這可能會令你感到有點退縮。在下一小節里,我們將會談論減少使用額外數據交互的性能成本的一些方法(在第1章我們粗略地討論了下)。
如果你確實在擔憂著這些情況,其實,關系型數據庫也有同樣的開銷。索引需要一定的存儲空間,必須通過掃描或查找,然后才能找到相應的記錄。其開銷也是存在的,當然他們對此做了很多的優化工作,使之變得更為有效。
再次說明,需要在Redis里手動地管理引用確實是頗為棘手。但是,對于你關心的那些問題,包括性能或存儲空間等,應該在經過測試后,才會有真正的理解。我想你會發現這不會是一個大問題。
## 數據交互和流水線(Round Trips and Pipelining)
我們已經提到過,與服務器頻繁交互是Redis的一種常見模式。這類情況可能很常出現,為了使我們能獲益更多,值得仔細去看看我們能利用哪些特性。
許多命令能接受一個或更多的參數,也有一種關聯命令(sister-command)可以接受多個參數。例如早前我們看到過`mget`命令,接受多個關鍵字,然后返回值:
~~~
keys = redis.lrange('newusers', 0, 10)
redis.mget(*keys.map {|u| "users:#{u}"})
~~~
或者是`sadd`命令,能添加一個或多個成員到集合里:
~~~
sadd friends:vladimir piter
sadd friends:paul jessica leto "leto II" chani
~~~
Redis還支持流水線功能。通常情況下,當一個客戶端發送請求到Redis后,在發送下一個請求之前必須等待Redis的答復。使用流水線功能,你可以發送多個請求,而不需要等待Redis響應。這不但減少了網絡開銷,還能獲得性能上的顯著提高。
值得一提的是,Redis會使用存儲器去排列命令,因此批量執行命令是一個好主意。至于具體要多大的批量,將取決于你要使用什么命令(更明確來說,該參數有多大)。另一方面來看,如果你要執行的命令需要差不多50個字符的關鍵字,你大概可以對此進行數千或數萬的批量操作。
對于不同的Redis載體,在流水線里運行命令的方式會有所差異。在Ruby里,你傳遞一個代碼塊到`pipelined`方法:
~~~
redis.pipelined do
9001.times do
redis.incr('powerlevel')
end
end
~~~
正如你可能猜想到的,流水線功能可以實際地加速一連串命令的處理。
## 事務(Transactions)
每一個Redis命令都具有原子性,包括那些一次處理多項事情的命令。此外,對于使用多個命令,Redis支持事務功能。
你可能不知道,但Redis實際上是單線程運行的,這就是為什么每一個Redis命令都能夠保證具有原子性。當一個命令在執行時,沒有其他命令會運行(我們會在往后的章節里簡略談論一下Scaling)。在你考慮到一些命令去做多項事情時,這會特別的有用。例如:
`incr`命令實際上就是一個`get`命令然后緊隨一個`set`命令。
`getset`命令設置一個新的值然后返回原始值。
`setnx`命令首先測試關鍵字是否存在,只有當關鍵字不存在時才設置值
雖然這些都很有用,但在實際開發時,往往會需要運行具有原子性的一組命令。若要這樣做,首先要執行`multi`命令,緊隨其后的是所有你想要執行的命令(作為事務的一部分),最后執行`exec`命令去實際執行命令,或者使用`discard`命令放棄執行命令。Redis的事務功能保證了什么?
* 事務中的命令將會按順序地被執行
* 事務中的命令將會如單個原子操作般被執行(沒有其它的客戶端命令會在中途被執行)
* 事務中的命令要么全部被執行,要么不會執行
你可以(也應該)在命令行界面對事務功能進行一下測試。還有一點要注意到,沒有什么理由不能結合流水線功能和事務功能。
~~~
multi
hincrby groups:1percent balance -9000000000
hincrby groups:99percent balance 9000000000
exec
~~~
最后,Redis能讓你指定一個關鍵字(或多個關鍵字),當關鍵字有改變時,可以查看或者有條件地應用一個事務。這是用于當你需要獲取值,且待運行的命令基于那些值時,所有都在一個事務里。對于上面展示的代碼,我們不能去實現自己的`incr`命令,因為一旦`exec`命令被調用,他們會全部被執行在一塊。我們不能這么做:
~~~
redis.multi()
current = redis.get('powerlevel')
redis.set('powerlevel', current + 1)
redis.exec()
~~~
(譯注:雖然Redis是單線程運行的,但是我們可以同時運行多個Redis客戶端進程,常見的并發問題還是會出現。像上面的代碼,在`get`運行之后,`set`運行之前,`powerlevel`的值可能會被另一個Redis客戶端給改變,從而造成錯誤。)
這些不是Redis的事務功能的工作。但是,如果我們增加一個`watch`到`powerlevel`,我們可以這樣做:
~~~
redis.watch('powerlevel')
current = redis.get('powerlevel')
redis.multi()
redis.set('powerlevel', current + 1)
redis.exec()
~~~
在我們調用`watch`后,如果另一個客戶端改變了`powerlevel`的值,我們的事務將會運行失敗。如果沒有客戶端改變`powerlevel`的值,那么事務會繼續工作。我們可以在一個循環里運行這些代碼,直到其能正常工作。
## 關鍵字反模式(Keys Anti-Pattern)
在下一章中,我們將會談論那些沒有確切關聯到數據結構的命令,其中的一些是管理或調試工具。然而有一個命令我想特別地在這里進行談論:`keys`命令。這個命令需要一個模式,然后查找所有匹配的關鍵字。這個命令看起來很適合一些任務,但這不應該用在實際的產品代碼里。為什么?因為這個命令通過線性掃描所有的關鍵字來進行匹配。或者,簡單地說,這個命令太慢了。
人們會如此去使用這個命令?一般會用來構建一個本地的Bug追蹤服務。每一個帳號都有一個`id`,你可能會通過一個看起來像`bug:account_id:bug_id`的關鍵字,把每一個Bug存儲到一個字符串數據結構值中去。如果你在任何時候需要查詢一個帳號的Bug(顯示它們,或者當用戶刪除了帳號時刪除掉這些Bugs),你可能會嘗試去使用`keys`命令:
~~~
keys bug:1233:*
~~~
更好的解決方法應該使用一個散列數據結構,就像我們可以使用散列數據結構來提供一種方法去展示二級索引,因此我們可以使用域來組織數據:
~~~
hset bugs:1233 1 "{id:1, account: 1233, subject: '...'}"
hset bugs:1233 2 "{id:2, account: 1233, subject: '...'}"
~~~
從一個帳號里獲取所有的Bug標識,可以簡單地調用`hkeys bugs:1233`。去刪除一個指定的Bug,可以調用`hdel bugs:1233 2`。如果要刪除了一個帳號,可以通過`del bugs:1233`把關鍵字刪除掉。
## 小結
結合這一章以及前一章,希望能讓你得到一些洞察力,了解如何使用Redis去支持(Power)實際項目。還有其他的模式可以讓你去構建各種類型的東西,但真正的關鍵是要理解基本的數據結構。你將能領悟到,這些數據結構是如何能夠實現你最初視角之外的東西。