标签系统

很多用户生成内容(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()方法为其加上缓存特性,具体方法如下:

  1. 在计算给定标签集合的交集时,使用SINTERSTORE命令而不是SINTER命令,将计算结果保存到指定的键里面用作缓存,而不是直接返回结果元素。

  2. 使用EXPIRE命令为缓存键设置过期时间,让缓存可以在指定的时限内持续生效,并在超过指定时限之后自动过期,从而强制更新缓存。

  3. 方法在每次执行的时候,都会先尝试从缓存里面寻找结果,如果找到则直接使用缓存中的结果,只有在未找到的时候才会实际地执行交集计算。

代码清单 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'}

重点回顾

  • 标签系统可以为系统中的内容打标签并进行分类,无论是用户生成内容网站,还是网店、商城和网络社区,都会大量地使用这一系统。

  • 为了实现标签系统,程序需要为带标签的每个目标创建一个集合,用于记录目标带有的所有标签;与此同时,还需要为每个标签都创建一个集合,用于记录所有带有该标签的目标。

  • 标签系统中根据多个标签查找目标的操作看上去简单,但实际上却涉及复杂的交集运算,因此可以为其设置缓存以复用运算结果并缩短操作的响应时间。