本文以"订单重复提交"为线索,从最简单的
SETNX出发,每遇到一个问题就解决一个问题,层层递进到生产级方案。适合有一定 Spring Boot 基础的 Java 开发者。
一、什么是分布式锁?
1. 从单机锁说起
在单机应用中,我们处理并发非常简单,用 Java 自带的锁就行:
java
public synchronized void createOrder(String orderId) {
// 同一时刻只有一个线程能进入
orderMapper.insert(orderId);
}
或者用 ReentrantLock:
java
private final ReentrantLock lock = new ReentrantLock();
public void createOrder(String orderId) {
lock.lock();
try {
orderMapper.insert(orderId);
} finally {
lock.unlock();
}
}
这些锁之所以有效,是因为所有线程都在同一个 JVM 里,共享同一块内存,锁的状态大家都能看到。
2. 单机锁为什么失效?
现在把应用部署到 3 台机器上,前面挂一个 Nginx 做负载均衡:
text
┌──────────┐
│ Nginx │
└────┬─────┘
│
┌───────┼───────┐
▼ ▼ ▼
实例 A 实例 B 实例 C
(JVM 1) (JVM 2) (JVM 3)
用户的两次请求,一次打到实例 A,一次打到实例 B。这时候:
- 实例 A 的
synchronized只锁住 A 的线程 - 实例 B 的
synchronized只锁住 B 的线程
两个 JVM 的锁互相不认识,等于没加锁。这就是单机锁的局限。
3. 分布式锁要解决什么?
分布式锁的核心目标很简单:
在分布式系统中,让多个进程/线程对同一资源的访问变得互斥。
具体来说,它需要满足以下几个条件:
| 特性 | 含义 |
|---|---|
| 互斥 | 任意时刻,只有一个客户端能持有锁 |
| 防死锁 | 持有锁的客户端崩溃后,锁能自动释放 |
| 安全释放 | 只有加锁的客户端才能释放锁,不能误删别人的 |
| 高可用 | 锁服务本身不能是单点,Redis 挂了锁还能用 |
| 可重入(可选) | 同一客户端可以重复获取同一把锁 |
4. 常见的分布式锁实现方案
业界主流的方案有三种:
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 基于数据库 | 唯一索引 / SELECT ... FOR UPDATE |
实现简单 | 性能差,易死锁 |
| 基于 Redis | SET NX PX + Lua |
性能高,部署简单 | 主从切换可能丢锁 |
| 基于 ZooKeeper / etcd | 临时顺序节点 / Lease | 强一致,可靠性高 | 性能略低,运维复杂 |
为什么 Redis 最流行?
- 大部分系统本来就用 Redis 做缓存,加锁不需要额外组件
- 性能极高,单机轻松支撑十万级 QPS
- API 简单,几行代码就能上手
但简单不等于容易。正是因为上手太容易,很多人写出了有问题的锁而不自知。接下来的六次迭代,就是带你把这个坑一个个填平。
二、引子:一个真实的 Bug
某个下午,运营反馈:用户点击"提交订单"后,后台出现了两条一模一样的订单记录。
排查发现:用户手抖点了两次,两次请求几乎同时到达,都查了"订单不存在",然后都执行了插入。
这不是数据库唯一索引能完全解决的------有些业务逻辑复杂,无法简单加唯一约束。我们需要的是:同一时刻,只允许一个线程执行这段逻辑。
下面我们在 Spring Boot 项目中,一步步实现它。
三、第一版:SETNX,能加锁,但会死锁
Redis 的 SETNX(SET if Not eXists)天生就是为互斥设计的:
java
public void createOrder(String orderId) {
String lockKey = "lock:order:" + orderId;
Boolean success = redisTemplate.opsForValue().setIfAbsent(lockKey, "1");
if (!Boolean.TRUE.equals(success)) {
throw new RuntimeException("请勿重复提交");
}
try {
// 业务逻辑
orderMapper.insert(orderId);
} finally {
redisTemplate.delete(lockKey);
}
}
看起来没问题。但如果业务执行中服务突然宕机,finally 没执行,这个 key 就永远留在 Redis 里------其他用户的请求全部被卡死。
这就是第一版的死穴:锁没有过期时间。
四、第二版:SETNX + EXPIRE,解决死锁,但引入新问题
加个过期时间就行了:
java
Boolean success = redisTemplate.opsForValue().setIfAbsent(lockKey, "1");
if (Boolean.TRUE.equals(success)) {
redisTemplate.expire(lockKey, 30, TimeUnit.SECONDS); // 新增
}
宕机也不怕了,30 秒后锁自动释放。
但这里有个隐患:setIfAbsent 和 expire 是两条命令,不是原子的。如果在两条命令之间服务宕机,锁同样永远不会释放。
概率很低,但生产环境不能赌概率。
五、第三版:SET NX PX,原子加锁
Redis 2.6.12 之后,SET 命令支持了 NX 和 PX 参数,可以一条命令搞定:
bash
SET lock:order:123 1 NX PX 30000
Spring Data Redis 提供了对应封装:
java
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(30));
一条命令,原子操作,既能互斥又能过期。到这里锁的"加"已经没问题了。
但释放呢?看下面的场景:
线程 A 获得锁,业务执行了 35 秒,锁在 30 秒时自动过期。
线程 B 获得锁开始执行。
线程 A 执行完毕,走 finally,
delete(lockKey)------把 B 的锁删了。
第三版的死穴:可能误删别人的锁。
六、第四版:UUID + Lua,安全释放
要避免误删,就得给锁加个"身份标识"。加锁时 value 存一个 UUID,释放前先校验:
java
public String tryLock(String key, long expireSeconds) {
String requestId = UUID.randomUUID().toString();
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(key, requestId, Duration.ofSeconds(expireSeconds));
return Boolean.TRUE.equals(success) ? requestId : null;
}
释放时用 Lua 脚本保证"校验 + 删除"的原子性:
lua
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
Java 侧:
java
private static final String UNLOCK_LUA =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
"return redis.call('del', KEYS[1]) else return 0 end";
public boolean unlock(String key, String requestId) {
Long result = redisTemplate.execute(
new DefaultRedisScript<>(UNLOCK_LUA, Long.class),
Collections.singletonList(key),
requestId);
return result != null && result > 0;
}
使用:
java
String lockKey = "lock:order:" + orderId;
String requestId = redisLock.tryLock(lockKey, 30);
if (requestId == null) {
throw new RuntimeException("请勿重复提交");
}
try {
orderMapper.insert(orderId);
} finally {
redisLock.unlock(lockKey, requestId);
}
到这里,锁的"加"和"放"都安全了。但还有第四个坑:
如果业务执行时间超过了 30 秒,锁自动过期,线程 B 拿到锁开始执行,此时两个线程同时在跑,互斥失效。
第四版的死穴:业务超时导致锁提前释放。
要解决它,就得在业务执行期间给锁"续命"。手写续期非常麻烦(后台线程、定时任务、异常处理......),这时候就该 Redisson 出场了。
七、第五版:Redisson,看门狗自动续期
Redisson 是 Redis 官方推荐的 Java 客户端,它把上面所有问题都封装好了,还额外提供了:
- 看门狗(Watch Dog) :业务没执行完,自动续期
- 可重入:同一线程可以重复获取同一把锁
- 重试等待 :
tryLock支持等待时间,内部自旋 - Lua 脚本:底层保证原子性
Spring Boot 集成
依赖:
xml
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.23.0</version>
</dependency>
如果 application.yml 里已有 spring.redis 配置,Starter 会自动装配,无需额外代码。想自定义的话:
java
@Configuration
public class RedissonConfig {
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://127.0.0.1:6379")
.setPassword(null)
.setDatabase(0);
return Redisson.create(config);
}
}
使用 RLock
java
@Service
public class OrderService {
@Autowired
private RedissonClient redissonClient;
public void createOrder(String orderId) {
RLock lock = redissonClient.getLock("lock:order:" + orderId);
boolean locked = false;
try {
// 最多等 10 秒,锁默认 30 秒(看门狗会自动续期)
locked = lock.tryLock(10, TimeUnit.SECONDS);
if (!locked) {
throw new RuntimeException("请勿重复提交");
}
orderMapper.insert(orderId);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
注意上面用的是 tryLock(waitTime, unit) 这个方法------只传等待时间,不传租期 。Redisson 会启用看门狗:默认锁 30 秒,每 10 秒续期一次,直到你主动 unlock。业务跑多久都不怕。
如果你传了租期 tryLock(10, 30, TimeUnit.SECONDS),看门狗不会启动,到点就释放。这一点非常容易踩坑。
到这里,锁本身已经足够健壮。但你会发现一个问题:每个方法都要写一遍 try-finally,太啰嗦了。
八、第六版:自定义注解 + AOP,业务无侵入
我们定义一个 @RedisLock 注解,用 AOP 统一处理加锁和解锁。
1. 定义注解
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RedisLock {
/** 锁的 key,支持 SpEL 表达式 */
String key();
/** 等待锁的时间,默认 0 表示不等待 */
long waitTime() default 0;
TimeUnit timeUnit() default TimeUnit.SECONDS;
}
2. 编写切面
java
@Aspect
@Component
public class RedisLockAspect {
@Autowired
private RedissonClient redissonClient;
private static final ExpressionParser PARSER = new SpelExpressionParser();
private static final ParameterNameDiscoverer NAME_DISCOVERER =
new DefaultParameterNameDiscoverer();
@Around("@annotation(redisLock)")
public Object around(ProceedingJoinPoint joinPoint, RedisLock redisLock) throws Throwable {
String key = parseKey(redisLock.key(), joinPoint);
RLock lock = redissonClient.getLock(key);
boolean locked = false;
try {
locked = lock.tryLock(redisLock.waitTime(), redisLock.timeUnit());
if (!locked) {
throw new RuntimeException("获取分布式锁失败:" + key);
}
return joinPoint.proceed();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new RuntimeException("获取锁被中断", e);
} finally {
if (locked && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
private String parseKey(String keySpEl, ProceedingJoinPoint joinPoint) {
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method method = signature.getMethod();
Object[] args = joinPoint.getArgs();
String[] paramNames = NAME_DISCOVERER.getParameterNames(method);
EvaluationContext context = new StandardEvaluationContext();
if (paramNames != null) {
for (int i = 0; i < paramNames.length; i++) {
context.setVariable(paramNames[i], args[i]);
}
}
return PARSER.parseExpression(keySpEl).getValue(context, String.class);
}
}
3. 业务代码变成一行注解
java
@Service
public class OrderService {
@RedisLock(key = "'lock:order:' + #orderId", waitTime = 5)
public void createOrder(String orderId) {
orderMapper.insert(orderId);
}
}
业务代码完全不用关心锁的存在,干净利落。
九、还有哪些坑?
到这里,我们的锁已经从"玩具"进化到了"生产级"。但还有几个问题必须知道。
1. 主从切换导致锁丢失
Redis 主从复制是异步的。极端情况下:
线程 A 在主节点加锁成功 → 主节点还没同步就宕机 → 从节点升主 → 线程 B 也能加锁成功。
两个线程同时持有"同一把锁",互斥失效。
这是 Redis 分布式锁的天然缺陷,无法通过客户端代码根治。解决方向:
- Redlock:部署 5 个独立主节点,超过半数加锁成功才算成功。但实现复杂,且对时钟敏感,争议较大。
- ZooKeeper / etcd:基于共识算法,强一致,代价是性能略低。
- Fencing Token:每次加锁返回单调递增的 token,下游资源校验 token 大小,拒绝旧 token 的写入。
如果业务对一致性要求极高(比如金融扣款),请优先考虑 ZooKeeper / etcd。
2. 锁与事务的顺序
Spring 中 @Transactional 和分布式锁一起用时,顺序很关键:
java
// 错误示范
@Transactional
public void wrong(String orderId) {
// 锁在事务内部,释放锁时事务可能还没提交
// 其他线程拿到锁,读到的是旧数据
}
正确做法是先加锁,再开事务;先提交事务,再释放锁。推荐在 Controller 或门面层加锁,事务方法内部调用:
java
public void facade(String orderId) {
lock.lock(); // 1. 加锁
try {
txService.doBusiness(orderId); // 2. 事务方法内部提交
} finally {
lock.unlock(); // 3. 事务提交后再释放
}
}
3. 锁粒度
锁的 key 越细,并发度越高。按订单 ID 加锁(lock:order:123)比全局锁(lock:order)好得多。但也要注意:
- 粒度太细 → Redis key 数量爆炸,内存压力大
- 粒度太粗 → 并发度低,锁竞争激烈
根据业务场景权衡,通常按业务主键加锁就够。
十、总结
回顾一下我们的六次迭代:
| 版本 | 方案 | 解决的问题 | 遗留问题 |
|---|---|---|---|
| 一 | SETNX | 互斥 | 宕机死锁 |
| 二 | SETNX + EXPIRE | 死锁 | 非原子 |
| 三 | SET NX PX | 原子性 | 误删别人的锁 |
| 四 | UUID + Lua | 安全释放 | 业务超时锁失效 |
| 五 | Redisson | 看门狗续期、可重入 | 代码重复 |
| 六 | 注解 + AOP | 业务无侵入 | 主从切换丢锁 |
核心结论:
- 加锁用
SET NX PX,不要用SETNX+EXPIRE。 - 释放用 Lua 校验 UUID,不要直接
DEL。 - 生产环境直接上 Redisson,别自己造轮子。
- Spring Boot 中配合自定义注解 + AOP,业务代码零侵入。
- 注意锁与事务的顺序,先加锁后开事务,先提交事务后释放锁。
- 接受 Redis 主从切换可能丢锁的事实,强一致场景选 ZooKeeper / etcd。
分布式锁没有银弹,理解每一版的缺陷比记住最终答案更重要。希望这篇文章能帮你在实际项目中做出正确的选择。
如果本文对你有帮助,欢迎点赞、收藏、关注。有问题欢迎在评论区交流。