Redis 的 ZUNIONSTORE 命令和 ZINTERSTORE 命令的另一种用法

ZUNIONSTOREZINTERSTORE 是用于有序集(sorted set)的聚合命令。

根据文档的说明:

  • ZUNIONSTORE 用于计算给定的一个或多个有序集的并集。
  • ZINTERSTORE 则用于计算给定的一个或多个有序集的交集。

除此之外, 这两个命令还有一种没有记载于文档的用法, 那就是 —— 这两个命令除了可以用于有序集之外,还可以用于集合(set)。

例子:使用 ZUNIONSTORE 和 ZINTERSTORE 计算爱好水果的并集和交集

作为例子, 下文展示了怎样用 ZUNIONSTOREZINTERSTORE 命令计算爱好水果的并集和交集。

首先,输入一些测试数据(注意,输入的三个键都是集合,并非有序集):

redis> SADD huangz orange banana tomato
(integer) 3

redis> SADD peter apple strawberry orange banana
(integer) 4

redis> SADD john lemon tomato orange apple
(integer) 4

接着,用 ZUNIONSTORE 命令计算三人爱好水果的并集:

redis> ZUNIONSTORE favorite-fruits-union 3 huangz peter john
(integer) 6

redis> ZRANGE favorite-fruits-union 0 -1 WITHSCORES
1) "lemon"
2) "1"
3) "strawberry"
4) "1"
5) "apple"
6) "2"
7) "banana"
8) "2"
9) "tomato"
10) "2"
11) "orange"
12) "3"

ZUNIONSTORE 的计算结果非常有用,它清晰地展示了输入的三个集合中,不同的水果出现了多少次。

相比之下, SUNION 命令的结果就显得单薄很多了:

redis> SUNION huangz peter john
1) "tomato"
2) "banana"
3) "apple"
4) "orange"
5) "lemon"
6) "strawberry"

SUNION 的结果只显示计算并集所得的元素,但并不显示元素在输入集合中出现了多少次。

ZUNIONSTORESUNION 两个命令的对比中可以看出:

  • 如果只是处理集合之间的关系,那么 SUNION 已经足够。
  • 另一方面,如果不仅要处理集合之间的关系,还需要对集合元素进行聚合计算,那么就必须使用 ZUNIONSTORE

三人爱好水果的并集可以通过 ZINTERSTORE 命令来计算:

redis> ZINTERSTORE favorite-fruits-inter 3 huangz peter john
(integer) 1

redis> ZRANGE favorite-fruits-inter 0 -1 WITHSCORES
1) "orange"
2) "3"

可以看出, ZINTERSTORE 的用处并不如 ZUNIONSTORE 那么大 —— 因为只计算并集的话, 记录元素出现的次数是没有用的(并集元素出现的次数必然等于输入集合的个数), 所以计算并集实际上只使用 SINTER 或者 SINTERSTORE 就可以了:

redis> SINTER huangz peter john
1) "orange"

实例:使用集合和 ZUNIONSTORE 实现签到功能

上面展示的计算爱好水果的例子非常有趣, 而且也非常实用 —— 将水果换成物品、商品、书籍、等等, 就可以衍生出更多玩法, 应用到各种不同的应用中。

作为一个更详细的例子,让我们用集合和 ZUNIONSTORE 命令来实现一个签到功能。

什么是签到?

签到在很多网站都有不同的叫法,比如出勤、打卡,等等, 有些是由用户自己操作的, 而另一些则是由网站后台自动记录的, 不管如何, 签到功能都有以下共同特性:

  • 记录用户的某一行为(通常是登录,或者完成某项任务),并进行计数。
  • 为用户提供签到次数统计,比如说,用户界面会显示“你已经签到了 xx 次”,诸如此类。
  • 对所有用户的签到进行统计,并进行签到次数排名。

以下是 扇贝网 上的一个签到排名示例:

../../_images/top-checkin.png

签到功能的实现

以下 Python 代码实现了一些基本的签到功能, 其中用到了前面提到的, 将 ZUNIONSTORE 应用到集合上的技巧, 并且也用到了集合本身的命令, 比如 SINTERSTORE

#coding: utf-8

from redis import Redis

r = Redis()


"""
key maker
"""

def _sign_key(date):
    """
    生成签到日期的键,例如 2013-4-13::signed
    """
    return "{0}::signed".format(date)

def _sign_count_key(user):
    """
    生成用户签到计数器的键,例如 tom::sign_count
    """
    return "{0}::sign_count".format(user)


"""
api implement
"""

def sign(user, date):
    """
    用户在指定日期签到
    """
    # 注意,为了保持示例实现的简单性
    # 目前的这个实现不能防止一个用户在同一天多次签到
    # 解决这个问题需要使用事务或者 Lua 脚本
    r.incr(_sign_count_key(user))
    r.sadd(_sign_key(date), user)

