PHP + Redis 实现一个简单的twitter
原文位于Redis官網(wǎng)http://redis.io/topics/twitter-clone
Redis是NoSQL數(shù)據(jù)庫中一個知名數(shù)據(jù)庫,在新浪微博中亦有部署,適合固定數(shù)據(jù)量的熱數(shù)據(jù)的訪問。
作為入門,這是一篇很好的教材,簡單描述了如何使用KV數(shù)據(jù)庫進(jìn)行數(shù)據(jù)庫的設(shè)計(jì)。新的項(xiàng)目www.xiayucha.com亦采用Redis + MySQL進(jìn)行開發(fā),考慮Redis文檔比較少,故翻譯了此文。
其他參考資料:
- Redis命令參考中文版(Redis Command Reference)
- Try Redis
?
我會在此文中描述如何使用PHP以及僅使用Redis來設(shè)計(jì)實(shí)現(xiàn)一個簡單的Twitter克隆。
很多編程社區(qū)常認(rèn)為KV儲存是一個特別的數(shù)據(jù)庫,在web應(yīng)用中不能替代關(guān)系數(shù)據(jù)庫。
本文嘗試證明這恰恰相反。
這個twitter克隆名為Retwis,結(jié)構(gòu)簡單,性能優(yōu)異,能很輕易地用N個web服務(wù)器和Redis服務(wù)器以分布式架構(gòu)。
在此獲取源碼http://code.google.com/p/redis/downloads/list。
我們使用PHP作為例子是因?yàn)樗鼙幻總€人讀懂,也能使用Ruby、Python、Erlang或其他語言獲取同樣(或者更佳)的效果。
注意:Retwis-RB是一個由Daniel Lucraft用Ruby與Sinatra寫的Retwis分支!
此文全部代碼在本頁尾部的Git repository鏈接里。
此文以PHP為例,但是Ruby程序員也能檢出其他源碼。他們很相似。
注意Retwis-J是Retwis的一個分支,由Costin Leau以Java和Spring框架寫成。
源碼能在GitHub找到,并且在springsource.org有綜合的文檔。
Key-value 數(shù)據(jù)庫基礎(chǔ)
KV數(shù)據(jù)的精髓,是能夠把value儲存在key里,此后該數(shù)據(jù)僅能夠通過確切的key來獲取,無法搜索一個值。
確切的來講,它更像一個大型HASH/字典,但它是持久化的,比如,當(dāng)你的程序終止運(yùn)行,數(shù)據(jù)不會消失。
比如我們能用SET命令以key foo 來儲存值 bar
?SET foo bar
Redis會永久儲存我們的數(shù)據(jù),所以之后我們可以問Redis:“儲存在key foo里的數(shù)據(jù)是什么?”,Redis會返回一個值:bar
?GET foo => bar
KV數(shù)據(jù)庫提供的其他常見操作有:DEL,用于刪除指定的key和關(guān)聯(lián)的value;
SET-if-not-exists (在Redis上稱為SETNX )僅會在key不存在的時候設(shè)置一個值;
INCR能夠?qū)χ付ǖ膋ey里儲存的數(shù)字進(jìn)行自增。
?SET foo 10
?INCR foo => 11
?INCR foo => 12
?INCR foo => 13
原子操作
目前為止它是相當(dāng)簡單的,但是INCR有些不同。設(shè)想一下,為什么要提供這個操作?畢竟我們自己能用以下簡單的命令實(shí)現(xiàn)這個功能:
?x = GET foo
?x = x + 1
?SET foo x
問題在于要使上面的操作正常進(jìn)行,同時只能有一個客戶端操作x的值??纯慈绻麅膳_電腦同時操作這個值會發(fā)生什么:
?x = GET foo (返回10)
?y = GET foo (返回10)
?x = x + 1 (x現(xiàn)在是11)
?y = y + 1 (y現(xiàn)在是11)
?SET foo x (foo現(xiàn)在是11)
?SET foo y (foo現(xiàn)在是11)
問題發(fā)生了!我們增加了值兩次,本應(yīng)該從10變成12,現(xiàn)在卻停留在了11。這是因?yàn)橛肎ET和SET來實(shí)現(xiàn)INCR不是一個原子操作(atomic operation)。
所以Redis\memcached之類提供了一個原子的INCR命令,服務(wù)器會保護(hù)get-increment-set操作,以防止同時的操作。
讓Redis與眾不同的是它提供了更多類似INCR的方案,用于解決模型復(fù)雜的問題。
因此你可以不使用任何SQL數(shù)據(jù)庫、僅用Redis寫一個完整的web應(yīng)用,而不至于抓狂。
超越Ke-Value數(shù)據(jù)庫
本節(jié)我們會看到構(gòu)建一個Twitter克隆所需Redis的功能。首先需要知道的是,Redis的值不僅僅可以是字符串(String)。
Redis的值可以是列表(Lists)也可以是集合(Sets),在操作更多類型的值時也是原子的,所以多方操作同一個KEY的值也是安全的。
讓我們從一個Lists開始:
?LPUSH mylist a (現(xiàn)在mylist含有一個元素:'a'的list)
?LPUSH mylist b (現(xiàn)在mylist含有元素'b,a')
?LPUSH mylist c (現(xiàn)在mylist含有'c,b,a')
LPUSH的意思是Left Push, 就是把一個元素加在列表(list)的左邊(或者說頭上)。
在PUSH操作之前,如果mylist這個鍵(key)不存在,Redis會自動創(chuàng)建一個空的list。
就像你能想到的一樣,同樣有個RPUSH操作可以把元素加在列表(list)的右邊(尾部)。
這對我們復(fù)制一個twitter非常有用,例如我們可以把用戶的更新儲存在username:updates里。
當(dāng)然,我們也有相應(yīng)的操作來獲取數(shù)據(jù)或者信息。比如LRANGE返回列表(list)的一個范圍內(nèi)的元素,或者所有元素
?LRANGE mylist 0 1 => c,b
LRANGE使用從零開始的索引(zero-based indexes),第一個元素的索引是0,第二個是1,以此類推。該命令的參數(shù)是:LRANGE key first-index last-index
參數(shù)last index可以是負(fù)數(shù),具有特殊的意義:-1是列表(list)的最后一個元素,-2是倒數(shù)第二個,以此類推。
所以,如果要獲取整個list,我們能使用以下命令:
?LRANGE mylist 0 -1 => c,b,a
其他重要的操作有LLEN,返回列表(list)的長度,LTRIM類似于LRANGE,但不僅僅會返回指定范圍內(nèi)的元素,而且還會原子地把列表(list)的值設(shè)置這個新的值。
我們將會使用這些list操作,但是注意閱讀Redis文檔來瀏覽所有redis支持的list操作。
數(shù)據(jù)類型:集合(set)
除了列表(list),Redis還提供了集合(sets)的支持,是不排序(unsorted)的元素集合。
它能夠添加、刪除、檢查元素是否存在,并且獲取兩個結(jié)合之間的交集。當(dāng)然它也能請求獲取集合(set)里一個或者多個元素。
幾個例子可以使概念更為清晰。記住:SADD是往集合(set)里添元素;SREM是從集合(set)里刪除元素;SISMEMBER是檢測一個元素是否包含在集合里;SINTER用于顯示兩個集合的交集。
其他操作有,SCARD用于獲取集合的基數(shù)(集合中元素的數(shù)量);SMEMBERS返回集合中所有的元素
?SADD myset a
?SADD myset b
?SADD myset foo
?SADD myset bar
?SCARD myset => 4
?SMEMBERS myset => bar,a,foo,b
注意SMEMBERS不會以我們添加的順序返回元素,因?yàn)榧?Sets)是一個未排序的元素集合。如果你要儲存順序,最好使用列表(Lists)取而代之。以下是基于集合的一些操作:
?SADD mynewset b
?SADD mynewset foo
?SADD mynewset hello
?SINTER myset mynewset => foo,b
SINTER能夠返回集合之間的交集,但并不僅限于兩個集合(Sets),你能獲取4個、5個甚至1000個集合(sets)的交集。
最后,讓我們看下SISMEMBER是如何工作的:
?SISMEMBER myset foo => 1
?SISMEMBER myset notamember => 0
Okay,我覺得我們可以開始coding啦!
先決條件
如果你還沒下載,請前往<<a href="http://code.google.com/p/redis/downloads/list">http: //code.google.com/p/redis/downloads/list>下載Retwis的源碼。它包含幾個PHP文件,是個簡單的 tar.gz文件。
實(shí)現(xiàn)的非常簡單,你會在里面找到PHP客戶端(redis.php),用于redis與PHP的交互。該庫由Ludovico Magnocavallo(http://qix.it/ )編寫,你可以在自己的項(xiàng)目中免費(fèi)使用。
但如果要更新庫的版本請下載Redis的發(fā)行版。(注意:現(xiàn)在有更好的PHP庫了,請檢查我們的客戶端頁面<<a href="http://redis.io/clients">http://redis.io/clients>)
你需要的另一個東西是正常運(yùn)行的Redis服務(wù)器。僅需要獲取源碼、用make編譯、用./redis-server就完工了,點(diǎn)兒也不須配置就可以在你的電腦上運(yùn)行Retwis。
?
數(shù)據(jù)結(jié)構(gòu)規(guī)劃
當(dāng)使用關(guān)系數(shù)據(jù)庫的時候,這一步往往是在設(shè)計(jì)數(shù)據(jù)表、索引的表單里處理。我們沒有表,那我們設(shè)計(jì)什么呢? 我們需要確認(rèn)物體使用的key以及key采用的類型。
讓我們從用戶這塊開始設(shè)計(jì)。當(dāng)然了,首先需要展示用戶的username, userid, password, followers,自己follow的用戶等。第一個問題是:如何在我們的系統(tǒng)中標(biāo)識一個用戶?
username是個好主意,因?yàn)樗俏ㄒ坏摹2贿^它太大了,我們想要降低內(nèi)存的使用。如果我們的數(shù)據(jù)庫是關(guān)系數(shù)據(jù)庫,我們能關(guān)聯(lián)唯一ID到每一個用戶。每一個對用戶的引用都通過ID來關(guān)聯(lián)。
做起來很簡單,因?yàn)槲覀冇形覀兊脑拥腎NCR命令!當(dāng)我們創(chuàng)建一個新用戶,我們假設(shè)這個用戶叫"antirez":
?INCR global:nextUserId => 1000
?SET uid:1000:username antirez
?SET uid:1000:password p1pp0
我們使用global:nextUserId為鍵(Key)是為了給每個新用戶分配一個唯一ID,然后用這個唯一ID來加入其他key,以識別保存用戶的其他數(shù)據(jù)。這就是kv數(shù)據(jù)庫的設(shè)計(jì)模式!請牢記于心,
除了已經(jīng)定義的KEY,我們還需要更多的來完整定義一個用戶,比如有時需要通過用戶名來獲取用戶ID,所以我們也需要設(shè)置這么一個鍵(Key)
?SET username:antirez:uid 1000
一開始看上去這樣很奇怪,但請記住我們只能通過key來獲取數(shù)據(jù)!這不可能告訴Redis返回包含某值的Key,這也是我們的強(qiáng)處。
用關(guān)系數(shù)據(jù)庫方式來講,這個新實(shí)例強(qiáng)迫我們組織數(shù)據(jù),以便于僅使用primary key訪問任何數(shù)據(jù)。
關(guān)注\被關(guān)注與更新
這也是在我們系統(tǒng)中另一個重要需求.每個用戶都有follower,也有follow的用戶.對此我們有最佳的數(shù)據(jù)結(jié)構(gòu)!那就是.....集合(Sets).那就讓我們在結(jié)構(gòu)中加入兩個新字段:
?uid:1000:followers => Set of uids of all the followers users
?uid:1000:following => Set of uids of all the following users
另一個重要的事情是我們需要有個地方來放用戶主頁上的更新。這個要以時間順序排序,最新的排在舊的前面。所以,最佳的類型是列表(List)。
基本上每個更新都會被LPUSH到該用戶的updates key.多虧了LRANGE,我們能夠?qū)崿F(xiàn)分頁等功能。請注意更新(updates)和帖子(posts)講的是同一個東西,實(shí)際上更新(updates)是有點(diǎn)小的帖子(posts)。
?uid:1000:posts => a List of post ids, every new post is LPUSHed here.
?
驗(yàn)證
OK,除了驗(yàn)證,或多或少我們已經(jīng)有了關(guān)于該用戶的一切東西。我們處理驗(yàn)證用一個簡單而健壯(魯棒)的辦法:我們不使用PHP的session或者其他類似方式。
我們的系統(tǒng)必須是能夠在不同不同服務(wù)器上分布式部署的,所以一切狀態(tài)都必須保存在Redis里。所以我們所需要的一個保存在已驗(yàn)證用戶cookie里的隨機(jī)字符串。
包含同樣隨機(jī)字符串的一個key告訴我們用戶的ID。我們需要使用兩個key來保證這個驗(yàn)證機(jī)制的健壯性:
?SET uid:1000:auth fea5e81ac8ca77622bed1c2132a021f9
?SET auth:fea5e81ac8ca77622bed1c2132a021f9 1000
為了驗(yàn)證一個用戶,我們需要做一些簡單的工作(login.php):
* 從登錄表單獲取用戶的用戶名和密碼
* 檢查是否存在一個鍵 username::uid
* 如果這個user id存在(假設(shè)1000)
* 檢查 uid:1000:password 是否匹配,如果不匹配,顯示錯誤信息
* 匹配則設(shè)置cookie為字符串"fea5e81ac8ca77622bed1c2132a021f9"(uid:1000:auth的值)
實(shí)例代碼:
每次用戶登錄都會運(yùn)行,但我們需要一個函數(shù)isLoggedIn用于檢驗(yàn)一個用戶是否已經(jīng)驗(yàn)證。
這些是isLoggedIn的邏輯步驟
* 從用戶獲取cookie里auth的值。如果沒有cookie,該用戶未登錄。我們稱這個cookie為
* 檢查auth:是否存在,存在則獲取值(例子里是1000)
* 為了再次確認(rèn),檢查uid:1000:auth是否匹配
* 用戶已驗(yàn)證,在全局變量$User中載入一點(diǎn)信息
也許代碼比描述更短:
把loadUserInfo作為一個獨(dú)立函數(shù)對于我們的應(yīng)用而言有點(diǎn)殺雞用牛刀了,但是對于復(fù)雜的應(yīng)用而言這是一個不錯的模板。
作為一個完整的驗(yàn)證,還剩下logout還沒實(shí)現(xiàn)。在logout的時候我們怎么做呢?
很簡單,僅僅改變uid:1000:auth里的隨機(jī)字符串,刪除舊的auth:并增加一個新的auth:
重要:logout過程解釋了為什么我們不僅僅查找auth:而是再次檢查了uid:1000:auth。真正的驗(yàn)證字符串是后者,auth:是易變的.
假設(shè)程序中有BUGs或者腳本被意外中斷,那么就有可能有多個auth:指向同一個用戶id。
logout代碼如下:(logout.php)
以上是我們所描述過的,應(yīng)該比較易于理解。
更新(Updates)
更新,或者稱為帖子(posts)的實(shí)現(xiàn)則更為簡單。為了在數(shù)據(jù)庫里創(chuàng)建一個新的帖子,我們做了以下工作:
?INCR global:nextPostId => 10343
?SET post:10343 "$owner_id|$time|I'm having fun with Retwis"
就像你看到的一樣,帖子的用戶id和時間直接儲存在了字符串里。
在這個例子中我們不需要根據(jù)時間或者用戶id來查找帖子,所以把他們緊湊地?cái)D在一個post字符串里更佳。
在新建一個帖子之后,我們獲得了帖子的id。需要LPUSH這個帖子的id到每一個follow了作者的用戶里去,當(dāng)然還有作者的帖子列表。
update.php這個文件展示了這個工作是如何完成的:
函數(shù)的核心是foreach。 通過SMEMBERS獲取當(dāng)前用戶的所有follower,然后循環(huán)會把帖子(post)LPUSH到每一個用戶的 uid::posts里
注意我們同時維護(hù)了一個所有帖子的時間線。為此我們還需要LPUSH到global:timeline里。
面對這個現(xiàn)實(shí),你是否開始覺得:SQL里面用ORDER BY來按時間排序有一點(diǎn)兒奇怪? 我確實(shí)是這么想的。
?
分頁
現(xiàn)在很清楚,我們能用LRANGE來獲取帖子的范圍,并在屏幕上顯示。代碼很簡單:
"); ??
'); ??
- ????return?true; ??
- } ??
- ??
- function?showUserPosts($userid,$start,$count)?{ ??
- ????$r?=?redisLink(); ??
- ????$key?=?($userid?==?-1)???"global:timeline"?:?"uid:$userid:posts"; ??
- ????$posts?=?$r->lrange($key,$start,$start+$count); ??
- ????$c?=?0; ??
- ????foreach($posts?as?$p)?{ ??
- ????????if?(showPost($p))?$c++; ??
- ????????if?($c?==?$count)?break; ??
- ????} ??
- ????return?count($posts)?==?$count+1; ??
- } ??
- ??
當(dāng)showUserPosts獲取帖子的范圍并傳遞給showPost時,showPost會簡單輸出一篇帖子的HTML代碼。
?
Following users 關(guān)注的用戶
如果用戶id 1000 (antirez)想要follow用戶id1000的pippo,我們做到這個僅需兩步SADD:
SADD uid:1000:following 1001
SADD uid:1001:followers 1000
再次注意這個相同的模式:在關(guān)系數(shù)據(jù)庫里的理論里follow的用戶和被follow的用戶是一張包含類似following_id和follower_id的單獨(dú)數(shù)據(jù)表。
用查詢你能明確follow和被follow的每一個用戶。在key-value數(shù)據(jù)里有一點(diǎn)特別,需要我們分別設(shè)置1000follow了1001并且1001被1000follow的關(guān)系。
這是需要付出的代價(jià),但是另一方面講,獲取這些數(shù)據(jù)即簡單又超快。并且這些是獨(dú)立的集合,允許我們做一些有趣的事情,比如使用SINTER獲取兩個不同用戶的集合。
這樣我們也許可以在我們的twitter復(fù)制品中加入一個功能:當(dāng)你訪問某個人的資料頁時顯示"你和foobar有34個共同關(guān)注者"之類的東西。
你能夠在follow.php中找到增加或者刪除following/folloer關(guān)系的代碼。它如你所見般平常。
使它能夠水平分割
親愛的讀者,如果你看到這里,你已經(jīng)是一個英雄了,謝謝你。在講到水平分割之前,看看單臺服務(wù)器的性能是個不錯的主意。
Retwis讓人驚訝地快,沒有任何緩存。在一臺非常緩慢和高負(fù)載的服務(wù)器上,以100個線程并發(fā)請求100000次進(jìn)行apache基準(zhǔn)測試,平均占用5ms。
這意味著你可以僅僅使用一臺linux服務(wù)器接受每天百萬用戶的訪問,并且慢的跟個傻猴似的,就算用更新的硬件。
雖然,就算你有一堆用戶,也許也不需要超過1臺服務(wù)器來跑應(yīng)用,但讓我們假設(shè)我們是Twitter,需要處理海量的訪問量呢?該怎么做?
Hashing the key
第一件事是把KEY進(jìn)行hash運(yùn)算并基于hash在不同服務(wù)器上處理請求。有大量知名的hash算法,例如ruby客戶端自帶的consistent hashing
大致意思是你能把key轉(zhuǎn)換成數(shù)字,并除以你的服務(wù)器數(shù)量
?server_id = crc32(key) % number_of_servers
這里還有大量因?yàn)樘砑右慌_服務(wù)器產(chǎn)生的問題,但這僅僅是大致的意思,哪怕使用一個類似consistent hashing的更好索引算法,
是不是key就可以分布式訪問了呢?所有用戶數(shù)據(jù)都分布在不同的服務(wù)器上,沒有inter-keys使用到(比如SINTER,否則你需要注意要在同一臺服務(wù)器上進(jìn)行)
這是Redis不像memcached一樣強(qiáng)制指定索引算法的原因,需要應(yīng)用來指定。另外,有幾個key訪問的比較頻繁。
特殊的Keys
比如每次發(fā)布新帖,我們都需要增加global:nextPostId。單臺服務(wù)器會有大量增加的請求。如何修復(fù)這個問題呢?一個簡單的辦法是用一臺專門的服務(wù)器來處理增加請求。
除非你有大量的請求,否則矯枉過正了。另一個小技巧是ID并不需要真正地增加,只要唯一即可。這樣你可以使用長度為不太可能發(fā)生碰撞的隨機(jī)字符串(除了MD5這樣的大小,幾乎是不可能)。
完工,我們成功消除了水平分割帶來的問題。
另一個問題是global:timeline。這里有個不是解決辦法的解決辦法,你可以分別保存在不同服務(wù)器上,并且在需要這些數(shù)據(jù)時從不同的服務(wù)器上取出來,或者用一個key來進(jìn)行排序。
如果你確實(shí)每秒有這么多帖子,你能夠再次用一臺獨(dú)立服務(wù)器專門處理這些請求。請記住,商用硬件的Redis能夠以100000/s的速度寫入數(shù)據(jù)。我猜測對于twitter這足夠了。
請隨意在下面評論處提問以及反饋。
總結(jié)
以上是生活随笔為你收集整理的PHP + Redis 实现一个简单的twitter的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 解决英文或数字在HTMl网页中不自动换行
- 下一篇: Firefox常用插件