Redis基础与数据结构

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,性能依然爆炸。

🌰 典型使用场景

  1. 缓存(最常见)
    • 用户登录 session 缓存
    • 商品详情页缓存(避免频繁查 DB)
  2. 排行榜 / 计数器
    • 用 Sorted Set 实现点赞排行榜、游戏积分榜
  3. 分布式锁
    • 利用 SET key value NX EX 实现跨服务的互斥操作
  4. 消息队列(轻量级)
    • 用 List 的 LPUSH / BRPOP 模拟队列(但不如 RabbitMQ/Kafka 专业)
  5. 实时统计
    • 用 HyperLogLog 估算 UV(独立访客数),节省内存

连接Redis

shell 复制代码
python -m venv .venv --p=python3.12
pip install redis
  1. 测试链接
python 复制代码
import redis

# 创建连接(默认本地、默认端口)
r = redis.Redis(host='localhost', port=6379, db=0, decode_responses=True)

# 测试
print(r.ping())  # 应该输出 True
  1. 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+ 元素) 避免!LRANGELREM 会阻塞 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 实际做了两件事:

  1. 用 GeoHash 算法 将 (lon, lat) 编码成一个 52 位整数(如 4068583985773977)
  2. 把这个整数作为 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 公式),非平面投影
无索引更新 修改位置需先 ZREMGEOADD

✅ 最佳实践

  1. 命名规范:geo:{type}:{region},如 geo:users:shanghai
  2. 定期清理:用 EXPIRE 或业务逻辑删除过期位置(如用户下线)
  3. 结合其他结构:
    • 用 Hash 存用户详细信息:user:1001 = {name, avatar, ...}
    • 用 Geospatial 只存位置:geo:users = {1001 → (lon, lat)}
  4. 避免全量拉取: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
相关推荐
茶杯梦轩2 小时前
从零起步学习Redis || 第十一章:主从切换时的哨兵机制如何实现及项目实战
服务器·redis
山岚的运维笔记2 小时前
SQL Server笔记 -- 第46章 窗口函数
数据库·笔记·sql·microsoft·sqlserver
rannn_1112 小时前
【苍穹外卖|Day7】缓存菜品、缓存套餐、添加购物车、查看购物车、清空购物车
java·spring boot·redis·后端·缓存·项目
科技块儿2 小时前
【工具对比】免费IP库用于广告投放是否可靠?误差率实测报告
网络·数据库·tcp/ip
晔子yy2 小时前
带你了解Java中的Mono接口
java·数据库·oracle
全栈前端老曹2 小时前
【Redis】发布订阅模型 —— Pub/Sub 原理、消息队列、聊天系统实战
前端·数据库·redis·设计模式·node.js·全栈·发布订阅模型
SQL必知必会2 小时前
使用 SQL 构建转化漏斗
数据库·sql·数据分析
丿BAIKAL巛2 小时前
Docker部署的Mysql数据库自动化备份
数据库·mysql·docker