Redis 實戰(zhàn) —— 02. Redis 簡單實踐 - 文章投票

需求

功能: P15
  • 發(fā)布文章
  • 獲取文章
  • 文章分組
  • 投支持票
數(shù)值及限制條件 P15
  1. 如果一篇文章獲得了至少 200 張支持票,那么這篇文章就是一篇有趣的文章
  2. 如果這個網(wǎng)站每天有 50 篇有趣的文章,那么網(wǎng)站要把這 50 篇文章放到文章列表頁前 100 位至少一天
  3. 支持文章評分(投支持票會加評分),且評分隨時間遞減

實現(xiàn)

投支持票 P15

如果要實現(xiàn)評分實時隨時間遞減,且支持按評分排序,那么工作量很大而且不精確??梢韵氲街挥袝r間戳會隨時間實時變化,如果我們把發(fā)布文章的時間戳當作初始評分,那么后發(fā)布的文章初始評分一定會更高,從另一個層面上實現(xiàn)了評分隨時間遞減。按照每個有趣文章每天 200 張支持票計算,平均到一天(86400 秒)中,每張票可以將分提高 432 分。

為了按照評分和時間排序獲取文章,需要文章 id 及相應信息存在兩個有序集合中,分別為:postTime 和 score 。

為了防止統(tǒng)一用戶對統(tǒng)一文章多次投票,需要記錄每篇文章投票的用戶id,存儲在集合中,為:votedUser:{articleId} 。

同時規(guī)定一篇文章發(fā)布期滿一周后不能再進行投票,評分將被固定下來,同時記錄文章已經(jīng)投票的用戶名單集合也會被刪除。

// redis key
type RedisKey string
const (
    // 發(fā)布時間 有序集合
    POST_TIME RedisKey = "postTime"
    // 文章評分 有序集合
    SCORE RedisKey = "score"
    // 文章投票用戶集合前綴
    VOTED_USER_PREFIX RedisKey = "votedUser:"
    // 發(fā)布文章數(shù) 字符串
    ARTICLE_COUNT RedisKey = "articleCount"
    // 發(fā)布文章哈希表前綴
    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) {
    // 計算當前時間能投票的文章的最早發(fā)布時間
    earliestPostTime := time.Now().Unix() - ONE_WEEK_SECONDS

    // 獲取 當前文章 的發(fā)布時間
    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
    }
    // 增加 當前文章 支持票數(shù)
    articleKey := ARTICLE_PREFIX + RedisKey(strconv.Itoa(articleId))
    _, err = conn.Do("HINCRBY", articleKey, 1)
    // 自增錯誤,則返回
    if err != nil {
        return
    }
}
發(fā)布文章 P17

可以使用 INCR 命令為每個文章生成一個自增唯一 id 。

將發(fā)布者的 userId 記錄到該文章的投票用戶集合中(即發(fā)布者默認為自己投支持票),同時設置過期時間為一周。

存儲文章相關信息,并將初始評分和發(fā)布時間記錄下來。

// 發(fā)布文章(沒有事務控制,第 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
    }

    // 設置 發(fā)布時間
    _, 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

分頁獲取支持四種排序,獲取錯誤時返回空數(shù)組。

注意:ZRANGEZREVRANGE 的范圍起止都是閉區(qū)間。

type ArticleOrder int
const (
    TIME_ASC ArticleOrder = iota
    TIME_DESC
    SCORE_ASC
    SCORE_DESC
)

// 根據(jù) 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 "", ""
    }
}

// 執(zhí)行分頁獲取文章邏輯(忽略部分簡單的參數(shù)校驗等邏輯)
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
    }

    // 獲取 起止下標(都是閉區(qū)間)
    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)
    // 執(zhí)行分頁獲取文章邏輯,并返回結果
    return doListArticles(conn, page, pageSize, command, redisKey)
}
文章分組 P19

支持將文章加入到分組集合,也支持將文章從分組集合中刪除。

// 設置分組
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 轉(zhuǎn)換成 []interface{}
    for i, articleId := range articleIds {
        args[i + 1] = articleId
    }

    // 不支持 []int 直接轉(zhuǎn) []interface{}
    // 也不支持 groupKey, articleIds... 這樣傳參(這樣匹配的參數(shù)是 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 轉(zhuǎn)換成 []interface{}
    for i, articleId := range articleIds {
        args[i + 1] = articleId
    }

    // 不支持 []int 直接轉(zhuǎn) []interface{}
    // 也不支持 groupKey, articleIds... 這樣傳參(這樣匹配的參數(shù)是 interface{}, ...interface{})
    _, _ = conn.Do("SREM", args...)
}
分組中分頁獲取文章 P20

分組信息和排序信息在不同的(有序)集合中,所以需要取兩個(有序)集合的交集,再進行分頁獲取。

