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

需求

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

實現

投支持票 P15

若是要實現評分實時隨時間遞減,且支持按評分排序,那麼工做量很大並且不精確。能夠想到只有時間戳會隨時間實時變化,若是咱們把發佈文章的時間戳看成初始評分,那麼後發佈的文章初始評分必定會更高,從另外一個層面上實現了評分隨時間遞減。按照每一個有趣文章天天 200 張支持票計算,平均到一天(86400 秒)中,每張票能夠將分提升 432 分。git

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

爲了防止統一用戶對統一文章屢次投票,須要記錄每篇文章投票的用戶id,存儲在集合中,爲:votedUser:{articleId} 。redis

同時規定一篇文章發佈期滿一週後不能再進行投票,評分將被固定下來,同時記錄文章已經投票的用戶名單集合也會被刪除。數組

// 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 記錄到該文章的投票用戶集合中(即發佈者默認爲本身投支持票),同時設置過時時間爲一週。服務器

存儲文章相關信息,並將初始評分和發佈時間記錄下來。app

// 發佈文章(沒有事務控制,第 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

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

注意:ZRANGEZREVRANGE 的範圍起止都是閉區間。函數

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

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

// 設置分組
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,不實時生成。

// 緩存過時時間 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 的入參。函數功能不只支持投支持/反對票,還支持取消投票
// 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 的思想,不少地方寫着不方便。
  • 雖然本身寫的一些私有的方法保證不會出現某些異常數據,可是仍是有一些會進行相應的處理,以防之後沒注意調用了出錯。

本文首發於公衆號:滿賦諸機(點擊查看原文) 開源在 GitHub :reading-notes/redis-in-action

相關文章
相關標籤/搜索