谈谈Redis的SETNX

Redis SETNX详解
本文探讨了Redis中SETNX指令的应用及潜在陷阱,特别是在缓存更新和实现锁机制方面。介绍了如何利用SETNX防止缓存雪崩,并展示了如何通过Lua脚本和改进后的SET指令解决SETNX存在的问题。
谈谈Redis的SETNX
发表于2015-09-14	

在 Redis 里,所谓 SETNX,是「SET if Not eXists」的缩写,也就是只有不存在的时候才设置,可以利用它来实现锁的效果,不过很多人没有意识到 SETNX 有陷阱!

比如说:某个查询数据库的接口,因为调用量比较大,所以加了缓存,并设定缓存过期后刷新,问题是当并发量比较大的时候,如果没有锁机制,那么缓存过期的瞬间,大量并发请求会穿透缓存直接查询数据库,造成雪崩效应,如果有锁机制,那么就可以控制只有一个请求去更新缓存,其它的请求视情况要么等待,要么使用过期的缓存。

下面以目前 PHP 社区里最流行的 PHPRedis 扩展为例,实现一段演示代码:

<?php

$ok = $redis->setNX($key, $value);

if ($ok) {
    $cache->update();
    $redis->del($key);
}

?>

缓存过期时,通过 SetNX  获取锁,如果成功了,那么更新缓存,然后删除锁。看上去逻辑非常简单,可惜有问题:如果请求执行因为某些原因意外退出了,导致创建了锁但是没有删除锁,那么这个锁将一直存在,以至于以后缓存再也得不到更新。于是乎我们需要给锁加一个过期时间以防不测:

<?php

$redis->multi();
$redis->setNX($key, $value);
$redis->expire($key, $ttl);
$redis->exec();

?>

因为 SetNX 不具备设置过期时间的功能,所以我们需要借助 Expire 来设置,同时我们需要把两者用 Multi/Exec 包裹起来以确保请求的原子性,以免 SetNX 成功了 Expire 却失败了。 可惜还有问题:当多个请求到达时,虽然只有一个请求的 SetNX 可以成功,但是任何一个请求的 Expire 却都可以成功,如此就意味着即便获取不到锁,也可以刷新过期时间,如果请求比较密集的话,那么过期时间会一直被刷新,导致锁一直有效。于是乎我们需要在保证原子性的同时,有条件的执行 Expire,接着便有了如下 Lua 代码:

local key   = KEYS[1]
local value = KEYS[2]
local ttl   = KEYS[3]

local ok = redis.call('setnx', key, value)
 
if ok == 1 then
  redis.call('expire', key, ttl)
end
 
return ok

没想到实现一个看起来很简单的功能还要用到 Lua 脚本,着实有些麻烦。其实 Redis 已经考虑到了大家的疾苦,从 2.6.12 起,SET 涵盖了 SETEX 的功能,并且 SET 本身已经包含了设置过期时间的功能,也就是说,我们前面需要的功能只用 SET 就可以实现。

<?php

$ok = $redis->set($key, $value, array('nx', 'ex' => $ttl));

if ($ok) {
    $cache->update();
    $redis->del($key);
}

?>

如上代码是完美的吗?答案是还差一点!设想一下,如果一个请求更新缓存的时间比较长,甚至比锁的有效期还要长,导致在缓存更新过程中,锁就失效了,此时另一个请求会获取锁,但前一个请求在缓存更新完毕的时候,如果不加以判断直接删除锁,就会出现误删除其它请求创建的锁的情况,所以我们在创建锁的时候需要引入一个随机值:

<?php

$ok = $redis->set($key, $random, array('nx', 'ex' => $ttl));

if ($ok) {
    $cache->update();

    if ($redis->get($key) == $random) {
        $redis->del($key);
    }
}

?>

如此基本实现了单机锁,假如要实现分布锁,请参考:Distributed locks with Redis,这里就不深入讨论了,总结:避免掉入 SETNX 陷阱的最好方法就是永远不要使用它!

 

转载于:https://www.cnblogs.com/Fly-Wind/p/5569663.html

