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