Redis 深入:数据结构选型、缓存三大问题与分布式锁
本文目录
Redis 在多数业务里同时扮演三个角色:缓存、计数器、协调原语(锁、轻量队列)。这三个角色对错误的容忍度完全不同——缓存错了大不了回源,锁错了可能重复扣款。混在一个实例、一套配置里用,是大量生产事故的共同起点。这篇文章按生产排查顺序展开:先定数据结构选型,再讲缓存穿透/击穿/雪崩的成因与对策,然后是过期与内存淘汰、分布式锁的正确写法与看门狗续期、Lua 原子性边界、持久化取舍,最后回到缓存与数据库一致性。示例基于 Redis 7.x 与 Node.js 客户端,命令行为以官方文档为准。
数据结构选型:按"怎么查"来定结构
选结构的第一问不是"数据长什么样",而是"你打算怎么查它"。查询方式定了,结构基本就定了:
| 结构 | 典型命令 | 适合的问题 | 不要用它做什么 |
|---|---|---|---|
| string | GET / SET / INCR | 计数器、限流计数、整体读写的值 | 存大 JSON 还频繁改一个字段(每次全量重写) |
| hash | HSET / HGET / HINCRBY | 对象局部读写:用户资料、购物车行、商品余量 | 字段超过几千个的超大 hash(HGETALL 是 O(N)) |
| list | LPUSH / LRANGE / LTRIM | 最新 N 条动态、固定长度时间线 | 无界队列(不配 LTRIM 就是内存事故) |
| set | SADD / SISMEMBER / SINTER | 去重、标签、共同关注交集 | 需要按权重排序的场景(用 zset) |
| zset | ZADD / ZRANGE / ZRANGEBYSCORE | 排行榜、延迟队列(score 存到期时间戳)、滑动窗口 | 百万成员还全量 ZRANGE 0 -1 |
| stream | XADD / XREADGROUP / XACK | 消费组、可回溯、需要 pending 确认的队列 | 超大吞吐日志管道(那是 Kafka 的领域) |
| geo | GEOADD / GEOSEARCH | 门店/附近的人(底层是 zset + geohash) | 高精度地理围栏(Redis 只做粗粒度索引) |
几个实践中反复验证的用法:
import { createClient } from 'redis';
const redis = createClient({ url: 'redis://127.0.0.1:6379' });
await redis.connect();
// 1. 计数器:INCR 是原子的,不要先 GET 再 SET
await redis.incr('stats:article:1001:views');
// 2. 对象局部更新:hash 只动一个字段,不重写整个对象
await redis.hSet('user:1001', { nickname: 'ada', level: 7 });
const level = await redis.hGet('user:1001', 'level');
await redis.hIncrBy('user:1001', 'loginCount', 1);
// 3. 固定长度时间线:LPUSH 之后必须紧跟 LTRIM
await redis.lPush('timeline:user:1001', 'post:90001');
await redis.lTrim('timeline:user:1001', 0, 999);
// 4. 排行榜:score 是分数,成员是 ID
await redis.zAdd('rank:game:2026-10-22', [
{ score: 95, value: 'user:1001' },
{ score: 88, value: 'user:1002' },
]);
const top10 = await redis.zRangeWithScores('rank:game:2026-10-22', 0, 9, { REV: true });
// 5. 延迟队列:score 存到期时间,定时轮询到期成员
const dueAt = Date.now() + 60_000;
await redis.zAdd('delay:jobs', [{ score: dueAt, value: 'job:order:timeout:5001' }]);
const due = await redis.zRangeByScore('delay:jobs', 0, Date.now(), { LIMIT: { from: 0, to: 100 } });
两条高频判断:list 与 stream 的取舍——消费后允许丢弃、不需要确认重试的轻量场景用 list;需要消费组、pending 列表、ack 语义(也就是"谁没处理完要能查出来")就上 stream。string 与 hash 的取舍——值只会整体读整体写用 string;会局部改字段、或字段要独立计数用 hash。把一个大 JSON 塞进 string 再用代码层做"读-改-写",在并发下还会引入丢失更新,是典型的反模式。
缓存穿透:查根本不存在的数据
成因
请求的 key 在缓存和数据库都不存在:恶意构造的不存在 ID、爬虫遍历枚举。每一击都绕过缓存直奔数据库,布再多缓存也没用。
对策一:空值缓存
把"确认不存在"也当成一种结果缓存起来,TTL 给短(30–60 秒),防止恶意 key 占住内存:
async function getUserWithNullCache(userId: string) {
const cacheKey = `user:${userId}`;
const cached = await redis.get(cacheKey);
if (cached === '__NULL__') return null; // 明确不存在,直接挡掉
if (cached !== null) return JSON.parse(cached);
const row = await db.query('SELECT * FROM users WHERE id = $1', [userId]);
if (row.length === 0) {
// 只有"确认不存在"才缓存空值;数据库超时/报错绝不能缓存,否则故障被放大成脏数据
await redis.set(cacheKey, '__NULL__', { EX: 60 });
return null;
}
await redis.set(cacheKey, JSON.stringify(row[0]), { EX: 3600 });
return row[0];
}
注意语义边界:__NULL__ 缓存的是"查询那一刻不存在",期间数据可能被创建。对"刚创建完立刻查"的业务,空值 TTL 越短越安全;对创建后要立即可见的场景(注册后马上登录),要么写路径主动删掉空值 key,要么不用空值缓存。
对策二:布隆过滤器
空值缓存挡不住攻击者每次换新 key。当 key 空间可枚举(比如订单号有规则)时,在缓存前面加一层布隆过滤器,先把"一定不存在"的请求拒掉:
# RedisBloom 模块提供 BF.* 命令;错误率与容量在建时就要定好
redis-cli BF.RESERVE user:filter 0.001 1000000
redis-cli BF.ADD user:filter 1001
redis-cli BF.EXISTS user:filter 1001 # 1
redis-cli BF.EXISTS user:filter 99999 # 0 -> 一定不存在,直接返回
布隆过滤器的三个性质必须记牢:说"不存在"就一定不存在;说"存在"只是可能存在(误判率就是建时定的错误率);标准布隆不支持删除。所以它适合"集合只增不删"的数据(订单号、已注册手机号),并在写入业务库时同步 BF.ADD。预算容量要按未来一年的规模估,超容后误判率上升,需要重建过滤器。
缓存击穿:热点 key 在过期瞬间被打穿
某个被高频访问的 key 过期的瞬间,成百上千个并发请求同时 miss,全部回源数据库重建缓存——数据库的连接池瞬间被打满。
对策一:互斥重建(拿不到锁就等或返回旧值)
async function getHotConfig(key: string) {
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const lockKey = `lock:rebuild:${key}`;
const token = crypto.randomUUID();
// 只放一个请求去重建,其余请求短暂等待后重试或返回降级值
const got = await redis.set(lockKey, token, { NX: true, EX: 10 });
if (got) {
try {
const fresh = await db.loadConfig(key);
await redis.set(key, JSON.stringify(fresh), { EX: 3600 });
return fresh;
} finally {
await releaseLock(lockKey, token); // 见下文分布式锁:带 token 校验的 Lua 删除
}
}
await sleep(50); // 等一等重建者
return getHotConfig(key); // 重试;生产环境要加重试次数上限
}
对策二:逻辑过期(不设 Redis TTL,过期时间写在 value 里)
interface LogicalValue<T> { expireAt: number; data: T; }
async function getWithLogicalExpiry(key: string, maxAgeMs: number) {
const raw = await redis.get(key);
if (!raw) return dbRebuild(key); // 首次加载并写入
const value: LogicalValue<unknown> = JSON.parse(raw);
if (value.expireAt > Date.now()) return value.data; // 未过期直接返回
const token = crypto.randomUUID();
const got = await redis.set(`lock:refresh:${key}`, token, { NX: true, EX: 10 });
if (got) {
// 只有一个请求异步刷新;其余请求继续用旧值——用短暂的数据陈旧换吞吐
refreshInBackground(key, maxAgeMs, token).catch(() => {});
}
return value.data;
}
| 方案 | 一致性窗口 | 吞吐 | 复杂度 | 适用 |
|---|---|---|---|---|
| 互斥重建 | 窗口极小(重建完成即可见) | 重建期其他请求等待 | 低,但要处理重试与超时 | 热点少、重建代价大 |
| 逻辑过期 | 窗口=刷新耗时,期间返回旧值 | 最高,永不 miss | 中,value 格式耦合 | 超热点、容忍陈旧(活动页、配置) |
缓存雪崩:大批失效或整体不可用
雪崩有两种形态,对策不同:
- 大批 key 同一时刻过期:同一批活动数据统一用
EX 3600,一小时后全部 miss。解法是 TTL 加随机抖动:
function jitteredTtl(baseSeconds: number, jitterRatio = 0.1): number {
const jitter = Math.floor(baseSeconds * jitterRatio);
return baseSeconds + Math.floor(Math.random() * (2 * jitter + 1)) - jitter;
}
await redis.set(key, value, { EX: jitteredTtl(3600) });
- Redis 整体不可用:主从切换失败、网络分区、大 key 引发阻塞。解法在部署与应用两层:部署层用主从 + 哨兵或 Cluster,避免单点;应用层要有本地二级缓存(保存低频变化数据的快照)与回源限流(对数据库侧用信号量限制并发回源数量),让"Redis 挂了"退化成"性能下降"而不是"数据库被打死"。预热也要错峰,脚本灌缓存不要全库一把梭。
过期删除与内存淘汰:两件不同的事
过期删除(key 到期后怎么消失):惰性删除(访问时检查)+ 定期删除(每秒多次抽样清理)。代价是存在"已到期但还占内存"的窗口,INFO keyspace 里能看到带 TTL 的键数量趋势。
内存淘汰(maxmemory 打满后怎么办):这是 maxmemory-policy 的事,8 种策略的选择比想象中重要:
| 策略 | 行为 | 适用 |
|---|---|---|
noeviction | 写入报 OOM 错误 | 纯存储型:锁、队列、计数器所在实例 |
allkeys-lru / allkeys-lfu | 全部 key 中淘汰最久未用/最少用 | 纯缓存实例(首选 lfu) |
volatile-lru / volatile-lfu | 只在设了 TTL 的 key 中淘汰 | 缓存与不设 TTL 的数据混用(不推荐混用,见下) |
allkeys-random / volatile-random | 随机淘汰 | 极少使用 |
volatile-ttl | 优先淘汰快到期的 | 极少使用 |
# 修改要同时写进配置文件,避免重启回退
redis-cli CONFIG SET maxmemory 4gb
redis-cli CONFIG SET maxmemory-policy allkeys-lfu
redis-cli CONFIG GET maxmemory-policy
redis-cli INFO memory | grep -E 'used_memory_human|maxmemory_human'
关键边界:缓存实例与锁/队列所在实例要物理隔离。一个 allkeys-lfu 的缓存实例里如果混放了分布式锁,内存紧张时锁可能被淘汰,锁语义直接崩坏。同理,缓存实例绝不能配 noeviction(会把淘汰问题变成写错误雪崩)。
分布式锁:单实例的正确写法
先说错误写法:SETNX key value 成功后再 EXPIRE key 10——两步之间进程崩溃就是永久的死锁。正确姿势是一条原子命令:
const token = crypto.randomUUID(); // 锁的持有者标识,删除时校验,防止误删别人的锁
const ok = await redis.set('lock:order:5001', token, { NX: true, EX: 10 });
释放锁必须"校验是我加的再删",这两步又要原子,交给 Lua:
-- KEYS[1] 锁键 ARGV[1] 持有者 token
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
async function releaseLock(lockKey: string, token: string): Promise<boolean> {
const result = await redis.eval(releaseScript, {
keys: [lockKey],
arguments: [token],
});
return result === 1;
}
不用 token 的版本有一个具体事故路径:持有者 A 因 GC 停顿超时,锁过期后 B 抢到;A 醒来执行 DEL,删掉的是 B 的锁;C 又抢到——两个非互斥的持有者同时工作。token 校验不能消除"A 超时后仍以为自己持锁"的问题,但至少保证不会删掉别人的锁。
锁续期:看门狗思想
锁的 TTL 是个两难:太短,业务没跑完锁就过期;太长,持锁进程崩了之后所有人在锁释放前都干等。看门狗(Redisson 客户端的默认机制)的思路是:TTL 给短,后台任务持续续期:
class WatchdogLock {
private timer?: NodeJS.Timeout;
async acquire(key: string, ttlMs = 30_000): Promise<boolean> {
this.token = crypto.randomUUID();
const ok = await redis.set(key, this.token, { NX: true, PX: ttlMs });
if (!ok) return false;
// 每 TTL/3 续一次:续期也是 Lua(校验 token 后 PEXPIRE)
this.timer = setInterval(() => this.renew(key, ttlMs), ttlMs / 3);
this.timer.unref();
return true;
}
async release(key: string): Promise<void> {
if (this.timer) clearInterval(this.timer);
await releaseLock(key, this.token!);
}
}
看门狗只能缓解"业务比预期慢",不能解决进程暂停与网络分区:进程冻结超过 TTL 时,续期失败而旧持有者无法感知,两个进程仍可能同时持锁。对正确性敏感的临界区(资金、库存扣减),锁只是第一道防线,后面必须还有唯一约束或状态机校验兜底(参见本站幂等设计一文)。
RedLock 的争议与实际建议
RedLock 通过向 N 个(通常 5 个)独立 Redis 实例依次加锁、过半成功且总耗时小于 TTL 才算持有,试图在无租期共识的情况下提供"多数派"安全。围绕它有一场著名争论:Martin Kleppmann 在《How to do distributed locking》(2016)中指出的核心问题有两个——依赖各节点时钟不跳变,以及进程暂停(GC、页面交换)会让锁在持有者无感知时失效,因此建议用 fencing token(单调递增令牌,资源端校验令牌序号)防护;Redis 作者 antirez 随后撰文回应了时钟与租期问题的处理方式。争论的工程结论可以这样落地:
| 场景 | 建议 |
|---|---|
| 效率型锁:避免重复计算/重复任务 | 单实例 SET NX EX + token 校验 + 幂等兜底,足够 |
| 正确性型锁:不允许并发修改的临界区 | 不要依赖 Redis 锁,用数据库事务/唯一约束,或具备共识与 fencing 能力的协调系统 |
| 必须用 Redis 锁且跨实例 | RedLock 可用,但必须接受时钟与暂停假设,并配合 fencing token |
Lua 保证原子性
Redis 单线程执行脚本期间不会插入其他客户端命令,所以 Lua 是"check-then-act"竞态的标准解法。例如库存扣减:
-- KEYS[1] 库存键 ARGV[1] 扣减数量
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock == nil then return -1 end
if stock < tonumber(ARGV[1]) then return 0 end
redis.call('DECRBY', KEYS[1], ARGV[1])
return 1
const allowed = await redis.eval(stockScript, { keys: ['stock:sku:9001'], arguments: ['1'] });
if (allowed === 0) throw new Error('SOLD_OUT');
三条边界:脚本执行是阻塞的,不要写长循环,复杂逻辑放到应用层;脚本运行错误不回滚,报错前已经执行的写会生效,所以校验要放在写之前;Cluster 下脚本涉及的 key 必须落在同一槽位,跨槽要靠 hash tag(如 stock:{sku:9001})把相关 key 归到一起,或者放弃脚本改客户端逻辑。
持久化:RDB 与 AOF 的取舍
| 维度 | RDB(快照) | AOF(追加日志) |
|---|---|---|
| 形态 | 某时刻全量二进制 | 每条写命令追加 |
| 数据窗口 | 两次快照之间的数据会丢 | everysec 最多丢约 1 秒 |
| 恢复速度 | 快(直接载入) | 慢(重放全量命令) |
| 性能影响 | fork 瞬间抖动 + COW 内存膨胀 | 持续小量写 + 定期 bgrewriteaof |
RDB 与 AOF 重写都要 fork 子进程:fork 本身要复制页表,实例越大抖动越明显;子进程写期间父进程改动的内存页会真实复制,内存开销可能接近翻倍,宿主机要预留余量。Redis 4.0+ 支持混合持久化(aof-use-rdb-preamble yes),AOF 重写产物 = RDB 头 + 增量命令,兼顾恢复速度与数据窗口,AOF 实例建议直接开启。
最重要的边界:复制是异步的。主实例写入后不等从实例确认,主实例宕机时最后一次复制窗口内的写入会丢。min-replicas-to-write / min-replicas-max-lag 可以降低窗口但引入拒绝写入的风险。缓存场景丢数据可接受(回源重建);锁、队列、计数器场景要评估丢失量,必要时改用专门的持久化队列。
缓存与数据库一致性
标准模式是旁路缓存(cache-aside):读——先查缓存,miss 回源并回填;写——更新数据库,然后删除缓存(不是更新缓存:并发写时"更新缓存"存在乱序覆盖,且可能缓存了计算成本高的视图)。即便如此仍有窗口:删除缓存后、他人读取回填前,数据库又变了——回填的是旧值。
延迟双删把窗口压小:更新前删一次,更新后延迟几百毫秒再删一次,把"读请求回填旧值"的时间覆盖掉:
async function updateProduct(id: string, patch: object) {
await redis.del(`product:${id}`); // 第一删:挡住更新前开始的回填
await db.update('products', patch).where({ id });
setTimeout(() => redis.del(`product:${id}`), 500); // 第二删:延迟取"读链路 P99 + 余量",无理论值
}
延迟双删的两个没被解决的部分:第二删失败(要靠重试队列补删);延迟时间靠经验(读慢的时候窗口照样存在)。更彻底的思路是订阅 binlog:一个消费组件伪装成数据库从库拉取 binlog,按表映射去删缓存。好处是业务写路径与缓存失效解耦、漏删可重放、顺序跟随 binlog;代价是链路更长、仍是最终一致。所以一致性设计的目标要明确:不是零窗口,而是窗口短(TTL 兜底)+ 关键读走库(支付状态这类强一致字段不要走缓存)。
注意事项与边界
- 生产环境禁用
KEYS,遍历用SCAN渐进式完成;SMEMBERS、HGETALL、LRANGE 0 -1在大 key 上都是 O(N),会阻塞单线程,先DEBUG OBJECT或用工具评估体量再操作。 - 大 key 的治理优先级高于调优:单个 string 不要超过几十 KB,集合类成员数控制在千级,超限就拆分(如按 user_id 哈希分桶)。
- 序列化格式要带版本演进策略:加字段可以,改字段语义不行,最好缓存值里嵌一个 schema 版本号,不匹配就当 miss。
- 所有 Redis 调用都要设置客户端超时(
command_timeout)与断线重连策略,慢命令会让没设超时的调用方整串挂起。 - 用
SLOWLOG GET与 latency monitor 常态化观测慢命令,延迟突增先查是不是 fork、AOF fsync、bigkey、热 key 打满单线程。
总结
Redis 的可靠性来自三件事:结构选对(按查询方式选结构,警惕 O(N) 命令)、角色隔离(缓存、锁、队列分实例分策略,别让 LRU 淘汰掉你的锁)、语义清醒(锁只能用于效率,正确性靠数据库约束兜底;缓存一致性只追求短窗口 + TTL 兜底)。穿透、击穿、雪崩各有对应解法,但共同的前提是回源路径本身有限流保护——缓存挡住的流量一旦全部落到数据库,任何缓存层都救不了。
参考
Live Notes
留言提交后会先进入审核,审核通过后对其他读者公开。