Redis 分布式锁灵活运用:从原理到解决误删问题
摘要:在集群部署或分布式系统中,传统的 synchronized 和 ReentrantLock 因局限于单 JVM 进程而失效。本文深入探讨基于 Redis 的分布式锁实现方案,从 setnx 命令的原子性操作入手,解决死锁隐患。通过手写 ILock 接口与 SimpleRedisLock 实现类,展示如何在 Spring Boot 项目中落地分布式锁,并重点剖析“锁误删”问题的成因与解决方案(唯一标识校验),为高并发场景下的数据安全提供可靠保障。
一、为什么需要分布式锁?
在单体架构中,我们习惯使用 synchronized 或 ReentrantLock 来保证线程安全。然而,当项目演进为微服务架构或集群部署模式时,JVM 层面的锁无法跨进程生效。此时,我们需要一种多进程可见且互斥的锁机制,即分布式锁。
一个合格的分布式锁必须满足以下三个核心条件:
主流方案对比
| 互斥实现 | 利用数据库唯一索引或行锁 | 利用 setnx 命令的原子性 | 利用临时有序节点的唯一性 |
| 高可用 | 好(主从复制) | 好(哨兵/集群模式) | 好(ZAB 协议) |
| 高性能 | 一般(依赖磁盘 I/O) | 极好(纯内存操作) | 一般(需多次网络交互) |
| 安全性 | 断开连接自动释放 | 需手动设置过期时间 | 临时节点,断开自动删除 |
结论:在对性能要求极高且业务允许短暂不一致的场景下(如秒杀、库存扣减),Redis 是实现分布式锁的首选方案。
二、Redis 分布式锁的核心原理
1. 基础命令:setnx 与原子性
Redis 提供了 SETNX (SET if Not Exists) 命令,只有当 Key 不存在时才会设置成功,天然具备互斥性。
# 尝试获取锁,NX 表示互斥,EX 表示设置过期时间(秒)
SET lock_key thread_id NX EX 10
为什么要原子性? 早期实现常分两步走:先 setnx 加锁,再 expire 设置超时。若在执行完第一步后服务宕机,锁将永远存在(死锁)。因此,必须将“设置值”与“设置过期时间”合并为一条原子命令。
- **返回 1 **:获取锁成功。
- 返回 0(nil):获取锁失败(锁已被占用)。
2. 释放锁
释放锁相对简单,直接删除 Key 即可:
DEL lock_key
注意:生产环境中需校验锁的持有者是否为当前线程,防止误删其他线程的锁(下文将详细展开)。
三、项目实战:手写分布式锁组件
在之前的 Redis操作中的项目中,我们处理并发通常依赖数据库乐观锁或简单的同步块。但在高并发秒杀场景下(项目中有可以通过积分兑换话费并且限量),我们需要更轻量级的锁。
下面我将展示如何在 Spring Boot 项目中,封装一个标准的分布式锁组件。
1. 定义标准接口 ILock
为了规范锁的行为,我们定义统一的接口,包含“尝试加锁”和“释放锁”两个核心方法。
package com.hmdp.utils;
public interface ILock {
/**
* 尝试获取锁
* @param timeoutSec 锁持有的超时时间(秒)
* @return true-获取成功,false-获取失败
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
2. 实现 SimpleRedisLock (初级版)
基于 StringRedisTemplate 实现上述接口。核心逻辑利用 setIfAbsent 方法对应 Redis 的 SET … NX EX 命令。
package com.hmdp.utils;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.concurrent.TimeUnit;
public class SimpleRedisLock implements ILock {
private final String name; // 锁的名称(业务标识)
private final StringRedisTemplate stringRedisTemplate;
// 锁的前缀,避免与其他 Key 冲突
private static final String LOCK_PREFIX = "lock:";
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
// 获取当前线程 ID 作为锁的值,方便排查问题
long threadId = Thread.currentThread().getId();
// 核心:原子性设置锁,并指定过期时间
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(LOCK_PREFIX + name, threadId + "", timeoutSec, TimeUnit.SECONDS);
// 防止 Boolean 对象为 null 导致空指针
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
// 直接删除 Key 释放锁
stringRedisTemplate.delete(LOCK_PREFIX + name);
}
}
3. 业务场景集成
假设我们有一个一人一单的秒杀场景,传统做法是在 Service 层加 synchronized(userId)。但在集群环境下,这无效。现在使用我们封装的 SimpleRedisLock 进行改造:
// 1. 构建锁实例,锁的粒度为"order:userId",确保同一用户只能持有一把锁
Long userId = UserHolder.getUser().getId();
SimpleRedisLock lock = new SimpleRedisLock("order:" + userId, stringRedisTemplate);
// 2. 尝试获取锁,设置超时时间为 10 秒
boolean isLock = lock.tryLock(10);
if (!isLock) {
// 获取锁失败,说明有重复请求或正在处理中
return Result.fail("请勿重复下单");
}
try {
// 3. 执行业务逻辑
// 注意:此处需获取代理对象以支持事务 (@Transactional) 生效
IVoucherOrderService proxy = (IVoucherOrderService) AopContext.currentProxy();
return proxy.createVoucherOrder(voucherId);
} catch (IllegalStateException e) {
throw new RuntimeException(e);
} finally {
// 4. 无论成功与否,必须在 finally 中释放锁
lock.unlock();
}
四、核心痛点:锁的误删问题与解决方案
1. 问题复现
上述初级版本存在一个严重隐患:锁误删。
场景推演:
2. 解决方案:唯一标识校验
为了解决这个问题,我们需要在释放锁之前,判断当前锁的持有者是否是自己。
改进思路:
- 加锁时:Value 不再仅存线程 ID,而是存 UUID + 线程ID,确保全局唯一性。
- 解锁时:先查询 Redis 中的 Value,与当前线程的标识比对,一致才删除。

3. 代码升级
package com.hmdp.utils;
import org.springframework.data.redis.core.StringRedisTemplate;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
public class SimpleRedisLock implements ILock {
private final String name;
// 使用 UUID + 线程ID 作为唯一标识,防止不同 JVM 中线程 ID 重复
private static final String ID_PREFIX = UUID.randomUUID().toString() + "-";
private static final String KEY_PREFIX = "lock:";
private final StringRedisTemplate stringRedisTemplate;
public SimpleRedisLock(String name, StringRedisTemplate stringRedisTemplate) {
this.name = name;
this.stringRedisTemplate = stringRedisTemplate;
}
@Override
public boolean tryLock(long timeoutSec) {
// 生成唯一线程标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
Boolean success = stringRedisTemplate.opsForValue()
.setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
@Override
public void unlock() {
// 1. 获取当前线程的唯一标识
String threadId = ID_PREFIX + Thread.currentThread().getId();
// 2. 获取 Redis 中锁的标识
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
// 3. 比对标识,一致则删除
if (threadId.equals(id)) {
stringRedisTemplate.delete(KEY_PREFIX + name);
}
}
}
注意:上述“查询 + 删除”的操作并非原子性。在高并发极端场景下,仍可能出现判断成功后、删除前锁过期的情况。生产级解决方案通常需要使用 Lua 脚本 将判断与删除合并为原子操作,这也是接下来进阶学习的方向。
五、总结与展望
通过上述实现,我们成功将 JVM 锁升级为Redis 分布式锁,并解决了基础的误删问题。
核心收获:
- 原子性加锁:利用 SET … NX EX 杜绝死锁。
- 唯一标识:引入 UUID 防止误删他人锁。
- 灵活封装:通过接口抽象,业务代码无侵入。
待优化点(进阶思考): 目前的实现仍存在非原子性删除的隐患,且不支持锁的可重入。在后续的博文中,我将进一步:
技术之路无止境,从 CRUD 到中间件原理,每一步都算数。
作者简介:大二在读,目前正通过自研项目(非遗文化+AI)与开源社区积累经验。专注技术使用与深度。欢迎关注我,一起用代码打破边界。
相关专栏:
- Spring Boot 系列总结
- Redis 实战进阶
(如果觉得文章对你有帮助,欢迎点赞👍、收藏⭐️,你的支持是我持续更新的动力!)




