Redis 数据结构与缓存设计
概述
Redis 是基于内存的键值数据库,单线程事件驱动模型,核心价值在于低延迟(亚毫秒级)和丰富的数据结构。作为缓存层是标配,作为消息队列、分布式锁、计数器等场景也广泛应用。
五种核心数据结构
| 结构 | 底层实现 | 常用场景 | 关键命令 |
|---|
| String | SDS(简单动态字符串) | 缓存、计数器、分布式锁 | SET/GET/INCR/DECR/SETNX |
| Hash | ziplist → listpack / hashtable | 对象存储(用户信息) | HSET/HGET/HGETALL |
| List | quicklist(linkedlist + ziplist 混合) | 消息队列、时间线 | LPUSH/RPOP/BLPOP |
| Set | intset / hashtable | 标签、关注、去重 | SADD/SINTER/SUNION |
| ZSet | ziplist / 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 Dog | AP,可能冲突 | 极高 | 低 | 绝大多数业务场景 |
| RedLock | 理论强但实践有争议 | 中 | 中 | 多数据中心 |
| ZooKeeper 临时节点 | CP,强一致 | 低 | 中 | 配置管理/选举 |
| etcd | CP,强一致 | 低 | 中 | 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