Redis 实战 —— 02. Redis 简单实践 - 文章投票

满赋诸机 2021-01-21 23:21:11
简单 redis 实战 实践


需求

功能: P15
  • 发布文章
  • 获取文章
  • 文章分组
  • 投支持票
数值及限制条件 P15
  1. 如果一篇文章获得了至少 200 张支持票,那么这篇文章就是一篇有趣的文章
  2. 如果这个网站每天有 50 篇有趣的文章,那么网站要把这 50 篇文章放到文章列表页前 100 位至少一天
  3. 支持文章评分(投支持票会加评分),且评分随时间递减

实现

投支持票 P15

如果要实现评分实时随时间递减,且支持按评分排序,那么工作量很大而且不精确。可以想到只有时间戳会随时间实时变化,如果我们把发布文章的时间戳当作初始评分,那么后发布的文章初始评分一定会更高,从另一个层面上实现了评分随时间递减。按照每个有趣文章每天 200 张支持票计算,平均到一天(86400 秒)中,每张票可以将分提高 432 分。

为了按照评分和时间排序获取文章,需要文章 id 及相应信息存在两个有序集合中,分别为:postTime 和 score 。

为了防止统一用户对统一文章多次投票,需要记录每篇文章投票的用户id,存储在集合中,为:votedUser:{articleId} 。

同时规定一篇文章发布期满一周后不能再进行投票,评分将被固定下来,同时记录文章已经投票的用户名单集合也会被删除。

// 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 记录到该文章的投票用户集合中(即发布者默认为自己投支持票),同时设置过期时间为一周。

存储文章相关信息,并将初始评分和发布时间记录下来。

// 发布文章(没有事务控制,第 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

分页获取支持四种排序,获取错误时返回空数组。

注意: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

支持将文章加入到分组集合,也支持将文章从分组集合中删除。

// 设置分组
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
版权声明
本文为[满赋诸机]所创,转载请带上原文链接,感谢
https://segmentfault.com/a/1190000039054993

  1. 【计算机网络 12(1),尚学堂马士兵Java视频教程
  2. 【程序猿历程,史上最全的Java面试题集锦在这里
  3. 【程序猿历程(1),Javaweb视频教程百度云
  4. Notes on MySQL 45 lectures (1-7)
  5. [computer network 12 (1), Shang Xuetang Ma soldier java video tutorial
  6. The most complete collection of Java interview questions in history is here
  7. [process of program ape (1), JavaWeb video tutorial, baidu cloud
  8. Notes on MySQL 45 lectures (1-7)
  9. 精进 Spring Boot 03:Spring Boot 的配置文件和配置管理,以及用三种方式读取配置文件
  10. Refined spring boot 03: spring boot configuration files and configuration management, and reading configuration files in three ways
  11. 精进 Spring Boot 03:Spring Boot 的配置文件和配置管理,以及用三种方式读取配置文件
  12. Refined spring boot 03: spring boot configuration files and configuration management, and reading configuration files in three ways
  13. 【递归,Java传智播客笔记
  14. [recursion, Java intelligence podcast notes
  15. [adhere to painting for 386 days] the beginning of spring of 24 solar terms
  16. K8S系列第八篇(Service、EndPoints以及高可用kubeadm部署)
  17. K8s Series Part 8 (service, endpoints and high availability kubeadm deployment)
  18. 【重识 HTML (3),350道Java面试真题分享
  19. 【重识 HTML (2),Java并发编程必会的多线程你竟然还不会
  20. 【重识 HTML (1),二本Java小菜鸟4面字节跳动被秒成渣渣
  21. [re recognize HTML (3) and share 350 real Java interview questions
  22. [re recognize HTML (2). Multithreading is a must for Java Concurrent Programming. How dare you not
  23. [re recognize HTML (1), two Java rookies' 4-sided bytes beat and become slag in seconds
  24. 造轮子系列之RPC 1:如何从零开始开发RPC框架
  25. RPC 1: how to develop RPC framework from scratch
  26. 造轮子系列之RPC 1:如何从零开始开发RPC框架
  27. RPC 1: how to develop RPC framework from scratch
  28. 一次性捋清楚吧,对乱糟糟的,Spring事务扩展机制
  29. 一文彻底弄懂如何选择抽象类还是接口,连续四年百度Java岗必问面试题
  30. Redis常用命令
  31. 一双拖鞋引发的血案,狂神说Java系列笔记
  32. 一、mysql基础安装
  33. 一位程序员的独白:尽管我一生坎坷,Java框架面试基础
  34. Clear it all at once. For the messy, spring transaction extension mechanism
  35. A thorough understanding of how to choose abstract classes or interfaces, baidu Java post must ask interview questions for four consecutive years
  36. Redis common commands
  37. A pair of slippers triggered the murder, crazy God said java series notes
  38. 1、 MySQL basic installation
  39. Monologue of a programmer: despite my ups and downs in my life, Java framework is the foundation of interview
  40. 【大厂面试】三面三问Spring循环依赖,请一定要把这篇看完(建议收藏)
  41. 一线互联网企业中,springboot入门项目
  42. 一篇文带你入门SSM框架Spring开发,帮你快速拿Offer
  43. 【面试资料】Java全集、微服务、大数据、数据结构与算法、机器学习知识最全总结,283页pdf
  44. 【leetcode刷题】24.数组中重复的数字——Java版
  45. 【leetcode刷题】23.对称二叉树——Java版
  46. 【leetcode刷题】22.二叉树的中序遍历——Java版
  47. 【leetcode刷题】21.三数之和——Java版
  48. 【leetcode刷题】20.最长回文子串——Java版
  49. 【leetcode刷题】19.回文链表——Java版
  50. 【leetcode刷题】18.反转链表——Java版
  51. 【leetcode刷题】17.相交链表——Java&python版
  52. 【leetcode刷题】16.环形链表——Java版
  53. 【leetcode刷题】15.汉明距离——Java版
  54. 【leetcode刷题】14.找到所有数组中消失的数字——Java版
  55. 【leetcode刷题】13.比特位计数——Java版
  56. oracle控制用户权限命令
  57. 三年Java开发,继阿里,鲁班二期Java架构师
  58. Oracle必须要启动的服务
  59. 万字长文!深入剖析HashMap,Java基础笔试题大全带答案
  60. 一问Kafka就心慌?我却凭着这份,图灵学院vip课程百度云