Hey~ friends,今天想跟大家分享一下Redis那些让人又爱又恨的使用场景,以及背后的一些神奇机制!整理这篇文章的时候我真的好希望能让10年前踩坑的自己看到😂
📌 第一部分:Redis最常见的五大使用场景
1️⃣ Cache-Aside 缓存大法好
性能提升的制胜法宝 在我们的电商系统中,商品详情页访问量巨大,如果每次都查数据库,怕是要把数据库给整垮了!上Redis缓存后,性能直接起飞 🚀!
Cache-Aside 缓存模式(旁路缓存模式),是非常经典的使用场景,流程很简单:
- 先查缓存
- 缓存没有,查数据库
- 放入缓存
先看示例代码
const Redis = require('ioredis');
const redis = new Redis();
async function getProductDetail(productId) {
// 先查缓存
const cacheKey = `product:${productId}`;
let product = await redis.get(cacheKey);
if (product) {
console.log('命中缓存,火箭速度!🚀');
return JSON.parse(product);
}
// 缓存没有,查数据库
console.log('未命中缓存,去数据库找找...');
product = await db.findProduct(productId);
// 放入缓存,设置1小时过期
await redis.setex(cacheKey, 3600, JSON.stringify(product));
return product;
}
🌟 经验分享
数据不要设置固定的过期时间
不要对所有数据设置固定过期时间,需要业务理解,根据数据的热度设置不同的过期时间,比如在电商场景下,热门商品的过期时间和普通商品的过期时间是不同的。
这样做既能提高用户体验: 热门商品的长时间缓存可以确保用户在频繁访问这些商品时,能够快速获取数据,减少等待时间。 也能保障资源利用率: 在缓存满负荷时,这些相对不热门的数据可以更快地被更新替换,提高缓存整体的利用效率。
// 👎 不推荐:统一设置固定过期时间
await redis.setex(key, 3600, value);
// 👍 推荐:根据数据特性设置不同过期时间
const getTTL = (product) => {
if (product.type === 'hot') return 1800; // 热门商品 30分钟
if (product.type === 'normal') return 3600; // 普通商品 1小时
return 7200; // 其他商品 2小时
}
await redis.setex(key, getTTL(product), value);
缓存删除 而不是更新
在旁路缓存策略下,我看到过很多这种代码
// 🙅 错误示例:先更新数据库,再更新缓存
async function updateUserWrong(userId, newData) {
await db.updateUser(userId, newData); // 第1步:更新数据库
await cache.set(`user:${userId}`, newData); // 第2步:更新缓存
// 问题:并发情况下可能导致脏数据
}
场景演示:假设有两个并发请求要更新同一个用户数据:
- 请求A:更新用户年龄为20
- 请求B:更新用户年龄为30
时间线 请求A 请求B
|
1秒 读取原始数据(年龄=18)
|
2秒 读取原始数据(年龄=18)
|
3秒 更新数据库(年龄=20)
|
4秒 更新数据库(年龄=30)
|
5秒 更新缓存(年龄=30)
|
6秒 更新缓存(年龄=20)
|
结果: 数据库中年龄=30,但缓存中年龄=20(脏数据)
A的更新覆盖了B的更新,导致了缓存不一致。数据库最后是B更新的,所以最新数据缓存应该是30。
正确做法 ✅
async function updateUserRight(userId, newData) {
await cache.del(`user:${userId}`); // 第1步:删除缓存
await db.updateUser(userId, newData); // 第2步:更新数据库
}
// 执行顺序:
// 1. A删除缓存
// 2. A更新数据库:年龄=20
// 3. B删除缓存
// 4. B更新数据库:年龄=30
// 5. 下次读取时,会从数据库加载最新值(30)
选择 "先删缓存,后更新数据库" 的方式简单可靠,出现不一致的时间窗口最小。
只要用了缓存一定要有预期:缓存中的数据和数据库可能是不一致的
那么对于一致性要求高的场景该怎么办呢? 答案:不用缓存!在某些场景下这句话不是开玩笑的。
对于一致性要求高的场景需要更严格的机制:
机制1: 延迟双删
- 第一次删除缓存:在更新数据库之前先删除 Redis 中的缓存数据,这是为了防止在更新数据库的过程中,有请求读取到旧的缓存数据并返回给用户,造成数据不一致。
- 延迟一段时间再次删除缓存:由于数据库更新操作可能需要一定时间才能完成,在这段时间内可能已经有请求在第一次删除缓存后去数据库查询了旧数据并写入了缓存。所以在更新数据库完成后,再延迟一段时间进行第二次删除缓存操作,就能确保即使在数据库更新期间有请求查询了旧数据并写入缓存,也能再次将其删除,从而保证后续请求从缓存中获取到的是最新的数据,避免了数据不一致的问题。
class UserService {
async updateUser(userId, newData) {
// 1. 第一次删除缓存
await cache.del(`user:${userId}`); // 50ms
// 2. 更新数据库
await db.updateUser(userId, newData); // 300ms
// 3. 延迟删除缓存
setTimeout(async () => {
await cache.del(`user:${userId}`);
}, 500); // 延迟时间根据业务情况设置 一定要大于 50ms + 300ms
}
}
这个机制的难点在于:延迟多久?上面每个每个操作都是盲猜比如 30ms,50ms。任何一个操作延迟了,这个删除都可能失败。所以需要考虑:
- 动态延迟
比如记录最近100次操作耗时, 在setTimeout时使用这个动态时间。
this.readTimings = new Array(100); // 存储最近100次读操作耗时
getDelayTime() {
const avg = this.readTimings.reduce((a, b) => a + b, 0) / this.readTimings.length;
const max = Math.max(...this.readTimings);
return Math.max(avg * 2, max * 1.2); // 留足余量
}
- 删除失败重试
重试机制就简单了, try catch 一下redis 返回结果,如果 catch到了失败就setTimeout 继续删除
async function delayDelete(key, retries = 3) {
const doDelete = async (attempt) => {
try {
await cache.del(key);
} catch (error) {
if (attempt < retries) {
// 递增重试延迟
const delay = Math.pow(2, attempt) * 100;
setTimeout(() => doDelete(attempt + 1), delay);
} else {
// 记录失败
metrics.recordDeleteFailure(key);
}
}
};
setTimeout(() => doDelete(0), 500);
}
这里的记录失败还是挺重要的 metrics.recordDeleteFailure 我们可以根据这些数据来看看时间是不是设置的有问题。
机制2: 消息队列
消息队列这个方式比双删更可靠一些,但是成本就是多了一个消息队列的组件。能hold住的或者系统已经集成了消息队列的,那解耦这个缓存更新操作再好不过了。
// 1. 更新服务
class UserUpdateService {
async updateUser(userId, newData) {
// 1. 更新数据库
await db.updateUser(userId, newData);
// 2. 这里注意,我们没有删redis缓存,而是发送更新消息
await messageQueue.send('user-updated', {
userId,
timestamp: Date.now(),
data: newData
});
}
}
// 2. 缓存更新消费者
class CacheUpdateConsumer {
async handleMessage(message) {
const {userId, timestamp, data} = message;
// 1. 获取当前缓存
const cached = await cache.get(`user:${userId}`);
// 2. 检查时间戳,避免更新旧数据
if (cached && cached.timestamp >= timestamp) {
return; // 跳过旧消息
}
// 3. 更新缓存
await cache.set(`user:${userId}`, {
data,
timestamp
});
}
}
// 3. 读取服务
class UserReadService {
async getUser(userId) {
const cached = await cache.get(`user:${userId}`);
if (cached) {
return cached.data;
}
// 缓存未命中,从数据库读取
const data = await db.getUser(userId);
await cache.set(`user:${userId}`, {
data,
timestamp: Date.now()
});
return data;
}
}
有了消息队列我们就有了最终一致性保障,可以利用消息队列的重试和补偿机制。方案比定时器双删更可靠了。
缓存预热
大促要来了,商品页要被刷爆了,库要被查崩了。那我们需要提前就把热点数据存到缓存里。防止数据库被刷爆。
class CacheWarmer {
async warmup() {
console.log('开始预热缓存...');
// 1. 获取热门商品
const hotProducts = await db.query(`
SELECT * FROM products
WHERE views > 1000
ORDER BY views DESC
LIMIT 100
`);
// 2. 批量加载到缓存
for (const product of hotProducts) {
await cache.set(
`product:${product.id}`,
product,
{expire: 3600} // 1小时过期
);
}
console.log(`预热完成: ${hotProducts.length} 个商品`);
}
}
// 使用示例
const warmer = new CacheWarmer();
await warmer.warmup();
一个基本的预热就完成了,但是作为生产预热了无数次数据的我,给大家分享一些不一样的:
智能预热
-
结合商品打点数据设置 TTL
其实哪些商品热,哪些商品不热。除了运营有一些预期外,更多的产品是无意变成热点产品的。结合商品的访问打点日志,给那些真实热度生成热点数据设置较长的TTL可以极大的提升内存使用率 -
多级缓存预热
缓存别只用redis, 本地缓存 和 CDN 缓存也用起来 -
选择合适的预热时机 大促时流量这么大,跑个预热数据就可以让系统崩掉。那预热时我们除了观测用户流量低峰,还可以根据系统负载进行预热,获取系统复杂情况,比如 > 0.8 慢预热。 <0.5 快预热
防止缓存穿透
如果我伪造一些请求,故意请求缓存里没有的数据,那么这个请求缓存找不到就去找数据库。这就叫做缓存穿透。
除了后面会降到的限流外,最简单的做法是缓存一个空值
class ProductService {
async getProduct(id) {
// 1. 查询缓存
const cached = await cache.get(`product:${id}`);
// 2. 如果有缓存,直接返回(包括空值)
if (cached !== undefined) {
// 注意:这里特意用 undefined 判断,因为 null 是我们缓存的空值
return cached === null ? null : cached;
}
// 3. 查询数据库
const product = await db.findProduct(id);
// 4. 无论是否存在,都缓存结果
await cache.set(
`product:${id}`,
product || null, // 存在则缓存商品,不存在则缓存null
{expire: 300} // 空值缓存时间短一些,比如5分钟
);
return product;
}
}
既然你访问的数据,数据库里面也不存在,那我就缓存这个“不存在”。让你每次都差缓存,无法穿到我的数据库里面。我个人认为缓存空值非常完美,除了占用的内存有点多。
但是和布隆过滤器对比,我可太喜欢缓存空值了。布隆过滤器是另一个防止缓存穿透的手段: (Bloom Filter)是一种空间效率极高的概率型数据结构(理解成一个hash表就可以),这个东西不仅仅是缓存了。在计算机领域到处都是他的影子,比如流数据处理,网址去重,垃圾邮件过滤,包括前面的缓存预热也可以使用。
他只是个数据结构,我就不解释原理了,我们拿来就用。
class BloomFilterService {
constructor() {
// 初始化布隆过滤器
this.bloomFilter = new BloomFilter({
size: 100000, // 预期元素数量
errorRate: 0.01 // 允许的错误率
});
}
// 初始化过滤器(系统启动时调用)
async initializeFilter() {
// 从数据库加载所有商品ID
const productIds = await db.getAllProductIds();
// 添加到布隆过滤器
for (const id of productIds) {
this.bloomFilter.add(id);
}
}
async getProduct(id) {
// 1. 布隆过滤器检查
if (!this.bloomFilter.mightContain(id)) {
return null; // 一定不存在
}
// 2. 查询缓存
const cached = await cache.get(`product:${id}`);
if (cached !== undefined) {
return cached;
}
// 3. 查询数据库
const product = await db.findProduct(id);
// 4. 更新缓存
await cache.set(`product:${id}`, product || null);
return product;
}
// 添加新商品时更新布隆过滤器
async addNewProduct(product) {
await db.saveProduct(product);
this.bloomFilter.add(product.id);
}
}
通过在缓存前架了一层布隆过滤器,直接让恶意请求都返回了。
实际生产的防护中比这个复杂一点,是组合策略,即多层防护:参数验证、限流、布隆过滤器。通过监控如果检测到这种异常请求会直接返回 temporary_unavailable
缓存雪崩
飞机晚点了,所有乘客都生气的去柜台要说法,原本排队处理的柜台被挤爆了。这种现象叫做缓存雪崩即缓存的key同一时间失效了,都去查库了。
最直觉的做法就是别让他同一时间过期,通过加随机值
封装一下redis,让set时候这个值随机一下:
async set(key, value, baseExpiry = 3600) {
// 基础过期时间加上随机值
const randomExpiry = this.getRandomExpiry(baseExpiry);
await cache.set(key, value, { expire: randomExpiry }); }
多级缓存在系统中也能降低雪崩的程度,通常在缓存前我们会放一个内存作为1级缓存,1级缓存过期时间比 redis 要短,redis 作为 2级缓存。
另一个直接做法就是别让他过期,比如前面的热点数据,我们可以有个refresh机制,让数据无限的续命。
如果真的缓存同时过期了,那为了防止并发读数据库重建那我们读的操作也可以上锁,但是不建议这么干,性能会大打折扣。
序列化/反序列化处理
对于redis 数据库类型的序列化和反序列化内容比较碎,每种数据类型都有一些特殊处理。使用 Node.js 的新手可能会在 set 时忘记stringify 直接 set进去一个 [Object]。那其实其他 js 数据类型也要单独处理的,比如 Date,Regexp,Set,Map。如果要存这些格式都要自己处理一下序列化逻辑(以及取出后也要执行反序列化)。因为毕竟这是 JS 的类型,Redis 不认识。
除了数据类型外,数据量大可以压缩后写入redis来节省内存。但是使用 JS 和 Python, Ruby 还需要注意循环引用的问题。
let arr = [];
arr.push(arr);
给个思考题,v8 垃圾回收能回收这个arr吗?
再看2个循环引用的例子
let obj1 = {};
let obj2 = {};
// 让obj1的一个属性引用obj2,同时让obj2的一个属性引用obj1
obj1.prop = obj2;
obj2.prop = obj1;
function createCycle() {
let outer = {};
function inner() {
// 内部函数引用了外部函数的变量(对象)
outer.someProp = inner;
}
inner();
return outer;
}
let cycleObj = createCycle();
怎么解决这种循环引用的问题呢?
- 手动解除引用
- 使用弱引用
那我们写个序列化函数 for 这种循环引用的情况
class CircularSerializer {
serialize(obj) {
const seen = new WeakSet();
return JSON.stringify(obj, (key, value) => {
// 处理循环引用
if (typeof value === 'object' && value !== null) {
if (seen.has(value)) {
return '[Circular]';
}
seen.add(value);
}
return value;
});
}
deserialize(data) {
const references = new Map();
return JSON.parse(data, (key, value) => {
if (value === '[Circular]') {
// 返回引用
return references.get(key);
}
if (typeof value === 'object' && value !== null) {
references.set(key, value);
}
return value;
});
}
}
CircularSerializer类就是通过WeakSet和Map来处理循环引用的。在serialize方法中,使用WeakSet来检测对象是否已经被处理过,如果是则返回[Circular]字符串表示循环引用。在deserialize方法中,使用Map来存储对象的引用,当遇到[Circular]字符串时,从Map中获取对应的引用返回,从而正确处理循环引用。
如果不处理这个循环引用的话,都没办法存到JSON里,更没办法存到 Redis 里。
2️⃣ 分布式锁-并发问题终结者
还记得那次双11活动,没用分布式锁导致超卖了1000件商品的惨痛经历吗?😱 后来用Redis的分布式锁完美解决了这个问题!
我加了把假 🔐?
加锁并不只是一个标记位而已,它需要考虑互斥性,原子性,防止死锁以及防止误释放,所以不能直接使用 set 命令做一个标记位,要使用 SET NX EX 命令。
// ❌ 错误示范:使用普通set命令
await redis.set(lockKey, value); // 没有保证原子性和互斥性
// ✅ 正确做法:使用SET NX EX命令
async function acquireLock(lockKey, clientId, ttl = 30) {
// SET NX(Not Exists)保证互斥性
// EX 设置过期时间,防止死锁
// clientId用于标识锁的持有者,防止误释放
return await redis.set(lockKey, clientId, 'NX', 'EX', ttl);
}
你可能认为只要一个客户端获取了锁,其他客户端就无法对同一个lockKey进行操作。但实际上 Redis 是key-value 存储系统,它本身没有像传统数据库那样复杂的事务和锁管理机制。从 Redis 服务器的角度来看,它只是根据这些命令的语义来存储或修改键值对,并没有内在的机制去阻止其他客户端对已经被 “锁定” 的键进行操作。
也就是说“锁定”是要靠应用层逻辑来控制的,要求客户端遵守这个“协议”。假设有两个客户端 A 和 B,A 通过SETNX获取了一个名为lockKey的锁。但是 B 并不知道lockKey被 A 当作了锁,B 可以直接执行SET(使用过NX选项) lockKey new_value,这样就修改了lockKey的值,这在 Redis 看来是完全正常的操作,因为 Redis 没有像传统数据库那样的机制去阻止 B 的这种行为,除非在应用层(也就是客户端代码)有额外的逻辑来检查和避免这种情况。
那我们得出结论:基于redis的锁,实现锁机制的责任在客户端!
让我们看一个实际的例子:
async function createOrder(userId, productId) {
const lockKey = `lock:order:${productId}`;
// 获取锁,设置超时时间,防止死锁
const locked = await redis.set(lockKey, userId, 'NX', 'EX', 10);
if (!locked) {
throw new Error('哎呀,别人正在下单,请稍后再试~');
}
try {
// 检查库存、创建订单等操作
await processOrder(userId, productId);
} finally {
// 记得释放锁!
await redis.del(lockKey);
}
}
这里是拿业务数据 userId 作为锁的标识,来防止误释放锁。当然你也可以使用 uuid 或者加入进程号之类的信息来确保锁的唯一性,这样就不用担心误释放锁了。
安全的解锁方式
但是这个释放锁在分布式环境中是有潜在风险的, 举个例子说明:
const Redis = require('ioredis');
const redis = new Redis();
async function releaseLock(redisClient, lockKey, lockValue) {
const currentValue = await redisClient.get(lockKey);
if (currentValue && currentValue === lockValue) {
// 这里存在一个时间间隙,在检查和删除之间可能会有其他操作修改锁的值
await redisClient.del(lockKey);
return true;
}
return false;
}
上面这个时间间隙如何理解呢?
假设存在三个客户端(A、B、C)和一个带有过期时间的 Redis 锁。客户端 A 成功获取了锁,并且锁的值设置为 A 的clientid。在 A 完成操作准备释放锁时,它首先检查锁的值是否为自己的clientid。此时,由于网络延迟或者其他任务的处理,在 A 还没有执行删除锁操作之前,锁的过期时间到了。 Redis 会将锁释放,然后客户端 B 可能会立即获取这个锁,并将锁的值设置为 B 的clientid。当 A 的操作继续执行删除锁操作时,虽然 A 在之前检查锁值时是自己的clientid,但在这中间锁已经被 B 获取并修改了值,A 就会误释放 B 持有的锁。所以,仅靠clientid作为唯一标识码不能完全防止锁被误释放。
文字阅读起来可能没有代码直观:
const Redis = require('ioredis');
const redis = new Redis();
// 问题示例
async function demonstrateProblem() {
const lockKey = 'resource_lock';
const clientA = 'client_A';
const clientB = 'client_B';
// 模拟客户端A获取锁
await redis.set(lockKey, clientA, 'NX', 'PX', 5000); // 5秒过期
// 模拟A的操作延迟
console.log('Client A is processing...');
await new Promise(resolve => setTimeout(resolve, 6000)); // 故意等待6秒
// 客户端A尝试释放锁,先获取clientid
const currentValue = await redis.get(lockKey);
// 此时锁已过期,客户端B可以获取锁
await redis.set(lockKey, clientB, 'NX', 'PX', 5000);
console.log('Client B acquired the lock');
// 这时 A 的currentValue 还是 clientA 导致把 B 的锁给释放了
if (currentValue === clientA) { // clientid 这个检查不够安全
await redis.del(lockKey);
console.log('Client A released the lock (wrongly!)');
}
}
如何消除这个时间间隙呢?需要将 读 和 删 这两个操作变成原子操作,在Redis 中可以通过 Lua脚本实现:
const releaseLockScript = `
local currentValue = redis.call('GET', KEYS[1])
if currentValue == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end
`;
async function releaseLock(redisClient, lockKey, lockValue) {
const script = redisClient.defineCommand('releaseLockScript', {
numberOfKeys: 1,
lua: releaseLockScript
});
const result = await redisClient.releaseLockScript(lockKey, lockValue);
return result === 1;
}
也可以通过 reids 事务将多个操作合并到一起,感兴趣的同学可以自行尝试。
被锁后,其他客户端如何重试获取锁?
首先来看一个错误的例子:
// ❌ 错误示范:立即重试,造成不必要的压力
async function acquireLockWithRetry(lockKey) {
while (true) {
if (await redis.set(lockKey, clientId, 'NX', 'EX', 30)) {
return true;
}
}
}
// ✅ 正确做法:使用退避策略
那我们如何重试呢?需要根据业务场景来定义不同策略:
- 对于短期锁:使用自旋锁或简单重试
- 对于高并发场景:使用指数退避
- 对于长期锁:使用订阅-发布模式
- 对于复杂关键业务:组合使用多种策略,就真的根据业务自定义了
但要注意的是,简单重试时不要使用固定间隔时间,可能造成"惊群效应",所有客户端同时重试。
使用指数退避是一个经济性高的做法:
async function acquireLockWithRetry(lockKey, maxRetries = 3) {
for (let i = 0; i < maxRetries; i++) {
if (await redis.set(lockKey, clientId, 'NX', 'EX', 30)) {
return true;
}
// 指数退避
await new Promise(resolve =>
setTimeout(resolve, Math.min(100 * Math.pow(2, i), 2000))
);
}
return false;
}
这里的指数退避可以实现简单又避免了惊群效应,但是它等待时间长,想等待时间短的话可以使用自旋锁。不过自旋锁适合短期的锁,因为他对CPU 压力较大,一直转太吃资源
async acquireLockWithSpinning(lockKey, clientId, ttl = 30000) {
const timeout = 10000; // 10秒总超时
const spinningInterval = 100; // 100ms自旋间隔
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
const acquired = await this.redis.eval(
this.lockScript,
1,
lockKey,
clientId,
ttl
);
if (acquired) {
return true;
}
// 短暂等待后继续尝试
await new Promise(resolve => setTimeout(resolve, spinningInterval));
}
return false;
}
长期的锁,一直等他也不合适,应该使用订阅-发布模式,这个模式下,客户端可以订阅锁的释放通知,一旦锁释放,客户端就可以获取锁。
async acquireLockWithPubSub(lockKey, clientId, ttl = 30000) {
const subscriber = this.redis.duplicate();
const channel = `lock:${lockKey}:released`;
try {
// 先尝试获取锁
const acquired = await this.redis.eval(
this.lockScript,
1,
lockKey,
clientId,
ttl
);
if (acquired) {
return true;
}
// 如果获取失败,订阅锁释放事件
return new Promise((resolve) => {
subscriber.subscribe(channel);
subscriber.on('message', async (chan, message) => {
if (chan === channel) {
// 收到释放消息后尝试获取锁
const acquired = await this.redis.eval(
this.lockScript,
1,
lockKey,
clientId,
ttl
);
if (acquired) {
subscriber.unsubscribe();
subscriber.quit();
resolve(true);
}
}
});
// 设置订阅超时
setTimeout(() => {
subscriber.unsubscribe();
subscriber.quit();
resolve(false);
}, 10000);
});
} catch (error) {
subscriber.quit();
throw error;
}
}
}
业务没处理完,锁到期了怎么办?
想象你去银行办理业务,银行给你一个号码牌,上面写着"有效时间30分钟"。但是你的业务很复杂,可能需要45分钟才能办完。这时候会出现两个问题:
- 如果严格按照30分钟的限制,你的业务还没办完就被赶出柜台,下一个人就可以来办理,这样你的业务就中断了。
- 如果让你继续办,但是号码牌已经过期了,其他客户看到过期了就来抢柜台了。
在分布式系统中锁也会遇到同样的问题。那么有几种解决方案:
- 续约机制
办理业务的时候,可以在25分钟的时候跟银行说:"我还需要多一点时间",银行就给你延长了有效期。这就是"续约",在业务执行过程中,定期更新锁的过期时间。 - 看门狗机制
银行安排一个保安(看门狗,这是个技术term 没有不尊重任何职业)专门负责观察你的业务进度,在你的号码牌快要过期的时候,自动帮你续约。这样你就可以专心办理业务,不用担心时间问题。 - 补偿机制
如果真的锁过期了,业务也中断了,就像银行给你一个专门的补办窗口,让你带着之前的材料继续完成未完成的业务。在系统中,我们会记录业务进度,如果中断了可以从断点继续。 - 预估机制
事先评估业务需要多长时间,就像你去银行之前,先估计自己的业务大概需要多久,然后申请足够长的号码牌有效期。在系统中,我们会根据业务复杂度设置合适的锁过期时间。
所以在实际应用中,我们通常会:
- 设置一个合理的初始过期时间
- 在业务执行过程中自动续约
- 准备好补偿机制以应对意外情况
- 监控整个过程,发现异常及时处理
上代码:
class DistributedLock {
constructor(redis) {
this.redis = redis;
// 续约Lua脚本:只有当锁的持有者是当前客户端时才续约
this.renewScript = `
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('pexpire', KEYS[1], ARGV[2])
else
return 0
end
`;
}
// 看门狗类:负责自动续约
async startWatchdog(lockKey, clientId, ttl) {
const renewInterval = Math.floor(ttl / 3); // 在TTL的1/3时进行续约
const watchdog = setInterval(async () => {
try {
// 尝试续约
const renewed = await this.redis.eval(
this.renewScript,
1,
lockKey,
clientId,
ttl
);
if (renewed === 0) {
// 续约失败,说明锁已经不属于我们了
console.log('Lock lost, stopping watchdog');
clearInterval(watchdog);
} else {
console.log('Lock renewed successfully');
}
} catch (error) {
console.error('Error renewing lock:', error);
clearInterval(watchdog);
}
}, renewInterval);
return watchdog;
}
// 使用看门狗机制的锁
async acquireLockWithWatchdog(lockKey, clientId, ttl = 30000) {
try {
// 尝试获取锁
const acquired = await this.redis.set(
lockKey,
clientId,
'NX',
'PX',
ttl
);
if (acquired === 'OK') {
// 启动看门狗进行自动续约
const watchdog = await this.startWatchdog(lockKey, clientId, ttl);
return { acquired: true, watchdog };
}
return { acquired: false, watchdog: null };
} catch (error) {
console.error('Error acquiring lock:', error);
return { acquired: false, watchdog: null };
}
}
// 释放锁
async releaseLock(lockKey, clientId, watchdog) {
try {
// 停止看门狗
if (watchdog) {
clearInterval(watchdog);
}
// 使用Lua脚本确保只释放自己的锁
const releaseScript = `
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
`;
const released = await this.redis.eval(
releaseScript,
1,
lockKey,
clientId
);
return released === 1;
} catch (error) {
console.error('Error releasing lock:', error);
return false;
}
}
}
// 使用示例
async function demonstrateLockWithWatchdog() {
const redis = new Redis();
const lock = new DistributedLock(redis);
const lockKey = 'business:123:lock';
const clientId = `client-${Date.now()}`;
try {
console.log('Trying to acquire lock...');
// 获取锁,TTL设置为30秒
const { acquired, watchdog } = await lock.acquireLockWithWatchdog(
lockKey,
clientId,
30000
);
if (acquired) {
console.log('Lock acquired, starting business logic...');
try {
// 模拟长时间运行的业务逻辑
await simulateLongRunningBusiness();
console.log('Business logic completed successfully');
} catch (error) {
console.error('Business logic failed:', error);
// 这里可以添加补偿逻辑
} finally {
// 释放锁并停止看门狗
const released = await lock.releaseLock(lockKey, clientId, watchdog);
console.log('Lock released:', released);
}
} else {
console.log('Failed to acquire lock');
}
} catch (error) {
console.error('Error:', error);
} finally {
redis.quit();
å }
}
考虑锁的可重入性
想象你进入一个需要门卡的办公室:
第一次刷卡进入办公室(获取锁) 你出去接水时,如果是不可重入的锁:
- 即使你手里有门卡也要等别人从办公室出来才能再次进入
但如果是可重入的锁:
- 因为系统记住了"这个门卡之前已经进来过"所以你可以直接再次刷卡进入
通过这个例子我们知道:可重入锁(Reentrant Lock)允许同一个客户端(或线程)多次获取同一个锁而不会造成死锁。
async function example() {
const lock = new RedisReentrantLock(redis, 'room-123');
// 第一次进入
await lock.acquire(); // lockCount = 1
// 第二次进入(重入)
await lock.acquire(); // lockCount = 2
// 第一次离开
await lock.release(); // lockCount = 1
// 第二次离开
await lock.release(); // lockCount = 0,钥匙被取走
}
通过这个例子我们可以看到,可重入锁的实现是通过计数器。整个实现关键包括:
- 锁的标识 lockValue:来确定唯一持有者,防止不同客户端之间干扰,可以用 uuid 生成
- 计数器 lockCount:acquire成功获取+1,release释放-1,当计数为0时真正删除锁
下面看代码:
// RedisReentrantLock.js
const Redis = require('ioredis');
const { v4: uuidv4 } = require('uuid');
class RedisReentrantLock {
constructor(redisClient, lockKey) {
this.redis = redisClient;
this.lockKey = lockKey;
this.lockValue = uuidv4(); // 用于标识锁的持有者
this.lockCount = 0; // 重入计数
}
async acquire(timeoutMs = 5000) {
const startTime = Date.now();
// 检查当前持有锁的标识符
const currentHolder = await this.redis.get(this.lockKey);
// 如果是同一个持有者,增加计数(重入)
if (currentHolder === this.lockValue) {
this.lockCount++;
// 更新过期时间
await this.redis.expire(this.lockKey, Math.ceil(timeoutMs / 1000));
return true;
}
// 尝试获取锁
while (Date.now() - startTime < timeoutMs) {
// 使用 SET NX 命令尝试获取锁
const acquired = await this.redis.set(
this.lockKey,
this.lockValue,
'NX',
'PX',
timeoutMs
);
if (acquired) {
this.lockCount = 1;
return true;
}
// 等待一段时间后重试
await new Promise(resolve => setTimeout(resolve, 100));
}
return false;
}
async release() {
// 检查是否是锁的持有者
const currentHolder = await this.redis.get(this.lockKey);
if (currentHolder !== this.lockValue) {
throw new Error('Cannot release lock - not the lock holder');
}
this.lockCount--;
// 只有当计数为0时才真正释放锁
if (this.lockCount === 0) {
await this.redis.del(this.lockKey);
}
}
}
多机 Redis 方案
前面的 SETNX 方案适合单点 redis,但是不满足高可用,万一单点redis挂了,那服务就要降级了。
假设你要在商场开一个店铺:
- 普通锁就像只和一个商场管理员打交道
- Redlock 就像同时和5个商场管理员打交道(为了更安全)
- 你需要找5个管理员同时确认
- 至少要有3个管理员(过半数)同意才算成功
- 如果拿不到足够的确认,就算租赁失败
为什么要这么复杂?
- 单个管理员可能出错 比如一个管理员可能记错了(Redis宕机)
- 商铺不能同时租给两个人,每个管理员都会检查商铺是否已租出
class Redlock {
constructor() {
// 创建5个Redis连接,就像找5个管理员
this.redisList = [
new Redis('localhost:6379'),
new Redis('localhost:6380'),
new Redis('localhost:6381'),
new Redis('localhost:6382'),
new Redis('localhost:6383')
];
// 需要超过半数的管理员同意(5个中的3个)
this.quorum = Math.floor(this.redisList.length / 2) + 1;
}
async lock(resourceKey, ttl) {
const lockId = uuid(); // 生成一个唯一的租约编号
let successCount = 0; // 记录成功数量
const startTime = Date.now();
// 向所有管理员申请租约
for (const redis of this.redisList) {
try {
// 尝试在每个Redis实例上获取锁
const result = await redis.set(
resourceKey, // 商铺编号
lockId, // 租约编号
'NX', // 只有没人租时才能租
'PX', // 设置租期
ttl // 租期时长
);
if (result === 'OK') {
successCount++; // 这个管理员同意了
}
} catch (err) {
// 这个管理员暂时联系不上,继续找下一个
console.error('无法联系到管理员:', err);
}
}
// 计算整个过程花了多少时间
const elapsedTime = Date.now() - startTime;
const isLockAcquired = successCount >= this.quorum;
if (isLockAcquired) {
// 成功租到商铺
return {
success: true,
lockId: lockId,
validityTime: ttl - elapsedTime
};
} else {
// 没租成功,取消已经获得的预约
await this.unlock(resourceKey, lockId);
return {
success: false
};
}
}
async unlock(resourceKey, lockId) {
// 解锁脚本,确保只能退掉自己的租约
const script = `
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
`;
// 通知所有管理员取消租约
for (const redis of this.redisList) {
try {
await redis.eval(script, 1, resourceKey, lockId);
} catch (err) {
console.error('退租通知失败:', err);
}
}
}
}
这个算法在一些大流量场景下的出现率很高,比如:
- 秒杀系统(确保商品不会超卖)
- 订单处理(防止重复处理)
- 定时任务(确保只有一个服务器执行, 但是我强烈建议 cron job使用K8S调度,为了定时任务实现分布式锁成本太大了)
我们假设有一个秒杀鞋的活动带入一下:
- 抢购前,我们需要发号,先拿到号的才能购买。Redlock 就是这个发号系统
- 抢购时,用户A拿到号,系统检查库存,购买记录,确认无误后扣库存,释放号码给下一个人
这样就避免了,同一个鞋子卖给2个人,或者1个人购买多次,或者100双鞋子卖出101双。
class SeckillSystem {
constructor() {
this.redlock = new Redlock([
// 5个Redis服务器,就像5个销售点
new Redis('redis1'),
new Redis('redis2'),
new Redis('redis3'),
new Redis('redis4'),
new Redis('redis5')
]);
}
async buySneakers(userId, productId) {
// 商品库存key 确保不超卖
const stockKey = `stock:${productId}`;
// 用户购买记录key 确保不能重复买
const userKey = `user:${userId}:product:${productId}`;
try {
// 第一步:获取锁(相当于拿到购买资格)
const lock = await this.redlock.lock(
`seckill:${productId}`,
5000 // 5秒超时
);
if (!lock.success) {
return '系统繁忙,请稍后再试';
}
try {
// 第二步:检查库存
const stock = await this.redis.get(stockKey);
if (stock <= 0) {
return '抱歉,商品已售罄';
}
// 第三步:检查用户是否已购买
const hasBought = await this.redis.get(userKey);
if (hasBought) {
return '您已购买过此商品';
}
// 第四步:扣减库存,记录购买
await this.redis.decr(stockKey);
await this.redis.set(userKey, '1');
// 第五步:创建订单
await this.createOrder(userId, productId);
return '购买成功!';
} finally {
// 释放锁,让其他人可以购买
await this.redlock.unlock(lock);
}
} catch (error) {
return '系统异常,请重试';
}
}
}
总结一下分布式锁: 单机 Reids 使用 SETNX 时需要考虑:
- 重入锁,防止业务没完成
- 锁重试机制
- 安全的释放锁(使用原子操作)
- 使用看门狗机制续锁 集群 Redis 使用 Redlock。
3️⃣ 计数器 - 限流大师
记得有次API被刷,服务器差点挂掉,用Redis的计数器功能轻松实现了接口限流,简直是救命稻草!🌾
async function rateLimiter(userId) {
const key = `ratelimit:${userId}`;
const limit = 100; // 每小时限制100次请求
// 计数加1,设置过期时间
const count = await redis.incr(key);
if (count === 1) {
await redis.expire(key, 3600);
}
if (count > limit) {
throw new Error('您的操作太频繁啦,休息一下吧~☕️');
}
}
时间突刺
这样限流最简单,但是可能会出现 “边界突刺”, 假设1小时限制只能有100个请求。
请求数量
^
100 | █ █
| █ █
| █ █
| █ █
+-----|-----|-----|------> 时间
1:59 2:00 2:01
问题:虽然每个独立的小时都没超过100,
但在跨越整点的短时间内(1:59-2:01)累计了200请求!
怎么办呢?使用滑动窗口限流
class SlidingWindowCounter {
async isAllowed(userId) {
const now = Date.now();
const windowSize = 3600000; // 1小时(毫秒)
const limit = 100;
const key = `sliding:${userId}`;
// 记录这次访问
const memberScore = now;
const member = `${now}`;
await redis.zadd(key, memberScore, member);
/**
Redis数据结构 (Sorted Set):
sliding:user123 =
{ "1677123456789": 1677123456789,
"1677123456790": 1677123456790, ... }
**/
// 移除一小时之前的数据
const windowStart = now - windowSize;
await redis.zremrangebyscore(key, 0, windowStart);
// 获取一小时内的访问次数
const count = await redis.zcard(key);
return count <= limit;
}
}
滑动窗口
结果平滑过度,任意一个小时内最多处理100个请求。
请求数量
^
100 | ████████████
| ███████████████
| ██████████████████
| █████████████████████
+-------------------------> 时间
平滑过渡,没有突刺
这里用到了陌生的reids命令: zadd, zremrangebyscore,不要紧,包教包会:
现在有一个入场签到系统:
入场时我们需要这个命令 zadd
await redis.zadd(key, memberScore, member);
不仅仅记录了观众的名字,还记录了入场的时间,这里的 score 是指时间,可以根据时间排序
我现在想统计总共入场了多少人:使用 zcard
const count = await redis.zcard(key);
现在又有了新需求,我只想保留最近1小时的签到记录(写到这里时候我发现用热榜这个例子更好。。。谁家有只保留最近1小时签到记录。。。):使用 zremrangebyscore
await redis.zremrangebyscore(key, 0, windowStart);
上面这种单条运行redis命令造成网络开销有点浪费其实,redis 中有个 pipeline 机制可以减少网络往返
class ImprovedSlidingWindowCounter {
async isAllowed(userId) {
const now = Date.now();
const windowSize = 3600000;
const limit = 100;
const key = `sliding:${userId}`;
// 使用 pipeline 优化 Redis 操作
const pipeline = redis.pipeline();
// 所有操作打包执行
pipeline.zadd(key, now, `${now}`);
pipeline.zremrangebyscore(key, 0, now - windowSize);
pipeline.zcard(key);
pipeline.expire(key, 3600); // 添加过期时间
const results = await pipeline.exec();
const count = results[2][1]; // 获取 zcard 的结果
return {
allowed: count <= limit,
current: count,
remaining: limit - count
};
}
}
这里我们除了使用 pipeline优化外,还甚至了key过期。细心的同学可能已经get到这里为什么要过期了: 有些人只访问几次就不来了,记录还一直存着,redis内存利用率不高。
在返回上面这次我们也更友好一点,通常这个remaining我们会放在响应头里必入 x-limit-remaining: 40 的样子。
有一个需要注意的是,pipeline 不能保证原子性,上面的z*命令需要用lua脚本包起来执行在生产环境中。
下面讲讲令牌桶限流,大概就是:
- 有一个票箱,定期生成票(令牌)
- 票箱有容量限制(桶容量)
- 游客来了就拿一张票(消耗令牌)
- 票不够就需要等待
被滑动窗口的名字骗了?
滑动窗口限流也有一定的局限性:处理突发流量能力不行,怎么讲?滑动窗口不够“平滑”!
举个例子: 假设系统平均每秒能处理10个请求。
场景:突发1000个请求 如果使用滑动窗口(1分钟限制600个请求),结果:
- 前600个请求通过
- 后400个请求直接拒绝
- 要等到下一分钟才能继续处理
如果使用令牌桶(每秒生成10个令牌,桶容量100),结果
- 立即处理100个(使用储存的令牌)
- 然后每秒处理10个
- 没有突然的截断,而是平滑处理
这样的优势是:
- 不会突然切断所有请求,应对突发流量很合适,能设置突发容量
- 公平性比较好,为每个用户维护单独的令牌桶
所以不要被名字骗了,滑动窗口适合严格时间限定, 关注总控制量:比如API每小时调用限制 令牌桶则关注速率平滑,要处理突发流量又要控制平均速率:比如视频播放,游戏服务器请求
我们为游乐园做一个令牌痛限流算法
class TokenBucket {
constructor(redis) {
this.redis = redis;
this.luaScript = `
-- 获取当前状态
local tokens_key = KEYS[1] -- 通行证数量的记录
local timestamp_key = KEYS[2] -- 上次补充时间的记录
-- 获取参数
local capacity = tonumber(ARGV[1])
local now = tonumber(ARGV[2])
local rate = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local burst = tonumber(ARGV[5])
-- 获取或初始化令牌数
-- 查看箱子现状 如果是新箱子,就放满通行证;否则查看现有数量
local last_tokens = tonumber(redis.call('get', tokens_key) or capacity)
local last_refreshed = tonumber(redis.call('get', timestamp_key) or now)
-- 计算新令牌
-- 计算经过了多少时间
local delta = math.max(0, now - last_refreshed)
-- 根据时间计算新增的通行证,但不能超过箱子容量
local filled_tokens = math.min(capacity, last_tokens + (delta/1000 * rate))
-- 是否允许请求
local allowed = filled_tokens >= requested
local new_tokens = filled_tokens
-- 6. 如果允许,扣减通行证
if allowed then
new_tokens = filled_tokens - requested
end
-- 更新状态
redis.call('set', tokens_key, new_tokens)
redis.call('set', timestamp_key, now)
-- 设置过期时间(防止内存泄漏)
local ttl = math.ceil((capacity - new_tokens) / rate * 1.5)
redis.call('expire', tokens_key, ttl)
redis.call('expire', timestamp_key, ttl)
return {allowed and 1 or 0, new_tokens, filled_tokens}
`;
}
async acquire(key, {
capacity = 10, // 桶容量 e.g 箱子最多能装10张通行证
refillRate = 1, // 每秒补充速率 e.g 每秒补充1张通行证
requested = 1, // 请求令牌数 e.g 这次需要1张通行证
burst = capacity // 突发容量 e.g 特殊情况下的额外容量
} = {}) {
const now = Date.now();
const tokens_key = `${key}:tokens`;
const timestamp_key = `${key}:ts`;
try {
const [allowed, remaining, filled] = await this.redis.eval(
this.luaScript,
2,
tokens_key,
timestamp_key,
capacity,
now,
refillRate,
requested,
burst
);
return {
allowed: allowed === 1,
remaining,
filled,
waitTime: !allowed ? (requested - remaining) / refillRate : 0
};
} catch (error) {
console.error('Token bucket error:', error);
return { allowed: false, error: true };
}
}
}
现在这个算法给游乐园试用一下!
class AmusementParkTokens {
constructor(redis) {
this.tokenBucket = new TokenBucket(redis);
}
async canEnterPark(visitorId) {
// 游乐园配置
const config = {
capacity: 100, // 票箱最多存100张票
refillRate: 10, // 每秒补充10张票
tokensPerRequest: 1 // 每人需要1张票
};
const result = await this.tokenBucket.isAllowed(visitorId, config);
if (result.allowed) {
return {
status: '欢迎入园!',
remainingTickets: result.remainingTokens
};
} else {
return {
status: '票已售完,请稍候...',
remainingTickets: result.remainingTokens,
suggestion: '预计等待时间:' +
Math.ceil((1 - result.remainingTokens) / config.refillRate) + '秒'
};
}
}
}
有了令牌痛秒杀限流就不怕了
- 平时:系统持续生成并储存令牌
- 秒杀开始:可以立即处理大量请求
- 令牌耗尽后:按固定速率处理新请求
预告
未写完的
- 消息队列 - 异步任务处理神器 每次用户下单后都要发短信、发邮件、推送消息,直接同步处理?太天真了! 用Redis的List数据结构实现简单的消息队列,轻松搞定!
- 排行榜 - 游戏必备功能 做过游戏排行榜的同学都知道,要按分数排序还要支持分页,用MySQL要写好多代码! 用Redis的Sorted Set,分分钟搞定!
💡 第二部分:Redis那些不得不说的机制
过期策略: Redis的过期删除策略结合了惰性删除和定期删除。
惰性删除:当你去获取key的时候才检查是否过期(懒惰但是节省CPU)
定期删除:每隔一段时间,随机抽查一些key,删除过期的(勤快但不保证及时)
内存淘汰机制: 当Redis内存不够用时,会触发内存淘汰
持久化机制: Redis可以持久化到磁盘,这样就不会丢失数据了,但是会增加磁盘的IO
🎯 总结 Redis真的是后端开发必备技能了,用好它能解决很多性能问题。不过也要注意,Redis不是万能的,该用数据库的地方还是要用数据库,要合理选型喔!
希望这篇文章对你有帮助!如果觉得有收获,别忘了点赞关注哦~ 下期我们聊聊Redis那些不得不说的机制!👋