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 里的一切都要「可重建」——缓存能回源、锁能过期、计数能对账,这样它出故障时你才睡得着觉。