Redis 實戰 —— 02. Redis 簡單實踐 - 文章投票
阿新 • • 發佈:2021-01-22
#### 需求
##### 功能: `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