php redis 唯一id,PHP + Redis 实现一个简单的twitter
Redis是NoSQL數據庫中一個知名數據庫,在新浪微博中亦有部署,適合固定數據量的熱數據的訪問。
作為入門,這是一篇很好的教材,簡單描述了如何使用KV數據庫進行數據庫的設計。新的項目www.xiayucha.com亦采用Redis + MySQL進行開發,考慮Redis文檔比較少,故翻譯了此文。
其他參考資料:
我會在此文中描述如何使用PHP以及僅使用Redis來設計實現一個簡單的Twitter克隆。
很多編程社區常認為KV儲存是一個特別的數據庫,在web應用中不能替代關系數據庫。
本文嘗試證明這恰恰相反。
這個twitter克隆名為Retwis,結構簡單,性能優異,能很輕易地用N個web服務器和Redis服務器以分布式架構。
我們使用PHP作為例子是因為它能被每個人讀懂,也能使用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 數據庫基礎
KV數據的精髓,是能夠把value儲存在key里,此后該數據僅能夠通過確切的key來獲取,無法搜索一個值。
確切的來講,它更像一個大型HASH/字典,但它是持久化的,比如,當你的程序終止運行,數據不會消失。
比如我們能用SET命令以key foo 來儲存值 bar
SET foo bar
Redis會永久儲存我們的數據,所以之后我們可以問Redis:“儲存在key foo里的數據是什么?”,Redis會返回一個值:bar
GET foo => bar
KV數據庫提供的其他常見操作有:DEL,用于刪除指定的key和關聯的value;
SET-if-not-exists (在Redis上稱為SETNX )僅會在key不存在的時候設置一個值;
INCR能夠對指定的key里儲存的數字進行自增。
SET foo 10
INCR foo => 11
INCR foo => 12
INCR foo => 13
原子操作
目前為止它是相當簡單的,但是INCR有些不同。設想一下,為什么要提供這個操作?畢竟我們自己能用以下簡單的命令實現這個功能:
x = GET foo
x = x + 1
SET foo x
問題在于要使上面的操作正常進行,同時只能有一個客戶端操作x的值。看看如果兩臺電腦同時操作這個值會發生什么:
x = GET foo (返回10)
y = GET foo (返回10)
x = x + 1 (x現在是11)
y = y + 1 (y現在是11)
SET foo x (foo現在是11)
SET foo y (foo現在是11)
問題發生了!我們增加了值兩次,本應該從10變成12,現在卻停留在了11。這是因為用GET和SET來實現INCR不是一個原子操作(atomic operation)。
所以Redis\memcached之類提供了一個原子的INCR命令,服務器會保護get-increment-set操作,以防止同時的操作。
讓Redis與眾不同的是它提供了更多類似INCR的方案,用于解決模型復雜的問題。
因此你可以不使用任何SQL數據庫、僅用Redis寫一個完整的web應用,而不至于抓狂。
超越Ke-Value數據庫
本節我們會看到構建一個Twitter克隆所需Redis的功能。首先需要知道的是,Redis的值不僅僅可以是字符串(String)。
Redis的值可以是列表(Lists)也可以是集合(Sets),在操作更多類型的值時也是原子的,所以多方操作同一個KEY的值也是安全的。
讓我們從一個Lists開始:
LPUSH mylist a (現在mylist含有一個元素:'a'的list)
LPUSH mylist b (現在mylist含有元素'b,a')
LPUSH mylist c (現在mylist含有'c,b,a')
LPUSH的意思是Left Push, 就是把一個元素加在列表(list)的左邊(或者說頭上)。
在PUSH操作之前,如果mylist這個鍵(key)不存在,Redis會自動創建一個空的list。
就像你能想到的一樣,同樣有個RPUSH操作可以把元素加在列表(list)的右邊(尾部)。
這對我們復制一個twitter非常有用,例如我們可以把用戶的更新儲存在username:updates里。
當然,我們也有相應的操作來獲取數據或者信息。比如LRANGE返回列表(list)的一個范圍內的元素,或者所有元素
LRANGE mylist 0 1 => c,b
LRANGE使用從零開始的索引(zero-based indexes),第一個元素的索引是0,第二個是1,以此類推。該命令的參數是:LRANGE key first-index last-index
參數last index可以是負數,具有特殊的意義:-1是列表(list)的最后一個元素,-2是倒數第二個,以此類推。
所以,如果要獲取整個list,我們能使用以下命令:
LRANGE mylist 0 -1 => c,b,a
其他重要的操作有LLEN,返回列表(list)的長度,LTRIM類似于LRANGE,但不僅僅會返回指定范圍內的元素,而且還會原子地把列表(list)的值設置這個新的值。
我們將會使用這些list操作,但是注意閱讀Redis文檔來瀏覽所有redis支持的list操作。
數據類型:集合(set)
除了列表(list),Redis還提供了集合(sets)的支持,是不排序(unsorted)的元素集合。
它能夠添加、刪除、檢查元素是否存在,并且獲取兩個結合之間的交集。當然它也能請求獲取集合(set)里一個或者多個元素。
幾個例子可以使概念更為清晰。記住:SADD是往集合(set)里添元素;SREM是從集合(set)里刪除元素;SISMEMBER是檢測一個元素是否包含在集合里;SINTER用于顯示兩個集合的交集。
其他操作有,SCARD用于獲取集合的基數(集合中元素的數量);SMEMBERS返回集合中所有的元素
SADD myset a
SADD myset b
SADD myset foo
SADD myset bar
SCARD myset => 4
SMEMBERS myset => bar,a,foo,b
注意SMEMBERS不會以我們添加的順序返回元素,因為集合(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啦!
先決條件
實現的非常簡單,你會在里面找到PHP客戶端(redis.php),用于redis與PHP的交互。該庫由Ludovico Magnocavallo(http://qix.it/)編寫,你可以在自己的項目中免費使用。
但如果要更新庫的版本請下載Redis的發行版。(注意:現在有更好的PHP庫了,請檢查我們的客戶端頁面)
你需要的另一個東西是正常運行的Redis服務器。僅需要獲取源碼、用make編譯、用./redis-server就完工了,點兒也不須配置就可以在你的電腦上運行Retwis。
數據結構規劃
當使用關系數據庫的時候,這一步往往是在設計數據表、索引的表單里處理。我們沒有表,那我們設計什么呢? 我們需要確認物體使用的key以及key采用的類型。
讓我們從用戶這塊開始設計。當然了,首先需要展示用戶的username, userid, password, followers,自己follow的用戶等。第一個問題是:如何在我們的系統中標識一個用戶?
username是個好主意,因為它是唯一的。不過它太大了,我們想要降低內存的使用。如果我們的數據庫是關系數據庫,我們能關聯唯一ID到每一個用戶。每一個對用戶的引用都通過ID來關聯。
做起來很簡單,因為我們有我們的原子的INCR命令!當我們創建一個新用戶,我們假設這個用戶叫"antirez":
INCR global:nextUserId => 1000
SET uid:1000:username antirez
SET uid:1000:password p1pp0
我們使用global:nextUserId為鍵(Key)是為了給每個新用戶分配一個唯一ID,然后用這個唯一ID來加入其他key,以識別保存用戶的其他數據。這就是kv數據庫的設計模式!請牢記于心,
除了已經定義的KEY,我們還需要更多的來完整定義一個用戶,比如有時需要通過用戶名來獲取用戶ID,所以我們也需要設置這么一個鍵(Key)
SET username:antirez:uid 1000
一開始看上去這樣很奇怪,但請記住我們只能通過key來獲取數據!這不可能告訴Redis返回包含某值的Key,這也是我們的強處。
用關系數據庫方式來講,這個新實例強迫我們組織數據,以便于僅使用primary key訪問任何數據。
關注\被關注與更新
這也是在我們系統中另一個重要需求.每個用戶都有follower,也有follow的用戶.對此我們有最佳的數據結構!那就是.....集合(Sets).那就讓我們在結構中加入兩個新字段:
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,我們能夠實現分頁等功能。請注意更新(updates)和帖子(posts)講的是同一個東西,實際上更新(updates)是有點小的帖子(posts)。
uid:1000:posts => a List of post ids, every new post is LPUSHed here.
驗證
OK,除了驗證,或多或少我們已經有了關于該用戶的一切東西。我們處理驗證用一個簡單而健壯(魯棒)的辦法:我們不使用PHP的session或者其他類似方式。
我們的系統必須是能夠在不同不同服務器上分布式部署的,所以一切狀態都必須保存在Redis里。所以我們所需要的一個保存在已驗證用戶cookie里的隨機字符串。
包含同樣隨機字符串的一個key告訴我們用戶的ID。我們需要使用兩個key來保證這個驗證機制的健壯性:
SET uid:1000:auth fea5e81ac8ca77622bed1c2132a021f9
SET auth:fea5e81ac8ca77622bed1c2132a021f9 1000
為了驗證一個用戶,我們需要做一些簡單的工作(login.php):
* 從登錄表單獲取用戶的用戶名和密碼
* 檢查是否存在一個鍵 username::uid
* 如果這個user id存在(假設1000)
* 檢查 uid:1000:password 是否匹配,如果不匹配,顯示錯誤信息
* 匹配則設置cookie為字符串"fea5e81ac8ca77622bed1c2132a021f9"(uid:1000:auth的值)
實例代碼:
PHP代碼
include("retwis.php");
#?Form?sanity?checks
if(!gt("username")?||?!gt("password"))
goback("You?need?to?enter?both?username?and?password?to?login.");
#?The?form?is?OK,?checkifthe?username?is?available
$username=?gt("username");
$password=?gt("password");
$r=?redisLink();
$userid=$r->get("username:$username:id");
if(!$userid)
goback("Wrong?username?or?password");
$realpassword=$r->get("uid:$userid:password");
if($realpassword!=$password)
goback("Wrong?useranme?or?password");
#?Username?/?password?OK,?set?the?cookieandredirect?to?index.php
$authsecret=$r->get("uid:$userid:auth");
setcookie("auth",$authsecret,time()+3600*24*365);
header("Location:?index.php");
每次用戶登錄都會運行,但我們需要一個函數isLoggedIn用于檢驗一個用戶是否已經驗證。
這些是isLoggedIn的邏輯步驟
* 從用戶獲取cookie里auth的值。如果沒有cookie,該用戶未登錄。我們稱這個cookie為
* 檢查auth:是否存在,存在則獲取值(例子里是1000)
* 為了再次確認,檢查uid:1000:auth是否匹配
* 用戶已驗證,在全局變量$User中載入一點信息
也許代碼比描述更短:
PHP代碼
functionisLoggedIn()?{
global$User,$_COOKIE;
if(isset($User))returntrue;
if(isset($_COOKIE['auth']))?{
$r=?redisLink();
$authcookie=$_COOKIE['auth'];
if($userid=$r->get("auth:$authcookie"))?{
if($r->get("uid:$userid:auth")?!=$authcookie)returnfalse;
loadUserInfo($userid);
returntrue;
}
}
returnfalse;
}
functionloadUserInfo($userid)?{
global$User;
$r=?redisLink();
$User['id']?=$userid;
$User['username']?=$r->get("uid:$userid:username");
returntrue;
}
把loadUserInfo作為一個獨立函數對于我們的應用而言有點殺雞用牛刀了,但是對于復雜的應用而言這是一個不錯的模板。
作為一個完整的驗證,還剩下logout還沒實現。在logout的時候我們怎么做呢?
很簡單,僅僅改變uid:1000:auth里的隨機字符串,刪除舊的auth:并增加一個新的auth:
重要:logout過程解釋了為什么我們不僅僅查找auth:而是再次檢查了uid:1000:auth。真正的驗證字符串是后者,auth:是易變的.
假設程序中有BUGs或者腳本被意外中斷,那么就有可能有多個auth:指向同一個用戶id。
logout代碼如下:(logout.php)
PHP代碼
include("retwis.php");
if(!isLoggedIn())?{
header("Location:?index.php");
exit;
}
$r=?redisLink();
$newauthsecret=?getrand();
$userid=$User['id'];
$oldauthsecret=$r->get("uid:$userid:auth");
$r->set("uid:$userid:auth",$newauthsecret);
$r->set("auth:$newauthsecret",$userid);
$r->delete("auth:$oldauthsecret");
header("Location:?index.php");
以上是我們所描述過的,應該比較易于理解。
更新(Updates)
更新,或者稱為帖子(posts)的實現則更為簡單。為了在數據庫里創建一個新的帖子,我們做了以下工作:
INCR global:nextPostId => 10343
SET post:10343 "$owner_id|$time|I'm having fun with Retwis"
就像你看到的一樣,帖子的用戶id和時間直接儲存在了字符串里。
在這個例子中我們不需要根據時間或者用戶id來查找帖子,所以把他們緊湊地擠在一個post字符串里更佳。
在新建一個帖子之后,我們獲得了帖子的id。需要LPUSH這個帖子的id到每一個follow了作者的用戶里去,當然還有作者的帖子列表。
update.php這個文件展示了這個工作是如何完成的:
PHP代碼
include("retwis.php");
if(!isLoggedIn()?||?!gt("status"))?{
header("Location:index.php");
exit;
}
$r=?redisLink();
$postid=$r->incr("global:nextPostId");
$status=str_replace("\n","?",gt("status"));
$post=$User['id']."|".time()."|".$status;
$r->set("post:$postid",$post);
$followers=$r->smembers("uid:".$User['id'].":followers");
if($followers===?false)$followers=?Array();
$followers[]?=$User['id'];
foreach($followersas$fid)?{
$r->push("uid:$fid:posts",$postid,false);
}
#?Push?the?post?on?the?timeline,andtrim?the?timeline?to?the
#?newest?1000?elements.
$r->push("global:timeline",$postid,false);
$r->ltrim("global:timeline",0,1000);
header("Location:?index.php");
函數的核心是foreach。 通過SMEMBERS獲取當前用戶的所有follower,然后循環會把帖子(post)LPUSH到每一個用戶的 uid::posts里
注意我們同時維護了一個所有帖子的時間線。為此我們還需要LPUSH到global:timeline里。
面對這個現實,你是否開始覺得:SQL里面用ORDER BY來按時間排序有一點兒奇怪? 我確實是這么想的。
分頁
現在很清楚,我們能用LRANGE來獲取帖子的范圍,并在屏幕上顯示。代碼很簡單:
PHP代碼
functionshowPost($id)?{
$r=?redisLink();
$postdata=$r->get("post:$id");
if(!$postdata)returnfalse;
$aux=explode("|",$postdata);
$id=$aux[0];
$time=$aux[1];
$username=$r->get("uid:$id:username");
$post=?join(array_splice($aux,2,count($aux)-2),"|");
$elapsed=?strElapsed($time);
$userlink=".urlencode($username)."">".utf8entities($username)."";
echo(''.$userlink.'?'.utf8entities($post)."
");
echo('posted?'.$elapsed.'?ago?via?web
');
returntrue;
}
functionshowUserPosts($userid,$start,$count)?{
$r=?redisLink();
$key=?($userid==?-1)??"global:timeline":"uid:$userid:posts";
$posts=$r->lrange($key,$start,$start+$count);
$c=?0;
foreach($postsas$p)?{
if(showPost($p))$c++;
if($c==$count)break;
}
returncount($posts)?==$count+1;
}
當showUserPosts獲取帖子的范圍并傳遞給showPost時,showPost會簡單輸出一篇帖子的HTML代碼。
Following users 關注的用戶
如果用戶id 1000 (antirez)想要follow用戶id1000的pippo,我們做到這個僅需兩步SADD:
SADD uid:1000:following 1001
SADD uid:1001:followers 1000
再次注意這個相同的模式:在關系數據庫里的理論里follow的用戶和被follow的用戶是一張包含類似following_id和follower_id的單獨數據表。
用查詢你能明確follow和被follow的每一個用戶。在key-value數據里有一點特別,需要我們分別設置1000follow了1001并且1001被1000follow的關系。
這是需要付出的代價,但是另一方面講,獲取這些數據即簡單又超快。并且這些是獨立的集合,允許我們做一些有趣的事情,比如使用SINTER獲取兩個不同用戶的集合。
這樣我們也許可以在我們的twitter復制品中加入一個功能:當你訪問某個人的資料頁時顯示"你和foobar有34個共同關注者"之類的東西。
你能夠在follow.php中找到增加或者刪除following/folloer關系的代碼。它如你所見般平常。
使它能夠水平分割
親愛的讀者,如果你看到這里,你已經是一個英雄了,謝謝你。在講到水平分割之前,看看單臺服務器的性能是個不錯的主意。
Retwis讓人驚訝地快,沒有任何緩存。在一臺非常緩慢和高負載的服務器上,以100個線程并發請求100000次進行apache基準測試,平均占用5ms。
這意味著你可以僅僅使用一臺linux服務器接受每天百萬用戶的訪問,并且慢的跟個傻猴似的,就算用更新的硬件。
雖然,就算你有一堆用戶,也許也不需要超過1臺服務器來跑應用,但讓我們假設我們是Twitter,需要處理海量的訪問量呢?該怎么做?
Hashing the key
第一件事是把KEY進行hash運算并基于hash在不同服務器上處理請求。有大量知名的hash算法,例如ruby客戶端自帶的consistent hashing
大致意思是你能把key轉換成數字,并除以你的服務器數量
server_id = crc32(key) % number_of_servers
這里還有大量因為添加一臺服務器產生的問題,但這僅僅是大致的意思,哪怕使用一個類似consistent hashing的更好索引算法,
是不是key就可以分布式訪問了呢?所有用戶數據都分布在不同的服務器上,沒有inter-keys使用到(比如SINTER,否則你需要注意要在同一臺服務器上進行)
這是Redis不像memcached一樣強制指定索引算法的原因,需要應用來指定。另外,有幾個key訪問的比較頻繁。
特殊的Keys
比如每次發布新帖,我們都需要增加global:nextPostId。單臺服務器會有大量增加的請求。如何修復這個問題呢?一個簡單的辦法是用一臺專門的服務器來處理增加請求。
除非你有大量的請求,否則矯枉過正了。另一個小技巧是ID并不需要真正地增加,只要唯一即可。這樣你可以使用長度為不太可能發生碰撞的隨機字符串(除了MD5這樣的大小,幾乎是不可能)。
完工,我們成功消除了水平分割帶來的問題。
另一個問題是global:timeline。這里有個不是解決辦法的解決辦法,你可以分別保存在不同服務器上,并且在需要這些數據時從不同的服務器上取出來,或者用一個key來進行排序。
如果你確實每秒有這么多帖子,你能夠再次用一臺獨立服務器專門處理這些請求。請記住,商用硬件的Redis能夠以100000/s的速度寫入數據。我猜測對于twitter這足夠了。
請隨意在下面評論處提問以及反饋。
總結
以上是生活随笔為你收集整理的php redis 唯一id,PHP + Redis 实现一个简单的twitter的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 社保卡医保卡居民健康卡电动读卡器|读写器
- 下一篇: ThinkPHP5.1批量删除