<think>我们被要求使用RedisSETNX命令实现分布式锁。SETNX命令是"SET if Not eXists"的缩写,它在键不存在时设置键的值,并返回1;如果键已存在,则不做任何操作并返回0。这一特性使其非常适合用于实现分布式锁。 实现分布式锁的基本思路: 1. 尝试获取锁:使用SETNX命令设置一个特定的键。如果返回1,表示获取锁成功;如果返回0,表示锁已被其他客户端持有。 2. 设置锁的过期时间:为了避免客户端崩溃导致锁无法释放,我们需要为锁设置一个过期时间。这可以通过EXPIRE命令实现。 3. 释放锁:当客户端完成操作后,需要删除该键以释放锁。 但是,上述步骤存在一个原子性问题:SETNX和EXPIRE是两个独立的命令,如果客户端在SETNX成功后、执行EXPIRE之前崩溃,那么锁将永远不会被释放。因此,我们需要确保设置键和设置过期时间的操作是原子的。 在Redis 2.6.12及以上版本,我们可以使用SET命令的扩展参数来实现原子操作: SET key value [EX seconds] [PX milliseconds] [NX|XX] 其中: - EX seconds:设置键的过期时间为seconds秒。 - PX milliseconds:设置键的过期时间为milliseconds毫秒。 - NX:只在键不存在时,才对键进行设置操作。 - XX:只在键已经存在时,才对键进行设置操作。 因此,我们可以使用以下命令来获取锁: SET lock_key unique_value EX timeout NX 这里,unique_value是一个唯一标识(例如UUID),用于确保只有锁的持有者才能释放锁(避免误删其他客户端的锁)。timeout是锁的自动过期时间。 释放锁时,我们需要先检查unique_value是否与设置的值匹配,然后删除键。但是,检查值和删除键是两个操作,需要保证原子性。我们可以使用Lua脚本来实现。 步骤总结: 1. 获取锁: SET lock_key unique_value EX timeout NX 如果返回OK,则获取锁成功;否则,获取失败。 2. 释放锁: 使用Lua脚本:先比较unique_value,如果匹配则删除键。 EVAL "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end" 1 lock_key unique_value 注意:锁的持有时间(timeout)需要根据业务操作的时间合理设置,避免业务未完成而锁自动释放。 下面是一个简单的Java代码示例(使用Jedis客户端):</think>### Redis SETNX 命令实现分布式锁指南 #### 一、SETNX 命令基础 `SETNX`(SET if Not eXists)是 Redis 的原子性操作命令,语法为: ```bash SETNX key value ``` - ✅ 成功:当键 `key` 不存在时设置值,返回 `1` - ❌ 失败:当键已存在时不做操作,返回 `0` - 特性:原子性操作(适合分布式锁场景) #### 二、分布式锁实现步骤 1. **获取锁** ```java // Jedis 示例 Jedis jedis = new Jedis("redis-host", 6379); String lockKey = "order_lock_123"; String requestId = UUID.randomUUID().toString(); // 唯一标识当前请求 int expireTime = 30; // 锁过期时间(秒) // 原子操作:SETNX + 过期时间 String result = jedis.set(lockKey, requestId, "NX", "EX", expireTime); boolean locked = "OK".equals(result); ``` - 使用 `NX`(Not eXist)和 `EX`(过期时间)参数保证原子性 - `requestId` 用于标识锁持有者(避免误删其他客户端的锁) 2. **处理业务逻辑** ```java if (locked) { try { // 执行受保护的临界区代码 processOrder(); } finally { // 确保锁被释放 releaseLock(jedis, lockKey, requestId); } } else { // 获取锁失败处理(等待/重试) } ``` 3. **释放锁(Lua 脚本保证原子性)** ```java private void releaseLock(Jedis jedis, String lockKey, String requestId) { String luaScript = "if redis.call('get', KEYS[1]) == ARGV[1] then " + " return redis.call('del', KEYS[1]) " + "else " + " return 0 " + "end"; jedis.eval(luaScript, 1, lockKey, requestId); } ``` - 通过 Lua 脚本保证 `GET` + `DEL` 操作的原子性 - 验证 `requestId` 避免误删其他客户端的锁 #### 三、关键注意事项 1. **锁过期时间** - 必须设置过期时间(防止死锁) - 时间 = 业务最大耗时 + 网络延迟缓冲(建议 10-30s) - 可通过**看门狗机制**自动续期锁(如 Redisson 实现) 2. **锁误删防护** - 必须使用唯一标识(如 `requestId`)标记锁持有者 - 释放时严格验证标识(避免释放其他客户端的锁) 3. **集群环境** - Redis 集群需使用 Redlock 算法(多节点获取锁) - 官方推荐方案:https://redis.io/topics/distlock 4. **重试策略** - 获取失败时建议指数退避重试 ```java int maxRetries = 3; for (int i = 0; i < maxRetries; i++) { if (tryAcquireLock()) break; Thread.sleep(100 * (2^i)); // 指数等待 } ``` #### 四、与 Redisson 对比 | 方案 | 优点 | 缺点 | |------|------|------| | **原生 SETNX** | 轻量级、无依赖 | 需手动处理续期/重试/集群 | | **Redisson[^3]** | 自动续期、Watchdog 监控、Redlock 支持 | 引入额外依赖 | > 推荐:简单场景用原生 SETNX,复杂分布式系统用 Redisson #### 五、典型应用场景 1. 秒杀库存扣减 2. 分布式任务调度 3. 防止重复支付 4. 数据库唯一约束旁路 ---
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值