文章

Redis 数据结构与缓存设计

Redis 数据结构与缓存设计

概述

Redis 是基于内存的键值数据库,单线程事件驱动模型,核心价值在于低延迟(亚毫秒级)和丰富的数据结构。作为缓存层是标配,作为消息队列、分布式锁、计数器等场景也广泛应用。

五种核心数据结构

结构底层实现常用场景关键命令
StringSDS(简单动态字符串)缓存、计数器、分布式锁SET/GET/INCR/DECR/SETNX
Hashziplist → listpack / hashtable对象存储(用户信息)HSET/HGET/HGETALL
Listquicklist(linkedlist + ziplist 混合)消息队列、时间线LPUSH/RPOP/BLPOP
Setintset / hashtable标签、关注、去重SADD/SINTER/SUNION
ZSetziplist / skiplist + dict排行榜、延迟队列ZADD/ZRANGE/ZRANK

底层数据结构演进(了解即可)

Redis 3.2 前:ziplist + linkedlist  → 各有缺陷
Redis 3.2:  引入 quicklist(混合结构)
Redis 7.0:  listpack 逐步替代 ziplist(减少连锁更新问题)

缓存设计三大问题

1. 缓存穿透

查一个不存在的数据 → 缓存没命中 → 请求穿透到 DB

# 场景:恶意查询 id=-1,每次穿透到 DB
方案原理优点缺点
布隆过滤器存储所有合法 key 的指纹内存占用极小有误判率(判存在但实际不存在)
缓存空值不存在的 key 也缓存(值 = null, TTL=短)简单可能缓存大量空值(恶意攻击时)
参数校验前置参数合法性检查零成本只能拦截明显异常
# 布隆过滤器示例(redisbloom 模块)
BF.ADD user_ids 1001
BF.EXISTS user_ids 1001   # → 1(存在)
BF.EXISTS user_ids -1     # → 0(不存在 → 直接拒绝,不查 DB)

# 缓存空值
def get_user(user_id):
    cache_key = f"user:{user_id}"
    user = redis.get(cache_key)
    if user == "NULL":      # 缓存的空标记
        return None
    if user:
        return json.loads(user)
    # 查 DB
    user = db.query(user_id)
    if user:
        redis.setex(cache_key, 3600, json.dumps(user))
    else:
        redis.setex(cache_key, 60, "NULL")   # 缓存空值,短 TTL
    return user

2. 缓存击穿

热点 key 过期瞬间,大量请求打到 DB

方案实现适用
互斥锁(Mutex)查不到缓存 → 抢锁 → 抢到者查 DB 重建 → 释放锁热点 key 少
逻辑过期缓存不设 TTL,value 中存储逻辑过期时间 → 异步重建热点 key 多、高并发
永不过期物理不过期,后台线程定期/通知更新配置类数据
# 互斥锁方案
def get_hot_data(key):
    data = redis.get(key)
    if data:
        return data

    lock_key = f"lock:{key}"
    if redis.set(lock_key, 1, nx=True, ex=10):  # 抢互斥锁
        try:
            data = db.query(...)                  # 查 DB
            redis.setex(key, 3600, data)         # 回写缓存
        finally:
            redis.delete(lock_key)                # 释放锁
        return data
    else:
        time.sleep(0.05)                          # 没抢到 → 等一会重试
        return get_hot_data(key)

# 逻辑过期方案
def get_with_logical_expire(key):
    data = redis.get(key)
    if not data:
        return None
    value = json.loads(data)
    if value['expire_at'] > time.time():          # 物理未过期
        return value['data']
    # 物理过期,异步重建
    lock_key = f"lock:{key}"
    if redis.set(lock_key, 1, nx=True, ex=10):
        thread_pool.submit(rebuild_cache, key)    # 异步重建
    return value['data']                          # 返回旧值(降级)

3. 缓存雪崩

