Redis分布式锁实现:从SETNX到Redisson Watchdog的完整演进
Redis分布式锁实现:从SETNX到Redisson Watchdog的完整演进
适读人群:Java后端开发、对分布式系统感兴趣的工程师 | 阅读时长:约24分钟
开篇故事
2019年618,我们的优惠券领取系统出了问题:同一张优惠券被同一个用户领取了3次。
数据库里有唯一索引,应该不会重复。但查日志发现,三次请求几乎同时到达(时间戳差距在10ms内),三个线程几乎同时判断「该用户没有领取」,然后都执行了INSERT,其中两个因为唯一索引失败,但第三个成功了。
问题根因:分布式环境下,多个服务实例并发执行,先查后改的操作不是原子的。
当时的「解决方案」是用Redis的SETNX实现分布式锁,但上线后又出了新问题:业务处理耗时超过锁的过期时间,锁自动释放,另一个线程抢到了锁,结果两个线程同时在处理同一个用户的优惠券领取请求。
这些坑,我一个个踩过来,今天完整地梳理分布式锁的演进历程。
一、分布式锁的三个必要条件
一个正确的分布式锁必须满足:
- 互斥性:同一时刻只有一个线程持有锁
- 不死锁:即使持锁线程崩溃,锁也能被释放(超时机制)
- 可重入性(可选):同一线程可以多次获取同一把锁
二、底层原理:演进过程
阶段一:SETNX + EXPIRE(有缺陷)
操作1: SETNX lock_key 1 -- 如果key不存在则设置,返回1表示成功
操作2: EXPIRE lock_key 30 -- 设置过期时间30秒缺陷:两个命令不是原子的!如果SETNX成功后服务崩溃,EXPIRE没有执行,锁永不过期(死锁)。
阶段二:SET NX PX(原子命令)
Redis 2.6.12版本引入了组合命令:
SET lock_key unique_value NX PX 30000
-- NX: 只在key不存在时设置
-- PX 30000: 过期时间30000毫秒(30秒)
-- 原子操作!但新问题:过期时间怎么设?
- 设短了:业务还没处理完,锁过期了,其他线程抢到锁,并发问题仍然存在
- 设长了:持锁线程崩溃,其他线程要等很久才能获取锁
阶段三:Lua脚本保证释放锁的原子性
释放锁时,必须先验证是自己的锁(通过unique_value),再删除:
-- 释放锁的Lua脚本(原子执行)
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end如果不用Lua脚本,用GET+DEL两个命令,会有这个问题:
线程A: GET lock_key → 获取到value=A的UUID
(此时锁恰好过期,被线程B抢到,value变成B的UUID)
线程A: DEL lock_key → 删除了线程B的锁! ← 错误!阶段四:Redisson Watchdog(锁续期)
Redisson解决了「业务时间超过锁过期时间」的问题。
Redisson加锁流程:
获取锁:
SET lock_key {uuid}:{threadId} NX PX 30000
Watchdog线程(后台线程):
每10秒检查一次(默认leaseTime的1/3)
如果锁还被当前线程持有:PEXPIRE lock_key 30000 ← 续期30秒
直到业务线程释放锁,Watchdog停止续期
释放锁:
Lua脚本:验证是自己的锁 → 删除 → 发布解锁事件(通知等待线程)Redisson用Hash结构支持可重入:
HSET lock_key {uuid}:{threadId} 1 -- 第一次加锁,计数=1
HSET lock_key {uuid}:{threadId} 2 -- 重入,计数+1
...
当计数减到0时,DEL lock_key -- 真正释放三、完整解决方案与代码
3.1 手动实现(理解原理用)
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import java.util.Collections;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* 基于Redis的分布式锁实现(教学用,生产建议用Redisson)
*/
public class RedisDistributedLock {
private final StringRedisTemplate redisTemplate;
private final String lockKey;
private final String lockValue; // UUID,唯一标识持锁线程
private final long expireSeconds;
// 释放锁的Lua脚本(原子性:GET + 比较 + DEL)
private static final String UNLOCK_SCRIPT = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
""";
public RedisDistributedLock(StringRedisTemplate redisTemplate,
String lockKey, long expireSeconds) {
this.redisTemplate = redisTemplate;
this.lockKey = lockKey;
this.lockValue = UUID.randomUUID().toString().replace("-", "");
this.expireSeconds = expireSeconds;
}
/**
* 尝试获取锁(非阻塞)
* @return true=获取成功,false=获取失败(锁被其他线程持有)
*/
public boolean tryLock() {
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(lockKey, lockValue, expireSeconds, TimeUnit.SECONDS);
return Boolean.TRUE.equals(success);
}
/**
* 尝试获取锁(带等待超时)
* @param waitTimeMs 最长等待时间(毫秒)
*/
public boolean tryLock(long waitTimeMs) throws InterruptedException {
long deadline = System.currentTimeMillis() + waitTimeMs;
while (System.currentTimeMillis() < deadline) {
if (tryLock()) return true;
Thread.sleep(50); // 50ms后重试
}
return false;
}
/**
* 释放锁(Lua脚本保证原子性)
*/
public void unlock() {
DefaultRedisScript<Long> script = new DefaultRedisScript<>(UNLOCK_SCRIPT, Long.class);
Long result = redisTemplate.execute(script,
Collections.singletonList(lockKey), lockValue);
if (result == null || result == 0) {
// 锁已经过期或被其他线程误删,记录告警
log.warn("分布式锁释放失败,可能已过期: key={}", lockKey);
}
}
}
// 使用示例
@Service
public class CouponService {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private CouponMapper couponMapper;
public boolean claimCoupon(Long userId, Long couponId) throws InterruptedException {
String lockKey = "coupon:claim:" + userId + ":" + couponId;
RedisDistributedLock lock = new RedisDistributedLock(redisTemplate, lockKey, 30);
if (!lock.tryLock(3000)) { // 最多等3秒
throw new BusinessException("系统繁忙,请稍后重试");
}
try {
// 双重检查
if (couponMapper.existsByUserIdAndCouponId(userId, couponId)) {
return false; // 已经领取过了
}
couponMapper.insert(new UserCoupon(userId, couponId));
return true;
} finally {
lock.unlock(); // 必须在finally中释放
}
}
}3.2 Redisson实现(生产推荐)
// 依赖
// implementation 'org.redisson:redisson-spring-boot-starter:3.23.4'
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import java.util.concurrent.TimeUnit;
@Service
public class InventoryService {
@Autowired
private RedissonClient redissonClient;
@Autowired
private ProductMapper productMapper;
/**
* 扣减库存(使用Redisson分布式锁)
* Watchdog自动续期,不用担心业务超时导致锁提前释放
*/
public boolean decreaseStock(Long productId, int quantity) {
String lockKey = "stock:lock:" + productId;
RLock lock = redissonClient.getLock(lockKey);
try {
// tryLock(waitTime, leaseTime, unit)
// waitTime=5s: 最多等5秒获取锁
// leaseTime=-1: 不设置过期时间,由Watchdog自动续期
boolean acquired = lock.tryLock(5, -1, TimeUnit.SECONDS);
if (!acquired) {
throw new BusinessException("获取锁超时: productId=" + productId);
}
// 查询最新库存(加锁后的当前读)
Product product = productMapper.selectById(productId);
if (product.getStock() < quantity) {
return false; // 库存不足
}
// 扣减库存
int affected = productMapper.decreaseStock(productId, quantity);
return affected > 0;
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new BusinessException("获取锁被中断");
} finally {
// 只有当前线程持有锁才释放(防止释放其他线程的锁)
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
/**
* 可重入锁示例
*/
public void processOrderWithReentrant(Long orderId) {
RLock lock = redissonClient.getLock("order:lock:" + orderId);
try {
lock.lock(); // 第一次加锁,计数=1
// 内部调用另一个需要同样锁的方法(可重入)
updateOrderStatus(orderId); // 这里会再次lock
} finally {
lock.unlock(); // 计数-1,减到0才真正释放
}
}
private void updateOrderStatus(Long orderId) {
RLock lock = redissonClient.getLock("order:lock:" + orderId);
try {
lock.lock(); // 可重入,计数=2,不阻塞
// 更新状态...
} finally {
lock.unlock(); // 计数=1
}
}
}3.3 Redisson配置
@Configuration
public class RedissonConfig {
@Value("${spring.redis.host}")
private String host;
@Value("${spring.redis.port}")
private int port;
@Value("${spring.redis.password:}")
private String password;
@Bean
public RedissonClient redissonClient() {
Config config = new Config();
// 单机模式
config.useSingleServer()
.setAddress("redis://" + host + ":" + port)
.setPassword(password.isEmpty() ? null : password)
.setConnectionMinimumIdleSize(5)
.setConnectionPoolSize(10)
.setIdleConnectionTimeout(10000)
.setConnectTimeout(3000)
.setTimeout(3000)
.setRetryAttempts(3)
.setRetryInterval(1500);
// Watchdog超时(默认30秒,设置为leaseTime=-1时生效)
config.setLockWatchdogTimeout(30000);
return Redisson.create(config);
}
}四、踩坑实录
坑1:忘记在finally中释放锁,锁泄漏等30秒
// 错误写法
public void processOrder(Long orderId) {
RLock lock = redisson.getLock("order:" + orderId);
lock.lock();
// 如果这里抛出异常,锁不会被释放!
orderService.process(orderId); // 假设这里抛出了RuntimeException
lock.unlock(); // 永远不会执行到这里
// 直到Watchdog超时(30秒),锁才会释放
}
// 正确写法
public void processOrder(Long orderId) {
RLock lock = redisson.getLock("order:" + orderId);
try {
lock.lock();
orderService.process(orderId);
} finally {
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}坑2:分布式锁粒度太粗,性能瓶颈
// 错误:对整个商品类目加锁,所有商品串行处理
public void decreaseStock(Long productId, int quantity) {
RLock lock = redisson.getLock("global:stock:lock"); // 全局锁!
// 所有商品的库存扣减都要抢这一把锁
// 100个商品并发下单 → 99个在等待 → 吞吐量极低
}
// 正确:按商品ID加锁(细粒度)
public void decreaseStock(Long productId, int quantity) {
RLock lock = redisson.getLock("stock:lock:" + productId); // 商品粒度
// 不同商品的锁互不影响,只有同一个商品的并发请求才会竞争
}坑3:Redis主从切换导致锁失效(CAP问题)
场景:Redis主节点宕机,哨兵选举新主节点。在主从切换期间(通常几秒),新写入主库的锁数据没有同步到从库,新主库上该锁不存在。
时间轴:
T1: 线程A在主库设置了锁 key=lock:123
T2: 主库宕机,锁数据未同步到从库
T3: 从库成为新主库,key=lock:123 不存在
T4: 线程B在新主库上设置锁成功
T5: 线程A和线程B同时持有锁!报警:数据重复处理,业务出错
这是Redis分布式锁在主从架构下的理论缺陷。
解决方案:
方案1:RedLock(多节点锁,N个独立Redis节点,过半数加锁成功才算成功)
优点:更可靠
缺点:需要多个独立Redis实例,实现复杂,性能下降N/2倍
争议:Martin Kleppmann认为RedLock也有问题
方案2:接受短暂的不一致(大多数场景的实用选择)
主从切换时间通常<5秒
大多数业务场景能接受这5秒内的极小概率重复处理
加上业务层幂等处理,即使锁失效了业务也能正确处理
方案3:使用ZooKeeper的分布式锁(CP系统,强一致)
缺点:性能不如Redis
方案4:数据库乐观锁(最终兜底)
无论分布式锁是否正常工作,数据库层面的乐观锁确保不会出现数据错误实用建议:
// 分布式锁 + 数据库乐观锁双保险
@Transactional
public boolean safeDecreaseStock(Long productId, int version, int quantity) {
// 带版本号的更新(乐观锁)
int affected = jdbcTemplate.update(
"UPDATE product SET stock = stock - ?, version = version + 1 " +
"WHERE id = ? AND version = ? AND stock >= ?",
quantity, productId, version, quantity
);
return affected > 0;
}五、总结与延伸
Redis分布式锁的演进:
SETNX + EXPIRE(非原子)
↓ 改进
SET NX PX(原子加锁)+ Lua脚本(原子释放)
↓ 改进
Redisson(Watchdog续期 + 可重入 + 公平锁)
↓ 更高可靠性(可选)
RedLock(多节点,但争议大)生产建议:直接用Redisson,不要自己造轮子。Redisson的实现比大多数自研方案更完善,包括Watchdog续期、可重入锁、公平锁、读写锁、多锁等。
分布式锁的局限:
- Redis分布式锁不是CP(强一致性),在主从切换时有短暂失效风险
- 锁和业务操作不是原子的,即使加了锁,业务层也要做幂等保护
- 锁是用来减少冲突的,不是用来替代事务的