取交集比較耗時,所以緩存 60s,不實時生成。

// 緩存過期時間 60s
const EXPIRE_SECONDS = 60

// 分頁獲取某分組下的文章(忽略簡單的參數(shù)校驗等邏輯;過期設置沒有在事務里)
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)

    // 執(zhí)行分頁獲取文章邏輯,并返回結果
    return doListArticles(conn, page, pageSize, command, targetRedisKey)
}
練習題:投反對票 P21

增加投反對票功能,并支持支持票和反對票互轉(zhuǎn)。

  • 看到這個練習和相應的提示后,又聯(lián)系平日里投票的場景,覺得題目中的方式并不合理。在投支持/反對票時處理相應的轉(zhuǎn)換邏輯符合用戶習慣,也能又較好的擴展性。
  • 更改處
    • 文章 HASH,增加一個 downvoteNum 字段,用于記錄投反對票人數(shù)
    • 文章投票用戶集合 SET 改為 HASH,用于存儲用戶投票的類型
    • UpvoteArticle 函數(shù)換為 VoteArticle,同時增加一個類型為 VoteType 的入?yún)?。函?shù)功能不僅支持投支持/反對票,還支持取消投票
// redis key
type RedisKey string
const (
    // 發(fā)布時間 有序集合
    POST_TIME RedisKey = "postTime"
    // 文章評分 有序集合
    SCORE RedisKey = "score"
    // 文章投票用戶集合前綴
    VOTED_USER_PREFIX RedisKey = "votedUser:"
    // 發(fā)布文章數(shù) 字符串
    ARTICLE_COUNT RedisKey = "articleCount"
    // 發(fā)布文章哈希表前綴
    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

// 根據(jù) 原有投票類型 和 新投票類型,獲取 分數(shù)、支持票數(shù)、反對票數(shù) 的增量(暫未處理“枚舉”不對的情況,直接全返回 0)
func getDelta(oldVoteType VoteType, newVoteType VoteType) (scoreDelta, upvoteNumDelta, downvoteNumDelta int) {
    // 類型不變,相關數(shù)值不用改變
    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
}

// 為 投票 更新數(shù)據(jù)(忽略部分參數(shù)校驗;沒有事務控制,第 4 章會介紹 Redis 事務)
func doVoteArticle(conn redis.Conn, userId int, articleId int, oldVoteType VoteType, voteType VoteType) {
    // 獲取 分數(shù)、支持票數(shù)、反對票數(shù) 增量
    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
    }
    // 更新 當前文章 支持票數(shù)
    articleKey := ARTICLE_PREFIX + RedisKey(strconv.Itoa(articleId))
    _, err = conn.Do("HINCRBY", articleKey, "upvoteNum", upvoteNumDelta)
    // 自增錯誤,則返回
    if err != nil {
        return
    }
    // 更新 當前文章 反對票數(shù)
    _, err = conn.Do("HINCRBY", articleKey, "downvoteNum", downvoteNumDelta)
    // 自增錯誤,則返回
    if err != nil {
        return
    }
}

// 執(zhí)行投票邏輯(忽略部分參數(shù)校驗;沒有事務控制,第 4 章會介紹 Redis 事務)
func VoteArticle(conn redis.Conn, userId int, articleId int, voteType VoteType) {
    // 計算當前時間能投票的文章的最早發(fā)布時間
    earliestPostTime := time.Now().Unix() - ONE_WEEK_SECONDS

    // 獲取 當前文章 的發(fā)布時間
    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
    }
    // 轉(zhuǎn)換后 oldVoteType 必為 "", "1", "2" 其中之一
    oldVoteType, err := redis.String(result, err)
    // 如果投票類型不變,則不進行處理
    if VoteType(oldVoteType) == voteType {
        return
    }

    // 執(zhí)行投票修改數(shù)據(jù)邏輯
    doVoteArticle(conn, userId, articleId, VoteType(oldVoteType), voteType)
}

小結

  • Redis 特性
    • 內(nèi)存存儲:Redis 速度非???/li>
    • 遠程:Redis 可以與多個客戶端和服務器進行連接
    • 持久化:服務器重啟之后仍然保持重啟之前的數(shù)據(jù)
    • 可擴展:主從復制和分片

所思所想

  • 代碼不是一次成形的,會在寫新功能的過程中不斷完善以前的邏輯,并抽取公共方法以達到較高的可維護性和可擴展性。
  • 感覺思路還是沒有轉(zhuǎn)過來(不知道還是這個 Redis 開源庫的問題),一直運用 Java 的思想,很多地方寫著不方便。
  • 雖然自己寫的一些私有的方法保證不會出現(xiàn)某些異常數(shù)據(jù),但是還是有一些會進行相應的處理,以防以后沒注意調(diào)用了出錯。
?著作權歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容