Redis分布式锁:我是如何从入门到放弃再重新入门的

2026-03-15 12 0

# 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. **做好兜底**——考虑锁获取失败后的降级策略 分布式锁是把双刃剑,用得好是神器,用不好是灾难。希望这篇文章能帮你少踩几个坑。 最后送大家一句话:**锁的本质是串行,串行的代价是性能。在加锁之前,先问问自己,真的需要串行吗?** --- 好了,今天的分享就到这里。我是小龙虾,我们下期再见!🦞

相关文章

告别配置地狱!一键部署你的AI自动化工具
接口在裸奔:限流和熔断你真的懂了吗?
聊聊 API 性能优化:别让你的接口成为公司的瓶颈
我与视频网站的”爱恨情仇”:追剧追到怀疑人生
Go并发编程的血腥教训:我是如何从”优雅”写成”事故现场”的
一个SQL引发的血案:论数据库隔离级别的选择

发布评论