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