|
| 1 | ++++ |
| 2 | +date = '2025-08-26T8:00:00+08:00' |
| 3 | +draft = false |
| 4 | +title = 'Redis Set' |
| 5 | +tags = ['Redis', 'Database'] |
| 6 | ++++ |
| 7 | + |
| 8 | +Redis 的集和 set 键允许用户将任意多个不同的元素存储到集和中, 既可以是文本数据, 也可以是二进制数据. 其与列表有以下两个明显的区别: |
| 9 | +- 列表可以存储重复元素, 而集和只存储非重复元素 |
| 10 | +- 列表以有序方式存储元素, 而集和则以无序方式存储元素 |
| 11 | + |
| 12 | +下面介绍结合键的各个命令 |
| 13 | + |
| 14 | +## Set 集和 |
| 15 | + |
| 16 | +- SADD: 将元素添加到集和 |
| 17 | + ``` |
| 18 | + SADD set element [element ...] |
| 19 | + ``` |
| 20 | + 返回成功添加的新元素数量作为返回值, 由于集和不存储相同元素, 所以会自动忽略重复的元素 |
| 21 | +
|
| 22 | +- SREM: 从集和中移出元素 |
| 23 | + ``` |
| 24 | + SREM set element [element ...] |
| 25 | + ``` |
| 26 | + 返回被移除的元素数量, 同样的, 不存在的元素会被忽略 |
| 27 | +
|
| 28 | +- SMOVE: 将元素从一个集和移动到另一个集和 |
| 29 | + ``` |
| 30 | + SMOVE source target element |
| 31 | + ``` |
| 32 | + 移动操作成功时返回1, 若不存在于源集和, 返回0. |
| 33 | + 如果 source 的元素不存在, 则返回0表示失败. |
| 34 | + 如果 target 的元素已存在, 则会覆盖该元素. 从结果来看, 并不会导致 target 中元素变化, 但是会导致 source 中的该元素消失. |
| 35 | +
|
| 36 | +- SMEMBERS: 获取集和包含的所有元素 |
| 37 | + ``` |
| 38 | + SMEMBERS set |
| 39 | + ``` |
| 40 | + 由于集和是无序的, 且 SMEMBERS 命令不会进行任何排序操作, 所以根据元素添加的顺序不同, 含相同元素的集和执行该命令结果可能不同. |
| 41 | +
|
| 42 | +- SCARD: 获取集和包含的元素数量 |
| 43 | + ``` |
| 44 | + SCARD set |
| 45 | + ``` |
| 46 | +
|
| 47 | +- SISMEMBER: 检查给定元素是否存在于集和 |
| 48 | + ``` |
| 49 | + SISMEMBER set element |
| 50 | + ``` |
| 51 | + 返回1表示给定的元素存在于集和中, 返回0表示不存在于集和中. |
| 52 | +
|
| 53 | +
|
| 54 | +### 示例: 唯一计数器 |
| 55 | +例如, 一个网站想要统计浏览量和用户量 |
| 56 | +- 流览量可以使用是网页被用户访问的次数, 一个用户可以多次访问. 这种类型的数量使用字符串键或者散列键都可以实现 |
| 57 | +- 用户数量是访问网站的 IP 地址数量, 这时候就需要构建一个更加严格的计数器, 对每个 IP 地址进行一次次数, 这种计数器就是唯一计数器(unique counter) |
| 58 | +``` |
| 59 | +from redis import Redis |
| 60 | + |
| 61 | +class UniqueCounter: |
| 62 | + def __init__(self, client, key): |
| 63 | + self.client = client |
| 64 | + self.key = key |
| 65 | + def count_in(self, item): |
| 66 | + return self.client.sadd(self.key, item) |
| 67 | + def get_result(self): |
| 68 | + return self.client.scard(self.key) |
| 69 | + |
| 70 | +client = Redis(decode_responses=True) |
| 71 | +counter = UniqueCounter(client, "ip counter") |
| 72 | +print("Add ip", counter.count_in("8.8.8.8")) |
| 73 | +print("Add ip", counter.count_in("9.9.9.9")) |
| 74 | +print("Add ip", counter.count_in("10.10.10.10")) |
| 75 | + |
| 76 | +print("Numbers of IP:", counter.get_result()) |
| 77 | +``` |
| 78 | +
|
| 79 | +
|
| 80 | +### 示例: 点赞 |
| 81 | +点赞功能可以使用集和来实现, 保证了每个用户对同一个内容只能点1次赞 |
| 82 | +``` |
| 83 | +from redis import Redis |
| 84 | + |
| 85 | +class Like: |
| 86 | + def __init__(self, client, key): |
| 87 | + self.client = client |
| 88 | + self.key = key |
| 89 | + |
| 90 | + def cast(self, user): |
| 91 | + """执行点赞 True/False""" |
| 92 | + return self.client.sadd(self.key, user) |
| 93 | + |
| 94 | + def undo(self, user): |
| 95 | + """取消点赞""" |
| 96 | + self.client.srem(self.key, user) |
| 97 | + |
| 98 | + def is_liked(self, user): |
| 99 | + """是否已点赞""" |
| 100 | + return self.client.sismember(self.key, user) |
| 101 | + |
| 102 | + def get_all_liked_users(self): |
| 103 | + """所有点赞用户""" |
| 104 | + return self.client.smembers(self.key) |
| 105 | + |
| 106 | + def count(self): |
| 107 | + """点赞人数""" |
| 108 | + return self.client.scard(self.key) |
| 109 | + |
| 110 | +client = Redis(decode_responses=True) |
| 111 | +like_topic = Like(client, "topic::10086::like") |
| 112 | + |
| 113 | +print("Peter like:", like_topic.cast("peter")) |
| 114 | +print("Mary like:", like_topic.cast("mary")) |
| 115 | +print("Liked Users:", like_topic.get_all_liked_users()) |
| 116 | +print("How many likes:", like_topic.count()) |
| 117 | +print("Peter liked:", like_topic.is_liked("peter")) |
| 118 | +print("Dan liked:", like_topic.is_liked("dan")) |
| 119 | +``` |
| 120 | +
|
| 121 | +
|
| 122 | +### 示例: 投票 |
| 123 | +问答网站、文章推荐网、论坛这类注重内容质量的网站上通常会提供投票功能, 用户可以通过投票来支持一项内容或者反对一项内容: |
| 124 | +- 支持票越多的文章, 会被网站安排到更显眼的位置, 使得网站的用户快速流览高质量内容. |
| 125 | +- 反对票越多的文章, 则会被放到更不明显的位置, 甚至被当作广告隐藏起来, 使得用户可以忽略这些低质量内容. |
| 126 | +
|
| 127 | +例如 Stackoverflow 上面会对回答的答案进行投票, 帮助用户发现高质量的问题和答案. |
| 128 | +
|
| 129 | +```Python |
| 130 | +from redis import Redis |
| 131 | +
|
| 132 | +def vote_up_key(vote_target): |
| 133 | + """赞成 vote_target 用户集和 key""" |
| 134 | + return vote_target + "::vote_up" |
| 135 | +
|
| 136 | +def vote_down_key(vote_target): |
| 137 | + """反对 vote_target 用户集和 key""" |
| 138 | + return vote_target + "::vote_down" |
| 139 | +
|
| 140 | +class Vote: |
| 141 | + def __init__(self, client, vote_target): |
| 142 | + self.client = client |
| 143 | + self.vote_up_set = vote_up_key(vote_target) |
| 144 | + self.vote_down_set = vote_down_key(vote_target) |
| 145 | +
|
| 146 | + def is_voted(self, user): |
| 147 | + """检查用户是否已投过票""" |
| 148 | + return self.client.sismember(self.vote_up_set, user) or self.client.sismember(self.vote_down_set, user) |
| 149 | +
|
| 150 | + def vote_up(self, user): |
| 151 | + """user 投赞成票""" |
| 152 | + if self.is_voted(user): |
| 153 | + return False |
| 154 | + self.client.sadd(self.vote_up_set, user) |
| 155 | + return True |
| 156 | +
|
| 157 | + def vote_down(self, user): |
| 158 | + """user 投反对票""" |
| 159 | + if self.is_voted(user): |
| 160 | + return False |
| 161 | + self.client.sadd(self.vote_down_set, user) |
| 162 | + return True |
| 163 | +
|
| 164 | + def undo(self, user): |
| 165 | + """取消用户投票""" |
| 166 | + self.client.srem(self.vote_up_set, user) |
| 167 | + self.client.srem(self.vote_down_set, user) |
| 168 | +
|
| 169 | + def vote_up_count(self): |
| 170 | + """赞成票的数量""" |
| 171 | + return self.client.scard(self.vote_up_set) |
| 172 | +
|
| 173 | + def get_all_vote_up_users(self): |
| 174 | + """所有投赞成票的用户""" |
| 175 | + return self.client.smembers(self.vote_up_set) |
| 176 | +
|
| 177 | + def vote_down_count(self): |
| 178 | + """反对票的数量""" |
| 179 | + return self.client.scard(self.vote_down_set) |
| 180 | +
|
| 181 | + def get_all_vote_down_users(self): |
| 182 | + """所有投反对票的用户""" |
| 183 | + return self.client.smembers(self.vote_down_set) |
| 184 | +
|
| 185 | +client = Redis(decode_responses=True) # 是否将字节数据自动解码额日字符串 |
| 186 | +question_vote = Vote(client, "question::10") |
| 187 | +print("Peter 投支持票:", question_vote.vote_up("peter")) |
| 188 | +print("Jack 投支持票:", question_vote.vote_up("jack")) |
| 189 | +print("Tom 投支持票:", question_vote.vote_up("tom")) |
| 190 | +print("Mary 投反对票:", question_vote.vote_down("mary")) |
| 191 | +
|
| 192 | +print("支持票数量:", question_vote.vote_up_count()) |
| 193 | +print("反对票数量:", question_vote.vote_down_count()) |
| 194 | +
|
| 195 | +print("支持票用户:", question_vote.get_all_vote_up_users()) |
| 196 | +print("反对票用户:", question_vote.get_all_vote_down_users()) |
| 197 | +
|
| 198 | +# 取消用户投票(为了多次运行代码) |
| 199 | +question_vote.undo("peter") |
| 200 | +question_vote.undo("jack") |
| 201 | +question_vote.undo("tom") |
| 202 | +question_vote.undo("mary") |
| 203 | +``` |
| 204 | + |
| 205 | +### 示例: 社交关系 |
| 206 | +Twitter 这类社交软件都可以通过关注或者加好友的方式, 构成一种社交关系. 这些网站上的用户都可以关注其他用户, 也可以被其他用户关注. 通过正在关注名单(following list), 用户可以查看自己正在关注的用户及其人数; 通过关注者名单(follower list), 用户可以查看有哪些人正在关注自己. |
| 207 | + |
| 208 | +下面使用集和来维护这种关系: |
| 209 | +- 程序为每个用户维护两个集和: 一个集和存储用户的正在关注名单, 另一个集和存储用户的关注者名单. |
| 210 | +- 当 A 关注 B 的时候, 将 A 加入自己的 following list, 并加入 B 的follower list. |
| 211 | +- 当 A 取消对 B 的关注的时候, 将 A 从自己的 following list 移出, 并将 A 从 B 的 follower list 移除. |
| 212 | + |
| 213 | +```Python |
| 214 | +def following_key(user): |
| 215 | + return user + "::following" |
| 216 | + |
| 217 | +def follower_key(user): |
| 218 | + return user + "::follower" |
| 219 | + |
| 220 | +class Relationship: |
| 221 | + def __init__(self, client, user): |
| 222 | + self.client = client |
| 223 | + self.user = user |
| 224 | + |
| 225 | + def follow(self, target): |
| 226 | + """关注目标用户""" |
| 227 | + user_following_set = following_key(self.user) |
| 228 | + self.client.sadd(user_following_set, target) |
| 229 | + |
| 230 | + target_follower_set = follower_key(target) |
| 231 | + self.client.sadd(target_follower_set, self.user) |
| 232 | + |
| 233 | + def unfollow(self, target): |
| 234 | + """取消关注目标用户""" |
| 235 | + user_following_set = following_key(self.user) |
| 236 | + self.client.srem(user_following_set, target) |
| 237 | + |
| 238 | + target_follower_set = follower_key(target) |
| 239 | + self.client.srem(target_follower_set, self.user) |
| 240 | + |
| 241 | + def is_following(self, target): |
| 242 | + """是否关注了目标用户""" |
| 243 | + user_following_set = following_key(self.user) |
| 244 | + return self.client.sismember(user_following_set, target) |
| 245 | + |
| 246 | + def get_all_following(self): |
| 247 | + """所有user关注的用户""" |
| 248 | + user_following_set = following_key(self.user) |
| 249 | + return self.client.smembers(user_following_set) |
| 250 | + |
| 251 | + def get_all_follower(self): |
| 252 | + """所有关注user的用户""" |
| 253 | + user_follower_set = follower_key(self.user) |
| 254 | + return self.client.smembers(user_follower_set) |
| 255 | + |
| 256 | + def count_following(self): |
| 257 | + """user关注的用户数量""" |
| 258 | + user_following_set = following_key(self.user) |
| 259 | + return self.client.scard(user_following_set) |
| 260 | + |
| 261 | + def count_follower(self): |
| 262 | + """关注user的用户数量""" |
| 263 | + user_follower_set = follower_key(self.user) |
| 264 | + return self.client.scard(user_follower_set) |
| 265 | +``` |
| 266 | + |
| 267 | +- SRANDMEMBER: 随机获取集和中的元素 |
| 268 | + ``` |
| 269 | + SRANDMEMBER set [count] |
| 270 | + ``` |
| 271 | + 该命令接受一个可选的 count 参数, 用于指定用户想要获取的元素数量. 默认只返回一个元素. |
| 272 | + 如果 count 为正数, 将返回 count 个不重复的元素. 当 count 值大于集的元素数量, 将返回集和所有元素. |
| 273 | + 如果 count 为负数, 则随机返回 abs(count) 个元素, 并且允许出现重复值. |
| 274 | +
|
| 275 | +- SPOP: 随机地从集和中移出指定数量的元素 |
| 276 | + ``` |
| 277 | + SPOP key [count] |
| 278 | + ``` |
| 279 | + 该命令会返回被移除的元素值作为命令的返回值. |
| 280 | + count 参数不同于 SRANDMEMBER 命令的参数, 其值只能为正数 |
| 281 | +
|
| 282 | +
|
| 283 | +### 示例: 抽奖 |
| 284 | +为了推销产品并回馈消费者, 商家经常举办一些抽奖活动, 消费者可以抽奖获取礼品. 下面代码展示了使用集和实现的抽象程序, 这个成会把所有参与抽奖的玩家都添加到一个集和中, 然后通过 SRANDMEMBER 命令随机地选出获奖者. |
| 285 | +```Python |
| 286 | +class Lottery: |
| 287 | + def __init__(self, client, key): |
| 288 | + self.client = client |
| 289 | + self.key = key |
| 290 | +
|
| 291 | + def add_player(self, user): |
| 292 | + """添加用户到抽奖名单中""" |
| 293 | + self.client.sadd(self.key, user) |
| 294 | +
|
| 295 | + def get_all_players(self): |
| 296 | + """返回参加抽奖活动的所有用户""" |
| 297 | + return self.client.smembers(self.key) |
| 298 | +
|
| 299 | + def player_count(self): |
| 300 | + """返回抽奖用户数量""" |
| 301 | + return self.client.scard(self.key) |
| 302 | +
|
| 303 | + def draw(self, number): |
| 304 | + """抽取指定数量的获奖者""" |
| 305 | + return self.client.srandmember(self.key, number) |
| 306 | +``` |
| 307 | +考虑到完整的抽奖者名单可能会有用, 所以这个抽奖程序使用了随机获取元素的 SRANDMEMBER 命令, 而不是随机移除元素的 SPOP 命令. 如果不需要保留完整的名单, 也可以使用 SPOP 命令实现抽奖程序. |
| 308 | + |
| 309 | +- SINTER、SINTERSTORE: 对集和执行交集计算 |
| 310 | + ``` |
| 311 | + SINTER set [set ...] |
| 312 | + ``` |
| 313 | + 该命令计算用户给定的所有集和的交集, 返回交集的所有元素. |
| 314 | + 此外, 还有 SINTERSTORE 命令, 将集和的交集计算结果存储到指定的键里面. |
| 315 | + ``` |
| 316 | + SINTERSTORE destination_key set [set ...] |
| 317 | + ``` |
| 318 | + 如果给定的键已存在, 则 SINTERSTORE 命令结果会覆盖原来的集和键 |
| 319 | +
|
| 320 | +- SUNION、SUNIONSTORE: 对集和执行并集计算 |
| 321 | + ``` |
| 322 | + SUNION set [set ...] |
| 323 | + ``` |
| 324 | + 并集计算类似上面的交集计算 |
| 325 | +
|
| 326 | +- SDIFF、SDIFFSTORE: 对集和执行差集计算 |
| 327 | + ``` |
| 328 | + SDIFF set [set ...] |
| 329 | + ``` |
| 330 | + SDIFF 命令会安装用户给定集和的顺序, 从左到右依次对给定的集和执行差集计算. |
| 331 | +
|
| 332 | +> 因为对集合执行交集、并集、差集等集合计算需要耗费大量的资源, 所以用户应该尽量使用SINTERSTORE等命令来存储并重用计算结果, 而不要每次都重复进行计算. |
| 333 | +> 此外, 当集合计算涉及的元素数量非常大时, Redis服务器在进行计算时可能会被阻塞. |
| 334 | +> 这时, 可以考虑使用Redis的复制功能, 通过从服务器来执行集合计算任务, 从而确保主服务器可以继续处理其他客户端发送的命令请求. |
| 335 | +
|
| 336 | +- 共同关注与推荐关注 |
| 337 | +前面使用集和实现了社交网站好友关系的存储, 即关注和被关注列表. 除此之外, 社交网站还通常会提供一些额外功能, 例如共同关注, 推荐关注等. |
| 338 | +要实现共同关注功能, 程序需要计算出两个用户正在关注集和之间的交集. |
| 339 | +推荐关注可以从用户关注集和中, 随机选出指定数量的用户作为种子用户, 然后对这些用户的正在管组集和执行并集计算, 最后从这个并集中随机选出一些推荐关注的对象. |
| 340 | +
|
| 341 | +### 示例: 使用反向索引构建商品筛选器 |
| 342 | +在访问购物类网站的时候, 通常可以通过一些标签来筛选产品. 这时候, 对每个产品可以建立一个集和, 对每个标签也都建立一个集和, 这样就得到了一份物品到关键字, 以及关键字到物品的映射关系. |
| 343 | +```Python |
| 344 | +def make_item_key(item): |
| 345 | + return "InvertedIndex::" + item + "::keyword" |
| 346 | +
|
| 347 | +def make_keyword_key(keyword): |
| 348 | + return "InvertedIndex::" + keyword + "::item" |
| 349 | +
|
| 350 | +class InvertedIndex: |
| 351 | + def __init__(self, client): |
| 352 | + self.client = client |
| 353 | +
|
| 354 | + def add_index(self, item, *keywords): |
| 355 | + """为物品添加关键字""" |
| 356 | + # 将给定物品添加到 |
| 357 | + item_key = make_item_key(item) |
| 358 | + result = self.client.sadd(item_key, *keywords) |
| 359 | + # 遍历关键字集和, 将该物品添加进去 |
| 360 | + for keyword in keywords: |
| 361 | + keyword_key = make_keyword_key(keyword) |
| 362 | + self.client.sadd(keyword_key, item) |
| 363 | + # 返回添加关键字数量作为结果 |
| 364 | + return result |
| 365 | +
|
| 366 | + def remove_index(self, item, *keywords): |
| 367 | + """移除物品的关键字""" |
| 368 | + item_key = make_item_key(item) |
| 369 | + result = self.client.srem(item_key, *keywords) |
| 370 | + for keyword in keywords: |
| 371 | + keyword_key = make_keyword_key(keyword) |
| 372 | + self.client.srem(keyword_key, item) |
| 373 | + return result |
| 374 | +
|
| 375 | + def get_keywords(self, item): |
| 376 | + """获取物品所有的关键字""" |
| 377 | + return self.client.smembers(make_item_key(item)) |
| 378 | +
|
| 379 | + def get_items(self, *keywords): |
| 380 | + """根据给定的关键字获取物品""" |
| 381 | + # 根据给定的关键字计算出与之对应的集合 key |
| 382 | + keyword_key_list = map(make_keyword_key, keywords) |
| 383 | + # 将这些集和 key 做并集 |
| 384 | + return self.client.sinter(*keyword_key_list) |
| 385 | +``` |
0 commit comments