大量缓存在同一时间过期,或 Redis 宕机,所有请求打到 DB

方案实现
TTL 随机化TTL = base + random(0, 300) 避免同时过期
多级缓存Redis + 本地缓存(Caffeine/Guava)双缓冲
熔断降级Hystrix/Sentinel 限流,DB 压力大时返回降级数据
Redis 高可用哨兵/Cluster → 防止单点故障
# TTL 随机化
import random
redis.setex(key, 3600 + random.randint(0, 300), value)

# 熔断降级判定
def query_with_fallback(key):
    try:
        return redis.get(key) or db.query(key)
    except (RedisError, DBOverloadError):
        return get_fallback_value(key)  # 静态降级数据

BigKey 问题

定义与危害

# 扫描 BigKey
redis-cli --bigkeys

# 查看 key 大小
redis-cli MEMORY USAGE mykey
类型BigKey 标准危害
String> 10KB网络传输慢
Hash/List/Set/ZSet> 5000 元素删除时阻塞、迁移慢、带宽高

解决方案

# Hash 拆分:user:1001 → user:1001:base, user:1001:extended
# List 按时间拆分:log:20260714 而不是单个 log

# 渐进式删除(避免 DEL 阻塞)
# Redis 4.0+ 用 UNLINK 异步删除
redis.unlink('big_key')

# 低版本用 SCAN 分批删
cursor = 0
while True:
    cursor, keys = redis.scan(cursor, match='prefix:*', count=100)
    if keys:
        redis.delete(*keys)
    if cursor == 0:
        break

热点 Key 发现

# 方法 1:redis-cli --hotkeys(Redis 4.0+)
redis-cli --hotkeys

# 方法 2:monitor 采样(生产慎用,性能影响大)
redis-cli monitor | head -10000 | awk '{print $4}' | sort | uniq -c | sort -rn | head -20

# 方法 3:客户端埋点(推荐)
# 在应用层统计 key 访问频率,异步上报

缓存更新策略

策略做法一致性适用场景
Cache Aside读:查缓存 → miss 则查 DB 回写
写:更新 DB → 删缓存
最终一致最常用
Read/Write Through缓存作为 DB 代理,应用只读缓存强一致对一致性要求高
Write Behind先写缓存 → 异步写 DB弱一致写密集、可容忍丢数据
# Cache Aside 标准写流程(先删缓存还是先更新 DB?)
# ✅ 推荐顺序:先更新 DB → 再删缓存
def update_user(user):
    db.update(user)                      # 1. 更新数据库
    redis.delete(f"user:{user.id}")      # 2. 删除缓存
    # 先删缓存再更新 DB 有风险:删完后其他请求读到旧数据并回写缓存

分布式锁实现方案演进

基础:SETNX + 过期时间

# ❌ 经典错误:SETNX 和 EXPIRE 不是原子操作
redis.setnx('lock:order:1001', 'thread-1')
redis.expire('lock:order:1001', 30)
# 两步之间进程 crash → 锁永不过期 → 死锁!

# ✅ 正确:原子操作(Redis 2.6.12+)
redis.set('lock:order:1001', 'thread-1', nx=True, ex=30)

标准实现:Lua 脚本保证原子释放

# 释放锁时必须验证所有者(Lua 脚本实现原子性)
UNLOCK_SCRIPT = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
    return redis.call('DEL', KEYS[1])
else
    return 0
