Redis基础 -- pd的后端笔记
文章目录
-
- [Redis基础 -- pd的后端笔记](#Redis基础 -- pd的后端笔记)
- [Redis 是什么?为什么用它?------ 理解它的定位与核心价值](#Redis 是什么?为什么用它?—— 理解它的定位与核心价值)
-
- 连接Redis
-
- [🌰 实战例子:实现一个"访问计数器"](#🌰 实战例子:实现一个“访问计数器”)
- [Hash 类型 ------ 高效存储对象的利器](#Hash 类型 —— 高效存储对象的利器)
-
- [🌰 实战:用 Hash 存用户资料](#🌰 实战:用 Hash 存用户资料)
- [List 类型 ------ 队列、栈、最新动态的万能工具](#List 类型 —— 队列、栈、最新动态的万能工具)
-
- [🌰 场景 1:实现一个"最新评论"功能(保留最近 100 条)](#🌰 场景 1:实现一个“最新评论”功能(保留最近 100 条))
- [🌰 场景 2:轻量级消息队列(生产者-消费者模型)](#🌰 场景 2:轻量级消息队列(生产者-消费者模型))
- [Set 类型 ------ 无序、唯一、支持交并差](#Set 类型 —— 无序、唯一、支持交并差)
-
- [🌰场景 1:用户标签系统(自动去重)](#🌰场景 1:用户标签系统(自动去重))
- [🌰 场景 2:共同关注(社交网络经典问题)](#🌰 场景 2:共同关注(社交网络经典问题))
- [🌰 场景 3:随机抽奖 / 抽样](#🌰 场景 3:随机抽奖 / 抽样)
- [Sorted Set(ZSet)------ 带权重的有序集合](#Sorted Set(ZSet)—— 带权重的有序集合)
-
- [🌰 场景 1:游戏积分排行榜(Top 10)](#🌰 场景 1:游戏积分排行榜(Top 10))
- [🌰 场景 2:延迟队列(Delayed Queue)](#🌰 场景 2:延迟队列(Delayed Queue))
- [Bitmaps ------ 用 1 个 bit 表示状态,极致省空间!](#Bitmaps —— 用 1 个 bit 表示状态,极致省空间!)
-
- [🌰 场景 1:用户月度签到(30 天只需 30 bits ≈ 4 字节!)](#🌰 场景 1:用户月度签到(30 天只需 30 bits ≈ 4 字节!))
- [🌰 场景 2:统计某日活跃用户数(DAU)](#🌰 场景 2:统计某日活跃用户数(DAU))
- [🌰 场景 3:连续签到天数(结合 BITFIELD)](#🌰 场景 3:连续签到天数(结合 BITFIELD))
- [Geospatial ------ 用 5 行代码实现"附近的人"](#Geospatial —— 用 5 行代码实现“附近的人”)
-
- [🌰 场景:查找"我附近的咖啡店"](#🌰 场景:查找“我附近的咖啡店”)
- [HyperLogLog ------ 用 12KB 内存估算 10 亿 UV](#HyperLogLog —— 用 12KB 内存估算 10 亿 UV)
-
- [🌰 场景:统计网站每日 UV(独立访客)](#🌰 场景:统计网站每日 UV(独立访客))
- [🌰 高级用法:分渠道 UV 合并](#🌰 高级用法:分渠道 UV 合并)
Redis 是什么?为什么用它?------ 理解它的定位与核心价值
Redis(Remote Dictionary Server)是一个开源的、基于内存的键值存储系统(in-memory key-value store),常被用作数据库、缓存、消息中间件。
注意:它不是传统的关系型数据库(比如 MySQL),而是一个 NoSQL 数据库,数据结构非常灵活。
🧠 类比理解:Redis 像你的"超快笔记本"
想象你平时写代码时,有些计算结果或用户信息需要反复查。如果每次都去硬盘上的数据库(比如 MySQL)读,就像每次都要翻厚厚的档案柜------慢!
而 Redis 就像你放在手边的便签本:
- 写得快(内存操作)
- 查得快(O(1) 时间复杂度)
- 但容量有限(内存比硬盘小)
- 关机可能丢内容(默认不持久化,但可配置)
所以,Redis 最常见的角色是"缓存"------把热点数据临时存起来,加速应用。
| 特性 | 说明 |
|---|---|
| 极快 | 所有操作在内存中完成,读写可达 10w+ QPS |
| 丰富的数据结构 | 不只是字符串!支持 String、Hash、List、Set、Sorted Set、Bitmap、HyperLogLog 等 |
| 持久化支持 | 可通过 RDB(快照)或 AOF(日志)将数据保存到磁盘,防止重启丢失 |
| 高可用 & 分布式 | 支持主从复制、哨兵(Sentinel)、集群(Cluster) |
| 原子操作 | 所有命令都是单线程执行的,天然线程安全 |
💡 虽然 Redis 是单线程处理命令(6.0 之前),但因为内存操作极快 + 非阻塞 I/O,性能依然爆炸。
🌰 典型使用场景
- 缓存(最常见)
- 用户登录 session 缓存
- 商品详情页缓存(避免频繁查 DB)
- 排行榜 / 计数器
- 用 Sorted Set 实现点赞排行榜、游戏积分榜
- 分布式锁
- 利用 SET key value NX EX 实现跨服务的互斥操作
- 消息队列(轻量级)
- 用 List 的 LPUSH / BRPOP 模拟队列(但不如 RabbitMQ/Kafka 专业)
- 实时统计
- 用 HyperLogLog 估算 UV(独立访客数),节省内存
连接Redis
shell
python -m venv .venv --p=python3.12
pip install redis
- 测试链接
python
import redis
# 创建连接(默认本地、默认端口)
r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
# 测试
print(r.ping()) # 应该输出 True
- String 类型:不只是"字符串"
- 虽然叫 String,但 Redis 的 String 实际上是一个 二进制安全的字节数组,可以存:
- 普通文本("hello")
- JSON 字符串('{"name": "Alice"}')
- 数字("42")→ 并且支持原子增减!
- 甚至图片的 base64 编码(不推荐,内存贵!)
🛠️ 常用 String 命令(附 Python 对应)
| 功能 | Redis CLI | Python (redis-py) |
|---|---|---|
| 设置值 | SET key value |
r.set('key', 'value') |
| 获取值 | GET key |
r.get('key') |
| 不存在才设 | SET key value NX |
r.set('key', 'value', nx=True) |
| 设值并带过期时间 | SET key value EX 60 |
r.set('key', 'value', ex=60) |
| 数字自增 | INCR key |
r.incr('counter') |
| 数字自减 | DECR key |
r.decr('counter') |
| 自增指定步长 | INCRBY key 5 |
r.incrby('counter', 5) |
⏳ 3. 过期时间(TTL):缓存的灵魂
缓存不能永久存在,否则数据会陈旧。Redis 的 EX(秒)或 PX(毫秒)参数让你轻松设置过期:
py
# 缓存用户信息,5分钟后失效
r.set("user:1001", '{"name": "Alice", "age": 30}', ex=300)
# 查看剩余生存时间(单位:秒)
ttl = r.ttl("user:1001")
print(f"剩余时间:{ttl} 秒")
🔥 最佳实践:永远给缓存设置合理的过期时间,避免"缓存雪崩"(大量 key 同时过期导致 DB 瞬间压力暴增)。
🌰 实战例子:实现一个"访问计数器"
python
import redis
# 创建连接(默认本地、默认端口)
r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
def get_index(page):
r.incr(page)
print(f"当前 {page} 页面访问次数:{r.get(page)}")
# 访问一次index
get_index('index')
# 访问一次user
get_index('user')
# 访问一次index
get_index('index')
| 问题 | 解决方案 |
|---|---|
| 中文乱码 / 返回 b'xxx' | 连接时加 decode_responses=True |
| 忘记设过期时间,内存爆了 | 所有缓存必须设 TTL;可用 CONFIG SET maxmemory 限制最大内存 |
| 用 String 存大对象(如整张表) | 避免!考虑是否该用数据库,或拆分成多个 key |
| 多次 set 覆盖旧值 | 如果需要"仅当不存在时设置",用 nx=True(对应 SET ... NX) |
Hash 类型 ------ 高效存储对象的利器
假设你要存一个用户信息:
❌ 做法一:用 String 存整个 JSON
py
r.set("user:1001", '{"name":"Alice","email":"alice@example.com","age":30}')
问题:
- 想只改
email?必须先GET整个字符串 → 解析 → 修改 →SET回去(读-改-写三步) - 浪费带宽和 CPU(尤其对象大时)
- 无法对单个字段设过期时间(整个 key 一起过期)
✅ 做法二:用 Hash 存!
Redis 的 Hash 就像一个"字典里的字典":
- 外层 key:
user:1001 - 内层 field-value:
name → Alice,email → alice@example.com
🔧 Hash 核心命令(Redis CLI vs Python)
| 功能 | Redis CLI | Python (redis-py) |
|---|---|---|
| 设置单个字段 | HSET user:1001 name Alice |
r.hset("user:1001", "name", "Alice") |
| 设置多个字段 | HMSET user:1001 name Alice age 30 |
r.hset("user:1001", mapping={"name": "Alice", "age": 30}) |
| 获取单个字段 | HGET user:1001 name |
r.hget("user:1001", "name") |
| 获取所有字段 | HGETALL user:1001 |
r.hgetall("user:1001") |
| 判断字段是否存在 | HEXISTS user:1001 email |
r.hexists("user:1001", "email") |
| 删除字段 | HDEL user:1001 age |
r.hdel("user:1001", "age") |
| 获取字段数量 | HLEN user:1001 |
r.hlen("user:1001") |
🌰 实战:用 Hash 存用户资料
python
import redis
r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
# 1. 设置用户信息(一次性)
user_id = "1001"
r.hset(f"user:{user_id}", mapping={
"name": "Alice",
"email": "alice@example.com",
"age": "30" # 注意:Redis 所有值都是字符串!
})
# 2. 只更新邮箱(无需读取整个对象!)
r.hset(f"user:{user_id}", "email", "alice_new@example.com")
# 3. 获取姓名
name = r.hget(f"user:{user_id}", "name")
print("Name:", name) # 输出: Alice
# 4. 获取全部信息
user_data = r.hgetall(f"user:{user_id}")
print("Full data:", user_data)
# 输出: {'name': 'Alice', 'email': 'alice_new@example.com', 'age': '30'}
📊 Hash vs String(存对象)对比
| 维度 | String(存 JSON) | Hash |
|---|---|---|
| 内存占用 | 较高(JSON 有冗余字符如引号、冒号) | 更低(Redis 对小 Hash 有特殊编码优化) |
| 修改粒度 | 必须整体替换 | 支持字段级操作 |
| 网络开销 | 大对象传输成本高 | 只传变更字段 |
| 适用场景 | 对象很少变更 / 整体读写 | 对象字段频繁单独更新 |
🔍 内部优化:当 Hash 的 field 数量少且 value 不大时,Redis 会用 ziplist 编码(连续内存),比 hashtable 更省内存!
⚠️ 常见误区 & 最佳实践
❌ 误区 1:Hash 适合存超大对象(比如 1000 个字段)
- 问题:Hash 虽好,但不宜过大。一个 key 包含成千上万个 field 会导致:
- 单次 HGETALL 阻塞 Redis(因为单线程!)
- 内存碎片增加
✅ 建议:单个 Hash 控制在 几百个字段内。超大对象考虑拆分或用其他结构。
❌ 误区 2:Hash 的 field 可以设独立过期时间
- 事实:Redis 的过期是 key 级别的!整个 user:1001 过期,所有 field 一起消失。
- ✅ 如果需要字段级过期,得自己用额外 key + 定时清理(不推荐),或改用单独的 key。
✅ 最佳实践:命名规范
- 使用冒号分隔:user:1001, product:5001
- 保持一致性,方便后期管理或使用 Redis 模式匹配(如 KEYS user:*)
✅ 本讲小练习
请用 Hash 完成以下任务:
- 创建一个商品 product:2001,包含字段:name="iPhone", price="5999", stock="1"。
- 将库存减少 1(模拟购买),不要先 get 再 set,直接用 HINCRBY。
- 查询当前库存。
- 删除 price 字段(假设促销时隐藏价格)。
py
# %% hash练习
r.hset("product:2001", mapping={
"name": "iPhone 13",
"price": "5999.00",
"stock": 1
})
def buy_product(product_id):
try:
if query_stock(product_id) > 0:
r.hincrby(f"product:{product_id}", "stock", -1)
print(f'{r.hget(f"product:{product_id}", "name")}购买成功,花费{r.hget(f"product:{product_id}", "price")}元')
return True
else:
print(f'{r.hget(f"product:{product_id}", "name")}库存不足, 购买失败')
return False
except Exception as e:
print(e)
def query_stock(product_id):
stock = r.hget(f"product:{product_id}", "stock")
print(f'{r.hget(f"product:{product_id}", "name")}的库存为{stock}')
return int(stock)
def hot_products(product_id):
# 大促时隐藏价格
r.hdel(f'product:{product_id}', 'price')
query_stock('2001')
buy_product('2001')
query_stock('2001')
buy_product('2001')
hot_products('2001')
在实际生产环境中,这段代码还存在 一个关键的并发安全问题,我们来一起分析并优化它!
⚠️ 问题:"先查后改"不是原子的!这在单线程测试时没问题,但在高并发场景下会出大问题!
🧨 并发场景示例:
- 初始库存 = 1
- 用户 A 和 B 同时调用
buy_product - 两人同时执行
query_stock()→ 都看到库存 = 1 - 两人都通过
if > 0判断 - 两人都执行
hincrby(..., -1)→ 库存变成 -1!
✅ 正确做法:用 Lua 脚本保证原子性
Redis 支持 Lua 脚本,脚本内的所有操作是原子执行的(因为 Redis 单线程)。
我们可以写一个 Lua 脚本,在一个命令里完成"查 + 判断 + 扣":
python
# 定义 Lua 脚本
lua_script = """
local stock = redis.call('HGET', KEYS[1], 'stock')
if stock and stock > 0 then
redis.call('HINCRBY', KEYS[1], 'stock', -1)
return 1 -- 成功
else
return 0 -- 失败
end
"""
# 编译脚本(可复用)
buy_script = r.register_script(lua_script)
def buy_product_safe(product_id):
key = f"product:{product_id}"
result = buy_script(keys=[key])
if result == 1:
name = r.hget(key, "name")
price = r.hget(key, "price") or "价格已隐藏"
print(f"{name} 购买成功,花费 {price} 元")
return True
else:
name = r.hget(key, "name")
print(f"{name} 库存不足,购买失败")
return False
buy_product_safe('2001')
🔒 这样,无论多少人同时抢购,库存永远不会变负数!
List 类型 ------ 队列、栈、最新动态的万能工具
🧠 List 是什么?------ 双端可操作的列表
Redis 的 List 本质是一个 双向链表(linked list),支持从头部(left)或尾部(right) 快速插入/弹出元素。
⚡ 时间复杂度:
- LPUSH / RPUSH(推入):O(1)
- LPOP / RPOP(弹出):O(1)
- 按索引访问(如 LINDEX):O(N) → 不适合随机访问
🔧 核心命令速览(CLI vs Python)
| 功能 | Redis CLI | Python (redis-py) |
|---|---|---|
| 从左边推入 | LPUSH mylist a b c |
r.lpush("mylist", "a", "b", "c") |
| 从右边推入 | RPUSH mylist x y z |
r.rpush("mylist", "x", "y", "z") |
| 从左边弹出 | LPOP mylist |
r.lpop("mylist") |
| 从右边弹出 | RPOP mylist |
r.rpop("mylist") |
| 获取长度 | LLEN mylist |
r.llen("mylist") |
| 获取范围(分页) | LRANGE mylist 0 9 |
r.lrange("mylist", 0, 9) |
| 阻塞弹出(队列关键!) | BLPOP mylist 5 |
r.blpop("mylist", timeout=5) |
🌰 场景 1:实现一个"最新评论"功能(保留最近 100 条)
用户发评论,我们只保留最新的 100 条,老的自动丢弃。
PY
def add_comment(user_id, comment):
key = f"comments:{user_id}"
# 从左边推入新评论(最新在前)
r.lpush(key, comment)
# 最多保留 100 条,超出就从右边裁剪掉
r.ltrim(key, 0, 99) # 保留索引 0 到 99(共 100 个)
# 测试
for i in range(105):
add_comment("user123", f"评论 {i}")
# 查看最新的 5 条
latest = r.lrange("comments:user123", 0, 4)
print("最新评论:", latest) # 应该是 [104, 103, 102, 101, 100]
✅ 优势:
- LTRIM 是 O(N),但 N 很小(只裁剪超出部分),效率高
- 列表天然按时间倒序,前端直接展示
🌰 场景 2:轻量级消息队列(生产者-消费者模型)
虽然 Redis 不是专业 MQ(如 RabbitMQ/Kafka),但对简单异步任务非常高效!
python
import random
# 模拟发邮件任务
r.rpush("email_queue", '{"to": "alice@example.com", "subject": "Welcome!"}')
while True:
# 阻塞等待,最多等 5 秒
msg = r.blpop("email_queue", timeout=5)
if msg:
queue_name, task = msg
print("处理任务:", task)
# 这里调用 send_email(task)
else:
print("超时,继续等待...")
# 模拟生产者发送消息
if random.uniform(0, 1) < 0.5:
r.rpush("email_queue", '{"to": "PD@example.com", "subject": "i love you!"}')
🔑 关键命令:BLPOP
- 如果队列有数据,立即返回
- 如果没有,阻塞等待直到有数据 or 超时
- 避免轮询(polling),节省 CPU 和网络
🧩 List 还能当"栈"用!
- 栈(Stack):后进先出(LIFO)→ 用 LPUSH + LPOP
- 队列(Queue):先进先出(FIFO)→ 用 RPUSH + LPOP
| 问题 | 正确做法 |
|---|---|
| 用 List 存超大列表(如 10w+ 元素) | 避免!LRANGE 或 LREM 会阻塞 Redis。考虑分片或用 Sorted Set |
用 LREM 删除中间元素 |
尽量避免!它是 O(N),且要遍历整个列表 |
消费者用 LPOP 而不是 BLPOP |
会导致频繁空轮询,浪费资源 → 优先用 BLPOP |
| 把 List 当数据库用(频繁随机访问) | Redis List 不适合!用 Hash 或直接查 DB |
Set 类型 ------ 无序、唯一、支持交并差
🧠 Set 是什么?------ Redis 中的"数学集合"
Redis 的 Set 是一个无序、不重复的字符串集合,底层用 哈希表(hashtable) 实现,所以:
- 插入、删除、查找 都是 O(1) 平均时间复杂度 ✅
- 元素没有顺序(不能按索引访问)⚠️
- 自动去重(重复添加无效)✅
| 功能 | Redis CLI | Python (redis-py) |
|---|---|---|
| 添加元素 | SADD myset a b c |
r.sadd("myset", "a", "b", "c") |
| 判断是否存在 | SISMEMBER myset a |
r.sismember("myset", "a") |
| 获取所有元素 | SMEMBERS myset |
r.smembers("myset") |
| 删除元素 | SREM myset a |
r.srem("myset", "a") |
| 随机弹出一个 | SPOP myset |
r.spop("myset") |
| 获取数量 | SCARD myset |
r.scard("myset") |
🧮 集合运算(重点!)
| 操作 | CLI | Python |
|---|---|---|
| 交集 | SINTER set1 set2 |
r.sinter("set1", "set2") |
| 并集 | SUNION set1 set2 |
r.sunion("set1", "set2") |
| 差集 | SDIFF set1 set2 |
r.sdiff("set1", "set2") |
| 交集存入新 key | SINTERSTORE dest set1 set2 |
r.sinterstore("dest", ["set1", "set2"]) |
💡 所有集合运算都不会修改原集合(除非用 *STORE 命令)
🌰场景 1:用户标签系统(自动去重)
给用户打标签,比如兴趣、角色、权限等。
python
# 给用户 1001 打标签
r.sadd("user:1001:tags", "python", "redis", "backend", "python") # "python" 只存一次
# 查看所有标签
tags = r.smembers("user:1001:tags")
print("用户标签:", tags) # {'redis', 'backend', 'python'}
# 判断是否有某标签
if r.sismember("user:1001:tags", "admin"):
print("是管理员")
✅ 优势:天然去重,查询快,适合权限/标签系统。
🌰 场景 2:共同关注(社交网络经典问题)
求 Alice 和 Bob 的共同关注(即两人都关注的人):
python
# 添加数据
r.sadd("user:alice:follows", "bob", "charlie", "david")
r.sadd("user:bob:follows", "alice", "charlie", "eve")
# 求交集 → 共同关注
common = r.sinter("user:alice:follows", "user:bob:follows")
print("共同关注:", common) # {'charlie'}
🌰 场景 3:随机抽奖 / 抽样
python
# 添加参与者
participants = ["user1", "user2", "user3", "user4", "user5"]
r.sadd("lottery:2024", *participants)
# 随机抽 2 人(不放回)
winners = r.srandmember("lottery:2024", 2) # 返回列表,可能有重复?不!Set 保证唯一
print("中奖者:", winners)
# 或者直接弹出(抽完就移除)
real_winner = r.spop("lottery:2024") # 弹出一个,集合中不再存在
⚠️ 注意:SRANDMEMBER key count
- 如果 count > 0:返回最多 count 个不重复元素(因为 Set 本身无重复)
- 如果 count < 0:允许重复(但一般不用)
⚠️ 常见误区 & 最佳实践
| 误区 | 正确理解 |
|---|---|
| "Set 有顺序" | ❌ 无序!不要依赖 SMEMBERS 的返回顺序 |
| "可以用 Set 存有序排行榜" | ❌ 用 Sorted Set(下一讲!) |
| "Set 能存任意大数据" | ❌ 单个元素最大 512MB,但整体建议控制在合理内存范围内 |
| "SMEMBERS 不会阻塞" | ⚠️ 如果 Set 有几十万个元素,SMEMBERS 会阻塞 Redis! ✅ 改用 SSCAN 分批遍历 |
✅ 安全遍历大 Set:用 SSCAN
python
for item in r.sscan_iter("huge_set", count=100):
print(item) # 每次取 100 个,不阻塞
Sorted Set(ZSet)------ 带权重的有序集合
Redis 的 Sorted Set(简称 ZSet) 本质是一个 有序的、不重复的字符串集合,每个成员(member)都关联一个 分数(score),Redis 按 score 升序 自动排序。
🔑 核心特点:
- 成员唯一(类似 Set)
- 按 score 排序(类似排行榜)
- 插入/查找/删除 O(log N)(底层用 跳表 + 哈希表 实现)
📊 类比:
- Python 中没有直接对应,但可以想象成 dict(member → score)+ 自动按 value 排序
- 数据库中的 ORDER BY score + UNIQUE(member)
🔧 核心命令速览(CLI vs Python)
| 功能 | Redis CLI | Python (redis-py) |
|---|---|---|
| 添加成员 | ZADD myzset 10 "a" 20 "b" |
r.zadd("myzset", {"a": 10, "b": 20}) |
| 获取排名(升序) | ZRANK myzset "b" |
r.zrank("myzset", "b") |
| 获取分数 | ZSCORE myzset "a" |
r.zscore("myzset", "a") |
| 按排名范围取 | ZRANGE myzset 0 9 |
r.zrange("myzset", 0, 9) |
| 按分数范围取 | ZRANGEBYSCORE myzset 0 100 |
r.zrangebyscore("myzset", 0, 100) |
| 增加分数 | ZINCRBY myzset 5 "a" |
r.zincrby("myzset", 5, "a") |
| 删除成员 | ZREM myzset "a" |
r.zrem("myzset", "a") |
| 反向排名(降序) | ZREVRANK, ZREVRANGE |
r.zrevrange(...) |
💡 注意:ZRANGE 默认返回 member 列表,加 WITHSCORES=True 可同时返回分数。
🌰 场景 1:游戏积分排行榜(Top 10)
py
scores = {
"Alice": 1500,
"Bob": 1200,
"Charlie": 1800,
"Diana": 1600,
}
def add_score_randomly(name, scores):
scores[name] += random.uniform(0,1000)
add_score_randomly("Alice", scores)
add_score_randomly("Bob", scores)
add_score_randomly("Charlie", scores)
add_score_randomly("Diana", scores)
r.zadd("game:leaderboard", scores)
# 获取 Top 3(降序)
top3 = r.zrevrange("game:leaderboard", 0, 2, withscores=True)
print("🏆 排行榜 Top 3:")
for rank, (player, score) in enumerate(top3, 1):
print(f"{rank}. {player} - {int(score)} 分")
✅ 优势:实时排序,O(log N) 插入,O(1) 查 Top K!
🌰 场景 2:延迟队列(Delayed Queue)
用 时间戳作为 score,实现"N 秒后执行"的任务。
python
import time
import json
def schedule_task(task_id, delay_seconds, payload):
execute_at = time.time() + delay_seconds
r.zadd("delayed_queue", {task_id: execute_at})
# 存储任务内容(用 String 或 Hash)
r.set(f"task:{task_id}", json.dumps(payload))
# 模拟调度一个 5 秒后执行的任务
schedule_task("task_001", 5, {"action": "send_email", "to": "user@example.com"})
# 消费者:不断检查是否有到期任务
while True:
now = time.time()
# 取所有 score <= now 的任务
ready_tasks = r.zrangebyscore("delayed_queue", 0, now)
if ready_tasks:
for task_id in ready_tasks:
payload = r.get(f"task:{task_id}")
print(f"执行任务 {task_id}: {payload}")
# 清理
r.zrem("delayed_queue", task_id)
r.delete(f"task:{task_id}")
else:
time.sleep(1) # 避免空转
⚡ 这就是轻量级 定时任务 / 延迟消息 的实现原理!
⚠️ 常见误区 & 最佳实践
| 问题 | 正确做法 |
|---|---|
| 用 ZSet 存超大排行榜(如 100w+ 用户) | ✅ 可以!ZSet 对 Top K 查询极高效,但避免 ZRANGE 0 -1 全量拉取 |
| 分数用字符串或非数字 | ❌ score 必须是 浮点数(Redis 会自动转) |
| 忽略 score 精度问题 | ⚠️ 浮点数有精度限制,避免用极高精度时间戳做唯一标识 |
| 频繁全量更新 ZSet | ❌ 用 ZADD 增量更新即可,支持覆盖或 XX/NX 选项 |
Bitmaps ------ 用 1 个 bit 表示状态,极致省空间!
🧠 Bitmaps 是什么?------ "用 String 当位数组"
Redis 的 Bitmaps 并非新数据类型,而是对 String 类型的位级操作:
- 一个 String 最大 512MB → 可表示 40 亿个 bit(512 * 1024 * 1024 * 8)
- 每个 bit 只能是 0 或 1,代表某种布尔状态(如:签到/未签到、在线/离线)
🔧 核心命令(位操作)
| 功能 | Redis CLI | Python (redis-py) |
|---|---|---|
| 设置某位为 1 | SETBIT key offset 1 |
r.setbit("key", offset, 1) |
| 获取某位值 | GETBIT key offset |
r.getbit("key", offset) |
| 统计 1 的个数 | BITCOUNT key |
r.bitcount("key") |
| 对多个 key 做位运算 | BITOP AND/OR/XOR dest src1 src2 |
r.bitop("AND", "dest", "src1", "src2") |
🌰 场景 1:用户月度签到(30 天只需 30 bits ≈ 4 字节!)
传统做法:用 Set 存每天签到日期 → 每天一个字符串(如 "20240201"),至少 8 字节/天 → 30 天 = 240 字节。
Bitmaps 做法:
- 用 1 个 key 表示一个月
- 第 0 位 = 1 号是否签到,第 1 位 = 2 号......第 29 位 = 30 号
python
user_id = "user:1001"
month_key = f"sign:{user_id}:202402"
# 用户在 2 月 5 日签到(offset = 4)
r.setbit(month_key, 4, 1)
# 查询 2 月 5 日是否签到
signed = r.getbit(month_key, 4)
print("已签到" if signed else "未签到")
# 统计本月签到天数
total_days = r.bitcount(month_key)
print(f"本月签到 {total_days} 天")
✅ 内存对比:
- Set 方案:30 天 × 8 字节 = 240 字节
- Bitmaps:30 bits = 4 字节(向上取整到字节)→ 节省 98%!
🌰 场景 2:统计某日活跃用户数(DAU)
假设用户 ID 是数字(如 1~100 万),我们可以:
- 用一个 Bitmap 表示一天
- 用户 ID 作为 offset,登录就设为 1
python
# 用户 123456 在 2024-02-12 登录 offset=123456
r.setbit("active:20240212", 123456, 1)
# 统计当日活跃用户数
dau = r.bitcount("active:20240212")
print(f"DAU: {dau}")
📊 如果有 100 万用户,这个 Bitmap 只需 1000000 / 8 ≈ 125 KB!
而用 Set 存 100 万个 ID,至少 几十 MB。
🌰 场景 3:连续签到天数(结合 BITFIELD)
想查用户最长连续签到?可以用 BITFIELD + 位扫描,但更常见的是:
- 先用 GETRANGE 获取整个月的字节
- 在应用层解析连续 1 的长度
或者,用 多个 Bitmaps + 位运算 实现高级分析(如周活跃、留存率)。
🧮 高级技巧:多 Bitmaps 位运算(求共同活跃用户)
假设:
- active:20240210 = 2 月 10 日活跃用户 Bitmap
- active:20240211 = 2 月 11 日活跃用户 Bitmap
求连续两天都活跃的用户数:
python
# 对两个 Bitmap 做 AND 运算,结果存入 new key
r.bitop("AND", "active:20240210_11", "active:20240210", "active:20240211")
# 统计结果中 1 的个数
retained = r.bitcount("active:20240210_11")
print(f"连续两天活跃用户: {retained}")
🔥 这就是留存分析的核心思路!
⚠️ 使用限制 & 注意事项
| 限制 | 说明 |
|---|---|
| 用户 ID 必须是整数且密集 | 如果 ID 是 UUID 或稀疏(如最大 ID=10 亿,但只有 1000 用户),会浪费大量空间 |
| 不支持负数 offset | offset ≥ 0 |
| 单个 key 最大 512MB | 最多支持约 40 亿位(够用!) |
| 调试困难 | GET key 返回的是乱码(二进制),建议用 redis-cli --bigkeys 或专用工具查看 |
✅ 什么时候用 Bitmaps?
| ✅ 推荐场景 | ❌ 不推荐场景 |
|---|---|
| 用户签到/打卡 | 存储非布尔状态(如分数、文本) |
| 活跃用户统计(DAU/MAU) | 用户 ID 是字符串或稀疏大整数 |
| 布隆过滤器(Bloom Filter)底层 | 需要频繁随机访问非位数据 |
| 权限开关(某功能开/关) | 数据量极小(不如直接用 String) |
✅ 小练习
- 模拟用户签到,计算其的最长连续签到天数
py
import redis
# 注意这里的decode_responses=False 二进制连接:处理 Bitmap、GET 原始字节
r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=False)
user_id = "user:1001"
month_key = f"sign:{user_id}:202402"
for i in range(1,4):
r.setbit(month_key,i,1)
for i in range(7,15):
r.setbit(month_key,i,1)
r.setbit(month_key, 20, 1)
r.setbit(month_key, 25, 1)
raw_bytes = r.get(month_key)
if raw_bytes:
bin_str = ''.join(
format(byte, '08b') # 每个字节转成 8 位二进制,前面补 0
for byte in raw_bytes
)
print("Full binary string:", bin_str) # 例如: '00100010'
# 执行算法获取最长的连续出勤天数
max_working = 0
left = 0
right = 0
while left < len(bin_str):
tmp = 0
if bin_str[left] == '1':
right = left
while right < len(bin_str) and bin_str[right] == '1':
right += 1
tmp += 1
max_working = max(tmp, max_working)
left = right
else:
left += 1
print(f'最长连续出勤天数为:{max_working}')
Geospatial ------ 用 5 行代码实现"附近的人"
🧠 Geospatial 是什么?------ 基于经纬度的空间索引
Redis 从 3.2 版本开始内置了地理空间支持,提供了一组 GEO* 命令,用于:
- 存储位置(经度 longitude + 纬度 latitude)
- 计算两点间距离
- 查询某点半径内的所有地点
- 获取地点的坐标
🔑 核心思想:
Redis 把 经纬度编码成一个 score(52 位整数),存入 Sorted Set。这个编码算法叫 GeoHash,它能保证相近的地理位置有相近的 score,从而支持范围查询。
🔧 核心命令速览(CLI vs Python)
| 功能 | Redis CLI | Python (redis-py) |
|---|---|---|
| 添加位置 | GEOADD cities 116.40 39.90 "Beijing" |
r.geoadd("cities", (116.40, 39.90, "Beijing")) |
| 获取坐标 | GEOPOS cities Beijing |
r.geopos("cities", "Beijing") |
| 计算距离 | GEODIST cities Beijing Shanghai km |
r.geodist("cities", "Beijing", "Shanghai", unit="km") |
| 查询附近 | GEORADIUS cities 116.40 39.90 100 km |
r.georadius("cities", 116.40, 39.90, 100, unit="km") |
| 查询附近(以已有成员为中心) | GEORADIUSBYMEMBER cities Beijing 50 km |
r.georadiusbymember("cities", "Beijing", 50, unit="km") |
⚠️ 注意:
- 经度在前,纬度在后!(lon, lat)→ 和 Google Maps 一致
- 单位可选:m(默认)、km、ft、mi
🌰 场景:查找"我附近的咖啡店"
python
import redis
r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)
# 1. 添加一批咖啡店(经度, 纬度, 名称)
cafes = [
(116.407526, 39.904556, "Starbucks Wangfujing"),
(116.415872, 39.913678, "Costa Gulou"),
(116.399231, 39.910523, "Luckin Cafe Xidan"),
(116.428566, 39.903214, "Manner Coffee Sanlitun"),
]
for i in cafes:
r.geoadd("beijing:cafes", i, nx=True)
# 2. 假设用户当前位置(比如王府井)
my_lon, my_lat = 116.407526, 39.904556
# 3. 查找 2 公里内的咖啡店
nearby = r.georadius(
"beijing:cafes",
my_lon, my_lat,
radius=2, # 2 公里
unit="km",
withdist=True, # 返回距离
withcoord=True, # 返回坐标
sort="ASC" # 按距离升序
)
for name, dist, coord in nearby:
print(f"📍 {name} | 距离: {dist:.2f} km | 坐标: ({coord[0]:.4f}, {coord[1]:.4f})")
输出:
📍 Starbucks Wangfujing | 距离: 0.00 km | 坐标: (116.4075, 39.9046)
📍 Luckin Cafe Xidan | 距离: 0.97 km | 坐标: (116.3992, 39.9105)
📍 Costa Gulou | 距离: 1.24 km | 坐标: (116.4159, 39.9137)
📍 Manner Coffee Sanlitun | 距离: 1.80 km | 坐标: (116.4286, 39.9032)
🔍 底层原理:GeoHash + Sorted Set
当你执行 GEOADD,Redis 实际做了两件事:
- 用 GeoHash 算法 将 (lon, lat) 编码成一个 52 位整数(如 4068583985773977)
- 把这个整数作为 score,地点名称作为 member,存入一个 ZSet
python
zset_data = r.zrange("beijing:cafes", 0, -1, withscores=True)
print(zset_data)
# [('Starbucks Wangfujing', 4069885372166843.0), ('Manner Coffee Sanlitun', 4069885469839122.0), ('Luckin Cafe Xidan', 4069885544724322.0), ('Costa Gulou', 4069885644536602.0)]
📌 所以:Geospatial 不是新数据类型,而是 ZSet 的语义封装!
| 限制 | 说明 |
|---|---|
| 精度有限 | GeoHash 默认精度约 米级,不适合厘米级定位(如室内导航) |
| 不支持多边形查询 | 只能查圆形区域(GEORADIUS),不能查"北京市朝阳区"这种多边形 |
| 内存占用 | 每个地点约占用 ~20 字节(比纯 ZSet 稍大) |
| 地球模型 | 使用 球面距离(Haversine 公式),非平面投影 |
| 无索引更新 | 修改位置需先 ZREM 再 GEOADD |
✅ 最佳实践
- 命名规范:
geo:{type}:{region},如geo:users:shanghai - 定期清理:用
EXPIRE或业务逻辑删除过期位置(如用户下线) - 结合其他结构:
- 用 Hash 存用户详细信息:
user:1001 = {name, avatar, ...} - 用 Geospatial 只存位置:
geo:users = {1001 → (lon, lat)}
- 用 Hash 存用户详细信息:
- 避免全量拉取:
GEORADIUS支持COUNT N限制返回数量,防止爆内存
HyperLogLog ------ 用 12KB 内存估算 10 亿 UV
🧠 HyperLogLog 是什么?------ 概率算法 + 极致省空间
HyperLogLog(HLL) 是一种概率数据结构,用于估算集合中不重复元素的数量(基数,Cardinality)。
它的神奇之处在于:
- 内存固定:无论存 1 个还是 10 亿个唯一值,只占 ~12KB 内存
- 误差可控:默认标准误差 ±0.81%(可通过参数调整)
- 支持合并:多个 HLL 可 PFMERGE 合并,适合分片统计
🔍 类比:
就像通过"抽样调查"估算全国人口,而不是挨家挨户数人头。
Redis 的 HLL 实现了 Philippe Flajolet 提出的算法。
🔧 核心命令(只有 3 个!)
| 功能 | Redis CLI | Python (redis-py) |
|---|---|---|
| 添加元素 | PFADD hll_key user1 user2 |
r.pfadd("hll_key", "user1", "user2") |
| 获取估算值 | PFCOUNT hll_key |
r.pfcount("hll_key") |
| 合并多个 HLL | PFMERGE dest src1 src2 |
r.pfmerge("dest", ["src1", "src2"]) |
🌰 场景:统计网站每日 UV(独立访客)
传统做法:
- 用 Set 存所有用户 ID → 内存爆炸(100 万用户 ≈ 几十 MB)
- 用数据库 SELECT COUNT(DISTINCT user_id) → 慢、IO 高
python
import uuid
# 模拟用户访问(用 UUID 代表用户)
for _ in range(100_000):
user_id = str(uuid.uuid4())
r.pfadd("uv:20240213", user_id)
# 获取今日 UV 估算值
estimated_uv = r.pfcount("uv:20240213")
print(f"今日独立访客数 ≈ {estimated_uv}")
✅ 内存对比:
- Set 方案:10 万 UUID × 36 字节 ≈ 3.6 MB
- HyperLogLog:固定 12 KB → 节省 99.7% 内存!
💡 即使有 1 亿 UV,HLL 依然只用 12KB!
🌰 高级用法:分渠道 UV 合并
假设你要统计:
- uv:ios
- uv:android
- uv:web
最后得到全平台 UV(注意:不是简单相加,因为用户可能跨端!):
python
# 分别统计各端
r.pfadd("uv:ios", "user1", "user2", "user3")
r.pfadd("uv:android", "user2", "user4", "user5") # user2 重复
# 合并得到去重后的总 UV
r.pfmerge("uv:total", ["uv:ios", "uv:android"])
total_uv = r.pfcount("uv:total")
print(f"全平台 UV ≈ {total_uv}") # 应为 5(user1~5),不是 3+3=6
⚠️ 使用限制 & 注意事项
| 限制 | 说明 |
|---|---|
| 只能计数,不能查具体元素 | 你无法知道"哪些用户访问了",只能知道"大约多少人" |
| 不适合小数据集 | 当元素 < 10000 时,误差可能较大;此时直接用 Set 更准 |
| 不可逆 | 一旦加入,无法删除单个元素(除非重建整个 HLL) |
| 不是精确值 | 永远是估算!不要用于需要 100% 精确的场景(如金融对账) |
✅ 最佳实践
| 场景 | 是否推荐 HLL |
|---|---|
| 网站/APP 日活(DAU) | ✅ 强烈推荐 |
| 广告点击去重 | ✅ 推荐 |
| 用户行为漏斗分析 | ✅ 推荐(各步骤 UV) |
| 精确去重(如防刷单) | ❌ 用 Set 或布隆过滤器 |
| 小规模数据(<1万) | ❌ 直接用 SCARD |