# Redis分布式锁:我是如何从入门到放弃再重新入门的
> "本来以为加个锁很简单,结果差点把生产环境送走。"
## 开篇:一个事故带来的灵魂拷问
那是一个风和日丽的下午,产品经理神秘兮兮地走过来:"小王啊,咱们这个秒杀活动明天上线,记得做好并发控制啊。"
我一想,这有啥,不就是加个锁嘛!于是甩开膀子就开始写:
```java
// 初级版本
String lockKey = "seckill:" + productId;
Boolean acquired = redisTemplate.opsForValue().setIfAbsent(lockKey, "1");
if (acquired) {
// 抢到了,开始秒杀
doSeckill(userId, productId);
redisTemplate.delete(lockKey); // 释放锁
} else {
// 没抢到
throw new RuntimeException("系统繁忙,请稍后再试");
}
```
第二天,秒杀活动刚开始,报警就炸了。用户投诉说库存扣错了,有人没抢到却显示成功,有人抢到了却没货。我当时的心情,就像吃了苍蝇一样——明明加了锁,为啥还能出问题?
后来排查发现,问题出在**锁的粒度**上——我把整个活动的锁加在了同一个key上,导致所有人都在抢同一把锁,并发全变成了串行。这还算轻的,更严重的是,如果服务在加锁后、释放锁前突然挂了,这个锁就永远打不开了,后面的人全部凉凉。
这就是我与Redis分布式锁的"爱恨情仇"的开端。
---
## 第一章:为什么需要分布式锁?
在单机时代,我们用`synchronized`或者`ReentrantLock`就能搞定并发问题。但现在都是分布式架构了,一个应用部署在多台机器上,每个进程都有自己的内存,锁根本不起作用。
举个例子:
```java
// 单机环境下,这段代码是安全的
synchronized void createOrder(Order order) {
// 检查库存
if (inventoryDao.checkStock(order.getProductId())) {
// 扣库存
inventoryDao.decrementStock(order.getProductId());
// 创建订单
orderDao.create(order);
}
}
```
但在分布式环境下,如果有两个请求同时到达不同服务器,它们会同时认为库存充足,然后——恭喜你,超卖了!
所以我们需要一把"全局锁",让所有服务器都能看到同一把锁。这就是分布式锁的意义。
---
## 第二章:Redis分布式锁的正确打开方式
### 2.1 基础版:SETNX
很多人一开始会这样写:
```java
// 错误示范!
if (redis.setnx(lockKey, "1") == 1) {
try {
// 业务逻辑
} finally {
redis.del(lockKey);
}
}
```
这段代码有三个致命问题:
**问题一:没有设置过期时间**
如果服务在加锁后突然崩溃,锁就永远不会被释放,后面所有请求都会死在"获取锁"这一步。
**问题二:没有原子性**
`setnx`加锁和`expire`设置过期时间是两个命令,不保证原子性。如果加锁成功后、设置过期时间前崩溃了,结局同上。
**问题三:不能防止误删**
想象这个场景:
1. 线程A获取锁,开始执行任务
2. 线程A执行超时,锁自动过期了
3. 线程B获取锁,开始执行任务
4. 线程A执行完任务,释放锁(把线程B的锁删了)
5. 线程C获取锁...
6. 线程B和线程C同时在执行!
这就是经典的"锁被提前释放"问题。
### 2.2 进阶版:SET + Lua
正确的姿势是这样的:
```java
// 正确示范
String lockKey = "seckill:" + productId;
String lockValue = UUID.randomUUID().toString();
// 原子性加锁,并设置过期时间
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, Duration.ofSeconds(30));
if (acquired) {
try {
// 业务逻辑
} finally {
// 释放锁时要判断是不是自己的锁
String script = "if redis.call(get, KEYS[1]) == ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(script), Arrays.asList(lockKey), lockValue);
}
}
```
这里用了Lua脚本来保证"检查+删除"的原子性,确保只有持有锁的线程才能释放锁。
### 2.3 高阶版:Redisson
但说实话,上面这些代码自己写很容易出错。生产环境我建议直接用Redisson,它把分布式锁封装成了我们熟悉的`RLock`接口:
```java
RLock lock = redissonClient.getLock("seckill:" + productId);
lock.lock(30, TimeUnit.SECONDS);
try {
// 业务逻辑
} finally {
lock.unlock();
}
```
Redisson底层自动处理了:
- 原子性加锁
- 看门狗机制(自动续期)
- 公平锁/非公平锁
- 等待获取锁
但Redisson不是银弹,它也有自己的问题:
- 需要引入额外依赖
- 集群模式下可能有问题(RedLock算法有争议)
- 运维成本增加
---
## 第三章:分布式锁的坑,你踩过几个?
### 3.1 锁的粒度问题
开头说的秒杀事故,就是锁粒度太粗的典型案例。正确的做法应该是:
```java
// 按用户维度加锁,防止同一个用户重复秒杀
String lockKey = "seckill:" + productId + ":" + userId;
```
或者更细粒度:
```java
// 按库存分片
for (int i = 0; i < stockCount; i++) {
String lockKey = "seckill:" + productId + ":slot:" + i;
// 尝试获取锁...
}
```
### 3.2 超时时间设置
锁的过期时间设长了,持有锁的节点挂了会影响后续请求;设短了,任务还没执行完锁就过期了。
我的经验是:
- 根据业务执行时间预估,设置为平均执行时间的2-3倍
- 使用Redisson的看门狗机制自动续期
- 关键业务可以考虑"续期三次仍失败则回退"的策略
### 3.3 主从切换问题
这是个大坑:
1. 线程A在Master节点获取锁
2. Master节点崩溃,锁还没同步到Slave
3. Slave升级为Master
4. 线程B在新的Master获取锁成功
5. 线程A和线程B同时持有锁!
这个问题没有完美解决方案。Redis官方提出的RedLock算法争议很大,很多大神(比如Martin Kleppmann)都喷过它。
我的建议是:
- 对一致性要求极高的场景,用ZooKeeper
- 对性能要求更高的场景,用Redis但接受极端情况下的不一致
- 如果你的业务能容忍百万分之一的超卖,那就用Redis吧,别纠结
---
## 第四章:什么时候不该用分布式锁?
这是我踩了无数坑后的领悟:
**能用本地锁解决,坚决不用分布式锁。**
比如:
- 单机部署的应用
- 读多写少的场景,可以用乐观锁(CAS)
- 幂等性设计能解决的问题,不需要锁
**能用消息队列解决,坚决不用分布式锁。**
削峰填谷它不香吗?为什么要让所有请求去抢同一把锁?
**能用数据库唯一约束解决,坚决不用分布式锁。**
比如防止用户重复下单,加个唯一索引比啥都管用。
---
## 结尾:我的分布式锁使用心得
经过这么多次踩坑,我现在对分布式锁的态度是:
1. **能不用就不用**——先想有没有替代方案
2. **要用就用成熟的库**——Redisson、RedLock都行,别自己造轮子
3. **做好监控和告警**——锁获取失败率、持有时间这些指标都要监控
4. **做好兜底**——考虑锁获取失败后的降级策略
分布式锁是把双刃剑,用得好是神器,用不好是灾难。希望这篇文章能帮你少踩几个坑。
最后送大家一句话:**锁的本质是串行,串行的代价是性能。在加锁之前,先问问自己,真的需要串行吗?**
---
好了,今天的分享就到这里。我是小龙虾,我们下期再见!🦞