需求

功能: 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

Redis 实战 —— 02. Redis 简单实践 - 文章投票的更多相关文章

  1. Redis实战之Redis + Jedis

    用Memcached,对于缓存对象大小有要求,单个对象不得大于1MB,且不支持复杂的数据类型,譬如SET 等.基于这些限制,有必要考虑Redis! 相关链接: Redis实战 Redis实战之Redi ...

  2. Redis实战之Redis + Jedis[转]

    http://blog.csdn.net/it_man/article/details/9730605 2013-08-03 11:01 1786人阅读 评论(0) 收藏 举报   目录(?)[-] ...

  3. 分布式缓存技术redis学习系列(五)——redis实战(redis与spring整合,分布式锁实现)

    本文是redis学习系列的第五篇,点击下面链接可回看系列文章 <redis简介以及linux上的安装> <详细讲解redis数据结构(内存模型)以及常用命令> <redi ...

  4. 分布式缓存技术redis系列(五)——redis实战(redis与spring整合,分布式锁实现)

    本文是redis学习系列的第五篇,点击下面链接可回看系列文章 <redis简介以及linux上的安装> <详细讲解redis数据结构(内存模型)以及常用命令> <redi ...

  5. Redis实战总结-Redis的高可用性

    在之前的博客<Redis实战总结-配置.持久化.复制>给出了一种Redis主从复制机制,简单地实现了Redis高可用.然后,如果Master服务器宕机,会导致整个Redis瘫痪,这种方式的 ...

  6. Redis实战之Redis命令

    阅读目录 1. 字符串命令 2. 列表命令 3. 集合命令 4. 散列命令 5. 有序集合命令 6. 发布与订阅命令 7. 小试牛刀 Redis可以存储键与5种不同数据结构类型之间的映射,这5种数据结 ...

  7. redis实战笔记(1)-第1章 初识Redis

    第1章 初识Redis 注:本书在redis3.0版本的,比如redis3.0以后支持服务端集群.3.0之前只能客户端分片.    本章主要内容 1.Redis与其他软件的相同之处和不同之处 2.Re ...

  8. 使用Redis构建文章投票网站

    涉及到的key: 1. article_time, 记录文章的发布时间,zset结构 2. article_score, 记录文章的得分, zset结构 得分 = 发布时间 + 投票用户数 X 432 ...

  9. redis实战---读书笔记

    第一章 初识redis redis 是一个远程内存数据库,性能强劲,具有复制特性以及为解决问题而生的独一无二的数据模型.   1. redis 简介 redis 是一种非关系型数据库(NOSQL) r ...

  10. Redis实战

    大约一年多前,公司同事开始使用Redis,不清楚是配置,还是版本的问题,当时的Redis经常在使用一段时间后,连接爆满且不释放.印象中,Redis 2.4.8以下的版本由于设计上的主从库同步问题,就会 ...

随机推荐

  1. 关于strcpy_s

    #include"stdafx.h" #include<iostream> #include<cstring> int main() { using nam ...

  2. 多线程调用HttpWebRequest并发连接限制

    .net 的 HttpWebRequest 或者 WebClient 在多线程情况下存在并发连接限制,这个限制在桌面操作系统如 windows xp , windows  7 下默认是2,在服务器操作 ...

  3. DELPHI 通過窗口句柄或窗口标题得到进程句柄

    DELPHI 通過窗口句柄或窗口标题得到进程句柄2009年05月08日 星期五 10:15procedure TForm1.Button1Click(Sender: TObject);varhWind ...

  4. MySql学习笔记(一) —— 关键字的使用

    1.distinct关键字 作用:检索出有不同值的列,比如一个商品表中存在供应商vend_id,一个供应商会对应很多商品,我们要查找有多少供应商,就可以用到该关键字去重. select distinc ...

  5. GAN_李弘毅讲解

    GAN_李弘毅讲解: 上式中,xi从data中sample的一部分,现在的目的就是最大化这个似然函数,使得Generator最可能产生data中的这些sample: 上式中之所以如此设计V函数,是为了 ...

  6. MATLAB 到 Python之路1_数据结构和简单操作

    在numpy中,用array来代替matrix,不同于MATLAB中的万物皆matrix,这里的数据首先以array存在,然后通过操作才能和矩阵形式的array运算 1,array的形式 1.1 一维 ...

  7. PAT甲题题解-1103. Integer Factorization (30)-(dfs)

    该题还不错~. 题意:给定N.K.P,使得可以分解成N = n1^P + … nk^P的形式,如果可以,输出sum(ni)最大的划分,如果sum一样,输出序列较大的那个.否则输出Impossible. ...

  8. 自然语言处理---用隐马尔科夫模型(HMM)实现词性标注---1998年1月份人民日报语料---learn---test---evaluation---Demo---java实现

    先放上一张Demo的测试图 测试的句子及每个分词的词性标注为:   目前/t 这/rzv 条/q 高速公路/n 之间/f 的/ude1 路段/n 已/d 紧急/a 封闭/v ./w 需要基础知识 HM ...

  9. Laravel 支付宝支付异步通知

    支付宝支付通知有前端通知(GET)和服务器异步通知(POST) 在配置支付宝支付时,需要注意的问题就是支付宝的回调操作: 1.在laravel中应该将支付宝通知路径组织csrf验证,否则会导致419错 ...

  10. Java Web -- Servlet(5) 开发Servlet的三种方法、配置Servlet具体解释、Servlet的生命周期(2)

    三.Servlet的生命周期 一个Java servlet具有一个生命周期,这个生命周期定义了一个Servlet怎样被加载并被初始化,怎样接收请求并作出对请求的响应,怎样被从服务中清除.Servlet ...