end
"""

# 错误示范(非原子,可能释放别人的锁)
# if redis.get('lock:order:1001') == 'thread-1':
#     redis.delete('lock:order:1001')          # 判断和删除之间有并发风险!

def acquire_lock(lock_key, holder_id, ttl=30):
    return redis.set(lock_key, holder_id, nx=True, ex=ttl)

def release_lock(lock_key, holder_id):
    return redis.eval(UNLOCK_SCRIPT, 1, lock_key, holder_id)

锁续期(Watch Dog)—— Redisson 方案

问题:业务执行超过锁 TTL → 锁自动释放 → 其他线程获取锁 → 并发安全问题

Redisson 的 Watch Dog 机制:
1. 获取锁 → 启动后台定时器
2. 每 lockTimeout/3(默认 30s/3=10s)自动续期 → 重置 TTL = 30s
3. 业务完成释放锁 → 停止续期
4. 进程 crash → 无法续期 → 锁自然过期
// Redisson 使用示例(自动 Watch Dog)
RLock lock = redisson.getLock("lock:order:1001");
try {
    // lock() 不带参数 → Watch Dog 自动续期
    lock.lock();
    businessLogic();  // 即使执行 5 分钟也不怕
} finally {
    lock.unlock();
}

RedLock 算法与争议

Redis 官方推荐的多节点分布式锁方案:

3 个独立 Redis Master(非主从)→ 向 N 个节点请求锁
→ 在大多数节点(N/2+1)上成功获取 → 总耗时 < TTL → 获取成功

Martin Kleppmann 的质疑(《How to do distributed locking》):
→ 锁依赖系统时钟(TTL 过期判断)
→ GC pause 可能导致锁提前过期而线程不自知
→ 需要 fencing token 机制(单调递增 ID)来防止"过期后继续写"

实用主义结论:
→ 非金融级场景,单节点 Redis + Watch Dog 足够
→ 金融/强一致场景 → 建议使用 ZooKeeper/etcd(CP 系统)

锁方案对比

方案一致性性能复杂度适用
Redis SETNX + Watch DogAP,可能冲突极高绝大多数业务场景
RedLock理论强但实践有争议多数据中心
ZooKeeper 临时节点CP,强一致配置管理/选举
etcdCP,强一致K8s 环境首选
数据库乐观锁(version)最终一致极高并发冲突低的场景

高级数据结构

HyperLogLog —— 海量去重计数

# 12KB 固定内存 → 约 2^64 个元素的基数估算 → 标准误差 0.81%
PFADD ua:20260714 user1 user2 user3 ...
PFCOUNT ua:20260714         # → 今日 DAU(近似值)
PFMERGE ua:weekly ua:20260714 ua:20260715 ...  # → 周活 DAU

# 使用场景:UV 统计、独立访客数、搜索去重
# 注意:PFCOUNT 是估算值(±0.81%),不是精确计数

BitMap —— 位图签到/打卡

# 用户 ID 作为 offset → 一个用户只占 1 个 bit
SETBIT signin:20260714 1001 1     # 用户 1001 签到
SETBIT signin:20260714 1002 1     # 用户 1002 签到
BITCOUNT signin:20260714          # → 总签到人数
GETBIT signin:20260714 1001       # → 用户 1001 是否签到

# 连续签到统计(按位与运算)
BITOP AND continuous signin:20260714 signin:20260715 signin:20260716
BITCOUNT continuous 0 -1          # → 连续 3 天签到人数

# 使用场景:签到、布隆过滤器、在线状态标记

GEO —— 地理位置

GEOADD shops 116.397128 39.916527 "shop:1001"    # 添加坐标
GEODIST shops "shop:1001" "shop:1002" m           # → 两点距离(米)
GEORADIUS shops 116.394 39.912 5 km               # → 5km 内的店铺
GEOPOS shops "shop:1001"                           # → 获取坐标

# 底层实现:ZSet (GeoHash 编码 → score)
ZRANGE shops 0 -1 WITHSCORES                       # → 可以看到 GeoHash 值

Stream —— Redis 版轻量消息队列

# 生产消息
XADD order:stream * action create user_id 1001 amount 88.0
# → "1689412345678-0"

# 消费组消费(推荐)
XGROUP CREATE order:stream order-group $ MKSTREAM
XREADGROUP GROUP order-group consumer-1 BLOCK 5000 STREAMS order:stream >

# 查看 pending 消息(未被确认的消息)
XPENDING order:stream order-group

# 确认消费完成
XACK order:stream order-group "1689412345678-0"

Stream vs List 做消息队列:Stream 支持消费组、消息确认、重复消费,功能更完善。但大规模消息场景仍建议 Kafka/Pulsar。

缓存一致性深度方案

Cache Aside + 延迟双删

# 标准 Cache Aside 的问题:
# 更新 DB → 删除缓存 → 下一请求查缓存 miss → 查 DB(可能刚写完还没同步到从库?)

# 延迟双删:多删一次,降低不一致概率
def update_with_double_delete(key, data):
    redis.delete(key)              # 1. 先删缓存
    db.update(data)                # 2. 更新 DB
    time.sleep(0.5)                # 3. 等待 500ms
    redis.delete(key)              # 4. 再删一次(防止步骤 1-2 之间有并发读写入脏数据)

基于 Binlog 的最终一致性(Canal + MQ + Redis)

MySQL Binlog → Canal 监听 → 解析变更事件 → MQ(Kafka)→ 消费者更新 Redis
// Canal 监听 Binlog → 异步更新/删除 Redis 缓存
// 优点:对业务代码零侵入、变更可靠不丢失
// 适用:跨系统数据同步、缓存与 DB 的最终一致性

// 流程示例:
// 1. Canal 订阅 MySQL orders 表
// 2. INSERT/UPDATE → 推送 Kafka topic "cache_sync"
// 3. Redis 消费者接收事件 → SET/DEL Redis key
// 4. 异常 → Kafka 重试 + 死信队列兜底

方案对比

方案一致性延迟复杂度适用
缓存旁路(Cache Aside)最终一致毫秒大多数业务
延迟双删最终一致(更可靠)毫秒并发不高的更新
Canal + MQ最终一致(最强)秒级跨系统/高并发写入
分布式事务(2PC)强一致金融/支付

内存管理与优化

Redis 内存模型

total_memory = used_memory + used_memory_rss(含碎片)

used_memory = 数据 + 进程本身
used_memory_rss = OS 实际分配(> used_memory)

碎片率 = used_memory_rss / used_memory
→ 正常:1.0 - 1.5
→ 过高(>1.5)→ 考虑启用 MEMORY PURGE 或重启
→ 过低(<1.0)→ OS 启用 Swap → 性能雪崩
redis-cli INFO memory
# used_memory_human         → 实际数据占用
# used_memory_rss_human     → OS 分配内存
# mem_fragmentation_ratio   → 碎片率(1-1.5 健康)
# maxmemory_human           → maxmemory 限制
# maxmemory_policy          → 淘汰策略

内存淘汰策略详解

策略行为适用
noeviction不淘汰,写请求报错不建议(除非纯缓存)
allkeys-lru所有 key 中 LRU 淘汰通用缓存推荐
volatile-lru有过期时间的 key 中 LRU 淘汰混合使用(持久 + 缓存)
allkeys-lfu所有 key 中 LFU 淘汰热点不均匀(推荐 Redis 4.0+)
volatile-lfu有过期时间的 key 中 LFU 淘汰缓存为主 + 带过期
allkeys-random随机淘汰很少用
volatile-ttl过期时间最近的淘汰临时数据

LRU vs LFU: LRU 看”最近是否被访问”,LFU 看”历史访问频率”。热点数据集中访问,LRU 可能误淘汰低频但持久的热点数据,LFU 更优。

Redis 7.0+ 内存效率优化

1. Listpack 替代 Ziplist(减少连锁更新问题)
2. 共享复制缓冲区(减少 Slave 内存占用)
3. AOF 多部分写(减少 fsync 阻塞)

关联知识

参考资源

学习时间

阶段时间备注
初次学习2026-07-14数据结构 + 缓存三大问题 + BigKey + 更新策略

状态: 📖 已掌握 下次复习日期: 2026-08-14