分布式锁在分布式系统中确保同一时间只有一个进程能操作共享资源,redis因其高性能和原子操作特性成为实现分布式锁的优选。核心实现基于setnx命令,通过set resource_name my_unique_id nx px 10000设置锁,其中resource_name为资源名,my_unique_id为唯一标识,nx保证键不存在时才设置成功,px设定过期时间防止死锁;释放锁需使用lua脚本确保判断与删除操作的原子性,避免误删他人锁。注意事项包括合理选择my_unique_id(如uuid)、设置过期时间、考虑锁续期机制及集群模式下的redlock算法以提高安全性。示例代码展示了Java中获取锁、执行业务逻辑及释放锁的完整流程,并强调异常处理与锁释放的可靠性。
分布式锁,说白了,就是在分布式系统里,大家得有个规矩,确保同一时间只有一个进程能操作某个共享资源。它就像是给一个热门资源发号施令,谁拿到号,谁才能进去,避免了多个人同时挤进去,把事情搞砸。redis因为其高性能和原子操作特性,成了实现这种“发号施令”机制的绝佳选择。
解决方案
实现redis分布式锁的核心思路,其实并不复杂,但要做到健壮可靠,就得考虑一些细节。最基础的实现,是利用Redis的SETNX(SET if Not eXists)命令。
当你需要获取锁时,尝试执行: SET resource_name my_unique_id NX PX 10000
这里面每个参数都有它的讲究:
- resource_name:这就是你想要保护的那个共享资源的名字,比如“订单创建锁”、“库存更新锁”。
- my_unique_id:这个非常关键!它不是随便设的,而是一个能唯一标识你当前这个请求(或者说,这个锁的持有者)的值,通常是一个UUID。为什么需要它?因为你释放锁的时候,得确保是你自己加的锁才能删,不能把别人加的锁给误删了。
- NX:这是“Not eXists”的缩写。它的作用是,只有当resource_name这个键不存在的时候,才设置成功。这保证了原子性:如果锁已经存在,你的操作就会失败,你也就拿不到锁。
- PX 10000:这是设置键的过期时间,单位是毫秒。这里是10秒。这个过期时间是用来防止死锁的。你想想,如果一个客户端拿到了锁,结果它崩了,或者网络断了,没来得及释放锁,那这个锁就会一直被它霸占着。有了过期时间,即使客户端挂了,锁到期也会自动释放,其他客户端就能重新获取了。
当你需要释放锁时,就不能简单地DEL resource_name了。因为可能出现这种情况:你拿到了锁,但业务逻辑执行时间太长,导致锁自动过期了。这时候,另一个客户端又拿到了这个锁。如果你再直接DEL,就误删了别人的锁。所以,释放锁必须是“判断-删除”的原子操作。Redis提供了Lua脚本来保证这种原子性:
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end
这个Lua脚本的逻辑很清晰:先检查当前锁的值是不是我之前设置的那个my_unique_id。如果是,就删除它并返回1;如果不是(说明锁已经过期或者被别人拿走了),就什么也不做,返回0。将这个脚本发送给Redis执行,整个过程就是原子的,不会有中间状态被其他客户端干扰。
为什么我们需要分布式锁?
说实话,这问题我刚开始接触分布式系统的时候也挺困惑的。我们平时写代码,一个方法里用个synchronized或者ReentrantLock不就行了吗?但仔细想想,在单机应用里,这些内置的锁确实能解决多线程并发问题。可一旦你的服务不再是单机了,而是部署在好几台机器上,或者你的业务拆成了好几个微服务,它们可能同时去操作同一个数据库记录、同一个文件,或者同一个外部API,那单机锁就彻底失效了。
举个例子,一个电商网站,用户下单购买商品。如果库存只有一件,同时有两个人几乎在同一时间点击了“购买”。如果你的库存扣减逻辑没有分布式锁保护,很可能出现两个人同时判断库存充足,然后都成功扣减了库存,最终导致库存变为负数,超卖了。这在业务上是绝对不能接受的。
再比如,一个定时任务,你部署了好几个实例,为了高可用。但这个任务可能需要生成一份报表,如果多个实例同时生成,那就重复工作,甚至导致数据混乱。
这时候,分布式锁就成了必需品。它提供了一种跨进程、跨机器的协调机制,确保在任何时刻,只有一个“玩家”能够进入关键的业务逻辑区域。它不是为了替代你代码里的那些synchronized,而是为了在更高维度上,解决系统间的并发冲突。这就像是把一个单人使用的厕所,升级成一个多隔间的厕所,每个隔间都有个“有人/无人”的标志,有人占着,其他人就得等。
Redis分布式锁的实现细节与注意事项
聊到实现,除了上面提到的基本原理,还有些“坑”和“技巧”是需要特别注意的。
首先是那个my_unique_id。我个人习惯用UUID,因为它够随机,冲突的概率极低。别小看这个ID,它是你释放锁时的“身份证明”。没有它,你的锁释放逻辑就是危险的。
其次是过期时间PX的选择。这个值定多少合适?这没有标准答案,得看你的业务场景。如果你的业务逻辑执行很快,比如几百毫秒,那锁的过期时间可以设短一点,比如5秒。如果你的业务逻辑可能涉及IO操作,或者耗时较长,比如几秒钟,那过期时间就得相应调长,比如30秒甚至更久。但也不能无限长,太长就失去了防止死锁的意义。这是一个权衡:太短可能业务还没跑完锁就过期了,太长又增加了死锁的风险。
这里有个进阶的思路:锁续期(Watchdog)。有些分布式锁的实现库,比如Redisson,就内置了“看门狗”机制。它会在你获取锁后,启动一个后台线程,每隔一段时间(比如过期时间的三分之一),就去检查你是否还持有锁。如果持有,它就自动帮你延长锁的过期时间。这样,即使你的业务逻辑执行时间超过了最初设定的过期时间,只要你还在正常运行,锁就不会被意外释放。这解决了“锁过期但业务未完成”的痛点,但同时也增加了系统的复杂性。
再说说那个Lua脚本,它的重要性在于保证了“检查-删除”的原子性。没有Lua脚本,你先GET再DEL,中间只要有那么一毫秒的间隔,另一个客户端就可能在这毫秒内拿到了锁,然后你再DEL,就误删了。Redis的单线程模型和Lua脚本的原子性,完美解决了这个问题。
当然,如果你的Redis是集群模式,比如主从或者哨兵模式,当主节点宕机,从节点晋升为主节点时,可能会出现一个问题:锁在旧主节点上被成功设置,但还没来得及同步到从节点,旧主节点就挂了。新的主节点上就没有这个锁,导致另一个客户端可以再次获取到锁,这就造成了“双重加锁”。对于这种极端情况,Redis官方提出了Redlock算法。它需要多个独立的Redis实例(通常是奇数个),客户端需要向半数以上的实例成功加锁才算获取成功。释放锁也需要向所有实例发送释放命令。Redlock比单实例锁复杂得多,性能也有所下降,但它在理论上提供了更高的安全性。不过,对于大多数场景,单实例的SET NX PX配合Lua脚本,已经足够应对了。是否需要Redlock,得看你的业务对一致性的要求有多高,以及你是否能接受其带来的复杂性和性能开销。我个人觉得,除非是金融级别或者对数据一致性有极高要求的场景,否则可能不需要一开始就上Redlock。
完整的Redis分布式锁使用示例
下面我用一个Java的伪代码示例,来展示一个比较完整的Redis分布式锁的使用流程。这里我们假设你已经有了一个Redis连接池(比如JedisPool)。
import redis.clients.jedis.Jedis; import redis.clients.jedis.JedisPool; import java.util.Collections; import java.util.UUID; import java.util.concurrent.TimeUnit; public class DistributedLockExample { private JedisPool jedisPool; private String lockKey; // 锁的名称 private String requestId; // 当前请求的唯一标识 private long expireTime; // 锁的过期时间,毫秒 // 构造函数,传入Redis连接池、锁名和过期时间 public DistributedLockExample(JedisPool jedisPool, String lockKey, long expireTime) { this.jedisPool = jedisPool; this.lockKey = lockKey; this.requestId = UUID.randomUUID().toString(); // 生成唯一ID this.expireTime = expireTime; } /** * 尝试获取锁 * @return true表示获取成功,false表示获取失败 */ public boolean tryLock() { try (Jedis jedis = jedisPool.getResource()) { // SET key value NX PX milliseconds // NX: 只在键不存在时设置 // PX: 设置过期时间,单位毫秒 String result = jedis.set(lockKey, requestId, "NX", "PX", expireTime); return "OK".equals(result); // 如果返回OK,表示获取锁成功 } catch (Exception e) { System.err.println("尝试获取锁时发生异常: " + e.getMessage()); // 实际应用中应该记录日志 return false; } } /** * 释放锁 * @return true表示释放成功,false表示释放失败(可能锁已过期或不属于当前请求) */ public boolean releaseLock() { // Lua脚本,保证原子性:先判断值是否一致,再删除 String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end"; try (Jedis jedis = jedisPool.getResource()) { // eval方法执行Lua脚本,KEYS[1]对应lockKey,ARGV[1]对应requestId Object result = jedis.eval(script, Collections.singletonList(lockKey), Collections.singletonList(requestId)); return Long.valueOf(1).equals(result); // Lua脚本返回1表示删除成功 } catch (Exception e) { System.err.println("释放锁时发生异常: " + e.getMessage()); // 实际应用中应该记录日志 return false; } } // 模拟业务逻辑执行 public void doBusinessLogic() throws InterruptedException { System.out.println(Thread.currentThread().getName() + " 正在执行业务逻辑..."); // 模拟耗时操作 TimeUnit.SECONDS.sleep(2); System.out.println(Thread.currentThread().getName() + " 业务逻辑执行完毕。"); } public static void main(String[] args) { // 假设Redis运行在本地6379端口 JedisPool pool = new JedisPool("localhost", 6379); String sharedResourceLock = "my_critical_resource_lock"; long lockExpireTime = 5000; // 锁的过期时间5秒 // 模拟多个线程同时竞争锁 for (int i = 0; i < 5; i++) { new Thread(() -> { DistributedLockExample lock = new DistributedLockExample(pool, sharedResourceLock, lockExpireTime); if (lock.tryLock()) { try { System.out.println(Thread.currentThread().getName() + " 成功获取到锁。"); lock.doBusinessLogic(); // 执行受保护的业务逻辑 } catch (InterruptedException e) { Thread.currentThread().interrupt(); System.err.println(Thread.currentThread().getName() + " 业务逻辑被中断。"); } finally { // 确保锁在任何情况下都被尝试释放 if (lock.releaseLock()) { System.out.println(Thread.currentThread().getName() + " 成功释放锁。"); } else { System.out.println(Thread.currentThread().getName() + " 未能释放锁,可能已过期或不属于我。"); } } } else { System.out.println(Thread.currentThread().getName() + " 未能获取到锁,资源正在被占用,稍后重试或放弃。"); // 实际应用中这里可以加入重试机制,比如指数退避 } }, "Worker-" + i).start(); } // 等待所有线程执行完毕 try { TimeUnit.SECONDS.sleep(10); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { pool.close(); // 关闭连接池 System.out.println("所有工作完成,Redis连接池关闭。"); } } }
这个示例展示了获取锁、执行业务、释放锁的完整流程。尤其要注意try-finally块的使用,它保证了无论业务逻辑是否抛出异常,释放锁的逻辑都会被执行。这在实际生产环境中至关重要。如果业务逻辑执行失败,或者中途抛出异常,而你没有在finally里释放锁,那这个锁就可能一直被霸占着,直到过期,从而影响其他请求。对于未能获取到锁的客户端,你可以选择立即返回失败,或者实现一个重试机制(比如带随机延迟的重试),直到获取成功或达到最大重试次数。这都是根据具体业务需求来定的。