标签系统¶
很多用户生成内容(UGC)的网站和应用都允许用户为他们提供的内容打标签。 举个例子,如果你在视频网站上传了一个Redis相关的视频,那么你可能会给这个视频加上“Redis”、“数据库”、“NoSQL”等标签,以便搜索这些标签的用户可以发现这个视频,而系统也可以基于标签将视频推荐给对这些标签感兴趣的用户。
网店、商城同样会大量使用标签,通过给不同的产品打上不同的标签,以此来让用户基于特定标签快速筛选自己的目标产品:比方说,一个热爱手机游戏的买家可能会更青睐带有“游戏手机”、“性能强劲”、“高清屏幕”等标签的手机,而一个打算给自己购买备用手机的买家则可能会更关注那些带有“性价比”、“耐用”、“超长待机”等标签的手机。
网络社区也会经常用到标签系统,很多社区都允许用户对他们自己或者别人发表的主题打标签,并基于标签对主题分门别类:比如在一个讨论Redis的主题里面,人们可能会为该主题打上“Redis”标签,而用户通过点击该标签就能够找到更多跟Redis相关的主题,诸如此类。
需求描述¶
你想要使用Redis创建一个标签系统,以便为系统中的内容打标签并进行分类。
解决方案¶
为了使用Redis构建标签系统,需要用到Redis的集合数据结构。
首先,对于每一个被打标签的目标对象(后简称“目标”),程序需要用一个集合来存储该目标的所有标签:跟列表相比,使用集合存储元素不仅不会出现重复,还带有支持快速增删元素、可以对元素执行集合运算等优点,因此集合毫无疑问地成为存储目标标签的最佳选择。
举个例子,如果想要给Redis数据库打上"Redis"
、"NoSQL"
和"Database"
三个标签,那么只需要执行以下命令即可:
redis> SADD Tag:target:Redis "Redis" "NoSQL" "Database"
(integer) 3
之后只需执行以下命令,就可以随时获取Redis数据库这一目标的所有标签:
redis> SMEMBERS Tag:target:Redis
1) "Redis"
2) "NoSQL"
3) "Database"
另一方面,由于我们有时候不仅想要通过目标获取与之对应的所有标签,还想要通过标签获取与之对应的所有目标。因此,除了需要用集合存储目标的所有标签之外,程序还需要用集合存储与特定标签关联的所有目标。
比如说,在执行上面展示的SADD
命令给Redis数据库打上三个标签之后,程序还需要执行以下命令序列,将Redis添加到这三个标签对应的目标集合中:
redis> SADD Tag:tag:Redis "Redis"
(integer) 1
redis> SADD Tag:tag:NoSQL "Redis"
(integer) 1
redis> SADD Tag:tag:Database "Redis"
(integer) 1
这样一来,程序不仅记录了目标与标签之间的关系,还记录了标签与目标之间的关系。有了后者,程序就可以随时根据标签找出与之对应的目标:
redis> SMEMBERS Tag:tag:Database
1) "Redis"
在此之上,程序还可以进一步扩展,基于集合运算找出同时带有多个标签的目标。比如在给多个数据库都打上标签之后,就可以基于集合运算找出同时带有多个指定标签的数据库。
实现代码¶
代码清单 CODE_TAG 展示了基于上述原理实现的标签系统程序。
代码清单 CODE_TAG 标签系统程序 tag.py
def make_target_key(target):
"""
目标的标签集合,用于记录目标关联的所有标签。
"""
return "Tag:target:{}".format(target)
def make_tag_key(tag):
"""
标签的目标集合,用于记录带有该标签的所有目标。
"""
return "Tag:tag:{}".format(tag)
class Tag:
def __init__(self, client):
self.client = client
def add(self, target, tags):
"""
尝试为目标添加任意多个标签,并返回成功添加的标签数量作为结果。
"""
tx = self.client.pipeline()
# 将target添加至每个给定tag对应的集合中
for tag_key in map(make_tag_key, tags):
tx.sadd(tag_key, target)
# 将所有给定tag添加至target对应的集合中
target_key = make_target_key(target)
tx.sadd(target_key, *tags)
return tx.execute()[-1] # 返回成功添加的标签数量
def remove(self, target, tags):
"""
尝试移除目标带有的任意多个标签,并返回成功移除的标签数量作为结果。
"""
tx = self.client.pipeline()
# 从每个给定tag对应的集合中移除target
for tag_key in map(make_tag_key, tags):
tx.srem(tag_key, target)
# 从target对应的集合中移除所有给定tag
target_key = make_target_key(target)
tx.srem(target_key, *tags)
return tx.execute()[-1] # 返回成功移除的标签数量
def get_tags_by_target(self, target):
"""
获取目标的所有标签。
"""
target_key = make_target_key(target)
return self.client.smembers(target_key)
def get_target_by_tags(self, tags):
"""
根据给定的任意多个标签找出同时带有这些标签的目标。
"""
# 找出所有给定tag对应的集合,然后对它们执行交集运算
tag_keys = map(make_tag_key, tags)
return self.client.sinter(*tag_keys)
作为例子,以下代码演示了如何使用这个程序对Redis、MongoDB和MySQL这三个数据库添加标签,然后基于标签搜索对应的数据库:
>>> from redis import Redis
>>> from tag import Tag
>>> client = Redis(decode_responses=True)
>>> tag = Tag(client)
>>> tag.add("Redis", {"Redis", "NoSQL", "Database"}) # 为数据库添加标签
3
>>> tag.add("MongoDB", {"MongoDB", "NoSQL", "Database"})
3
>>> tag.add("MySQL", {"MySQL", "SQL", "Database"})
3
>>> tag.get_tags_by_target("Redis") # 根据数据库查找标签
{'Redis', 'Database', 'NoSQL'}
>>> tag.get_target_by_tags({"NoSQL"}) # 根据标签查找数据库
{'Redis', 'MongoDB'}
>>> tag.get_target_by_tags({"Database"})
{'Redis', 'MongoDB', 'MySQL'}
>>> tag.get_target_by_tags({"Database", "SQL"}) # 根据多个标签查找数据库
{'MySQL'}
可以看到,程序不仅可以基于数据库查找与之对应的标签,还可以基于标签查找与之对应的数据库:比如基于数据库"Redis"
找出它对应的三个标签"Redis"
、"Database"
和"NoSQL"
,又或者基于标签"Database"
和"SQL"
,找出同时带有这两个标签的数据库"MySQL"
。
扩展实现:为标签查找功能加上缓存¶
尽管get_tags_by_target()
方法使用起来非常简单,但它实际上执行的却是非常复杂的交集运算:计算涉及的集合数量越多,集合包含的元素数量越多,这个操作执行所需的时间也会越多。
为了改善这个问题,可以修改get_tags_by_target()
方法为其加上缓存特性,具体方法如下:
在计算给定标签集合的交集时,使用
SINTERSTORE
命令而不是SINTER
命令,将计算结果保存到指定的键里面用作缓存,而不是直接返回结果元素。使用
EXPIRE
命令为缓存键设置过期时间,让缓存可以在指定的时限内持续生效,并在超过指定时限之后自动过期,从而强制更新缓存。方法在每次执行的时候,都会先尝试从缓存里面寻找结果,如果找到则直接使用缓存中的结果,只有在未找到的时候才会实际地执行交集计算。
代码清单 CODE_CACHED 展示了带缓存特性的get_cached_target_by_tags()
方法的具体实现。
代码清单 CODE_CACHED 带缓存的标签查找方法 tag.py
CACHE_TTL = 60
def make_cached_targets_key(tags):
"""
缓存多标签交集运算结果的集合。
"""
# 使用sorted确保多个集合输入无论如何排列都会产生相同的缓存键
return "Tag:cached_targets:{}".format(sorted(tags))
class Tag:
def get_cached_target_by_tags(self, tags):
"""
缓存版本的get_target_by_tags()方法,结果最多每分钟刷新一次。
"""
# 尝试直接从缓存中获取给定标签的交集结果……
cache_key = make_cached_targets_key(tags)
cached_targets = self.client.smembers(cache_key)
if cached_targets != set():
return cached_targets # 返回缓存结果
# 缓存不存在……
# 那么先计算并储存交集,然后再设置过期时间,最后再返回交集元素
tx = self.client.pipeline()
tx.sinterstore(cache_key, map(make_tag_key, tags))
tx.expire(cache_key, CACHE_TTL)
tx.smembers(cache_key)
return tx.execute()[-1] # 执行事务并返回交集元素
带缓存的get_cached_target_by_tags()
方法的用法跟无缓存版本完全一样,唯一的区别在于,在缓存更新之前,即使输入集合的元素发生变化,该方法返回的结果也不会变化:
>>> from redis import Redis
>>> from tag import Tag
>>> client = Redis(decode_responses=True)
>>> tag = Tag(client)
>>> tag.add("Redis", {"DB"}) # 有三个数据库带有DB标签
1
>>> tag.add("MySQL", {"DB"})
1
>>> tag.add("PostgreSQL", {"DB"})
1
>>> tag.get_cached_target_by_tags({"DB"}) # 生成并返回缓存
{'PostgreSQL', 'Redis', 'MySQL'}
>>> tag.add("MongoDB", {"DB"}) # 加入第四个带有DB标签的数据库
1
>>> tag.get_cached_target_by_tags({"DB"}) # 复用已缓存结果
{'PostgreSQL', 'Redis', 'MySQL'}
>>> tag.get_cached_target_by_tags({"DB"}) # 缓存更新
{'PostgreSQL', 'Redis', 'MongoDB', 'MySQL'}
重点回顾¶
标签系统可以为系统中的内容打标签并进行分类,无论是用户生成内容网站,还是网店、商城和网络社区,都会大量地使用这一系统。
为了实现标签系统,程序需要为带标签的每个目标创建一个集合,用于记录目标带有的所有标签;与此同时,还需要为每个标签都创建一个集合,用于记录所有带有该标签的目标。
标签系统中根据多个标签查找目标的操作看上去简单,但实际上却涉及复杂的交集运算,因此可以为其设置缓存以复用运算结果并缩短操作的响应时间。