1. 程式人生 > >Redis 實戰 —— 02. Redis 簡單實踐 - 文章投票

Redis 實戰 —— 02. Redis 簡單實踐 - 文章投票

#### 需求 ##### 功能: `P15` - 釋出文章 - 獲取文章 - 文章分組 - 投支援票 ##### 數值及限制條件 `P15` 1. 如果一篇文章獲得了至少 200 張支援票,那麼這篇文章就是一篇有趣的文章 2. 如果這個網站每天有 50 篇有趣的文章,那麼網站要把這 50 篇文章放到文章列表頁前 100 位至少一天 3. 支援文章評分(投支援票會加評分),且評分隨時間遞減 #### 實現 ##### 投支援票 `P15` 如果要實現評分實時隨時間遞減,且支援按評分排序,那麼工作量很大而且不精確。可以想到只有時間戳會隨時間實時變化,如果我們把釋出文章的時間戳當作初始評分,那麼後釋出的文章初始評分一定會更高,從另一個層面上實現了評分隨時間遞減。按照每個有趣文章每天 200 張支援票計算,平均到一天(86400 秒)中,每張票可以將分提高 432 分。 為了按照評分和時間排序獲取文章,需要文章 id 及相應資訊存在兩個有序集合中,分別為:postTime 和 score 。 為了防止統一使用者對統一文章多次投票,需要記錄每篇文章投票的使用者id,儲存在集合中,為:votedUser:{articleId} 。 同時規定一篇文章釋出期滿一週後不能再進行投票,評分將被固定下來,同時記錄文章已經投票的使用者名稱單集合也會被刪除。 ```go // redis key type RedisKey string const ( // 釋出時間 有序集合 POST_TIME RedisKey = "postTime" // 文章評分 有序集合 SCORE RedisKey = "score" // 文章投票使用者集合字首 VOTED_USER_PREFIX RedisKey = "votedUser:" // 釋出文章數 字串 ARTICLE_COUNT RedisKey = "articleCount" // 釋出文章雜湊表字首 ARTICLE_PREFIX RedisKey = "article:" // 分組字首 GROUP_PREFIX RedisKey = "group:" ) const ONE_WEEK_SECONDS = int64(7 * 24 * 60 * 60) const UPVOTE_SCORE = 432 // 使用者 userId 給文章 articleId 投贊成票(沒有事務控制,第 4 章會介紹 Redis 事務) func UpvoteArticle(conn redis.Conn, userId int, articleId int) { // 計算當前時間能投票的文章的最早釋出時間 earliestPostTime := time.Now().Unix() - ONE_WEEK_SECONDS // 獲取 當前文章 的釋出時間 postTime, err := redis.Int64(conn.Do("ZSCORE", POST_TIME, articleId)) // 獲取錯誤 或 文章 articleId 的投票截止時間已過,則返回 if err != nil || postTime < earliestPostTime { return } // 當前文章可以投票,則進行投票操作 votedUserKey := VOTED_USER_PREFIX + RedisKey(strconv.Itoa(articleId)) addedNum, err := redis.Int(conn.Do("SADD", votedUserKey, userId)) // 新增錯誤 或 當前已投過票,則返回 if err != nil || addedNum == 0 { return } // 使用者已成功新增到當前文章的投票集合中,則增加 當前文章 得分 _, err = conn.Do("ZINCRBY", SCORE, UPVOTE_SCORE, articleId) // 自增錯誤,則返回 if err != nil { return } // 增加 當前文章 支援票數 articleKey := ARTICLE_PREFIX + RedisKey(strconv.Itoa(articleId)) _, err = conn.Do("HINCRBY", articleKey, 1) // 自增錯誤,則返回 if err != nil { return } } ``` ##### 釋出文章 `P17` 可以使用 `INCR` 命令為每個文章生成一個自增唯一 id 。 將釋出者的 userId 記錄到該文章的投票使用者集合中(即釋出者預設為自己投支援票),同時設定過期時間為一週。 儲存文章相關資訊,並將初始評分和釋出時間記錄下來。 ```go // 釋出文章(沒有事務控制,第 4 章會介紹 Redis 事務) func PostArticle(conn redis.Conn, userId int, title string, link string) { // 獲取當前文章自增 id articleId, err := redis.Int(conn.Do("INCR", ARTICLE_COUNT)) if err != nil { return } // 將作者加入到投票使用者集合中 votedUserKey := VOTED_USER_PREFIX + RedisKey(strconv.Itoa(articleId)) _, err = conn.Do("SADD", votedUserKey, userId) if err != nil { return } // 設定 投票使用者集合 過期時間為一週 _, err = conn.Do("EXPIRE", votedUserKey, ONE_WEEK_SECONDS) if err != nil { return } postTime := time.Now().Unix() articleKey := ARTICLE_PREFIX + RedisKey(strconv.Itoa(articleId)) // 設定文章相關資訊 _, err = conn.Do("HMSET", articleKey, "title", title, "link", link, "userId", userId, "postTime", postTime, "upvoteNum", 1, ) if err != nil { return } // 設定 釋出時間 _, err = conn.Do("ZADD", POST_TIME, postTime, articleId) if err != nil { return } // 設定 文章評分 score := postTime + UPVOTE_SCORE _, err = conn.Do("ZADD", SCORE, score, articleId) if err != nil { return } } ``` ##### 分頁獲取文章 `P18` 分頁獲取支援四種排序,獲取錯誤時返回空陣列。 注意:`ZRANGE` 和 `ZREVRANGE` 的範圍起止都是閉區間。 ```go type ArticleOrder int const ( TIME_ASC ArticleOrder = iota TIME_DESC SCORE_ASC SCORE_DESC ) // 根據 ArticleOrder 獲取相應的 命令 和 RedisKey func getCommandAndRedisKey(articleOrder ArticleOrder) (string, RedisKey) { switch articleOrder { case TIME_ASC: return "ZRANGE", POST_TIME case TIME_DESC: return "ZREVRANGE", POST_TIME case SCORE_ASC: return "ZRANGE", SCORE case SCORE_DESC: return "ZREVRANGE", SCORE default: return "", "" } } // 執行分頁獲取文章邏輯(忽略部分簡單的引數校驗等邏輯) func doListArticles(conn redis.Conn, page int, pageSize int, command string, redisKey RedisKey) []map[string]string { var articles []map[string]string // ArticleOrder 不對,返回空列表 if command == "" || redisKey == ""{ return nil } // 獲取 起止下標(都是閉區間) start := (page - 1) * pageSize end := start + pageSize - 1 // 獲取 文章id 列表 ids, err := redis.Ints(conn.Do(command, redisKey, start, end)) if err != nil { return articles } // 獲取每篇文章資訊 for _, id := range ids { articleKey := ARTICLE_PREFIX + RedisKey(strconv.Itoa(id)) article, err := redis.StringMap(conn.Do("HGETALL", articleKey)) if err == nil { articles = append(articles, article) } } return articles } // 分頁獲取文章 func ListArticles(conn redis.Conn, page int, pageSize int, articleOrder ArticleOrder) []map[string]string { // 獲取 ArticleOrder 對應的 命令 和 RedisKey command, redisKey := getCommandAndRedisKey(articleOrder) // 執行分頁獲取文章邏輯,並返回結果 return doListArticles(conn, page, pageSize, command, redisKey) } ``` ##### 文章分組 `P19` 支援將文章加入到分組集合,也支援將文章從分組集合中刪除。 ```go // 設定分組 func AddToGroup(conn redis.Conn, groupId int, articleIds ...int) { groupKey := GROUP_PREFIX + RedisKey(strconv.Itoa(groupId)) args := make([]interface{}, 1 + len(articleIds)) args[0] = groupKey // []int 轉換成 []interface{} for i, articleId := range articleIds { args[i + 1] = articleId } // 不支援 []int 直接轉 []interface{} // 也不支援 groupKey, articleIds... 這樣傳參(這樣匹配的引數是 interface{}, ...interface{}) _, _ = conn.Do("SADD", args...) } // 取消分組 func RemoveFromGroup(conn redis.Conn, groupId int, articleIds ...int) { groupKey := GROUP_PREFIX + RedisKey(strconv.Itoa(groupId)) args := make([]interface{}, 1 + len(articleIds)) args[0] = groupKey // []int 轉換成 []interface{} for i, articleId := range articleIds { args[i + 1] = articleId } // 不支援 []int 直接轉 []interface{} // 也不支援 groupKey, articleIds... 這樣傳參(這樣匹配的引數是 interface{}, ...interface{}) _, _ = conn.Do("SREM", args...) } ``` ##### 分組中分頁獲取文章 `P20` 分組資訊和排序資訊在不同的(有序)集合中,所以需要取兩個(有序)集合的交集,再進行分頁獲取。 取交集比較耗時,所以快取 60s,不實時生成。 ```go // 快取過期時間 60s const EXPIRE_SECONDS = 60 // 分頁獲取某分組下的文章(忽略簡單的引數校驗等邏輯;過期設定沒有在事務裡) func ListArticlesFromGroup(conn redis.Conn, groupId int, page int, pageSize int, articleOrder ArticleOrder) []map[string]string { // 獲取 ArticleOrder 對應的 命令 和 RedisKey command, redisKey := getCommandAndRedisKey(articleOrder) // ArticleOrder 不對,返回空列表,防止多做取交集操作 if command == "" || redisKey == ""{ return nil } groupKey := GROUP_PREFIX + RedisKey(strconv.Itoa(groupId)) targetRedisKey := redisKey + RedisKey("-inter-") + groupKey exists, err := redis.Int(conn.Do("EXISTS", targetRedisKey)) // 交集不存在或已過期,則取交集 if err == nil || exists != 1 { _, err := conn.Do("ZINTERSTORE", targetRedisKey, 2, redisKey, groupKey) if err != nil { return nil } } // 設定過期時間(過期設定失敗,不影響查詢) _, _ = conn.Do("EXPIRE", targetRedisKey, EXPIRE_SECONDS) // 執行分頁獲取文章邏輯,並返回結果 return doListArticles(conn, page, pageSize, command, targetRedisKey) } ``` ##### 練習題:投反對票 `P21` 增加投反對票功能,並支援支援票和反對票互轉。 - 看到這個練習和相應的提示後,又聯絡平日裡投票的場景,覺得題目中的方式並不合理。在投支援/反對票時處理相應的轉換邏輯符合使用者習慣,也能又較好的擴充套件性。 - 更改處 - 文章 HASH,增加一個 downvoteNum 欄位,用於記錄投反對票人數 - 文章投票使用者集合 SET 改為 HASH,用於儲存使用者投票的型別 - UpvoteArticle 函式換為 VoteArticle,同時增加一個型別為 VoteType 的入參。函式功能不僅支援投支援/反對票,還支援取消投票 ```go // redis key type RedisKey string const ( // 釋出時間 有序集合 POST_TIME RedisKey = "postTime" // 文章評分 有序集合 SCORE RedisKey = "score" // 文章投票使用者集合字首 VOTED_USER_PREFIX RedisKey = "votedUser:" // 釋出文章數 字串 ARTICLE_COUNT RedisKey = "articleCount" // 釋出文章雜湊表字首 ARTICLE_PREFIX RedisKey = "article:" // 分組字首 GROUP_PREFIX RedisKey = "group:" ) type VoteType string const ( // 未投票 NONVOTE VoteType = "" // 投支援票 UPVOTE VoteType = "1" // 投反對票 DOWNVOTE VoteType = "2" ) const ONE_WEEK_SECONDS = int64(7 * 24 * 60 * 60) const UPVOTE_SCORE = 432 // 根據 原有投票型別 和 新投票型別,獲取 分數、支援票數、反對票數 的增量(暫未處理“列舉”不對的情況,直接全返回 0) func getDelta(oldVoteType VoteType, newVoteType VoteType) (scoreDelta, upvoteNumDelta, downvoteNumDelta int) { // 型別不變,相關數值不用改變 if oldVoteType == newVoteType { return 0, 0, 0 } switch oldVoteType { case NONVOTE: if newVoteType == UPVOTE { return UPVOTE_SCORE, 1, 0 } if newVoteType == DOWNVOTE { return -UPVOTE_SCORE, 0, 1 } case UPVOTE: if newVoteType == NONVOTE { return -UPVOTE_SCORE, -1, 0 } if newVoteType == DOWNVOTE { return -(UPVOTE_SCORE << 1), -1, 1 } case DOWNVOTE: if newVoteType == NONVOTE { return UPVOTE_SCORE, 0, -1 } if newVoteType == UPVOTE { return UPVOTE_SCORE << 1, 1, -1 } default: return 0, 0, 0 } return 0, 0, 0 } // 為 投票 更新資料(忽略部分引數校驗;沒有事務控制,第 4 章會介紹 Redis 事務) func doVoteArticle(conn redis.Conn, userId int, articleId int, oldVoteType VoteType, voteType VoteType) { // 獲取 分數、支援票數、反對票數 增量 scoreDelta, upvoteNumDelta, downvoteNumDelta := getDelta(oldVoteType, voteType) // 更新當前使用者投票型別 votedUserKey := VOTED_USER_PREFIX + RedisKey(strconv.Itoa(articleId)) _, err := conn.Do("HSET", votedUserKey, userId, voteType) // 設定錯誤,則返回 if err != nil { return } // 更新 當前文章 得分 _, err = conn.Do("ZINCRBY", SCORE, scoreDelta, articleId) // 自增錯誤,則返回 if err != nil { return } // 更新 當前文章 支援票數 articleKey := ARTICLE_PREFIX + RedisKey(strconv.Itoa(articleId)) _, err = conn.Do("HINCRBY", articleKey, "upvoteNum", upvoteNumDelta) // 自增錯誤,則返回 if err != nil { return } // 更新 當前文章 反對票數 _, err = conn.Do("HINCRBY", articleKey, "downvoteNum", downvoteNumDelta) // 自增錯誤,則返回 if err != nil { return } } // 執行投票邏輯(忽略部分引數校驗;沒有事務控制,第 4 章會介紹 Redis 事務) func VoteArticle(conn redis.Conn, userId int, articleId int, voteType VoteType) { // 計算當前時間能投票的文章的最早釋出時間 earliestPostTime := time.Now().Unix() - ONE_WEEK_SECONDS // 獲取 當前文章 的釋出時間 postTime, err := redis.Int64(conn.Do("ZSCORE", POST_TIME, articleId)) // 獲取錯誤 或 文章 articleId 的投票截止時間已過,則返回 if err != nil || postTime < earliestPostTime { return } // 獲取集合中投票型別 votedUserKey := VOTED_USER_PREFIX + RedisKey(strconv.Itoa(articleId)) result, err := conn.Do("HGET", votedUserKey, userId) // 查詢錯誤,則返回 if err != nil { return } // 轉換後 oldVoteType 必為 "", "1", "2" 其中之一 oldVoteType, err := redis.String(result, err) // 如果投票型別不變,則不進行處理 if VoteType(oldVoteType) == voteType { return } // 執行投票修改資料邏輯 doVoteArticle(conn, userId, articleId, VoteType(oldVoteType), voteType) } ``` #### 小結 - Redis 特性 - 記憶體儲存:Redis 速度非常快 - 遠端:Redis 可以與多個客戶端和伺服器進行連線 - 持久化:伺服器重啟之後仍然保持重啟之前的資料 - 可擴充套件:主從複製和分片 #### 所思所想 - 程式碼不是一次成形的,會在寫新功能的過程中不斷完善以前的邏輯,並抽取公共方法以達到較高的可維護性和可擴充套件性。 - 感覺思路還是沒有轉過來(不知道還是這個 Redis 開源庫的問題),一直運用 Java 的思想,很多地方寫著不方便。 - 雖然自己寫的一些私有的方法保證不會出現某些異常資料,但是還是有一些會進行相應的處理,以防以後沒注意呼叫了出錯。 > 本文首發於公眾號:滿賦諸機([點選檢視原文](https://mp.weixin.qq.com/s/aVsBoaScnlpDFMGRudIPAQ)) 開源在 GitHub :[reading-notes/redis-in-action](https://github.com/idealism-xxm/reading-notes/tree/master/redis-in-action) ![](https://user-images.githubusercontent.com/16055078/103480735-02019200-4e11-11eb-91a2-70a687781