本文講述使用 PHP 以及 Redis 來設(shè)計和實現(xiàn)一個簡單的微博。編程社區(qū)傳統(tǒng)上認(rèn)為,在開發(fā) web 應(yīng)用程序時,作為特殊目的的鍵值存儲數(shù)據(jù)庫不能用于替換關(guān)系型數(shù)據(jù)庫。本文將向你展示 Redis 在鍵值層之上的數(shù)據(jù)結(jié)構(gòu)是實現(xiàn)各種應(yīng)用程序的有效數(shù)據(jù)模型。
在繼續(xù)之前,你可以花點(diǎn)時間體驗一下在線演示(http://retwis.redis.io,譯者注),看看我們究竟要做什么。長話短說:這是個練手,但是已經(jīng)足夠復(fù)雜到讓你學(xué)習(xí)如何創(chuàng)建一個更復(fù)雜的程序的基礎(chǔ)。
注意:這篇文章的原始版本寫于 2009 年 Redis 發(fā)布時。當(dāng)時還不清楚 Redis 的數(shù)據(jù)模型適合整個程序。5 年以后的今天,已經(jīng)有許多應(yīng)用程序使用 Redis 作為他們的主要存儲,所以今天這篇文章的目的就是作為新學(xué)者的教程。你講學(xué)習(xí)如何使用 Redis 設(shè)計一個簡單的數(shù)據(jù)層,如何應(yīng)用不同的數(shù)據(jù)結(jié)構(gòu)。
我們的微博系統(tǒng),叫做 Retwis,結(jié)構(gòu)簡單,具有很高的性能,只需少許努力能夠分布于任意數(shù)量的 web 和 Redis 服務(wù)器。你可以在這里找到源代。
我使用 PHP 來做這個例子,是因為每個人都能看懂。使用 Ruby,Python,Erlang 等等語言也能得到同樣(或更好)的結(jié)果。也有一些其他的實現(xiàn)(但是不是所有的實現(xiàn)都使用和當(dāng)前版本教程同樣的數(shù)據(jù)層,所以請使用 PHP 官方實現(xiàn)會更好)。
此處省略一萬字。。。
(原文此處是對 Redis 數(shù)據(jù)類型的介紹,可以參考本系列文章的第 2 篇和第 3 篇,譯者注)
如果你還沒有下載 Retwis 源碼請先下載。包含一些 PHP 文件和 Predis 的一份拷貝(例子中我們使用的客戶端庫)。
另外你想要做的一件事是一個運(yùn)行的 Redis 服務(wù)器。下載源碼,使用 make 構(gòu)建,使用./redis-server 運(yùn)行,你就可以開始了。只是玩玩或者運(yùn)行我們的 Retwis 的話,不需要配置。
當(dāng)使用關(guān)系型數(shù)據(jù)庫時,必須先設(shè)計數(shù)據(jù)庫模式,這樣我們先需要知道表,索引等數(shù)據(jù)庫確定的東西。Redis 沒有表,那我們需要設(shè)計什么呢?我們需要確定需要什么鍵來表示我們的對象,以及這些鍵需要存儲什么值。
讓我們從用戶開始。我們需要用戶名、用戶 id,密碼,用戶粉絲(following),關(guān)注列表等等來表示用戶。第一個問題是,我們?nèi)绻麡?biāo)識一個用戶?像在關(guān)系型數(shù)據(jù)庫,一個好的解決方案是用不同的號碼來標(biāo)識不同的用戶,所以我們可以關(guān)聯(lián)一個唯一 ID 給每個用戶。對這個用戶的引用通過其 ID。產(chǎn)生唯一 ID 非常簡單,使用我們的原子 INCR 操作。當(dāng)我們創(chuàng)建一個新用戶我們就可以(假設(shè)用戶名為 antirez):
INCR next_user_id => 1000
HMSET user:1000 username antirez password p1pp0
注意:在真實程序中你應(yīng)該使用哈希的密碼,為了簡化我們直接存儲密碼明文。
我們使用 next_user_id 鍵為每一位新用戶提供唯一 ID。然后我們使用唯一 ID 來命名存儲用戶數(shù)據(jù)的哈希結(jié)構(gòu)的鍵。記住,這是使用鍵值存儲的通用設(shè)計模式!除了字段已經(jīng)被定義了以外,我們還需要更多東西來完整定義一個用戶。例如,有時通過用戶名獲得用戶 ID,于是我們每次添加一個用戶,我們也需要操作用戶的鍵,使用用戶名作為字段,用 ID 作為值的哈希。
HSET users antirez 1000
這一開始看起來有點(diǎn)奇怪,但是記住,我們只能采取直接訪問數(shù)據(jù)的方式,而沒有第二層索引。沒法告訴 Redis 根據(jù)一個指定值返回其鍵。這也是我們的優(yōu)勢。強(qiáng)制我們使用按照主鍵來訪問一切的新的范式來組織數(shù)據(jù),此處主鍵是關(guān)系型數(shù)據(jù)庫中的術(shù)語。
我們的系統(tǒng)還有一個核心需求。一個用戶可能有很多關(guān)注他的用戶,我們稱他們?yōu)槠浞劢z。一個用戶也可能會關(guān)注其他用戶,我們稱他們?yōu)槠潢P(guān)注者。我們有一個為此量身打造的數(shù)據(jù)結(jié)構(gòu),就是集合。獨(dú)一無二的集合元素,常量時間測試存在性,是兩個非常有趣的特性。然而,記錄一個用戶開始關(guān)注另一個用戶的時間怎么辦?在我們加強(qiáng)版的微博系統(tǒng)里面。我們使用有序集合而不是一個簡單的集合,用粉絲或者粉兒的用戶 ID 作為元素,用用戶關(guān)系創(chuàng)建時的 unix 時間作為分?jǐn)?shù)。
讓我們來定義我們的鍵:
followers:1000 => Sorted Set of uids of all the followers users
following:1000 => Sorted Set of uids of all the following users
我們添加一個粉絲:
ZADD followers:1000 1401267618 1234 => Add user 1234 with time 1401267618
另外一件重要的事情我們需要一個用戶首頁的位置來展示用戶的更新。我們需要按照時間順序來訪問這些數(shù)據(jù),從最近的到最老的,為此最好的數(shù)據(jù)結(jié)構(gòu)就是列表?;旧厦恳粋€更新都會被 LPUSH 到用戶更新鍵,多虧了 LRANGE,我們能實現(xiàn)分頁等等。注意,我們可以互換地使用更新(updates)和帖子(posts)這兩個詞,因為某種意義上說,更新其實就是小型帖子。
posts:1000 => a List of post ids - every new post is LPUSHed here.
這個列表基本上就是用戶的時間軸。我們會加入他自己帖子 ID,以及其關(guān)注者創(chuàng)建的帖子?;旧衔覀儗崿F(xiàn)了一個寫分列。
好了,我們或多或少已經(jīng)有了關(guān)于用戶的一切,除了身份驗證。我們會用一種簡單而又健壯的方式處理身份驗證:我們不想使用 PHP 的會話機(jī)制,我們的系統(tǒng)要為輕松地分布式部署于很多 web 服務(wù)器上而準(zhǔn)備,所以我們會保存全部狀態(tài)到 Redis 數(shù)據(jù)庫中。所有我們要做的就是要設(shè)置一個猜不出來的字符串作為認(rèn)證用戶的 cookie,以及一個持有該字符串的客戶端的用戶 ID 的一個鍵。
我們需要兩件事情來使得這個可以工作得健壯。第一,當(dāng)前認(rèn)證秘鑰(不可猜測的字符串)是用戶對象的一部分,所以當(dāng)創(chuàng)建用戶時,我們需要在哈希中設(shè)置一個認(rèn)證字段:
HSET user:1000 auth fea5e81ac8ca77622bed1c2132a021f9
另外,我們需要映射認(rèn)證秘鑰到用戶 ID,所以我們也需要一個認(rèn)證鍵,使用哈希來映射秘鑰和用戶 ID。
HSET auths fea5e81ac8ca77622bed1c2132a021f9 1000
為了認(rèn)證一個用戶,我們只需要簡單幾步(請查看 Retwis 項目中的 login.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, check if the username is available
$username = gt("username");
$password = gt("password");
$r = redisLink();
$userid = $r->hget("users",$username);
if (!$userid)
goback("Wrong username or password");
$realpassword = $r->hget("user:$userid","password");
if ($realpassword != $password)
goback("Wrong useranme or password");
# Username / password OK, set the cookie and redirect to index.php
$authsecret = $r->hget("user:$userid","auth");
setcookie("auth",$authsecret,time()+3600*24*365);
header("Location: index.php");
這些發(fā)生在每次用戶登錄時,但是我們還需要一個 isLoggedIn 函數(shù)來檢查用戶是否已經(jīng)通過身份認(rèn)證。以下是 isLoggedIn 函數(shù)的邏輯步驟:
<authcookie>
。<authcookie>
是否存在于 auths 哈希字段中,以及其值(即用戶 ID,本例中是 1000)。代碼也許比上面的描述更簡單:
function isLoggedIn() {
global $User, $_COOKIE;
if (isset($User)) return true;
if (isset($_COOKIE['auth'])) {
$r = redisLink();
$authcookie = $_COOKIE['auth'];
if ($userid = $r->hget("auths",$authcookie)) {
if ($r->hget("user:$userid","auth") != $authcookie) return false;
loadUserInfo($userid);
return true;
}
}
return false;
}
function loadUserInfo($userid) {
global $User;
$r = redisLink();
$User['id'] = $userid;
$User['username'] = $r->hget("user:$userid","username");
return true;
}