Fadey's Blog

arrow_back 返回首页
Redis封面

Redis 实战:缓存策略、数据结构与分布式锁

Redis 是后端工程师绕不开的一层:用好了是性能倍增器,用不好就是「缓存和数据库不一致」「热点挂了全站雪崩」的无限循环。这篇文章不讲 API 大全,而是按「怎么用对」这条线,把数据结构选型、缓存三大经典问题、分布式锁、持久化选型一次讲清。

Redis 的定位:不只是缓存

大多数团队从「加个缓存」开始用 Redis,但它的实际角色远不止于此:

  • 缓存:热点数据前置,数据库只承接写和未命中读;
  • 分布式锁:跨进程互斥,配合过期时间防止死锁;
  • 计数器与限流:INCR 天然原子,滑窗限流的基础;
  • 排行榜:ZSET 按分数排序,ZRANGE ... REV 取 TopN;
  • 轻量队列:LPUSH + BRPOP,或更可靠的 Streams。

定位要先想清楚:Redis 是「可以丢的数据加速层」还是「唯一数据源」,这决定了持久化和高可用的严格程度。

五种基础数据结构与典型场景

# String:缓存对象、计数器、分布式锁
SET user:1001:name "Fadey" EX 3600
INCR article:1001:views
SET lock:order:42 "token-abc" NX EX 30

# Hash:部分字段更新、对象存储(比整个 String 反序列化更省)
HSET user:1001 name "Fadey" age 30
HINCRBY user:1001 age 1
HGETALL user:1001

# List:最新列表、简单队列
LPUSH feed:global "article:1005"
LRANGE feed:global 0 9          # 取最新 10 条
BRPOP queue:email 30            # 阻塞式消费(最多等 30s)

# Set:去重、标签、共同关注
SADD article:1001:tags "redis" "cache"
SINTER user:1:follow user:2:follow   # 共同关注

# ZSet:排行榜、延时队列
ZADD rank:2026 3500 "player:7"
ZREVRANGE rank:2026 0 9 WITHSCORES   # Top10

选型经验:能用 Hash 存对象就用 Hash(可以只更新单个字段),只有整个对象总是整体读写时才用 String + 序列化。

缓存三大经典问题

1. 缓存穿透:查不存在的数据

恶意请求或不存在的 ID 每次都击穿到数据库。两种解法配合用:

// 方案 A:缓存空值(短过期),防重复穿透
async function getUser(id) {
    const key = `user:${id}`;
    let raw = await redis.get(key);
    if (raw !== null) {
        return raw === '__NULL__' ? null : JSON.parse(raw);
    }
    const user = await db.findUser(id);
    if (user) {
        await redis.set(key, JSON.stringify(user), 'EX', 3600);
    } else {
        // 空结果也缓存,但过期时间短(如 60s),避免新用户被持续穿透
        await redis.set(key, '__NULL__', 'EX', 60);
    }
    return user;
}

// 方案 B:布隆过滤器在入口层拦掉「一定不存在」的 ID
// 误判率可控(如 1%):判不存在 = 一定不存在,直接返回;判存在 = 再查缓存/DB

2. 缓存击穿:热点 Key 突然过期

某个爆款商品的缓存刚好过期,瞬间大量请求同时回源。核心思路是让只有一个请求去重建缓存:

// 互斥重建:抢到锁的请求回源,其余短暂等待后重试缓存
async function getHotProduct(id) {
    const key = `product:${id}`;
    const cached = await redis.get(key);
    if (cached) return JSON.parse(cached);

    const lockKey = `lock:rebuild:${key}`;
    const got = await redis.set(lockKey, '1', 'NX', 'EX', 10);
    if (got) {
        try {
            const data = await db.findProduct(id);
            // 逻辑过期:不设 TTL,值里带 expiresAt,由后台任务续期
            await redis.set(key, JSON.stringify(data), 'EX', 3600);
            return data;
        } finally {
            await redis.del(lockKey);
        }
    }
    await sleep(50);
    return getHotProduct(id); // 重试一次,此时缓存大概率已就绪
}

更工程化的做法是逻辑过期 + 异步重建:值永远不物理过期,读到「逻辑已过期」的数据时先返回旧值,同时丢一个后台任务去刷新——用户永远不等。

3. 缓存雪崩:大批 Key 同时失效或 Redis 整体挂掉

  • 过期时间加随机抖动:EX 3600 + rand(0, 600),避免整点集体过期;
  • 多级缓存:本地缓存(如进程内 LRU)扛住 Redis 故障瞬间的读流量;
  • 熔断降级:Redis 不可用时直接返回兜底数据或限流,别把全部流量放给数据库;
  • 高可用部署:哨兵或 Cluster,避免单点故障就是全站故障。

过期与内存淘汰

