目录
前言
如何展示?
一个简单的投票程序
基于zset的进行排序
如何处理重复投票
限制投票时间
群组功能
反对票
- 本文开发语言: 基于python 3.x
- 开发工具: pycharm 2024
- redis-cli: 5.x
- redis-ser 5.x
我同学面试又又又又又遇到不会的了, 我同学今天面试一家大厂, 结果问到了使用Redis怎么实现投票和排行榜的功能, 他又没答上来, 真是气死我了.... .... 真的是枉为我的同学 ... ...
前言
现在很多网站, 基本上都有排行榜的功能, 或者是点赞投票的功能, 例如微博的热搜排行:
或者是stack overflow的网站:
都具备这些点赞的选项, 点赞数越高的, 排行就会越靠前(或者可以加入一些其他的算法来计算积分).
我们知道, 这些发表的文章和问答, 都是持久化入库的, 由于排行榜和投票这类的功能具备很强的时效性, 什么叫时效性? 排行榜中的数据很有可能只是展示某一个时间段的投票数据, 假设某东商城里面有一个11月份某某电器最热销售榜, 里面记录着用户点赞数最多的商品, 那么这个11月份某某电器最热销售榜对于12月份来说, 就是一个历史数据了.
这类数据数据量不大, 被纳入排行榜竞争的商品, 可能只有一千多个, 这个数据量不是很大, 而且有很强的时效性, 存活时间段, 后续排行榜结束之后, 就会被持久化入库, 作为历史记录来存储, 然后用来记录下一个月份的数据. 这种数据使用redis来存储计算, 就有非常的优势:
- 抗高并发, 点赞这种操作是具有很高的并发操作的, 尤其是京东这种日活很高的用户, 我不说平均能达到多少, 最高的时候, 可能同一个商品点赞能达到1000qps, 这不过分吧. 但是redis这种基于内存管理的单线程模型来说, 处理性能高, 线程安全, 处理这种临时数据最合适不过.
如何展示?
那么我们应该了解第一个问题:
像上面这种榜单展示的数据, 到底是从数据库中查询, 还是从Redis中查询? 对于这种热点数据, 实时性强, 访问频率高, Redis这样的内存数据库则更为合适, 当然背后也肯定做了某种持久化的操作, 我们本次讲解的是redis, 因此忽略持久化的过程.
既然要展示这些榜单的信息, 那么应该就需要存储这些关键的内容部分, 例如一个榜单里面的某一条排行内容可能展示的数据如下:
- title : 也就是标题, 例如上图中的标题就为 荣耀笔记本X16....
- specs: 规格或者特征, 也可以是一种描述, 例如 16gb + 512GB, 轻薄本, 等等.
- poster: 当前页面可能没有显示, 但是在一些博客网站的榜单, 经常会显示作者
- time: 这个就更不用说了, 创建时间
- votes: 这个是必须要的, 这个代表投票数, 此时, 因为榜单的排序就是根据投票数来判定, 投票数越高, 排名就越靠前. 同时这个投票数也会同步展示给用户看.
每一个商品 或者 博客, 都有自己的唯一分布式id, 这里为了简单, 我就将id唯一定义为一串简单的数字(假设他们不会重复), 选择Redis中的散列来进行存储, 如下:
那么我只需要通过product的 业务前缀+id 就可以获取所有的排行榜商品了.
请注意这里只是简单的获取排行榜, 事实上, 为了和其他业务进行区分, 排行榜应该有排行榜的业务前缀 , 例如: product_sales_rank_, 或者是: jd_mall_product_sales_rank_ , 但是无论是哪一种, 都应该具有一定的可读性,能够清晰地表达数据的业务含义和用途.
包括这里的业务前缀除外的id部分, 和id连接的冒号: 也可以换做其他的, 例如-, 或者是@, 但是这样做需要保证在应用层处理的时候, 不会出现异常.
一个简单的投票程序
显而易见, 我们投票, 只需要给每一个product对应的id的key所对应的value中的votes值+1就行, 如下:
这个代码实现起来非常简单吧, 如下:
import redis
# 创建连接
r = redis.Redis(host='localhost', port=6379, db=0)
def product_vote(redis_conn, product_id):
"""
对产品进行投票,增加产品的投票数
Args:
redis_conn (redis.Redis): Redis连接对象
product_id (int): 产品ID
Returns:
None
"""
# hincrby 命令用于将redis的哈希表 key 中域 field 的值加上增量 increment
# 第一个参数是哈希表的key,第二个参数是域,第三个参数是增量
product_profix = 'product:'
# key
hash_key = product_profix + str(product_id)
# filed
product_vote_field = 'votes'
redis_conn.hincrby(hash_key, product_vote_field, 1)
这样就可以对一个排行榜数据进行投票操作了. 这里的排行榜的获取, 可能会存在分页的需要, 我们考虑进去. 由于其hincrby具备单线程模型的特征, 无需考虑并发问题.
我们发现, 接下来的问题就是, 如何基于一个hset中的filed进行排序? redis没有直接提供基于域进行排序的功能啊, 因此只能在应用层进行处理了, 但是引用层处理你就需要获取所有的参与此排行的商品id, 不然你就无法获取到索引的商品信息. 更无法进行排序和分页获取.
可能的实现就是你新增商品或者是让商品参与到排行统计的时候, 维护一个参与排行的排行表:
# 获取所有的参与排行的商品id
def get_all_product_id(redis_conn):
ids = redis_conn.smembers('product_list')
return ids
然后要获取此排行的时候, 就可以根据此排行表来获取所有的参与此排行的商品id了. 然后再做应用层的处理, 如下:
def get_product_votes(redis_conn):
# 获取所有产品ID
ids = get_all_product_id(redis_conn)
product_prefix = 'product:'
product_vote_field = 'votes'
list_data = []
for product_id in ids:
hash_key = product_prefix + str(product_id)
hset = redis_conn.hgetall(hash_key)
list_data.append(hset)
# reverse=True 表示降序排列
list_data.sort(key=lambda x: x[product_vote_field], reverse=True)
return list_data
因此只是简单的基于hset中的votes这个filed的进行排序, 是非常复杂的.
下面是基于分页的版本:
def get_product_votes_by_page(redis_conn,page):
# 获取所有产品ID
ids = get_all_product_id(redis_conn)
product_prefix = 'product:'
product_vote_field = 'votes'
# 假设每页10个数据
start = (page - 1) * 10
end = start + 10
list_data = []
for product_id in ids:
hash_key = product_prefix + str(product_id)
hset = redis_conn.hgetall(hash_key)
list_data.append(hset)
# reverse=True 表示降序排列
list_data.sort(key=lambda x: x[product_vote_field], reverse=True)
# 切片
return list_data[start:end]
我们总结一下, 所谓的投票, 其实就是给每个产品的票数进行+1, 然后获取的时候根据票数进行排序和展示就可以了, 就这么简单.
然后我们新增的文章就只需要往这个key为product_list的表中插入产品id, 还有往这个prodcut中插入产品信息就可以了. 代码我就不写了......
但是需要注意的是, 我们往两个key中写入了数据, 通常就会涉及到事务的问题, 这个内容请读者自行研究...
有的人不喜欢在应用层进行处理, 他不喜欢, 可不可以换个方法?
有的人不太喜欢将数据全部拿到应用层做处理, 而是直接在数据库层面进行排序, 那我们就应该思考, Redis中什么数据结构, 可以进行快速的排序?
基于zset的进行排序
我们展示一段zset的代码, 如下:
zadd zset-key 101 item1
zadd zset-key 102 item2
zadd zset-key 103 item3
zrange zset-key 0 -1 withscores
- 输出:
1) "item1"
2) "101"
3) "item2"
4) "102"
5) "item3"
6) "103"
zrangebyscore zset-key 102 103 withscores
- 输出:
1) "item2"
2) "102"
3) "item3"
4) "103"
ZREVRANGE zset-key 0 -1 withscores
- 输出:
1) "item3"
2) "103"
3) "item2"
4) "102"
5) "item1"
6) "101"
上述是一段redis的指令, 其中最关键的就是zrevrange命令, 也就是通过对分值进行降序排序, 然后返回的形式. (zrange是升序.)
所以我们可以将我们之前设计的product的表中, 将votes抽离出来, 单独使用zset来进行计数和排序:
我们接下来想要接下来不就是直接拿到这个zset-key:votes的所有的item不就行了, 并且设计者withscores, 让其也同时返回票数, 如下:
def get_product_votes_by_page_zset(redis_conn,page):
# 假设一页10个数据
start = (page - 1) * 10
end = start + 10 - 1
# 通过zset表来获取所有的产品item(item类似于product:00000002,直接就是信息表的key)
items = redis_conn.zrevrange('votes:', start, end, withscores=True)
products = []
for item, score in items:
# 一个dict
product = redis_conn.hgetall(item)
# 添加id
product['id'] = item.split(':')[1]
# 添加votes
product['score'] = score
products.append(product)
return products
可以就可以返回一个排好序的product列表啦. ..
写入数据也需要往key为product+id的逻辑数据集合中插入产品信息, 和往score:表中插入票数就可以了.
进行投票的方法如下:
def product_vote_zset(redis_conn, product_id):
product_prefix = 'product:'
zset_filed = product_prefix + str(product_id)
redis_conn.zincrby('votes:', 1, zset_filed)
仅仅只是把votes:表中的对应的产品的票数+1即可.
但是一个投票系统不会真的就这么简单吧? 我们尝试思考还有没有其他的一些限制, 或者缺失的功能.
如何处理重复投票
这里面的每一个产品, 都不能进行重复的投票吧? 我一旦可以进行重复的投票, 那么这个排行榜就失去了意义, 因为可以所以操作某个商品的票数, 自然顾客也就无法通过票数和排行判断一个产品的好坏.
因此我们应该在投票的时候做出某些限制.
在对产品编号为a的产品进行投票之后, 立即将投票的用户插入到这个产品的已投票用户列表中, 如下:
下次某个用户进行投票, 就需要先经过这个表, 这个表的数据结构为zset, 我们知道往zset的中添加一个数据, 如果成功了返回值就为1, 否则就为0, 因此可以利用这一点, 每次添加的时候就将这个用户add进去, 如果返回值为1, 就让其成功投票, 否则就禁止投票.
无论是上述的简单的投票程序, 还是基于zset的投票程序, 其思路差不多, 我么就以基于zset实现的排序进行投票操作, 代码如下:
def product_vote_zset(redis_conn, product_id):
# 模拟获取用户id
user_id = get_user_id()
product_prefix = 'product:'
zset_filed = product_prefix + str(product_id)
# 拼接为name
item_name = 'user:' + str(user_id)
# 判断是否已经投过票
if redis_conn.sadd("voted", item_name) == 1:
redis_conn.zincrby('votes:', 1, zset_filed)
else:
print("您已经投过票")
# 模拟获取用户id
def get_user_id():
import random
return random.randint(1, 100000)
同时需要注意, 这个投票可能在某个时间段之后就过期了, 就无法进行投票了, 因此此时这个user表的维护也就没有什么作用了, 留着也是占用内存, 因此可以将其删除掉, 使用ttl, 在set的时候让他在指定时间段过期就行.
限制投票时间
这是一个非常正规的要求和操作, 投票投票, 总得有一个截止时间吧, 你总不能说, 我jd11月份手机排行榜, 在12月份还能进行投票吧? 如果可以的话, 那这个11月份, 12月份的时间概念还有什么意义?
我们还是拿商品来举例子, 假设我们的商品一周之后就不能进行投票了, 这是我们目前投票的数据结构:
我们当前是就了time的, 也就是推送的时间, 也或者说是开始进行投票的时间, 那么我们应该通过这个值, 和当前的时间对比, 如果对比之后发现时间已经超过一周了或者是其他设置的时间, 那么就不允许投票, 接下来我们说说如何判断是否超过一周.
首先我可以后去当前的时间戳, 然后跟记录的时间戳相减, 就知道两者之间的时间戳差值, 然后计算这个差值, 和一周的时间戳时间到底谁更大即可, 如下:
def is_within_one_week(past_timestamp):
# 获取当前时间戳
current_timestamp = time.time()
# 计算时间差(秒)
time_difference = current_timestamp - past_timestamp
# 一周的时间(秒),7天 * 24小时/天 * 60分钟/小时 * 60秒/分钟
one_week_seconds = 7 * 24 * 60 * 60
# 判断时间差是否超过一周
return time_difference <= one_week_seconds
然后在投票的时候, 进行比较即可, 这里还是以基于zset进行排序的那个案例中的投票方法进行举例, 如下:
def product_vote_zset(redis_conn, product_id):
# 模拟获取用户id
user_id = get_user_id()
# 拼接为name
item_name = 'user:' + str(user_id)
# 获取votes:的对应产品的filed
product_prefix = 'product:'
zset_filed = product_prefix + str(product_id)
# 获取产品的推送时间
product_post_timestamp = redis_conn.hgetall("product:"+str(product_id))['time']
# 判断是否已经投过票 并且 投票时效没有过期
if redis_conn.sadd("voted", item_name) == 1 and is_within_one_week(product_post_timestamp):
redis_conn.zincrby('votes:', 1, zset_filed)
else:
print("您已经投过票")
# 模拟获取用户id
def get_user_id():
import random
return random.randint(1, 100000)
群组功能
我们上述的功能实现, 都只是仅仅局限于"产品"两个字, 但是没有仔细考虑, 产品还有拥有自己的分类, 例如 "电器"类的产品有"电器"类的排行榜, "手机"类的产品有"手机"类的排行榜, 我们上述的操作, 仅仅只是将所有的商品 围在一个大的圈子里面进行排行榜展示, 里面鱼龙混杂, 有手机, 有电脑, 有洗衣机, 还有电饭煲
这种排行榜会存在不同产品的比较, 而不同产品的比较又没有什么实际意义, 对于用户来说, 他只想看到"手机" 这一类下的投票, 并不想在"手机"类的排行榜中突然看到了一个拖把 ...
因此我们需要对商品进行一个简单的分组, 将他们分开, 可以设计如下数据结构: 每个组为一个集合结构(set), 然后每次新增文章的时候都将对应的文章id, 存储到对应的一个或者多个组中, 如下:
但是我们仔细回顾排行榜的细节, 发现, 这个组的数据结构仅仅只是redis中的set结构, 并不能说可以完成排序的功能, 因为他里面没有任何进行排序的值.
因此我们如果要将现在的这个结构进行改造, 改造成一个zset的集合, 不就可以了吗, 如下:
那么问题就是, 如何将set的集合, 转化为一个zset集合, 并且zset中的score的数据来源, 是哪里?
很容易, 我们要做这个转换, 很容易联想到前面我们所作的程序里面的这个结构:
这样我只要知道productid, 不就可以在votes表中, 找到对应的 score值了. 皆大欢喜.
剩下的就是需要思考, 如何将数据, 从这个votes中, 转储到这个组的zset中去.
那就得说一个命令了, 如下:
这个命令用于集合set和有序集合zset之间的操作, ZINTERSTORE 是 Redis 提供的一个用于处理有序集合(sorted set)的命令。它可以将多个有序集合进行交集运算,并将结果存储到另一个有序集合中
例如, 假设有以下两个有序集合:
ZADD zset1 1 "one" 2 "two" 3 "three"
ZADD zset2 2 "two" 3 "three" 4 "four"
计算 zset1 和 zset2 的交集,并将结果存储到 zset_intersection (下面的2表示有两个集合)
ZINTERSTORE zset_intersection 2 zset1 zset2
为了将votes中的zset数据, 跟key: groups:group_name的set数据做交集, 并且需要将set中的权重值降为0, 将zset中的数据权重设置为1, 可以使用如下命令:
ZINTERSTORE zset:groups:group_name 2 zset-votes set-groups:group_name weights 1 0
于是就有了上述表格中的数据. 此时你就可以携带组的id, 来get到对应组的文章列表啦, 又由于现在使用的是zset的结构的组, 因此你可以基于分数给这个组里面的内容进行排序, 代码如下:
def get_group_products(redis_conn, group_id, page):
group_prefix = 'score:groups:'
group_key = group_prefix + str(group_id)
if not redis_conn.exists(group_key) :
redis_conn.zinterstore(group_key,["votes:","groups:"+str(group_id)], aggregate="max")
# 60秒过期
redis_conn.expire(group_key, 60)
# 复用之前的函数
return get_product_votes_by_page_zset(redis_conn, page, group_key)
# 分页并排序获取zset的数据
def get_product_votes_by_page_zset(redis_conn,page, key):
# 假设一页10个数据
start = (page - 1) * 10
end = start + 10 - 1
# 通过zset表来获取所有的产品item(item类似于product:00000002,直接就是信息表的key)
items = redis_conn.zrevrange(key, start, end, withscores=True)
products = []
for item, score in items:
# 一个dict
product = redis_conn.hgetall(item)
# 添加id
product['id'] = item.split(':')[1]
# 添加votes
product['score'] = score
products.append(product)
return products
注意下面这段代码的逻辑 :
redis_conn.zinterstore(group_key,["votes:","groups:"+str(group_id)], aggregate="max")
这段代码中是取得votes这个zset和groups:group_id这个set中的最大值, 其中groups:group_id所有成员分值被视为1, 因此你需要注意, 所有的新加入到这个votes中的分值按理来说应该做出一些调整, 例如默认的votes分值为1, 但是这好像并不符合逻辑, 因为默认的投票数应该为0, 因此在votes的投票数, 更应该换成某种计算的公式, 例如: 对应的scores: 应该为f(x), 这个x就是对应的产品id.
但是也请记住, 我们上述的办法仅仅只是获得一个临时的数据, 也就是说, 这个基于分组的数据, 排序或者排行, 至少经过60s才会统计以一次. 并且一个产品会拥有多个组, 更新一个产品的分数, 可能会需要更新多个组的分数. 那么你在跟新产品分数的时候, 通过使用zinterstore去更新分组的分数, 如果在数据量比较大的时候, 这是一个非常耗时的操作. 因此才会设置这个缓存操作, 同时也可以适当延迟这个缓存.
反对票
光能支持还不够, 有支持就有反对, 因此反对的逻辑该如何思考? 既然是反对, 我们可以考虑在发对的时候, 对votes:的zset集合的票数分值-1即可, 如果你是基于的函数进行的分值的计算, 那么在函数的逻辑中, 加入对应的扣减分数的逻辑就行.
例如:
def product_vote(redis_conn, product_id, isvote=True):
# hincrby 命令用于将redis的哈希表 key 中域 field 的值加上增量 increment
# 第一个参数是哈希表的key,第二个参数是域,第三个参数是增量
product_prefix = 'product:'
# key
hash_key = product_prefix + str(product_id)
# filed
product_vote_field = 'votes'
vote = 0
if isvote:
vote = 1
else:
vote = -1
redis_conn.hincrby(hash_key, product_vote_field, vote)
目录
前言
如何展示?
一个简单的投票程序
基于zset的进行排序
如何处理重复投票
限制投票时间
群组功能
反对票
- 本文开发语言: 基于python 3.x
- 开发工具: pycharm 2024
- redis-cli: 5.x
- redis-ser 5.x
我同学面试又又又又又遇到不会的了, 我同学今天面试一家大厂, 结果问到了使用Redis怎么实现投票和排行榜的功能, 他又没答上来, 真是气死我了.... .... 真的是枉为我的同学 ... ...
前言
现在很多网站, 基本上都有排行榜的功能, 或者是点赞投票的功能, 例如微博的热搜排行:
或者是stack overflow的网站:
都具备这些点赞的选项, 点赞数越高的, 排行就会越靠前(或者可以加入一些其他的算法来计算积分).
我们知道, 这些发表的文章和问答, 都是持久化入库的, 由于排行榜和投票这类的功能具备很强的时效性, 什么叫时效性? 排行榜中的数据很有可能只是展示某一个时间段的投票数据, 假设某东商城里面有一个11月份某某电器最热销售榜, 里面记录着用户点赞数最多的商品, 那么这个11月份某某电器最热销售榜对于12月份来说, 就是一个历史数据了.
这类数据数据量不大, 被纳入排行榜竞争的商品, 可能只有一千多个, 这个数据量不是很大, 而且有很强的时效性, 存活时间段, 后续排行榜结束之后, 就会被持久化入库, 作为历史记录来存储, 然后用来记录下一个月份的数据. 这种数据使用redis来存储计算, 就有非常的优势:
- 抗高并发, 点赞这种操作是具有很高的并发操作的, 尤其是京东这种日活很高的用户, 我不说平均能达到多少, 最高的时候, 可能同一个商品点赞能达到1000qps, 这不过分吧. 但是redis这种基于内存管理的单线程模型来说, 处理性能高, 线程安全, 处理这种临时数据最合适不过.
如何展示?
那么我们应该了解第一个问题:
像上面这种榜单展示的数据, 到底是从数据库中查询, 还是从Redis中查询? 对于这种热点数据, 实时性强, 访问频率高, Redis这样的内存数据库则更为合适, 当然背后也肯定做了某种持久化的操作, 我们本次讲解的是redis, 因此忽略持久化的过程.
既然要展示这些榜单的信息, 那么应该就需要存储这些关键的内容部分, 例如一个榜单里面的某一条排行内容可能展示的数据如下:
- title : 也就是标题, 例如上图中的标题就为 荣耀笔记本X16....
- specs: 规格或者特征, 也可以是一种描述, 例如 16gb + 512GB, 轻薄本, 等等.
- poster: 当前页面可能没有显示, 但是在一些博客网站的榜单, 经常会显示作者
- time: 这个就更不用说了, 创建时间
- votes: 这个是必须要的, 这个代表投票数, 此时, 因为榜单的排序就是根据投票数来判定, 投票数越高, 排名就越靠前. 同时这个投票数也会同步展示给用户看.
每一个商品 或者 博客, 都有自己的唯一分布式id, 这里为了简单, 我就将id唯一定义为一串简单的数字(假设他们不会重复), 选择Redis中的散列来进行存储, 如下:
那么我只需要通过product的 业务前缀+id 就可以获取所有的排行榜商品了.
请注意这里只是简单的获取排行榜, 事实上, 为了和其他业务进行区分, 排行榜应该有排行榜的业务前缀 , 例如: product_sales_rank_, 或者是: jd_mall_product_sales_rank_ , 但是无论是哪一种, 都应该具有一定的可读性,能够清晰地表达数据的业务含义和用途.
包括这里的业务前缀除外的id部分, 和id连接的冒号: 也可以换做其他的, 例如-, 或者是@, 但是这样做需要保证在应用层处理的时候, 不会出现异常.
一个简单的投票程序
显而易见, 我们投票, 只需要给每一个product对应的id的key所对应的value中的votes值+1就行, 如下:
这个代码实现起来非常简单吧, 如下:
import redis
# 创建连接
r = redis.Redis(host='localhost', port=6379, db=0)
def product_vote(redis_conn, product_id):
"""
对产品进行投票,增加产品的投票数
Args:
redis_conn (redis.Redis): Redis连接对象
product_id (int): 产品ID
Returns:
None
"""
# hincrby 命令用于将redis的哈希表 key 中域 field 的值加上增量 increment
# 第一个参数是哈希表的key,第二个参数是域,第三个参数是增量
product_profix = 'product:'
# key
hash_key = product_profix + str(product_id)
# filed
product_vote_field = 'votes'
redis_conn.hincrby(hash_key, product_vote_field, 1)
这样就可以对一个排行榜数据进行投票操作了. 这里的排行榜的获取, 可能会存在分页的需要, 我们考虑进去. 由于其hincrby具备单线程模型的特征, 无需考虑并发问题.
我们发现, 接下来的问题就是, 如何基于一个hset中的filed进行排序? redis没有直接提供基于域进行排序的功能啊, 因此只能在应用层进行处理了, 但是引用层处理你就需要获取所有的参与此排行的商品id, 不然你就无法获取到索引的商品信息. 更无法进行排序和分页获取.
可能的实现就是你新增商品或者是让商品参与到排行统计的时候, 维护一个参与排行的排行表:
# 获取所有的参与排行的商品id
def get_all_product_id(redis_conn):
ids = redis_conn.smembers('product_list')
return ids
然后要获取此排行的时候, 就可以根据此排行表来获取所有的参与此排行的商品id了. 然后再做应用层的处理, 如下:
def get_product_votes(redis_conn):
# 获取所有产品ID
ids = get_all_product_id(redis_conn)
product_prefix = 'product:'
product_vote_field = 'votes'
list_data = []
for product_id in ids:
hash_key = product_prefix + str(product_id)
hset = redis_conn.hgetall(hash_key)
list_data.append(hset)
# reverse=True 表示降序排列
list_data.sort(key=lambda x: x[product_vote_field], reverse=True)
return list_data
因此只是简单的基于hset中的votes这个filed的进行排序, 是非常复杂的.
下面是基于分页的版本:
def get_product_votes_by_page(redis_conn,page):
# 获取所有产品ID
ids = get_all_product_id(redis_conn)
product_prefix = 'product:'
product_vote_field = 'votes'
# 假设每页10个数据
start = (page - 1) * 10
end = start + 10
list_data = []
for product_id in ids:
hash_key = product_prefix + str(product_id)
hset = redis_conn.hgetall(hash_key)
list_data.append(hset)
# reverse=True 表示降序排列
list_data.sort(key=lambda x: x[product_vote_field], reverse=True)
# 切片
return list_data[start:end]
我们总结一下, 所谓的投票, 其实就是给每个产品的票数进行+1, 然后获取的时候根据票数进行排序和展示就可以了, 就这么简单.
然后我们新增的文章就只需要往这个key为product_list的表中插入产品id, 还有往这个prodcut中插入产品信息就可以了. 代码我就不写了......
但是需要注意的是, 我们往两个key中写入了数据, 通常就会涉及到事务的问题, 这个内容请读者自行研究...
有的人不喜欢在应用层进行处理, 他不喜欢, 可不可以换个方法?
有的人不太喜欢将数据全部拿到应用层做处理, 而是直接在数据库层面进行排序, 那我们就应该思考, Redis中什么数据结构, 可以进行快速的排序?
基于zset的进行排序
我们展示一段zset的代码, 如下:
zadd zset-key 101 item1
zadd zset-key 102 item2
zadd zset-key 103 item3
zrange zset-key 0 -1 withscores
- 输出:
1) "item1"
2) "101"
3) "item2"
4) "102"
5) "item3"
6) "103"
zrangebyscore zset-key 102 103 withscores
- 输出:
1) "item2"
2) "102"
3) "item3"
4) "103"
ZREVRANGE zset-key 0 -1 withscores
- 输出:
1) "item3"
2) "103"
3) "item2"
4) "102"
5) "item1"
6) "101"
上述是一段redis的指令, 其中最关键的就是zrevrange命令, 也就是通过对分值进行降序排序, 然后返回的形式. (zrange是升序.)
所以我们可以将我们之前设计的product的表中, 将votes抽离出来, 单独使用zset来进行计数和排序:
我们接下来想要接下来不就是直接拿到这个zset-key:votes的所有的item不就行了, 并且设计者withscores, 让其也同时返回票数, 如下:
def get_product_votes_by_page_zset(redis_conn,page):
# 假设一页10个数据
start = (page - 1) * 10
end = start + 10 - 1
# 通过zset表来获取所有的产品item(item类似于product:00000002,直接就是信息表的key)
items = redis_conn.zrevrange('votes:', start, end, withscores=True)
products = []
for item, score in items:
# 一个dict
product = redis_conn.hgetall(item)
# 添加id
product['id'] = item.split(':')[1]
# 添加votes
product['score'] = score
products.append(product)
return products
可以就可以返回一个排好序的product列表啦. ..
写入数据也需要往key为product+id的逻辑数据集合中插入产品信息, 和往score:表中插入票数就可以了.
进行投票的方法如下:
def product_vote_zset(redis_conn, product_id):
product_prefix = 'product:'
zset_filed = product_prefix + str(product_id)
redis_conn.zincrby('votes:', 1, zset_filed)
仅仅只是把votes:表中的对应的产品的票数+1即可.
但是一个投票系统不会真的就这么简单吧? 我们尝试思考还有没有其他的一些限制, 或者缺失的功能.
如何处理重复投票
这里面的每一个产品, 都不能进行重复的投票吧? 我一旦可以进行重复的投票, 那么这个排行榜就失去了意义, 因为可以所以操作某个商品的票数, 自然顾客也就无法通过票数和排行判断一个产品的好坏.
因此我们应该在投票的时候做出某些限制.
在对产品编号为a的产品进行投票之后, 立即将投票的用户插入到这个产品的已投票用户列表中, 如下:
下次某个用户进行投票, 就需要先经过这个表, 这个表的数据结构为zset, 我们知道往zset的中添加一个数据, 如果成功了返回值就为1, 否则就为0, 因此可以利用这一点, 每次添加的时候就将这个用户add进去, 如果返回值为1, 就让其成功投票, 否则就禁止投票.
无论是上述的简单的投票程序, 还是基于zset的投票程序, 其思路差不多, 我么就以基于zset实现的排序进行投票操作, 代码如下:
def product_vote_zset(redis_conn, product_id):
# 模拟获取用户id
user_id = get_user_id()
product_prefix = 'product:'
zset_filed = product_prefix + str(product_id)
# 拼接为name
item_name = 'user:' + str(user_id)
# 判断是否已经投过票
if redis_conn.sadd("voted", item_name) == 1:
redis_conn.zincrby('votes:', 1, zset_filed)
else:
print("您已经投过票")
# 模拟获取用户id
def get_user_id():
import random
return random.randint(1, 100000)
同时需要注意, 这个投票可能在某个时间段之后就过期了, 就无法进行投票了, 因此此时这个user表的维护也就没有什么作用了, 留着也是占用内存, 因此可以将其删除掉, 使用ttl, 在set的时候让他在指定时间段过期就行.
限制投票时间
这是一个非常正规的要求和操作, 投票投票, 总得有一个截止时间吧, 你总不能说, 我jd11月份手机排行榜, 在12月份还能进行投票吧? 如果可以的话, 那这个11月份, 12月份的时间概念还有什么意义?
我们还是拿商品来举例子, 假设我们的商品一周之后就不能进行投票了, 这是我们目前投票的数据结构:
我们当前是就了time的, 也就是推送的时间, 也或者说是开始进行投票的时间, 那么我们应该通过这个值, 和当前的时间对比, 如果对比之后发现时间已经超过一周了或者是其他设置的时间, 那么就不允许投票, 接下来我们说说如何判断是否超过一周.
首先我可以后去当前的时间戳, 然后跟记录的时间戳相减, 就知道两者之间的时间戳差值, 然后计算这个差值, 和一周的时间戳时间到底谁更大即可, 如下:
def is_within_one_week(past_timestamp):
# 获取当前时间戳
current_timestamp = time.time()
# 计算时间差(秒)
time_difference = current_timestamp - past_timestamp
# 一周的时间(秒),7天 * 24小时/天 * 60分钟/小时 * 60秒/分钟
one_week_seconds = 7 * 24 * 60 * 60
# 判断时间差是否超过一周
return time_difference <= one_week_seconds
然后在投票的时候, 进行比较即可, 这里还是以基于zset进行排序的那个案例中的投票方法进行举例, 如下:
def product_vote_zset(redis_conn, product_id):
# 模拟获取用户id
user_id = get_user_id()
# 拼接为name
item_name = 'user:' + str(user_id)
# 获取votes:的对应产品的filed
product_prefix = 'product:'
zset_filed = product_prefix + str(product_id)
# 获取产品的推送时间
product_post_timestamp = redis_conn.hgetall("product:"+str(product_id))['time']
# 判断是否已经投过票 并且 投票时效没有过期
if redis_conn.sadd("voted", item_name) == 1 and is_within_one_week(product_post_timestamp):
redis_conn.zincrby('votes:', 1, zset_filed)
else:
print("您已经投过票")
# 模拟获取用户id
def get_user_id():
import random
return random.randint(1, 100000)
群组功能
我们上述的功能实现, 都只是仅仅局限于"产品"两个字, 但是没有仔细考虑, 产品还有拥有自己的分类, 例如 "电器"类的产品有"电器"类的排行榜, "手机"类的产品有"手机"类的排行榜, 我们上述的操作, 仅仅只是将所有的商品 围在一个大的圈子里面进行排行榜展示, 里面鱼龙混杂, 有手机, 有电脑, 有洗衣机, 还有电饭煲
这种排行榜会存在不同产品的比较, 而不同产品的比较又没有什么实际意义, 对于用户来说, 他只想看到"手机" 这一类下的投票, 并不想在"手机"类的排行榜中突然看到了一个拖把 ...
因此我们需要对商品进行一个简单的分组, 将他们分开, 可以设计如下数据结构: 每个组为一个集合结构(set), 然后每次新增文章的时候都将对应的文章id, 存储到对应的一个或者多个组中, 如下:
但是我们仔细回顾排行榜的细节, 发现, 这个组的数据结构仅仅只是redis中的set结构, 并不能说可以完成排序的功能, 因为他里面没有任何进行排序的值.
因此我们如果要将现在的这个结构进行改造, 改造成一个zset的集合, 不就可以了吗, 如下:
那么问题就是, 如何将set的集合, 转化为一个zset集合, 并且zset中的score的数据来源, 是哪里?
很容易, 我们要做这个转换, 很容易联想到前面我们所作的程序里面的这个结构:
这样我只要知道productid, 不就可以在votes表中, 找到对应的 score值了. 皆大欢喜.
剩下的就是需要思考, 如何将数据, 从这个votes中, 转储到这个组的zset中去.
那就得说一个命令了, 如下:
这个命令用于集合set和有序集合zset之间的操作, ZINTERSTORE 是 Redis 提供的一个用于处理有序集合(sorted set)的命令。它可以将多个有序集合进行交集运算,并将结果存储到另一个有序集合中
例如, 假设有以下两个有序集合:
ZADD zset1 1 "one" 2 "two" 3 "three"
ZADD zset2 2 "two" 3 "three" 4 "four"
计算 zset1 和 zset2 的交集,并将结果存储到 zset_intersection (下面的2表示有两个集合)
ZINTERSTORE zset_intersection 2 zset1 zset2
为了将votes中的zset数据, 跟key: groups:group_name的set数据做交集, 并且需要将set中的权重值降为0, 将zset中的数据权重设置为1, 可以使用如下命令:
ZINTERSTORE zset:groups:group_name 2 zset-votes set-groups:group_name weights 1 0
于是就有了上述表格中的数据. 此时你就可以携带组的id, 来get到对应组的文章列表啦, 又由于现在使用的是zset的结构的组, 因此你可以基于分数给这个组里面的内容进行排序, 代码如下:
def get_group_products(redis_conn, group_id, page):
group_prefix = 'score:groups:'
group_key = group_prefix + str(group_id)
if not redis_conn.exists(group_key) :
redis_conn.zinterstore(group_key,["votes:","groups:"+str(group_id)], aggregate="max")
# 60秒过期
redis_conn.expire(group_key, 60)
# 复用之前的函数
return get_product_votes_by_page_zset(redis_conn, page, group_key)
# 分页并排序获取zset的数据
def get_product_votes_by_page_zset(redis_conn,page, key):
# 假设一页10个数据
start = (page - 1) * 10
end = start + 10 - 1
# 通过zset表来获取所有的产品item(item类似于product:00000002,直接就是信息表的key)
items = redis_conn.zrevrange(key, start, end, withscores=True)
products = []
for item, score in items:
# 一个dict
product = redis_conn.hgetall(item)
# 添加id
product['id'] = item.split(':')[1]
# 添加votes
product['score'] = score
products.append(product)
return products
注意下面这段代码的逻辑 :
redis_conn.zinterstore(group_key,["votes:","groups:"+str(group_id)], aggregate="max")
这段代码中是取得votes这个zset和groups:group_id这个set中的最大值, 其中groups:group_id所有成员分值被视为1, 因此你需要注意, 所有的新加入到这个votes中的分值按理来说应该做出一些调整, 例如默认的votes分值为1, 但是这好像并不符合逻辑, 因为默认的投票数应该为0, 因此在votes的投票数, 更应该换成某种计算的公式, 例如: 对应的scores: 应该为f(x), 这个x就是对应的产品id.
但是也请记住, 我们上述的办法仅仅只是获得一个临时的数据, 也就是说, 这个基于分组的数据, 排序或者排行, 至少经过60s才会统计以一次. 并且一个产品会拥有多个组, 更新一个产品的分数, 可能会需要更新多个组的分数. 那么你在跟新产品分数的时候, 通过使用zinterstore去更新分组的分数, 如果在数据量比较大的时候, 这是一个非常耗时的操作. 因此才会设置这个缓存操作, 同时也可以适当延迟这个缓存.
反对票
光能支持还不够, 有支持就有反对, 因此反对的逻辑该如何思考? 既然是反对, 我们可以考虑在发对的时候, 对votes:的zset集合的票数分值-1即可, 如果你是基于的函数进行的分值的计算, 那么在函数的逻辑中, 加入对应的扣减分数的逻辑就行.
例如:
def product_vote(redis_conn, product_id, isvote=True):
# hincrby 命令用于将redis的哈希表 key 中域 field 的值加上增量 increment
# 第一个参数是哈希表的key,第二个参数是域,第三个参数是增量
product_prefix = 'product:'
# key
hash_key = product_prefix + str(product_id)
# filed
product_vote_field = 'votes'
vote = 0
if isvote:
vote = 1
else:
vote = -1
redis_conn.hincrby(hash_key, product_vote_field, vote)