前言:当 synchronized 不再有效
兄弟们,欢迎来到 Redis 进化日志的第五天。在前四天里,我们夯实了 Redis 的基础(数据结构、持久化、高可用)。今天,我们要聊一个后端开发中极具分量的话题------分布式锁。
在单体应用时代,遇到并发问题(比如扣减库存),我们习惯用 Java 自带的 synchronized 或 ReentrantLock。但在现在的微服务架构下,服务往往是多节点部署的。
-
问题核心:JVM 级别的锁只能管住当前这台服务器内部的线程,管不住其他服务器的线程。
-
解决方案:我们需要一个独立于所有应用服务器之外的"第三方组件"来统一管理锁,Redis 恰好就是最合适的人选。
今天我们像剥洋葱一样,模拟一个分布式锁是如何一步步修复,最终进化成完全体的。
一、 雏形阶段:简单的 SETNX
Redis 提供了一个命令 SETNX,逻辑很简单:如果 Key 不存在,则设值成功(拿锁);如果 Key 存在,则设值失败(排队)。
代码实现 V1.0:
java
/**
* 阶段一:最原始的分布式锁
* 存在严重死锁风险
*/
public void seckillV1() {
String lockKey = "product_001";
// 1. 尝试加锁
// 对应 Redis 命令:SETNX product_001 locked
Boolean result = redisTemplate.opsForValue().setIfAbsent(lockKey, "locked");
if (result) {
// 加锁成功
try {
// 2. 执行业务逻辑
doBusiness();
} finally {
// 3. 释放锁
// 【潜在风险】:如果代码执行到 doBusiness() 时服务器宕机或断电,
// finally 块永远不会被执行。Redis 里的 lockKey 将永远存在。
// 导致后续所有线程都无法拿到锁,形成【死锁】。
redisTemplate.delete(lockKey);
}
}
}
二、 改进阶段:引入过期时间 (Expire)
为了解决 V1 版本宕机导致的死锁问题,最直观的办法是给锁加一个过期时间 (TTL)。即使服务宕机,Redis 也会在一段时间后自动删除这个 Key。
代码实现 V2.0(错误示范):
java
// ... 加锁成功后 ...
if (result) {
// 【原子性问题】:
// "加锁"和"设置过期时间"是两步独立的操作。
// 如果刚执行完 setIfAbsent,还没来得及执行 expire,服务器这就挂了...
// 结果依然是死锁。
redisTemplate.expire(lockKey, 10, TimeUnit.SECONDS);
// ... 执行业务 ...
}
代码实现 V2.1(正确姿势):
Redis 从 2.6.12 版本开始,扩展了 SET 命令,支持原子性操作。
java
/**
* 阶段二:利用原子命令解决死锁
* 但仍存在"误删锁"风险
*/
public void seckillV2() {
String lockKey = "product_001";
// 1. 原子加锁:同时设置 NX (互斥) 和 EX (过期时间)
// 对应 Redis 命令:SET product_001 locked NX EX 10
Boolean result = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "locked", 10, TimeUnit.SECONDS);
if (result) {
try {
doBusiness();
} finally {
redisTemplate.delete(lockKey);
}
}
}
三、 进阶阶段:解决"误删锁"问题
V2.1 看起来已经不错了,但在高并发下的极端网络延迟场景中,依然有 Bug。
场景推演:
-
线程 A 拿到锁,过期时间 10 秒。
-
线程 A 业务卡顿,执行了 15 秒。此时第 10 秒时锁自动失效。
-
线程 B 进场,拿到新锁。
-
线程 A 终于执行完了,运行
finally块中的delete。 -
后果 :线程 A 删掉的不是自己的锁,而是线程 B 刚加上的锁。系统锁机制失效。
解决方案:
解铃还须系铃人。我们在加锁时存入一个唯一标识(UUID),删除前判断一下:"这是我的锁吗?"
java
/**
* 阶段三:引入 UUID 和 Lua 脚本
* 保证"加锁者"和"解锁者"是同一个人
*/
public void seckillV3() {
String lockKey = "product_001";
// 生成当前线程的唯一标识
String uuid = UUID.randomUUID().toString();
// 1. 加锁:Value 存入 UUID
Boolean isLocked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, uuid, 10, TimeUnit.SECONDS);
if (isLocked) {
try {
doBusiness();
} finally {
// 2. 释放锁:必须使用 Lua 脚本保证原子性
// 逻辑:如果 GET 到的值等于我的 UUID,则 DEL;否则返回 0。
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
// 执行 Lua 脚本
redisTemplate.execute(
new DefaultRedisScript<>(script, Long.class),
Collections.singletonList(lockKey), // KEYS[1]
uuid // ARGV[1]
);
}
}
}
四、 完善阶段:续期问题与 Redisson
V3 版本通过 Lua 脚本已经非常严谨了,但还有一个痛点:过期时间设多少合适?
-
设短了(如 5s):业务没跑完锁就丢了。
-
设长了(如 60s):万一服务器挂了,其他线程要等 60s 才能拿锁,用户体验极差。
我们需要一种**"自动续期"**机制:业务只要还在跑,锁的时间就自动延长。
Java 社区最成熟的 Redis 框架 Redisson 帮我们完美解决了这个问题。
1. Redisson 的"看门狗" (WatchDog) 机制
当你调用 Redisson 的 lock() 方法时,它会在后台启动一个定时任务(看门狗):
-
默认加锁 30 秒。
-
每隔 10 秒(
lockWatchdogTimeout / 3)检查一次。 -
如果当前线程还持有锁,就通过 Lua 脚本把过期时间重置回 30 秒。
-
如果服务宕机,看门狗线程消失,不再续期,锁在 30 秒后自动释放。
2. 最终版代码(推荐在生产环境使用)
java
@Autowired
private RedissonClient redisson;
/**
* 阶段四:使用 Redisson 实现工业级分布式锁
* 支持可重入、自动续期、阻塞等待
*/
public void seckillFinal() {
String lockKey = "product_001";
// 1. 获取锁对象(此时还没真正加锁)
RLock lock = redisson.getLock(lockKey);
try {
// 2. 加锁
// - 默认过期时间 30s
// - 自动启动看门狗线程进行续期
// - 支持可重入(底层是 Hash 结构,记录加锁次数)
lock.lock();
// 3. 执行业务
// 哪怕业务执行 1 分钟,看门狗也会不断续期,锁不会断
doBusiness();
} finally {
// 4. 释放锁
// 严谨判断:当前锁是否存在?是否是当前线程持有的?
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
// 解锁,同时会停止后台的看门狗线程
lock.unlock();
}
}
}
五、 面试常见追问:关于 Redlock
Q:Redisson 方案已经完美了吗?如果 Redis 主节点挂了怎么办?
A: 这是分布式系统中的经典问题。
-
场景:线程 A 在 Master 节点加锁成功,但数据还没同步到 Slave,Master 突然挂了。Slave 晋升为 Master,线程 B 去新 Master 加锁也能成功。此时 A 和 B 同时持有锁,互斥失效。
-
官方方案 (Redlock):
Redis 之父提出了 Redlock 算法,要求部署 5 个独立的 Redis 节点,加锁时必须在超过半数(3个)节点上都加锁成功才算有效。
-
实际工程建议:
不推荐使用 Redlock。
-
成本过高:维护 5 个独立 Redis 实例成本太高。
-
依然不保证 100%:分布式专家证明了在严重网络延迟下 Redlock 依然可能有问题。
-
结论 :如果你的业务是金融级 (坚决不能错),请放弃 Redis 分布式锁,改用 Zookeeper 或 数据库悲观锁 (CP 模型)。对于绝大多数互联网业务(秒杀、防重提交),Redisson + 主从架构 已经足够优秀了。
-
总结:Redis 分布式锁进化论
回过头看,我们从最简陋的代码一步步优化到工业级方案。为了方便大家记忆,我把这四个阶段整理成了里程碑 ,每个阶段我们先看它到底是个啥:
🚩 V1.0:石器时代 (SETNX)
-
这是什么 :这是利用 Redis 单线程特性的最原始手段,相当于**"占坑模式"**。谁先抢到坑位(Key),谁就拥有锁。
-
实现方式 :
SETNX key value。 -
致命缺陷 :死锁风险。如果抢到坑位的人突然"挂了"(服务器宕机),坑位永远不会释放,后面的人永远进不来,业务直接瘫痪。
🚩 V2.0:青铜时代 (Atomic SET)
-
这是什么 :这是给锁加装了**"自动销毁装置"**。利用 Redis 的 TTL(过期时间)机制,保证锁不会永久存在。
-
实现方式 :
SET key value NX EX 10(原子命令)。 -
致命缺陷 :误删锁风险 。如果业务执行时间太长(超过了 TTL),锁会自动销毁。此时不仅锁失效了,那个超时的线程执行完后,还会把别人的锁给误删掉,导致系统"裸奔"。
🚩 V3.0:白银时代 (Lua Script + UUID)
-
这是什么:这是给锁发了一张**"身份证"**。在删除锁之前,必须先核对身份(UUID),并利用 Lua 脚本保证"验身+删除"是不可打断的。
-
实现方式:Value 存 UUID + Lua 脚本删锁。
-
致命缺陷 :续期难题。锁的过期时间到底设多少?这是一个死局:设长了影响性能,设短了怕业务跑不完。我们需要一个能自动"续杯"的机制。
🚩 V4.0:王者时代 (Redisson WatchDog)
-
这是什么 :这是一个全自动化的"管家" 。它不仅帮我们加锁,还在后台启动了一个**"看门狗"线程**,专门负责监测业务状态并自动续期。
-
实现方式:Redisson 框架 + Netty 时间轮。
-
地位 :彻底解决了续期和可重入问题,是目前 Java 后端领域最推荐、最成熟的分布式锁方案。
💡下期预告
锁的问题搞定了,但如果有人恶意攻击,疯狂查询不存在的数据,直接穿透缓存打到数据库怎么办?
【Day 6】缓存的三大经典问题:穿透、击穿、雪崩,到底怎么防?(附布隆过滤器实战)
关注专栏,带你接着卷!