Redis 的删除是「惰性删除 + 定期抽样」的组合,所以内存并不会在 Key 过期的瞬间立刻释放,容量规划要留余量。当内存到达 maxmemory 时的行为由淘汰策略决定:

# redis.conf 常用配置
maxmemory 2gb
maxmemory-policy allkeys-lru     # 纯缓存场景:所有 Key 按 LRU 淘汰
# maxmemory-policy volatile-lru  # 混合场景:只淘汰设置了过期时间的 Key
# maxmemory-policy noeviction    # 当数据库用:内存满时写入报错(危险但可控)

选择原则:纯缓存用 allkeys-lru;Redis 里混着不能丢的数据(锁、队列)用 volatile-lru 并给缓存 Key 全部设上 TTL。noeviction 只适合「Redis 就是数据库」的场景。

分布式锁:SET NX EX 与释放校验

一个正确的 Redis 锁要同时解决三个问题:互斥(NX)、防死锁(EX)、防误删(value 校验)。释放锁必须用 Lua 保证「校验 + 删除」的原子性:

const crypto = require('crypto');

// 加锁:NX 保证互斥,EX 保证宕机后自动释放
async function acquireLock(redis, key, ttlMs = 30000) {
    const token = crypto.randomUUID();           // 唯一标识「这把锁是我的」
    const ok = await redis.set(key, token, 'NX', 'PX', ttlMs);
    return ok ? token : null;
}

// 解锁:Lua 脚本原子执行「比对 token + 删除」,防止删掉别人的锁
const UNLOCK_SCRIPT = `
    if redis.call('GET', KEYS[1]) == ARGV[1] then
        return redis.call('DEL', KEYS[1])
    end
    return 0
`;
async function releaseLock(redis, key, token) {
    await redis.eval(UNLOCK_SCRIPT, 1, key, token);
}

两个常见误区:

  • 锁过期了但业务还没跑完:需要看门狗(watchdog)自动续期,或者业务侧接受「锁过期即放弃互斥」并用幂等兜底;
  • 主从切换丢锁:异步复制下主节点刚写入锁就宕机,从节点还没同步,另一个客户端就能拿到锁。对正确性极敏感的场景,用 Redlock 或直接上 etcd / ZooKeeper。

Pipeline、事务与 Lua

# Pipeline:批量发命令、一次收结果,省的是 RTT(网络往返),不是原子性
# 伪代码:redis.pipeline().set('a', 1).set('b', 2).incr('c').exec()

# MULTI/EXEC:命令排队后原子执行,但没有回滚——中途失败前面的命令已生效
MULTI
INCR stock:1001
DECR user:7:points
EXEC

# Lua:真正的服务端原子脚本,适合「读-判断-写」组合
EVAL "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" 1 lock:order order-token-1

取舍:省网络开销用 Pipeline,要原子性用 Lua;MULTI/EXEC 因为不支持回滚、也不能在事务里读值做判断,实际使用频率反而最低。

持久化选型:RDB vs AOF

  • RDB:定时快照,文件小、恢复快;缺点是两次快照之间的数据会丢(分钟级);
  • AOF:追加写命令日志,appendfsync everysec 最多丢 1 秒;缺点是文件大、恢复慢;
  • 实践组合:同时开启,重启时优先用 AOF 恢复(数据更全),RDB 作为冷备。
# 常用持久化配置
save 900 1            # 900 秒内至少 1 次写入就触发 RDB
appendonly yes
appendfsync everysec  # 每秒刷盘(平衡性能与丢失窗口)

线上运维:大 Key 与热 Key

  • 大 Key(单个 Key 几百 KB 到几 MB):DEL 会阻塞主线程,删除用 UNLINK 异步回收;拆分 Hash 分桶存储;
  • 热 Key(单 Key QPS 上万):本地缓存顶一层、Key 加随机后缀分散到多个分片、读写分离到多个副本;
  • 发现手段:redis-cli --bigkeys 定期扫描,MONITOR 只在短时排查用(会拖慢实例);
  • 连接管理:应用侧用连接池并设置合理上限,避免高峰期把 maxclients 打满;
  • 禁用危险命令:KEYS * 换 SCAN 游标遍历;生产 rename-command 掉 FLUSHALL、CONFIG。
缓存的本质是「用一致性换性能」。每一处缓存都要能回答一个问题:数据不一致时,用户能接受多大误差?答不上来,就先别加。

总结

本文串起了 Redis 实战的主线:数据结构选型 → 穿透/击穿/雪崩的防御 → 过期淘汰策略 → 分布式锁的正确实现 → Pipeline/Lua → 持久化 → 线上运维。如果只记一句话:Redis 里的一切都要「可重建」——缓存能回源、锁能过期、计数能对账,这样它出故障时你才睡得着觉。