def signed(user, date):
    """
    用户在指定日期已经签到了吗?
    """
    return r.sismember(_sign_key(date), user)

def sign_count_of(user):
    """
    返回给定用户的签到次数
    """
    count = r.get(_sign_count_key(user))

    if count is None:
        return 0
    else:
        return int(count)

def top_sign(top_sign_name, *dates):
    """
    将给定日期内的签到排名保存到 top_sign_name 键中,
    并从高到低返回排名结果。
    """
    date_keys = map(_sign_key, dates)
    r.zunionstore(top_sign_name, date_keys)
    return r.zrevrange(top_sign_name, 0, -1, withscores=True)

def full_sign(full_sign_name, *dates):
    """
    将给定日期内的全勤记录保存到 full_sign_name 键中,
    并返回全勤记录。
    """
    date_keys = map(_sign_key, dates)
    r.sinterstore(full_sign_name, date_keys)
    return r.smembers(full_sign_name)


"""
test
"""

if __name__ == "__main__":

    r.flushdb()


    # test sign() and signed()

    assert(
        not signed("tom", "2013-4-13")
    )

    sign("tom", "2013-4-13")

    assert(
        signed("tom", "2013-4-13")
    )

    r.flushdb()


    # test sign_count_of()

    assert(
        sign_count_of("tom") == 0
    )

    sign("tom", "2013-4-13")

    assert(
        sign_count_of("tom") == 1
    )

    sign("tom", "2013-4-14")

    assert(
        sign_count_of("tom") == 2
    )

    r.flushdb()


    # test top_sign() and full_sign()

    days = ["2013-4-13", "2013-4-14", "2013-4-15"]

    sign("tom", days[0])
    sign("peter", days[0])
    sign("john", days[0])

    sign("peter", days[1])
    sign("john", days[1])
    
    sign("peter", days[2])

    ts = top_sign("2013-4-13 to 2013-4-15 top sign", *days)
    assert(
        ts[0] == ("peter", 3.0) and
        ts[1] == ("john", 2.0)  and
        ts[2] == ("tom", 1.0)
    )

    assert(
        full_sign("2013-4-13 to 2013-4-15 full sign", *days) == set(["peter"])
    )

    r.flushdb()

多得 ZUNIONSTORE 可以直接用于集合, 不然实现这个签到功能就没有那么简单了:

  • 因为如果单纯使用集合命令,那么签到排名的实现就会非常复杂、而且低效。
  • 而如果直接使用有序集而不是集合来记录签到信息,又会浪费不少额外的空间:因为每个有序集元素都必须保存一个值为 1.0double 分值,更不必说 有序集消耗的空间本来就比集合消耗的空间要多 了。

关于这个技巧的说明

好的, 看过前面的两个例子 我想你已经熟悉 ZUNIONSTOREZINTERSTORE 处理集合这一用法了, 不过还有两件事要说明一下。

首先, ZUNIONSTOREZINTERSTORE 在处理集合时的功能和处理有序集时完全一样。

也即是说, ZUNIONSTOREZINTERSTORE 两个命令的各个选项, 比如 WEIGHTSAGGREGATE , 在处理集合时都是可以正常使用的。

关于这些选项的具体信息请参考 ZUNIONSTORE 的文档ZINTERSTORE 的文档

其次,使用 ZUNIONSTOREZINTERSTORE 处理集合的这个技巧只是 一个未写入文档的特性 , 它并不是一个废弃特性, 所以可以放心使用。

至少在目前的 2.6 、 2.8 和 3.0 (unstable)三个版本中,这个特性都是可以正常使用的。

底层实现

最后, 是时候揭晓谜题了 —— 为什么 ZUNIONSTOREZINTERSTORE 既可以对有序集进行处理, 又可以对集合进行处理?

原理是这样的: ZUNIONSTOREZINTERSTORE 两个命令在执行时, 都会对一个迭代器(iterator)进行处理, 而这个迭代器是一个多态迭代器:

  • 当输入的键是集合时,它就迭代集合。
  • 当如何的键是有序集时,它就迭代有序集。
../../_images/iterator.png

如果在迭代集合的时候, 用户没有通过 WEIGHTS 命令设置集合元素的分值(score), 那么 ZUNIONSTOREZINTERSTORE 就将集合元素的分值当作 1.0

也即是说, 当不使用 WEIGHTS 参数调用 ZUNIONSTORE 或者 ZINTERSTORE 两个命令时, 它们将集合输入当作分值都是 1.0 的有序集来看待。

以上就是 ZUNIONSTOREZINTERSTORE 既可以处理有序集,又可以处理集合的原因, 有兴趣知道完整信息的话, 可以参考 ZUNIONSTOREZINTERSTORE实现源码

huangz
2013.4.13

留言

comments powered by Disqus